Skip to content
Merged
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
170 changes: 86 additions & 84 deletions contract/contracts/predifi-contract/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
#![no_std]

use predifi_errors::PrediFiError;
use soroban_sdk::IntoVal;
use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, Symbol, Vec};
use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, token, Address, Env, IntoVal, Symbol, Vec,
};

#[contracterror]
#[derive(Copy, Clone, Debug, PartialEq)]
pub enum PredifiError {
Unauthorized = 10,
PoolNotResolved = 22,
AlreadyClaimed = 60,
}

#[contracttype]
#[derive(Clone)]
Expand Down Expand Up @@ -44,6 +52,7 @@ pub enum DataKey {
UserPredictionCount(Address),
UserPredictionIndex(Address, u32),
Config,
Paused,
}

#[contracttype]
Expand All @@ -58,8 +67,8 @@ pub struct PredifiContract;

#[contractimpl]
impl PredifiContract {
/// Cross-contract call to access control using u32 role,
/// matching the dummy and real contract's external ABI.
// ── Private helpers ───────────────────────────────────────────────────────

fn has_role(env: &Env, contract: &Address, user: &Address, role: u32) -> bool {
env.invoke_contract(
contract,
Expand All @@ -68,21 +77,36 @@ impl PredifiContract {
)
}

fn require_role(env: &Env, user: &Address, role: u32) -> Result<(), PrediFiError> {
let config = Self::get_config(env)?;
fn require_role(env: &Env, user: &Address, role: u32) -> Result<(), PredifiError> {
let config = Self::get_config(env);
if !Self::has_role(env, &config.access_control, user, role) {
return Err(PrediFiError::Unauthorized);
return Err(PredifiError::Unauthorized);
}
Ok(())
}

fn get_config(env: &Env) -> Result<Config, PrediFiError> {
fn get_config(env: &Env) -> Config {
env.storage()
.instance()
.get(&DataKey::Config)
.ok_or(PrediFiError::NotInitialized)
.expect("Config not set")
}

fn is_paused(env: &Env) -> bool {
env.storage()
.instance()
.get(&DataKey::Paused)
.unwrap_or(false)
}

fn require_not_paused(env: &Env) {
if Self::is_paused(env) {
panic!("Contract is paused");
}
}

// ── Public interface ──────────────────────────────────────────────────────

/// Initialize the contract. Idempotent — safe to call multiple times.
pub fn init(env: Env, access_control: Address, treasury: Address, fee_bps: u32) {
if !env.storage().instance().has(&DataKey::Config) {
Expand All @@ -96,36 +120,52 @@ impl PredifiContract {
}
}

/// Set fee in basis points. Caller must have Admin role (0).
pub fn set_fee_bps(env: Env, admin: Address, fee_bps: u32) -> Result<(), PrediFiError> {
/// Pause the contract. Only callable by Admin (role 0).
pub fn pause(env: Env, admin: Address) {
admin.require_auth();
Self::require_role(&env, &admin, 0)?;
Self::require_role(&env, &admin, 0)
.unwrap_or_else(|_| panic!("Unauthorized: missing required role"));
env.storage().instance().set(&DataKey::Paused, &true);
}

if fee_bps > 10000 {
return Err(PrediFiError::InvalidFeeBps);
}
/// Unpause the contract. Only callable by Admin (role 0).
pub fn unpause(env: Env, admin: Address) {
admin.require_auth();
Self::require_role(&env, &admin, 0)
.unwrap_or_else(|_| panic!("Unauthorized: missing required role"));
env.storage().instance().set(&DataKey::Paused, &false);
}

let mut config = Self::get_config(&env)?;
/// Set fee in basis points. Caller must have Admin role (0).
pub fn set_fee_bps(env: Env, admin: Address, fee_bps: u32) -> Result<(), PredifiError> {
Self::require_not_paused(&env);
admin.require_auth();
Self::require_role(&env, &admin, 0)?;
assert!(fee_bps <= 10_000, "fee_bps exceeds 10000");
let mut config = Self::get_config(&env);
config.fee_bps = fee_bps;
env.storage().instance().set(&DataKey::Config, &config);
Ok(())
}

/// Set treasury address. Caller must have Admin role (0).
pub fn set_treasury(env: Env, admin: Address, treasury: Address) -> Result<(), PrediFiError> {
pub fn set_treasury(env: Env, admin: Address, treasury: Address) -> Result<(), PredifiError> {
Self::require_not_paused(&env);
admin.require_auth();
Self::require_role(&env, &admin, 0)?;
let mut config = Self::get_config(&env)?;
let mut config = Self::get_config(&env);
config.treasury = treasury;
env.storage().instance().set(&DataKey::Config, &config);
Ok(())
}

/// Create a new prediction pool. Returns the new pool ID.
pub fn create_pool(env: Env, end_time: u64, token: Address) -> Result<u64, PrediFiError> {
if end_time <= env.ledger().timestamp() {
return Err(PrediFiError::TimeConstraintError);
}
pub fn create_pool(env: Env, end_time: u64, token: Address) -> u64 {
Self::require_not_paused(&env);
assert!(
end_time > env.ledger().timestamp(),
"end_time must be in the future"
);

let pool_id: u64 = env
.storage()
Expand All @@ -142,14 +182,11 @@ impl PredifiContract {
};

env.storage().instance().set(&DataKey::Pool(pool_id), &pool);
env.storage().instance().set(
&DataKey::PoolIdCounter,
&(pool_id
.checked_add(1)
.ok_or(PrediFiError::ArithmeticError)?),
);
env.storage()
.instance()
.set(&DataKey::PoolIdCounter, &(pool_id + 1));

Ok(pool_id)
pool_id
}

/// Resolve a pool with a winning outcome. Caller must have Operator role (1).
Expand All @@ -158,23 +195,18 @@ impl PredifiContract {
operator: Address,
pool_id: u64,
outcome: u32,
) -> Result<(), PrediFiError> {
) -> Result<(), PredifiError> {
Self::require_not_paused(&env);
operator.require_auth();
Self::require_role(&env, &operator, 1)?;

let mut pool: Pool = env
.storage()
.instance()
.get(&DataKey::Pool(pool_id))
.ok_or(PrediFiError::PoolNotFound)?;
.expect("Pool not found");

if pool.resolved {
return Err(PrediFiError::PoolAlreadyResolved);
}

if env.ledger().timestamp() < pool.end_time {
return Err(PrediFiError::PoolExpiryError);
}
assert!(!pool.resolved, "Pool already resolved");

pool.resolved = true;
pool.outcome = outcome;
Expand All @@ -184,61 +216,37 @@ impl PredifiContract {
}

/// Place a prediction on a pool.
pub fn place_prediction(
env: Env,
user: Address,
pool_id: u64,
amount: i128,
outcome: u32,
) -> Result<(), PrediFiError> {
pub fn place_prediction(env: Env, user: Address, pool_id: u64, amount: i128, outcome: u32) {
Self::require_not_paused(&env);
user.require_auth();

if amount <= 0 {
return Err(PrediFiError::InvalidPredictionAmount);
}
assert!(amount > 0, "amount must be positive");

let mut pool: Pool = env
.storage()
.instance()
.get(&DataKey::Pool(pool_id))
.ok_or(PrediFiError::PoolNotFound)?;
.expect("Pool not found");

if pool.resolved {
return Err(PrediFiError::PoolAlreadyResolved);
}
assert!(!pool.resolved, "Pool already resolved");
assert!(env.ledger().timestamp() < pool.end_time, "Pool has ended");

if env.ledger().timestamp() >= pool.end_time {
return Err(PrediFiError::PredictionTooLate);
}

// Transfer stake into the contract
let token_client = token::Client::new(&env, &pool.token);
token_client.transfer(&user, env.current_contract_address(), &amount);

// Record prediction
env.storage().instance().set(
&DataKey::Prediction(user.clone(), pool_id),
&Prediction { amount, outcome },
);

// Update total pool stake
pool.total_stake = pool
.total_stake
.checked_add(amount)
.ok_or(PrediFiError::ArithmeticError)?;
pool.total_stake = pool.total_stake.checked_add(amount).expect("overflow");
env.storage().instance().set(&DataKey::Pool(pool_id), &pool);

// Update per-outcome stake
let outcome_key = DataKey::OutcomeStake(pool_id, outcome);
let current_stake: i128 = env.storage().instance().get(&outcome_key).unwrap_or(0);
let new_outcome_stake = current_stake
.checked_add(amount)
.ok_or(PrediFiError::ArithmeticError)?;
env.storage()
.instance()
.set(&outcome_key, &new_outcome_stake);
.set(&outcome_key, &(current_stake + amount));

// Index prediction for pagination
let count: u32 = env
.storage()
.instance()
Expand All @@ -250,38 +258,36 @@ impl PredifiContract {
env.storage()
.instance()
.set(&DataKey::UserPredictionCount(user.clone()), &(count + 1));

Ok(())
}

/// Claim winnings from a resolved pool. Returns the amount paid out (0 for losers).
pub fn claim_winnings(env: Env, user: Address, pool_id: u64) -> Result<i128, PrediFiError> {
pub fn claim_winnings(env: Env, user: Address, pool_id: u64) -> Result<i128, PredifiError> {
Self::require_not_paused(&env);
user.require_auth();

let pool: Pool = env
.storage()
.instance()
.get(&DataKey::Pool(pool_id))
.ok_or(PrediFiError::PoolNotFound)?;
.expect("Pool not found");

if !pool.resolved {
return Err(PrediFiError::PoolNotResolved);
return Err(PredifiError::PoolNotResolved);
}

if env
.storage()
.instance()
.has(&DataKey::HasClaimed(user.clone(), pool_id))
{
return Err(PrediFiError::AlreadyClaimed);
return Err(PredifiError::AlreadyClaimed);
}

// Mark as claimed immediately to prevent re-entrancy
env.storage()
.instance()
.set(&DataKey::HasClaimed(user.clone(), pool_id), &true);

// Return 0 for users with no prediction or wrong outcome
let prediction: Option<Prediction> = env
.storage()
.instance()
Expand All @@ -296,7 +302,6 @@ impl PredifiContract {
return Ok(0);
}

// Share = (user_stake / winning_stake) * total_pool
let winning_stake: i128 = env
.storage()
.instance()
Expand All @@ -310,9 +315,9 @@ impl PredifiContract {
let winnings = prediction
.amount
.checked_mul(pool.total_stake)
.ok_or(PrediFiError::ArithmeticError)?
.expect("overflow")
.checked_div(winning_stake)
.ok_or(PrediFiError::ArithmeticError)?;
.expect("division by zero");

let token_client = token::Client::new(&env, &pool.token);
token_client.transfer(&env.current_contract_address(), &user, &winnings);
Expand All @@ -339,7 +344,6 @@ impl PredifiContract {
return results;
}

// core::cmp::min — NOT std::cmp::min (this crate is no_std)
let end = core::cmp::min(offset.saturating_add(limit), count);

for i in offset..end {
Expand Down Expand Up @@ -375,6 +379,4 @@ impl PredifiContract {
}
}

mod integration_test;
mod test;
mod test_utils;
Loading