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:?}"),
+ }
+}