Skip to content
Closed
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
68 changes: 61 additions & 7 deletions contracts/commitment_core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -956,21 +995,29 @@ 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"));
if commitment.status != String::from_str(&e, "active") {
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(
Expand All @@ -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(
Expand All @@ -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,
Expand Down
140 changes: 140 additions & 0 deletions contracts/commitment_core/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
13 changes: 12 additions & 1 deletion docs/commitment_core/SEMANTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,24 @@ 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 |
| --- | --- | --- | --- | --- | --- | --- |
| `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) |

Expand All @@ -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) |
Expand Down
Loading