Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 112 additions & 3 deletions contracts/referral/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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");

Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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<LeaderboardEntry> = env
.storage()
.persistent()
.get(&DataKey::TopReferrers)
.unwrap_or(Vec::new(env));

let mut existing_idx: Option<u32> = 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<LeaderboardEntry> = 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()
Expand Down Expand Up @@ -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()),
Expand Down Expand Up @@ -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<LeaderboardEntry> {
let board: Vec<LeaderboardEntry> = 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)]
Expand Down
153 changes: 153 additions & 0 deletions contracts/referral/tests/lifecycle_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading