@@ -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 ) ]
3233pub 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+
373393fn 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