diff --git a/docs/contributor-guides/testing-visualizations.mdx b/docs/contributor-guides/testing-visualizations.mdx index 664798b9..8f92a016 100644 --- a/docs/contributor-guides/testing-visualizations.mdx +++ b/docs/contributor-guides/testing-visualizations.mdx @@ -126,6 +126,57 @@ Verify your fixture passes: cargo test -p visualsign- test_my_protocol ``` +## Property-based testing (Solana) + +Solana IDL parsing includes [proptest](https://proptest-rs.github.io/proptest/)-based fuzz tests that verify crash safety and correctness across randomly generated IDLs and instruction data. These tests live in: + +- `src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs` — parser-level fuzz and roundtrip tests +- `src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs` — full-pipeline integration tests + +### Running fuzz tests + +```bash +# Default 256 cases per property +cargo test -p visualsign-solana --test fuzz_idl_parsing + +# More iterations for deeper fuzzing +PROPTEST_CASES=5000 cargo test -p visualsign-solana --test fuzz_idl_parsing +``` + +### Testing against real IDLs + +The `scripts/fuzz_all_idls.sh` script runs fuzz tests against all embedded production IDLs in one pass: + +```bash +./scripts/fuzz_all_idls.sh +``` + +You can also target a specific IDL: + +```bash +IDL_FILE=/path/to/my_program.json cargo test -p visualsign-solana --test fuzz_idl_parsing real_idl +``` + +### Roundtrip tests + +A roundtrip test constructs an IDL and matching borsh-encoded instruction bytes, feeds them through the parser, and verifies the output matches expectations. "Roundtrip" refers to the encode-then-decode cycle: you know exactly what went in, so you can assert exactly what comes out. + +There are two kinds in use: + +- **Concrete roundtrips** (e.g., `roundtrip_single_u64_arg`) — Hand-crafted IDL JSON and hand-crafted byte payloads. These assert that specific parsed values match exactly (e.g., `amount == 42`). They serve as specification-by-example: each test documents one type scenario (no args, mixed primitives, `Option`, `Vec`, defined structs, multi-instruction dispatch). + +- **Property-based roundtrips** (e.g., `fuzz_valid_data_always_parses_ok`) — Randomly generated IDL shapes paired with machine-generated valid borsh bytes from `arb_valid_instruction_bytes`. These assert that parsing succeeds and the instruction name matches, without checking specific field values. They verify the parser's contract holds across all type combinations, not just the hand-picked examples. + +Both kinds complement each other: concrete roundtrips pin down known-good behavior, while property-based roundtrips explore the space of inputs you did not think to write by hand. + +### Adding a new test + +1. Write a strategy that generates the IDL shape you want to test (or use an existing one from `solana_parser_fuzz_core::proptest`) +2. Add a `proptest!` test that exercises the parser with generated inputs +3. Add a concrete roundtrip test for the same scenario to serve as specification-by-example +4. Run the tests — if proptest finds a failure, it saves a regression seed to `.proptest-regressions` +5. Commit the `.proptest-regressions` file so the failing case is reproduced in CI + ## Validation checklist Before submitting your visualization: diff --git a/scripts/fuzz_all_idls.sh b/scripts/fuzz_all_idls.sh new file mode 100755 index 00000000..7a0a123c --- /dev/null +++ b/scripts/fuzz_all_idls.sh @@ -0,0 +1,150 @@ +#!/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]+") + 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/Cargo.lock b/src/Cargo.lock index 371475f1..56a9279a 100644 --- a/src/Cargo.lock +++ b/src/Cargo.lock @@ -9355,6 +9355,16 @@ dependencies = [ "serde_with", ] +[[package]] +name = "solana-parser-fuzz-core" +version = "0.0.0" +source = "git+https://github.com/anchorageoss/solana-parser.git?rev=a0c554d#a0c554d7a4d756cbe6c9bed080737faa9aa74705" +dependencies = [ + "proptest", + "serde_json", + "solana_parser", +] + [[package]] name = "solana-poh-config" version = "2.2.1" @@ -10549,7 +10559,7 @@ dependencies = [ [[package]] name = "solana_parser" version = "0.1.0" -source = "git+https://github.com/prasincs/solana-parser.git?rev=8248d99e42ce8a56ad440ed9b2201607feb1a150#8248d99e42ce8a56ad440ed9b2201607feb1a150" +source = "git+https://github.com/anchorageoss/solana-parser.git?rev=a0c554d#a0c554d7a4d756cbe6c9bed080737faa9aa74705" dependencies = [ "bincode", "bs58 0.5.1", @@ -13021,8 +13031,10 @@ dependencies = [ "generated", "hex", "jupiter-swap-api-client", + "proptest", "serde", "serde_json", + "solana-parser-fuzz-core", "solana-program", "solana-sdk", "solana-system-interface 1.0.0", diff --git a/src/Cargo.toml b/src/Cargo.toml index 91fdaa83..5dcab309 100644 --- a/src/Cargo.toml +++ b/src/Cargo.toml @@ -59,3 +59,4 @@ tracing = "0.1.41" # Pin visualsign dependencies visualsign = { path = "./visualsign" } + diff --git a/src/chain_parsers/visualsign-solana/Cargo.toml b/src/chain_parsers/visualsign-solana/Cargo.toml index 0498f3c1..4bd54cee 100644 --- a/src/chain_parsers/visualsign-solana/Cargo.toml +++ b/src/chain_parsers/visualsign-solana/Cargo.toml @@ -6,10 +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 -# Tracking: https://github.com/tkhq/solana-parser/pull/20 -solana_parser = { git = "https://github.com/prasincs/solana-parser.git", rev = "8248d99e42ce8a56ad440ed9b2201607feb1a150" } +solana_parser = { git = "https://github.com/anchorageoss/solana-parser.git", rev = "a0c554d" } visualsign = { workspace = true } generated = { path = "../../generated" } serde_json = { workspace = true } @@ -28,8 +25,10 @@ spl-token-2022 = "10.0.0" spl-token-2022-interface = "2.1.0" [dev-dependencies] +solana-parser-fuzz-core = { git = "https://github.com/anchorageoss/solana-parser.git", rev = "a0c554d", features = ["proptest"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" jupiter-swap-api-client = "0.2.0" base64 = "0.22.1" bs58 = "0.5" +proptest = "1" diff --git a/src/chain_parsers/visualsign-solana/tests/common/mod.rs b/src/chain_parsers/visualsign-solana/tests/common/mod.rs new file mode 100644 index 00000000..5887fd1e --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/common/mod.rs @@ -0,0 +1,44 @@ +//! Shared test helpers for IDL-based fuzz and integration tests. + +use solana_parser::decode_idl_data; +use solana_parser::solana::structs::Idl; + +/// Decode an IDL JSON string, extract the discriminator for the instruction at +/// `inst_idx`, and return `(idl, data)` where `data` = discriminator ++ `arg_bytes`. +/// +/// Returns `None` if decoding fails, the IDL has no instructions, or the +/// selected instruction has no discriminator. +pub fn build_disc_data( + idl_json: &str, + inst_idx: usize, + arg_bytes: &[u8], +) -> Option<(Idl, Vec)> { + let idl = decode_idl_data(idl_json).ok()?; + if idl.instructions.is_empty() { + return None; + } + let inst = &idl.instructions[inst_idx % idl.instructions.len()]; + let disc = inst.discriminator.as_ref()?; + let mut data = disc.clone(); + data.extend_from_slice(arg_bytes); + Some((idl, data)) +} + +/// Build instruction bytes using a 50/50 valid-discriminator / random-data split. +/// +/// When `use_valid_disc` is true, attempts to prepend a real discriminator from +/// the IDL instruction at `inst_idx`. Falls back to raw `data` if decoding +/// fails, the IDL has no instructions, or the instruction has no discriminator. +pub fn build_maybe_disc_bytes( + idl_json: &str, + use_valid_disc: bool, + inst_idx: usize, + data: Vec, +) -> Vec { + if use_valid_disc { + if let Some((_idl, disc_data)) = build_disc_data(idl_json, inst_idx, &data) { + return disc_data; + } + } + data +} diff --git a/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.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 new file mode 100644 index 00000000..d101ce72 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -0,0 +1,954 @@ +//! 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 +//! - 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` +//! +//! # Adding new tests +//! +//! 1. **Write a strategy** for the IDL shape you want to cover (see +//! `arb_defined_struct_idl_json` / `arb_defined_enum_idl_json` for examples), +//! or reuse one from `solana_parser_fuzz_core::proptest`. +//! 2. **Add a proptest** in the `proptest!` block — use the 50/50 valid-disc / +//! random-data pattern for crash-safety, or `arb_idl_and_valid_bytes` for +//! correctness assertions. +//! 3. **Add a concrete roundtrip test** that hand-crafts an IDL + borsh bytes +//! and asserts exact parsed values. This pins the behavior as +//! specification-by-example. +//! 4. **Run tests** — proptest saves any failing seed to +//! `fuzz_idl_parsing.proptest-regressions`. Commit that file. + +use proptest::prelude::*; +use solana_parser::solana::structs::{ + Defined, EnumFields, Idl, IdlEnumVariant, IdlField, IdlType, IdlTypeDefinition, + IdlTypeDefinitionType, +}; +use solana_parser::{decode_idl_data, parse_instruction_with_idl}; +use solana_parser_fuzz_core::proptest as arb; +use std::sync::Arc; + +// parse_instruction_with_idl ignores the program_id parameter (_program_id); +// use an obviously fake value to avoid confusion with real known programs. +const TEST_PROGRAM_ID: &str = "deadbeef1234deadbeef5678deadbeef"; + +// ── Local strategies ───────────────────────────────────────────────────────── +// +// Core strategies (`arb_identifier`, `arb_primitive_idl_type`, `arb_idl_type`, +// `arb_idl_instruction`, `arb_idl`, `arb_idl_json`, `arb_bytes_for_type`, +// `arb_valid_instruction_bytes`) live in `solana_parser_fuzz_core::proptest` +// (aliased as `arb`) and are shared with `pipeline_integration.rs`. + +/// IDL JSON with a defined struct type correlated between `types` and instruction args. +/// +/// Exercises the `Defined` type resolution path through `types`. +/// Fields use `arb_idl_type()` (not just primitives), so container types +/// like `Vec`, `Option`, and `Array` appear inside the struct. +fn arb_defined_struct_idl_json() -> impl Strategy { + ( + arb::arb_identifier(), + prop::collection::vec( + (arb::arb_identifier(), arb::arb_idl_type()) + .prop_map(|(n, t)| IdlField { name: n, r#type: t }), + 1..=8, + ), + 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() + }) +} + +/// IDL JSON with a defined enum type correlated between `types` and instruction args. +/// +/// Generates enums with a mix of unit, tuple, and named (struct-like) variants, +/// exercising the `Defined` → `Enum` type resolution path. +/// Variant fields use `arb_idl_type()` so containers appear inside variants. +fn arb_defined_enum_idl_json() -> impl Strategy { + ( + arb::arb_identifier(), + prop::collection::vec( + ( + arb::arb_identifier(), + prop::option::of(prop::bool::ANY.prop_flat_map(|use_named| { + if use_named { + prop::collection::vec( + (arb::arb_identifier(), arb::arb_idl_type()) + .prop_map(|(n, t)| IdlField { name: n, r#type: t }), + 1..=4, + ) + .prop_map(EnumFields::Named) + .boxed() + } else { + prop::collection::vec(arb::arb_idl_type(), 1..=4) + .prop_map(EnumFields::Tuple) + .boxed() + } + })), + ) + .prop_map(|(name, fields)| IdlEnumVariant { name, fields }), + 1..=6, + ), + arb::arb_idl_instruction(), + prop::collection::vec(arb::arb_idl_instruction(), 0..=4), + ) + .prop_map(|(enum_name, variants, mut main_inst, mut extra_insts)| { + main_inst.args = vec![IdlField { + name: "data".to_string(), + r#type: IdlType::Defined(Defined::String(enum_name.clone())), + }]; + extra_insts.push(main_inst); + let idl = Idl { + instructions: extra_insts, + types: vec![IdlTypeDefinition { + name: enum_name, + r#type: IdlTypeDefinitionType::Enum { variants }, + }], + }; + serde_json::to_string(&idl).unwrap() + }) +} + +/// IDL JSON with a defined alias type (a named wrapper around another type). +/// +/// Exercises the `Defined` → `Alias` type resolution path. The alias value +/// uses `arb_idl_type()` so it can be a primitive, Vec, Option, or Array. +fn arb_defined_alias_idl_json() -> impl Strategy { + ( + arb::arb_identifier(), + arb::arb_idl_type(), + arb::arb_idl_instruction(), + prop::collection::vec(arb::arb_idl_instruction(), 0..=4), + ) + .prop_map(|(alias_name, alias_type, mut main_inst, mut extra_insts)| { + main_inst.args = vec![IdlField { + name: "data".to_string(), + r#type: IdlType::Defined(Defined::String(alias_name.clone())), + }]; + extra_insts.push(main_inst); + let idl = Idl { + instructions: extra_insts, + types: vec![IdlTypeDefinition { + name: alias_name, + r#type: IdlTypeDefinitionType::Alias { value: alias_type }, + }], + }; + serde_json::to_string(&idl).unwrap() + }) +} + +/// IDL JSON where the single instruction has a `Vec` arg. +/// +/// Used to stress-test the SizeGuard, which guards against large length-prefix +/// 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::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() + }) + }) +} + +/// 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::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)) + }) + }) +} + +// ── 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 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::arb_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()]; + 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); + } + } + } + + /// `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 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::arb_idl_json(), + inst_idx in any::(), + arg_bytes in prop::collection::vec(any::(), 0..1300usize), + ) { + 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); + } + } + } + + /// IDLs with defined enum types must not panic regardless of instruction bytes. + /// Uses the same 50/50 valid-discriminator mix as the struct variant test. + /// + /// Exercises unit, tuple, and named enum variants through the `Defined` → + /// `Enum` type resolution path. + #[test] + fn fuzz_defined_enum_types_never_panics( + idl_json in arb_defined_enum_idl_json(), + use_valid_disc in any::(), + inst_idx in any::(), + data in prop::collection::vec(any::(), 0..200usize), + ) { + if let Ok(idl) = decode_idl_data(&idl_json) { + if use_valid_disc && !idl.instructions.is_empty() { + let inst = &idl.instructions[inst_idx % idl.instructions.len()]; + let expected_name = inst.name.clone(); + if let Some(disc) = &inst.discriminator { + let mut d = disc.clone(); + d.extend_from_slice(&data); + if let Ok(result) = parse_instruction_with_idl(&d, TEST_PROGRAM_ID, &idl) { + prop_assert_eq!(&result.instruction_name, &expected_name, + "enum-type instruction must dispatch to the correct handler"); + } + } + } else { + let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + } + } + } + + /// IDLs with defined alias types must not panic regardless of instruction bytes. + /// Uses the same 50/50 valid-discriminator mix as the struct/enum tests. + /// + /// An alias is a named wrapper around another type (e.g., `type Amount = u64`). + #[test] + fn fuzz_defined_alias_types_never_panics( + idl_json in arb_defined_alias_idl_json(), + use_valid_disc in any::(), + inst_idx in any::(), + data in prop::collection::vec(any::(), 0..200usize), + ) { + if let Ok(idl) = decode_idl_data(&idl_json) { + if use_valid_disc && !idl.instructions.is_empty() { + let inst = &idl.instructions[inst_idx % idl.instructions.len()]; + let expected_name = inst.name.clone(); + if let Some(disc) = &inst.discriminator { + let mut d = disc.clone(); + d.extend_from_slice(&data); + if let Ok(result) = parse_instruction_with_idl(&d, TEST_PROGRAM_ID, &idl) { + prop_assert_eq!(&result.instruction_name, &expected_name, + "alias-type instruction must dispatch to the correct handler"); + } + } + } else { + let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + } + } + } + + /// Valid input must always parse successfully. + /// + /// Unlike the other crash-safety tests, this one asserts `result.is_ok()` — + /// 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, inst_idx, bytes) in arb_idl_and_valid_bytes(), + ) { + 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. + /// + /// 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); + } + } + } + } +} + +// ── 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)); +} + +// ── 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)); +} + +/// An instruction whose arg is a defined struct containing a field that +/// references another defined struct — exercises recursive type resolution. +#[test] +fn roundtrip_nested_defined_struct() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "placeOrder", "accounts": [], "args": [ + {"name": "order", "type": {"defined": "Order"}} + ]}], + "types": [ + { + "name": "Order", + "type": {"kind": "struct", "fields": [ + {"name": "amount", "type": "u64"}, + {"name": "config", "type": {"defined": "AssetConfig"}}, + ]} + }, + { + "name": "AssetConfig", + "type": {"kind": "struct", "fields": [ + {"name": "decimals", "type": "u8"}, + {"name": "active", "type": "bool"}, + ]} + }, + ] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let mut data = disc.clone(); + data.extend_from_slice(&7500u64.to_le_bytes()); // order.amount + data.push(6u8); // order.config.decimals + data.push(1u8); // order.config.active = true + + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.instruction_name, "placeOrder"); + let order = &result.program_call_args["order"]; + assert_eq!(order["amount"], serde_json::json!(7500)); + let config = &order["config"]; + assert_eq!(config["decimals"], serde_json::json!(6)); + assert_eq!(config["active"], serde_json::json!(true)); +} + +/// An instruction whose arg is a defined enum with unit, tuple, and named +/// variants — exercises enum discriminant dispatch and field decoding. +#[test] +fn roundtrip_defined_enum_arg() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "setMode", "accounts": [], "args": [ + {"name": "mode", "type": {"defined": "Mode"}} + ]}], + "types": [{ + "name": "Mode", + "type": {"kind": "enum", "variants": [ + {"name": "Off"}, + {"name": "Fixed", "fields": [{"name": "rate", "type": "u64"}]}, + {"name": "Scaled", "fields": ["u32", "bool"]}, + ]} + }] + }) + .to_string(); + + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + // Variant 0: Off (unit) + let mut data = disc.clone(); + data.push(0u8); // variant index + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.instruction_name, "setMode"); + + // Variant 1: Fixed { rate: 500 } (named) + let mut data = disc.clone(); + data.push(1u8); // variant index + data.extend_from_slice(&500u64.to_le_bytes()); + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.instruction_name, "setMode"); + + // Variant 2: Scaled(100, true) (tuple) + let mut data = disc.clone(); + data.push(2u8); // variant index + data.extend_from_slice(&100u32.to_le_bytes()); + data.push(1u8); // true + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl).unwrap(); + assert_eq!(result.instruction_name, "setMode"); +} + +// ── Error-path tests ───────────────────────────────────────────────────────── + +/// An instruction arg that references a `Defined("MissingType")` not present in +/// the `types` array must produce `Err`, not panic. +/// +/// Note: `decode_idl_data` does NOT validate that instruction-arg Defined +/// references exist in `types` — the error only surfaces at parse time. +#[test] +fn dangling_defined_reference_returns_err() { + let idl_json = serde_json::json!({ + "instructions": [{"name": "broken", "accounts": [], "args": [ + {"name": "data", "type": {"defined": "MissingType"}} + ]}], + "types": [] + }) + .to_string(); + + // IDL loads successfully — dangling ref is not caught at load time. + let idl = decode_idl_data(&idl_json).unwrap(); + let disc = idl.instructions[0].discriminator.as_ref().unwrap(); + + let mut data = disc.clone(); + data.extend_from_slice(&[0u8; 16]); // arbitrary payload + + let result = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl); + assert!( + result.is_err(), + "expected Err for dangling Defined reference, got Ok" + ); +} + +// ── SizeGuard boundary tests ────────────────────────────────────────────────── + +/// A Vec arg with a length prefix that vastly exceeds the backing data +/// 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" + ); +} + +// ── 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 + } + } +} + +/// Crash-safety test against a real IDL loaded from IDL_FILE. +/// +/// Uses TestRunner::run directly to load the IDL once (not per iteration). +/// Applies the same 50/50 valid/random discriminator mix as +/// `fuzz_idl_parsing_never_panics`. On `Ok` with a valid discriminator, +/// asserts the instruction name matches the selected instruction. +#[test] +fn real_idl_never_panics() { + let Some((_, idl)) = load_idl_from_env() else { + return; + }; + + let strategy = ( + any::(), + any::(), + prop::collection::vec(any::(), 0..1300usize), + ); + + let config = ProptestConfig::default(); + let mut runner = proptest::test_runner::TestRunner::new(config); + let idl_ref = idl.clone(); + runner + .run(&strategy, move |(use_valid_disc, inst_idx, data)| { + if use_valid_disc && !idl_ref.instructions.is_empty() { + let inst = &idl_ref.instructions[inst_idx % idl_ref.instructions.len()]; + let expected_name = &inst.name; + if let Some(disc) = &inst.discriminator { + let mut d = disc.clone(); + d.extend_from_slice(&data); + if let Ok(result) = parse_instruction_with_idl(&d, TEST_PROGRAM_ID, &idl_ref) { + prop_assert_eq!( + &result.instruction_name, + expected_name, + "discriminator must dispatch to the correct instruction" + ); + } + } + } else { + let _ = parse_instruction_with_idl(&data, TEST_PROGRAM_ID, &idl_ref); + } + Ok(()) + }) + .expect("real_idl_never_panics failed"); +} + +/// 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::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"); +} 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..81505784 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs @@ -0,0 +1,394 @@ +//! 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. + +mod common; + +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_parser_fuzz_core::proptest as arb; +use solana_sdk::instruction::{AccountMeta, Instruction}; +use solana_sdk::message::Message; +use solana_sdk::pubkey::Pubkey; +use solana_sdk::transaction::Transaction as SolanaTransaction; +use visualsign::vsptrait::VisualSignOptions; +use visualsign::{ + AnnotatedPayloadField, SignablePayload, SignablePayloadField, SignablePayloadFieldPreviewLayout, +}; +use visualsign_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()); +} + +// ── Property-based pipeline tests ──────────────────────────────────────────── + +proptest! { + // Default 256 cases; override with PROPTEST_CASES=N. + #![proptest_config(ProptestConfig::default())] + + /// 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::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 bytes = common::build_maybe_disc_bytes(&idl_json, use_valid_disc, inst_idx, data); + let tx = build_transaction(program_id, vec![], bytes); + let _ = transaction_to_visual_sign(tx, options_with_idl(&program_id, &idl_json, "F")); + } + + /// The number of instruction fields in the output always equals the number + /// of instructions in the transaction — regardless of valid/invalid discriminator. + #[test] + fn fuzz_pipeline_field_count_invariant( + idl_json in arb::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 bytes = common::build_maybe_disc_bytes(&idl_json, use_valid_disc, inst_idx, data); + let tx = build_transaction(program_id, vec![], bytes); + let inst_count = tx.message.instructions.len(); + let options = options_with_idl(&program_id, &idl_json, "F"); + 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::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(()); }; + if idl.instructions.is_empty() { 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}"); + } + } + } +}