Skip to content
Open
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions contracts/reward-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
128 changes: 115 additions & 13 deletions contracts/reward-engine/src/verification.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -256,8 +256,8 @@ fn collect_pending(e: &Env, cursor: u64, limit: u32) -> soroban_sdk::Vec<Verific
///
/// # Ordering (atomicity) rationale
///
/// This function follows the pattern *read-all validate-all call
/// external contracts write local state*. Every validation (reward bounds,
/// This function follows the pattern *read-all → validate-all → call
/// external contracts → write local state*. Every validation (reward bounds,
/// task budget, free completion slot, user cooldown) runs before any storage
/// mutation in this contract, and the `Approved` verification record is only
/// written *after* both `complete_task` and `mint` have returned.
Expand All @@ -266,7 +266,7 @@ fn collect_pending(e: &Env, cursor: u64, limit: u32) -> soroban_sdk::Vec<Verific
/// panics, but the engine never relies on that: by deferring the local write
/// until after the external calls succeed, a panic in either call can never
/// leave an orphaned `Approved` record, a consumed completion slot, or a
/// `total_paid`/token-supply discrepancy regardless of sub-call rollback
/// `total_paid`/token-supply discrepancy — regardless of sub-call rollback
/// semantics. The registry's own double-claim and max-completions guards
/// remain the authoritative checks; the `completions < max_completions`
/// check here is defensive pre-validation that fails fast before any state
Expand Down Expand Up @@ -306,7 +306,7 @@ fn approve_and_pay(

require_cooldown_elapsed(e, user);

// Cross-contract calls first see the doc comment above for why the
// Cross-contract calls first — see the doc comment above for why the
// local state mutations are deferred until both have succeeded.
let registry_id = storage::read_registry(e);
e.invoke_contract::<Val>(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
///
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -2486,6 +2486,108 @@ mod test {
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, &reg_id);
reg_client.add_sponsor(&admin, &engine_id);
engine_client.initialize(&admin, &token_id, &reg_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);
}
}

#[test]
#[should_panic(expected = "engine: page size exceeds maximum of 50")]
fn test_get_pending_verifications_paged_limit_exceeds_max_panics() {
Expand Down
1 change: 1 addition & 0 deletions contracts/task-registry/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ ecotask-types = { path = "../ecotask-types" }

[dev-dependencies]
soroban-sdk = { version = "26.0.1", features = ["testutils"] }
proptest = "=1.6.0"
124 changes: 124 additions & 0 deletions contracts/task-registry/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1988,4 +1988,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);
}
}
}