diff --git a/contracts/job_registry/src/lib.rs b/contracts/job_registry/src/lib.rs index 4cab8cc7..b9084b77 100644 --- a/contracts/job_registry/src/lib.rs +++ b/contracts/job_registry/src/lib.rs @@ -371,18 +371,48 @@ impl JobRegistryContract { Ok(()) } - pub fn get_job(env: Env, job_id: u64) -> JobRecord { + /// Retrieves a job record by its ID. + /// + /// This is a view function that provides the full state of a job, + /// including its status, client, and assigned freelancer. + /// + /// # Arguments + /// * `env` - The Soroban environment + /// * `job_id` - The unique identifier of the job + /// + /// # Returns + /// * `Ok(JobRecord)` - The job record if found + /// * `Err(JobRegistryError::JobNotFound)` - If the job ID does not exist + pub fn get_job(env: Env, job_id: u64) -> Result { env.storage() .persistent() .get(&DataKey::Job(job_id)) - .expect("job not found") + .ok_or(JobRegistryError::JobNotFound) } - pub fn get_bids(env: Env, job_id: u64) -> Vec { - env.storage() + /// Retrieves all bids for a specific job. + /// + /// This is a view function that returns the history of all bids + /// submitted for a given job. If a job exists but has no bids, + /// an empty vector is returned. + /// + /// # Arguments + /// * `env` - The Soroban environment + /// * `job_id` - The unique identifier of the job + /// + /// # Returns + /// * `Ok(Vec)` - A vector of all bids submitted for the job + /// * `Err(JobRegistryError::JobNotFound)` - If the job ID does not exist + pub fn get_bids(env: Env, job_id: u64) -> Result, JobRegistryError> { + if !env.storage().persistent().has(&DataKey::Job(job_id)) { + return Err(JobRegistryError::JobNotFound); + } + + Ok(env + .storage() .persistent() .get(&DataKey::Bids(job_id)) - .unwrap_or_else(|| Vec::new(&env)) + .unwrap_or_else(|| Vec::new(&env))) } pub fn get_deliverable(env: Env, job_id: u64) -> Bytes { @@ -858,4 +888,24 @@ mod test { let empty_deliverable = Bytes::from_slice(&env, b""); cc.submit_deliverable(&1u64, &freelancer, &empty_deliverable); } + + #[test] + #[should_panic(expected = "Error(Contract, #1)")] + fn test_get_job_not_found() { + let env = Env::default(); + let contract_id = env.register_contract(None, JobRegistryContract); + let cc = JobRegistryContractClient::new(&env, &contract_id); + + cc.get_job(&999u64); + } + + #[test] + #[should_panic(expected = "Error(Contract, #1)")] + fn test_get_bids_job_not_found() { + let env = Env::default(); + let contract_id = env.register_contract(None, JobRegistryContract); + let cc = JobRegistryContractClient::new(&env, &contract_id); + + cc.get_bids(&999u64); + } } diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index 7daead64..899fec3e 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -4,6 +4,9 @@ use soroban_sdk::{ contract, contractimpl, contracttype, Address, Bytes, Env, IntoVal, Symbol, Vec, }; +mod profile; +mod storage; + // Types matching Job Registry contract's public types for cross-contract decoding #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -48,7 +51,6 @@ pub struct ReputationScore { #[contracttype] pub enum DataKey { - Score(Address, Role), Admin, JobRegistry, Reviewed(u64, Address), @@ -93,7 +95,13 @@ impl ReputationContract { // call JobRegistry.get_job(job_id) and decode into local JobRecord let get_sym = Symbol::new(&env, "get_job"); let args = soroban_sdk::vec![&env, job_id.into_val(&env)]; - let job: JobRecord = env.invoke_contract::(®istry_addr, &get_sym, args); + let job: JobRecord = env + .invoke_contract::>( + ®istry_addr, + &get_sym, + args, + ) + .unwrap(); // verify job is completed (ratings only allowed after completion) assert!(job.status == JobStatus::Completed, "job not completed"); @@ -115,21 +123,19 @@ impl ReputationContract { ); // update reputation aggregates for target - let mut rep = Self::get_score(env.clone(), target.clone(), Role::Freelancer); - // we'll treat target role as Freelancer for simplicity; callers should ensure correct role - rep.total_points = rep.total_points.saturating_add(score as i32); - rep.reviews = rep.reviews.saturating_add(1); - rep.total_jobs = rep.total_jobs.saturating_add(1); - - // compute new averaged score in basis points: avg = total_points / reviews, scaled - let avg = rep.total_points / (rep.reviews as i32); - let bps = avg.saturating_mul(2000); // 1->2000 ... 5->10000 - rep.score = Self::clamp_score(bps); + let mut profile = storage::read_profile_or_default(&env, &target); - env.storage() - .persistent() - .set(&DataKey::Score(rep.address.clone(), rep.role.clone()), &rep); + // We assume target is a freelancer for now in submit_rating + // In a more complex system, we might need to know which role was rated. + profile.freelancer_points = profile.freelancer_points.saturating_add(score as i32); + profile.freelancer_jobs = profile.freelancer_jobs.saturating_add(1); + // compute new averaged score in basis points: avg = total_points / jobs, scaled + let avg = profile.freelancer_points / (profile.freelancer_jobs as i32); + let bps = avg.saturating_mul(2000); // 1->2000 ... 5->10000 + profile.freelancer_score = Self::clamp_score(bps); + + storage::write_profile(&env, &target, &profile); env.storage().persistent().set(&reviewed_key, &true); } @@ -143,14 +149,21 @@ impl ReputationContract { .expect("not initialized"); admin.require_auth(); - let mut reputation = Self::get_score(env.clone(), address, role.clone()); - reputation.score = Self::clamp_score(reputation.score.saturating_add(delta)); - reputation.total_jobs = reputation.total_jobs.saturating_add(1); + let mut profile = storage::read_profile_or_default(&env, &address); + match role { + Role::Client => { + profile.client_score = + Self::clamp_score(profile.client_score.saturating_add(delta)); + profile.client_jobs = profile.client_jobs.saturating_add(1); + } + Role::Freelancer => { + profile.freelancer_score = + Self::clamp_score(profile.freelancer_score.saturating_add(delta)); + profile.freelancer_jobs = profile.freelancer_jobs.saturating_add(1); + } + } - env.storage().persistent().set( - &DataKey::Score(reputation.address.clone(), role), - &reputation, - ); + storage::write_profile(&env, &address, &profile); } /// Slash address for fraud / abandonment — reduces score by 20%. @@ -162,27 +175,53 @@ impl ReputationContract { .expect("not initialized"); admin.require_auth(); - let mut reputation = Self::get_score(env.clone(), address, role.clone()); - reputation.score = Self::clamp_score(reputation.score.saturating_sub(2000)); + let mut profile = storage::read_profile_or_default(&env, &address); + match role { + Role::Client => { + profile.client_score = Self::clamp_score(profile.client_score.saturating_sub(2000)); + } + Role::Freelancer => { + profile.freelancer_score = + Self::clamp_score(profile.freelancer_score.saturating_sub(2000)); + } + } - env.storage().persistent().set( - &DataKey::Score(reputation.address.clone(), role), - &reputation, - ); + storage::write_profile(&env, &address, &profile); } pub fn get_score(env: Env, address: Address, role: Role) -> ReputationScore { - env.storage() - .persistent() - .get(&DataKey::Score(address.clone(), role.clone())) - .unwrap_or_else(|| ReputationScore { + let profile = storage::read_profile_or_default(&env, &address); + match role { + Role::Client => ReputationScore { + address, + role: Role::Client, + score: profile.client_score, + total_jobs: profile.client_jobs, + total_points: profile.client_points, + reviews: profile.client_jobs, // reviews and total_jobs are unified + }, + Role::Freelancer => ReputationScore { address, - role, - score: 5000, - total_jobs: 0, - total_points: 0, - reviews: 0, - }) + role: Role::Freelancer, + score: profile.freelancer_score, + total_jobs: profile.freelancer_jobs, + total_points: profile.freelancer_points, + reviews: profile.freelancer_jobs, + }, + } + } + + /// Update profile metadata hash (IPFS CID) + pub fn update_profile_metadata(env: Env, address: Address, metadata_hash: Bytes) { + address.require_auth(); + let mut profile = storage::read_profile_or_default(&env, &address); + profile.metadata_hash = Some(metadata_hash); + storage::write_profile(&env, &address, &profile); + } + + /// Get profile metadata hash + pub fn get_profile_metadata(env: Env, address: Address) -> Option { + storage::read_profile(&env, &address).and_then(|p| p.metadata_hash) } /// Frontend-friendly aggregate metrics for public profile pages. @@ -266,4 +305,44 @@ mod test { let score = client.get_score(&address, &Role::Client); assert_eq!(score.score, 3000); // 5000 - 2000 } + + #[test] + fn test_profile_metadata() { + let env = Env::default(); + env.mock_all_auths(); + + let address = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &contract_id); + + let hash = Bytes::from_slice(&env, b"QmProfileHash"); + client.update_profile_metadata(&address, &hash); + + let saved_hash = client.get_profile_metadata(&address); + assert_eq!(saved_hash, Some(hash)); + } + + #[test] + fn test_unified_storage() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let address = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &contract_id); + + client.initialize(&admin); + + // Update freelancer score + client.update_score(&address, &Role::Freelancer, &1000); + // Update client score for SAME address + client.update_score(&address, &Role::Client, &500); + + let f_score = client.get_score(&address, &Role::Freelancer); + let c_score = client.get_score(&address, &Role::Client); + + assert_eq!(f_score.score, 6000); + assert_eq!(c_score.score, 5500); + } } diff --git a/contracts/reputation/src/profile.rs b/contracts/reputation/src/profile.rs new file mode 100644 index 00000000..5a47aa10 --- /dev/null +++ b/contracts/reputation/src/profile.rs @@ -0,0 +1,35 @@ +use soroban_sdk::{contracttype, Address, Bytes, Env}; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Profile { + pub address: Address, + pub client_score: i32, + pub client_points: i32, + pub client_jobs: u32, + pub freelancer_score: i32, + pub freelancer_points: i32, + pub freelancer_jobs: u32, + pub metadata_hash: Option, +} + +impl Profile { + pub fn new(_env: &Env, address: Address) -> Self { + Self { + address, + client_score: 5000, + client_points: 0, + client_jobs: 0, + freelancer_score: 5000, + freelancer_points: 0, + freelancer_jobs: 0, + metadata_hash: None, + } + } + + pub fn default(_env: Env) -> Self { + // This is tricky because we need an address. + // We'll leave it to the caller to provide an address. + panic!("Profile needs an address; use new(env, address)") + } +} diff --git a/contracts/reputation/src/storage.rs b/contracts/reputation/src/storage.rs index 0f56acbf..1b55d46f 100644 --- a/contracts/reputation/src/storage.rs +++ b/contracts/reputation/src/storage.rs @@ -1,21 +1,23 @@ -use soroban_sdk::{Address, Env}; use crate::profile::Profile; +use soroban_sdk::{Address, Env}; #[soroban_sdk::contracttype] -pub enum StorageKey { Profile(Address) } +pub enum StorageKey { + Profile(Address), +} pub fn read_profile(env: &Env, address: &Address) -> Option { - env.storage().persistent().get(&StorageKey::Profile(address.clone())) + env.storage() + .persistent() + .get(&StorageKey::Profile(address.clone())) } pub fn read_profile_or_default(env: &Env, address: &Address) -> Profile { - read_profile(env, address).unwrap_or_else(Profile::default) + read_profile(env, address).unwrap_or_else(|| Profile::new(env, address.clone())) } pub fn write_profile(env: &Env, address: &Address, profile: &Profile) { - env.storage().persistent().set(&StorageKey::Profile(address.clone()), profile); -} - -pub fn profile_exists(env: &Env, address: &Address) -> bool { - env.storage().persistent().has(&StorageKey::Profile(address.clone())) + env.storage() + .persistent() + .set(&StorageKey::Profile(address.clone()), profile); } diff --git a/docs/contracts/job_registry.md b/docs/contracts/job_registry.md index 0ecba519..70ec4c0e 100644 --- a/docs/contracts/job_registry.md +++ b/docs/contracts/job_registry.md @@ -28,10 +28,38 @@ The `JobRegistry` contract manages job postings, bid submissions, bid acceptance - `Unauthorized` (3): caller is not the job's client. - `BidNotFound` (6): selected freelancer did not submit a bid. -### Notes - This implementation strengthens trustlessness by ensuring bid acceptance can only succeed for bidders who actually participated in the auction. +## `get_job` + +### Purpose + +`get_job` is a view function that retrieves the full record of a specific job. + +### Behavior + +- Retrieves the `JobRecord` from persistent storage. +- Returns the job details if it exists. + +### Errors + +- `JobNotFound` (1): The specified job ID does not exist. + +## `get_bids` + +### Purpose + +`get_bids` is a view function that retrieves all bids submitted for a specific job. + +### Behavior + +- Verifies the job exists. +- Retrieves the list of `BidRecord`s 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. ## `submit_deliverable` ### Purpose