diff --git a/.cargo-mutants.toml b/.cargo-mutants.toml index 9fd326aa..bc8a53e0 100644 --- a/.cargo-mutants.toml +++ b/.cargo-mutants.toml @@ -26,6 +26,12 @@ examine_globs = [ "src/utils/crypto.rs", "src/commands/deploy.rs", "src/utils/templates.rs", + # Contract testing infrastructure + "src/utils/mock_soroban.rs", + "src/utils/wasm_hash.rs", + "src/utils/contract_mocks.rs", + "src/utils/contract_testing.rs", + "src/utils/test_generator.rs", ] # ── Skip ───────────────────────────────────────────────────────────────────── diff --git a/.github/workflows/fuzzing.yml b/.github/workflows/fuzzing.yml index c690d8b2..fca75283 100644 --- a/.github/workflows/fuzzing.yml +++ b/.github/workflows/fuzzing.yml @@ -7,6 +7,7 @@ on: - 'src/**' - 'fuzz/**' - 'tests/property_tests.rs' + - 'tests/contract_property_tests.rs' - '.github/workflows/fuzzing.yml' - 'Cargo.toml' pull_request: @@ -14,6 +15,7 @@ on: - 'src/**' - 'fuzz/**' - 'tests/property_tests.rs' + - 'tests/contract_property_tests.rs' - '.github/workflows/fuzzing.yml' - 'Cargo.toml' # Allow manual dispatch with configurable fuzz duration. @@ -66,6 +68,9 @@ jobs: - name: Run property-based tests run: cargo test --test property_tests --locked -- --test-threads=1 + - name: Run contract property-based tests + run: cargo test --test contract_property_tests --locked -- --test-threads=1 + - name: Run all tests (includes property tests) run: cargo test --locked -- --test-threads=1 @@ -122,6 +127,11 @@ jobs: - fuzz_wasm_hash - fuzz_encrypted_bundle_parse - fuzz_template_operations + # Contract fuzzing harnesses + - fuzz_wasm_validation + - fuzz_contract_invocation + - fuzz_contract_spec_parse + - fuzz_test_generator steps: - uses: actions/checkout@v4 diff --git a/FUZZING_GUIDE.md b/FUZZING_GUIDE.md index b36e3ff9..f506e712 100644 --- a/FUZZING_GUIDE.md +++ b/FUZZING_GUIDE.md @@ -44,6 +44,9 @@ in the contract crate and use the same `PROPTEST_CASES` setting as this guide. # Run property tests with default 256 cases per property. cargo test --test property_tests +# Run contract property tests. +cargo test --test contract_property_tests + # Increase cases for a deeper search. PROPTEST_CASES=5000 cargo test --test property_tests @@ -63,6 +66,9 @@ cargo fuzz list --fuzz-dir fuzz # Run a specific target for 60 seconds. cargo fuzz run fuzz_validate_public_key --fuzz-dir fuzz -- -max_total_time=60 +# Run a contract fuzz target. +cargo fuzz run fuzz_wasm_validation --fuzz-dir fuzz -- -max_total_time=60 + # Run with a size cap (good for initial exploration). cargo fuzz run fuzz_passphrase_strength --fuzz-dir fuzz \ -- -max_total_time=120 -max_len=1024 @@ -120,6 +126,7 @@ Additional property suites live in their own files: |---|---|---| | [`tests/config_property_tests.rs`](tests/config_property_tests.rs) | Config round trips | TOML/JSON serialization preserves every value; merging is identity-on-empty, idempotent, and overlay-wins; malformed combinations (unknown network, duplicate wallet, non-HTTP endpoint, unknown overlay key) are rejected | | [`tests/wallet_import_property_tests.rs`](tests/wallet_import_property_tests.rs) | Wallet import/backup | The same invariants the wallet fuzz targets assert, run on stable in every `cargo test` sweep | +| [`tests/contract_property_tests.rs`](tests/contract_property_tests.rs) | Contract testing infrastructure | WASM validation, WASM hash computation, mock contract invocation, mock storage, mock addresses, and mock environment invariants | ### Writing new properties @@ -164,6 +171,10 @@ macro that receives raw bytes and should **never panic** regardless of input. | `fuzz_wallet_backup_parse` | Wallet backup documents: malformed JSON, truncated files, invalid StrKeys, oversized inputs, Unicode names | | `fuzz_wallet_import_envelope` | Encrypted backup envelopes: base64 fields, salt/nonce lengths, truncated ciphertext, KDF parameters, plaintext/encrypted classification | | `fuzz_wallet_backup_structured` | Near-valid backup documents built with `arbitrary::Arbitrary`, to reach the semantic checks the byte-level harness rarely hits | +| `fuzz_wasm_validation` | WASM binary validation: magic header, minimum size, panic-freedom | +| `fuzz_contract_invocation` | Mock contract client invocation: call counting, return/error determinism, reset | +| `fuzz_contract_spec_parse` | Contract test spec JSON parsing: malformed JSON, truncated docs, hostile Unicode | +| `fuzz_test_generator` | Test case generation from source: malformed Rust, empty files, arbitrary fragments | ### Wallet import & backup harnesses @@ -203,6 +214,46 @@ The invariants asserted by the harnesses: - **No misclassification** — a JSON document is never treated as an encrypted bundle, which would prompt for a passphrase that does not exist. +### Contract fuzzing harnesses + +The contract fuzzing harnesses target the Soroban contract testing infrastructure +in StarForge. These harnesses exercise the mock Soroban environment, WASM +validation, contract spec parsing, and test case generation — all of which +process inputs that could come from untrusted contract source files or test +specifications. + +```bash +# WASM validation: magic header, minimum size, panic-freedom. +cargo fuzz run fuzz_wasm_validation --fuzz-dir fuzz -- -max_total_time=60 + +# Mock contract invocation: call counting, return/error determinism. +cargo fuzz run fuzz_contract_invocation --fuzz-dir fuzz -- -max_total_time=60 + +# Contract test spec JSON parsing: malformed JSON, truncated docs. +cargo fuzz run fuzz_contract_spec_parse --fuzz-dir fuzz -- -max_total_time=60 + +# Test case generation from source: malformed Rust, empty files. +cargo fuzz run fuzz_test_generator --fuzz-dir fuzz -- -max_total_time=60 +``` + +Seed corpora ship under `fuzz/corpus/fuzz_wasm_validation/`, +`fuzz/corpus/fuzz_contract_spec_parse/`, and +`fuzz/corpus/fuzz_test_generator/`, covering valid WASM binaries, valid +contract test specs, and valid Rust source fragments respectively. + +The invariants asserted by the contract harnesses: + +- **Totality** — every input is handled gracefully; nothing panics. +- **WASM validation** — inputs shorter than 8 bytes or without the `\0asm` + magic header are rejected; valid headers with sufficient length are accepted. +- **Mock invocation** — call counts always match the number of invocations; + pre-configured return values and errors are returned deterministically; + errors take priority over return values; reset clears all state. +- **Spec parsing** — malformed JSON, truncated documents, and hostile Unicode + produce errors, never panics. +- **Test generation** — malformed Rust source, empty files, and arbitrary + fragments produce errors or empty results, never panics. + ### Running a target ```bash @@ -313,7 +364,7 @@ The fuzzing CI pipeline is defined in | Job | Trigger | What it does | |---|---|---| -| `property-tests` | Every push / PR | Runs `cargo test --test property_tests` with 2 000 cases | +| `property-tests` | Every push / PR | Runs `cargo test --test property_tests` and `cargo test --test contract_property_tests` with 2 000 cases | | `fuzz-build` | Every push / PR | Compiles all fuzz targets (catches compilation errors) | | `fuzz-smoke` | Every push / PR | 30-second smoke run per target in a matrix | | `coverage` | Every push / PR | Generates LCOV + JSON; uploads to Codecov | @@ -335,7 +386,7 @@ both systems: ### Property test ```rust -// In tests/property_tests.rs +// In tests/contract_property_tests.rs proptest! { #[test] fn prop_my_contract_validates_input(amount in valid_amount_string()) { @@ -377,3 +428,8 @@ because they process untrusted external input or handle cryptographic material: | `check_passphrase_strength` | `utils/crypto.rs` | zxcvbn integration, minimum length gate | | `compute_local_wasm_hash` | `commands/deploy.rs` | On-chain hash consistency | | `validate_contract_id` | `utils/config.rs` | Contract address validation | +| `validate_wasm` | `utils/mock_soroban.rs` | WASM binary validation, magic header checks | +| `compute_wasm_hash` | `utils/wasm_hash.rs` | WASM hash computation, environment validation | +| `MockContractClient::invoke` | `utils/contract_mocks.rs` | Mock contract invocation, call logging | +| `load_contract_test_spec` | `utils/contract_testing.rs` | Contract test spec parsing (JSON/TOML) | +| `generate_from_source` | `utils/test_generator.rs` | Test case generation from Rust source | diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index b403903f..d05dfc01 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -25,6 +25,9 @@ hex = "0.4" # JSON assembly for the structured wallet-backup harness. serde_json = "1.0" +# Temp-file creation for harnesses that write fuzzer input to disk. +tempfile = "3.8" + # ── Fuzz targets ───────────────────────────────────────────────────────────── # Each [[bin]] entry corresponds to a file under fuzz/fuzz_targets/. # Run a specific target with: @@ -101,3 +104,31 @@ name = "fuzz_wallet_backup_structured" path = "fuzz_targets/fuzz_wallet_backup_structured.rs" test = false doc = false + +# ── Contract fuzzing harnesses ─────────────────────────────────────────────── +# These harnesses target Soroban contract testing infrastructure: WASM +# validation, mock contract invocation, spec parsing, and test generation. + +[[bin]] +name = "fuzz_wasm_validation" +path = "fuzz_targets/fuzz_wasm_validation.rs" +test = false +doc = false + +[[bin]] +name = "fuzz_contract_invocation" +path = "fuzz_targets/fuzz_contract_invocation.rs" +test = false +doc = false + +[[bin]] +name = "fuzz_contract_spec_parse" +path = "fuzz_targets/fuzz_contract_spec_parse.rs" +test = false +doc = false + +[[bin]] +name = "fuzz_test_generator" +path = "fuzz_targets/fuzz_test_generator.rs" +test = false +doc = false diff --git a/fuzz/corpus/fuzz_contract_spec_parse/empty_spec.json b/fuzz/corpus/fuzz_contract_spec_parse/empty_spec.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/fuzz/corpus/fuzz_contract_spec_parse/empty_spec.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/fuzz/corpus/fuzz_contract_spec_parse/malformed_spec.json b/fuzz/corpus/fuzz_contract_spec_parse/malformed_spec.json new file mode 100644 index 00000000..4eb384d7 --- /dev/null +++ b/fuzz/corpus/fuzz_contract_spec_parse/malformed_spec.json @@ -0,0 +1 @@ +{"contract_id":"CA3C5F6A9B2D1E0F4C7A8B3D5E6F1A2C3B4D5E6F7A8B9C0D1E2F3A4B5C6D7E8F","environment":"testnet","test_cases":[{"name":"test_transfer","description":"Test basic transfer","inputs":{"from":"GABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789","to":"GABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789","amount":"100"},"expected":{"success":true}] \ No newline at end of file diff --git a/fuzz/corpus/fuzz_contract_spec_parse/valid_spec.json b/fuzz/corpus/fuzz_contract_spec_parse/valid_spec.json new file mode 100644 index 00000000..42fbf555 --- /dev/null +++ b/fuzz/corpus/fuzz_contract_spec_parse/valid_spec.json @@ -0,0 +1 @@ +{"contract_id":"CA3C5F6A9B2D1E0F4C7A8B3D5E6F1A2C3B4D5E6F7A8B9C0D1E2F3A4B5C6D7E8F","environment":"testnet","test_cases":[{"name":"test_transfer","description":"Test basic transfer","inputs":{"from":"GABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789","to":"GABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789","amount":"100"},"expected":{"success":true}}]} \ No newline at end of file diff --git a/fuzz/corpus/fuzz_test_generator/empty_source.rs b/fuzz/corpus/fuzz_test_generator/empty_source.rs new file mode 100644 index 00000000..e75385e5 --- /dev/null +++ b/fuzz/corpus/fuzz_test_generator/empty_source.rs @@ -0,0 +1 @@ +// empty source file for fuzzing diff --git a/fuzz/corpus/fuzz_test_generator/valid_source.rs b/fuzz/corpus/fuzz_test_generator/valid_source.rs new file mode 100644 index 00000000..c4b7dc7d --- /dev/null +++ b/fuzz/corpus/fuzz_test_generator/valid_source.rs @@ -0,0 +1,5 @@ +#[test] +fn test_transfer() { + let result = contract.transfer("alice", "bob", 100); + assert!(result.is_ok()); +} \ No newline at end of file diff --git a/fuzz/corpus/fuzz_wasm_validation/valid_wasm.bin b/fuzz/corpus/fuzz_wasm_validation/valid_wasm.bin new file mode 100644 index 00000000..8c008a23 --- /dev/null +++ b/fuzz/corpus/fuzz_wasm_validation/valid_wasm.bin @@ -0,0 +1 @@ +\0asm\x01\x00\x00\x00\x01\x07\x01\x03\x65\x6e\x76\x00\x02\x00\x00 \ No newline at end of file diff --git a/fuzz/fuzz_targets/fuzz_contract_invocation.rs b/fuzz/fuzz_targets/fuzz_contract_invocation.rs new file mode 100644 index 00000000..5ad59202 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_contract_invocation.rs @@ -0,0 +1,74 @@ +//! Fuzz harness: mock contract invocation. +//! +//! Exercises `MockContractClient::invoke` with structured, arbitrary inputs +//! generated via `arbitrary::Arbitrary`. This drives the mock contract +//! client's call-logging, response-lookup, and error-handling paths with +//! random function names, argument vectors, and caller identities. +//! +//! The harness verifies that: +//! - Invocation never panics for any input. +//! - Call counts are always consistent with the number of invocations. +//! - Pre-configured errors and return values are returned deterministically. +//! +//! Run with: +//! cargo fuzz run fuzz_contract_invocation + +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use starforge::utils::contract_mocks::{MockAddress, MockContractClient}; + +/// Structured fuzzer input for contract invocation. +#[derive(Debug, Arbitrary)] +struct FuzzInvocation { + /// Function name to invoke (arbitrary string). + function: String, + /// Arguments as JSON values. + args: Vec, + /// Whether to pre-configure a return value. + configure_return: bool, + /// Whether to pre-configure an error. + configure_error: bool, + /// Number of times to invoke before checking. + repeat: u8, +} + +fuzz_target!(|input: FuzzInvocation| { + let contract = MockAddress::contract(1); + let client = MockContractClient::new(contract.clone()); + + // Optionally pre-configure a return value or error. + if input.configure_return { + client.mock_return(&input.function, serde_json::json!(42u64)); + } + if input.configure_error { + client.mock_error(&input.function, "fuzz-error"); + } + + // Invoke the function `repeat` times — must never panic. + for _ in 0..input.repeat { + let caller = if input.repeat % 2 == 0 { + Some(MockAddress::account(1)) + } else { + None + }; + let _ = client.invoke(&input.function, input.args.clone(), caller, 100); + } + + // Postcondition: call count must match the number of invocations. + let expected_count = input.repeat as usize; + assert_eq!( + client.call_count(&input.function), + expected_count, + "call count mismatch for function {:?}", + input.function + ); + + // Postcondition: total calls must match. + assert_eq!( + client.total_calls(), + expected_count, + "total call count mismatch" + ); +}); diff --git a/fuzz/fuzz_targets/fuzz_contract_spec_parse.rs b/fuzz/fuzz_targets/fuzz_contract_spec_parse.rs new file mode 100644 index 00000000..a2f07642 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_contract_spec_parse.rs @@ -0,0 +1,27 @@ +//! Fuzz harness: contract test spec parsing. +//! +//! Exercises `load_contract_test_spec` with arbitrary byte sequences written +//! to a temporary file. The spec parser is a trust boundary — the JSON/TOML +//! document comes from a file the user was handed — so it must be total: +//! malformed JSON, truncated documents, deeply nested structures, and +//! hostile Unicode all have to produce an error, never a panic. +//! +//! Run with: +//! cargo fuzz run fuzz_contract_spec_parse + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use starforge::utils::contract_testing::load_contract_test_spec; +use std::io::Write; + +fuzz_target!(|data: &[u8]| { + // Write the fuzzer input to a temp file with a .json extension so the + // parser dispatches to the JSON deserializer. + let mut tmp = tempfile::NamedTempFile::with_suffix(".json").unwrap(); + tmp.write_all(data).unwrap(); + tmp.flush().unwrap(); + + // Must never panic — only return Ok or Err. + let _ = load_contract_test_spec(tmp.path()); +}); diff --git a/fuzz/fuzz_targets/fuzz_test_generator.rs b/fuzz/fuzz_targets/fuzz_test_generator.rs new file mode 100644 index 00000000..9dcb8a27 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_test_generator.rs @@ -0,0 +1,25 @@ +//! Fuzz harness: contract test case generation from source. +//! +//! Exercises `generate_from_source` with arbitrary Rust source fragments +//! written to a temporary file. The generator parses `pub fn` signatures +//! and produces test cases; it must be total — malformed source, empty +//! files, and hostile Unicode must produce an error, never a panic. +//! +//! Run with: +//! cargo fuzz run fuzz_test_generator + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use starforge::utils::test_generator::generate_from_source; +use std::io::Write; + +fuzz_target!(|data: &[u8]| { + // Write the fuzzer input to a temp file as Rust source. + let mut tmp = tempfile::NamedTempFile::with_suffix(".rs").unwrap(); + tmp.write_all(data).unwrap(); + tmp.flush().unwrap(); + + // Must never panic — only return Ok or Err. + let _ = generate_from_source(tmp.path()); +}); diff --git a/fuzz/fuzz_targets/fuzz_wasm_validation.rs b/fuzz/fuzz_targets/fuzz_wasm_validation.rs new file mode 100644 index 00000000..e928273a --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_wasm_validation.rs @@ -0,0 +1,20 @@ +//! Fuzz harness: WASM validation for Soroban contracts. +//! +//! Exercises `mock_soroban::validate_wasm` with arbitrary byte sequences. +//! The validator is a trust boundary — it runs on every WASM file before +//! the contract testing framework processes it — so it must be total: +//! no input should ever cause a panic, out-of-bounds read, or unbounded +//! allocation. +//! +//! Run with: +//! cargo fuzz run fuzz_wasm_validation + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use starforge::utils::mock_soroban::validate_wasm; + +fuzz_target!(|data: &[u8]| { + // Must never panic regardless of input size or content. + let _ = validate_wasm(data); +}); diff --git a/tests/contract_property_tests.rs b/tests/contract_property_tests.rs new file mode 100644 index 00000000..b1fd46d1 --- /dev/null +++ b/tests/contract_property_tests.rs @@ -0,0 +1,317 @@ +//! Property-based tests for Soroban contract testing infrastructure. +//! +//! These tests use `proptest` to automatically generate inputs and verify +//! invariants that must hold for all valid (and many invalid) inputs in the +//! contract testing, WASM validation, and mock execution paths. +//! +//! Run with: +//! cargo test --test contract_property_tests +//! +//! Increase iterations for deeper coverage: +//! PROPTEST_CASES=5000 cargo test --test contract_property_tests + +#![allow(dead_code, unused_imports)] + +use proptest::prelude::*; +use starforge::utils::contract_mocks::{ + MockAddress, MockContractClient, MockEnvironment, MockStorage, StorageKey, +}; +use starforge::utils::mock_soroban::validate_wasm; +use starforge::utils::wasm_hash::{compute_wasm_hash, BuildEnvironment}; + +// ───────────────────────────────────────────────────────────────────────────── +// 1. WASM validation — property tests +// ───────────────────────────────────────────────────────────────────────────── + +proptest! { + /// Any byte sequence shorter than 8 bytes must be rejected. + #[test] + fn prop_short_wasm_rejected(data in prop::collection::vec(any::(), 0..8)) { + let result = validate_wasm(&data); + prop_assert!(result.is_err(), "short WASM should be rejected"); + } + + /// Any byte sequence with a valid 4-byte magic header but < 8 bytes is rejected. + #[test] + fn prop_magic_header_short_rejected(data in prop::collection::vec(any::(), 4..8)) { + let mut input = b"\0asm".to_vec(); + input.extend(data); + let result = validate_wasm(&input); + prop_assert!(result.is_err(), "short WASM with magic header should be rejected"); + } + + /// Any byte sequence >= 8 bytes with a valid magic header is accepted. + #[test] + fn prop_valid_magic_header_accepted(data in prop::collection::vec(any::(), 4..1024)) { + let mut input = b"\0asm".to_vec(); + input.extend(data); + let result = validate_wasm(&input); + prop_assert!(result.is_ok(), "WASM with valid header and >= 8 bytes should be accepted"); + } + + /// Any byte sequence without the magic header is rejected. + #[test] + fn prop_missing_magic_header_rejected(data in prop::collection::vec(any::(), 8..1024)) { + prop_assume!(!data.starts_with(b"\0asm")); + let result = validate_wasm(&data); + prop_assert!(result.is_err(), "WASM without magic header should be rejected"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. WASM hash computation — property tests +// ───────────────────────────────────────────────────────────────────────────── + +proptest! { + /// WASM hash of empty bytes must be rejected. + #[test] + fn prop_empty_wasm_hash_rejected(_data: ()) { + let result = compute_wasm_hash(&[], BuildEnvironment::Linux); + prop_assert!(result.is_err(), "empty WASM should be rejected"); + } + + /// WASM hash of bytes without magic header must be rejected. + #[test] + fn prop_no_magic_wasm_hash_rejected(data in prop::collection::vec(any::(), 8..1024)) { + prop_assume!(!data.starts_with(b"\0asm")); + let result = compute_wasm_hash(&data, BuildEnvironment::Linux); + prop_assert!(result.is_err(), "WASM without magic header should be rejected"); + } + + /// WASM hash of valid WASM is always a 64-char lowercase hex string. + #[test] + fn prop_valid_wasm_hash_format(data in prop::collection::vec(any::(), 4..1024)) { + let mut input = b"\0asm".to_vec(); + input.extend(data); + if let Ok(hash) = compute_wasm_hash(&input, BuildEnvironment::Linux) { + prop_assert_eq!(hash.len(), 64); + prop_assert!(hash.chars().all(|c| c.is_ascii_hexdigit())); + prop_assert!(hash.chars().all(|c| !c.is_ascii_uppercase())); + } + } + + /// WASM hash is deterministic: same input always produces same output. + #[test] + fn prop_wasm_hash_deterministic(data in prop::collection::vec(any::(), 4..1024)) { + let mut input = b"\0asm".to_vec(); + input.extend(data); + if let Ok(h1) = compute_wasm_hash(&input, BuildEnvironment::Linux) { + let h2 = compute_wasm_hash(&input, BuildEnvironment::Linux).unwrap(); + prop_assert_eq!(h1, h2); + } + } + + /// WASM hash on unsupported environment is rejected. + #[test] + fn prop_unsupported_env_rejected(data in prop::collection::vec(any::(), 8..1024)) { + let mut input = b"\0asm".to_vec(); + input.extend(data); + let result = compute_wasm_hash(&input, BuildEnvironment::Unsupported("bsd".into())); + prop_assert!(result.is_err(), "unsupported environment should be rejected"); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Mock contract invocation — property tests +// ───────────────────────────────────────────────────────────────────────────── + +proptest! { + /// Invoking a function N times always records exactly N calls. + #[test] + fn prop_call_count_matches_invocations( + function in "[a-z_]{1,20}", + repeat in 0u8..10, + ) { + let client = MockContractClient::new(MockAddress::contract(1)); + for _ in 0..repeat { + let _ = client.invoke(&function, vec![], None, 100); + } + prop_assert_eq!(client.call_count(&function), repeat as usize); + prop_assert_eq!(client.total_calls(), repeat as usize); + } + + /// Pre-configured return values are returned deterministically. + #[test] + fn prop_mock_return_is_deterministic( + function in "[a-z_]{1,20}", + value in any::(), + ) { + let client = MockContractClient::new(MockAddress::contract(1)); + client.mock_return(&function, serde_json::json!(value)); + let result1 = client.invoke(&function, vec![], None, 100); + let result2 = client.invoke(&function, vec![], None, 100); + prop_assert!(result1.is_ok()); + prop_assert!(result2.is_ok()); + prop_assert_eq!(result1.unwrap(), result2.unwrap()); + } + + /// Pre-configured errors are returned deterministically. + #[test] + fn prop_mock_error_is_deterministic( + function in "[a-z_]{1,20}", + error_msg in "[a-z_]{1,30}", + ) { + let client = MockContractClient::new(MockAddress::contract(1)); + client.mock_error(&function, &error_msg); + let result1 = client.invoke(&function, vec![], None, 100); + let result2 = client.invoke(&function, vec![], None, 100); + prop_assert!(result1.is_err()); + prop_assert!(result2.is_err()); + prop_assert_eq!(result1.unwrap_err(), result2.unwrap_err()); + } + + /// Error takes priority over return value when both are configured. + #[test] + fn prop_error_takes_priority_over_return( + function in "[a-z_]{1,20}", + ) { + let client = MockContractClient::new(MockAddress::contract(1)); + client.mock_return(&function, serde_json::json!(42u64)); + client.mock_error(&function, "error"); + let result = client.invoke(&function, vec![], None, 100); + prop_assert!(result.is_err(), "error should take priority over return value"); + } + + /// Reset clears all call history and configurations. + #[test] + fn prop_reset_clears_state( + function in "[a-z_]{1,20}", + repeat in 1u8..5, + ) { + let client = MockContractClient::new(MockAddress::contract(1)); + client.mock_return(&function, serde_json::json!(1u64)); + for _ in 0..repeat { + let _ = client.invoke(&function, vec![], None, 100); + } + prop_assert_eq!(client.total_calls(), repeat as usize); + client.reset(); + prop_assert_eq!(client.total_calls(), 0); + prop_assert_eq!(client.call_count(&function), 0); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Mock storage — property tests +// ───────────────────────────────────────────────────────────────────────────── + +proptest! { + /// Setting and getting a value always returns the same value. + #[test] + fn prop_storage_set_get_roundtrip( + key in "[a-z_]{1,20}", + value in any::(), + ) { + let mut storage = MockStorage::new(); + let storage_key = StorageKey::instance(&key); + storage.set(storage_key.clone(), serde_json::json!(value)); + let retrieved = storage.get(&storage_key); + prop_assert!(retrieved.is_some()); + prop_assert_eq!(retrieved.unwrap(), &serde_json::json!(value)); + } + + /// Removing a key makes it absent. + #[test] + fn prop_storage_remove_makes_absent( + key in "[a-z_]{1,20}", + value in any::(), + ) { + let mut storage = MockStorage::new(); + let storage_key = StorageKey::persistent(&key); + storage.set(storage_key.clone(), serde_json::json!(value)); + prop_assert!(storage.has(&storage_key)); + storage.remove(&storage_key); + prop_assert!(!storage.has(&storage_key)); + } + + /// Storage length matches the number of unique keys set. + #[test] + fn prop_storage_len_matches_keys( + keys in prop::collection::vec("[a-z]{1,10}", 0..20), + value in any::(), + ) { + let mut storage = MockStorage::new(); + let unique_count = keys.iter().collect::>().len(); + for key in &keys { + storage.set(StorageKey::instance(key), serde_json::json!(value)); + } + prop_assert_eq!(storage.len(), unique_count); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Mock address — property tests +// ───────────────────────────────────────────────────────────────────────────── + +proptest! { + /// Account addresses always start with 'G' and are 57 chars (GA + 55 digits). + #[test] + fn prop_account_address_format(id in any::()) { + let addr = MockAddress::account(id); + let s = addr.as_str(); + prop_assert!(s.starts_with('G')); + prop_assert_eq!(s.len(), 57); + } + + /// Contract addresses always start with 'C' and are 63 chars (C + 62 hex). + #[test] + fn prop_contract_address_format(id in any::()) { + let addr = MockAddress::contract(id); + let s = addr.as_str(); + prop_assert!(s.starts_with('C')); + prop_assert_eq!(s.len(), 63); + } + + /// Display trait matches as_str. + #[test] + fn prop_display_matches_as_str(id in any::()) { + let addr = MockAddress::account(id); + prop_assert_eq!(format!("{}", addr), addr.as_str()); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 6. Mock environment — property tests +// ───────────────────────────────────────────────────────────────────────────── + +proptest! { + /// Reset clears storage, events, and auth but preserves ledger config. + #[test] + fn prop_env_reset_preserves_ledger( + initial_seq in 1u32..1000, + ) { + let mut env = MockEnvironment::new(); + env.ledger = env.ledger.clone().at_sequence(initial_seq); + env.storage.set(StorageKey::instance("test"), serde_json::json!(1u64)); + env.emit_event( + MockAddress::contract(1), + vec![serde_json::json!("test")], + serde_json::json!({"data": 1}), + ); + env.auth.auto_approve(MockAddress::account(1)); + + prop_assert!(!env.storage.is_empty()); + prop_assert!(!env.events.is_empty()); + prop_assert!(env.auth.auth_count() > 0); + + env.reset(); + + prop_assert!(env.storage.is_empty()); + prop_assert!(env.events.is_empty()); + prop_assert_eq!(env.auth.auth_count(), 0); + prop_assert_eq!(env.ledger.sequence, initial_seq); + } + + /// advance_ledger increments sequence and timestamp. + #[test] + fn prop_ledger_advance_increments( + initial_seq in 1u32..1000, + ledgers in 1u32..100, + ) { + let mut env = MockEnvironment::new(); + env.ledger = env.ledger.clone().at_sequence(initial_seq); + let initial_ts = env.ledger.timestamp; + env.advance_ledger(ledgers); + prop_assert_eq!(env.ledger.sequence, initial_seq + ledgers); + prop_assert_eq!(env.ledger.timestamp, initial_ts + u64::from(ledgers) * 5); + } +}