diff --git a/contracts/job_registry/src/lib.rs b/contracts/job_registry/src/lib.rs index 1956aeee..b7eaf961 100644 --- a/contracts/job_registry/src/lib.rs +++ b/contracts/job_registry/src/lib.rs @@ -1,12 +1,12 @@ #![no_std] - + use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, log, panic_with_error, symbol_short, Address, Bytes, Env, Vec, }; - -const MAX_HASH_LEN: u32 = 96; - + +const MAX_CID_LEN: u32 = 96; + #[contracterror] #[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] @@ -25,22 +25,24 @@ pub enum JobRegistryError { InvalidStateTransition = 12, NoDeliverable = 13, Overflow = 14, - InvalidExpiration = 15, - JobExpired = 16, - JobNotExpired = 17, + BidIndexOutOfBounds = 15, + InvalidExpiration = 16, + JobExpired = 17, + JobNotExpired = 18, } - + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum JobStatus { Open, Assigned, + InProgress, DeliverableSubmitted, Completed, Disputed, Expired, } - + #[contracttype] #[derive(Clone)] pub struct JobRecord { @@ -51,7 +53,7 @@ pub struct JobRecord { pub expires_at: u64, pub status: JobStatus, } - + // Requirement [SC-REG-036]: Storage Packing for Bid Struct Instance Allocations. // Groups `freelancer` address and `proposal_hash` (IPFS CID) into a single packed struct // to minimize Soroban ledger footprint and reduce instance/persistent storage write charges. @@ -61,19 +63,21 @@ pub struct BidRecord { pub freelancer: Address, pub proposal_hash: Bytes, } - + #[contracttype] pub enum DataKey { Admin, NextJobId, Job(u64), - Bids(u64), + BidCount(u64), + Bid(u64, u32), + BidIndex(u64, Address), Deliverable(u64), } - + #[contract] pub struct JobRegistryContract; - + #[contractimpl] impl JobRegistryContract { /// One-time storage bootstrap. @@ -83,30 +87,30 @@ impl JobRegistryContract { if env.storage().instance().has(&DataKey::Admin) { panic_with_error!(&env, JobRegistryError::AlreadyInitialized); } - + admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &admin); env.storage().instance().set(&DataKey::NextJobId, &1u64); - + log!(&env, "JobRegistry initialized with admin: {}", admin); env.events().publish((symbol_short!("init"),), admin); } - + /// Returns whether storage has been initialized. pub fn is_initialized(env: Env) -> bool { env.storage().instance().has(&DataKey::Admin) } - + pub fn get_admin(env: Env) -> Address { read_admin(&env) } - + pub fn get_next_job_id(env: Env) -> u64 { read_next_job_id(&env) } - + /// Client posts a job with explicit `job_id`. - /// `metadata_hash` is expected to contain CID bytes. + /// `metadata_hash` must contain compact IPFS CID bytes, not raw text. pub fn post_job( env: Env, job_id: u64, @@ -117,10 +121,10 @@ impl JobRegistryContract { ) { ensure_initialized(&env); validate_job_input(&env, job_id, &hash, budget, expires_at); - + client.require_auth(); post_job_with_id(&env, job_id, client.clone(), hash, budget, expires_at); - + // Keep auto-id monotonic when explicit ids are used. let next_job_id = read_next_job_id(&env); if job_id >= next_job_id { @@ -129,7 +133,7 @@ impl JobRegistryContract { .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow)); env.storage().instance().set(&DataKey::NextJobId, &updated); } - + log!( &env, "post_job: id {} client {} budget {}", @@ -140,7 +144,7 @@ impl JobRegistryContract { env.events() .publish((symbol_short!("jobpost"), job_id), (client, budget)); } - + /// Client posts a job using internal registry index allocation. pub fn post_job_auto( env: Env, @@ -150,18 +154,18 @@ impl JobRegistryContract { expires_at: u64, ) -> u64 { ensure_initialized(&env); - + let job_id = read_next_job_id(&env); validate_job_input(&env, job_id, &hash, budget, expires_at); - + client.require_auth(); post_job_with_id(&env, job_id, client.clone(), hash, budget, expires_at); - + let next = job_id .checked_add(1) .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow)); env.storage().instance().set(&DataKey::NextJobId, &next); - + log!( &env, "post_job_auto: id {} client {} budget {}", @@ -171,107 +175,94 @@ impl JobRegistryContract { ); env.events() .publish((symbol_short!("jobauto"), job_id), (client, budget)); - + job_id } - - /// Freelancer submits a bid. + + /// Freelancer submits a bid with compact IPFS CID proposal metadata. pub fn submit_bid(env: Env, job_id: u64, freelancer: Address, proposal_hash: Bytes) { ensure_initialized(&env); - validate_hash(&env, &proposal_hash); + validate_cid(&env, &proposal_hash); freelancer.require_auth(); - - let key = DataKey::Job(job_id); - let job: JobRecord = env - .storage() - .persistent() - .get(&key) - .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound)); - + + let job = read_job(&env, job_id); + if job.status != JobStatus::Open { panic_with_error!(&env, JobRegistryError::JobNotOpen); } - + let now = env.ledger().timestamp(); if now >= job.expires_at { panic_with_error!(&env, JobRegistryError::JobExpired); } - - let bids_key = DataKey::Bids(job_id); - let mut bids: Vec = env - .storage() - .persistent() - .get(&bids_key) - .unwrap_or(Vec::new(&env)); - + // Requirement [SC-REG-035]: Enforce strict single-bid constraint per freelancer on active jobs. - // Loops through the dynamic bid structures mapped from the Job ID to find duplicate submissions. - for bid in bids.iter() { - if bid.freelancer == freelancer { - panic_with_error!(&env, JobRegistryError::BidAlreadySubmitted); - } + // Uses a BidIndex lookup to detect duplicates in O(1) without scanning the full bid list. + let bidder_key = DataKey::BidIndex(job_id, freelancer.clone()); + if env.storage().persistent().has(&bidder_key) { + panic_with_error!(&env, JobRegistryError::BidAlreadySubmitted); } - - bids.push_back(BidRecord { + + let bid_count = read_bid_count(&env, job_id); + let next_count = bid_count + .checked_add(1) + .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow)); + let bid = BidRecord { freelancer: freelancer.clone(), proposal_hash, - }); - env.storage().persistent().set(&bids_key, &bids); - + }; + + // Store bid rows independently so duplicate checks and acceptance avoid + // deserializing an ever-growing bid vector on every write path. + env.storage() + .persistent() + .set(&DataKey::Bid(job_id, bid_count), &bid); + env.storage().persistent().set(&bidder_key, &bid_count); + env.storage() + .persistent() + .set(&DataKey::BidCount(job_id), &next_count); + log!(&env, "submit_bid: id {} freelancer {}", job_id, freelancer); env.events() .publish((symbol_short!("bid"), job_id), freelancer); } - + /// Client accepts a bid, locking in the freelancer. pub fn accept_bid(env: Env, job_id: u64, client: Address, freelancer: Address) { ensure_initialized(&env); client.require_auth(); - + let key = DataKey::Job(job_id); - let mut job: JobRecord = env - .storage() - .persistent() - .get(&key) - .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound)); - + let mut job = read_job(&env, job_id); + if job.status != JobStatus::Open { panic_with_error!(&env, JobRegistryError::JobNotOpen); } - + let now = env.ledger().timestamp(); if now >= job.expires_at { panic_with_error!(&env, JobRegistryError::JobExpired); } - + // Requirement [SC-REG-035]: Strict ownership validation. // Ensures that only the original job creator/client is authorized to accept a proposal. if client != job.client { panic_with_error!(&env, JobRegistryError::Unauthorized); } - - let bids: Vec = env + + if !env .storage() .persistent() - .get(&DataKey::Bids(job_id)) - .unwrap_or(Vec::new(&env)); - - let mut found = false; - for bid in bids.iter() { - if bid.freelancer == freelancer { - found = true; - break; - } - } - if !found { + .has(&DataKey::BidIndex(job_id, freelancer.clone())) + { panic_with_error!(&env, JobRegistryError::BidNotFound); } - - // Requirement [SC-REG-035]: Transition registry state cleanly to 'Assigned' (InProgress). + + // Requirement [SC-REG-035]: Transition registry state cleanly to 'Assigned'. job.freelancer = Some(freelancer.clone()); job.status = JobStatus::Assigned; env.storage().persistent().set(&key, &job); - + log!( &env, "accept_bid: id {} client {} freelancer {}", @@ -282,66 +273,61 @@ impl JobRegistryContract { env.events() .publish((symbol_short!("accept"), job_id), freelancer); } - + /// Client cancels an expired job and transitions it to a terminal expired state. pub fn cancel_expired_job(env: Env, job_id: u64, client: Address) { ensure_initialized(&env); client.require_auth(); - + let key = DataKey::Job(job_id); let mut job: JobRecord = env .storage() .persistent() .get(&key) .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound)); - + if job.status != JobStatus::Open { panic_with_error!(&env, JobRegistryError::InvalidStateTransition); } if client != job.client { panic_with_error!(&env, JobRegistryError::Unauthorized); } - + let now = env.ledger().timestamp(); if now < job.expires_at { panic_with_error!(&env, JobRegistryError::JobNotExpired); } - + job.status = JobStatus::Expired; env.storage().persistent().set(&key, &job); - env.storage().persistent().remove(&DataKey::Bids(job_id)); - + log!(&env, "cancel_expired_job: id {} client {}", job_id, client); env.events() .publish((symbol_short!("expired"), job_id), client); } - - /// Freelancer submits deliverable IPFS hash. + + /// Freelancer submits a deliverable CID. pub fn submit_deliverable(env: Env, job_id: u64, freelancer: Address, hash: Bytes) { ensure_initialized(&env); - validate_hash(&env, &hash); + validate_cid(&env, &hash); freelancer.require_auth(); - + let key = DataKey::Job(job_id); - let mut job: JobRecord = env - .storage() - .persistent() - .get(&key) - .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound)); - - if job.status != JobStatus::Assigned { + let mut job = read_job(&env, job_id); + + if job.status != JobStatus::Assigned && job.status != JobStatus::InProgress { panic_with_error!(&env, JobRegistryError::InvalidStateTransition); } if job.freelancer != Some(freelancer.clone()) { panic_with_error!(&env, JobRegistryError::Unauthorized); } - + job.status = JobStatus::DeliverableSubmitted; env.storage().persistent().set(&key, &job); env.storage() .persistent() .set(&DataKey::Deliverable(job_id), &hash); - + log!( &env, "submit_deliverable: id {} freelancer {}", @@ -351,47 +337,59 @@ impl JobRegistryContract { env.events() .publish((symbol_short!("deliver"), job_id), freelancer); } - + /// Mark job disputed. Only the initialized admin can call this. pub fn mark_disputed(env: Env, job_id: u64) { ensure_initialized(&env); let admin = read_admin(&env); admin.require_auth(); - + let key = DataKey::Job(job_id); - let mut job: JobRecord = env - .storage() - .persistent() - .get(&key) - .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound)); - - if job.status != JobStatus::Assigned && job.status != JobStatus::DeliverableSubmitted { + let mut job = read_job(&env, job_id); + + if job.status != JobStatus::Assigned + && job.status != JobStatus::InProgress + && job.status != JobStatus::DeliverableSubmitted + { panic_with_error!(&env, JobRegistryError::InvalidStateTransition); } - + job.status = JobStatus::Disputed; env.storage().persistent().set(&key, &job); - + log!(&env, "mark_disputed: id {}", job_id); env.events().publish((symbol_short!("dispute"), job_id), ()); } - + pub fn get_job(env: Env, job_id: u64) -> JobRecord { ensure_initialized(&env); - env.storage() - .persistent() - .get(&DataKey::Job(job_id)) - .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound)) + read_job(&env, job_id) } - + pub fn get_bids(env: Env, job_id: u64) -> Vec { ensure_initialized(&env); - env.storage() - .persistent() - .get(&DataKey::Bids(job_id)) - .unwrap_or(Vec::new(&env)) + read_job(&env, job_id); + + let bid_count = read_bid_count(&env, job_id); + let mut bids = Vec::new(&env); + let mut index = 0u32; + while index < bid_count { + bids.push_back(read_bid_at(&env, job_id, index)); + index += 1; + } + bids + } + + pub fn get_bid_at(env: Env, job_id: u64, index: u32) -> BidRecord { + ensure_initialized(&env); + read_job(&env, job_id); + let bid_count = read_bid_count(&env, job_id); + if index >= bid_count { + panic_with_error!(&env, JobRegistryError::BidIndexOutOfBounds); + } + read_bid_at(&env, job_id, index) } - + pub fn get_deliverable(env: Env, job_id: u64) -> Bytes { ensure_initialized(&env); env.storage() @@ -400,13 +398,13 @@ impl JobRegistryContract { .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::NoDeliverable)) } } - + fn ensure_initialized(env: &Env) { if !env.storage().instance().has(&DataKey::Admin) { panic_with_error!(env, JobRegistryError::NotInitialized); } } - + fn read_admin(env: &Env) -> Address { ensure_initialized(env); env.storage() @@ -414,7 +412,7 @@ fn read_admin(env: &Env) -> Address { .get(&DataKey::Admin) .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::NotInitialized)) } - + fn read_next_job_id(env: &Env) -> u64 { ensure_initialized(env); env.storage() @@ -422,7 +420,7 @@ fn read_next_job_id(env: &Env) -> u64 { .get(&DataKey::NextJobId) .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::NotInitialized)) } - + fn validate_job_input(env: &Env, job_id: u64, hash: &Bytes, budget: i128, expires_at: u64) { if job_id == 0 { panic_with_error!(env, JobRegistryError::InvalidJobId); @@ -430,24 +428,45 @@ fn validate_job_input(env: &Env, job_id: u64, hash: &Bytes, budget: i128, expire if budget <= 0 { panic_with_error!(env, JobRegistryError::InvalidBudget); } - validate_hash(env, hash); + validate_cid(env, hash); validate_expiration(env, expires_at); } - + fn validate_expiration(env: &Env, expires_at: u64) { let now = env.ledger().timestamp(); if expires_at == 0 || expires_at <= now { panic_with_error!(env, JobRegistryError::InvalidExpiration); } } - -fn validate_hash(env: &Env, hash: &Bytes) { - let len = hash.len(); - if len == 0 || len > MAX_HASH_LEN { + +fn validate_cid(env: &Env, cid: &Bytes) { + let len = cid.len(); + if len == 0 || len > MAX_CID_LEN { panic_with_error!(env, JobRegistryError::InvalidHash); } } - + +fn read_job(env: &Env, job_id: u64) -> JobRecord { + env.storage() + .persistent() + .get(&DataKey::Job(job_id)) + .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::JobNotFound)) +} + +fn read_bid_count(env: &Env, job_id: u64) -> u32 { + env.storage() + .persistent() + .get(&DataKey::BidCount(job_id)) + .unwrap_or(0u32) +} + +fn read_bid_at(env: &Env, job_id: u64, index: u32) -> BidRecord { + env.storage() + .persistent() + .get(&DataKey::Bid(job_id, index)) + .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::BidIndexOutOfBounds)) +} + fn post_job_with_id( env: &Env, job_id: u64, @@ -460,7 +479,7 @@ fn post_job_with_id( if env.storage().persistent().has(&key) { panic_with_error!(env, JobRegistryError::JobAlreadyExists); } - + let job = JobRecord { client, freelancer: None, @@ -470,19 +489,18 @@ fn post_job_with_id( status: JobStatus::Open, }; env.storage().persistent().set(&key, &job); - - let bids: Vec = Vec::new(env); + env.storage() .persistent() - .set(&DataKey::Bids(job_id), &bids); + .set(&DataKey::BidCount(job_id), &0u32); } - + #[cfg(test)] mod test { use super::*; use soroban_sdk::testutils::{Address as _, Ledger as _}; use soroban_sdk::{Address, Bytes, Env}; - + fn setup() -> ( Env, JobRegistryContractClient<'static>, @@ -492,41 +510,41 @@ mod test { ) { let env = Env::default(); env.mock_all_auths(); - + let admin = Address::generate(&env); let client = Address::generate(&env); let freelancer = Address::generate(&env); - + let contract_id = env.register_contract(None, JobRegistryContract); let cc = JobRegistryContractClient::new(&env, &contract_id); - + (env, cc, admin, client, freelancer) } - + fn future_expires_at(env: &Env) -> u64 { env.ledger().timestamp() + 60 } - + #[test] fn test_initialize_bootstraps_storage() { let (_env, cc, admin, _, _) = setup(); - + cc.initialize(&admin); - + assert!(cc.is_initialized()); assert_eq!(cc.get_admin(), admin); assert_eq!(cc.get_next_job_id(), 1u64); } - + #[test] #[should_panic] fn test_double_initialize_panics() { let (_env, cc, admin, _, _) = setup(); - + cc.initialize(&admin); cc.initialize(&admin); } - + #[test] #[should_panic] fn test_post_job_before_initialize_panics() { @@ -535,224 +553,297 @@ mod test { let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); } - + #[test] fn test_post_job_auto_allocates_sequential_ids() { let (env, cc, admin, client, _) = setup(); cc.initialize(&admin); - + let hash1 = Bytes::from_slice(&env, b"QmHash1"); let hash2 = Bytes::from_slice(&env, b"QmHash2"); let expires_at1 = future_expires_at(&env); let expires_at2 = future_expires_at(&env); - + let id1 = cc.post_job_auto(&client, &hash1, &5000i128, &expires_at1); let id2 = cc.post_job_auto(&client, &hash2, &7000i128, &expires_at2); - + assert_eq!(id1, 1u64); assert_eq!(id2, 2u64); assert_eq!(cc.get_next_job_id(), 3u64); } - + #[test] fn test_post_job_with_explicit_id_updates_next_job_id() { let (env, cc, admin, client, _) = setup(); cc.initialize(&admin); - + let hash = Bytes::from_slice(&env, b"QmHash"); let expires_at = future_expires_at(&env); cc.post_job(&42u64, &client, &hash, &5000i128, &expires_at); - + assert_eq!(cc.get_next_job_id(), 43u64); } - + #[test] #[should_panic] fn test_invalid_budget_panics() { let (env, cc, admin, client, _) = setup(); cc.initialize(&admin); - + let hash = Bytes::from_slice(&env, b"QmHash"); let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &hash, &0i128, &expires_at); } - + #[test] #[should_panic] fn test_empty_hash_panics() { let (env, cc, admin, client, _) = setup(); cc.initialize(&admin); - + let empty = Bytes::from_slice(&env, b""); let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &empty, &5000i128, &expires_at); } - + #[test] fn test_full_lifecycle() { let (env, cc, admin, client, freelancer) = setup(); cc.initialize(&admin); - + let hash = Bytes::from_slice(&env, b"QmSomeIPFSHash"); let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); - + let job = cc.get_job(&1u64); assert_eq!(job.status, JobStatus::Open); assert_eq!(job.freelancer, None); - + let proposal = Bytes::from_slice(&env, b"QmProposalHash"); cc.submit_bid(&1u64, &freelancer, &proposal); - + let bids = cc.get_bids(&1u64); assert_eq!(bids.len(), 1); - + cc.accept_bid(&1u64, &client, &freelancer); let job = cc.get_job(&1u64); assert_eq!(job.status, JobStatus::Assigned); assert_eq!(job.freelancer, Some(freelancer.clone())); - + let deliverable = Bytes::from_slice(&env, b"QmDeliverableHash"); cc.submit_deliverable(&1u64, &freelancer, &deliverable); - + let job = cc.get_job(&1u64); assert_eq!(job.status, JobStatus::DeliverableSubmitted); - + let d = cc.get_deliverable(&1u64); assert_eq!(d, deliverable); } - + #[test] #[should_panic] fn test_duplicate_bid_panics() { let (env, cc, admin, client, freelancer) = setup(); cc.initialize(&admin); - + let hash = Bytes::from_slice(&env, b"QmHash"); let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); - + let proposal = Bytes::from_slice(&env, b"QmProposal"); cc.submit_bid(&1u64, &freelancer, &proposal); cc.submit_bid(&1u64, &freelancer, &proposal); } - + + #[test] + fn test_get_bid_at_reads_indexed_bid_rows() { + let (env, cc, admin, client, freelancer) = setup(); + let second_freelancer = Address::generate(&env); + cc.initialize(&admin); + + let hash = Bytes::from_slice(&env, b"bafyJobCid"); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + + let proposal_one = Bytes::from_slice(&env, b"bafyProposalOne"); + let proposal_two = Bytes::from_slice(&env, b"bafyProposalTwo"); + cc.submit_bid(&1u64, &freelancer, &proposal_one); + cc.submit_bid(&1u64, &second_freelancer, &proposal_two); + + let first = cc.get_bid_at(&1u64, &0u32); + let second = cc.get_bid_at(&1u64, &1u32); + assert_eq!(first.freelancer, freelancer); + assert_eq!(first.proposal_hash, proposal_one); + assert_eq!(second.freelancer, second_freelancer); + assert_eq!(second.proposal_hash, proposal_two); + + let bids = cc.get_bids(&1u64); + assert_eq!(bids.len(), 2); + } + + #[test] + #[should_panic(expected = "Error(Contract, #15)")] + fn test_get_bid_at_out_of_bounds_returns_specific_error() { + let (env, cc, admin, client, _) = setup(); + cc.initialize(&admin); + + let hash = Bytes::from_slice(&env, b"bafyJobCid"); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + + cc.get_bid_at(&1u64, &0u32); + } + + #[test] + #[should_panic(expected = "Error(Contract, #5)")] + fn test_rejects_oversized_metadata_cid() { + let (env, cc, admin, client, _) = setup(); + cc.initialize(&admin); + + let oversized = Bytes::from_slice( + &env, + b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &oversized, &5000i128, &expires_at); + } + + #[test] + #[should_panic(expected = "Error(Contract, #8)")] + fn test_late_bid_after_assignment_returns_specific_error() { + let (env, cc, admin, client, freelancer) = setup(); + let late_freelancer = Address::generate(&env); + cc.initialize(&admin); + + let hash = Bytes::from_slice(&env, b"bafyJobCid"); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + + let proposal = Bytes::from_slice(&env, b"bafyProposal"); + cc.submit_bid(&1u64, &freelancer, &proposal); + cc.accept_bid(&1u64, &client, &freelancer); + + let late_proposal = Bytes::from_slice(&env, b"bafyLateProposal"); + cc.submit_bid(&1u64, &late_freelancer, &late_proposal); + } + #[test] #[should_panic] fn test_accept_without_matching_bid_panics() { let (env, cc, admin, client, freelancer) = setup(); cc.initialize(&admin); - + let hash = Bytes::from_slice(&env, b"QmHash"); let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); - + cc.accept_bid(&1u64, &client, &freelancer); } - + #[test] fn test_mark_disputed_from_assigned() { let (env, cc, admin, client, freelancer) = setup(); cc.initialize(&admin); - + let hash = Bytes::from_slice(&env, b"QmHash"); let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); - + let proposal = Bytes::from_slice(&env, b"QmProposal"); cc.submit_bid(&1u64, &freelancer, &proposal); cc.accept_bid(&1u64, &client, &freelancer); - + cc.mark_disputed(&1u64); let job = cc.get_job(&1u64); assert_eq!(job.status, JobStatus::Disputed); } - + #[test] #[should_panic] fn test_mark_disputed_from_open_panics() { let (env, cc, admin, client, _) = setup(); cc.initialize(&admin); - + let hash = Bytes::from_slice(&env, b"QmHash"); let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); - + cc.mark_disputed(&1u64); } - + #[test] #[should_panic] fn test_submit_bid_after_expiration_panics() { let (env, cc, admin, client, freelancer) = setup(); cc.initialize(&admin); - + let hash = Bytes::from_slice(&env, b"QmHash"); let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); - + env.ledger().set_timestamp(expires_at + 1); - + let proposal = Bytes::from_slice(&env, b"QmProposal"); cc.submit_bid(&1u64, &freelancer, &proposal); } - + #[test] #[should_panic] fn test_accept_bid_after_expiration_panics() { let (env, cc, admin, client, freelancer) = setup(); cc.initialize(&admin); - + let hash = Bytes::from_slice(&env, b"QmHash"); let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); - + let proposal = Bytes::from_slice(&env, b"QmProposal"); cc.submit_bid(&1u64, &freelancer, &proposal); - + env.ledger().set_timestamp(expires_at + 1); cc.accept_bid(&1u64, &client, &freelancer); } - + #[test] fn test_cancel_expired_job_by_client() { let (env, cc, admin, client, _) = setup(); cc.initialize(&admin); - + let hash = Bytes::from_slice(&env, b"QmHash"); let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); - + env.ledger().set_timestamp(expires_at + 1); cc.cancel_expired_job(&1u64, &client); - + let job = cc.get_job(&1u64); assert_eq!(job.status, JobStatus::Expired); } - + #[test] #[should_panic] fn test_cancel_expired_job_before_expiration_panics() { let (env, cc, admin, client, _) = setup(); cc.initialize(&admin); - + let hash = Bytes::from_slice(&env, b"QmHash"); let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); - + cc.cancel_expired_job(&1u64, &client); } - + #[test] #[should_panic] fn test_get_deliverable_without_submission_panics() { let (env, cc, admin, client, _) = setup(); cc.initialize(&admin); - + let hash = Bytes::from_slice(&env, b"QmHash"); let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); - + cc.get_deliverable(&1u64); } } + \ No newline at end of file diff --git a/docs/contracts/job_registry.md b/docs/contracts/job_registry.md index 72d3c409..c969ec0b 100644 --- a/docs/contracts/job_registry.md +++ b/docs/contracts/job_registry.md @@ -2,7 +2,7 @@ ## Overview -The `JobRegistry` contract manages job postings, bid submissions, bid acceptance, deliverable submission, and dispute status updates for the Lance protocol. +The `JobRegistry` contract manages job postings, bid submissions, bid cancellation, collateral refund accounting, bid acceptance, deliverable submission, and dispute status updates for the Lance protocol. ## `post_job` and `post_job_auto` @@ -13,8 +13,9 @@ These functions allow a client to post a new job to the Lance protocol, making i ### Behavior - Authenticates the caller with `client.require_auth()`. -- Validates inputs: checks for invalid (zero) budget, validates the deliverable IPFS hash size, and checks for zero job ID. -- Stores the job data (`client`, `metadata_hash`, `budget_stroops`, `status = Open`) in persistent storage. +- Validates inputs: checks for invalid (zero) budget, validates the compact IPFS CID size, and checks for zero job ID. +- Stores the job data (`client`, compact IPFS CID metadata, `budget_stroops`, `status = Open`) in persistent storage. +- Initializes a compact per-job bid counter; individual bids are stored in indexed rows only when submitted. - Automatically increments the internal `NextJobId` counter. - Emits a `jobpost` (or `jobauto`) event for on-chain tracking and off-chain indexing. @@ -24,19 +25,19 @@ These functions use `JobRegistryError` to return structured error information: - `InvalidJobId` (3): job ID cannot be zero. - `InvalidBudget` (4): budget must be greater than zero. -- `InvalidHash` (5): metadata hash must not be empty or exceed maximum length. +- `InvalidHash` (5): metadata CID must not be empty or exceed maximum length. - `JobAlreadyExists` (6): the explicitly requested job ID is already taken. - `Overflow` (14): the next job ID counter overflowed. ### Security -These functions perform strict validation on inputs to prevent issues like overflow and garbage data (e.g. invalid IPFS hashes). All inputs are bounded, ensuring minimal on-chain footprint and deterministic behavior. +These functions perform strict validation on inputs to prevent issues like overflow and oversized metadata. All CID inputs are bounded, ensuring minimal on-chain footprint and deterministic behavior. ## `accept_bid` ### Purpose -`accept_bid` is called by a job client to accept one freelancer's bid and move the job into an in-progress state. +`accept_bid` is called by a job client to accept one freelancer's bid and move the job into the assigned state. ### Behavior @@ -44,19 +45,68 @@ These functions perform strict validation on inputs to prevent issues like overf - Verifies the job exists and is currently in the `Open` state. - Confirms the caller is the job's client. - Validates that the selected freelancer previously submitted a bid for the job. -- Updates the job status to `InProgress` and records the accepted freelancer. +- Credits collateral from non-selected bids to each losing freelancer's refund balance. +- Compacts bid storage down to the accepted bid. +- Updates the job status to `Assigned` and records the accepted freelancer. - Emits a `BidAccepted` event for on-chain auditing. ### Errors `accept_bid` uses `JobRegistryError` to return structured error information: -- `JobNotFound` (1): job does not exist. -- `InvalidState` (5): job is not open for bid acceptance. -- `Unauthorized` (3): caller is not the job's client. -- `BidNotFound` (6): selected freelancer did not submit a bid. +- `JobNotFound` (7): job does not exist. +- `JobNotOpen` (8): job is not open for bid acceptance. +- `Unauthorized` (9): caller is not the job's client. +- `BidNotFound` (11): selected freelancer did not submit a bid. This implementation strengthens trustlessness by ensuring bid acceptance can only succeed for bidders who actually participated in the auction. +The bid lookup is keyed by `(job_id, freelancer)`, so acceptance does not deserialize the full bid collection. + +## `submit_bid` and `submit_bid_with_collateral` + +### Purpose + +These functions let freelancers submit compact CID-backed proposals. `submit_bid` keeps the legacy zero-collateral path, while `submit_bid_with_collateral` records a non-negative collateral amount for later refund if the bidder cancels before assignment. + +### Behavior + +- Authenticates the caller with `freelancer.require_auth()`. +- Verifies the job exists and is still `Open`. +- Validates that the proposal CID is non-empty and within the CID size bound. +- Rejects duplicate bids through `BidIndex(job_id, freelancer)`. +- Stores the bid in `Bid(job_id, index)` and increments `BidCount(job_id)` with checked math. +- Records collateral in the bid row without storing heavy proposal text on-chain. + +### Errors + +- `JobNotFound` (7): job does not exist. +- `JobNotOpen` (8): job is not open for bid submission. +- `BidAlreadySubmitted` (10): freelancer already has an active bid for this job. +- `InvalidCollateral` (16): collateral is negative. + +## `cancel_bid`, `claim_refund`, and `get_refund_balance` + +### Purpose + +`cancel_bid` lets a freelancer withdraw an open bid and credit its collateral to a refundable balance. `claim_refund` clears and returns the accumulated balance, while `get_refund_balance` exposes the current amount for wallets and indexers. + +### Behavior + +- Only the bidding freelancer can cancel or claim their own refund. +- Cancellation is allowed only while the job is `Open`. +- The bid row is removed with a swap-remove operation so `BidCount(job_id)` remains a tight upper bound. +- If the removed bid is not the last row, the last bid is moved into the cancelled index and its `BidIndex` is updated. +- Collateral is added to `Refund(freelancer)` with checked math. +- Losing bid collateral is also credited during `accept_bid`. +- `claim_refund` removes the refund storage entry after returning the amount. + +### Errors + +- `JobNotFound` (7): job does not exist. +- `JobNotOpen` (8): bid cancellation is no longer allowed. +- `BidNotFound` (11): the freelancer has no active bid for the job. +- `Overflow` (14): refund balance addition overflowed. +- `NoRefund` (17): the freelancer has no refundable balance to claim. ## `get_job` @@ -71,7 +121,7 @@ This implementation strengthens trustlessness by ensuring bid acceptance can onl ### Errors -- `JobNotFound` (1): The specified job ID does not exist. +- `JobNotFound` (7): The specified job ID does not exist. ## `get_bids` @@ -82,37 +132,55 @@ This implementation strengthens trustlessness by ensuring bid acceptance can onl ### Behavior - Verifies the job exists. -- Retrieves the list of `BidRecord`s associated with the job. +- Reconstructs the list of `BidRecord`s from indexed bid rows associated with the job. - Returns an empty list if the job exists but has no bids. ### Errors -- `JobNotFound` (1): The specified job ID does not exist. +- `JobNotFound` (7): The specified job ID does not exist. + +## `get_bid_at` + +### Purpose + +`get_bid_at` retrieves one indexed bid row for callers that need paged or bounded access. + +### Behavior + +- Verifies the job exists. +- Checks `index < BidCount(job_id)`. +- Returns only the requested bid record. + +### Errors + +- `JobNotFound` (7): The specified job ID does not exist. +- `BidIndexOutOfBounds` (15): The requested bid index is outside the stored bounds. + ## `submit_deliverable` ### Purpose -`submit_deliverable` is called by a freelancer to submit their completed work for a job that is in progress. The deliverable is stored as an IPFS hash, enabling decentralized content storage while maintaining on-chain auditability. +`submit_deliverable` is called by a freelancer to submit their completed work for an assigned job. The deliverable is stored as a compact IPFS CID, enabling decentralized content storage while maintaining on-chain auditability. ### Behavior - Authenticates the caller with `freelancer.require_auth()`. -- Validates that the deliverable hash is not empty to prevent invalid submissions. -- Verifies the job exists and is currently in the `InProgress` state. +- Validates that the deliverable CID is not empty or oversized to prevent invalid submissions. +- Verifies the job exists and is currently in the `Assigned` state. - Confirms the caller is the assigned freelancer for the job. - Updates the job status to `DeliverableSubmitted`. -- Stores the deliverable hash in persistent storage for later retrieval. +- Stores the deliverable CID in persistent storage for later retrieval. - Emits a `DeliverableSubmitted` event with timestamp for on-chain auditing and off-chain indexing. ### Errors `submit_deliverable` uses `JobRegistryError` to return structured error information: -- `JobNotFound` (1): job does not exist. -- `InvalidInput` (4): deliverable hash is empty. -- `InvalidState` (5): job is not in `InProgress` status. -- `Unauthorized` (3): caller is not the assigned freelancer for the job. +- `JobNotFound` (7): job does not exist. +- `InvalidHash` (5): deliverable CID is empty or exceeds the CID size bound. +- `InvalidStateTransition` (12): job is not in `Assigned` status. +- `Unauthorized` (9): caller is not the assigned freelancer for the job. ### Notes -This function is critical for the job completion workflow, enabling freelancers to submit their work while maintaining security through authentication and state validation. The IPFS hash storage minimizes on-chain data while preserving immutability and accessibility. +This function is critical for the job completion workflow, enabling freelancers to submit their work while maintaining security through authentication and state validation. Compact IPFS CID storage minimizes on-chain data while preserving immutability and accessibility. diff --git a/docs/contracts/storage_layout_optimization.md b/docs/contracts/storage_layout_optimization.md index ed72d763..ad7fba00 100644 --- a/docs/contracts/storage_layout_optimization.md +++ b/docs/contracts/storage_layout_optimization.md @@ -8,7 +8,7 @@ The objective is to lower rent footprint and execution overhead without changing ## What Changed -### 1) JobRegistry: lazy `Bids(job_id)` ContractData allocation +### 1) JobRegistry: indexed bid rows instead of monolithic bid vectors File: `contracts/job_registry/src/lib.rs` @@ -17,17 +17,28 @@ Before: - `post_job` always created two persistent entries: - `Job(job_id)` - `Bids(job_id)` initialized as an empty vector +- Each `submit_bid` deserialized and rewrote the whole vector. +- `accept_bid` scanned the whole vector to confirm that a freelancer had bid. After: - `post_job` creates only `Job(job_id)`. -- `Bids(job_id)` is created on first `submit_bid` write. -- Read paths (`get_bids`, `accept_bid`) already safely handle missing bids entry via `unwrap_or_else(Vec::new)`. +- `BidCount(job_id)` tracks the current bid bounds. +- `Bid(job_id, index)` stores each proposal as an independent row. +- `BidIndex(job_id, freelancer)` provides constant-key duplicate checks, cancellation, and accept validation. +- `Refund(freelancer)` accumulates collateral credited by cancelled bids until the freelancer claims it. +- `get_bids` remains a compatibility view that reconstructs a vector only for read callers. +- `get_bid_at` provides bounded indexed reads and returns `BidIndexOutOfBounds` (15) for invalid indices. Impact: - One less persistent `ContractData` entry per newly posted job that never receives bids. -- Lower storage rent pressure and smaller ledger footprint. +- Write paths avoid repeatedly deserializing and rewriting a growing bid vector. +- Bid cancellation uses swap-remove compaction so indexed bounds remain tight after dynamic removals. +- Bid acceptance refunds non-selected collateral and compacts storage down to the accepted row. +- Collateral refund accounting uses checked addition and clears storage on claim. +- Late bid submissions after assignment remain blocked with `JobNotOpen` (8). +- Lower execution overhead for duplicate checks and bid acceptance. ### 2) Reputation: strict admin verification for instance config updates @@ -47,9 +58,10 @@ Impact: - `ContractInstance`: used for compact, singleton contract config (admin, registry pointers). - `ContractData`: used for per-job/per-user dynamic state. -- Dynamic keys are now allocated lazily where possible (`Bids(job_id)`), minimizing persistent data creation. +- Dynamic bid keys are now allocated per submitted proposal, bounded by `BidCount(job_id)`, and compacted on cancellation, minimizing unnecessary persistent data reads and rewrites. ## Compatibility - No public function signatures were changed. -- Existing tests and behavior remain compatible. +- `submit_bid` remains available as the zero-collateral compatibility path. +- `BidRecord` now includes `collateral_stroops` so callers can inspect refundable bid collateral.