diff --git a/contracts/referral/src/lib.rs b/contracts/referral/src/lib.rs index 6a111ec9..66bd184c 100644 --- a/contracts/referral/src/lib.rs +++ b/contracts/referral/src/lib.rs @@ -6,7 +6,8 @@ // - Referee (new user): register() // - Pool contract: record_activity() // - Referrer: claim_rewards() -// - Anyone: read-only view functions (get_stats, get_referrer, get_pending_reward) +// - Anyone: read-only view functions (get_stats, get_referrer, get_pending_reward, +// get_top_referrers) // // #799: on-chain referral program. A referee names their referrer once via // register(). The pool contract calls record_activity() whenever it @@ -20,7 +21,7 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, token, - Address, Env, Symbol, + Address, Env, Symbol, Vec, }; const LEDGERS_PER_DAY: u32 = 17_280; @@ -34,6 +35,10 @@ const MAX_BPS: u32 = 10_000; const DEFAULT_BORROW_REWARD_BPS: u32 = 500; /// Referee deposits: referrer earns 10% of the yield fee from their deposits. const DEFAULT_DEPOSIT_REWARD_BPS: u32 = 1_000; +/// #943: number of top referrers tracked for get_top_referrers(). A referrer +/// only needs to be tracked here once their referral_count exceeds the +/// current lowest-ranked tracked entry. +const MAX_LEADERBOARD_SIZE: u32 = 25; const EVT: Symbol = symbol_short!("REFERRAL"); @@ -56,6 +61,14 @@ pub struct ReferralStats { pub referral_count: u32, } +/// #943: one row of the get_top_referrers() leaderboard. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct LeaderboardEntry { + pub referrer: Address, + pub referral_count: u32, +} + #[contracttype] pub enum DataKey { Admin, @@ -74,6 +87,9 @@ pub enum DataKey { /// referrer -> number of referees who have completed a qualifying /// activity ReferralCount(Address), + /// #943: descending-sorted leaderboard of the top MAX_LEADERBOARD_SIZE + /// referrers by referral_count. + TopReferrers, } fn bump_instance(env: &Env) { @@ -93,6 +109,75 @@ fn require_not_paused(env: &Env) { } } +/// #943: keep the on-chain leaderboard current whenever a referrer's +/// referral_count changes. Referrers outside the tracked top +/// MAX_LEADERBOARD_SIZE are left untouched to avoid unbounded storage +/// growth from a naive "record every referrer" approach. +fn update_leaderboard(env: &Env, referrer: &Address, new_count: u32) { + let mut board: Vec = env + .storage() + .persistent() + .get(&DataKey::TopReferrers) + .unwrap_or(Vec::new(env)); + + let mut existing_idx: Option = None; + for i in 0..board.len() { + if &board.get(i).unwrap().referrer == referrer { + existing_idx = Some(i); + break; + } + } + + let entry = LeaderboardEntry { + referrer: referrer.clone(), + referral_count: new_count, + }; + + if let Some(i) = existing_idx { + board.set(i, entry); + } else if board.len() < MAX_LEADERBOARD_SIZE { + board.push_back(entry); + } else { + let mut min_idx: u32 = 0; + let mut min_count = board.get(0).unwrap().referral_count; + for i in 1..board.len() { + let count = board.get(i).unwrap().referral_count; + if count < min_count { + min_count = count; + min_idx = i; + } + } + if new_count <= min_count { + // Doesn't crack the tracked top set — nothing to persist. + return; + } + board.set(min_idx, entry); + } + + // Re-sort descending by referral_count (selection sort — board is + // capped at MAX_LEADERBOARD_SIZE so this stays cheap). + let mut sorted: Vec = Vec::new(env); + let len = board.len(); + for _ in 0..len { + let mut best_idx: u32 = 0; + let mut best = board.get(0).unwrap(); + for j in 1..board.len() { + let candidate = board.get(j).unwrap(); + if candidate.referral_count > best.referral_count { + best_idx = j; + best = candidate; + } + } + sorted.push_back(best); + board.remove(best_idx); + } + + env.storage().persistent().set(&DataKey::TopReferrers, &sorted); + env.storage() + .persistent() + .extend_ttl(&DataKey::TopReferrers, REGISTRY_TTL, REGISTRY_TTL); +} + fn require_admin(env: &Env, admin: &Address) { let stored_admin: Address = env .storage() @@ -293,10 +378,12 @@ impl ReferralContract { .extend_ttl(&activated_key, REGISTRY_TTL, REGISTRY_TTL); let count_key = DataKey::ReferralCount(referrer.clone()); let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - env.storage().persistent().set(&count_key, &(count + 1)); + let new_count = count + 1; + env.storage().persistent().set(&count_key, &new_count); env.storage() .persistent() .extend_ttl(&count_key, REGISTRY_TTL, REGISTRY_TTL); + update_leaderboard(&env, &referrer, new_count); env.events().publish( (EVT, symbol_short!("activatd")), (referee.clone(), referrer.clone()), @@ -372,6 +459,28 @@ impl ReferralContract { referral_count, } } + + /// #943: top referrers by referral_count, descending. `limit` caps the + /// number of rows returned; 0 (or a value larger than the tracked set) + /// returns the full tracked leaderboard, which holds at most + /// MAX_LEADERBOARD_SIZE entries. + pub fn get_top_referrers(env: Env, limit: u32) -> Vec { + let board: Vec = env + .storage() + .persistent() + .get(&DataKey::TopReferrers) + .unwrap_or(Vec::new(&env)); + let take = if limit == 0 || limit > board.len() { + board.len() + } else { + limit + }; + let mut result = Vec::new(&env); + for i in 0..take { + result.push_back(board.get(i).unwrap()); + } + result + } } #[cfg(test)] diff --git a/contracts/referral/tests/lifecycle_tests.rs b/contracts/referral/tests/lifecycle_tests.rs index 2667820c..e4499b4c 100644 --- a/contracts/referral/tests/lifecycle_tests.rs +++ b/contracts/referral/tests/lifecycle_tests.rs @@ -272,6 +272,159 @@ fn test_claim_rewards_transfers_token_and_resets_pending() { assert_eq!(client.claim_rewards(&referrer, &token), 0); } +#[test] +fn test_get_top_referrers_empty_by_default() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, _pool) = setup(&env); + + assert_eq!(client.get_top_referrers(&0).len(), 0); + assert_eq!(client.get_top_referrers(&5).len(), 0); +} + +#[test] +fn test_get_top_referrers_ranks_by_referral_count_descending() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, pool) = setup(&env); + let token = setup_token(&env); + + let referrer_a = Address::generate(&env); + let referrer_b = Address::generate(&env); + let referrer_c = Address::generate(&env); + + // referrer_a: 3 activated referees, referrer_b: 1, referrer_c: 2. + for _ in 0..3 { + let referee = Address::generate(&env); + client.register(&referee, &referrer_a); + client.record_activity(&pool, &referee, &Symbol::new(&env, "deposit"), &0i128, &token); + } + let referee_b = Address::generate(&env); + client.register(&referee_b, &referrer_b); + client.record_activity(&pool, &referee_b, &Symbol::new(&env, "deposit"), &0i128, &token); + + for _ in 0..2 { + let referee = Address::generate(&env); + client.register(&referee, &referrer_c); + client.record_activity(&pool, &referee, &Symbol::new(&env, "deposit"), &0i128, &token); + } + + let board = client.get_top_referrers(&0); + assert_eq!(board.len(), 3); + assert_eq!(board.get(0).unwrap().referrer, referrer_a); + assert_eq!(board.get(0).unwrap().referral_count, 3); + assert_eq!(board.get(1).unwrap().referrer, referrer_c); + assert_eq!(board.get(1).unwrap().referral_count, 2); + assert_eq!(board.get(2).unwrap().referrer, referrer_b); + assert_eq!(board.get(2).unwrap().referral_count, 1); +} + +#[test] +fn test_get_top_referrers_respects_limit() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, pool) = setup(&env); + let token = setup_token(&env); + + for _ in 0..4 { + let referrer = Address::generate(&env); + let referee = Address::generate(&env); + client.register(&referee, &referrer); + client.record_activity(&pool, &referee, &Symbol::new(&env, "deposit"), &0i128, &token); + } + + assert_eq!(client.get_top_referrers(&2).len(), 2); + // A limit larger than the tracked set just returns everything tracked. + assert_eq!(client.get_top_referrers(&100).len(), 4); +} + +#[test] +fn test_get_top_referrers_does_not_double_count_repeat_activity() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, pool) = setup(&env); + let token = setup_token(&env); + let referee = Address::generate(&env); + let referrer = Address::generate(&env); + client.register(&referee, &referrer); + + // Activation (first qualifying activity) plus a second activity from the + // same already-activated referee must only ever count once. + client.record_activity( + &pool, + &referee, + &Symbol::new(&env, "borrow"), + &1_000_0000000i128, + &token, + ); + client.record_activity( + &pool, + &referee, + &Symbol::new(&env, "borrow"), + &500_0000000i128, + &token, + ); + + let board = client.get_top_referrers(&0); + assert_eq!(board.len(), 1); + assert_eq!(board.get(0).unwrap().referral_count, 1); +} + +#[test] +fn test_get_top_referrers_evicts_lowest_when_full() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _admin, pool) = setup(&env); + let token = setup_token(&env); + + // Fill the leaderboard to its MAX_LEADERBOARD_SIZE (25) cap, each with a + // distinct referral_count so ranking (and eviction) is unambiguous. + let mut referrers = std::vec::Vec::new(); + for i in 0..25u32 { + let referrer = Address::generate(&env); + for _ in 0..=i { + let referee = Address::generate(&env); + client.register(&referee, &referrer); + client.record_activity(&pool, &referee, &Symbol::new(&env, "deposit"), &0i128, &token); + } + referrers.push(referrer); + } + // Lowest-ranked tracked referrer so far has referral_count == 1. + assert_eq!(client.get_top_referrers(&0).len(), 25); + assert_eq!( + client.get_top_referrers(&0).get(24).unwrap().referral_count, + 1 + ); + + // A brand-new referrer with 2 activated referees beats the current + // lowest entry (count 1) and should bump it off the board. + let challenger = Address::generate(&env); + for _ in 0..2 { + let referee = Address::generate(&env); + client.register(&referee, &challenger); + client.record_activity(&pool, &referee, &Symbol::new(&env, "deposit"), &0i128, &token); + } + + let board = client.get_top_referrers(&0); + assert_eq!(board.len(), 25); + let mut found_challenger = false; + let mut found_evicted = false; + for i in 0..board.len() { + let entry = board.get(i).unwrap(); + if entry.referrer == challenger { + found_challenger = true; + } + if entry.referrer == referrers.get(0).unwrap().clone() { + found_evicted = true; + } + } + assert!(found_challenger, "challenger should be on the leaderboard"); + assert!( + !found_evicted, + "lowest-ranked referrer (count 1) should have been evicted" + ); +} + #[test] fn test_pause_blocks_register_and_claim() { let env = Env::default();