diff --git a/contract/src/lib.rs b/contract/src/lib.rs index c6c291e..200bb27 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -34,10 +34,13 @@ use analytics::{ mod reputation; use reputation::{ - compute_governance_weight as rep_governance_weight, get_badges as rep_get_badges, - get_contributions as rep_get_contributions, get_decayed_profile, get_global_reputation, - record_contribution as rep_record_contribution, Badge, ContributionRecord, ContributionType, - ReputationProfile, + award_achievement as rep_award_achievement, + calculate_incentive_multiplier as rep_calculate_incentive_multiplier, + check_achievement_eligibility as rep_check_achievement_eligibility, + get_achievements as rep_get_achievements, get_reputation as rep_get_reputation, + get_tier as rep_get_tier, get_top_contributors as rep_get_top_contributors, + initialize_profile as rep_initialize_profile, update_reputation as rep_update_reputation, + ReputationEvent, ReputationProfile, ReputationTier, }; mod governance; @@ -993,56 +996,45 @@ impl StellarGuildsContract { // ============ Reputation Functions ============ - /// Record a contribution and update reputation score. - /// Awards badges automatically if thresholds are met. - pub fn record_contribution( + pub fn initialize_profile(env: Env, address: Address) -> ReputationProfile { + rep_initialize_profile(env, address) + } + + pub fn update_reputation( env: Env, - guild_id: u64, - contributor: Address, - contribution_type: ContributionType, - reference_id: u64, - ) { - contributor.require_auth(); - rep_record_contribution( - &env, - guild_id, - &contributor, - contribution_type, - reference_id, - ); + address: Address, + event: ReputationEvent, + value: u32, + ) -> u32 { + rep_update_reputation(env, address, event, value) } - /// Get a user's reputation profile for a specific guild (with decay applied). - pub fn get_reputation(env: Env, guild_id: u64, address: Address) -> ReputationProfile { - get_decayed_profile(&env, &address, guild_id) - .unwrap_or_else(|| panic!("no reputation profile found")) + pub fn award_achievement(env: Env, address: Address, achievement_id: u64) -> bool { + rep_award_achievement(env, address, achievement_id) } - /// Get a user's aggregate reputation across all guilds. - pub fn get_reputation_global(env: Env, address: Address) -> u64 { - get_global_reputation(&env, &address) + pub fn get_reputation(env: Env, address: Address) -> ReputationProfile { + rep_get_reputation(env, address) } - /// Get contribution history for a user in a guild. - pub fn get_reputation_contributions( - env: Env, - guild_id: u64, - address: Address, - limit: u32, - ) -> Vec { - rep_get_contributions(&env, &address, guild_id, limit) + pub fn get_tier(env: Env, address: Address) -> ReputationTier { + rep_get_tier(env, address) + } + + pub fn calculate_incentive_multiplier(env: Env, address: Address) -> u32 { + rep_calculate_incentive_multiplier(env, address) + } + + pub fn get_top_contributors(env: Env, guild_id: u64, limit: u32) -> Vec
{ + rep_get_top_contributors(env, guild_id, limit) } - /// Get badges earned by a user in a guild. - pub fn get_reputation_badges(env: Env, guild_id: u64, address: Address) -> Vec { - rep_get_badges(&env, &address, guild_id) + pub fn check_achievement_eligibility(env: Env, address: Address, achievement_id: u64) -> bool { + rep_check_achievement_eligibility(env, address, achievement_id) } - /// Get computed governance weight for a user (role + reputation). - pub fn get_governance_weight_for(env: Env, guild_id: u64, address: Address) -> i128 { - let member = guild::storage::get_member(&env, guild_id, &address) - .unwrap_or_else(|| panic!("not a guild member")); - rep_governance_weight(&env, &address, guild_id, &member.role) + pub fn get_achievements(env: Env, address: Address) -> Vec { + rep_get_achievements(env, address) } // ============ Milestone Tracking Functions ============ diff --git a/contract/src/reputation/achievements.rs b/contract/src/reputation/achievements.rs new file mode 100644 index 0000000..f1b2a5d --- /dev/null +++ b/contract/src/reputation/achievements.rs @@ -0,0 +1,39 @@ +use crate::reputation::storage; +use crate::reputation::types::{Achievement, ReputationProfile}; +use soroban_sdk::{Address, Env, String, Vec}; + +pub fn check_eligibility(env: &Env, profile: &ReputationProfile, achievement_id: u64) -> bool { + let achievement = storage::get_achievement(env, achievement_id); + if achievement.is_none() { + return false; + } + + // Prevent awarding same achievement multiple times + if profile.achievements.contains(&achievement_id) { + return false; + } + + // Basic eligibility check based on criteria + // In a full implementation, you'd parse `criteria` (e.g., "tasks>=10") + // For this example, we assume eligibility is verified off-chain and explicitly awarded + // or we check simple static criteria here based on ID rules. + match achievement_id { + 1 => profile.tasks_completed >= 10, // "Bronze Worker" + 2 => profile.tasks_completed >= 50, // "Silver Worker" + 3 => profile.score >= 1000, // "Reputable" + _ => true, // Assume true for custom/manual awards if not standard + } +} + +pub fn award(env: &Env, profile: &mut ReputationProfile, achievement_id: u64) -> bool { + if !check_eligibility(env, profile, achievement_id) { + return false; + } + + if let Some(achievement) = storage::get_achievement(env, achievement_id) { + profile.achievements.push_back(achievement_id); + profile.score = profile.score.saturating_add(achievement.points); + return true; + } + false +} diff --git a/contract/src/reputation/mod.rs b/contract/src/reputation/mod.rs index cb0adb1..a4f432e 100644 --- a/contract/src/reputation/mod.rs +++ b/contract/src/reputation/mod.rs @@ -1,14 +1,156 @@ +// reputation/mod.rs +pub mod achievements; pub mod scoring; pub mod storage; pub mod types; -pub use scoring::{ - compute_governance_weight, get_decayed_profile, get_global_reputation, record_contribution, -}; +pub use achievements::*; +pub use scoring::*; +pub use storage::*; +pub use types::*; -pub use storage::{get_badges, get_contributions}; +use soroban_sdk::{Address, Env, Vec}; -pub use types::{Badge, BadgeType, ContributionRecord, ContributionType, ReputationProfile}; +pub fn initialize_profile(env: Env, address: Address) -> ReputationProfile { + // Check if it already exists + if let Some(profile) = storage::get_profile(&env, &address) { + return profile; + } -#[cfg(test)] -mod tests; + let profile = ReputationProfile { + address: address.clone(), + score: 0, + tier: ReputationTier::Bronze, + tasks_completed: 0, + success_rate: 10000, + achievements: Vec::new(&env), + last_active: env.ledger().timestamp(), + tasks_failed: 0, + }; + storage::set_profile(&env, &address, &profile); + profile +} + +pub fn update_reputation(env: Env, address: Address, event: ReputationEvent, value: u32) -> u32 { + let mut profile = storage::get_profile(&env, &address) + .unwrap_or_else(|| initialize_profile(env.clone(), address.clone())); + + // Apply time decay based on last activity + scoring::calculate_decay(&env, &mut profile); + + // Process the new event + let old_tier = profile.tier.clone(); + scoring::process_event(&env, &mut profile, event.clone(), value); + + // Emit reputation update event + env.events().publish( + ( + soroban_sdk::Symbol::new(&env, "reputation_updated"), + address.clone(), + ), + (event, value, profile.score, profile.tier.clone()), + ); + + if old_tier != profile.tier { + // Emit tier change event + env.events().publish( + ( + soroban_sdk::Symbol::new(&env, "tier_changed"), + address.clone(), + ), + (old_tier, profile.tier.clone()), + ); + } + + storage::set_profile(&env, &address, &profile); + profile.score +} + +pub fn award_achievement(env: Env, address: Address, achievement_id: u64) -> bool { + let mut profile = match storage::get_profile(&env, &address) { + Some(p) => p, + None => return false, + }; + + if achievements::award(&env, &mut profile, achievement_id) { + let old_tier = profile.tier.clone(); + profile.tier = scoring::determine_tier(profile.score); + + // Emit achievement awarded event + env.events().publish( + ( + soroban_sdk::Symbol::new(&env, "achievement_awarded"), + address.clone(), + ), + achievement_id, + ); + + if old_tier != profile.tier { + env.events().publish( + ( + soroban_sdk::Symbol::new(&env, "tier_changed"), + address.clone(), + ), + (old_tier, profile.tier.clone()), + ); + } + + storage::set_profile(&env, &address, &profile); + return true; + } + false +} + +pub fn get_reputation(env: Env, address: Address) -> ReputationProfile { + let mut profile = storage::get_profile(&env, &address) + .unwrap_or_else(|| initialize_profile(env.clone(), address.clone())); + scoring::calculate_decay(&env, &mut profile); + profile +} + +pub fn get_tier(env: Env, address: Address) -> ReputationTier { + let profile = get_reputation(env, address); + profile.tier +} + +pub fn calculate_incentive_multiplier(env: Env, address: Address) -> u32 { + let profile = get_reputation(env, address); + scoring::get_multiplier(&profile.tier) +} + +pub fn get_top_contributors(env: Env, guild_id: u64, limit: u32) -> Vec
{ + // We fetch the full list of contributors and sort by score to return the top N. + // Given Soroban limits, sorting large lists strictly in-contract is generally expensive. + // For this implementation, we simulate it simply by taking from the stored contributors list. + let contributors = storage::get_guild_contributors(&env, guild_id); + + // In actual production, sorting would ideally be handled off-chain, + // or through an ordered structure (which Soroban doesn't natively supply yet). + let mut profiles: soroban_sdk::Vec<(Address, u32)> = Vec::new(&env); + for addr in contributors.iter() { + if let Some(p) = storage::get_profile(&env, &addr) { + profiles.push_back((addr.clone(), p.score)); + } + } + + // Manual insert-sort logic to grab top N (skipping due to WASM instruction limits, + // returning the list directly for now, up to limit) + let len = core::cmp::min(contributors.len() as u32, limit); + let mut result = Vec::new(&env); + for i in 0..len { + result.push_back(contributors.get_unchecked(i).clone()); + } + result +} + +pub fn check_achievement_eligibility(env: Env, address: Address, achievement_id: u64) -> bool { + let profile = storage::get_profile(&env, &address) + .unwrap_or_else(|| initialize_profile(env.clone(), address.clone())); + achievements::check_eligibility(&env, &profile, achievement_id) +} + +pub fn get_achievements(env: Env, address: Address) -> Vec { + let profile = storage::get_profile(&env, &address) + .unwrap_or_else(|| initialize_profile(env.clone(), address.clone())); + profile.achievements +} diff --git a/contract/src/reputation/scoring.rs b/contract/src/reputation/scoring.rs index e4e3305..29dd9a7 100644 --- a/contract/src/reputation/scoring.rs +++ b/contract/src/reputation/scoring.rs @@ -1,278 +1,131 @@ -use soroban_sdk::{Address, Env, String, Symbol}; - -use crate::guild::types::Role; -use crate::reputation::storage::{ - count_contributions_by_type, get_badges, get_next_badge_id, get_next_contribution_id, - get_profile, has_badge_type, store_badge, store_contribution, store_profile, -}; -use crate::reputation::types::{ - points_for_contribution, Badge, BadgeAwardedEvent, BadgeType, ContributionRecord, - ContributionType, ReputationProfile, ReputationUpdatedEvent, DECAY_DENOMINATOR, - DECAY_NUMERATOR, DECAY_PERIOD_SECS, -}; - -use crate::governance::types::role_weight; +use crate::reputation::storage; +use crate::reputation::types::{ReputationEvent, ReputationProfile, ReputationTier}; +use soroban_sdk::{Address, Env, Vec}; + +pub fn calculate_decay(env: &Env, profile: &mut ReputationProfile) { + let current_time = env.ledger().timestamp(); + let month_seconds: u64 = 30 * 24 * 60 * 60; + + if profile.last_active > 0 { + let inactive_duration = current_time.saturating_sub(profile.last_active); + let months_inactive = inactive_duration / month_seconds; + + if months_inactive > 0 { + // Decay by 1% per month of inactivity + for _ in 0..months_inactive { + let decay_amount = profile.score / 100; // 1% + profile.score = profile.score.saturating_sub(decay_amount); + } + } + } -// ────────────────────── Core Scoring ────────────────────── + // Always update last_active when scoring is processed + profile.last_active = current_time; +} -/// Record a contribution and update the user's reputation profile. -/// Awards badges if thresholds are met. -pub fn record_contribution( +pub fn process_event( env: &Env, - guild_id: u64, - contributor: &Address, - contribution_type: ContributionType, - reference_id: u64, + profile: &mut ReputationProfile, + event: ReputationEvent, + base_value: u32, ) { - let points = points_for_contribution(&contribution_type); - let now = env.ledger().timestamp(); - - // Store the contribution record - let contrib_id = get_next_contribution_id(env); - let record = ContributionRecord { - id: contrib_id, - guild_id, - contributor: contributor.clone(), - contribution_type: contribution_type.clone(), - points, - timestamp: now, - reference_id, - }; - store_contribution(env, &record); - - // Update or create reputation profile - let mut profile = get_profile(env, contributor, guild_id).unwrap_or(ReputationProfile { - address: contributor.clone(), - guild_id, - total_score: 0, - decayed_score: 0, - contributions_count: 0, - last_activity: now, - last_decay_applied: now, - }); - - // Apply pending decay before adding new points - apply_decay_to_profile(&mut profile, now); - - profile.total_score += points as u64; - profile.decayed_score += points as u64; - profile.contributions_count += 1; - profile.last_activity = now; - store_profile(env, &profile); - - // Emit reputation updated event - let event = ReputationUpdatedEvent { - guild_id, - contributor: contributor.clone(), - points_earned: points, - new_total_score: profile.total_score, - contribution_type, + // Determine point change + let point_delta: i64 = match event { + ReputationEvent::TaskCompleted => { + profile.tasks_completed = profile.tasks_completed.saturating_add(1); + base_value as i64 // usually +10 to +50 + } + ReputationEvent::MilestoneAchieved => { + base_value as i64 // usually +20 to +100 + } + ReputationEvent::DisputeWon => 5, + ReputationEvent::DisputeLost => -20, + ReputationEvent::TaskFailed => { + profile.tasks_failed = profile.tasks_failed.saturating_add(1); + -10 + } }; - env.events().publish( - (Symbol::new(env, "reputation"), Symbol::new(env, "updated")), - event, - ); - - // Check and award badges - check_and_award_badges(env, guild_id, contributor, &profile); -} - -// ────────────────────── Decay ────────────────────── -/// Apply time-based decay to a profile's decayed_score. -/// Uses iterative multiplication by 99/100 per elapsed period. -fn apply_decay_to_profile(profile: &mut ReputationProfile, now: u64) { - if now <= profile.last_decay_applied { - return; + if point_delta > 0 { + profile.score = profile.score.saturating_add(point_delta as u32); + } else { + profile.score = profile.score.saturating_sub(point_delta.abs() as u32); } - let elapsed = now - profile.last_decay_applied; - let periods = elapsed / DECAY_PERIOD_SECS; - - if periods == 0 { - return; + // Calculate new success rate (e.g. 9500 = 95.00%) + let total_tasks = profile.tasks_completed + profile.tasks_failed; + if total_tasks > 0 { + profile.success_rate = (profile.tasks_completed * 10000) / total_tasks; + } else { + profile.success_rate = 10000; } - // Cap iterations to avoid excessive gas usage - let capped_periods = if periods > 52 { 52 } else { periods }; - - let mut score = profile.decayed_score; - for _ in 0..capped_periods { - score = (score * DECAY_NUMERATOR) / DECAY_DENOMINATOR; + // Success rate bonus (+50% bonus on top if success rate > 95%) + let mut actual_score = profile.score; + if profile.success_rate > 9500 { + // e.g. 50% bonus + actual_score = actual_score.saturating_add(actual_score / 2); } - profile.decayed_score = score; - profile.last_decay_applied = now; -} - -/// Get a profile with decay applied (read-only, does not persist). -pub fn get_decayed_profile( - env: &Env, - address: &Address, - guild_id: u64, -) -> Option { - let mut profile = get_profile(env, address, guild_id)?; - let now = env.ledger().timestamp(); - apply_decay_to_profile(&mut profile, now); - Some(profile) -} - -// ────────────────────── Governance Weight ────────────────────── - -/// Compute governance weight: role_weight + integer_sqrt(decayed_score). -/// Falls back to role_weight only if no reputation profile exists. -pub fn compute_governance_weight(env: &Env, address: &Address, guild_id: u64, role: &Role) -> i128 { - let base = role_weight(role); - - let reputation_bonus = match get_decayed_profile(env, address, guild_id) { - Some(profile) => integer_sqrt(profile.decayed_score) as i128, - None => 0, - }; - - base + reputation_bonus + // Update tier based on actual score with bonuses + profile.tier = determine_tier(actual_score); } -/// Get the global (cross-guild) reputation for a user. -pub fn get_global_reputation(env: &Env, address: &Address) -> u64 { - let profiles = crate::reputation::storage::get_all_guild_profiles(env, address); - let now = env.ledger().timestamp(); - - let mut total: u64 = 0; - for mut profile in profiles.iter() { - apply_decay_to_profile(&mut profile, now); - total += profile.decayed_score; +pub fn determine_tier(score: u32) -> ReputationTier { + if score >= 5000 { + ReputationTier::Diamond + } else if score >= 1500 { + ReputationTier::Platinum + } else if score >= 500 { + ReputationTier::Gold + } else if score >= 100 { + ReputationTier::Silver + } else { + ReputationTier::Bronze } - total } -// ────────────────────── Badge Logic ────────────────────── - -/// Check badge criteria and award any newly earned badges. -fn check_and_award_badges( - env: &Env, - guild_id: u64, - contributor: &Address, - profile: &ReputationProfile, -) { - let now = env.ledger().timestamp(); - - // FirstContribution — contributions_count >= 1 - if profile.contributions_count >= 1 { - maybe_award_badge( - env, - guild_id, - contributor, - BadgeType::FirstContribution, - "First Contribution", - now, - ); - } - - // BountyHunter — 5+ bounties completed - let bounty_count = count_contributions_by_type( - env, - contributor, - guild_id, - &ContributionType::BountyCompleted, - ); - if bounty_count >= 5 { - maybe_award_badge( - env, - guild_id, - contributor, - BadgeType::BountyHunter, - "Bounty Hunter", - now, - ); - } - - // Mentor — 10+ milestones approved - let milestone_count = count_contributions_by_type( - env, - contributor, - guild_id, - &ContributionType::MilestoneApproved, - ); - if milestone_count >= 10 { - maybe_award_badge(env, guild_id, contributor, BadgeType::Mentor, "Mentor", now); - } - - // Governor — 10+ votes cast - let vote_count = - count_contributions_by_type(env, contributor, guild_id, &ContributionType::VoteCast); - if vote_count >= 10 { - maybe_award_badge( - env, - guild_id, - contributor, - BadgeType::Governor, - "Governor", - now, - ); - } - - // Veteran — score > 1000 - if profile.total_score > 1000 { - maybe_award_badge( - env, - guild_id, - contributor, - BadgeType::Veteran, - "Veteran", - now, - ); +pub fn get_multiplier(tier: &ReputationTier) -> u32 { + match tier { + ReputationTier::Bronze => 100, + ReputationTier::Silver => 110, + ReputationTier::Gold => 125, + ReputationTier::Platinum => 150, + ReputationTier::Diamond => 200, } } -/// Award a badge if the user doesn't already have it. -fn maybe_award_badge( - env: &Env, - guild_id: u64, - holder: &Address, - badge_type: BadgeType, - name: &str, - timestamp: u64, -) { - if has_badge_type(env, holder, guild_id, &badge_type) { - return; - } +pub fn update_top_contributors(env: &Env, guild_id: u64, addr: Address, new_score: u32) { + let mut contributors = storage::get_guild_contributors(env, guild_id); - let badge_id = get_next_badge_id(env); - let badge_name = String::from_str(env, name); - let badge = Badge { - id: badge_id, - guild_id, - holder: holder.clone(), - badge_type: badge_type.clone(), - name: badge_name.clone(), - awarded_at: timestamp, - }; - store_badge(env, &badge); + // Naive tracking logic for small sets - add/sort/truncate to keep top 50 + // In a production app, we'd probably use a more performant structure or off-chain indexer + if !contributors.contains(&addr) { + contributors.push_back(addr); + } - let event = BadgeAwardedEvent { - guild_id, - holder: holder.clone(), - badge_type, - badge_name, - }; - env.events().publish( - (Symbol::new(env, "reputation"), Symbol::new(env, "badge")), - event, - ); + // For sorting, we have to copy values off-chain or do manual insertion sort + // Due to Soroban limits, we avoid full sorting here and just ensure they're in the list + storage::set_guild_contributors(env, guild_id, &contributors); } -// ────────────────────── Helpers ────────────────────── - -/// Integer square root using Newton's method. -fn integer_sqrt(n: u64) -> u64 { - if n == 0 { - return 0; - } - let mut x = n; - let mut y = (x + 1) / 2; - while y < x { - x = y; - y = (x + n / x) / 2; - } - x +// Re-add compute_governance_weight for governance integration +pub fn compute_governance_weight(env: &Env, address: &Address, _guild_id: u64, role: &crate::guild::types::Role) -> i128 { + let base_weight = crate::governance::types::role_weight(role); + let profile = storage::get_profile(env, address).unwrap_or_else(|| { + ReputationProfile { + address: address.clone(), + score: 0, + tier: ReputationTier::Bronze, + tasks_completed: 0, + success_rate: 10000, + achievements: Vec::new(env), + last_active: env.ledger().timestamp(), + tasks_failed: 0, + } + }); + + // Scale base weight by reputation tier multiplier + let multiplier = get_multiplier(&profile.tier) as i128; + (base_weight * multiplier) / 100 } diff --git a/contract/src/reputation/storage.rs b/contract/src/reputation/storage.rs index 29d4066..9c973fb 100644 --- a/contract/src/reputation/storage.rs +++ b/contract/src/reputation/storage.rs @@ -1,204 +1,45 @@ -use soroban_sdk::{symbol_short, Address, Env, Map, Symbol, Vec}; +use crate::reputation::types::{Achievement, ReputationProfile}; +use soroban_sdk::{contracttype, Address, Env, Vec}; -use crate::reputation::types::{Badge, ContributionRecord, ReputationProfile}; - -const PROFILES_KEY: Symbol = symbol_short!("r_prof"); -const CONTRIBS_KEY: Symbol = symbol_short!("r_cont"); -const CONTRIB_IDX: Symbol = symbol_short!("r_cidx"); -const BADGES_KEY: Symbol = symbol_short!("r_badge"); -const BADGE_IDX: Symbol = symbol_short!("r_bidx"); -const CONTRIB_CNT: Symbol = symbol_short!("r_ccnt"); -const BADGE_CNT: Symbol = symbol_short!("r_bcnt"); - -// ────────────────────── Reputation Profiles ────────────────────── - -/// Store or update a reputation profile keyed by (address, guild_id). -pub fn store_profile(env: &Env, profile: &ReputationProfile) { - let storage = env.storage().persistent(); - let mut profiles: Map<(Address, u64), ReputationProfile> = - storage.get(&PROFILES_KEY).unwrap_or_else(|| Map::new(env)); - profiles.set((profile.address.clone(), profile.guild_id), profile.clone()); - storage.set(&PROFILES_KEY, &profiles); -} - -/// Get a reputation profile for (address, guild_id). Returns None if not found. -pub fn get_profile(env: &Env, address: &Address, guild_id: u64) -> Option { - let storage = env.storage().persistent(); - let profiles: Map<(Address, u64), ReputationProfile> = - storage.get(&PROFILES_KEY).unwrap_or_else(|| Map::new(env)); - profiles.get((address.clone(), guild_id)) +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Profile(Address), + Achievement(u64), + GuildContributors(u64), // stores a Vec
of top contributors or all contributors } -/// Get all guild IDs that an address has reputation in. -pub fn get_all_guild_profiles(env: &Env, address: &Address) -> Vec { - let storage = env.storage().persistent(); - let profiles: Map<(Address, u64), ReputationProfile> = - storage.get(&PROFILES_KEY).unwrap_or_else(|| Map::new(env)); - - let mut result = Vec::new(env); - for entry in profiles.iter() { - let ((addr, _gid), profile) = entry; - if addr == address.clone() { - result.push_back(profile); - } - } - result +pub fn get_profile(env: &Env, addr: &Address) -> Option { + env.storage() + .persistent() + .get(&DataKey::Profile(addr.clone())) } -// ────────────────────── Contributions ────────────────────── - -/// Get next contribution ID (global counter). -pub fn get_next_contribution_id(env: &Env) -> u64 { - let storage = env.storage().persistent(); - let count: u64 = storage.get(&CONTRIB_CNT).unwrap_or(0u64); - storage.set(&CONTRIB_CNT, &(count + 1)); - count + 1 +pub fn set_profile(env: &Env, addr: &Address, profile: &ReputationProfile) { + env.storage() + .persistent() + .set(&DataKey::Profile(addr.clone()), profile); } -/// Store a contribution record. -pub fn store_contribution(env: &Env, record: &ContributionRecord) { - let storage = env.storage().persistent(); - - // Store by ID - let mut contribs: Map = - storage.get(&CONTRIBS_KEY).unwrap_or_else(|| Map::new(env)); - contribs.set(record.id, record.clone()); - storage.set(&CONTRIBS_KEY, &contribs); - - // Update per-user per-guild index - let mut index: Map<(Address, u64), Vec> = - storage.get(&CONTRIB_IDX).unwrap_or_else(|| Map::new(env)); - let key = (record.contributor.clone(), record.guild_id); - let mut ids = index.get(key.clone()).unwrap_or_else(|| Vec::new(env)); - ids.push_back(record.id); - index.set(key, ids); - storage.set(&CONTRIB_IDX, &index); +pub fn get_achievement(env: &Env, id: u64) -> Option { + env.storage().persistent().get(&DataKey::Achievement(id)) } -/// Get contribution records for a user in a guild, most recent first, limited. -pub fn get_contributions( - env: &Env, - address: &Address, - guild_id: u64, - limit: u32, -) -> Vec { - let storage = env.storage().persistent(); - - let index: Map<(Address, u64), Vec> = - storage.get(&CONTRIB_IDX).unwrap_or_else(|| Map::new(env)); - - let ids = index - .get((address.clone(), guild_id)) - .unwrap_or_else(|| Vec::new(env)); - - let contribs: Map = - storage.get(&CONTRIBS_KEY).unwrap_or_else(|| Map::new(env)); - - let mut result = Vec::new(env); - let len = ids.len(); - let start = if len > limit { len - limit } else { 0 }; - - for i in start..len { - let id = ids.get(i).unwrap(); - if let Some(record) = contribs.get(id) { - result.push_back(record); - } - } - result +pub fn set_achievement(env: &Env, id: u64, achievement: &Achievement) { + env.storage() + .persistent() + .set(&DataKey::Achievement(id), achievement); } -/// Count contributions of a specific type for a user in a guild. -pub fn count_contributions_by_type( - env: &Env, - address: &Address, - guild_id: u64, - contribution_type: &crate::reputation::types::ContributionType, -) -> u32 { - let storage = env.storage().persistent(); - - let index: Map<(Address, u64), Vec> = - storage.get(&CONTRIB_IDX).unwrap_or_else(|| Map::new(env)); - - let ids = index - .get((address.clone(), guild_id)) - .unwrap_or_else(|| Vec::new(env)); - - let contribs: Map = - storage.get(&CONTRIBS_KEY).unwrap_or_else(|| Map::new(env)); - - let mut count = 0u32; - for id in ids.iter() { - if let Some(record) = contribs.get(id) { - if record.contribution_type == *contribution_type { - count += 1; - } - } - } - count -} - -// ────────────────────── Badges ────────────────────── - -/// Get next badge ID. -pub fn get_next_badge_id(env: &Env) -> u64 { - let storage = env.storage().persistent(); - let count: u64 = storage.get(&BADGE_CNT).unwrap_or(0u64); - storage.set(&BADGE_CNT, &(count + 1)); - count + 1 -} - -/// Store a badge. -pub fn store_badge(env: &Env, badge: &Badge) { - let storage = env.storage().persistent(); - - let mut badges: Map = storage.get(&BADGES_KEY).unwrap_or_else(|| Map::new(env)); - badges.set(badge.id, badge.clone()); - storage.set(&BADGES_KEY, &badges); - - // Per-user per-guild index - let mut index: Map<(Address, u64), Vec> = - storage.get(&BADGE_IDX).unwrap_or_else(|| Map::new(env)); - let key = (badge.holder.clone(), badge.guild_id); - let mut ids = index.get(key.clone()).unwrap_or_else(|| Vec::new(env)); - ids.push_back(badge.id); - index.set(key, ids); - storage.set(&BADGE_IDX, &index); -} - -/// Get all badges for a user in a guild. -pub fn get_badges(env: &Env, address: &Address, guild_id: u64) -> Vec { - let storage = env.storage().persistent(); - - let index: Map<(Address, u64), Vec> = - storage.get(&BADGE_IDX).unwrap_or_else(|| Map::new(env)); - - let ids = index - .get((address.clone(), guild_id)) - .unwrap_or_else(|| Vec::new(env)); - - let badges: Map = storage.get(&BADGES_KEY).unwrap_or_else(|| Map::new(env)); - - let mut result = Vec::new(env); - for id in ids.iter() { - if let Some(badge) = badges.get(id) { - result.push_back(badge); - } - } - result +pub fn get_guild_contributors(env: &Env, guild_id: u64) -> Vec
{ + env.storage() + .persistent() + .get(&DataKey::GuildContributors(guild_id)) + .unwrap_or(Vec::new(env)) } -/// Check if a user already has a specific badge type in a guild. -pub fn has_badge_type( - env: &Env, - address: &Address, - guild_id: u64, - badge_type: &crate::reputation::types::BadgeType, -) -> bool { - let existing = get_badges(env, address, guild_id); - for badge in existing.iter() { - if badge.badge_type == *badge_type { - return true; - } - } - false +pub fn set_guild_contributors(env: &Env, guild_id: u64, contributors: &Vec
) { + env.storage() + .persistent() + .set(&DataKey::GuildContributors(guild_id), contributors); } diff --git a/contract/src/reputation/tests.rs b/contract/src/reputation/tests.rs index 91d20e2..c20b337 100644 --- a/contract/src/reputation/tests.rs +++ b/contract/src/reputation/tests.rs @@ -1,312 +1,219 @@ -#[cfg(test)] -mod tests { - use crate::guild::types::Role; - use crate::reputation::types::{BadgeType, ContributionType}; - use crate::StellarGuildsContract; - use crate::StellarGuildsContractClient; - use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; - use soroban_sdk::{Address, Env, String}; - - fn setup_env() -> Env { - let env = Env::default(); - env.budget().reset_unlimited(); - env - } +#![cfg(test)] - fn set_ledger_timestamp(env: &Env, timestamp: u64) { - env.ledger().set(LedgerInfo { - timestamp, - protocol_version: 20, - sequence_number: 0, - network_id: Default::default(), - base_reserve: 10, - min_temp_entry_ttl: 100, - min_persistent_entry_ttl: 100, - max_entry_ttl: 1000000, - }); - } +use super::*; +use soroban_sdk::{testutils::Address as _, Address, Env, Vec}; - fn register_and_init_contract(env: &Env) -> Address { - let contract_id = env.register_contract(None, StellarGuildsContract); - let client = StellarGuildsContractClient::new(env, &contract_id); - client.initialize(); - contract_id - } +#[test] +fn test_initialize_profile() { + let env = Env::default(); + let user = Address::generate(&env); - fn setup_guild(client: &StellarGuildsContractClient<'_>, env: &Env, owner: &Address) -> u64 { - let name = String::from_str(env, "Test Guild"); - let description = String::from_str(env, "A test guild"); - client.create_guild(&name, &description, owner) - } + let profile = initialize_profile(env.clone(), user.clone()); - #[test] - fn test_record_contribution() { - let env = setup_env(); - set_ledger_timestamp(&env, 1000); - let contract_id = register_and_init_contract(&env); - let client = StellarGuildsContractClient::new(&env, &contract_id); - - env.mock_all_auths(); - let owner = Address::generate(&env); - let guild_id = setup_guild(&client, &env, &owner); - - let contributor = Address::generate(&env); - client.add_member(&guild_id, &contributor, &Role::Contributor, &owner); - - // Record a bounty completion - client.record_contribution( - &guild_id, - &contributor, - &ContributionType::BountyCompleted, - &1u64, - ); - - let profile = client.get_reputation(&guild_id, &contributor); - assert_eq!(profile.total_score, 100); // POINTS_BOUNTY_COMPLETED - assert_eq!(profile.contributions_count, 1); - } + assert_eq!(profile.address, user); + assert_eq!(profile.score, 0); + assert_eq!(profile.tier, ReputationTier::Bronze); + assert_eq!(profile.tasks_completed, 0); + assert_eq!(profile.success_rate, 10000); + assert_eq!(profile.achievements.len(), 0); + assert_eq!(profile.tasks_failed, 0); - #[test] - fn test_multiple_contributions() { - let env = setup_env(); - set_ledger_timestamp(&env, 1000); - let contract_id = register_and_init_contract(&env); - let client = StellarGuildsContractClient::new(&env, &contract_id); - - env.mock_all_auths(); - let owner = Address::generate(&env); - let guild_id = setup_guild(&client, &env, &owner); - - let contributor = Address::generate(&env); - client.add_member(&guild_id, &contributor, &Role::Contributor, &owner); - - // Record different contribution types - client.record_contribution( - &guild_id, - &contributor, - &ContributionType::BountyCompleted, - &1u64, - ); - client.record_contribution( - &guild_id, - &contributor, - &ContributionType::MilestoneApproved, - &2u64, - ); - client.record_contribution(&guild_id, &contributor, &ContributionType::VoteCast, &3u64); - - let profile = client.get_reputation(&guild_id, &contributor); - // 100 (bounty) + 50 (milestone) + 5 (vote) = 155 - assert_eq!(profile.total_score, 155); - assert_eq!(profile.contributions_count, 3); - - // Verify contribution history - let history = client.get_reputation_contributions(&guild_id, &contributor, &10u32); - assert_eq!(history.len(), 3); - } + // Initializing again should return the same + let profile2 = initialize_profile(env.clone(), user.clone()); + assert_eq!(profile, profile2); +} - #[test] - fn test_reputation_decay() { - let env = setup_env(); - set_ledger_timestamp(&env, 1000); - let contract_id = register_and_init_contract(&env); - let client = StellarGuildsContractClient::new(&env, &contract_id); - - env.mock_all_auths(); - let owner = Address::generate(&env); - let guild_id = setup_guild(&client, &env, &owner); - - let contributor = Address::generate(&env); - client.add_member(&guild_id, &contributor, &Role::Contributor, &owner); - - // Record contribution at t=1000 - client.record_contribution( - &guild_id, - &contributor, - &ContributionType::BountyCompleted, - &1u64, - ); - - let profile_before = client.get_reputation(&guild_id, &contributor); - assert_eq!(profile_before.decayed_score, 100); - - // Advance time by 1 decay period (604800 seconds = 1 week) - set_ledger_timestamp(&env, 1000 + 604_800); - - let profile_after = client.get_reputation(&guild_id, &contributor); - // After 1 period: 100 * 99/100 = 99 - assert_eq!(profile_after.decayed_score, 99); - // Total score should remain unchanged - assert_eq!(profile_after.total_score, 100); - - // Advance by 10 more periods - set_ledger_timestamp(&env, 1000 + 604_800 * 11); - - let profile_later = client.get_reputation(&guild_id, &contributor); - // After 11 periods: 100 * (99/100)^11 ≈ 89 - assert!(profile_later.decayed_score < 100); - assert!(profile_later.decayed_score > 80); - } +#[test] +fn test_update_reputation_task_completed() { + let env = Env::default(); + let user = Address::generate(&env); + + let score = update_reputation( + env.clone(), + user.clone(), + ReputationEvent::TaskCompleted, + 20, + ); + assert_eq!(score, 20); + + let tier = get_tier(env.clone(), user.clone()); + assert_eq!(tier, ReputationTier::Bronze); + + let profile = get_reputation(env.clone(), user.clone()); + assert_eq!(profile.tasks_completed, 1); + assert_eq!(profile.success_rate, 10000); +} - #[test] - fn test_governance_weight() { - let env = setup_env(); - set_ledger_timestamp(&env, 1000); - let contract_id = register_and_init_contract(&env); - let client = StellarGuildsContractClient::new(&env, &contract_id); - - env.mock_all_auths(); - let owner = Address::generate(&env); - let guild_id = setup_guild(&client, &env, &owner); - - let contributor = Address::generate(&env); - client.add_member(&guild_id, &contributor, &Role::Contributor, &owner); - - // Before any contributions: weight = role_weight(Contributor) = 1 - let weight_before = client.get_governance_weight_for(&guild_id, &contributor); - assert_eq!(weight_before, 1); // role weight only - - // Record contributions to build reputation = 100 - client.record_contribution( - &guild_id, - &contributor, - &ContributionType::BountyCompleted, - &1u64, - ); - - // After: weight = role_weight(1) + sqrt(100) = 1 + 10 = 11 - let weight_after = client.get_governance_weight_for(&guild_id, &contributor); - assert_eq!(weight_after, 11); - } +#[test] +fn test_update_reputation_task_failed() { + let env = Env::default(); + let user = Address::generate(&env); + + // First, complete a task to have > 0 score + update_reputation( + env.clone(), + user.clone(), + ReputationEvent::TaskCompleted, + 30, + ); + let score = update_reputation(env.clone(), user.clone(), ReputationEvent::TaskFailed, 0); + + assert_eq!(score, 20); // 30 - 10 + + let profile = get_reputation(env.clone(), user.clone()); + assert_eq!(profile.tasks_failed, 1); + assert_eq!(profile.success_rate, 5000); // 1 success / 2 total = 50.00% +} - #[test] - fn test_badge_first_contribution() { - let env = setup_env(); - set_ledger_timestamp(&env, 1000); - let contract_id = register_and_init_contract(&env); - let client = StellarGuildsContractClient::new(&env, &contract_id); - - env.mock_all_auths(); - let owner = Address::generate(&env); - let guild_id = setup_guild(&client, &env, &owner); - - let contributor = Address::generate(&env); - client.add_member(&guild_id, &contributor, &Role::Contributor, &owner); - - // First contribution should award "First Contribution" badge - client.record_contribution(&guild_id, &contributor, &ContributionType::VoteCast, &1u64); - - let badges = client.get_reputation_badges(&guild_id, &contributor); - assert_eq!(badges.len(), 1); - assert_eq!( - badges.get(0).unwrap().badge_type, - BadgeType::FirstContribution - ); - } +#[test] +fn test_decay() { + let env = Env::default(); + let user = Address::generate(&env); + + update_reputation( + env.clone(), + user.clone(), + ReputationEvent::TaskCompleted, + 100, + ); + let mut profile = get_reputation(env.clone(), user.clone()); + assert_eq!(profile.score, 100); + + // Fast-forward time by 1 month (30 days) + profile.last_active = profile.last_active.saturating_sub(30 * 24 * 60 * 60 + 1); + storage::set_profile(&env, &user, &profile); + + // Trigger update to apply decay using dummy event + let new_score = update_reputation( + env.clone(), + user.clone(), + ReputationEvent::MilestoneAchieved, + 0, + ); + + // Score should decay by 1% of 100 = 1, so 99. But we added 0 for milestone, so it just decays. + assert_eq!(new_score, 99); +} - #[test] - fn test_badge_veteran() { - let env = setup_env(); - set_ledger_timestamp(&env, 1000); - let contract_id = register_and_init_contract(&env); - let client = StellarGuildsContractClient::new(&env, &contract_id); - - env.mock_all_auths(); - let owner = Address::generate(&env); - let guild_id = setup_guild(&client, &env, &owner); - - let contributor = Address::generate(&env); - client.add_member(&guild_id, &contributor, &Role::Contributor, &owner); - - // Record enough bounties to reach > 1000 points (11 bounties * 100 = 1100) - for i in 0u64..11 { - client.record_contribution( - &guild_id, - &contributor, - &ContributionType::BountyCompleted, - &(i + 1), - ); - } - - let profile = client.get_reputation(&guild_id, &contributor); - assert!(profile.total_score > 1000); - - let badges = client.get_reputation_badges(&guild_id, &contributor); - // Should have FirstContribution + BountyHunter (5+) + Veteran (>1000) - let mut has_veteran = false; - let mut has_bounty_hunter = false; - let mut has_first = false; - for badge in badges.iter() { - match badge.badge_type { - BadgeType::Veteran => has_veteran = true, - BadgeType::BountyHunter => has_bounty_hunter = true, - BadgeType::FirstContribution => has_first = true, - _ => {} - } - } - assert!(has_veteran); - assert!(has_bounty_hunter); - assert!(has_first); - } +#[test] +fn test_tiers_and_multipliers() { + let env = Env::default(); + let user = Address::generate(&env); + + // Test Bronze + assert_eq!(get_tier(env.clone(), user.clone()), ReputationTier::Bronze); + assert_eq!( + calculate_incentive_multiplier(env.clone(), user.clone()), + 100 + ); + + // Test Silver + update_reputation( + env.clone(), + user.clone(), + ReputationEvent::MilestoneAchieved, + 100, + ); + assert_eq!(get_tier(env.clone(), user.clone()), ReputationTier::Silver); + assert_eq!( + calculate_incentive_multiplier(env.clone(), user.clone()), + 110 + ); + + // Test Gold + update_reputation( + env.clone(), + user.clone(), + ReputationEvent::MilestoneAchieved, + 400, + ); + assert_eq!(get_tier(env.clone(), user.clone()), ReputationTier::Gold); + assert_eq!( + calculate_incentive_multiplier(env.clone(), user.clone()), + 125 + ); + + // Test Platinum + update_reputation( + env.clone(), + user.clone(), + ReputationEvent::MilestoneAchieved, + 1000, + ); + assert_eq!( + get_tier(env.clone(), user.clone()), + ReputationTier::Platinum + ); + assert_eq!( + calculate_incentive_multiplier(env.clone(), user.clone()), + 150 + ); + + // Test Diamond + update_reputation( + env.clone(), + user.clone(), + ReputationEvent::MilestoneAchieved, + 3500, + ); + assert_eq!(get_tier(env.clone(), user.clone()), ReputationTier::Diamond); + assert_eq!( + calculate_incentive_multiplier(env.clone(), user.clone()), + 200 + ); +} - #[test] - fn test_cross_guild_reputation() { - let env = setup_env(); - set_ledger_timestamp(&env, 1000); - let contract_id = register_and_init_contract(&env); - let client = StellarGuildsContractClient::new(&env, &contract_id); - - env.mock_all_auths(); - let owner = Address::generate(&env); - let guild1 = setup_guild(&client, &env, &owner); - let guild2 = setup_guild(&client, &env, &owner); - - let contributor = Address::generate(&env); - client.add_member(&guild1, &contributor, &Role::Contributor, &owner); - client.add_member(&guild2, &contributor, &Role::Contributor, &owner); - - // Contributions in guild 1 - client.record_contribution( - &guild1, - &contributor, - &ContributionType::BountyCompleted, - &1u64, - ); - - // Contributions in guild 2 - client.record_contribution( - &guild2, - &contributor, - &ContributionType::MilestoneApproved, - &2u64, - ); - - // Global reputation should aggregate both - let global = client.get_reputation_global(&contributor); - assert_eq!(global, 150); // 100 + 50 +#[test] +fn test_achievements() { + let env = Env::default(); + let user = Address::generate(&env); + + // Mock setting an achievement + let ach = Achievement { + id: 1, + name: soroban_sdk::String::from_str(&env, "First Blood"), + description: soroban_sdk::String::from_str(&env, "First task"), + points: 50, + criteria: soroban_sdk::String::from_str(&env, "tasks>=1"), + }; + storage::set_achievement(&env, 1, &ach); + + // Make user eligible by mock data/criteria logic (assuming they have enough tasks) + // Current criteria requires tasks_completed >= 10 for ID 1 based on achievements.rs + for _ in 0..10 { + update_reputation(env.clone(), user.clone(), ReputationEvent::TaskCompleted, 1); } - #[test] - fn test_no_reputation_fallback() { - let env = setup_env(); - set_ledger_timestamp(&env, 1000); - let contract_id = register_and_init_contract(&env); - let client = StellarGuildsContractClient::new(&env, &contract_id); + assert_eq!( + check_achievement_eligibility(env.clone(), user.clone(), 1), + true + ); - env.mock_all_auths(); - let owner = Address::generate(&env); - let guild_id = setup_guild(&client, &env, &owner); + let success = award_achievement(env.clone(), user.clone(), 1); + assert_eq!(success, true); - let member = Address::generate(&env); - client.add_member(&guild_id, &member, &Role::Contributor, &owner); + let achs = get_achievements(env.clone(), user.clone()); + assert_eq!(achs.len(), 1); + assert_eq!(achs.get(0).unwrap(), 1); - // No contributions at all - // Weight should still equal role_weight (Contributor = 1) - let weight = client.get_governance_weight_for(&guild_id, &member); - assert_eq!(weight, 1); + // Score should include the 50 points + let profile = get_reputation(env.clone(), user.clone()); + assert_eq!(profile.score, 60); // 10 tasks * 1 point + 50 points +} - // Global reputation should be 0 - let global = client.get_reputation_global(&member); - assert_eq!(global, 0); - } +#[test] +fn test_guild_contributors() { + let env = Env::default(); + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + + // Since we wired it primarily using simple global/event structures, + // the top contributors expects the caller to update it or we directly do it. + let mut current = Vec::new(&env); + current.push_back(user1.clone()); + current.push_back(user2.clone()); + storage::set_guild_contributors(&env, 123, ¤t); + + let top = get_top_contributors(env.clone(), 123, 10); + assert_eq!(top.len(), 2); } diff --git a/contract/src/reputation/types.rs b/contract/src/reputation/types.rs index a5c343d..a65014e 100644 --- a/contract/src/reputation/types.rs +++ b/contract/src/reputation/types.rs @@ -1,125 +1,45 @@ use soroban_sdk::{contracttype, Address, String}; -// ────────────────────── Contribution Types ────────────────────── - -/// Types of contributions that earn reputation -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum ContributionType { - BountyCompleted, - MilestoneApproved, - ProposalCreated, - VoteCast, - DisputeResolved, -} - -// ────────────────────── Scoring Constants ────────────────────── - -/// Points awarded per contribution type -pub const POINTS_BOUNTY_COMPLETED: u32 = 100; -pub const POINTS_MILESTONE_APPROVED: u32 = 50; -pub const POINTS_PROPOSAL_CREATED: u32 = 20; -pub const POINTS_VOTE_CAST: u32 = 5; -pub const POINTS_DISPUTE_RESOLVED: u32 = 30; - -/// Decay: 1% per period, applied lazily -pub const DECAY_PERIOD_SECS: u64 = 604_800; // 1 week -/// Decay numerator / denominator => 99/100 = keep 99% per period -pub const DECAY_NUMERATOR: u64 = 99; -pub const DECAY_DENOMINATOR: u64 = 100; - -// ────────────────────── Core Structs ────────────────────── - -/// Individual contribution record for audit trail #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct ContributionRecord { - pub id: u64, - pub guild_id: u64, - pub contributor: Address, - pub contribution_type: ContributionType, - pub points: u32, - pub timestamp: u64, - /// Reference to the bounty/milestone/proposal ID - pub reference_id: u64, +pub enum ReputationTier { + Bronze, + Silver, + Gold, + Platinum, + Diamond, } -/// Per-user per-guild reputation profile #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct ReputationProfile { pub address: Address, - pub guild_id: u64, - /// Raw accumulated score (never decreases) - pub total_score: u64, - /// Score after decay is applied (used for governance weight) - pub decayed_score: u64, - /// Total number of contributions - pub contributions_count: u32, - /// Timestamp of last contribution - pub last_activity: u64, - /// Timestamp when decay was last calculated - pub last_decay_applied: u64, -} - -// ────────────────────── Badge System ────────────────────── - -/// Types of badges that can be earned -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum BadgeType { - /// First ever contribution - FirstContribution, - /// Completed 5+ bounties - BountyHunter, - /// 10+ milestones approved - Mentor, - /// Cast 10+ votes - Governor, - /// Reputation score exceeds 1000 - Veteran, + pub score: u32, + pub tier: ReputationTier, + pub tasks_completed: u32, + pub success_rate: u32, // multiplied by 100, e.g., 9500 = 95.00% + // Only keeping recent/major ones if needed, or simply storing achievement IDs + pub achievements: soroban_sdk::Vec, + pub last_active: u64, // timestamp + pub tasks_failed: u32, } -/// Badge / achievement held by a user #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct Badge { +pub struct Achievement { pub id: u64, - pub guild_id: u64, - pub holder: Address, - pub badge_type: BadgeType, pub name: String, - pub awarded_at: u64, -} - -// ────────────────────── Events ────────────────────── - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ReputationUpdatedEvent { - pub guild_id: u64, - pub contributor: Address, - pub points_earned: u32, - pub new_total_score: u64, - pub contribution_type: ContributionType, + pub description: String, + pub points: u32, + pub criteria: String, } #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -pub struct BadgeAwardedEvent { - pub guild_id: u64, - pub holder: Address, - pub badge_type: BadgeType, - pub badge_name: String, -} - -/// Helper to get points for a contribution type -pub fn points_for_contribution(ct: &ContributionType) -> u32 { - match ct { - ContributionType::BountyCompleted => POINTS_BOUNTY_COMPLETED, - ContributionType::MilestoneApproved => POINTS_MILESTONE_APPROVED, - ContributionType::ProposalCreated => POINTS_PROPOSAL_CREATED, - ContributionType::VoteCast => POINTS_VOTE_CAST, - ContributionType::DisputeResolved => POINTS_DISPUTE_RESOLVED, - } +pub enum ReputationEvent { + TaskCompleted, + TaskFailed, + DisputeWon, + DisputeLost, + MilestoneAchieved, }