From 5768e6fb94e24ea8c72595a3daa25d7c4a2497d0 Mon Sep 17 00:00:00 2001 From: Benedict315 Date: Wed, 27 May 2026 14:30:30 +0100 Subject: [PATCH] feat: optimize job registry bid handling with indexed storage and compact CID validation --- contracts/job_registry/src/lib.rs | 231 ++++++++++++------ docs/contracts/job_registry.md | 64 +++-- docs/contracts/storage_layout_optimization.md | 17 +- 3 files changed, 213 insertions(+), 99 deletions(-) diff --git a/contracts/job_registry/src/lib.rs b/contracts/job_registry/src/lib.rs index 7b0e1f36..7b2dd261 100644 --- a/contracts/job_registry/src/lib.rs +++ b/contracts/job_registry/src/lib.rs @@ -5,7 +5,7 @@ use soroban_sdk::{ 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)] @@ -25,12 +25,14 @@ pub enum JobRegistryError { InvalidStateTransition = 12, NoDeliverable = 13, Overflow = 14, + BidIndexOutOfBounds = 15, } #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum JobStatus { Open, + Assigned, InProgress, DeliverableSubmitted, Completed, @@ -59,7 +61,9 @@ pub enum DataKey { Admin, NextJobId, Job(u64), - Bids(u64), + BidCount(u64), + Bid(u64, u32), + BidIndex(u64, Address), Deliverable(u64), } @@ -98,7 +102,7 @@ impl JobRegistryContract { } /// 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, client: Address, hash: Bytes, budget: i128) { ensure_initialized(&env); validate_job_input(&env, job_id, &hash, budget); @@ -154,41 +158,41 @@ impl JobRegistryContract { 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 bids_key = DataKey::Bids(job_id); - let mut bids: Vec = env - .storage() - .persistent() - .get(&bids_key) - .unwrap_or(Vec::new(&env)); - - for bid in bids.iter() { - if bid.freelancer == freelancer { - panic_with_error!(&env, JobRegistryError::BidAlreadySubmitted); - } + 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() @@ -201,11 +205,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 job.status != JobStatus::Open { panic_with_error!(&env, JobRegistryError::JobNotOpen); @@ -214,25 +214,16 @@ impl JobRegistryContract { 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); } job.freelancer = Some(freelancer.clone()); - job.status = JobStatus::InProgress; + job.status = JobStatus::Assigned; env.storage().persistent().set(&key, &job); log!( @@ -246,20 +237,16 @@ impl JobRegistryContract { .publish((symbol_short!("accept"), job_id), freelancer); } - /// 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)); + let mut job = read_job(&env, job_id); - if job.status != JobStatus::InProgress { + if job.status != JobStatus::Assigned && job.status != JobStatus::InProgress { panic_with_error!(&env, JobRegistryError::InvalidStateTransition); } if job.freelancer != Some(freelancer.clone()) { @@ -289,13 +276,12 @@ impl JobRegistryContract { 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)); + let mut job = read_job(&env, job_id); - if job.status != JobStatus::InProgress && job.status != JobStatus::DeliverableSubmitted { + if job.status != JobStatus::Assigned + && job.status != JobStatus::InProgress + && job.status != JobStatus::DeliverableSubmitted + { panic_with_error!(&env, JobRegistryError::InvalidStateTransition); } @@ -308,18 +294,31 @@ impl JobRegistryContract { 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 { @@ -360,16 +359,37 @@ fn validate_job_input(env: &Env, job_id: u64, hash: &Bytes, budget: i128) { if budget <= 0 { panic_with_error!(env, JobRegistryError::InvalidBudget); } - validate_hash(env, hash); + validate_cid(env, hash); } -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, client: Address, hash: Bytes, budget: i128) { let key = DataKey::Job(job_id); if env.storage().persistent().has(&key) { @@ -385,10 +405,9 @@ fn post_job_with_id(env: &Env, job_id: u64, client: Address, hash: Bytes, budget }; 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)] @@ -512,7 +531,7 @@ mod test { cc.accept_bid(&1u64, &client, &freelancer); let job = cc.get_job(&1u64); - assert_eq!(job.status, JobStatus::InProgress); + assert_eq!(job.status, JobStatus::Assigned); assert_eq!(job.freelancer, Some(freelancer.clone())); let deliverable = Bytes::from_slice(&env, b"QmDeliverableHash"); @@ -539,6 +558,74 @@ mod test { 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"); + cc.post_job(&1u64, &client, &hash, &5000i128); + + 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"); + cc.post_job(&1u64, &client, &hash, &5000i128); + + 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", + ); + cc.post_job(&1u64, &client, &oversized, &5000i128); + } + + #[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"); + cc.post_job(&1u64, &client, &hash, &5000i128); + + 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() { diff --git a/docs/contracts/job_registry.md b/docs/contracts/job_registry.md index 72d3c409..09100968 100644 --- a/docs/contracts/job_registry.md +++ b/docs/contracts/job_registry.md @@ -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,20 @@ 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. +- 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. ## `get_job` @@ -71,7 +73,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 +84,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..075137e1 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,24 @@ 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 and accept validation. +- `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. +- 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,7 +54,7 @@ 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, and bounded by `BidCount(job_id)`, minimizing unnecessary persistent data reads and rewrites. ## Compatibility