From fcb2f0ae026d1405a5e21c1d6d6ce4862bb2d4f3 Mon Sep 17 00:00:00 2001 From: Aniekan Victory Date: Wed, 27 May 2026 16:21:59 +0100 Subject: [PATCH 1/2] feat(frontend): add route-level error UI and wrap proposals list with ErrorBoundary (#453) --- contracts/reputation/src/lib.rs | 765 +++++++++++++++++++--------- contracts/reputation/src/profile.rs | 66 ++- contracts/reputation/src/storage.rs | 2 +- 3 files changed, 580 insertions(+), 253 deletions(-) diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index 6ec8fefe..ad28fe62 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -8,7 +8,8 @@ use soroban_sdk::{ mod profile; mod storage; -// Types matching Job Registry contract's public types for cross-contract decoding +use profile::{Profile, RoleMetrics}; + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum JobStatus { @@ -41,13 +42,13 @@ pub enum Role { pub struct ReputationScore { pub address: Address, pub role: Role, - /// Score in basis points (0–10000 = 0–100%) pub score: i32, pub total_jobs: u32, - /// Sum of raw rating points (1-5) to compute aggregates off-chain - pub total_points: i32, - /// Number of reviews counted + pub total_points: i128, pub reviews: u32, + pub average_rating_bps: i32, + pub badge_level: u32, + pub blacklisted: bool, } #[contracttype] @@ -56,12 +57,14 @@ pub struct ReputationView { pub address: Address, pub client: ReputationScore, pub freelancer: ReputationScore, + pub is_blacklisted: bool, } #[contracttype] pub enum DataKey { Admin, JobRegistry, + AuthorizedUpdater, Reviewed(u64, Address), } @@ -75,6 +78,7 @@ pub enum ReputationError { NotJobParticipant = 5, AlreadyReviewed = 6, ContractStateError = 7, + Blacklisted = 8, } #[contracttype] @@ -95,8 +99,11 @@ pub struct ReputationUpdatedEvent { pub rating: u32, pub new_score: i32, pub total_jobs: u32, - pub total_points: i32, + pub total_points: i128, pub reviews: u32, + pub average_rating_bps: i32, + pub badge_level: u32, + pub blacklisted: bool, pub updated_at: u64, } @@ -108,9 +115,28 @@ pub struct ScoreAdjustedEvent { pub delta: i32, pub new_score: i32, pub total_jobs: u32, + pub badge_level: u32, pub adjusted_at: u64, } +#[contracttype] +#[derive(Clone)] +pub struct AuthorizedContractUpdatedEvent { + pub by_admin: Address, + pub contract_address: Address, + pub updated_at: u64, +} + +#[contracttype] +#[derive(Clone)] +pub struct BlacklistUpdatedEvent { + pub address: Address, + pub is_blacklisted: bool, + pub client_score: i32, + pub freelancer_score: i32, + pub updated_at: u64, +} + #[contract] pub struct ReputationContract; @@ -120,6 +146,11 @@ impl ReputationContract { const INSTANCE_TTL_EXTEND_TO: u32 = 150_000; const PERSISTENT_TTL_THRESHOLD: u32 = 50_000; const PERSISTENT_TTL_EXTEND_TO: u32 = 150_000; + const SCORE_SCALE: i128 = 10_000; + const MAX_RATING: i128 = 5; + const DEFAULT_SCORE_BPS: i32 = 5_000; + const SLASH_DECAY_BPS: i32 = 8_000; + const BLACKLIST_DECAY_BPS: i32 = 1_000; fn bump_instance_ttl(env: &Env) { env.storage() @@ -127,36 +158,156 @@ 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) + fn clamp_score(value: i32) -> i32 { + value.clamp(0, 10_000) + } + + fn clamp_score_i128(value: i128) -> i32 { + Self::clamp_score(value.clamp(0, Self::SCORE_SCALE) as i32) + } + + fn read_admin(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| soroban_sdk::panic_with_error!(env, ReputationError::NotInitialized)) + } + + fn read_authorized_updater(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::AuthorizedUpdater) + .unwrap_or_else(|| soroban_sdk::panic_with_error!(env, ReputationError::NotInitialized)) + } + + fn read_job_registry(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::JobRegistry) + .unwrap_or_else(|| soroban_sdk::panic_with_error!(env, ReputationError::NotInitialized)) + } + + fn require_admin(env: &Env, admin: &Address) { + let configured_admin = Self::read_admin(env); + admin.require_auth(); + if *admin != configured_admin { + soroban_sdk::panic_with_error!(env, ReputationError::Unauthorized); + } + } + + fn require_authorized_contract(env: &Env, caller_contract: &Address) { + let authorized_contract = Self::read_authorized_updater(env); + caller_contract.require_auth(); + if *caller_contract != authorized_contract { + soroban_sdk::panic_with_error!(env, ReputationError::Unauthorized); + } + } + + fn role_metrics(profile: &Profile, role: &Role) -> &RoleMetrics { + match role { + Role::Client => &profile.client, + Role::Freelancer => &profile.freelancer, + } + } + + fn role_metrics_mut(profile: &mut Profile, role: &Role) -> &mut RoleMetrics { + match role { + Role::Client => &mut profile.client, + Role::Freelancer => &mut profile.freelancer, + } } fn score_from_profile( address: &Address, role: Role, - profile: &profile::Profile, + profile: &Profile, ) -> ReputationScore { - match role { - Role::Client => ReputationScore { - address: address.clone(), - role: Role::Client, - score: profile.client_score, - total_jobs: profile.client_jobs, - total_points: profile.client_points, - reviews: profile.client_jobs, - }, - Role::Freelancer => ReputationScore { - address: address.clone(), - role: Role::Freelancer, - score: profile.freelancer_score, - total_jobs: profile.freelancer_jobs, - total_points: profile.freelancer_points, - reviews: profile.freelancer_jobs, - }, + let metrics = Self::role_metrics(profile, &role); + ReputationScore { + address: address.clone(), + role, + score: metrics.score, + total_jobs: metrics.completed_jobs, + total_points: metrics.review.total_points, + reviews: metrics.review.reviews, + average_rating_bps: metrics.review.average_rating_bps, + badge_level: metrics.badge_level, + blacklisted: profile.is_blacklisted, } } - /// Upgrades the current contract WASM. Only callable by admin. + fn checked_add_points(env: &Env, current: i128, incoming: u32) -> i128 { + current + .checked_add(incoming as i128) + .unwrap_or_else(|| soroban_sdk::panic_with_error!(env, ReputationError::ContractStateError)) + } + + fn average_rating_bps(env: &Env, total_points: i128, reviews: u32) -> i32 { + if reviews == 0 { + return Self::DEFAULT_SCORE_BPS; + } + + let numerator = total_points + .checked_mul(Self::SCORE_SCALE) + .unwrap_or_else(|| soroban_sdk::panic_with_error!(env, ReputationError::ContractStateError)); + let denominator = (reviews as i128) + .checked_mul(Self::MAX_RATING) + .unwrap_or_else(|| soroban_sdk::panic_with_error!(env, ReputationError::ContractStateError)); + + if denominator == 0 { + return Self::DEFAULT_SCORE_BPS; + } + + Self::clamp_score_i128(numerator / denominator) + } + + fn apply_decay_bps(env: &Env, score: i32, decay_bps: i32) -> i32 { + let decayed = (score as i128) + .checked_mul(decay_bps as i128) + .unwrap_or_else(|| soroban_sdk::panic_with_error!(env, ReputationError::ContractStateError)) + / Self::SCORE_SCALE; + Self::clamp_score_i128(decayed) + } + + fn badge_level(metrics: &RoleMetrics, is_blacklisted: bool) -> u32 { + if is_blacklisted { + 0 + } else if metrics.completed_jobs >= 5 && metrics.score >= 9_500 { + 3 + } else if metrics.completed_jobs >= 3 && metrics.score >= 8_500 { + 2 + } else if metrics.completed_jobs >= 1 && metrics.score >= 7_000 { + 1 + } else { + 0 + } + } + + fn refresh_badge(metrics: &mut RoleMetrics, is_blacklisted: bool) { + metrics.badge_level = Self::badge_level(metrics, is_blacklisted); + } + + fn apply_review(env: &Env, metrics: &mut RoleMetrics, score: u32, is_blacklisted: bool) { + metrics.review.total_points = + Self::checked_add_points(env, metrics.review.total_points, score); + metrics.review.reviews = metrics.review.reviews.saturating_add(1); + metrics.completed_jobs = metrics.completed_jobs.saturating_add(1); + metrics.review.average_rating_bps = + Self::average_rating_bps(env, metrics.review.total_points, metrics.review.reviews); + metrics.score = metrics.review.average_rating_bps; + Self::refresh_badge(metrics, is_blacklisted); + } + + fn apply_manual_delta(metrics: &mut RoleMetrics, delta: i32, is_blacklisted: bool) { + metrics.score = Self::clamp_score(metrics.score.saturating_add(delta)); + Self::refresh_badge(metrics, is_blacklisted); + } + + fn apply_role_decay(env: &Env, metrics: &mut RoleMetrics, decay_bps: i32, is_blacklisted: bool) { + metrics.score = Self::apply_decay_bps(env, metrics.score, decay_bps); + Self::refresh_badge(metrics, is_blacklisted); + } + pub fn upgrade( env: Env, caller: Address, @@ -165,12 +316,7 @@ impl ReputationContract { Self::bump_instance_ttl(&env); caller.require_auth(); - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .ok_or(ReputationError::NotInitialized)?; - + let admin = Self::read_admin(&env); if caller != admin { return Err(ReputationError::Unauthorized); } @@ -197,37 +343,35 @@ impl ReputationContract { Self::bump_instance_ttl(&env); } - /// Set the JobRegistry contract address (admin only) pub fn set_job_registry(env: Env, admin: Address, registry: Address) { - let configured_admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .expect("not initialized"); - - admin.require_auth(); - assert!(admin == configured_admin, "only admin can set job registry"); + Self::require_admin(&env, &admin); + env.storage().instance().set(&DataKey::JobRegistry, ®istry); + Self::bump_instance_ttl(&env); + } + pub fn set_authorized_contract(env: Env, admin: Address, contract_address: Address) { + Self::require_admin(&env, &admin); env.storage() .instance() - .set(&DataKey::JobRegistry, ®istry); + .set(&DataKey::AuthorizedUpdater, &contract_address); + env.events().publish( + ("reputation", "AuthorizedContractUpdated"), + AuthorizedContractUpdatedEvent { + by_admin: admin, + contract_address, + updated_at: env.ledger().timestamp(), + }, + ); Self::bump_instance_ttl(&env); } - /// 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.require_auth(); if !(1u32..=5u32).contains(&score) { soroban_sdk::panic_with_error!(&env, ReputationError::InvalidInput); } - let registry_addr: Address = env - .storage() - .instance() - .get(&DataKey::JobRegistry) - .expect("job registry not set"); - + let registry_addr = Self::read_job_registry(&env); let get_sym = Symbol::new(&env, "get_job"); let args = soroban_sdk::vec![&env, job_id.into_val(&env)]; let job: JobRecord = env @@ -245,9 +389,10 @@ impl ReputationContract { let caller_addr = caller.clone(); let is_client = caller_addr == job.client; let is_freelancer = match job.freelancer.clone() { - Some(f) => caller_addr == f, + Some(freelancer) => caller_addr == freelancer, None => false, }; + if !(is_client || is_freelancer) { soroban_sdk::panic_with_error!(&env, ReputationError::Unauthorized); } @@ -258,33 +403,38 @@ impl ReputationContract { } let mut profile = storage::read_profile_or_default(&env, &target); - 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); - }; + if profile.is_blacklisted { + soroban_sdk::panic_with_error!(&env, ReputationError::Blacklisted); + } + + let (role, total_points, total_jobs, new_score, reviews, average_rating_bps, badge_level) = + if target == job.client { + let is_blacklisted = profile.is_blacklisted; + Self::apply_review(&env, &mut profile.client, score, is_blacklisted); + ( + Role::Client, + profile.client.review.total_points, + profile.client.completed_jobs, + profile.client.score, + profile.client.review.reviews, + profile.client.review.average_rating_bps, + profile.client.badge_level, + ) + } else if job.freelancer.as_ref() == Some(&target) { + let is_blacklisted = profile.is_blacklisted; + Self::apply_review(&env, &mut profile.freelancer, score, is_blacklisted); + ( + Role::Freelancer, + profile.freelancer.review.total_points, + profile.freelancer.completed_jobs, + profile.freelancer.score, + profile.freelancer.review.reviews, + profile.freelancer.review.average_rating_bps, + profile.freelancer.badge_level, + ) + } else { + soroban_sdk::panic_with_error!(&env, ReputationError::NotJobParticipant); + }; storage::write_profile(&env, &target, &profile); env.storage().persistent().set(&reviewed_key, &true); @@ -304,38 +454,31 @@ impl ReputationContract { new_score, total_jobs, total_points, - reviews: total_jobs, + reviews, + average_rating_bps, + badge_level, + blacklisted: profile.is_blacklisted, updated_at: env.ledger().timestamp(), }, ); Self::bump_instance_ttl(&env); } - /// Update reputation after a completed job. `delta` in basis points. - /// Score is clamped to [0, 10000]. - pub fn update_score(env: Env, address: Address, role: Role, delta: i32) { - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .expect("not initialized"); - admin.require_auth(); + pub fn update_score(env: Env, caller_contract: Address, address: Address, role: Role, delta: i32) { + Self::require_authorized_contract(&env, &caller_contract); let mut profile = storage::read_profile_or_default(&env, &address); - 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) - } - }; + if profile.is_blacklisted { + soroban_sdk::panic_with_error!(&env, ReputationError::Blacklisted); + } + + let is_blacklisted = profile.is_blacklisted; + let metrics = Self::role_metrics_mut(&mut profile, &role); + let previous_score = metrics.score; + Self::apply_manual_delta(metrics, delta, is_blacklisted); + let new_score = metrics.score; + let total_jobs = metrics.completed_jobs; + let badge_level = metrics.badge_level; storage::write_profile(&env, &address, &profile); env.events().publish( @@ -343,36 +486,31 @@ impl ReputationContract { ScoreAdjustedEvent { address, role, - delta, + delta: new_score.saturating_sub(previous_score), new_score, total_jobs, + badge_level, adjusted_at: env.ledger().timestamp(), }, ); Self::bump_instance_ttl(&env); } - /// Slash address for fraud / abandonment — reduces score by 20%. - pub fn slash(env: Env, address: Address, role: Role, _reason: Symbol) { - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .expect("not initialized"); - admin.require_auth(); + pub fn slash(env: Env, caller_contract: Address, address: Address, role: Role, _reason: Symbol) { + Self::require_authorized_contract(&env, &caller_contract); let mut profile = storage::read_profile_or_default(&env, &address); - 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) - } - }; + if profile.is_blacklisted { + soroban_sdk::panic_with_error!(&env, ReputationError::Blacklisted); + } + + let is_blacklisted = profile.is_blacklisted; + let metrics = Self::role_metrics_mut(&mut profile, &role); + let previous_score = metrics.score; + Self::apply_role_decay(&env, metrics, Self::SLASH_DECAY_BPS, is_blacklisted); + let new_score = metrics.score; + let total_jobs = metrics.completed_jobs; + let badge_level = metrics.badge_level; storage::write_profile(&env, &address, &profile); env.events().publish( @@ -380,22 +518,61 @@ impl ReputationContract { ScoreAdjustedEvent { address, role, - delta: -2_000, + delta: new_score.saturating_sub(previous_score), new_score, total_jobs, + badge_level, adjusted_at: env.ledger().timestamp(), }, ); Self::bump_instance_ttl(&env); } + pub fn blacklist_profile(env: Env, caller_contract: Address, address: Address, _reason: Symbol) { + Self::require_authorized_contract(&env, &caller_contract); + + let mut profile = storage::read_profile_or_default(&env, &address); + if !profile.is_blacklisted { + profile.is_blacklisted = true; + let is_blacklisted = profile.is_blacklisted; + Self::apply_role_decay(&env, &mut profile.client, Self::BLACKLIST_DECAY_BPS, is_blacklisted); + Self::apply_role_decay( + &env, + &mut profile.freelancer, + Self::BLACKLIST_DECAY_BPS, + is_blacklisted, + ); + } + + let client_score = profile.client.score; + let freelancer_score = profile.freelancer.score; + storage::write_profile(&env, &address, &profile); + env.events().publish( + ("reputation", "BlacklistUpdated"), + BlacklistUpdatedEvent { + address, + is_blacklisted: true, + client_score, + freelancer_score, + updated_at: env.ledger().timestamp(), + }, + ); + Self::bump_instance_ttl(&env); + } + + pub fn is_blacklisted(env: Env, address: Address) -> bool { + Self::bump_instance_ttl(&env); + storage::read_profile(&env, &address) + .map(|profile| profile.is_blacklisted) + .unwrap_or(false) + } + pub fn get_score(env: Env, address: Address, role: Role) -> ReputationScore { Self::bump_instance_ttl(&env); let profile = storage::read_profile_or_default(&env, &address); Self::score_from_profile(&address, role, &profile) } - /// 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); @@ -404,14 +581,11 @@ impl ReputationContract { Self::bump_instance_ttl(&env); } - /// Get profile metadata hash pub fn get_profile_metadata(env: Env, address: Address) -> Option { Self::bump_instance_ttl(&env); - storage::read_profile(&env, &address).and_then(|p| p.metadata_hash) + storage::read_profile(&env, &address).and_then(|profile| profile.metadata_hash) } - /// Frontend-friendly aggregate metrics for public profile pages. - /// Returns: [score_bps, total_jobs, total_points, reviews] pub fn get_public_metrics(env: Env, address: Address, role_name: Symbol) -> Vec { let role = if role_name == Symbol::new(&env, "client") { Role::Client @@ -420,17 +594,19 @@ impl ReputationContract { } else { soroban_sdk::panic_with_error!(&env, ReputationError::InvalidInput); }; - let rep = Self::get_score(env.clone(), address, role); + let rep = Self::get_score(env.clone(), address, role); let mut metrics = Vec::new(&env); metrics.push_back(rep.score as i128); metrics.push_back(rep.total_jobs as i128); - metrics.push_back(rep.total_points as i128); + metrics.push_back(rep.total_points); metrics.push_back(rep.reviews as i128); + metrics.push_back(rep.badge_level as i128); + metrics.push_back(rep.average_rating_bps as i128); + metrics.push_back(if rep.blacklisted { 1 } else { 0 }); metrics } - /// Read both role snapshots for a single address in one call. pub fn query_reputation(env: Env, address: Address) -> ReputationView { Self::bump_instance_ttl(&env); let profile = storage::read_profile_or_default(&env, &address); @@ -440,16 +616,11 @@ impl ReputationContract { address, client, freelancer, + is_blacklisted: profile.is_blacklisted, } } } -impl ReputationContract { - fn clamp_score(value: i32) -> i32 { - value.clamp(0, 10_000) - } -} - #[cfg(test)] mod test { use super::*; @@ -470,188 +641,318 @@ mod test { env.storage().persistent().set(&MockKey::Job(job_id), &job); } - pub fn get_job(env: Env, _job_id: u64) -> Result { + pub fn get_job(env: Env, job_id: u64) -> Result { Ok(env .storage() .persistent() - .get(&MockKey::Job(_job_id)) + .get(&MockKey::Job(job_id)) .expect("mock job missing")) } } + #[contract] + pub struct AuthorizedAdjuster; + + #[contractimpl] + impl AuthorizedAdjuster { + pub fn award(env: Env, reputation: Address, target: Address, role: Role, delta: i32) { + let reputation_client = ReputationContractClient::new(&env, &reputation); + let caller_contract = env.current_contract_address(); + reputation_client.update_score(&caller_contract, &target, &role, &delta); + } + + pub fn slash(env: Env, reputation: Address, target: Address, role: Role, reason: Symbol) { + let reputation_client = ReputationContractClient::new(&env, &reputation); + let caller_contract = env.current_contract_address(); + reputation_client.slash(&caller_contract, &target, &role, &reason); + } + + pub fn blacklist(env: Env, reputation: Address, target: Address, reason: Symbol) { + let reputation_client = ReputationContractClient::new(&env, &reputation); + let caller_contract = env.current_contract_address(); + reputation_client.blacklist_profile(&caller_contract, &target, &reason); + } + } + + fn setup_job( + env: &Env, + registry: &Address, + job_id: u64, + client_address: &Address, + freelancer: &Address, + ) { + let job = JobRecord { + client: client_address.clone(), + freelancer: Some(freelancer.clone()), + metadata_hash: Bytes::from_slice(env, b"QmJob"), + budget_stroops: 10, + status: JobStatus::Completed, + }; + let registry_client = MockJobRegistryClient::new(env, registry); + registry_client.set_job(&job_id, &job); + } + #[test] - fn test_initial_score() { + fn test_empty_profile_reads_are_safe() { let env = Env::default(); let address = Address::generate(&env); let contract_id = env.register_contract(None, ReputationContract); let client = ReputationContractClient::new(&env, &contract_id); let score = client.get_score(&address, &Role::Freelancer); - assert_eq!(score.score, 5000); + assert_eq!(score.score, 5_000); assert_eq!(score.total_jobs, 0); + assert_eq!(score.total_points, 0); + assert_eq!(score.reviews, 0); + assert_eq!(score.average_rating_bps, 5_000); + assert_eq!(score.badge_level, 0); + assert!(!score.blacklisted); + + let view = client.query_reputation(&address); + assert_eq!(view.client.score, 5_000); + assert_eq!(view.freelancer.score, 5_000); + assert!(!view.is_blacklisted); + + let metadata = client.get_profile_metadata(&address); + assert_eq!(metadata, None); } #[test] - fn test_update_score() { + fn test_authorized_contract_updates_score() { 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); + let target = Address::generate(&env); + let reputation_id = env.register_contract(None, ReputationContract); + let adjuster_id = env.register_contract(None, AuthorizedAdjuster); + let client = ReputationContractClient::new(&env, &reputation_id); + let adjuster = AuthorizedAdjusterClient::new(&env, &adjuster_id); client.initialize(&admin); - client.update_score(&address, &Role::Freelancer, &500); + client.set_authorized_contract(&admin, &adjuster_id); - let score = client.get_score(&address, &Role::Freelancer); - assert_eq!(score.score, 5500); - assert_eq!(score.total_jobs, 1); + adjuster.award(&reputation_id, &target, &Role::Freelancer, &1_500); + + let score = client.get_score(&target, &Role::Freelancer); + assert_eq!(score.score, 6_500); + assert_eq!(score.total_jobs, 0); + assert_eq!(score.badge_level, 0); } #[test] - fn test_slash() { + fn test_slash_uses_fixed_point_decay() { 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); + let client_addr = Address::generate(&env); + let freelancer = Address::generate(&env); + let reputation_id = env.register_contract(None, ReputationContract); + let registry_id = env.register_contract(None, MockJobRegistry); + let adjuster_id = env.register_contract(None, AuthorizedAdjuster); + let client = ReputationContractClient::new(&env, &reputation_id); + let adjuster = AuthorizedAdjusterClient::new(&env, &adjuster_id); client.initialize(&admin); - client.slash( - &address, - &Role::Client, - &soroban_sdk::Symbol::new(&env, "fraud"), + client.set_job_registry(&admin, ®istry_id); + client.set_authorized_contract(&admin, &adjuster_id); + + setup_job(&env, ®istry_id, 1, &client_addr, &freelancer); + client.submit_rating(&client_addr, &1, &freelancer, &5); + + adjuster.slash( + &reputation_id, + &freelancer, + &Role::Freelancer, + &Symbol::new(&env, "fraud"), ); - let score = client.get_score(&address, &Role::Client); - assert_eq!(score.score, 3000); // 5000 - 2000 + let score = client.get_score(&freelancer, &Role::Freelancer); + assert_eq!(score.score, 8_000); + assert_eq!(score.badge_level, 1); } #[test] - fn test_profile_metadata() { + fn test_badge_upgrades_reflect_immediately_in_public_getters() { 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 admin = Address::generate(&env); + let freelancer = Address::generate(&env); + let client_one = Address::generate(&env); + let client_two = Address::generate(&env); + let client_three = Address::generate(&env); + let reputation_id = env.register_contract(None, ReputationContract); + let registry_id = env.register_contract(None, MockJobRegistry); + let client = ReputationContractClient::new(&env, &reputation_id); - let hash = Bytes::from_slice(&env, b"QmProfileHash"); - client.update_profile_metadata(&address, &hash); + client.initialize(&admin); + client.set_job_registry(&admin, ®istry_id); - let saved_hash = client.get_profile_metadata(&address); - assert_eq!(saved_hash, Some(hash)); + setup_job(&env, ®istry_id, 11, &client_one, &freelancer); + setup_job(&env, ®istry_id, 12, &client_two, &freelancer); + setup_job(&env, ®istry_id, 13, &client_three, &freelancer); + + client.submit_rating(&client_one, &11, &freelancer, &5); + let after_first = client.get_public_metrics(&freelancer, &Symbol::new(&env, "freelancer")); + assert_eq!(after_first.get(4), Some(1)); + + client.submit_rating(&client_two, &12, &freelancer, &5); + let after_second = client.get_public_metrics(&freelancer, &Symbol::new(&env, "freelancer")); + assert_eq!(after_second.get(4), Some(1)); + + client.submit_rating(&client_three, &13, &freelancer, &5); + let after_third = client.get_public_metrics(&freelancer, &Symbol::new(&env, "freelancer")); + assert_eq!(after_third.get(4), Some(2)); + assert_eq!(after_third.get(5), Some(10_000)); + + let score = client.get_score(&freelancer, &Role::Freelancer); + assert_eq!(score.badge_level, 2); + assert_eq!(score.total_jobs, 3); } #[test] - fn test_unified_storage() { + fn test_blacklist_clears_badges_and_sets_flag() { 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); + let freelancer = Address::generate(&env); + let client_one = Address::generate(&env); + let client_two = Address::generate(&env); + let client_three = Address::generate(&env); + let reputation_id = env.register_contract(None, ReputationContract); + let registry_id = env.register_contract(None, MockJobRegistry); + let adjuster_id = env.register_contract(None, AuthorizedAdjuster); + let client = ReputationContractClient::new(&env, &reputation_id); + let adjuster = AuthorizedAdjusterClient::new(&env, &adjuster_id); client.initialize(&admin); + client.set_job_registry(&admin, ®istry_id); + client.set_authorized_contract(&admin, &adjuster_id); + + setup_job(&env, ®istry_id, 21, &client_one, &freelancer); + setup_job(&env, ®istry_id, 22, &client_two, &freelancer); + setup_job(&env, ®istry_id, 23, &client_three, &freelancer); + + client.submit_rating(&client_one, &21, &freelancer, &5); + client.submit_rating(&client_two, &22, &freelancer, &5); + client.submit_rating(&client_three, &23, &freelancer, &5); + adjuster.blacklist(&reputation_id, &freelancer, &Symbol::new(&env, "fraud")); + + let score = client.get_score(&freelancer, &Role::Freelancer); + assert!(score.blacklisted); + assert_eq!(score.score, 1_000); + assert_eq!(score.badge_level, 0); + + let view = client.query_reputation(&freelancer); + assert!(view.is_blacklisted); + assert!(client.is_blacklisted(&freelancer)); + } - // 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); + #[test] + #[should_panic(expected = "Error(Contract, #3)")] + fn test_get_public_metrics_rejects_unknown_role() { + let env = Env::default(); + let address = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &contract_id); - assert_eq!(f_score.score, 6000); - assert_eq!(c_score.score, 5500); + client.get_public_metrics(&address, &Symbol::new(&env, "bogus")); } #[test] - fn test_query_reputation_returns_both_roles() { + fn test_submit_rating_updates_client_and_freelancer_paths() { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); - let address = Address::generate(&env); + let caller = Address::generate(&env); + let target = Address::generate(&env); + let freelancer = Address::generate(&env); + let caller_two = Address::generate(&env); let contract_id = env.register_contract(None, ReputationContract); let client = ReputationContractClient::new(&env, &contract_id); - client.initialize(&admin); - client.update_score(&address, &Role::Freelancer, &1000); - client.update_score(&address, &Role::Client, &500); - let view = client.query_reputation(&address); - assert_eq!(view.address, address); - assert_eq!(view.client.score, 5500); - assert_eq!(view.client.total_jobs, 1); - assert_eq!(view.client.total_points, 0); - assert_eq!(view.freelancer.score, 6000); - assert_eq!(view.freelancer.total_jobs, 1); - assert_eq!(view.freelancer.total_points, 0); + let registry_id = env.register_contract(None, MockJobRegistry); + client.set_job_registry(&admin, ®istry_id); + + setup_job(&env, ®istry_id, 7, &caller, &freelancer); + setup_job(&env, ®istry_id, 8, &caller_two, &target); + + client.submit_rating(&caller, &7, &freelancer, &5); + let freelancer_score = client.get_score(&freelancer, &Role::Freelancer); + assert_eq!(freelancer_score.score, 10_000); + assert_eq!(freelancer_score.total_jobs, 1); + assert_eq!(freelancer_score.total_points, 5); + assert_eq!(freelancer_score.reviews, 1); + assert_eq!(freelancer_score.average_rating_bps, 10_000); + assert_eq!(freelancer_score.badge_level, 1); + + client.submit_rating(&caller_two, &8, &target, &4); + let second_freelancer_score = client.get_score(&target, &Role::Freelancer); + assert_eq!(second_freelancer_score.score, 8_000); + assert_eq!(second_freelancer_score.total_jobs, 1); + assert_eq!(second_freelancer_score.total_points, 4); + assert_eq!(second_freelancer_score.reviews, 1); + assert_eq!(second_freelancer_score.average_rating_bps, 8_000); } #[test] - #[should_panic(expected = "Error(Contract, #3)")] - fn test_get_public_metrics_rejects_unknown_role() { + #[should_panic(expected = "Error(Contract, #2)")] + fn test_direct_score_adjustment_requires_authorized_contract() { let env = Env::default(); - let address = Address::generate(&env); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let target = Address::generate(&env); let contract_id = env.register_contract(None, ReputationContract); + let authorized_contract = env.register_contract(None, AuthorizedAdjuster); let client = ReputationContractClient::new(&env, &contract_id); - client.get_public_metrics(&address, &soroban_sdk::Symbol::new(&env, "bogus")); + client.initialize(&admin); + client.set_authorized_contract(&admin, &authorized_contract); + client.update_score(&attacker, &target, &Role::Freelancer, &500); } #[test] - fn test_submit_rating_updates_client_and_freelancer_paths() { + #[should_panic(expected = "Error(Contract, #2)")] + fn test_direct_reviews_from_unverified_public_keys_are_rejected() { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); - let caller = Address::generate(&env); - let target = Address::generate(&env); + let attacker = Address::generate(&env); + let job_client = 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 registry_id = env.register_contract(None, MockJobRegistry); + client.set_job_registry(&admin, ®istry_id); + setup_job(&env, ®istry_id, 33, &job_client, &freelancer); - let mock_id = env.register_contract(None, MockJobRegistry); - client.set_job_registry(&admin, &mock_id); + client.submit_rating(&attacker, &33, &freelancer, &5); + } - 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); + #[test] + fn test_profile_metadata() { + let env = Env::default(); + env.mock_all_auths(); - 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); + let address = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &contract_id); - 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); + 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] diff --git a/contracts/reputation/src/profile.rs b/contracts/reputation/src/profile.rs index 5a47aa10..b1ab4e2b 100644 --- a/contracts/reputation/src/profile.rs +++ b/contracts/reputation/src/profile.rs @@ -1,35 +1,61 @@ -use soroban_sdk::{contracttype, Address, Bytes, Env}; +use soroban_sdk::{contracttype, Address, Bytes}; + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ReviewAggregate { + pub total_points: i128, + pub reviews: u32, + pub average_rating_bps: i32, +} + +impl ReviewAggregate { + pub fn new() -> Self { + Self { + total_points: 0, + reviews: 0, + average_rating_bps: 5_000, + } + } +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RoleMetrics { + pub score: i32, + pub completed_jobs: u32, + pub review: ReviewAggregate, + pub badge_level: u32, +} + +impl RoleMetrics { + pub fn new() -> Self { + Self { + score: 5_000, + completed_jobs: 0, + review: ReviewAggregate::new(), + badge_level: 0, + } + } +} #[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 client: RoleMetrics, + pub freelancer: RoleMetrics, + pub is_blacklisted: bool, pub metadata_hash: Option, } impl Profile { - pub fn new(_env: &Env, address: Address) -> Self { + pub fn new(address: Address) -> Self { Self { address, - client_score: 5000, - client_points: 0, - client_jobs: 0, - freelancer_score: 5000, - freelancer_points: 0, - freelancer_jobs: 0, + client: RoleMetrics::new(), + freelancer: RoleMetrics::new(), + is_blacklisted: false, 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 1c1242dc..f20489cd 100644 --- a/contracts/reputation/src/storage.rs +++ b/contracts/reputation/src/storage.rs @@ -23,7 +23,7 @@ pub fn read_profile(env: &Env, address: &Address) -> Option { } pub fn read_profile_or_default(env: &Env, address: &Address) -> Profile { - read_profile(env, address).unwrap_or_else(|| Profile::new(env, address.clone())) + read_profile(env, address).unwrap_or_else(|| Profile::new(address.clone())) } pub fn write_profile(env: &Env, address: &Address, profile: &Profile) { From f2d0564c42f16d2711e166e60dbc890955baea1f Mon Sep 17 00:00:00 2001 From: Aniekan Victory Date: Wed, 27 May 2026 17:16:05 +0100 Subject: [PATCH 2/2] fix: add explicit lifetimes for reputation role metrics --- contracts/reputation/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index ad28fe62..399dc684 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -203,14 +203,14 @@ impl ReputationContract { } } - fn role_metrics(profile: &Profile, role: &Role) -> &RoleMetrics { + fn role_metrics<'a>(profile: &'a Profile, role: &Role) -> &'a RoleMetrics { match role { Role::Client => &profile.client, Role::Freelancer => &profile.freelancer, } } - fn role_metrics_mut(profile: &mut Profile, role: &Role) -> &mut RoleMetrics { + fn role_metrics_mut<'a>(profile: &'a mut Profile, role: &Role) -> &'a mut RoleMetrics { match role { Role::Client => &mut profile.client, Role::Freelancer => &mut profile.freelancer,