diff --git a/contracts/job_registry/src/lib.rs b/contracts/job_registry/src/lib.rs index baf3e744..fca2ebcd 100644 --- a/contracts/job_registry/src/lib.rs +++ b/contracts/job_registry/src/lib.rs @@ -1,16 +1,15 @@ #![no_std] - + use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, log, panic_with_error, symbol_short, token, Address, Bytes, Env, Vec, }; -#[allow(dead_code)] -const MAX_HASH_LEN: u32 = 96; +const MAX_CID_LEN: u32 = 96; // Requirement [SC-REG-037]: Contract-wide budget floor and ceiling enforced at input validation. // MIN prevents dust spam; MAX caps exposure to a realistic large project value. -const MIN_BUDGET_STROOPS: i128 = 100_000; // 0.01 XLM +const MIN_BUDGET_STROOPS: i128 = 100_000; // 0.01 XLM const MAX_BUDGET_STROOPS: i128 = 100_000_000_000_000; // 10,000,000 XLM #[contracterror] @@ -31,16 +30,16 @@ pub enum JobRegistryError { InvalidStateTransition = 12, NoDeliverable = 13, Overflow = 14, - InvalidExpiration = 15, - JobExpired = 16, - JobNotExpired = 17, - InvalidCollateral = 18, - BidWindowClosed = 19, - CollateralNotFound = 20, - CollateralAlreadyReleased = 21, - BidIndexOutOfBounds = 22, + BidIndexOutOfBounds = 15, + InvalidExpiration = 16, + JobExpired = 17, + JobNotExpired = 18, + InvalidCollateral = 19, + BidWindowClosed = 20, + CollateralNotFound = 21, + CollateralAlreadyReleased = 22, } - + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum JobStatus { @@ -53,7 +52,7 @@ pub enum JobStatus { Expired, Defaulted, } - + #[contracttype] #[derive(Clone)] pub struct JobRecord { @@ -92,10 +91,10 @@ pub enum DataKey { BidIndex(u64, Address), Deliverable(u64), } - + #[contract] pub struct JobRegistryContract; - + #[contractimpl] impl JobRegistryContract { /// One-time storage bootstrap. @@ -105,7 +104,7 @@ 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); @@ -117,16 +116,17 @@ impl JobRegistryContract { 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` and collateral lockup details. + /// `metadata_hash` must be a valid IPFS CID (CIDv0 or CIDv1). pub fn post_job( env: Env, job_id: u64, @@ -201,7 +201,7 @@ impl JobRegistryContract { collateral_amount: i128, ) -> u64 { ensure_initialized(&env); - + let job_id = read_next_job_id(&env); validate_job_input( @@ -246,7 +246,6 @@ impl JobRegistryContract { job_id } - /// Freelancer submits a bid. /// Freelancer submits a bid, with optionally provided freelancer collateral. pub fn submit_bid( env: Env, @@ -283,10 +282,6 @@ impl JobRegistryContract { panic_with_error!(&env, JobRegistryError::JobExpired); } - if collateral_stroops < 0 { - panic_with_error!(&env, JobRegistryError::InvalidBudget); - } - let bids_key = DataKey::Bids(job_id); let mut bids: Vec = env @@ -301,7 +296,7 @@ impl JobRegistryContract { panic_with_error!(&env, JobRegistryError::BidAlreadySubmitted); } } - + bids.push_back(BidRecord { freelancer: freelancer.clone(), proposal_hash, @@ -332,7 +327,7 @@ impl JobRegistryContract { ensure_initialized(&env); client.require_auth(); - + let key = DataKey::Job(job_id); let mut job: JobRecord = env @@ -422,11 +417,7 @@ impl JobRegistryContract { 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 client != job.client { panic_with_error!(&env, JobRegistryError::Unauthorized); @@ -502,7 +493,7 @@ impl JobRegistryContract { ensure_initialized(&env); client.require_auth(); - + let key = DataKey::Job(job_id); let mut job: JobRecord = env @@ -510,7 +501,7 @@ impl JobRegistryContract { .persistent() .get(&key) .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound)); - + if job.status != JobStatus::Open { panic_with_error!(&env, JobRegistryError::InvalidStateTransition); } @@ -522,7 +513,7 @@ impl JobRegistryContract { if env.ledger().timestamp() < job.expires_at { panic_with_error!(&env, JobRegistryError::JobNotExpired); } - + job.status = JobStatus::Expired; // Refund collateral if locked @@ -554,7 +545,7 @@ impl JobRegistryContract { validate_hash(&env, &hash); freelancer.require_auth(); - + let key = DataKey::Job(job_id); let mut job: JobRecord = env @@ -570,7 +561,7 @@ impl JobRegistryContract { if job.freelancer != Some(freelancer.clone()) { panic_with_error!(&env, JobRegistryError::Unauthorized); } - + job.status = JobStatus::DeliverableSubmitted; env.storage().persistent().set(&key, &job); @@ -589,7 +580,7 @@ impl JobRegistryContract { let admin = read_admin(&env); admin.require_auth(); - + let key = DataKey::Job(job_id); let mut job: JobRecord = env @@ -603,7 +594,7 @@ impl JobRegistryContract { { panic_with_error!(&env, JobRegistryError::InvalidStateTransition); } - + job.status = JobStatus::Disputed; env.storage().persistent().set(&key, &job); @@ -646,11 +637,7 @@ impl JobRegistryContract { 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); // [SC-REG-025]: Strict ownership validation — only the original job creator (client) // is authorised to trigger the default slashing flow. Third-party callers are @@ -668,7 +655,7 @@ impl JobRegistryContract { // [SC-REG-025]: Expiration check — the ledger timestamp must exceed `expires_at` // to confirm the freelancer is definitively in default. Calling before expiry is - // blocked (error code 17 = JobNotExpired) to prevent premature slashing. + // blocked (error code 18 = JobNotExpired) to prevent premature slashing. let now = env.ledger().timestamp(); if now < job.expires_at { panic_with_error!(&env, JobRegistryError::JobNotExpired); @@ -745,7 +732,7 @@ impl JobRegistryContract { .get(&DataKey::Job(job_id)) .unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound)) } - + pub fn get_bids(env: Env, job_id: u64) -> Vec { ensure_initialized(&env); @@ -755,6 +742,23 @@ impl JobRegistryContract { .unwrap_or(Vec::new(&env)) } + /// Convenience indexed accessor over the `Bids` vec. + pub fn get_bid_at(env: Env, job_id: u64, index: u32) -> BidRecord { + ensure_initialized(&env); + + let bids: Vec = env + .storage() + .persistent() + .get(&DataKey::Bids(job_id)) + .unwrap_or(Vec::new(&env)); + + if index >= bids.len() { + panic_with_error!(&env, JobRegistryError::BidIndexOutOfBounds); + } + + bids.get_unchecked(index) + } + // Requirement [SC-REG-039]: Gas-efficient paginated getter avoids loading the full bids vector // when only a window of records is needed. Callers supply an offset and a limit; the function // returns at most `limit` entries starting at `offset`, clamping automatically at the end. @@ -797,20 +801,20 @@ 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 { env.storage() .instance() .get(&DataKey::Admin) .unwrap_or_else(|| panic_with_error!(env, JobRegistryError::NotInitialized)) } - + fn read_next_job_id(env: &Env) -> u64 { env.storage() .instance() @@ -846,7 +850,7 @@ fn validate_job_input( validate_hash(env, hash); validate_expiration(env, expires_at); } - + fn validate_expiration(env: &Env, expires_at: u64) { let now = env.ledger().timestamp(); @@ -897,28 +901,28 @@ fn validate_ipfs_cid(env: &Env, hash: &Bytes) { 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, @@ -935,7 +939,7 @@ fn post_job_with_id( if env.storage().persistent().has(&key) { panic_with_error!(env, JobRegistryError::JobAlreadyExists); } - + let job = JobRecord { client, freelancer: None, @@ -952,6 +956,9 @@ fn post_job_with_id( env.storage().persistent().set(&key, &job); let bids: Vec = Vec::new(env); + env.storage() + .persistent() + .set(&DataKey::Bids(job_id), &bids); env.storage() .persistent() @@ -1009,7 +1016,7 @@ mod test { use super::*; use soroban_sdk::testutils::{Address as _, Ledger as _}; use soroban_sdk::{Address, Bytes, Env}; - + fn setup() -> ( Env, JobRegistryContractClient<'static>, @@ -1020,17 +1027,17 @@ 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() + 30 * 24 * 60 * 60 } @@ -1044,25 +1051,23 @@ mod test { #[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(); - let (_env, cc, admin, _, _) = setup(); - cc.initialize(&admin); cc.initialize(&admin); } - + #[test] #[should_panic] fn test_post_job_before_initialize_panics() { @@ -1071,74 +1076,74 @@ mod test { let expires_at = future_expires_at(&env); cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &2000u64, &token_addr, &1000i128); } - + #[test] fn test_post_job_auto_allocates_sequential_ids() { let (env, cc, admin, client, _, token_addr) = 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, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at1); - let id2 = cc.post_job_auto(&client, &hash2, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at2); + let id1 = cc.post_job_auto(&client, &hash1, &MIN_BUDGET_STROOPS, &expires_at1, &default_bidding_deadline(&env), &token_addr, &0i128); + let id2 = cc.post_job_auto(&client, &hash2, &MIN_BUDGET_STROOPS, &expires_at2, &default_bidding_deadline(&env), &token_addr, &0i128); 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, _, token_addr) = setup(); cc.initialize(&admin); - - let hash = Bytes::from_slice(&env, b"QmHash"); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&42u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&42u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); assert_eq!(cc.get_next_job_id(), 43u64); } - + #[test] #[should_panic] fn test_invalid_budget_panics() { let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - - let hash = Bytes::from_slice(&env, b"QmHash"); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &0i128, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &0i128, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); } - + #[test] #[should_panic] fn test_empty_hash_panics() { let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - + let empty = Bytes::from_slice(&env, b""); env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &empty, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &empty, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); } - + #[test] fn test_full_lifecycle() { let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - - let hash = Bytes::from_slice(&env, b"QmSomeIPFSHash"); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &1000i128); 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"); + + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &1000i128); let bids = cc.get_bids(&1u64); @@ -1149,13 +1154,13 @@ mod test { 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"); + + let deliverable = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); 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); @@ -1163,55 +1168,54 @@ mod test { let job = cc.get_job(&1u64); assert_eq!(job.status, JobStatus::Completed); assert!(!job.collateral_locked); - assert_eq!(tc.balance(&freelancer), 1000); } - + #[test] #[should_panic] fn test_duplicate_bid_panics() { let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - - let hash = Bytes::from_slice(&env, b"QmHash"); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); - let proposal = Bytes::from_slice(&env, b"QmProposal"); + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &500i128); cc.submit_bid(&1u64, &freelancer, &proposal, &500i128); } - + #[test] #[should_panic] fn test_accept_without_matching_bid_panics() { let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - - let hash = Bytes::from_slice(&env, b"QmHash"); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); cc.accept_bid(&1u64, &client, &freelancer); } - + #[test] fn test_mark_disputed_from_assigned() { let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - - let hash = Bytes::from_slice(&env, b"QmHash"); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); - let proposal = Bytes::from_slice(&env, b"QmProposal"); + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &0i128); 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() { @@ -1221,41 +1225,35 @@ mod test { let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); cc.mark_disputed(&1u64); } - + #[test] #[should_panic] fn test_get_deliverable_without_submission_panics() { let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - - let hash = Bytes::from_slice(&env, b"QmHash"); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); - env.ledger().set_timestamp(expires_at + 1); - - let proposal = Bytes::from_slice(&env, b"QmProposal"); - cc.submit_bid(&1u64, &freelancer, &proposal, &100i128); + cc.get_deliverable(&1u64); } - + #[test] #[should_panic] fn test_invalid_cidv0_prefix_panics() { let (env, cc, admin, client, _, token_addr) = 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, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); - let hash = Bytes::from_slice(&env, b"bafxbeigdyrzt5sbi7ee3xjc3vyqptsyfuwwspw2gx6pqdfaaaaabbbbbccccc"); env.ledger().set_timestamp(100); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &1000u64, &token_addr, &1000i128); + // "bafx..." prefix is invalid for a 46-byte CIDv0 (must start "Qm") + let hash = Bytes::from_slice(&env, b"bafxbeigdyrzt5sbi7ee3xjc3vyqptsyfuwwspw2gx6pqdf4"); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); } // --- SC-REG-037: Budget Bounds Tests --- @@ -1264,10 +1262,10 @@ mod test { fn test_budget_at_minimum_succeeds() { let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - - let hash = Bytes::from_slice(&env, b"QmHash"); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); let job = cc.get_job(&1u64); assert_eq!(job.budget_stroops, MIN_BUDGET_STROOPS); @@ -1280,43 +1278,43 @@ mod test { let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MAX_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &MAX_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); let job = cc.get_job(&1u64); assert_eq!(job.budget_stroops, MAX_BUDGET_STROOPS); } - + #[test] #[should_panic] fn test_budget_below_minimum_panics() { let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - - let hash = Bytes::from_slice(&env, b"QmHash"); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &(MIN_BUDGET_STROOPS - 1), &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &(MIN_BUDGET_STROOPS - 1), &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); } #[test] #[should_panic] fn test_budget_above_maximum_panics() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &(MAX_BUDGET_STROOPS + 1), &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &(MAX_BUDGET_STROOPS + 1), &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); } - + #[test] #[should_panic] fn test_zero_budget_still_panics() { - let (env, cc, admin, client, _) = setup(); + let (env, cc, admin, client, _, token_addr) = setup(); cc.initialize(&admin); - - let hash = Bytes::from_slice(&env, b"QmHash"); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &0i128, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &0i128, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); } #[test] @@ -1326,7 +1324,7 @@ mod test { let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); assert_eq!(cc.get_bids_count(&1u64), 0u32); } @@ -1338,11 +1336,11 @@ mod test { let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); for _ in 0..3u32 { let freelancer = Address::generate(&env); - let proposal = Bytes::from_slice(&env, b"QmProposal"); + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &DEFAULT_COLLATERAL_STROOPS); } @@ -1356,11 +1354,11 @@ mod test { let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); for _ in 0..5u32 { let freelancer = Address::generate(&env); - let proposal = Bytes::from_slice(&env, b"QmProposal"); + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &DEFAULT_COLLATERAL_STROOPS); } @@ -1375,11 +1373,11 @@ mod test { let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); for _ in 0..5u32 { let freelancer = Address::generate(&env); - let proposal = Bytes::from_slice(&env, b"QmProposal"); + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &DEFAULT_COLLATERAL_STROOPS); } @@ -1394,11 +1392,11 @@ mod test { let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &default_bidding_deadline(&env), &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); for _ in 0..3u32 { let freelancer = Address::generate(&env); - let proposal = Bytes::from_slice(&env, b"QmProposal"); + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &DEFAULT_COLLATERAL_STROOPS); } @@ -1408,14 +1406,14 @@ mod test { #[test] fn test_enforce_default_slashing_success() { - let (env, cc, admin, client, freelancer) = setup(); + let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); - let proposal = Bytes::from_slice(&env, b"QmProposal"); + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &12345i128); cc.accept_bid(&1u64, &client, &freelancer); @@ -1423,10 +1421,8 @@ mod test { let job = cc.get_job(&1u64); assert_eq!(job.status, JobStatus::Assigned); - // Advance ledger timestamp to default threshold env.ledger().set_timestamp(expires_at + 1); - // Client triggers default and gets 100% of collateral slashed let slashed = cc.enforce_default_slashing(&1u64, &client); assert_eq!(slashed, 12345i128); @@ -1434,105 +1430,169 @@ mod test { assert_eq!(updated_job.status, JobStatus::Defaulted); } + #[test] + fn test_get_bid_at_reads_indexed_bid_rows() { + let (env, cc, admin, client, freelancer, token_addr) = setup(); + let second_freelancer = Address::generate(&env); + cc.initialize(&admin); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); + + let proposal_one = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + let proposal_two = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9g"); + cc.submit_bid(&1u64, &freelancer, &proposal_one, &0i128); + cc.submit_bid(&1u64, &second_freelancer, &proposal_two, &0i128); + + 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, _, token_addr) = setup(); + cc.initialize(&admin); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); + + cc.get_bid_at(&1u64, &0u32); + } + + #[test] + #[should_panic(expected = "Error(Contract, #5)")] + fn test_rejects_oversized_metadata_cid() { + let (env, cc, admin, client, _, token_addr) = 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, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); + } + + #[test] + #[should_panic(expected = "Error(Contract, #8)")] + fn test_late_bid_after_assignment_returns_specific_error() { + let (env, cc, admin, client, freelancer, token_addr) = setup(); + let late_freelancer = Address::generate(&env); + cc.initialize(&admin); + + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + let expires_at = future_expires_at(&env); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); + + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + cc.submit_bid(&1u64, &freelancer, &proposal, &0i128); + cc.accept_bid(&1u64, &client, &freelancer); + + let late_proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9g"); + cc.submit_bid(&1u64, &late_freelancer, &late_proposal, &0i128); + } + #[test] #[should_panic] fn test_enforce_default_slashing_before_expiration_panics() { - let (env, cc, admin, client, freelancer) = setup(); + let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); - let proposal = Bytes::from_slice(&env, b"QmProposal"); + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &100i128); cc.accept_bid(&1u64, &client, &freelancer); - // Calling enforce default slashing before job expires must fail cc.enforce_default_slashing(&1u64, &client); } #[test] #[should_panic] fn test_enforce_default_slashing_unauthorized_panics() { - let (env, cc, admin, client, freelancer) = setup(); + let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); - let proposal = Bytes::from_slice(&env, b"QmProposal"); + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &200i128); cc.accept_bid(&1u64, &client, &freelancer); env.ledger().set_timestamp(expires_at + 1); - // A third-party address (represented by freelancer here) attempts to default cc.enforce_default_slashing(&1u64, &freelancer); } #[test] #[should_panic] fn test_enforce_default_slashing_invalid_state_panics() { - let (env, cc, admin, client, freelancer) = setup(); + let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); - let proposal = Bytes::from_slice(&env, b"QmProposal"); + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &300i128); env.ledger().set_timestamp(expires_at + 1); - // The job status is Open (not Assigned). Enforce default slashing should fail. cc.enforce_default_slashing(&1u64, &client); } #[test] #[should_panic] fn test_submit_bid_negative_collateral_panics() { - let (env, cc, admin, client, freelancer) = setup(); + let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmHash"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); - let proposal = Bytes::from_slice(&env, b"QmProposal"); - // Bid with negative collateral must panic + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &-100i128); } - // ────────────────────────────────────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────────────── // SC-REG-025: Additional collateral slashing edge-case tests - // ────────────────────────────────────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────────────── /// [SC-REG-025] A freelancer who bid zero collateral results in a slashed amount /// of 0 (not a panic). The job still transitions cleanly to `Defaulted`. #[test] fn test_enforce_default_slashing_zero_collateral_returns_zero() { - let (env, cc, admin, client, freelancer) = setup(); + let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmIPFSZero"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); - // Freelancer bids with zero collateral (allowed) - let proposal = Bytes::from_slice(&env, b"QmProposalZero"); + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &0i128); cc.accept_bid(&1u64, &client, &freelancer); - // Advance past expiry env.ledger().set_timestamp(expires_at + 1); let slashed = cc.enforce_default_slashing(&1u64, &client); - // 0 collateral → 0 slash; no overflow, no panic assert_eq!(slashed, 0i128); - // State must still be Defaulted assert_eq!(cc.get_job(&1u64).status, JobStatus::Defaulted); } @@ -1541,14 +1601,14 @@ mod test { #[test] #[should_panic] fn test_enforce_default_slashing_double_slash_panics() { - let (env, cc, admin, client, freelancer) = setup(); + let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - let hash = Bytes::from_slice(&env, b"QmIPFSDouble"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); - let proposal = Bytes::from_slice(&env, b"QmProposalDouble"); + let proposal = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); cc.submit_bid(&1u64, &freelancer, &proposal, &500i128); cc.accept_bid(&1u64, &client, &freelancer); @@ -1562,30 +1622,26 @@ mod test { /// is used for the slashing calculation. Others are unaffected. #[test] fn test_enforce_default_slashing_multiple_bids_only_accepted_slashed() { - let (env, cc, admin, client, freelancer) = setup(); + let (env, cc, admin, client, freelancer, token_addr) = setup(); cc.initialize(&admin); - // Generate a second bidder let bidder2 = Address::generate(&env); - let hash = Bytes::from_slice(&env, b"QmIPFSMulti"); + let hash = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); let expires_at = future_expires_at(&env); - cc.post_job(&1u64, &client, &hash, &5000i128, &expires_at); + cc.post_job(&1u64, &client, &hash, &MIN_BUDGET_STROOPS, &expires_at, &default_bidding_deadline(&env), &token_addr, &0i128); - // Two bids with different collateral amounts - let p1 = Bytes::from_slice(&env, b"QmProposal1"); - cc.submit_bid(&1u64, &freelancer, &p1, &1000i128); // accepted freelancer - let p2 = Bytes::from_slice(&env, b"QmProposal2"); - cc.submit_bid(&1u64, &bidder2, &p2, &9999i128); // competing bidder + let p1 = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9f"); + cc.submit_bid(&1u64, &freelancer, &p1, &1000i128); + let p2 = Bytes::from_slice(&env, b"QmZ4t45v9y2X6a9f5d3v2X5a9f5d3v2X5a9f5d3v2X5a9g"); + cc.submit_bid(&1u64, &bidder2, &p2, &9999i128); - // Accept the first freelancer's bid cc.accept_bid(&1u64, &client, &freelancer); env.ledger().set_timestamp(expires_at + 1); - // Slashed amount must equal only the accepted bid's collateral (1000) let slashed = cc.enforce_default_slashing(&1u64, &client); assert_eq!(slashed, 1000i128); assert_eq!(cc.get_job(&1u64).status, JobStatus::Defaulted); } -} +} \ No newline at end of file diff --git a/docs/contracts/job_registry.md b/docs/contracts/job_registry.md index c969ec0b..6cf433d7 100644 --- a/docs/contracts/job_registry.md +++ b/docs/contracts/job_registry.md @@ -33,35 +33,6 @@ These functions use `JobRegistryError` to return structured error information: 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 the assigned state. - -### Behavior - -- Authenticates the caller with `client.require_auth()`. -- 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. -- 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` (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 @@ -108,6 +79,35 @@ These functions let freelancers submit compact CID-backed proposals. `submit_bid - `Overflow` (14): refund balance addition overflowed. - `NoRefund` (17): the freelancer has no refundable balance to claim. +## `accept_bid` + +### Purpose + +`accept_bid` is called by a job client to accept one freelancer's bid and move the job into the assigned state. + +### Behavior + +- Authenticates the caller with `client.require_auth()`. +- 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. +- 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` (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. + ## `get_job` ### Purpose @@ -183,4 +183,4 @@ These functions let freelancers submit compact CID-backed proposals. `submit_bid ### Notes -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. +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. \ No newline at end of file diff --git a/docs/contracts/storage_layout_optimization.md b/docs/contracts/storage_layout_optimization.md index ad7fba00..7b886d81 100644 --- a/docs/contracts/storage_layout_optimization.md +++ b/docs/contracts/storage_layout_optimization.md @@ -1,27 +1,17 @@ # Storage Layout Optimization (ContractData vs ContractInstance) - ## Overview - This change optimizes Soroban storage layout by reducing unnecessary `ContractData` writes and tightening `ContractInstance`-based config control. - The objective is to lower rent footprint and execution overhead without changing external behavior. - ## What Changed - ### 1) JobRegistry: indexed bid rows instead of monolithic bid vectors - File: `contracts/job_registry/src/lib.rs` - 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)`. - `BidCount(job_id)` tracks the current bid bounds. - `Bid(job_id, index)` stores each proposal as an independent row. @@ -29,9 +19,7 @@ After: - `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. - 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. @@ -39,29 +27,19 @@ Impact: - 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 - File: `contracts/reputation/src/lib.rs` - `set_job_registry` now verifies: - - caller auth (`require_auth`) and - equality with admin stored in `ContractInstance` (`DataKey::Admin`). - Impact: - - Preserves intended instance-based config authority model. - Prevents unauthorized instance config writes by any authenticated address. - ## Why this aligns with ContractData vs ContractInstance - - `ContractInstance`: used for compact, singleton contract config (admin, registry pointers). - `ContractData`: used for per-job/per-user dynamic state. - 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. - `submit_bid` remains available as the zero-collateral compatibility path. -- `BidRecord` now includes `collateral_stroops` so callers can inspect refundable bid collateral. +- `BidRecord` now includes `collateral_stroops` so callers can inspect refundable bid collateral. \ No newline at end of file