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 036f7db..7f4691d 100644 --- a/prediction_market/src/lib.rs +++ b/prediction_market/src/lib.rs @@ -5,6 +5,9 @@ use soroban_sdk::{ Env, Executable, 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. @@ -467,13 +470,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(()) } @@ -595,6 +599,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 @@ -836,8 +842,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) ───────────── @@ -906,24 +913,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 ────────────────────────────────────── @@ -1620,6 +1658,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 8cc0965..d3b290e 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 ────────────────────────────────────────────── @@ -1243,14 +1334,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 +1375,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 +1394,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 +1419,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 +1469,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 +2211,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 +2278,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 +2300,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() @@ -2231,7 +2340,7 @@ fn test_withdraw_fees_cannot_take_empty_side_principal() { advance_time(&t.env, DISPUTE_WINDOW_SECS); t.client.finalize_zero_side(&id); let withdrawn = withdraw_all_admin_fees(&t, &treasury); - assert_eq!(withdrawn, 1_5000000); + assert_eq!(withdrawn, 2_0000000); let alice_before = t.xlm.balance(&alice); t.client.claim(&alice, &id); @@ -2287,8 +2396,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] @@ -2302,7 +2411,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); @@ -2318,7 +2427,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:?}"), + } +}