diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 1df027f..072b557 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -71,6 +71,28 @@ pub struct Milestone { /// `EscrowJob` places dynamic fields at the end of the serialized state. /// This keeps the fixed-size prefix compact and reduces the size impact /// of the variable-length `milestones` vector on Soroban serialization. +#[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 { @@ -99,6 +121,10 @@ pub struct ContractConfig { #[contracttype] pub enum DataKey { + JobCore(u64), + JobMilestones(u64), + Admin, + AgentJudge, Job(u64), Config, // Replaces separate Admin + AgentJudge entries JobLock(u64), @@ -191,6 +217,7 @@ pub enum EscrowError { UpgradeUnauthorized = 10, InvalidStateTransition = 11, ReentrancyDetected = 12, + MathOverflow = 13, MultisigRequired = 13, InsufficientSignatures = 14, AlreadySigned = 15, @@ -332,6 +359,65 @@ fn checked_i128_add(lhs: i128, rhs: i128) -> Result { lhs.checked_add(rhs).ok_or(EscrowError::InvalidInput) } +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 checked_u32_add(lhs: u32, rhs: u32) -> Result { + lhs.checked_add(rhs).ok_or(EscrowError::MathOverflow) +} + +#[derive(Clone, Debug, PartialEq)] +struct AllocationSplit { + payee_amount: i128, + payer_amount: i128, + total_payout: i128, +} + +fn checked_allocation_split( + remaining: i128, + payee_amount: i128, + payer_amount: i128, +) -> Result { + if payee_amount < 0 || payer_amount < 0 { + return Err(EscrowError::InvalidInput); + } + + let total_payout = checked_i128_add(payee_amount, payer_amount)?; + if total_payout > remaining { + return Err(EscrowError::AmountMismatch); + } + + Ok(AllocationSplit { + payee_amount, + payer_amount, + total_payout, + }) +} + +fn view_milestone(record: &MilestoneRecord) -> Milestone { + Milestone { + amount: record.amount, + status: if record.released { + MilestoneStatus::Released + } else { + MilestoneStatus::Pending + }, + } +} + #[contract] pub struct EscrowContract; @@ -354,16 +440,89 @@ 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 checked_add_i128(env: &Env, a: i128, b: i128) -> Result { + a.checked_add(b).ok_or_else(|| { + log!(env, "checked_add_i128 overflow: {} + {}", a, b); + EscrowError::InvalidInput + }) fn enter_job_lock(env: &Env, job_id: u64) -> Result { let lock_key = DataKey::JobLock(job_id); if env.storage().temporary().has(&lock_key) { @@ -654,6 +813,17 @@ impl EscrowContract { token_addr: Address, ) -> Result<(), EscrowError> { client.require_auth(); + 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 + .checked_add(30 * 24 * 60 * 60) + .expect("job expiration overflow"); let key = DataKey::Job(job_id); if env.storage().persistent().has(&key) { return Err(EscrowError::InvalidInput); @@ -669,15 +839,17 @@ let expires_at = now .checked_add(expires_duration) .ok_or(EscrowError::ArithmeticError)?; - 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, + milestone_count: 0, milestones: Vec::new(&env), requires_multisig: false, token_decimals: 0, @@ -691,6 +863,21 @@ let expires_at = now client, freelancer ); + 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 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"); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); Ok(()) @@ -722,12 +909,15 @@ let expires_at = now "too many milestones" ); + milestones.push_back(MilestoneRecord { job.total_amount = next_total; job.milestones.push_back(Milestone { amount, - status: MilestoneStatus::Pending, + released: false, }); log!(&env, "add_milestone: job {} amount {}", job_id, amount); + core.milestone_count = checked_u32_add(core.milestone_count, 1).expect("math overflow"); + Self::persist_job(&env, job_id, &core, &milestones); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); Ok(()) @@ -735,25 +925,39 @@ let expires_at = now /// 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); } if amount <= 0 { return Err(EscrowError::InvalidInput); } + + if milestones.is_empty() { + return Err(EscrowError::InvalidInput); + } + + // Query token decimals dynamically; custom assets vary (USDC=6, XLM=7, etc.) + // Query token decimals dynamically; stored so off-chain consumers can + // correctly display amounts (USDC=6, XLM=7, etc.). + // Amounts are already in the token's smallest unit so no rounding check needed. + let decimals = token::Client::new(&env, &job.token).decimals(); + job.token_decimals = decimals; + + let mut total_milestones_amount = 0i128; + for m in milestones.iter() { + total_milestones_amount = checked_i128_add(total_milestones_amount, m.amount)?; + for m in job.milestones.iter() { + total_milestones_amount = + Self::checked_add_i128(&env, total_milestones_amount, m.amount)?; if amount > Self::MAX_JOB_BUDGET { return Err(EscrowError::InvalidInput); } @@ -775,16 +979,22 @@ let expires_at = now let _guard = enter_reentrancy_guard(&env); let next_status = EscrowStatus::Funded; + core.status.validate_transition(&next_status)?; + core.total_amount = amount; + core.status = next_status; job.status.validate_transition(&next_status)?; job.total_amount = amount; job.status = next_status; job.funded_ledger_seq = env.ledger().sequence(); // 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); + Self::persist_job(&env, job_id, &core, &milestones); + + exit_reentrancy_guard(&env); env.storage().persistent().set(&key, &job); // Emit deposit event for off-chain logging @@ -802,28 +1012,24 @@ let expires_at = now 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 !(core.status == EscrowStatus::Funded || core.status == EscrowStatus::WorkInProgress) { Self::assert_not_same_ledger_as_funding(&env, &job)?; if !(job.status == EscrowStatus::Funded || job.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; } @@ -834,6 +1040,13 @@ let expires_at = now None => return Err(EscrowError::NoPendingMilestones), }; + let mut milestone = milestones.get(idx).unwrap(); + milestone.released = true; + milestones.set(idx, milestone.clone()); + + core.released_amount = checked_i128_add(core.released_amount, milestone.amount)?; + core.released_milestones = checked_u32_add(core.released_milestones, 1)?; + job.released_amount = Self::checked_add_i128(&env, job.released_amount, milestone.amount)?; let mut milestone = job.milestones.get(idx).unwrap(); let lock_key = Self::enter_job_lock(&env, job_id)?; @@ -842,17 +1055,24 @@ let expires_at = now job.released_amount = checked_i128_add(job.released_amount, milestone.amount)?; - 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; let _guard = enter_reentrancy_guard(&env); env.storage().persistent().set(&key, &job); + let token_client = token::Client::new(&env, &core.token); + token_client.transfer( + &env.current_contract_address(), + &core.freelancer, + &milestone.amount, + ); + Self::payout_with_fee(&env, job_id, &job, milestone.amount); Self::payout_with_fee(&env, job_id, &job, milestone.amount)?; log!( @@ -861,6 +1081,7 @@ let expires_at = now job_id, milestone.amount ); + Self::persist_job(&env, job_id, &core, &milestones); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); Self::exit_job_lock(&env, lock_key); @@ -886,6 +1107,21 @@ let expires_at = now ) -> Result<(), EscrowError> { caller.require_auth(); + let (mut core, mut milestones) = Self::load_job(&env, job_id).expect("job not found"); + Self::bump_job_ttl(&env, job_id); + + assert!( + core.status == EscrowStatus::Funded || core.status == EscrowStatus::WorkInProgress, + "job not in releaseable state" + ); + assert!(caller == core.client, "only client can release"); + assert!( + milestone_index < milestones.len(), + "invalid milestone index" + ); + + let mut milestone = milestones.get(milestone_index).expect("invalid milestone"); + assert!(!milestone.released, "milestone already released"); let key = DataKey::Job(job_id); let mut job: EscrowJob = env .storage() @@ -920,9 +1156,18 @@ if milestone_index >= job.milestones.len() { ); let lock_key = Self::enter_job_lock(&env, job_id).expect("reentrant job operation"); - milestone.status = MilestoneStatus::Released; - job.milestones.set(milestone_index, milestone.clone()); - + milestone.released = true; + milestones.set(milestone_index, milestone.clone()); + + core.released_amount = + checked_i128_add(core.released_amount, milestone.amount).expect("math overflow"); + core.released_milestones = + checked_u32_add(core.released_milestones, 1).expect("math overflow"); + let next_status = if core.released_amount == core.total_amount { + job.released_amount = job + .released_amount + .checked_add(milestone.amount) + .expect("released_amount overflow"); job.released_amount = checked_i128_add(job.released_amount, milestone.amount)?; assert!( job.released_amount <= job.total_amount, @@ -933,6 +1178,19 @@ if milestone_index >= job.milestones.len() { } else { EscrowStatus::WorkInProgress }; + core.status + .validate_transition(&next_status) + .expect("invalid state transition"); + core.status = next_status; + + enter_reentrancy_guard(&env); + + let token_client = token::Client::new(&env, &core.token); + token_client.transfer( + &env.current_contract_address(), + &core.freelancer, + &milestone.amount, + ); job.status.validate_transition(&next_status)?; job.status = next_status; @@ -947,6 +1205,9 @@ if milestone_index >= job.milestones.len() { job_id, milestone.amount ); + Self::persist_job(&env, job_id, &core, &milestones); + + exit_reentrancy_guard(&env); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); Self::exit_job_lock(&env, lock_key); @@ -957,31 +1218,28 @@ if milestone_index >= job.milestones.len() { 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 !(core.status == EscrowStatus::Funded || core.status == EscrowStatus::WorkInProgress) { Self::assert_not_same_ledger_as_funding(&env, &job)?; if !(job.status == EscrowStatus::Funded || job.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; + core.status.validate_transition(&next_status)?; + core.status = next_status; job.status.validate_transition(&next_status)?; job.status = next_status; job.dispute_deadline = env.ledger().timestamp() + Self::DISPUTE_RESOLUTION_WINDOW; 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)?; @@ -999,6 +1257,44 @@ if milestone_index >= job.milestones.len() { // 1. Authenticate the caller caller.require_auth(); + 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 == core.client || caller == core.freelancer, + "unauthorized: only client or freelancer can raise a dispute" + ); + + // 3. Job must still be active + assert!( + 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!( + 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 <= 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; + let mut disputed_core = core.clone(); + disputed_core.status.validate_transition(&next_status)?; + disputed_core.status = next_status; let key = DataKey::Job(job_id); let mut job: EscrowJob = env .storage() @@ -1047,26 +1343,23 @@ if !(job.status == EscrowStatus::Funded job.status = next_status; job.dispute_deadline = now + Self::DISPUTE_RESOLUTION_WINDOW; 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, ), ); @@ -1095,6 +1388,14 @@ if !(job.status == EscrowStatus::Funded return Err(EscrowError::InvalidInput); } + 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 = checked_i128_sub(core.total_amount, core.released_amount) + .expect("invalid released amount"); + let allocation = checked_allocation_split(remaining, payee_amount, payer_amount) + .expect("invalid allocation split"); let key = DataKey::Job(job_id); let mut job: EscrowJob = env .storage() @@ -1118,9 +1419,13 @@ if !(job.status == EscrowStatus::Funded let lock_key = Self::enter_job_lock(&env, job_id).expect("reentrant job operation"); let next_status = EscrowStatus::Resolved; - job.status + core.status .validate_transition(&next_status) .expect("invalid state transition"); + core.released_amount = + checked_i128_add(core.released_amount, allocation.total_payout).expect("math overflow"); + core.status = next_status; + job.released_amount = Self::checked_add_i128(&env, job.released_amount, total_payout) job.released_amount = checked_i128_add(job.released_amount, total_payout) .expect("released amount overflow"); job.status = next_status; @@ -1128,6 +1433,12 @@ if !(job.status == EscrowStatus::Funded let _guard = enter_reentrancy_guard(&env); env.storage().persistent().set(&key, &job); + let token_client = token::Client::new(&env, &core.token); + if allocation.payee_amount > 0 { + token_client.transfer( + &env.current_contract_address(), + &core.freelancer, + &allocation.payee_amount, let token_client = token::Client::new(&env, &job.token); let mut freelancer_amount = payee_amount; @@ -1158,8 +1469,12 @@ if !(job.status == EscrowStatus::Funded &freelancer_amount, ); } - if payer_amount > 0 { - token_client.transfer(&env.current_contract_address(), &job.client, &payer_amount); + if allocation.payer_amount > 0 { + token_client.transfer( + &env.current_contract_address(), + &core.client, + &allocation.payer_amount, + ); } log!( @@ -1169,6 +1484,14 @@ if !(job.status == EscrowStatus::Funded payee_amount, payer_amount ); + Self::persist_job( + &env, + job_id, + &core, + &Self::read_milestones(&env, job_id).expect("job not found"), + ); + + exit_reentrancy_guard(&env); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); Self::exit_job_lock(&env, lock_key); @@ -1179,36 +1502,45 @@ if !(job.status == EscrowStatus::Funded 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 !(core.status == EscrowStatus::Funded || core.status == EscrowStatus::WorkInProgress) { Self::assert_not_same_ledger_as_funding(&env, &job)?; if !(job.status == EscrowStatus::Funded || job.status == EscrowStatus::WorkInProgress) { return Err(EscrowError::InvalidState); } - if client != job.client { + if client != core.client { return Err(EscrowError::Unauthorized); } + let remaining = checked_i128_sub(core.total_amount, core.released_amount)?; + let remaining = Self::checked_sub_i128(&env, job.total_amount, job.released_amount)?; + + let next_status = EscrowStatus::Refunded; + core.status.validate_transition(&next_status)?; + core.released_amount = checked_i128_add(core.released_amount, remaining)?; + core.released_milestones = core.milestone_count; + core.status = next_status; + + enter_reentrancy_guard(&env); + let remaining = job.total_amount - job.released_amount; let lock_key = Self::enter_job_lock(&env, job_id)?; let _guard = 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); } let next_status = EscrowStatus::Refunded; job.status = next_status; log!(&env, "refund: job {} amount {}", job_id, remaining); + Self::persist_job(&env, job_id, &core, &milestones); env.storage().persistent().set(&key, &job); Self::bump_job_ttl(&env, &key); Self::exit_job_lock(&env, lock_key); @@ -1223,6 +1555,25 @@ if !(job.status == EscrowStatus::Funded Ok(()) } + pub fn get_job(env: Env, job_id: u64) -> EscrowJob { + 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 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 milestones.iter() { + statuses.push_back(if m.released { + MilestoneStatus::Released + } else { + MilestoneStatus::Pending + }); + } + statuses /// Client cancels a brief and triggers graceful refund behavior. /// Supports Setup (no funds moved yet), Funded, and WorkInProgress states. pub fn cancel_brief(env: Env, job_id: u64, client: Address) -> Result<(), EscrowError> { @@ -1317,6 +1668,63 @@ if !(job.status == EscrowStatus::Funded config.agent_judge } + #[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); + } pub fn get_token_decimals(env: Env, job_id: u64) -> u32 { let key = DataKey::Job(job_id); let job: EscrowJob = env.storage().persistent().get(&key).expect("job not found"); @@ -3224,6 +3632,190 @@ mod test { assert_eq!(job.released_amount, 0); } + #[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] + #[should_panic(expected = "Error(Contract, #12)")] + fn test_reentrant_resolve_dispute_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); + cc.raise_dispute(&1u64, &client); + + token_client.arm(&true); + cc.resolve_dispute(&1u64, &2500i128, &2500i128); + } + + #[test] + #[should_panic(expected = "invalid allocation split")] + fn test_resolve_dispute_over_allocated_split_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 = 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, &5000i128); + cc.add_milestone(&1u64, &3000i128); + cc.deposit(&1u64, &8000i128); + cc.raise_dispute(&1u64, &client); + + cc.resolve_dispute(&1u64, &5000i128, &4000i128); + } + + #[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_resolve_dispute_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); + cc.raise_dispute(&1u64, &client); + + env.budget().reset_unlimited(); + + cc.resolve_dispute(&1u64, &4000i128, &3000i128); + + 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) + // ───────────────────────────────────────────────────────────────────────── + #[test] fn test_dispute_event_emission() { let env = Env::default(); diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index c7f11c7..39c79e4 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -6,6 +6,20 @@ mod storage; #[cfg(test)] mod test; +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ReputationScore { + pub address: Address, + pub role: Role, + /// Score in basis points (0\u201310000 = 0\u2013100%) + pub score: i32, + pub total_jobs: u32, + pub total_points: i128, + pub reviews: u32, + pub average_rating_bps: i32, + pub badge_level: u32, + pub blacklisted: bool, +} use profile::{BadgeLevel, Profile}; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, Address, Env, String, @@ -216,6 +230,24 @@ impl ReputationContract { storage::read_profile_or_default(&env, &address) } + fn score_from_profile(address: &Address, role: Role, profile: &profile::Profile) -> ReputationScore { + fn score_from_profile( + address: &Address, + role: Role, + profile: &Profile, + ) -> ReputationScore { + let metrics = Self::role_metrics(profile, &role); + ReputationScore { + address: address.clone(), + role, + score: metrics.score, + total_jobs: metrics.completed_jobs, + total_points: metrics.review.total_points, + reviews: metrics.review.reviews, + average_rating_bps: metrics.review.average_rating_bps, + badge_level: metrics.badge_level, + blacklisted: profile.is_blacklisted, + } /// Get client badge level for an address pub fn get_client_badge(env: Env, address: Address) -> BadgeLevel { Self::bump_instance_ttl(&env); @@ -429,6 +461,47 @@ impl ReputationContract { }, ); + let mut profile = storage::read_profile_or_default(&env, &address); + let (new_score, total_jobs) = match role { + Role::Client => { + 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) + } + }; + if profile.is_blacklisted { + soroban_sdk::panic_with_error!(&env, ReputationError::Blacklisted); + } + + let is_blacklisted = profile.is_blacklisted; + let metrics = Self::role_metrics_mut(&mut profile, &role); + let previous_score = metrics.score; + Self::apply_manual_delta(metrics, delta, is_blacklisted); + let new_score = metrics.score; + let total_jobs = metrics.completed_jobs; + let badge_level = metrics.badge_level; + + profile.refresh_badges(); + storage::write_profile(&env, &address, &profile); + env.events().publish( + ("reputation", "ScoreAdjusted"), + ScoreAdjustedEvent { + address, + role, + delta: new_score.saturating_sub(previous_score), + new_score, + total_jobs, + badge_level, + adjusted_at: env.ledger().timestamp(), + }, + ); + Self::bump_instance_ttl(&env); Ok(()) } @@ -479,6 +552,14 @@ impl ReputationContract { profile.freelancer.score = Self::clamp_score(Self::apply_decay(profile.freelancer.score)?); + #[contractimpl] + impl MockJobRegistry { + pub fn set_job(env: Env, job_id: u64, job: JobRecord) { + env.storage() + .persistent() + .set(&MockKey::Job(job_id), &job); + env.storage().persistent().set(&MockKey::Job(job_id), &job); + } profile.last_activity = now; let old_client_badge = profile.client_badge.clone(); @@ -530,6 +611,155 @@ impl ReputationContract { ); } + #[test] + #[should_panic(expected = "Error(Contract, #2)")] + fn test_direct_reviews_from_unverified_public_keys_are_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let job_client = Address::generate(&env); + let freelancer = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &contract_id); + + client.initialize(&admin); + let registry_id = env.register_contract(None, MockJobRegistry); + client.set_job_registry(&admin, ®istry_id); + setup_job(&env, ®istry_id, 33, &job_client, &freelancer); + + client.submit_rating(&attacker, &33, &freelancer, &5); + } + + #[test] + fn test_profile_metadata() { + let env = Env::default(); + env.mock_all_auths(); + + let address = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &contract_id); + + let hash = Bytes::from_slice(&env, b"QmProfileHash"); + client.update_profile_metadata(&address, &hash); + + let saved_hash = client.get_profile_metadata(&address); + assert_eq!(saved_hash, Some(hash)); + } + + // ── Issue #402: badge minting ── + + #[test] + fn test_badge_starts_at_bronze_for_default_score() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + + // Default score is 5000 → Bronze + let badge = client.get_badge(&addr, &Role::Freelancer); + assert_eq!(badge, BadgeLevel::Bronze); + } + + #[test] + fn test_badge_upgrades_to_silver_at_6000() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + + // Raise score by 1000 → 5000+1000 = 6000 → Silver + client.update_score(&addr, &Role::Freelancer, &1000); + let badge = client.get_badge(&addr, &Role::Freelancer); + assert_eq!(badge, BadgeLevel::Silver); + } + + #[test] + fn test_badge_upgrades_to_gold_at_8000() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + + client.update_score(&addr, &Role::Freelancer, &3000); // 5000+3000=8000 + assert_eq!(client.get_badge(&addr, &Role::Freelancer), BadgeLevel::Gold); + } + + #[test] + fn test_slash_downgrades_badge() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + + 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)")] + assert_eq!(view.client.total_points, 0); + assert_eq!(view.freelancer.score, 6000); + assert_eq!(view.freelancer.total_jobs, 1); + assert_eq!(view.freelancer.total_points, 0); + // Bring to Gold first, then slash twice to drop back to Bronze + client.update_score(&addr, &Role::Client, &3000); // 8000 → Gold + assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Gold); + client.slash(&addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 6000 → Silver + assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Silver); + client.slash(&addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 4000 → Bronze + assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Bronze); + } + + // ── Issue #406: badge metadata mapping ── + + #[test] + fn test_set_and_get_badge_metadata() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + + let uri = Bytes::from_slice(&env, b"ipfs://QmBronzeBadge"); + client.set_badge_metadata(&admin, &addr, &BadgeTier::Bronze, &uri); + + let result = client.get_badge_metadata(&addr, &BadgeTier::Bronze); + assert_eq!(result, Some(uri)); + } + + #[test] + fn test_badge_metadata_returns_none_when_unset() { + let env = Env::default(); + env.mock_all_auths(); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + + let result = client.get_badge_metadata(&addr, &BadgeTier::Gold); + assert_eq!(result, None); + } if profile.freelancer_badge != old_freelancer_badge { env.events().publish( ("reputation", "BadgeUpgraded"), @@ -555,8 +785,23 @@ impl ReputationContract { .ok_or(ReputationError::ProfileNotFound) } + #[test] + #[should_panic(expected = "Error(Contract, #2)")] + #[should_panic(expected = "Error(Contract, #2)")] + fn test_upgrade_requires_admin() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &contract_id); + + client.initialize(&admin); + let wasm_hash = BytesN::from_array(&env, &[0; 32]); + client.upgrade(&attacker, &wasm_hash); /// Check if a caller is authorized pub fn is_authorized_caller(env: Env, caller: Address) -> bool { Self::verify_authorized_caller(&env, &caller).is_ok() } -} +} \ No newline at end of file