From 24ffa0573796548cfa42e40585c8dcd099f16cf7 Mon Sep 17 00:00:00 2001 From: cybermaxi7 Date: Sat, 28 Mar 2026 11:57:44 +0100 Subject: [PATCH 1/2] Implement escrow deposit/release, add security tests, and reputation submit_rating (fixes #13 #15 #19 #21) --- contracts/escrow/src/lib.rs | 377 +++++++++++++++++++++++++++++++- contracts/reputation/src/lib.rs | 93 +++++++- 2 files changed, 465 insertions(+), 5 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 1bdc7e88..42171a52 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env}; +use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, Vec}; #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -23,6 +23,9 @@ pub struct EscrowJob { pub milestones: u32, pub milestones_released: u32, pub status: EscrowStatus, + pub created_at: u64, + pub expires_at: u64, + pub milestones_completed: Vec, } #[contracttype] @@ -61,8 +64,21 @@ 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 = Vec::new(&env); + let mut i = 0u32; + while i < milestones { + completed.push_back(false); + i += 1; + } 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 { @@ -74,6 +90,9 @@ impl EscrowContract { milestones, milestones_released: 0, status: EscrowStatus::Active, + created_at: now, + expires_at, + milestones_completed: completed, }; env.storage().persistent().set(&key, &job); } @@ -85,17 +104,29 @@ impl EscrowContract { 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" ); + 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) + .expect("invalid milestone index"); + assert!(!already, "milestone already released"); 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); token_client.transfer( &env.current_contract_address(), @@ -119,6 +150,55 @@ impl EscrowContract { env.storage().persistent().set(&key, &job); } + /// Happy-path release for an explicit milestone index (0-based). + /// Only the client may call this to release the funds for a specific milestone. + pub fn release_funds(env: Env, job_id: u64, caller: Address, milestone_index: u32) { + 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!(milestone_index < job.milestones, "invalid milestone index"); + + let already: bool = job + .milestones_completed + .get(milestone_index) + .expect("invalid milestone index"); + assert!(!already, "milestone already released"); + + 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; + + 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) { caller.require_auth(); @@ -361,4 +441,295 @@ mod test { 100_000 ); } + + // --- Edge case and security tests --- + + #[test] + #[should_panic(expected = "amount must be > 0")] + fn test_deposit_zero_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, &0i128, &1u32); + } + + #[test] + #[should_panic(expected = "amount must be > 0")] + fn test_deposit_negative_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, &-100i128, &1u32); + } + + #[test] + #[should_panic(expected = "job already exists")] + fn test_double_deposit_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, &1000i128, &2u32); + // second deposit for same job should panic + cc.deposit(&1u64, &client, &freelancer, &token_addr, &1000i128, &2u32); + } + + #[test] + #[should_panic(expected = "only client can release")] + fn test_unauthorized_release_funds_by_freelancer_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, &1000i128, &2u32); + + // freelancer attempts to release funds directly + cc.release_funds(&1u64, &freelancer, &0u32); + } + + #[test] + #[should_panic(expected = "invalid milestone index")] + fn test_release_funds_invalid_index_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, &1000i128, &2u32); + + // client tries to release milestone index out of range + cc.release_funds(&1u64, &client, &5u32); + } + + #[test] + #[should_panic(expected = "milestone already released")] + fn test_release_funds_twice_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, &1000i128, &2u32); + + // first release succeeds + cc.release_funds(&1u64, &client, &0u32); + // second release of same milestone should panic + cc.release_funds(&1u64, &client, &0u32); + } + + #[test] + #[should_panic(expected = "all milestones already released")] + fn test_release_milestone_overflow_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, &900i128, &3u32); + + cc.release_milestone(&1u64, &client); + cc.release_milestone(&1u64, &client); + cc.release_milestone(&1u64, &client); + // one extra + cc.release_milestone(&1u64, &client); + } + + #[test] + #[should_panic(expected = "unauthorized")] + fn test_open_dispute_by_rando_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.open_dispute(&1u64, &rando); + } + + #[test] + #[should_panic(expected = "job not disputed")] + fn test_resolve_dispute_not_disputed_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, &1000i128, &2u32); + + cc.resolve_dispute(&1u64, &5000u32); + } + + #[test] + #[should_panic(expected = "only client can refund")] + fn test_refund_by_non_client_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, &1000i128, &2u32); + + cc.refund(&1u64, &freelancer); + } + + #[test] + #[should_panic(expected = "job not active")] + fn test_open_dispute_on_completed_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); + + // attempt to open dispute after completion + cc.open_dispute(&1u64, &freelancer); + } + + #[test] + #[should_panic] + fn test_resolve_dispute_non_admin_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, &9000i128, &3u32); + + // rando attempts to resolve dispute (will fail on admin.require_auth or job not disputed) + cc.resolve_dispute(&1u64, &5000u32); + } + + #[test] + #[should_panic(expected = "job not found")] + fn test_get_job_not_found_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + + cc.get_job(&999u64); + } } diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index ece48778..7213cb30 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -1,6 +1,27 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol}; +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol, Bytes, IntoVal}; + +// Types matching Job Registry contract's public types for cross-contract decoding +#[contracttype] +#[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, + pub budget_stroops: i128, + pub status: JobStatus, +} #[contracttype] #[derive(Clone, PartialEq)] @@ -14,10 +35,14 @@ pub struct ReputationScore { /// Score in basis points (0–10000 = 0–100%) pub score: i32, pub total_jobs: u32, + /// Sum of raw rating points (1-5) to compute aggregates off-chain + pub total_points: i32, + /// Number of reviews counted + pub reviews: u32, } #[contracttype] -pub enum DataKey { Score(Address, Role), Admin } +pub enum DataKey { Score(Address, Role), Admin, JobRegistry, Reviewed(u64, Address) } #[contract] pub struct ReputationContract; @@ -31,6 +56,68 @@ impl ReputationContract { env.storage().instance().set(&DataKey::Admin, &admin); } + /// Set the JobRegistry contract address (admin only) + pub fn set_job_registry(env: Env, admin: Address, registry: Address) { + admin.require_auth(); + env.storage().instance().set(&DataKey::JobRegistry, ®istry); + } + + /// Submit a rating for a target address tied to a Job ID. Caller must be the client or freelancer + /// on the job, and the job must be Completed. + pub fn submit_rating(env: Env, caller: Address, job_id: u64, target: Address, score: u32) { + // caller must authorize + caller.require_auth(); + + // validate score in 1..=5 + assert!(score >= 1 && score <= 5, "score out of range"); + + // ensure job registry is configured + let registry_addr: Address = env + .storage() + .instance() + .get(&DataKey::JobRegistry) + .expect("job registry not set"); + + // call JobRegistry.get_job(job_id) and decode into local JobRecord + let get_sym = Symbol::new(&env, "get_job"); + let args = soroban_sdk::vec![&env, job_id.into_val(&env)]; + let job: JobRecord = env.invoke_contract::(®istry_addr, &get_sym, args); + + // verify job is completed (ratings only allowed after completion) + assert!(job.status == JobStatus::Completed, "job not completed"); + + // verify caller is participant + let caller_addr = caller.clone(); + let is_client = caller_addr == job.client; + let is_freelancer = match job.freelancer.clone() { + Some(f) => caller_addr == f, + None => false, + }; + assert!(is_client || is_freelancer, "unauthorized to rate"); + + // prevent double review + let reviewed_key = DataKey::Reviewed(job_id, caller.clone()); + assert!(!env.storage().persistent().has(&reviewed_key), "already reviewed"); + + // update reputation aggregates for target + let mut rep = Self::get_score(env.clone(), target.clone(), Role::Freelancer); + // we'll treat target role as Freelancer for simplicity; callers should ensure correct role + rep.total_points = rep.total_points.saturating_add(score as i32); + rep.reviews = rep.reviews.saturating_add(1); + rep.total_jobs = rep.total_jobs.saturating_add(1); + + // compute new averaged score in basis points: avg = total_points / reviews, scaled + let avg = (rep.total_points as i32) / (rep.reviews as i32); + let bps = avg.saturating_mul(2000); // 1->2000 ... 5->10000 + rep.score = Self::clamp_score(bps); + + env.storage() + .persistent() + .set(&DataKey::Score(rep.address.clone(), rep.role.clone()), &rep); + + env.storage().persistent().set(&reviewed_key, &true); + } + /// Update reputation after a completed job. `delta` in basis points. /// Score is clamped to [0, 10000]. pub fn update_score(env: Env, address: Address, role: Role, delta: i32) { @@ -77,6 +164,8 @@ impl ReputationContract { role, score: 5000, total_jobs: 0, + total_points: 0, + reviews: 0, }) } } From 3f88b26b102c9ea5bd099bfd11e1773343db4487 Mon Sep 17 00:00:00 2001 From: cybermaxi7 Date: Sat, 28 Mar 2026 12:22:09 +0100 Subject: [PATCH 2/2] Fix clippy: use range contains and remove unnecessary cast --- contracts/reputation/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index 7213cb30..cb6434c9 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -69,7 +69,7 @@ impl ReputationContract { caller.require_auth(); // validate score in 1..=5 - assert!(score >= 1 && score <= 5, "score out of range"); + assert!((1u32..=5u32).contains(&score), "score out of range"); // ensure job registry is configured let registry_addr: Address = env @@ -107,7 +107,7 @@ impl ReputationContract { rep.total_jobs = rep.total_jobs.saturating_add(1); // compute new averaged score in basis points: avg = total_points / reviews, scaled - let avg = (rep.total_points as i32) / (rep.reviews as i32); + let avg = rep.total_points / (rep.reviews as i32); let bps = avg.saturating_mul(2000); // 1->2000 ... 5->10000 rep.score = Self::clamp_score(bps);