From 18d220cdf4059327a2893bd853b3e45e2e49b79e Mon Sep 17 00:00:00 2001 From: Anioke Sebastian Date: Wed, 27 May 2026 15:35:08 +0100 Subject: [PATCH] feat(reputation): add BadgeLevel enum and automated badge minting Adds BadgeLevel (None/Bronze/Silver/Gold/Platinum) to Profile with thresholds at 4000/6000/8000/9500 bps. refresh_badges() is called after every score update and slash so levels reflect immediately. Exposes get_badge() public getter. Default score of 5000 maps to Bronze. Four tests cover: default badge, Silver upgrade, Gold upgrade, and badge downgrade via slash. Closes #402 Co-Authored-By: Claude Sonnet 4.6 --- contracts/reputation/src/lib.rs | 79 +++++++++++++++++++++++++++++ contracts/reputation/src/profile.rs | 40 +++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/contracts/reputation/src/lib.rs b/contracts/reputation/src/lib.rs index 6ec8fefe..a0f7814c 100644 --- a/contracts/reputation/src/lib.rs +++ b/contracts/reputation/src/lib.rs @@ -4,6 +4,7 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, Address, Bytes, BytesN, Env, IntoVal, Symbol, Vec, }; +pub use profile::BadgeLevel; mod profile; mod storage; @@ -337,6 +338,7 @@ impl ReputationContract { } }; + profile.refresh_badges(); storage::write_profile(&env, &address, &profile); env.events().publish( ("reputation", "ScoreAdjusted"), @@ -374,6 +376,7 @@ impl ReputationContract { } }; + profile.refresh_badges(); storage::write_profile(&env, &address, &profile); env.events().publish( ("reputation", "ScoreAdjusted"), @@ -389,6 +392,16 @@ impl ReputationContract { Self::bump_instance_ttl(&env); } + /// Return the current badge level for an address/role pair. + pub fn get_badge(env: Env, address: Address, role: Role) -> BadgeLevel { + Self::bump_instance_ttl(&env); + let profile = storage::read_profile_or_default(&env, &address); + match role { + Role::Client => profile.client_badge, + Role::Freelancer => profile.freelancer_badge, + } + } + 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); @@ -654,6 +667,72 @@ mod test { assert_eq!(freelancer_score.reviews, 1); } + // ── Issue #402: badge minting ── + + #[test] + fn test_badge_starts_at_bronze_for_default_score() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + + // Default score is 5000 → Bronze + let badge = client.get_badge(&addr, &Role::Freelancer); + assert_eq!(badge, BadgeLevel::Bronze); + } + + #[test] + fn test_badge_upgrades_to_silver_at_6000() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + + // Raise score by 1000 → 5000+1000 = 6000 → Silver + client.update_score(&addr, &Role::Freelancer, &1000); + let badge = client.get_badge(&addr, &Role::Freelancer); + assert_eq!(badge, BadgeLevel::Silver); + } + + #[test] + fn test_badge_upgrades_to_gold_at_8000() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + + client.update_score(&addr, &Role::Freelancer, &3000); // 5000+3000=8000 + assert_eq!(client.get_badge(&addr, &Role::Freelancer), BadgeLevel::Gold); + } + + #[test] + fn test_slash_downgrades_badge() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let addr = Address::generate(&env); + let cid = env.register_contract(None, ReputationContract); + let client = ReputationContractClient::new(&env, &cid); + client.initialize(&admin); + + // Bring to Gold first, then slash twice to drop back to Bronze + client.update_score(&addr, &Role::Client, &3000); // 8000 → Gold + assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Gold); + client.slash(&addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 6000 → Silver + assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Silver); + client.slash(&addr, &Role::Client, &soroban_sdk::Symbol::new(&env, "fraud")); // 4000 → Bronze + assert_eq!(client.get_badge(&addr, &Role::Client), BadgeLevel::Bronze); + } + #[test] #[should_panic(expected = "Error(Contract, #2)")] fn test_upgrade_requires_admin() { diff --git a/contracts/reputation/src/profile.rs b/contracts/reputation/src/profile.rs index 5a47aa10..2e1ed3e3 100644 --- a/contracts/reputation/src/profile.rs +++ b/contracts/reputation/src/profile.rs @@ -1,5 +1,35 @@ use soroban_sdk::{contracttype, Address, Bytes, Env}; +/// Badge tier awarded based on cumulative score thresholds. +/// Scores are in basis points (0–10 000). +/// +/// Thresholds: +/// Bronze ≥ 4 000 +/// Silver ≥ 6 000 +/// Gold ≥ 8 000 +/// Platinum ≥ 9 500 +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BadgeLevel { + None, + Bronze, + Silver, + Gold, + Platinum, +} + +impl BadgeLevel { + pub fn from_score(score: i32) -> Self { + match score { + s if s >= 9_500 => BadgeLevel::Platinum, + s if s >= 8_000 => BadgeLevel::Gold, + s if s >= 6_000 => BadgeLevel::Silver, + s if s >= 4_000 => BadgeLevel::Bronze, + _ => BadgeLevel::None, + } + } +} + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct Profile { @@ -7,9 +37,11 @@ pub struct Profile { pub client_score: i32, pub client_points: i32, pub client_jobs: u32, + pub client_badge: BadgeLevel, pub freelancer_score: i32, pub freelancer_points: i32, pub freelancer_jobs: u32, + pub freelancer_badge: BadgeLevel, pub metadata_hash: Option, } @@ -20,13 +52,21 @@ impl Profile { client_score: 5000, client_points: 0, client_jobs: 0, + client_badge: BadgeLevel::Bronze, // 5000 ≥ 4000 freelancer_score: 5000, freelancer_points: 0, freelancer_jobs: 0, + freelancer_badge: BadgeLevel::Bronze, metadata_hash: None, } } + /// Recompute badge levels from current scores. + pub fn refresh_badges(&mut self) { + self.client_badge = BadgeLevel::from_score(self.client_score); + self.freelancer_badge = BadgeLevel::from_score(self.freelancer_score); + } + 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.