diff --git a/contracts/README.md b/contracts/README.md index f19ce6c6..065ef9ce 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -1,6 +1,6 @@ # CommitLabs Soroban Contracts -Soroban (Rust) smart-contract workspace backing the CommitLabs liquidity commitment protocol. The frontend and backend service layer interact with the escrow contract through Stellar Soroban RPC, so this document treats `contracts/escrow/src/lib.rs` as the source of truth and cross-checks behavior against the focused tests in `contracts/escrow/src/test_funding_window.rs`. +Soroban (Rust) smart-contract workspace backing the CommitLabs liquidity commitment protocol. The frontend and backend service layer interact with the escrow contract through Stellar Soroban RPC, so this document treats `contracts/escrow/src/lib.rs` as the source of truth and cross-checks behavior against the focused tests in `contracts/escrow/src/test_funding_window.rs`, `contracts/escrow/src/test_yield_reserve.rs`, and `contracts/escrow/src/test_penalty_math.rs`. ## Workspace Layout @@ -12,6 +12,7 @@ contracts/ | `-- src/ | |-- lib.rs # EscrowContract implementation, types, errors | |-- test_funding_window.rs # Funding-time maturity, expiry, and cancellation tests +| |-- test_penalty_math.rs # Penalty rounding and minimum partial-withdrawal tests | `-- test_yield_reserve.rs # Yield-pool reservation accounting tests `-- scripts/ |-- deploy-testnet.sh # Build, deploy, initialize helper @@ -75,7 +76,7 @@ Creation records the selected lock length in the existing `maturity - created_at | `settle_commitment(commitment_id, caller)` | Owner or admin auth | ABI alias for matured release used by the backend. Returns `SettlementResult { settlementAmount, finalStatus }`. | | `refund(commitment_id)` | Commitment owner auth | Requires `Funded`; applies early-exit penalty unless inside `GracePeriodSeconds`; releases the yield reservation; moves to `Refunded`. | | `early_exit_commitment(commitment_id, caller)` | `caller` auth | Backend-facing early-exit alias around refund semantics. Returns `EarlyExitResult { exitAmount, penaltyAmount, finalStatus }`. | -| `refund_partial(commitment_id, amount)` | Commitment owner auth | Partial early exit on a funded commitment; remaining principal stays funded unless fully withdrawn. The yield reservation is released only when the remaining principal reaches zero. | +| `refund_partial(commitment_id, amount)` | Commitment owner auth | Partial early exit on a funded commitment; remaining principal stays funded unless fully withdrawn. Requires `amount >= MIN_PARTIAL_WITHDRAWAL` (`10_000_000` stroops / 1 XLM) and either `remaining == 0` or `remaining >= MIN_REMAINING_PRINCIPAL` (`10_000_000` stroops / 1 XLM), else returns `BelowMinimum`. Computes penalty via ceiling rounding `penalty_for`. The yield reservation is released only when the remaining principal reaches zero. | | `dispute(commitment_id, caller, reason)` | Owner or admin auth | Requires `Funded`; stores a `DisputeRecord`, categorizes the reason, and moves to `Disputed`. The yield reservation stays held while frozen. Clears any pending ownership transfer. | | `resolve_dispute(commitment_id, release_to_owner)` | Admin auth | Resolves `Disputed` or `Violated` commitments. If `release_to_owner` is true, pays owner; otherwise refunds/penalizes according to the dispute path. Consumes the reservation on a matured release, and releases it on any yield-forfeiting path. Clears any pending ownership transfer. | | `transfer_ownership(commitment_id, new_owner)` | Current owner auth | Creates a pending ownership transfer offer expiring in 7 days. Requires `Funded`. The commitment remains owned by the current owner until `accept_ownership` is called. No-op if `new_owner` equals current owner. Rejects transfers to the contract's own address. | @@ -139,6 +140,8 @@ Pause checks are executed immediately after initialization check (`require_init` | `MAX_AMOUNT` | `1_000_000_000_000` | Maximum commitment principal accepted by `create_commitment`. | | `MAX_DURATION_DAYS` | `365` | Maximum duration accepted by `create_commitment`. | | `MAX_PENALTY_BPS` | `10_000` | Maximum penalty basis points, where `10_000` is 100%. | +| `MIN_PARTIAL_WITHDRAWAL` | `10_000_000` | Minimum partial withdrawal amount (1 XLM at 7 decimals); full exits allow amounts below this threshold. | +| `MIN_REMAINING_PRINCIPAL` | `10_000_000` | Minimum remaining principal for partial exits (1 XLM at 7 decimals); leaving `0 < remaining < min` returns `BelowMinimum`. | | Yield rates | `yield_rate_bps` / `calculate_accrued_yield` | Safe: 500 bps annualized, Balanced: 700 bps, Aggressive: 1000 bps. | | Yield pool | `deposit_yield_pool` / `withdraw_yield_pool` / `get_yield_pool_status` | `available = balance - reserved`. `fund_escrow` reserves `accrued_yield` from `available`, so matured releases can always be paid. | @@ -163,6 +166,15 @@ Solvency is enforced when the promise is made, not when it is redeemed: Commitments funded before reservation accounting existed have no entry under `DataKey::Reserved(id)`. They are never un-reserved, and their matured release falls back to the previous behaviour of paying `accrued_yield` straight out of `YieldPool`, which can still return `InsufficientYieldPool`. +## Penalty Math + +Early-exit penalties favor the protocol and are computed consistently across all refund paths (`refund`, `early_exit_commitment`, `refund_partial`, and `resolve_dispute` refund branch) using the shared `penalty_for(amount, penalty_bps)` helper: + +- **Ceiling Division**: Penalty is calculated as `(amount * bps + 9_999) / 10_000` (for `bps > 0`), ensuring any non-zero withdrawal fraction rounds up in the protocol's favour (e.g. 1 stroop pays 1 stroop penalty; 49 stroops at 200 bps pays 1 stroop; 1,001 stroops at 500 bps pays 51 stroops penalty). When `bps == 0`, penalty is `0`. +- **Fund Conservation & Clamping**: Penalty is clamped so `0 <= penalty <= amount`, and `refund = amount - penalty`, guaranteeing `penalty + refund == amount` for all inputs. +- **Slicing Invariant**: Because penalties round up, slicing a withdrawal into smaller chunks never incurs a lower total penalty than withdrawing the full amount at once. +- **Minimum Partial Withdrawals and Remainders**: `refund_partial` enforces `amount >= MIN_PARTIAL_WITHDRAWAL` (`10_000_000` stroops / 1 XLM) and requires any non-zero remaining balance to satisfy `remaining >= MIN_REMAINING_PRINCIPAL` (`10_000_000` stroops / 1 XLM). Violations return `Error::BelowMinimum = 22`. Full withdrawals (`remaining == 0`) bypass these minimums to support legacy or small positions. + ## Worked Refund and Grace-Period Example The test suite's `refund_within_grace_period_is_penalty_free` covers the intended grace-window behavior: @@ -174,7 +186,7 @@ The test suite's `refund_within_grace_period_is_penalty_free` covers the intende 5. Owner calls `refund(commitment_id)`. 6. Because the current time is inside the configured grace window, the penalty is `0`, owner receives the full `1_000`, fee recipient receives `0`, and commitment becomes `Refunded`. -Outside the grace window, the same 500 bps early exit returns `950` to the owner and sends `50` to the fee recipient, as covered by `refund_outside_grace_period_still_applies_penalty` and the default-penalty refund tests. +Outside the grace window, the same 500 bps early exit returns `950` to the owner and sends `50` to the fee recipient (or 1,001 units returns `950` refund and `51` penalty), as covered by `refund_outside_grace_period_still_applies_penalty` and the penalty math tests. ## Violation Path @@ -227,12 +239,13 @@ The `Error` enum in `contracts/escrow/src/lib.rs` is `#[repr(u32)]`; these numer | 19 | `NoPendingAdmin` | No pending admin proposal exists. | | 20 | `NoPendingTransfer` | No pending ownership transfer exists for this commitment. | | 21 | `TransferExpired` | The ownership transfer offer has expired. | +| 22 | `BelowMinimum` | Partial withdrawal amount or remaining principal is below the required minimum (`10_000_000` stroops). | Backend callers should use the normalized `BackendError` response shape and preserve method context in details. Contract errors that cannot be parsed should still be surfaced as `BLOCKCHAIN_CALL_FAILED` with the method name so API consumers can distinguish RPC/configuration failures from business-rule failures. -`FundingWindowExpired` is error code `16`; it is returned without moving tokens when funding is attempted after the 7-day window. Codes 1-15 and their enum positions remain unchanged. `ReservedYieldInvariant` is error code `17`. +`FundingWindowExpired` is error code `16`; it is returned without moving tokens when funding is attempted after the 7-day window. Codes 1-15 and their enum positions remain unchanged. `ReservedYieldInvariant` is error code `17`. `BelowMinimum` is error code `18`. -Run the isolated funding-window tests with `cd contracts && cargo test -p commitlabs-escrow`. +Run the isolated test suites with `cd contracts && cargo test -p commitlabs-escrow`. ## Deployment Flow diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index cfa14d13..46b6e304 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -46,6 +46,12 @@ pub const MAX_DURATION_DAYS: u32 = 365; /// Upper bound for penalty basis points (10_000 = 100%). pub const MAX_PENALTY_BPS: u32 = 10_000; +/// Minimum amount that may be withdrawn via `refund_partial` (10_000_000 = 1 XLM at 7 decimals). +pub const MIN_PARTIAL_WITHDRAWAL: i128 = 10_000_000; + +/// Minimum principal that must remain in a commitment after `refund_partial` unless fully withdrawn (10_000_000 = 1 XLM at 7 decimals). +pub const MIN_REMAINING_PRINCIPAL: i128 = 10_000_000; + /// Bound full-record owner reads so a single query does not exceed Soroban /// simulation/result size limits. const MAX_USER_COMMITMENTS_READ: u32 = 100; @@ -246,6 +252,8 @@ pub enum Error { NoPendingTransfer = 20, /// The ownership transfer offer has expired. TransferExpired = 21, + /// Partial withdrawal amount or remaining principal is below minimum threshold. + BelowMinimum = 22, } /// Result of an early exit commitment. @@ -990,9 +998,14 @@ impl EscrowContract { return Err(Error::InvalidAmount); } - let (penalty, refund_amount) = Self::compute_refund_amount(amount, c.penalty_bps)?; - let remaining = c.amount.checked_sub(amount).ok_or(Error::InvalidAmount)?; + if remaining > 0 + && (amount < MIN_PARTIAL_WITHDRAWAL || remaining < MIN_REMAINING_PRINCIPAL) + { + return Err(Error::BelowMinimum); + } + + let (penalty, refund_amount) = Self::penalty_for(amount, c.penalty_bps)?; c.amount = remaining; if remaining == 0 { Self::clear_pending_ownership(&env, commitment_id); @@ -1136,7 +1149,7 @@ impl EscrowContract { c.status = EscrowStatus::Released; paid = payout; } else { - let (penalty, refund_amount) = Self::compute_refund_amount(c.amount, c.penalty_bps)?; + let (penalty, refund_amount) = Self::penalty_for(c.amount, c.penalty_bps)?; Self::clear_pending_ownership(&env, commitment_id); c.status = EscrowStatus::Refunded; paid = refund_amount; @@ -1627,16 +1640,11 @@ impl EscrowContract { return Err(Error::InvalidState); } - let penalty = if Self::is_within_grace_period(env, &c) { - 0 + let (penalty, refund_amount) = if Self::is_within_grace_period(env, &c) { + (0, c.amount) } else { - let penalty_mul = c - .amount - .checked_mul(i128::from(c.penalty_bps)) - .ok_or(Error::InvalidAmount)?; - penalty_mul / i128::from(MAX_PENALTY_BPS) + Self::penalty_for(c.amount, c.penalty_bps)? }; - let refund_amount = c.amount.checked_sub(penalty).ok_or(Error::InvalidAmount)?; // Early exit forfeits yield: drop the reservation but keep the pool // balance untouched. @@ -1680,7 +1688,10 @@ impl EscrowContract { Ok(()) } - fn compute_refund_amount(amount: i128, penalty_bps: u32) -> Result<(i128, i128), Error> { + /// Calculate early-exit penalty rounded up in the protocol's favour, and the resulting refund. + /// Uses ceiling division: `(amount * bps + 9_999) / 10_000`. + /// Guaranteed: `penalty <= amount` and `penalty + refund == amount`. + pub(crate) fn penalty_for(amount: i128, penalty_bps: u32) -> Result<(i128, i128), Error> { if amount <= 0 { return Err(Error::InvalidAmount); } @@ -1688,13 +1699,24 @@ impl EscrowContract { return Err(Error::PenaltyTooHigh); } - let penalty = amount - .checked_mul(i128::from(penalty_bps)) - .ok_or(Error::InvalidAmount)? - / i128::from(MAX_PENALTY_BPS); - let refund_amount = amount.checked_sub(penalty).ok_or(Error::InvalidAmount)?; + let penalty = if penalty_bps == 0 { + 0 + } else { + let mul = amount + .checked_mul(i128::from(penalty_bps)) + .ok_or(Error::InvalidAmount)?; + let ceil_add = mul + .checked_add(i128::from(MAX_PENALTY_BPS - 1)) + .ok_or(Error::InvalidAmount)?; + let raw_penalty = ceil_add / i128::from(MAX_PENALTY_BPS); + raw_penalty.min(amount) + }; + + let refund = amount + .checked_sub(penalty) + .ok_or(Error::InvalidAmount)?; - Ok((penalty, refund_amount)) + Ok((penalty, refund)) } fn get_default_penalty_internal(env: &Env, risk: RiskProfile) -> Result { @@ -2016,3 +2038,5 @@ mod test_two_step; mod test_pause; #[cfg(test)] mod test_attestation; +#[cfg(test)] +mod test_penalty_math; diff --git a/contracts/escrow/src/test_penalty_math.rs b/contracts/escrow/src/test_penalty_math.rs new file mode 100644 index 00000000..e4fdf371 --- /dev/null +++ b/contracts/escrow/src/test_penalty_math.rs @@ -0,0 +1,317 @@ +use super::*; +use proptest::prelude::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + Address, Env, Map, +}; + +#[test] +fn test_penalty_for_table() { + struct Case { + amount: i128, + bps: u32, + expected_penalty: i128, + expected_refund: i128, + } + + let cases = [ + // 1. Single unit at 200 bps (rounds up to 1) + Case { amount: 1, bps: 200, expected_penalty: 1, expected_refund: 0 }, + // 2. 200 bps boundary (10_000 / 200 = 50) - 1: 49 base units + Case { amount: 49, bps: 200, expected_penalty: 1, expected_refund: 48 }, + // 3. 200 bps boundary exact: 50 base units + Case { amount: 50, bps: 200, expected_penalty: 1, expected_refund: 49 }, + // 4. 200 bps boundary + 1: 51 base units + Case { amount: 51, bps: 200, expected_penalty: 2, expected_refund: 49 }, + // 5. Single unit at 500 bps (boundary 10_000 / 500 = 20) + Case { amount: 1, bps: 500, expected_penalty: 1, expected_refund: 0 }, + // 6. 500 bps boundary - 1: 19 base units + Case { amount: 19, bps: 500, expected_penalty: 1, expected_refund: 18 }, + // 7. 500 bps boundary exact: 20 base units + Case { amount: 20, bps: 500, expected_penalty: 1, expected_refund: 19 }, + // 8. 500 bps boundary + 1: 21 base units + Case { amount: 21, bps: 500, expected_penalty: 2, expected_refund: 19 }, + // 9. Exact round amount at 500 bps: 1,000 -> 50 penalty, 950 refund + Case { amount: 1_000, bps: 500, expected_penalty: 50, expected_refund: 950 }, + // 10. Rounding up at 500 bps: 1,001 -> 51 penalty (ceil), 950 refund + Case { amount: 1_001, bps: 500, expected_penalty: 51, expected_refund: 950 }, + // 11. 300 bps boundary (10_000 / 300 = 33.33) -> 33 + Case { amount: 33, bps: 300, expected_penalty: 1, expected_refund: 32 }, + // 12. 300 bps boundary + 1 -> 34 + Case { amount: 34, bps: 300, expected_penalty: 2, expected_refund: 32 }, + // 13. 0 bps: always 0 penalty, full refund + Case { amount: 1, bps: 0, expected_penalty: 0, expected_refund: 1 }, + Case { amount: 10_000, bps: 0, expected_penalty: 0, expected_refund: 10_000 }, + // 14. 10_000 bps (100%): always full penalty, 0 refund + Case { amount: 1, bps: 10_000, expected_penalty: 1, expected_refund: 0 }, + Case { amount: 10_000, bps: 10_000, expected_penalty: 10_000, expected_refund: 0 }, + // 15. MAX_AMOUNT at 500 bps + Case { + amount: MAX_AMOUNT, + bps: 500, + expected_penalty: 50_000_000_000, + expected_refund: 950_000_000_000, + }, + // 16. MAX_AMOUNT at 0 bps + Case { + amount: MAX_AMOUNT, + bps: 0, + expected_penalty: 0, + expected_refund: MAX_AMOUNT, + }, + // 17. MAX_AMOUNT at 10_000 bps + Case { + amount: MAX_AMOUNT, + bps: 10_000, + expected_penalty: MAX_AMOUNT, + expected_refund: 0, + }, + ]; + + for c in cases { + let (penalty, refund) = EscrowContract::penalty_for(c.amount, c.bps).expect("valid case"); + let amount = c.amount; + let bps = c.bps; + assert_eq!( + penalty, c.expected_penalty, + "penalty mismatch for amount={amount}, bps={bps}" + ); + assert_eq!( + refund, c.expected_refund, + "refund mismatch for amount={amount}, bps={bps}" + ); + assert_eq!( + penalty + refund, + c.amount, + "penalty + refund must equal amount" + ); + } + + // Invalid cases + assert_eq!( + EscrowContract::penalty_for(0, 500), + Err(Error::InvalidAmount) + ); + assert_eq!( + EscrowContract::penalty_for(-100, 500), + Err(Error::InvalidAmount) + ); + assert_eq!( + EscrowContract::penalty_for(1_000, 10_001), + Err(Error::PenaltyTooHigh) + ); +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 100, ..ProptestConfig::default() })] + + #[test] + fn proptest_penalty_math_properties( + amount in 1i128..=MAX_AMOUNT, + bps in 0u32..=MAX_PENALTY_BPS, + ) { + let (penalty, refund) = EscrowContract::penalty_for(amount, bps).unwrap(); + + // 1. Conservation of funds + prop_assert_eq!(penalty + refund, amount); + + // 2. Bounded penalty + prop_assert!(penalty >= 0); + prop_assert!(penalty <= amount); + + // 3. Protocol-favouring rounding: penalty >= floor(amount * bps / 10_000) + let floor_penalty = (amount * i128::from(bps)) / 10_000; + prop_assert!(penalty >= floor_penalty); + + // 4. Exact boundaries for 0 and 10_000 bps + if bps == 0 { + prop_assert_eq!(penalty, 0); + prop_assert_eq!(refund, amount); + } + if bps == 10_000 { + prop_assert_eq!(penalty, amount); + prop_assert_eq!(refund, 0); + } + } +} + +struct TestFixture<'a> { + env: &'a Env, + client: EscrowContractClient<'a>, + sac: StellarAssetClient<'a>, + token: Address, + admin: Address, + owner: Address, + fee_recipient: Address, +} + +impl<'a> TestFixture<'a> { + fn new(env: &'a Env) -> Self { + env.mock_all_auths(); + env.ledger().with_mut(|l| l.timestamp = 1000); + + let admin = Address::generate(env); + let owner = Address::generate(env); + let fee_recipient = Address::generate(env); + + let token = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let sac = StellarAssetClient::new(env, &token); + + let contract_id = env.register(EscrowContract, ()); + let client = EscrowContractClient::new(env, &contract_id); + client.initialize(&admin, &token, &fee_recipient, &200, &300, &500); + + sac.mint(&owner, &1_000_000_000); + sac.mint(&admin, &1_000_000_000); + + Self { + env, + client, + sac, + token, + admin, + owner, + fee_recipient, + } + } + + fn create_and_fund(&self, amount: i128, penalty_bps: u32) -> u64 { + let pool = 10_000_000; + self.client.deposit_yield_pool(&pool); + + let id = self.client.create_commitment( + &self.owner, + &self.token, + &amount, + &RiskProfile::Safe, + &30, + &penalty_bps, + &Map::new(self.env), + ); + self.client.fund_escrow(&id); + id + } +} + +#[test] +fn test_refund_partial_minimums_and_balances() { + let env = Env::default(); + let f = TestFixture::new(&env); + + let initial_owner_balance = f.sac.balance(&f.owner); + let initial_fee_balance = f.sac.balance(&f.fee_recipient); + + let principal = 50_000_000; // 5 XLM + let id = f.create_and_fund(principal, 500); // 500 bps = 5% penalty + + let owner_after_fund = f.sac.balance(&f.owner); + assert_eq!(owner_after_fund, initial_owner_balance - principal); + + // 1. Partial withdrawal below MIN_PARTIAL_WITHDRAWAL (10_000_000) rejected + let sub_min = MIN_PARTIAL_WITHDRAWAL - 1; + let res = f.client.try_refund_partial(&id, &sub_min); + assert_eq!(res, Err(Ok(Error::BelowMinimum))); + assert_eq!(f.sac.balance(&f.owner), owner_after_fund); + assert_eq!(f.sac.balance(&f.fee_recipient), initial_fee_balance); + + // 2. Partial withdrawal leaving dust remainder (0 < remaining < MIN_REMAINING_PRINCIPAL) rejected + let leaves_dust = principal - 1; // leaves 1 stroop + let res_dust = f.client.try_refund_partial(&id, &leaves_dust); + assert_eq!(res_dust, Err(Ok(Error::BelowMinimum))); + assert_eq!(f.sac.balance(&f.owner), owner_after_fund); + assert_eq!(f.sac.balance(&f.fee_recipient), initial_fee_balance); + + // 3. Valid partial withdrawal: withdraw 20_000_000, leaving 30_000_000 + let withdraw_1 = 20_000_000; + // 20_000_000 * 500 / 10_000 = 1_000_000 penalty, 19_000_000 refund + let refund_1 = f.client.refund_partial(&id, &withdraw_1); + assert_eq!(refund_1, 19_000_000); + assert_eq!( + f.sac.balance(&f.fee_recipient), + initial_fee_balance + 1_000_000 + ); + assert_eq!(f.sac.balance(&f.owner), owner_after_fund + 19_000_000); + + let c = f.client.get_commitment(&id); + assert_eq!(c.amount, 30_000_000); + assert_eq!(c.status, EscrowStatus::Funded); + + // 4. Full withdrawal of the remaining balance: withdraw remaining 30_000_000 + // 30_000_000 * 500 / 10_000 = 1_500_000 penalty, 28_500_000 refund + let refund_2 = f.client.refund_partial(&id, &30_000_000); + assert_eq!(refund_2, 28_500_000); + assert_eq!( + f.sac.balance(&f.fee_recipient), + initial_fee_balance + 1_000_000 + 1_500_000 + ); + assert_eq!( + f.sac.balance(&f.owner), + owner_after_fund + 19_000_000 + 28_500_000 + ); + + let c_final = f.client.get_commitment(&id); + assert_eq!(c_final.amount, 0); + assert_eq!(c_final.status, EscrowStatus::Refunded); +} + +#[test] +fn test_legacy_commitment_below_minimum_can_fully_withdraw() { + let env = Env::default(); + let f = TestFixture::new(&env); + + // Legacy commitment created with amount below MIN_PARTIAL_WITHDRAWAL (e.g. 5_000_000) + let small_principal = 5_000_000; + let id = f.create_and_fund(small_principal, 500); + + // Partial withdrawal leaving a remainder is rejected + let res = f.client.try_refund_partial(&id, &2_000_000); + assert_eq!(res, Err(Ok(Error::BelowMinimum))); + + // Full withdrawal (remaining == 0) is permitted even though amount < MIN_PARTIAL_WITHDRAWAL + let (penalty, expected_refund) = EscrowContract::penalty_for(small_principal, 500).unwrap(); + assert_eq!(penalty + expected_refund, small_principal); + let refund = f.client.refund_partial(&id, &small_principal); + assert_eq!(refund, expected_refund); + assert_eq!(f.client.get_commitment(&id).status, EscrowStatus::Refunded); + assert_eq!(f.client.get_commitment(&id).amount, 0); +} + +#[test] +fn test_slicing_withdrawal_never_pays_less_penalty_than_full_withdrawal() { + // For various amounts and basis points, verify that slicing a withdrawal + // into smaller chunks always results in total penalty >= full single withdrawal penalty. + let test_bps = [100, 200, 300, 500, 1000, 2500, 5000]; + let amounts = [ + 30_000_000, + 55_555_555, + 100_000_000, + 123_456_789, + 500_000_000, + ]; + + for bps in test_bps { + for total in amounts { + let (full_penalty, _) = EscrowContract::penalty_for(total, bps).unwrap(); + + // Slice into chunks of MIN_PARTIAL_WITHDRAWAL (10_000_000) + let chunk_size = MIN_PARTIAL_WITHDRAWAL; + let mut remaining = total; + let mut sum_sliced_penalty = 0i128; + + while remaining > 0 { + let this_chunk = chunk_size.min(remaining); + let (chunk_pen, _) = EscrowContract::penalty_for(this_chunk, bps).unwrap(); + sum_sliced_penalty += chunk_pen; + remaining -= this_chunk; + } + + assert!( + sum_sliced_penalty >= full_penalty, + "sliced penalty ({sum_sliced_penalty}) must be >= full penalty ({full_penalty}) for total={total}, bps={bps}" + ); + } + } +} diff --git a/contracts/escrow/src/test_yield_reserve.rs b/contracts/escrow/src/test_yield_reserve.rs index 8f2fa92d..c0b2873d 100644 --- a/contracts/escrow/src/test_yield_reserve.rs +++ b/contracts/escrow/src/test_yield_reserve.rs @@ -284,18 +284,19 @@ fn refund_releases_reservation_without_touching_balance() { fn partial_refund_keeps_reservation_until_full_exit() { let env = Env::default(); let ctx = setup(&env); - ctx.client.deposit_yield_pool(&POOL_DEPOSIT); + let pool_deposit = 1_000_000; + ctx.client.deposit_yield_pool(&pool_deposit); - let id = create_commitment(&ctx, PRINCIPAL, RiskProfile::Safe, DURATION_DAYS, 500); + let id = create_commitment(&ctx, 20_000_000, RiskProfile::Safe, DURATION_DAYS, 500); ctx.client.fund_escrow(&id); let accrued_yield = ctx.client.get_commitment(&id).accrued_yield; - // Partial exits keep the full reservation: over-reserved, never under. - ctx.client.refund_partial(&id, &400); - assert_status(&ctx, POOL_DEPOSIT, accrued_yield); + // Partial exits meet minimums and keep the full reservation: over-reserved, never under. + ctx.client.refund_partial(&id, &10_000_000); + assert_status(&ctx, pool_deposit, accrued_yield); - ctx.client.refund_partial(&id, &600); - assert_status(&ctx, POOL_DEPOSIT, 0); + ctx.client.refund_partial(&id, &10_000_000); + assert_status(&ctx, pool_deposit, 0); } #[test] diff --git a/src/lib/soroban/errors.test.ts b/src/lib/soroban/errors.test.ts index 29e47efa..e7ac4e34 100644 --- a/src/lib/soroban/errors.test.ts +++ b/src/lib/soroban/errors.test.ts @@ -51,7 +51,7 @@ describe('contract error table', () => { expect(errorBlock).not.toBeNull(); const contractErrors = [...(errorBlock?.[1] ?? '').matchAll(/^\s*(\w+)\s*=\s*(\d+)\s*,/gm)]; - expect(contractErrors).toHaveLength(21); + expect(contractErrors).toHaveLength(22); for (const [, name, rawCode] of contractErrors) { const code = Number(rawCode) as EscrowErrorCode; expect(CONTRACT_ERRORS[code]).toMatchObject({ name, code }); diff --git a/src/lib/soroban/errors.ts b/src/lib/soroban/errors.ts index 30debf0e..385aab33 100644 --- a/src/lib/soroban/errors.ts +++ b/src/lib/soroban/errors.ts @@ -22,6 +22,7 @@ export enum EscrowErrorCode { NoPendingAdmin = 19, NoPendingTransfer = 20, TransferExpired = 21, + BelowMinimum = 22, } export interface ContractError { @@ -158,6 +159,12 @@ export const CONTRACT_ERRORS: Record = { message: 'The ownership transfer offer has expired.', hint: 'Ask the current owner to create a new transfer offer.', }, + [EscrowErrorCode.BelowMinimum]: { + code: EscrowErrorCode.BelowMinimum, + name: 'BelowMinimum', + message: 'The partial withdrawal or the remaining principal is below the 1-unit minimum.', + hint: 'Withdraw at least 1 unit and leave at least 1 unit, or exit the full amount.', + }, }; export type TxErrorKind =