diff --git a/soroban-contracts/Cargo.toml b/soroban-contracts/Cargo.toml new file mode 100644 index 00000000..47fdb569 --- /dev/null +++ b/soroban-contracts/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "lumenpulse" +version = "0.1.0" +edition = "2021" +authors = ["LumenPulse Team"] +description = "LumenPulse Soroban smart contracts — contributor registry with badge tiering" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +testutils = ["soroban-sdk/testutils"] + +[dependencies] +soroban-sdk = { version = "20.0.0" } + +[dev-dependencies] +soroban-sdk = { version = "20.0.0", features = ["testutils"] } + +[profile.release] +opt-level = "z" +overflow-checks = true +debug = 0 +strip = "symbols" +debug-assertions = false +panic = "abort" +codegen-units = 1 +lto = true + +[profile.release-with-logs] +inherits = "release" +debug-assertions = true \ No newline at end of file diff --git a/soroban-contracts/src/lib.rs b/soroban-contracts/src/lib.rs new file mode 100644 index 00000000..c8b1ba14 --- /dev/null +++ b/soroban-contracts/src/lib.rs @@ -0,0 +1,419 @@ +//! # LumenPulse — Contributor Registry with Badge Tiering +//! +//! This Soroban smart contract extends the contributor-registry to support +//! on-chain "Badges" and tiers based on contribution history and reputation. +//! +//! ## Key Design Decisions +//! - Badges are stored as a `Vec` (integer IDs) to keep storage costs low. +//! - A bitmask u64 field is also maintained for ultra-cheap tier checks in Vault. +//! - Admin-gated `award_badge` / `revoke_badge` functions guard write access. +//! - `get_tier_multiplier` lets the Vault contract query reward multipliers. +//! +//! ## Badge ID Convention (extend as needed) +//! | ID | Badge Name | Tier Multiplier | +//! |----|-----------------|-----------------| +//! | 0 | Newcomer | 100 bps (1×) | +//! | 1 | Contributor | 110 bps (1.1×) | +//! | 2 | Trusted | 125 bps (1.25×)| +//! | 3 | Veteran | 150 bps (1.5×) | +//! | 4 | Core | 175 bps (1.75×)| +//! | 5 | Legend | 200 bps (2×) | +//! |6–63| Reserved/Custom | 100 bps (1×) | + +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, + Address, Env, Map, Symbol, Vec, +}; + +// ─── Storage key symbols ───────────────────────────────────────────────────── + +const ADMIN_KEY: Symbol = symbol_short!("ADMIN"); +const REGISTRY_KEY: Symbol = symbol_short!("REGISTRY"); + +// ─── Badge constants ────────────────────────────────────────────────────────── + +/// Maximum badge ID supported by the bitmask (u64 holds bits 0–63). +pub const MAX_BADGE_ID: u32 = 63; + +/// Basis-points multipliers for each tier badge (index = badge_id). +/// Values beyond index 5 default to 100 bps (1×). +const TIER_MULTIPLIERS_BPS: [u32; 6] = [ + 100, // 0 — Newcomer (1.00×) + 110, // 1 — Contributor(1.10×) + 125, // 2 — Trusted (1.25×) + 150, // 3 — Veteran (1.50×) + 175, // 4 — Core (1.75×) + 200, // 5 — Legend (2.00×) +]; + +// ─── Data types ─────────────────────────────────────────────────────────────── + +/// On-chain record stored for every registered contributor. +/// +/// # Fields +/// * `address` — Stellar account address of the contributor. +/// * `reputation_score` — Cumulative reputation points (updated externally). +/// * `contribution_count`— Total accepted contributions. +/// * `badges` — Ordered list of awarded badge IDs (duplicates rejected). +/// * `badge_bitmask` — u64 bitmask mirror of `badges` for cheap tier checks. +/// * `registered_at` — Ledger timestamp of first registration. +/// * `last_active` — Ledger timestamp of most recent update. +#[contracttype] +#[derive(Clone, Debug)] +pub struct ContributorData { + pub address: Address, + pub reputation_score: u64, + pub contribution_count: u32, + /// Vector of awarded badge IDs — primary badge storage (integer IDs keep + /// storage costs low per the issue guideline). + pub badges: Vec, + /// Bitmask mirror of `badges` — bit N is set when badge ID N is awarded. + /// Allows the Vault to do a single integer comparison instead of iterating + /// the full vector when checking tier eligibility. + pub badge_bitmask: u64, + pub registered_at: u64, + pub last_active: u64, +} + +/// Events emitted by the contract (used in `Events::publish`). +#[contracttype] +pub enum ContractEvent { + ContributorRegistered, + BadgeAwarded, + BadgeRevoked, + ReputationUpdated, +} + +// ─── Error codes (returned as u32 via panic) ───────────────────────────────── +// Using explicit numeric literals so callers can match them reliably. + +/// Not authorised — caller is not the admin. +pub const ERR_UNAUTHORIZED: u32 = 1; +/// Contributor already exists in the registry. +pub const ERR_ALREADY_REGISTERED: u32 = 2; +/// Contributor not found in the registry. +pub const ERR_NOT_FOUND: u32 = 3; +/// Badge ID is out of the allowed range (0–63). +pub const ERR_INVALID_BADGE: u32 = 4; +/// Contributor already holds this badge. +pub const ERR_BADGE_ALREADY_HELD: u32 = 5; +/// Contributor does not hold this badge. +pub const ERR_BADGE_NOT_HELD: u32 = 6; + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +fn load_registry(env: &Env) -> Map { + env.storage() + .persistent() + .get(®ISTRY_KEY) + .unwrap_or(Map::new(env)) +} + +fn save_registry(env: &Env, registry: &Map) { + env.storage().persistent().set(®ISTRY_KEY, registry); +} + +fn load_admin(env: &Env) -> Address { + env.storage() + .instance() + .get(&ADMIN_KEY) + .expect("admin not initialised") +} + +fn require_admin(env: &Env, caller: &Address) { + let admin = load_admin(env); + if *caller != admin { + panic!("{}", ERR_UNAUTHORIZED); + } +} + +/// Convert a `Vec` of badge IDs into a u64 bitmask. +fn badges_to_bitmask(badges: &Vec) -> u64 { + let mut mask: u64 = 0; + for id in badges.iter() { + if id <= MAX_BADGE_ID { + mask |= 1u64 << id; + } + } + mask +} + +/// Return the highest-tier multiplier (in basis points) for a bitmask. +/// Scans from the top tier down so the best multiplier wins. +fn best_multiplier_for_mask(mask: u64) -> u32 { + // Iterate from highest tier badge down. + for badge_id in (0..TIER_MULTIPLIERS_BPS.len()).rev() { + if mask & (1u64 << badge_id) != 0 { + return TIER_MULTIPLIERS_BPS[badge_id]; + } + } + // No recognised tier badge — base rate. + 100 +} + +// ─── Contract ──────────────────────────────────────────────────────────────── + +#[contract] +pub struct ContributorRegistry; + +#[contractimpl] +impl ContributorRegistry { + + // ── Initialisation ─────────────────────────────────────────────────────── + + /// Initialise the contract with an admin address. + /// Must be called once immediately after deployment. + pub fn initialize(env: Env, admin: Address) { + // Prevent re-initialisation. + if env.storage().instance().has(&ADMIN_KEY) { + panic!("{}", ERR_UNAUTHORIZED); + } + env.storage().instance().set(&ADMIN_KEY, &admin); + } + + // ── Admin management ───────────────────────────────────────────────────── + + /// Transfer admin role to a new address. Only the current admin may call. + pub fn transfer_admin(env: Env, caller: Address, new_admin: Address) { + caller.require_auth(); + require_admin(&env, &caller); + env.storage().instance().set(&ADMIN_KEY, &new_admin); + } + + /// Return the current admin address. + pub fn get_admin(env: Env) -> Address { + load_admin(&env) + } + + // ── Contributor registration ────────────────────────────────────────────── + + /// Register a new contributor. Callable by anyone (self-registration). + pub fn register_contributor(env: Env, contributor: Address) { + contributor.require_auth(); + + let mut registry = load_registry(&env); + + if registry.contains_key(contributor.clone()) { + panic!("{}", ERR_ALREADY_REGISTERED); + } + + let now = env.ledger().timestamp(); + let data = ContributorData { + address: contributor.clone(), + reputation_score: 0, + contribution_count: 0, + badges: Vec::new(&env), + badge_bitmask: 0, + registered_at: now, + last_active: now, + }; + + registry.set(contributor.clone(), data); + save_registry(&env, ®istry); + + env.events().publish( + (symbol_short!("contrib"), symbol_short!("reg")), + contributor, + ); + } + + // ── Badge management ───────────────────────────────────────────────────── + + /// Award a badge to a contributor. + /// + /// # Arguments + /// * `admin` — Must be the contract admin (checked on-chain). + /// * `contributor` — Target contributor address. + /// * `badge_id` — Integer badge ID in range 0–63. + /// + /// Emits a `badge_award` event on success. + pub fn award_badge( + env: Env, + admin: Address, + contributor: Address, + badge_id: u32, + ) { + admin.require_auth(); + require_admin(&env, &admin); + + if badge_id > MAX_BADGE_ID { + panic!("{}", ERR_INVALID_BADGE); + } + + let mut registry = load_registry(&env); + + let mut data = registry + .get(contributor.clone()) + .unwrap_or_else(|| panic!("{}", ERR_NOT_FOUND)); + + // Reject duplicates. + for existing in data.badges.iter() { + if existing == badge_id { + panic!("{}", ERR_BADGE_ALREADY_HELD); + } + } + + data.badges.push_back(badge_id); + data.badge_bitmask = badges_to_bitmask(&data.badges); + data.last_active = env.ledger().timestamp(); + + registry.set(contributor.clone(), data); + save_registry(&env, ®istry); + + env.events().publish( + (symbol_short!("badge"), symbol_short!("award")), + (contributor, badge_id), + ); + } + + /// Revoke a previously awarded badge from a contributor. + /// + /// # Arguments + /// * `admin` — Must be the contract admin. + /// * `contributor` — Target contributor address. + /// * `badge_id` — Badge ID to revoke. + /// + /// Emits a `badge_revoke` event on success. + pub fn revoke_badge( + env: Env, + admin: Address, + contributor: Address, + badge_id: u32, + ) { + admin.require_auth(); + require_admin(&env, &admin); + + if badge_id > MAX_BADGE_ID { + panic!("{}", ERR_INVALID_BADGE); + } + + let mut registry = load_registry(&env); + + let mut data = registry + .get(contributor.clone()) + .unwrap_or_else(|| panic!("{}", ERR_NOT_FOUND)); + + // Find and remove the badge. + let mut found = false; + let mut new_badges: Vec = Vec::new(&env); + for id in data.badges.iter() { + if id == badge_id { + found = true; // Skip — effectively removes it. + } else { + new_badges.push_back(id); + } + } + + if !found { + panic!("{}", ERR_BADGE_NOT_HELD); + } + + data.badges = new_badges; + data.badge_bitmask = badges_to_bitmask(&data.badges); + data.last_active = env.ledger().timestamp(); + + registry.set(contributor.clone(), data); + save_registry(&env, ®istry); + + env.events().publish( + (symbol_short!("badge"), symbol_short!("revoke")), + (contributor, badge_id), + ); + } + + // ── Reputation & contribution tracking ─────────────────────────────────── + + /// Increment a contributor's reputation score and contribution count. + /// Only admin may call to prevent score manipulation. + pub fn update_reputation( + env: Env, + admin: Address, + contributor: Address, + score_delta: u64, + ) { + admin.require_auth(); + require_admin(&env, &admin); + + let mut registry = load_registry(&env); + + let mut data = registry + .get(contributor.clone()) + .unwrap_or_else(|| panic!("{}", ERR_NOT_FOUND)); + + data.reputation_score = data.reputation_score.saturating_add(score_delta); + data.contribution_count = data.contribution_count.saturating_add(1); + data.last_active = env.ledger().timestamp(); + + registry.set(contributor.clone(), data); + save_registry(&env, ®istry); + + env.events().publish( + (symbol_short!("rep"), symbol_short!("update")), + (contributor, score_delta), + ); + } + + // ── Vault-facing tier queries ───────────────────────────────────────────── + + /// Return the best reward multiplier (basis points) for a contributor. + /// + /// The Vault calls this when calculating matching-pool rewards. + /// + /// # Returns + /// A u32 in basis points where 100 = 1×, 200 = 2×, etc. + /// Returns 100 (base rate) if contributor is not found or has no tier badge. + pub fn get_tier_multiplier(env: Env, contributor: Address) -> u32 { + let registry = load_registry(&env); + match registry.get(contributor) { + Some(data) => best_multiplier_for_mask(data.badge_bitmask), + None => 100, // base rate — safe default for Vault calls + } + } + + /// Return `true` if the contributor holds the specified badge. + /// Uses the bitmask for O(1) lookup — safe for Vault hot-path calls. + pub fn has_badge(env: Env, contributor: Address, badge_id: u32) -> bool { + if badge_id > MAX_BADGE_ID { + return false; + } + let registry = load_registry(&env); + match registry.get(contributor) { + Some(data) => data.badge_bitmask & (1u64 << badge_id) != 0, + None => false, + } + } + + /// Return the bitmask directly for multi-badge checks in a single call. + pub fn get_badge_bitmask(env: Env, contributor: Address) -> u64 { + let registry = load_registry(&env); + match registry.get(contributor) { + Some(data) => data.badge_bitmask, + None => 0, + } + } + + // ── Read queries ────────────────────────────────────────────────────────── + + /// Fetch the full `ContributorData` record for an address. + pub fn get_contributor(env: Env, contributor: Address) -> Option { + let registry = load_registry(&env); + registry.get(contributor) + } + + /// Return the list of badge IDs held by a contributor. + pub fn get_badges(env: Env, contributor: Address) -> Vec { + let registry = load_registry(&env); + match registry.get(contributor) { + Some(data) => data.badges, + None => Vec::new(&env), + } + } + + /// Return the total number of registered contributors. + pub fn contributor_count(env: Env) -> u32 { + let registry = load_registry(&env); + registry.len() + } +} \ No newline at end of file diff --git a/soroban-contracts/src/test.rs b/soroban-contracts/src/test.rs new file mode 100644 index 00000000..00a5bb9f --- /dev/null +++ b/soroban-contracts/src/test.rs @@ -0,0 +1,425 @@ +//! # Unit Tests — Contributor Registry with Badge Tiering +//! +//! Tests cover every public function and all error paths. +//! Run with: `cargo test --features testutils` + +#![cfg(test)] + +use soroban_sdk::{ + testutils::{Address as _, Ledger, LedgerInfo}, + Address, Env, Vec, +}; + +use crate::{ + ContributorRegistry, ContributorRegistryClient, + ERR_UNAUTHORIZED, ERR_ALREADY_REGISTERED, ERR_NOT_FOUND, + ERR_INVALID_BADGE, ERR_BADGE_ALREADY_HELD, ERR_BADGE_NOT_HELD, + MAX_BADGE_ID, +}; + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +/// Stand up a fresh contract environment and return (env, client, admin). +fn setup() -> (Env, ContributorRegistryClient<'static>, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, ContributorRegistry); + let client: ContributorRegistryClient<'static> = + ContributorRegistryClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.initialize(&admin); + + (env, client, admin) +} + +/// Advance the ledger timestamp by `secs` seconds. +fn advance_time(env: &Env, secs: u64) { + let current = env.ledger().timestamp(); + env.ledger().set(LedgerInfo { + timestamp: current + secs, + ..Default::default() + }); +} + +// ─── Initialisation ─────────────────────────────────────────────────────────── + +#[test] +fn test_initialize_sets_admin() { + let (_env, client, admin) = setup(); + assert_eq!(client.get_admin(), admin); +} + +#[test] +#[should_panic] +fn test_double_initialize_panics() { + let (env, client, _admin) = setup(); + let attacker = Address::generate(&env); + client.initialize(&attacker); // Must panic — already initialised. +} + +// ─── Admin transfer ─────────────────────────────────────────────────────────── + +#[test] +fn test_transfer_admin() { + let (env, client, admin) = setup(); + let new_admin = Address::generate(&env); + client.transfer_admin(&admin, &new_admin); + assert_eq!(client.get_admin(), new_admin); +} + +#[test] +#[should_panic] +fn test_transfer_admin_non_admin_panics() { + let (env, client, _admin) = setup(); + let rando = Address::generate(&env); + let new_admin = Address::generate(&env); + client.transfer_admin(&rando, &new_admin); // ERR_UNAUTHORIZED +} + +// ─── Contributor registration ───────────────────────────────────────────────── + +#[test] +fn test_register_contributor_success() { + let (env, client, _admin) = setup(); + let contributor = Address::generate(&env); + + client.register_contributor(&contributor); + + let data = client.get_contributor(&contributor).expect("contributor should exist"); + assert_eq!(data.address, contributor); + assert_eq!(data.reputation_score, 0); + assert_eq!(data.contribution_count, 0); + assert_eq!(data.badge_bitmask, 0); + assert_eq!(data.badges.len(), 0); +} + +#[test] +fn test_contributor_count_increments() { + let (env, client, _admin) = setup(); + assert_eq!(client.contributor_count(), 0); + + let c1 = Address::generate(&env); + let c2 = Address::generate(&env); + client.register_contributor(&c1); + assert_eq!(client.contributor_count(), 1); + client.register_contributor(&c2); + assert_eq!(client.contributor_count(), 2); +} + +#[test] +#[should_panic] +fn test_double_register_panics() { + let (env, client, _admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + client.register_contributor(&contributor); // ERR_ALREADY_REGISTERED +} + +#[test] +fn test_get_contributor_returns_none_for_unknown() { + let (env, client, _admin) = setup(); + let unknown = Address::generate(&env); + assert!(client.get_contributor(&unknown).is_none()); +} + +// ─── award_badge ────────────────────────────────────────────────────────────── + +#[test] +fn test_award_badge_sets_vector_and_bitmask() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + + // Award badge 1 (Contributor tier). + client.award_badge(&admin, &contributor, &1u32); + + let data = client.get_contributor(&contributor).unwrap(); + assert_eq!(data.badges.len(), 1); + assert_eq!(data.badges.get(0).unwrap(), 1u32); + // Bit 1 should be set: bitmask == 0b10 == 2. + assert_eq!(data.badge_bitmask, 2u64); +} + +#[test] +fn test_award_multiple_badges() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + + client.award_badge(&admin, &contributor, &0u32); // Newcomer + client.award_badge(&admin, &contributor, &3u32); // Veteran + + let data = client.get_contributor(&contributor).unwrap(); + assert_eq!(data.badges.len(), 2); + // Bits 0 and 3 set: 0b1001 == 9. + assert_eq!(data.badge_bitmask, 9u64); +} + +#[test] +fn test_get_badges_helper() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + client.award_badge(&admin, &contributor, &2u32); + client.award_badge(&admin, &contributor, &5u32); + + let badges = client.get_badges(&contributor); + assert_eq!(badges.len(), 2); +} + +#[test] +fn test_has_badge_true_and_false() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + client.award_badge(&admin, &contributor, &3u32); + + assert!(client.has_badge(&contributor, &3u32)); + assert!(!client.has_badge(&contributor, &0u32)); + assert!(!client.has_badge(&contributor, &5u32)); +} + +#[test] +fn test_get_badge_bitmask() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + client.award_badge(&admin, &contributor, &0u32); // bit 0 → mask 1 + client.award_badge(&admin, &contributor, &2u32); // bit 2 → mask 4 + + assert_eq!(client.get_badge_bitmask(&contributor), 5u64); // 0b101 +} + +#[test] +#[should_panic] +fn test_award_badge_non_admin_panics() { + let (env, client, _admin) = setup(); + let contributor = Address::generate(&env); + let rando = Address::generate(&env); + client.register_contributor(&contributor); + client.award_badge(&rando, &contributor, &1u32); // ERR_UNAUTHORIZED +} + +#[test] +#[should_panic] +fn test_award_badge_unknown_contributor_panics() { + let (env, client, admin) = setup(); + let unknown = Address::generate(&env); + client.award_badge(&admin, &unknown, &1u32); // ERR_NOT_FOUND +} + +#[test] +#[should_panic] +fn test_award_badge_invalid_id_panics() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + client.award_badge(&admin, &contributor, &(MAX_BADGE_ID + 1)); // ERR_INVALID_BADGE +} + +#[test] +#[should_panic] +fn test_award_duplicate_badge_panics() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + client.award_badge(&admin, &contributor, &1u32); + client.award_badge(&admin, &contributor, &1u32); // ERR_BADGE_ALREADY_HELD +} + +// ─── revoke_badge ───────────────────────────────────────────────────────────── + +#[test] +fn test_revoke_badge_removes_from_vector_and_bitmask() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + + client.award_badge(&admin, &contributor, &1u32); + client.award_badge(&admin, &contributor, &3u32); + // Before revoke: bits 1 and 3 → bitmask 0b1010 = 10. + assert_eq!(client.get_badge_bitmask(&contributor), 10u64); + + client.revoke_badge(&admin, &contributor, &1u32); + + let data = client.get_contributor(&contributor).unwrap(); + assert_eq!(data.badges.len(), 1); + assert_eq!(data.badges.get(0).unwrap(), 3u32); + // Only bit 3 remains → bitmask 0b1000 = 8. + assert_eq!(data.badge_bitmask, 8u64); + assert!(!client.has_badge(&contributor, &1u32)); + assert!(client.has_badge(&contributor, &3u32)); +} + +#[test] +#[should_panic] +fn test_revoke_badge_not_held_panics() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + client.revoke_badge(&admin, &contributor, &2u32); // ERR_BADGE_NOT_HELD +} + +#[test] +#[should_panic] +fn test_revoke_badge_non_admin_panics() { + let (env, client, _admin) = setup(); + let contributor = Address::generate(&env); + let rando = Address::generate(&env); + client.register_contributor(&contributor); + client.revoke_badge(&rando, &contributor, &0u32); // ERR_UNAUTHORIZED +} + +// ─── Reputation & contribution count ────────────────────────────────────────── + +#[test] +fn test_update_reputation() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + + client.update_reputation(&admin, &contributor, &50u64); + client.update_reputation(&admin, &contributor, &25u64); + + let data = client.get_contributor(&contributor).unwrap(); + assert_eq!(data.reputation_score, 75); + assert_eq!(data.contribution_count, 2); +} + +#[test] +#[should_panic] +fn test_update_reputation_non_admin_panics() { + let (env, client, _admin) = setup(); + let contributor = Address::generate(&env); + let rando = Address::generate(&env); + client.register_contributor(&contributor); + client.update_reputation(&rando, &contributor, &10u64); // ERR_UNAUTHORIZED +} + +// ─── Tier multiplier (Vault integration) ───────────────────────────────────── + +#[test] +fn test_get_tier_multiplier_no_badge_returns_base() { + let (env, client, _admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + + // No badges → base rate 100 bps. + assert_eq!(client.get_tier_multiplier(&contributor), 100u32); +} + +#[test] +fn test_get_tier_multiplier_unknown_contributor_returns_base() { + let (env, client, _admin) = setup(); + let unknown = Address::generate(&env); + assert_eq!(client.get_tier_multiplier(&unknown), 100u32); +} + +#[test] +fn test_get_tier_multiplier_newcomer() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + client.award_badge(&admin, &contributor, &0u32); // Newcomer + assert_eq!(client.get_tier_multiplier(&contributor), 100u32); +} + +#[test] +fn test_get_tier_multiplier_contributor_tier() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + client.award_badge(&admin, &contributor, &1u32); // Contributor tier + assert_eq!(client.get_tier_multiplier(&contributor), 110u32); +} + +#[test] +fn test_get_tier_multiplier_veteran() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + client.award_badge(&admin, &contributor, &3u32); // Veteran + assert_eq!(client.get_tier_multiplier(&contributor), 150u32); +} + +#[test] +fn test_get_tier_multiplier_legend() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + client.award_badge(&admin, &contributor, &5u32); // Legend + assert_eq!(client.get_tier_multiplier(&contributor), 200u32); +} + +#[test] +fn test_get_tier_multiplier_best_badge_wins() { + /// Contributor holds Newcomer (0) AND Veteran (3) — should get Veteran's 150. + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + client.award_badge(&admin, &contributor, &0u32); // Newcomer 100 + client.award_badge(&admin, &contributor, &3u32); // Veteran 150 + assert_eq!(client.get_tier_multiplier(&contributor), 150u32); +} + +#[test] +fn test_multiplier_after_revoke_drops() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + client.award_badge(&admin, &contributor, &4u32); // Core 175 + assert_eq!(client.get_tier_multiplier(&contributor), 175u32); + + client.revoke_badge(&admin, &contributor, &4u32); + // No remaining tier badges → base 100. + assert_eq!(client.get_tier_multiplier(&contributor), 100u32); +} + +// ─── Edge cases ─────────────────────────────────────────────────────────────── + +#[test] +fn test_max_badge_id_is_valid() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + // Badge 63 is the highest allowed; should succeed. + client.award_badge(&admin, &contributor, &MAX_BADGE_ID); + assert!(client.has_badge(&contributor, &MAX_BADGE_ID)); + // Bitmask bit 63 → 2^63. + assert_eq!(client.get_badge_bitmask(&contributor), 1u64 << 63); +} + +#[test] +fn test_has_badge_unknown_contributor_returns_false() { + let (env, client, _admin) = setup(); + let unknown = Address::generate(&env); + assert!(!client.has_badge(&unknown, &0u32)); +} + +#[test] +fn test_has_badge_out_of_range_returns_false() { + let (env, client, _admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + // Out-of-range badge IDs always return false without panicking. + assert!(!client.has_badge(&contributor, &(MAX_BADGE_ID + 1))); + assert!(!client.has_badge(&contributor, &u32::MAX)); +} + +#[test] +fn test_timestamps_update_on_activity() { + let (env, client, admin) = setup(); + let contributor = Address::generate(&env); + client.register_contributor(&contributor); + + let registered_at = client.get_contributor(&contributor).unwrap().registered_at; + + advance_time(&env, 3600); // +1 hour + client.award_badge(&admin, &contributor, &1u32); + + let data = client.get_contributor(&contributor).unwrap(); + assert!(data.last_active > registered_at); + assert_eq!(data.registered_at, registered_at); // registered_at must not change +} \ No newline at end of file