diff --git a/contracts/escrow/src/test/create_contract_bounds.rs b/contracts/escrow/src/test/create_contract_bounds.rs new file mode 100644 index 0000000..aca2b9f --- /dev/null +++ b/contracts/escrow/src/test/create_contract_bounds.rs @@ -0,0 +1,211 @@ +// Tests for every input-validation guard in `create_contract`. +// +// Guards (in execution order): +// 1. client == freelancer → InvalidParticipant +// 2. milestone_amounts.is_empty() → EmptyMilestones +// 3. len > MAX_MILESTONES (10) → TooManyMilestones +// 4. len == MAX_MILESTONES → succeeds +// 5. any amount <= 0 → InvalidMilestoneAmount +// 6. safe_add_amounts overflow → PotentialOverflow +// 7. total > MAX_TOTAL_ESCROW_STROOPS → InvalidMilestoneAmount + +use soroban_sdk::{testutils::Address as _, vec, Address, Env, Vec}; + +use crate::{ + DepositMode, Escrow, EscrowClient, EscrowError, MAX_MILESTONES, MAX_TOTAL_ESCROW_STROOPS, +}; + +// Returns (env, contract_address). Each test creates EscrowClient locally so +// the borrow of `env` stays in the same scope — same pattern as pause_controls. +fn setup() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Escrow, ()); + (env, contract_id) +} + +fn assert_err( + result: Result< + Result, + Result, + >, + expected: EscrowError, +) { + match result { + Err(Ok(e)) => { + let want: soroban_sdk::Error = expected.into(); + assert_eq!(e, want, "wrong error: expected {:?}", expected); + } + other => panic!("expected {:?}, got {:?}", expected, other), + } +} + +// guard 1 ───────────────────────────────────────────────────────────────────── + +#[test] +fn rejects_same_client_and_freelancer() { + let (env, cid) = setup(); + let client = EscrowClient::new(&env, &cid); + let same = Address::generate(&env); + assert_err( + client.try_create_contract(&same, &same, &vec![&env, 100_i128], &DepositMode::ExactTotal), + EscrowError::InvalidParticipant, + ); +} + +// guard 2 ───────────────────────────────────────────────────────────────────── + +#[test] +fn rejects_empty_milestones() { + let (env, cid) = setup(); + let client = EscrowClient::new(&env, &cid); + let c = Address::generate(&env); + let f = Address::generate(&env); + assert_err( + client.try_create_contract(&c, &f, &Vec::new(&env), &DepositMode::ExactTotal), + EscrowError::EmptyMilestones, + ); +} + +// guard 3 ───────────────────────────────────────────────────────────────────── + +#[test] +fn rejects_one_over_max_milestones() { + let (env, cid) = setup(); + let client = EscrowClient::new(&env, &cid); + let c = Address::generate(&env); + let f = Address::generate(&env); + let mut amounts: Vec = Vec::new(&env); + for _ in 0..=MAX_MILESTONES { + amounts.push_back(1_i128); + } + assert_eq!(amounts.len(), MAX_MILESTONES + 1); + assert_err( + client.try_create_contract(&c, &f, &amounts, &DepositMode::ExactTotal), + EscrowError::TooManyMilestones, + ); +} + +// guard 4 — boundary success ────────────────────────────────────────────────── + +#[test] +fn accepts_exactly_max_milestones() { + let (env, cid) = setup(); + let client = EscrowClient::new(&env, &cid); + let c = Address::generate(&env); + let f = Address::generate(&env); + let mut amounts: Vec = Vec::new(&env); + for _ in 0..MAX_MILESTONES { + amounts.push_back(1_i128); + } + assert_eq!(amounts.len(), MAX_MILESTONES); + client.create_contract(&c, &f, &amounts, &DepositMode::ExactTotal); +} + +// guard 5 ───────────────────────────────────────────────────────────────────── + +#[test] +fn rejects_zero_milestone_amount() { + let (env, cid) = setup(); + let client = EscrowClient::new(&env, &cid); + let c = Address::generate(&env); + let f = Address::generate(&env); + assert_err( + client.try_create_contract(&c, &f, &vec![&env, 0_i128], &DepositMode::ExactTotal), + EscrowError::InvalidMilestoneAmount, + ); +} + +#[test] +fn rejects_negative_milestone_amount() { + let (env, cid) = setup(); + let client = EscrowClient::new(&env, &cid); + let c = Address::generate(&env); + let f = Address::generate(&env); + assert_err( + client.try_create_contract(&c, &f, &vec![&env, -1_i128], &DepositMode::ExactTotal), + EscrowError::InvalidMilestoneAmount, + ); +} + +// guard 6 — overflow caught before cap check ────────────────────────────────── + +#[test] +fn rejects_amounts_that_would_overflow_i128() { + let (env, cid) = setup(); + let client = EscrowClient::new(&env, &cid); + let c = Address::generate(&env); + let f = Address::generate(&env); + // Both > i128::MAX / 2, so checked_add returns None on the second iteration. + let large = i128::MAX / 2 + 2; + assert_err( + client.try_create_contract(&c, &f, &vec![&env, large, large], &DepositMode::ExactTotal), + EscrowError::PotentialOverflow, + ); +} + +// guard 7 ───────────────────────────────────────────────────────────────────── + +#[test] +fn accepts_total_exactly_at_cap() { + let (env, cid) = setup(); + let client = EscrowClient::new(&env, &cid); + let c = Address::generate(&env); + let f = Address::generate(&env); + client.create_contract( + &c, + &f, + &vec![&env, MAX_TOTAL_ESCROW_STROOPS], + &DepositMode::ExactTotal, + ); +} + +#[test] +fn rejects_total_one_over_cap() { + let (env, cid) = setup(); + let client = EscrowClient::new(&env, &cid); + let c = Address::generate(&env); + let f = Address::generate(&env); + assert_err( + client.try_create_contract( + &c, + &f, + &vec![&env, MAX_TOTAL_ESCROW_STROOPS + 1], + &DepositMode::ExactTotal, + ), + EscrowError::InvalidMilestoneAmount, + ); +} + +#[test] +fn rejects_multi_milestone_total_over_cap() { + let (env, cid) = setup(); + let client = EscrowClient::new(&env, &cid); + let c = Address::generate(&env); + let f = Address::generate(&env); + let half = MAX_TOTAL_ESCROW_STROOPS / 2 + 1; + assert_err( + client.try_create_contract(&c, &f, &vec![&env, half, half], &DepositMode::ExactTotal), + EscrowError::InvalidMilestoneAmount, + ); +} + +// ordering ──────────────────────────────────────────────────────────────────── + +// When both count > MAX_MILESTONES and total > cap, TooManyMilestones wins +// because the count guard runs first in create_contract. +#[test] +fn count_guard_fires_before_amount_guard() { + let (env, cid) = setup(); + let client = EscrowClient::new(&env, &cid); + let c = Address::generate(&env); + let f = Address::generate(&env); + let mut amounts: Vec = Vec::new(&env); + for _ in 0..=MAX_MILESTONES { + amounts.push_back(MAX_TOTAL_ESCROW_STROOPS); + } + assert_err( + client.try_create_contract(&c, &f, &amounts, &DepositMode::ExactTotal), + EscrowError::TooManyMilestones, + ); +} diff --git a/contracts/escrow/src/test/mod.rs b/contracts/escrow/src/test/mod.rs index 9f95ca6..d748291 100644 --- a/contracts/escrow/src/test/mod.rs +++ b/contracts/escrow/src/test/mod.rs @@ -6,6 +6,7 @@ use crate::{Escrow, EscrowClient, EscrowError}; // ─── Submodules ─────────────────────────────────────────────────────────────── +mod create_contract_bounds; mod admin_auth_helper; mod dispute; mod emergency_controls; diff --git a/docs/escrow/milestone-validation.md b/docs/escrow/milestone-validation.md index 6625712..c9b0b3d 100644 --- a/docs/escrow/milestone-validation.md +++ b/docs/escrow/milestone-validation.md @@ -1,3 +1,35 @@ +# Milestone Validation + +Describes every input guard enforced by `create_contract` and where each is tested. + +## Guards (in execution order) + +| # | Condition | Error | Test | +|---|-----------|-------|------| +| 1 | `client == freelancer` | `InvalidParticipant` | `rejects_same_client_and_freelancer` | +| 2 | `milestone_amounts.is_empty()` | `EmptyMilestones` | `rejects_empty_milestones` | +| 3 | `len > MAX_MILESTONES` (10) | `TooManyMilestones` | `rejects_one_over_max_milestones` | +| 4 | `len == MAX_MILESTONES` | *(success)* | `accepts_exactly_max_milestones` | +| 5 | any `amount <= 0` | `InvalidMilestoneAmount` | `rejects_zero_milestone_amount`, `rejects_negative_milestone_amount` | +| 6 | `safe_add_amounts` overflow | `PotentialOverflow` | `rejects_amounts_that_would_overflow_i128` | +| 7 | `total > MAX_TOTAL_ESCROW_STROOPS` | `InvalidMilestoneAmount` | `rejects_total_one_over_cap`, `rejects_multi_milestone_total_over_cap` | + +## Constants + +- `MAX_MILESTONES = 10` — hard cap on milestone count per contract +- `MAX_TOTAL_ESCROW_STROOPS = 10_000_000_000_000` — 1 000 000 XLM in stroops + +## Overflow safety + +Amounts are accumulated with `safe_add_amounts`, which wraps `i128::checked_add`. If the running total would overflow `i128`, the call returns `None` and the contract panics with `PotentialOverflow` before the cap comparison is ever reached. This means `i128::MAX` inputs and near-overflow pairs are caught cleanly without silent wrapping. + +## Guard ordering + +Count is checked before amounts. When a caller passes more than 10 milestones *and* a total above the cap, `TooManyMilestones` is returned — verified by `count_guard_fires_before_amount_guard`. + +## Tests + +All guards are covered in `contracts/escrow/src/test/create_contract_bounds.rs`, wired via `mod.rs`. # Escrow Contract: Milestone Validation and Approval Flow ## Overview