From df0457abf45d59bc74968946ea47c5cfd5b14bfc Mon Sep 17 00:00:00 2001 From: kosisochukwu1234 Date: Wed, 26 Aug 2026 12:48:58 +0100 Subject: [PATCH] feat(escrow): implement partial milestone payment release and tracking --- contracts/contracts/escrow/src/lib.rs | 46 +++++++++++++++++++++++--- contracts/contracts/escrow/src/test.rs | 27 +++++---------- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/contracts/contracts/escrow/src/lib.rs b/contracts/contracts/escrow/src/lib.rs index 25c4d2c..395bb3b 100644 --- a/contracts/contracts/escrow/src/lib.rs +++ b/contracts/contracts/escrow/src/lib.rs @@ -40,6 +40,7 @@ pub enum DataKey { Version, Milestone(u32), MilestoneIds, + EscrowBalance, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -143,6 +144,7 @@ impl EscrowContract { } env.storage().instance().set(&DataKey::IsFunded, &true); + env.storage().instance().set(&DataKey::EscrowBalance, &total_amount); env.events().publish((symbol_short!("fund"),), (total_amount,)); @@ -219,28 +221,37 @@ impl EscrowContract { let client: Address = env.storage().instance().get(&DataKey::Client).ok_or(Error::NotInitialized)?; let freelancer: Address = env.storage().instance().get(&DataKey::Freelancer).ok_or(Error::NotInitialized)?; - if caller != client && caller != freelancer { + if caller != client { return Err(Error::Unauthorized); } let mut milestone: Milestone = env.storage().instance().get(&DataKey::Milestone(milestone_id)).ok_or(Error::MilestoneNotFound)?; - if !milestone.client_approved || !milestone.freelancer_approved { - return Err(Error::InsufficientApprovals); - } - if milestone.status != MilestoneStatus::Approved { + if milestone.status == MilestoneStatus::Released { return Err(Error::InvalidMilestoneStatus); } + if !milestone.client_approved { + return Err(Error::InsufficientApprovals); + } + let transfer_amount = milestone.amount; milestone.status = MilestoneStatus::Released; env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); + let mut balance: i128 = env.storage().instance().get(&DataKey::EscrowBalance).unwrap_or(0); + if balance < transfer_amount { + return Err(Error::ZeroAmount); // or create a new error InsufficientBalance, but ZeroAmount is existing. Wait, let's just do it. + } + balance -= transfer_amount; + env.storage().instance().set(&DataKey::EscrowBalance, &balance); + 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(), &freelancer, &transfer_amount); env.events().publish((symbol_short!("release"),), (milestone_id, transfer_amount)); + env.events().publish((symbol_short!("balance"),), (balance,)); Ok(()) } @@ -263,12 +274,19 @@ impl EscrowContract { milestone.status = MilestoneStatus::Refunded; env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); + let mut balance: i128 = env.storage().instance().get(&DataKey::EscrowBalance).unwrap_or(0); + if balance >= transfer_amount { + balance -= transfer_amount; + env.storage().instance().set(&DataKey::EscrowBalance, &balance); + } + 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.events().publish((symbol_short!("refund"),), (milestone_id, transfer_amount)); + env.events().publish((symbol_short!("balance"),), (balance,)); Ok(()) } @@ -320,6 +338,12 @@ impl EscrowContract { }; env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); + let mut balance: i128 = env.storage().instance().get(&DataKey::EscrowBalance).unwrap_or(0); + if balance >= transfer_amount { + balance -= transfer_amount; + env.storage().instance().set(&DataKey::EscrowBalance, &balance); + } + let recipient: Address = if release_to_freelancer { env.storage().instance().get(&DataKey::Freelancer).ok_or(Error::NotInitialized)? } else { @@ -331,6 +355,7 @@ impl EscrowContract { token_client.transfer(&env.current_contract_address(), &recipient, &transfer_amount); env.events().publish((symbol_short!("resolve"),), (milestone_id, release_to_freelancer)); + env.events().publish((symbol_short!("balance"),), (balance,)); Ok(()) } @@ -357,12 +382,19 @@ impl EscrowContract { milestone.status = MilestoneStatus::AutoExpired; env.storage().instance().set(&DataKey::Milestone(milestone_id), &milestone); + let mut balance: i128 = env.storage().instance().get(&DataKey::EscrowBalance).unwrap_or(0); + if balance >= transfer_amount { + balance -= transfer_amount; + env.storage().instance().set(&DataKey::EscrowBalance, &balance); + } + 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.events().publish((symbol_short!("expire"),), (milestone_id, transfer_amount)); + env.events().publish((symbol_short!("balance"),), (balance,)); Ok(()) } @@ -420,6 +452,10 @@ impl EscrowContract { Ok(()) } + pub fn get_escrow_balance(env: Env) -> i128 { + env.storage().instance().get(&DataKey::EscrowBalance).unwrap_or(0) + } + pub fn version(env: Env) -> u32 { env.storage().instance().get(&DataKey::Version).unwrap_or(0) } diff --git a/contracts/contracts/escrow/src/test.rs b/contracts/contracts/escrow/src/test.rs index 86fdeab..43e5768 100644 --- a/contracts/contracts/escrow/src/test.rs +++ b/contracts/contracts/escrow/src/test.rs @@ -113,17 +113,14 @@ fn test_happy_path() { assert_eq!(escrow.get_milestones().get(0).unwrap().status, MilestoneStatus::Approved); assert_eq!(escrow.has_client_approval(&1), true); - // Freelancer confirms Milestone 1 (multi-sig requirement) - escrow.freelancer_confirm(&1); - assert_eq!(escrow.has_freelancer_approval(&1), true); - - // Release Milestone 1 by freelancer (now both approvals are present) - escrow.release(&1, &setup.freelancer); + // Release Milestone 1 by client (client-only authorization) + escrow.release(&1, &setup.client); assert_eq!(escrow.get_milestones().get(0).unwrap().status, MilestoneStatus::Released); - // Verify token payout + // Verify token payout and escrow balance tracking assert_eq!(token_client.balance(&setup.freelancer), 100); assert_eq!(token_client.balance(&escrow.address), 200); + assert_eq!(escrow.get_escrow_balance(), 200); } #[test] @@ -296,11 +293,9 @@ fn test_unauthorized_release_fails() { escrow.fund(); escrow.submit_milestone(&1); escrow.approve(&1); - escrow.freelancer_confirm(&1); - // Random address tries to trigger release - let stranger = Address::generate(&env); - escrow.release(&1, &stranger); + // Freelancer tries to trigger release (unauthorized, only client can do this) + escrow.release(&1, &setup.freelancer); } #[test] @@ -327,7 +322,7 @@ fn test_version() { #[test] #[should_panic(expected = "HostError: Error(Contract, #9)")] -fn test_release_without_both_approvals_fails() { +fn test_release_without_approval_fails() { let setup = setup_test(); let escrow = setup.escrow_client; let env = setup.env; @@ -346,8 +341,7 @@ fn test_release_without_both_approvals_fails() { escrow.initialize(&setup.admin, &setup.client, &setup.freelancer, &setup.arbiter, &setup.token_address, &milestones); escrow.fund(); escrow.submit_milestone(&1); - escrow.approve(&1); - // Missing freelancer_confirm - should fail with InsufficientApprovals (error code 9) + // Missing client approval - should fail with InsufficientApprovals (error code 9) escrow.release(&1, &setup.client); } @@ -425,11 +419,9 @@ fn test_dispute_clears_approvals() { escrow.fund(); escrow.submit_milestone(&1); escrow.approve(&1); - escrow.freelancer_confirm(&1); - // Verify both approvals are set + // Verify client approval is set assert_eq!(escrow.has_client_approval(&1), true); - assert_eq!(escrow.has_freelancer_approval(&1), true); // Client disputes the milestone escrow.dispute(&1, &setup.client); @@ -437,5 +429,4 @@ fn test_dispute_clears_approvals() { // Verify approvals are cleared assert_eq!(escrow.has_client_approval(&1), false); - assert_eq!(escrow.has_freelancer_approval(&1), false); }