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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 0 additions & 33 deletions leaderboard/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
21 changes: 14 additions & 7 deletions leaderboard/src/tests.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -789,20 +789,27 @@ 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]
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),
}
}
85 changes: 64 additions & 21 deletions prediction_market/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) ─────────────
Expand Down Expand Up @@ -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<Address> = 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::<bool>(
&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 ──────────────────────────────────────
Expand Down Expand Up @@ -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) {
Expand Down
Loading