From da4b37c9fc9451dbfeee408a11e01597962da41e Mon Sep 17 00:00:00 2001 From: Cmitchelle7 Date: Sat, 29 Aug 2026 10:31:24 -0700 Subject: [PATCH 1/3] test: add proptest coverage for task-registry and reward-engine (#86) --- contracts/reward-engine/Cargo.toml | 1 + contracts/reward-engine/src/verification.rs | 102 ++++++++++++++++ contracts/task-registry/Cargo.toml | 1 + contracts/task-registry/src/registry.rs | 124 ++++++++++++++++++++ 4 files changed, 228 insertions(+) diff --git a/contracts/reward-engine/Cargo.toml b/contracts/reward-engine/Cargo.toml index 6a65970..d820796 100644 --- a/contracts/reward-engine/Cargo.toml +++ b/contracts/reward-engine/Cargo.toml @@ -24,3 +24,4 @@ ecotask-types = { path = "../ecotask-types" } soroban-sdk = { version = "26.0.1", features = ["testutils"] } eco-token = { path = "../eco-token" } task-registry = { path = "../task-registry" } +proptest = "=1.6.0" diff --git a/contracts/reward-engine/src/verification.rs b/contracts/reward-engine/src/verification.rs index c4053e4..53d5d6f 100644 --- a/contracts/reward-engine/src/verification.rs +++ b/contracts/reward-engine/src/verification.rs @@ -2454,4 +2454,106 @@ mod test { let proof_cid = String::from_str(&e, &long_cid); client.submit_proof(&oracle, &user, &task_id, &proof_cid); } + + use proptest::prelude::*; + + proptest! { + #![proptest_config(ProptestConfig::with_cases(20))] + + /// Property: approve_proof succeeds iff reward_amount falls within + /// [min_reward, max_reward] AND within the task's declared budget + /// (1000, per `setup()`'s create_test_task call). + #[test] + fn proptest_reward_range_enforced( + min_reward in 1i128..=1000, + range_width in 0i128..=1000, + reward_amount in 1i128..=2500, + ) { + let (e, admin, oracle, user, task_id, client) = setup(); + e.mock_all_auths_allowing_non_root_auth(); + + let max_reward = min_reward + range_width; + client.set_reward_range(&admin, &min_reward, &max_reward); + + let proof_cid = String::from_str(&e, "QmFuzzRange"); + client.submit_proof(&oracle, &user, &task_id, &proof_cid); + + let result = client.try_approve_proof(&oracle, &user, &task_id, &reward_amount); + + let within_range = reward_amount >= min_reward && reward_amount <= max_reward; + let within_budget = reward_amount <= 1000; + prop_assert_eq!(result.is_ok(), within_range && within_budget); + } + + /// Property: total_paid always equals the running sum of approved + /// reward amounts, across a randomized sequence of approvals. + #[test] + fn proptest_total_paid_accumulation( + amounts in prop::collection::vec(1i128..=1000, 1..=5), + ) { + let e = Env::default(); + e.mock_all_auths_allowing_non_root_auth(); + + let admin = Address::generate(&e); + let oracle = Address::generate(&e); + + let token_id = deploy_token(&e, &admin); + let reg_id = deploy_registry(&e, &admin); + let engine_id = e.register(RewardEngine, ()); + let engine_client = RewardEngineClient::new(&e, &engine_id); + + let reg_client = task_registry::RegistryContractClient::new(&e, ®_id); + reg_client.add_sponsor(&admin, &engine_id); + engine_client.initialize(&admin, &token_id, ®_id, &oracle); + + let cids = ["QmFuzzTotal0", "QmFuzzTotal1", "QmFuzzTotal2", "QmFuzzTotal3", "QmFuzzTotal4"]; + let mut expected_total: i128 = 0; + for (i, amount) in amounts.iter().enumerate() { + let user = Address::generate(&e); + let expires_at = e.ledger().timestamp() + 1_000_000; + let task_id = reg_client.create_task( + &admin, + &String::from_str(&e, "fuzz-total-paid"), + &soroban_sdk::BytesN::<32>::random(&e), + amount, + &1, + &expires_at, + ); + let cid = String::from_str(&e, cids[i]); + engine_client.submit_proof(&oracle, &user, &task_id, &cid); + engine_client.approve_proof(&oracle, &user, &task_id, amount); + expected_total += amount; + + prop_assert_eq!(engine_client.total_paid(), expected_total); + } + } + + /// Property: a second approval for the same user succeeds iff the + /// elapsed ledgers since the last reward are >= the configured cooldown. + #[test] + fn proptest_cooldown_boundary( + cooldown in 1u64..=100, + elapsed_offset in -5i64..=5, + ) { + let (e, admin, oracle, user, task1, task2, client) = setup_cooldown(); + + client.set_user_cooldown(&admin, &cooldown); + + let p1 = String::from_str(&e, "QmFuzzCooldown1"); + client.submit_proof(&oracle, &user, &task1, &p1); + client.approve_proof(&oracle, &user, &task1, &500); + + let base: u32 = e.ledger().sequence(); + let signed_target = base as i64 + cooldown as i64 + elapsed_offset; + let target: u32 = if signed_target < 0 { 0 } else { signed_target as u32 }; + e.ledger().set_sequence_number(target); + let elapsed: u64 = (target as u64).saturating_sub(base as u64); + + let p2 = String::from_str(&e, "QmFuzzCooldown2"); + client.submit_proof(&oracle, &user, &task2, &p2); + let result = client.try_approve_proof(&oracle, &user, &task2, &500); + + prop_assert_eq!(result.is_ok(), elapsed >= cooldown); + } + } } diff --git a/contracts/task-registry/Cargo.toml b/contracts/task-registry/Cargo.toml index 62df812..4736f93 100644 --- a/contracts/task-registry/Cargo.toml +++ b/contracts/task-registry/Cargo.toml @@ -13,3 +13,4 @@ ecotask-types = { path = "../ecotask-types" } [dev-dependencies] soroban-sdk = { version = "26.0.1", features = ["testutils"] } +proptest = "=1.6.0" diff --git a/contracts/task-registry/src/registry.rs b/contracts/task-registry/src/registry.rs index 7dd2a58..21ea2b6 100644 --- a/contracts/task-registry/src/registry.rs +++ b/contracts/task-registry/src/registry.rs @@ -1952,4 +1952,128 @@ mod test { assert_eq!(id, i as u64); // tasks were created 0..n in order } } + + use proptest::prelude::*; + + proptest! { + #![proptest_config(ProptestConfig::with_cases(20))] + + /// Property: create_task succeeds iff reward_amount > 0, max_completions > 0, + /// and expires_at is strictly in the future — and task_count only advances + /// on success. + #[test] + fn proptest_create_task_validation( + reward_amount in -1000i128..=1_000_000_000i128, + max_completions in 0u32..=100, + expires_offset in -1000i64..=1_000_000i64, + ) { + let (e, admin, client) = setup(); + e.mock_all_auths(); + + let now = e.ledger().timestamp(); + let expires_at = if expires_offset < 0 { + now.saturating_sub((-expires_offset) as u64) + } else { + now + expires_offset as u64 + }; + + let count_before = client.task_count(); + let loc_hash: BytesN<32> = BytesN::random(&e); + let task_type = String::from_str(&e, "fuzz-task"); + + let result = client.try_create_task( + &admin, + &task_type, + &loc_hash, + &reward_amount, + &max_completions, + &expires_at, + ); + + let should_succeed = reward_amount > 0 && max_completions > 0 && expires_at > now; + prop_assert_eq!(result.is_ok(), should_succeed); + + let count_after = client.task_count(); + if should_succeed { + prop_assert_eq!(count_after, count_before + 1); + } else { + prop_assert_eq!(count_after, count_before); + } + } + + /// Property: complete_task succeeds iff the probe timestamp is <= expires_at + /// (strict-less expiry semantics), for randomized durations and probe offsets + /// straddling the boundary. + #[test] + fn proptest_expiry_boundary( + duration in 1u64..=1_000_000, + probe_offset in -5i64..=5, + ) { + let (e, admin, client) = setup(); + e.mock_all_auths(); + + e.ledger().set_timestamp(1_000_000); + let start = e.ledger().timestamp(); + let expires_at = start + duration; + + let task_id = create_test_task( + &client, + &admin, + &String::from_str(&e, "fuzz-boundary"), + 1, + duration, + ); + + let probe_timestamp = if probe_offset < 0 { + expires_at.saturating_sub((-probe_offset) as u64) + } else { + expires_at + probe_offset as u64 + }; + e.ledger().set_timestamp(probe_timestamp); + + let user = Address::generate(&e); + let result = client.try_complete_task(&admin, &task_id, &user); + + if probe_timestamp <= expires_at { + prop_assert!(result.is_ok()); + } else { + prop_assert!(result.is_err()); + } + } + + /// Invariant: no sequence of valid complete_task calls ever leaves + /// completions > max_completions, and status flips to Completed exactly + /// when the cap is reached. + #[test] + fn proptest_completion_count_invariant( + max_completions in 1u32..=8, + attempts in 1u32..=12, + ) { + let (e, admin, client) = setup(); + e.mock_all_auths(); + + let task_id = create_test_task( + &client, + &admin, + &String::from_str(&e, "fuzz-completions"), + max_completions, + 1_000_000, + ); + + let mut successes = 0u32; + for _ in 0..attempts { + let user = Address::generate(&e); + let result = client.try_complete_task(&admin, &task_id, &user); + if result.is_ok() { + successes += 1; + } + let task = client.get_task(&task_id); + prop_assert!(task.completions <= task.max_completions); + if task.completions == task.max_completions { + prop_assert_eq!(task.status, TaskStatus::Completed); + } + } + prop_assert!(successes <= max_completions); + } + } } From f27e93434fe56030f3c6527d85440446dde8ee9a Mon Sep 17 00:00:00 2001 From: Cmitchelle7 Date: Sun, 30 Aug 2026 03:15:59 -0700 Subject: [PATCH 2/3] Fix merge: remove duplicate closing braces introduced during conflict resolution --- contracts/reward-engine/src/verification.rs | 31 ++++++++++----------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/contracts/reward-engine/src/verification.rs b/contracts/reward-engine/src/verification.rs index da63fb2..a59a9f6 100644 --- a/contracts/reward-engine/src/verification.rs +++ b/contracts/reward-engine/src/verification.rs @@ -1,4 +1,4 @@ -use crate::storage; +use crate::storage; use ecotask_types::{Task, TaskStatus}; use soroban_sdk::{ contract, contractevent, contractimpl, vec, Address, BytesN, Env, IntoVal, String, Symbol, Val, @@ -256,8 +256,8 @@ fn collect_pending(e: &Env, cursor: u64, limit: u32) -> soroban_sdk::Vec soroban_sdk::Vec( @@ -910,7 +910,7 @@ impl RewardEngine { // Same atomic ordering as `approve_proof`: validation, then the // cross-contract calls, then the local Approved write (see // `approve_and_pay`). A mint failure here leaves the verification - // Disputed — not Approved — with no completed task or payout. + // Disputed — not Approved — with no completed task or payout. approve_and_pay(&e, &user, task_id, reward_amount, &mut verification); } else { verification.status = VerificationStatus::Rejected; @@ -982,7 +982,7 @@ impl RewardEngine { /// last verification already returned (pass 0 for the first page); fetch /// the next page with the `seq` of the last returned verification. /// Because `seq` values are immutable and unique, resolving an entry - /// between pages can never shift or reorder the remaining entries — each + /// between pages can never shift or reorder the remaining entries — each /// pending verification is returned exactly once across the whole /// pagination, even when approvals, rejections, or disputes happen /// mid-pagination. @@ -994,7 +994,7 @@ impl RewardEngine { /// removed from the pending list, every later entry shifts left by one, /// and a caller resuming from a saved offset silently skips an entry. /// The cursor is now a sequence number. Persisted offset cursors are - /// invalid and must be re-anchored — restart from 0, or read `seq` off + /// invalid and must be re-anchored — restart from 0, or read `seq` off /// the last record already processed. /// /// # Arguments @@ -1046,7 +1046,7 @@ impl RewardEngine { /// Pageable history of a single user's verifications across all tasks, /// ordered by submission. Reads only the requested page of the user's - /// sequence index — never the user's full history. + /// sequence index — never the user's full history. /// /// # Arguments /// @@ -1699,7 +1699,7 @@ mod test { // A quiet period of INSTANCE_TTL_EXTEND_TO - 1 ledgers (one short // of the refreshed TTL) must not break the engine: approve_proof - // still succeeds — oracle check, verification lookup, registry + // still succeeds — oracle check, verification lookup, registry // complete_task, and token mint all work with the config intact. e.ledger() .set_sequence_number(e.ledger().sequence() + INSTANCE_TTL_EXTEND_TO - 1); @@ -2265,7 +2265,7 @@ mod test { client.reject_proof(&oracle, &users[2], &task_id); client.dispute_proof(&admin, &users[5], &task_id); - // Page 1 resumes at seq 2: it must return seqs 4 and 5 — exactly + // Page 1 resumes at seq 2: it must return seqs 4 and 5 — exactly // once, with no skips caused by the mid-pagination resolutions. let page1 = client.get_pending_verifications_paged(&cursor, &2); assert_eq!(page1.len(), 2); @@ -2279,7 +2279,7 @@ mod test { assert_eq!(page2.len(), 0); // The unbounded view returns exactly the remaining pending set, in - // submission order (seqs 1, 2, 4, 5 — the rejected and disputed + // submission order (seqs 1, 2, 4, 5 — the rejected and disputed // entries are gone). let pending = client.get_pending_verifications_paged(&0, &50); assert_eq!(pending.len(), 4); @@ -2451,7 +2451,7 @@ mod test { fn test_cooldown_zero_disabled() { let (e, _admin, oracle, user, task1, task2, client) = setup_cooldown(); - // Default cooldown is 0 (disabled) — back-to-back rewards succeed. + // Default cooldown is 0 (disabled) — back-to-back rewards succeed. let p1 = String::from_str(&e, "QmCooldownDisabled1"); client.submit_proof(&oracle, &user, &task1, &p1); client.approve_proof(&oracle, &user, &task1, &500); @@ -2486,7 +2486,6 @@ mod test { client.submit_proof(&oracle, &user, &task_id, &proof_cid); } -<<<<<<< HEAD use proptest::prelude::*; proptest! { @@ -2587,7 +2586,8 @@ mod test { prop_assert_eq!(result.is_ok(), elapsed >= cooldown); } -======= + } + #[test] #[should_panic(expected = "engine: page size exceeds maximum of 50")] fn test_get_pending_verifications_paged_limit_exceeds_max_panics() { @@ -2604,6 +2604,5 @@ mod test { e.mock_all_auths_allowing_non_root_auth(); client.get_verifications_by_user(&user, &0, &51); ->>>>>>> upstream/main } } From 1459f1d5389c84debaa67fdd07d5ad958c467328 Mon Sep 17 00:00:00 2001 From: Cmitchelle7 Date: Sun, 30 Aug 2026 03:32:00 -0700 Subject: [PATCH 3/3] docs: add changelog entry for proptest coverage (#86) --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78f446b..2d91acf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -185,6 +185,16 @@ Error strings: every successful minter rotation, containing the `admin` (topic), `previous_minter`, and `new_minter` fields. +#### `task-registry` and `reward-engine` + +- **[#86] Added proptest fuzz coverage, extending the pattern from #41 + (previously eco-token only).** `task-registry` gains properties covering + create_task input validation, expiry boundary semantics, and the + completion-count invariant (`completions <= max_completions` under any + valid call sequence). `reward-engine` gains properties covering reward + range enforcement, `total_paid` accumulation across randomized approval + sequences, and the cooldown boundary (`saturating_sub` behavior from + both sides). No production contract logic changed; test-only addition. ### Changed