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 new file mode 100644 index 00000000..00fca303 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,99 @@ +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: Extract fuzz_transaction_string crash output + id: extract_fuzz_transaction_string + if: steps.fuzz_transaction_string.outcome == 'failure' + run: | + body=$(awk '/^─+$/{found=1} found{print}' /tmp/fuzz_transaction_string.txt) + 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 + 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: Extract fuzz_versioned_transaction crash output + id: extract_fuzz_versioned_transaction + if: steps.fuzz_versioned_transaction.outcome == 'failure' + run: | + body=$(awk '/^─+$/{found=1} found{print}' /tmp/fuzz_versioned_transaction.txt) + 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/main.yml b/.github/workflows/main.yml index c6ad3512..a9618854 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] jobs: ubuntu: + if: | + github.ref == 'refs/heads/main' || + github.base_ref == 'main' runs-on: ubuntu-latest-4-cores steps: - name: git checkout diff --git a/.github/workflows/proptest.yml b/.github/workflows/proptest.yml new file mode 100644 index 00000000..2925c7c0 --- /dev/null +++ b/.github/workflows/proptest.yml @@ -0,0 +1,73 @@ +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 + permissions: + pull-requests: write + 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 + 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 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/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/Cargo.lock b/src/Cargo.lock index 371475f1..c4848a0a 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]] @@ -10549,7 +10549,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?branch=solana-parser-add-arbitrary#3a523cc6fdc4d72e69cc2b08387f44cbf16ea22c" dependencies = [ "bincode", "bs58 0.5.1", @@ -10557,6 +10557,7 @@ dependencies = [ "heck 0.5.0", "hex", "log", + "proptest", "serde", "serde_json", "sha2 0.10.9", @@ -12127,7 +12128,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -13021,6 +13022,7 @@ dependencies = [ "generated", "hex", "jupiter-swap-api-client", + "proptest", "serde", "serde_json", "solana-program", @@ -13312,7 +13314,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]] 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..784ff0c5 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", branch = "solana-parser-add-arbitrary" } 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 = { 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" base64 = "0.22.1" bs58 = "0.5" +proptest = "1" 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" 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( 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..913720a7 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/fuzz_idl_parsing.rs @@ -0,0 +1,666 @@ +//! 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` + +use proptest::prelude::*; +use solana_parser::arb; +use solana_parser::solana::structs::{ + 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"; + +// ── 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 with a defined struct type correlated between `types` and instruction args. +/// +/// Exercises the `Defined` type resolution path through `types`. +fn arb_defined_struct_idl_json() -> impl Strategy { + ( + arb::arb_identifier(), + prop::collection::vec( + (arb::arb_identifier(), arb::arb_primitive_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 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); + } + } + } + + /// 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)); +} + +// ── 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 + } + } +} + +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::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..470ed077 --- /dev/null +++ b/src/chain_parsers/visualsign-solana/tests/pipeline_integration.rs @@ -0,0 +1,417 @@ +//! 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::arb; +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::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 = 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 — 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 = 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) { + 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(()); }; + 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}"); + } + } + } +}