From c2ee73984b4dcf47d9cb2fb29aabe0a5f82d0d58 Mon Sep 17 00:00:00 2001 From: Henry Ebubechukwu Date: Thu, 23 Apr 2026 14:47:32 +0100 Subject: [PATCH 1/2] Update the lib.rs file with the latest contract implementation --- contracts/reputation/src/lib.rs | 333 ++++++++++++++++++++++++++------ 1 file changed, 278 insertions(+), 55 deletions(-) diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index 38a5899c..c643607c 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -41,7 +41,7 @@ pub enum Role { pub struct ReputationScore { pub address: Address, pub role: Role, - /// Score in basis points (0–10000 = 0–100%) + /// Score in basis points (0\u201310000 = 0\u2013100%) pub score: i32, pub total_jobs: u32, /// Sum of raw rating points (1-5) to compute aggregates off-chain @@ -50,6 +50,14 @@ pub struct ReputationScore { pub reviews: u32, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ReputationView { + pub address: Address, + pub client: ReputationScore, + pub freelancer: ReputationScore, +} + #[contracttype] pub enum DataKey { Admin, @@ -62,6 +70,11 @@ pub enum DataKey { pub enum ReputationError { NotInitialized = 1, Unauthorized = 2, + InvalidInput = 3, + JobNotCompleted = 4, + NotJobParticipant = 5, + AlreadyReviewed = 6, + ContractStateError = 7, } #[contracttype] @@ -72,6 +85,32 @@ pub struct ContractUpgradedEvent { pub upgraded_at: u64, } +#[contracttype] +#[derive(Clone)] +pub struct ReputationUpdatedEvent { + pub job_id: u64, + pub caller: Address, + pub target: Address, + pub role: Role, + pub rating: u32, + pub new_score: i32, + pub total_jobs: u32, + pub total_points: i32, + pub reviews: u32, + pub updated_at: u64, +} + +#[contracttype] +#[derive(Clone)] +pub struct ScoreAdjustedEvent { + pub address: Address, + pub role: Role, + pub delta: i32, + pub new_score: i32, + pub total_jobs: u32, + pub adjusted_at: u64, +} + #[contract] pub struct ReputationContract; @@ -88,6 +127,31 @@ impl ReputationContract { .extend_ttl(Self::INSTANCE_TTL_THRESHOLD, Self::INSTANCE_TTL_EXTEND_TO); } + fn score_from_rating(score: u32) -> i32 { + (score as i32).saturating_mul(2_000) + } + + fn score_from_profile(address: &Address, role: Role, profile: &profile::Profile) -> ReputationScore { + match role { + Role::Client => ReputationScore { + address: address.clone(), + role: Role::Client, + score: profile.client_score, + total_jobs: profile.client_jobs, + total_points: profile.client_points, + reviews: profile.client_jobs, + }, + Role::Freelancer => ReputationScore { + address: address.clone(), + role: Role::Freelancer, + score: profile.freelancer_score, + total_jobs: profile.freelancer_jobs, + total_points: profile.freelancer_points, + reviews: profile.freelancer_jobs, + }, + } + } + /// Upgrades the current contract WASM. Only callable by admin. pub fn upgrade( env: Env, @@ -149,20 +213,17 @@ impl ReputationContract { /// 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(); + if !(1u32..=5u32).contains(&score) { + soroban_sdk::panic_with_error!(&env, ReputationError::InvalidInput); + } - // validate score in 1..=5 - assert!((1u32..=5u32).contains(&score), "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 @@ -173,37 +234,53 @@ impl ReputationContract { ) .unwrap(); - // verify job is completed (ratings only allowed after completion) - assert!(job.status == JobStatus::Completed, "job not completed"); + if job.status != JobStatus::Completed { + soroban_sdk::panic_with_error!(&env, ReputationError::JobNotCompleted); + } - // 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"); + if !(is_client || is_freelancer) { + soroban_sdk::panic_with_error!(&env, ReputationError::Unauthorized); + } - // prevent double review let reviewed_key = DataKey::Reviewed(job_id, caller.clone()); - assert!( - !env.storage().persistent().has(&reviewed_key), - "already reviewed" - ); + if env.storage().persistent().has(&reviewed_key) { + soroban_sdk::panic_with_error!(&env, ReputationError::AlreadyReviewed); + } - // update reputation aggregates for target let mut profile = storage::read_profile_or_default(&env, &target); - - // We assume target is a freelancer for now in submit_rating - // In a more complex system, we might need to know which role was rated. - profile.freelancer_points = profile.freelancer_points.saturating_add(score as i32); - profile.freelancer_jobs = profile.freelancer_jobs.saturating_add(1); - - // compute new averaged score in basis points: avg = total_points / jobs, scaled - let avg = profile.freelancer_points / (profile.freelancer_jobs as i32); - let bps = avg.saturating_mul(2000); // 1->2000 ... 5->10000 - profile.freelancer_score = Self::clamp_score(bps); + let (role, total_points, total_jobs, new_score) = if target == job.client { + profile.client_points = profile.client_points.saturating_add(score as i32); + profile.client_jobs = profile.client_jobs.saturating_add(1); + let avg = profile.client_points / (profile.client_jobs as i32); + let bps = Self::score_from_rating(avg as u32); + profile.client_score = Self::clamp_score(bps); + ( + Role::Client, + profile.client_points, + profile.client_jobs, + profile.client_score, + ) + } else if job.freelancer.as_ref() == Some(&target) { + profile.freelancer_points = profile.freelancer_points.saturating_add(score as i32); + profile.freelancer_jobs = profile.freelancer_jobs.saturating_add(1); + let avg = profile.freelancer_points / (profile.freelancer_jobs as i32); + let bps = Self::score_from_rating(avg as u32); + profile.freelancer_score = Self::clamp_score(bps); + ( + Role::Freelancer, + profile.freelancer_points, + profile.freelancer_jobs, + profile.freelancer_score, + ) + } else { + soroban_sdk::panic_with_error!(&env, ReputationError::NotJobParticipant); + }; storage::write_profile(&env, &target, &profile); env.storage().persistent().set(&reviewed_key, &true); @@ -212,6 +289,21 @@ impl ReputationContract { Self::PERSISTENT_TTL_THRESHOLD, Self::PERSISTENT_TTL_EXTEND_TO, ); + env.events().publish( + ("reputation", "ReputationUpdated"), + ReputationUpdatedEvent { + job_id, + caller, + target, + role, + rating: score, + new_score, + total_jobs, + total_points, + reviews: total_jobs, + updated_at: env.ledger().timestamp(), + }, + ); Self::bump_instance_ttl(&env); } @@ -226,20 +318,32 @@ impl ReputationContract { admin.require_auth(); let mut profile = storage::read_profile_or_default(&env, &address); - match role { + let (new_score, total_jobs) = match role { Role::Client => { - profile.client_score = - Self::clamp_score(profile.client_score.saturating_add(delta)); + profile.client_score = Self::clamp_score(profile.client_score.saturating_add(delta)); profile.client_jobs = profile.client_jobs.saturating_add(1); + (profile.client_score, profile.client_jobs) } Role::Freelancer => { profile.freelancer_score = Self::clamp_score(profile.freelancer_score.saturating_add(delta)); profile.freelancer_jobs = profile.freelancer_jobs.saturating_add(1); + (profile.freelancer_score, profile.freelancer_jobs) } - } + }; storage::write_profile(&env, &address, &profile); + env.events().publish( + ("reputation", "ScoreAdjusted"), + ScoreAdjustedEvent { + address, + role, + delta, + new_score, + total_jobs, + adjusted_at: env.ledger().timestamp(), + }, + ); Self::bump_instance_ttl(&env); } @@ -253,41 +357,37 @@ impl ReputationContract { admin.require_auth(); let mut profile = storage::read_profile_or_default(&env, &address); - match role { + let (new_score, total_jobs) = match role { Role::Client => { profile.client_score = Self::clamp_score(profile.client_score.saturating_sub(2000)); + (profile.client_score, profile.client_jobs) } Role::Freelancer => { profile.freelancer_score = Self::clamp_score(profile.freelancer_score.saturating_sub(2000)); + (profile.freelancer_score, profile.freelancer_jobs) } - } + }; storage::write_profile(&env, &address, &profile); + env.events().publish( + ("reputation", "ScoreAdjusted"), + ScoreAdjustedEvent { + address, + role, + delta: -2_000, + new_score, + total_jobs, + adjusted_at: env.ledger().timestamp(), + }, + ); Self::bump_instance_ttl(&env); } pub fn get_score(env: Env, address: Address, role: Role) -> ReputationScore { Self::bump_instance_ttl(&env); let profile = storage::read_profile_or_default(&env, &address); - match role { - Role::Client => ReputationScore { - address, - role: Role::Client, - score: profile.client_score, - total_jobs: profile.client_jobs, - total_points: profile.client_points, - reviews: profile.client_jobs, // reviews and total_jobs are unified - }, - Role::Freelancer => ReputationScore { - address, - role: Role::Freelancer, - score: profile.freelancer_score, - total_jobs: profile.freelancer_jobs, - total_points: profile.freelancer_points, - reviews: profile.freelancer_jobs, - }, - } + Self::score_from_profile(&address, role, &profile) } /// Update profile metadata hash (IPFS CID) @@ -308,11 +408,12 @@ impl ReputationContract { /// Frontend-friendly aggregate metrics for public profile pages. /// Returns: [score_bps, total_jobs, total_points, reviews] pub fn get_public_metrics(env: Env, address: Address, role_name: Symbol) -> Vec { - Self::bump_instance_ttl(&env); let role = if role_name == Symbol::new(&env, "client") { Role::Client - } else { + } else if role_name == Symbol::new(&env, "freelancer") { Role::Freelancer + } else { + soroban_sdk::panic_with_error!(&env, ReputationError::InvalidInput); }; let rep = Self::get_score(env.clone(), address, role); @@ -323,6 +424,19 @@ impl ReputationContract { metrics.push_back(rep.reviews as i128); metrics } + + /// Read both role snapshots for a single address in one call. + pub fn query_reputation(env: Env, address: Address) -> ReputationView { + Self::bump_instance_ttl(&env); + let profile = storage::read_profile_or_default(&env, &address); + let client = Self::score_from_profile(&address, Role::Client, &profile); + let freelancer = Self::score_from_profile(&address, Role::Freelancer, &profile); + ReputationView { + address, + client, + freelancer, + } + } } impl ReputationContract { @@ -337,6 +451,31 @@ mod test { use soroban_sdk::testutils::Address as _; use soroban_sdk::{Address, BytesN, Env}; + #[contract] + pub struct MockJobRegistry; + + #[contracttype] + enum MockKey { + Job(u64), + } + + #[contractimpl] + impl MockJobRegistry { + pub fn set_job(env: Env, job_id: u64, job: JobRecord) { + env.storage() + .persistent() + .set(&MockKey::Job(job_id), &job); + } + + pub fn get_job(env: Env, _job_id: u64) -> Result { + Ok(env + .storage() + .persistent() + .get(&MockKey::Job(_job_id)) + .expect("mock job missing")) + } + } + #[test] fn test_initial_score() { let env = Env::default(); @@ -429,7 +568,91 @@ mod test { } #[test] - #[should_panic(expected = "Error(Contract, #2)")] + fn test_query_reputation_returns_both_roles() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let address = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &contract_id); + + client.initialize(&admin); + client.update_score(&address, &Role::Freelancer, &1000); + client.update_score(&address, &Role::Client, &500); + + let view = client.query_reputation(&address); + assert_eq!(view.address, address); + assert_eq!(view.client.score, 5500); + assert_eq!(view.client.total_jobs, 1); + assert_eq!(view.client.total_points, 500); + assert_eq!(view.freelancer.score, 6000); + assert_eq!(view.freelancer.total_jobs, 1); + assert_eq!(view.freelancer.total_points, 1000); + } + + #[test] + #[should_panic(expected = "Error(Contract, #3)")] + fn test_get_public_metrics_rejects_unknown_role() { + let env = Env::default(); + let address = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &contract_id); + + client.get_public_metrics(&address, &soroban_sdk::Symbol::new(&env, "bogus")); + } + + #[test] + fn test_submit_rating_updates_client_and_freelancer_paths() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let caller = Address::generate(&env); + let target = Address::generate(&env); + let freelancer = Address::generate(&env); + let caller2 = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let mock_id = env.register_contract(None, MockJobRegistry); + client.set_job_registry(&admin, &mock_id); + + let job = JobRecord { + client: caller.clone(), + freelancer: Some(freelancer.clone()), + metadata_hash: Bytes::from_slice(&env, b"QmJob"), + budget_stroops: 10, + status: JobStatus::Completed, + }; + let mock_client = MockJobRegistryClient::new(&env, &mock_id); + mock_client.set_job(&7u64, &job); + let other_job = JobRecord { + client: caller2.clone(), + freelancer: Some(target.clone()), + metadata_hash: Bytes::from_slice(&env, b"QmJob2"), + budget_stroops: 10, + status: JobStatus::Completed, + }; + mock_client.set_job(&8u64, &other_job); + + client.submit_rating(&caller, &7u64, &freelancer, &5u32); + let client_score = client.get_score(&freelancer, &Role::Freelancer); + assert_eq!(client_score.score, 10_000); + assert_eq!(client_score.total_jobs, 1); + assert_eq!(client_score.total_points, 5); + + client.submit_rating(&caller2, &8u64, &target, &4u32); + let freelancer_score = client.get_score(&target, &Role::Freelancer); + assert_eq!(freelancer_score.score, 8_000); + assert_eq!(freelancer_score.total_jobs, 1); + assert_eq!(freelancer_score.total_points, 4); + assert_eq!(freelancer_score.reviews, 1); + } + + #[test] + #[should_panic(expected = "Error(Contract, #2)")] fn test_upgrade_requires_admin() { let env = Env::default(); env.mock_all_auths(); @@ -443,4 +666,4 @@ mod test { let wasm_hash = BytesN::from_array(&env, &[0; 32]); client.upgrade(&attacker, &wasm_hash); } -} +} \ No newline at end of file From a5ef563f06a6ba1445d14521c6b476b65db0136c Mon Sep 17 00:00:00 2001 From: Henry Eb Date: Wed, 27 May 2026 22:25:12 +0100 Subject: [PATCH 2/2] optimize escrow storage --- contracts/escrow/src/lib.rs | 588 ++++++++++++++++++++++++++---------- 1 file changed, 430 insertions(+), 158 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 51c4f606..ff083b58 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -66,6 +66,28 @@ pub struct Milestone { pub status: MilestoneStatus, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct MilestoneRecord { + pub amount: i128, + pub released: bool, +} + +#[contracttype] +#[derive(Clone)] +pub struct EscrowJobCore { + pub client: Address, + pub freelancer: Address, + pub token: Address, + pub total_amount: i128, + pub released_amount: i128, + pub released_milestones: u32, + pub status: EscrowStatus, + pub created_at: u64, + pub expires_at: u64, + pub milestone_count: u32, +} + #[contracttype] #[derive(Clone)] pub struct EscrowJob { @@ -82,7 +104,8 @@ pub struct EscrowJob { #[contracttype] pub enum DataKey { - Job(u64), + JobCore(u64), + JobMilestones(u64), Admin, AgentJudge, JobRegistry, @@ -120,6 +143,7 @@ pub enum EscrowError { UpgradeUnauthorized = 10, InvalidStateTransition = 11, ReentrancyDetected = 12, + MathOverflow = 13, } #[contracttype] @@ -191,6 +215,33 @@ fn exit_reentrancy_guard(env: &Env) { env.storage().instance().remove(&DataKey::Locked); } +fn job_core_key(job_id: u64) -> DataKey { + DataKey::JobCore(job_id) +} + +fn job_milestones_key(job_id: u64) -> DataKey { + DataKey::JobMilestones(job_id) +} + +fn checked_i128_add(lhs: i128, rhs: i128) -> Result { + lhs.checked_add(rhs).ok_or(EscrowError::MathOverflow) +} + +fn checked_i128_sub(lhs: i128, rhs: i128) -> Result { + lhs.checked_sub(rhs).ok_or(EscrowError::MathOverflow) +} + +fn view_milestone(record: &MilestoneRecord) -> Milestone { + Milestone { + amount: record.amount, + status: if record.released { + MilestoneStatus::Released + } else { + MilestoneStatus::Pending + }, + } +} + #[contract] pub struct EscrowContract; @@ -207,14 +258,84 @@ impl EscrowContract { .extend_ttl(Self::INSTANCE_TTL_THRESHOLD, Self::INSTANCE_TTL_EXTEND_TO); } - fn bump_job_ttl(env: &Env, key: &DataKey) { - if env.storage().persistent().has(key) { + fn bump_job_ttl(env: &Env, job_id: u64) { + let core_key = job_core_key(job_id); + let milestones_key = job_milestones_key(job_id); + + if env.storage().persistent().has(&core_key) { env.storage().persistent().extend_ttl( - key, + &core_key, Self::PERSISTENT_TTL_THRESHOLD, Self::PERSISTENT_TTL_EXTEND_TO, ); } + if env.storage().persistent().has(&milestones_key) { + env.storage().persistent().extend_ttl( + &milestones_key, + Self::PERSISTENT_TTL_THRESHOLD, + Self::PERSISTENT_TTL_EXTEND_TO, + ); + } + } + + fn read_job_core(env: &Env, job_id: u64) -> Result { + env.storage() + .persistent() + .get(&job_core_key(job_id)) + .ok_or(EscrowError::JobNotFound) + } + + fn read_milestones(env: &Env, job_id: u64) -> Result, EscrowError> { + env.storage() + .persistent() + .get(&job_milestones_key(job_id)) + .ok_or(EscrowError::JobNotFound) + } + + fn persist_job( + env: &Env, + job_id: u64, + core: &EscrowJobCore, + milestones: &Vec, + ) { + let core_key = job_core_key(job_id); + let milestones_key = job_milestones_key(job_id); + env.storage().persistent().set(&core_key, core); + env.storage().persistent().set(&milestones_key, milestones); + Self::bump_job_ttl(env, job_id); + } + + fn load_job( + env: &Env, + job_id: u64, + ) -> Result<(EscrowJobCore, Vec), EscrowError> { + let core = Self::read_job_core(env, job_id)?; + let milestones = Self::read_milestones(env, job_id)?; + Ok((core, milestones)) + } + + fn to_public_job( + env: &Env, + core: EscrowJobCore, + milestones: Vec, + ) -> EscrowJob { + let mut public_milestones = Vec::new(env); + // Rebuild the user-facing milestone vector from the compact ledger record. + for milestone in milestones.iter() { + public_milestones.push_back(view_milestone(&milestone)); + } + + EscrowJob { + client: core.client, + freelancer: core.freelancer, + token: core.token, + total_amount: core.total_amount, + released_amount: core.released_amount, + status: core.status, + created_at: core.created_at, + expires_at: core.expires_at, + milestones: public_milestones, + } } fn sync_dispute_to_job_registry(env: &Env, job_id: u64) -> Result<(), EscrowError> { @@ -383,23 +504,29 @@ impl EscrowContract { token_addr: Address, ) { client.require_auth(); - let key = DataKey::Job(job_id); - if env.storage().persistent().has(&key) { + let core_key = job_core_key(job_id); + let milestones_key = job_milestones_key(job_id); + if env.storage().persistent().has(&core_key) + || env.storage().persistent().has(&milestones_key) + { panic!("job already exists"); } let now: u64 = env.ledger().timestamp(); - let expires_at = now + 30 * 24 * 60 * 60; + let expires_at = now + .checked_add(30 * 24 * 60 * 60) + .expect("job expiration overflow"); - let job = EscrowJob { + let core = EscrowJobCore { client: client.clone(), freelancer: freelancer.clone(), token: token_addr, total_amount: 0, released_amount: 0, + released_milestones: 0, status: EscrowStatus::Setup, created_at: now, expires_at, - milestones: Vec::new(&env), + milestone_count: 0, }; log!( &env, @@ -408,43 +535,45 @@ impl EscrowContract { client, freelancer ); - env.storage().persistent().set(&key, &job); - Self::bump_job_ttl(&env, &key); + env.storage().persistent().set(&core_key, &core); + env.storage() + .persistent() + .set(&milestones_key, &Vec::::new(&env)); + Self::bump_job_ttl(&env, job_id); } /// Add a milestone to the job (setup phase only). pub fn add_milestone(env: Env, job_id: u64, amount: i128) { - let key = DataKey::Job(job_id); - let mut job: EscrowJob = env.storage().persistent().get(&key).expect("job not found"); - Self::bump_job_ttl(&env, &key); - job.client.require_auth(); - assert!(job.status == EscrowStatus::Setup, "not in setup phase"); + let mut core = Self::read_job_core(&env, job_id).expect("job not found"); + let mut milestones = Self::read_milestones(&env, job_id).expect("job not found"); + Self::bump_job_ttl(&env, job_id); + core.client.require_auth(); + assert!(core.status == EscrowStatus::Setup, "not in setup phase"); assert!(amount > 0, "amount must be > 0"); - job.milestones.push_back(Milestone { + milestones.push_back(MilestoneRecord { amount, - status: MilestoneStatus::Pending, + released: false, }); log!(&env, "add_milestone: job {} amount {}", job_id, amount); - env.storage().persistent().set(&key, &job); - Self::bump_job_ttl(&env, &key); + core.milestone_count = core + .milestone_count + .checked_add(1) + .expect("milestone count overflow"); + Self::persist_job(&env, job_id, &core, &milestones); } /// Client deposits total amount and transitions job to Funded. pub fn deposit(env: Env, job_id: u64, amount: i128) -> Result<(), EscrowError> { - let key = DataKey::Job(job_id); - let mut job: EscrowJob = env - .storage() - .persistent() - .get(&key) - .ok_or(EscrowError::JobNotFound)?; - Self::bump_job_ttl(&env, &key); + let mut core = Self::read_job_core(&env, job_id)?; + let milestones = Self::read_milestones(&env, job_id)?; + Self::bump_job_ttl(&env, job_id); // Caller must be client - job.client.require_auth(); + core.client.require_auth(); // Only allow deposit in Setup state - if job.status != EscrowStatus::Setup { + if core.status != EscrowStatus::Setup { return Err(EscrowError::InvalidState); } @@ -452,13 +581,13 @@ impl EscrowContract { return Err(EscrowError::InvalidInput); } - if job.milestones.is_empty() { + if milestones.is_empty() { return Err(EscrowError::InvalidInput); } let mut total_milestones_amount = 0i128; - for m in job.milestones.iter() { - total_milestones_amount = total_milestones_amount.saturating_add(m.amount); + for m in milestones.iter() { + total_milestones_amount = checked_i128_add(total_milestones_amount, m.amount)?; } if total_milestones_amount != amount { @@ -468,17 +597,16 @@ impl EscrowContract { enter_reentrancy_guard(&env); let next_status = EscrowStatus::Funded; - job.status.validate_transition(&next_status)?; - job.total_amount = amount; - job.status = next_status; + core.status.validate_transition(&next_status)?; + core.total_amount = amount; + core.status = next_status; // Transfer tokens from client to contract - let token_client = token::Client::new(&env, &job.token); - token_client.transfer(&job.client, &env.current_contract_address(), &amount); + let token_client = token::Client::new(&env, &core.token); + token_client.transfer(&core.client, &env.current_contract_address(), &amount); log!(&env, "deposit: job {} amount {}", job_id, amount); - env.storage().persistent().set(&key, &job); - Self::bump_job_ttl(&env, &key); + Self::persist_job(&env, job_id, &core, &milestones); exit_reentrancy_guard(&env); @@ -497,26 +625,21 @@ impl EscrowContract { pub fn release_milestone(env: Env, job_id: u64, caller: Address) -> Result<(), EscrowError> { caller.require_auth(); - let key = DataKey::Job(job_id); - let mut job: EscrowJob = env - .storage() - .persistent() - .get(&key) - .ok_or(EscrowError::JobNotFound)?; - Self::bump_job_ttl(&env, &key); + let (mut core, mut milestones) = Self::load_job(&env, job_id)?; + Self::bump_job_ttl(&env, job_id); - if !(job.status == EscrowStatus::Funded || job.status == EscrowStatus::WorkInProgress) { + if !(core.status == EscrowStatus::Funded || core.status == EscrowStatus::WorkInProgress) { return Err(EscrowError::InvalidState); } - if caller != job.client { + if caller != core.client { return Err(EscrowError::Unauthorized); } // Find next pending milestone let mut found_idx: Option = None; - for idx in 0..job.milestones.len() { - if job.milestones.get(idx).unwrap().status == MilestoneStatus::Pending { + for idx in 0..milestones.len() { + if !milestones.get(idx).unwrap().released { found_idx = Some(idx); break; } @@ -527,26 +650,30 @@ impl EscrowContract { None => return Err(EscrowError::NoPendingMilestones), }; - let mut milestone = job.milestones.get(idx).unwrap(); - milestone.status = MilestoneStatus::Released; - job.milestones.set(idx, milestone.clone()); + let mut milestone = milestones.get(idx).unwrap(); + milestone.released = true; + milestones.set(idx, milestone.clone()); - job.released_amount = job.released_amount.saturating_add(milestone.amount); + core.released_amount = checked_i128_add(core.released_amount, milestone.amount)?; + core.released_milestones = core + .released_milestones + .checked_add(1) + .ok_or(EscrowError::MathOverflow)?; - let next_status = if job.released_amount == job.total_amount { + let next_status = if core.released_amount == core.total_amount { EscrowStatus::Completed } else { EscrowStatus::WorkInProgress }; - job.status.validate_transition(&next_status)?; - job.status = next_status; + core.status.validate_transition(&next_status)?; + core.status = next_status; enter_reentrancy_guard(&env); - let token_client = token::Client::new(&env, &job.token); + let token_client = token::Client::new(&env, &core.token); token_client.transfer( &env.current_contract_address(), - &job.freelancer, + &core.freelancer, &milestone.amount, ); @@ -556,8 +683,7 @@ impl EscrowContract { job_id, milestone.amount ); - env.storage().persistent().set(&key, &job); - Self::bump_job_ttl(&env, &key); + Self::persist_job(&env, job_id, &core, &milestones); exit_reentrancy_guard(&env); @@ -575,49 +701,47 @@ impl EscrowContract { 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"); - Self::bump_job_ttl(&env, &key); + let (mut core, mut milestones) = Self::load_job(&env, job_id).expect("job not found"); + Self::bump_job_ttl(&env, job_id); assert!( - job.status == EscrowStatus::Funded || job.status == EscrowStatus::WorkInProgress, + core.status == EscrowStatus::Funded || core.status == EscrowStatus::WorkInProgress, "job not in releaseable state" ); - assert!(caller == job.client, "only client can release"); + assert!(caller == core.client, "only client can release"); assert!( - milestone_index < job.milestones.len(), + milestone_index < milestones.len(), "invalid milestone index" ); - let mut milestone = job - .milestones - .get(milestone_index) - .expect("invalid milestone"); - assert!( - milestone.status == MilestoneStatus::Pending, - "milestone already released" - ); + let mut milestone = milestones.get(milestone_index).expect("invalid milestone"); + assert!(!milestone.released, "milestone already released"); - milestone.status = MilestoneStatus::Released; - job.milestones.set(milestone_index, milestone.clone()); + milestone.released = true; + milestones.set(milestone_index, milestone.clone()); - job.released_amount += milestone.amount; - let next_status = if job.released_amount == job.total_amount { + core.released_amount = + checked_i128_add(core.released_amount, milestone.amount).expect("math overflow"); + core.released_milestones = core + .released_milestones + .checked_add(1) + .expect("math overflow"); + let next_status = if core.released_amount == core.total_amount { EscrowStatus::Completed } else { EscrowStatus::WorkInProgress }; - job.status + core.status .validate_transition(&next_status) .expect("invalid state transition"); - job.status = next_status; + core.status = next_status; enter_reentrancy_guard(&env); - let token_client = token::Client::new(&env, &job.token); + let token_client = token::Client::new(&env, &core.token); token_client.transfer( &env.current_contract_address(), - &job.freelancer, + &core.freelancer, &milestone.amount, ); @@ -627,8 +751,7 @@ impl EscrowContract { job_id, milestone.amount ); - env.storage().persistent().set(&key, &job); - Self::bump_job_ttl(&env, &key); + Self::persist_job(&env, job_id, &core, &milestones); exit_reentrancy_guard(&env); } @@ -637,28 +760,22 @@ impl EscrowContract { pub fn open_dispute(env: Env, job_id: u64, caller: Address) -> Result<(), EscrowError> { caller.require_auth(); - let key = DataKey::Job(job_id); - let mut job: EscrowJob = env - .storage() - .persistent() - .get(&key) - .ok_or(EscrowError::JobNotFound)?; - Self::bump_job_ttl(&env, &key); + let mut core = Self::read_job_core(&env, job_id)?; + Self::bump_job_ttl(&env, job_id); - if !(job.status == EscrowStatus::Funded || job.status == EscrowStatus::WorkInProgress) { + if !(core.status == EscrowStatus::Funded || core.status == EscrowStatus::WorkInProgress) { return Err(EscrowError::InvalidState); } - if !(caller == job.client || caller == job.freelancer) { + if !(caller == core.client || caller == core.freelancer) { return Err(EscrowError::Unauthorized); } let next_status = EscrowStatus::Disputed; - job.status.validate_transition(&next_status)?; - job.status = next_status; + core.status.validate_transition(&next_status)?; + core.status = next_status; log!(&env, "open_dispute: job {}", job_id); - env.storage().persistent().set(&key, &job); - Self::bump_job_ttl(&env, &key); + Self::persist_job(&env, job_id, &core, &Self::read_milestones(&env, job_id)?); Self::sync_dispute_to_job_registry(&env, job_id)?; @@ -676,61 +793,62 @@ impl EscrowContract { // 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"); - Self::bump_job_ttl(&env, &key); + let core = Self::read_job_core(&env, job_id).expect("job not found"); + Self::bump_job_ttl(&env, job_id); // 2. Only client or freelancer may raise a dispute assert!( - caller == job.client || caller == job.freelancer, + caller == core.client || caller == core.freelancer, "unauthorized: only client or freelancer can raise a dispute" ); // 3. Job must still be active assert!( - job.status == EscrowStatus::Funded || job.status == EscrowStatus::WorkInProgress, + core.status == EscrowStatus::Funded || core.status == EscrowStatus::WorkInProgress, "dispute cannot be raised: job is not in active state" ); // 4. Prevent dispute if all funds are already released assert!( - job.released_amount < job.total_amount, + core.released_amount < core.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; + let deadline = core + .expires_at + .checked_add(grace_period) + .ok_or(EscrowError::MathOverflow)?; assert!( - now <= job.expires_at + grace_period, + now <= deadline, "dispute cannot be raised: deadline has drastically expired" ); // 6. Lock funds by transitioning to Disputed — blocks release_funds & release_milestone let next_status = EscrowStatus::Disputed; - job.status.validate_transition(&next_status)?; - job.status = next_status; + let mut disputed_core = core.clone(); + disputed_core.status.validate_transition(&next_status)?; + disputed_core.status = next_status; log!(&env, "raise_dispute: job {}", job_id); - env.storage().persistent().set(&key, &job); - Self::bump_job_ttl(&env, &key); + Self::persist_job( + &env, + job_id, + &disputed_core, + &Self::read_milestones(&env, job_id)?, + ); Self::sync_dispute_to_job_registry(&env, job_id)?; // 7. Emit DisputeRaised event for backend / AI Judge to consume - let mut released_count = 0u32; - for m in job.milestones.iter() { - if m.status == MilestoneStatus::Released { - released_count += 1; - } - } - env.events().publish( ("escrow", "DisputeRaised"), ( job_id, caller.clone(), - released_count, - job.milestones.len(), + core.released_milestones, + core.milestone_count, now, ), ); @@ -753,34 +871,35 @@ impl EscrowContract { assert!(payee_amount >= 0, "payee_amount must be >= 0"); assert!(payer_amount >= 0, "payer_amount must be >= 0"); - let key = DataKey::Job(job_id); - let mut job: EscrowJob = env.storage().persistent().get(&key).expect("job not found"); - Self::bump_job_ttl(&env, &key); - assert!(job.status == EscrowStatus::Disputed, "job not disputed"); + let mut core = Self::read_job_core(&env, job_id).expect("job not found"); + Self::bump_job_ttl(&env, job_id); + assert!(core.status == EscrowStatus::Disputed, "job not disputed"); - let remaining = job.total_amount - job.released_amount; - let total_payout = payee_amount + payer_amount; + let remaining = checked_i128_sub(core.total_amount, core.released_amount) + .expect("invalid released amount"); + let total_payout = checked_i128_add(payee_amount, payer_amount).expect("math overflow"); assert!(total_payout <= remaining, "payout exceeds remaining funds"); let next_status = EscrowStatus::Resolved; - job.status + core.status .validate_transition(&next_status) .expect("invalid state transition"); - job.released_amount += total_payout; - job.status = next_status; + core.released_amount = + checked_i128_add(core.released_amount, total_payout).expect("math overflow"); + core.status = next_status; enter_reentrancy_guard(&env); - let token_client = token::Client::new(&env, &job.token); + let token_client = token::Client::new(&env, &core.token); if payee_amount > 0 { token_client.transfer( &env.current_contract_address(), - &job.freelancer, + &core.freelancer, &payee_amount, ); } if payer_amount > 0 { - token_client.transfer(&env.current_contract_address(), &job.client, &payer_amount); + token_client.transfer(&env.current_contract_address(), &core.client, &payer_amount); } log!( @@ -790,8 +909,12 @@ impl EscrowContract { payee_amount, payer_amount ); - env.storage().persistent().set(&key, &job); - Self::bump_job_ttl(&env, &key); + Self::persist_job( + &env, + job_id, + &core, + &Self::read_milestones(&env, job_id).expect("job not found"), + ); exit_reentrancy_guard(&env); } @@ -800,39 +923,35 @@ impl EscrowContract { pub fn refund(env: Env, job_id: u64, client: Address) -> Result<(), EscrowError> { client.require_auth(); - let key = DataKey::Job(job_id); - let mut job: EscrowJob = env - .storage() - .persistent() - .get(&key) - .ok_or(EscrowError::JobNotFound)?; - Self::bump_job_ttl(&env, &key); + let mut core = Self::read_job_core(&env, job_id)?; + let milestones = Self::read_milestones(&env, job_id)?; + Self::bump_job_ttl(&env, job_id); - if !(job.status == EscrowStatus::Funded || job.status == EscrowStatus::WorkInProgress) { + if !(core.status == EscrowStatus::Funded || core.status == EscrowStatus::WorkInProgress) { return Err(EscrowError::InvalidState); } - if client != job.client { + if client != core.client { return Err(EscrowError::Unauthorized); } - let remaining = job.total_amount - job.released_amount; + let remaining = checked_i128_sub(core.total_amount, core.released_amount)?; let next_status = EscrowStatus::Refunded; - job.status.validate_transition(&next_status)?; - job.released_amount = job.total_amount; - job.status = next_status; + core.status.validate_transition(&next_status)?; + core.released_amount = core.total_amount; + core.released_milestones = core.milestone_count; + core.status = next_status; enter_reentrancy_guard(&env); if remaining > 0 { - let token_client = token::Client::new(&env, &job.token); - token_client.transfer(&env.current_contract_address(), &job.client, &remaining); + let token_client = token::Client::new(&env, &core.token); + token_client.transfer(&env.current_contract_address(), &core.client, &remaining); } log!(&env, "refund: job {} amount {}", job_id, remaining); - env.storage().persistent().set(&key, &job); - Self::bump_job_ttl(&env, &key); + Self::persist_job(&env, job_id, &core, &milestones); exit_reentrancy_guard(&env); @@ -845,20 +964,22 @@ impl EscrowContract { } pub fn get_job(env: Env, job_id: u64) -> EscrowJob { - let key = DataKey::Job(job_id); - let job: EscrowJob = env.storage().persistent().get(&key).expect("job not found"); - Self::bump_job_ttl(&env, &key); - job + let (core, milestones) = Self::load_job(&env, job_id).expect("job not found"); + Self::bump_job_ttl(&env, job_id); + Self::to_public_job(&env, core, milestones) } /// Retrieve the status of all milestones for a given job. pub fn get_milestone_status(env: Env, job_id: u64) -> Vec { - let key = DataKey::Job(job_id); - let job: EscrowJob = env.storage().persistent().get(&key).expect("job not found"); - Self::bump_job_ttl(&env, &key); + let milestones = Self::read_milestones(&env, job_id).expect("job not found"); + Self::bump_job_ttl(&env, job_id); let mut statuses = Vec::new(&env); - for m in job.milestones.iter() { - statuses.push_back(m.status); + for m in milestones.iter() { + statuses.push_back(if m.released { + MilestoneStatus::Released + } else { + MilestoneStatus::Pending + }); } statuses } @@ -880,6 +1001,65 @@ mod test { admin_client.mint(to, &100_000); } + #[contracttype] + enum AttackKey { + Escrow, + JobId, + Client, + Armed, + } + + #[contract] + pub struct ReentrantTokenContract; + + #[contractimpl] + impl ReentrantTokenContract { + pub fn initialize(env: Env, escrow: Address, job_id: u64, client: Address) { + env.storage().instance().set(&AttackKey::Escrow, &escrow); + env.storage().instance().set(&AttackKey::JobId, &job_id); + env.storage().instance().set(&AttackKey::Client, &client); + env.storage().instance().set(&AttackKey::Armed, &false); + } + + pub fn arm(env: Env, armed: bool) { + env.storage().instance().set(&AttackKey::Armed, &armed); + } + + pub fn balance(_env: Env, _id: Address) -> i128 { + 0 + } + + pub fn transfer(env: Env, _from: Address, _to: Address, _amount: i128) { + let armed = env + .storage() + .instance() + .get::<_, bool>(&AttackKey::Armed) + .unwrap_or(false); + if !armed { + return; + } + + let escrow = env + .storage() + .instance() + .get::<_, Address>(&AttackKey::Escrow) + .expect("escrow not configured"); + let job_id = env + .storage() + .instance() + .get::<_, u64>(&AttackKey::JobId) + .expect("job not configured"); + let client = env + .storage() + .instance() + .get::<_, Address>(&AttackKey::Client) + .expect("client not configured"); + + let cc = EscrowContractClient::new(&env, &escrow); + cc.release_funds(&job_id, &client, &0u32); + } + } + #[test] fn test_happy_path_lifecycle() { let env = Env::default(); @@ -1617,6 +1797,98 @@ mod test { cc.release_milestone(&1u64, &client); } + #[test] + #[should_panic(expected = "Error(Contract, #12)")] + fn test_reentrant_release_attack_panics() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = Address::generate(&env); + let client = Address::generate(&env); + let freelancer = Address::generate(&env); + + let token_addr = env.register_contract(None, ReentrantTokenContract); + + let contract_id = env.register_contract(None, EscrowContract); + let cc = EscrowContractClient::new(&env, &contract_id); + + let token_client = ReentrantTokenContractClient::new(&env, &token_addr); + token_client.initialize(&contract_id, &1u64, &client); + + cc.initialize(&admin, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + cc.add_milestone(&1u64, &5000i128); + cc.deposit(&1u64, &5000i128); + + token_client.arm(&true); + cc.release_funds(&1u64, &client, &0u32); + } + + #[test] + fn test_release_funds_gas_budget_stays_below_threshold() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = 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, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + cc.add_milestone(&1u64, &2500i128); + cc.add_milestone(&1u64, &2500i128); + cc.add_milestone(&1u64, &2500i128); + cc.add_milestone(&1u64, &2500i128); + cc.deposit(&1u64, &10_000i128); + + env.budget().reset_unlimited(); + + cc.release_funds(&1u64, &client, &3u32); + + let budget = env.budget(); + assert!(budget.cpu_instruction_cost() < 1_500_000); + assert!(budget.memory_bytes_cost() < 200_000); + } + + #[test] + fn test_refund_gas_budget_stays_below_threshold() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let agent_judge = 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, &agent_judge); + cc.create_job(&1u64, &client, &freelancer, &token_addr); + cc.add_milestone(&1u64, &6000i128); + cc.add_milestone(&1u64, &4000i128); + cc.deposit(&1u64, &10_000i128); + + env.budget().reset_unlimited(); + + cc.refund(&1u64, &client); + + let budget = env.budget(); + assert!(budget.cpu_instruction_cost() < 1_300_000); + assert!(budget.memory_bytes_cost() < 180_000); + } + // ───────────────────────────────────────────────────────────────────────── // Comprehensive Escrow Dispute & Resolution Tests (>90% coverage) // ─────────────────────────────────────────────────────────────────────────