Skip to content

Commit 0c01208

Browse files
authored
Implement Private/Invite-Only Prediction Markets (#382)
* feat: implement safe math wrapper for proportion calculations (#304) - Add SafeMath module with fixed-point arithmetic - Implement safe percentage and proportion calculations - Add three rounding modes: ProtocolFavor, Neutral, UserFavor - Include overflow/underflow protection - Add comprehensive unit tests (12 tests) - Add usage examples for real-world scenarios (9 examples) - All tests passing, clippy clean, formatted * feat: implement private and invite-only prediction markets * fix: resolve cargo fmt and clippy warnings * fix: correct stake calculation in test_state_consistency_across_many_pools
1 parent bbe61bc commit 0c01208

File tree

4 files changed

+222
-73
lines changed

4 files changed

+222
-73
lines changed

contract/contracts/predifi-contract/src/integration_test.rs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ fn test_full_market_lifecycle() {
102102
);
103103

104104
// 2. Place Predictions
105-
client.place_prediction(&user1, &pool_id, &100, &1, &None); // User 1 bets 100 on Outcome 1
106-
client.place_prediction(&user2, &pool_id, &200, &2, &None); // User 2 bets 200 on Outcome 2
107-
client.place_prediction(&user3, &pool_id, &300, &1, &None); // User 3 bets 300 on Outcome 1 (Total Outcome 1 = 400)
105+
client.place_prediction(&user1, &pool_id, &100, &1, &None, &None); // User 1 bets 100 on Outcome 1
106+
client.place_prediction(&user2, &pool_id, &200, &2, &None, &None); // User 2 bets 200 on Outcome 2
107+
client.place_prediction(&user3, &pool_id, &300, &1, &None, &None); // User 3 bets 300 on Outcome 1 (Total Outcome 1 = 400)
108108

109109
// Total stake = 100 + 200 + 300 = 600
110110
assert_eq!(token_ctx.token.balance(&client.address), 600);
@@ -187,11 +187,11 @@ fn test_multi_user_betting_and_balance_verification() {
187187
// U4: 500 on 1
188188
// Total 1: 1500, Total 2: 1000, Total 3: 1500. Total Stake: 4000.
189189

190-
client.place_prediction(&users.get(0).unwrap(), &pool_id, &500, &1, &None);
191-
client.place_prediction(&users.get(1).unwrap(), &pool_id, &1000, &2, &None);
192-
client.place_prediction(&users.get(2).unwrap(), &pool_id, &500, &1, &None);
193-
client.place_prediction(&users.get(3).unwrap(), &pool_id, &1500, &3, &None);
194-
client.place_prediction(&users.get(4).unwrap(), &pool_id, &500, &1, &None);
190+
client.place_prediction(&users.get(0).unwrap(), &pool_id, &500, &1, &None, &None);
191+
client.place_prediction(&users.get(1).unwrap(), &pool_id, &1000, &2, &None, &None);
192+
client.place_prediction(&users.get(2).unwrap(), &pool_id, &500, &1, &None, &None);
193+
client.place_prediction(&users.get(3).unwrap(), &pool_id, &1500, &3, &None, &None);
194+
client.place_prediction(&users.get(4).unwrap(), &pool_id, &500, &1, &None, &None);
195195

196196
assert_eq!(token_ctx.token.balance(&client.address), 4000);
197197

@@ -257,9 +257,9 @@ fn test_market_resolution_multiple_winners() {
257257
// U3: 500 on 2
258258
// Total 1: 500, Total 2: 500. Total Stake: 1000.
259259

260-
client.place_prediction(&user1, &pool_id, &200, &1, &None);
261-
client.place_prediction(&user2, &pool_id, &300, &1, &None);
262-
client.place_prediction(&user3, &pool_id, &500, &2, &None);
260+
client.place_prediction(&user1, &pool_id, &200, &1, &None, &None);
261+
client.place_prediction(&user2, &pool_id, &300, &1, &None, &None);
262+
client.place_prediction(&user3, &pool_id, &500, &2, &None, &None);
263263

264264
// Advance time past end_time=3600, then resolve
265265
env.ledger().with_mut(|li| li.timestamp = 3601);

contract/contracts/predifi-contract/src/lib.rs

Lines changed: 149 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,22 @@ pub enum MarketState {
120120
Canceled = 2,
121121
}
122122

123+
#[contracttype]
124+
#[derive(Clone)]
125+
pub struct CreatePoolParams {
126+
pub end_time: u64,
127+
pub token: Address,
128+
pub options_count: u32,
129+
pub description: String,
130+
pub metadata_url: String,
131+
pub min_stake: i128,
132+
pub max_stake: i128,
133+
pub initial_liquidity: i128,
134+
pub category: Symbol,
135+
pub private: bool,
136+
pub whitelist_key: Option<Symbol>,
137+
}
138+
123139
#[contracttype]
124140
#[derive(Clone)]
125141
pub struct Pool {
@@ -1159,32 +1175,38 @@ impl PredifiContract {
11591175

11601176
// Validate: category must be in the allowed list
11611177
assert!(
1162-
Self::validate_category(&env, &category),
1178+
Self::validate_category(&env, &params.category),
11631179
"category must be one of the allowed categories"
11641180
);
11651181

11661182
// Validate: token must be on the allowed betting whitelist
1167-
if !Self::is_token_whitelisted(&env, &token) {
1183+
if !Self::is_token_whitelisted(&env, &params.token) {
11681184
soroban_sdk::panic_with_error!(&env, PredifiError::TokenNotWhitelisted);
11691185
}
11701186

11711187
let current_time = env.ledger().timestamp();
11721188

11731189
// Validate: end_time must be in the future
1174-
assert!(end_time > current_time, "end_time must be in the future");
1190+
assert!(
1191+
params.end_time > current_time,
1192+
"end_time must be in the future"
1193+
);
11751194

11761195
// Validate: minimum pool duration (1 hour)
11771196
assert!(
1178-
end_time >= current_time + MIN_POOL_DURATION,
1197+
params.end_time >= current_time + MIN_POOL_DURATION,
11791198
"end_time must be at least 1 hour in the future"
11801199
);
11811200

11821201
// Validate: options_count must be at least 2 (binary or more outcomes)
1183-
assert!(options_count >= 2, "options_count must be at least 2");
1202+
assert!(
1203+
params.options_count >= 2,
1204+
"options_count must be at least 2"
1205+
);
11841206

11851207
// Validate: options_count must not exceed maximum limit
11861208
assert!(
1187-
options_count <= MAX_OPTIONS_COUNT,
1209+
params.options_count <= MAX_OPTIONS_COUNT,
11881210
"options_count exceeds maximum allowed value"
11891211
);
11901212

@@ -1233,7 +1255,7 @@ impl PredifiContract {
12331255
.unwrap_or(0);
12341256
// Initialize pool data structure
12351257
let pool = Pool {
1236-
end_time,
1258+
end_time: params.end_time,
12371259
resolved: false,
12381260
canceled: false,
12391261
state: MarketState::Active,
@@ -1271,14 +1293,14 @@ impl PredifiContract {
12711293
}
12721294

12731295
// Update category index
1274-
let category_count_key = DataKey::CatPoolCt(category.clone());
1296+
let category_count_key = DataKey::CatPoolCt(params.category.clone());
12751297
let category_count: u32 = env
12761298
.storage()
12771299
.persistent()
12781300
.get(&category_count_key)
12791301
.unwrap_or(0);
12801302

1281-
let category_index_key = DataKey::CatPoolIx(category.clone(), category_count);
1303+
let category_index_key = DataKey::CatPoolIx(params.category.clone(), category_count);
12821304
env.storage()
12831305
.persistent()
12841306
.set(&category_index_key, &pool_id);
@@ -1615,6 +1637,7 @@ impl PredifiContract {
16151637
amount: i128,
16161638
outcome: u32,
16171639
referrer: Option<Address>,
1640+
invite_key: Option<Symbol>,
16181641
) {
16191642
Self::require_not_paused(&env);
16201643
user.require_auth();
@@ -1643,6 +1666,32 @@ impl PredifiContract {
16431666
assert!(pool.state == MarketState::Active, "Pool is not active");
16441667
assert!(env.ledger().timestamp() < pool.end_time, "Pool has ended");
16451668

1669+
// Check private pool authorization
1670+
// Check private pool authorization
1671+
if pool.private {
1672+
let whitelist_key_data = DataKey::Whitelist(pool_id, user.clone());
1673+
let is_whitelisted = env
1674+
.storage()
1675+
.persistent()
1676+
.get(&whitelist_key_data)
1677+
.unwrap_or(false);
1678+
1679+
let has_valid_invite = if let Some(ref pool_key) = pool.whitelist_key {
1680+
if let Some(ref prov_key) = invite_key {
1681+
pool_key == prov_key
1682+
} else {
1683+
false
1684+
}
1685+
} else {
1686+
false
1687+
};
1688+
1689+
assert!(
1690+
is_whitelisted || user == pool.creator || has_valid_invite,
1691+
"User not authorized for private pool"
1692+
);
1693+
}
1694+
16461695
// Validate: outcome must be within the valid options range
16471696
assert!(
16481697
outcome < pool.options_count,
@@ -2223,6 +2272,97 @@ impl PredifiContract {
22232272
results
22242273
}
22252274

2275+
/// Add a user to a private pool's whitelist. Only callable by pool creator.
2276+
pub fn add_to_whitelist(
2277+
env: Env,
2278+
creator: Address,
2279+
pool_id: u64,
2280+
user: Address,
2281+
) -> Result<(), PredifiError> {
2282+
Self::require_not_paused(&env);
2283+
creator.require_auth();
2284+
2285+
let pool_key = DataKey::Pool(pool_id);
2286+
let pool: Pool = env
2287+
.storage()
2288+
.persistent()
2289+
.get(&pool_key)
2290+
.expect("Pool not found");
2291+
Self::extend_persistent(&env, &pool_key);
2292+
2293+
if pool.creator != creator {
2294+
return Err(PredifiError::Unauthorized);
2295+
}
2296+
2297+
assert!(pool.private, "Pool is not private");
2298+
2299+
let whitelist_key = DataKey::Whitelist(pool_id, user);
2300+
env.storage().persistent().set(&whitelist_key, &true);
2301+
Self::extend_persistent(&env, &whitelist_key);
2302+
2303+
Ok(())
2304+
}
2305+
2306+
/// Remove a user from a private pool's whitelist. Only callable by pool creator.
2307+
pub fn remove_from_whitelist(
2308+
env: Env,
2309+
creator: Address,
2310+
pool_id: u64,
2311+
user: Address,
2312+
) -> Result<(), PredifiError> {
2313+
Self::require_not_paused(&env);
2314+
creator.require_auth();
2315+
2316+
let pool_key = DataKey::Pool(pool_id);
2317+
let pool: Pool = env
2318+
.storage()
2319+
.persistent()
2320+
.get(&pool_key)
2321+
.expect("Pool not found");
2322+
Self::extend_persistent(&env, &pool_key);
2323+
2324+
if pool.creator != creator {
2325+
return Err(PredifiError::Unauthorized);
2326+
}
2327+
2328+
assert!(pool.private, "Pool is not private");
2329+
2330+
let whitelist_key = DataKey::Whitelist(pool_id, user);
2331+
env.storage().persistent().remove(&whitelist_key);
2332+
2333+
Ok(())
2334+
}
2335+
2336+
/// Check if a user is whitelisted for a private pool.
2337+
pub fn is_whitelisted(env: Env, pool_id: u64, user: Address) -> bool {
2338+
let pool_key = DataKey::Pool(pool_id);
2339+
let pool: Pool = env
2340+
.storage()
2341+
.persistent()
2342+
.get(&pool_key)
2343+
.expect("Pool not found");
2344+
Self::extend_persistent(&env, &pool_key);
2345+
2346+
if !pool.private {
2347+
return true;
2348+
}
2349+
2350+
if user == pool.creator {
2351+
return true;
2352+
}
2353+
2354+
let whitelist_key = DataKey::Whitelist(pool_id, user);
2355+
let is_whitelisted = env
2356+
.storage()
2357+
.persistent()
2358+
.get(&whitelist_key)
2359+
.unwrap_or(false);
2360+
if env.storage().persistent().has(&whitelist_key) {
2361+
Self::extend_persistent(&env, &whitelist_key);
2362+
}
2363+
is_whitelisted
2364+
}
2365+
22262366
/// Get comprehensive stats for a pool.
22272367
pub fn get_pool_stats(env: Env, pool_id: u64) -> PoolStats {
22282368
let pool_key = DataKey::Pool(pool_id);

contract/contracts/predifi-contract/src/stress_test.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ fn test_high_volume_predictions_single_pool() {
110110

111111
// Split users between outcome 0 and 1
112112
let outcome = i % 2;
113-
client.place_prediction(&user, &pool_id, &stake_per_user, &outcome, &None);
113+
client.place_prediction(&user, &pool_id, &stake_per_user, &outcome, &None, &None);
114114
}
115115

116116
// Use client to get details instead of direct storage access
@@ -148,7 +148,7 @@ fn test_bulk_claim_winnings() {
148148
for _ in 0..num_users {
149149
let user = Address::generate(&env);
150150
token_admin_client.mint(&user, &stake_per_user);
151-
client.place_prediction(&user, &pool_id, &stake_per_user, &0, &None); // All on outcome 0
151+
client.place_prediction(&user, &pool_id, &stake_per_user, &0, &None, &None); // All on outcome 0
152152
users.push(user);
153153
}
154154

@@ -221,7 +221,7 @@ fn test_max_outcomes_high_volume() {
221221
for i in 0..max_options {
222222
let user = Address::generate(&env);
223223
token_admin_client.mint(&user, &1000);
224-
client.place_prediction(&user, &pool_id, &1000, &i, &None);
224+
client.place_prediction(&user, &pool_id, &1000, &i, &None, &None);
225225
}
226226

227227
let pool = client.get_pool(&pool_id);
@@ -256,7 +256,7 @@ fn test_prediction_throughput_measurement() {
256256
for _ in 0..num_predictions {
257257
let user = Address::generate(&env);
258258
token_admin_client.mint(&user, &100);
259-
client.place_prediction(&user, &pool_id, &100, &0, &None);
259+
client.place_prediction(&user, &pool_id, &100, &0, &None, &None);
260260
}
261261

262262
let end_ledger = env.ledger().timestamp();
@@ -297,7 +297,7 @@ fn test_resolution_under_load() {
297297
for &pid in &pool_ids {
298298
let user = Address::generate(&env);
299299
token_admin_client.mint(&user, &1000);
300-
client.place_prediction(&user, &pid, &1000, &0, &None);
300+
client.place_prediction(&user, &pid, &1000, &0, &None, &None);
301301
}
302302

303303
// Advance time

0 commit comments

Comments
 (0)