From 1ed56e80307a69465bfd8ea4f65414e50ae5d314 Mon Sep 17 00:00:00 2001 From: D'Angelo Rodriguez <70290504+dangelo352@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:54:19 -0400 Subject: [PATCH 1/2] feat: prorate early exit penalty --- README.md | 2 +- contracts/commitment_core/src/fee_tests.rs | 54 ++++++++++- contracts/commitment_core/src/lib.rs | 108 ++++++++++++++++++--- contracts/commitment_core/src/tests.rs | 89 +++++++++++++---- docs/EARLY_EXIT.md | 41 ++++++++ docs/FEES.md | 12 +-- 6 files changed, 263 insertions(+), 43 deletions(-) create mode 100644 docs/EARLY_EXIT.md diff --git a/README.md b/README.md index ecc4674..455f3da 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ cargo test ## Fee Structure -Protocol fee collection (creation, attestation, transformation, early exit) is documented in [docs/FEES.md](docs/FEES.md). +Protocol fee collection (creation, attestation, transformation, early exit) is documented in [docs/FEES.md](docs/FEES.md). Early-exit penalty decay and events are documented in [docs/EARLY_EXIT.md](docs/EARLY_EXIT.md). ## Documentation diff --git a/contracts/commitment_core/src/fee_tests.rs b/contracts/commitment_core/src/fee_tests.rs index 6ad0fa7..1edb544 100644 --- a/contracts/commitment_core/src/fee_tests.rs +++ b/contracts/commitment_core/src/fee_tests.rs @@ -180,7 +180,8 @@ fn test_create_commitment_with_zero_fee() { let amount = 1_000_000i128; let rules = default_rules(&e); - let commitment_id = create_commitment_direct(&e, &contract_id, &user, amount, &token_address, &rules); + let commitment_id = + create_commitment_direct(&e, &contract_id, &user, amount, &token_address, &rules); // Verify commitment amount is full amount (no fee deducted) let commitment = client.get_commitment(&commitment_id); @@ -204,7 +205,8 @@ fn test_create_commitment_with_creation_fee() { let expected_net = amount - expected_fee; let rules = default_rules(&e); - let commitment_id = create_commitment_direct(&e, &contract_id, &user, amount, &token_address, &rules); + let commitment_id = + create_commitment_direct(&e, &contract_id, &user, amount, &token_address, &rules); // Verify commitment amount is net amount (after fee) let commitment = client.get_commitment(&commitment_id); @@ -347,6 +349,52 @@ fn test_early_exit_with_creation_fee_and_penalty() { assert_eq!(token_client.balance(&user), expected_user_balance); } +#[test] +fn test_early_exit_penalty_prorates_halfway_to_expiry() { + let (e, _admin, contract_id, user, token_address, client) = setup_test(); + let token_client = TokenClient::new(&e, &token_address); + + let amount = 1_000_000i128; + let mut rules = default_rules(&e); + rules.duration_days = 2; + rules.early_exit_penalty = 10; + + let commitment_id = create_commitment_direct(&e, &contract_id, &user, amount, &token_address, &rules); + + e.ledger().with_mut(|l| { + l.timestamp = 86_400; + }); + early_exit_direct(&e, &contract_id, &commitment_id, &user); + + let expected_penalty = 50_000i128; + let expected_returned = amount - expected_penalty; + let expected_user_balance = 10_000_000i128 - amount + expected_returned; + + assert_eq!(client.get_collected_fees(&token_address), expected_penalty); + assert_eq!(token_client.balance(&user), expected_user_balance); +} + +#[test] +fn test_early_exit_penalty_zero_at_exact_expiry() { + let (e, _admin, contract_id, user, token_address, client) = setup_test(); + let token_client = TokenClient::new(&e, &token_address); + + let amount = 1_000_000i128; + let mut rules = default_rules(&e); + rules.duration_days = 2; + rules.early_exit_penalty = 10; + + let commitment_id = create_commitment_direct(&e, &contract_id, &user, amount, &token_address, &rules); + + e.ledger().with_mut(|l| { + l.timestamp = 172_800; + }); + early_exit_direct(&e, &contract_id, &commitment_id, &user); + + assert_eq!(client.get_collected_fees(&token_address), 0); + assert_eq!(token_client.balance(&user), 10_000_000i128); +} + // ============================================================================ // Fee Recipient Tests // ============================================================================ @@ -628,4 +676,4 @@ fn test_complete_fee_lifecycle() { // 7. Verify no fees remaining assert_eq!(client.get_collected_fees(&token_address), 0); -} \ No newline at end of file +} diff --git a/contracts/commitment_core/src/lib.rs b/contracts/commitment_core/src/lib.rs index 0269de6..726dcbf 100644 --- a/contracts/commitment_core/src/lib.rs +++ b/contracts/commitment_core/src/lib.rs @@ -31,6 +31,8 @@ pub mod fuzzing; /// Maximum page size for paginated owner-commitment queries. const MAX_PAGE_SIZE: u32 = 50; +const BPS_SCALE: i128 = 10_000; +const PERCENT_TO_BPS: u32 = 100; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -347,6 +349,62 @@ fn remove_from_owner_commitments(e: &Env, owner: &Address, commitment_id: &Strin } } +fn ceil_div_positive(numerator: i128, denominator: i128) -> i128 { + SafeMath::div( + SafeMath::add(numerator, SafeMath::sub(denominator, 1)), + denominator, + ) +} + +fn u64_to_i128(value: u64) -> i128 { + if value > i128::MAX as u64 { + panic!("Math: u64 to i128 overflow"); + } + value as i128 +} + +fn compute_prorata_penalty( + elapsed: u64, + duration: u64, + principal: i128, + penalty_bps: u32, +) -> i128 { + if principal <= 0 || penalty_bps == 0 || duration == 0 || elapsed >= duration { + return 0; + } + + let remaining = duration - elapsed; + let penalty_bps_i128 = penalty_bps as i128; + let duration_i128 = u64_to_i128(duration); + let remaining_i128 = u64_to_i128(remaining); + + let max_penalty = SafeMath::div(SafeMath::mul(principal, penalty_bps_i128), BPS_SCALE); + let scaled = SafeMath::mul(SafeMath::mul(principal, penalty_bps_i128), remaining_i128); + let denominator = SafeMath::mul(BPS_SCALE, duration_i128); + let prorated = ceil_div_positive(scaled, denominator); + + let bounded_to_max = if prorated > max_penalty { + max_penalty + } else { + prorated + }; + + if bounded_to_max > principal { + principal + } else { + bounded_to_max + } +} + +fn elapsed_ratio_bps(elapsed: u64, duration: u64) -> u32 { + if duration == 0 || elapsed >= duration { + return BPS_SCALE as u32; + } + + let scaled = SafeMath::mul(u64_to_i128(elapsed), BPS_SCALE); + SafeMath::div(scaled, u64_to_i128(duration)) as u32 +} + #[contract] /// Main protocol contract for commitment state transitions and asset custody. /// @@ -1107,20 +1165,20 @@ impl CommitmentCoreContract { /// * `caller` - Must be the commitment owner; `require_auth` is enforced. /// /// # Penalty arithmetic - /// `penalty = SafeMath::penalty_amount(current_value, early_exit_penalty)` - /// which computes `(current_value * early_exit_penalty) / 100` using checked - /// integer arithmetic. Division truncates toward zero, so small values (e.g. - /// `current_value < 100 / early_exit_penalty`) may yield a zero penalty. - /// The penalty is credited to `CollectedFees(asset_address)` as protocol revenue. + /// `max_penalty = current_value * early_exit_penalty / 100` + /// `penalty = ceil(max_penalty * remaining_duration / total_duration)` + /// using checked integer arithmetic. Ceiling division prevents rounding from + /// reducing protocol solvency, and the result is bounded to + /// `0..=max_penalty`. The penalty is credited to + /// `CollectedFees(asset_address)` as protocol revenue for later + /// `FeeRecipient` withdrawal. /// `returned = current_value - penalty` is transferred back to the owner only /// when `returned > 0`; a 100% penalty results in no transfer. /// /// # Overflow safety - /// `SafeMath::mul(current_value, early_exit_penalty as i128)` panics with - /// `"Math: multiplication overflow"` if the intermediate product exceeds `i128::MAX`. - /// Callers must ensure `current_value * early_exit_penalty <= i128::MAX`. - /// In practice `early_exit_penalty <= 100`, so values up to `i128::MAX / 100` - /// are safe. + /// The prorated numerator multiplies current value, penalty bps, and + /// remaining duration with `SafeMath::mul`, so malformed or extreme stored + /// values fail instead of wrapping. /// /// # Trust boundaries /// - Only the commitment owner (verified via `require_auth` + owner equality check) @@ -1163,10 +1221,26 @@ impl CommitmentCoreContract { fail(&e, CommitmentError::NotActive, "exit"); } - let penalty = SafeMath::penalty_amount( + let now = e.ledger().timestamp(); + let duration = commitment + .expires_at + .checked_sub(commitment.created_at) + .unwrap_or_else(|| { + set_reentrancy_guard(&e, false); + fail(&e, CommitmentError::ArithmeticOverflow, "early_exit") + }); + let elapsed = if now <= commitment.created_at { + 0 + } else { + now - commitment.created_at + }; + let penalty = compute_prorata_penalty( + elapsed, + duration, commitment.current_value, - commitment.rules.early_exit_penalty, + commitment.rules.early_exit_penalty * PERCENT_TO_BPS, ); + let elapsed_ratio = elapsed_ratio_bps(elapsed, duration); let returned = SafeMath::sub(commitment.current_value, penalty); let original_val = commitment.current_value; @@ -1176,7 +1250,7 @@ impl CommitmentCoreContract { let current_fees: i128 = e.storage().instance().get(&fee_key).unwrap_or(0); e.storage() .instance() - .set(&fee_key, &(current_fees + penalty)); + .set(&fee_key, &SafeMath::add(current_fees, penalty)); } commitment.status = String::from_str(&e, "early_exit"); @@ -1190,7 +1264,7 @@ impl CommitmentCoreContract { .unwrap_or(0); e.storage() .instance() - .set(&DataKey::TotalValueLocked, &(tvl - original_val)); + .set(&DataKey::TotalValueLocked, &SafeMath::sub(tvl, original_val)); if returned > 0 { transfer_assets( @@ -1219,7 +1293,11 @@ impl CommitmentCoreContract { set_reentrancy_guard(&e, false); e.events().publish( (symbol_short!("EarlyExt"), commitment_id, caller), - (penalty, returned, e.ledger().timestamp()), + (penalty, returned, now), + ); + e.events().publish( + (Symbol::new(&e, "early_exit_settled"),), + (original_val, penalty, elapsed_ratio), ); } diff --git a/contracts/commitment_core/src/tests.rs b/contracts/commitment_core/src/tests.rs index 54e849b..3619896 100644 --- a/contracts/commitment_core/src/tests.rs +++ b/contracts/commitment_core/src/tests.rs @@ -2643,7 +2643,7 @@ fn test_early_exit_state_update() { #[test] fn test_early_exit_penalty_values() { - // Test penalty calculation logic with boundary and representative values + // Test maximum penalty calculation logic with boundary and representative values. let test_cases = [ (1000i128, 10u32, 100i128, 900i128), // 10% of 1000 (1000i128, 5u32, 50i128, 950i128), // 5% of 1000 @@ -2666,6 +2666,44 @@ fn test_early_exit_penalty_values() { } } +#[test] +fn test_prorata_early_exit_penalty_full_at_start() { + let penalty = compute_prorata_penalty(0, 100, 1000, 1000); + assert_eq!(penalty, 100); +} + +#[test] +fn test_prorata_early_exit_penalty_halfway() { + let penalty = compute_prorata_penalty(50, 100, 1000, 1000); + assert_eq!(penalty, 50); +} + +#[test] +fn test_prorata_early_exit_penalty_near_expiry_rounds_up() { + let penalty = compute_prorata_penalty(99, 100, 1000, 1000); + assert_eq!(penalty, 1); +} + +#[test] +fn test_prorata_early_exit_penalty_at_expiry_zero() { + let penalty = compute_prorata_penalty(100, 100, 1000, 1000); + assert_eq!(penalty, 0); +} + +#[test] +fn test_prorata_early_exit_elapsed_ratio_boundaries() { + assert_eq!(elapsed_ratio_bps(0, 100), 0); + assert_eq!(elapsed_ratio_bps(50, 100), 5000); + assert_eq!(elapsed_ratio_bps(100, 100), 10000); + assert_eq!(elapsed_ratio_bps(150, 100), 10000); +} + +#[test] +#[should_panic(expected = "Math: multiplication overflow")] +fn test_prorata_early_exit_penalty_overflow_rejects() { + compute_prorata_penalty(0, 100, i128::MAX, 10_000); +} + #[test] fn test_early_exit_penalty_zero_current_value() { let current_value = 0i128; @@ -2686,19 +2724,20 @@ fn test_early_exit_penalty_with_loss() { let _e = Env::default(); // Simulate commitment that has lost value - // Initial: 1000, Current: 800 (20% loss) - // Penalty on current: 800 * 10% = 80 - // Returned: 800 - 80 = 720 + // Initial: 1000, Current: 800 (20% loss), halfway through the term. + // Max penalty on current: 800 * 10% = 80 + // Prorated penalty: 80 * 50% remaining = 40 + // Returned: 800 - 40 = 760 let _initial_amount = 1000i128; let current_value = 800i128; - let penalty_percent = 10u32; + let penalty_bps = 1000u32; - let penalty = (current_value * (penalty_percent as i128)) / 100; + let penalty = compute_prorata_penalty(50, 100, current_value, penalty_bps); let returned = current_value - penalty; - assert_eq!(penalty, 80); - assert_eq!(returned, 720); + assert_eq!(penalty, 40); + assert_eq!(returned, 760); assert_eq!(penalty + returned, current_value); } @@ -2708,9 +2747,9 @@ fn test_early_exit_penalty_small_amounts() { // Test with small amounts where rounding might occur let current_value = 10i128; - let penalty_percent = 10u32; + let penalty_bps = 1000u32; - let penalty = (current_value * (penalty_percent as i128)) / 100; + let penalty = compute_prorata_penalty(0, 100, current_value, penalty_bps); let returned = current_value - penalty; assert_eq!(penalty, 1); @@ -2753,10 +2792,10 @@ fn test_early_exit_after_value_reduction() { // (e.g., through allocation or loss) let _initial_amount = 1000i128; let current_value = 700i128; // Reduced from 1000 - let penalty_percent = 10u32; + let penalty_bps = 1000u32; // Early exit penalty applies to current_value (700), not initial (1000) - let penalty = (current_value * (penalty_percent as i128)) / 100; + let penalty = compute_prorata_penalty(0, 100, current_value, penalty_bps); let returned = current_value - penalty; assert_eq!(penalty, 70); // 10% of 700 @@ -2780,9 +2819,13 @@ fn test_early_exit_different_commitment_types() { commitment.rules.commitment_type = String::from_str(&e, commitment_type); - // Verify penalty calculation is independent of type - let penalty = - (commitment.current_value * (commitment.rules.early_exit_penalty as i128)) / 100; + // Verify maximum penalty calculation is independent of type. + let penalty = compute_prorata_penalty( + 0, + 100, + commitment.current_value, + commitment.rules.early_exit_penalty * 100, + ); assert_eq!(penalty, 100); // Always 10% of 1000 } } @@ -2808,7 +2851,12 @@ fn test_early_exit_zero_penalty() { 0, // 0% penalty ); - let penalty = (commitment.current_value * (commitment.rules.early_exit_penalty as i128)) / 100; + let penalty = compute_prorata_penalty( + 0, + 100, + commitment.current_value, + commitment.rules.early_exit_penalty * 100, + ); let returned = commitment.current_value - penalty; assert_eq!(penalty, 0); @@ -2832,7 +2880,12 @@ fn test_early_exit_high_penalty() { 50, // 50% penalty ); - let penalty = (commitment.current_value * (commitment.rules.early_exit_penalty as i128)) / 100; + let penalty = compute_prorata_penalty( + 0, + 100, + commitment.current_value, + commitment.rules.early_exit_penalty * 100, + ); let returned = commitment.current_value - penalty; assert_eq!(penalty, 500); @@ -2853,7 +2906,7 @@ fn test_early_exit_conservation_invariant() { ]; for (current_value, penalty_percent) in test_values.iter() { - let penalty = (current_value * (*penalty_percent as i128)) / 100; + let penalty = compute_prorata_penalty(0, 100, *current_value, *penalty_percent * 100); let returned = current_value - penalty; // Conservation invariant diff --git a/docs/EARLY_EXIT.md b/docs/EARLY_EXIT.md new file mode 100644 index 0000000..1ec6407 --- /dev/null +++ b/docs/EARLY_EXIT.md @@ -0,0 +1,41 @@ +# Early Exit Penalty + +`commitment_core::early_exit` lets the commitment owner close an active commitment before maturity. The contract returns the post-penalty value to the owner, marks the commitment as `early_exit`, and marks the linked NFT inactive. + +## Pro-Rata Penalty + +The configured `rules.early_exit_penalty` remains a whole-number percent from `0` to `100`, but the charged amount now decays linearly as the commitment approaches expiry. + +```text +max_penalty = current_value * early_exit_penalty / 100 +elapsed = min(now - created_at, expires_at - created_at) +remaining = (expires_at - created_at) - elapsed +penalty = ceil(max_penalty * remaining / (expires_at - created_at)) +returned = current_value - penalty +``` + +At creation time the penalty equals the configured maximum. At or after expiry the early-exit penalty is zero. The charged penalty is always bounded to `0..=max_penalty`. + +Ceiling division is intentional: fractional penalties round up by one stroop so rounding cannot reduce protocol solvency. Checked arithmetic is used for the scaled numerator and TVL/fee ledger updates, so malformed or extreme stored values fail instead of wrapping. + +## Fee Routing + +Early-exit penalties are protocol revenue. The penalty is credited to `CollectedFees(asset)` and can be withdrawn to the configured `FeeRecipient` through `withdraw_fees`, matching the rest of the `commitment_core` fee model. + +## Events + +`early_exit` keeps the legacy `EarlyExt` event for compatibility: + +```text +topics: (EarlyExt, commitment_id, caller) +data: (penalty, returned, timestamp) +``` + +It also emits `early_exit_settled` with the stable accounting payload: + +```text +topics: (early_exit_settled) +data: (amount, penalty, elapsed_ratio_bps) +``` + +`elapsed_ratio_bps` is `0` at creation, `10_000` at or after expiry, and otherwise floors `elapsed * 10_000 / duration`. diff --git a/docs/FEES.md b/docs/FEES.md index 9c3d0fb..0b1acc9 100644 --- a/docs/FEES.md +++ b/docs/FEES.md @@ -7,7 +7,7 @@ End-to-end reference for protocol revenue across all fee-collecting contracts. E | Fee | Contract | Collection entrypoint | Rate key | Rate validation | Recipient key | Accumulation | Withdrawal | |-----|----------|----------------------|----------|-----------------|---------------|--------------|------------| | Commitment creation | `commitment_core` | `create_commitment` | `CreationFeeBps` | 0–10000 bps (`set_creation_fee_bps`) | `FeeRecipient` | `CollectedFees(asset)` | `withdraw_fees` (Treasurer/Admin) | -| Early exit penalty | `commitment_core` | `early_exit` | `rules.early_exit_penalty` (per-commitment, percent 0–100) | Validated at creation by commitment type | `FeeRecipient` | `CollectedFees(asset)` | `withdraw_fees` (Treasurer/Admin) | +| Early exit penalty | `commitment_core` | `early_exit` | `rules.early_exit_penalty` (per-commitment, percent 0–100, prorated by remaining duration) | Validated at creation by commitment type | `FeeRecipient` | `CollectedFees(asset)` | `withdraw_fees` (Treasurer/Admin) | | Attestation verification | `attestation_engine` | `write_attestation` (via `record_fees`, `record_drawdown`, `_attest_internal`) | `AttestationFeeAmount` + `AttestationFeeAsset` | `amount >= 0` (`set_attestation_fee`); `0` disables | `FeeRecipient` | `CollectedFees(asset)` | `withdraw_fees` (Admin) | | Transformation | `commitment_transformation` | `create_tranches` | `TransformationFeeBps` | 0–10000 bps (`set_transformation_fee`) | `FeeRecipient` | `CollectedFees(fee_asset)` | `withdraw_fees` (Admin) | | Marketplace sale | `commitment_marketplace` | `buy_nft`, `accept_offer`, `end_auction` | `MarketplaceFee` | **No validation on set**; `end_auction` caps at 10000 bps at settlement | `FeeRecipient` | Direct transfer (no `CollectedFees`) | N/A — paid to recipient at sale time | @@ -40,12 +40,12 @@ Used by: `commitment_core` (creation fee), `commitment_transformation` (transfor Early exit uses **whole-number percent** (0–100), not basis points: -```rust -penalty = SafeMath::penalty_amount(current_value, early_exit_penalty) - = (current_value * early_exit_penalty) / 100 +```text +max_penalty = (current_value * early_exit_penalty) / 100 +penalty = ceil(max_penalty * remaining_duration / total_duration) ``` -Integer division truncates toward zero. Small `current_value` values can yield a zero penalty (documented in `commitment_core::early_exit` rustdoc). +The maximum penalty is prorated by the unserved portion of the commitment. Ceiling division rounds fractional penalties up by one stroop, so rounding cannot reduce protocol solvency; the result is still bounded to `0..=max_penalty`. See [EARLY_EXIT.md](EARLY_EXIT.md). ### Dust and tranche rounding (`commitment_transformation`) @@ -69,7 +69,7 @@ Both round toward zero (floor for positive values). The sum of tranche amounts c | Fee | When | Calculation | Token flow | |-----|------|-------------|------------| | Creation | `create_commitment` | `fees::fee_from_bps(amount, CreationFeeBps)`; default bps `0` | Owner transfers full `amount` to contract; `creation_fee` credited to `CollectedFees(asset_address)`; NFT minted with `net_amount = amount - creation_fee`; TVL incremented by `net_amount` | -| Early exit | `early_exit` | `SafeMath::penalty_amount(current_value, rules.early_exit_penalty)` | Penalty added to `CollectedFees(asset)`; `returned = current_value - penalty` transferred to owner when `returned > 0` | +| Early exit | `early_exit` | `ceil(max_penalty * remaining_duration / total_duration)` where `max_penalty = current_value * rules.early_exit_penalty / 100` | Penalty added to `CollectedFees(asset)`; `returned = current_value - penalty` transferred to owner when `returned > 0` | #### Storage keys From d77ea3d23aaf781b39c00da80f6b4090183fe48e Mon Sep 17 00:00:00 2001 From: D'Angelo Rodriguez <70290504+dangelo352@users.noreply.github.com> Date: Sun, 21 Jun 2026 23:05:03 -0400 Subject: [PATCH 2/2] test: update early exit integration expectations --- tests/integration/e2e_tests.rs | 5 +++-- tests/integration/frontend_tests.rs | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/integration/e2e_tests.rs b/tests/integration/e2e_tests.rs index b789415..c2d7d17 100644 --- a/tests/integration/e2e_tests.rs +++ b/tests/integration/e2e_tests.rs @@ -223,8 +223,9 @@ fn test_e2e_early_exit_with_penalty() { String::from_str(&harness.env, "early_exit") ); - // Verify penalty was applied - let expected_penalty = amount * early_exit_penalty as i128 / 100; + // Verify pro-rata penalty was applied after 10 of 30 days elapsed. + let max_penalty = amount * early_exit_penalty as i128 / 100; + let expected_penalty = (max_penalty * 20 + 29) / 30; let expected_return = amount - expected_penalty; let balance_after_exit = harness.balance(user); diff --git a/tests/integration/frontend_tests.rs b/tests/integration/frontend_tests.rs index a299b29..0161cdd 100644 --- a/tests/integration/frontend_tests.rs +++ b/tests/integration/frontend_tests.rs @@ -406,10 +406,11 @@ fn test_frontend_early_exit_flow() { ) }); - // Verify user received funds back (minus penalty) + // Verify user received funds back (minus pro-rata penalty) let balance_after = harness.balance(user); let penalty_percent = 10u32; // From default_rules (Balanced) - let expected_return = amount - (amount * penalty_percent as i128 / 100); + let max_penalty = amount * penalty_percent as i128 / 100; + let expected_return = amount - (max_penalty / 2); assert_eq!(balance_after - balance_before, expected_return); // Verify commitment status changed