diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index 38a5899c..a00991d4 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -62,6 +62,11 @@ pub enum DataKey { pub enum ReputationError { NotInitialized = 1, Unauthorized = 2, + InvalidInput = 3, + JobNotCompleted = 4, + NotJobParticipant = 5, + AlreadyReviewed = 6, + ContractStateError = 7, } #[contracttype] @@ -72,6 +77,32 @@ pub struct ContractUpgradedEvent { pub upgraded_at: u64, } +#[contracttype] +#[derive(Clone)] +pub struct ReputationUpdatedEvent { + pub job_id: u64, + pub caller: Address, + pub target: Address, + pub role: Role, + pub rating: u32, + pub new_score: i32, + pub total_jobs: u32, + pub total_points: i32, + pub reviews: u32, + pub updated_at: u64, +} + +#[contracttype] +#[derive(Clone)] +pub struct ScoreAdjustedEvent { + pub address: Address, + pub role: Role, + pub delta: i32, + pub new_score: i32, + pub total_jobs: u32, + pub adjusted_at: u64, +} + #[contract] pub struct ReputationContract; @@ -88,6 +119,10 @@ impl ReputationContract { .extend_ttl(Self::INSTANCE_TTL_THRESHOLD, Self::INSTANCE_TTL_EXTEND_TO); } + fn score_from_rating(score: u32) -> i32 { + (score as i32).saturating_mul(2_000) + } + /// Upgrades the current contract WASM. Only callable by admin. pub fn upgrade( env: Env, @@ -149,20 +184,17 @@ impl ReputationContract { /// Submit a rating for a target address tied to a Job ID. Caller must be the client or freelancer /// on the job, and the job must be Completed. pub fn submit_rating(env: Env, caller: Address, job_id: u64, target: Address, score: u32) { - // caller must authorize caller.require_auth(); + if !(1u32..=5u32).contains(&score) { + soroban_sdk::panic_with_error!(&env, ReputationError::InvalidInput); + } - // validate score in 1..=5 - assert!((1u32..=5u32).contains(&score), "score out of range"); - - // ensure job registry is configured let registry_addr: Address = env .storage() .instance() .get(&DataKey::JobRegistry) .expect("job registry not set"); - // 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 @@ -173,37 +205,53 @@ impl ReputationContract { ) .unwrap(); - // verify job is completed (ratings only allowed after completion) - assert!(job.status == JobStatus::Completed, "job not completed"); + if job.status != JobStatus::Completed { + soroban_sdk::panic_with_error!(&env, ReputationError::JobNotCompleted); + } - // verify caller is participant let caller_addr = caller.clone(); let is_client = caller_addr == job.client; let is_freelancer = match job.freelancer.clone() { Some(f) => caller_addr == f, None => false, }; - assert!(is_client || is_freelancer, "unauthorized to rate"); + if !(is_client || is_freelancer) { + soroban_sdk::panic_with_error!(&env, ReputationError::Unauthorized); + } - // prevent double review let reviewed_key = DataKey::Reviewed(job_id, caller.clone()); - assert!( - !env.storage().persistent().has(&reviewed_key), - "already reviewed" - ); + if env.storage().persistent().has(&reviewed_key) { + soroban_sdk::panic_with_error!(&env, ReputationError::AlreadyReviewed); + } - // update reputation aggregates for target let mut profile = storage::read_profile_or_default(&env, &target); - - // 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); + let (role, total_points, total_jobs, new_score) = if target == job.client { + profile.client_points = profile.client_points.saturating_add(score as i32); + profile.client_jobs = profile.client_jobs.saturating_add(1); + let avg = profile.client_points / (profile.client_jobs as i32); + let bps = Self::score_from_rating(avg as u32); + profile.client_score = Self::clamp_score(bps); + ( + Role::Client, + profile.client_points, + profile.client_jobs, + profile.client_score, + ) + } else if job.freelancer.as_ref() == Some(&target) { + profile.freelancer_points = profile.freelancer_points.saturating_add(score as i32); + profile.freelancer_jobs = profile.freelancer_jobs.saturating_add(1); + let avg = profile.freelancer_points / (profile.freelancer_jobs as i32); + let bps = Self::score_from_rating(avg as u32); + profile.freelancer_score = Self::clamp_score(bps); + ( + Role::Freelancer, + profile.freelancer_points, + profile.freelancer_jobs, + profile.freelancer_score, + ) + } else { + soroban_sdk::panic_with_error!(&env, ReputationError::NotJobParticipant); + }; storage::write_profile(&env, &target, &profile); env.storage().persistent().set(&reviewed_key, &true); @@ -212,6 +260,21 @@ impl ReputationContract { Self::PERSISTENT_TTL_THRESHOLD, Self::PERSISTENT_TTL_EXTEND_TO, ); + env.events().publish( + ("reputation", "ReputationUpdated"), + ReputationUpdatedEvent { + job_id, + caller, + target, + role, + rating: score, + new_score, + total_jobs, + total_points, + reviews: total_jobs, + updated_at: env.ledger().timestamp(), + }, + ); Self::bump_instance_ttl(&env); } @@ -226,20 +289,33 @@ impl ReputationContract { admin.require_auth(); let mut profile = storage::read_profile_or_default(&env, &address); - match role { + let (new_score, total_jobs) = 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); + (profile.client_score, profile.client_jobs) } Role::Freelancer => { profile.freelancer_score = Self::clamp_score(profile.freelancer_score.saturating_add(delta)); profile.freelancer_jobs = profile.freelancer_jobs.saturating_add(1); + (profile.freelancer_score, profile.freelancer_jobs) } - } + }; storage::write_profile(&env, &address, &profile); + env.events().publish( + ("reputation", "ScoreAdjusted"), + ScoreAdjustedEvent { + address, + role, + delta, + new_score, + total_jobs, + adjusted_at: env.ledger().timestamp(), + }, + ); Self::bump_instance_ttl(&env); } @@ -253,17 +329,30 @@ impl ReputationContract { admin.require_auth(); let mut profile = storage::read_profile_or_default(&env, &address); - match role { + let (new_score, total_jobs) = match role { Role::Client => { profile.client_score = Self::clamp_score(profile.client_score.saturating_sub(2000)); + (profile.client_score, profile.client_jobs) } Role::Freelancer => { profile.freelancer_score = Self::clamp_score(profile.freelancer_score.saturating_sub(2000)); + (profile.freelancer_score, profile.freelancer_jobs) } - } + }; storage::write_profile(&env, &address, &profile); + env.events().publish( + ("reputation", "ScoreAdjusted"), + ScoreAdjustedEvent { + address, + role, + delta: -2_000, + new_score, + total_jobs, + adjusted_at: env.ledger().timestamp(), + }, + ); Self::bump_instance_ttl(&env); } @@ -337,6 +426,28 @@ mod test { use soroban_sdk::testutils::Address as _; use soroban_sdk::{Address, BytesN, Env}; + #[contract] + pub struct MockJobRegistry; + + #[contracttype] + enum MockKey { + Job(u64), + } + + #[contractimpl] + impl MockJobRegistry { + pub fn set_job(env: Env, job_id: u64, job: JobRecord) { + env.storage().persistent().set(&MockKey::Job(job_id), &job); + } + + pub fn get_job(env: Env, _job_id: u64) -> Result { + env.storage() + .persistent() + .get(&MockKey::Job(_job_id)) + .ok_or_else(|| soroban_sdk::Error::from_contract_error(1)) + } + } + #[test] fn test_initial_score() { let env = Env::default(); @@ -428,6 +539,55 @@ mod test { assert_eq!(c_score.score, 5500); } + #[test] + fn test_submit_rating_updates_client_and_freelancer_paths() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let caller = Address::generate(&env); + let target = Address::generate(&env); + let freelancer = Address::generate(&env); + let caller2 = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &contract_id); + client.initialize(&admin); + + let mock_id = env.register_contract(None, MockJobRegistry); + client.set_job_registry(&admin, &mock_id); + + let job = JobRecord { + client: caller.clone(), + freelancer: Some(freelancer.clone()), + metadata_hash: Bytes::from_slice(&env, b"QmJob"), + budget_stroops: 10, + status: JobStatus::Completed, + }; + let mock_client = MockJobRegistryClient::new(&env, &mock_id); + mock_client.set_job(&7u64, &job); + let other_job = JobRecord { + client: caller2.clone(), + freelancer: Some(target.clone()), + metadata_hash: Bytes::from_slice(&env, b"QmJob2"), + budget_stroops: 10, + status: JobStatus::Completed, + }; + mock_client.set_job(&8u64, &other_job); + + client.submit_rating(&caller, &7u64, &freelancer, &5u32); + let client_score = client.get_score(&freelancer, &Role::Freelancer); + assert_eq!(client_score.score, 10_000); + assert_eq!(client_score.total_jobs, 1); + assert_eq!(client_score.total_points, 5); + + client.submit_rating(&caller2, &8u64, &target, &4u32); + let freelancer_score = client.get_score(&target, &Role::Freelancer); + assert_eq!(freelancer_score.score, 8_000); + assert_eq!(freelancer_score.total_jobs, 1); + assert_eq!(freelancer_score.total_points, 4); + assert_eq!(freelancer_score.reviews, 1); + } + #[test] #[should_panic(expected = "Error(Contract, #2)")] fn test_upgrade_requires_admin() { diff --git a/docs/contracts/reputation_score_update_logic.md b/docs/contracts/reputation_score_update_logic.md new file mode 100644 index 00000000..99bfaa16 --- /dev/null +++ b/docs/contracts/reputation_score_update_logic.md @@ -0,0 +1,38 @@ +# Reputation Score Update Logic + +## Overview + +This document describes the Lance reputation contract update flow for on-chain rating and administrative score adjustments. + +## Behavior + +- Ratings are only accepted after a job reaches `Completed`. +- Only job participants may rate a target address. +- A participant can rate each job only once. +- Ratings update the correct role bucket: + - `Client` when the target is the job client. + - `Freelancer` when the target is the assigned freelancer. +- Administrative score updates and slashing emit reputation events for off-chain auditability. + +## Validation + +- Rating input is restricted to the `1..=5` range. +- Non-participants are rejected with a dedicated Soroban error code. +- Duplicate reviews are rejected with a dedicated Soroban error code. +- Score values are clamped to the `0..=10000` basis-point range. + +## Logging and Auditability + +The contract emits state-changing events for: + +- contract upgrades +- score adjustments +- rating submissions + +These events are intended for backend indexing and debugging in Testnet and production environments. + +## Files + +- `contracts/reputation/src/lib.rs` +- `contracts/reputation/src/storage.rs` +- `contracts/reputation/src/profile.rs`