diff --git a/contracts/README.md b/contracts/README.md index 2c1b7a4..eb5ad04 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -158,22 +158,15 @@ Yield is funded by the admin through `deposit_yield_pool(admin, amount)`. The co points (`penalty_bps`, max `10_000`) and is paid to the configured fee recipient on `refund` / adverse `resolve_dispute`. -### Refund math model and invariants +### Commitment limits -Refunds are computed with integer basis-point math: +To prevent arithmetic overflow (e.g. during maturity timestamp calculations) and ensure input sanity, the following upper-bound limits are enforced in `create_commitment`: +- **Maximum Amount (`MAX_AMOUNT`)**: `1_000_000_000_000` (1T units) +- **Maximum Duration (`MAX_DURATION_DAYS`)**: `365` days (1 year) +- **Maximum Penalty (`MAX_PENALTY_BPS`)**: `10_000` bps (100%) -- `penalty = floor(amount * penalty_bps / 10_000)` -- `refund = amount - penalty` +Attempts to exceed these limits will return `InvalidAmount` or `InvalidDuration` errors, respectively. -This keeps the split stable and preserves the invariant `refund + penalty == amount` -for valid principal amounts. The contract enforces `0 <= penalty_bps <= 10_000` -and uses checked arithmetic so overflowing intermediate multiplication is rejected -instead of wrapping. Boundary cases are documented in the contract tests: - -- `penalty_bps = 0` → full principal refund, zero penalty -- `penalty_bps = 10_000` → zero refund, full principal penalty -- tiny amounts (`1`, `2`, `3`, etc.) remain non-negative and partition cleanly -- seeded deterministic property tests cover randomized mid-range values and overflow guards ### Errors diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index bececd2..785f708 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -22,12 +22,19 @@ use soroban_sdk::{ }; // Configuration constants for escrow contract +// Configuration constants for escrow contract +// Number of seconds in a day used for maturity calculation. const SECONDS_PER_DAY: u64 = 86_400; -// Maximum allowed commitment amount (example limit) + +/// Upper bound for commitment amount enforced by `create_commitment`. +/// Aligns with backend `CommitmentLimits.max_amount`. const MAX_AMOUNT: i128 = 1_000_000_000_000; -// Maximum allowed duration in days + +/// Upper bound for commitment duration (in days) enforced by `create_commitment`. +/// Aligns with backend `CommitmentLimits.max_duration_days`. const MAX_DURATION_DAYS: u32 = 365; -// Maximum penalty basis points (100% = 10_000 bps) + +/// Upper bound for penalty basis points (10_000 = 100%). const MAX_PENALTY_BPS: u32 = 10_000; /// Storage keys for persistent contract state. @@ -294,9 +301,14 @@ impl EscrowContract { /// Create a new (unfunded) commitment escrow. Returns the new commitment id. /// + /// Validates input against upper bounds defined by backend `CommitmentLimits`: + /// * `amount` must be > 0 and <= `MAX_AMOUNT`. + /// * `duration_days` must be > 0 and <= `MAX_DURATION_DAYS`. + /// * `penalty_bps` must be <= `MAX_PENALTY_BPS`. + /// /// `duration_days` is converted to an absolute maturity timestamp using the - /// current ledger time. `penalty_bps` is the early-exit penalty applied on - /// `refund`. + /// current ledger time with checked arithmetic to avoid overflow. `penalty_bps` + /// is the early-exit penalty applied on `refund`. pub fn create_commitment( env: Env, owner: Address, diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 2a268ce..4265e8b 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -473,6 +473,18 @@ fn create_rejects_invalid_amount() { assert_eq!(res, Err(Ok(Error::InvalidAmount))); } +#[test] +fn create_rejects_overflow_duration() { + let f = setup(); + // Set timestamp close to max to cause overflow when adding duration + f.env.ledger().set_timestamp(u64::MAX - 10); + let owner = Address::generate(&f.env); + fund_owner(&f, &owner, 1_000); + // Use a duration that will overflow when added to current timestamp + let res = f.client.try_create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Safe, &10u32, &2000u32); + assert_eq!(res, Err(Ok(Error::InvalidDuration))); +} + #[test] fn create_rejects_excessive_penalty() { let f = setup(); @@ -515,103 +527,35 @@ fn owner_index_tracks_commitments() { assert_eq!(ids.len(), 2); assert_eq!(ids.get(0).unwrap(), a); assert_eq!(ids.get(1).unwrap(), b); - - #[test] - fn create_rejects_excessive_amount() { - let f = setup(); - let owner = Address::generate(&f.env); - let res = f.client.try_create_commitment( - &owner, - &f.asset, - &(MAX_AMOUNT + 1), - &RiskProfile::Safe, - &(MAX_DURATION_DAYS + 1), - &2000, - ); - assert_eq!(res, Err(Ok(Error::InvalidAmount))); - } - - #[test] - fn create_rejects_excessive_duration() { - let f = setup(); - let owner = Address::generate(&f.env); - let res = f.client.try_create_commitment( - &owner, - &f.asset, - &1_000, - &RiskProfile::Safe, - &(MAX_DURATION_DAYS + 1), - &2000, - ); - assert_eq!(res, Err(Ok(Error::InvalidDuration))); - } -} - -fn assert_refund_invariants(amount: i128, penalty_bps: u32) { - let (penalty, refund) = EscrowContract::compute_refund_amount(amount, penalty_bps) - .expect("valid refund inputs must compute deterministically"); - - assert!(refund >= 0, "refund must never be negative"); - assert!(penalty >= 0, "penalty must never be negative"); - assert_eq!(refund + penalty, amount, "refund and penalty must partition principal"); - assert!(penalty <= amount, "penalty must never exceed principal"); -} - -#[test] -fn deterministic_seeded_refund_inputs_preserve_penalty_invariants() { - let mut runner = TestRunner::deterministic(); - let strategy = (1i128..=1_000_000i128, 0u32..=10_000u32); - - runner - .run(&strategy, |(amount, penalty_bps)| { - let (penalty, refund) = EscrowContract::compute_refund_amount(amount, penalty_bps) - .map_err(|_| TestCaseError::fail("refund math should stay within arithmetic bounds"))?; - - prop_assert_eq!(refund + penalty, amount); - prop_assert!(refund >= 0); - prop_assert!(penalty >= 0); - prop_assert!(penalty <= amount); - Ok(()) - }) - .unwrap(); } #[test] -fn penalty_bps_zero_returns_full_refund() { - let amount = 9_876; - let (penalty, refund) = EscrowContract::compute_refund_amount(amount, 0) - .expect("zero penalty must be computable"); - - assert_eq!(penalty, 0); - assert_eq!(refund, amount); - assert_eq!(refund + penalty, amount); -} - -#[test] -fn penalty_bps_max_returns_zero_refund() { - let amount = 9_876; - let (penalty, refund) = EscrowContract::compute_refund_amount(amount, 10_000) - .expect("max penalty must be computable"); - - assert_eq!(penalty, amount); - assert_eq!(refund, 0); - assert_eq!(refund + penalty, amount); +fn create_rejects_excessive_amount() { + let f = setup(); + let owner = Address::generate(&f.env); + let res = f.client.try_create_commitment( + &owner, + &f.asset, + &(MAX_AMOUNT + 1), + &RiskProfile::Safe, + &30, + &2000, + ); + assert_eq!(res, Err(Ok(Error::InvalidAmount))); } #[test] -fn overflow_guard_rejects_extreme_amounts() { - let overflow_amount = i128::MAX / 10_000 + 1; - let err = EscrowContract::compute_refund_amount(overflow_amount, 10_000) - .expect_err("overflowing intermediate multiplication must be rejected"); - - assert_eq!(err, Error::InvalidAmount); +fn create_rejects_excessive_duration() { + let f = setup(); + let owner = Address::generate(&f.env); + let res = f.client.try_create_commitment( + &owner, + &f.asset, + &1_000, + &RiskProfile::Safe, + &(MAX_DURATION_DAYS + 1), + &2000, + ); + assert_eq!(res, Err(Ok(Error::InvalidDuration))); } -#[test] -fn small_amount_edge_cases_keep_refund_penalty_invariants() { - for amount in [1, 2, 3, 5, 10] { - assert_refund_invariants(amount, 0); - assert_refund_invariants(amount, 1); - assert_refund_invariants(amount, 10_000); - } -}