Skip to content
Merged
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
19 changes: 6 additions & 13 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 17 additions & 5 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
128 changes: 36 additions & 92 deletions contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
}
Loading