From a5ef563f06a6ba1445d14521c6b476b65db0136c Mon Sep 17 00:00:00 2001 From: Henry Eb Date: Wed, 27 May 2026 22:25:12 +0100 Subject: [PATCH 1/4] 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) // ───────────────────────────────────────────────────────────────────────── From cfa6d4ddd9a93bcbdd9132bdd09f7adb02c91695 Mon Sep 17 00:00:00 2001 From: Henry Eb Date: Thu, 28 May 2026 03:03:21 +0100 Subject: [PATCH 2/4] feat(escrow): checked allocation splits --- contracts/escrow/src/lib.rs | 156 +++++++++++++++++++++++++++++++----- 1 file changed, 136 insertions(+), 20 deletions(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index ff083b58..3cda9a44 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -231,6 +231,38 @@ 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, @@ -556,10 +588,7 @@ impl EscrowContract { released: false, }); log!(&env, "add_milestone: job {} amount {}", job_id, amount); - core.milestone_count = core - .milestone_count - .checked_add(1) - .expect("milestone count overflow"); + core.milestone_count = checked_u32_add(core.milestone_count, 1).expect("math overflow"); Self::persist_job(&env, job_id, &core, &milestones); } @@ -655,10 +684,7 @@ impl EscrowContract { milestones.set(idx, milestone.clone()); core.released_amount = checked_i128_add(core.released_amount, milestone.amount)?; - core.released_milestones = core - .released_milestones - .checked_add(1) - .ok_or(EscrowError::MathOverflow)?; + core.released_milestones = checked_u32_add(core.released_milestones, 1)?; let next_status = if core.released_amount == core.total_amount { EscrowStatus::Completed @@ -722,10 +748,8 @@ impl EscrowContract { 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"); + core.released_milestones = + checked_u32_add(core.released_milestones, 1).expect("math overflow"); let next_status = if core.released_amount == core.total_amount { EscrowStatus::Completed } else { @@ -877,29 +901,33 @@ impl EscrowContract { 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 allocation = checked_allocation_split(remaining, payee_amount, payer_amount) + .expect("invalid allocation split"); let next_status = EscrowStatus::Resolved; core.status .validate_transition(&next_status) .expect("invalid state transition"); core.released_amount = - checked_i128_add(core.released_amount, total_payout).expect("math overflow"); + checked_i128_add(core.released_amount, allocation.total_payout).expect("math overflow"); core.status = next_status; enter_reentrancy_guard(&env); let token_client = token::Client::new(&env, &core.token); - if payee_amount > 0 { + if allocation.payee_amount > 0 { token_client.transfer( &env.current_contract_address(), &core.freelancer, - &payee_amount, + &allocation.payee_amount, ); } - if payer_amount > 0 { - token_client.transfer(&env.current_contract_address(), &core.client, &payer_amount); + if allocation.payer_amount > 0 { + token_client.transfer( + &env.current_contract_address(), + &core.client, + &allocation.payer_amount, + ); } log!( @@ -939,7 +967,7 @@ impl EscrowContract { let next_status = EscrowStatus::Refunded; core.status.validate_transition(&next_status)?; - core.released_amount = core.total_amount; + core.released_amount = checked_i128_add(core.released_amount, remaining)?; core.released_milestones = core.milestone_count; core.status = next_status; @@ -1825,6 +1853,62 @@ mod test { 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(); @@ -1858,6 +1942,38 @@ mod test { 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(); From 5da938bfb15079272667182c202039b84ac151e2 Mon Sep 17 00:00:00 2001 From: Henry Eb Date: Thu, 28 May 2026 03:16:23 +0100 Subject: [PATCH 3/4] feat(backend): bound cors and api routing --- .../20260528000001_production_bounds.sql | 36 ++++ backend/src/config.rs | 167 ++++++++++++++++++ backend/src/main.rs | 18 +- backend/src/routes/appeals.rs | 2 + backend/src/routes/bids.rs | 15 +- backend/src/routes/deliverables.rs | 12 +- backend/src/routes/disputes.rs | 3 + backend/src/routes/evidence.rs | 12 +- backend/src/routes/health.rs | 6 +- backend/src/routes/jobs.rs | 19 +- backend/src/routes/milestones.rs | 12 +- backend/src/routes/mod.rs | 26 +-- backend/src/routes/pagination.rs | 57 ++++++ backend/src/routes/users.rs | 17 +- backend/src/routes/verdicts.rs | 1 + 15 files changed, 366 insertions(+), 37 deletions(-) create mode 100644 backend/migrations/20260528000001_production_bounds.sql create mode 100644 backend/src/config.rs create mode 100644 backend/src/routes/pagination.rs diff --git a/backend/migrations/20260528000001_production_bounds.sql b/backend/migrations/20260528000001_production_bounds.sql new file mode 100644 index 00000000..182c58f0 --- /dev/null +++ b/backend/migrations/20260528000001_production_bounds.sql @@ -0,0 +1,36 @@ +-- Migration 004: production query bounds and lookup indexes + +CREATE INDEX IF NOT EXISTS jobs_status_created_idx + ON jobs (status, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS jobs_client_status_idx + ON jobs (client_address, status, created_at DESC); + +CREATE INDEX IF NOT EXISTS jobs_freelancer_status_idx + ON jobs (freelancer_address, status, created_at DESC) + WHERE freelancer_address IS NOT NULL; + +CREATE INDEX IF NOT EXISTS bids_job_created_idx + ON bids (job_id, created_at ASC, id ASC); + +CREATE INDEX IF NOT EXISTS milestones_job_status_idx + ON milestones (job_id, status, index ASC); + +CREATE INDEX IF NOT EXISTS disputes_job_created_idx + ON disputes (job_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS disputes_status_created_idx + ON disputes (status, created_at DESC); + +CREATE INDEX IF NOT EXISTS evidence_dispute_created_idx + ON evidence (dispute_id, created_at ASC, id ASC); + +CREATE INDEX IF NOT EXISTS verdicts_dispute_created_idx + ON verdicts (dispute_id, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS appeals_status_created_idx + ON appeals (status, created_at DESC, id DESC); + +CREATE INDEX IF NOT EXISTS arbiter_votes_appeal_created_idx + ON arbiter_votes (appeal_id, created_at ASC, id ASC); + diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 00000000..fdede925 --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,167 @@ +use std::{env, time::Duration}; + +use anyhow::{anyhow, Context, Result}; +use axum::http::{header, HeaderName, HeaderValue, Method}; +use sqlx::postgres::PgPoolOptions; +use sqlx::PgPool; +use tower_http::cors::{AllowCredentials, AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; + +#[derive(Clone, Debug)] +pub struct DatabasePoolConfig { + pub max_connections: u32, + pub min_connections: u32, + pub acquire_timeout: Duration, + pub idle_timeout: Duration, + pub max_lifetime: Duration, +} + +impl DatabasePoolConfig { + pub fn from_env() -> Result { + let max_connections = read_u32_env("DATABASE_MAX_CONNECTIONS", 16)?; + let min_connections = read_u32_env("DATABASE_MIN_CONNECTIONS", 2)?; + + if min_connections > max_connections { + return Err(anyhow!( + "DATABASE_MIN_CONNECTIONS ({min_connections}) cannot exceed DATABASE_MAX_CONNECTIONS ({max_connections})" + )); + } + + Ok(Self { + max_connections, + min_connections, + acquire_timeout: Duration::from_secs(read_u64_env("DATABASE_ACQUIRE_TIMEOUT_SECS", 5)?), + idle_timeout: Duration::from_secs(read_u64_env("DATABASE_IDLE_TIMEOUT_SECS", 300)?), + max_lifetime: Duration::from_secs(read_u64_env("DATABASE_MAX_LIFETIME_SECS", 1_800)?), + }) + } + + pub fn connect_pool(&self, database_url: &str) -> Result { + let pool = PgPoolOptions::new() + .max_connections(self.max_connections) + .min_connections(self.min_connections) + .acquire_timeout(self.acquire_timeout) + .idle_timeout(Some(self.idle_timeout)) + .max_lifetime(Some(self.max_lifetime)) + .test_before_acquire(true) + .connect(database_url); + + Ok(pool.await.context("failed to connect to PostgreSQL")?) + } +} + +#[derive(Clone, Debug)] +pub struct CorsConfig { + allowed_origins: Vec, +} + +impl CorsConfig { + pub fn from_env() -> Result { + let app_env = env::var("APP_ENV").unwrap_or_else(|_| "development".to_string()); + let raw = env::var("CORS_ALLOWED_ORIGINS").ok(); + + if raw.is_none() && app_env.eq_ignore_ascii_case("production") { + return Err(anyhow!( + "CORS_ALLOWED_ORIGINS must be set when APP_ENV=production" + )); + } + + let origins = match raw { + Some(raw) => parse_allowed_origins(&raw)?, + None => default_dev_origins(), + }; + + if origins.is_empty() { + return Err(anyhow!("at least one CORS origin must be configured")); + } + + Ok(Self { + allowed_origins: origins, + }) + } + + pub fn layer(&self) -> CorsLayer { + CorsLayer::new() + .allow_origin(AllowOrigin::list(self.allowed_origins.clone())) + .allow_methods(AllowMethods::list([ + Method::GET, + Method::POST, + Method::PUT, + Method::PATCH, + Method::DELETE, + Method::OPTIONS, + ])) + .allow_headers(AllowHeaders::list([ + header::ACCEPT, + header::AUTHORIZATION, + header::CONTENT_TYPE, + HeaderName::from_static("x-wallet-address"), + ])) + .allow_credentials(AllowCredentials::yes()) + .max_age(Duration::from_secs(600)) + } +} + +fn default_dev_origins() -> Vec { + [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:5173", + ] + .into_iter() + .map(|origin| HeaderValue::from_str(origin).expect("static origin")) + .collect() +} + +fn parse_allowed_origins(raw: &str) -> Result> { + raw.split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|origin| { + HeaderValue::from_str(origin).with_context(|| format!("invalid CORS origin: {origin}")) + }) + .collect() +} + +fn read_u32_env(name: &str, default: u32) -> Result { + match env::var(name) { + Ok(value) => value + .parse::() + .with_context(|| format!("invalid integer in {name}")), + Err(env::VarError::NotPresent) => Ok(default), + Err(err) => Err(err.into()), + } +} + +fn read_u64_env(name: &str, default: u64) -> Result { + match env::var(name) { + Ok(value) => value + .parse::() + .with_context(|| format!("invalid integer in {name}")), + Err(env::VarError::NotPresent) => Ok(default), + Err(err) => Err(err.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_allowed_origins_trims_and_filters_empty_values() { + let origins = parse_allowed_origins(" https://a.example , , https://b.example ") + .expect("origins should parse"); + assert_eq!(origins.len(), 2); + } + + #[test] + fn rejects_invalid_origin_values() { + let err = parse_allowed_origins("not a url").unwrap_err(); + assert!(err.to_string().contains("invalid CORS origin")); + } + + #[test] + fn defaults_to_dev_origins_when_unset() { + let origins = default_dev_origins(); + assert!(origins.len() >= 3); + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 7c2aeca9..5a0a7b47 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,10 +1,10 @@ use axum::Router; use dotenvy::dotenv; -use sqlx::postgres::PgPoolOptions; use std::net::SocketAddr; -use tower_http::{cors::CorsLayer, trace::TraceLayer}; +use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +mod config; mod db; mod error; mod models; @@ -27,16 +27,16 @@ async fn main() -> anyhow::Result<()> { let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - let pool = PgPoolOptions::new() - .max_connections(10) - .connect(&database_url) - .await?; + let pool_config = config::DatabasePoolConfig::from_env()?; + let cors_config = config::CorsConfig::from_env()?; + + let pool = pool_config.connect_pool(&database_url).await?; sqlx::migrate!("./migrations").run(&pool).await?; let state = AppState::new(pool.clone()); tokio::spawn(worker::run_judge_worker(pool)); - let app = build_router(state); + let app = build_router(state, cors_config); let port: u16 = std::env::var("PORT") .unwrap_or_else(|_| "3001".to_string()) @@ -49,10 +49,10 @@ async fn main() -> anyhow::Result<()> { Ok(()) } -fn build_router(state: AppState) -> Router { +fn build_router(state: AppState, cors_config: config::CorsConfig) -> Router { Router::new() .nest("/api", routes::api_router()) - .layer(CorsLayer::permissive()) + .layer(cors_config.layer()) .layer(TraceLayer::new_for_http()) .with_state(state) } diff --git a/backend/src/routes/appeals.rs b/backend/src/routes/appeals.rs index 65351907..afde3e95 100644 --- a/backend/src/routes/appeals.rs +++ b/backend/src/routes/appeals.rs @@ -22,6 +22,7 @@ pub fn router() -> Router { /// /// Creates an appeal for a dispute whose job budget exceeds the threshold /// (1000 USDC in stroops). Only resolved disputes can be appealed. +#[tracing::instrument(skip(state, req))] pub async fn create_appeal( State(state): State, Path(dispute_id): Path, @@ -99,6 +100,7 @@ pub async fn create_appeal( /// An arbiter casts their vote on an open appeal. /// When the quorum (3-of-5) is reached the appeal closes and overrides /// the original AI judge verdict. +#[tracing::instrument(skip(state, req))] async fn cast_vote( State(state): State, Path(appeal_id): Path, diff --git a/backend/src/routes/bids.rs b/backend/src/routes/bids.rs index d17c7014..182c65f0 100644 --- a/backend/src/routes/bids.rs +++ b/backend/src/routes/bids.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, Json, }; use uuid::Uuid; @@ -8,22 +8,32 @@ use crate::{ db::AppState, error::{AppError, Result}, models::{AcceptBidRequest, Bid, CreateBidRequest, Job}, + routes::pagination::PaginationQuery, }; +#[tracing::instrument(skip(state, pagination))] pub async fn list_bids( State(state): State, Path(job_id): Path, + Query(pagination): Query, ) -> Result>> { + let bounds = pagination.bounds(); let bids = sqlx::query_as::<_, Bid>( r#"SELECT id, job_id, freelancer_address, proposal, proposal_hash, status, created_at - FROM bids WHERE job_id = $1 ORDER BY created_at ASC"#, + FROM bids + WHERE job_id = $1 + ORDER BY created_at ASC, id ASC + LIMIT $2 OFFSET $3"#, ) .bind(job_id) + .bind(bounds.limit) + .bind(bounds.offset) .fetch_all(&state.pool) .await?; Ok(Json(bids)) } +#[tracing::instrument(skip(state, req))] pub async fn create_bid( State(state): State, Path(job_id): Path, @@ -58,6 +68,7 @@ pub async fn create_bid( Ok(Json(bid)) } +#[tracing::instrument(skip(state, req))] pub async fn accept_bid( State(state): State, Path((job_id, bid_id)): Path<(Uuid, Uuid)>, diff --git a/backend/src/routes/deliverables.rs b/backend/src/routes/deliverables.rs index c6e35c11..8bea4795 100644 --- a/backend/src/routes/deliverables.rs +++ b/backend/src/routes/deliverables.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, Json, }; use uuid::Uuid; @@ -8,25 +8,33 @@ use crate::{ db::AppState, error::{AppError, Result}, models::{Deliverable, SubmitDeliverableRequest}, + routes::pagination::PaginationQuery, }; +#[tracing::instrument(skip(state, pagination))] pub async fn list_deliverables( State(state): State, Path(job_id): Path, + Query(pagination): Query, ) -> Result>> { + let bounds = pagination.bounds(); let deliverables = sqlx::query_as::<_, Deliverable>( r#"SELECT id, job_id, milestone_index, submitted_by, label, kind, url, file_hash, created_at FROM deliverables WHERE job_id = $1 - ORDER BY milestone_index ASC, created_at DESC"#, + ORDER BY milestone_index ASC, created_at DESC, id DESC + LIMIT $2 OFFSET $3"#, ) .bind(job_id) + .bind(bounds.limit) + .bind(bounds.offset) .fetch_all(&state.pool) .await?; Ok(Json(deliverables)) } +#[tracing::instrument(skip(state, req))] pub async fn submit_deliverable( State(state): State, Path(job_id): Path, diff --git a/backend/src/routes/disputes.rs b/backend/src/routes/disputes.rs index c1f34f32..9fe4c8aa 100644 --- a/backend/src/routes/disputes.rs +++ b/backend/src/routes/disputes.rs @@ -24,6 +24,7 @@ pub fn router() -> Router { } /// Open a dispute from within the job routes (/jobs/:id/dispute) +#[tracing::instrument(skip(state, req))] pub async fn open_dispute_for_job( State(state): State, Path(job_id): Path, @@ -67,6 +68,7 @@ pub async fn open_dispute_for_job( Ok(Json(dispute)) } +#[tracing::instrument(skip(state))] async fn get_dispute( State(state): State, Path(dispute_id): Path, @@ -81,6 +83,7 @@ async fn get_dispute( Ok(Json(dispute)) } +#[tracing::instrument(skip(state))] pub async fn get_job_dispute( State(state): State, Path(job_id): Path, diff --git a/backend/src/routes/evidence.rs b/backend/src/routes/evidence.rs index e14da981..f0e4e787 100644 --- a/backend/src/routes/evidence.rs +++ b/backend/src/routes/evidence.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, Json, }; use uuid::Uuid; @@ -8,25 +8,33 @@ use crate::{ db::AppState, error::Result, models::{Evidence, SubmitEvidenceRequest}, + routes::pagination::PaginationQuery, }; +#[tracing::instrument(skip(state, pagination))] pub async fn list_evidence( State(state): State, Path(dispute_id): Path, + Query(pagination): Query, ) -> Result>> { + let bounds = pagination.bounds(); let evidence = sqlx::query_as::<_, Evidence>( r#"SELECT id, dispute_id, submitted_by, content, file_hash, created_at FROM evidence WHERE dispute_id = $1 - ORDER BY created_at ASC"#, + ORDER BY created_at ASC, id ASC + LIMIT $2 OFFSET $3"#, ) .bind(dispute_id) + .bind(bounds.limit) + .bind(bounds.offset) .fetch_all(&state.pool) .await?; Ok(Json(evidence)) } +#[tracing::instrument(skip(state, req))] pub async fn submit_evidence( State(state): State, Path(dispute_id): Path, diff --git a/backend/src/routes/health.rs b/backend/src/routes/health.rs index 15b922b0..5c577d1f 100644 --- a/backend/src/routes/health.rs +++ b/backend/src/routes/health.rs @@ -3,8 +3,12 @@ use serde_json::{json, Value}; use crate::db::AppState; +#[tracing::instrument(skip(state))] pub async fn health(State(state): State) -> (StatusCode, Json) { - match sqlx::query("SELECT 1").execute(&state.pool).await { + match sqlx::query_scalar::<_, i32>("SELECT 1") + .fetch_one(&state.pool) + .await + { Ok(_) => ( StatusCode::OK, Json(json!({ "status": "ok", "db": "connected" })), diff --git a/backend/src/routes/jobs.rs b/backend/src/routes/jobs.rs index 249348f0..f8b62f4e 100644 --- a/backend/src/routes/jobs.rs +++ b/backend/src/routes/jobs.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, routing::{get, post}, Json, Router, }; @@ -9,7 +9,7 @@ use crate::{ db::AppState, error::{AppError, Result}, models::{CreateJobRequest, Job, MarkJobFundedRequest}, - routes::{bids, deliverables, milestones}, + routes::{bids, deliverables, milestones, pagination::PaginationQuery}, }; pub fn router() -> Router { @@ -35,18 +35,27 @@ pub fn router() -> Router { ) } -async fn list_jobs(State(state): State) -> Result>> { +#[tracing::instrument(skip(state, pagination))] +async fn list_jobs( + State(state): State, + Query(pagination): Query, +) -> Result>> { + let bounds = pagination.bounds(); let jobs = sqlx::query_as::<_, Job>( r#"SELECT id, title, description, budget_usdc, milestones, client_address, freelancer_address, status, metadata_hash, on_chain_job_id, created_at, updated_at - FROM jobs ORDER BY created_at DESC"#, + FROM jobs ORDER BY created_at DESC, id DESC + LIMIT $1 OFFSET $2"#, ) + .bind(bounds.limit) + .bind(bounds.offset) .fetch_all(&state.pool) .await?; Ok(Json(jobs)) } +#[tracing::instrument(skip(state))] async fn get_job(State(state): State, Path(id): Path) -> Result> { let job = sqlx::query_as::<_, Job>( r#"SELECT id, title, description, budget_usdc, milestones, client_address, @@ -61,6 +70,7 @@ async fn get_job(State(state): State, Path(id): Path) -> Result< Ok(Json(job)) } +#[tracing::instrument(skip(state, req))] async fn create_job( State(state): State, Json(req): Json, @@ -120,6 +130,7 @@ async fn create_job( Ok(Json(job)) } +#[tracing::instrument(skip(state, req))] async fn mark_job_funded( State(state): State, Path(job_id): Path, diff --git a/backend/src/routes/milestones.rs b/backend/src/routes/milestones.rs index 60b53ebd..8522e69d 100644 --- a/backend/src/routes/milestones.rs +++ b/backend/src/routes/milestones.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, Json, }; use uuid::Uuid; @@ -8,25 +8,33 @@ use crate::{ db::AppState, error::{AppError, Result}, models::Milestone, + routes::pagination::PaginationQuery, }; +#[tracing::instrument(skip(state, pagination))] pub async fn list_milestones( State(state): State, Path(job_id): Path, + Query(pagination): Query, ) -> Result>> { + let bounds = pagination.bounds(); let milestones = sqlx::query_as::<_, Milestone>( r#"SELECT id, job_id, index, title, amount_usdc, status, tx_hash, released_at FROM milestones WHERE job_id = $1 - ORDER BY index ASC"#, + ORDER BY index ASC, id ASC + LIMIT $2 OFFSET $3"#, ) .bind(job_id) + .bind(bounds.limit) + .bind(bounds.offset) .fetch_all(&state.pool) .await?; Ok(Json(milestones)) } +#[tracing::instrument(skip(state))] pub async fn release_milestone( State(state): State, Path((job_id, milestone_id)): Path<(Uuid, Uuid)>, diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 03cc1045..a3760e4b 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -7,6 +7,7 @@ pub mod evidence; pub mod health; pub mod jobs; pub mod milestones; +pub mod pagination; pub mod uploads; pub mod users; pub mod verdicts; @@ -16,17 +17,18 @@ use axum::{routing::get, Router}; pub fn api_router() -> Router { Router::new() - // health check — outside versioned prefix so load balancers can reach it + // Legacy health alias for older clients and edge checks. .route("/health", get(health::health)) - // v1 API routes - .nest( - "/v1", - Router::new() - .nest("/jobs", jobs::router()) - .nest("/disputes", disputes::router()) - .nest("/appeals", appeals::router()) - .nest("/users", users::router()) - .nest("/auth", auth::router()) - .nest("/uploads", uploads::router()), - ) + .nest("/v1", v1_router()) +} + +fn v1_router() -> Router { + Router::new() + .route("/health", get(health::health)) + .nest("/jobs", jobs::router()) + .nest("/disputes", disputes::router()) + .nest("/appeals", appeals::router()) + .nest("/users", users::router()) + .nest("/auth", auth::router()) + .nest("/uploads", uploads::router()) } diff --git a/backend/src/routes/pagination.rs b/backend/src/routes/pagination.rs new file mode 100644 index 00000000..7fc7daa3 --- /dev/null +++ b/backend/src/routes/pagination.rs @@ -0,0 +1,57 @@ +use serde::Deserialize; + +#[derive(Clone, Copy, Debug, Deserialize)] +pub struct PaginationQuery { + pub limit: Option, + pub offset: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PaginationBounds { + pub limit: i64, + pub offset: i64, +} + +impl PaginationQuery { + const DEFAULT_LIMIT: u32 = 25; + const MAX_LIMIT: u32 = 100; + + pub fn bounds(self) -> PaginationBounds { + let limit = self + .limit + .unwrap_or(Self::DEFAULT_LIMIT) + .clamp(1, Self::MAX_LIMIT) as i64; + let offset = self.offset.unwrap_or(0) as i64; + + PaginationBounds { limit, offset } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bounds_apply_defaults() { + let bounds = PaginationQuery { + limit: None, + offset: None, + } + .bounds(); + + assert_eq!(bounds.limit, 25); + assert_eq!(bounds.offset, 0); + } + + #[test] + fn bounds_clamp_limit_to_maximum() { + let bounds = PaginationQuery { + limit: Some(1_000), + offset: Some(42), + } + .bounds(); + + assert_eq!(bounds.limit, 100); + assert_eq!(bounds.offset, 42); + } +} diff --git a/backend/src/routes/users.rs b/backend/src/routes/users.rs index f5be1a8a..a2be6d0c 100644 --- a/backend/src/routes/users.rs +++ b/backend/src/routes/users.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::HeaderMap, routing::get, Json, Router, @@ -13,6 +13,7 @@ use crate::{ ProfileJobLedgerEntry, ProfileMetrics, PublicProfile, UpdateProfileRequest, UserProfileRecord, }, + routes::pagination::PaginationQuery, }; pub fn router() -> Router { @@ -21,18 +22,27 @@ pub fn router() -> Router { .route("/:address/profile", get(get_profile).put(upsert_profile)) } -async fn list_users(State(state): State) -> Result>> { +#[tracing::instrument(skip(state, pagination))] +async fn list_users( + State(state): State, + Query(pagination): Query, +) -> Result>> { + let bounds = pagination.bounds(); let users = sqlx::query_scalar::<_, String>( r#"SELECT DISTINCT address FROM profiles - ORDER BY address ASC"#, + ORDER BY address ASC + LIMIT $1 OFFSET $2"#, ) + .bind(bounds.limit) + .bind(bounds.offset) .fetch_all(&state.pool) .await?; Ok(Json(users)) } +#[tracing::instrument(skip(state))] async fn get_profile( State(state): State, Path(address): Path, @@ -145,6 +155,7 @@ async fn get_profile( Ok(Json(response)) } +#[tracing::instrument(skip(state, headers, req))] async fn upsert_profile( State(state): State, Path(address): Path, diff --git a/backend/src/routes/verdicts.rs b/backend/src/routes/verdicts.rs index 22a15526..0563fdeb 100644 --- a/backend/src/routes/verdicts.rs +++ b/backend/src/routes/verdicts.rs @@ -10,6 +10,7 @@ use crate::{ models::Verdict, }; +#[tracing::instrument(skip(state))] pub async fn get_verdict( State(state): State, Path(dispute_id): Path, From d41a5d11568bcc5509c32ae64fe5e86f30d24233 Mon Sep 17 00:00:00 2001 From: Henry Ebubechukwu Date: Sun, 31 May 2026 17:23:00 +0100 Subject: [PATCH 4/4] fix: trim migration trailing blank line --- backend/migrations/20260528000001_production_bounds.sql | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/migrations/20260528000001_production_bounds.sql b/backend/migrations/20260528000001_production_bounds.sql index 182c58f0..768608a2 100644 --- a/backend/migrations/20260528000001_production_bounds.sql +++ b/backend/migrations/20260528000001_production_bounds.sql @@ -33,4 +33,3 @@ CREATE INDEX IF NOT EXISTS appeals_status_created_idx CREATE INDEX IF NOT EXISTS arbiter_votes_appeal_created_idx ON arbiter_votes (appeal_id, created_at ASC, id ASC); -