diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 7abc1996..1bdc7e88 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -1,10 +1,16 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env}; #[contracttype] -#[derive(Clone, PartialEq)] -pub enum EscrowStatus { Active, Completed, Disputed, Resolved, Refunded } +#[derive(Clone, Debug, PartialEq)] +pub enum EscrowStatus { + Active, + Completed, + Disputed, + Resolved, + Refunded, +} #[contracttype] #[derive(Clone)] @@ -20,33 +26,339 @@ pub struct EscrowJob { } #[contracttype] -pub enum DataKey { Job(u64), Admin } +pub enum DataKey { + Job(u64), + Admin, +} #[contract] pub struct EscrowContract; #[contractimpl] impl EscrowContract { - pub fn initialize(_env: Env, _admin: Address) { todo!() } + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&DataKey::Admin) { + panic!("already initialized"); + } + env.storage().instance().set(&DataKey::Admin, &admin); + } /// Client deposits USDC and opens an escrow job. pub fn deposit( - _env: Env, _job_id: u64, _client: Address, _freelancer: Address, - _token: Address, _amount: i128, _milestones: u32, - ) { todo!() } + env: Env, + job_id: u64, + client: Address, + freelancer: Address, + token_addr: Address, + amount: i128, + milestones: u32, + ) { + client.require_auth(); + assert!(milestones > 0, "milestones must be > 0"); + assert!(amount > 0, "amount must be > 0"); + + let key = DataKey::Job(job_id); + if env.storage().persistent().has(&key) { + panic!("job already exists"); + } + + let token_client = token::Client::new(&env, &token_addr); + token_client.transfer(&client, &env.current_contract_address(), &amount); + + let job = EscrowJob { + client, + freelancer, + token: token_addr, + total_amount: amount, + released_amount: 0, + milestones, + milestones_released: 0, + status: EscrowStatus::Active, + }; + env.storage().persistent().set(&key, &job); + } + + /// Client approves a milestone -- releases proportional USDC to freelancer. + pub fn release_milestone(env: Env, job_id: u64, caller: Address) { + caller.require_auth(); + + let key = DataKey::Job(job_id); + let mut job: EscrowJob = env.storage().persistent().get(&key).expect("job not found"); + + assert!(job.status == EscrowStatus::Active, "job not active"); + assert!(caller == job.client, "only client can release"); + assert!( + job.milestones_released < job.milestones, + "all milestones already released" + ); - /// Client approves a milestone — releases proportional USDC to freelancer. - pub fn release_milestone(_env: Env, _job_id: u64, _caller: Address) { todo!() } + let per_milestone = job.total_amount / (job.milestones as i128); + job.milestones_released += 1; + job.released_amount += per_milestone; + + let token_client = token::Client::new(&env, &job.token); + token_client.transfer( + &env.current_contract_address(), + &job.freelancer, + &per_milestone, + ); + + if job.milestones_released == job.milestones { + let remainder = job.total_amount - job.released_amount; + if remainder > 0 { + token_client.transfer( + &env.current_contract_address(), + &job.freelancer, + &remainder, + ); + job.released_amount += remainder; + } + job.status = EscrowStatus::Completed; + } + + env.storage().persistent().set(&key, &job); + } /// Either party opens a dispute, locking remaining funds. - pub fn open_dispute(_env: Env, _job_id: u64, _caller: Address) { todo!() } + pub fn open_dispute(env: Env, job_id: u64, caller: Address) { + caller.require_auth(); + + let key = DataKey::Job(job_id); + let mut job: EscrowJob = env.storage().persistent().get(&key).expect("job not found"); + + assert!(job.status == EscrowStatus::Active, "job not active"); + assert!( + caller == job.client || caller == job.freelancer, + "unauthorized" + ); + + job.status = EscrowStatus::Disputed; + env.storage().persistent().set(&key, &job); + } + + /// 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) { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); + admin.require_auth(); - /// 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) { todo!() } + assert!(freelancer_share_bps <= 10_000, "bps out of range"); + + let key = DataKey::Job(job_id); + let mut job: EscrowJob = env.storage().persistent().get(&key).expect("job not found"); + assert!(job.status == EscrowStatus::Disputed, "job not disputed"); + + let remaining = job.total_amount - job.released_amount; + let freelancer_share = (remaining * (freelancer_share_bps as i128)) / 10_000; + let client_share = remaining - freelancer_share; + + let token_client = token::Client::new(&env, &job.token); + if freelancer_share > 0 { + token_client.transfer( + &env.current_contract_address(), + &job.freelancer, + &freelancer_share, + ); + } + if client_share > 0 { + token_client.transfer( + &env.current_contract_address(), + &job.client, + &client_share, + ); + } + + job.released_amount = job.total_amount; + job.status = EscrowStatus::Resolved; + env.storage().persistent().set(&key, &job); + } /// Client recoups funds if freelancer never responded. - pub fn refund(_env: Env, _job_id: u64, _client: Address) { todo!() } + pub fn refund(env: Env, job_id: u64, client: Address) { + client.require_auth(); + + let key = DataKey::Job(job_id); + let mut job: EscrowJob = env.storage().persistent().get(&key).expect("job not found"); + + assert!(job.status == EscrowStatus::Active, "job not active"); + assert!(client == job.client, "only client can refund"); + + let remaining = job.total_amount - job.released_amount; + if remaining > 0 { + let token_client = token::Client::new(&env, &job.token); + token_client.transfer( + &env.current_contract_address(), + &job.client, + &remaining, + ); + } + + job.released_amount = job.total_amount; + job.status = EscrowStatus::Refunded; + env.storage().persistent().set(&key, &job); + } + + pub fn get_job(env: Env, job_id: u64) -> EscrowJob { + env.storage() + .persistent() + .get(&DataKey::Job(job_id)) + .expect("job not found") + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::{token, Address, Env}; + + fn setup_token(env: &Env, admin: &Address) -> Address { + let contract = env.register_stellar_asset_contract_v2(admin.clone()); + contract.address() + } + + fn mint(env: &Env, token_addr: &Address, to: &Address) { + let admin_client = token::StellarAssetClient::new(env, token_addr); + admin_client.mint(to, &100_000); + } + + #[test] + fn test_happy_path_lifecycle() { + 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); + + let tc = token::Client::new(&env, &token_addr); + assert_eq!(tc.balance(&contract_id), 9000); + + cc.release_milestone(&1u64, &client); + assert_eq!(tc.balance(&freelancer), 3000); + + cc.release_milestone(&1u64, &client); + assert_eq!(tc.balance(&freelancer), 6000); + + cc.release_milestone(&1u64, &client); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::Completed); + assert_eq!(tc.balance(&freelancer), 9000); + assert_eq!(tc.balance(&contract_id), 0); + } + + #[test] + #[should_panic(expected = "already initialized")] + fn test_double_init() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + + cc.initialize(&admin); + cc.initialize(&admin); + } + + #[test] + #[should_panic(expected = "only client can release")] + fn test_unauthorized_release() { + 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.release_milestone(&1u64, &rando); + } + + #[test] + fn test_dispute_50_50_split() { + 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, &4u32); + + cc.release_milestone(&1u64, &client); + let tc = token::Client::new(&env, &token_addr); + assert_eq!(tc.balance(&freelancer), 2500); + + cc.open_dispute(&1u64, &freelancer); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::Disputed); + + 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); + } + + #[test] + fn test_refund() { + 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, &5000i128, &2u32); + assert_eq!( + token::Client::new(&env, &token_addr).balance(&client), + 95_000 + ); - pub fn get_job(_env: Env, _job_id: u64) -> EscrowJob { todo!() } + cc.refund(&1u64, &client); + let job = cc.get_job(&1u64); + assert_eq!(job.status, EscrowStatus::Refunded); + assert_eq!( + token::Client::new(&env, &token_addr).balance(&client), + 100_000 + ); + } } diff --git a/contracts/job_registry/src/lib.rs b/contracts/job_registry/src/lib.rs index a7d5e483..1f904a9b 100644 --- a/contracts/job_registry/src/lib.rs +++ b/contracts/job_registry/src/lib.rs @@ -3,15 +3,21 @@ use soroban_sdk::{contract, contractimpl, contracttype, Address, Bytes, Env, Vec}; #[contracttype] -#[derive(Clone, PartialEq)] -pub enum JobStatus { Open, InProgress, DeliverableSubmitted, Completed, Disputed } +#[derive(Clone, Debug, PartialEq)] +pub enum JobStatus { + Open, + InProgress, + DeliverableSubmitted, + Completed, + Disputed, +} #[contracttype] #[derive(Clone)] pub struct JobRecord { pub client: Address, pub freelancer: Option
, - pub metadata_hash: Bytes, // IPFS CID + pub metadata_hash: Bytes, pub budget_stroops: i128, pub status: JobStatus, } @@ -24,7 +30,11 @@ pub struct BidRecord { } #[contracttype] -pub enum DataKey { Job(u64), Bids(u64), Deliverable(u64) } +pub enum DataKey { + Job(u64), + Bids(u64), + Deliverable(u64), +} #[contract] pub struct JobRegistryContract; @@ -32,21 +42,261 @@ pub struct JobRegistryContract; #[contractimpl] impl JobRegistryContract { /// Client posts a job. `metadata_hash` = IPFS CID bytes. - pub fn post_job(_env: Env, _job_id: u64, _client: Address, _hash: Bytes, _budget: i128) { todo!() } + pub fn post_job(env: Env, job_id: u64, client: Address, hash: Bytes, budget: i128) { + client.require_auth(); + + let key = DataKey::Job(job_id); + if env.storage().persistent().has(&key) { + panic!("job already exists"); + } + + let job = JobRecord { + client, + freelancer: None, + metadata_hash: hash, + budget_stroops: budget, + status: JobStatus::Open, + }; + env.storage().persistent().set(&key, &job); + + let bids: Vec = Vec::new(&env); + env.storage() + .persistent() + .set(&DataKey::Bids(job_id), &bids); + } /// Freelancer submits a bid. - pub fn submit_bid(_env: Env, _job_id: u64, _freelancer: Address, _proposal_hash: Bytes) { todo!() } + pub fn submit_bid(env: Env, job_id: u64, freelancer: Address, proposal_hash: Bytes) { + freelancer.require_auth(); + + let key = DataKey::Job(job_id); + let job: JobRecord = env.storage().persistent().get(&key).expect("job not found"); + assert!(job.status == JobStatus::Open, "job not open for bids"); + + let bids_key = DataKey::Bids(job_id); + let mut bids: Vec = env + .storage() + .persistent() + .get(&bids_key) + .unwrap_or(Vec::new(&env)); + + bids.push_back(BidRecord { + freelancer, + proposal_hash, + }); + env.storage().persistent().set(&bids_key, &bids); + } /// Client accepts a bid, locking in the freelancer. - pub fn accept_bid(_env: Env, _job_id: u64, _client: Address, _freelancer: Address) { todo!() } + pub fn accept_bid(env: Env, job_id: u64, client: Address, freelancer: Address) { + client.require_auth(); + + let key = DataKey::Job(job_id); + let mut job: JobRecord = env.storage().persistent().get(&key).expect("job not found"); + + assert!(job.status == JobStatus::Open, "job not open"); + assert!(client == job.client, "only client can accept bids"); + + job.freelancer = Some(freelancer); + job.status = JobStatus::InProgress; + env.storage().persistent().set(&key, &job); + } /// Freelancer submits deliverable IPFS hash. - pub fn submit_deliverable(_env: Env, _job_id: u64, _freelancer: Address, _hash: Bytes) { todo!() } + pub fn submit_deliverable(env: Env, job_id: u64, freelancer: Address, hash: Bytes) { + freelancer.require_auth(); + + let key = DataKey::Job(job_id); + let mut job: JobRecord = env.storage().persistent().get(&key).expect("job not found"); + + assert!(job.status == JobStatus::InProgress, "job not in progress"); + assert!( + job.freelancer == Some(freelancer.clone()), + "not the assigned freelancer" + ); + + job.status = JobStatus::DeliverableSubmitted; + env.storage().persistent().set(&key, &job); + env.storage() + .persistent() + .set(&DataKey::Deliverable(job_id), &hash); + } /// Mark job disputed (called by escrow via cross-contract invoke). - pub fn mark_disputed(_env: Env, _job_id: u64) { todo!() } + pub fn mark_disputed(env: Env, job_id: u64) { + let key = DataKey::Job(job_id); + let mut job: JobRecord = env.storage().persistent().get(&key).expect("job not found"); + + assert!( + job.status == JobStatus::InProgress || job.status == JobStatus::DeliverableSubmitted, + "invalid state for dispute" + ); + + job.status = JobStatus::Disputed; + env.storage().persistent().set(&key, &job); + } + + pub fn get_job(env: Env, job_id: u64) -> JobRecord { + env.storage() + .persistent() + .get(&DataKey::Job(job_id)) + .expect("job not found") + } + + pub fn get_bids(env: Env, job_id: u64) -> Vec { + env.storage() + .persistent() + .get(&DataKey::Bids(job_id)) + .unwrap_or(Vec::new(&env)) + } + + pub fn get_deliverable(env: Env, job_id: u64) -> Bytes { + env.storage() + .persistent() + .get(&DataKey::Deliverable(job_id)) + .expect("no deliverable") + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::{Address, Bytes, Env}; + + #[test] + fn test_full_lifecycle() { + let env = Env::default(); + env.mock_all_auths(); + + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let contract_id = env.register_contract(None, JobRegistryContract); + let cc = JobRegistryContractClient::new(&env, &contract_id); + + let hash = Bytes::from_slice(&env, b"QmSomeIPFSHash"); + cc.post_job(&1u64, &client, &hash, &5000i128); + + let job = cc.get_job(&1u64); + assert_eq!(job.status, JobStatus::Open); + assert_eq!(job.freelancer, None); + + let proposal = Bytes::from_slice(&env, b"QmProposalHash"); + cc.submit_bid(&1u64, &freelancer, &proposal); + + let bids = cc.get_bids(&1u64); + assert_eq!(bids.len(), 1); + + cc.accept_bid(&1u64, &client, &freelancer); + let job = cc.get_job(&1u64); + assert_eq!(job.status, JobStatus::InProgress); + assert_eq!(job.freelancer, Some(freelancer.clone())); + + let deliverable = Bytes::from_slice(&env, b"QmDeliverableHash"); + cc.submit_deliverable(&1u64, &freelancer, &deliverable); + + let job = cc.get_job(&1u64); + assert_eq!(job.status, JobStatus::DeliverableSubmitted); + + let d = cc.get_deliverable(&1u64); + assert_eq!(d, deliverable); + } + + #[test] + #[should_panic(expected = "job not open for bids")] + fn test_bid_on_non_open_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let client = Address::generate(&env); + let freelancer1 = Address::generate(&env); + let freelancer2 = Address::generate(&env); + + let contract_id = env.register_contract(None, JobRegistryContract); + let cc = JobRegistryContractClient::new(&env, &contract_id); + + let hash = Bytes::from_slice(&env, b"QmHash"); + cc.post_job(&1u64, &client, &hash, &5000i128); + cc.accept_bid(&1u64, &client, &freelancer1); + + let proposal = Bytes::from_slice(&env, b"QmLate"); + cc.submit_bid(&1u64, &freelancer2, &proposal); + } + + #[test] + fn test_mark_disputed_from_in_progress() { + let env = Env::default(); + env.mock_all_auths(); + + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let contract_id = env.register_contract(None, JobRegistryContract); + let cc = JobRegistryContractClient::new(&env, &contract_id); + + let hash = Bytes::from_slice(&env, b"QmHash"); + cc.post_job(&1u64, &client, &hash, &5000i128); + cc.accept_bid(&1u64, &client, &freelancer); + + cc.mark_disputed(&1u64); + let job = cc.get_job(&1u64); + assert_eq!(job.status, JobStatus::Disputed); + } + + #[test] + fn test_mark_disputed_from_deliverable_submitted() { + let env = Env::default(); + env.mock_all_auths(); + + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let contract_id = env.register_contract(None, JobRegistryContract); + let cc = JobRegistryContractClient::new(&env, &contract_id); + + let hash = Bytes::from_slice(&env, b"QmHash"); + cc.post_job(&1u64, &client, &hash, &5000i128); + cc.accept_bid(&1u64, &client, &freelancer); + + let deliverable = Bytes::from_slice(&env, b"QmDeliverable"); + cc.submit_deliverable(&1u64, &freelancer, &deliverable); + + cc.mark_disputed(&1u64); + let job = cc.get_job(&1u64); + assert_eq!(job.status, JobStatus::Disputed); + } + + #[test] + #[should_panic(expected = "invalid state for dispute")] + fn test_mark_disputed_from_open_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let client = Address::generate(&env); + + let contract_id = env.register_contract(None, JobRegistryContract); + let cc = JobRegistryContractClient::new(&env, &contract_id); + + let hash = Bytes::from_slice(&env, b"QmHash"); + cc.post_job(&1u64, &client, &hash, &5000i128); + + cc.mark_disputed(&1u64); + } + + #[test] + #[should_panic(expected = "job already exists")] + fn test_duplicate_job_id() { + let env = Env::default(); + env.mock_all_auths(); + + let client = Address::generate(&env); + + let contract_id = env.register_contract(None, JobRegistryContract); + let cc = JobRegistryContractClient::new(&env, &contract_id); - pub fn get_job(_env: Env, _job_id: u64) -> JobRecord { todo!() } - pub fn get_bids(_env: Env, _job_id: u64) -> Vec { todo!() } - pub fn get_deliverable(_env: Env, _job_id: u64) -> Bytes { todo!() } + let hash = Bytes::from_slice(&env, b"QmHash"); + cc.post_job(&1u64, &client, &hash, &5000i128); + cc.post_job(&1u64, &client, &hash, &5000i128); + } }