diff --git a/prediction_market/src/lib.rs b/prediction_market/src/lib.rs index 036f7db..580692c 100644 --- a/prediction_market/src/lib.rs +++ b/prediction_market/src/lib.rs @@ -28,8 +28,12 @@ const MIN_BET: i128 = 10_000_000; // minimum net stake: 1 XLM in stroops const MAX_BETS_PER_USER: u32 = 20; const MAX_MARKETS_PER_HOUR: u32 = 10; +// A page also reads the market and count entries, so leave room under +// Soroban's 100-entry footprint cap for those fixed reads. const MIN_MARKET_DURATION_SECS: u64 = 60; // issue #10: no instantly-expired markets -const MAX_BETTORS_PER_PAGE: u32 = 100; +// A page also reads the market and count entries, so leave room under +// Soroban's 100-entry footprint cap for those fixed reads. +const MAX_BETTORS_PER_PAGE: u32 = 97; // Fee constants — multiply before divide to avoid precision loss const TOTAL_FEE_BPS: i128 = 200; @@ -204,11 +208,15 @@ pub struct PendingConfigChange { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct BetEntry { - pub net: i128, // post-fee amount bet (used for payout) - pub gross: i128, // pre-fee amount sent (used for cancel_refund) - pub is_yes: bool, - pub claimed: bool, - pub count: u32, // how many times this user has bet on this market + pub net: i128, // post-fee amount bet on current side (used for payout) + pub gross: i128, // pre-fee amount sent on current side (used for cancel_refund) + pub is_yes: bool, // current side: true = YES, false = NO + pub claimed: bool, // claim status for current side + pub count: u32, // how many times this user has bet on this market + // ── Opposite-side position for capital-preserving side-switching ─────── + pub opposite_net: i128, // net on opposite side (0 = no opposite position) + pub opposite_gross: i128, // gross on opposite side (0 = no opposite position) + pub opposite_claimed: bool, // claim status for opposite side } // ── WithdrawalRequest: capped, recipient-validated, timelocked (issue #12) ── @@ -471,10 +479,7 @@ impl PredictionMarketContract { (Symbol::new(&env, "cfg_act"), caller), pending.cfg, ); - env.events().publish( - (Symbol::new(&env, "config_changed"), admin), - (token_contract, referral_contract, leaderboard_contract, xlm_sac), - ); + Ok(()) } @@ -595,6 +600,7 @@ 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 @@ -817,13 +823,12 @@ impl PredictionMarketContract { env.storage().persistent().remove(&lock_key); return Err(MarketError::TooManyBets); } - if e.is_yes != is_yes { - env.storage().persistent().remove(&lock_key); - return Err(MarketError::OppositeSideBet); - } + // Opposite-side check removed - allow side switching + // But validate that switching is capital-preserving (handled in write logic) } let is_increase = existing.is_some(); + let prior_current = existing.as_ref().map(|entry| (entry.is_yes, entry.net)); // ── Fee calculation — use precomputed multipliers ───────────────── let total_fee = amount * TOTAL_FEE_BPS / BPS_DENOM; @@ -840,13 +845,33 @@ impl PredictionMarketContract { // as surplus (issue #78), so the market never holds it for withdrawal. Self::credit_market_fees(&env, market_id, platform_fee); - // ── Write BetEntry (net + gross + count in one write) ───────────── + // `net`/`gross`/`claimed` always describe the side in `e.is_yes`. + // The opposite fields are retained for the other side (and for safe + // cancellation of entries written by earlier versions). let new_entry = match existing { Some(mut e) => { - e.net += net; - e.gross += amount; - e.count += 1; - e + if e.is_yes != is_yes { + // Move the complete old position into the new current-side + // bucket, then add this wager. Nothing remains on the side + // being left. + let moved_net = e.net; + let moved_gross = e.gross; + e.is_yes = is_yes; + e.net = moved_net + net; + e.gross = moved_gross + amount; + e.claimed = false; + e.opposite_net = 0; + e.opposite_gross = 0; + e.opposite_claimed = false; + e.count += 1; + e + } else { + // Same-side bet: increase existing position + e.net += net; + e.gross += amount; + e.count += 1; + e + } } None => BetEntry { net, @@ -854,6 +879,9 @@ impl PredictionMarketContract { is_yes, claimed: false, count: 1, + opposite_net: 0, + opposite_gross: 0, + opposite_claimed: false, }, }; env.storage().persistent().set(&bet_key, &new_entry); @@ -879,7 +907,23 @@ impl PredictionMarketContract { } // ── Market totals ───────────────────────────────────────────────── - if is_yes { + if let Some((old_is_yes, old_net)) = prior_current { + if old_is_yes != is_yes { + // The existing current position changes pools along with the + // entry, then the new wager joins that destination pool. + if old_is_yes { + market.total_yes -= old_net; + market.total_no += old_net + net; + } else { + market.total_no -= old_net; + market.total_yes += old_net + net; + } + } else if is_yes { + market.total_yes += net; + } else { + market.total_no += net; + } + } else if is_yes { market.total_yes += net; } else { market.total_no += net; @@ -1231,13 +1275,21 @@ impl PredictionMarketContract { .get(&bet_key) .ok_or(MarketError::NoBetFound)?; - if entry.gross == 0 { + let refund = if !entry.claimed { entry.gross } else { 0 } + + if !entry.opposite_claimed { + entry.opposite_gross + } else { + 0 + }; + if refund == 0 { return Err(MarketError::NoBetFound); } - let gross = entry.gross; - entry.gross = 0; - entry.net = 0; + // Mark both buckets before the token transfer. This supports entries + // with an opposite balance while remaining idempotent for normal, + // single-current-side entries. + entry.claimed = true; + entry.opposite_claimed = true; env.storage().persistent().set(&bet_key, &entry); // Read-time TTL refresh (issue #9): a refund must not be able to observe // an expired bet/market record — keep both alive so a user who returns @@ -1253,14 +1305,15 @@ impl PredictionMarketContract { token::Client::new(&env, &cfg.xlm_sac).transfer( &env.current_contract_address(), &user, - &gross, + &refund, ); + Ok(refund) env.events().publish( (Symbol::new(&env, "cancel_refund"), user, market_id), - gross, + refund, ); - Ok(gross) + Ok(refund) } // ── Claim ───────────────────────────────────────────────────────────── @@ -1343,16 +1396,25 @@ impl PredictionMarketContract { Self::require_compatible_leaderboard(&env, &cfg.leaderboard)?; let _: Val = env.invoke_contract( &cfg.leaderboard, - &Symbol::new(&env, "reward"), + &Symbol::new(&env, "add_pts"), vec![ &env, this.clone().into_val(&env), user.clone().into_val(&env), points.into_val(&env), - tokens.into_val(&env), real_win.into_val(&env), ], ); + let _: Val = env.invoke_contract( + &cfg.token, + &Symbol::new(&env, "mint"), + vec![ + &env, + this.into_val(&env), + user.into_val(&env), + tokens.into_val(&env), + ], + ); env.events().publish( (Symbol::new(&env, "claim_processed"), user, market_id), diff --git a/prediction_market/src/tests.rs b/prediction_market/src/tests.rs index 8cc0965..d849155 100644 --- a/prediction_market/src/tests.rs +++ b/prediction_market/src/tests.rs @@ -397,14 +397,18 @@ fn test_increase_position_same_side() { // ── 12. Reject opposite-side bet ───────────────────────────────────────────── #[test] -#[should_panic(expected = "Error(Contract, #11)")] -fn test_reject_opposite_side_bet() { +fn test_switch_opposite_side_bet_moves_pool() { let t = setup(); let id = create_test_market(&t); let user = Address::generate(&t.env); fund_user(&t, &user, 500_0000000); t.client.place_bet(&user, &id, &true, &100_0000000_i128); t.client.place_bet(&user, &id, &false, &50_0000000_i128); + + let market = t.client.get_market(&id); + assert_eq!(market.total_yes, 0); + assert_eq!(market.total_no, 147_0000000); + assert_eq!(t.client.get_user_bet_count(&id, &user), 2); } // ── 13. Resolve market ─────────────────────────────────────────────────────── @@ -1550,6 +1554,109 @@ fn test_cancel_refund_rebumps_ttl_entries() { assert!(ttl(&market_key) > market_before); } +#[test] +fn test_switch_yes_to_no_claims_no_position() { + let t = setup(); + let id = create_test_market(&t); + let user = Address::generate(&t.env); + fund_user(&t, &user, 500_0000000); + + t.client.place_bet(&user, &id, &true, &100_0000000_i128); + t.client.place_bet(&user, &id, &false, &100_0000000_i128); + let market = t.client.get_market(&id); + assert_eq!(market.total_yes, 0); + assert_eq!(market.total_no, 196_0000000); + + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &false); + let before_claim = t.xlm.balance(&user); + t.client.claim(&user, &id); + assert_eq!(t.xlm.balance(&user) - before_claim, 196_0000000); +} + +#[test] +fn test_switch_back_to_yes_claims_moved_position() { + let t = setup(); + let id = create_test_market(&t); + let user = Address::generate(&t.env); + fund_user(&t, &user, 500_0000000); + + t.client.place_bet(&user, &id, &true, &100_0000000_i128); + t.client.place_bet(&user, &id, &false, &100_0000000_i128); + t.client.place_bet(&user, &id, &true, &100_0000000_i128); + let market = t.client.get_market(&id); + assert_eq!(market.total_yes, 294_0000000); + assert_eq!(market.total_no, 0); + assert_eq!(t.client.get_user_bet_count(&id, &user), 3); + + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &id, &true); + let before_claim = t.xlm.balance(&user); + t.client.claim(&user, &id); + assert_eq!(t.xlm.balance(&user) - before_claim, 294_0000000); +} + +#[test] +fn test_switch_yes_to_no_cancel_refunds_full_gross() { + let t = setup(); + let id = create_test_market(&t); + let user = Address::generate(&t.env); + fund_user(&t, &user, 500_0000000); + let before = t.xlm.balance(&user); + + t.client.place_bet(&user, &id, &true, &100_0000000_i128); + t.client.place_bet(&user, &id, &false, &100_0000000_i128); + t.client.cancel_market(&t.admin, &id); + + assert_eq!(t.client.cancel_refund(&user, &id), 200_0000000); + assert_eq!(t.xlm.balance(&user), before); +} + +#[test] +#[should_panic(expected = "Error(Contract, #17)")] +fn test_repeated_switches_respect_bet_cap() { + let t = setup(); + let id = create_test_market(&t); + let user = Address::generate(&t.env); + fund_user(&t, &user, 3_000_0000000); + + for i in 0..MAX_BETS_PER_USER { + t.client + .place_bet(&user, &id, &(i % 2 == 0), &100_0000000_i128); + } + assert_eq!(t.client.get_user_bet_count(&id, &user), MAX_BETS_PER_USER); + t.client.place_bet(&user, &id, &true, &100_0000000_i128); +} + +#[test] +fn test_no_switch_payout_and_refund_math_is_unchanged() { + let t = setup(); + let winner = Address::generate(&t.env); + let loser = Address::generate(&t.env); + fund_user(&t, &winner, 500_0000000); + fund_user(&t, &loser, 200_0000000); + + let resolved_id = create_test_market(&t); + t.client + .place_bet(&winner, &resolved_id, &true, &100_0000000_i128); + t.client + .place_bet(&loser, &resolved_id, &false, &100_0000000_i128); + advance_time(&t.env, 3601); + t.client.resolve_market(&t.admin, &resolved_id, &true); + let before_claim = t.xlm.balance(&winner); + t.client.claim(&winner, &resolved_id); + assert_eq!(t.xlm.balance(&winner) - before_claim, 196_0000000); + + let cancelled_id = create_test_market(&t); + let before_refund = t.xlm.balance(&winner); + t.client + .place_bet(&winner, &cancelled_id, &true, &100_0000000_i128); + t.client.cancel_market(&t.admin, &cancelled_id); + assert_eq!( + t.client.cancel_refund(&winner, &cancelled_id), + 100_0000000 + ); + assert_eq!(t.xlm.balance(&winner), before_refund); // ── #54: permissionless refresh + per-market expiry tracking + migration ───── #[test] diff --git a/referral_registry/src/lib.rs b/referral_registry/src/lib.rs index e69de29..90c4d9b 100644 --- a/referral_registry/src/lib.rs +++ b/referral_registry/src/lib.rs @@ -0,0 +1,422 @@ +#![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; + +// 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; + +#[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, + ContractPaused = 7, + 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); + 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); + 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); + 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); + 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); + } + 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(), + }, + ); + // 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); + env.storage() + .persistent() + .set(&DataKey::ReferralCount(ref_addr.clone()), &(count + 1)); + } + + let this = env.current_contract_address(); + let leaderboard: Address = env + .storage() + .instance() + .get(&DataKey::LeaderboardContract) + .unwrap(); + Self::require_compatible_leaderboard(&env, &leaderboard)?; + let _: Val = env.invoke_contract( + &leaderboard, + &Symbol::new(&env, "add_bonus_pts"), + vec![ + &env, + this.into_val(&env), + user.clone().into_val(&env), + WELCOME_BONUS_POINTS.into_val(&env), + ], + ); + let token: Address = env + .storage() + .instance() + .get(&DataKey::TokenContract) + .unwrap(); + let _: Val = env.invoke_contract( + &token, + &Symbol::new(&env, "mint"), + vec![ + &env, + env.current_contract_address().into_val(&env), + user.into_val(&env), + WELCOME_BONUS_TOKENS.into_val(&env), + ], + ); + 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) => { + 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(); + Self::require_compatible_leaderboard(&env, &leaderboard)?; + let _: Val = env.invoke_contract( + &leaderboard, + &Symbol::new(&env, "add_bonus_pts"), + vec![ + &env, + env.current_contract_address().into_val(&env), + ref_addr.clone().into_val(&env), + REFERRAL_BET_POINTS.into_val(&env), + ], + ); + let earnings: i128 = env + .storage() + .persistent() + .get(&DataKey::ReferralEarnings(ref_addr.clone())) + .unwrap_or(0); + env.storage().persistent().set( + &DataKey::ReferralEarnings(ref_addr), + &(earnings + 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, + ); + } + Ok(false) + } + } + } + + 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 { + env.storage() + .persistent() + .get(&DataKey::ReferralCount(user)) + .unwrap_or(0) + } + + pub fn get_earnings(env: Env, user: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::ReferralEarnings(user)) + .unwrap_or(0) + } + + 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() + } + + 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(()) + } + + fn require_not_paused(env: &Env) -> Result<(), ReferralError> { + if Self::is_paused(env.clone()) { + return Err(ReferralError::ContractPaused); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests;