diff --git a/contracts/commitment_core/src/lib.rs b/contracts/commitment_core/src/lib.rs index 0269de6..570351d 100644 --- a/contracts/commitment_core/src/lib.rs +++ b/contracts/commitment_core/src/lib.rs @@ -139,6 +139,11 @@ pub struct CommitmentCreatedEvent { /// - **Safe**: Low risk. Max loss ≤ 10%, Early exit penalty ≥ 15%. Target: Stable yield pools. /// - **Balanced**: Medium risk. Max loss ≤ 30%, Early exit penalty ≥ 10%. Target: Mixed yield/growth pools. /// - **Aggressive**: High risk. Max loss ≤ 100%, Early exit penalty ≥ 5%. Target: High-yield/volatile pools. +/// +/// `grace_period_days` relaxes observational violation checks only: +/// max-loss checks are ignored before `created_at + grace_period_days`, and +/// duration checks use `expires_at + grace_period_days`. A zero grace period +/// preserves the original strict max-loss and expiry behavior. pub struct CommitmentRules { pub duration_days: u32, pub max_loss_percent: u32, @@ -220,6 +225,40 @@ fn transfer_assets(e: &Env, from: &Address, to: &Address, asset_address: &Addres token_client.transfer(from, to, &amount); } +fn grace_seconds(e: &Env, grace_period_days: u32, context: &str) -> u64 { + TimeUtils::checked_days_to_seconds(grace_period_days) + .unwrap_or_else(|| fail(e, CommitmentError::ExpirationOverflow, context)) +} + +fn checked_grace_deadline(e: &Env, anchor: u64, grace_period_days: u32, context: &str) -> u64 { + let grace = grace_seconds(e, grace_period_days, context); + anchor + .checked_add(grace) + .unwrap_or_else(|| fail(e, CommitmentError::ExpirationOverflow, context)) +} + +fn within_loss_grace(e: &Env, commitment: &Commitment, current_time: u64) -> bool { + if commitment.rules.grace_period_days == 0 { + return false; + } + + current_time < checked_grace_deadline( + e, + commitment.created_at, + commitment.rules.grace_period_days, + "chk", + ) +} + +fn duration_violation_deadline(e: &Env, commitment: &Commitment) -> u64 { + checked_grace_deadline( + e, + commitment.expires_at, + commitment.rules.grace_period_days, + "chk", + ) +} + /// Helper function to call NFT contract mint function. fn call_nft_mint( e: &Env, @@ -956,6 +995,12 @@ impl CommitmentCoreContract { e.storage().instance().set(&DataKey::TotalValueLocked, &updated_tvl); } + /// Return whether an active commitment currently violates loss or duration rules. + /// + /// The grace period suppresses temporary max-loss breaches during the initial + /// grace window and extends the duration violation deadline by the same number + /// of days. This is observational only; it emits a `Violated` event when a + /// violation is present but does not mutate commitment status. pub fn check_violations(e: Env, commitment_id: String) -> bool { let commitment = read_commitment(&e, &commitment_id) .unwrap_or_else(|| fail(&e, CommitmentError::CommitmentNotFound, "chk")); @@ -963,14 +1008,16 @@ impl CommitmentCoreContract { return false; } - let current_time = e.ledger().timestamp(); + let current_time = TimeUtils::now(&e); let loss_percent = if commitment.amount > 0 { SafeMath::loss_percent(commitment.amount, commitment.current_value) } else { 0 }; - let violated = (loss_percent > commitment.rules.max_loss_percent as i128) - || (current_time >= commitment.expires_at); + let loss_violated = loss_percent > commitment.rules.max_loss_percent as i128 + && !within_loss_grace(&e, &commitment, current_time); + let duration_violated = current_time >= duration_violation_deadline(&e, &commitment); + let violated = loss_violated || duration_violated; if violated { e.events().publish( @@ -981,6 +1028,11 @@ impl CommitmentCoreContract { violated } + /// Return `(has_violations, loss_violated, duration_violated, loss_percent, time_remaining)`. + /// + /// Grace period semantics match `check_violations`: loss breaches are ignored + /// during the initial grace window, and duration uses `expires_at + grace`. + /// `time_remaining` counts down to the grace-adjusted duration deadline. pub fn get_violation_details(e: Env, commitment_id: String) -> (bool, bool, bool, i128, u64) { let commitment = read_commitment(&e, &commitment_id).unwrap_or_else(|| { fail( @@ -990,16 +1042,18 @@ impl CommitmentCoreContract { ) }); - let now = e.ledger().timestamp(); + let now = TimeUtils::now(&e); let loss_percent = if commitment.amount > 0 { SafeMath::loss_percent(commitment.amount, commitment.current_value) } else { 0 }; - let loss_violated = loss_percent > commitment.rules.max_loss_percent as i128; - let duration_violated = now >= commitment.expires_at; + let loss_violated = loss_percent > commitment.rules.max_loss_percent as i128 + && !within_loss_grace(&e, &commitment, now); + let duration_deadline = duration_violation_deadline(&e, &commitment); + let duration_violated = now >= duration_deadline; let has_violations = loss_violated || duration_violated; - let time_remaining = commitment.expires_at.saturating_sub(now); + let time_remaining = duration_deadline.saturating_sub(now); ( has_violations, diff --git a/contracts/commitment_core/src/tests.rs b/contracts/commitment_core/src/tests.rs index 54e849b..e95396f 100644 --- a/contracts/commitment_core/src/tests.rs +++ b/contracts/commitment_core/src/tests.rs @@ -1329,6 +1329,81 @@ fn test_check_violations_loss_limit_exceeded() { assert!(has_violations, "Should have loss limit violation"); } +#[test] +fn test_check_violations_loss_limit_relaxed_inside_grace_period() { + let e = Env::default(); + let contract_id = e.register_contract(None, CommitmentCoreContract); + let owner = Address::generate(&e); + let commitment_id = "test_commitment_loss_grace"; + + let created_at = 1000u64; + let mut commitment = create_test_commitment( + &e, + commitment_id, + &owner, + 1000, + 800, // 20% loss - would exceed 10% outside grace + 10, + 30, + created_at, + ); + commitment.rules.grace_period_days = 5; + + store_commitment(&e, &contract_id, &commitment); + + e.ledger().with_mut(|l| { + l.timestamp = created_at + 2 * 86400; + }); + + let has_violations = e.as_contract(&contract_id, || { + CommitmentCoreContract::check_violations(e.clone(), String::from_str(&e, commitment_id)) + }); + let (has_detail_violation, loss_violated, duration_violated, loss_percent, _time_remaining) = e + .as_contract(&contract_id, || { + CommitmentCoreContract::get_violation_details( + e.clone(), + String::from_str(&e, commitment_id), + ) + }); + + assert!( + !has_violations, + "Grace period should suppress temporary loss breach" + ); + assert!( + !has_detail_violation, + "Details should preserve grace semantics" + ); + assert!(!loss_violated, "Loss should not violate inside grace"); + assert!(!duration_violated, "Duration should not violate before expiry"); + assert_eq!(loss_percent, 20, "Details still report observed loss percent"); +} + +#[test] +fn test_check_violations_loss_limit_enforced_after_grace_period() { + let e = Env::default(); + let contract_id = e.register_contract(None, CommitmentCoreContract); + let owner = Address::generate(&e); + let commitment_id = "test_commitment_loss_after_grace"; + + let created_at = 1000u64; + let mut commitment = + create_test_commitment(&e, commitment_id, &owner, 1000, 800, 10, 30, created_at); + commitment.rules.grace_period_days = 5; + + store_commitment(&e, &contract_id, &commitment); + + e.ledger().with_mut(|l| { + l.timestamp = created_at + 5 * 86400; + }); + + let has_violations = e.as_contract(&contract_id, || { + CommitmentCoreContract::check_violations(e.clone(), String::from_str(&e, commitment_id)) + }); + + assert!(has_violations, "Grace ends at the exact grace deadline"); +} + #[test] fn test_check_violations_duration_expired() { let e = Env::default(); @@ -1363,6 +1438,71 @@ fn test_check_violations_duration_expired() { assert!(has_violations, "Should have duration violation"); } +#[test] +fn test_check_violations_duration_relaxed_until_expiry_grace_deadline() { + let e = Env::default(); + let contract_id = e.register_contract(None, CommitmentCoreContract); + let owner = Address::generate(&e); + let commitment_id = "test_commitment_duration_grace"; + + let created_at = 1000u64; + let mut commitment = + create_test_commitment(&e, commitment_id, &owner, 1000, 980, 10, 30, created_at); + commitment.rules.grace_period_days = 3; + + store_commitment(&e, &contract_id, &commitment); + + e.ledger().with_mut(|l| { + l.timestamp = commitment.expires_at + 2 * 86400; + }); + + let (has_violations, loss_violated, duration_violated, _loss_percent, time_remaining) = e + .as_contract(&contract_id, || { + CommitmentCoreContract::get_violation_details( + e.clone(), + String::from_str(&e, commitment_id), + ) + }); + + assert!(!has_violations, "Expiry grace should suppress duration violation"); + assert!(!loss_violated, "Loss remains within bounds"); + assert!( + !duration_violated, + "Duration should not violate before grace deadline" + ); + assert_eq!(time_remaining, 86400, "One grace day should remain"); + + e.ledger().with_mut(|l| { + l.timestamp = commitment.expires_at + 3 * 86400; + }); + + let has_violations = e.as_contract(&contract_id, || { + CommitmentCoreContract::check_violations(e.clone(), String::from_str(&e, commitment_id)) + }); + + assert!(has_violations, "Duration should violate at grace deadline"); +} + +#[test] +#[should_panic(expected = "Duration would cause expiration timestamp overflow")] +fn test_check_violations_grace_deadline_overflow_rejected() { + let e = Env::default(); + let contract_id = e.register_contract(None, CommitmentCoreContract); + let owner = Address::generate(&e); + let commitment_id = "test_commitment_grace_overflow"; + + let mut commitment = + create_test_commitment(&e, commitment_id, &owner, 1000, 980, 10, 30, 1000); + commitment.rules.grace_period_days = 1; + commitment.expires_at = u64::MAX - 10; + + store_commitment(&e, &contract_id, &commitment); + + e.as_contract(&contract_id, || { + CommitmentCoreContract::check_violations(e.clone(), String::from_str(&e, commitment_id)) + }); +} + #[test] fn test_check_violations_both_violations() { let e = Env::default(); diff --git a/docs/commitment_core/SEMANTICS.md b/docs/commitment_core/SEMANTICS.md index ce943dc..3b887d7 100644 --- a/docs/commitment_core/SEMANTICS.md +++ b/docs/commitment_core/SEMANTICS.md @@ -27,6 +27,16 @@ stateDiagram-v2 `check_violations` is intentionally shown as a self-edge: it returns `true` and emits a `Violated` event when the active commitment currently violates max-loss or expiration rules, but it does not write a new status. The persistent `"violated"` transition is performed by `update_value` when the new value breaches `rules.max_loss_percent`. +### Grace Period Semantics + +`CommitmentRules.grace_period_days` is enforced by observational violation checks without changing the stored commitment status: + +- Max-loss checks are relaxed during the initial grace window. A drawdown is not reported as a loss violation while `now < created_at + grace_period_days`. +- Duration checks use the grace-adjusted deadline `expires_at + grace_period_days`. A commitment violates duration when `now >= expires_at + grace_period_days`. +- A zero grace period preserves the original behavior: max-loss is checked immediately and duration violates at `expires_at`. +- Grace deadline math uses `TimeUtils::checked_days_to_seconds` plus checked timestamp addition; overflow rejects the check instead of wrapping. +- `get_violation_details` keeps the same tuple shape, but `loss_violated`, `duration_violated`, `has_violations`, and `time_remaining` all use the same grace-adjusted semantics as `check_violations`. + ### Transition Table | Entrypoint | Status transition | Preconditions and guards | Errors on rejected path | State writes | Emitted event | Source | @@ -34,7 +44,7 @@ stateDiagram-v2 | `create_commitment` | `[new] -> "active"` | Reentrancy guard clear; contract not paused or in emergency; owner auth; non-zero owner; rate limit passes; positive amount; valid rules; expiration does not overflow; sufficient balance; NFT contract initialized; generated ID unused. | `ZeroAddress`, `InvalidAmount`, rule validation panics, `ExpirationOverflow`, `InsufficientBalance`, `NotInitialized`, `DuplicateCommitmentId`, `ArithmeticOverflow`. | Stores `Commitment.status = "active"`, owner index, total counter, TVL, all-ID index, the minted NFT token id, and collected fees only when `creation_fee > 0`. | Topic `Created`; payload includes amount, rules, NFT token id, and timestamp. The `CommitmentCreatedEvent` struct documents the same domain event shape. | [`lib.rs` lines 552-638](../../contracts/commitment_core/src/lib.rs#L552-L638) | | `update_value` | `"active" -> "active"` when loss stays within `max_loss_percent` | Caller is admin or authorized updater; rate limit passes; `new_value` is non-negative; commitment exists and is active. | `NotAuthorizedUpdater`, `CommitmentNotFound`, `NotActive`, `ArithmeticOverflow`. | Updates `current_value`; adjusts TVL by `new_value - old_value`; keeps status active. | Topic `ValUpd`; payload includes new value and timestamp. | [`lib.rs` lines 904-957](../../contracts/commitment_core/src/lib.rs#L904-L957) | | `update_value` | `"active" -> "violated"` when loss exceeds `max_loss_percent` | Same guards as the non-violating update path. The loss check is `SafeMath::loss_percent(amount, new_value) > rules.max_loss_percent`. | Same as the non-violating update path. | Updates `current_value`; stores `status = "violated"`; adjusts TVL by `new_value - old_value`. | Topic `Violated`; payload includes loss percent, max loss percent, and timestamp. | [`lib.rs` lines 920-957](../../contracts/commitment_core/src/lib.rs#L920-L957) | -| `check_violations` | `"active" -> "active"` observation only | Commitment exists and is active. It checks max-loss and `current_time >= expires_at`. | `CommitmentNotFound`; non-active commitments return `false` without writing state. | No state writes. This entrypoint does not persist `"violated"`. | Topic `Violated` with `RuleViol` payload when the active commitment violates a rule. | [`lib.rs` lines 959-982](../../contracts/commitment_core/src/lib.rs#L959-L982) | +| `check_violations` | `"active" -> "active"` observation only | Commitment exists and is active. It checks max-loss outside the initial grace window and duration at `expires_at + grace_period_days`. | `CommitmentNotFound`, `ExpirationOverflow`; non-active commitments return `false` without writing state. | No state writes. This entrypoint does not persist `"violated"`. | Topic `Violated` with `RuleViol` payload when the active commitment violates a rule. | [`lib.rs` lines 959-982](../../contracts/commitment_core/src/lib.rs#L959-L982) | | `settle` | `"active" -> "settled"` | Reentrancy guard clear; contract not paused; commitment exists; current time is at or after `expires_at`; commitment is active; NFT contract initialized. | `CommitmentNotFound`, `NotExpired`, `AlreadySettled`, `NotActive`, `NotInitialized`. | Stores `status = "settled"`; removes the owner index entry; decreases TVL by settlement amount; transfers assets; invokes `commitment_nft::settle`. | Topic `Settled`; payload includes settlement amount and timestamp. The `CommitmentSettledEvent` struct documents the same domain event shape. | [`lib.rs` lines 1032-1101](../../contracts/commitment_core/src/lib.rs#L1032-L1101) | | `early_exit` | `"active" -> "early_exit"` | Reentrancy guard clear; contract not paused; commitment exists; caller auth succeeds; caller is the commitment owner; commitment is active; NFT contract initialized. | `CommitmentNotFound`, `Unauthorized`, `NotActive`, `NotInitialized`. | Credits penalty to collected fees when positive; stores `status = "early_exit"` and `current_value = 0`; decreases TVL by the pre-penalty value; transfers the returned amount when positive; invokes `commitment_nft::mark_inactive`. | Topic `EarlyExt`; payload includes penalty, returned amount, and timestamp. | [`lib.rs` lines 1147-1224](../../contracts/commitment_core/src/lib.rs#L1147-L1224) | @@ -60,6 +70,7 @@ The state machine has no entrypoint that returns `"violated"`, `"settled"`, or ` | Non-violating value updates keep `"active"` and emit `ValUpd`. | [`test_update_value_no_violation`](../../contracts/commitment_core/src/tests.rs#L2915-L2947) | | Violating value updates persist `"violated"` and emit `Violated`. | [`test_update_value_triggers_violation`](../../contracts/commitment_core/src/tests.rs#L2949-L2982) | | `check_violations` reports a current rule violation without being the persistent transition path. | [`test_check_violations_after_update_value`](../../contracts/commitment_core/src/tests.rs#L2984-L3015) | +| Grace periods suppress temporary max-loss and expiry violations until their checked deadlines. | `test_check_violations_loss_limit_relaxed_inside_grace_period`, `test_check_violations_loss_limit_enforced_after_grace_period`, `test_check_violations_duration_relaxed_until_expiry_grace_deadline`, `test_check_violations_grace_deadline_overflow_rejected` | | Early exit starts from `"active"` and is covered by the status/penalty test group. | [`test_early_exit_status_transition`](../../contracts/commitment_core/src/tests.rs#L2864-L2889) | | Early exit rejects settled, violated, and already exited commitments. | [`test_early_exit_already_settled`](../../contracts/commitment_core/src/tests.rs#L2512-L2543), [`test_early_exit_already_violated`](../../contracts/commitment_core/src/tests.rs#L2545-L2576), [`test_early_exit_already_exited`](../../contracts/commitment_core/src/tests.rs#L2578-L2609) | | Allocation rejects all terminal statuses even though it does not change lifecycle status. | [`test_allocate_when_settled_fails`](../../contracts/commitment_core/src/tests.rs#L2184-L2215), [`test_allocate_when_violated_fails`](../../contracts/commitment_core/src/tests.rs#L2217-L2248), [`test_allocate_when_early_exit_fails`](../../contracts/commitment_core/src/tests.rs#L2250-L2275) |