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
23 changes: 18 additions & 5 deletions contracts/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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. |

Expand All @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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

Expand Down
60 changes: 42 additions & 18 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1680,21 +1688,35 @@ 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);
}
if penalty_bps > MAX_PENALTY_BPS {
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<u32, Error> {
Expand Down Expand Up @@ -2016,3 +2038,5 @@ mod test_two_step;
mod test_pause;
#[cfg(test)]
mod test_attestation;
#[cfg(test)]
mod test_penalty_math;
Loading
Loading