diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 00000000..e81728fc --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1 @@ +examine_globs = ["src/zk_range_proof.rs", "src/verkle.rs"] diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml new file mode 100644 index 00000000..db2239cb --- /dev/null +++ b/.github/workflows/mutation.yml @@ -0,0 +1,57 @@ +name: Mutation Testing + +on: + schedule: + # Nightly at 03:00 UTC — mutation testing is slow so not run per-PR + - cron: '0 3 * * *' + workflow_dispatch: + # Allow manual trigger from the Actions tab + inputs: + filter: + description: 'Test name filter (optional)' + required: false + default: '' + type: string + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -D warnings + +jobs: + mutants: + name: cargo-mutants (zk_range_proof.rs, verkle.rs) + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-mutants + run: cargo install cargo-mutants --locked + + - name: Run mutation tests + run: | + FILTER="${{ inputs.filter }}" + if [ -n "$FILTER" ]; then + cargo mutants \ + --file src/zk_range_proof.rs \ + --file src/verkle.rs \ + --timeout 300 \ + -- -- "$FILTER" + else + cargo mutants \ + --file src/zk_range_proof.rs \ + --file src/verkle.rs \ + --timeout 300 + fi + working-directory: contracts/ledgerlens-score + + - name: Upload mutation report + if: always() + uses: actions/upload-artifact@v4 + with: + name: mutation-report + path: contracts/ledgerlens-score/mutants.out + retention-days: 30 diff --git a/contracts/ledgerlens-score/src/test_verkle.rs b/contracts/ledgerlens-score/src/test_verkle.rs index 91e90c95..878237b8 100644 --- a/contracts/ledgerlens-score/src/test_verkle.rs +++ b/contracts/ledgerlens-score/src/test_verkle.rs @@ -15,6 +15,11 @@ use soroban_sdk::{ }; use crate::{ + verkle::{ + self, xor32, derive_evaluation_point, derive_value_element, hash_leaf, + encode_proof, decode_proof, commitment_to_bytes48, bytes48_to_commitment, + verify_proof, + }, LedgerLensScoreContract, LedgerLensScoreContractClient, ScoreSubmission, }; @@ -777,3 +782,168 @@ fn range_proof_all_scores_below_80() { assert!(all_below_80, "range claim 'all < 80' must hold"); } + +// ── Pure verkle primitive tests (catch mutants in verkle.rs) ─────────────── + +#[test] +fn test_xor32_commutative() { + let a = [1u8; 32]; + let b = [2u8; 32]; + let ab = xor32(&a, &b); + let ba = xor32(&b, &a); + assert_eq!(ab, ba, "xor32 must be commutative"); +} + +#[test] +fn test_xor32_identity() { + let a = [0xABu8; 32]; + let zero = [0u8; 32]; + assert_eq!(xor32(&a, &zero), a, "a XOR 0 == a"); +} + +#[test] +fn test_xor32_self_inverse() { + let a = [0xABu8; 32]; + let zero = [0u8; 32]; + assert_eq!(xor32(&a, &a), zero, "a XOR a == 0"); +} + +#[test] +fn test_derive_evaluation_point_deterministic() { + let env = Env::default(); + let wallet_bytes = [3u8; 56]; + let pair_bytes = [4u8; 9]; + let z1 = derive_evaluation_point(&env, &wallet_bytes, &pair_bytes); + let z2 = derive_evaluation_point(&env, &wallet_bytes, &pair_bytes); + assert_eq!(z1, z2, "derive_evaluation_point must be deterministic"); +} + +#[test] +fn test_derive_evaluation_point_differs_for_diff_inputs() { + let env = Env::default(); + let wallet_a = [1u8; 56]; + let wallet_b = [2u8; 56]; + let pair = [3u8; 9]; + let za = derive_evaluation_point(&env, &wallet_a, &pair); + let zb = derive_evaluation_point(&env, &wallet_b, &pair); + assert_ne!(za, zb, "different wallets must produce different z"); +} + +#[test] +fn test_derive_value_element_deterministic() { + let env = Env::default(); + let z = [5u8; 32]; + let v1 = derive_value_element(&env, 42, 1000, &z); + let v2 = derive_value_element(&env, 42, 1000, &z); + assert_eq!(v1, v2, "derive_value_element must be deterministic"); +} + +#[test] +fn test_derive_value_element_differs_for_diff_scores() { + let env = Env::default(); + let z = [5u8; 32]; + let v1 = derive_value_element(&env, 42, 1000, &z); + let v2 = derive_value_element(&env, 99, 1000, &z); + assert_ne!(v1, v2, "different scores must produce different v"); +} + +#[test] +fn test_hash_leaf_deterministic() { + let env = Env::default(); + let z = [6u8; 32]; + let v = [7u8; 32]; + let h1 = hash_leaf(&env, &z, &v); + let h2 = hash_leaf(&env, &z, &v); + assert_eq!(h1, h2, "hash_leaf must be deterministic"); +} + +#[test] +fn test_encode_decode_roundtrip_member() { + let env = Env::default(); + let z = [8u8; 32]; + let v = [9u8; 32]; + let witness = [10u8; 32]; + let encoded = encode_proof(&env, true, &z, &v, &witness); + let decoded = decode_proof(&encoded); + assert!(decoded.is_some(), "decode must succeed for valid proof"); + let (is_member, dz, dv, dw) = decoded.unwrap(); + assert!(is_member, "round-tripped is_member must be true"); + assert_eq!(dz, z, "round-tripped z must match"); + assert_eq!(dv, v, "round-tripped v must match"); + assert_eq!(dw, witness, "round-tripped witness must match"); +} + +#[test] +fn test_encode_decode_roundtrip_nonmember() { + let env = Env::default(); + let z = [11u8; 32]; + let v = [0u8; 32]; + let witness = [12u8; 32]; + let encoded = encode_proof(&env, false, &z, &v, &witness); + let decoded = decode_proof(&encoded); + assert!(decoded.is_some(), "decode must succeed for non-member proof"); + let (is_member, _dz, _dv, _dw) = decoded.unwrap(); + assert!(!is_member, "round-tripped is_member must be false"); +} + +#[test] +fn test_decode_fails_for_short_input() { + let env = Env::default(); + let short = Bytes::from_array(&env, &[1u8, 2u8]); + assert!(decode_proof(&short).is_none(), "decode must fail for too-short input"); +} + +#[test] +fn test_commitment_bytes48_roundtrip() { + let env = Env::default(); + let commit = [13u8; 32]; + let b48 = commitment_to_bytes48(&env, &commit); + let decoded = bytes48_to_commitment(&b48); + assert!(decoded.is_some(), "bytes48_to_commitment must succeed for valid prefix"); + assert_eq!(decoded.unwrap(), commit, "round-tripped commitment must match"); +} + +#[test] +fn test_commitment_bytes48_prefix_check() { + let env = Env::default(); + // A 48-byte array without the correct prefix should be rejected. + let bad = BytesN::<48>::from_array(&env, &[0xFFu8; 48]); + assert!(bytes48_to_commitment(&bad).is_none(), "must reject missing prefix"); +} + +#[test] +fn test_verify_proof_with_bogus_witness() { + let env = Env::default(); + let commitment = [0u8; 32]; + let z = [14u8; 32]; + let v = [15u8; 32]; + let bogus_witness = [16u8; 32]; + // A random witness should not verify against empty commitment. + assert!( + !verify_proof(&env, &commitment, &z, &v, &bogus_witness), + "bogus witness must not verify" + ); +} + +#[test] +fn test_update_commitment_is_deterministic() { + let env = Env::default(); + let prev = [17u8; 32]; + let z = [18u8; 32]; + let v = [19u8; 32]; + let c1 = verkle::update_commitment(&env, &prev, &z, &v); + let c2 = verkle::update_commitment(&env, &prev, &z, &v); + assert_eq!(c1, c2, "update_commitment must be deterministic"); +} + +#[test] +fn test_update_commitment_different_z_different_result() { + let env = Env::default(); + let prev = [20u8; 32]; + let v = [22u8; 32]; + let z1 = [21u8; 32]; + let z2 = [23u8; 32]; + let c1 = verkle::update_commitment(&env, &prev, &z1, &v); + let c2 = verkle::update_commitment(&env, &prev, &z2, &v); + assert_ne!(c1, c2, "different z must produce different commitments"); +} diff --git a/contracts/ledgerlens-score/src/test_zk_range_proof.rs b/contracts/ledgerlens-score/src/test_zk_range_proof.rs index 54817607..ed55a3a9 100644 --- a/contracts/ledgerlens-score/src/test_zk_range_proof.rs +++ b/contracts/ledgerlens-score/src/test_zk_range_proof.rs @@ -1,7 +1,7 @@ extern crate std; use crate::{ - zk_range_proof::{Sc, SeededPrng, get_generators, compress_pt, prove_range_proof}, + zk_range_proof::{Fe, Pt, Sc, SeededPrng, Bulletproof, get_generators, compress_pt, decompress_pt_32, is_on_curve, prove_range_proof}, LedgerLensScoreContract, LedgerLensScoreContractClient, }; use soroban_sdk::{testutils::Address as _, Address, Bytes, BytesN, Env, Symbol, Vec}; @@ -257,3 +257,137 @@ fn test_verify_score_range_proof_tampered_proof() { ); assert!(!result); } + +// ── Pure arithmetic tests (catch mutants in Fe/Sc operations) ────────────── + +#[test] +fn test_fe_arithmetic_add_identity() { + let a = Fe::from_u64(42); + let zero = Fe::zero(); + assert_eq!(a.add(zero).to_bytes(), a.to_bytes(), "a + 0 == a"); +} + +#[test] +fn test_fe_arithmetic_mul_identity() { + let a = Fe::from_u64(7); + let one = Fe::one(); + let r = a.mul(one); + assert_eq!(r.to_bytes(), a.to_bytes(), "a * 1 == a"); +} + +#[test] +fn test_fe_arithmetic_mul_zero() { + let a = Fe::from_u64(99); + let zero = Fe::zero(); + let r = a.mul(zero); + assert!(r.is_zero(), "a * 0 == 0"); +} + +#[test] +fn test_fe_arithmetic_neg_add() { + let a = Fe::from_u64(123); + let neg = a.neg(); + let r = a.add(neg); + assert!(r.is_zero(), "a + (-a) == 0"); +} + +#[test] +fn test_fe_arithmetic_mul_invert() { + let env = Env::default(); + let a = Fe::from_u64(5); + let inv = a.invert(); + let r = a.mul(inv); + let one = Fe::one(); + assert_eq!(r.to_bytes(), one.to_bytes(), "a * a^(-1) == 1"); +} + +#[test] +fn test_fe_arithmetic_sub_self() { + let a = Fe::from_u64(77); + let r = a.sub(a); + assert!(r.is_zero(), "a - a == 0"); +} + +#[test] +fn test_sc_arithmetic_mul_identity() { + let a = Sc::from_u64(42); + let one = Sc::one(); + let r = a.mul(one); + assert_eq!(r.to_bytes(), a.to_bytes(), "a * 1 == a"); +} + +#[test] +fn test_sc_arithmetic_mul_zero() { + let a = Sc::from_u64(99); + let zero = Sc::zero(); + let r = a.mul(zero); + assert!(r.is_zero(), "a * 0 == 0"); +} + +#[test] +fn test_sc_arithmetic_add_neg() { + let a = Sc::from_u64(7); + let r = a.add(a.neg()); + assert!(r.is_zero(), "a + (-a) == 0"); +} + +// ── Curve point tests ────────────────────────────────────────────────────── + +#[test] +fn test_is_on_curve_valid_points() { + let (g_pt, h_pt, d) = get_generators(); + assert!(is_on_curve(g_pt.x, g_pt.y, d), "generator G is on curve"); + assert!(is_on_curve(h_pt.x, h_pt.y, d), "generator H is on curve"); +} + +#[test] +fn test_point_add_identity() { + let (g_pt, _h_pt, d) = get_generators(); + let id = Pt::identity(); + let r = g_pt.add(id, d); + assert_eq!(r.x.to_bytes(), g_pt.x.to_bytes(), "G + identity == G (x)"); + assert_eq!(r.y.to_bytes(), g_pt.y.to_bytes(), "G + identity == G (y)"); +} + +#[test] +fn test_point_mul_scalar_zero() { + let (g_pt, _h_pt, d) = get_generators(); + let zero = Sc::zero(); + let r = g_pt.mul(zero, d); + assert!(r.is_identity(), "G * 0 == identity"); +} + +#[test] +fn test_point_mul_scalar_one() { + let (g_pt, _h_pt, d) = get_generators(); + let one = Sc::one(); + let r = g_pt.mul(one, d); + assert_eq!(r.x.to_bytes(), g_pt.x.to_bytes(), "G * 1 == G (x)"); + assert_eq!(r.y.to_bytes(), g_pt.y.to_bytes(), "G * 1 == G (y)"); +} + +// ── Serialisation round-trip tests ───────────────────────────────────────── + +#[test] +fn test_compress_decompress_roundtrip() { + let env = Env::default(); + let (g_pt, _h_pt, _d) = get_generators(); + let compressed = compress_pt(&env, &g_pt); + let decompressed = decompress_pt_32(&env, &compressed); + assert!(decompressed.is_some(), "decompress must succeed"); + let pt = decompressed.unwrap(); + assert_eq!(pt.x.to_bytes(), g_pt.x.to_bytes(), "x must match after round-trip"); + assert_eq!(pt.y.to_bytes(), g_pt.y.to_bytes(), "y must match after round-trip"); +} + +#[test] +fn test_bulletproof_roundtrip() { + let env = Env::default(); + let v = 9u32; + let r = Sc::from_u64(123456789); + let prng = SeededPrng::new([2u8; 32]); + let proof = prove_range_proof(&env, v, r, prng); + let bytes = proof.to_bytes(&env); + let decoded = Bulletproof::from_bytes(&bytes); + assert!(decoded.is_some(), "deserialize must succeed"); +} diff --git a/scripts/mutation-test.sh b/scripts/mutation-test.sh new file mode 100755 index 00000000..45bb2701 --- /dev/null +++ b/scripts/mutation-test.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# ────────────────────────────────────────────────────────────────────────────── +# mutation-test.sh — Run cargo-mutants scoped to zk_range_proof & verkle +# +# Usage: +# ./scripts/mutation-test.sh # run all tests (slow) +# ./scripts/mutation-test.sh -- test_verkle # filter to verkle only +# ./scripts/mutation-test.sh -- test_zk # filter to range proof only +# +# Requires: cargo-mutants >= 27 +# Install: cargo install cargo-mutants --locked +# ────────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +CRATE_DIR="$(dirname "$0")/../contracts/ledgerlens-score" +FILTER_ARGS=("$@") + +echo "==> cargo-mutants: zk_range_proof.rs + verkle.rs" +echo " Filter args: ${FILTER_ARGS[*]:-(none)}" +echo "" + +exec cargo mutants \ + --manifest-path "$CRATE_DIR/Cargo.toml" \ + --file src/zk_range_proof.rs \ + --file src/verkle.rs \ + --timeout 300 \ + "${FILTER_ARGS[@]}"