Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 55 additions & 5 deletions contracts/job_registry/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<JobRecord, JobRegistryError> {
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<BidRecord> {
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<BidRecord>)` - 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<Vec<BidRecord>, 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 {
Expand Down Expand Up @@ -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);
}
}
155 changes: 117 additions & 38 deletions contracts/reputation/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -48,7 +51,6 @@ pub struct ReputationScore {

#[contracttype]
pub enum DataKey {
Score(Address, Role),
Admin,
JobRegistry,
Reviewed(u64, Address),
Expand Down Expand Up @@ -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::<JobRecord>(&registry_addr, &get_sym, args);
let job: JobRecord = env
.invoke_contract::<Result<JobRecord, soroban_sdk::Error>>(
&registry_addr,
&get_sym,
args,
)
.unwrap();

// verify job is completed (ratings only allowed after completion)
assert!(job.status == JobStatus::Completed, "job not completed");
Expand All @@ -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);
}

Expand All @@ -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%.
Expand All @@ -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<Bytes> {
storage::read_profile(&env, &address).and_then(|p| p.metadata_hash)
}

/// Frontend-friendly aggregate metrics for public profile pages.
Expand Down Expand Up @@ -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);
}
}
35 changes: 35 additions & 0 deletions contracts/reputation/src/profile.rs
Original file line number Diff line number Diff line change
@@ -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<Bytes>,
}

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)")
}
}
20 changes: 11 additions & 9 deletions contracts/reputation/src/storage.rs
Original file line number Diff line number Diff line change
@@ -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<Profile> {
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);
}
32 changes: 30 additions & 2 deletions docs/contracts/job_registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading