Skip to content
Merged
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
250 changes: 222 additions & 28 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ pub enum DataKey {
Admin,
}

#[contracttype]
#[derive(Clone)]
pub struct DisputeRaisedEvent {
pub job_id: u64,
pub initiator: Address,
pub milestones_released: u32,
pub milestones_total: u32,
pub raised_at: u64,
}

#[contract]
pub struct EscrowContract;

Expand Down Expand Up @@ -64,12 +74,9 @@ impl EscrowContract {
if env.storage().persistent().has(&key) {
panic!("job already exists");
}
// record timestamps
let now: u64 = env.ledger().timestamp();
// seed an expiration (30 days) to avoid locking funds indefinitely
let expires_at = now + 30 * 24 * 60 * 60;

// initialize milestones completion vector
let mut completed: Vec<bool> = Vec::new(&env);
let mut i = 0u32;
while i < milestones {
Expand All @@ -78,7 +85,6 @@ impl EscrowContract {
}

let token_client = token::Client::new(&env, &token_addr);
// transfer tokens from client into contract custody (client must authorize)
token_client.transfer(&client, &env.current_contract_address(), &amount);

let job = EscrowJob {
Expand Down Expand Up @@ -111,9 +117,7 @@ impl EscrowContract {
assert!(job.status == EscrowStatus::Active, "job not active");
assert!(caller == job.client, "only client can release");

// determine the next milestone index (0-based)
let idx = job.milestones_released;
// ensure it wasn't already completed (defensive)
let already: bool = job
.milestones_completed
.get(idx)
Expand All @@ -123,8 +127,6 @@ impl EscrowContract {
let per_milestone = job.total_amount / (job.milestones as i128);
job.milestones_released += 1;
job.released_amount += per_milestone;

// mark completed
job.milestones_completed.set(idx, true);

let token_client = token::Client::new(&env, &job.token);
Expand Down Expand Up @@ -170,15 +172,13 @@ impl EscrowContract {

let per_milestone = job.total_amount / (job.milestones as i128);

// transfer funds for this milestone
let token_client = token::Client::new(&env, &job.token);
token_client.transfer(
&env.current_contract_address(),
&job.freelancer,
&per_milestone,
);

// update bookkeeping
job.milestones_completed.set(milestone_index, true);
job.milestones_released += 1;
job.released_amount += per_milestone;
Expand Down Expand Up @@ -216,6 +216,61 @@ impl EscrowContract {
env.storage().persistent().set(&key, &job);
}

/// Either party formally raises a dispute with on-chain event emission.
/// Locks funds, transitions state to Disputed, and signals the AI Judge.
pub fn raise_dispute(env: Env, job_id: u64, caller: Address) {
// 1. Authenticate the caller
caller.require_auth();

let key = DataKey::Job(job_id);
let mut job: EscrowJob = env
.storage()
.persistent()
.get(&key)
.expect("job not found");

// 2. Only client or freelancer may raise a dispute
assert!(
caller == job.client || caller == job.freelancer,
"unauthorized: only client or freelancer can raise a dispute"
);

// 3. Job must still be active
assert!(
job.status == EscrowStatus::Active,
"dispute cannot be raised: job is not active"
);

// 4. Prevent dispute if all funds are already released
assert!(
job.released_amount < job.total_amount,
"dispute cannot be raised: all funds already released"
);

// 5. Prevent dispute if deadline has drastically expired (7-day grace period)
let now: u64 = env.ledger().timestamp();
let grace_period: u64 = 7 * 24 * 60 * 60;
assert!(
now <= job.expires_at + grace_period,
"dispute cannot be raised: deadline has drastically expired"
);

// 6. Lock funds by transitioning to Disputed — blocks release_funds & release_milestone
job.status = EscrowStatus::Disputed;
env.storage().persistent().set(&key, &job);

// 7. Emit DisputeRaised event for backend / AI Judge to consume
let event_data = DisputeRaisedEvent {
job_id,
initiator: caller,
milestones_released: job.milestones_released,
milestones_total: job.milestones,
raised_at: now,
};
env.events()
.publish(("escrow", "DisputeRaised"), event_data);
}

/// Admin (AI judge authority) resolves dispute -- splits funds by BPS.
/// `freelancer_share_bps`: 0-10000 (100% = 10000).
pub fn resolve_dispute(env: Env, job_id: u64, freelancer_share_bps: u32) {
Expand Down Expand Up @@ -405,9 +460,7 @@ mod test {
cc.resolve_dispute(&1u64, &5000u32);
let job = cc.get_job(&1u64);
assert_eq!(job.status, EscrowStatus::Resolved);
// Freelancer: 2500 (milestone) + 3750 (50% of 7500 remaining) = 6250
assert_eq!(tc.balance(&freelancer), 6250);
// Client: 100000 - 10000 (deposited) + 3750 (50% of 7500) = 93750
assert_eq!(tc.balance(&client), 93750);
}

Expand Down Expand Up @@ -502,7 +555,6 @@ mod test {

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &1000i128, &2u32);
// second deposit for same job should panic
cc.deposit(&1u64, &client, &freelancer, &token_addr, &1000i128, &2u32);
}

Expand All @@ -524,8 +576,6 @@ mod test {

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &1000i128, &2u32);

// freelancer attempts to release funds directly
cc.release_funds(&1u64, &freelancer, &0u32);
}

Expand All @@ -547,8 +597,6 @@ mod test {

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &1000i128, &2u32);

// client tries to release milestone index out of range
cc.release_funds(&1u64, &client, &5u32);
}

Expand All @@ -570,10 +618,7 @@ mod test {

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &1000i128, &2u32);

// first release succeeds
cc.release_funds(&1u64, &client, &0u32);
// second release of same milestone should panic
cc.release_funds(&1u64, &client, &0u32);
}

Expand All @@ -599,7 +644,6 @@ mod test {
cc.release_milestone(&1u64, &client);
cc.release_milestone(&1u64, &client);
cc.release_milestone(&1u64, &client);
// one extra
cc.release_milestone(&1u64, &client);
}

Expand All @@ -622,7 +666,6 @@ mod test {

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &1000i128, &2u32);

cc.open_dispute(&1u64, &rando);
}

Expand All @@ -644,7 +687,6 @@ mod test {

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &1000i128, &2u32);

cc.resolve_dispute(&1u64, &5000u32);
}

Expand All @@ -666,7 +708,6 @@ mod test {

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &1000i128, &2u32);

cc.refund(&1u64, &freelancer);
}

Expand All @@ -692,8 +733,6 @@ mod test {
cc.release_milestone(&1u64, &client);
cc.release_milestone(&1u64, &client);
cc.release_milestone(&1u64, &client);

// attempt to open dispute after completion
cc.open_dispute(&1u64, &freelancer);
}

Expand All @@ -716,8 +755,6 @@ mod test {

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &9000i128, &3u32);

// rando attempts to resolve dispute (will fail on admin.require_auth or job not disputed)
cc.resolve_dispute(&1u64, &5000u32);
}

Expand All @@ -732,4 +769,161 @@ mod test {

cc.get_job(&999u64);
}

// --- raise_dispute tests ---

#[test]
fn test_raise_dispute_by_client_locks_funds() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let client = Address::generate(&env);
let freelancer = Address::generate(&env);

let token_addr = setup_token(&env, &admin);
mint(&env, &token_addr, &client);

let contract_id = env.register_contract(None, EscrowContract);
let cc = EscrowContractClient::new(&env, &contract_id);

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &9000i128, &3u32);

cc.raise_dispute(&1u64, &client);

let job = cc.get_job(&1u64);
assert_eq!(job.status, EscrowStatus::Disputed);
}

#[test]
fn test_raise_dispute_by_freelancer_locks_funds() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let client = Address::generate(&env);
let freelancer = Address::generate(&env);

let token_addr = setup_token(&env, &admin);
mint(&env, &token_addr, &client);

let contract_id = env.register_contract(None, EscrowContract);
let cc = EscrowContractClient::new(&env, &contract_id);

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &9000i128, &3u32);

cc.raise_dispute(&1u64, &freelancer);

let job = cc.get_job(&1u64);
assert_eq!(job.status, EscrowStatus::Disputed);
}

#[test]
#[should_panic(expected = "unauthorized: only client or freelancer can raise a dispute")]
fn test_raise_dispute_by_third_party_panics() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let client = Address::generate(&env);
let freelancer = Address::generate(&env);
let rando = Address::generate(&env);

let token_addr = setup_token(&env, &admin);
mint(&env, &token_addr, &client);

let contract_id = env.register_contract(None, EscrowContract);
let cc = EscrowContractClient::new(&env, &contract_id);

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &1000i128, &2u32);
cc.raise_dispute(&1u64, &rando);
}

#[test]
#[should_panic]
fn test_raise_dispute_on_completed_job_panics() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let client = Address::generate(&env);
let freelancer = Address::generate(&env);

let token_addr = setup_token(&env, &admin);
mint(&env, &token_addr, &client);

let contract_id = env.register_contract(None, EscrowContract);
let cc = EscrowContractClient::new(&env, &contract_id);

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &9000i128, &3u32);

cc.release_milestone(&1u64, &client);
cc.release_milestone(&1u64, &client);
cc.release_milestone(&1u64, &client);

cc.raise_dispute(&1u64, &client);
}

#[test]
#[should_panic]
fn test_raise_dispute_blocks_release_funds() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let client = Address::generate(&env);
let freelancer = Address::generate(&env);

let token_addr = setup_token(&env, &admin);
mint(&env, &token_addr, &client);

let contract_id = env.register_contract(None, EscrowContract);
let cc = EscrowContractClient::new(&env, &contract_id);

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &1000i128, &2u32);

cc.raise_dispute(&1u64, &freelancer);

// Should panic — job is now Disputednot Active
cc.release_funds(&1u64, &client, &0u32);
}

#[test]
fn test_raise_dispute_then_resolve() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let client = Address::generate(&env);
let freelancer = Address::generate(&env);

let token_addr = setup_token(&env, &admin);
mint(&env, &token_addr, &client);

let contract_id = env.register_contract(None, EscrowContract);
let cc = EscrowContractClient::new(&env, &contract_id);

cc.initialize(&admin);
cc.deposit(&1u64, &client, &freelancer, &token_addr, &10_000i128, &2u32);

cc.raise_dispute(&1u64, &freelancer);

let job = cc.get_job(&1u64);
assert_eq!(job.status, EscrowStatus::Disputed);

// Admin resolves 70% to freelancer
cc.resolve_dispute(&1u64, &7000u32);

let job = cc.get_job(&1u64);
assert_eq!(job.status, EscrowStatus::Resolved);

let tc = token::Client::new(&env, &token_addr);
assert_eq!(tc.balance(&freelancer), 7000);
assert_eq!(tc.balance(&client), 93000);
}
}
Loading