Skip to content

Commit a497b89

Browse files
committed
feat: Implement Referral Fee Distribution Mechanism
1 parent 201bac1 commit a497b89

File tree

5 files changed

+327
-78
lines changed

5 files changed

+327
-78
lines changed

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

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,9 @@ fn test_full_market_lifecycle() {
9999
);
100100

101101
// 2. Place Predictions
102-
client.place_prediction(&user1, &pool_id, &100, &1); // User 1 bets 100 on Outcome 1
103-
client.place_prediction(&user2, &pool_id, &200, &2); // User 2 bets 200 on Outcome 2
104-
client.place_prediction(&user3, &pool_id, &300, &1); // User 3 bets 300 on Outcome 1 (Total Outcome 1 = 400)
102+
client.place_prediction(&user1, &pool_id, &100, &1, &None); // User 1 bets 100 on Outcome 1
103+
client.place_prediction(&user2, &pool_id, &200, &2, &None); // User 2 bets 200 on Outcome 2
104+
client.place_prediction(&user3, &pool_id, &300, &1, &None); // User 3 bets 300 on Outcome 1 (Total Outcome 1 = 400)
105105

106106
// Total stake = 100 + 200 + 300 = 600
107107
assert_eq!(token_ctx.token.balance(&client.address), 600);
@@ -181,11 +181,11 @@ fn test_multi_user_betting_and_balance_verification() {
181181
// U4: 500 on 1
182182
// Total 1: 1500, Total 2: 1000, Total 3: 1500. Total Stake: 4000.
183183

184-
client.place_prediction(&users.get(0).unwrap(), &pool_id, &500, &1);
185-
client.place_prediction(&users.get(1).unwrap(), &pool_id, &1000, &2);
186-
client.place_prediction(&users.get(2).unwrap(), &pool_id, &500, &1);
187-
client.place_prediction(&users.get(3).unwrap(), &pool_id, &1500, &3);
188-
client.place_prediction(&users.get(4).unwrap(), &pool_id, &500, &1);
184+
client.place_prediction(&users.get(0).unwrap(), &pool_id, &500, &1, &None);
185+
client.place_prediction(&users.get(1).unwrap(), &pool_id, &1000, &2, &None);
186+
client.place_prediction(&users.get(2).unwrap(), &pool_id, &500, &1, &None);
187+
client.place_prediction(&users.get(3).unwrap(), &pool_id, &1500, &3, &None);
188+
client.place_prediction(&users.get(4).unwrap(), &pool_id, &500, &1, &None);
189189

190190
assert_eq!(token_ctx.token.balance(&client.address), 4000);
191191

@@ -248,9 +248,9 @@ fn test_market_resolution_multiple_winners() {
248248
// U3: 500 on 2
249249
// Total 1: 500, Total 2: 500. Total Stake: 1000.
250250

251-
client.place_prediction(&user1, &pool_id, &200, &1);
252-
client.place_prediction(&user2, &pool_id, &300, &1);
253-
client.place_prediction(&user3, &pool_id, &500, &2);
251+
client.place_prediction(&user1, &pool_id, &200, &1, &None);
252+
client.place_prediction(&user2, &pool_id, &300, &1, &None);
253+
client.place_prediction(&user3, &pool_id, &500, &2, &None);
254254

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

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

Lines changed: 168 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,12 @@ pub enum DataKey {
194194
/// Token whitelist: TokenWhitelist(token_address) -> true if allowed for betting.
195195
TokenWl(Address),
196196
PartCnt(u64),
197+
/// Referrer for a (user, pool): Referrer(user, pool_id) -> referrer Address.
198+
Referrer(Address, u64),
199+
/// Referred volume per (referrer, pool) for payout/analytics: ReferredVolume(referrer, pool_id) -> i128.
200+
ReferredVolume(Address, u64),
201+
/// Referral cut in bps (e.g. 5000 = 50% of referrer's fee share). Instance storage, default 5000.
202+
ReferralCutBps,
197203
}
198204

199205
#[contracttype]
@@ -326,6 +332,15 @@ pub struct WinningsClaimedEvent {
326332
pub amount: i128,
327333
}
328334

335+
#[contractevent(topics = ["referral_paid"])]
336+
#[derive(Clone, Debug, Eq, PartialEq)]
337+
pub struct ReferralPaidEvent {
338+
pub pool_id: u64,
339+
pub referrer: Address,
340+
pub referred_user: Address,
341+
pub amount: i128,
342+
}
343+
329344
// ── Monitoring & Alert Events ─────────────────────────────────────────────────
330345
// These events are classified by severity and are intended for consumption by
331346
// off-chain monitoring tools (Horizon event streaming, Grafana, SIEM, etc.).
@@ -678,6 +693,17 @@ impl PredifiContract {
678693
config
679694
}
680695

696+
/// Referral cut in basis points (e.g. 5000 = 50% of referrer's fee share to referrer). Default 5000.
697+
fn read_referral_cut_bps(env: &Env) -> u32 {
698+
let bps = env
699+
.storage()
700+
.instance()
701+
.get(&DataKey::ReferralCutBps)
702+
.unwrap_or(5000u32);
703+
Self::extend_instance(env);
704+
bps
705+
}
706+
681707
fn is_paused(env: &Env) -> bool {
682708
let paused = env
683709
.storage()
@@ -859,6 +885,35 @@ impl PredifiContract {
859885
Ok(())
860886
}
861887

888+
/// Set referral cut in basis points (e.g. 5000 = 50% of referrer's fee share). Caller must have Admin role (0).
889+
/// Must be ≤ 10_000.
890+
pub fn set_referral_cut_bps(
891+
env: Env,
892+
admin: Address,
893+
referral_cut_bps: u32,
894+
) -> Result<(), PredifiError> {
895+
Self::require_not_paused(&env);
896+
admin.require_auth();
897+
if let Err(e) = Self::require_role(&env, &admin, 0) {
898+
UnauthorizedAdminAttemptEvent {
899+
caller: admin,
900+
operation: Symbol::new(&env, "set_referral_cut_bps"),
901+
timestamp: env.ledger().timestamp(),
902+
}
903+
.publish(&env);
904+
return Err(e);
905+
}
906+
assert!(
907+
referral_cut_bps <= 10_000,
908+
"referral_cut_bps must be at most 10000"
909+
);
910+
env.storage()
911+
.instance()
912+
.set(&DataKey::ReferralCutBps, &referral_cut_bps);
913+
Self::extend_instance(&env);
914+
Ok(())
915+
}
916+
862917
/// Add a token to the allowed betting whitelist. Caller must have Admin role (0).
863918
pub fn add_token_to_whitelist(
864919
env: Env,
@@ -950,6 +1005,21 @@ impl PredifiContract {
9501005
Self::is_token_whitelisted(&env, &token)
9511006
}
9521007

1008+
/// Get referral cut in basis points (e.g. 5000 = 50% of referrer's fee share).
1009+
pub fn get_referral_cut_bps(env: Env) -> u32 {
1010+
Self::read_referral_cut_bps(&env)
1011+
}
1012+
1013+
/// Get total referred volume for a (referrer, pool_id) in base token units.
1014+
pub fn get_referred_volume(env: Env, referrer: Address, pool_id: u64) -> i128 {
1015+
let key = DataKey::ReferredVolume(referrer, pool_id);
1016+
let vol = env.storage().persistent().get(&key).unwrap_or(0);
1017+
if env.storage().persistent().has(&key) {
1018+
Self::extend_persistent(&env, &key);
1019+
}
1020+
vol
1021+
}
1022+
9531023
/// Withdraw accumulated protocol fees or unused liquidity from the contract.
9541024
/// Only callable by Admin (role 0).
9551025
///
@@ -1365,15 +1435,33 @@ impl PredifiContract {
13651435
}
13661436

13671437
/// Place a prediction on a pool. Cannot predict on canceled or resolved pools.
1438+
/// Optional `referrer`: if set, that address will receive a referral cut of the protocol fee
1439+
/// when this user claims winnings. Stored only on first prediction for (user, pool_id).
13681440
/// PRE: amount > 0 (INV-7), pool.state = Active, current_time < pool.end_time
13691441
/// PRE: pool.min_stake <= amount <= pool.max_stake (unless max_stake == 0)
13701442
/// POST: pool.total_stake increases by amount, OutcomeStake increases by amount (INV-1)
13711443
#[allow(clippy::needless_borrows_for_generic_args)]
1372-
pub fn place_prediction(env: Env, user: Address, pool_id: u64, amount: i128, outcome: u32) {
1444+
pub fn place_prediction(
1445+
env: Env,
1446+
user: Address,
1447+
pool_id: u64,
1448+
amount: i128,
1449+
outcome: u32,
1450+
referrer: Option<Address>,
1451+
) {
13731452
Self::require_not_paused(&env);
13741453
user.require_auth();
13751454
assert!(amount > 0, "amount must be positive");
13761455

1456+
// Validate referrer if provided: cannot be self or contract
1457+
if let Some(ref r) = referrer {
1458+
assert!(r != &user, "referrer cannot be self");
1459+
assert!(
1460+
r != &env.current_contract_address(),
1461+
"referrer cannot be contract"
1462+
);
1463+
}
1464+
13771465
Self::enter_reentrancy_guard(&env);
13781466

13791467
let pool_key = DataKey::Pool(pool_id);
@@ -1408,21 +1496,43 @@ impl PredifiContract {
14081496
}
14091497

14101498
let pred_key = DataKey::Pred(user.clone(), pool_id);
1411-
if let Some(mut existing_pred) = env.storage().persistent().get::<_, Prediction>(&pred_key)
1412-
{
1499+
let existing_pred = env.storage().persistent().get::<_, Prediction>(&pred_key);
1500+
if let Some(mut existing_pred) = existing_pred {
14131501
assert!(
14141502
existing_pred.outcome == outcome,
14151503
"Cannot change prediction outcome"
14161504
);
14171505
existing_pred.amount = existing_pred.amount.checked_add(amount).expect("overflow");
14181506
env.storage().persistent().set(&pred_key, &existing_pred);
14191507
Self::extend_persistent(&env, &pred_key);
1508+
1509+
// Track referred volume: if this user already has a referrer, add to their volume
1510+
let referrer_key = DataKey::Referrer(user.clone(), pool_id);
1511+
if let Some(referrer_addr) = env.storage().persistent().get::<_, Address>(&referrer_key)
1512+
{
1513+
Self::extend_persistent(&env, &referrer_key);
1514+
let vol_key = DataKey::ReferredVolume(referrer_addr.clone(), pool_id);
1515+
let vol: i128 = env.storage().persistent().get(&vol_key).unwrap_or(0);
1516+
env.storage().persistent().set(&vol_key, &(vol + amount));
1517+
Self::extend_persistent(&env, &vol_key);
1518+
}
14201519
} else {
14211520
env.storage()
14221521
.persistent()
14231522
.set(&pred_key, &Prediction { amount, outcome });
14241523
Self::extend_persistent(&env, &pred_key);
14251524

1525+
// Store referrer on first prediction and track referred volume
1526+
if let Some(ref referrer_addr) = referrer {
1527+
let referrer_key = DataKey::Referrer(user.clone(), pool_id);
1528+
env.storage().persistent().set(&referrer_key, referrer_addr);
1529+
Self::extend_persistent(&env, &referrer_key);
1530+
let vol_key = DataKey::ReferredVolume(referrer_addr.clone(), pool_id);
1531+
let vol: i128 = env.storage().persistent().get(&vol_key).unwrap_or(0);
1532+
env.storage().persistent().set(&vol_key, &(vol + amount));
1533+
Self::extend_persistent(&env, &vol_key);
1534+
}
1535+
14261536
let pc_key = DataKey::PartCnt(pool_id);
14271537
let pc: u32 = env.storage().persistent().get(&pc_key).unwrap_or(0);
14281538
env.storage().persistent().set(&pc_key, &(pc + 1));
@@ -1578,14 +1688,66 @@ impl PredifiContract {
15781688
return Ok(0);
15791689
}
15801690

1581-
// Use pure function for winnings calculation (verifiable)
1582-
let winnings = Self::calculate_winnings(prediction.amount, winning_stake, pool.total_stake);
1691+
// Protocol fee: deducted from pool before distribution (flat fee_bps, no dependency on 317)
1692+
let config = Self::get_config(&env);
1693+
let fee_bps_i = config.fee_bps as i128;
1694+
let protocol_fee_total = SafeMath::percentage(
1695+
pool.total_stake,
1696+
fee_bps_i,
1697+
RoundingMode::ProtocolFavor,
1698+
)
1699+
.map_err(|_| PredifiError::InvalidAmount)?;
1700+
let payout_pool = pool
1701+
.total_stake
1702+
.checked_sub(protocol_fee_total)
1703+
.ok_or(PredifiError::InvalidAmount)?;
1704+
1705+
// Winnings = user's share of the payout pool (after fee)
1706+
let winnings =
1707+
Self::calculate_winnings(prediction.amount, winning_stake, payout_pool);
15831708

15841709
// Verify invariant: winnings ≤ total_stake (INV-4)
15851710
assert!(winnings <= pool.total_stake, "Winnings exceed total stake");
15861711

1587-
// --- INTERACTIONS (Winnings Payout) ---
1712+
// --- INTERACTIONS ---
15881713
let token_client = token::Client::new(&env, &pool.token);
1714+
1715+
// Referral: portion of protocol fee attributable to this user goes to referrer
1716+
let referrer_key = DataKey::Referrer(user.clone(), pool_id);
1717+
if let Some(referrer) = env.storage().persistent().get::<_, Address>(&referrer_key) {
1718+
Self::extend_persistent(&env, &referrer_key);
1719+
if protocol_fee_total > 0 && pool.total_stake > 0 {
1720+
let protocol_fee_share = SafeMath::proportion(
1721+
prediction.amount,
1722+
pool.total_stake,
1723+
protocol_fee_total,
1724+
RoundingMode::Neutral,
1725+
)
1726+
.map_err(|_| PredifiError::InvalidAmount)?;
1727+
let referral_cut_bps = Self::read_referral_cut_bps(&env) as i128;
1728+
let referral_amount = SafeMath::percentage(
1729+
protocol_fee_share,
1730+
referral_cut_bps,
1731+
RoundingMode::Neutral,
1732+
)
1733+
.map_err(|_| PredifiError::InvalidAmount)?;
1734+
if referral_amount > 0 {
1735+
token_client.transfer(
1736+
&env.current_contract_address(),
1737+
&referrer,
1738+
&referral_amount,
1739+
);
1740+
ReferralPaidEvent {
1741+
pool_id,
1742+
referrer: referrer.clone(),
1743+
referred_user: user.clone(),
1744+
amount: referral_amount,
1745+
}
1746+
.publish(&env);
1747+
}
1748+
}
1749+
}
1750+
15891751
token_client.transfer(&env.current_contract_address(), &user, &winnings);
15901752

15911753
Self::exit_reentrancy_guard(&env);

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

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

107107
// Split users between outcome 0 and 1
108108
let outcome = i % 2;
109-
client.place_prediction(&user, &pool_id, &stake_per_user, &outcome);
109+
client.place_prediction(&user, &pool_id, &stake_per_user, &outcome, &None);
110110
}
111111

112112
// Use client to get details instead of direct storage access
@@ -140,7 +140,7 @@ fn test_bulk_claim_winnings() {
140140
for _ in 0..num_users {
141141
let user = Address::generate(&env);
142142
token_admin_client.mint(&user, &stake_per_user);
143-
client.place_prediction(&user, &pool_id, &stake_per_user, &0); // All on outcome 0
143+
client.place_prediction(&user, &pool_id, &stake_per_user, &0, &None); // All on outcome 0
144144
users.push(user);
145145
}
146146

@@ -207,7 +207,7 @@ fn test_max_outcomes_high_volume() {
207207
for i in 0..max_options {
208208
let user = Address::generate(&env);
209209
token_admin_client.mint(&user, &1000);
210-
client.place_prediction(&user, &pool_id, &1000, &i);
210+
client.place_prediction(&user, &pool_id, &1000, &i, &None);
211211
}
212212

213213
let pool = client.get_pool(&pool_id);
@@ -239,7 +239,7 @@ fn test_prediction_throughput_measurement() {
239239
for _ in 0..num_predictions {
240240
let user = Address::generate(&env);
241241
token_admin_client.mint(&user, &100);
242-
client.place_prediction(&user, &pool_id, &100, &0);
242+
client.place_prediction(&user, &pool_id, &100, &0, &None);
243243
}
244244

245245
let end_ledger = env.ledger().timestamp();
@@ -277,7 +277,7 @@ fn test_resolution_under_load() {
277277
for &pid in &pool_ids {
278278
let user = Address::generate(&env);
279279
token_admin_client.mint(&user, &1000);
280-
client.place_prediction(&user, &pid, &1000, &0);
280+
client.place_prediction(&user, &pid, &1000, &0, &None);
281281
}
282282

283283
// Advance time

0 commit comments

Comments
 (0)