From 94d0a6f44c4f7ffb86463a4c670c69d69ef0a145 Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Wed, 13 May 2026 20:49:28 +0000 Subject: [PATCH 1/6] feat(solana): add SPL Token (TokenKeg) program support Resolves #76. Adds a dedicated `SplTokenVisualizer` for the SPL Token program (`TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA`) so token transactions render with structured fields instead of falling back to `UnknownProgramVisualizer`. ## What it covers Full instruction coverage via `TokenInstruction::unpack()` from the `spl-token` crate, including: - MintTo / MintToChecked (amount + decimals + mint + destination) - Transfer / TransferChecked (amount + source + destination + owner) - Burn / BurnChecked (amount + account + mint + owner) - Approve / ApproveChecked (amount + source + delegate + owner) - InitializeMint / InitializeMintV1 - FreezeAccount / ThawAccount - SetAuthority (all authority types, including current authority signer) - Revoke, CloseAccount, default branch fallback For every instruction whose semantics depend on a signing owner / authority, the Expanded layout now surfaces that signer field (addressing the original Copilot review feedback on this PR): - Transfer / Burn / Approve: "Owner" from account index 2 - TransferChecked / ApproveChecked: "Owner" from account index 3 - BurnChecked: "Owner" from account index 2 - SetAuthority: "Current Authority" from account index 1 Without these the visualization would silently drop the security- relevant signer for the action. ## swig_wallet interaction `presets/swig_wallet/mod.rs::visualize_inner_instruction` now skips `SplToken` alongside `UnknownProgram`, so swig's inner-instruction summary keeps using its richer `format_token_instruction_summary` (From/To/Owner/Amount lines) instead of the new visualizer's terse title. ## Test coverage - 23 unit tests in `presets/spl_token/tests.rs` covering parsing (`unpack_instruction`) and visualization for every supported variant, including the default-branch path (Revoke, CloseAccount, InitializeMint). - Real-transaction fixture for MintTo at `tests/fixtures/spl_token/mint_to_example.json` with strict per-field assertions (the validation loop now fails on any divergence rather than just printing mismatches). - `core/visualsign.rs`: renamed `test_unknown_program_tokenkeg` -> `test_spl_token_tokenkeg_recognition` (now asserts TokenKeg is recognized as SPL Token) and added `test_unknown_program_fallback` with a clearly-fake program id to keep the unknown-program path exercised. Coverage on `presets/spl_token/mod.rs`: 82% lines / 86% regions, above the 70% target in TESTING.md. ## Build integration Auto-discovered by `presets/mod.rs` alphabetical ordering. Placed before `UnknownProgramVisualizer` in the visualizer chain so the generic catch-all only matches when no specific preset claims the program id. ## Rebase notes Rebased onto post-#288 main. `SplTokenConfig` was updated from `HashMap`/`HashSet` to `BTreeMap` to match the new `SolanaIntegrationConfigData` signature and to satisfy the workspace clippy `disallowed-types` rule introduced by #288. `cargo build -p visualsign-solana`, `cargo clippy -p visualsign-solana --all-targets -- -D warnings`, and `cargo test -p visualsign-solana` (165 unit tests including the 23 spl_token tests) all pass locally. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 5 + TESTING.md | 76 ++ .../visualsign-solana/.gitignore | 4 + .../visualsign-solana/TESTING.md | 505 +++++++ .../visualsign-solana/src/core/visualsign.rs | 115 +- .../visualsign-solana/src/presets/mod.rs | 1 + .../src/presets/spl_token/config.rs | 23 + .../src/presets/spl_token/mod.rs | 543 ++++++++ .../src/presets/spl_token/tests.rs | 1191 +++++++++++++++++ .../src/presets/swig_wallet/mod.rs | 6 +- .../tests/fixtures/spl_token/README.md | 149 +++ .../fixtures/spl_token/mint_to_example.json | 36 + 12 files changed, 2650 insertions(+), 4 deletions(-) create mode 100644 TESTING.md create mode 100644 src/chain_parsers/visualsign-solana/TESTING.md create mode 100644 src/chain_parsers/visualsign-solana/src/presets/spl_token/config.rs create mode 100644 src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs create mode 100644 src/chain_parsers/visualsign-solana/src/presets/spl_token/tests.rs create mode 100644 src/chain_parsers/visualsign-solana/tests/fixtures/spl_token/README.md create mode 100644 src/chain_parsers/visualsign-solana/tests/fixtures/spl_token/mint_to_example.json diff --git a/.gitignore b/.gitignore index 29d1ba5d..05e85233 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ **/target out .surfpool/ + +# Coverage artifacts (see TESTING.md) +lcov.info +*.profraw +*.profdata diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 00000000..b40648c6 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,76 @@ +# Testing Guide for visualsign-parser + +For chain-specific testing approaches, see: +- [Solana Testing Guide](src/chain_parsers/visualsign-solana/TESTING.md) - Fixture-based testing with real transaction data + +## Test Coverage + +### Prerequisites + +```bash +# Install cargo-llvm-cov +cargo install cargo-llvm-cov +``` + +### Viewing Coverage + +```bash +# Generate HTML report and open in browser (simplest approach) +cargo llvm-cov --workspace --open + +# Or for a specific package +cargo llvm-cov --package visualsign-solana --lib --open +``` + +Reports are generated in `target/llvm-cov/html/` and opened automatically. + +**For remote workstations:** + +If you're developing on a remote machine and need to access the coverage report from your local browser: + +```bash +# Generate HTML report (without --open) +cargo llvm-cov --package visualsign-solana --lib --html + +# Serve the report on a port +cd target/llvm-cov/html +python3 -m http.server 8080 + +# Then access from your local machine at: +# http://:8080 +``` + +Or use SSH port forwarding: +```bash +# On your local machine: +ssh -L 8080:localhost:8080 user@remote-host + +# Then access at http://localhost:8080 +``` + +### For CI/CD + +```bash +# Generate lcov.info for external coverage tools (Codecov, Coveralls, etc.) +cargo llvm-cov --workspace --lcov --output-path lcov.info + +# Or fail if coverage is below threshold +cargo llvm-cov --workspace --fail-under-lines 70 +``` + +### More Information + +See [cargo-llvm-cov documentation](https://github.com/taiki-e/cargo-llvm-cov) for advanced usage. + +## Running Tests + +```bash +# Run all tests +cargo test --workspace + +# Run tests for a specific package +cargo test --package visualsign-solana + +# Run with output visible +cargo test --package visualsign-solana -- --nocapture +``` diff --git a/src/chain_parsers/visualsign-solana/.gitignore b/src/chain_parsers/visualsign-solana/.gitignore index f56db336..57311232 100644 --- a/src/chain_parsers/visualsign-solana/.gitignore +++ b/src/chain_parsers/visualsign-solana/.gitignore @@ -6,5 +6,9 @@ target/ # These are backup files generated by rustfmt **/*.rs.bk +# Coverage artifacts (see /TESTING.md) +lcov.info +*.profraw +*.profdata test-ledger diff --git a/src/chain_parsers/visualsign-solana/TESTING.md b/src/chain_parsers/visualsign-solana/TESTING.md new file mode 100644 index 00000000..48fc3e0f --- /dev/null +++ b/src/chain_parsers/visualsign-solana/TESTING.md @@ -0,0 +1,505 @@ +# Testing Guide for visualsign-solana + +This document establishes the testing philosophy and practices for all program presets in the visualsign-solana crate. + +## Test Philosophy + +### Unit Tests for Instruction Parsing + +All program preset tests (SPL Token, System Program, Stake Pool, etc.) follow the same principles: + +- **Each fixture tests ONE specific instruction** in isolation +- **These are UNIT tests**, not integration tests for full multi-instruction transactions +- **Fixtures are the source of truth** - they contain real transaction data from actual on-chain transactions +- **No network dependencies** - test fixtures are manually extracted and committed to git +- **Field names match explorer output** - makes manual verification against Solscan/explorers easy + +### Integration Testing + +For testing full multi-instruction transactions and end-to-end workflows, use higher-level integration tests in the parent visualsign-parser project, not at this crate level. + +## Creating Test Fixtures + +### Overview + +Test fixtures validate that our instruction parsers correctly extract and display parameters from real Solana transactions. Each fixture: + +1. References a real transaction from an explorer (Solscan, Solana Explorer, etc.) +2. Contains the raw instruction data for a specific instruction within that transaction +3. Specifies expected field values to validate parser output + +### Step-by-Step Guide + +#### Step 1: Find a Transaction + +Find a transaction on an explorer that contains the instruction type you want to test: +- Solscan: `https://solscan.io/tx/?cluster=` +- Solana Explorer: `https://explorer.solana.com/tx/?cluster=` + +**Note**: Most transactions contain multiple instructions. You'll extract just the one instruction you want to test. + +#### Step 2: Fetch Raw Transaction Data + +Use Solana RPC to get the raw transaction data: + +```bash +# For devnet +curl -X POST https://api.devnet.solana.com \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":1, + "method":"getTransaction", + "params":[ + "", + { + "encoding":"json", + "maxSupportedTransactionVersion":0 + } + ] + }' | python3 -m json.tool > transaction.json + +# For mainnet +curl -X POST https://api.mainnet-beta.solana.com \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":1, + "method":"getTransaction", + "params":[ + "", + { + "encoding":"json", + "maxSupportedTransactionVersion":0 + } + ] + }' | python3 -m json.tool > transaction.json +``` + +#### Step 3: Extract Instruction Data + +From the RPC response, locate the specific instruction you want to test: + +```json +{ + "result": { + "transaction": { + "message": { + "accountKeys": ["pubkey1", "pubkey2", ...], + "instructions": [ + { + "programIdIndex": 5, + "accounts": [0, 1, 2], + "data": "6YCQpfSgHpSj" + } + ] + } + } + } +} +``` + +Key fields: +- `instructions[INDEX].data` - **base58-encoded** instruction data (when using `encoding: "json"`) +- `instructions[INDEX].accounts` - array of account indices that map to `accountKeys` +- `instructions[INDEX].programIdIndex` - index into `accountKeys` for the program ID + +**Important: Encoding Formats** + +Solana RPC uses different encodings depending on the request: +- `encoding: "json"` → instruction data is **base58 encoded** ✓ (we use this) +- `encoding: "base64"` → entire transaction is base64 encoded + +When decoding fixtures in tests, use `bs58::decode()` for the instruction data. + +#### Step 4: Get Expected Field Values + +Use `jsonParsed` encoding to see what the instruction parameters should be: + +```bash +curl -X POST https://api.devnet.solana.com \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":1, + "method":"getTransaction", + "params":[ + "", + {"encoding":"jsonParsed","maxSupportedTransactionVersion":0} + ] + }' +``` + +Look for the `parsed.info` object in the response - this contains the expected field values. Use field names that match what the explorer shows (e.g., "mint", "account", "authority" for SPL Token). + +#### Step 5: Create Fixture JSON + +Create a fixture file in `tests/fixtures//_example.json`: + +```json +{ + "description": "Brief description of what this instruction does", + "source": "https://solscan.io/tx/?cluster=", + "signature": "", + "cluster": "devnet", + "full_transaction_note": "Optional: This transaction has X instructions: [0] InstructionA, [1] InstructionB. We're testing instruction [N].", + "instruction_index": 1, + "instruction_data": "", + "program_id": "", + "accounts": [ + { + "pubkey": "", + "signer": false, + "writable": true, + "description": "Human-readable description of this account's role" + } + ], + "expected_fields": { + "fieldname1": "expected value 1", + "fieldname2": "expected value 2" + } +} +``` + +**Fixture Fields Explained:** + +- `description` - Brief human-readable explanation of the instruction +- `source` - URL to the transaction on an explorer for manual verification +- `signature` - Transaction signature (for traceability) +- `cluster` - "devnet" or "mainnet-beta" +- `full_transaction_note` - (Optional) Context about the full transaction if it has multiple instructions +- `instruction_index` - Which instruction this was in the original transaction (for reference) +- `instruction_data` - Base58-encoded instruction data from RPC response +- `program_id` - The program ID for this instruction +- `accounts` - Array of accounts with their metadata +- `expected_fields` - Key-value pairs of field names and expected values to validate + +#### Step 6: Write Test Code + +Create a test function that: + +1. Loads the fixture using a helper function +2. Decodes the base58 instruction data +3. Creates a Solana `Instruction` struct +4. Calls your program's visualization function +5. Validates the extracted fields match expected values + +Example test structure: + +```rust +#[test] +fn test_instruction_from_real_transaction() { + let fixture = load_fixture("instruction_name_example"); + + // Create Instruction from fixture + let instruction = create_instruction_from_fixture(&fixture); + + // Visualize the instruction + let preview_layout = visualize_instruction(&instruction); + + // Validate fields + validate_fields(&preview_layout, &fixture.expected_fields); +} +``` + +See `src/presets/spl_token/tests.rs` for a complete reference implementation. + +#### Step 7: Run the Tests + +```bash +# Run all tests for a specific program +cargo test --package visualsign-solana --lib presets::::tests -- --nocapture + +# Run a specific fixture test +cargo test --package visualsign-solana --lib presets::::tests::test__real_transaction -- --nocapture +``` + +The `--nocapture` flag will show detailed output including: +- Transaction details and source URL +- Extracted fields from the parser +- Validation results (✓ for matches, ✗ for mismatches) + +## Critical Testing Rules + +### 1. NEVER Modify Fixture Data to Pass Tests + +**❌ WRONG:** +```json +// Test failing? Let me "fix" the instruction_data... +{ + "instruction_data": "SomeValueICalculated" +} +``` + +**✓ RIGHT:** +- Fixture data represents REAL on-chain transactions +- If tests fail, fix the parser code, not the fixture +- Use explorer's parsed view to verify what expected values should be + +### 2. Fixtures Are the Source of Truth + +The fixture data comes from actual on-chain transactions. If your parser disagrees with the fixture: +1. First verify the fixture is correct by checking the explorer +2. Then fix the parser logic +3. Only update a fixture if you initially extracted it incorrectly + +### 3. Use Base58 Decoding for Instruction Data + +```rust +// ✓ RIGHT - instruction data from JSON RPC is base58 encoded +let data = bs58::decode(&fixture.instruction_data) + .into_vec() + .unwrap(); + +// ❌ WRONG - don't use base64 +let data = base64::decode(&fixture.instruction_data).unwrap(); +``` + +### 4. Match Explorer Field Names + +Field names in `expected_fields` should match what users see in Solscan or Solana Explorer: + +```rust +// ✓ RIGHT - matches explorer output +"mint": "5pdHyGbtCmZdJ7ye71nzeke8kcQ4ngJNPHqoDvE5L2WT" + +// ❌ WRONG - doesn't match explorer +"token_mint": "5pdHyGbtCmZdJ7ye71nzeke8kcQ4ngJNPHqoDvE5L2WT" +``` + +This makes manual verification much easier. + +## Test Infrastructure + +### Fixture Test Helpers + +Each program preset should implement these helper functions in its `tests.rs`: + +```rust +// Load fixture from JSON file +fn load_fixture(name: &str) -> TestFixture { + let fixture_path = format!( + "{}/tests/fixtures//{}.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)) +} + +// Create Instruction from fixture data +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 is base58 encoded + let data = bs58::decode(&fixture.instruction_data) + .into_vec() + .unwrap(); + + Instruction { + program_id, + accounts, + data, + } +} + +// Validate extracted fields against expected values +fn validate_fields(preview_layout: &PreviewLayout, expected_fields: &serde_json::Map) { + for (key, expected_value) in expected_fields { + let expected_str = expected_value.as_str().unwrap(); + + if let Some(expanded) = &preview_layout.expanded { + let found = expanded.fields.iter().any(|field| { + if let SignablePayloadField::TextV2 { common, text_v2 } = &field.signable_payload_field { + let label_matches = common.label.to_lowercase().replace(" ", "_") == key.to_lowercase(); + let value_matches = text_v2.text == expected_str; + if label_matches { + if value_matches { + println!("✓ {}: {} (matches)", key, expected_str); + } else { + println!("✗ {}: expected '{}', got '{}'", key, expected_str, text_v2.text); + } + return value_matches; + } + false + } else { + false + } + }); + + if !found { + println!("✗ {}: field not found in output", key); + } + } + } +} +``` + +### Fixture Struct + +```rust +#[derive(Debug, serde::Deserialize)] +struct TestFixture { + description: String, + source: String, + signature: String, + cluster: String, + #[serde(default)] + full_transaction_note: Option, + 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, + description: String, +} +``` + +## Directory Structure + +``` +visualsign-solana/ +├── TESTING.md # This file +├── src/ +│ └── presets/ +│ ├── spl_token/ +│ │ ├── mod.rs # Implementation +│ │ └── tests.rs # Tests +│ ├── system/ +│ │ ├── mod.rs +│ │ └── tests.rs +│ └── stake_pool/ +│ ├── mod.rs +│ └── tests.rs +└── tests/ + └── fixtures/ + ├── spl_token/ + │ ├── README.md # Program-specific notes + │ ├── mint_to_example.json + │ ├── transfer_example.json + │ └── ... + ├── system/ + │ ├── README.md + │ ├── transfer_example.json + │ └── ... + └── stake_pool/ + ├── README.md + └── ... +``` + +## Example: Complete Test Flow + +Let's walk through testing an SPL Token MintTo instruction: + +1. **Find transaction**: https://solscan.io/tx/35XirCzssnAVUB2FbLrf8vYUYmTq5omepqyR8tr5Y6eJ6yurs3LcRfzGxzn92wU3w5vBvM8BfodXsscz7nin8SbC?cluster=devnet + +2. **Fetch raw data**: +```bash +curl -X POST https://api.devnet.solana.com \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":1, + "method":"getTransaction", + "params":[ + "35XirCzssnAVUB2FbLrf8vYUYmTq5omepqyR8tr5Y6eJ6yurs3LcRfzGxzn92wU3w5vBvM8BfodXsscz7nin8SbC", + {"encoding":"json","maxSupportedTransactionVersion":0} + ] + }' +``` + +3. **Extract instruction [1]** (the MintTo instruction): +- instruction_data: `"6YCQpfSgHpSj"` (base58) +- accounts: [mint, destination, authority] + +4. **Get expected values** using jsonParsed encoding: +```bash +curl -X POST https://api.devnet.solana.com \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":1, + "method":"getTransaction", + "params":[ + "35XirCzssnAVUB2FbLrf8vYUYmTq5omepqyR8tr5Y6eJ6yurs3LcRfzGxzn92wU3w5vBvM8BfodXsscz7nin8SbC", + {"encoding":"jsonParsed","maxSupportedTransactionVersion":0} + ] + }' +``` + +Response shows: `amount: "1230000000"`, `mint: "5pdH..."`, etc. + +5. **Create fixture** at `tests/fixtures/spl_token/mint_to_example.json` + +6. **Run test**: +```bash +cargo test --package visualsign-solana --lib presets::spl_token::tests::test_mint_to_real_transaction -- --nocapture +``` + +7. **Validate output**: +``` +✓ amount: 1230000000 (matches) +✓ mint: 5pdHyGbtCmZdJ7ye71nzeke8kcQ4ngJNPHqoDvE5L2WT (matches) +✓ account: D7ZapNPiycy1imiL7deUiBXiqUGMisUuAmvAwSdxUKQT (matches) +✓ mintauthority: 9AM41swmGH1iq3L1oNnV8T385BwzVUeNUMuGqKJbiDMm (matches) +``` + +## Benefits of This Approach + +1. **Real-world validation** - Tests use actual on-chain transaction data +2. **No network dependencies** - Fixtures are committed to git, tests run offline +3. **Fast and focused** - Each test validates one instruction in isolation +4. **Easy to debug** - Clear output shows what fields match or mismatch +5. **Regression prevention** - Fixtures ensure parser changes don't break existing functionality +6. **Documentation** - Fixtures serve as examples of real instruction usage + +## Using Coverage to Find Missing Fixtures + +Coverage reports help identify which instruction types need test fixtures. + +**Quick start:** +```bash +# Generate HTML report and open in browser +cargo llvm-cov --package visualsign-solana --lib --open +``` + +Then navigate to `src/presets//mod.rs` to see which instruction match arms are uncovered (red). + +**Coverage-driven workflow:** + +1. Run coverage (see [project TESTING.md](/TESTING.md)) +2. Find uncovered match arms in `src/presets/spl_token/mod.rs` (or other program presets) +3. Each uncovered instruction type → create a new fixture following the guide above +4. Re-run coverage to verify the new fixture covers the code + +## Adopting This Pattern for New Programs + +When adding a new program preset (e.g., Metaplex, Raydium, etc.): + +1. Create `tests/fixtures//` directory +2. Add a program-specific README.md with any special notes +3. Implement the test helper functions in `/tests.rs` +4. Create fixture JSON files for key instruction types +5. Write fixture tests that validate field extraction +6. Reference this TESTING.md for the overall philosophy and process diff --git a/src/chain_parsers/visualsign-solana/src/core/visualsign.rs b/src/chain_parsers/visualsign-solana/src/core/visualsign.rs index 9caa1504..10783792 100644 --- a/src/chain_parsers/visualsign-solana/src/core/visualsign.rs +++ b/src/chain_parsers/visualsign-solana/src/core/visualsign.rs @@ -1012,9 +1012,10 @@ mod tests { } #[test] - fn test_unknown_program_tokenkeg() { + fn test_spl_token_tokenkeg_recognition() { // Test case from GitHub issue #76 // Transaction with TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA program + // This should be recognized as an SPL Token instruction, not unknown program let tokenkeg_tx = "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEDGtcy7Vc3xB54TVH4H/JNV6GLORFZVW2eiFky1mqlJTJHohT28K37lWNJzHkspHGumVg0rwhDxT5hd/JUEGupaAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpiz/aiPOGc/sEVBMImlZdQN5iFK0CVj9fTne9d3VuvB0BAgIBACMGAAF5QmVCZ074eW/VU/D+KlEJonY3BgtzkD1DFS0OaNFWDA=="; let tx_result = SolanaTransactionWrapper::from_string(tokenkeg_tx); @@ -1026,7 +1027,7 @@ mod tests { VisualSignOptions { metadata: None, decode_transfers: true, - transaction_name: Some("TokenKeg Test".to_string()), + transaction_name: Some("SPL Token Test".to_string()), developer_config: None, }, ); @@ -1064,8 +1065,116 @@ mod tests { "Should contain TokenKeg program ID in the output" ); - println!("✅ TokenKeg transaction parsed successfully"); + // Verify it's recognized as SPL Token, not unknown program + // The instruction should have a meaningful name like "Mint To" instead of just showing raw data + assert!( + json_str.contains("Mint To") || json_str.contains("SPL Token"), + "Should recognize TokenKeg as SPL Token program with proper instruction parsing" + ); + + println!("✅ TokenKeg transaction parsed successfully as SPL Token"); println!("Number of instruction fields: {}", instruction_fields.len()); println!("JSON output:\n{json_str}"); } + + #[test] + fn test_unknown_program_fallback() { + // Test that truly unknown programs are handled by the UnknownProgramVisualizer + // Using a program ID that will never be supported + use solana_sdk::{ + hash::Hash, instruction::CompiledInstruction, message::Message, pubkey::Pubkey, + signature::Signature, transaction::Transaction as SolanaTransaction, + }; + + // Use an address with "FAKE" spelled in ASCII hex (0x46414B45) repeated throughout + // This creates a program ID that's clearly for testing and will never be real + let unknown_program_id = Pubkey::new_from_array([ + 0x46, 0x41, 0x4B, 0x45, // "FAKE" in ASCII + 0x50, 0x52, 0x4F, 0x47, // "PROG" in ASCII + 0x52, 0x41, 0x4D, 0x21, // "RAM!" in ASCII + 0x46, 0x41, 0x4B, 0x45, // "FAKE" repeated + 0x50, 0x52, 0x4F, 0x47, // "PROG" repeated + 0x52, 0x41, 0x4D, 0x21, // "RAM!" repeated + 0x21, 0x21, 0x21, 0x21, // "!!!!" padding + 0x00, 0x00, 0x00, 0x00, // null padding + ]); + + let fee_payer = Pubkey::new_unique(); + + // Create a simple instruction with some data + let instruction_data = vec![0x01, 0x02, 0x03, 0x04, 0x05]; + let compiled_instruction = CompiledInstruction { + program_id_index: 1, + accounts: vec![0], + data: instruction_data.clone(), + }; + + let message = Message { + header: solana_sdk::message::MessageHeader { + num_required_signatures: 1, + num_readonly_signed_accounts: 0, + num_readonly_unsigned_accounts: 1, + }, + account_keys: vec![fee_payer, unknown_program_id], + recent_blockhash: Hash::new_unique(), + instructions: vec![compiled_instruction], + }; + + let transaction = SolanaTransaction { + signatures: vec![Signature::default()], + message, + }; + + let payload_result = SolanaVisualSignConverter.to_visual_sign_payload( + SolanaTransactionWrapper::Legacy(transaction), + VisualSignOptions { + decode_transfers: false, + metadata: None, + transaction_name: Some("Unknown Program Test".to_string()), + developer_config: None, + }, + ); + + assert!( + payload_result.is_ok(), + "Should convert unknown program transaction to payload" + ); + + let payload = payload_result.unwrap(); + + let instruction_fields: Vec<_> = payload + .fields + .iter() + .filter(|f| f.label().starts_with("Instruction")) + .collect(); + + assert_eq!( + instruction_fields.len(), + 1, + "Should have exactly 1 instruction" + ); + + let json_str = payload.to_json().unwrap(); + let program_id_str = unknown_program_id.to_string(); + assert!( + json_str.contains(&program_id_str), + "Should contain the unknown program ID in the output" + ); + + let instruction_data_hex = "0102030405"; + assert!( + json_str.contains(instruction_data_hex), + "Should show instruction data as hex for unknown programs" + ); + + // The output should contain "Program ID" fields which is what UnknownProgramVisualizer shows + assert!( + json_str.contains("Program ID"), + "Unknown program should display with 'Program ID' field" + ); + + println!("✅ Unknown program correctly handled by UnknownProgramVisualizer"); + println!("Program ID: {program_id_str}"); + println!("Instruction data hex: {instruction_data_hex}"); + } } diff --git a/src/chain_parsers/visualsign-solana/src/presets/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/mod.rs index 28bc00c6..393f7f98 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/mod.rs @@ -17,6 +17,7 @@ pub mod meteora_dlmm; pub mod neutral_trade; pub mod onre_app; pub mod orca_whirlpool; +pub mod spl_token; pub mod stakepool; pub mod swig_wallet; pub mod system; diff --git a/src/chain_parsers/visualsign-solana/src/presets/spl_token/config.rs b/src/chain_parsers/visualsign-solana/src/presets/spl_token/config.rs new file mode 100644 index 00000000..9e16b34a --- /dev/null +++ b/src/chain_parsers/visualsign-solana/src/presets/spl_token/config.rs @@ -0,0 +1,23 @@ +use crate::core::{SolanaIntegrationConfig, SolanaIntegrationConfigData}; + +pub struct SplTokenConfig; + +impl SolanaIntegrationConfig for SplTokenConfig { + fn new() -> Self { + Self + } + + fn data(&self) -> &SolanaIntegrationConfigData { + static DATA: std::sync::OnceLock = std::sync::OnceLock::new(); + DATA.get_or_init(|| { + let mut programs = std::collections::BTreeMap::new(); + let mut spl_token_instructions = std::collections::BTreeMap::new(); + spl_token_instructions.insert("*", vec!["*"]); + programs.insert( + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + spl_token_instructions, + ); + SolanaIntegrationConfigData { programs } + }) + } +} diff --git a/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs new file mode 100644 index 00000000..1eba33ba --- /dev/null +++ b/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs @@ -0,0 +1,543 @@ +//! SPL Token preset implementation for Solana +//! Handles the Token Program (TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA) + +mod config; + +use crate::core::{ + InstructionVisualizer, SolanaIntegrationConfig, VisualizerContext, VisualizerKind, +}; +use config::SplTokenConfig; +use solana_program::program_option::COption; +use spl_token::instruction::{AuthorityType, TokenInstruction}; +use visualsign::errors::VisualSignError; +use visualsign::field_builders::*; +use visualsign::{ + AnnotatedPayloadField, SignablePayloadField, SignablePayloadFieldCommon, + SignablePayloadFieldListLayout, SignablePayloadFieldPreviewLayout, SignablePayloadFieldTextV2, +}; + +// Create a static instance that we can reference +static SPL_TOKEN_CONFIG: SplTokenConfig = SplTokenConfig; + +pub struct SplTokenVisualizer; + +impl InstructionVisualizer for SplTokenVisualizer { + fn visualize_tx_commands( + &self, + context: &VisualizerContext, + ) -> Result { + let instruction = context + .current_instruction() + .ok_or_else(|| VisualSignError::MissingData("No instruction found".into()))?; + + let token_instruction = TokenInstruction::unpack(&instruction.data).map_err(|e| { + VisualSignError::DecodeError(format!("Failed to unpack SPL token instruction: {e}")) + })?; + + create_token_preview_layout(&token_instruction, instruction, context) + } + + fn get_config(&self) -> Option<&dyn SolanaIntegrationConfig> { + Some(&SPL_TOKEN_CONFIG) + } + + fn kind(&self) -> VisualizerKind { + VisualizerKind::Payments("SplToken") + } +} + +fn format_authority_type(authority_type: &AuthorityType) -> &'static str { + match authority_type { + AuthorityType::MintTokens => "Mint Tokens", + AuthorityType::FreezeAccount => "Freeze Account", + AuthorityType::AccountOwner => "Account Owner", + AuthorityType::CloseAccount => "Close Account", + } +} + +fn format_coption_pubkey(coption: &COption) -> String { + match coption { + COption::Some(pubkey) => pubkey.to_string(), + COption::None => "None".to_string(), + } +} + +fn create_token_preview_layout( + token_instruction: &TokenInstruction, + instruction: &solana_sdk::instruction::Instruction, + context: &VisualizerContext, +) -> Result { + match token_instruction { + TokenInstruction::MintTo { amount } => { + let instruction_name = format!("Mint To: {amount}"); + + let condensed_fields = vec![create_text_field("Instruction", &instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", "Mint To")?, + create_text_field("Amount", &amount.to_string())?, + ]; + + // MintTo accounts: [0] mint, [1] destination account, [2] mint authority + if let Some(mint_account) = instruction.accounts.first() { + expanded_fields.push(create_text_field("mint", &mint_account.pubkey.to_string())?); + } + if let Some(destination) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field( + "account", + &destination.pubkey.to_string(), + )?); + } + if let Some(authority) = instruction.accounts.get(2) { + expanded_fields.push(create_text_field( + "mintAuthority", + &authority.pubkey.to_string(), + )?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + let expanded_fields = expanded_fields; + + create_preview_layout_field( + &instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::MintToChecked { amount, decimals } => { + let instruction_name = format!("Mint To: {amount} (decimals: {decimals})"); + + let condensed_fields = vec![create_text_field("Instruction", &instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", "Mint To (Checked)")?, + create_text_field("Amount", &amount.to_string())?, + create_text_field("Decimals", &decimals.to_string())?, + ]; + + // MintToChecked accounts: [0] mint, [1] destination account, [2] mint authority + if let Some(mint_account) = instruction.accounts.first() { + expanded_fields.push(create_text_field("mint", &mint_account.pubkey.to_string())?); + } + if let Some(destination) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field( + "account", + &destination.pubkey.to_string(), + )?); + } + if let Some(authority) = instruction.accounts.get(2) { + expanded_fields.push(create_text_field( + "mintAuthority", + &authority.pubkey.to_string(), + )?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + let expanded_fields = expanded_fields; + + create_preview_layout_field( + &instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::SetAuthority { + authority_type, + new_authority, + } => { + let authority_type_str = format_authority_type(authority_type); + let new_authority_str = format_coption_pubkey(new_authority); + let instruction_name = format!("Set Authority: {authority_type_str}"); + + let condensed_fields = vec![create_text_field("Instruction", &instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", "Set Authority")?, + create_text_field("Authority Type", authority_type_str)?, + create_text_field("New Authority", &new_authority_str)?, + ]; + + // SetAuthority accounts: [0] account whose authority is being set, [1] current authority + if let Some(account) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Account", &account.pubkey.to_string())?); + } + if let Some(current_authority) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field( + "Current Authority", + ¤t_authority.pubkey.to_string(), + )?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + let expanded_fields = expanded_fields; + + create_preview_layout_field( + &instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::Transfer { amount } => { + let instruction_name = "Transfer"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + create_text_field("Amount", &amount.to_string())?, + ]; + + // Transfer accounts: [0] source account, [1] destination account, [2] owner + if let Some(source) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Source", &source.pubkey.to_string())?); + } + if let Some(destination) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field( + "Destination", + &destination.pubkey.to_string(), + )?); + } + if let Some(owner) = instruction.accounts.get(2) { + expanded_fields.push(create_text_field("Owner", &owner.pubkey.to_string())?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + let expanded_fields = expanded_fields; + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::TransferChecked { amount, decimals } => { + let instruction_name = "Transfer (Checked)"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + create_text_field("Amount", &amount.to_string())?, + create_text_field("Decimals", &decimals.to_string())?, + ]; + + // TransferChecked accounts: [0] source account, [1] mint, [2] destination account, [3] owner + if let Some(source) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Source", &source.pubkey.to_string())?); + } + if let Some(mint) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field("Token Mint", &mint.pubkey.to_string())?); + } + if let Some(destination) = instruction.accounts.get(2) { + expanded_fields.push(create_text_field( + "Destination", + &destination.pubkey.to_string(), + )?); + } + if let Some(owner) = instruction.accounts.get(3) { + expanded_fields.push(create_text_field("Owner", &owner.pubkey.to_string())?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + let expanded_fields = expanded_fields; + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::Burn { amount } => { + let instruction_name = "Burn"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + create_text_field("Amount", &amount.to_string())?, + ]; + + // Burn accounts: [0] token account to burn from, [1] mint, [2] owner + if let Some(account) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Account", &account.pubkey.to_string())?); + } + if let Some(mint) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field("Token Mint", &mint.pubkey.to_string())?); + } + if let Some(owner) = instruction.accounts.get(2) { + expanded_fields.push(create_text_field("Owner", &owner.pubkey.to_string())?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + let expanded_fields = expanded_fields; + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::BurnChecked { amount, decimals } => { + let instruction_name = "Burn (Checked)"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + create_text_field("Amount", &amount.to_string())?, + create_text_field("Decimals", &decimals.to_string())?, + ]; + + // BurnChecked accounts: [0] token account to burn from, [1] mint, [2] owner + if let Some(account) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Account", &account.pubkey.to_string())?); + } + if let Some(mint) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field("Token Mint", &mint.pubkey.to_string())?); + } + if let Some(owner) = instruction.accounts.get(2) { + expanded_fields.push(create_text_field("Owner", &owner.pubkey.to_string())?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + let expanded_fields = expanded_fields; + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::Approve { amount } => { + let instruction_name = "Approve"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + create_text_field("Amount", &amount.to_string())?, + ]; + + // Approve accounts: [0] source account, [1] delegate, [2] owner + if let Some(source) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Source", &source.pubkey.to_string())?); + } + if let Some(delegate) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field("Delegate", &delegate.pubkey.to_string())?); + } + if let Some(owner) = instruction.accounts.get(2) { + expanded_fields.push(create_text_field("Owner", &owner.pubkey.to_string())?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + let expanded_fields = expanded_fields; + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::ApproveChecked { amount, decimals } => { + let instruction_name = "Approve (Checked)"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + create_text_field("Amount", &amount.to_string())?, + create_text_field("Decimals", &decimals.to_string())?, + ]; + + // ApproveChecked accounts: [0] source account, [1] mint, [2] delegate, [3] owner + if let Some(source) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Source", &source.pubkey.to_string())?); + } + if let Some(mint) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field("Token Mint", &mint.pubkey.to_string())?); + } + if let Some(delegate) = instruction.accounts.get(2) { + expanded_fields.push(create_text_field("Delegate", &delegate.pubkey.to_string())?); + } + if let Some(owner) = instruction.accounts.get(3) { + expanded_fields.push(create_text_field("Owner", &owner.pubkey.to_string())?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + let expanded_fields = expanded_fields; + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + _ => { + // Handle other token instructions with basic layout + let instruction_name = format_token_instruction(token_instruction); + + let condensed_fields = vec![ + create_text_field("Instruction", &instruction_name)?, + create_text_field("Program", "SPL Token")?, + ]; + + let expanded_fields = vec![ + create_text_field("Instruction", &instruction_name)?, + create_text_field("Program", "SPL Token")?, + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Raw Data", &hex::encode(&instruction.data))?, + ]; + + create_preview_layout_field( + &instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + } +} + +fn create_preview_layout_field( + title: &str, + condensed_fields: Vec, + expanded_fields: Vec, + instruction: &solana_sdk::instruction::Instruction, + context: &VisualizerContext, +) -> Result { + let condensed = SignablePayloadFieldListLayout { + fields: condensed_fields, + }; + let expanded = SignablePayloadFieldListLayout { + fields: expanded_fields, + }; + + let preview_layout = SignablePayloadFieldPreviewLayout { + title: Some(SignablePayloadFieldTextV2 { + text: title.to_string(), + }), + subtitle: Some(SignablePayloadFieldTextV2 { + text: String::new(), + }), + condensed: Some(condensed), + expanded: Some(expanded), + }; + + Ok(AnnotatedPayloadField { + static_annotation: None, + dynamic_annotation: None, + signable_payload_field: SignablePayloadField::PreviewLayout { + common: SignablePayloadFieldCommon { + label: format!("Instruction {}", context.instruction_index() + 1), + fallback_text: format!( + "Program ID: {}\nData: {}", + instruction.program_id, + hex::encode(&instruction.data) + ), + }, + preview_layout, + }, + }) +} + +fn format_token_instruction(instruction: &TokenInstruction) -> String { + match instruction { + TokenInstruction::InitializeMint { .. } => "Initialize Mint".to_string(), + TokenInstruction::InitializeMint2 { .. } => "Initialize Mint (v2)".to_string(), + TokenInstruction::InitializeAccount => "Initialize Token Account".to_string(), + TokenInstruction::InitializeAccount2 { .. } => "Initialize Token Account (v2)".to_string(), + TokenInstruction::InitializeAccount3 { .. } => "Initialize Token Account (v3)".to_string(), + TokenInstruction::InitializeMultisig { .. } => "Initialize Multisig".to_string(), + TokenInstruction::InitializeMultisig2 { .. } => "Initialize Multisig (v2)".to_string(), + TokenInstruction::Transfer { .. } => "Transfer".to_string(), + TokenInstruction::TransferChecked { .. } => "Transfer (Checked)".to_string(), + TokenInstruction::Approve { .. } => "Approve".to_string(), + TokenInstruction::ApproveChecked { .. } => "Approve (Checked)".to_string(), + TokenInstruction::Revoke => "Revoke".to_string(), + TokenInstruction::SetAuthority { .. } => "Set Authority".to_string(), + // Note: MintTo and MintToChecked are handled specially in create_token_preview_layout + // and never reach this function, so they are intentionally omitted here + TokenInstruction::Burn { .. } => "Burn".to_string(), + TokenInstruction::BurnChecked { .. } => "Burn (Checked)".to_string(), + TokenInstruction::CloseAccount => "Close Account".to_string(), + TokenInstruction::FreezeAccount => "Freeze Account".to_string(), + TokenInstruction::ThawAccount => "Thaw Account".to_string(), + TokenInstruction::SyncNative => "Sync Native".to_string(), + TokenInstruction::GetAccountDataSize { .. } => "Get Account Data Size".to_string(), + TokenInstruction::InitializeImmutableOwner => "Initialize Immutable Owner".to_string(), + TokenInstruction::AmountToUiAmount { .. } => "Amount To UI Amount".to_string(), + TokenInstruction::UiAmountToAmount { .. } => "UI Amount To Amount".to_string(), + // These cases are handled specially above and should never reach here + TokenInstruction::MintTo { .. } | TokenInstruction::MintToChecked { .. } => { + unreachable!("MintTo instructions are handled specially in create_token_preview_layout") + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests; diff --git a/src/chain_parsers/visualsign-solana/src/presets/spl_token/tests.rs b/src/chain_parsers/visualsign-solana/src/presets/spl_token/tests.rs new file mode 100644 index 00000000..9807bc3d --- /dev/null +++ b/src/chain_parsers/visualsign-solana/src/presets/spl_token/tests.rs @@ -0,0 +1,1191 @@ +use super::*; +use crate::core::VisualizerContext; +use solana_parser::solana::structs::SolanaAccount; +use solana_sdk::instruction::{AccountMeta, Instruction}; +use solana_sdk::pubkey::Pubkey; +use spl_token::instruction as token_instruction; +use spl_token::instruction::AuthorityType; +use std::str::FromStr; +use visualsign::SignablePayloadField; + +/// Test case for instructions with amount only +struct AmountTestCase { + name: &'static str, + expected_name: &'static str, + amount: u64, + builder: fn(&Pubkey, &Pubkey, &Pubkey, &Pubkey, u64) -> solana_sdk::instruction::Instruction, + variant_check: fn(&TokenInstruction) -> bool, +} + +/// Test case for checked instructions (amount + decimals) +struct CheckedTestCase { + name: &'static str, + expected_name: &'static str, + amount: u64, + decimals: u8, + builder: fn( + &Pubkey, + &Pubkey, + &Pubkey, + &Pubkey, + &Pubkey, + u64, + u8, + ) -> solana_sdk::instruction::Instruction, + variant_check: fn(&TokenInstruction) -> bool, +} + +/// Test case for simple instructions (no parameters) +struct SimpleTestCase { + name: &'static str, + expected_name: &'static str, + builder: fn(&Pubkey, &Pubkey, &Pubkey) -> solana_sdk::instruction::Instruction, + variant_check: fn(&TokenInstruction) -> bool, +} + +fn run_amount_test(test: &AmountTestCase) { + let key1 = Pubkey::new_unique(); + let key2 = Pubkey::new_unique(); + let key3 = Pubkey::new_unique(); + let key4 = Pubkey::new_unique(); + + let instruction = (test.builder)(&key1, &key2, &key3, &key4, test.amount); + let parsed = TokenInstruction::unpack(&instruction.data).unwrap(); + + assert!( + (test.variant_check)(&parsed), + "{}: variant mismatch", + test.name + ); + assert_eq!( + format_token_instruction(&parsed), + test.expected_name, + "{}: name mismatch", + test.name + ); + + // Verify amount + let parsed_amount = match parsed { + TokenInstruction::Transfer { amount } => amount, + TokenInstruction::Burn { amount } => amount, + TokenInstruction::Approve { amount } => amount, + TokenInstruction::MintTo { amount } => amount, + _ => panic!("{}: Expected instruction with amount field", test.name), + }; + assert_eq!(parsed_amount, test.amount, "{}: amount mismatch", test.name); +} + +fn run_checked_test(test: &CheckedTestCase) { + let key1 = Pubkey::new_unique(); + let key2 = Pubkey::new_unique(); + let key3 = Pubkey::new_unique(); + let key4 = Pubkey::new_unique(); + let key5 = Pubkey::new_unique(); + + let instruction = (test.builder)( + &key1, + &key2, + &key3, + &key4, + &key5, + test.amount, + test.decimals, + ); + let parsed = TokenInstruction::unpack(&instruction.data).unwrap(); + + assert!( + (test.variant_check)(&parsed), + "{}: variant mismatch", + test.name + ); + assert_eq!( + format_token_instruction(&parsed), + test.expected_name, + "{}: name mismatch", + test.name + ); + + // Verify amount and decimals + let (parsed_amount, parsed_decimals) = match parsed { + TokenInstruction::TransferChecked { amount, decimals } => (amount, decimals), + TokenInstruction::BurnChecked { amount, decimals } => (amount, decimals), + TokenInstruction::ApproveChecked { amount, decimals } => (amount, decimals), + TokenInstruction::MintToChecked { amount, decimals } => (amount, decimals), + _ => panic!("{}: Expected checked instruction", test.name), + }; + assert_eq!(parsed_amount, test.amount, "{}: amount mismatch", test.name); + assert_eq!( + parsed_decimals, test.decimals, + "{}: decimals mismatch", + test.name + ); +} + +fn run_simple_test(test: &SimpleTestCase) { + let key1 = Pubkey::new_unique(); + let key2 = Pubkey::new_unique(); + let key3 = Pubkey::new_unique(); + + let instruction = (test.builder)(&key1, &key2, &key3); + let parsed = TokenInstruction::unpack(&instruction.data).unwrap(); + + assert!( + (test.variant_check)(&parsed), + "{}: variant mismatch", + test.name + ); + assert_eq!( + format_token_instruction(&parsed), + test.expected_name, + "{}: name mismatch", + test.name + ); +} + +#[test] +fn test_amount_instructions() { + let test_cases = [ + AmountTestCase { + name: "Transfer", + expected_name: "Transfer", + amount: 1000, + builder: |source, dest, owner, _unused, amount| { + token_instruction::transfer(&spl_token::id(), source, dest, owner, &[], amount) + .unwrap() + }, + variant_check: |i| matches!(i, TokenInstruction::Transfer { .. }), + }, + AmountTestCase { + name: "Burn", + expected_name: "Burn", + amount: 250, + builder: |account, mint, owner, _unused, amount| { + token_instruction::burn(&spl_token::id(), account, mint, owner, &[], amount) + .unwrap() + }, + variant_check: |i| matches!(i, TokenInstruction::Burn { .. }), + }, + AmountTestCase { + name: "Approve", + expected_name: "Approve", + amount: 10000, + builder: |source, delegate, owner, _unused, amount| { + token_instruction::approve(&spl_token::id(), source, delegate, owner, &[], amount) + .unwrap() + }, + variant_check: |i| matches!(i, TokenInstruction::Approve { .. }), + }, + ]; + + for test in &test_cases { + run_amount_test(test); + } +} + +#[test] +fn test_checked_instructions() { + let test_cases = [ + CheckedTestCase { + name: "TransferChecked", + expected_name: "Transfer (Checked)", + amount: 5000, + decimals: 6, + builder: |source, mint, dest, owner, _unused, amount, decimals| { + token_instruction::transfer_checked( + &spl_token::id(), + source, + mint, + dest, + owner, + &[], + amount, + decimals, + ) + .unwrap() + }, + variant_check: |i| matches!(i, TokenInstruction::TransferChecked { .. }), + }, + CheckedTestCase { + name: "BurnChecked", + expected_name: "Burn (Checked)", + amount: 750, + decimals: 9, + builder: |account, mint, owner, _unused1, _unused2, amount, decimals| { + token_instruction::burn_checked( + &spl_token::id(), + account, + mint, + owner, + &[], + amount, + decimals, + ) + .unwrap() + }, + variant_check: |i| matches!(i, TokenInstruction::BurnChecked { .. }), + }, + CheckedTestCase { + name: "ApproveChecked", + expected_name: "Approve (Checked)", + amount: 15000, + decimals: 6, + builder: |source, mint, delegate, owner, _unused, amount, decimals| { + token_instruction::approve_checked( + &spl_token::id(), + source, + mint, + delegate, + owner, + &[], + amount, + decimals, + ) + .unwrap() + }, + variant_check: |i| matches!(i, TokenInstruction::ApproveChecked { .. }), + }, + ]; + + for test in &test_cases { + run_checked_test(test); + } +} + +#[test] +fn test_simple_instructions() { + let test_cases = [ + SimpleTestCase { + name: "Revoke", + expected_name: "Revoke", + builder: |source, owner, _unused| { + token_instruction::revoke(&spl_token::id(), source, owner, &[]).unwrap() + }, + variant_check: |i| matches!(i, TokenInstruction::Revoke), + }, + SimpleTestCase { + name: "CloseAccount", + expected_name: "Close Account", + builder: |account, destination, owner| { + token_instruction::close_account(&spl_token::id(), account, destination, owner, &[]) + .unwrap() + }, + variant_check: |i| matches!(i, TokenInstruction::CloseAccount), + }, + SimpleTestCase { + name: "FreezeAccount", + expected_name: "Freeze Account", + builder: |account, mint, freeze_authority| { + token_instruction::freeze_account( + &spl_token::id(), + account, + mint, + freeze_authority, + &[], + ) + .unwrap() + }, + variant_check: |i| matches!(i, TokenInstruction::FreezeAccount), + }, + SimpleTestCase { + name: "ThawAccount", + expected_name: "Thaw Account", + builder: |account, mint, freeze_authority| { + token_instruction::thaw_account( + &spl_token::id(), + account, + mint, + freeze_authority, + &[], + ) + .unwrap() + }, + variant_check: |i| matches!(i, TokenInstruction::ThawAccount), + }, + ]; + + for test in &test_cases { + run_simple_test(test); + } +} + +#[test] +fn test_initialize_mint() { + let mint = Pubkey::new_unique(); + let mint_authority = Pubkey::new_unique(); + let freeze_authority = Some(Pubkey::new_unique()); + let decimals = 6u8; + + let instruction = token_instruction::initialize_mint( + &spl_token::id(), + &mint, + &mint_authority, + freeze_authority.as_ref(), + decimals, + ) + .unwrap(); + + let parsed = TokenInstruction::unpack(&instruction.data).unwrap(); + assert!(matches!(parsed, TokenInstruction::InitializeMint { .. })); + assert_eq!(format_token_instruction(&parsed), "Initialize Mint"); + + if let TokenInstruction::InitializeMint { + decimals: parsed_decimals, + mint_authority: parsed_mint_auth, + freeze_authority: parsed_freeze_auth, + } = parsed + { + assert_eq!(parsed_decimals, decimals); + assert_eq!(parsed_mint_auth, mint_authority); + assert_eq!(parsed_freeze_auth, freeze_authority.into()); + } +} + +#[test] +fn test_initialize_mint2() { + let instruction = token_instruction::initialize_mint2( + &spl_token::id(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + Some(&Pubkey::new_unique()), + 9, + ) + .unwrap(); + + let parsed = TokenInstruction::unpack(&instruction.data).unwrap(); + assert!(matches!(parsed, TokenInstruction::InitializeMint2 { .. })); + assert_eq!(format_token_instruction(&parsed), "Initialize Mint (v2)"); +} + +#[test] +fn test_freeze_and_thaw_coverage() { + // Explicitly test FreezeAccount instruction formatting + let freeze_instruction = token_instruction::freeze_account( + &spl_token::id(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &[], + ) + .unwrap(); + + let freeze_parsed = TokenInstruction::unpack(&freeze_instruction.data).unwrap(); + assert!(matches!(freeze_parsed, TokenInstruction::FreezeAccount)); + assert_eq!(format_token_instruction(&freeze_parsed), "Freeze Account"); + + // Explicitly test ThawAccount instruction formatting + let thaw_instruction = token_instruction::thaw_account( + &spl_token::id(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &Pubkey::new_unique(), + &[], + ) + .unwrap(); + + let thaw_parsed = TokenInstruction::unpack(&thaw_instruction.data).unwrap(); + assert!(matches!(thaw_parsed, TokenInstruction::ThawAccount)); + assert_eq!(format_token_instruction(&thaw_parsed), "Thaw Account"); +} + +#[test] +fn test_transfer_visualization_with_addresses() { + // Create a transfer instruction + let source = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let amount = 1000u64; + + let instruction = + token_instruction::transfer(&spl_token::id(), &source, &destination, &owner, &[], amount) + .unwrap(); + + // Create a context with this instruction + let sender = SolanaAccount { + account_key: source.to_string(), + signer: false, + writable: false, + }; + let instructions = vec![instruction.clone()]; + let idl_registry = crate::idl::IdlRegistry::new(); + let context = VisualizerContext::new(&sender, 0, &instructions, &idl_registry); + + // Visualize the instruction + let visualizer = SplTokenVisualizer; + let result = visualizer.visualize_tx_commands(&context).unwrap(); + + // Verify the result structure + match result.signable_payload_field { + SignablePayloadField::PreviewLayout { + common, + preview_layout, + } => { + // Check label + assert_eq!(common.label, "Instruction 1"); + + // Check title + assert_eq!(preview_layout.title.as_ref().unwrap().text, "Transfer"); + + // Check that we have expanded fields + let expanded = preview_layout.expanded.as_ref().unwrap(); + assert!(!expanded.fields.is_empty()); + + // Verify Program ID field exists + let has_program_id = expanded.fields.iter().any(|field| { + matches!( + &field.signable_payload_field, + SignablePayloadField::TextV2 { common, .. } if common.label == "Program ID" + ) + }); + assert!(has_program_id, "Should have Program ID field"); + + // Verify Raw Data field exists + let has_raw_data = expanded.fields.iter().any(|field| { + matches!( + &field.signable_payload_field, + SignablePayloadField::TextV2 { common, .. } if common.label == "Raw Data" + ) + }); + assert!(has_raw_data, "Should have Raw Data field"); + } + _ => panic!("Expected PreviewLayout"), + } +} + +#[test] +fn test_mint_to_visualization_with_amount() { + // Create a mint_to instruction + let mint = Pubkey::new_unique(); + let account = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let amount = 5000u64; + + let instruction = + token_instruction::mint_to(&spl_token::id(), &mint, &account, &authority, &[], amount) + .unwrap(); + + // Create a context + let sender = SolanaAccount { + account_key: authority.to_string(), + signer: false, + writable: false, + }; + let instructions = vec![instruction.clone()]; + let idl_registry = crate::idl::IdlRegistry::new(); + let context = VisualizerContext::new(&sender, 0, &instructions, &idl_registry); + + // Visualize + let visualizer = SplTokenVisualizer; + let result = visualizer.visualize_tx_commands(&context).unwrap(); + + // Verify the result + match result.signable_payload_field { + SignablePayloadField::PreviewLayout { preview_layout, .. } => { + // Check title contains amount + let title = &preview_layout.title.as_ref().unwrap().text; + assert!(title.contains("Mint To")); + assert!(title.contains(&amount.to_string())); + + // Check expanded fields contain Amount field + let expanded = preview_layout.expanded.as_ref().unwrap(); + let has_amount_field = expanded.fields.iter().any(|field| { + matches!( + &field.signable_payload_field, + SignablePayloadField::TextV2 { common, .. } if common.label == "Amount" + ) + }); + assert!(has_amount_field, "Should have Amount field"); + } + _ => panic!("Expected PreviewLayout"), + } +} + +#[test] +fn test_freeze_account_visualization() { + // Create a freeze_account instruction + let account = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let freeze_authority = Pubkey::new_unique(); + + let instruction = token_instruction::freeze_account( + &spl_token::id(), + &account, + &mint, + &freeze_authority, + &[], + ) + .unwrap(); + + // Create a context + let sender = SolanaAccount { + account_key: freeze_authority.to_string(), + signer: false, + writable: false, + }; + let instructions = vec![instruction.clone()]; + let idl_registry = crate::idl::IdlRegistry::new(); + let context = VisualizerContext::new(&sender, 0, &instructions, &idl_registry); + + // Visualize + let visualizer = SplTokenVisualizer; + let result = visualizer.visualize_tx_commands(&context).unwrap(); + + // Verify the result + match result.signable_payload_field { + SignablePayloadField::PreviewLayout { preview_layout, .. } => { + // Check title + assert_eq!( + preview_layout.title.as_ref().unwrap().text, + "Freeze Account" + ); + + // Check expanded fields contain program info + let expanded = preview_layout.expanded.as_ref().unwrap(); + assert!(!expanded.fields.is_empty()); + } + _ => panic!("Expected PreviewLayout"), + } +} + +#[test] +fn test_thaw_account_visualization() { + // Create a thaw_account instruction + let account = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let freeze_authority = Pubkey::new_unique(); + + let instruction = + token_instruction::thaw_account(&spl_token::id(), &account, &mint, &freeze_authority, &[]) + .unwrap(); + + // Create a context + let sender = SolanaAccount { + account_key: freeze_authority.to_string(), + signer: false, + writable: false, + }; + let instructions = vec![instruction.clone()]; + let idl_registry = crate::idl::IdlRegistry::new(); + let context = VisualizerContext::new(&sender, 0, &instructions, &idl_registry); + + // Visualize + let visualizer = SplTokenVisualizer; + let result = visualizer.visualize_tx_commands(&context).unwrap(); + + // Verify the result + match result.signable_payload_field { + SignablePayloadField::PreviewLayout { preview_layout, .. } => { + // Check title + assert_eq!(preview_layout.title.as_ref().unwrap().text, "Thaw Account"); + + // Check expanded fields contain program info + let expanded = preview_layout.expanded.as_ref().unwrap(); + assert!(!expanded.fields.is_empty()); + } + _ => panic!("Expected PreviewLayout"), + } +} + +#[test] +fn test_transfer_checked_visualization_with_decimals() { + // Create a transfer_checked instruction + let source = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let amount = 2500u64; + let decimals = 6u8; + + let instruction = token_instruction::transfer_checked( + &spl_token::id(), + &source, + &mint, + &destination, + &owner, + &[], + amount, + decimals, + ) + .unwrap(); + + // Create a context + let sender = SolanaAccount { + account_key: owner.to_string(), + signer: false, + writable: false, + }; + let instructions = vec![instruction.clone()]; + let idl_registry = crate::idl::IdlRegistry::new(); + let context = VisualizerContext::new(&sender, 0, &instructions, &idl_registry); + + // Visualize + let visualizer = SplTokenVisualizer; + let result = visualizer.visualize_tx_commands(&context).unwrap(); + + // Verify the result + match result.signable_payload_field { + SignablePayloadField::PreviewLayout { preview_layout, .. } => { + // Check title + let title = &preview_layout.title.as_ref().unwrap().text; + assert_eq!(title, "Transfer (Checked)"); + + // Check expanded fields + let expanded = preview_layout.expanded.as_ref().unwrap(); + + // Should have Instruction field + let has_instruction_field = expanded.fields.iter().any(|field| { + matches!( + &field.signable_payload_field, + SignablePayloadField::TextV2 { common, .. } if common.label == "Instruction" + ) + }); + assert!(has_instruction_field, "Should have Instruction field"); + + // Should have Token Mint field (for checked instructions) + let has_mint_field = expanded.fields.iter().any(|field| { + matches!( + &field.signable_payload_field, + SignablePayloadField::TextV2 { common, .. } if common.label == "Token Mint" + ) + }); + assert!(has_mint_field, "Should have Token Mint field"); + } + _ => panic!("Expected PreviewLayout"), + } +} + +#[test] +fn test_set_authority_with_mint_tokens() { + // Test SetAuthority with MintTokens authority type + let account = Pubkey::new_unique(); + let current_authority = Pubkey::new_unique(); + let new_authority = Pubkey::new_unique(); + + let instruction = token_instruction::set_authority( + &spl_token::id(), + &account, + Some(&new_authority), + AuthorityType::MintTokens, + ¤t_authority, + &[], + ) + .unwrap(); + + let parsed = TokenInstruction::unpack(&instruction.data).unwrap(); + assert!(matches!(parsed, TokenInstruction::SetAuthority { .. })); + + // Create a context + let sender = SolanaAccount { + account_key: current_authority.to_string(), + signer: false, + writable: false, + }; + let instructions = vec![instruction.clone()]; + let idl_registry = crate::idl::IdlRegistry::new(); + let context = VisualizerContext::new(&sender, 0, &instructions, &idl_registry); + + // Visualize + let visualizer = SplTokenVisualizer; + let result = visualizer.visualize_tx_commands(&context).unwrap(); + + // Verify the result + match result.signable_payload_field { + SignablePayloadField::PreviewLayout { preview_layout, .. } => { + // Check title contains authority type + let title = &preview_layout.title.as_ref().unwrap().text; + assert!(title.contains("Set Authority")); + assert!(title.contains("Mint Tokens")); + + // Check expanded fields + let expanded = preview_layout.expanded.as_ref().unwrap(); + + // Should have Authority Type field + let has_authority_type = expanded.fields.iter().any(|field| { + if let SignablePayloadField::TextV2 { common, text_v2 } = + &field.signable_payload_field + { + common.label == "Authority Type" && text_v2.text == "Mint Tokens" + } else { + false + } + }); + assert!(has_authority_type, "Should have Authority Type field"); + + // Should have New Authority field with the pubkey + let has_new_authority = expanded.fields.iter().any(|field| { + if let SignablePayloadField::TextV2 { common, text_v2 } = + &field.signable_payload_field + { + common.label == "New Authority" && text_v2.text == new_authority.to_string() + } else { + false + } + }); + assert!( + has_new_authority, + "Should have New Authority field with pubkey" + ); + } + _ => panic!("Expected PreviewLayout"), + } +} + +#[test] +fn test_set_authority_with_none() { + // Test SetAuthority with None as new_authority + let account = Pubkey::new_unique(); + let current_authority = Pubkey::new_unique(); + + let instruction = token_instruction::set_authority( + &spl_token::id(), + &account, + None, + AuthorityType::FreezeAccount, + ¤t_authority, + &[], + ) + .unwrap(); + + let parsed = TokenInstruction::unpack(&instruction.data).unwrap(); + assert!(matches!(parsed, TokenInstruction::SetAuthority { .. })); + + // Create a context + let sender = SolanaAccount { + account_key: current_authority.to_string(), + signer: false, + writable: false, + }; + let instructions = vec![instruction.clone()]; + let idl_registry = crate::idl::IdlRegistry::new(); + let context = VisualizerContext::new(&sender, 0, &instructions, &idl_registry); + + // Visualize + let visualizer = SplTokenVisualizer; + let result = visualizer.visualize_tx_commands(&context).unwrap(); + + // Verify the result + match result.signable_payload_field { + SignablePayloadField::PreviewLayout { preview_layout, .. } => { + // Check title + let title = &preview_layout.title.as_ref().unwrap().text; + assert!(title.contains("Set Authority")); + assert!(title.contains("Freeze Account")); + + // Check expanded fields + let expanded = preview_layout.expanded.as_ref().unwrap(); + + // Should have Authority Type field + let has_authority_type = expanded.fields.iter().any(|field| { + if let SignablePayloadField::TextV2 { common, text_v2 } = + &field.signable_payload_field + { + common.label == "Authority Type" && text_v2.text == "Freeze Account" + } else { + false + } + }); + assert!(has_authority_type, "Should have Authority Type field"); + + // Should have New Authority field with "None" + let has_new_authority = expanded.fields.iter().any(|field| { + if let SignablePayloadField::TextV2 { common, text_v2 } = + &field.signable_payload_field + { + common.label == "New Authority" && text_v2.text == "None" + } else { + false + } + }); + assert!( + has_new_authority, + "Should have New Authority field with None" + ); + } + _ => panic!("Expected PreviewLayout"), + } +} + +#[test] +fn test_set_authority_all_types() { + // Test all authority types to ensure format_authority_type works correctly + let test_cases = [ + (AuthorityType::MintTokens, "Mint Tokens"), + (AuthorityType::FreezeAccount, "Freeze Account"), + (AuthorityType::AccountOwner, "Account Owner"), + (AuthorityType::CloseAccount, "Close Account"), + ]; + + for (authority_type, expected_name) in test_cases.iter() { + let account = Pubkey::new_unique(); + let current_authority = Pubkey::new_unique(); + let new_authority = Pubkey::new_unique(); + + let instruction = token_instruction::set_authority( + &spl_token::id(), + &account, + Some(&new_authority), + authority_type.clone(), + ¤t_authority, + &[], + ) + .unwrap(); + + let parsed = TokenInstruction::unpack(&instruction.data).unwrap(); + + if let TokenInstruction::SetAuthority { + authority_type: parsed_auth_type, + .. + } = parsed + { + assert_eq!(parsed_auth_type, *authority_type); + assert_eq!(format_authority_type(&parsed_auth_type), *expected_name); + } else { + panic!("Expected SetAuthority instruction"); + } + } +} + +fn run_visualization_test( + instruction: solana_sdk::instruction::Instruction, + expected_title_substr: &str, + expected_expanded_labels: &[&str], +) { + let sender = SolanaAccount { + account_key: Pubkey::new_unique().to_string(), + signer: false, + writable: false, + }; + let instructions = vec![instruction.clone()]; + let idl_registry = crate::idl::IdlRegistry::new(); + let context = VisualizerContext::new(&sender, 0, &instructions, &idl_registry); + + let visualizer = SplTokenVisualizer; + let result = visualizer.visualize_tx_commands(&context).unwrap(); + + match result.signable_payload_field { + SignablePayloadField::PreviewLayout { preview_layout, .. } => { + let title = &preview_layout.title.as_ref().unwrap().text; + assert!( + title.contains(expected_title_substr), + "Title `{title}` should contain `{expected_title_substr}`" + ); + + let expanded = preview_layout.expanded.as_ref().unwrap(); + for label in expected_expanded_labels { + let has_label = expanded.fields.iter().any(|field| { + matches!( + &field.signable_payload_field, + SignablePayloadField::TextV2 { common, .. } if common.label == *label + ) + }); + assert!(has_label, "Expected expanded field with label `{label}`"); + } + } + _ => panic!("Expected PreviewLayout"), + } +} + +#[test] +fn test_burn_visualization() { + let account = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let instruction = + token_instruction::burn(&spl_token::id(), &account, &mint, &owner, &[], 250).unwrap(); + run_visualization_test(instruction, "Burn", &["Program ID", "Amount", "Raw Data"]); +} + +#[test] +fn test_burn_checked_visualization() { + let account = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let instruction = + token_instruction::burn_checked(&spl_token::id(), &account, &mint, &owner, &[], 250, 6) + .unwrap(); + run_visualization_test( + instruction, + "Burn", + &["Program ID", "Amount", "Decimals", "Raw Data"], + ); +} + +#[test] +fn test_approve_visualization() { + let source = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let instruction = + token_instruction::approve(&spl_token::id(), &source, &delegate, &owner, &[], 10_000) + .unwrap(); + run_visualization_test( + instruction, + "Approve", + &["Program ID", "Amount", "Raw Data"], + ); +} + +#[test] +fn test_approve_checked_visualization() { + let source = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let delegate = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let instruction = token_instruction::approve_checked( + &spl_token::id(), + &source, + &mint, + &delegate, + &owner, + &[], + 10_000, + 6, + ) + .unwrap(); + run_visualization_test( + instruction, + "Approve", + &["Program ID", "Amount", "Decimals", "Raw Data"], + ); +} + +#[test] +fn test_mint_to_checked_visualization() { + let mint = Pubkey::new_unique(); + let account = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let instruction = token_instruction::mint_to_checked( + &spl_token::id(), + &mint, + &account, + &authority, + &[], + 7_500, + 9, + ) + .unwrap(); + run_visualization_test( + instruction, + "Mint To", + &["Program ID", "Amount", "Decimals", "Raw Data"], + ); +} + +#[test] +fn test_revoke_visualization_falls_through_to_default() { + // Revoke has no dedicated branch — exercises the fallback layout. + let source = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let instruction = token_instruction::revoke(&spl_token::id(), &source, &owner, &[]).unwrap(); + run_visualization_test( + instruction, + "Revoke", + &["Instruction", "Program", "Raw Data"], + ); +} + +#[test] +fn test_close_account_visualization_falls_through_to_default() { + let account = Pubkey::new_unique(); + let dest = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + let instruction = + token_instruction::close_account(&spl_token::id(), &account, &dest, &owner, &[]).unwrap(); + run_visualization_test( + instruction, + "Close Account", + &["Instruction", "Program", "Raw Data"], + ); +} + +#[test] +fn test_initialize_mint_visualization_falls_through_to_default() { + let mint = Pubkey::new_unique(); + let mint_authority = Pubkey::new_unique(); + let instruction = + token_instruction::initialize_mint(&spl_token::id(), &mint, &mint_authority, None, 6) + .unwrap(); + run_visualization_test( + instruction, + "Initialize Mint", + &["Instruction", "Program", "Raw Data"], + ); +} + +/// Load a transaction fixture and test field extraction +mod fixture_tests { + use super::*; + use serde_json::Value; + + #[derive(Debug, serde::Deserialize)] + #[allow(dead_code)] + struct TestFixture { + description: String, + source: String, + signature: String, + cluster: String, + #[serde(default)] + full_transaction_note: Option, + instruction_index: usize, + instruction_data: String, + program_id: String, + accounts: Vec, + expected_fields: serde_json::Map, + } + + #[derive(Debug, serde::Deserialize)] + #[allow(dead_code)] + struct TestAccount { + pubkey: String, + signer: bool, + writable: bool, + description: String, + } + + fn load_fixture(name: &str) -> TestFixture { + let fixture_path = format!( + "{}/tests/fixtures/spl_token/{}.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().unwrap(); + + Instruction { + program_id, + accounts, + data, + } + } + + fn load_full_transaction_instructions(fixture: &TestFixture) -> Vec { + // In a real scenario, we'd load all instructions from the transaction + // For now, we just create the one instruction from the fixture + // TODO: Extend fixture format to include all transaction instructions + vec![create_instruction_from_fixture(fixture)] + } + + #[test] + fn test_mint_to_real_transaction() { + let fixture = load_fixture("mint_to_example"); + 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!(); + + // Load instructions - this is a UNIT test for SPL Token parsing + // We only test the specific instruction, not the full transaction context + let instructions = load_full_transaction_instructions(&fixture); + + // 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 idl_registry = crate::idl::IdlRegistry::new(); + let context = VisualizerContext::new(&sender, 0, &instructions, &idl_registry); + + // Visualize + let visualizer = SplTokenVisualizer; + let result = visualizer.visualize_tx_commands(&context).unwrap(); + + // Extract and print all fields + match result.signable_payload_field { + SignablePayloadField::PreviewLayout { + preview_layout, + common, + } => { + println!("=== 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 { + if let SignablePayloadField::TextV2 { common, text_v2 } = + &field.signable_payload_field + { + println!(" {}: {}", common.label, text_v2.text); + } + } + } + + // Validate against expected fields. Each `expected_fields` entry must + // be present in the rendered Expanded layout AND match its expected + // value — collect every divergence and assert at the end so the test + // surfaces all mismatches at once instead of bailing on the first. + let expanded = preview_layout + .expanded + .as_ref() + .expect("Expected PreviewLayout to have an expanded layout"); + + let mut failures: Vec = Vec::new(); + for (key, expected_value) in &fixture.expected_fields { + let expected_str = expected_value + .as_str() + .unwrap_or_else(|| panic!("expected_fields[{key}] must be a JSON string")); + + let mut matched_value: Option = None; + for field in &expanded.fields { + let SignablePayloadField::TextV2 { common, text_v2 } = + &field.signable_payload_field + else { + continue; + }; + if common.label.to_lowercase().replace(' ', "_") == key.to_lowercase() { + matched_value = Some(text_v2.text.clone()); + break; + } + } + + match matched_value { + Some(actual) if actual == expected_str => { + println!("✓ {key}: {expected_str} (matches)"); + } + Some(actual) => { + failures + .push(format!("{key}: expected {expected_str:?}, got {actual:?}")); + } + None => failures.push(format!("{key}: field not found in expanded output")), + } + } + + assert!( + failures.is_empty(), + "fixture validation failures:\n - {}", + failures.join("\n - ") + ); + } + _ => panic!("Expected PreviewLayout"), + } + } +} diff --git a/src/chain_parsers/visualsign-solana/src/presets/swig_wallet/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/swig_wallet/mod.rs index e543044b..bc88d7dc 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/swig_wallet/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/swig_wallet/mod.rs @@ -862,7 +862,11 @@ fn visualize_inner_instruction(instruction: Instruction) -> Option { visualize_with_any(&visualizer_refs, &context) .and_then(|result| result.ok()) .and_then(|viz_result| match viz_result.kind { - VisualizerKind::Payments("UnknownProgram") => None, + // Skip catch-all and SPL Token; both produce summaries that are less informative + // than swig's own format_token_instruction_summary for inner instructions. + VisualizerKind::Payments("UnknownProgram") | VisualizerKind::Payments("SplToken") => { + None + } _ => summarize_visualized_field(&viz_result.field), }) } diff --git a/src/chain_parsers/visualsign-solana/tests/fixtures/spl_token/README.md b/src/chain_parsers/visualsign-solana/tests/fixtures/spl_token/README.md new file mode 100644 index 00000000..6cd0765d --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/fixtures/spl_token/README.md @@ -0,0 +1,149 @@ +# SPL Token Transaction Test Fixtures + +This directory contains real transaction data from Solana explorers for validating SPL Token instruction field extraction. + +## General Testing Philosophy + +**See [/TESTING.md](/TESTING.md) for the complete testing guide**, including: +- Fixture philosophy and test principles +- Step-by-step guide for creating fixtures +- Critical testing rules (never modify fixture data!) +- Test infrastructure and helper functions + +This README contains only SPL Token-specific notes and examples. + +## SPL Token Program Details + +- **Program ID**: `TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA` +- **Crate**: `spl-token = "7.0.0"` +- **Instruction Enum**: `spl_token::instruction::TokenInstruction` + +## Covered Instructions + +Current fixtures test these SPL Token instructions: +- [x] MintTo - Minting tokens to an account +- [ ] Transfer - Transferring tokens between accounts +- [ ] TransferChecked - Transfer with explicit decimals check +- [ ] Burn - Burning tokens from an account +- [ ] BurnChecked - Burn with explicit decimals check +- [ ] Approve - Delegating token authority +- [ ] ApproveChecked - Approve with explicit decimals check +- [ ] SetAuthority - Changing account authorities +- [ ] InitializeMint - Creating a new token mint +- [ ] InitializeAccount - Creating a new token account + +## SPL Token-Specific Notes + +### Field Name Conventions + +SPL Token field names should match what Solscan's `jsonParsed` format returns: + +| Our Field Name | jsonParsed Field | Description | +|----------------|------------------|-------------| +| `mint` | `mint` | Token mint address | +| `account` | `account` or `destination` | Token account address | +| `amount` | `amount` | Token amount (as string, in base units) | +| `mintAuthority` | `mintAuthority` | Mint authority address | +| `source` | `source` | Source token account | +| `destination` | `destination` | Destination token account | +| `owner` | `owner` | Account owner/signer | +| `delegate` | `delegate` | Delegated authority | + +### Account Layouts + +Common account ordering for SPL Token instructions: + +**MintTo / MintToChecked**: +- [0] mint +- [1] destination account +- [2] mint authority + +**Transfer / TransferChecked**: +- [0] source account +- [1] destination account (or mint for TransferChecked) +- [2] owner/authority + +**Burn / BurnChecked**: +- [0] account to burn from (or mint for BurnChecked) +- [1] mint (for BurnChecked) +- [2] owner + +**SetAuthority**: +- [0] account whose authority is being set +- [1] current authority + +**Approve / ApproveChecked**: +- [0] source account +- [1] delegate +- [2] owner + +See the [SPL Token documentation](https://spl.solana.com/token) for complete details. + +## Running SPL Token Tests + +```bash +# Run all SPL Token fixture tests +cargo test --package visualsign-solana --lib presets::spl_token::tests::fixture_tests -- --nocapture + +# Run a specific fixture test +cargo test --package visualsign-solana --lib presets::spl_token::tests::test_mint_to_real_transaction -- --nocapture +``` + +## Creating New SPL Token Fixtures + +Follow the general process in [/TESTING.md](/TESTING.md) with these SPL Token specifics: + +1. **Find transaction** on Solscan or Solana Explorer +2. **Fetch with JSON encoding** to get base58 instruction data: +```bash +curl -X POST https://api.devnet.solana.com \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":1, + "method":"getTransaction", + "params":[ + "", + {"encoding":"json","maxSupportedTransactionVersion":0} + ] + }' | python3 -m json.tool > transaction.json +``` + +3. **Get expected fields** using jsonParsed: +```bash +curl -X POST https://api.devnet.solana.com \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0", + "id":1, + "method":"getTransaction", + "params":[ + "", + {"encoding":"jsonParsed","maxSupportedTransactionVersion":0} + ] + }' | python3 -m json.tool > transaction_parsed.json +``` + +Look for the instruction in the response and find the `parsed.info` object - these are your `expected_fields`. + +4. **Create fixture JSON** using the field names from `parsed.info` + +5. **Test and validate** - run the test and ensure all fields match ✓ + +## Example Fixture + +See `mint_to_example.json` for a complete working example with: +- Real devnet transaction +- Base58-encoded instruction data +- Proper account metadata +- Expected fields matching Solscan output + +## Contributing + +When adding new SPL Token instruction fixtures: +1. ✓ Use real devnet or mainnet transactions +2. ✓ Match field names to Solscan's jsonParsed output +3. ✓ Include `full_transaction_note` if the transaction has multiple instructions +4. ✓ Add account descriptions explaining each account's role +5. ✓ Verify all expected fields pass validation +6. ✗ **Never** modify instruction_data to make tests pass diff --git a/src/chain_parsers/visualsign-solana/tests/fixtures/spl_token/mint_to_example.json b/src/chain_parsers/visualsign-solana/tests/fixtures/spl_token/mint_to_example.json new file mode 100644 index 00000000..88964989 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/fixtures/spl_token/mint_to_example.json @@ -0,0 +1,36 @@ +{ + "description": "MintTo instruction - minting 1230 tokens to an account", + "source": "https://solscan.io/tx/35XirCzssnAVUB2FbLrf8vYUYmTq5omepqyR8tr5Y6eJ6yurs3LcRfzGxzn92wU3w5vBvM8BfodXsscz7nin8SbC?cluster=devnet", + "signature": "35XirCzssnAVUB2FbLrf8vYUYmTq5omepqyR8tr5Y6eJ6yurs3LcRfzGxzn92wU3w5vBvM8BfodXsscz7nin8SbC", + "cluster": "devnet", + "full_transaction_note": "This transaction has 2 instructions: [0] CreateIdempotent (Associated Token Account), [1] MintTo (SPL Token). We're testing instruction [1].", + "instruction_index": 1, + "instruction_data": "6YCQpfSgHpSj", + "program_id": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "accounts": [ + { + "pubkey": "5pdHyGbtCmZdJ7ye71nzeke8kcQ4ngJNPHqoDvE5L2WT", + "signer": false, + "writable": true, + "description": "Token Mint" + }, + { + "pubkey": "D7ZapNPiycy1imiL7deUiBXiqUGMisUuAmvAwSdxUKQT", + "signer": false, + "writable": true, + "description": "Destination token account" + }, + { + "pubkey": "9AM41swmGH1iq3L1oNnV8T385BwzVUeNUMuGqKJbiDMm", + "signer": true, + "writable": false, + "description": "Mint authority" + } + ], + "expected_fields": { + "amount": "1230000000", + "mint": "5pdHyGbtCmZdJ7ye71nzeke8kcQ4ngJNPHqoDvE5L2WT", + "account": "D7ZapNPiycy1imiL7deUiBXiqUGMisUuAmvAwSdxUKQT", + "mintauthority": "9AM41swmGH1iq3L1oNnV8T385BwzVUeNUMuGqKJbiDMm" + } +} From c8b8dc11e9b4833b92f577457f01e57d9267c857 Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Wed, 13 May 2026 21:03:13 +0000 Subject: [PATCH 2/6] feat(solana/spl_token): surface mint pubkey on every instruction that carries it Closes the gap flagged in review: the original PR only surfaced the mint for MintTo / MintToChecked / TransferChecked / Burn(Checked) / ApproveChecked, leaving readers unable to tell which mint was involved for the other ~half of the SPL Token instruction set. Adds dedicated visualization arms for every variant whose accounts contain a mint: - InitializeMint / InitializeMint2 -- surfaces Token Mint (acct[0]), Decimals, Mint Authority, and Freeze Authority (the last three come from the instruction data). - InitializeAccount / InitializeAccount2 / InitializeAccount3 -- surfaces Account (acct[0]), Token Mint (acct[1]), and Owner (from acct[2] for V1, from instruction data for V2/V3). - FreezeAccount / ThawAccount -- surfaces Account (acct[0]), Token Mint (acct[1]), and Freeze Authority (acct[2]). For variants whose accounts genuinely do not contain a mint, surfaces an explicit "Token Mint" notice so readers know why it is absent and what to do: - Transfer / Approve -- "Not in instruction -- use TransferChecked / ApproveChecked to surface and verify mint." - Revoke -- "Not in instruction -- derived from the source account's stored state." - CloseAccount -- "Not in instruction -- derived from the closed account's stored state." Also adds CloseAccount and Revoke as explicit arms (previously falling through to the catch-all default) with Account/Destination/Owner surfaced. ## Test updates - Renamed and strengthened `test_revoke_visualization_falls_through_to_default`, `test_close_account_visualization_falls_through_to_default`, and `test_initialize_mint_visualization_falls_through_to_default` to assert the new named fields instead of the old default-layout shape. - Strengthened `test_freeze_account_visualization` and `test_thaw_account_visualization` to verify the rendered "Token Mint" field matches the input mint pubkey (the prior assertion was only `!fields.is_empty()`, which would have silently accepted a regression). cargo build / cargo clippy -- -D warnings / cargo test presets::spl_token (23 tests) / cargo fmt --check: all clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/presets/spl_token/mod.rs | 341 +++++++++++++++++- .../src/presets/spl_token/tests.rs | 81 ++++- 2 files changed, 408 insertions(+), 14 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs index 1eba33ba..49ef1c61 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs @@ -209,7 +209,14 @@ fn create_token_preview_layout( create_text_field("Amount", &amount.to_string())?, ]; - // Transfer accounts: [0] source account, [1] destination account, [2] owner + // Transfer accounts: [0] source account, [1] destination account, [2] owner. + // The mint is not in the instruction — it lives inside the source/destination + // token accounts on-chain. Use TransferChecked to surface the mint without an + // off-chain account lookup. + expanded_fields.push(create_text_field( + "Token Mint", + "Not in instruction — use TransferChecked to surface and verify mint", + )?); if let Some(source) = instruction.accounts.first() { expanded_fields.push(create_text_field("Source", &source.pubkey.to_string())?); } @@ -368,7 +375,12 @@ fn create_token_preview_layout( create_text_field("Amount", &amount.to_string())?, ]; - // Approve accounts: [0] source account, [1] delegate, [2] owner + // Approve accounts: [0] source account, [1] delegate, [2] owner. + // Mint is not in the instruction — use ApproveChecked to surface and verify. + expanded_fields.push(create_text_field( + "Token Mint", + "Not in instruction — use ApproveChecked to surface and verify mint", + )?); if let Some(source) = instruction.accounts.first() { expanded_fields.push(create_text_field("Source", &source.pubkey.to_string())?); } @@ -435,8 +447,331 @@ fn create_token_preview_layout( context, ) } + TokenInstruction::InitializeMint { + decimals, + mint_authority, + freeze_authority, + } => { + let instruction_name = "Initialize Mint"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + create_text_field("Decimals", &decimals.to_string())?, + create_text_field("Mint Authority", &mint_authority.to_string())?, + create_text_field("Freeze Authority", &format_coption_pubkey(freeze_authority))?, + ]; + + // InitializeMint accounts: [0] mint, [1] rent sysvar + if let Some(mint) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Token Mint", &mint.pubkey.to_string())?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::InitializeMint2 { + decimals, + mint_authority, + freeze_authority, + } => { + let instruction_name = "Initialize Mint (v2)"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + create_text_field("Decimals", &decimals.to_string())?, + create_text_field("Mint Authority", &mint_authority.to_string())?, + create_text_field("Freeze Authority", &format_coption_pubkey(freeze_authority))?, + ]; + + // InitializeMint2 accounts: [0] mint + if let Some(mint) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Token Mint", &mint.pubkey.to_string())?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::InitializeAccount => { + let instruction_name = "Initialize Token Account"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + ]; + + // InitializeAccount accounts: [0] account, [1] mint, [2] owner, [3] rent + if let Some(account) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Account", &account.pubkey.to_string())?); + } + if let Some(mint) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field("Token Mint", &mint.pubkey.to_string())?); + } + if let Some(owner) = instruction.accounts.get(2) { + expanded_fields.push(create_text_field("Owner", &owner.pubkey.to_string())?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::InitializeAccount2 { owner } => { + let instruction_name = "Initialize Token Account (v2)"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + create_text_field("Owner", &owner.to_string())?, + ]; + + // InitializeAccount2 accounts: [0] account, [1] mint, [2] rent (owner is in + // instruction data, not the account list) + if let Some(account) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Account", &account.pubkey.to_string())?); + } + if let Some(mint) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field("Token Mint", &mint.pubkey.to_string())?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::InitializeAccount3 { owner } => { + let instruction_name = "Initialize Token Account (v3)"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + create_text_field("Owner", &owner.to_string())?, + ]; + + // InitializeAccount3 accounts: [0] account, [1] mint (owner is in instruction data) + if let Some(account) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Account", &account.pubkey.to_string())?); + } + if let Some(mint) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field("Token Mint", &mint.pubkey.to_string())?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::FreezeAccount => { + let instruction_name = "Freeze Account"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + ]; + + // FreezeAccount accounts: [0] account to freeze, [1] mint, [2] freeze authority + if let Some(account) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Account", &account.pubkey.to_string())?); + } + if let Some(mint) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field("Token Mint", &mint.pubkey.to_string())?); + } + if let Some(authority) = instruction.accounts.get(2) { + expanded_fields.push(create_text_field( + "Freeze Authority", + &authority.pubkey.to_string(), + )?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::ThawAccount => { + let instruction_name = "Thaw Account"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + ]; + + // ThawAccount accounts: [0] account to thaw, [1] mint, [2] freeze authority + if let Some(account) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Account", &account.pubkey.to_string())?); + } + if let Some(mint) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field("Token Mint", &mint.pubkey.to_string())?); + } + if let Some(authority) = instruction.accounts.get(2) { + expanded_fields.push(create_text_field( + "Freeze Authority", + &authority.pubkey.to_string(), + )?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::CloseAccount => { + let instruction_name = "Close Account"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + ]; + + // CloseAccount accounts: [0] account to close, [1] lamport destination, [2] owner. + // The mint is not in the instruction — it lives inside the closed token account. + expanded_fields.push(create_text_field( + "Token Mint", + "Not in instruction — derived from the closed account's stored state", + )?); + if let Some(account) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Account", &account.pubkey.to_string())?); + } + if let Some(destination) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field( + "Destination", + &destination.pubkey.to_string(), + )?); + } + if let Some(owner) = instruction.accounts.get(2) { + expanded_fields.push(create_text_field("Owner", &owner.pubkey.to_string())?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } + TokenInstruction::Revoke => { + let instruction_name = "Revoke"; + + let condensed_fields = vec![create_text_field("Instruction", instruction_name)?]; + + let mut expanded_fields = vec![ + create_text_field("Program ID", &instruction.program_id.to_string())?, + create_text_field("Instruction", instruction_name)?, + ]; + + // Revoke accounts: [0] source, [1] owner. The mint is not in the instruction. + expanded_fields.push(create_text_field( + "Token Mint", + "Not in instruction — derived from the source account's stored state", + )?); + if let Some(source) = instruction.accounts.first() { + expanded_fields.push(create_text_field("Source", &source.pubkey.to_string())?); + } + if let Some(owner) = instruction.accounts.get(1) { + expanded_fields.push(create_text_field("Owner", &owner.pubkey.to_string())?); + } + + expanded_fields.push(create_text_field( + "Raw Data", + &hex::encode(&instruction.data), + )?); + + create_preview_layout_field( + instruction_name, + condensed_fields, + expanded_fields, + instruction, + context, + ) + } _ => { - // Handle other token instructions with basic layout + // Fallback for the remaining instructions (InitializeMultisig{,2}, + // InitializeImmutableOwner, SyncNative, GetAccountDataSize, AmountToUiAmount, + // UiAmountToAmount) — these are either rare in user-facing flows or do not + // reference a mint/token account in a way worth surfacing as named fields. let instruction_name = format_token_instruction(token_instruction); let condensed_fields = vec![ diff --git a/src/chain_parsers/visualsign-solana/src/presets/spl_token/tests.rs b/src/chain_parsers/visualsign-solana/src/presets/spl_token/tests.rs index 9807bc3d..af637bb7 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/spl_token/tests.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/spl_token/tests.rs @@ -538,9 +538,25 @@ fn test_freeze_account_visualization() { "Freeze Account" ); - // Check expanded fields contain program info + // Check expanded fields surface Account, Token Mint, and Freeze Authority with + // the correct pubkey values. let expanded = preview_layout.expanded.as_ref().unwrap(); - assert!(!expanded.fields.is_empty()); + let mint_field = expanded + .fields + .iter() + .find_map(|f| match &f.signable_payload_field { + SignablePayloadField::TextV2 { common, text_v2 } + if common.label == "Token Mint" => + { + Some(text_v2.text.clone()) + } + _ => None, + }); + assert_eq!( + mint_field.as_deref(), + Some(mint.to_string().as_str()), + "Freeze Account should surface the mint pubkey" + ); } _ => panic!("Expected PreviewLayout"), } @@ -577,9 +593,24 @@ fn test_thaw_account_visualization() { // Check title assert_eq!(preview_layout.title.as_ref().unwrap().text, "Thaw Account"); - // Check expanded fields contain program info + // Check expanded fields surface the mint pubkey under the "Token Mint" label. let expanded = preview_layout.expanded.as_ref().unwrap(); - assert!(!expanded.fields.is_empty()); + let mint_field = expanded + .fields + .iter() + .find_map(|f| match &f.signable_payload_field { + SignablePayloadField::TextV2 { common, text_v2 } + if common.label == "Token Mint" => + { + Some(text_v2.text.clone()) + } + _ => None, + }); + assert_eq!( + mint_field.as_deref(), + Some(mint.to_string().as_str()), + "Thaw Account should surface the mint pubkey" + ); } _ => panic!("Expected PreviewLayout"), } @@ -971,20 +1002,30 @@ fn test_mint_to_checked_visualization() { } #[test] -fn test_revoke_visualization_falls_through_to_default() { - // Revoke has no dedicated branch — exercises the fallback layout. +fn test_revoke_visualization() { + // Revoke has no mint in its instruction (accounts: source, owner) — verify Source + // and Owner are surfaced, and that the "Token Mint" notice explains the absence. let source = Pubkey::new_unique(); let owner = Pubkey::new_unique(); let instruction = token_instruction::revoke(&spl_token::id(), &source, &owner, &[]).unwrap(); run_visualization_test( instruction, "Revoke", - &["Instruction", "Program", "Raw Data"], + &[ + "Program ID", + "Instruction", + "Token Mint", + "Source", + "Owner", + "Raw Data", + ], ); } #[test] -fn test_close_account_visualization_falls_through_to_default() { +fn test_close_account_visualization() { + // CloseAccount has no mint in its instruction (accounts: account, destination, owner) — + // verify Account/Destination/Owner are surfaced and the "Token Mint" notice is present. let account = Pubkey::new_unique(); let dest = Pubkey::new_unique(); let owner = Pubkey::new_unique(); @@ -993,12 +1034,22 @@ fn test_close_account_visualization_falls_through_to_default() { run_visualization_test( instruction, "Close Account", - &["Instruction", "Program", "Raw Data"], + &[ + "Program ID", + "Instruction", + "Token Mint", + "Account", + "Destination", + "Owner", + "Raw Data", + ], ); } #[test] -fn test_initialize_mint_visualization_falls_through_to_default() { +fn test_initialize_mint_visualization() { + // InitializeMint surfaces the mint pubkey (account[0]), Decimals, Mint Authority, + // and Freeze Authority (from instruction data). let mint = Pubkey::new_unique(); let mint_authority = Pubkey::new_unique(); let instruction = @@ -1007,7 +1058,15 @@ fn test_initialize_mint_visualization_falls_through_to_default() { run_visualization_test( instruction, "Initialize Mint", - &["Instruction", "Program", "Raw Data"], + &[ + "Program ID", + "Instruction", + "Decimals", + "Mint Authority", + "Freeze Authority", + "Token Mint", + "Raw Data", + ], ); } From 0203a291d05063fcbdcb9ba979f9f09dc3ab0daa Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Wed, 13 May 2026 21:10:32 +0000 Subject: [PATCH 3/6] test(solana/spl_token): add e2e integration tests for mint visibility Drives each new SPL Token code path through the public `transaction_to_visual_sign` API end-to-end: SolanaTransaction (built with spl_token::instruction::*) -> transaction_to_visual_sign -> SolanaVisualSignConverter -> SplTokenVisualizer (dispatched by program id) -> SignablePayload Unlike the in-module unit tests, which exercise the visualizer in isolation, these tests prove the dispatch + conversion chain works for real Transaction inputs (the same path parser_cli takes after decoding base64). 10 tests covering the mint-bearing variants and the mint-absent variants: - pipeline_initialize_mint_surfaces_mint_decimals_and_authorities - pipeline_initialize_mint2_surfaces_mint_decimals_and_authorities - pipeline_initialize_account_surfaces_account_mint_and_owner - pipeline_initialize_account3_surfaces_mint_and_owner_from_instruction_data - pipeline_freeze_account_surfaces_account_mint_and_authority - pipeline_thaw_account_surfaces_account_mint_and_authority - pipeline_close_account_explains_mint_absence_and_surfaces_destination - pipeline_revoke_explains_mint_absence_and_surfaces_source_and_owner - pipeline_unchecked_transfer_directs_user_to_transfer_checked - pipeline_transfer_checked_surfaces_mint_explicitly Each test asserts the rendered Expanded layout either contains the literal mint pubkey (the surfaced cases) or contains a "Token Mint" notice mentioning "Not in instruction" / "TransferChecked" (the explained-absence cases). Combined with the existing devnet MintTo fixture test (`test_mint_to_real_transaction`), which has now been verified end-to-end through `parser_cli` against the real on-chain transaction 35XirCzssnAVUB2FbLrf8vYUYmTq5omepqyR8tr5Y6eJ6yurs3LcRfzGxzn92wU3w5vBvM8BfodXsscz7nin8SbC, this gives the SPL Token preset full pipeline coverage for every documented variant. cargo test -p visualsign-solana --test spl_token_e2e: 10/10 pass. cargo clippy -p visualsign-solana --all-targets -- -D warnings: clean. cargo fmt --check: clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../visualsign-solana/tests/spl_token_e2e.rs | 316 ++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 src/chain_parsers/visualsign-solana/tests/spl_token_e2e.rs diff --git a/src/chain_parsers/visualsign-solana/tests/spl_token_e2e.rs b/src/chain_parsers/visualsign-solana/tests/spl_token_e2e.rs new file mode 100644 index 00000000..99fc9d71 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/spl_token_e2e.rs @@ -0,0 +1,316 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +//! Full-pipeline integration tests for the SPL Token preset. +//! +//! Drives the complete stack end-to-end via the public +//! `transaction_to_visual_sign` API: +//! +//! SolanaTransaction (built with spl_token::instruction::*) +//! -> transaction_to_visual_sign +//! -> SolanaVisualSignConverter +//! -> SplTokenVisualizer (dispatched by program id) +//! -> SignablePayload +//! +//! Each test asserts that the rendered Expanded layout surfaces the mint +//! pubkey (or, for variants whose accounts cannot carry it, the explicit +//! "Token Mint" notice). The unit tests in `presets/spl_token/tests.rs` +//! exercise the visualizer in isolation; these tests prove the dispatch + +//! conversion chain works for real Transaction inputs. + +mod common; + +use solana_sdk::message::Message; +use solana_sdk::pubkey::Pubkey; +use solana_sdk::transaction::Transaction as SolanaTransaction; +use spl_token::instruction as token_instruction; +use visualsign_solana::transaction_to_visual_sign; + +use common::{find_text, instruction_fields, options_no_idl}; + +fn build_tx(instruction: solana_sdk::instruction::Instruction) -> SolanaTransaction { + let fee_payer = Pubkey::new_unique(); + SolanaTransaction::new_unsigned(Message::new(&[instruction], Some(&fee_payer))) +} + +#[test] +fn pipeline_initialize_mint_surfaces_mint_decimals_and_authorities() { + let mint = Pubkey::new_unique(); + let mint_authority = Pubkey::new_unique(); + let freeze_authority = Pubkey::new_unique(); + + let ix = token_instruction::initialize_mint( + &spl_token::id(), + &mint, + &mint_authority, + Some(&freeze_authority), + 9, + ) + .unwrap(); + + let payload = transaction_to_visual_sign(build_tx(ix), options_no_idl()).unwrap(); + let layouts = instruction_fields(&payload); + assert_eq!(layouts.len(), 1); + let expanded = layouts[0].expanded.as_ref().unwrap(); + + assert_eq!( + find_text(&expanded.fields, "Token Mint"), + Some(mint.to_string()) + ); + assert_eq!(find_text(&expanded.fields, "Decimals"), Some("9".into())); + assert_eq!( + find_text(&expanded.fields, "Mint Authority"), + Some(mint_authority.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Freeze Authority"), + Some(freeze_authority.to_string()) + ); +} + +#[test] +fn pipeline_initialize_mint2_surfaces_mint_decimals_and_authorities() { + let mint = Pubkey::new_unique(); + let mint_authority = Pubkey::new_unique(); + + let ix = token_instruction::initialize_mint2(&spl_token::id(), &mint, &mint_authority, None, 6) + .unwrap(); + + let payload = transaction_to_visual_sign(build_tx(ix), options_no_idl()).unwrap(); + let layouts = instruction_fields(&payload); + let expanded = layouts[0].expanded.as_ref().unwrap(); + + assert_eq!( + find_text(&expanded.fields, "Token Mint"), + Some(mint.to_string()) + ); + assert_eq!(find_text(&expanded.fields, "Decimals"), Some("6".into())); + assert_eq!( + find_text(&expanded.fields, "Mint Authority"), + Some(mint_authority.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Freeze Authority"), + Some("None".into()) + ); +} + +#[test] +fn pipeline_initialize_account_surfaces_account_mint_and_owner() { + let account = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + + let ix = + token_instruction::initialize_account(&spl_token::id(), &account, &mint, &owner).unwrap(); + + let payload = transaction_to_visual_sign(build_tx(ix), options_no_idl()).unwrap(); + let expanded = instruction_fields(&payload)[0].expanded.as_ref().unwrap(); + + assert_eq!( + find_text(&expanded.fields, "Account"), + Some(account.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Token Mint"), + Some(mint.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Owner"), + Some(owner.to_string()) + ); +} + +#[test] +fn pipeline_initialize_account3_surfaces_mint_and_owner_from_instruction_data() { + // InitializeAccount3 carries the owner in instruction data rather than + // as an account, so it tests the "owner from data" code path. + let account = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + + let ix = + token_instruction::initialize_account3(&spl_token::id(), &account, &mint, &owner).unwrap(); + + let payload = transaction_to_visual_sign(build_tx(ix), options_no_idl()).unwrap(); + let expanded = instruction_fields(&payload)[0].expanded.as_ref().unwrap(); + + assert_eq!( + find_text(&expanded.fields, "Account"), + Some(account.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Token Mint"), + Some(mint.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Owner"), + Some(owner.to_string()) + ); +} + +#[test] +fn pipeline_freeze_account_surfaces_account_mint_and_authority() { + let account = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let freeze_authority = Pubkey::new_unique(); + + let ix = token_instruction::freeze_account( + &spl_token::id(), + &account, + &mint, + &freeze_authority, + &[], + ) + .unwrap(); + + let payload = transaction_to_visual_sign(build_tx(ix), options_no_idl()).unwrap(); + let expanded = instruction_fields(&payload)[0].expanded.as_ref().unwrap(); + + assert_eq!( + find_text(&expanded.fields, "Account"), + Some(account.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Token Mint"), + Some(mint.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Freeze Authority"), + Some(freeze_authority.to_string()) + ); +} + +#[test] +fn pipeline_thaw_account_surfaces_account_mint_and_authority() { + let account = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let freeze_authority = Pubkey::new_unique(); + + let ix = + token_instruction::thaw_account(&spl_token::id(), &account, &mint, &freeze_authority, &[]) + .unwrap(); + + let payload = transaction_to_visual_sign(build_tx(ix), options_no_idl()).unwrap(); + let expanded = instruction_fields(&payload)[0].expanded.as_ref().unwrap(); + + assert_eq!( + find_text(&expanded.fields, "Account"), + Some(account.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Token Mint"), + Some(mint.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Freeze Authority"), + Some(freeze_authority.to_string()) + ); +} + +#[test] +fn pipeline_close_account_explains_mint_absence_and_surfaces_destination() { + let account = Pubkey::new_unique(); + let dest = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + + let ix = + token_instruction::close_account(&spl_token::id(), &account, &dest, &owner, &[]).unwrap(); + + let payload = transaction_to_visual_sign(build_tx(ix), options_no_idl()).unwrap(); + let expanded = instruction_fields(&payload)[0].expanded.as_ref().unwrap(); + + assert_eq!( + find_text(&expanded.fields, "Account"), + Some(account.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Destination"), + Some(dest.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Owner"), + Some(owner.to_string()) + ); + let mint_notice = find_text(&expanded.fields, "Token Mint").expect("Token Mint notice"); + assert!( + mint_notice.contains("Not in instruction"), + "CloseAccount Token Mint should be an explanatory notice, got: {mint_notice}" + ); +} + +#[test] +fn pipeline_revoke_explains_mint_absence_and_surfaces_source_and_owner() { + let source = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + + let ix = token_instruction::revoke(&spl_token::id(), &source, &owner, &[]).unwrap(); + + let payload = transaction_to_visual_sign(build_tx(ix), options_no_idl()).unwrap(); + let expanded = instruction_fields(&payload)[0].expanded.as_ref().unwrap(); + + assert_eq!( + find_text(&expanded.fields, "Source"), + Some(source.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Owner"), + Some(owner.to_string()) + ); + let mint_notice = find_text(&expanded.fields, "Token Mint").expect("Token Mint notice"); + assert!( + mint_notice.contains("Not in instruction"), + "Revoke Token Mint should be an explanatory notice, got: {mint_notice}" + ); +} + +#[test] +fn pipeline_unchecked_transfer_directs_user_to_transfer_checked() { + // Transfer (unchecked) cannot carry the mint in its instruction accounts. + // The notice should tell the reader to use TransferChecked. + let source = Pubkey::new_unique(); + let dest = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + + let ix = + token_instruction::transfer(&spl_token::id(), &source, &dest, &owner, &[], 1_000).unwrap(); + + let payload = transaction_to_visual_sign(build_tx(ix), options_no_idl()).unwrap(); + let expanded = instruction_fields(&payload)[0].expanded.as_ref().unwrap(); + + let mint_notice = find_text(&expanded.fields, "Token Mint").expect("Token Mint notice"); + assert!( + mint_notice.contains("TransferChecked"), + "unchecked Transfer should direct the reader to TransferChecked, got: {mint_notice}" + ); +} + +#[test] +fn pipeline_transfer_checked_surfaces_mint_explicitly() { + // TransferChecked is the "happy path" — mint is in the instruction + // accounts and surfaces directly. + let source = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let dest = Pubkey::new_unique(); + let owner = Pubkey::new_unique(); + + let ix = token_instruction::transfer_checked( + &spl_token::id(), + &source, + &mint, + &dest, + &owner, + &[], + 1_000, + 6, + ) + .unwrap(); + + let payload = transaction_to_visual_sign(build_tx(ix), options_no_idl()).unwrap(); + let expanded = instruction_fields(&payload)[0].expanded.as_ref().unwrap(); + + assert_eq!( + find_text(&expanded.fields, "Token Mint"), + Some(mint.to_string()), + "TransferChecked should surface the literal mint pubkey" + ); + assert_eq!(find_text(&expanded.fields, "Decimals"), Some("6".into())); +} From 5bf34b832231ebf39eed1559d78124ca518b11a7 Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Wed, 13 May 2026 22:18:51 +0000 Subject: [PATCH 4/6] fix(solana/spl_token): use ASCII separator in mint-absence notices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration test `parser_charset_validation_all_chains` asserts that parsed SignablePayload JSON is pure ASCII (`json_str.is_ascii()`). The em-dash (U+2014) I used in the new mint-absence notices broke that invariant when an SPL Token Transfer/Approve/Revoke/CloseAccount instruction appears in any parsed transaction (such as the inner instructions of the Jupiter swap fixture used by the test). Replace `—` with `--` in the four user-rendered notice strings: - Transfer: "Not in instruction -- use TransferChecked to ..." - Approve: "Not in instruction -- use ApproveChecked to ..." - CloseAccount: "Not in instruction -- derived from the closed ..." - Revoke: "Not in instruction -- derived from the source ..." Comments and println! debug output keep `—` since they never appear in parsed payload output. All existing unit/e2e tests still pass (assertions check for `Not in instruction` / `TransferChecked` substrings, which are unaffected). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../visualsign-solana/src/presets/spl_token/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs index 49ef1c61..a9f18152 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs @@ -215,7 +215,7 @@ fn create_token_preview_layout( // off-chain account lookup. expanded_fields.push(create_text_field( "Token Mint", - "Not in instruction — use TransferChecked to surface and verify mint", + "Not in instruction -- use TransferChecked to surface and verify mint", )?); if let Some(source) = instruction.accounts.first() { expanded_fields.push(create_text_field("Source", &source.pubkey.to_string())?); @@ -379,7 +379,7 @@ fn create_token_preview_layout( // Mint is not in the instruction — use ApproveChecked to surface and verify. expanded_fields.push(create_text_field( "Token Mint", - "Not in instruction — use ApproveChecked to surface and verify mint", + "Not in instruction -- use ApproveChecked to surface and verify mint", )?); if let Some(source) = instruction.accounts.first() { expanded_fields.push(create_text_field("Source", &source.pubkey.to_string())?); @@ -704,7 +704,7 @@ fn create_token_preview_layout( // The mint is not in the instruction — it lives inside the closed token account. expanded_fields.push(create_text_field( "Token Mint", - "Not in instruction — derived from the closed account's stored state", + "Not in instruction -- derived from the closed account's stored state", )?); if let Some(account) = instruction.accounts.first() { expanded_fields.push(create_text_field("Account", &account.pubkey.to_string())?); @@ -745,7 +745,7 @@ fn create_token_preview_layout( // Revoke accounts: [0] source, [1] owner. The mint is not in the instruction. expanded_fields.push(create_text_field( "Token Mint", - "Not in instruction — derived from the source account's stored state", + "Not in instruction -- derived from the source account's stored state", )?); if let Some(source) = instruction.accounts.first() { expanded_fields.push(create_text_field("Source", &source.pubkey.to_string())?); From 97b0bef83de711ab7b47d2a766d871f9bf7cb92c Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Wed, 13 May 2026 22:21:09 +0000 Subject: [PATCH 5/6] refactor(solana/spl_token): move mint-absence notice from rendered field to tracing The explicit "Token Mint: Not in instruction -- ..." field on unchecked Transfer / Approve / Revoke / CloseAccount was visible to wallet UIs even when it was just educational footer text. That clutters the hardware-wallet view for what is the most common SPL Token path (unchecked Transfer is what most wallets emit). Replaces each notice field with a `tracing::debug!` line that keeps the operator-observability signal but stays out of the user-facing SignablePayload. Wallet integrators see a clean Source/Destination/Owner layout; operators tailing logs at debug level still see "mint omitted, use TransferChecked" / etc. Updates the affected tests: - presets/spl_token/tests.rs: test_revoke_visualization and test_close_account_visualization drop "Token Mint" from the expected expanded-label list. - tests/spl_token_e2e.rs: pipeline tests for CloseAccount / Revoke / unchecked Transfer rename from `*_explains_mint_absence_*` to describe the actual rendered output and assert `find_text("Token Mint") == None`. All 33 SPL Token tests (10 e2e + 23 unit) pass. cargo clippy --all-targets -- -D warnings and cargo fmt --check are clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/presets/spl_token/mod.rs | 37 ++++++------- .../src/presets/spl_token/tests.rs | 15 ++---- .../visualsign-solana/tests/spl_token_e2e.rs | 52 +++++++++++++------ 3 files changed, 56 insertions(+), 48 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs b/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs index a9f18152..36fc318b 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/spl_token/mod.rs @@ -211,12 +211,12 @@ fn create_token_preview_layout( // Transfer accounts: [0] source account, [1] destination account, [2] owner. // The mint is not in the instruction — it lives inside the source/destination - // token accounts on-chain. Use TransferChecked to surface the mint without an - // off-chain account lookup. - expanded_fields.push(create_text_field( - "Token Mint", - "Not in instruction -- use TransferChecked to surface and verify mint", - )?); + // token accounts on-chain. Signal the absence via tracing rather than a visible + // field so wallet UIs stay clean; observers can still flag the deprecated + // unchecked variant. + tracing::debug!( + "spl_token: unchecked Transfer omits mint from instruction; use TransferChecked to verify" + ); if let Some(source) = instruction.accounts.first() { expanded_fields.push(create_text_field("Source", &source.pubkey.to_string())?); } @@ -376,11 +376,10 @@ fn create_token_preview_layout( ]; // Approve accounts: [0] source account, [1] delegate, [2] owner. - // Mint is not in the instruction — use ApproveChecked to surface and verify. - expanded_fields.push(create_text_field( - "Token Mint", - "Not in instruction -- use ApproveChecked to surface and verify mint", - )?); + // Mint is not in the instruction — signal via tracing, not a visible field. + tracing::debug!( + "spl_token: unchecked Approve omits mint from instruction; use ApproveChecked to verify" + ); if let Some(source) = instruction.accounts.first() { expanded_fields.push(create_text_field("Source", &source.pubkey.to_string())?); } @@ -701,11 +700,10 @@ fn create_token_preview_layout( ]; // CloseAccount accounts: [0] account to close, [1] lamport destination, [2] owner. - // The mint is not in the instruction — it lives inside the closed token account. - expanded_fields.push(create_text_field( - "Token Mint", - "Not in instruction -- derived from the closed account's stored state", - )?); + // The mint is not in the instruction; it lives inside the closed token account. + tracing::debug!( + "spl_token: CloseAccount omits mint from instruction (derived from token account state)" + ); if let Some(account) = instruction.accounts.first() { expanded_fields.push(create_text_field("Account", &account.pubkey.to_string())?); } @@ -743,10 +741,9 @@ fn create_token_preview_layout( ]; // Revoke accounts: [0] source, [1] owner. The mint is not in the instruction. - expanded_fields.push(create_text_field( - "Token Mint", - "Not in instruction -- derived from the source account's stored state", - )?); + tracing::debug!( + "spl_token: Revoke omits mint from instruction (derived from source token account state)" + ); if let Some(source) = instruction.accounts.first() { expanded_fields.push(create_text_field("Source", &source.pubkey.to_string())?); } diff --git a/src/chain_parsers/visualsign-solana/src/presets/spl_token/tests.rs b/src/chain_parsers/visualsign-solana/src/presets/spl_token/tests.rs index af637bb7..9a1d8687 100644 --- a/src/chain_parsers/visualsign-solana/src/presets/spl_token/tests.rs +++ b/src/chain_parsers/visualsign-solana/src/presets/spl_token/tests.rs @@ -1004,28 +1004,22 @@ fn test_mint_to_checked_visualization() { #[test] fn test_revoke_visualization() { // Revoke has no mint in its instruction (accounts: source, owner) — verify Source - // and Owner are surfaced, and that the "Token Mint" notice explains the absence. + // and Owner surface and that no Token Mint field is rendered (the unchecked + // variant's mint absence is reported via tracing::debug! instead). let source = Pubkey::new_unique(); let owner = Pubkey::new_unique(); let instruction = token_instruction::revoke(&spl_token::id(), &source, &owner, &[]).unwrap(); run_visualization_test( instruction, "Revoke", - &[ - "Program ID", - "Instruction", - "Token Mint", - "Source", - "Owner", - "Raw Data", - ], + &["Program ID", "Instruction", "Source", "Owner", "Raw Data"], ); } #[test] fn test_close_account_visualization() { // CloseAccount has no mint in its instruction (accounts: account, destination, owner) — - // verify Account/Destination/Owner are surfaced and the "Token Mint" notice is present. + // verify Account/Destination/Owner are surfaced and no Token Mint field is rendered. let account = Pubkey::new_unique(); let dest = Pubkey::new_unique(); let owner = Pubkey::new_unique(); @@ -1037,7 +1031,6 @@ fn test_close_account_visualization() { &[ "Program ID", "Instruction", - "Token Mint", "Account", "Destination", "Owner", diff --git a/src/chain_parsers/visualsign-solana/tests/spl_token_e2e.rs b/src/chain_parsers/visualsign-solana/tests/spl_token_e2e.rs index 99fc9d71..c60ddb69 100644 --- a/src/chain_parsers/visualsign-solana/tests/spl_token_e2e.rs +++ b/src/chain_parsers/visualsign-solana/tests/spl_token_e2e.rs @@ -207,7 +207,10 @@ fn pipeline_thaw_account_surfaces_account_mint_and_authority() { } #[test] -fn pipeline_close_account_explains_mint_absence_and_surfaces_destination() { +fn pipeline_close_account_surfaces_account_destination_and_owner() { + // CloseAccount cannot carry the mint in its instruction. The rendered output + // should surface the three account fields cleanly and omit the mint field + // entirely (mint absence is reported via tracing::debug! for operators). let account = Pubkey::new_unique(); let dest = Pubkey::new_unique(); let owner = Pubkey::new_unique(); @@ -230,15 +233,16 @@ fn pipeline_close_account_explains_mint_absence_and_surfaces_destination() { find_text(&expanded.fields, "Owner"), Some(owner.to_string()) ); - let mint_notice = find_text(&expanded.fields, "Token Mint").expect("Token Mint notice"); - assert!( - mint_notice.contains("Not in instruction"), - "CloseAccount Token Mint should be an explanatory notice, got: {mint_notice}" + assert_eq!( + find_text(&expanded.fields, "Token Mint"), + None, + "CloseAccount should not render a Token Mint field" ); } #[test] -fn pipeline_revoke_explains_mint_absence_and_surfaces_source_and_owner() { +fn pipeline_revoke_surfaces_source_and_owner() { + // Revoke cannot carry the mint. Source + Owner surface; no Token Mint field. let source = Pubkey::new_unique(); let owner = Pubkey::new_unique(); @@ -255,17 +259,19 @@ fn pipeline_revoke_explains_mint_absence_and_surfaces_source_and_owner() { find_text(&expanded.fields, "Owner"), Some(owner.to_string()) ); - let mint_notice = find_text(&expanded.fields, "Token Mint").expect("Token Mint notice"); - assert!( - mint_notice.contains("Not in instruction"), - "Revoke Token Mint should be an explanatory notice, got: {mint_notice}" + assert_eq!( + find_text(&expanded.fields, "Token Mint"), + None, + "Revoke should not render a Token Mint field" ); } #[test] -fn pipeline_unchecked_transfer_directs_user_to_transfer_checked() { - // Transfer (unchecked) cannot carry the mint in its instruction accounts. - // The notice should tell the reader to use TransferChecked. +fn pipeline_unchecked_transfer_renders_without_mint_field() { + // Transfer (unchecked) cannot carry the mint in its instruction. The + // rendered output surfaces Source/Destination/Owner and omits the mint + // field entirely (a tracing::debug! line flags the unchecked variant for + // operators, but it does not pollute the wallet view). let source = Pubkey::new_unique(); let dest = Pubkey::new_unique(); let owner = Pubkey::new_unique(); @@ -276,10 +282,22 @@ fn pipeline_unchecked_transfer_directs_user_to_transfer_checked() { let payload = transaction_to_visual_sign(build_tx(ix), options_no_idl()).unwrap(); let expanded = instruction_fields(&payload)[0].expanded.as_ref().unwrap(); - let mint_notice = find_text(&expanded.fields, "Token Mint").expect("Token Mint notice"); - assert!( - mint_notice.contains("TransferChecked"), - "unchecked Transfer should direct the reader to TransferChecked, got: {mint_notice}" + assert_eq!( + find_text(&expanded.fields, "Source"), + Some(source.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Destination"), + Some(dest.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Owner"), + Some(owner.to_string()) + ); + assert_eq!( + find_text(&expanded.fields, "Token Mint"), + None, + "unchecked Transfer should not render a Token Mint field" ); } From a09f6072addfdb32bc21068c89477b059a7a5a7b Mon Sep 17 00:00:00 2001 From: Prasanna Gautam Date: Wed, 13 May 2026 22:39:02 +0000 Subject: [PATCH 6/6] test(solana/spl_token): add dump_spl_b64 dev tool for generating base64 examples Adds an `#[ignore]`d integration test that prints base64-encoded SolanaTransaction wire format for every SPL Token instruction variant the preset handles. Each tx uses deterministic stand-in pubkeys so output is reproducible across runs. Run with: cargo test -p visualsign-solana --test dump_spl_b64 \ -- --nocapture --include-ignored Each captured base64 can be fed directly to `parser_cli`: parser_cli --chain solana --network SOLANA_MAINNET \ --output json -t '' Useful when adding fixtures, debugging visualizer output, or building reference JSON outputs for documentation, without depending on mainnet RPC (which rate-limits aggressively). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../visualsign-solana/tests/dump_spl_b64.rs | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 src/chain_parsers/visualsign-solana/tests/dump_spl_b64.rs diff --git a/src/chain_parsers/visualsign-solana/tests/dump_spl_b64.rs b/src/chain_parsers/visualsign-solana/tests/dump_spl_b64.rs new file mode 100644 index 00000000..33b5277d --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/dump_spl_b64.rs @@ -0,0 +1,176 @@ +#![allow(clippy::unwrap_used)] +//! Print base64-encoded SPL Token transactions to stdout, one per instruction +//! variant the preset claims to handle. Run with `cargo test --test +//! dump_spl_b64 -- --nocapture --include-ignored` to capture the output and +//! feed it to `parser_cli`. +//! +//! Marked `#[ignore]` so it doesn't run in normal CI; this is a dev tool for +//! generating reproducible base64 examples without depending on mainnet RPC. + +use base64::Engine; +use solana_sdk::message::Message; +use solana_sdk::pubkey::Pubkey; +use solana_sdk::transaction::Transaction as SolanaTransaction; +use spl_token::instruction as token_instruction; + +fn b64_of(ix: solana_sdk::instruction::Instruction) -> String { + let fee_payer = Pubkey::new_unique(); + let tx = SolanaTransaction::new_unsigned(Message::new(&[ix], Some(&fee_payer))); + let bytes = bincode::serialize(&tx).unwrap(); + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn print_case(name: &str, ix: solana_sdk::instruction::Instruction) { + println!("\n=== {name} ==="); + println!("{}", b64_of(ix)); +} + +#[test] +#[ignore] +fn dump_all_spl_token_variants() { + // Deterministic stand-in pubkeys so the same `cargo test` invocation + // always produces the same base64 (until the rng is initialized). + let mint = Pubkey::from([1u8; 32]); + let account = Pubkey::from([2u8; 32]); + let source = Pubkey::from([3u8; 32]); + let dest = Pubkey::from([4u8; 32]); + let owner = Pubkey::from([5u8; 32]); + let mint_authority = Pubkey::from([6u8; 32]); + let freeze_authority = Pubkey::from([7u8; 32]); + let delegate = Pubkey::from([8u8; 32]); + + print_case( + "InitializeMint (decimals=9, freeze authority set)", + token_instruction::initialize_mint( + &spl_token::id(), + &mint, + &mint_authority, + Some(&freeze_authority), + 9, + ) + .unwrap(), + ); + + print_case( + "InitializeMint2 (decimals=6, no freeze authority)", + token_instruction::initialize_mint2(&spl_token::id(), &mint, &mint_authority, None, 6) + .unwrap(), + ); + + print_case( + "InitializeAccount", + token_instruction::initialize_account(&spl_token::id(), &account, &mint, &owner).unwrap(), + ); + + print_case( + "InitializeAccount3 (owner in instruction data)", + token_instruction::initialize_account3(&spl_token::id(), &account, &mint, &owner).unwrap(), + ); + + print_case( + "MintTo (amount=1230000000)", + token_instruction::mint_to( + &spl_token::id(), + &mint, + &account, + &mint_authority, + &[], + 1_230_000_000, + ) + .unwrap(), + ); + + print_case( + "MintToChecked (amount=1230000000, decimals=6)", + token_instruction::mint_to_checked( + &spl_token::id(), + &mint, + &account, + &mint_authority, + &[], + 1_230_000_000, + 6, + ) + .unwrap(), + ); + + print_case( + "Transfer (unchecked, amount=1000)", + token_instruction::transfer(&spl_token::id(), &source, &dest, &owner, &[], 1_000).unwrap(), + ); + + print_case( + "TransferChecked (amount=1000, decimals=6)", + token_instruction::transfer_checked( + &spl_token::id(), + &source, + &mint, + &dest, + &owner, + &[], + 1_000, + 6, + ) + .unwrap(), + ); + + print_case( + "Burn (amount=500)", + token_instruction::burn(&spl_token::id(), &account, &mint, &owner, &[], 500).unwrap(), + ); + + print_case( + "BurnChecked (amount=500, decimals=6)", + token_instruction::burn_checked(&spl_token::id(), &account, &mint, &owner, &[], 500, 6) + .unwrap(), + ); + + print_case( + "Approve (unchecked, amount=10000)", + token_instruction::approve(&spl_token::id(), &source, &delegate, &owner, &[], 10_000) + .unwrap(), + ); + + print_case( + "ApproveChecked (amount=10000, decimals=6)", + token_instruction::approve_checked( + &spl_token::id(), + &source, + &mint, + &delegate, + &owner, + &[], + 10_000, + 6, + ) + .unwrap(), + ); + + print_case( + "Revoke", + token_instruction::revoke(&spl_token::id(), &source, &owner, &[]).unwrap(), + ); + + print_case( + "FreezeAccount", + token_instruction::freeze_account( + &spl_token::id(), + &account, + &mint, + &freeze_authority, + &[], + ) + .unwrap(), + ); + + print_case( + "ThawAccount", + token_instruction::thaw_account(&spl_token::id(), &account, &mint, &freeze_authority, &[]) + .unwrap(), + ); + + print_case( + "CloseAccount", + token_instruction::close_account(&spl_token::id(), &account, &dest, &owner, &[]).unwrap(), + ); +}