Skip to content
Merged
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
5 changes: 5 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down Expand Up @@ -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.
Expand Down
66 changes: 55 additions & 11 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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<u64, Error> {
Self::require_init(&env)?;
Ok(Self::grace_period_seconds(&env))
}

// ── Internal helpers ────────────────────────────────────────────────────

fn execute_refund(
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
53 changes: 53 additions & 0 deletions contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading