diff --git a/contracts/README.md b/contracts/README.md index d6df2bc3..2c1b7a49 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -81,6 +81,8 @@ create_commitment ──► fund_escrow ──► release (matured: p | `get_yield_pool_balance()` | Read the yield pool balance available for matured release payouts. | | `release(commitment_id, caller)` | Return principal plus accrued yield to owner once matured (`Funded → Released`). | | `refund(commitment_id)` | Early-exit refund of principal minus `penalty_bps` (`Funded → Refunded`). | +| `set_grace_period(admin, grace_period_seconds)` | Admin-only configuration of the penalty-free grace window before maturity. | +| `get_grace_period()` | Read the currently configured penalty-free grace period in seconds. | | `dispute(commitment_id, caller, reason)` | Freeze a funded commitment pending admin resolution. The reason is automatically categorized. | | `resolve_dispute(commitment_id, release_to_owner)` | Admin-only settlement of a disputed commitment. | | `get_dispute(commitment_id)` | Read the dispute record for a commitment (category, reason, timestamp, initiator). | @@ -136,6 +138,9 @@ const result = await invokeContractMethod( console.log(`Exit Amount: ${result.exitAmount}, Penalty: ${result.penaltyAmount}`); ``` +#### Grace period behavior +The contract supports a configurable penalty-free window before commitment maturity. If a funded commitment is refunded while the ledger time is within the configured grace period before maturity, the early-exit penalty is waived and the full principal is returned. + ### Yield model Matured `release` payouts now return the locked principal plus the commitment's accrued yield. Yield is calculated at commitment creation using a simple annualized model based on the selected `RiskProfile` and the commitment duration. diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index f1fb34ed..a022f698 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -41,14 +41,14 @@ pub enum DataKey { Dispute(u64), /// Default penalty in basis points for each RiskProfile. DefaultPenalty(RiskProfile), - /// Contract paused flag; true halts write operations. + /// Contract pause flag used for emergency write halts. Paused, - /// Accumulated yield pool balance available to pay matured commitments. + /// On-chain yield pool balance used to pay matured commitment yield. YieldPool, - /// Attestation history for a commitment, keyed by commitment id. + /// Historical attestation records keyed by commitment id. Attestations(u64), - /// Minimum compliance score threshold; scores below this auto-violate a funded commitment. - ViolationThreshold, + /// Configurable penalty-free grace period before maturity, in seconds. + GracePeriodSeconds, } /// Risk profile chosen at creation time. Determines the early-exit penalty @@ -111,7 +111,7 @@ pub struct DisputeRecord { pub disputed_by: Address, } -/// A single compliance attestation entry appended to a commitment's history. +/// Historical compliance attestation stored against a commitment. #[contracttype] #[derive(Clone)] pub struct AttestationRecord { @@ -253,6 +253,9 @@ impl EscrowContract { env.storage() .instance() .set(&DataKey::DefaultPenalty(RiskProfile::Aggressive), &aggressive_default_penalty_bps); + env.storage() + .instance() + .set(&DataKey::GracePeriodSeconds, &0u64); Ok(()) } @@ -992,6 +995,26 @@ impl EscrowContract { .ok_or(Error::NotInitialized) } + /// Admin-only setter for the penalty-free grace period before maturity. + /// If the commitment is refunded within the configured window before + /// maturity, the early-exit penalty is waived. + pub fn set_grace_period(env: Env, admin: Address, grace_period_seconds: u64) -> Result<(), Error> { + Self::require_init(&env)?; + admin.require_auth(); + env.storage() + .instance() + .set(&DataKey::GracePeriodSeconds, &grace_period_seconds); + env.events() + .publish((Symbol::new(&env, "set_grace_period"), admin), (grace_period_seconds,)); + Ok(()) + } + + /// Returns the currently configured penalty-free grace period in seconds. + pub fn get_grace_period(env: Env) -> Result { + Self::require_init(&env)?; + Ok(Self::grace_period_seconds(&env)) + } + // ── Internal helpers ──────────────────────────────────────────────────── fn execute_refund( @@ -1005,11 +1028,15 @@ impl EscrowContract { return Err(Error::InvalidState); } - let penalty_mul = c - .amount - .checked_mul(c.penalty_bps as i128) - .ok_or(Error::InvalidAmount)?; - let penalty = penalty_mul / MAX_PENALTY_BPS as i128; + let penalty = if Self::is_within_grace_period(env, &c) { + 0 + } else { + let penalty_mul = c + .amount + .checked_mul(c.penalty_bps as i128) + .ok_or(Error::InvalidAmount)?; + penalty_mul / MAX_PENALTY_BPS as i128 + }; let refund_amount = c .amount .checked_sub(penalty) @@ -1066,6 +1093,23 @@ impl EscrowContract { Ok((penalty, refund_amount)) } + fn grace_period_seconds(env: &Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::GracePeriodSeconds) + .unwrap_or(0) + } + + fn is_within_grace_period(env: &Env, c: &Commitment) -> bool { + let now = env.ledger().timestamp(); + let grace = Self::grace_period_seconds(env); + if grace == 0 || now >= c.maturity { + return false; + } + let threshold = c.maturity.saturating_sub(grace); + now >= threshold + } + fn next_id(env: &Env) -> u64 { let id: u64 = env .storage() diff --git a/contracts/escrow/src/test.rs b/contracts/escrow/src/test.rs index 49fead50..99a034fc 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -385,6 +385,59 @@ fn refund_applies_penalty_to_fee_recipient() { assert_eq!(f.client.get_commitment(&id).status, EscrowStatus::Refunded); } +#[test] +fn refund_within_grace_period_is_penalty_free() { + let f = setup(); + let owner = Address::generate(&f.env); + fund_owner(&f, &owner, 1_000); + + // Admin configures a 1-day penalty-free grace window. + f.client.set_grace_period(&f.admin, &SECONDS_PER_DAY); + + let id = f + .client + .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Aggressive, &30, &500); + f.client.fund_escrow(&id); + + // Advance to the exact start of the grace window. + f.env.ledger().set_timestamp(29 * SECONDS_PER_DAY); + let refunded = f.client.refund(&id); + + assert_eq!(refunded, 1_000); + assert_eq!(f.token.balance(&owner), 1_000); + assert_eq!(f.token.balance(&f.fee_recipient), 0); +} + +#[test] +fn refund_outside_grace_period_still_applies_penalty() { + let f = setup(); + let owner = Address::generate(&f.env); + fund_owner(&f, &owner, 1_000); + + f.client.set_grace_period(&f.admin, &SECONDS_PER_DAY); + + let id = f + .client + .create_commitment(&owner, &f.asset, &1_000, &RiskProfile::Aggressive, &30, &500); + f.client.fund_escrow(&id); + + // Advance to just before the grace window begins. + f.env.ledger().set_timestamp(28 * SECONDS_PER_DAY); + let refunded = f.client.refund(&id); + + assert_eq!(refunded, 950); + assert_eq!(f.token.balance(&f.fee_recipient), 50); +} + +#[test] +fn admin_can_set_and_get_grace_period() { + let f = setup(); + assert_eq!(f.client.get_grace_period(), 0); + + f.client.set_grace_period(&f.admin, &SECONDS_PER_DAY); + assert_eq!(f.client.get_grace_period(), SECONDS_PER_DAY); +} + #[test] fn dispute_freezes_then_admin_resolves() { let f = setup();