Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cargo/mutants.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
examine_globs = ["src/zk_range_proof.rs", "src/verkle.rs"]
57 changes: 57 additions & 0 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
@@ -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
170 changes: 170 additions & 0 deletions contracts/ledgerlens-score/src/test_verkle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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");
}
136 changes: 135 additions & 1 deletion contracts/ledgerlens-score/src/test_zk_range_proof.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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");
}
Loading
Loading