From 452adf9238e453fee841b0ee31555283b77b396a Mon Sep 17 00:00:00 2001 From: chisomvictorcv-sketch Date: Sat, 30 May 2026 10:32:34 -0700 Subject: [PATCH] feat: add milestone deadlines, time-based validation, and auto-expiry --- contracts/contracts/escrow/src/lib.rs | 84 ++++++++++++++++++++++++++- lib/escrow/types.ts | 3 + 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/contracts/contracts/escrow/src/lib.rs b/contracts/contracts/escrow/src/lib.rs index 85b6009..e38b1cc 100644 --- a/contracts/contracts/escrow/src/lib.rs +++ b/contracts/contracts/escrow/src/lib.rs @@ -12,12 +12,14 @@ pub enum MilestoneStatus { Approved = 3, // Approved by client, ready for release Released = 4, // Funds successfully transferred to freelancer Refunded = 5, // Funds returned to client - Disputed = 6, // Under dispute, waiting for arbiter resolution + Disputed = 6, + AutoExpired = 7, // Under dispute, waiting for arbiter resolution } #[derive(Clone, Debug, PartialEq, Eq)] #[contracttype] pub struct Milestone { + pub deadline: u64, pub id: u32, pub amount: i128, pub status: MilestoneStatus, @@ -35,6 +37,7 @@ pub enum DataKey { IsFunded, ClientApproval(u32), FreelancerApproval(u32), + MilestoneDeadline(u32), } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -50,6 +53,8 @@ pub enum Error { ZeroAmount = 8, InsufficientApprovals = 9, AlreadyApproved = 10, + DeadlineExceeded = 11, + AlreadyExpired = 12, } #[contract] @@ -87,6 +92,14 @@ impl EscrowContract { env.storage().instance().set(&DataKey::Arbiter, &arbiter); env.storage().instance().set(&DataKey::Token, &token); env.storage().instance().set(&DataKey::Milestones, &milestones); + + // Store milestone deadlines for time-based validation + for i in 0..milestones.len() { + let m = milestones.get(i).unwrap(); + if m.deadline > 0 { + env.storage().instance().set(&DataKey::MilestoneDeadline(m.id), &m.deadline); + } + } env.storage().instance().set(&DataKey::IsFunded, &false); Ok(()) @@ -151,7 +164,10 @@ impl EscrowContract { if milestone.status != MilestoneStatus::Funded { return Err(Error::InvalidMilestoneStatus); } - milestone.status = MilestoneStatus::Submitted; + if milestone.deadline > 0 && env.ledger().timestamp() > milestone.deadline { + return Err(Error::DeadlineExceeded); + } + milestone.status = MilestoneStatus::Submitted; } updated_milestones.push_back(milestone); } @@ -416,6 +432,70 @@ impl EscrowContract { Ok(()) } + + /// Auto-expire a milestone that has exceeded its deadline without being completed. + /// Refunds the milestone amount back to the client. + /// Can be called by anyone once the deadline has passed. + pub fn auto_expire(env: Env, milestone_id: u32) -> Result<(), Error> { + let milestones: Vec = env.storage().instance().get(&DataKey::Milestones).ok_or(Error::NotInitialized)?; + let mut found = false; + let mut transfer_amount: i128 = 0; + let mut updated_milestones = Vec::new(&env); + + for i in 0..milestones.len() { + let mut milestone = milestones.get(i).unwrap(); + if milestone.id == milestone_id { + found = true; + + // Check deadline exists and has passed + if milestone.deadline == 0 { + return Err(Error::InvalidMilestoneStatus); + } + if env.ledger().timestamp() <= milestone.deadline { + return Err(Error::InvalidMilestoneStatus); + } + + // Only expire if not yet released, refunded, or disputed + if milestone.status == MilestoneStatus::Released || + milestone.status == MilestoneStatus::Refunded || + milestone.status == MilestoneStatus::Disputed || + milestone.status == MilestoneStatus::AutoExpired { + return Err(Error::AlreadyExpired); + } + + transfer_amount = milestone.amount; + milestone.status = MilestoneStatus::AutoExpired; + } + updated_milestones.push_back(milestone); + } + + if !found { + return Err(Error::MilestoneNotFound); + } + + // Refund the expired milestone amount to the client + let client: Address = env.storage().instance().get(&DataKey::Client).ok_or(Error::NotInitialized)?; + let token_address: Address = env.storage().instance().get(&DataKey::Token).ok_or(Error::NotInitialized)?; + let token_client = token::Client::new(&env, &token_address); + token_client.transfer(&env.current_contract_address(), &client, &transfer_amount); + + env.storage().instance().set(&DataKey::Milestones, &updated_milestones); + Ok(()) + } + + /// Check if a milestone has passed its deadline. + pub fn is_milestone_expired(env: Env, milestone_id: u32) -> Result { + let deadline: u64 = env.storage().instance().get(&DataKey::MilestoneDeadline(milestone_id)).unwrap_or(0); + if deadline == 0 { + return Ok(false); + } + Ok(env.ledger().timestamp() > deadline) + } + + /// Get the deadline for a specific milestone. + pub fn get_milestone_deadline(env: Env, milestone_id: u32) -> Result { + env.storage().instance().get(&DataKey::MilestoneDeadline(milestone_id)).ok_or(Error::MilestoneNotFound) + } // --- State Getters --- pub fn get_client(env: Env) -> Result { diff --git a/lib/escrow/types.ts b/lib/escrow/types.ts index c8f68e9..c7cbbbc 100644 --- a/lib/escrow/types.ts +++ b/lib/escrow/types.ts @@ -26,6 +26,7 @@ export type EscrowStatus = | 'refunded' export type MilestoneStatus = + | 'auto_expired' | 'pending' | 'in_progress' | 'submitted' @@ -131,6 +132,8 @@ export interface MilestoneInput { description?: string amount: string dueDate?: string + /** Unix timestamp (seconds) for milestone deadline. After this, the milestone can be auto-expired. */ + deadline?: number sortOrder?: number }