From 1d1531e74272e7eefa7af14536ffc9e98abe3c0c Mon Sep 17 00:00:00 2001 From: John Imeobong Date: Wed, 27 May 2026 14:32:55 +0000 Subject: [PATCH] Add grace period functionality for penalty-free early exits --- contracts/README.md | 5 +++ contracts/escrow/src/lib.rs | 77 +++++++++++++++++++++++++++++++++--- contracts/escrow/src/test.rs | 53 +++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 6 deletions(-) diff --git a/contracts/README.md b/contracts/README.md index db4689f1..6eb68e6e 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -53,6 +53,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). | @@ -105,6 +107,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 4083c3d0..c777bce9 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -39,6 +39,14 @@ pub enum DataKey { Dispute(u64), /// Default penalty in basis points for each RiskProfile. DefaultPenalty(RiskProfile), + /// Contract pause flag used for emergency write halts. + Paused, + /// On-chain yield pool balance used to pay matured commitment yield. + YieldPool, + /// Historical attestation records keyed by commitment id. + Attestations(u64), + /// Configurable penalty-free grace period before maturity, in seconds. + GracePeriodSeconds, } /// Risk profile chosen at creation time. Determines the early-exit penalty @@ -99,6 +107,15 @@ pub struct DisputeRecord { pub disputed_by: Address, } +/// Historical compliance attestation stored against a commitment. +#[contracttype] +#[derive(Clone)] +pub struct AttestationRecord { + pub attestor: Address, + pub compliance_score: u32, + pub timestamp: u64, +} + /// A single escrow / commitment record. #[contracttype] #[derive(Clone)] @@ -221,6 +238,9 @@ impl EscrowContract { env.storage() .instance() .set(&DataKey::DefaultPenalty(RiskProfile::Aggressive), &aggressive_default_penalty_bps); + env.storage() + .instance() + .set(&DataKey::GracePeriodSeconds, &0u64); Ok(()) } @@ -594,7 +614,11 @@ impl EscrowContract { c.status = EscrowStatus::Released; paid = payout; } else { - let penalty = (c.amount * c.penalty_bps as i128) / MAX_PENALTY_BPS as i128; + let penalty = if Self::is_within_grace_period(&env, &c) { + 0 + } else { + (c.amount * c.penalty_bps as i128) / MAX_PENALTY_BPS as i128 + }; paid = c.amount - penalty; token.transfer(&contract, &c.owner, &paid); c.status = EscrowStatus::Refunded; @@ -686,6 +710,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( @@ -696,11 +740,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) @@ -744,6 +792,23 @@ impl EscrowContract { .ok_or(Error::NotInitialized) } + 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 22eaef23..070ad9cf 100644 --- a/contracts/escrow/src/test.rs +++ b/contracts/escrow/src/test.rs @@ -250,6 +250,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();