From e2eacd4e018292445db29cacdd4a7bc0d312f1dd Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Wed, 11 Mar 2026 16:35:59 -0400 Subject: [PATCH 01/20] initial proptest --- src/Cargo.lock | 1 + .../visualsign-solana/Cargo.toml | 1 + .../tests/fuzz_idl_parsing.rs | 341 ++++++++++++++++++ 3 files changed, 343 insertions(+) create mode 100644 src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs diff --git a/src/Cargo.lock b/src/Cargo.lock index 371475f1..f4018660 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -13021,6 +13021,7 @@ dependencies = [ "generated", "hex", "jupiter-swap-api-client", + "proptest", "serde", "serde_json", "solana-program", diff --git a/src/chain_parsers/visualsign-solana/Cargo.toml b/src/chain_parsers/visualsign-solana/Cargo.toml index 0498f3c1..70a21e41 100644 --- a/src/chain_parsers/visualsign-solana/Cargo.toml +++ b/src/chain_parsers/visualsign-solana/Cargo.toml @@ -33,3 +33,4 @@ serde_json = "1.0" jupiter-swap-api-client = "0.2.0" base64 = "0.22.1" bs58 = "0.5" +proptest = "1" diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs new file mode 100644 index 00000000..505c125b --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -0,0 +1,341 @@ +//! Property-based fuzz tests for IDL instruction parsing. +//! +//! These tests verify that `decode_idl_data` and `parse_instruction_with_idl` +//! (from `solana_parser`) never panic regardless of: +//! +//! - IDL shape: varying instruction counts, argument counts, and argument types +//! - Instruction data bytes: fully random, correct-discriminator prefix, empty, overlong +//! +//! Run: `cargo test --test fuzz_idl_parsing` +//! More iterations: `PROPTEST_CASES=5000 cargo test --test fuzz_idl_parsing` + +use proptest::prelude::*; +use solana_parser::{decode_idl_data, parse_instruction_with_idl}; + +const TEST_PROGRAM_ID: &str = "11111111111111111111111111111111"; + +// ── Strategies ─────────────────────────────────────────────────────────────── + +/// All primitive IDL types in their JSON wire format (as expected by `decode_idl_data`). +fn arb_primitive_type() -> impl Strategy { + prop_oneof![ + Just(serde_json::json!("bool")), + Just(serde_json::json!("u8")), + Just(serde_json::json!("u16")), + Just(serde_json::json!("u32")), + Just(serde_json::json!("u64")), + Just(serde_json::json!("u128")), + Just(serde_json::json!("i8")), + Just(serde_json::json!("i16")), + Just(serde_json::json!("i32")), + Just(serde_json::json!("i64")), + Just(serde_json::json!("i128")), + Just(serde_json::json!("f32")), + Just(serde_json::json!("f64")), + Just(serde_json::json!("publicKey")), + Just(serde_json::json!("string")), + Just(serde_json::json!("bytes")), + ] +} + +/// IDL type: a primitive or a container (Vec, Option, Array) wrapping a primitive. +fn arb_idl_type() -> impl Strategy { + arb_primitive_type().prop_flat_map(|prim| { + let p_vec = prim.clone(); + let p_opt = prim.clone(); + let p_arr = prim.clone(); + prop_oneof![ + // Weighted 4:1:1:1 — most fields are primitives, containers less frequent. + 4 => Just(prim), + 1 => Just(serde_json::json!({"vec": p_vec})), + 1 => Just(serde_json::json!({"option": p_opt})), + 1 => (1usize..=4).prop_map(move |n| serde_json::json!({"array": [p_arr.clone(), n]})), + ] + }) +} + +/// Valid identifier: starts with [a-z], followed by 1–15 lowercase alphanumeric chars. +fn arb_identifier() -> impl Strategy { + "[a-z][a-z0-9]{1,15}" +} + +/// Random IDL instruction: a name + 0–6 args of randomly-chosen types. +fn arb_idl_instruction() -> impl Strategy { + ( + arb_identifier(), + prop::collection::vec( + (arb_identifier(), arb_idl_type()) + .prop_map(|(name, ty)| serde_json::json!({"name": name, "type": ty})), + 0..=6, + ), + ) + .prop_map(|(name, args)| { + serde_json::json!({ + "name": name, + "accounts": [], + "args": args, + }) + }) +} + +/// Full IDL JSON string with 1–4 randomly-structured instructions. +fn arb_idl_json() -> impl Strategy { + prop::collection::vec(arb_idl_instruction(), 1..=4).prop_map(|instructions| { + serde_json::json!({ + "instructions": instructions, + "types": [], + }) + .to_string() + }) +} + +// ── Crash-safety property tests ────────────────────────────────────────────── + +proptest! { + // Default is 256 cases. Override with PROPTEST_CASES=5000 for deeper fuzzing. + #![proptest_config(ProptestConfig::default())] + + /// Core crash-safety test: a random IDL paired with random instruction bytes + /// must never cause a panic — only `Ok` or a clean `Err`. + #[test] + fn fuzz_idl_parsing_never_panics( + idl_json in arb_idl_json(), + data in prop::collection::vec(any::(), 0..200usize), + ) { + // If the IDL itself fails to decode, that's fine; we only care about panics. + if let Ok(idl) = decode_idl_data(&idl_json) { + let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + } + } + + /// `decode_idl_data` must not panic on completely arbitrary string input. + #[test] + fn fuzz_decode_idl_data_arbitrary_input(s in any::()) { + let _ = decode_idl_data(&s); + } + + /// Take a valid 8-byte discriminator from a parsed IDL and append random + /// bytes for the argument payload. The parser must return `Ok` or a clean + /// `Err` — never a panic. + #[test] + fn fuzz_valid_discriminator_random_args( + idl_json in arb_idl_json(), + arg_bytes in prop::collection::vec(any::(), 0..200usize), + ) { + if let Ok(idl) = decode_idl_data(&idl_json) { + if let Some(inst) = idl.instructions.first() { + if let Some(disc) = &inst.discriminator { + let mut data = disc.clone(); + data.extend_from_slice(&arg_bytes); + let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + } + } + } + } +} + +// ── Valid-data roundtrip tests ──────────────────────────────────────────────── +// +// These tests construct an IDL, extract the computed discriminator, then build +// correctly-serialized instruction data and assert that parsing succeeds. + +#[test] +fn roundtrip_no_args() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "initialize", "accounts": [], "args": []}], + "types": [] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let result = parse_instruction_with_idl(disc, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.instruction_name, "initialize"); + assert!(result.program_call_args.is_empty()); +} + +#[test] +fn roundtrip_single_u64_arg() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "deposit", "accounts": [], "args": [ + {"name": "amount", "type": "u64"} + ]}], + "types": [] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let mut data = disc.clone(); + data.extend_from_slice(&42u64.to_le_bytes()); + + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.instruction_name, "deposit"); + assert_eq!(result.program_call_args["amount"], serde_json::json!(42)); +} + +#[test] +fn roundtrip_mixed_primitive_args() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "swap", "accounts": [], "args": [ + {"name": "amountIn", "type": "u64"}, + {"name": "minOut", "type": "u64"}, + {"name": "slippage", "type": "u16"}, + {"name": "isExact", "type": "bool"}, + ]}], + "types": [] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let mut data = disc.clone(); + data.extend_from_slice(&1000u64.to_le_bytes()); // amountIn + data.extend_from_slice(&900u64.to_le_bytes()); // minOut + data.extend_from_slice(&50u16.to_le_bytes()); // slippage + data.push(1u8); // isExact = true + + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.instruction_name, "swap"); + assert_eq!(result.program_call_args["amountIn"], serde_json::json!(1000)); + assert_eq!(result.program_call_args["minOut"], serde_json::json!(900)); + assert_eq!(result.program_call_args["slippage"], serde_json::json!(50)); + assert_eq!(result.program_call_args["isExact"], serde_json::json!(true)); +} + +#[test] +fn roundtrip_option_some() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "setFee", "accounts": [], "args": [ + {"name": "feeBps", "type": {"option": "u16"}} + ]}], + "types": [] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let mut data = disc.clone(); + data.push(1u8); // Some + data.extend_from_slice(&300u16.to_le_bytes()); + + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.program_call_args["feeBps"], serde_json::json!(300)); +} + +#[test] +fn roundtrip_option_none() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "setFee", "accounts": [], "args": [ + {"name": "feeBps", "type": {"option": "u16"}} + ]}], + "types": [] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let mut data = disc.clone(); + data.push(0u8); // None + + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.program_call_args["feeBps"], serde_json::Value::Null); +} + +#[test] +fn roundtrip_vec_u8() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "writeData", "accounts": [], "args": [ + {"name": "payload", "type": {"vec": "u8"}} + ]}], + "types": [] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let elements: [u8; 3] = [10, 20, 30]; + let mut data = disc.clone(); + data.extend_from_slice(&(elements.len() as u32).to_le_bytes()); // u32 length prefix + data.extend_from_slice(&elements); + + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!( + result.program_call_args["payload"], + serde_json::json!([10, 20, 30]) + ); +} + +#[test] +fn roundtrip_array_u32() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "setParams", "accounts": [], "args": [ + {"name": "limits", "type": {"array": ["u32", 3]}} + ]}], + "types": [] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let mut data = disc.clone(); + for val in [100u32, 200, 300] { + data.extend_from_slice(&val.to_le_bytes()); + } + + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!( + result.program_call_args["limits"], + serde_json::json!([100, 200, 300]) + ); +} + +#[test] +fn roundtrip_multiple_instructions_distinct_dispatch() { + // IDL with 3 instructions; verify each is dispatched by its own discriminator. + let idl_json = serde_json::json!({ + "instructions": [ + {"name": "initialize", "accounts": [], "args": []}, + {"name": "deposit", "accounts": [], "args": [{"name": "amount", "type": "u32"}]}, + {"name": "withdraw", "accounts": [], "args": [ + {"name": "amount", "type": "u32"}, + {"name": "all", "type": "bool"}, + ]}, + ], + "types": [] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + + // initialize — no args + let disc0 = idl.instructions[0].discriminator.as_ref().unwrap(); + let r = parse_instruction_with_idl(disc0, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(r.instruction_name, "initialize"); + assert!(r.program_call_args.is_empty()); + + // deposit — single u32 + let disc1 = idl.instructions[1].discriminator.as_ref().unwrap(); + let mut data1 = disc1.clone(); + data1.extend_from_slice(&99u32.to_le_bytes()); + let r = parse_instruction_with_idl(&data1, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(r.instruction_name, "deposit"); + assert_eq!(r.program_call_args["amount"], serde_json::json!(99)); + + // withdraw — u32 + bool + let disc2 = idl.instructions[2].discriminator.as_ref().unwrap(); + let mut data2 = disc2.clone(); + data2.extend_from_slice(&50u32.to_le_bytes()); + data2.push(0u8); // all = false + let r = parse_instruction_with_idl(&data2, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(r.instruction_name, "withdraw"); + assert_eq!(r.program_call_args["amount"], serde_json::json!(50)); + assert_eq!(r.program_call_args["all"], serde_json::json!(false)); +} From 0d616c92dfbac1ca4cef80c4b49c226198633844 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Wed, 11 Mar 2026 18:35:29 -0400 Subject: [PATCH 02/20] Next step using broader constructions --- .../tests/fuzz_idl_parsing.rs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index 505c125b..0bcfd42d 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -59,14 +59,14 @@ fn arb_identifier() -> impl Strategy { "[a-z][a-z0-9]{1,15}" } -/// Random IDL instruction: a name + 0–6 args of randomly-chosen types. +/// Random IDL instruction: a name + 0–20 args of randomly-chosen types. fn arb_idl_instruction() -> impl Strategy { ( arb_identifier(), prop::collection::vec( (arb_identifier(), arb_idl_type()) .prop_map(|(name, ty)| serde_json::json!({"name": name, "type": ty})), - 0..=6, + 0..=20, ), ) .prop_map(|(name, args)| { @@ -78,9 +78,9 @@ fn arb_idl_instruction() -> impl Strategy { }) } -/// Full IDL JSON string with 1–4 randomly-structured instructions. +/// Full IDL JSON string with 1–16 randomly-structured instructions. fn arb_idl_json() -> impl Strategy { - prop::collection::vec(arb_idl_instruction(), 1..=4).prop_map(|instructions| { + prop::collection::vec(arb_idl_instruction(), 1..=16).prop_map(|instructions| { serde_json::json!({ "instructions": instructions, "types": [], @@ -114,16 +114,18 @@ proptest! { let _ = decode_idl_data(&s); } - /// Take a valid 8-byte discriminator from a parsed IDL and append random - /// bytes for the argument payload. The parser must return `Ok` or a clean - /// `Err` — never a panic. + /// Take a valid 8-byte discriminator from a randomly-selected instruction + /// (not always the first) and append random arg bytes up to MAX_CURSOR_LENGTH + /// (1232). The parser must return `Ok` or a clean `Err` — never a panic. #[test] fn fuzz_valid_discriminator_random_args( idl_json in arb_idl_json(), - arg_bytes in prop::collection::vec(any::(), 0..200usize), + inst_idx in any::(), + arg_bytes in prop::collection::vec(any::(), 0..1300usize), ) { if let Ok(idl) = decode_idl_data(&idl_json) { - if let Some(inst) = idl.instructions.first() { + if !idl.instructions.is_empty() { + let inst = &idl.instructions[inst_idx % idl.instructions.len()]; if let Some(disc) = &inst.discriminator { let mut data = disc.clone(); data.extend_from_slice(&arg_bytes); From c602a3d0296ac0aa593dbd161ede689b30af343f Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Wed, 11 Mar 2026 21:34:05 -0400 Subject: [PATCH 03/20] pipeline tests --- .../tests/pipeline_integration.rs | 409 ++++++++++++++++++ 1 file changed, 409 insertions(+) create mode 100644 src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs diff --git a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs new file mode 100644 index 00000000..1395f377 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs @@ -0,0 +1,409 @@ +//! Full-pipeline integration tests for IDL-based instruction visualization. +//! +//! These tests drive the complete stack end-to-end: +//! +//! SolanaTransaction +//! → transaction_to_visual_sign (public API) +//! → create_idl_registry_from_options (options → IdlRegistry) +//! → decode_instructions (SolanaTransaction × IdlRegistry) +//! → UnknownProgramVisualizer (catch-all visualizer) +//! → try_idl_parsing (IDL path when registered) +//! → try_parse_with_idl (discriminator match + arg decode) +//! → SignablePayload (inspectable output) +//! +//! Contrast with fuzz_idl_parsing.rs, which calls parse_instruction_with_idl +//! directly and never exercises IdlRegistry, the visualizer dispatch, or the +//! SignablePayloadField wrapping. + +use std::collections::HashMap; + +use generated::parser::{ChainMetadata, Idl as ProtoIdl, SolanaMetadata, chain_metadata}; +use proptest::prelude::*; +use solana_parser::decode_idl_data; +use solana_sdk::instruction::{AccountMeta, Instruction}; +use solana_sdk::message::Message; +use solana_sdk::pubkey::Pubkey; +use solana_sdk::transaction::Transaction as SolanaTransaction; +use visualsign::{ + AnnotatedPayloadField, SignablePayload, SignablePayloadField, SignablePayloadFieldPreviewLayout, +}; +use visualsign::vsptrait::VisualSignOptions; +use visualsign_solana::transaction_to_visual_sign; + +// ── Transaction builders ────────────────────────────────────────────────────── + +fn build_transaction(program_id: Pubkey, extra_accounts: Vec, data: Vec) -> SolanaTransaction { + let fee_payer = Pubkey::new_unique(); + let account_metas: Vec = extra_accounts + .iter() + .map(|pk| AccountMeta::new_readonly(*pk, false)) + .collect(); + let ix = Instruction::new_with_bytes(program_id, &data, account_metas); + SolanaTransaction::new_unsigned(Message::new(&[ix], Some(&fee_payer))) +} + +fn build_multi_instruction_transaction(pairs: Vec<(Pubkey, Vec)>) -> SolanaTransaction { + let fee_payer = Pubkey::new_unique(); + let ixs: Vec = pairs + .into_iter() + .map(|(pid, data)| Instruction::new_with_bytes(pid, &data, vec![])) + .collect(); + SolanaTransaction::new_unsigned(Message::new(&ixs, Some(&fee_payer))) +} + +// ── VisualSignOptions builders ──────────────────────────────────────────────── + +fn options_with_idl(program_id: &Pubkey, idl_json: &str, name: &str) -> VisualSignOptions { + let mut idl_mappings = HashMap::new(); + idl_mappings.insert( + program_id.to_string(), + ProtoIdl { + value: idl_json.to_string(), + program_name: Some(name.to_string()), + idl_type: None, + idl_version: None, + signature: None, + }, + ); + VisualSignOptions { + metadata: Some(ChainMetadata { + metadata: Some(chain_metadata::Metadata::Solana(SolanaMetadata { + idl_mappings, + network_id: None, + idl: None, + })), + }), + decode_transfers: false, + transaction_name: None, + developer_config: None, + abi_registry: None, + } +} + +fn options_no_idl() -> VisualSignOptions { + VisualSignOptions { + metadata: None, + decode_transfers: false, + transaction_name: None, + developer_config: None, + abi_registry: None, + } +} + +// ── Field inspection helpers ────────────────────────────────────────────────── + +/// Returns the PreviewLayout for every instruction field in the payload. +/// Instruction fields have label "Instruction N"; the Accounts summary uses "Accounts". +fn instruction_fields(payload: &SignablePayload) -> Vec<&SignablePayloadFieldPreviewLayout> { + payload.fields.iter().filter_map(|f| { + if let SignablePayloadField::PreviewLayout { common, preview_layout } = f { + if common.label.starts_with("Instruction") { + return Some(preview_layout); + } + } + None + }).collect() +} + +/// Searches a flat slice of AnnotatedPayloadFields for a TextV2 field with the given label. +fn find_text(fields: &[AnnotatedPayloadField], label: &str) -> Option { + fields.iter().find_map(|f| { + if let SignablePayloadField::TextV2 { common, text_v2 } = &f.signable_payload_field { + if common.label == label { + return Some(text_v2.text.clone()); + } + } + None + }) +} + +// ── Concrete integration tests ──────────────────────────────────────────────── + +/// Happy path: valid discriminator + correctly serialized args. +/// Verifies the IDL code path is taken and arg values appear in condensed fields. +#[test] +fn pipeline_idl_path_correct_data() { + let program_id = Pubkey::new_unique(); + + let idl_json = serde_json::json!({ + "instructions": [{"name": "deposit", "accounts": [], "args": [ + {"name": "amount", "type": "u64"} + ]}], + "types": [] + }).to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let mut data = disc.clone(); + data.extend_from_slice(&42u64.to_le_bytes()); + + let payload = transaction_to_visual_sign( + build_transaction(program_id, vec![], data), + options_with_idl(&program_id, &idl_json, "My Program"), + ).unwrap(); + + let inst_fields = instruction_fields(&payload); + assert_eq!(inst_fields.len(), 1); + + let layout = inst_fields[0]; + let title = layout.title.as_ref().unwrap().text.as_str(); + assert!(title.contains("(IDL)"), "expected IDL title, got: {title}"); + + let condensed = layout.condensed.as_ref().unwrap(); + assert_eq!(find_text(&condensed.fields, "Instruction"), Some("deposit".into())); + assert_eq!(find_text(&condensed.fields, "amount"), Some("42".into())); +} + +/// IDL is registered but the instruction data has a non-matching discriminator. +/// The IDL path is attempted and gracefully falls back to raw data display. +#[test] +fn pipeline_idl_discriminator_miss() { + let program_id = Pubkey::new_unique(); + + let idl_json = serde_json::json!({ + "instructions": [{"name": "deposit", "accounts": [], "args": []}], + "types": [] + }).to_string(); + + // Discriminator that will never match "deposit" + let data = vec![0xde, 0xad, 0xbe, 0xef, 0x00, 0x01, 0x02, 0x03]; + + let payload = transaction_to_visual_sign( + build_transaction(program_id, vec![], data), + options_with_idl(&program_id, &idl_json, "My Program"), + ).unwrap(); + + let inst_fields = instruction_fields(&payload); + let layout = inst_fields[0]; + + // IDL was registered so the IDL path is attempted — title still shows "(IDL)" + let title = layout.title.as_ref().unwrap().text.as_str(); + assert!(title.contains("(IDL)"), "IDL attempted, got: {title}"); + + // Expanded fields report the parse failure + let expanded = layout.expanded.as_ref().unwrap(); + assert_eq!( + find_text(&expanded.fields, "Status"), + Some("IDL parsing failed - showing raw data".into()), + ); +} + +/// No IDL registered for the program. +/// Falls back to raw hex layout; title is the program ID, no "(IDL)" marker. +#[test] +fn pipeline_no_idl_registered() { + let program_id = Pubkey::new_unique(); + + let payload = transaction_to_visual_sign( + build_transaction(program_id, vec![], vec![1, 2, 3]), + options_no_idl(), + ).unwrap(); + + let inst_fields = instruction_fields(&payload); + let layout = inst_fields[0]; + + let title = layout.title.as_ref().unwrap().text.as_str(); + assert!(!title.contains("(IDL)"), "no IDL registered, got: {title}"); + assert_eq!(title, program_id.to_string()); +} + +/// Named accounts from the IDL appear in the expanded fields with their pubkey values. +#[test] +fn pipeline_named_accounts() { + let program_id = Pubkey::new_unique(); + let depositor = Pubkey::new_unique(); + + let idl_json = serde_json::json!({ + "instructions": [{"name": "deposit", + "accounts": [{"name": "depositor", "isMut": false, "isSigner": true}], + "args": [] + }], + "types": [] + }).to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let payload = transaction_to_visual_sign( + build_transaction(program_id, vec![depositor], disc.clone()), + options_with_idl(&program_id, &idl_json, "Test Program"), + ).unwrap(); + + let inst_fields = instruction_fields(&payload); + let expanded = inst_fields[0].expanded.as_ref().unwrap(); + + assert_eq!( + find_text(&expanded.fields, "depositor"), + Some(depositor.to_string()), + ); +} + +/// One field is emitted per instruction — the field count invariant holds. +#[test] +fn pipeline_field_count_equals_instruction_count() { + let program_id = Pubkey::new_unique(); + + let tx = build_multi_instruction_transaction(vec![ + (program_id, vec![1]), + (program_id, vec![2]), + (program_id, vec![3]), + ]); + + let payload = transaction_to_visual_sign(tx, options_no_idl()).unwrap(); + assert_eq!(instruction_fields(&payload).len(), 3); +} + +/// Two instructions for two different programs: one has an IDL, one does not. +/// Each instruction takes the correct path independently. +#[test] +fn pipeline_multi_instruction_mixed_programs() { + let program_a = Pubkey::new_unique(); // has IDL registered + let program_b = Pubkey::new_unique(); // no IDL + + let idl_json = serde_json::json!({ + "instructions": [{"name": "swap", "accounts": [], "args": []}], + "types": [] + }).to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc_a = idl.instructions[0].discriminator.as_ref().unwrap().clone(); + + let tx = build_multi_instruction_transaction(vec![ + (program_a, disc_a), + (program_b, vec![0xde, 0xad]), + ]); + + let payload = transaction_to_visual_sign(tx, options_with_idl(&program_a, &idl_json, "A")).unwrap(); + + let inst_fields = instruction_fields(&payload); + assert_eq!(inst_fields.len(), 2); + + let title_a = inst_fields[0].title.as_ref().unwrap().text.as_str(); + assert!(title_a.contains("(IDL)"), "program_a has IDL, got: {title_a}"); + + let title_b = inst_fields[1].title.as_ref().unwrap().text.as_str(); + assert!(!title_b.contains("(IDL)"), "program_b has no IDL, got: {title_b}"); + assert_eq!(title_b, program_b.to_string()); +} + +// ── Proptest strategies (duplicated from fuzz_idl_parsing.rs) ───────────────── + +fn arb_primitive_type() -> impl Strategy { + prop_oneof![ + Just(serde_json::json!("bool")), + Just(serde_json::json!("u8")), + Just(serde_json::json!("u16")), + Just(serde_json::json!("u32")), + Just(serde_json::json!("u64")), + Just(serde_json::json!("u128")), + Just(serde_json::json!("i8")), + Just(serde_json::json!("i16")), + Just(serde_json::json!("i32")), + Just(serde_json::json!("i64")), + Just(serde_json::json!("i128")), + Just(serde_json::json!("f32")), + Just(serde_json::json!("f64")), + Just(serde_json::json!("publicKey")), + Just(serde_json::json!("string")), + Just(serde_json::json!("bytes")), + ] +} + +fn arb_idl_type() -> impl Strategy { + arb_primitive_type().prop_flat_map(|prim| { + let p_vec = prim.clone(); + let p_opt = prim.clone(); + let p_arr = prim.clone(); + prop_oneof![ + 4 => Just(prim), + 1 => Just(serde_json::json!({"vec": p_vec})), + 1 => Just(serde_json::json!({"option": p_opt})), + 1 => (1usize..=4).prop_map(move |n| serde_json::json!({"array": [p_arr.clone(), n]})), + ] + }) +} + +fn arb_identifier() -> impl Strategy { + "[a-z][a-z0-9]{1,15}" +} + +fn arb_idl_instruction() -> impl Strategy { + ( + arb_identifier(), + prop::collection::vec( + (arb_identifier(), arb_idl_type()) + .prop_map(|(name, ty)| serde_json::json!({"name": name, "type": ty})), + 0..=20, + ), + ) + .prop_map(|(name, args)| serde_json::json!({"name": name, "accounts": [], "args": args})) +} + +fn arb_idl_json() -> impl Strategy { + prop::collection::vec(arb_idl_instruction(), 1..=16).prop_map(|instructions| { + serde_json::json!({"instructions": instructions, "types": []}).to_string() + }) +} + +// ── Property-based pipeline tests ──────────────────────────────────────────── + +proptest! { + // Default 256 cases; override with PROPTEST_CASES=N. + #![proptest_config(ProptestConfig::default())] + + /// Random IDL registered for a program + random instruction bytes: the full + /// pipeline must never panic and must return Ok. + #[test] + fn fuzz_pipeline_never_panics( + idl_json in arb_idl_json(), + data in prop::collection::vec(any::(), 0..1300usize), + ) { + let program_id = Pubkey::new_unique(); + let tx = build_transaction(program_id, vec![], data); + let _ = transaction_to_visual_sign(tx, options_with_idl(&program_id, &idl_json, "F")); + } + + /// The number of instruction fields in the output always equals the number + /// of instructions in the transaction. + #[test] + fn fuzz_pipeline_field_count_invariant( + idl_json in arb_idl_json(), + data in prop::collection::vec(any::(), 0..1300usize), + ) { + let program_id = Pubkey::new_unique(); + let tx = build_transaction(program_id, vec![], data); + let inst_count = tx.message.instructions.len(); + let options = options_with_idl(&program_id, &idl_json, "F"); + if let Ok(payload) = transaction_to_visual_sign(tx, options) { + prop_assert_eq!(instruction_fields(&payload).len(), inst_count); + } + } + + /// When instruction data begins with a valid discriminator from the IDL, + /// the IDL code path is always taken — title contains "(IDL)". + #[test] + fn fuzz_pipeline_idl_path_taken_on_valid_discriminator( + idl_json in arb_idl_json(), + inst_idx in any::(), + arg_bytes in prop::collection::vec(any::(), 0..200usize), + ) { + let Ok(idl) = decode_idl_data(&idl_json) else { return Ok(()); }; + let inst = &idl.instructions[inst_idx % idl.instructions.len()]; + let Some(disc) = &inst.discriminator else { return Ok(()); }; + + let mut data = disc.clone(); + data.extend_from_slice(&arg_bytes); + + let program_id = Pubkey::new_unique(); + let tx = build_transaction(program_id, vec![], data); + let options = options_with_idl(&program_id, &idl_json, "F"); + + if let Ok(payload) = transaction_to_visual_sign(tx, options) { + for layout in instruction_fields(&payload) { + let title = layout.title.as_ref().unwrap().text.as_str(); + prop_assert!(title.contains("(IDL)"), "expected IDL title, got: {title}"); + } + } + } +} From ea4eaa5ccda41a57fa2053b6637a45f6129e28a5 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Wed, 11 Mar 2026 22:11:02 -0400 Subject: [PATCH 04/20] check ok a bit more --- .../tests/fuzz_idl_parsing.rs | 254 +++++++++++++++++- .../tests/pipeline_integration.rs | 50 +++- 2 files changed, 292 insertions(+), 12 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index 0bcfd42d..da892d82 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -5,6 +5,9 @@ //! //! - IDL shape: varying instruction counts, argument counts, and argument types //! - Instruction data bytes: fully random, correct-discriminator prefix, empty, overlong +//! - Defined types (structs) referenced from instruction args +//! - Nested container types: `Vec>`, `Option>` +//! - SizeGuard boundary: large Vec/String length prefixes with little backing data //! //! Run: `cargo test --test fuzz_idl_parsing` //! More iterations: `PROPTEST_CASES=5000 cargo test --test fuzz_idl_parsing` @@ -38,18 +41,25 @@ fn arb_primitive_type() -> impl Strategy { ] } -/// IDL type: a primitive or a container (Vec, Option, Array) wrapping a primitive. +/// IDL type: a primitive or a container (Vec, Option, Array, or nested combo) +/// wrapping a primitive. +/// +/// Weights: 4 primitive : 1 Vec : 1 Option : 1 Array : 1 Vec> : 1 Option> fn arb_idl_type() -> impl Strategy { arb_primitive_type().prop_flat_map(|prim| { let p_vec = prim.clone(); let p_opt = prim.clone(); let p_arr = prim.clone(); + let p_vec_opt = prim.clone(); // Vec> + let p_opt_vec = prim.clone(); // Option> prop_oneof![ - // Weighted 4:1:1:1 — most fields are primitives, containers less frequent. + // Most fields are primitives; containers and nested types less frequent. 4 => Just(prim), 1 => Just(serde_json::json!({"vec": p_vec})), 1 => Just(serde_json::json!({"option": p_opt})), 1 => (1usize..=4).prop_map(move |n| serde_json::json!({"array": [p_arr.clone(), n]})), + 1 => Just(serde_json::json!({"vec": {"option": p_vec_opt}})), + 1 => Just(serde_json::json!({"option": {"vec": p_opt_vec}})), ] }) } @@ -78,7 +88,7 @@ fn arb_idl_instruction() -> impl Strategy { }) } -/// Full IDL JSON string with 1–16 randomly-structured instructions. +/// Full IDL JSON string with 1–16 randomly-structured instructions (primitive + container types). fn arb_idl_json() -> impl Strategy { prop::collection::vec(arb_idl_instruction(), 1..=16).prop_map(|instructions| { serde_json::json!({ @@ -89,22 +99,96 @@ fn arb_idl_json() -> impl Strategy { }) } +/// IDL JSON string where one instruction references a randomly-generated defined struct. +/// +/// This exercises the `Defined` type resolution path through `types`. +fn arb_defined_struct_idl_json() -> impl Strategy { + ( + arb_identifier(), // struct name + prop::collection::vec( + (arb_identifier(), arb_primitive_type()) + .prop_map(|(n, t)| serde_json::json!({"name": n, "type": t})), + 1..=8, // struct fields (primitives only — avoids Defined-in-Defined depth limit) + ), + arb_identifier(), // instruction name + // extra instructions that use primitive args, not the defined type + prop::collection::vec(arb_idl_instruction(), 0..=4), + ) + .prop_map(|(struct_name, fields, inst_name, mut extra_insts)| { + let main_inst = serde_json::json!({ + "name": inst_name, + "accounts": [], + "args": [{"name": "data", "type": {"defined": struct_name}}] + }); + extra_insts.push(main_inst); + serde_json::json!({ + "instructions": extra_insts, + "types": [{ + "name": struct_name, + "type": {"kind": "struct", "fields": fields} + }] + }) + .to_string() + }) +} + +/// IDL JSON string where every instruction has at least one `Vec` arg. +/// +/// Used to stress-test the SizeGuard, which guards against large length-prefix +/// attacks (e.g. claiming a Vec of 10,000,000 u8 when the cursor has 4 bytes). +fn arb_vec_arg_idl_json() -> impl Strategy { + (arb_identifier(), arb_idl_type()).prop_map(|(inst_name, elem_type)| { + serde_json::json!({ + "instructions": [{ + "name": inst_name, + "accounts": [], + "args": [{"name": "data", "type": {"vec": elem_type}}] + }], + "types": [] + }) + .to_string() + }) +} + // ── Crash-safety property tests ────────────────────────────────────────────── proptest! { // Default is 256 cases. Override with PROPTEST_CASES=5000 for deeper fuzzing. #![proptest_config(ProptestConfig::default())] - /// Core crash-safety test: a random IDL paired with random instruction bytes - /// must never cause a panic — only `Ok` or a clean `Err`. + /// Core crash-safety test: a random IDL paired with instruction data that is + /// either (a) fully random bytes or (b) a valid discriminator prefix followed + /// by random arg bytes — 50/50 split. + /// + /// Using a valid discriminator for half of all inputs ensures the argument- + /// decoding code paths are covered, not just the discriminator-matching paths. + /// + /// On the valid-discriminator branch: if parsing returns `Ok`, the instruction + /// name must be non-empty — confirming that the parse code path was taken, not + /// just that an `Err` was returned silently. #[test] fn fuzz_idl_parsing_never_panics( idl_json in arb_idl_json(), + use_valid_disc in any::(), + inst_idx in any::(), data in prop::collection::vec(any::(), 0..200usize), ) { - // If the IDL itself fails to decode, that's fine; we only care about panics. if let Ok(idl) = decode_idl_data(&idl_json) { - let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + if use_valid_disc && !idl.instructions.is_empty() { + let inst = &idl.instructions[inst_idx % idl.instructions.len()]; + if let Some(disc) = &inst.discriminator { + let mut d = disc.clone(); + d.extend_from_slice(&data); + if let Ok(result) = parse_instruction_with_idl(&d, TEST_PROGRAM_ID, &idl) { + prop_assert!(!result.instruction_name.is_empty(), + "Ok result must have a non-empty instruction name"); + } + // Err is also acceptable — random arg bytes may be too short or malformed + } + } else { + // Random bytes: only crash-safety matters, not the Ok/Err outcome + let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + } } } @@ -117,6 +201,9 @@ proptest! { /// Take a valid 8-byte discriminator from a randomly-selected instruction /// (not always the first) and append random arg bytes up to MAX_CURSOR_LENGTH /// (1232). The parser must return `Ok` or a clean `Err` — never a panic. + /// + /// On `Ok`: the instruction name must match the selected instruction, confirming + /// that discriminator dispatch routed to the correct handler. #[test] fn fuzz_valid_discriminator_random_args( idl_json in arb_idl_json(), @@ -126,9 +213,72 @@ proptest! { if let Ok(idl) = decode_idl_data(&idl_json) { if !idl.instructions.is_empty() { let inst = &idl.instructions[inst_idx % idl.instructions.len()]; + let expected_name = inst.name.clone(); if let Some(disc) = &inst.discriminator { let mut data = disc.clone(); data.extend_from_slice(&arg_bytes); + if let Ok(result) = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl) { + prop_assert_eq!(&result.instruction_name, &expected_name, + "discriminator must dispatch to the correct instruction"); + } + // Err is acceptable — random arg bytes may be too short or malformed + } + } + } + } + + /// IDLs with defined struct types must not panic regardless of instruction bytes. + /// Uses the same 50/50 valid-discriminator mix as the core test. + /// + /// On the valid-discriminator branch: if parsing returns `Ok`, the instruction + /// name must match the selected instruction, confirming that defined-type + /// resolution was attempted (not short-circuited before dispatch). + #[test] + fn fuzz_defined_struct_types_never_panics( + idl_json in arb_defined_struct_idl_json(), + use_valid_disc in any::(), + inst_idx in any::(), + data in prop::collection::vec(any::(), 0..200usize), + ) { + if let Ok(idl) = decode_idl_data(&idl_json) { + if use_valid_disc && !idl.instructions.is_empty() { + let inst = &idl.instructions[inst_idx % idl.instructions.len()]; + let expected_name = inst.name.clone(); + if let Some(disc) = &inst.discriminator { + let mut d = disc.clone(); + d.extend_from_slice(&data); + if let Ok(result) = parse_instruction_with_idl(&d, TEST_PROGRAM_ID, &idl) { + prop_assert_eq!(&result.instruction_name, &expected_name, + "defined-type instruction must dispatch to the correct handler"); + } + // Err is acceptable — random arg bytes may not satisfy struct field layout + } + } else { + let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + } + } + } + + /// SizeGuard stress: a Vec arg instruction with a valid discriminator followed + /// by an arbitrary u32 length prefix and a short trailing payload. + /// + /// The SizeGuard must prevent the parser from allocating memory proportional + /// to the claimed length when the cursor contains far fewer bytes + /// (budget = MAX_CURSOR_LENGTH × MAX_ALLOC_PER_CURSOR_LENGTH = 1232 × 24 = 29 568 bytes). + #[test] + fn fuzz_size_guard_vec_length_prefix( + idl_json in arb_vec_arg_idl_json(), + length_prefix in any::(), + trailing in prop::collection::vec(any::(), 0..=8usize), + ) { + if let Ok(idl) = decode_idl_data(&idl_json) { + if !idl.instructions.is_empty() { + // There is exactly one instruction in arb_vec_arg_idl_json + let inst = &idl.instructions[0]; + if let Some(disc) = &inst.discriminator { + let mut data = disc.clone(); + data.extend_from_slice(&length_prefix.to_le_bytes()); + data.extend_from_slice(&trailing); let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); } } @@ -341,3 +491,93 @@ fn roundtrip_multiple_instructions_distinct_dispatch() { assert_eq!(r.program_call_args["amount"], serde_json::json!(50)); assert_eq!(r.program_call_args["all"], serde_json::json!(false)); } + +// ── Defined type (struct) roundtrip tests ──────────────────────────────────── + +/// An instruction whose single arg is a defined struct with primitive fields +/// is decoded correctly end-to-end. +#[test] +fn roundtrip_defined_struct_arg() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "createOrder", "accounts": [], "args": [ + {"name": "params", "type": {"defined": "OrderParams"}} + ]}], + "types": [{ + "name": "OrderParams", + "type": {"kind": "struct", "fields": [ + {"name": "price", "type": "u64"}, + {"name": "quantity", "type": "u32"}, + {"name": "side", "type": "bool"}, + ]} + }] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let mut data = disc.clone(); + data.extend_from_slice(&5000u64.to_le_bytes()); // price + data.extend_from_slice(&10u32.to_le_bytes()); // quantity + data.push(1u8); // side = buy + + // Must parse and return Ok with the struct contents. + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.instruction_name, "createOrder"); + // Struct fields are nested under the "params" key. + let params = &result.program_call_args["params"]; + assert_eq!(params["price"], serde_json::json!(5000)); + assert_eq!(params["quantity"], serde_json::json!(10)); + assert_eq!(params["side"], serde_json::json!(true)); +} + +// ── SizeGuard boundary tests ────────────────────────────────────────────────── + +/// A Vec arg with a length prefix that vastly exceeds the backing data +/// must be rejected cleanly (Err), not panic or over-allocate. +/// +/// SizeGuard budget = MAX_CURSOR_LENGTH (1232) × MAX_ALLOC_PER_CURSOR_LENGTH (24) = 29 568 bytes. +#[test] +fn size_guard_huge_vec_length_prefix_is_rejected_cleanly() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "writeData", "accounts": [], "args": [ + {"name": "payload", "type": {"vec": "u8"}} + ]}], + "types": [] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + // Claim 10 000 000 elements but provide zero backing bytes. + let mut data = disc.clone(); + data.extend_from_slice(&10_000_000u32.to_le_bytes()); + + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + // Must be Err, not a panic or OOM. + assert!(result.is_err(), "expected Err for over-budget Vec length, got Ok"); +} + +/// Same as above but with a Vec (8 bytes/element) — smaller element count +/// is still enough to exceed the budget relative to cursor length. +#[test] +fn size_guard_vec_u64_over_budget() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "setRates", "accounts": [], "args": [ + {"name": "rates", "type": {"vec": "u64"}} + ]}], + "types": [] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + // 100 000 × 8 bytes = 800 000 bytes, far exceeds the 29 568-byte budget. + let mut data = disc.clone(); + data.extend_from_slice(&100_000u32.to_le_bytes()); + + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + assert!(result.is_err(), "expected Err for over-budget Vec length"); +} diff --git a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs index 1395f377..7d5367d9 100644 --- a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs +++ b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs @@ -315,11 +315,15 @@ fn arb_idl_type() -> impl Strategy { let p_vec = prim.clone(); let p_opt = prim.clone(); let p_arr = prim.clone(); + let p_vec_opt = prim.clone(); // Vec> + let p_opt_vec = prim.clone(); // Option> prop_oneof![ 4 => Just(prim), 1 => Just(serde_json::json!({"vec": p_vec})), 1 => Just(serde_json::json!({"option": p_opt})), 1 => (1usize..=4).prop_map(move |n| serde_json::json!({"array": [p_arr.clone(), n]})), + 1 => Just(serde_json::json!({"vec": {"option": p_vec_opt}})), + 1 => Just(serde_json::json!({"option": {"vec": p_opt_vec}})), ] }) } @@ -352,27 +356,63 @@ proptest! { // Default 256 cases; override with PROPTEST_CASES=N. #![proptest_config(ProptestConfig::default())] - /// Random IDL registered for a program + random instruction bytes: the full - /// pipeline must never panic and must return Ok. + /// Random IDL registered for a program + instruction data that is either + /// (a) a valid discriminator prefix + random arg bytes, or (b) fully random + /// bytes — 50/50 split. The full pipeline must never panic. + /// + /// The valid-discriminator half ensures argument-decoding code is exercised, + /// not just the discriminator-matching paths. #[test] fn fuzz_pipeline_never_panics( idl_json in arb_idl_json(), + use_valid_disc in any::(), + inst_idx in any::(), data in prop::collection::vec(any::(), 0..1300usize), ) { let program_id = Pubkey::new_unique(); - let tx = build_transaction(program_id, vec![], data); + let bytes = if use_valid_disc { + if let Ok(idl) = decode_idl_data(&idl_json) { + if !idl.instructions.is_empty() { + let inst = &idl.instructions[inst_idx % idl.instructions.len()]; + if let Some(disc) = &inst.discriminator { + let mut d = disc.clone(); + d.extend_from_slice(&data); + d + } else { data } + } else { data } + } else { data } + } else { + data + }; + let tx = build_transaction(program_id, vec![], bytes); let _ = transaction_to_visual_sign(tx, options_with_idl(&program_id, &idl_json, "F")); } /// The number of instruction fields in the output always equals the number - /// of instructions in the transaction. + /// of instructions in the transaction — regardless of valid/invalid discriminator. #[test] fn fuzz_pipeline_field_count_invariant( idl_json in arb_idl_json(), + use_valid_disc in any::(), + inst_idx in any::(), data in prop::collection::vec(any::(), 0..1300usize), ) { let program_id = Pubkey::new_unique(); - let tx = build_transaction(program_id, vec![], data); + let bytes = if use_valid_disc { + if let Ok(idl) = decode_idl_data(&idl_json) { + if !idl.instructions.is_empty() { + let inst = &idl.instructions[inst_idx % idl.instructions.len()]; + if let Some(disc) = &inst.discriminator { + let mut d = disc.clone(); + d.extend_from_slice(&data); + d + } else { data } + } else { data } + } else { data } + } else { + data + }; + let tx = build_transaction(program_id, vec![], bytes); let inst_count = tx.message.instructions.len(); let options = options_with_idl(&program_id, &idl_json, "F"); if let Ok(payload) = transaction_to_visual_sign(tx, options) { From d927cac250356ced52679329b3b510c478756caf Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Wed, 11 Mar 2026 22:35:44 -0400 Subject: [PATCH 05/20] check good discriminators with good args are ok --- .../tests/fuzz_idl_parsing.rs | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index da892d82..ee617b3a 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -13,7 +13,9 @@ //! More iterations: `PROPTEST_CASES=5000 cargo test --test fuzz_idl_parsing` use proptest::prelude::*; +use solana_parser::solana::structs::{IdlInstruction, IdlType, IdlTypeDefinition, IdlTypeDefinitionType}; use solana_parser::{decode_idl_data, parse_instruction_with_idl}; +use std::sync::Arc; const TEST_PROGRAM_ID: &str = "11111111111111111111111111111111"; @@ -150,6 +152,161 @@ fn arb_vec_arg_idl_json() -> impl Strategy { }) } +// ── Valid-input byte-generation strategies ──────────────────────────────────── +// +// These strategies produce borsh-correct bytes for a given IdlType so that the +// parser can be asserted to return Ok — not merely "didn't panic". +// +// Size constraints keep every generated payload ≤ MAX_CURSOR_LENGTH (1232 bytes): +// Vec: 0..=2 elements, String/Bytes: 0..=16 bytes of content. +// With 20 args per instruction (the max from arb_idl_instruction), worst-case: +// Vec>> × 2 elements × 20 args + 8-byte disc = ~620 bytes. + +/// Generate borsh-correct bytes for `ty`, resolving Defined types against `types`. +/// +/// Returns a `BoxedStrategy` so the function can recurse for container types. +fn arb_bytes_for_type(ty: IdlType, types: Arc>) -> BoxedStrategy> { + match ty { + IdlType::Bool => + any::().prop_map(|b| vec![b as u8]).boxed(), + IdlType::U8 => + any::().prop_map(|v| vec![v]).boxed(), + IdlType::U16 => + any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), + IdlType::U32 => + any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), + IdlType::U64 => + any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), + IdlType::U128 => + any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), + IdlType::I8 => + any::().prop_map(|v| vec![v as u8]).boxed(), + IdlType::I16 => + any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), + IdlType::I32 => + any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), + IdlType::I64 => + any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), + IdlType::I128 => + any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), + // Use raw bit patterns to avoid NaN/inf — parser calls read_f32/f64 which accept any bits. + IdlType::F32 => + any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), + IdlType::F64 => + any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), + // PublicKey: exactly 32 bytes, no length prefix. + IdlType::PublicKey => + prop::collection::vec(any::(), 32).boxed(), + // String: borsh u32-length-prefixed valid UTF-8. + IdlType::String => + "[a-z0-9]{0,16}".prop_map(|s| { + let b = s.as_bytes(); + let mut out = (b.len() as u32).to_le_bytes().to_vec(); + out.extend_from_slice(b); + out + }).boxed(), + // Bytes: borsh u32-length-prefixed raw bytes. + IdlType::Bytes => + prop::collection::vec(any::(), 0..=16).prop_map(|bytes| { + let mut out = (bytes.len() as u32).to_le_bytes().to_vec(); + out.extend(bytes); + out + }).boxed(), + // Option: 1-byte tag (0=None, 1=Some) + inner bytes when Some. + IdlType::Option(inner) => { + let some_strat = arb_bytes_for_type(*inner, types); + prop_oneof![ + 1 => Just(vec![0u8]), + 1 => some_strat.prop_map(|b| { let mut out = vec![1u8]; out.extend(b); out }), + ].boxed() + } + // Vec: u32 length prefix + N encoded elements (N ≤ 2 to bound total size). + IdlType::Vec(inner) => { + let inner_strat = arb_bytes_for_type(*inner, types); + prop::collection::vec(inner_strat, 0..=2).prop_map(|items| { + let mut out = (items.len() as u32).to_le_bytes().to_vec(); + for item in items { out.extend(item); } + out + }).boxed() + } + // Array: exactly N encoded elements, no length prefix. + IdlType::Array(inner, n) => { + let inner_strat = arb_bytes_for_type(*inner, types); + prop::collection::vec(inner_strat, n..=n) + .prop_map(|items| items.into_iter().flatten().collect()) + .boxed() + } + // Defined: look up the struct/alias in `types` and encode its fields in order. + // Enum variants are not yet handled — fall back to empty bytes. + IdlType::Defined(defined) => { + let name = defined.to_string(); + match types.iter().find(|t| t.name == name).map(|t| t.r#type.clone()) { + Some(IdlTypeDefinitionType::Struct { fields }) => { + fields.into_iter() + .map(|f| arb_bytes_for_type(f.r#type, types.clone())) + .fold(Just(Vec::new()).boxed(), |acc, strat| { + (acc, strat) + .prop_map(|(mut a, b)| { a.extend(b); a }) + .boxed() + }) + } + Some(IdlTypeDefinitionType::Alias { value }) => + arb_bytes_for_type(value, types), + _ => + // Enum or unknown — produce empty bytes; test will tolerate Err here. + Just(vec![]).boxed(), + } + } + } +} + +/// Generate the discriminator + borsh-correct arg bytes for one instruction. +fn arb_valid_instruction_bytes( + inst: &IdlInstruction, + types: Arc>, +) -> BoxedStrategy> { + let disc = match &inst.discriminator { + Some(d) => d.clone(), + None => return Just(vec![]).boxed(), + }; + inst.args.iter() + .map(|field| arb_bytes_for_type(field.r#type.clone(), types.clone())) + .fold(Just(disc).boxed(), |acc, strat| { + (acc, strat).prop_map(|(mut a, b)| { a.extend(b); a }).boxed() + }) +} + +/// Strategy that produces `(idl_json, instruction_index, valid_borsh_bytes)`. +/// +/// The bytes are always correctly encoded for the selected instruction's arg +/// layout — so `parse_instruction_with_idl` is expected to return `Ok`. +fn arb_idl_and_valid_bytes() -> impl Strategy)> { + arb_idl_json().prop_flat_map(|idl_json| { + match decode_idl_data(&idl_json) { + Err(_) => { + // arb_idl_json generates well-formed IDLs; this branch is rare. + Just((idl_json, 0usize, vec![])).boxed() + } + Ok(idl) => { + let n = idl.instructions.len(); + let types = Arc::new(idl.types.clone()); + let instructions = idl.instructions.clone(); + let idl_json_owned = idl_json.clone(); + (0..n) + .prop_flat_map(move |inst_idx| { + let byte_strat = arb_valid_instruction_bytes( + &instructions[inst_idx], + types.clone(), + ); + let j = idl_json_owned.clone(); + byte_strat.prop_map(move |bytes| (j.clone(), inst_idx, bytes)) + }) + .boxed() + } + } + }) +} + // ── Crash-safety property tests ────────────────────────────────────────────── proptest! { @@ -259,6 +416,30 @@ proptest! { } } + /// Valid input must always parse successfully. + /// + /// Unlike the other crash-safety tests, this one asserts `result.is_ok()` — + /// not merely "didn't panic". The bytes are generated by `arb_idl_and_valid_bytes`, + /// which produces a correctly borsh-encoded payload for every instruction layout. + /// + /// A failure here indicates a genuine parser bug: the parser rejected data + /// that it should have accepted according to its own IDL contract. + /// + /// On `Ok`: instruction name must match the selected instruction, confirming + /// discriminator dispatch and arg decoding both succeeded. + #[test] + fn fuzz_valid_data_always_parses_ok( + (idl_json, inst_idx, bytes) in arb_idl_and_valid_bytes(), + ) { + let Ok(idl) = decode_idl_data(&idl_json) else { return Ok(()); }; + if idl.instructions.is_empty() || bytes.is_empty() { return Ok(()); } + let expected_name = idl.instructions[inst_idx].name.clone(); + let result = parse_instruction_with_idl(&bytes, TEST_PROGRAM_ID, &idl); + prop_assert!(result.is_ok(), + "parser rejected correctly-encoded input for instruction '{expected_name}': {:?}", result); + prop_assert_eq!(&result.unwrap().instruction_name, &expected_name); + } + /// SizeGuard stress: a Vec arg instruction with a valid discriminator followed /// by an arbitrary u32 length prefix and a short trailing payload. /// From ddb2e6871fd3ba78731b8bc02dd62df119ee90b4 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Thu, 12 Mar 2026 11:29:04 -0400 Subject: [PATCH 06/20] fuzz all idls --- scripts/fuzz_all_idls.sh | 151 ++++++++++++++++++ .../fuzz_idl_parsing.proptest-regressions | 7 + .../tests/fuzz_idl_parsing.rs | 149 ++++++++++++++++- 3 files changed, 302 insertions(+), 5 deletions(-) create mode 100755 scripts/fuzz_all_idls.sh create mode 100644 src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.proptest-regressions diff --git a/scripts/fuzz_all_idls.sh b/scripts/fuzz_all_idls.sh new file mode 100755 index 00000000..0e7d7c3c --- /dev/null +++ b/scripts/fuzz_all_idls.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# fuzz_all_idls.sh — run IDL fuzz tests against every embedded Solana IDL. +# +# The embedded IDLs live in the solana_parser git dependency: +# +# ape_pro.json 6 instructions 4 types (Ape Pro) +# cndy.json 7 instructions 4 types (Metaplex Candy Machine) +# collision.json 1 instruction 2 types (test fixture: duplicate type names) +# cyclic.json 1 instruction 2 types (test fixture: cyclic type references) +# drift.json 199 instructions 81 types (Drift Protocol V2) +# jupiter.json 34 instructions 8 types (Jupiter Swap) +# jupiter_agg_v6.json 14 instructions 9 types (Jupiter Aggregator V6) +# jupiter_limit.json 8 instructions 12 types (Jupiter Limit) +# kamino.json 36 instructions 51 types (Kamino) +# lifinity.json 3 instructions 4 types (Lifinity Swap V2) +# meteora.json 64 instructions 38 types (Meteora) +# openbook.json 29 instructions 32 types (Openbook) +# orca.json 49 instructions 11 types (Orca Whirlpool) +# raydium.json 10 instructions 5 types (Raydium) +# stabble.json 17 instructions 8 types (Stabble) +# +# For each IDL the script runs two test functions from fuzz_idl_parsing.rs: +# +# real_idl_never_panics +# — 50/50 valid/random discriminator mix; on Ok asserts correct dispatch. +# +# real_idl_valid_data_always_parses_ok +# — generates borsh-correct bytes for every instruction; asserts is_ok(). +# +# Usage: +# ./scripts/fuzz_all_idls.sh +# PROPTEST_CASES=1000 ./scripts/fuzz_all_idls.sh +# ./scripts/fuzz_all_idls.sh /path/to/extra.json ... # append extra IDLs +# +# Requirements: cargo, python3 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORKSPACE_TOML="$SCRIPT_DIR/../src/Cargo.toml" +CASES="${PROPTEST_CASES:-256}" + +# ── Locate the solana_parser IDL directory via cargo metadata ───────────────── + +IDL_DIR="$(python3 - "$WORKSPACE_TOML" <<'PY' +import json, os, subprocess, sys + +manifest = sys.argv[1] +result = subprocess.run( + ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1"], + capture_output=True, text=True, check=True, +) +data = json.loads(result.stdout) +for pkg in data["packages"]: + if pkg["name"] == "solana_parser": + idl_dir = os.path.join(os.path.dirname(pkg["manifest_path"]), "src", "solana", "idls") + if os.path.isdir(idl_dir): + print(idl_dir) + sys.exit(0) +print("error: solana_parser IDL directory not found", file=sys.stderr) +sys.exit(1) +PY +)" + +# ── Collect IDL files: embedded + any extras passed as arguments ────────────── + +IDL_FILES=("$IDL_DIR"/*.json) +for extra in "${@}"; do + IDL_FILES+=("$extra") +done + +# ── Build once so the loop doesn't pay compilation cost each iteration ───────── + +echo "Building test binary..." +cargo test \ + --manifest-path "$WORKSPACE_TOML" \ + -p visualsign-solana \ + --test fuzz_idl_parsing \ + --no-run \ + 2>&1 | grep -E "^( Compiling| Finished|error)" || true +echo "" + +# ── Run tests for each IDL ──────────────────────────────────────────────────── + +PASS=0 +FAIL=0 +FAILED_IDLS=() + +printf "%-30s %13s %7s %s\n" "IDL" "Instructions" "Types" "Result" +printf "%-30s %13s %7s %s\n" "───────────────────────────" "────────────" "─────" "──────" + +for idl_file in "${IDL_FILES[@]}"; do + name="$(basename "$idl_file" .json)" + + # Get instruction/type counts + read -r inst_count type_count < <(python3 -c " +import json, sys +try: + d = json.load(open(sys.argv[1])) + print(len(d.get('instructions', [])), len(d.get('types', []))) +except Exception: + print(0, 0) +" "$idl_file") + + printf "%-30s %13s %7s " "$name" "$inst_count" "$type_count" + + # Run both real_idl_* tests for this IDL. + output=$(IDL_FILE="$idl_file" PROPTEST_CASES="$CASES" \ + cargo test \ + --manifest-path "$WORKSPACE_TOML" \ + -p visualsign-solana \ + --test fuzz_idl_parsing \ + real_idl \ + --quiet \ + 2>&1) + + # Extract "N passed; M failed" directly from cargo's summary line. + summary=$(echo "$output" | grep -oE "[0-9]+ passed; [0-9]+ failed" | head -1) + + if [ -z "$summary" ]; then + echo "FAIL (no test result)" + FAIL=$(( FAIL + 1 )) + FAILED_IDLS+=("$name ($idl_file)") + else + failed_count=$(echo "$summary" | grep -oE "^[0-9]+ failed" | grep -oE "^[0-9]+" || \ + echo "$summary" | grep -oE "[0-9]+ failed" | grep -oE "[0-9]+") + if [ "${failed_count:-0}" -gt 0 ]; then + echo "FAIL ($summary)" + FAIL=$(( FAIL + 1 )) + FAILED_IDLS+=("$name ($idl_file)") + else + echo "PASS ($summary)" + PASS=$(( PASS + 1 )) + fi + fi +done + +echo "" +echo "Results: $PASS passed, $FAIL failed (PROPTEST_CASES=$CASES)" + +if (( FAIL > 0 )); then + echo "" + echo "Failed:" + for entry in "${FAILED_IDLS[@]}"; do + echo " $entry" + done + echo "" + echo "Re-run a single IDL with full output:" + echo " IDL_FILE= cargo test --manifest-path src/Cargo.toml -p visualsign-solana --test fuzz_idl_parsing real_idl" + exit 1 +fi diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.proptest-regressions b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.proptest-regressions new file mode 100644 index 00000000..8975787e --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.proptest-regressions @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc af260e671c772ce9f858d25d370f930405baa1e38d323d04a6b86f5b0982ae76 # shrinks to use_valid_disc = false, inst_idx = 0, data = [96, 247, 167, 54, 77, 60, 130, 112, 23, 135, 236, 197, 133, 142, 88, 108, 64, 218, 209, 129, 250, 150, 186, 54, 43, 160, 137, 32, 205, 132, 202, 26, 174, 203, 20, 98, 247, 80, 244, 152, 252, 101, 178, 140, 198, 102, 80, 54, 82, 62, 163, 135, 165, 173, 17, 174, 62, 190, 173, 224, 31, 58, 19, 128, 45, 138, 230, 242, 82, 195, 167, 81, 90, 82, 25, 97, 210, 136, 193, 160, 123, 110, 175, 153, 225, 104, 136, 247, 155, 179, 167, 210, 222, 171, 40, 97, 8, 141, 4, 14, 43, 115, 107, 233, 127, 59, 27, 2, 194, 98, 59, 150, 23, 29, 52, 44, 88, 15, 65, 56, 250, 8, 57, 66, 49, 202, 59, 199, 67, 225, 28, 14, 38, 143, 151, 214, 24, 113, 127, 232, 43, 190, 32, 251, 148, 232, 57, 198, 91, 208, 56, 22, 7, 4, 124, 46, 207, 80, 111, 189, 170, 87, 32, 112, 94, 190, 229, 196, 90, 8, 119, 225, 207, 253, 142, 30, 190, 126, 146, 167, 66, 84, 206, 44, 204, 201, 184, 253, 254, 142, 64, 4, 55, 14, 232, 209, 159, 254, 158, 169, 189, 165, 175, 224, 240, 0, 100, 71, 218, 71, 251, 223, 192, 80, 218, 160, 192, 100, 249, 248, 161, 48, 24, 255, 66, 234, 202, 197, 70, 206, 104, 107, 6, 175, 41, 188, 59, 233, 220, 44, 99, 83, 53, 176, 74, 155, 61, 236, 166, 45, 86, 131, 155, 3, 169, 117, 220, 78, 183, 254, 76, 245, 100, 128, 6, 254, 96, 26, 127, 42, 208, 93, 68, 243, 146, 25, 135, 58, 159, 136, 119, 89, 197, 176, 137, 91, 21, 238, 25, 211, 217, 58, 254, 191, 16, 79, 171, 26, 216, 133, 162, 241, 148, 223, 106, 196, 233, 200, 206, 159, 103, 63, 29, 98, 112, 116, 181, 85, 155, 170, 157, 236, 177, 132, 151, 149, 86, 2, 250, 114, 56, 131, 62, 109, 21, 238, 197, 38, 230, 144, 91, 200, 71, 253, 168, 139, 139, 47, 105, 248, 141, 236, 9, 146, 152, 25, 56, 191, 83, 60, 44, 158, 240, 203, 162, 94, 5, 16, 208, 44, 113, 50, 21, 35, 74, 227, 233, 55, 18, 2, 180, 12, 31, 22, 182, 133, 125, 109, 158, 81, 165, 27, 33, 232, 2, 69, 83, 15, 164, 148, 209, 186, 176, 24, 253, 215, 25, 143, 106, 4, 162, 39, 125, 166, 174, 147, 255, 238, 149, 78, 84, 127, 38, 87, 219, 41, 24, 113, 114, 112, 54, 211, 71, 85, 102, 111, 111, 18, 128, 137, 226, 237, 31, 206, 89, 193, 241, 238, 211, 33, 157, 73, 78, 223, 66, 85, 206, 120, 32, 76, 37, 248, 213, 218, 68, 35, 97, 169, 94, 168, 238, 183, 125, 180, 177, 206, 114, 198, 140, 37, 152, 134, 59, 52, 150, 60, 250, 4, 141, 174, 247, 5, 103, 179, 98, 254, 229, 219, 54, 94, 102, 158, 21, 105, 245, 142, 5, 83, 95, 114, 187, 234, 51, 254, 66, 68, 114, 240, 144, 193, 29, 133, 125, 34, 245, 119, 3, 191, 226, 105, 209, 12, 14, 71, 31, 139, 38, 240, 190, 19, 45, 176, 109, 149, 240, 219, 43, 94, 214, 186, 249, 241, 142, 213, 39, 240, 50, 241, 92, 65, 101, 150, 251, 10, 148, 196, 221, 70, 21, 38, 83, 109, 101, 168, 135, 164, 113, 132, 156, 89, 11, 244, 198, 214, 173, 113, 239, 241, 98, 8, 193, 3, 121, 108, 128, 72, 253, 131, 200, 197, 30, 213, 229, 135, 213, 90, 204, 249, 33, 185, 198, 1, 112, 164, 225, 254, 74, 46, 180, 239, 35, 223, 185, 72, 175, 56, 148, 102, 45, 117, 225, 190, 218, 41, 20, 225, 92, 121, 177, 84, 207, 151, 70, 166, 210, 183, 66, 232, 75, 5, 28, 70, 1, 30, 3, 123, 67, 90, 201, 202, 255, 78, 102, 105, 148, 239, 107, 78, 198, 114, 109, 249, 161, 44, 25, 52, 204, 80, 192, 190, 202, 26, 185, 249, 149, 254, 211, 76, 69, 227, 89, 254, 99, 252, 243, 155, 200, 81, 238, 149, 125, 181, 164, 108, 231, 173, 185, 242, 15, 193, 100, 142, 103, 213, 61, 191, 208, 6, 160, 7, 116, 15, 14, 243, 216, 80, 5, 15, 116, 182, 20, 51, 93, 190, 2, 16, 242, 142, 46, 94, 190, 199, 89, 216, 191, 108, 40, 25, 177, 68, 26, 225, 188, 193, 144, 126, 135, 118, 184, 220, 51, 59, 218, 8, 28, 55, 183, 220, 73, 230, 160, 181, 33, 6, 60, 23, 132, 249, 131, 89, 237, 64, 17, 104, 46, 160, 153, 215, 107, 76, 106, 218, 20, 255, 3, 12, 163, 189, 184, 39, 153, 46, 246, 143, 20, 233, 88, 199, 33, 244, 222, 30, 89, 19, 98, 11, 136, 133, 135, 204, 195, 185, 69, 169, 9, 71, 241, 233, 29, 218, 232, 228, 22, 234, 44, 52, 140, 250, 100, 168, 167, 242, 203, 43, 13, 50, 103, 205, 209, 203, 155, 101, 113, 245, 127, 78, 91, 157, 51, 53, 102, 60, 44, 15, 74, 161, 108, 101, 152, 163, 25, 21, 73, 102, 206, 31, 193, 6, 254, 71, 52, 179, 194, 242, 42, 222, 103, 244, 117, 117, 103, 87, 158, 205, 62, 208, 20, 152, 140, 7, 124, 42, 88, 105, 2, 117, 243, 212, 253, 35, 226, 247, 67, 73, 109, 160, 224, 76, 5, 112, 129, 199, 124, 193, 32, 13, 174, 198, 186, 7, 120, 245, 119, 40, 170, 33, 10, 217, 80, 119, 57, 234, 236, 245, 162, 44, 41, 244, 179, 118, 37, 175, 155, 38, 56, 124, 1, 123, 122, 4, 76, 101, 51, 218, 145, 190, 24, 113, 39, 44, 209, 96, 43, 153, 5, 38, 90, 169, 83, 11, 51, 219, 138, 231, 219, 239, 96, 171, 188, 150, 229, 20, 223, 49, 123, 58, 81, 170, 142, 157, 115, 134, 98, 56, 183, 174, 38, 178, 165, 227, 99, 216, 163, 169, 51, 48, 83, 225, 105, 212, 61, 171, 53, 92, 67, 174, 29, 4, 44, 163, 128, 158, 255, 251, 145, 37, 33, 206, 64, 83, 28, 104, 175, 200, 163, 57, 205, 175, 156, 155, 152, 13, 73, 120, 81, 69] diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index ee617b3a..2d328157 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -13,7 +13,9 @@ //! More iterations: `PROPTEST_CASES=5000 cargo test --test fuzz_idl_parsing` use proptest::prelude::*; -use solana_parser::solana::structs::{IdlInstruction, IdlType, IdlTypeDefinition, IdlTypeDefinitionType}; +use solana_parser::solana::structs::{ + EnumFields, IdlInstruction, IdlType, IdlTypeDefinition, IdlTypeDefinitionType, +}; use solana_parser::{decode_idl_data, parse_instruction_with_idl}; use std::sync::Arc; @@ -236,8 +238,7 @@ fn arb_bytes_for_type(ty: IdlType, types: Arc>) -> BoxedS .prop_map(|items| items.into_iter().flatten().collect()) .boxed() } - // Defined: look up the struct/alias in `types` and encode its fields in order. - // Enum variants are not yet handled — fall back to empty bytes. + // Defined: look up the struct/enum/alias in `types` and encode accordingly. IdlType::Defined(defined) => { let name = defined.to_string(); match types.iter().find(|t| t.name == name).map(|t| t.r#type.clone()) { @@ -250,10 +251,51 @@ fn arb_bytes_for_type(ty: IdlType, types: Arc>) -> BoxedS .boxed() }) } + Some(IdlTypeDefinitionType::Enum { variants }) => { + // borsh: 1-byte variant index + optional fields for that variant. + let n = variants.len(); + if n == 0 { + return Just(vec![]).boxed(); + } + let variants_owned = variants.clone(); + let types_inner = types.clone(); + (0..n) + .prop_flat_map(move |idx| { + let variant = variants_owned[idx].clone(); + let types_v = types_inner.clone(); + let idx_byte = idx as u8; // borsh enum index is u8 + let fields_strat: BoxedStrategy> = + match variant.fields { + None => Just(vec![]).boxed(), + Some(EnumFields::Named(fields)) => fields + .into_iter() + .map(|f| arb_bytes_for_type(f.r#type, types_v.clone())) + .fold(Just(vec![]).boxed(), |acc, s| { + (acc, s) + .prop_map(|(mut a, b)| { a.extend(b); a }) + .boxed() + }), + Some(EnumFields::Tuple(tys)) => tys + .into_iter() + .map(|t| arb_bytes_for_type(t, types_v.clone())) + .fold(Just(vec![]).boxed(), |acc, s| { + (acc, s) + .prop_map(|(mut a, b)| { a.extend(b); a }) + .boxed() + }), + }; + fields_strat.prop_map(move |f| { + let mut out = vec![idx_byte]; + out.extend(f); + out + }) + }) + .boxed() + } Some(IdlTypeDefinitionType::Alias { value }) => arb_bytes_for_type(value, types), - _ => - // Enum or unknown — produce empty bytes; test will tolerate Err here. + None => + // Unknown defined type — fall back to empty bytes. Just(vec![]).boxed(), } } @@ -762,3 +804,100 @@ fn size_guard_vec_u64_over_budget() { let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); assert!(result.is_err(), "expected Err for over-budget Vec length"); } + +// ── Real-IDL property tests (driven by IDL_FILE env var) ───────────────────── +// +// These tests are skipped when IDL_FILE is unset, so CI passes without it. +// +// Usage: +// IDL_FILE=/path/to/jupiter.json cargo test --test fuzz_idl_parsing real_idl +// IDL_FILE=/path/to/drift.json PROPTEST_CASES=1000 cargo test --test fuzz_idl_parsing real_idl +// +// See scripts/fuzz_all_idls.sh to run against all embedded IDLs in one pass. + +fn load_idl_from_env() -> Option<(String, solana_parser::solana::structs::Idl)> { + let path = std::env::var("IDL_FILE").ok()?; + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("IDL_FILE={path}: {e}")); + match decode_idl_data(&json) { + Ok(idl) => Some((json, idl)), + Err(e) => { + // IDL failed validation (e.g. duplicate type names, cyclic references). + // Skip these tests — they are not valid inputs for real_idl_* tests. + eprintln!("IDL_FILE={path}: skipping — decode failed: {e}"); + None + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::default())] + + /// Crash-safety test against a real IDL loaded from IDL_FILE. + /// + /// Uses the same 50/50 valid/random discriminator mix as + /// `fuzz_idl_parsing_never_panics`. On `Ok` with a valid discriminator, + /// asserts the instruction name matches the selected instruction. + #[test] + fn real_idl_never_panics( + use_valid_disc in any::(), + inst_idx in any::(), + data in prop::collection::vec(any::(), 0..1300usize), + ) { + let Some((_, idl)) = load_idl_from_env() else { return Ok(()); }; + if use_valid_disc && !idl.instructions.is_empty() { + let inst = &idl.instructions[inst_idx % idl.instructions.len()]; + let expected_name = inst.name.clone(); + if let Some(disc) = &inst.discriminator { + let mut d = disc.clone(); + d.extend_from_slice(&data); + if let Ok(result) = parse_instruction_with_idl(&d, TEST_PROGRAM_ID, &idl) { + prop_assert_eq!(&result.instruction_name, &expected_name, + "discriminator must dispatch to the correct instruction"); + } + } + } else { + let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + } + } +} + +/// Valid-data parse test against a real IDL loaded from IDL_FILE. +/// +/// Uses TestRunner::run directly so the strategy can be built from the +/// runtime-loaded IDL (not possible with the proptest! macro, which requires +/// strategies to be fully determined at compile time). +/// +/// For every instruction in the IDL, generates correctly borsh-encoded bytes +/// (discriminator + all args) and asserts the parser returns Ok with the +/// expected instruction name. +#[test] +fn real_idl_valid_data_always_parses_ok() { + let Some((_, idl)) = load_idl_from_env() else { return; }; + let n = idl.instructions.len(); + if n == 0 { return; } + + let types = Arc::new(idl.types.clone()); + let instructions = idl.instructions.clone(); + + let strategy = (0..n).prop_flat_map(move |inst_idx| { + arb_valid_instruction_bytes(&instructions[inst_idx], types.clone()) + .prop_map(move |bytes| (inst_idx, bytes)) + }); + + let config = ProptestConfig::default(); + let mut runner = proptest::test_runner::TestRunner::new(config); + let idl_ref = idl.clone(); + runner + .run(&strategy, move |(inst_idx, bytes)| { + let expected = &idl_ref.instructions[inst_idx].name; + let result = parse_instruction_with_idl(&bytes, TEST_PROGRAM_ID, &idl_ref); + prop_assert!( + result.is_ok(), + "instruction '{expected}' rejected correctly-encoded input: {:?}", result + ); + prop_assert_eq!(&result.unwrap().instruction_name, expected); + Ok(()) + }) + .expect("real_idl_valid_data_always_parses_ok failed"); +} From 390bc144507b8348fcfa265c79a5594e5e361b3c Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Thu, 12 Mar 2026 22:20:46 -0400 Subject: [PATCH 07/20] update to anchor solana parser dependency --- src/Cargo.toml | 8 ++++++++ src/chain_parsers/visualsign-solana/Cargo.toml | 5 +++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Cargo.toml b/src/Cargo.toml index 91fdaa83..79aa9f30 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -59,3 +59,11 @@ tracing = "0.1.41" # Pin visualsign dependencies visualsign = { path = "./visualsign" } + +# --------------------------------------------------------------------------- +# Local development overrides +# Uncomment the [patch] block below to use a local checkout of solana-parser +# instead of the pinned git revision. Remove (or re-comment) before committing. +# --------------------------------------------------------------------------- +# [patch."https://github.com/shahan-khatchadourian-anchorage/solana-parser.git"] +# solana_parser = { path = "../../solana-parser" } diff --git a/src/chain_parsers/visualsign-solana/Cargo.toml b/src/chain_parsers/visualsign-solana/Cargo.toml index 70a21e41..739cdb97 100644 --- a/src/chain_parsers/visualsign-solana/Cargo.toml +++ b/src/chain_parsers/visualsign-solana/Cargo.toml @@ -8,8 +8,9 @@ edition = "2024" tracing = { workspace = true } # Using custom fork for IDL-based instruction parsing features not yet in upstream # Features: parse_instruction_with_idl, CustomIdlConfig, decode_idl_data -# Tracking: https://github.com/tkhq/solana-parser/pull/20 -solana_parser = { git = "https://github.com/prasincs/solana-parser.git", rev = "8248d99e42ce8a56ad440ed9b2201607feb1a150" } +# Upstream PR: https://github.com/tkhq/solana-parser/pull/20 +# Fork branch: https://github.com/shahan-khatchadourian-anchorage/solana-parser/tree/prasincs/custom-idl-support +solana_parser = { git = "https://github.com/shahan-khatchadourian-anchorage/solana-parser.git", branch = "solana-parser-add-arbitrary" } visualsign = { workspace = true } generated = { path = "../../generated" } serde_json = { workspace = true } From 4068d0823f4a8614903cadf0ace91c369b629e7a Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Fri, 13 Mar 2026 19:08:55 -0400 Subject: [PATCH 08/20] add proptest-based fuzz and pipeline integration tests for IDL parsing Property tests verify that decode_idl_data and parse_instruction_with_idl never panic regardless of IDL shape or instruction byte content, and that valid borsh-encoded bytes always round-trip through the parser cleanly. - fuzz_idl_parsing.rs: crash-safety tests covering random IDLs, valid discriminator prefixes with random arg bytes, defined struct types, nested Vec/Option containers, and SizeGuard boundary conditions - pipeline_integration.rs: end-to-end tests through parse_transaction covering discriminator dispatch, field count invariants, named accounts, and multi-program transactions Depends on solana_parser::arb (solana-parser-add-arbitrary branch) which provides prop_recursive-based strategies for IdlType and correlated borsh byte generators for roundtrip assertions. Adds src/.cargo/config.toml (gitignored) as local dev override to redirect solana_parser git dep to local checkout without committing a [patch] block. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + src/Cargo.lock | 2 +- src/Cargo.toml | 7 - .../visualsign-solana/Cargo.toml | 7 +- .../tests/fuzz_idl_parsing.rs | 372 +++--------------- .../tests/pipeline_integration.rs | 69 +--- 6 files changed, 69 insertions(+), 389 deletions(-) diff --git a/.gitignore b/.gitignore index d33ec6c2..b4e65d04 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ **/target out +src/.cargo/ diff --git a/src/Cargo.lock b/src/Cargo.lock index f4018660..189cbdfb 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -10549,7 +10549,6 @@ dependencies = [ [[package]] name = "solana_parser" version = "0.1.0" -source = "git+https://github.com/prasincs/solana-parser.git?rev=8248d99e42ce8a56ad440ed9b2201607feb1a150#8248d99e42ce8a56ad440ed9b2201607feb1a150" dependencies = [ "bincode", "bs58 0.5.1", @@ -10557,6 +10556,7 @@ dependencies = [ "heck 0.5.0", "hex", "log", + "proptest", "serde", "serde_json", "sha2 0.10.9", diff --git a/src/Cargo.toml b/src/Cargo.toml index 79aa9f30..5dcab309 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -60,10 +60,3 @@ tracing = "0.1.41" # Pin visualsign dependencies visualsign = { path = "./visualsign" } -# --------------------------------------------------------------------------- -# Local development overrides -# Uncomment the [patch] block below to use a local checkout of solana-parser -# instead of the pinned git revision. Remove (or re-comment) before committing. -# --------------------------------------------------------------------------- -# [patch."https://github.com/shahan-khatchadourian-anchorage/solana-parser.git"] -# solana_parser = { path = "../../solana-parser" } diff --git a/src/chain_parsers/visualsign-solana/Cargo.toml b/src/chain_parsers/visualsign-solana/Cargo.toml index 739cdb97..784ff0c5 100644 --- a/src/chain_parsers/visualsign-solana/Cargo.toml +++ b/src/chain_parsers/visualsign-solana/Cargo.toml @@ -6,11 +6,7 @@ edition = "2024" [dependencies] tracing = { workspace = true } -# Using custom fork for IDL-based instruction parsing features not yet in upstream -# Features: parse_instruction_with_idl, CustomIdlConfig, decode_idl_data -# Upstream PR: https://github.com/tkhq/solana-parser/pull/20 -# Fork branch: https://github.com/shahan-khatchadourian-anchorage/solana-parser/tree/prasincs/custom-idl-support -solana_parser = { git = "https://github.com/shahan-khatchadourian-anchorage/solana-parser.git", branch = "solana-parser-add-arbitrary" } +solana_parser = { git = "https://github.com/anchorageoss/solana-parser.git", branch = "solana-parser-add-arbitrary" } visualsign = { workspace = true } generated = { path = "../../generated" } serde_json = { workspace = true } @@ -29,6 +25,7 @@ spl-token-2022 = "10.0.0" spl-token-2022-interface = "2.1.0" [dev-dependencies] +solana_parser = { git = "https://github.com/anchorageoss/solana-parser.git", branch = "solana-parser-add-arbitrary", features = ["proptest"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" jupiter-swap-api-client = "0.2.0" diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index 2d328157..3703518e 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -13,339 +13,90 @@ //! More iterations: `PROPTEST_CASES=5000 cargo test --test fuzz_idl_parsing` use proptest::prelude::*; +use solana_parser::arb; use solana_parser::solana::structs::{ - EnumFields, IdlInstruction, IdlType, IdlTypeDefinition, IdlTypeDefinitionType, + Defined, Idl, IdlField, IdlType, IdlTypeDefinition, IdlTypeDefinitionType, }; use solana_parser::{decode_idl_data, parse_instruction_with_idl}; use std::sync::Arc; const TEST_PROGRAM_ID: &str = "11111111111111111111111111111111"; -// ── Strategies ─────────────────────────────────────────────────────────────── - -/// All primitive IDL types in their JSON wire format (as expected by `decode_idl_data`). -fn arb_primitive_type() -> impl Strategy { - prop_oneof![ - Just(serde_json::json!("bool")), - Just(serde_json::json!("u8")), - Just(serde_json::json!("u16")), - Just(serde_json::json!("u32")), - Just(serde_json::json!("u64")), - Just(serde_json::json!("u128")), - Just(serde_json::json!("i8")), - Just(serde_json::json!("i16")), - Just(serde_json::json!("i32")), - Just(serde_json::json!("i64")), - Just(serde_json::json!("i128")), - Just(serde_json::json!("f32")), - Just(serde_json::json!("f64")), - Just(serde_json::json!("publicKey")), - Just(serde_json::json!("string")), - Just(serde_json::json!("bytes")), - ] -} - -/// IDL type: a primitive or a container (Vec, Option, Array, or nested combo) -/// wrapping a primitive. -/// -/// Weights: 4 primitive : 1 Vec : 1 Option : 1 Array : 1 Vec> : 1 Option> -fn arb_idl_type() -> impl Strategy { - arb_primitive_type().prop_flat_map(|prim| { - let p_vec = prim.clone(); - let p_opt = prim.clone(); - let p_arr = prim.clone(); - let p_vec_opt = prim.clone(); // Vec> - let p_opt_vec = prim.clone(); // Option> - prop_oneof![ - // Most fields are primitives; containers and nested types less frequent. - 4 => Just(prim), - 1 => Just(serde_json::json!({"vec": p_vec})), - 1 => Just(serde_json::json!({"option": p_opt})), - 1 => (1usize..=4).prop_map(move |n| serde_json::json!({"array": [p_arr.clone(), n]})), - 1 => Just(serde_json::json!({"vec": {"option": p_vec_opt}})), - 1 => Just(serde_json::json!({"option": {"vec": p_opt_vec}})), - ] - }) -} - -/// Valid identifier: starts with [a-z], followed by 1–15 lowercase alphanumeric chars. -fn arb_identifier() -> impl Strategy { - "[a-z][a-z0-9]{1,15}" -} - -/// Random IDL instruction: a name + 0–20 args of randomly-chosen types. -fn arb_idl_instruction() -> impl Strategy { - ( - arb_identifier(), - prop::collection::vec( - (arb_identifier(), arb_idl_type()) - .prop_map(|(name, ty)| serde_json::json!({"name": name, "type": ty})), - 0..=20, - ), - ) - .prop_map(|(name, args)| { - serde_json::json!({ - "name": name, - "accounts": [], - "args": args, - }) - }) -} - -/// Full IDL JSON string with 1–16 randomly-structured instructions (primitive + container types). -fn arb_idl_json() -> impl Strategy { - prop::collection::vec(arb_idl_instruction(), 1..=16).prop_map(|instructions| { - serde_json::json!({ - "instructions": instructions, - "types": [], - }) - .to_string() - }) -} +// ── Local strategies ───────────────────────────────────────────────────────── +// +// Core strategies (`arb_identifier`, `arb_primitive_idl_type`, `arb_idl_type`, +// `arb_idl_field`, `arb_idl_instruction`, `arb_idl`, `arb_idl_json`, +// `arb_bytes_for_type`, `arb_valid_instruction_bytes`) live in +// `solana_parser::arb` and are shared with `pipeline_integration.rs`. -/// IDL JSON string where one instruction references a randomly-generated defined struct. +/// IDL JSON with a defined struct type correlated between `types` and instruction args. /// -/// This exercises the `Defined` type resolution path through `types`. +/// Exercises the `Defined` type resolution path through `types`. fn arb_defined_struct_idl_json() -> impl Strategy { ( - arb_identifier(), // struct name + arb::arb_identifier(), prop::collection::vec( - (arb_identifier(), arb_primitive_type()) - .prop_map(|(n, t)| serde_json::json!({"name": n, "type": t})), - 1..=8, // struct fields (primitives only — avoids Defined-in-Defined depth limit) + (arb::arb_identifier(), arb::arb_primitive_idl_type()) + .prop_map(|(n, t)| IdlField { name: n, r#type: t }), + 1..=8, ), - arb_identifier(), // instruction name - // extra instructions that use primitive args, not the defined type - prop::collection::vec(arb_idl_instruction(), 0..=4), + arb::arb_idl_instruction(), + prop::collection::vec(arb::arb_idl_instruction(), 0..=4), ) - .prop_map(|(struct_name, fields, inst_name, mut extra_insts)| { - let main_inst = serde_json::json!({ - "name": inst_name, - "accounts": [], - "args": [{"name": "data", "type": {"defined": struct_name}}] - }); - extra_insts.push(main_inst); - serde_json::json!({ - "instructions": extra_insts, - "types": [{ - "name": struct_name, - "type": {"kind": "struct", "fields": fields} - }] - }) - .to_string() - }) + .prop_map(|(struct_name, fields, mut main_inst, mut extra_insts)| { + main_inst.args = vec![IdlField { + name: "data".to_string(), + r#type: IdlType::Defined(Defined::String(struct_name.clone())), + }]; + extra_insts.push(main_inst); + let idl = Idl { + instructions: extra_insts, + types: vec![IdlTypeDefinition { + name: struct_name, + r#type: IdlTypeDefinitionType::Struct { fields }, + }], + }; + serde_json::to_string(&idl).unwrap() + }) } -/// IDL JSON string where every instruction has at least one `Vec` arg. +/// IDL JSON where the single instruction has a `Vec` arg. /// /// Used to stress-test the SizeGuard, which guards against large length-prefix /// attacks (e.g. claiming a Vec of 10,000,000 u8 when the cursor has 4 bytes). fn arb_vec_arg_idl_json() -> impl Strategy { - (arb_identifier(), arb_idl_type()).prop_map(|(inst_name, elem_type)| { - serde_json::json!({ - "instructions": [{ - "name": inst_name, - "accounts": [], - "args": [{"name": "data", "type": {"vec": elem_type}}] - }], - "types": [] + arb::arb_idl_instruction().prop_flat_map(|base_inst| { + arb::arb_idl_type().prop_map(move |elem_type| { + let mut inst = base_inst.clone(); + inst.args = vec![IdlField { + name: "data".to_string(), + r#type: IdlType::Vec(Box::new(elem_type)), + }]; + let idl = Idl { instructions: vec![inst], types: vec![] }; + serde_json::to_string(&idl).unwrap() }) - .to_string() }) } -// ── Valid-input byte-generation strategies ──────────────────────────────────── -// -// These strategies produce borsh-correct bytes for a given IdlType so that the -// parser can be asserted to return Ok — not merely "didn't panic". -// -// Size constraints keep every generated payload ≤ MAX_CURSOR_LENGTH (1232 bytes): -// Vec: 0..=2 elements, String/Bytes: 0..=16 bytes of content. -// With 20 args per instruction (the max from arb_idl_instruction), worst-case: -// Vec>> × 2 elements × 20 args + 8-byte disc = ~620 bytes. - -/// Generate borsh-correct bytes for `ty`, resolving Defined types against `types`. -/// -/// Returns a `BoxedStrategy` so the function can recurse for container types. -fn arb_bytes_for_type(ty: IdlType, types: Arc>) -> BoxedStrategy> { - match ty { - IdlType::Bool => - any::().prop_map(|b| vec![b as u8]).boxed(), - IdlType::U8 => - any::().prop_map(|v| vec![v]).boxed(), - IdlType::U16 => - any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), - IdlType::U32 => - any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), - IdlType::U64 => - any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), - IdlType::U128 => - any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), - IdlType::I8 => - any::().prop_map(|v| vec![v as u8]).boxed(), - IdlType::I16 => - any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), - IdlType::I32 => - any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), - IdlType::I64 => - any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), - IdlType::I128 => - any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), - // Use raw bit patterns to avoid NaN/inf — parser calls read_f32/f64 which accept any bits. - IdlType::F32 => - any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), - IdlType::F64 => - any::().prop_map(|v| v.to_le_bytes().to_vec()).boxed(), - // PublicKey: exactly 32 bytes, no length prefix. - IdlType::PublicKey => - prop::collection::vec(any::(), 32).boxed(), - // String: borsh u32-length-prefixed valid UTF-8. - IdlType::String => - "[a-z0-9]{0,16}".prop_map(|s| { - let b = s.as_bytes(); - let mut out = (b.len() as u32).to_le_bytes().to_vec(); - out.extend_from_slice(b); - out - }).boxed(), - // Bytes: borsh u32-length-prefixed raw bytes. - IdlType::Bytes => - prop::collection::vec(any::(), 0..=16).prop_map(|bytes| { - let mut out = (bytes.len() as u32).to_le_bytes().to_vec(); - out.extend(bytes); - out - }).boxed(), - // Option: 1-byte tag (0=None, 1=Some) + inner bytes when Some. - IdlType::Option(inner) => { - let some_strat = arb_bytes_for_type(*inner, types); - prop_oneof![ - 1 => Just(vec![0u8]), - 1 => some_strat.prop_map(|b| { let mut out = vec![1u8]; out.extend(b); out }), - ].boxed() - } - // Vec: u32 length prefix + N encoded elements (N ≤ 2 to bound total size). - IdlType::Vec(inner) => { - let inner_strat = arb_bytes_for_type(*inner, types); - prop::collection::vec(inner_strat, 0..=2).prop_map(|items| { - let mut out = (items.len() as u32).to_le_bytes().to_vec(); - for item in items { out.extend(item); } - out - }).boxed() - } - // Array: exactly N encoded elements, no length prefix. - IdlType::Array(inner, n) => { - let inner_strat = arb_bytes_for_type(*inner, types); - prop::collection::vec(inner_strat, n..=n) - .prop_map(|items| items.into_iter().flatten().collect()) - .boxed() - } - // Defined: look up the struct/enum/alias in `types` and encode accordingly. - IdlType::Defined(defined) => { - let name = defined.to_string(); - match types.iter().find(|t| t.name == name).map(|t| t.r#type.clone()) { - Some(IdlTypeDefinitionType::Struct { fields }) => { - fields.into_iter() - .map(|f| arb_bytes_for_type(f.r#type, types.clone())) - .fold(Just(Vec::new()).boxed(), |acc, strat| { - (acc, strat) - .prop_map(|(mut a, b)| { a.extend(b); a }) - .boxed() - }) - } - Some(IdlTypeDefinitionType::Enum { variants }) => { - // borsh: 1-byte variant index + optional fields for that variant. - let n = variants.len(); - if n == 0 { - return Just(vec![]).boxed(); - } - let variants_owned = variants.clone(); - let types_inner = types.clone(); - (0..n) - .prop_flat_map(move |idx| { - let variant = variants_owned[idx].clone(); - let types_v = types_inner.clone(); - let idx_byte = idx as u8; // borsh enum index is u8 - let fields_strat: BoxedStrategy> = - match variant.fields { - None => Just(vec![]).boxed(), - Some(EnumFields::Named(fields)) => fields - .into_iter() - .map(|f| arb_bytes_for_type(f.r#type, types_v.clone())) - .fold(Just(vec![]).boxed(), |acc, s| { - (acc, s) - .prop_map(|(mut a, b)| { a.extend(b); a }) - .boxed() - }), - Some(EnumFields::Tuple(tys)) => tys - .into_iter() - .map(|t| arb_bytes_for_type(t, types_v.clone())) - .fold(Just(vec![]).boxed(), |acc, s| { - (acc, s) - .prop_map(|(mut a, b)| { a.extend(b); a }) - .boxed() - }), - }; - fields_strat.prop_map(move |f| { - let mut out = vec![idx_byte]; - out.extend(f); - out - }) - }) - .boxed() - } - Some(IdlTypeDefinitionType::Alias { value }) => - arb_bytes_for_type(value, types), - None => - // Unknown defined type — fall back to empty bytes. - Just(vec![]).boxed(), - } - } - } -} - -/// Generate the discriminator + borsh-correct arg bytes for one instruction. -fn arb_valid_instruction_bytes( - inst: &IdlInstruction, - types: Arc>, -) -> BoxedStrategy> { - let disc = match &inst.discriminator { - Some(d) => d.clone(), - None => return Just(vec![]).boxed(), - }; - inst.args.iter() - .map(|field| arb_bytes_for_type(field.r#type.clone(), types.clone())) - .fold(Just(disc).boxed(), |acc, strat| { - (acc, strat).prop_map(|(mut a, b)| { a.extend(b); a }).boxed() - }) -} - -/// Strategy that produces `(idl_json, instruction_index, valid_borsh_bytes)`. +/// Strategy that produces `(idl, instruction_index, valid_borsh_bytes)`. /// /// The bytes are always correctly encoded for the selected instruction's arg /// layout — so `parse_instruction_with_idl` is expected to return `Ok`. -fn arb_idl_and_valid_bytes() -> impl Strategy)> { - arb_idl_json().prop_flat_map(|idl_json| { - match decode_idl_data(&idl_json) { - Err(_) => { - // arb_idl_json generates well-formed IDLs; this branch is rare. - Just((idl_json, 0usize, vec![])).boxed() - } - Ok(idl) => { - let n = idl.instructions.len(); - let types = Arc::new(idl.types.clone()); - let instructions = idl.instructions.clone(); - let idl_json_owned = idl_json.clone(); - (0..n) - .prop_flat_map(move |inst_idx| { - let byte_strat = arb_valid_instruction_bytes( - &instructions[inst_idx], - types.clone(), - ); - let j = idl_json_owned.clone(); - byte_strat.prop_map(move |bytes| (j.clone(), inst_idx, bytes)) - }) - .boxed() - } - } +fn arb_idl_and_valid_bytes() -> impl Strategy)> { + arb::arb_idl().prop_flat_map(|idl| { + let n = idl.instructions.len(); + let types = Arc::new(idl.types.clone()); + let instructions = idl.instructions.clone(); + let idl_owned = idl.clone(); + (0..n) + .prop_flat_map(move |inst_idx| { + let byte_strat = arb::arb_valid_instruction_bytes( + &instructions[inst_idx], + types.clone(), + ); + let idl_c = idl_owned.clone(); + byte_strat.prop_map(move |bytes| (idl_c.clone(), inst_idx, bytes)) + }) }) } @@ -367,7 +118,7 @@ proptest! { /// just that an `Err` was returned silently. #[test] fn fuzz_idl_parsing_never_panics( - idl_json in arb_idl_json(), + idl_json in arb::arb_idl_json(), use_valid_disc in any::(), inst_idx in any::(), data in prop::collection::vec(any::(), 0..200usize), @@ -405,7 +156,7 @@ proptest! { /// that discriminator dispatch routed to the correct handler. #[test] fn fuzz_valid_discriminator_random_args( - idl_json in arb_idl_json(), + idl_json in arb::arb_idl_json(), inst_idx in any::(), arg_bytes in prop::collection::vec(any::(), 0..1300usize), ) { @@ -471,9 +222,8 @@ proptest! { /// discriminator dispatch and arg decoding both succeeded. #[test] fn fuzz_valid_data_always_parses_ok( - (idl_json, inst_idx, bytes) in arb_idl_and_valid_bytes(), + (idl, inst_idx, bytes) in arb_idl_and_valid_bytes(), ) { - let Ok(idl) = decode_idl_data(&idl_json) else { return Ok(()); }; if idl.instructions.is_empty() || bytes.is_empty() { return Ok(()); } let expected_name = idl.instructions[inst_idx].name.clone(); let result = parse_instruction_with_idl(&bytes, TEST_PROGRAM_ID, &idl); @@ -881,7 +631,7 @@ fn real_idl_valid_data_always_parses_ok() { let instructions = idl.instructions.clone(); let strategy = (0..n).prop_flat_map(move |inst_idx| { - arb_valid_instruction_bytes(&instructions[inst_idx], types.clone()) + arb::arb_valid_instruction_bytes(&instructions[inst_idx], types.clone()) .prop_map(move |bytes| (inst_idx, bytes)) }); diff --git a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs index 7d5367d9..80f12621 100644 --- a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs +++ b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs @@ -19,6 +19,7 @@ use std::collections::HashMap; use generated::parser::{ChainMetadata, Idl as ProtoIdl, SolanaMetadata, chain_metadata}; use proptest::prelude::*; +use solana_parser::arb; use solana_parser::decode_idl_data; use solana_sdk::instruction::{AccountMeta, Instruction}; use solana_sdk::message::Message; @@ -287,68 +288,6 @@ fn pipeline_multi_instruction_mixed_programs() { assert_eq!(title_b, program_b.to_string()); } -// ── Proptest strategies (duplicated from fuzz_idl_parsing.rs) ───────────────── - -fn arb_primitive_type() -> impl Strategy { - prop_oneof![ - Just(serde_json::json!("bool")), - Just(serde_json::json!("u8")), - Just(serde_json::json!("u16")), - Just(serde_json::json!("u32")), - Just(serde_json::json!("u64")), - Just(serde_json::json!("u128")), - Just(serde_json::json!("i8")), - Just(serde_json::json!("i16")), - Just(serde_json::json!("i32")), - Just(serde_json::json!("i64")), - Just(serde_json::json!("i128")), - Just(serde_json::json!("f32")), - Just(serde_json::json!("f64")), - Just(serde_json::json!("publicKey")), - Just(serde_json::json!("string")), - Just(serde_json::json!("bytes")), - ] -} - -fn arb_idl_type() -> impl Strategy { - arb_primitive_type().prop_flat_map(|prim| { - let p_vec = prim.clone(); - let p_opt = prim.clone(); - let p_arr = prim.clone(); - let p_vec_opt = prim.clone(); // Vec> - let p_opt_vec = prim.clone(); // Option> - prop_oneof![ - 4 => Just(prim), - 1 => Just(serde_json::json!({"vec": p_vec})), - 1 => Just(serde_json::json!({"option": p_opt})), - 1 => (1usize..=4).prop_map(move |n| serde_json::json!({"array": [p_arr.clone(), n]})), - 1 => Just(serde_json::json!({"vec": {"option": p_vec_opt}})), - 1 => Just(serde_json::json!({"option": {"vec": p_opt_vec}})), - ] - }) -} - -fn arb_identifier() -> impl Strategy { - "[a-z][a-z0-9]{1,15}" -} - -fn arb_idl_instruction() -> impl Strategy { - ( - arb_identifier(), - prop::collection::vec( - (arb_identifier(), arb_idl_type()) - .prop_map(|(name, ty)| serde_json::json!({"name": name, "type": ty})), - 0..=20, - ), - ) - .prop_map(|(name, args)| serde_json::json!({"name": name, "accounts": [], "args": args})) -} - -fn arb_idl_json() -> impl Strategy { - prop::collection::vec(arb_idl_instruction(), 1..=16).prop_map(|instructions| { - serde_json::json!({"instructions": instructions, "types": []}).to_string() - }) -} // ── Property-based pipeline tests ──────────────────────────────────────────── @@ -364,7 +303,7 @@ proptest! { /// not just the discriminator-matching paths. #[test] fn fuzz_pipeline_never_panics( - idl_json in arb_idl_json(), + idl_json in arb::arb_idl_json(), use_valid_disc in any::(), inst_idx in any::(), data in prop::collection::vec(any::(), 0..1300usize), @@ -392,7 +331,7 @@ proptest! { /// of instructions in the transaction — regardless of valid/invalid discriminator. #[test] fn fuzz_pipeline_field_count_invariant( - idl_json in arb_idl_json(), + idl_json in arb::arb_idl_json(), use_valid_disc in any::(), inst_idx in any::(), data in prop::collection::vec(any::(), 0..1300usize), @@ -424,7 +363,7 @@ proptest! { /// the IDL code path is always taken — title contains "(IDL)". #[test] fn fuzz_pipeline_idl_path_taken_on_valid_discriminator( - idl_json in arb_idl_json(), + idl_json in arb::arb_idl_json(), inst_idx in any::(), arg_bytes in prop::collection::vec(any::(), 0..200usize), ) { From 200af66a5d30e7a1a5678918ff79ffaafe7ce4fa Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Mon, 23 Mar 2026 22:23:40 -0400 Subject: [PATCH 09/20] fix: use solana-parser-fuzz-core directly and pin dep to rev Switch from non-existent `solana_parser::arb` import to `solana_parser_fuzz_core::proptest` (Option B). Pin both solana_parser and solana-parser-fuzz-core git deps to rev a0c554d instead of a floating branch reference, ensuring reproducible builds. Addresses review blockers 1 & 2 (import path mismatch and floating branch dep) from PR #203. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/Cargo.lock | 13 ++++++++++++- src/chain_parsers/visualsign-solana/Cargo.toml | 5 +++-- .../visualsign-solana/tests/fuzz_idl_parsing.rs | 8 ++++---- .../visualsign-solana/tests/pipeline_integration.rs | 2 +- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 189cbdfb..56a9279a 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -9355,6 +9355,16 @@ dependencies = [ "serde_with", ] +[[package]] +name = "solana-parser-fuzz-core" +version = "0.0.0" +source = "git+https://github.com/anchorageoss/solana-parser.git?rev=a0c554d#a0c554d7a4d756cbe6c9bed080737faa9aa74705" +dependencies = [ + "proptest", + "serde_json", + "solana_parser", +] + [[package]] name = "solana-poh-config" version = "2.2.1" @@ -10549,6 +10559,7 @@ dependencies = [ [[package]] name = "solana_parser" version = "0.1.0" +source = "git+https://github.com/anchorageoss/solana-parser.git?rev=a0c554d#a0c554d7a4d756cbe6c9bed080737faa9aa74705" dependencies = [ "bincode", "bs58 0.5.1", @@ -10556,7 +10567,6 @@ dependencies = [ "heck 0.5.0", "hex", "log", - "proptest", "serde", "serde_json", "sha2 0.10.9", @@ -13024,6 +13034,7 @@ dependencies = [ "proptest", "serde", "serde_json", + "solana-parser-fuzz-core", "solana-program", "solana-sdk", "solana-system-interface 1.0.0", diff --git a/src/chain_parsers/visualsign-solana/Cargo.toml b/src/chain_parsers/visualsign-solana/Cargo.toml index 784ff0c5..5a23e8f5 100644 --- a/src/chain_parsers/visualsign-solana/Cargo.toml +++ b/src/chain_parsers/visualsign-solana/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] tracing = { workspace = true } -solana_parser = { git = "https://github.com/anchorageoss/solana-parser.git", branch = "solana-parser-add-arbitrary" } +solana_parser = { git = "https://github.com/anchorageoss/solana-parser.git", rev = "a0c554d" } visualsign = { workspace = true } generated = { path = "../../generated" } serde_json = { workspace = true } @@ -25,7 +25,8 @@ spl-token-2022 = "10.0.0" spl-token-2022-interface = "2.1.0" [dev-dependencies] -solana_parser = { git = "https://github.com/anchorageoss/solana-parser.git", branch = "solana-parser-add-arbitrary", features = ["proptest"] } +solana_parser = { git = "https://github.com/anchorageoss/solana-parser.git", rev = "a0c554d" } +solana-parser-fuzz-core = { git = "https://github.com/anchorageoss/solana-parser.git", rev = "a0c554d", features = ["proptest"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" jupiter-swap-api-client = "0.2.0" diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index 3703518e..b49b0ec1 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -13,7 +13,7 @@ //! More iterations: `PROPTEST_CASES=5000 cargo test --test fuzz_idl_parsing` use proptest::prelude::*; -use solana_parser::arb; +use solana_parser_fuzz_core::proptest as arb; use solana_parser::solana::structs::{ Defined, Idl, IdlField, IdlType, IdlTypeDefinition, IdlTypeDefinitionType, }; @@ -25,9 +25,9 @@ const TEST_PROGRAM_ID: &str = "11111111111111111111111111111111"; // ── Local strategies ───────────────────────────────────────────────────────── // // Core strategies (`arb_identifier`, `arb_primitive_idl_type`, `arb_idl_type`, -// `arb_idl_field`, `arb_idl_instruction`, `arb_idl`, `arb_idl_json`, -// `arb_bytes_for_type`, `arb_valid_instruction_bytes`) live in -// `solana_parser::arb` and are shared with `pipeline_integration.rs`. +// `arb_idl_instruction`, `arb_idl`, `arb_idl_json`, `arb_bytes_for_type`, +// `arb_valid_instruction_bytes`) live in `solana_parser_fuzz_core::proptest` +// (aliased as `arb`) and are shared with `pipeline_integration.rs`. /// IDL JSON with a defined struct type correlated between `types` and instruction args. /// diff --git a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs index 80f12621..335222e1 100644 --- a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs +++ b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs @@ -19,7 +19,7 @@ use std::collections::HashMap; use generated::parser::{ChainMetadata, Idl as ProtoIdl, SolanaMetadata, chain_metadata}; use proptest::prelude::*; -use solana_parser::arb; +use solana_parser_fuzz_core::proptest as arb; use solana_parser::decode_idl_data; use solana_sdk::instruction::{AccountMeta, Instruction}; use solana_sdk::message::Message; From 0f0fd07e0bee2138230f47341513c29cb3f095d7 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Mon, 23 Mar 2026 22:24:20 -0400 Subject: [PATCH 10/20] fix: guard against divide-by-zero when IDL has no instructions Add early return in fuzz_pipeline_idl_path_taken_on_valid_discriminator when idl.instructions is empty, preventing a panic from inst_idx % idl.instructions.len(). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../visualsign-solana/tests/pipeline_integration.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs index 335222e1..67932c30 100644 --- a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs +++ b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs @@ -368,6 +368,7 @@ proptest! { arg_bytes in prop::collection::vec(any::(), 0..200usize), ) { let Ok(idl) = decode_idl_data(&idl_json) else { return Ok(()); }; + if idl.instructions.is_empty() { return Ok(()); } let inst = &idl.instructions[inst_idx % idl.instructions.len()]; let Some(disc) = &inst.discriminator else { return Ok(()); }; From b7ae8566f81de0ce7e940ea00029287dd4efe299 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Mon, 23 Mar 2026 22:32:12 -0400 Subject: [PATCH 11/20] refactor: extract shared test helpers into common module Move duplicated disc+data building logic from pipeline_integration.rs into tests/common/mod.rs (build_disc_data, build_maybe_disc_bytes). Both test files now import the shared module. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../visualsign-solana/tests/common/mod.rs | 44 +++++++++++++++++++ .../tests/fuzz_idl_parsing.rs | 2 + .../tests/pipeline_integration.rs | 32 ++------------ 3 files changed, 50 insertions(+), 28 deletions(-) create mode 100644 src/chain_parsers/visualsign-solana/tests/common/mod.rs diff --git a/src/chain_parsers/visualsign-solana/tests/common/mod.rs b/src/chain_parsers/visualsign-solana/tests/common/mod.rs new file mode 100644 index 00000000..5887fd1e --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/common/mod.rs @@ -0,0 +1,44 @@ +//! Shared test helpers for IDL-based fuzz and integration tests. + +use solana_parser::decode_idl_data; +use solana_parser::solana::structs::Idl; + +/// Decode an IDL JSON string, extract the discriminator for the instruction at +/// `inst_idx`, and return `(idl, data)` where `data` = discriminator ++ `arg_bytes`. +/// +/// Returns `None` if decoding fails, the IDL has no instructions, or the +/// selected instruction has no discriminator. +pub fn build_disc_data( + idl_json: &str, + inst_idx: usize, + arg_bytes: &[u8], +) -> Option<(Idl, Vec)> { + let idl = decode_idl_data(idl_json).ok()?; + if idl.instructions.is_empty() { + return None; + } + let inst = &idl.instructions[inst_idx % idl.instructions.len()]; + let disc = inst.discriminator.as_ref()?; + let mut data = disc.clone(); + data.extend_from_slice(arg_bytes); + Some((idl, data)) +} + +/// Build instruction bytes using a 50/50 valid-discriminator / random-data split. +/// +/// When `use_valid_disc` is true, attempts to prepend a real discriminator from +/// the IDL instruction at `inst_idx`. Falls back to raw `data` if decoding +/// fails, the IDL has no instructions, or the instruction has no discriminator. +pub fn build_maybe_disc_bytes( + idl_json: &str, + use_valid_disc: bool, + inst_idx: usize, + data: Vec, +) -> Vec { + if use_valid_disc { + if let Some((_idl, disc_data)) = build_disc_data(idl_json, inst_idx, &data) { + return disc_data; + } + } + data +} diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index b49b0ec1..943a9e8a 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -12,6 +12,8 @@ //! Run: `cargo test --test fuzz_idl_parsing` //! More iterations: `PROPTEST_CASES=5000 cargo test --test fuzz_idl_parsing` +mod common; + use proptest::prelude::*; use solana_parser_fuzz_core::proptest as arb; use solana_parser::solana::structs::{ diff --git a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs index 67932c30..5186c6af 100644 --- a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs +++ b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs @@ -15,6 +15,8 @@ //! directly and never exercises IdlRegistry, the visualizer dispatch, or the //! SignablePayloadField wrapping. +mod common; + use std::collections::HashMap; use generated::parser::{ChainMetadata, Idl as ProtoIdl, SolanaMetadata, chain_metadata}; @@ -309,20 +311,7 @@ proptest! { data in prop::collection::vec(any::(), 0..1300usize), ) { let program_id = Pubkey::new_unique(); - let bytes = if use_valid_disc { - if let Ok(idl) = decode_idl_data(&idl_json) { - if !idl.instructions.is_empty() { - let inst = &idl.instructions[inst_idx % idl.instructions.len()]; - if let Some(disc) = &inst.discriminator { - let mut d = disc.clone(); - d.extend_from_slice(&data); - d - } else { data } - } else { data } - } else { data } - } else { - data - }; + let bytes = common::build_maybe_disc_bytes(&idl_json, use_valid_disc, inst_idx, data); let tx = build_transaction(program_id, vec![], bytes); let _ = transaction_to_visual_sign(tx, options_with_idl(&program_id, &idl_json, "F")); } @@ -337,20 +326,7 @@ proptest! { data in prop::collection::vec(any::(), 0..1300usize), ) { let program_id = Pubkey::new_unique(); - let bytes = if use_valid_disc { - if let Ok(idl) = decode_idl_data(&idl_json) { - if !idl.instructions.is_empty() { - let inst = &idl.instructions[inst_idx % idl.instructions.len()]; - if let Some(disc) = &inst.discriminator { - let mut d = disc.clone(); - d.extend_from_slice(&data); - d - } else { data } - } else { data } - } else { data } - } else { - data - }; + let bytes = common::build_maybe_disc_bytes(&idl_json, use_valid_disc, inst_idx, data); let tx = build_transaction(program_id, vec![], bytes); let inst_count = tx.message.instructions.len(); let options = options_with_idl(&program_id, &idl_json, "F"); From 8d42d99deff44ae480ced2577506e01e2278416b Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Mon, 23 Mar 2026 22:46:40 -0400 Subject: [PATCH 12/20] test: add enum type coverage for defined-type fuzz testing Add arb_defined_enum_idl_json() strategy and fuzz_defined_enum_types_never_panics proptest that exercises unit, tuple, and named enum variants through the Defined type resolution path. Previously only Struct variants were covered. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/fuzz_idl_parsing.rs | 84 ++++++++++++++++++- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index 943a9e8a..7d8853b3 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -12,12 +12,11 @@ //! Run: `cargo test --test fuzz_idl_parsing` //! More iterations: `PROPTEST_CASES=5000 cargo test --test fuzz_idl_parsing` -mod common; - use proptest::prelude::*; use solana_parser_fuzz_core::proptest as arb; use solana_parser::solana::structs::{ - Defined, Idl, IdlField, IdlType, IdlTypeDefinition, IdlTypeDefinitionType, + Defined, EnumFields, Idl, IdlEnumVariant, IdlField, IdlType, IdlTypeDefinition, + IdlTypeDefinitionType, }; use solana_parser::{decode_idl_data, parse_instruction_with_idl}; use std::sync::Arc; @@ -62,6 +61,55 @@ fn arb_defined_struct_idl_json() -> impl Strategy { }) } +/// IDL JSON with a defined enum type correlated between `types` and instruction args. +/// +/// Generates enums with a mix of unit, tuple, and named (struct-like) variants, +/// exercising the `Defined` → `Enum` type resolution path. +fn arb_defined_enum_idl_json() -> impl Strategy { + ( + arb::arb_identifier(), + prop::collection::vec( + ( + arb::arb_identifier(), + prop::option::of(prop::bool::ANY.prop_flat_map(|use_named| { + if use_named { + prop::collection::vec( + (arb::arb_identifier(), arb::arb_primitive_idl_type()) + .prop_map(|(n, t)| IdlField { name: n, r#type: t }), + 1..=4, + ) + .prop_map(|fields| EnumFields::Named(fields)) + .boxed() + } else { + prop::collection::vec(arb::arb_primitive_idl_type(), 1..=4) + .prop_map(|types| EnumFields::Tuple(types)) + .boxed() + } + })), + ) + .prop_map(|(name, fields)| IdlEnumVariant { name, fields }), + 1..=6, + ), + arb::arb_idl_instruction(), + prop::collection::vec(arb::arb_idl_instruction(), 0..=4), + ) + .prop_map(|(enum_name, variants, mut main_inst, mut extra_insts)| { + main_inst.args = vec![IdlField { + name: "data".to_string(), + r#type: IdlType::Defined(Defined::String(enum_name.clone())), + }]; + extra_insts.push(main_inst); + let idl = Idl { + instructions: extra_insts, + types: vec![IdlTypeDefinition { + name: enum_name, + r#type: IdlTypeDefinitionType::Enum { variants }, + }], + }; + serde_json::to_string(&idl).unwrap() + }) +} + /// IDL JSON where the single instruction has a `Vec` arg. /// /// Used to stress-test the SizeGuard, which guards against large length-prefix @@ -211,6 +259,36 @@ proptest! { } } + /// IDLs with defined enum types must not panic regardless of instruction bytes. + /// Uses the same 50/50 valid-discriminator mix as the struct variant test. + /// + /// Exercises unit, tuple, and named enum variants through the `Defined` → + /// `Enum` type resolution path. + #[test] + fn fuzz_defined_enum_types_never_panics( + idl_json in arb_defined_enum_idl_json(), + use_valid_disc in any::(), + inst_idx in any::(), + data in prop::collection::vec(any::(), 0..200usize), + ) { + if let Ok(idl) = decode_idl_data(&idl_json) { + if use_valid_disc && !idl.instructions.is_empty() { + let inst = &idl.instructions[inst_idx % idl.instructions.len()]; + let expected_name = inst.name.clone(); + if let Some(disc) = &inst.discriminator { + let mut d = disc.clone(); + d.extend_from_slice(&data); + if let Ok(result) = parse_instruction_with_idl(&d, TEST_PROGRAM_ID, &idl) { + prop_assert_eq!(&result.instruction_name, &expected_name, + "enum-type instruction must dispatch to the correct handler"); + } + } + } else { + let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + } + } + } + /// Valid input must always parse successfully. /// /// Unlike the other crash-safety tests, this one asserts `result.is_ok()` — From ec1870bef2d5229b251989919b89ef05d87ae76e Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Tue, 24 Mar 2026 09:25:23 -0400 Subject: [PATCH 13/20] docs: add property-based testing and roundtrip test documentation Add a "Property-based testing (Solana)" section to testing-visualizations.mdx covering fuzz test execution, real-IDL testing with fuzz_all_idls.sh, roundtrip test types (concrete vs property-based), and how to add new tests. Add an "Adding new tests" section to the fuzz_idl_parsing.rs module doc so the file is self-documenting for contributors. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../testing-visualizations.mdx | 51 +++++++++++++++++++ .../tests/fuzz_idl_parsing.rs | 14 +++++ 2 files changed, 65 insertions(+) diff --git a/docs/contributor-guides/testing-visualizations.mdx b/docs/contributor-guides/testing-visualizations.mdx index 664798b9..8f92a016 100644 --- a/docs/contributor-guides/testing-visualizations.mdx +++ b/docs/contributor-guides/testing-visualizations.mdx @@ -126,6 +126,57 @@ Verify your fixture passes: cargo test -p visualsign- test_my_protocol ``` +## Property-based testing (Solana) + +Solana IDL parsing includes [proptest](https://proptest-rs.github.io/proptest/)-based fuzz tests that verify crash safety and correctness across randomly generated IDLs and instruction data. These tests live in: + +- `src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs` — parser-level fuzz and roundtrip tests +- `src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs` — full-pipeline integration tests + +### Running fuzz tests + +```bash +# Default 256 cases per property +cargo test -p visualsign-solana --test fuzz_idl_parsing + +# More iterations for deeper fuzzing +PROPTEST_CASES=5000 cargo test -p visualsign-solana --test fuzz_idl_parsing +``` + +### Testing against real IDLs + +The `scripts/fuzz_all_idls.sh` script runs fuzz tests against all embedded production IDLs in one pass: + +```bash +./scripts/fuzz_all_idls.sh +``` + +You can also target a specific IDL: + +```bash +IDL_FILE=/path/to/my_program.json cargo test -p visualsign-solana --test fuzz_idl_parsing real_idl +``` + +### Roundtrip tests + +A roundtrip test constructs an IDL and matching borsh-encoded instruction bytes, feeds them through the parser, and verifies the output matches expectations. "Roundtrip" refers to the encode-then-decode cycle: you know exactly what went in, so you can assert exactly what comes out. + +There are two kinds in use: + +- **Concrete roundtrips** (e.g., `roundtrip_single_u64_arg`) — Hand-crafted IDL JSON and hand-crafted byte payloads. These assert that specific parsed values match exactly (e.g., `amount == 42`). They serve as specification-by-example: each test documents one type scenario (no args, mixed primitives, `Option`, `Vec`, defined structs, multi-instruction dispatch). + +- **Property-based roundtrips** (e.g., `fuzz_valid_data_always_parses_ok`) — Randomly generated IDL shapes paired with machine-generated valid borsh bytes from `arb_valid_instruction_bytes`. These assert that parsing succeeds and the instruction name matches, without checking specific field values. They verify the parser's contract holds across all type combinations, not just the hand-picked examples. + +Both kinds complement each other: concrete roundtrips pin down known-good behavior, while property-based roundtrips explore the space of inputs you did not think to write by hand. + +### Adding a new test + +1. Write a strategy that generates the IDL shape you want to test (or use an existing one from `solana_parser_fuzz_core::proptest`) +2. Add a `proptest!` test that exercises the parser with generated inputs +3. Add a concrete roundtrip test for the same scenario to serve as specification-by-example +4. Run the tests — if proptest finds a failure, it saves a regression seed to `.proptest-regressions` +5. Commit the `.proptest-regressions` file so the failing case is reproduced in CI + ## Validation checklist Before submitting your visualization: diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index 7d8853b3..2c2a7fae 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -11,6 +11,20 @@ //! //! Run: `cargo test --test fuzz_idl_parsing` //! More iterations: `PROPTEST_CASES=5000 cargo test --test fuzz_idl_parsing` +//! +//! # Adding new tests +//! +//! 1. **Write a strategy** for the IDL shape you want to cover (see +//! `arb_defined_struct_idl_json` / `arb_defined_enum_idl_json` for examples), +//! or reuse one from `solana_parser_fuzz_core::proptest`. +//! 2. **Add a proptest** in the `proptest!` block — use the 50/50 valid-disc / +//! random-data pattern for crash-safety, or `arb_idl_and_valid_bytes` for +//! correctness assertions. +//! 3. **Add a concrete roundtrip test** that hand-crafts an IDL + borsh bytes +//! and asserts exact parsed values. This pins the behavior as +//! specification-by-example. +//! 4. **Run tests** — proptest saves any failing seed to +//! `fuzz_idl_parsing.proptest-regressions`. Commit that file. use proptest::prelude::*; use solana_parser_fuzz_core::proptest as arb; From 9691660c38d6ef9fea21c81cc1c6e0d846c602b9 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Tue, 24 Mar 2026 09:38:09 -0400 Subject: [PATCH 14/20] test: add nested defined struct roundtrip test Add roundtrip_nested_defined_struct covering the case where a struct field references another defined struct (Order containing AssetConfig), exercising recursive type resolution through the types array. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/fuzz_idl_parsing.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index 2c2a7fae..7ee05476 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -598,6 +598,50 @@ fn roundtrip_defined_struct_arg() { assert_eq!(params["side"], serde_json::json!(true)); } +/// An instruction whose arg is a defined struct containing a field that +/// references another defined struct — exercises recursive type resolution. +#[test] +fn roundtrip_nested_defined_struct() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "placeOrder", "accounts": [], "args": [ + {"name": "order", "type": {"defined": "Order"}} + ]}], + "types": [ + { + "name": "Order", + "type": {"kind": "struct", "fields": [ + {"name": "amount", "type": "u64"}, + {"name": "config", "type": {"defined": "AssetConfig"}}, + ]} + }, + { + "name": "AssetConfig", + "type": {"kind": "struct", "fields": [ + {"name": "decimals", "type": "u8"}, + {"name": "active", "type": "bool"}, + ]} + }, + ] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let mut data = disc.clone(); + data.extend_from_slice(&7500u64.to_le_bytes()); // order.amount + data.push(6u8); // order.config.decimals + data.push(1u8); // order.config.active = true + + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.instruction_name, "placeOrder"); + let order = &result.program_call_args["order"]; + assert_eq!(order["amount"], serde_json::json!(7500)); + let config = &order["config"]; + assert_eq!(config["decimals"], serde_json::json!(6)); + assert_eq!(config["active"], serde_json::json!(true)); +} + // ── SizeGuard boundary tests ────────────────────────────────────────────────── /// A Vec arg with a length prefix that vastly exceeds the backing data From 2d230d8df5b53819ff5c2fcf7ecdc7168408bf66 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Tue, 24 Mar 2026 10:30:21 -0400 Subject: [PATCH 15/20] fix: use fake program ID and fix fuzz_all_idls.sh parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace system program ID (111...1) with zeroes for TEST_PROGRAM_ID since parse_instruction_with_idl ignores it (_program_id). Avoids confusion with real known programs. Fix dead first branch in fuzz_all_idls.sh failed_count parsing — the summary line starts with the passed count so ^[0-9]+ failed never matches. Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/fuzz_all_idls.sh | 3 +-- src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/fuzz_all_idls.sh b/scripts/fuzz_all_idls.sh index 0e7d7c3c..7a0a123c 100755 --- a/scripts/fuzz_all_idls.sh +++ b/scripts/fuzz_all_idls.sh @@ -122,8 +122,7 @@ except Exception: FAIL=$(( FAIL + 1 )) FAILED_IDLS+=("$name ($idl_file)") else - failed_count=$(echo "$summary" | grep -oE "^[0-9]+ failed" | grep -oE "^[0-9]+" || \ - echo "$summary" | grep -oE "[0-9]+ failed" | grep -oE "[0-9]+") + failed_count=$(echo "$summary" | grep -oE "[0-9]+ failed" | grep -oE "[0-9]+") if [ "${failed_count:-0}" -gt 0 ]; then echo "FAIL ($summary)" FAIL=$(( FAIL + 1 )) diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index 7ee05476..37ec295a 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -35,7 +35,9 @@ use solana_parser::solana::structs::{ use solana_parser::{decode_idl_data, parse_instruction_with_idl}; use std::sync::Arc; -const TEST_PROGRAM_ID: &str = "11111111111111111111111111111111"; +// parse_instruction_with_idl ignores the program_id parameter (_program_id); +// use an obviously fake value to avoid confusion with real known programs. +const TEST_PROGRAM_ID: &str = "00000000000000000000000000000000"; // ── Local strategies ───────────────────────────────────────────────────────── // From 61aa7ca7c08ce15b867986f41551d9dce2c0a51a Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Tue, 24 Mar 2026 10:40:40 -0400 Subject: [PATCH 16/20] test: add alias type coverage, container fields in defined types, and dangling ref test - Add arb_defined_alias_idl_json() strategy and fuzz_defined_alias_types_never_panics proptest for IdlTypeDefinitionType::Alias coverage. - Upgrade arb_defined_struct_idl_json and arb_defined_enum_idl_json to use arb_idl_type() instead of arb_primitive_idl_type() for inner fields, so container types (Vec, Option, Array) are exercised inside defined types. - Add dangling_defined_reference_returns_err test verifying that a Defined("MissingType") reference not in the types array returns Err at parse time (decode_idl_data does not catch this upfront). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/fuzz_idl_parsing.rs | 94 ++++++++++++++++++- 1 file changed, 91 insertions(+), 3 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index 37ec295a..a91424fb 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -49,11 +49,13 @@ const TEST_PROGRAM_ID: &str = "00000000000000000000000000000000"; /// IDL JSON with a defined struct type correlated between `types` and instruction args. /// /// Exercises the `Defined` type resolution path through `types`. +/// Fields use `arb_idl_type()` (not just primitives), so container types +/// like `Vec`, `Option`, and `Array` appear inside the struct. fn arb_defined_struct_idl_json() -> impl Strategy { ( arb::arb_identifier(), prop::collection::vec( - (arb::arb_identifier(), arb::arb_primitive_idl_type()) + (arb::arb_identifier(), arb::arb_idl_type()) .prop_map(|(n, t)| IdlField { name: n, r#type: t }), 1..=8, ), @@ -81,6 +83,7 @@ fn arb_defined_struct_idl_json() -> impl Strategy { /// /// Generates enums with a mix of unit, tuple, and named (struct-like) variants, /// exercising the `Defined` → `Enum` type resolution path. +/// Variant fields use `arb_idl_type()` so containers appear inside variants. fn arb_defined_enum_idl_json() -> impl Strategy { ( arb::arb_identifier(), @@ -90,14 +93,14 @@ fn arb_defined_enum_idl_json() -> impl Strategy { prop::option::of(prop::bool::ANY.prop_flat_map(|use_named| { if use_named { prop::collection::vec( - (arb::arb_identifier(), arb::arb_primitive_idl_type()) + (arb::arb_identifier(), arb::arb_idl_type()) .prop_map(|(n, t)| IdlField { name: n, r#type: t }), 1..=4, ) .prop_map(|fields| EnumFields::Named(fields)) .boxed() } else { - prop::collection::vec(arb::arb_primitive_idl_type(), 1..=4) + prop::collection::vec(arb::arb_idl_type(), 1..=4) .prop_map(|types| EnumFields::Tuple(types)) .boxed() } @@ -126,6 +129,34 @@ fn arb_defined_enum_idl_json() -> impl Strategy { }) } +/// IDL JSON with a defined alias type (a named wrapper around another type). +/// +/// Exercises the `Defined` → `Alias` type resolution path. The alias value +/// uses `arb_idl_type()` so it can be a primitive, Vec, Option, or Array. +fn arb_defined_alias_idl_json() -> impl Strategy { + ( + arb::arb_identifier(), + arb::arb_idl_type(), + arb::arb_idl_instruction(), + prop::collection::vec(arb::arb_idl_instruction(), 0..=4), + ) + .prop_map(|(alias_name, alias_type, mut main_inst, mut extra_insts)| { + main_inst.args = vec![IdlField { + name: "data".to_string(), + r#type: IdlType::Defined(Defined::String(alias_name.clone())), + }]; + extra_insts.push(main_inst); + let idl = Idl { + instructions: extra_insts, + types: vec![IdlTypeDefinition { + name: alias_name, + r#type: IdlTypeDefinitionType::Alias { value: alias_type }, + }], + }; + serde_json::to_string(&idl).unwrap() + }) +} + /// IDL JSON where the single instruction has a `Vec` arg. /// /// Used to stress-test the SizeGuard, which guards against large length-prefix @@ -305,6 +336,35 @@ proptest! { } } + /// IDLs with defined alias types must not panic regardless of instruction bytes. + /// Uses the same 50/50 valid-discriminator mix as the struct/enum tests. + /// + /// An alias is a named wrapper around another type (e.g., `type Amount = u64`). + #[test] + fn fuzz_defined_alias_types_never_panics( + idl_json in arb_defined_alias_idl_json(), + use_valid_disc in any::(), + inst_idx in any::(), + data in prop::collection::vec(any::(), 0..200usize), + ) { + if let Ok(idl) = decode_idl_data(&idl_json) { + if use_valid_disc && !idl.instructions.is_empty() { + let inst = &idl.instructions[inst_idx % idl.instructions.len()]; + let expected_name = inst.name.clone(); + if let Some(disc) = &inst.discriminator { + let mut d = disc.clone(); + d.extend_from_slice(&data); + if let Ok(result) = parse_instruction_with_idl(&d, TEST_PROGRAM_ID, &idl) { + prop_assert_eq!(&result.instruction_name, &expected_name, + "alias-type instruction must dispatch to the correct handler"); + } + } + } else { + let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + } + } + } + /// Valid input must always parse successfully. /// /// Unlike the other crash-safety tests, this one asserts `result.is_ok()` — @@ -644,6 +704,34 @@ fn roundtrip_nested_defined_struct() { assert_eq!(config["active"], serde_json::json!(true)); } +// ── Error-path tests ───────────────────────────────────────────────────────── + +/// An instruction arg that references a `Defined("MissingType")` not present in +/// the `types` array must produce `Err`, not panic. +/// +/// Note: `decode_idl_data` does NOT validate that instruction-arg Defined +/// references exist in `types` — the error only surfaces at parse time. +#[test] +fn dangling_defined_reference_returns_err() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "broken", "accounts": [], "args": [ + {"name": "data", "type": {"defined": "MissingType"}} + ]}], + "types": [] + }) + .to_string(); + + // IDL loads successfully — dangling ref is not caught at load time. + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let mut data = disc.clone(); + data.extend_from_slice(&[0u8; 16]); // arbitrary payload + + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + assert!(result.is_err(), "expected Err for dangling Defined reference, got Ok"); +} + // ── SizeGuard boundary tests ────────────────────────────────────────────────── /// A Vec arg with a length prefix that vastly exceeds the backing data From 2452a8d6af0fbf5a20e94e8db809d99b9685f613 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Tue, 24 Mar 2026 10:43:25 -0400 Subject: [PATCH 17/20] style: apply rustfmt and fix clippy warnings Run cargo fmt and replace redundant closures with function references (EnumFields::Named, EnumFields::Tuple) per clippy::redundant_closure. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/fuzz_idl_parsing.rs | 118 ++++++++++-------- .../tests/pipeline_integration.rs | 75 +++++++---- 2 files changed, 119 insertions(+), 74 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index a91424fb..ed67a495 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -27,12 +27,12 @@ //! `fuzz_idl_parsing.proptest-regressions`. Commit that file. use proptest::prelude::*; -use solana_parser_fuzz_core::proptest as arb; use solana_parser::solana::structs::{ Defined, EnumFields, Idl, IdlEnumVariant, IdlField, IdlType, IdlTypeDefinition, IdlTypeDefinitionType, }; use solana_parser::{decode_idl_data, parse_instruction_with_idl}; +use solana_parser_fuzz_core::proptest as arb; use std::sync::Arc; // parse_instruction_with_idl ignores the program_id parameter (_program_id); @@ -62,21 +62,21 @@ fn arb_defined_struct_idl_json() -> impl Strategy { arb::arb_idl_instruction(), prop::collection::vec(arb::arb_idl_instruction(), 0..=4), ) - .prop_map(|(struct_name, fields, mut main_inst, mut extra_insts)| { - main_inst.args = vec![IdlField { - name: "data".to_string(), - r#type: IdlType::Defined(Defined::String(struct_name.clone())), - }]; - extra_insts.push(main_inst); - let idl = Idl { - instructions: extra_insts, - types: vec![IdlTypeDefinition { - name: struct_name, - r#type: IdlTypeDefinitionType::Struct { fields }, - }], - }; - serde_json::to_string(&idl).unwrap() - }) + .prop_map(|(struct_name, fields, mut main_inst, mut extra_insts)| { + main_inst.args = vec![IdlField { + name: "data".to_string(), + r#type: IdlType::Defined(Defined::String(struct_name.clone())), + }]; + extra_insts.push(main_inst); + let idl = Idl { + instructions: extra_insts, + types: vec![IdlTypeDefinition { + name: struct_name, + r#type: IdlTypeDefinitionType::Struct { fields }, + }], + }; + serde_json::to_string(&idl).unwrap() + }) } /// IDL JSON with a defined enum type correlated between `types` and instruction args. @@ -97,11 +97,11 @@ fn arb_defined_enum_idl_json() -> impl Strategy { .prop_map(|(n, t)| IdlField { name: n, r#type: t }), 1..=4, ) - .prop_map(|fields| EnumFields::Named(fields)) + .prop_map(EnumFields::Named) .boxed() } else { prop::collection::vec(arb::arb_idl_type(), 1..=4) - .prop_map(|types| EnumFields::Tuple(types)) + .prop_map(EnumFields::Tuple) .boxed() } })), @@ -169,7 +169,10 @@ fn arb_vec_arg_idl_json() -> impl Strategy { name: "data".to_string(), r#type: IdlType::Vec(Box::new(elem_type)), }]; - let idl = Idl { instructions: vec![inst], types: vec![] }; + let idl = Idl { + instructions: vec![inst], + types: vec![], + }; serde_json::to_string(&idl).unwrap() }) }) @@ -185,15 +188,12 @@ fn arb_idl_and_valid_bytes() -> impl Strategy)> { let types = Arc::new(idl.types.clone()); let instructions = idl.instructions.clone(); let idl_owned = idl.clone(); - (0..n) - .prop_flat_map(move |inst_idx| { - let byte_strat = arb::arb_valid_instruction_bytes( - &instructions[inst_idx], - types.clone(), - ); - let idl_c = idl_owned.clone(); - byte_strat.prop_map(move |bytes| (idl_c.clone(), inst_idx, bytes)) - }) + (0..n).prop_flat_map(move |inst_idx| { + let byte_strat = + arb::arb_valid_instruction_bytes(&instructions[inst_idx], types.clone()); + let idl_c = idl_owned.clone(); + byte_strat.prop_map(move |bytes| (idl_c.clone(), inst_idx, bytes)) + }) }) } @@ -475,16 +475,19 @@ fn roundtrip_mixed_primitive_args() { let mut data = disc.clone(); data.extend_from_slice(&1000u64.to_le_bytes()); // amountIn - data.extend_from_slice(&900u64.to_le_bytes()); // minOut - data.extend_from_slice(&50u16.to_le_bytes()); // slippage - data.push(1u8); // isExact = true + data.extend_from_slice(&900u64.to_le_bytes()); // minOut + data.extend_from_slice(&50u16.to_le_bytes()); // slippage + data.push(1u8); // isExact = true let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); assert_eq!(result.instruction_name, "swap"); - assert_eq!(result.program_call_args["amountIn"], serde_json::json!(1000)); - assert_eq!(result.program_call_args["minOut"], serde_json::json!(900)); + assert_eq!( + result.program_call_args["amountIn"], + serde_json::json!(1000) + ); + assert_eq!(result.program_call_args["minOut"], serde_json::json!(900)); assert_eq!(result.program_call_args["slippage"], serde_json::json!(50)); - assert_eq!(result.program_call_args["isExact"], serde_json::json!(true)); + assert_eq!(result.program_call_args["isExact"], serde_json::json!(true)); } #[test] @@ -501,7 +504,7 @@ fn roundtrip_option_some() { let disc = idl.instructions[0].discriminator.as_ref().unwrap(); let mut data = disc.clone(); - data.push(1u8); // Some + data.push(1u8); // Some data.extend_from_slice(&300u16.to_le_bytes()); let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); @@ -618,7 +621,7 @@ fn roundtrip_multiple_instructions_distinct_dispatch() { let r = parse_instruction_with_idl(&data2, TEST_PROGRAM_ID, &idl).unwrap(); assert_eq!(r.instruction_name, "withdraw"); assert_eq!(r.program_call_args["amount"], serde_json::json!(50)); - assert_eq!(r.program_call_args["all"], serde_json::json!(false)); + assert_eq!(r.program_call_args["all"], serde_json::json!(false)); } // ── Defined type (struct) roundtrip tests ──────────────────────────────────── @@ -647,17 +650,17 @@ fn roundtrip_defined_struct_arg() { let mut data = disc.clone(); data.extend_from_slice(&5000u64.to_le_bytes()); // price - data.extend_from_slice(&10u32.to_le_bytes()); // quantity - data.push(1u8); // side = buy + data.extend_from_slice(&10u32.to_le_bytes()); // quantity + data.push(1u8); // side = buy // Must parse and return Ok with the struct contents. let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); assert_eq!(result.instruction_name, "createOrder"); // Struct fields are nested under the "params" key. let params = &result.program_call_args["params"]; - assert_eq!(params["price"], serde_json::json!(5000)); + assert_eq!(params["price"], serde_json::json!(5000)); assert_eq!(params["quantity"], serde_json::json!(10)); - assert_eq!(params["side"], serde_json::json!(true)); + assert_eq!(params["side"], serde_json::json!(true)); } /// An instruction whose arg is a defined struct containing a field that @@ -692,8 +695,8 @@ fn roundtrip_nested_defined_struct() { let mut data = disc.clone(); data.extend_from_slice(&7500u64.to_le_bytes()); // order.amount - data.push(6u8); // order.config.decimals - data.push(1u8); // order.config.active = true + data.push(6u8); // order.config.decimals + data.push(1u8); // order.config.active = true let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); assert_eq!(result.instruction_name, "placeOrder"); @@ -701,7 +704,7 @@ fn roundtrip_nested_defined_struct() { assert_eq!(order["amount"], serde_json::json!(7500)); let config = &order["config"]; assert_eq!(config["decimals"], serde_json::json!(6)); - assert_eq!(config["active"], serde_json::json!(true)); + assert_eq!(config["active"], serde_json::json!(true)); } // ── Error-path tests ───────────────────────────────────────────────────────── @@ -729,7 +732,10 @@ fn dangling_defined_reference_returns_err() { data.extend_from_slice(&[0u8; 16]); // arbitrary payload let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); - assert!(result.is_err(), "expected Err for dangling Defined reference, got Ok"); + assert!( + result.is_err(), + "expected Err for dangling Defined reference, got Ok" + ); } // ── SizeGuard boundary tests ────────────────────────────────────────────────── @@ -757,7 +763,10 @@ fn size_guard_huge_vec_length_prefix_is_rejected_cleanly() { let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); // Must be Err, not a panic or OOM. - assert!(result.is_err(), "expected Err for over-budget Vec length, got Ok"); + assert!( + result.is_err(), + "expected Err for over-budget Vec length, got Ok" + ); } /// Same as above but with a Vec (8 bytes/element) — smaller element count @@ -780,7 +789,10 @@ fn size_guard_vec_u64_over_budget() { data.extend_from_slice(&100_000u32.to_le_bytes()); let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); - assert!(result.is_err(), "expected Err for over-budget Vec length"); + assert!( + result.is_err(), + "expected Err for over-budget Vec length" + ); } // ── Real-IDL property tests (driven by IDL_FILE env var) ───────────────────── @@ -795,8 +807,7 @@ fn size_guard_vec_u64_over_budget() { fn load_idl_from_env() -> Option<(String, solana_parser::solana::structs::Idl)> { let path = std::env::var("IDL_FILE").ok()?; - let json = std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("IDL_FILE={path}: {e}")); + let json = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("IDL_FILE={path}: {e}")); match decode_idl_data(&json) { Ok(idl) => Some((json, idl)), Err(e) => { @@ -851,9 +862,13 @@ proptest! { /// expected instruction name. #[test] fn real_idl_valid_data_always_parses_ok() { - let Some((_, idl)) = load_idl_from_env() else { return; }; + let Some((_, idl)) = load_idl_from_env() else { + return; + }; let n = idl.instructions.len(); - if n == 0 { return; } + if n == 0 { + return; + } let types = Arc::new(idl.types.clone()); let instructions = idl.instructions.clone(); @@ -872,7 +887,8 @@ fn real_idl_valid_data_always_parses_ok() { let result = parse_instruction_with_idl(&bytes, TEST_PROGRAM_ID, &idl_ref); prop_assert!( result.is_ok(), - "instruction '{expected}' rejected correctly-encoded input: {:?}", result + "instruction '{expected}' rejected correctly-encoded input: {:?}", + result ); prop_assert_eq!(&result.unwrap().instruction_name, expected); Ok(()) diff --git a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs index 5186c6af..81505784 100644 --- a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs +++ b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs @@ -21,21 +21,25 @@ use std::collections::HashMap; use generated::parser::{ChainMetadata, Idl as ProtoIdl, SolanaMetadata, chain_metadata}; use proptest::prelude::*; -use solana_parser_fuzz_core::proptest as arb; use solana_parser::decode_idl_data; +use solana_parser_fuzz_core::proptest as arb; use solana_sdk::instruction::{AccountMeta, Instruction}; use solana_sdk::message::Message; use solana_sdk::pubkey::Pubkey; use solana_sdk::transaction::Transaction as SolanaTransaction; +use visualsign::vsptrait::VisualSignOptions; use visualsign::{ AnnotatedPayloadField, SignablePayload, SignablePayloadField, SignablePayloadFieldPreviewLayout, }; -use visualsign::vsptrait::VisualSignOptions; use visualsign_solana::transaction_to_visual_sign; // ── Transaction builders ────────────────────────────────────────────────────── -fn build_transaction(program_id: Pubkey, extra_accounts: Vec, data: Vec) -> SolanaTransaction { +fn build_transaction( + program_id: Pubkey, + extra_accounts: Vec, + data: Vec, +) -> SolanaTransaction { let fee_payer = Pubkey::new_unique(); let account_metas: Vec = extra_accounts .iter() @@ -98,14 +102,22 @@ fn options_no_idl() -> VisualSignOptions { /// Returns the PreviewLayout for every instruction field in the payload. /// Instruction fields have label "Instruction N"; the Accounts summary uses "Accounts". fn instruction_fields(payload: &SignablePayload) -> Vec<&SignablePayloadFieldPreviewLayout> { - payload.fields.iter().filter_map(|f| { - if let SignablePayloadField::PreviewLayout { common, preview_layout } = f { - if common.label.starts_with("Instruction") { - return Some(preview_layout); + payload + .fields + .iter() + .filter_map(|f| { + if let SignablePayloadField::PreviewLayout { + common, + preview_layout, + } = f + { + if common.label.starts_with("Instruction") { + return Some(preview_layout); + } } - } - None - }).collect() + None + }) + .collect() } /// Searches a flat slice of AnnotatedPayloadFields for a TextV2 field with the given label. @@ -133,7 +145,8 @@ fn pipeline_idl_path_correct_data() { {"name": "amount", "type": "u64"} ]}], "types": [] - }).to_string(); + }) + .to_string(); let idl = decode_idl_data(&idl_json).unwrap(); let disc = idl.instructions[0].discriminator.as_ref().unwrap(); @@ -144,7 +157,8 @@ fn pipeline_idl_path_correct_data() { let payload = transaction_to_visual_sign( build_transaction(program_id, vec![], data), options_with_idl(&program_id, &idl_json, "My Program"), - ).unwrap(); + ) + .unwrap(); let inst_fields = instruction_fields(&payload); assert_eq!(inst_fields.len(), 1); @@ -154,7 +168,10 @@ fn pipeline_idl_path_correct_data() { assert!(title.contains("(IDL)"), "expected IDL title, got: {title}"); let condensed = layout.condensed.as_ref().unwrap(); - assert_eq!(find_text(&condensed.fields, "Instruction"), Some("deposit".into())); + assert_eq!( + find_text(&condensed.fields, "Instruction"), + Some("deposit".into()) + ); assert_eq!(find_text(&condensed.fields, "amount"), Some("42".into())); } @@ -167,7 +184,8 @@ fn pipeline_idl_discriminator_miss() { let idl_json = serde_json::json!({ "instructions": [{"name": "deposit", "accounts": [], "args": []}], "types": [] - }).to_string(); + }) + .to_string(); // Discriminator that will never match "deposit" let data = vec![0xde, 0xad, 0xbe, 0xef, 0x00, 0x01, 0x02, 0x03]; @@ -175,7 +193,8 @@ fn pipeline_idl_discriminator_miss() { let payload = transaction_to_visual_sign( build_transaction(program_id, vec![], data), options_with_idl(&program_id, &idl_json, "My Program"), - ).unwrap(); + ) + .unwrap(); let inst_fields = instruction_fields(&payload); let layout = inst_fields[0]; @@ -201,7 +220,8 @@ fn pipeline_no_idl_registered() { let payload = transaction_to_visual_sign( build_transaction(program_id, vec![], vec![1, 2, 3]), options_no_idl(), - ).unwrap(); + ) + .unwrap(); let inst_fields = instruction_fields(&payload); let layout = inst_fields[0]; @@ -223,7 +243,8 @@ fn pipeline_named_accounts() { "args": [] }], "types": [] - }).to_string(); + }) + .to_string(); let idl = decode_idl_data(&idl_json).unwrap(); let disc = idl.instructions[0].discriminator.as_ref().unwrap(); @@ -231,7 +252,8 @@ fn pipeline_named_accounts() { let payload = transaction_to_visual_sign( build_transaction(program_id, vec![depositor], disc.clone()), options_with_idl(&program_id, &idl_json, "Test Program"), - ).unwrap(); + ) + .unwrap(); let inst_fields = instruction_fields(&payload); let expanded = inst_fields[0].expanded.as_ref().unwrap(); @@ -267,7 +289,8 @@ fn pipeline_multi_instruction_mixed_programs() { let idl_json = serde_json::json!({ "instructions": [{"name": "swap", "accounts": [], "args": []}], "types": [] - }).to_string(); + }) + .to_string(); let idl = decode_idl_data(&idl_json).unwrap(); let disc_a = idl.instructions[0].discriminator.as_ref().unwrap().clone(); @@ -277,20 +300,26 @@ fn pipeline_multi_instruction_mixed_programs() { (program_b, vec![0xde, 0xad]), ]); - let payload = transaction_to_visual_sign(tx, options_with_idl(&program_a, &idl_json, "A")).unwrap(); + let payload = + transaction_to_visual_sign(tx, options_with_idl(&program_a, &idl_json, "A")).unwrap(); let inst_fields = instruction_fields(&payload); assert_eq!(inst_fields.len(), 2); let title_a = inst_fields[0].title.as_ref().unwrap().text.as_str(); - assert!(title_a.contains("(IDL)"), "program_a has IDL, got: {title_a}"); + assert!( + title_a.contains("(IDL)"), + "program_a has IDL, got: {title_a}" + ); let title_b = inst_fields[1].title.as_ref().unwrap().text.as_str(); - assert!(!title_b.contains("(IDL)"), "program_b has no IDL, got: {title_b}"); + assert!( + !title_b.contains("(IDL)"), + "program_b has no IDL, got: {title_b}" + ); assert_eq!(title_b, program_b.to_string()); } - // ── Property-based pipeline tests ──────────────────────────────────────────── proptest! { From 8092c3ac9f1a15f7cfdfe2353399ca2b2d562150 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Tue, 24 Mar 2026 10:57:47 -0400 Subject: [PATCH 18/20] chore: remove duplicate dev-dep and stale gitignore entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove redundant solana_parser entry from [dev-dependencies] — it is already declared in [dependencies] and visible to integration tests. Avoids rev-skew risk if one is bumped without the other. Remove src/.cargo/ from .gitignore — the directory does not exist and is no longer needed now that the git dep is pinned to a rev instead of a floating branch. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 1 - src/chain_parsers/visualsign-solana/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/.gitignore b/.gitignore index b4e65d04..d33ec6c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ **/target out -src/.cargo/ diff --git a/src/chain_parsers/visualsign-solana/Cargo.toml b/src/chain_parsers/visualsign-solana/Cargo.toml index 5a23e8f5..4bd54cee 100644 --- a/src/chain_parsers/visualsign-solana/Cargo.toml +++ b/src/chain_parsers/visualsign-solana/Cargo.toml @@ -25,7 +25,6 @@ spl-token-2022 = "10.0.0" spl-token-2022-interface = "2.1.0" [dev-dependencies] -solana_parser = { git = "https://github.com/anchorageoss/solana-parser.git", rev = "a0c554d" } solana-parser-fuzz-core = { git = "https://github.com/anchorageoss/solana-parser.git", rev = "a0c554d", features = ["proptest"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" From df14b89afee49fdb1682dfc2c6bfe88da20bdcee Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Tue, 24 Mar 2026 10:59:33 -0400 Subject: [PATCH 19/20] perf: load IDL once in real_idl_never_panics and add enum roundtrip test Convert real_idl_never_panics from proptest! macro to TestRunner::run so the IDL is loaded from disk once instead of on every iteration (256+ redundant file reads). Add roundtrip_defined_enum_arg covering all three enum variant shapes (unit, named, tuple) as specification-by-example, matching the existing concrete roundtrip pattern for structs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/fuzz_idl_parsing.rs | 111 +++++++++++++----- 1 file changed, 84 insertions(+), 27 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index ed67a495..5a65a9a2 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -707,6 +707,50 @@ fn roundtrip_nested_defined_struct() { assert_eq!(config["active"], serde_json::json!(true)); } +/// An instruction whose arg is a defined enum with unit, tuple, and named +/// variants — exercises enum discriminant dispatch and field decoding. +#[test] +fn roundtrip_defined_enum_arg() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "setMode", "accounts": [], "args": [ + {"name": "mode", "type": {"defined": "Mode"}} + ]}], + "types": [{ + "name": "Mode", + "type": {"kind": "enum", "variants": [ + {"name": "Off"}, + {"name": "Fixed", "fields": [{"name": "rate", "type": "u64"}]}, + {"name": "Scaled", "fields": ["u32", "bool"]}, + ]} + }] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + // Variant 0: Off (unit) + let mut data = disc.clone(); + data.push(0u8); // variant index + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.instruction_name, "setMode"); + + // Variant 1: Fixed { rate: 500 } (named) + let mut data = disc.clone(); + data.push(1u8); // variant index + data.extend_from_slice(&500u64.to_le_bytes()); + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.instruction_name, "setMode"); + + // Variant 2: Scaled(100, true) (tuple) + let mut data = disc.clone(); + data.push(2u8); // variant index + data.extend_from_slice(&100u32.to_le_bytes()); + data.push(1u8); // true + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.instruction_name, "setMode"); +} + // ── Error-path tests ───────────────────────────────────────────────────────── /// An instruction arg that references a `Defined("MissingType")` not present in @@ -819,36 +863,49 @@ fn load_idl_from_env() -> Option<(String, solana_parser::solana::structs::Idl)> } } -proptest! { - #![proptest_config(ProptestConfig::default())] +/// Crash-safety test against a real IDL loaded from IDL_FILE. +/// +/// Uses TestRunner::run directly to load the IDL once (not per iteration). +/// Applies the same 50/50 valid/random discriminator mix as +/// `fuzz_idl_parsing_never_panics`. On `Ok` with a valid discriminator, +/// asserts the instruction name matches the selected instruction. +#[test] +fn real_idl_never_panics() { + let Some((_, idl)) = load_idl_from_env() else { + return; + }; - /// Crash-safety test against a real IDL loaded from IDL_FILE. - /// - /// Uses the same 50/50 valid/random discriminator mix as - /// `fuzz_idl_parsing_never_panics`. On `Ok` with a valid discriminator, - /// asserts the instruction name matches the selected instruction. - #[test] - fn real_idl_never_panics( - use_valid_disc in any::(), - inst_idx in any::(), - data in prop::collection::vec(any::(), 0..1300usize), - ) { - let Some((_, idl)) = load_idl_from_env() else { return Ok(()); }; - if use_valid_disc && !idl.instructions.is_empty() { - let inst = &idl.instructions[inst_idx % idl.instructions.len()]; - let expected_name = inst.name.clone(); - if let Some(disc) = &inst.discriminator { - let mut d = disc.clone(); - d.extend_from_slice(&data); - if let Ok(result) = parse_instruction_with_idl(&d, TEST_PROGRAM_ID, &idl) { - prop_assert_eq!(&result.instruction_name, &expected_name, - "discriminator must dispatch to the correct instruction"); + let strategy = ( + any::(), + any::(), + prop::collection::vec(any::(), 0..1300usize), + ); + + let config = ProptestConfig::default(); + let mut runner = proptest::test_runner::TestRunner::new(config); + let idl_ref = idl.clone(); + runner + .run(&strategy, move |(use_valid_disc, inst_idx, data)| { + if use_valid_disc && !idl_ref.instructions.is_empty() { + let inst = &idl_ref.instructions[inst_idx % idl_ref.instructions.len()]; + let expected_name = &inst.name; + if let Some(disc) = &inst.discriminator { + let mut d = disc.clone(); + d.extend_from_slice(&data); + if let Ok(result) = parse_instruction_with_idl(&d, TEST_PROGRAM_ID, &idl_ref) { + prop_assert_eq!( + &result.instruction_name, + expected_name, + "discriminator must dispatch to the correct instruction" + ); + } } + } else { + let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl_ref); } - } else { - let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); - } - } + Ok(()) + }) + .expect("real_idl_never_panics failed"); } /// Valid-data parse test against a real IDL loaded from IDL_FILE. From 9aa37fd2b239fa7ddf461abfa76f1123716f6832 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Wed, 25 Mar 2026 10:23:18 -0400 Subject: [PATCH 20/20] chore: use deadbeef-style fake TEST_PROGRAM_ID Co-Authored-By: Claude Opus 4.6 (1M context) --- src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs index 5a65a9a2..d101ce72 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -37,7 +37,7 @@ use std::sync::Arc; // parse_instruction_with_idl ignores the program_id parameter (_program_id); // use an obviously fake value to avoid confusion with real known programs. -const TEST_PROGRAM_ID: &str = "00000000000000000000000000000000"; +const TEST_PROGRAM_ID: &str = "deadbeef1234deadbeef5678deadbeef"; // ── Local strategies ───────────────────────────────────────────────────────── //