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
74 changes: 72 additions & 2 deletions contracts/contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ pub enum DataKey {
Token,
Milestones,
IsFunded,
ClientApproval(u32),
FreelancerApproval(u32),
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
Expand All @@ -46,6 +48,8 @@ pub enum Error {
InvalidMilestoneStatus = 6,
Unauthorized = 7,
ZeroAmount = 8,
InsufficientApprovals = 9,
AlreadyApproved = 10,
}

#[contract]
Expand Down Expand Up @@ -165,6 +169,12 @@ impl EscrowContract {
let client: Address = env.storage().instance().get(&DataKey::Client).ok_or(Error::NotInitialized)?;
client.require_auth();

// Check if already approved by client
let approval_key = DataKey::ClientApproval(milestone_id);
if env.storage().instance().has(&approval_key) {
return Err(Error::AlreadyApproved);
}

let milestones: Vec<Milestone> = env.storage().instance().get(&DataKey::Milestones).ok_or(Error::NotInitialized)?;
let mut found = false;
let mut updated_milestones = Vec::new(&env);
Expand All @@ -176,6 +186,8 @@ impl EscrowContract {
if milestone.status != MilestoneStatus::Submitted {
return Err(Error::InvalidMilestoneStatus);
}
// Set client approval
env.storage().instance().set(&approval_key, &true);
milestone.status = MilestoneStatus::Approved;
}
updated_milestones.push_back(milestone);
Expand All @@ -189,8 +201,41 @@ impl EscrowContract {
Ok(())
}

/// Freelancer confirms milestone completion (second signature for multi-sig release).
pub fn freelancer_confirm(env: Env, milestone_id: u32) -> Result<(), Error> {
let freelancer: Address = env.storage().instance().get(&DataKey::Freelancer).ok_or(Error::NotInitialized)?;
freelancer.require_auth();

let milestones: Vec<Milestone> = env.storage().instance().get(&DataKey::Milestones).ok_or(Error::NotInitialized)?;
let mut found = false;

for i in 0..milestones.len() {
let milestone = milestones.get(i).unwrap();
if milestone.id == milestone_id {
found = true;
if milestone.status != MilestoneStatus::Approved {
return Err(Error::InvalidMilestoneStatus);
}
// Check if already confirmed by freelancer
let approval_key = DataKey::FreelancerApproval(milestone_id);
if env.storage().instance().has(&approval_key) {
return Err(Error::AlreadyApproved);
}
// Set freelancer confirmation
env.storage().instance().set(&approval_key, &true);
}
}

if !found {
return Err(Error::MilestoneNotFound);
}

Ok(())
}

/// Transfers funds of an approved milestone to the freelancer.
/// Can be triggered by either client or freelancer.
/// Requires multi-signature: both client and freelancer must have approved.
/// Can be triggered by either client or freelancer after both approvals.
pub fn release(env: Env, milestone_id: u32, caller: Address) -> Result<(), Error> {
caller.require_auth();

Expand All @@ -201,6 +246,17 @@ impl EscrowContract {
return Err(Error::Unauthorized);
}

// Check multi-signature requirement: both client and freelancer must have approved
let client_approval_key = DataKey::ClientApproval(milestone_id);
let freelancer_approval_key = DataKey::FreelancerApproval(milestone_id);

let client_approved: bool = env.storage().instance().get(&client_approval_key).unwrap_or(false);
let freelancer_approved: bool = env.storage().instance().get(&freelancer_approval_key).unwrap_or(false);

if !client_approved || !freelancer_approved {
return Err(Error::InsufficientApprovals);
}

let milestones: Vec<Milestone> = env.storage().instance().get(&DataKey::Milestones).ok_or(Error::NotInitialized)?;
let mut found = false;
let mut transfer_amount: i128 = 0;
Expand Down Expand Up @@ -276,6 +332,7 @@ impl EscrowContract {

/// Puts a milestone into dispute, halting regular flow and delegating resolution to the arbiter.
/// Can be raised by client or freelancer.
/// Clears any existing approvals when dispute is raised.
pub fn dispute(env: Env, milestone_id: u32, caller: Address) -> Result<(), Error> {
caller.require_auth();

Expand All @@ -294,10 +351,13 @@ impl EscrowContract {
let mut milestone = milestones.get(i).unwrap();
if milestone.id == milestone_id {
found = true;
if milestone.status != MilestoneStatus::Funded && milestone.status != MilestoneStatus::Submitted {
if milestone.status != MilestoneStatus::Funded && milestone.status != MilestoneStatus::Submitted && milestone.status != MilestoneStatus::Approved {
return Err(Error::InvalidMilestoneStatus);
}
milestone.status = MilestoneStatus::Disputed;
// Clear approvals when dispute is raised
env.storage().instance().remove(&DataKey::ClientApproval(milestone_id));
env.storage().instance().remove(&DataKey::FreelancerApproval(milestone_id));
}
updated_milestones.push_back(milestone);
}
Expand Down Expand Up @@ -381,6 +441,16 @@ impl EscrowContract {
pub fn is_funded(env: Env) -> bool {
env.storage().instance().get(&DataKey::IsFunded).unwrap_or(false)
}

/// Check if client has approved a specific milestone
pub fn has_client_approval(env: Env, milestone_id: u32) -> bool {
env.storage().instance().get(&DataKey::ClientApproval(milestone_id)).unwrap_or(false)
}

/// Check if freelancer has approved a specific milestone
pub fn has_freelancer_approval(env: Env, milestone_id: u32) -> bool {
env.storage().instance().get(&DataKey::FreelancerApproval(milestone_id)).unwrap_or(false)
}
}

mod test;
115 changes: 113 additions & 2 deletions contracts/contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,16 @@ fn test_happy_path() {
escrow.submit_milestone(&1);
assert_eq!(escrow.get_milestones().get(0).unwrap().status, MilestoneStatus::Submitted);

// Approve Milestone 1
// Approve Milestone 1 by client
escrow.approve(&1);
assert_eq!(escrow.get_milestones().get(0).unwrap().status, MilestoneStatus::Approved);
assert_eq!(escrow.has_client_approval(&1), true);

// Release Milestone 1 by freelancer
// 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);
assert_eq!(escrow.get_milestones().get(0).unwrap().status, MilestoneStatus::Released);

Expand Down Expand Up @@ -159,6 +164,7 @@ fn test_dispute_and_resolve_to_freelancer() {
escrow.initialize(&setup.client, &setup.freelancer, &setup.arbiter, &setup.token_address, &milestones);
escrow.fund();
escrow.submit_milestone(&1);
escrow.approve(&1);

// Client disputes the milestone
escrow.dispute(&1, &setup.client);
Expand Down Expand Up @@ -190,6 +196,7 @@ fn test_dispute_and_resolve_to_client() {

escrow.initialize(&setup.client, &setup.freelancer, &setup.arbiter, &setup.token_address, &milestones);
escrow.fund();
escrow.submit_milestone(&1);

// Freelancer disputes milestone (perhaps client won't approve)
escrow.dispute(&1, &setup.freelancer);
Expand Down Expand Up @@ -262,8 +269,112 @@ 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);
}

#[test]
#[should_panic(expected = "HostError: Error(Contract, #9)")]
fn test_release_without_both_approvals_fails() {
let setup = setup_test();
let escrow = setup.escrow_client;
let env = setup.env;

let milestone = Milestone {
id: 1,
amount: 100,
status: MilestoneStatus::Pending,
description: String::from_str(&env, "Milestone"),
};
let milestones = vec![&env, milestone];

escrow.initialize(&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)
escrow.release(&1, &setup.client);
}

#[test]
#[should_panic(expected = "HostError: Error(Contract, #10)")]
fn test_double_client_approval_fails() {
let setup = setup_test();
let escrow = setup.escrow_client;
let env = setup.env;

let milestone = Milestone {
id: 1,
amount: 100,
status: MilestoneStatus::Pending,
description: String::from_str(&env, "Milestone"),
};
let milestones = vec![&env, milestone];

escrow.initialize(&setup.client, &setup.freelancer, &setup.arbiter, &setup.token_address, &milestones);
escrow.fund();
escrow.submit_milestone(&1);
escrow.approve(&1);
// Double approval should fail with AlreadyApproved (error code 10)
escrow.approve(&1);
}

#[test]
#[should_panic(expected = "HostError: Error(Contract, #10)")]
fn test_double_freelancer_confirmation_fails() {
let setup = setup_test();
let escrow = setup.escrow_client;
let env = setup.env;

let milestone = Milestone {
id: 1,
amount: 100,
status: MilestoneStatus::Pending,
description: String::from_str(&env, "Milestone"),
};
let milestones = vec![&env, milestone];

escrow.initialize(&setup.client, &setup.freelancer, &setup.arbiter, &setup.token_address, &milestones);
escrow.fund();
escrow.submit_milestone(&1);
escrow.approve(&1);
escrow.freelancer_confirm(&1);
// Double confirmation should fail with AlreadyApproved (error code 10)
escrow.freelancer_confirm(&1);
}

#[test]
fn test_dispute_clears_approvals() {
let setup = setup_test();
let escrow = setup.escrow_client;
let env = setup.env;

let milestone = Milestone {
id: 1,
amount: 400,
status: MilestoneStatus::Pending,
description: String::from_str(&env, "High Value Milestone"),
};
let milestones = vec![&env, milestone];

escrow.initialize(&setup.client, &setup.freelancer, &setup.arbiter, &setup.token_address, &milestones);
escrow.fund();
escrow.submit_milestone(&1);
escrow.approve(&1);
escrow.freelancer_confirm(&1);

// Verify both approvals are 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);
assert_eq!(escrow.get_milestones().get(0).unwrap().status, MilestoneStatus::Disputed);

// Verify approvals are cleared
assert_eq!(escrow.has_client_approval(&1), false);
assert_eq!(escrow.has_freelancer_approval(&1), false);
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,25 @@
}
]
],
[
[
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4",
{
"function": {
"contract_fn": {
"contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMDR4",
"function_name": "submit_milestone",
"args": [
{
"u32": 1
}
]
}
},
"sub_invocations": []
}
]
],
[
[
"CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4",
Expand Down Expand Up @@ -204,6 +223,26 @@
},
"live_until": 6311999
},
{
"entry": {
"last_modified_ledger_seq": 0,
"data": {
"contract_data": {
"ext": "v0",
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4",
"key": {
"ledger_key_nonce": {
"nonce": "2032731177588607455"
}
},
"durability": "temporary",
"val": "void"
}
},
"ext": "v0"
},
"live_until": 6311999
},
{
"entry": {
"last_modified_ledger_seq": 0,
Expand Down Expand Up @@ -233,7 +272,7 @@
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M",
"key": {
"ledger_key_nonce": {
"nonce": "2032731177588607455"
"nonce": "4270020994084947596"
}
},
"durability": "temporary",
Expand Down
Loading
Loading