Skip to content

Commit 5768e6f

Browse files
committed
feat: optimize job registry bid handling with indexed storage and compact CID validation
1 parent bf6bc2f commit 5768e6f

3 files changed

Lines changed: 213 additions & 99 deletions

File tree

contracts/job_registry/src/lib.rs

Lines changed: 159 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use soroban_sdk::{
55
Address, Bytes, Env, Vec,
66
};
77

8-
const MAX_HASH_LEN: u32 = 96;
8+
const MAX_CID_LEN: u32 = 96;
99

1010
#[contracterror]
1111
#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)]
@@ -25,12 +25,14 @@ pub enum JobRegistryError {
2525
InvalidStateTransition = 12,
2626
NoDeliverable = 13,
2727
Overflow = 14,
28+
BidIndexOutOfBounds = 15,
2829
}
2930

3031
#[contracttype]
3132
#[derive(Clone, Debug, PartialEq)]
3233
pub enum JobStatus {
3334
Open,
35+
Assigned,
3436
InProgress,
3537
DeliverableSubmitted,
3638
Completed,
@@ -59,7 +61,9 @@ pub enum DataKey {
5961
Admin,
6062
NextJobId,
6163
Job(u64),
62-
Bids(u64),
64+
BidCount(u64),
65+
Bid(u64, u32),
66+
BidIndex(u64, Address),
6367
Deliverable(u64),
6468
}
6569

@@ -98,7 +102,7 @@ impl JobRegistryContract {
98102
}
99103

100104
/// Client posts a job with explicit `job_id`.
101-
/// `metadata_hash` is expected to contain CID bytes.
105+
/// `metadata_hash` must contain compact IPFS CID bytes, not raw text.
102106
pub fn post_job(env: Env, job_id: u64, client: Address, hash: Bytes, budget: i128) {
103107
ensure_initialized(&env);
104108
validate_job_input(&env, job_id, &hash, budget);
@@ -154,41 +158,41 @@ impl JobRegistryContract {
154158
job_id
155159
}
156160

157-
/// Freelancer submits a bid.
161+
/// Freelancer submits a bid with compact IPFS CID proposal metadata.
158162
pub fn submit_bid(env: Env, job_id: u64, freelancer: Address, proposal_hash: Bytes) {
159163
ensure_initialized(&env);
160-
validate_hash(&env, &proposal_hash);
164+
validate_cid(&env, &proposal_hash);
161165
freelancer.require_auth();
162166

163-
let key = DataKey::Job(job_id);
164-
let job: JobRecord = env
165-
.storage()
166-
.persistent()
167-
.get(&key)
168-
.unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
167+
let job = read_job(&env, job_id);
169168

170169
if job.status != JobStatus::Open {
171170
panic_with_error!(&env, JobRegistryError::JobNotOpen);
172171
}
173172

174-
let bids_key = DataKey::Bids(job_id);
175-
let mut bids: Vec<BidRecord> = env
176-
.storage()
177-
.persistent()
178-
.get(&bids_key)
179-
.unwrap_or(Vec::new(&env));
180-
181-
for bid in bids.iter() {
182-
if bid.freelancer == freelancer {
183-
panic_with_error!(&env, JobRegistryError::BidAlreadySubmitted);
184-
}
173+
let bidder_key = DataKey::BidIndex(job_id, freelancer.clone());
174+
if env.storage().persistent().has(&bidder_key) {
175+
panic_with_error!(&env, JobRegistryError::BidAlreadySubmitted);
185176
}
186177

187-
bids.push_back(BidRecord {
178+
let bid_count = read_bid_count(&env, job_id);
179+
let next_count = bid_count
180+
.checked_add(1)
181+
.unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::Overflow));
182+
let bid = BidRecord {
188183
freelancer: freelancer.clone(),
189184
proposal_hash,
190-
});
191-
env.storage().persistent().set(&bids_key, &bids);
185+
};
186+
187+
// Store bid rows independently so duplicate checks and acceptance avoid
188+
// deserializing an ever-growing bid vector on every write path.
189+
env.storage()
190+
.persistent()
191+
.set(&DataKey::Bid(job_id, bid_count), &bid);
192+
env.storage().persistent().set(&bidder_key, &bid_count);
193+
env.storage()
194+
.persistent()
195+
.set(&DataKey::BidCount(job_id), &next_count);
192196

193197
log!(&env, "submit_bid: id {} freelancer {}", job_id, freelancer);
194198
env.events()
@@ -201,11 +205,7 @@ impl JobRegistryContract {
201205
client.require_auth();
202206

203207
let key = DataKey::Job(job_id);
204-
let mut job: JobRecord = env
205-
.storage()
206-
.persistent()
207-
.get(&key)
208-
.unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
208+
let mut job = read_job(&env, job_id);
209209

210210
if job.status != JobStatus::Open {
211211
panic_with_error!(&env, JobRegistryError::JobNotOpen);
@@ -214,25 +214,16 @@ impl JobRegistryContract {
214214
panic_with_error!(&env, JobRegistryError::Unauthorized);
215215
}
216216

217-
let bids: Vec<BidRecord> = env
217+
if !env
218218
.storage()
219219
.persistent()
220-
.get(&DataKey::Bids(job_id))
221-
.unwrap_or(Vec::new(&env));
222-
223-
let mut found = false;
224-
for bid in bids.iter() {
225-
if bid.freelancer == freelancer {
226-
found = true;
227-
break;
228-
}
229-
}
230-
if !found {
220+
.has(&DataKey::BidIndex(job_id, freelancer.clone()))
221+
{
231222
panic_with_error!(&env, JobRegistryError::BidNotFound);
232223
}
233224

234225
job.freelancer = Some(freelancer.clone());
235-
job.status = JobStatus::InProgress;
226+
job.status = JobStatus::Assigned;
236227
env.storage().persistent().set(&key, &job);
237228

238229
log!(
@@ -246,20 +237,16 @@ impl JobRegistryContract {
246237
.publish((symbol_short!("accept"), job_id), freelancer);
247238
}
248239

249-
/// Freelancer submits deliverable IPFS hash.
240+
/// Freelancer submits a deliverable CID.
250241
pub fn submit_deliverable(env: Env, job_id: u64, freelancer: Address, hash: Bytes) {
251242
ensure_initialized(&env);
252-
validate_hash(&env, &hash);
243+
validate_cid(&env, &hash);
253244
freelancer.require_auth();
254245

255246
let key = DataKey::Job(job_id);
256-
let mut job: JobRecord = env
257-
.storage()
258-
.persistent()
259-
.get(&key)
260-
.unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
247+
let mut job = read_job(&env, job_id);
261248

262-
if job.status != JobStatus::InProgress {
249+
if job.status != JobStatus::Assigned && job.status != JobStatus::InProgress {
263250
panic_with_error!(&env, JobRegistryError::InvalidStateTransition);
264251
}
265252
if job.freelancer != Some(freelancer.clone()) {
@@ -289,13 +276,12 @@ impl JobRegistryContract {
289276
admin.require_auth();
290277

291278
let key = DataKey::Job(job_id);
292-
let mut job: JobRecord = env
293-
.storage()
294-
.persistent()
295-
.get(&key)
296-
.unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound));
279+
let mut job = read_job(&env, job_id);
297280

298-
if job.status != JobStatus::InProgress && job.status != JobStatus::DeliverableSubmitted {
281+
if job.status != JobStatus::Assigned
282+
&& job.status != JobStatus::InProgress
283+
&& job.status != JobStatus::DeliverableSubmitted
284+
{
299285
panic_with_error!(&env, JobRegistryError::InvalidStateTransition);
300286
}
301287

@@ -308,18 +294,31 @@ impl JobRegistryContract {
308294

309295
pub fn get_job(env: Env, job_id: u64) -> JobRecord {
310296
ensure_initialized(&env);
311-
env.storage()
312-
.persistent()
313-
.get(&DataKey::Job(job_id))
314-
.unwrap_or_else(|| panic_with_error!(&env, JobRegistryError::JobNotFound))
297+
read_job(&env, job_id)
315298
}
316299

317300
pub fn get_bids(env: Env, job_id: u64) -> Vec<BidRecord> {
318301
ensure_initialized(&env);
319-
env.storage()
320-
.persistent()
321-
.get(&DataKey::Bids(job_id))
322-
.unwrap_or(Vec::new(&env))
302+
read_job(&env, job_id);
303+
304+
let bid_count = read_bid_count(&env, job_id);
305+
let mut bids = Vec::new(&env);
306+
let mut index = 0u32;
307+
while index < bid_count {
308+
bids.push_back(read_bid_at(&env, job_id, index));
309+
index += 1;
310+
}
311+
bids
312+
}
313+
314+
pub fn get_bid_at(env: Env, job_id: u64, index: u32) -> BidRecord {
315+
ensure_initialized(&env);
316+
read_job(&env, job_id);
317+
let bid_count = read_bid_count(&env, job_id);
318+
if index >= bid_count {
319+
panic_with_error!(&env, JobRegistryError::BidIndexOutOfBounds);
320+
}
321+
read_bid_at(&env, job_id, index)
323322
}
324323

325324
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) {
360359
if budget <= 0 {
361360
panic_with_error!(env, JobRegistryError::InvalidBudget);
362361
}
363-
validate_hash(env, hash);
362+
validate_cid(env, hash);
364363
}
365364

366-
fn validate_hash(env: &Env, hash: &Bytes) {
367-
let len = hash.len();
368-
if len == 0 || len > MAX_HASH_LEN {
365+
fn validate_cid(env: &Env, cid: &Bytes) {
366+
let len = cid.len();
367+
if len == 0 || len > MAX_CID_LEN {
369368
panic_with_error!(env, JobRegistryError::InvalidHash);
370369
}
371370
}
372371

372+
fn read_job(env: &Env, job_id: u64) -> JobRecord {
373+
env.storage()
374+
.persistent()
375+
.get(&DataKey::Job(job_id))
376+
.unwrap_or_else(|| panic_with_error!(env, JobRegistryError::JobNotFound))
377+
}
378+
379+
fn read_bid_count(env: &Env, job_id: u64) -> u32 {
380+
env.storage()
381+
.persistent()
382+
.get(&DataKey::BidCount(job_id))
383+
.unwrap_or(0u32)
384+
}
385+
386+
fn read_bid_at(env: &Env, job_id: u64, index: u32) -> BidRecord {
387+
env.storage()
388+
.persistent()
389+
.get(&DataKey::Bid(job_id, index))
390+
.unwrap_or_else(|| panic_with_error!(env, JobRegistryError::BidIndexOutOfBounds))
391+
}
392+
373393
fn post_job_with_id(env: &Env, job_id: u64, client: Address, hash: Bytes, budget: i128) {
374394
let key = DataKey::Job(job_id);
375395
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
385405
};
386406
env.storage().persistent().set(&key, &job);
387407

388-
let bids: Vec<BidRecord> = Vec::new(env);
389408
env.storage()
390409
.persistent()
391-
.set(&DataKey::Bids(job_id), &bids);
410+
.set(&DataKey::BidCount(job_id), &0u32);
392411
}
393412

394413
#[cfg(test)]
@@ -512,7 +531,7 @@ mod test {
512531

513532
cc.accept_bid(&1u64, &client, &freelancer);
514533
let job = cc.get_job(&1u64);
515-
assert_eq!(job.status, JobStatus::InProgress);
534+
assert_eq!(job.status, JobStatus::Assigned);
516535
assert_eq!(job.freelancer, Some(freelancer.clone()));
517536

518537
let deliverable = Bytes::from_slice(&env, b"QmDeliverableHash");
@@ -539,6 +558,74 @@ mod test {
539558
cc.submit_bid(&1u64, &freelancer, &proposal);
540559
}
541560

561+
#[test]
562+
fn test_get_bid_at_reads_indexed_bid_rows() {
563+
let (env, cc, admin, client, freelancer) = setup();
564+
let second_freelancer = Address::generate(&env);
565+
cc.initialize(&admin);
566+
567+
let hash = Bytes::from_slice(&env, b"bafyJobCid");
568+
cc.post_job(&1u64, &client, &hash, &5000i128);
569+
570+
let proposal_one = Bytes::from_slice(&env, b"bafyProposalOne");
571+
let proposal_two = Bytes::from_slice(&env, b"bafyProposalTwo");
572+
cc.submit_bid(&1u64, &freelancer, &proposal_one);
573+
cc.submit_bid(&1u64, &second_freelancer, &proposal_two);
574+
575+
let first = cc.get_bid_at(&1u64, &0u32);
576+
let second = cc.get_bid_at(&1u64, &1u32);
577+
assert_eq!(first.freelancer, freelancer);
578+
assert_eq!(first.proposal_hash, proposal_one);
579+
assert_eq!(second.freelancer, second_freelancer);
580+
assert_eq!(second.proposal_hash, proposal_two);
581+
582+
let bids = cc.get_bids(&1u64);
583+
assert_eq!(bids.len(), 2);
584+
}
585+
586+
#[test]
587+
#[should_panic(expected = "Error(Contract, #15)")]
588+
fn test_get_bid_at_out_of_bounds_returns_specific_error() {
589+
let (env, cc, admin, client, _) = setup();
590+
cc.initialize(&admin);
591+
592+
let hash = Bytes::from_slice(&env, b"bafyJobCid");
593+
cc.post_job(&1u64, &client, &hash, &5000i128);
594+
595+
cc.get_bid_at(&1u64, &0u32);
596+
}
597+
598+
#[test]
599+
#[should_panic(expected = "Error(Contract, #5)")]
600+
fn test_rejects_oversized_metadata_cid() {
601+
let (env, cc, admin, client, _) = setup();
602+
cc.initialize(&admin);
603+
604+
let oversized = Bytes::from_slice(
605+
&env,
606+
b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
607+
);
608+
cc.post_job(&1u64, &client, &oversized, &5000i128);
609+
}
610+
611+
#[test]
612+
#[should_panic(expected = "Error(Contract, #8)")]
613+
fn test_late_bid_after_assignment_returns_specific_error() {
614+
let (env, cc, admin, client, freelancer) = setup();
615+
let late_freelancer = Address::generate(&env);
616+
cc.initialize(&admin);
617+
618+
let hash = Bytes::from_slice(&env, b"bafyJobCid");
619+
cc.post_job(&1u64, &client, &hash, &5000i128);
620+
621+
let proposal = Bytes::from_slice(&env, b"bafyProposal");
622+
cc.submit_bid(&1u64, &freelancer, &proposal);
623+
cc.accept_bid(&1u64, &client, &freelancer);
624+
625+
let late_proposal = Bytes::from_slice(&env, b"bafyLateProposal");
626+
cc.submit_bid(&1u64, &late_freelancer, &late_proposal);
627+
}
628+
542629
#[test]
543630
#[should_panic]
544631
fn test_accept_without_matching_bid_panics() {

0 commit comments

Comments
 (0)