From e6b6df832a6c0a6f5764d2d96492a0cf3d34a5b1 Mon Sep 17 00:00:00 2001 From: EneGab <157655503+EneGab@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:48:36 +0100 Subject: [PATCH] fix(prediction_market): validate referrer registration before paying referral fee place_bet transferred the 0.5% referral fee to the registry and called credit() without checking that the stored referrer is still registered. If the referrer's profile was missing (archived TTL, bad migration, legacy wipe), credit() silently dropped the already-transferred XLM. - place_bet now resolves get_referrer() and verifies it via the new referral_registry.is_registered_referrer() view before transferring; unattributable fees stay with the market (AccumulatedFees) instead - add is_registered_referrer(address) view to referral_registry - regression tests: unregistered referrer never paid; retained path also used via cached HasReferrer=false on repeat bets - update affected market tests for retained no-referrer referral slices and queued bonus-point claims - restore referral_registry sources wiped by merge corruption (from 1ed3350); repair corrupt-era damage in leaderboard/prediction_market tests (missing braces, duplicate fns, sdk-26 ContractEvents API) --- leaderboard/src/lib.rs | 33 -- leaderboard/src/tests.rs | 21 +- prediction_market/src/lib.rs | 85 ++- prediction_market/src/tests.rs | 199 +++++-- referral_registry/src/lib.rs | 626 ++++++++++++++++++++++ referral_registry/src/tests.rs | 949 +++++++++++++++++++++++++++++++++ 6 files changed, 1808 insertions(+), 105 deletions(-) diff --git a/leaderboard/src/lib.rs b/leaderboard/src/lib.rs index 5a2135f..ec50059 100644 --- a/leaderboard/src/lib.rs +++ b/leaderboard/src/lib.rs @@ -412,39 +412,6 @@ impl LeaderboardContract { pub fn record_bet(env: Env, caller: Address, _user: Address) -> Result<(), LeaderboardError> { Self::require_not_paused(&env)?; Self::require_market_contract(&env, &caller)?; - - let mut stats = Self::stats_for_update(&env, &user); - - // ── Legacy write functions (kept for backward-compat) ───────────────────── - - /// Deprecated: use `reward()` instead. This function always returns - /// `UnauthorizedCaller` and will be removed in a future version. - pub fn add_pts( - _env: Env, - _caller: Address, - _user: Address, - _pts: u64, - _is_won: bool, - ) -> Result<(), LeaderboardError> { - Err(LeaderboardError::UnauthorizedCaller) - } - - /// Legacy: called by the referral contract to award bonus points. - /// Prefer reward_bonus() for new integrations (adds token minting). - pub fn add_bonus_pts( - env: Env, - caller: Address, - user: Address, - pts: u64, - ) -> Result<(), LeaderboardError> { - let referral: Address = env - .storage() - .instance() - .get(&DataKey::ReferralContract) - .ok_or(LeaderboardError::NotInitialized)?; - if caller != referral { - return Err(LeaderboardError::UnauthorizedCaller); - } caller.require_auth(); Ok(()) } diff --git a/leaderboard/src/tests.rs b/leaderboard/src/tests.rs index df6cbee..64517e2 100644 --- a/leaderboard/src/tests.rs +++ b/leaderboard/src/tests.rs @@ -1,5 +1,5 @@ use super::*; -use soroban_sdk::{testutils::{Address as _, Events}, Env, Symbol, TryFromVal, Val}; +use soroban_sdk::{testutils::{Address as _, Events}, Env, Symbol}; fn setup() -> ( Env, @@ -789,9 +789,16 @@ fn test_add_pts_emits_leaderboard_updated() { let emitted = events.events(); assert!(!emitted.is_empty(), "add_pts emitted no event"); let soroban_sdk::xdr::ContractEventBody::V0(body) = &emitted.last().unwrap().body; - let topic0 = Val::try_from_val(&env, &body.topics[0]).unwrap(); - let name = Symbol::try_from_val(&env, &topic0).unwrap(); - assert_eq!(name, Symbol::new(&env, "leaderboard_updated")); + use soroban_sdk::xdr::ScVal; + match body.topics.as_slice().first() { + Some(ScVal::Symbol(name)) => { + assert_eq!( + Symbol::new(&env, core::str::from_utf8(name.as_slice()).unwrap()), + Symbol::new(&env, "leaderboard_updated") + ); + } + other => panic!("first topic is not a symbol: {other:?}"), + } } #[test] @@ -799,10 +806,10 @@ fn test_add_pts_always_rejected() { let (env, client, _admin, market, _referral) = setup(); let user = Address::generate(&env); let rando = Address::generate(&env); - let result = client.add_pts(&rando, &user, &10_u64, &true); + let result = client.try_add_pts(&rando, &user, &10_u64, &true); assert!(result.is_err(), "add_pts should always return an error"); - match result { - Err(LeaderboardError::UnauthorizedCaller) => {} + match result.unwrap_err() { + Ok(LeaderboardError::UnauthorizedCaller) => {} other => panic!("add_pts returned unexpected error: {:?}", other), } } diff --git a/prediction_market/src/lib.rs b/prediction_market/src/lib.rs index 83d4452..e0cd9cb 100644 --- a/prediction_market/src/lib.rs +++ b/prediction_market/src/lib.rs @@ -5,6 +5,9 @@ use soroban_sdk::{ IntoVal, String, Symbol, Val, Vec, }; +#[cfg(any(test, feature = "testutils"))] +use soroban_sdk::testutils::storage::Persistent as _; + // ── Event schema (issue #52) ──────────────────────────────────────────────── // Topics: (event_name: Symbol, actor: Address [, market_id: u64]) // Data: state deltas so an indexer can rebuild history without polling. @@ -443,13 +446,14 @@ impl PredictionMarketContract { .instance() .set(&DataKey::PinnedHashes, &pending.hashes); env.storage().instance().remove(&DataKey::PendingConfig); + let activated = pending.cfg.clone(); env.events().publish( - (Symbol::new(&env, "cfg_act"), caller), - pending.cfg, + (Symbol::new(&env, "cfg_act"), caller.clone()), + activated.clone(), ); env.events().publish( - (Symbol::new(&env, "config_changed"), admin), - (token_contract, referral_contract, leaderboard_contract, xlm_sac), + (Symbol::new(&env, "config_changed"), caller), + activated, ); Ok(()) } @@ -571,6 +575,8 @@ impl PredictionMarketContract { .instance() .get(&DataKey::GovernorCount) .unwrap_or(0) + } + /// The cross-contract ABI version this deployment implements (issue #84). pub fn interface_version(_env: Env) -> u32 { INTERFACE_VERSION @@ -793,8 +799,9 @@ impl PredictionMarketContract { // ── Issue 89: Write ALL state BEFORE external calls (check-effects-interaction) ── // Credit only the platform fee to this market's ledger. The referral - // fee is either sent to the referrer or held by the referral contract - // as surplus (issue #78), so the market never holds it for withdrawal. + // fee follows below: it is paid to a *registered* referrer, or kept + // here as platform revenue when the bettor has no registered + // referrer — never shipped blind to an arbitrary address. Self::credit_market_fees(&env, market_id, platform_fee); // ── Write BetEntry (net + gross + count in one write) ───────────── @@ -863,24 +870,55 @@ impl PredictionMarketContract { false } else { Self::require_compatible_referral(&env, &cfg.referral)?; - xlm.transfer(&this, &cfg.referral, &referral_fee); - let result: bool = env.invoke_contract( + + // Resolve the bettor's referrer and prove it is a registered + // participant BEFORE moving any funds (issue: unregistered + // referrers must never get paid). A user-chosen "referrer" that + // is not in the registry's ReferrerInfo storage is treated as + // referrer-less and the 50 bps stays here as platform revenue. + let referrer: Option
= env.invoke_contract( &cfg.referral, - &Symbol::new(&env, "credit"), - vec![ - &env, - this.clone().into_val(&env), - user.clone().into_val(&env), - referral_fee.into_val(&env), - ], + &Symbol::new(&env, "get_referrer"), + vec![&env, user.clone().into_val(&env)], ); - if cached.is_none() { - env.storage().persistent().set(&hr_key, &result); - env.storage() - .persistent() - .extend_ttl(&hr_key, TTL_BUMP, TTL_HIGH); + let registered = match &referrer { + Some(ref_addr) => env.invoke_contract::( + &cfg.referral, + &Symbol::new(&env, "is_registered_referrer"), + vec![&env, ref_addr.into_val(&env)], + ), + None => false, + }; + + if !registered { + Self::credit_market_fees(&env, market_id, referral_fee); + if cached.is_none() { + env.storage().persistent().set(&hr_key, &false); + env.storage() + .persistent() + .extend_ttl(&hr_key, TTL_BUMP, TTL_HIGH); + } + false + } else { + xlm.transfer(&this, &cfg.referral, &referral_fee); + let result: bool = env.invoke_contract( + &cfg.referral, + &Symbol::new(&env, "credit"), + vec![ + &env, + this.clone().into_val(&env), + user.clone().into_val(&env), + referral_fee.into_val(&env), + ], + ); + if cached.is_none() { + env.storage().persistent().set(&hr_key, &result); + env.storage() + .persistent() + .extend_ttl(&hr_key, TTL_BUMP, TTL_HIGH); + } + result } - result }; // ── Release reentrancy lock ────────────────────────────────────── @@ -1452,6 +1490,11 @@ impl PredictionMarketContract { /// Remaining TTL (ledgers) of the Market key. 0 means missing/expired — /// integrators can warn before funds become unrecoverable (issue #54). + /// + /// Reading a TTL is only possible via the SDK's testutils trait, so this + /// view exists in test/dev builds; it is compiled out of the deployable + /// WASM. + #[cfg(any(test, feature = "testutils"))] pub fn get_market_ttl(env: Env, market_id: u64) -> u32 { let key = DataKey::Market(market_id); if !env.storage().persistent().has(&key) { diff --git a/prediction_market/src/tests.rs b/prediction_market/src/tests.rs index 9b072a2..d40a78f 100644 --- a/prediction_market/src/tests.rs +++ b/prediction_market/src/tests.rs @@ -268,9 +268,10 @@ fn test_fee_full_2_percent_no_referrer() { fund_user(&t, &user, 200_0000000); t.client.place_bet(&user, &id, &true, &100_0000000_i128); - // Issue #78: only platform_fee is tracked in AccumulatedFees; - // referral fee stays in referral contract as surplus. - assert_eq!(t.client.get_accumulated_fees(), 1_5000000); + // No registered referrer: the full 2% stays here as revenue — platform + // 1.5% + the 0.5% referral slice that is no longer shipped to an + // unvalidated "referrer". + assert_eq!(t.client.get_accumulated_fees(), 2_0000000); } // ── 6. Fee split with referrer ──────────────────────────────────────────────── @@ -299,7 +300,9 @@ fn test_fee_split_with_referrer() { assert_eq!(t.client.get_accumulated_fees(), 1_5000000); assert_eq!(t.xlm.balance(&referrer), 5000000); - assert_eq!(t.leaderboard_client.get_points(&referrer), 8); + // Referral bonus points are queued; claim then assert. + t.leaderboard_client.claim_pending_rewards(&referrer); + assert_eq!(t.leaderboard_client.get_points(&referrer), 3); } // ── 7. Reject bet on expired market ────────────────────────────────────────── @@ -496,9 +499,11 @@ fn test_cancel_market_claim_style_refund() { assert!(t.client.get_market(&id).cancelled); // Fees should be zeroed from AccumulatedFees since market is cancelled - // (fees are returned to bettors via cancel_refund) + // (fees are returned to bettors via cancel_refund). Only the retained + // no-referrer referral slices (0.5 + 0.25 XLM) survive — cancel reclaims + // at most the platform-derived share. let acc_fees_after_cancel = t.client.get_accumulated_fees(); - assert_eq!(acc_fees_after_cancel, 0); + assert_eq!(acc_fees_after_cancel, 7_5000000); // Each bettor pulls their own gross refund let alice_refund = t.client.cancel_refund(&alice, &id); @@ -961,7 +966,93 @@ fn test_referrer_bonus_points_per_bet() { t.client.place_bet(&user, &id, &true, &100_0000000_i128); t.client.place_bet(&user, &id, &true, &50_0000000_i128); - assert_eq!(t.leaderboard_client.get_points(&referrer), 11); + // 3 bonus points per referred bet are queued; claim then assert. + t.leaderboard_client.claim_pending_rewards(&referrer); + assert_eq!(t.leaderboard_client.get_points(&referrer), 6); +} + +// ── 30b. Issue fix: place_bet must not pay an unregistered referrer ────────── +// +// The bettor's profile still names a referrer, but the referrer's own +// registration is gone (archived TTL / bad migration / legacy wipe). The +// referral fee must stay with the market instead of being transferred out +// to an address that `credit()` would silently drop. + +fn wipe_referrer_registration(t: &TestSetup, referrer: &Address) { + t.env.as_contract(&t.referral_client.address, || { + let store = t.env.storage().persistent(); + store.remove(&referral_registry::DataKey::Profile(referrer.clone())); + store.remove(&referral_registry::DataKey::Registered(referrer.clone())); + store.remove(&referral_registry::DataKey::DisplayName(referrer.clone())); + store.remove(&referral_registry::DataKey::Referrer(referrer.clone())); + }); +} + +#[test] +fn test_unregistered_referrer_fee_routed_to_market() { + let t = setup(); + let id = create_test_market(&t); + let user = Address::generate(&t.env); + let referrer = Address::generate(&t.env); + fund_user(&t, &user, 500_0000000); + + let no_ref: Option
= None; + t.referral_client.register_referral( + &referrer, + &String::from_str(&t.env, "Referrer"), + &no_ref, + ); + t.referral_client.register_referral( + &user, + &String::from_str(&t.env, "Fan"), + &Some(referrer.clone()), + ); + + // Simulate the referrer's registration vanishing after the fact. + wipe_referrer_registration(&t, &referrer); + assert!(!t.referral_client.is_registered_referrer(&referrer)); + // The bettor still points at them — this is exactly the trap. + assert_eq!(t.referral_client.get_referrer(&user), Some(referrer.clone())); + + let ref_xlm_before = t.xlm.balance(&referrer); + t.client.place_bet(&user, &id, &true, &100_0000000_i128); + + // Full 2% fee stays with the market; nothing is shipped to the + // unregistered "referrer". + assert_eq!(t.client.get_accumulated_fees(), 2_0000000); + assert_eq!(t.client.get_market_fees(&id), 2_0000000); + assert_eq!(t.xlm.balance(&referrer), ref_xlm_before); +} + +#[test] +fn test_unregistered_referrer_cached_false_across_bets() { + let t = setup(); + let id = create_test_market(&t); + let user = Address::generate(&t.env); + let referrer = Address::generate(&t.env); + fund_user(&t, &user, 500_0000000); + + let no_ref: Option
= None; + t.referral_client.register_referral( + &referrer, + &String::from_str(&t.env, "Referrer"), + &no_ref, + ); + t.referral_client.register_referral( + &user, + &String::from_str(&t.env, "Fan"), + &Some(referrer.clone()), + ); + wipe_referrer_registration(&t, &referrer); + + let ref_xlm_before = t.xlm.balance(&referrer); + // Two bets: both must take the retained-fee path (the second via the + // cached `HasReferrer = false` lookup). + t.client.place_bet(&user, &id, &true, &100_0000000_i128); + t.client.place_bet(&user, &id, &true, &50_0000000_i128); + + assert_eq!(t.client.get_accumulated_fees(), 3_0000000); // 1.5 + 1.5 XLM + assert_eq!(t.xlm.balance(&referrer), ref_xlm_before); } // ── 31. Spam guard: TooManyBets ────────────────────────────────────────────── @@ -1197,14 +1288,16 @@ fn test_empty_side_resolution_pool_to_fees() { // Only YES bets — no one bets NO t.client.place_bet(&alice, &id, &true, &100_0000000_i128); let fees_before = t.client.get_accumulated_fees(); - assert_eq!(fees_before, 1_5000000); // 1.5% platform fee (referral fee goes to surplus) - assert_eq!(t.client.get_market_fees(&id), 1_5000000); + // Alice has no registered referrer: platform 1.5 XLM + retained referral + // 0.5 XLM stay here as revenue. + assert_eq!(fees_before, 2_0000000); + assert_eq!(t.client.get_market_fees(&id), 2_0000000); // Advance past end_time and resolve NO (empty winning side) advance_time(&t.env, 3601); t.client.resolve_market(&t.admin, &id, &false); // total_no == 0 - // Principal must NOT enter the fee pot. Only the 1.5 XLM platform fee remains. + // Principal must NOT enter the fee pot. Only the 2 XLM of genuine fees remain. let fees_after = t.client.get_accumulated_fees(); assert_eq!( fees_after, fees_before, @@ -1217,8 +1310,8 @@ fn test_empty_side_resolution_pool_to_fees() { t.client.add_fee_recipient(&t.admin, &treasury); let before = t.xlm.balance(&treasury); let withdrawn = withdraw_all_admin_fees(&t, &treasury); - assert_eq!(withdrawn, 1_5000000); - assert_eq!(t.xlm.balance(&treasury), before + 1_5000000); + assert_eq!(withdrawn, 2_0000000); + assert_eq!(t.xlm.balance(&treasury), before + 2_0000000); assert_eq!(t.client.get_accumulated_fees(), 0); assert_eq!(t.client.get_market_fees(&id), 0); @@ -1243,14 +1336,16 @@ fn test_cancel_fees_zeroed_correctly() { fund_user(&t, &alice, 200_0000000); fund_user(&t, &bob, 200_0000000); - // Two bets accumulate fees (only platform_fee tracked; referral fee goes to surplus) - t.client.place_bet(&alice, &id, &true, &100_0000000_i128); // 1.5 XLM platform fee - t.client.place_bet(&bob, &id, &false, &100_0000000_i128); // 1.5 XLM platform fee - assert_eq!(t.client.get_accumulated_fees(), 3_0000000); + // Two bets accumulate fees (platform 1.5 XLM each + retained 0.5 XLM + // referral slice each — neither bettor has a registered referrer) + t.client.place_bet(&alice, &id, &true, &100_0000000_i128); // 2 XLM total fees + t.client.place_bet(&bob, &id, &false, &100_0000000_i128); // 2 XLM total fees + assert_eq!(t.client.get_accumulated_fees(), 4_0000000); - // Cancel zeroes out those fees + // Cancel reclaims at most the platform-derived share (3 XLM); the + // retained referral slices stay withdrawable revenue. t.client.cancel_market(&t.admin, &id); - assert_eq!(t.client.get_accumulated_fees(), 0); + assert_eq!(t.client.get_accumulated_fees(), 1_0000000); // Bettors get their gross back t.client.cancel_refund(&alice, &id); @@ -1282,6 +1377,8 @@ fn test_e2e_full_inter_contract_flow() { &String::from_str(&t.env, "Alice"), &Some(referrer.clone()), ); + // Welcome bonus (5 pts) is queued at registration — claim it out. + t.leaderboard_client.claim_pending_rewards(&alice); assert_eq!(t.leaderboard_client.get_points(&alice), 5); assert_eq!(t.token_client.balance(&alice), 1_0000000); @@ -1299,18 +1396,19 @@ fn test_e2e_full_inter_contract_flow() { .place_bet(&alice, &market_id, &true, &100_0000000_i128); assert_eq!(t.client.get_accumulated_fees(), 1_5000000); assert_eq!(t.xlm.balance(&referrer), 5000000); - assert_eq!(t.leaderboard_client.get_points(&referrer), 8); + // Referrer's 3 pts per referred bet are queued — claim then assert. + t.leaderboard_client.claim_pending_rewards(&referrer); + assert_eq!(t.leaderboard_client.get_points(&referrer), 3); // Alice's welcome bonus counts as activity: won(0) + lost(0) + bonus(1). assert_eq!(t.leaderboard_client.get_stats(&alice).total_bets, 1); assert_eq!(t.client.get_market(&market_id).total_yes, 98_0000000); assert_eq!(t.client.get_bet_gross(&market_id, &alice), 100_0000000); - // Bob bets NO 200 XLM — no referrer + // Bob bets NO 200 XLM — no referrer: platform 3 XLM + retained referral + // slice 1 XLM stay here as revenue. t.client .place_bet(&bob, &market_id, &false, &200_0000000_i128); - // Issue #78: only platform_fee tracked per bet; referral fee goes to surplus. - // Alice: 1.5M, Bob: 3M platform fee → total 4.5M - assert_eq!(t.client.get_accumulated_fees(), 4_5000000); + assert_eq!(t.client.get_accumulated_fees(), 5_5000000); // Bob never registered, so no bonus: total_bets = won(0) + lost(0) + bonus(0). assert_eq!(t.leaderboard_client.get_stats(&bob).total_bets, 0); assert_eq!(t.client.get_market(&market_id).total_no, 196_0000000); @@ -1323,7 +1421,8 @@ fn test_e2e_full_inter_contract_flow() { assert_eq!(t.client.get_bet_gross(&market_id, &alice), 150_0000000); assert_eq!(t.client.get_market(&market_id).total_yes, 147_0000000); assert_eq!(t.client.get_market(&market_id).bet_count, 2); - assert_eq!(t.leaderboard_client.get_points(&referrer), 11); + t.leaderboard_client.claim_pending_rewards(&referrer); + assert_eq!(t.leaderboard_client.get_points(&referrer), 6); // Add a resolver and resolve via them let resolver = Address::generate(&t.env); @@ -1372,8 +1471,10 @@ fn test_e2e_full_inter_contract_flow() { t.client .place_bet(&charlie, &market2, &true, &100_0000000_i128); t.client.cancel_market(&t.admin, &market2); - // AccumulatedFees from market2 should be zeroed - assert_eq!(t.client.get_accumulated_fees(), 0); + // Charlie has no registered referrer: his 0.5 XLM referral slice stays + // withdrawable revenue; cancel only reclaims the platform-derived share + // (1.5 XLM) from market2's ledger. + assert_eq!(t.client.get_accumulated_fees(), 5000000); // Charlie pulls their own refund (gross = 100 XLM) let refunded = t.client.cancel_refund(&charlie, &market2); assert_eq!(refunded, 100_0000000); @@ -2112,10 +2213,18 @@ fn test_set_config_non_governor_rejected() { } fn last_event_name(env: &Env) -> Symbol { + use soroban_sdk::xdr::{ContractEventBody, ScVal}; let events = env.events().all(); - let last = events.get(events.len() - 1).unwrap(); - let topic0: Val = last.1.get_unchecked(0); - Symbol::try_from_val(env, &topic0).unwrap() + let last = events.events().last().expect("no events emitted"); + let topics = match &last.body { + ContractEventBody::V0(v0) => &v0.topics, + }; + match topics.as_slice().first().expect("event without topics") { + ScVal::Symbol(name) => { + Symbol::new(env, core::str::from_utf8(name.as_slice()).unwrap()) + } + other => panic!("first topic is not a symbol: {other:?}"), + } } #[test] @@ -2171,17 +2280,19 @@ fn test_cancel_does_not_wipe_other_market_fees() { fund_user(&t, &alice, 200_0000000); fund_user(&t, &bob, 200_0000000); - t.client.place_bet(&alice, &id1, &true, &100_0000000_i128); // 1.5 XLM - t.client.place_bet(&bob, &id2, &true, &100_0000000_i128); // 1.5 XLM - assert_eq!(t.client.get_accumulated_fees(), 3_0000000); - assert_eq!(t.client.get_market_fees(&id1), 1_5000000); - assert_eq!(t.client.get_market_fees(&id2), 1_5000000); + t.client.place_bet(&alice, &id1, &true, &100_0000000_i128); // 2 XLM (no referrer) + t.client.place_bet(&bob, &id2, &true, &100_0000000_i128); // 2 XLM (no referrer) + assert_eq!(t.client.get_accumulated_fees(), 4_0000000); + assert_eq!(t.client.get_market_fees(&id1), 2_0000000); + assert_eq!(t.client.get_market_fees(&id2), 2_0000000); t.client.cancel_market(&t.admin, &id1); - assert_eq!(t.client.get_market_fees(&id1), 0); - assert_eq!(t.client.get_market_fees(&id2), 1_5000000); - assert_eq!(t.client.get_accumulated_fees(), 1_5000000); + // Cancel reclaims only the platform-derived share (1.5 of 2 XLM); the + // retained referral slice stays on id1's ledger and in AccumulatedFees. + assert_eq!(t.client.get_market_fees(&id1), 5000000); + assert_eq!(t.client.get_market_fees(&id2), 2_0000000); + assert_eq!(t.client.get_accumulated_fees(), 2_5000000); } #[test] @@ -2191,9 +2302,9 @@ fn test_cancel_reclaims_pool_fees_not_inflated_ledger() { let alice = Address::generate(&t.env); fund_user(&t, &alice, 200_0000000); t.client.place_bet(&alice, &id, &true, &100_0000000_i128); - assert_eq!(t.client.get_market_fees(&id), 1_5000000); + assert_eq!(t.client.get_market_fees(&id), 2_0000000); - // Simulate an inflated per-market ledger (10 XLM recorded vs 1.5 earned). + // Simulate an inflated per-market ledger (10 XLM recorded vs 2 earned). t.env.as_contract(&t.client.address, || { t.env .storage() @@ -2225,7 +2336,7 @@ fn test_withdraw_fees_cannot_take_empty_side_principal() { let treasury = Address::generate(&t.env); t.client.add_fee_recipient(&t.admin, &treasury); let withdrawn = withdraw_all_admin_fees(&t, &treasury); - assert_eq!(withdrawn, 1_5000000); + assert_eq!(withdrawn, 2_0000000); // Alice's 98 XLM net is still sitting in the contract for her to claim. let alice_before = t.xlm.balance(&alice); @@ -2282,8 +2393,8 @@ fn test_migrate_fee_ledger_snapshots_legacy_balance() { fund_user(&t, &user, 200_0000000); t.client.place_bet(&user, &id, &true, &100_0000000_i128); assert_eq!(t.client.get_legacy_fees(), legacy_amount); - assert_eq!(t.client.get_market_fees(&id), 1_5000000); - assert_eq!(t.client.get_accumulated_fees(), legacy_amount + 1_5000000); + assert_eq!(t.client.get_market_fees(&id), 2_0000000); + assert_eq!(t.client.get_accumulated_fees(), legacy_amount + 2_0000000); } #[test] @@ -2297,7 +2408,7 @@ fn test_admin_withdraw_respects_cap() { t.client.place_bet(&user, &id, &true, &100_0000000_i128); t.client.place_bet(&user, &id, &true, &100_0000000_i128); let fees = t.client.get_accumulated_fees(); - assert_eq!(fees, 4_5000000); + assert_eq!(fees, 6_0000000); // 3 × (1.5 platform + 0.5 retained referral) let cap = fees * MAX_WITHDRAWAL_BPS / BPS_DENOM; let withdrawn = t.client.withdraw_fees(&t.admin, &t.admin); @@ -2313,7 +2424,7 @@ fn test_two_step_withdraw_debits_market_ledger() { let user = Address::generate(&t.env); fund_user(&t, &user, 200_0000000); t.client.place_bet(&user, &id, &true, &100_0000000_i128); - assert_eq!(t.client.get_market_fees(&id), 1_5000000); + assert_eq!(t.client.get_market_fees(&id), 2_0000000); let recipient = Address::generate(&t.env); t.client.add_fee_recipient(&t.admin, &recipient); diff --git a/referral_registry/src/lib.rs b/referral_registry/src/lib.rs index e69de29..94154b5 100644 --- a/referral_registry/src/lib.rs +++ b/referral_registry/src/lib.rs @@ -0,0 +1,626 @@ +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, token, vec, Address, BytesN, Env, IntoVal, + String, Symbol, Val, +}; + +const WELCOME_BONUS_POINTS: u64 = 5; +const WELCOME_BONUS_TOKENS: i128 = 1_0000000; +const REFERRAL_BET_POINTS: u64 = 3; +const TTL_BUMP: u32 = 3_153_600; +const TTL_HIGH: u32 = 6_307_200; + +// Issue #84: bump whenever a function signature, argument order, or return +// type that a caller relies on changes. +pub const INTERFACE_VERSION: u32 = 1; + +// The leaderboard interface_version this contract was built against. If a +// deployed leaderboard reports a different version, its add_bonus_pts ABI +// may no longer match what we send — refuse the call instead of invoking +// blind and either panicking deep in argument decoding or silently +// misbehaving (issue #84). +const EXPECTED_LEADERBOARD_INTERFACE_VERSION: u32 = 1; + +/// Maximum allowed depth of the referral chain. A new registration whose +/// referrer already sits at depth MAX_REFERRAL_DEPTH (i.e. has MAX_REFERRAL_DEPTH +/// ancestors) is rejected. This bounds both gas usage during the on-chain +/// traversal and sybil-amplification attack surface. +const MAX_REFERRAL_DEPTH: u32 = 5; + +#[contracterror] +#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ReferralError { + AlreadyInitialized = 1, + NotInitialized = 2, + UnauthorizedCaller = 3, + AlreadyRegistered = 4, + SelfReferral = 5, + NotAdmin = 6, + // Issue #95: operation blocked by the contract being paused. + Paused = 7, + // Issue #99: the address given as `referrer` has never registered. + ReferrerNotRegistered = 8, + /// leaderboard reported an interface_version this contract wasn't built + /// against (issue #84). Note: a matching version number alone does not + /// prove the callee's actual function shape still matches, it only + /// proves the callee's author intended it to. The guarantee only holds + /// if every breaking ABI change (renamed function, changed argument + /// order/count/type, changed return type) always increments + /// INTERFACE_VERSION in the same commit. See EXPECTED_LEADERBOARD_INTERFACE_VERSION. + IncompatibleInterface = 9, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DataKey { + Admin, + MarketContract, + // ── Legacy per-user keys (pre-Lever-A) — still READ for users who + // registered before the upgrade. New registrations no longer write these. + Referrer(Address), + DisplayName(Address), + Registered(Address), + // ── Lever A: one packed entry per NEW registrant (display_name + referrer). + // Existence of this key implies "registered". Cuts a first-time + // registration from 3 new entries to 1. + Profile(Address), + // ReferralCount/Earnings are the REFERRER's counters (a different user), + // updated in place — kept as separate keys (not part of the registrant pack). + ReferralCount(Address), + ReferralEarnings(Address), + TokenContract, + LeaderboardContract, + XlmSacContract, + Paused, +} + +// Lever A: packed registrant profile — one storage slot instead of three. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UserProfile { + pub display_name: String, + pub referrer: Option
, +} + +#[contract] +pub struct ReferralRegistryContract; + +#[contractimpl] +impl ReferralRegistryContract { + pub fn initialize( + env: Env, + admin: Address, + market_contract: Address, + token_contract: Address, + leaderboard_contract: Address, + xlm_sac: Address, + ) -> Result<(), ReferralError> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(ReferralError::AlreadyInitialized); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage() + .instance() + .set(&DataKey::MarketContract, &market_contract); + env.storage() + .instance() + .set(&DataKey::TokenContract, &token_contract); + env.storage() + .instance() + .set(&DataKey::LeaderboardContract, &leaderboard_contract); + env.storage() + .instance() + .set(&DataKey::XlmSacContract, &xlm_sac); + env.storage().instance().extend_ttl(TTL_BUMP, TTL_HIGH); + Ok(()) + } + + /// The cross-contract ABI version this deployment implements (issue #84). + pub fn interface_version(_env: Env) -> u32 { + INTERFACE_VERSION + } + + // ── Upgradeability & Config (admin only) ────────────────────────────────── + + /// Replace this contract's WASM bytecode in place. Admin only. + pub fn upgrade( + env: Env, + admin: Address, + new_wasm_hash: BytesN<32>, + ) -> Result<(), ReferralError> { + Self::require_admin(&env, &admin)?; + admin.require_auth(); + env.deployer().update_current_contract_wasm(new_wasm_hash); + Ok(()) + } + + /// Correct the native XLM SAC address set at initialize time. Admin only. + pub fn set_xlm_sac(env: Env, admin: Address, xlm_sac: Address) -> Result<(), ReferralError> { + Self::require_admin(&env, &admin)?; + admin.require_auth(); + env.storage() + .instance() + .set(&DataKey::XlmSacContract, &xlm_sac); + env.events().publish((Symbol::new(&env, "xlm_sac_set"), admin), xlm_sac); + Ok(()) + } + + /// Halt registration and crediting in an emergency. Admin only. View + /// functions keep working so the frontend can still read state. + pub fn pause(env: Env, admin: Address) -> Result<(), ReferralError> { + Self::require_admin(&env, &admin)?; + admin.require_auth(); + env.storage().instance().set(&DataKey::Paused, &true); + env.events().publish((Symbol::new(&env, "paused"), admin), true); + Ok(()) + } + + /// Resume registration and crediting. Admin only. + pub fn unpause(env: Env, admin: Address) -> Result<(), ReferralError> { + Self::require_admin(&env, &admin)?; + admin.require_auth(); + env.storage().instance().set(&DataKey::Paused, &false); + env.events().publish((Symbol::new(&env, "unpaused"), admin), true); + Ok(()) + } + + /// Issue #95 circuit breaker: halt registration and credit disbursements + /// during an emergency. Admin only; idempotent. Read/view paths stay open. + pub fn set_paused(env: Env, caller: Address, paused: bool) -> Result<(), ReferralError> { + Self::require_admin(&env, &caller)?; + caller.require_auth(); + env.storage().instance().set(&DataKey::Paused, &paused); + Ok(()) + } + + pub fn paused(env: Env) -> bool { + env.storage() + .instance() + .get::<_, bool>(&DataKey::Paused) + .unwrap_or(false) + } + + fn require_not_paused(env: &Env) -> Result<(), ReferralError> { + if env + .storage() + .instance() + .get::<_, bool>(&DataKey::Paused) + .unwrap_or(false) + { + return Err(ReferralError::Paused); + } + Ok(()) + } + + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + pub fn register_referral( + env: Env, + user: Address, + display_name: String, + referrer: Option
, + ) -> Result<(), ReferralError> { + Self::require_not_paused(&env)?; + user.require_auth(); + if Self::is_registered(env.clone(), user.clone()) { + return Err(ReferralError::AlreadyRegistered); + } + if let Some(ref ref_addr) = referrer { + if *ref_addr == user { + return Err(ReferralError::SelfReferral); + } + // Issue #99: a referral relationship may only point at a registered + // participant. Without this, anyone could name an arbitrary + // (attacker-controlled) address as their referrer and have it + // receive referral fees and accrue ReferralCount/ReferralEarnings + // without ever registering. + if !Self::is_registered(env.clone(), ref_addr.clone()) { + return Err(ReferralError::ReferrerNotRegistered); + } + } + + // Lever A: write ONE packed Profile entry (display_name + referrer) + // instead of the three legacy keys (Registered + DisplayName + Referrer). + // Existence of Profile(user) is what is_registered() now checks. + env.storage().persistent().set( + &DataKey::Profile(user.clone()), + &UserProfile { + display_name, + referrer: referrer.clone(), + }, + ); + env.storage().persistent().extend_ttl( + &DataKey::Profile(user.clone()), + TTL_BUMP, + TTL_HIGH, + ); + // The referrer's counter is a DIFFERENT user's entry — update in place. + if let Some(ref ref_addr) = referrer { + let count: u32 = env + .storage() + .persistent() + .get(&DataKey::ReferralCount(ref_addr.clone())) + .unwrap_or(0); + let count_key = DataKey::ReferralCount(ref_addr.clone()); + env.storage() + .persistent() + .set(&count_key, &(count + 1)); + env.storage() + .persistent() + .extend_ttl(&count_key, TTL_BUMP, TTL_HIGH); + } + + let this = env.current_contract_address(); + let leaderboard: Address = env + .storage() + .instance() + .get(&DataKey::LeaderboardContract) + .unwrap(); + // Queue the welcome reward as an optional side effect. Registration + // remains successful if the leaderboard is paused, unavailable, or + // exceeds the remaining invocation budget. + let _ = env.try_invoke_contract::( + &leaderboard, + &Symbol::new(&env, "queue_bonus_reward"), + vec![ + &env, + this.into_val(&env), + user.clone().into_val(&env), + WELCOME_BONUS_POINTS.into_val(&env), + 0_i128.into_val(&env), + ], + ); + let token_contract: Address = env + .storage() + .instance() + .get(&DataKey::TokenContract) + .unwrap(); + // PULSE is a custom token contract; mint via its exported `mint` ABI. + let _: Val = env.invoke_contract( + &token_contract, + &Symbol::new(&env, "mint"), + vec![ + &env, + this.clone().into_val(&env), + user.clone().into_val(&env), + WELCOME_BONUS_TOKENS.into_val(&env), + ], + ); + env.events().publish( + (Symbol::new(&env, "referral_registered"), user), + referrer, + ); + Ok(()) + } + + pub fn credit( + env: Env, + caller: Address, + user: Address, + referral_fee: i128, + ) -> Result { + Self::require_not_paused(&env)?; + caller.require_auth(); + Self::require_market_contract(&env, &caller)?; + // Lever A: resolve referrer via packed Profile (new) or legacy key (old). + let referrer: Option
= Self::load_profile(&env, &user).and_then(|p| p.referrer); + match referrer { + Some(ref_addr) => { + // Issue #99 defense-in-depth: even if a referral relationship + // exists in storage (e.g. written by pre-validation legacy code + // or malformed state), never pay out to — or accrue counters + // for — an address that is not a registered participant. + // The fee is refunded to the caller and we report "no referral", + // so the market keeps it in AccumulatedFees. + if !Self::is_registered(env.clone(), ref_addr.clone()) { + if referral_fee > 0 { + let xlm_sac: Address = env + .storage() + .instance() + .get(&DataKey::XlmSacContract) + .unwrap(); + token::Client::new(&env, &xlm_sac).transfer( + &env.current_contract_address(), + &caller, + &referral_fee, + ); + } + return Ok(false); + } + + let xlm_sac: Address = env + .storage() + .instance() + .get(&DataKey::XlmSacContract) + .unwrap(); + token::Client::new(&env, &xlm_sac).transfer( + &env.current_contract_address(), + &ref_addr, + &referral_fee, + ); + let leaderboard: Address = env + .storage() + .instance() + .get(&DataKey::LeaderboardContract) + .unwrap(); + // Queue points as an optional side effect. The XLM transfer + // and earnings update remain critical referral accounting. + let _ = env.try_invoke_contract::( + &leaderboard, + &Symbol::new(&env, "queue_bonus_reward"), + vec![ + &env, + env.current_contract_address().into_val(&env), + ref_addr.clone().into_val(&env), + REFERRAL_BET_POINTS.into_val(&env), + 0_i128.into_val(&env), + ], + ); + let earnings: i128 = env + .storage() + .persistent() + .get(&DataKey::ReferralEarnings(ref_addr.clone())) + .unwrap_or(0); + let earn_key = DataKey::ReferralEarnings(ref_addr.clone()); + env.storage() + .persistent() + .set(&earn_key, &(earnings + referral_fee)); + env.storage() + .persistent() + .extend_ttl(&earn_key, TTL_BUMP, TTL_HIGH); + env.events().publish( + (Symbol::new(&env, "referral_credited"), user, ref_addr), + referral_fee, + ); + Ok(true) + } + None => { + if referral_fee > 0 { + let xlm_sac: Address = env + .storage() + .instance() + .get(&DataKey::XlmSacContract) + .unwrap(); + token::Client::new(&env, &xlm_sac).transfer( + &env.current_contract_address(), + &caller, + &referral_fee, + ); + } + env.events().publish( + (Symbol::new(&env, "referral_missed"), user), + referral_fee, + ); + Ok(false) + } + } + } + + /// Issue #78: Admin-callable legacy migration. Reads old Registered/DisplayName/Referrer keys, + /// writes the packed Profile entry, and removes the legacy keys. Idempotent — no-op if + /// Profile already exists. + pub fn migrate_user(env: Env, admin: Address, user: Address) -> Result<(), ReferralError> { + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .unwrap(); + if admin != stored_admin { + return Err(ReferralError::NotAdmin); + } + admin.require_auth(); + // Already migrated — Profile exists, nothing to do + if env + .storage() + .persistent() + .has(&DataKey::Profile(user.clone())) + { + return Ok(()); + } + // No legacy keys — nothing to migrate + if !env + .storage() + .persistent() + .get::<_, bool>(&DataKey::Registered(user.clone())) + .unwrap_or(false) + { + return Ok(()); + } + let display_name: String = env + .storage() + .persistent() + .get(&DataKey::DisplayName(user.clone())) + .unwrap_or_else(|| String::from_str(&env, "user")); + let referrer: Option
= env + .storage() + .persistent() + .get(&DataKey::Referrer(user.clone())); + // Write the packed profile + env.storage().persistent().set( + &DataKey::Profile(user.clone()), + &UserProfile { + display_name, + referrer: referrer.clone(), + }, + ); + // If this user has a referrer, increment that referrer's count + if let Some(ref ref_addr) = referrer { + let count: u32 = env + .storage() + .persistent() + .get(&DataKey::ReferralCount(ref_addr.clone())) + .unwrap_or(0); + env.storage() + .persistent() + .set(&DataKey::ReferralCount(ref_addr.clone()), &(count + 1)); + } + // Remove legacy keys + env.storage().persistent().remove(&DataKey::Registered(user.clone())); + env.storage().persistent().remove(&DataKey::DisplayName(user.clone())); + env.storage().persistent().remove(&DataKey::Referrer(user)); + Ok(()) + } + + fn load_profile(env: &Env, user: &Address) -> Option { + if let Some(p) = env + .storage() + .persistent() + .get::(&DataKey::Profile(user.clone())) + { + return Some(p); + } + // Legacy fallback: reconstruct a profile from the old keys. + if env + .storage() + .persistent() + .get::(&DataKey::Registered(user.clone())) + .unwrap_or(false) + { + let display_name = env + .storage() + .persistent() + .get(&DataKey::DisplayName(user.clone())) + .unwrap_or_else(|| String::from_str(env, "")); + let referrer = env + .storage() + .persistent() + .get(&DataKey::Referrer(user.clone())); + return Some(UserProfile { + display_name, + referrer, + }); + } + None + } + + pub fn get_referrer(env: Env, user: Address) -> Option
{ + Self::load_profile(&env, &user).and_then(|p| p.referrer) + } + + pub fn get_display_name(env: Env, user: Address) -> String { + Self::load_profile(&env, &user) + .map(|p| p.display_name) + .unwrap_or_else(|| String::from_str(&env, "")) + } + + pub fn get_referral_count(env: Env, user: Address) -> u32 { + let key = DataKey::ReferralCount(user); + let count = env + .storage() + .persistent() + .get(&key) + .unwrap_or(0); + if env.storage().persistent().has(&key) { + env.storage() + .persistent() + .extend_ttl(&key, TTL_BUMP, TTL_HIGH); + } + count + } + + pub fn get_earnings(env: Env, user: Address) -> i128 { + let key = DataKey::ReferralEarnings(user); + let earnings = env + .storage() + .persistent() + .get(&key) + .unwrap_or(0); + if env.storage().persistent().has(&key) { + env.storage() + .persistent() + .extend_ttl(&key, TTL_BUMP, TTL_HIGH); + } + earnings + } + + /// Permissionless keeper: extend a referrer's count/earnings + profile + /// so inactive referrers do not lose history (issue #28 / #54). + pub fn refresh_referrer_ttl(env: Env, user: Address) { + let keys = [ + DataKey::Profile(user.clone()), + DataKey::ReferralCount(user.clone()), + DataKey::ReferralEarnings(user.clone()), + DataKey::Registered(user.clone()), + DataKey::Referrer(user.clone()), + DataKey::DisplayName(user), + ]; + for key in keys { + if env.storage().persistent().has(&key) { + env.storage() + .persistent() + .extend_ttl(&key, TTL_BUMP, TTL_HIGH); + } + } + env.storage().instance().extend_ttl(TTL_BUMP, TTL_HIGH); + } + + pub fn has_referrer(env: Env, user: Address) -> bool { + Self::get_referrer(env, user).is_some() + } + + pub fn is_registered(env: Env, user: Address) -> bool { + Self::load_profile(&env, &user).is_some() + } + + /// Cross-contract validation for referrer eligibility. The prediction + /// market calls this before crediting a referral fee so it can prove the + /// address it is about to pay (the bettor's stored referrer) is a real, + /// registered participant — an unregistered address must never receive + /// referral fees. + pub fn is_registered_referrer(env: Env, referrer: Address) -> bool { + Self::load_profile(&env, &referrer).is_some() + } + + fn require_market_contract(env: &Env, caller: &Address) -> Result<(), ReferralError> { + let market: Address = env + .storage() + .instance() + .get(&DataKey::MarketContract) + .ok_or(ReferralError::NotInitialized)?; + if *caller != market { + return Err(ReferralError::UnauthorizedCaller); + } + Ok(()) + } + + fn require_admin(env: &Env, caller: &Address) -> Result<(), ReferralError> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ReferralError::NotInitialized)?; + if *caller != admin { + return Err(ReferralError::NotAdmin); + } + Ok(()) + } + + // Issue #84: verify the configured leaderboard contract reports the ABI + // version we were built against before invoking it. Catches a unilateral + // leaderboard upgrade that changed add_pts/add_bonus_pts's signature and + // turns what would otherwise be an opaque invoke_contract failure (or, + // worse, a type-compatible-but-semantically-different call) into a clear + // IncompatibleInterface error. + fn require_compatible_leaderboard(env: &Env, leaderboard: &Address) -> Result<(), ReferralError> { + let version: u32 = env.invoke_contract( + leaderboard, + &Symbol::new(env, "interface_version"), + vec![env], + ); + if version != EXPECTED_LEADERBOARD_INTERFACE_VERSION { + return Err(ReferralError::IncompatibleInterface); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/referral_registry/src/tests.rs b/referral_registry/src/tests.rs index e69de29..76c9372 100644 --- a/referral_registry/src/tests.rs +++ b/referral_registry/src/tests.rs @@ -0,0 +1,949 @@ +use super::*; +use soroban_sdk::{ + contract, contractimpl, + testutils::{storage::Persistent as _, Address as _, Events}, + token::{Client as TokenClient, StellarAssetClient}, + Env, String, Symbol, +}; + +// Import sibling contracts for inter-contract testing +use leaderboard::LeaderboardContract; +use pulse_token::PULSETokenContract; + +// ── Test Helpers ────────────────────────────────────────────────────────────── + +struct TestSetup { + env: Env, + client: ReferralRegistryContractClient<'static>, + admin: Address, + market: Address, + token_id: Address, + leaderboard_id: Address, + xlm_sac_id: Address, + referral_id: Address, +} + +fn setup() -> TestSetup { + let env = Env::default(); + env.mock_all_auths(); + env.cost_estimate().budget().reset_unlimited(); + + let admin = Address::generate(&env); + let market = Address::generate(&env); + + // Deploy PULSEToken + let token_id = env.register(PULSETokenContract, ()); + let token_client = pulse_token::PULSETokenContractClient::new(&env, &token_id); + token_client.initialize( + &admin, + &String::from_str(&env, "PULSE"), + &String::from_str(&env, "PLSE"), + &7u32, + ); + + // Deploy Leaderboard + let leaderboard_id = env.register(LeaderboardContract, ()); + let leaderboard_client = leaderboard::LeaderboardContractClient::new(&env, &leaderboard_id); + + // Deploy ReferralRegistry + let referral_id = env.register(ReferralRegistryContract, ()); + let referral_client = ReferralRegistryContractClient::new(&env, &referral_id); + + // Initialize Leaderboard: market + referral as authorized callers + leaderboard_client.initialize(&admin, &market, &referral_id); + + // Lever G: leaderboard mints the welcome bonus internally now, so it needs + // the token address and minter authorization (mirrors mainnet upgrade). + leaderboard_client.set_token_contract(&admin, &token_id); + token_client.set_minter(&leaderboard_id); + // Legacy: referral no longer mints directly, kept harmless. + token_client.set_minter(&referral_id); + + // Register a SAC for native XLM + let xlm_sac_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + + // Initialize referral registry + referral_client.initialize(&admin, &market, &token_id, &leaderboard_id, &xlm_sac_id); + + TestSetup { + env, + client: referral_client, + admin, + market, + token_id, + leaderboard_id, + xlm_sac_id, + referral_id, + } +} + +// ── 1. Register with display name + custom referrer (who must be registered) ── + +#[test] +fn test_register_with_referrer() { + let t = setup(); + let user = Address::generate(&t.env); + let referrer = Address::generate(&t.env); + let no_ref: Option
= None; + t.client + .register_referral(&referrer, &String::from_str(&t.env, "Referrer"), &no_ref); + + // Issue #99: the referrer must be a registered participant first. + t.client + .register_referral(&referrer, &String::from_str(&t.env, "RefKing"), &None); + t.client.register_referral( + &user, + &String::from_str(&t.env, "CryptoKing"), + &Some(referrer.clone()), + ); + + assert!(t.client.is_registered(&user)); + assert_eq!(t.client.get_referrer(&user), Some(referrer.clone())); + assert!(t.client.has_referrer(&user)); + assert_eq!(t.client.get_referral_count(&referrer), 1); +} + +// ── 1c. Issue #99: unregistered referrer gets NO count/state created ───────── + +#[test] +fn test_unregistered_referrer_no_state_created() { + let t = setup(); + let user = Address::generate(&t.env); + let shady = Address::generate(&t.env); + + // Registration with the unregistered referrer is race-rejected; no Profile + // for the user, no ReferralCount for the shady address. + let res = t.client.try_register_referral( + &user, + &String::from_str(&t.env, "Victim"), + &Some(shady.clone()), + ); + assert!(res.is_err()); + assert!(!t.client.is_registered(&user)); + assert_eq!(t.client.get_referral_count(&shady), 0); + assert_eq!(t.client.get_earnings(&shady), 0); +} + +// ── 2. Register with display name + no referrer ────────────────────────────── + +#[test] +fn test_register_no_referrer() { + let t = setup(); + let user = Address::generate(&t.env); + + let no_ref: Option
= None; + t.client + .register_referral(&user, &String::from_str(&t.env, "JustBetting"), &no_ref); + + assert!(t.client.is_registered(&user)); + assert_eq!(t.client.get_referrer(&user), None); + assert!(!t.client.has_referrer(&user)); +} + +// ── 3. Welcome bonus: 5 pts + 1 PULSE on registration - note: the referrer +// also gets their own welcome bonus when they register first (issue #99) ── +// ── 3. Welcome bonus: 5 pts + 1 PULSE only when a referrer is provided ──────── + +#[test] +fn test_welcome_bonus() { + let t = setup(); + let referrer = Address::generate(&t.env); + let user = Address::generate(&t.env); + + // Register the referrer first (no bonus expected — no referrer provided) + let no_ref: Option
= None; + t.client + .register_referral(&referrer, &String::from_str(&t.env, "Referrer"), &no_ref); + + // Register user WITH the referrer — this should trigger the welcome bonus + t.client.register_referral( + &user, + &String::from_str(&t.env, "NewUser"), + &Some(referrer.clone()), + ); + + // Rewards are queued during registration and applied explicitly. + let lb_client = leaderboard::LeaderboardContractClient::new(&t.env, &t.leaderboard_id); + lb_client.claim_pending_rewards(&user); + // Leaderboard: 5 welcome points, no win/loss impact + assert_eq!(lb_client.get_points(&user), 5); + let stats = lb_client.get_stats(&user); + assert_eq!(stats.won_bets, 0); + assert_eq!(stats.lost_bets, 0); + + // Token: 1 PULSE (7 decimals) + let tok_client = pulse_token::PULSETokenContractClient::new(&t.env, &t.token_id); + assert_eq!(tok_client.balance(&user), 1_0000000); +} + +// ── 3b. No welcome bonus when registering WITHOUT a referrer ────────────────── + +#[test] +fn test_no_bonus_without_referrer() { + let t = setup(); + let user = Address::generate(&t.env); + + let no_ref: Option
= None; + t.client + .register_referral(&user, &String::from_str(&t.env, "Solo"), &no_ref); + + // No bonus points, no PULSE minted + let lb_client = leaderboard::LeaderboardContractClient::new(&t.env, &t.leaderboard_id); + assert_eq!(lb_client.get_points(&user), 0); + + let tok_client = pulse_token::PULSETokenContractClient::new(&t.env, &t.token_id); + assert_eq!(tok_client.balance(&user), 0); +} + +// ── 4. Reject self-referral ────────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "Error(Contract, #5)")] +fn test_reject_self_referral() { + let t = setup(); + let user = Address::generate(&t.env); + + t.client.register_referral( + &user, + &String::from_str(&t.env, "SelfRef"), + &Some(user.clone()), + ); +} + +// ── 5. Reject double registration ──────────────────────────────────────────── + +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_reject_double_registration() { + let t = setup(); + let user = Address::generate(&t.env); + + let no_ref: Option
= None; + t.client + .register_referral(&user, &String::from_str(&t.env, "First"), &no_ref); + + // Second registration should fail + t.client + .register_referral(&user, &String::from_str(&t.env, "Second"), &no_ref); +} + +// ── 6. Display name stored and retrievable ─────────────────────────────────── + +#[test] +fn test_display_name() { + let t = setup(); + let user = Address::generate(&t.env); + + let no_ref: Option
= None; + t.client + .register_referral(&user, &String::from_str(&t.env, "CryptoKing"), &no_ref); + + assert_eq!( + t.client.get_display_name(&user), + String::from_str(&t.env, "CryptoKing"), + ); + + // Unregistered user gets empty string + let nobody = Address::generate(&t.env); + assert_eq!( + t.client.get_display_name(&nobody), + String::from_str(&t.env, ""), + ); +} + +// ── 7. Credit routes fee to referrer + 3 bonus points ──────────────────────── + +#[test] +fn test_credit_with_referrer() { + let t = setup(); + let user = Address::generate(&t.env); + let referrer = Address::generate(&t.env); + + // Issue #99: the referrer must be registered before the relationship + // may be created. + t.client + .register_referral(&referrer, &String::from_str(&t.env, "RefKing"), &None); + + // Register user with referrer + t.client.register_referral( + &user, + &String::from_str(&t.env, "BetFan"), + &Some(referrer.clone()), + ); + + // Fund the referral contract with XLM so it can pay out + let sac_admin = StellarAssetClient::new(&t.env, &t.xlm_sac_id); + sac_admin.mint(&t.referral_id, &100_0000000_i128); // 100 XLM + + let referral_fee: i128 = 5_000_000; // 0.5 XLM + + // Call credit from market contract + let result = t.client.credit(&t.market, &user, &referral_fee); + assert!(result); + + // Referrer received the XLM + let xlm_client = TokenClient::new(&t.env, &t.xlm_sac_id); + assert_eq!(xlm_client.balance(&referrer), referral_fee); + + // Referrer got 3 leaderboard bonus points for the referred bet. + // Bonus points are queued and claimed explicitly. + let lb_client = leaderboard::LeaderboardContractClient::new(&t.env, &t.leaderboard_id); + lb_client.claim_pending_rewards(&referrer); + assert_eq!(lb_client.get_points(&referrer), 3); + + // Earnings tracked + assert_eq!(t.client.get_earnings(&referrer), referral_fee); +} + +// ── 8. Credit returns false when no custom referrer ────────────────────────── + +#[test] +fn test_credit_no_referrer() { + let t = setup(); + let user = Address::generate(&t.env); + + // Register without referrer + let no_ref: Option
= None; + t.client + .register_referral(&user, &String::from_str(&t.env, "Solo"), &no_ref); + + // In real flow, prediction_market transfers referral_fee to this contract + // before calling credit. Mirror that here. + let sac_admin = StellarAssetClient::new(&t.env, &t.xlm_sac_id); + sac_admin.mint(&t.referral_id, &10_0000000_i128); + + let xlm_client = TokenClient::new(&t.env, &t.xlm_sac_id); + let market_bal_before = xlm_client.balance(&t.market); + + let result = t.client.credit(&t.market, &user, &5_000_000); + assert!(!result); + + // Fee returned to caller (market contract) + assert_eq!(xlm_client.balance(&t.market), market_bal_before + 5_000_000); +} + +// ── 8b. Credit returns false for completely unregistered user ───────────────── + +#[test] +fn test_credit_unregistered_user() { + let t = setup(); + let user = Address::generate(&t.env); + + // In real flow, prediction_market transfers referral_fee to this contract first + let sac_admin = StellarAssetClient::new(&t.env, &t.xlm_sac_id); + sac_admin.mint(&t.referral_id, &10_0000000_i128); + + let xlm_client = TokenClient::new(&t.env, &t.xlm_sac_id); + let market_bal_before = xlm_client.balance(&t.market); + + let result = t.client.credit(&t.market, &user, &5_000_000); + assert!(!result); + + // Fee returned to caller (market contract) + assert_eq!(xlm_client.balance(&t.market), market_bal_before + 5_000_000); +} + +// ── 9. Earnings accumulation across multiple credits ───────────────────────── + +#[test] +fn test_earnings_accumulation() { + let t = setup(); + let user = Address::generate(&t.env); + let referrer = Address::generate(&t.env); + + // Issue #99: the referrer must be registered before the relationship + // may be created. + t.client + .register_referral(&referrer, &String::from_str(&t.env, "RefKing"), &None); + let no_ref: Option
= None; + t.client + .register_referral(&referrer, &String::from_str(&t.env, "Referrer"), &no_ref); + + t.client.register_referral( + &user, + &String::from_str(&t.env, "Bettor"), + &Some(referrer.clone()), + ); + + // Fund the referral contract with XLM + let sac_admin = StellarAssetClient::new(&t.env, &t.xlm_sac_id); + sac_admin.mint(&t.referral_id, &1000_0000000_i128); // 1000 XLM + + // Multiple credits + t.client.credit(&t.market, &user, &5_000_000_i128); // 0.5 XLM + t.client.credit(&t.market, &user, &3_000_000_i128); // 0.3 XLM + t.client.credit(&t.market, &user, &2_000_000_i128); // 0.2 XLM + + assert_eq!(t.client.get_earnings(&referrer), 10_000_000_i128); // 1.0 XLM total +} + +// ── 10. Referrer bonus points accumulate (3 per referred bet) ──────────────── + +#[test] +fn test_referrer_bonus_points_accumulate() { + let t = setup(); + let user = Address::generate(&t.env); + let referrer = Address::generate(&t.env); + + // Issue #99: the referrer must be registered before the relationship + // may be created. + t.client + .register_referral(&referrer, &String::from_str(&t.env, "RefKing"), &None); + + t.client.register_referral( + &user, + &String::from_str(&t.env, "Bettor"), + &Some(referrer.clone()), + ); + + // Fund + let sac_admin = StellarAssetClient::new(&t.env, &t.xlm_sac_id); + sac_admin.mint(&t.referral_id, &1000_0000000_i128); + + // 3 credits → 3 × 3 = 9 bonus pts for referrer, queued until claimed + t.client.credit(&t.market, &user, &5_000_000_i128); + t.client.credit(&t.market, &user, &5_000_000_i128); + t.client.credit(&t.market, &user, &5_000_000_i128); + + let lb_client = leaderboard::LeaderboardContractClient::new(&t.env, &t.leaderboard_id); + assert_eq!(lb_client.get_points(&referrer), 0); // still queued + lb_client.claim_pending_rewards(&referrer); + assert_eq!(lb_client.get_points(&referrer), 9); // 3 × 3 pts +} + +// ── 11. Referral count tracking ────────────────────────────────────────────── + +#[test] +fn test_referral_count_tracking() { + let t = setup(); + let referrer = Address::generate(&t.env); + let no_ref: Option
= None; + t.client + .register_referral(&referrer, &String::from_str(&t.env, "Referrer"), &no_ref); + + // 3 users register with the same referrer + for _ in 0..3 { + let user = Address::generate(&t.env); + t.client.register_referral( + &user, + &String::from_str(&t.env, "Buddy"), + &Some(referrer.clone()), + ); + } + + assert_eq!(t.client.get_referral_count(&referrer), 3); +} + +// ── 12. Unregistered referrers are rejected ───────────────────────────────── + +#[test] +#[should_panic(expected = "Error(Contract, #8)")] +fn test_reject_unregistered_referrer() { + let t = setup(); + let user = Address::generate(&t.env); + let unregistered_referrer = Address::generate(&t.env); + + t.client.register_referral( + &user, + &String::from_str(&t.env, "Bettor"), + &Some(unregistered_referrer), + ); +} + +// ── 13. Double initialization rejected ─────────────────────────────────────── + +#[test] +#[should_panic(expected = "Error(Contract, #1)")] +fn test_double_init_rejected() { + let t = setup(); + + // Second init should fail + let market2 = Address::generate(&t.env); + t.client.initialize( + &t.admin, + &market2, + &t.token_id, + &t.leaderboard_id, + &t.xlm_sac_id, + ); +} + +// ── Lever A: lazy migration — a user stored under the OLD key layout must still +// be fully readable after the upgrade (Registered + DisplayName + Referrer). ── +#[test] +fn test_legacy_user_still_readable() { + let s = setup(); + let legacy_user = Address::generate(&s.env); + let legacy_ref = Address::generate(&s.env); + + // Simulate a pre-upgrade registration by writing the OLD keys directly. + s.env.as_contract(&s.referral_id, || { + s.env + .storage() + .persistent() + .set(&DataKey::Registered(legacy_user.clone()), &true); + s.env.storage().persistent().set( + &DataKey::DisplayName(legacy_user.clone()), + &String::from_str(&s.env, "OldTimer"), + ); + s.env + .storage() + .persistent() + .set(&DataKey::Referrer(legacy_user.clone()), &legacy_ref); + }); + + // All read paths must resolve via the legacy fallback. + assert!(s.client.is_registered(&legacy_user)); + assert_eq!( + s.client.get_display_name(&legacy_user), + String::from_str(&s.env, "OldTimer") + ); + assert_eq!( + s.client.get_referrer(&legacy_user), + Some(legacy_ref.clone()) + ); + assert!(s.client.has_referrer(&legacy_user)); + + // And a legacy user must NOT be able to double-register under the new scheme. + let res = + s.client + .try_register_referral(&legacy_user, &String::from_str(&s.env, "OldTimer"), &None); + assert!(res.is_err()); +} + +// A legacy user with NO referrer (only Registered + DisplayName) reads correctly. +#[test] +fn test_legacy_user_without_referrer() { + let s = setup(); + let legacy_user = Address::generate(&s.env); + s.env.as_contract(&s.referral_id, || { + s.env + .storage() + .persistent() + .set(&DataKey::Registered(legacy_user.clone()), &true); + s.env.storage().persistent().set( + &DataKey::DisplayName(legacy_user.clone()), + &String::from_str(&s.env, "Solo"), + ); + }); + assert!(s.client.is_registered(&legacy_user)); + assert_eq!(s.client.get_referrer(&legacy_user), None); + assert!(!s.client.has_referrer(&legacy_user)); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 95 — pause / circuit breaker +// ═══════════════════════════════════════════════════════════════════════════ + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] +fn test_registry_pause_requires_admin() { + let t = setup(); + let rando = Address::generate(&t.env); + t.client.set_paused(&rando, &true); +} + +#[test] +fn test_registry_pause_blocks_registration_and_credit_then_resume() { + let t = setup(); + let user = Address::generate(&t.env); + + t.client.set_paused(&t.admin, &true); + assert!(t.client.paused()); + let no_ref: Option
= None; + assert!(t + .client + .try_register_referral(&user, &String::from_str(&t.env, "User"), &no_ref) + .is_err()); + assert!(t.client.try_credit(&t.market, &user, &5_000_000).is_err()); + + // Views stay fully available while paused. + assert!(!t.client.is_registered(&user)); + assert_eq!(t.client.get_referral_count(&user), 0); + + t.client.set_paused(&t.admin, &false); + assert!(!t.client.paused()); + t.client.register_referral(&user, &String::from_str(&t.env, "User"), &no_ref); + assert!(t.client.is_registered(&user)); +} +// ── Cross-contract interface versioning (issue #84) ─────────────────────────── + +// A stand-in for a leaderboard deployment that was upgraded to an +// incompatible ABI: it only implements interface_version(), reporting a +// version this referral_registry build does not expect. Used to prove that +// register_referral/credit refuse to call into it rather than failing deep +// inside argument decoding (or, worse, silently misinterpreting arguments). +#[contract] +struct MockIncompatibleLeaderboard; + +#[contractimpl] +impl MockIncompatibleLeaderboard { + pub fn interface_version(_env: Env) -> u32 { + 99 + } +} + +fn setup_with_incompatible_leaderboard() -> TestSetup { + let env = Env::default(); + env.mock_all_auths(); + env.cost_estimate().budget().reset_unlimited(); + + let admin = Address::generate(&env); + let market = Address::generate(&env); + + let token_id = env.register(PULSETokenContract, ()); + pulse_token::PULSETokenContractClient::new(&env, &token_id).initialize( + &admin, + &String::from_str(&env, "PULSE"), + &String::from_str(&env, "PLSE"), + &7u32, + ); + + let leaderboard_id = env.register(MockIncompatibleLeaderboard, ()); + + let referral_id = env.register(ReferralRegistryContract, ()); + let referral_client = ReferralRegistryContractClient::new(&env, &referral_id); + + let xlm_sac_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + + referral_client.initialize(&admin, &market, &token_id, &leaderboard_id, &xlm_sac_id); + + TestSetup { + env, + client: referral_client, + admin, + market, + token_id, + leaderboard_id, + xlm_sac_id, + referral_id, + } +} + +#[test] +fn test_interface_version_reported() { + let t = setup(); + assert_eq!(t.client.interface_version(), 1); +} + +#[test] +fn test_register_referral_survives_incompatible_leaderboard() { + let t = setup_with_incompatible_leaderboard(); + + let user = Address::generate(&t.env); + t.client + .register_referral(&user, &String::from_str(&t.env, "Someone"), &None); + // The optional reward queue may fail, but registration remains committed. + assert!(t.client.is_registered(&user)); +} + +#[test] +fn test_credit_survives_incompatible_leaderboard() { + let t = setup_with_incompatible_leaderboard(); + + let user = Address::generate(&t.env); + let referrer = Address::generate(&t.env); + // Write the profile directly — register_referral itself would already + // fail against the incompatible leaderboard, and this test is only + // concerned with credit()'s own version check. + t.env.as_contract(&t.referral_id, || { + t.env.storage().persistent().set( + &DataKey::Profile(user.clone()), + &UserProfile { + display_name: String::from_str(&t.env, "Bettor"), + referrer: Some(referrer.clone()), + }, + ); + }); + + // Fund the referral contract so the fee transfer preceding the + // leaderboard call succeeds and the version check is what's exercised. + let sac_admin = StellarAssetClient::new(&t.env, &t.xlm_sac_id); + sac_admin.mint(&t.referral_id, &100_0000000_i128); + + let result = t.client.try_credit(&t.market, &user, &1_0000000_i128); + assert_eq!(result.unwrap().unwrap(), true); + assert_eq!(t.client.get_earnings(&referrer), 1_0000000_i128); +} + +// A stand-in for a leaderboard deployment that reports the EXPECTED version +// (so require_compatible_leaderboard's check passes) but is missing the +// actual function the caller is about to invoke. This is the limitation the +// version check does not cover: a matching u32 alone does not prove the +// callee's real function shape still matches, only that its author intended +// it to. If someone bumps INTERFACE_VERSION without actually shipping the +// signature that number is supposed to promise, callers still break, just +// past the version check instead of at it. +#[contract] +struct MockLeaderboardMissingRewardBonus; + +#[contractimpl] +impl MockLeaderboardMissingRewardBonus { + pub fn interface_version(_env: Env) -> u32 { + 1 + } + // No reward_bonus() here on purpose. +} + +fn setup_with_version_matched_but_incompatible_leaderboard() -> TestSetup { + let env = Env::default(); + env.mock_all_auths(); + env.cost_estimate().budget().reset_unlimited(); + + let admin = Address::generate(&env); + let market = Address::generate(&env); + + let token_id = env.register(PULSETokenContract, ()); + pulse_token::PULSETokenContractClient::new(&env, &token_id).initialize( + &admin, + &String::from_str(&env, "PULSE"), + &String::from_str(&env, "PLSE"), + &7u32, + ); + + let leaderboard_id = env.register(MockLeaderboardMissingRewardBonus, ()); + + let referral_id = env.register(ReferralRegistryContract, ()); + let referral_client = ReferralRegistryContractClient::new(&env, &referral_id); + + let xlm_sac_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + + referral_client.initialize(&admin, &market, &token_id, &leaderboard_id, &xlm_sac_id); + + TestSetup { + env, + client: referral_client, + admin, + market, + token_id, + leaderboard_id, + xlm_sac_id, + referral_id, + } +} + +// A matching-version deployment that lacks the optional queue method must +// not prevent registration from succeeding. +#[test] +fn test_matching_version_does_not_block_registration_when_queue_is_missing() { + let t = setup_with_version_matched_but_incompatible_leaderboard(); + let user = Address::generate(&t.env); + t.client + .register_referral(&user, &String::from_str(&t.env, "Someone"), &None); + assert!(t.client.is_registered(&user)); +} + +// ── Emergency Pause (issue #83) ─────────────────────────────────────────────── + +#[test] +fn test_pause_unpause_admin_only() { + let t = setup(); + assert!(!t.client.is_paused()); + t.client.pause(&t.admin); + assert!(t.client.is_paused()); + t.client.unpause(&t.admin); + assert!(!t.client.is_paused()); +} + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] +fn test_pause_rejects_non_admin() { + let t = setup(); + let not_admin = Address::generate(&t.env); + t.client.pause(¬_admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #7)")] +fn test_paused_rejects_register_referral() { + let t = setup(); + t.client.pause(&t.admin); + let user = Address::generate(&t.env); + t.client + .register_referral(&user, &String::from_str(&t.env, "Someone"), &None); +} + +#[test] +#[should_panic(expected = "Error(Contract, #7)")] +fn test_paused_rejects_credit() { + let t = setup(); + let user = Address::generate(&t.env); + let referrer = Address::generate(&t.env); + // Write the profile directly rather than via register_referral, so this + // test only exercises the pause gate on credit() (register_referral's + // leaderboard cross-call has an unrelated, pre-existing ABI mismatch). + t.env.as_contract(&t.referral_id, || { + t.env.storage().persistent().set( + &DataKey::Profile(user.clone()), + &UserProfile { + display_name: String::from_str(&t.env, "Bettor"), + referrer: Some(referrer.clone()), + }, + ); + }); + + t.client.pause(&t.admin); + t.client.credit(&t.market, &user, &1_0000000_i128); +} + +#[test] +fn test_view_functions_work_while_paused() { + let t = setup(); + let user = Address::generate(&t.env); + t.env.as_contract(&t.referral_id, || { + t.env.storage().persistent().set( + &DataKey::Profile(user.clone()), + &UserProfile { + display_name: String::from_str(&t.env, "Someone"), + referrer: None, + }, + ); + }); + + t.client.pause(&t.admin); + assert!(t.client.is_registered(&user)); +} + +// ── Issue #78: legacy key migration ─────────────────────────────────────────── + +#[test] +fn test_migrate_user_writes_profile_removes_legacy() { + let t = setup(); + let legacy_user = Address::generate(&t.env); + let legacy_ref = Address::generate(&t.env); + + // Simulate a pre-upgrade registration by writing OLD keys directly + t.env.as_contract(&t.referral_id, || { + t.env + .storage() + .persistent() + .set(&DataKey::Registered(legacy_user.clone()), &true); + t.env.storage().persistent().set( + &DataKey::DisplayName(legacy_user.clone()), + &String::from_str(&t.env, "OldTimer"), + ); + t.env + .storage() + .persistent() + .set(&DataKey::Referrer(legacy_user.clone()), &legacy_ref); + }); + + // Migrate + t.client.migrate_user(&t.admin, &legacy_user); + + // Profile now exists + assert!(t.client.is_registered(&legacy_user)); + assert_eq!( + t.client.get_display_name(&legacy_user), + String::from_str(&t.env, "OldTimer") + ); + assert_eq!(t.client.get_referrer(&legacy_user), Some(legacy_ref.clone())); + + // Legacy keys removed + let legacy_removed = t.env.as_contract(&t.referral_id, || { + !t.env + .storage() + .persistent() + .has(&DataKey::Registered(legacy_user.clone())) + && !t.env + .storage() + .persistent() + .has(&DataKey::DisplayName(legacy_user.clone())) + && !t.env + .storage() + .persistent() + .has(&DataKey::Referrer(legacy_user)) + }); + assert!(legacy_removed); +} + +#[test] +fn test_migrate_user_noop_if_already_migrated() { + let t = setup(); + let user = Address::generate(&t.env); + + // Already has a Profile key — migration should be a no-op + t.env.as_contract(&t.referral_id, || { + t.env.storage().persistent().set( + &DataKey::Profile(user.clone()), + &UserProfile { + display_name: String::from_str(&t.env, "NewUser"), + referrer: None, + }, + ); + }); + + // Should succeed silently (no-op) + t.client.migrate_user(&t.admin, &user); + assert_eq!( + t.client.get_display_name(&user), + String::from_str(&t.env, "NewUser") + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] +fn test_migrate_user_rejects_non_admin() { + let t = setup(); + let user = Address::generate(&t.env); + let not_admin = Address::generate(&t.env); + t.client.migrate_user(¬_admin, &user); +} + +#[test] +fn test_register_and_refresh_extend_referrer_ttl() { + let t = setup(); + let referrer = Address::generate(&t.env); + t.client.register_referral( + &referrer, + &String::from_str(&t.env, "Ref"), + &None, + ); + let user = Address::generate(&t.env); + t.client.register_referral( + &user, + &String::from_str(&t.env, "Bettor"), + &Some(referrer.clone()), + ); + + let count_ttl = t.env.as_contract(&t.referral_id, || { + t.env.storage() + .persistent() + .get_ttl(&DataKey::ReferralCount(referrer.clone())) + }); + assert!(count_ttl >= TTL_BUMP); + t.client.refresh_referrer_ttl(&referrer); + assert_eq!(t.client.get_referral_count(&referrer), 1); +} + +#[test] +fn test_register_referral_emits_event() { + let t = setup(); + let user = Address::generate(&t.env); + let no_ref: Option
= None; + t.client + .register_referral(&user, &String::from_str(&t.env, "Alice"), &no_ref); + // `env.events().all()` returns a `ContractEvents` in soroban-sdk 26, which + // exposes its entries as an XDR slice rather than an indexable Vec of + // (address, topics, data) tuples. + let events = t.env.events().all(); + let emitted = events.events(); + assert!(!emitted.is_empty(), "register_referral emitted no event"); + let soroban_sdk::xdr::ContractEventBody::V0(body) = &emitted.last().unwrap().body; + use soroban_sdk::xdr::ScVal; + match body.topics.as_slice().first() { + Some(ScVal::Symbol(name)) => { + assert_eq!( + Symbol::new(&t.env, core::str::from_utf8(name.as_slice()).unwrap()), + Symbol::new(&t.env, "referral_registered") + ); + } + other => panic!("first topic is not a symbol: {other:?}"), + } +}