From f2c7302b0cb2c507550c5cf88072bf7b0817138f Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Wed, 11 Mar 2026 16:35:59 -0400 Subject: [PATCH 01/19] 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 c9bb956c773116f85da62de3cadb954538a6ab08 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Wed, 11 Mar 2026 18:35:29 -0400 Subject: [PATCH 02/19] 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 d3029af4558c1e5b57bc813823164ca43a05167c Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Wed, 11 Mar 2026 21:34:05 -0400 Subject: [PATCH 03/19] 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 d610b92d6724c3715265c2b237c80890a68a3d8a Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Wed, 11 Mar 2026 22:11:02 -0400 Subject: [PATCH 04/19] 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 998fb1c2332113efc72e61c7603bd8fc57dd6f0e Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Wed, 11 Mar 2026 22:35:44 -0400 Subject: [PATCH 05/19] 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 dd65009645194e674dba56111f451a232f7192df Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Thu, 12 Mar 2026 11:29:04 -0400 Subject: [PATCH 06/19] 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 7c61fb867016768b1a842534d4e3c8ab20318265 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Thu, 12 Mar 2026 22:20:46 -0400 Subject: [PATCH 07/19] 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 0d6395e5cf4d3a8edde594ad9582f736a92fbfc9 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Fri, 13 Mar 2026 19:08:55 -0400 Subject: [PATCH 08/19] 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 962012512c124a67def2235473dc656fd6f2c5d2 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Fri, 13 Mar 2026 23:17:30 -0400 Subject: [PATCH 09/19] Add cargo fuzz targets for visualsign-solana Two libFuzzer targets covering the full visualsign-solana stack: - fuzz_transaction_string: arbitrary bytes into transaction_string_to_visual_sign - fuzz_versioned_transaction: arbitrary bytes deserialized as VersionedTransaction then passed to versioned_transaction_to_visual_sign Run with: cargo +nightly fuzz run (from src/chain_parsers/visualsign-solana/fuzz/) Co-Authored-By: Claude Sonnet 4.6 --- .../visualsign-solana/fuzz/.gitignore | 4 +++ .../visualsign-solana/fuzz/Cargo.toml | 33 +++++++++++++++++++ .../fuzz_targets/fuzz_transaction_string.rs | 14 ++++++++ .../fuzz_versioned_transaction.rs | 15 +++++++++ .../fuzz/rust-toolchain.toml | 2 ++ 5 files changed, 68 insertions(+) create mode 100644 src/chain_parsers/visualsign-solana/fuzz/.gitignore create mode 100644 src/chain_parsers/visualsign-solana/fuzz/Cargo.toml create mode 100644 src/chain_parsers/visualsign-solana/fuzz/fuzz_targets/fuzz_transaction_string.rs create mode 100644 src/chain_parsers/visualsign-solana/fuzz/fuzz_targets/fuzz_versioned_transaction.rs create mode 100644 src/chain_parsers/visualsign-solana/fuzz/rust-toolchain.toml diff --git a/src/chain_parsers/visualsign-solana/fuzz/.gitignore b/src/chain_parsers/visualsign-solana/fuzz/.gitignore new file mode 100644 index 00000000..1a45eee7 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/fuzz/.gitignore @@ -0,0 +1,4 @@ +target +corpus +artifacts +coverage diff --git a/src/chain_parsers/visualsign-solana/fuzz/Cargo.toml b/src/chain_parsers/visualsign-solana/fuzz/Cargo.toml new file mode 100644 index 00000000..3016319b --- /dev/null +++ b/src/chain_parsers/visualsign-solana/fuzz/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "visualsign-solana-fuzz" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +bincode = "1.3.3" +solana-sdk = "2.1.15" +visualsign = { path = "../../../visualsign" } + +[dependencies.visualsign-solana] +path = ".." + +[[bin]] +name = "fuzz_transaction_string" +path = "fuzz_targets/fuzz_transaction_string.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_versioned_transaction" +path = "fuzz_targets/fuzz_versioned_transaction.rs" +test = false +doc = false +bench = false diff --git a/src/chain_parsers/visualsign-solana/fuzz/fuzz_targets/fuzz_transaction_string.rs b/src/chain_parsers/visualsign-solana/fuzz/fuzz_targets/fuzz_transaction_string.rs new file mode 100644 index 00000000..b0fcd70b --- /dev/null +++ b/src/chain_parsers/visualsign-solana/fuzz/fuzz_targets/fuzz_transaction_string.rs @@ -0,0 +1,14 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use visualsign_solana::transaction_string_to_visual_sign; +use visualsign::vsptrait::VisualSignOptions; + +// Feed arbitrary bytes as a transaction string into the full visualsign-solana +// stack. Exercises base64/hex decoding, transaction deserialization, IDL +// dispatch, and SignablePayload construction. +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + let _ = transaction_string_to_visual_sign(s, VisualSignOptions::default()); + } +}); diff --git a/src/chain_parsers/visualsign-solana/fuzz/fuzz_targets/fuzz_versioned_transaction.rs b/src/chain_parsers/visualsign-solana/fuzz/fuzz_targets/fuzz_versioned_transaction.rs new file mode 100644 index 00000000..c85d94e5 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/fuzz/fuzz_targets/fuzz_versioned_transaction.rs @@ -0,0 +1,15 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use visualsign_solana::versioned_transaction_to_visual_sign; +use visualsign::vsptrait::VisualSignOptions; +use solana_sdk::transaction::VersionedTransaction; + +// Try to deserialize arbitrary bytes as a VersionedTransaction then pass it +// through the full visualsign-solana stack. Exercises the versioned transaction +// path including address table lookup handling and IDL dispatch. +fuzz_target!(|data: &[u8]| { + if let Ok(tx) = bincode::deserialize::(data) { + let _ = versioned_transaction_to_visual_sign(tx, VisualSignOptions::default()); + } +}); diff --git a/src/chain_parsers/visualsign-solana/fuzz/rust-toolchain.toml b/src/chain_parsers/visualsign-solana/fuzz/rust-toolchain.toml new file mode 100644 index 00000000..5d56faf9 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/fuzz/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" From ed1c41474ccc3db2d26e0610f02f2e730bea66bd Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Fri, 13 Mar 2026 23:21:43 -0400 Subject: [PATCH 10/19] Add proptest and fuzz label-triggered CI jobs - proptest label: runs cargo test -p visualsign-solana - fuzz label: installs nightly + cargo-fuzz, runs each fuzz target for 30s - ubuntu job: restricted to main push/PR to avoid triggering on label events Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/main.yml | 93 +++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c6ad3512..fc9febe1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,11 +5,13 @@ on: branches: - main pull_request: - branches: - - main + types: [opened, synchronize, reopened, labeled] jobs: ubuntu: + if: | + github.ref == 'refs/heads/main' || + github.base_ref == 'main' runs-on: ubuntu-latest-4-cores steps: - name: git checkout @@ -56,3 +58,90 @@ jobs: run: make -C src lint - name: Run tests run: make -C src test + + proptest: + if: contains(github.event.pull_request.labels.*.name, 'proptest') + runs-on: ubuntu-latest-4-cores + steps: + - name: git checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 # v1.13.0 + with: + components: clippy, rustfmt + - name: Cache Rust dependencies + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + src/target/ + key: ${{ runner.os }}-cargo-proptest-${{ hashFiles('src/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-proptest- + ${{ runner.os }}-cargo- + - name: install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + version: "21.4" + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: free disk space + run: | + sudo swapoff -a + sudo rm -f /swapfile + sudo apt clean + df -h + - name: Run codegen + run: make -C src generated + - name: Run proptest tests + run: cargo test -p visualsign-solana + working-directory: src + + fuzz: + if: contains(github.event.pull_request.labels.*.name, 'fuzz') + runs-on: ubuntu-latest-4-cores + steps: + - name: git checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + - name: Install Rust (nightly) + uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 # v1.13.0 + with: + toolchain: nightly + - name: Install cargo-fuzz + run: cargo install cargo-fuzz + - name: Cache Rust dependencies + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + src/target/ + key: ${{ runner.os }}-cargo-fuzz-${{ hashFiles('src/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-fuzz- + ${{ runner.os }}-cargo- + - name: install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + version: "21.4" + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: free disk space + run: | + sudo swapoff -a + sudo rm -f /swapfile + sudo apt clean + df -h + - name: Run codegen + run: make -C src generated + - name: Fuzz fuzz_transaction_string (30s) + run: cargo +nightly fuzz run fuzz_transaction_string -- -max_total_time=30 + working-directory: src/chain_parsers/visualsign-solana/fuzz + - name: Fuzz fuzz_versioned_transaction (30s) + run: cargo +nightly fuzz run fuzz_versioned_transaction -- -max_total_time=30 + working-directory: src/chain_parsers/visualsign-solana/fuzz From 633b31b899ec3ff8286e470ef2793c36789cf4f7 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Fri, 13 Mar 2026 23:34:13 -0400 Subject: [PATCH 11/19] Add clippy steps to proptest and fuzz CI jobs Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/main.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fc9febe1..a2c74e2d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -96,6 +96,9 @@ jobs: df -h - name: Run codegen run: make -C src generated + - name: Run clippy + run: cargo clippy -p visualsign-solana -- -D warnings + working-directory: src - name: Run proptest tests run: cargo test -p visualsign-solana working-directory: src @@ -139,6 +142,9 @@ jobs: df -h - name: Run codegen run: make -C src generated + - name: Run clippy + run: cargo +nightly clippy -- -D warnings + working-directory: src/chain_parsers/visualsign-solana/fuzz - name: Fuzz fuzz_transaction_string (30s) run: cargo +nightly fuzz run fuzz_transaction_string -- -max_total_time=30 working-directory: src/chain_parsers/visualsign-solana/fuzz From e2b9ee85db77fc1200bd307aa0959c49e8631892 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Fri, 13 Mar 2026 23:39:17 -0400 Subject: [PATCH 12/19] Fix formatting and remove redundant clippy steps from CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs cargo fmt to fix formatting in test files. Removes clippy steps from proptest and fuzz jobs — already enforced by the ubuntu job. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/main.yml | 6 -- .../tests/fuzz_idl_parsing.rs | 101 ++++++++++-------- .../tests/pipeline_integration.rs | 73 +++++++++---- 3 files changed, 108 insertions(+), 72 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a2c74e2d..fc9febe1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -96,9 +96,6 @@ jobs: df -h - name: Run codegen run: make -C src generated - - name: Run clippy - run: cargo clippy -p visualsign-solana -- -D warnings - working-directory: src - name: Run proptest tests run: cargo test -p visualsign-solana working-directory: src @@ -142,9 +139,6 @@ jobs: df -h - name: Run codegen run: make -C src generated - - name: Run clippy - run: cargo +nightly clippy -- -D warnings - working-directory: src/chain_parsers/visualsign-solana/fuzz - name: Fuzz fuzz_transaction_string (30s) run: cargo +nightly fuzz run fuzz_transaction_string -- -max_total_time=30 working-directory: src/chain_parsers/visualsign-solana/fuzz 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..913720a7 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -43,21 +43,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 where the single instruction has a `Vec` arg. @@ -72,7 +72,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() }) }) @@ -88,15 +91,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)) + }) }) } @@ -319,16 +319,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] @@ -345,7 +348,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(); @@ -462,7 +465,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 ──────────────────────────────────── @@ -491,17 +494,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)); } // ── SizeGuard boundary tests ────────────────────────────────────────────────── @@ -529,7 +532,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 @@ -552,7 +558,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) ───────────────────── @@ -567,8 +576,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) => { @@ -623,9 +631,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(); @@ -644,7 +656,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 80f12621..470ed077 100644 --- a/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs +++ b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs @@ -25,15 +25,19 @@ 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() @@ -96,14 +100,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. @@ -131,7 +143,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(); @@ -142,7 +155,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); @@ -152,7 +166,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())); } @@ -165,7 +182,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]; @@ -173,7 +191,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]; @@ -199,7 +218,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]; @@ -221,7 +241,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(); @@ -229,7 +250,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(); @@ -265,7 +287,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(); @@ -275,20 +298,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 fae97c2b1a03f2accc3f8e8a74f05a12327dd323 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Fri, 13 Mar 2026 23:41:33 -0400 Subject: [PATCH 13/19] Update Cargo.lock for solana_parser git dependency Co-Authored-By: Claude Sonnet 4.6 --- src/Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Cargo.lock b/src/Cargo.lock index 189cbdfb..3fc05ce5 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -786,7 +786,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -797,7 +797,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -3121,7 +3121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5707,7 +5707,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5877,7 +5877,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 1.1.3", "proc-macro2", "quote", "syn 2.0.112", @@ -7648,7 +7648,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -12127,7 +12127,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -13313,7 +13313,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] From 2568ac78626847b250976a8b75c9cc29fa2f2831 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Fri, 13 Mar 2026 23:45:14 -0400 Subject: [PATCH 14/19] Update Cargo.lock with solana_parser git source Co-Authored-By: Claude Sonnet 4.6 --- src/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cargo.lock b/src/Cargo.lock index 3fc05ce5..c4848a0a 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -10549,6 +10549,7 @@ dependencies = [ [[package]] name = "solana_parser" version = "0.1.0" +source = "git+https://github.com/anchorageoss/solana-parser.git?branch=solana-parser-add-arbitrary#3a523cc6fdc4d72e69cc2b08387f44cbf16ea22c" dependencies = [ "bincode", "bs58 0.5.1", From 4888982bea0f0e6138e048eee9d2166f4f925941 Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Sat, 14 Mar 2026 00:04:50 -0400 Subject: [PATCH 15/19] Post fuzz crash summary as PR comment on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On crash, extracts the libFuzzer summary (everything after the ─── line) and posts it as a PR comment via gh. No artifacts or separate jobs needed. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/main.yml | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fc9febe1..8ef69552 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -140,8 +140,39 @@ jobs: - name: Run codegen run: make -C src generated - name: Fuzz fuzz_transaction_string (30s) - run: cargo +nightly fuzz run fuzz_transaction_string -- -max_total_time=30 + id: fuzz_transaction_string + continue-on-error: true + run: | + cargo +nightly fuzz run fuzz_transaction_string -- -max_total_time=30 2>&1 | tee /tmp/fuzz_transaction_string.txt + exit ${PIPESTATUS[0]} working-directory: src/chain_parsers/visualsign-solana/fuzz + - name: Post fuzz_transaction_string crash comment + if: steps.fuzz_transaction_string.outcome == 'failure' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + body=$(awk '/^─+$/{found=1} found{print}' /tmp/fuzz_transaction_string.txt) + gh pr comment ${{ github.event.pull_request.number }} --body "### Fuzz crash: \`fuzz_transaction_string\` + \`\`\` + ${body} + \`\`\`" - name: Fuzz fuzz_versioned_transaction (30s) - run: cargo +nightly fuzz run fuzz_versioned_transaction -- -max_total_time=30 + id: fuzz_versioned_transaction + continue-on-error: true + run: | + cargo +nightly fuzz run fuzz_versioned_transaction -- -max_total_time=30 2>&1 | tee /tmp/fuzz_versioned_transaction.txt + exit ${PIPESTATUS[0]} working-directory: src/chain_parsers/visualsign-solana/fuzz + - name: Post fuzz_versioned_transaction crash comment + if: steps.fuzz_versioned_transaction.outcome == 'failure' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + body=$(awk '/^─+$/{found=1} found{print}' /tmp/fuzz_versioned_transaction.txt) + gh pr comment ${{ github.event.pull_request.number }} --body "### Fuzz crash: \`fuzz_versioned_transaction\` + \`\`\` + ${body} + \`\`\`" + - name: Fail if any fuzz target crashed + if: steps.fuzz_transaction_string.outcome == 'failure' || steps.fuzz_versioned_transaction.outcome == 'failure' + run: exit 1 From cd4b7d2495fb35ba339fe2b3369f230a17b63abf Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Sat, 14 Mar 2026 00:14:46 -0400 Subject: [PATCH 16/19] Split proptest and fuzz workflows into separate files Move proptest and fuzz jobs out of main.yml into dedicated workflow files (proptest.yml, fuzz.yml) so they appear as distinct named checks. Add pull-requests: write permission to fuzz job to allow posting crash comments via gh pr comment. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/fuzz.yml | 85 +++++++++++++++++++++++ .github/workflows/main.yml | 120 +-------------------------------- .github/workflows/proptest.yml | 47 +++++++++++++ 3 files changed, 133 insertions(+), 119 deletions(-) create mode 100644 .github/workflows/fuzz.yml create mode 100644 .github/workflows/proptest.yml diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..efef5e78 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,85 @@ +name: Fuzz Testing + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + +jobs: + fuzz: + if: contains(github.event.pull_request.labels.*.name, 'fuzz') + runs-on: ubuntu-latest-4-cores + permissions: + pull-requests: write + steps: + - name: git checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + - name: Install Rust (nightly) + uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 # v1.13.0 + with: + toolchain: nightly + - name: Install cargo-fuzz + run: cargo install cargo-fuzz + - name: Cache Rust dependencies + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + src/target/ + key: ${{ runner.os }}-cargo-fuzz-${{ hashFiles('src/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-fuzz- + ${{ runner.os }}-cargo- + - name: install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + version: "21.4" + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: free disk space + run: | + sudo swapoff -a + sudo rm -f /swapfile + sudo apt clean + df -h + - name: Run codegen + run: make -C src generated + - name: Fuzz fuzz_transaction_string (30s) + id: fuzz_transaction_string + continue-on-error: true + run: | + cargo +nightly fuzz run fuzz_transaction_string -- -max_total_time=30 2>&1 | tee /tmp/fuzz_transaction_string.txt + exit ${PIPESTATUS[0]} + working-directory: src/chain_parsers/visualsign-solana/fuzz + - name: Post fuzz_transaction_string crash comment + if: steps.fuzz_transaction_string.outcome == 'failure' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + body=$(awk '/^─+$/{found=1} found{print}' /tmp/fuzz_transaction_string.txt) + gh pr comment ${{ github.event.pull_request.number }} --body "### Fuzz crash: \`fuzz_transaction_string\` + \`\`\` + ${body} + \`\`\`" + - name: Fuzz fuzz_versioned_transaction (30s) + id: fuzz_versioned_transaction + continue-on-error: true + run: | + cargo +nightly fuzz run fuzz_versioned_transaction -- -max_total_time=30 2>&1 | tee /tmp/fuzz_versioned_transaction.txt + exit ${PIPESTATUS[0]} + working-directory: src/chain_parsers/visualsign-solana/fuzz + - name: Post fuzz_versioned_transaction crash comment + if: steps.fuzz_versioned_transaction.outcome == 'failure' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + body=$(awk '/^─+$/{found=1} found{print}' /tmp/fuzz_versioned_transaction.txt) + gh pr comment ${{ github.event.pull_request.number }} --body "### Fuzz crash: \`fuzz_versioned_transaction\` + \`\`\` + ${body} + \`\`\`" + - name: Fail if any fuzz target crashed + if: steps.fuzz_transaction_string.outcome == 'failure' || steps.fuzz_versioned_transaction.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8ef69552..a9618854 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,7 +5,7 @@ on: branches: - main pull_request: - types: [opened, synchronize, reopened, labeled] + types: [opened, synchronize, reopened] jobs: ubuntu: @@ -58,121 +58,3 @@ jobs: run: make -C src lint - name: Run tests run: make -C src test - - proptest: - if: contains(github.event.pull_request.labels.*.name, 'proptest') - runs-on: ubuntu-latest-4-cores - steps: - - name: git checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - name: Install Rust - uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 # v1.13.0 - with: - components: clippy, rustfmt - - name: Cache Rust dependencies - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - src/target/ - key: ${{ runner.os }}-cargo-proptest-${{ hashFiles('src/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-proptest- - ${{ runner.os }}-cargo- - - name: install protoc - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 - with: - version: "21.4" - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: free disk space - run: | - sudo swapoff -a - sudo rm -f /swapfile - sudo apt clean - df -h - - name: Run codegen - run: make -C src generated - - name: Run proptest tests - run: cargo test -p visualsign-solana - working-directory: src - - fuzz: - if: contains(github.event.pull_request.labels.*.name, 'fuzz') - runs-on: ubuntu-latest-4-cores - steps: - - name: git checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - name: Install Rust (nightly) - uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 # v1.13.0 - with: - toolchain: nightly - - name: Install cargo-fuzz - run: cargo install cargo-fuzz - - name: Cache Rust dependencies - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 - with: - path: | - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - src/target/ - key: ${{ runner.os }}-cargo-fuzz-${{ hashFiles('src/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-fuzz- - ${{ runner.os }}-cargo- - - name: install protoc - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 - with: - version: "21.4" - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: free disk space - run: | - sudo swapoff -a - sudo rm -f /swapfile - sudo apt clean - df -h - - name: Run codegen - run: make -C src generated - - name: Fuzz fuzz_transaction_string (30s) - id: fuzz_transaction_string - continue-on-error: true - run: | - cargo +nightly fuzz run fuzz_transaction_string -- -max_total_time=30 2>&1 | tee /tmp/fuzz_transaction_string.txt - exit ${PIPESTATUS[0]} - working-directory: src/chain_parsers/visualsign-solana/fuzz - - name: Post fuzz_transaction_string crash comment - if: steps.fuzz_transaction_string.outcome == 'failure' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - body=$(awk '/^─+$/{found=1} found{print}' /tmp/fuzz_transaction_string.txt) - gh pr comment ${{ github.event.pull_request.number }} --body "### Fuzz crash: \`fuzz_transaction_string\` - \`\`\` - ${body} - \`\`\`" - - name: Fuzz fuzz_versioned_transaction (30s) - id: fuzz_versioned_transaction - continue-on-error: true - run: | - cargo +nightly fuzz run fuzz_versioned_transaction -- -max_total_time=30 2>&1 | tee /tmp/fuzz_versioned_transaction.txt - exit ${PIPESTATUS[0]} - working-directory: src/chain_parsers/visualsign-solana/fuzz - - name: Post fuzz_versioned_transaction crash comment - if: steps.fuzz_versioned_transaction.outcome == 'failure' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - body=$(awk '/^─+$/{found=1} found{print}' /tmp/fuzz_versioned_transaction.txt) - gh pr comment ${{ github.event.pull_request.number }} --body "### Fuzz crash: \`fuzz_versioned_transaction\` - \`\`\` - ${body} - \`\`\`" - - name: Fail if any fuzz target crashed - if: steps.fuzz_transaction_string.outcome == 'failure' || steps.fuzz_versioned_transaction.outcome == 'failure' - run: exit 1 diff --git a/.github/workflows/proptest.yml b/.github/workflows/proptest.yml new file mode 100644 index 00000000..3b7f76f6 --- /dev/null +++ b/.github/workflows/proptest.yml @@ -0,0 +1,47 @@ +name: Property Tests + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + +jobs: + proptest: + if: contains(github.event.pull_request.labels.*.name, 'proptest') + runs-on: ubuntu-latest-4-cores + steps: + - name: git checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 # v1.13.0 + with: + components: clippy, rustfmt + - name: Cache Rust dependencies + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + src/target/ + key: ${{ runner.os }}-cargo-proptest-${{ hashFiles('src/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-proptest- + ${{ runner.os }}-cargo- + - name: install protoc + uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 + with: + version: "21.4" + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: free disk space + run: | + sudo swapoff -a + sudo rm -f /swapfile + sudo apt clean + df -h + - name: Run codegen + run: make -C src generated + - name: Run proptest tests + run: cargo test -p visualsign-solana + working-directory: src From c767a98d86c4b9376379a6266b73c59aea0269bc Mon Sep 17 00:00:00 2001 From: Shahan Khatchadourian Date: Sat, 14 Mar 2026 05:10:42 -0400 Subject: [PATCH 17/19] Add reusable post-failure-comment action tagging @copilot Shared composite action posts crash/failure output as a PR comment and tags @copilot to fix the issue. Fuzz and proptest workflows use it via extract steps that write output to GITHUB_OUTPUT. Co-Authored-By: Claude Sonnet 4.6 --- .../actions/post-failure-comment/action.yml | 33 +++++++++++++++ .github/workflows/fuzz.yml | 42 ++++++++++++------- .github/workflows/proptest.yml | 28 ++++++++++++- 3 files changed, 88 insertions(+), 15 deletions(-) create mode 100644 .github/actions/post-failure-comment/action.yml diff --git a/.github/actions/post-failure-comment/action.yml b/.github/actions/post-failure-comment/action.yml new file mode 100644 index 00000000..15c3c01f --- /dev/null +++ b/.github/actions/post-failure-comment/action.yml @@ -0,0 +1,33 @@ +name: Post Failure Comment +description: 'Post a PR comment with failure details and tag @copilot to fix the issue' + +inputs: + pr-number: + description: Pull request number + required: true + title: + description: Short title describing what failed + required: true + body: + description: Failure output to include in the comment + required: true + gh-token: + description: GitHub token with pull-requests write permission + required: true + +runs: + using: composite + steps: + - name: Post comment + shell: bash + env: + GH_TOKEN: ${{ inputs.gh-token }} + COMMENT_BODY: ${{ inputs.body }} + run: | + gh pr comment ${{ inputs.pr-number }} --body "### ${{ inputs.title }} + + \`\`\` + ${COMMENT_BODY} + \`\`\` + + @copilot please investigate the failure above and fix the issue in this PR." diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index efef5e78..00fca303 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -53,16 +53,23 @@ jobs: cargo +nightly fuzz run fuzz_transaction_string -- -max_total_time=30 2>&1 | tee /tmp/fuzz_transaction_string.txt exit ${PIPESTATUS[0]} working-directory: src/chain_parsers/visualsign-solana/fuzz - - name: Post fuzz_transaction_string crash comment + - name: Extract fuzz_transaction_string crash output + id: extract_fuzz_transaction_string if: steps.fuzz_transaction_string.outcome == 'failure' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | body=$(awk '/^─+$/{found=1} found{print}' /tmp/fuzz_transaction_string.txt) - gh pr comment ${{ github.event.pull_request.number }} --body "### Fuzz crash: \`fuzz_transaction_string\` - \`\`\` - ${body} - \`\`\`" + delim=$(openssl rand -hex 8) + echo "body<<${delim}" >> "$GITHUB_OUTPUT" + echo "${body}" >> "$GITHUB_OUTPUT" + echo "${delim}" >> "$GITHUB_OUTPUT" + - name: Post fuzz_transaction_string crash comment + if: steps.fuzz_transaction_string.outcome == 'failure' + uses: ./.github/actions/post-failure-comment + with: + pr-number: ${{ github.event.pull_request.number }} + title: "Fuzz crash: `fuzz_transaction_string`" + body: ${{ steps.extract_fuzz_transaction_string.outputs.body }} + gh-token: ${{ secrets.GITHUB_TOKEN }} - name: Fuzz fuzz_versioned_transaction (30s) id: fuzz_versioned_transaction continue-on-error: true @@ -70,16 +77,23 @@ jobs: cargo +nightly fuzz run fuzz_versioned_transaction -- -max_total_time=30 2>&1 | tee /tmp/fuzz_versioned_transaction.txt exit ${PIPESTATUS[0]} working-directory: src/chain_parsers/visualsign-solana/fuzz - - name: Post fuzz_versioned_transaction crash comment + - name: Extract fuzz_versioned_transaction crash output + id: extract_fuzz_versioned_transaction if: steps.fuzz_versioned_transaction.outcome == 'failure' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | body=$(awk '/^─+$/{found=1} found{print}' /tmp/fuzz_versioned_transaction.txt) - gh pr comment ${{ github.event.pull_request.number }} --body "### Fuzz crash: \`fuzz_versioned_transaction\` - \`\`\` - ${body} - \`\`\`" + delim=$(openssl rand -hex 8) + echo "body<<${delim}" >> "$GITHUB_OUTPUT" + echo "${body}" >> "$GITHUB_OUTPUT" + echo "${delim}" >> "$GITHUB_OUTPUT" + - name: Post fuzz_versioned_transaction crash comment + if: steps.fuzz_versioned_transaction.outcome == 'failure' + uses: ./.github/actions/post-failure-comment + with: + pr-number: ${{ github.event.pull_request.number }} + title: "Fuzz crash: `fuzz_versioned_transaction`" + body: ${{ steps.extract_fuzz_versioned_transaction.outputs.body }} + gh-token: ${{ secrets.GITHUB_TOKEN }} - name: Fail if any fuzz target crashed if: steps.fuzz_transaction_string.outcome == 'failure' || steps.fuzz_versioned_transaction.outcome == 'failure' run: exit 1 diff --git a/.github/workflows/proptest.yml b/.github/workflows/proptest.yml index 3b7f76f6..2925c7c0 100644 --- a/.github/workflows/proptest.yml +++ b/.github/workflows/proptest.yml @@ -8,6 +8,8 @@ jobs: proptest: if: contains(github.event.pull_request.labels.*.name, 'proptest') runs-on: ubuntu-latest-4-cores + permissions: + pull-requests: write steps: - name: git checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -43,5 +45,29 @@ jobs: - name: Run codegen run: make -C src generated - name: Run proptest tests - run: cargo test -p visualsign-solana + id: proptest + continue-on-error: true + run: | + cargo test -p visualsign-solana 2>&1 | tee /tmp/proptest.txt + exit ${PIPESTATUS[0]} working-directory: src + - name: Extract proptest failure output + id: extract_proptest + if: steps.proptest.outcome == 'failure' + run: | + body=$(grep -A 50 'FAILED\|proptest\|thread.*panicked' /tmp/proptest.txt | head -100) + delim=$(openssl rand -hex 8) + echo "body<<${delim}" >> "$GITHUB_OUTPUT" + echo "${body}" >> "$GITHUB_OUTPUT" + echo "${delim}" >> "$GITHUB_OUTPUT" + - name: Post proptest failure comment + if: steps.proptest.outcome == 'failure' + uses: ./.github/actions/post-failure-comment + with: + pr-number: ${{ github.event.pull_request.number }} + title: "Property test failure" + body: ${{ steps.extract_proptest.outputs.body }} + gh-token: ${{ secrets.GITHUB_TOKEN }} + - name: Fail if proptest failed + if: steps.proptest.outcome == 'failure' + run: exit 1 From 7210c4b98bdee89bae1812a38c2e48d03f3175de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:26:27 +0000 Subject: [PATCH 18/19] Initial plan From d955ae72c16871b5b647f03149650499e7db7f8d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:40:13 +0000 Subject: [PATCH 19/19] Fix panics in decode_instructions on malformed fuzz inputs Co-authored-by: shahan-khatchadourian-anchorage <263420032+shahan-khatchadourian-anchorage@users.noreply.github.com> --- .../src/core/instructions.rs | 72 +++++++++++-------- 1 file changed, 41 insertions(+), 31 deletions(-) diff --git a/src/chain_parsers/visualsign-solana/src/core/instructions.rs b/src/chain_parsers/visualsign-solana/src/core/instructions.rs index 19f0541d..5f0459fc 100644 --- a/src/chain_parsers/visualsign-solana/src/core/instructions.rs +++ b/src/chain_parsers/visualsign-solana/src/core/instructions.rs @@ -25,33 +25,53 @@ pub fn decode_instructions( let message = &transaction.message; let account_keys = &message.account_keys; - // Convert compiled instructions to full instructions + // Convert compiled instructions to full instructions, skipping those with out-of-bounds + // account indices (which can occur with malformed/fuzz inputs). let instructions: Vec = message .instructions .iter() - .map(|ci| Instruction { - program_id: account_keys[ci.program_id_index as usize], - accounts: ci + .filter_map(|ci| { + let program_id_idx = ci.program_id_index as usize; + if program_id_idx >= account_keys.len() { + return None; + } + let program_id = account_keys[program_id_idx]; + + let accounts: Vec = ci .accounts .iter() - .map(|&i| { - solana_sdk::instruction::AccountMeta::new_readonly( - account_keys[i as usize], - false, - ) + .filter_map(|&i| { + if (i as usize) < account_keys.len() { + Some(solana_sdk::instruction::AccountMeta::new_readonly( + account_keys[i as usize], + false, + )) + } else { + None + } }) - .collect(), - data: ci.data.clone(), + .collect(); + + Some(Instruction { + program_id, + accounts, + data: ci.data.clone(), + }) }) .collect(); + // Use the zero pubkey as a placeholder sender when there are no account keys. + let sender_key = account_keys + .first() + .map(|k| k.to_string()) + .unwrap_or_else(|| solana_sdk::pubkey::Pubkey::default().to_string()); + let results: Result, VisualSignError> = instructions .iter() .enumerate() .map(|(instruction_index, instruction)| { - // Create sender account from first account key (typically the fee payer) let sender = SolanaAccount { - account_key: account_keys[0].to_string(), + account_key: sender_key.clone(), signer: false, writable: false, }; @@ -59,30 +79,20 @@ pub fn decode_instructions( let context = VisualizerContext::new(&sender, instruction_index, &instructions, idl_registry); - // Try to visualize with available visualizers (including unknown_program fallback) + // Try to visualize with available visualizers (including unknown_program fallback). + // Return an error instead of panicking if all visualizers decline the instruction. visualize_with_any(&visualizers_refs, &context) - .unwrap_or_else(|| { - panic!( - "No visualizer available for instruction {} at index {}", + .ok_or_else(|| { + VisualSignError::ParseError(TransactionParseError::DecodeError(format!( + "Failed to visualize instruction {} at index {}", instruction.program_id, instruction_index - ) - }) + ))) + })? .map(|viz_result| viz_result.field) }) .collect(); - let fields = results?; - - // Self-check: ensure we have the same number of instruction fields as input instructions - if fields.len() != instructions.len() { - return Err(VisualSignError::InvariantViolation(format!( - "Instruction count mismatch: expected {} instructions, got {} fields. This should never happen with unknown_program fallback.", - instructions.len(), - fields.len() - ))); - } - - Ok(fields) + Ok(results?) } pub fn decode_transfers(