diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz-solana.yml similarity index 88% rename from .github/workflows/fuzz.yml rename to .github/workflows/fuzz-solana.yml index 5d9774bd..0e91baef 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz-solana.yml @@ -1,9 +1,12 @@ -name: Fuzz Testing +name: "Fuzz Testing: Solana" on: pull_request: types: [opened, synchronize, reopened, labeled] +env: + NIGHTLY_VERSION: nightly-2026-03-13 + jobs: fuzz: if: contains(github.event.pull_request.labels.*.name, 'fuzz') @@ -18,7 +21,7 @@ jobs: - name: Install Rust (nightly) uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 # v1.13.0 with: - toolchain: nightly-2026-03-13 + toolchain: ${{ env.NIGHTLY_VERSION }} - name: Install cargo-fuzz run: cargo install cargo-fuzz --locked - name: Cache Rust dependencies @@ -50,12 +53,12 @@ jobs: - name: Fuzz fuzz_transaction_string (30s) id: fuzz_transaction_string continue-on-error: true - run: cargo +nightly-2026-03-13 fuzz run fuzz_transaction_string -- -max_total_time=30 + run: cargo +${{ env.NIGHTLY_VERSION }} fuzz run fuzz_transaction_string -- -max_total_time=30 working-directory: src/chain_parsers/visualsign-solana/fuzz - name: Fuzz fuzz_versioned_transaction (30s) id: fuzz_versioned_transaction continue-on-error: true - run: cargo +nightly-2026-03-13 fuzz run fuzz_versioned_transaction -- -max_total_time=30 + run: cargo +${{ env.NIGHTLY_VERSION }} fuzz run fuzz_versioned_transaction -- -max_total_time=30 working-directory: src/chain_parsers/visualsign-solana/fuzz - name: Label PR on fuzz failure env: diff --git a/.github/workflows/proptest.yml b/.github/workflows/proptest-solana.yml similarity index 98% rename from .github/workflows/proptest.yml rename to .github/workflows/proptest-solana.yml index 40123c18..5e57d54e 100644 --- a/.github/workflows/proptest.yml +++ b/.github/workflows/proptest-solana.yml @@ -1,4 +1,4 @@ -name: Property Tests +name: "Property Tests: Solana" on: pull_request: diff --git a/src/chain_parsers/visualsign-solana/tests/common/mod.rs b/src/chain_parsers/visualsign-solana/tests/common/mod.rs index bacdf5e8..7503d8a2 100644 --- a/src/chain_parsers/visualsign-solana/tests/common/mod.rs +++ b/src/chain_parsers/visualsign-solana/tests/common/mod.rs @@ -143,3 +143,21 @@ pub fn find_text(fields: &[AnnotatedPayloadField], label: &str) -> Option 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) => { + eprintln!("IDL_FILE={path}: skipping — decode failed: {e}"); + None + } + } +} 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 d101ce72..55ae0a47 100644 --- a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -35,6 +35,8 @@ use solana_parser::{decode_idl_data, parse_instruction_with_idl}; use solana_parser_fuzz_core::proptest as arb; use std::sync::Arc; +mod common; + // parse_instruction_with_idl ignores the program_id parameter (_program_id); // use an obviously fake value to avoid confusion with real known programs. const TEST_PROGRAM_ID: &str = "deadbeef1234deadbeef5678deadbeef"; @@ -849,19 +851,7 @@ fn size_guard_vec_u64_over_budget() { // // 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 - } - } -} +use common::load_idl_from_env; /// Crash-safety test against a real IDL loaded from IDL_FILE. /// diff --git a/src/chain_parsers/visualsign-solana/tests/real_idl_validation.rs b/src/chain_parsers/visualsign-solana/tests/real_idl_validation.rs new file mode 100644 index 00000000..11caa613 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/real_idl_validation.rs @@ -0,0 +1,86 @@ +//! Structural invariant tests for real production IDLs. +//! +//! These tests assert properties of the decoded IDL structure itself — +//! no random input generation, no proptest. They verify that `decode_idl_data` +//! produces well-formed IDLs from production JSON files. +//! +//! Run: `IDL_FILE=/path/to/idl.json cargo test --test real_idl_validation` +//! All IDLs: `scripts/fuzz_all_idls.sh` + +mod common; + +use common::load_idl_from_env; + +/// Every instruction in the decoded IDL must have a discriminator computed by +/// decode_idl_data (either provided explicitly or derived via Anchor's SHA256 +/// scheme). A missing discriminator means the instruction is unreachable. +#[test] +fn real_idl_all_instructions_have_discriminators() { + let Some((_, idl)) = load_idl_from_env() else { + return; + }; + for inst in &idl.instructions { + let disc = inst + .discriminator + .as_ref() + .unwrap_or_else(|| panic!("instruction '{}' has no discriminator", inst.name)); + assert_eq!( + disc.len(), + 8, + "instruction '{}' discriminator must be 8 bytes, got {}", + inst.name, + disc.len() + ); + } +} + +/// No two instructions in the IDL may share a discriminator — a collision would +/// make them indistinguishable at parse time. +#[test] +fn real_idl_discriminators_are_unique() { + let Some((_, idl)) = load_idl_from_env() else { + return; + }; + let mut seen: std::collections::HashMap, &str> = std::collections::HashMap::new(); + for inst in &idl.instructions { + if let Some(disc) = &inst.discriminator { + if let Some(existing) = seen.get(disc) { + panic!( + "discriminator collision between '{}' and '{}': {:?}", + existing, inst.name, disc + ); + } + seen.insert(disc.clone(), &inst.name); + } + } +} + +/// No two instructions may share a name — duplicate names make dispatch results +/// ambiguous and hint at an IDL construction error. +#[test] +fn real_idl_instruction_names_are_unique() { + let Some((_, idl)) = load_idl_from_env() else { + return; + }; + let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new(); + for inst in &idl.instructions { + assert!( + seen.insert(inst.name.as_str()), + "duplicate instruction name: '{}'", + inst.name + ); + } +} + +/// compute_idl_hash must be deterministic — the same JSON must produce the +/// same hash on every call. +#[test] +fn real_idl_idl_hash_is_stable() { + let Some((json, _)) = load_idl_from_env() else { + return; + }; + let h1 = solana_parser::compute_idl_hash(&json); + let h2 = solana_parser::compute_idl_hash(&json); + assert_eq!(h1, h2, "IDL hash must be deterministic"); + assert!(!h1.is_empty(), "IDL hash must not be empty"); +} diff --git a/src/rust-toolchain.toml b/src/rust-toolchain.toml index bb2af9b2..dd5e6333 100644 --- a/src/rust-toolchain.toml +++ b/src/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.88" +channel = "1.88.0" components = [ "rustfmt", "cargo", "clippy" ] profile = "minimal"