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
16 changes: 13 additions & 3 deletions contracts/contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,12 @@ impl EscrowContract {
return Err(Error::InvalidMilestoneStatus);
}

// While a dispute is open the escrowed funds are frozen: `release` must be
// blocked until the arbitrator resolves the dispute.
if milestone.status == MilestoneStatus::Disputed {
return Err(Error::InvalidMilestoneStatus);
}

if !milestone.client_approved {
return Err(Error::InsufficientApprovals);
}
Expand Down Expand Up @@ -469,15 +475,19 @@ 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)?;
let arbiter: Address = env.storage().instance().get(&DataKey::Arbiter).ok_or(Error::NotInitialized)?;

if caller != client && caller != freelancer {
// Role-based access: only the escrow participants (client/freelancer) or the
// authorized arbitrator may raise a dispute.
if caller != client && caller != freelancer && caller != arbiter {
return Err(Error::Unauthorized);
}

let mut milestone: Milestone = env.storage().instance().get(&DataKey::Milestone(milestone_id)).ok_or(Error::MilestoneNotFound)?;

if milestone.status != MilestoneStatus::Funded
&& milestone.status != MilestoneStatus::Submitted
// A dispute can only be raised after the milestone has been funded and
// submitted (Submitted/Approved) - it must not bypass the normal lifecycle.
if milestone.status != MilestoneStatus::Submitted
&& milestone.status != MilestoneStatus::Approved
{
return Err(Error::InvalidMilestoneStatus);
Expand Down
122 changes: 122 additions & 0 deletions contracts/contracts/escrow/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -612,3 +612,125 @@ fn test_successful_security_events_are_emitted() {
1
);
}

// --- Dispute escrow mechanism edge cases (issue #220) ---

#[test]
#[should_panic(expected = "HostError: Error(Contract, #6)")]
fn test_dispute_without_funding_fails() {
let setup = setup_test();
// Milestone is Pending (never funded): a dispute must not bypass escrow funding.
initialize_single_milestone(&setup, 150);

setup.escrow_client.dispute(&1, &setup.client);
}

#[test]
#[should_panic(expected = "HostError: Error(Contract, #6)")]
fn test_dispute_before_submission_fails() {
let setup = setup_test();
// Funded but not submitted yet: a dispute must not bypass milestone submission.
initialize_single_milestone(&setup, 150);
setup.escrow_client.fund();

setup.escrow_client.dispute(&1, &setup.client);
}

#[test]
fn test_arbiter_can_raise_dispute() {
let setup = setup_test();
initialize_single_milestone(&setup, 150);
setup.escrow_client.fund();
setup.escrow_client.submit_milestone(&1);

// The authorized arbitrator is allowed to raise a dispute.
setup.escrow_client.dispute(&1, &setup.arbiter);

assert_eq!(
setup.escrow_client.get_milestones().get(0).unwrap().status,
MilestoneStatus::Disputed
);
}

#[test]
#[should_panic(expected = "HostError: Error(Contract, #6)")]
fn test_release_blocked_while_disputed() {
let setup = setup_test();
initialize_single_milestone(&setup, 150);
setup.escrow_client.fund();
setup.escrow_client.submit_milestone(&1);
setup.escrow_client.approve(&1);
setup.escrow_client.dispute(&1, &setup.client);

// Funds are locked while the dispute is open, even for the client.
setup.escrow_client.release(&1, &setup.client);
}

#[test]
#[should_panic(expected = "HostError: Error(Contract, #6)")]
fn test_refund_blocked_while_disputed() {
let setup = setup_test();
initialize_single_milestone(&setup, 150);
setup.escrow_client.fund();
setup.escrow_client.submit_milestone(&1);
setup.escrow_client.dispute(&1, &setup.freelancer);

// The freelancer cannot refund out from under an open dispute.
setup.escrow_client.refund(&1, &setup.freelancer);
}

#[test]
#[should_panic(expected = "HostError: Error(Contract, #6)")]
fn test_multiple_disputes_rejected() {
let setup = setup_test();
initialize_single_milestone(&setup, 150);
setup.escrow_client.fund();
setup.escrow_client.submit_milestone(&1);

setup.escrow_client.dispute(&1, &setup.client);
setup.escrow_client.resolve_dispute(&1, &true);

// Once resolved, the milestone is terminal: a second dispute is rejected.
setup.escrow_client.dispute(&1, &setup.client);
}

#[test]
#[should_panic(expected = "HostError: Error(Contract, #6)")]
fn test_resolve_without_dispute_fails() {
let setup = setup_test();
initialize_single_milestone(&setup, 150);
setup.escrow_client.fund();
setup.escrow_client.submit_milestone(&1);

setup.escrow_client.resolve_dispute(&1, &true);
}

#[test]
fn test_dispute_events_are_emitted() {
let setup = setup_test();
let env = setup.env.clone();

initialize_single_milestone(&setup, 150);
setup.escrow_client.fund();
setup.escrow_client.submit_milestone(&1);

setup.escrow_client.dispute(&1, &setup.client);
assert_eq!(
env.events()
.all()
.filter_by_contract(&setup.escrow_client.address)
.events()
.len(),
1
);

setup.escrow_client.resolve_dispute(&1, &true);
assert_eq!(
env.events()
.all()
.filter_by_contract(&setup.escrow_client.address)
.events()
.len(),
1
);
}
Loading