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
25 changes: 25 additions & 0 deletions contracts/predictify-hybrid/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1362,6 +1362,18 @@ impl AdminManager {
// Store in multi-admin storage
env.storage().persistent().set(&admin_key, &assignment);

// Track admins in a list so role enumeration stays in sync.
let list_key = Symbol::new(env, "AdminList");
let mut admin_list: Vec<Address> = env
.storage()
.persistent()
.get(&list_key)
.unwrap_or_else(|| Vec::new(env));
if !admin_list.contains(new_admin) {
admin_list.push_back(new_admin.clone());
env.storage().persistent().set(&list_key, &admin_list);
}

// Update admin count
let count_key = Symbol::new(env, "AdminCount");
let current_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0);
Expand Down Expand Up @@ -1398,6 +1410,14 @@ impl AdminManager {

env.storage().persistent().remove(&admin_key);

let list_key = Symbol::new(env, "AdminList");
if let Some(mut admin_list) = env.storage().persistent().get::<_, Vec<Address>>(&list_key) {
if let Some(index) = admin_list.first_index_of(admin_to_remove) {
admin_list.remove(index);
env.storage().persistent().set(&list_key, &admin_list);
}
}

// Update count
let count_key = Symbol::new(env, "AdminCount");
let current_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0);
Expand Down Expand Up @@ -3409,6 +3429,11 @@ impl AdminSystemIntegration {

let admin_key = AdminManager::get_admin_key(env, &original_admin);
env.storage().persistent().set(&admin_key, &assignment);
let mut admin_list = Vec::new(env);
admin_list.push_back(original_admin.clone());
env.storage()
.persistent()
.set(&Symbol::new(env, "AdminList"), &admin_list);
env.storage()
.persistent()
.set(&Symbol::new(env, "AdminCount"), &1u32);
Expand Down
2 changes: 1 addition & 1 deletion contracts/predictify-hybrid/src/admin_auth_audit_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ impl TestSetup {

fn initialized() -> Self {
let setup = Self::uninitialized();
setup.client().initialize(&setup.admin, &None);
setup.client().initialize(&setup.admin, &None, &None);
setup
}

Expand Down
153 changes: 124 additions & 29 deletions contracts/predictify-hybrid/src/balances.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,15 +103,18 @@ impl BalanceManager {
/// Withdraw funds from the user's balance.
///
/// This function transfers tokens from the contract back to the user's wallet.
/// It follows the Checks-Effects-Interactions (CEI) pattern to prevent reentrancy and ensuring safety:
/// It follows a strict "Check-Transfer-then-Debit" pattern so a failed transfer never leaves
/// a phantom debit in storage:
/// 1. Checks: Validate authorization, circuit breaker, and sufficient balance.
/// 2. Effects: Update (subtract) user balance in contract storage.
/// 2. Compute: Derive the post-withdraw balance without mutating storage.
/// 3. Interactions: Execute token transfer (from contract to user).
/// 4. Effects: Persist the debited balance only after the transfer succeeds.
///
/// # Invariants
/// - `amount` must be strictly positive.
/// - Withdrawal is only permitted if the user has sufficient available balance.
/// - Circuit breaker must allow withdrawals.
/// - Balance storage is only mutated after the outbound transfer succeeds.
///
/// # Parameters
/// * `env` - The Soroban environment.
Expand Down Expand Up @@ -145,21 +148,22 @@ impl BalanceManager {
return Err(Error::CBOpen);
}

// Check sufficient balance and subtract (Effects)
// sub_balance will return Error::InsufficientBalance if amount > current_balance.amount
let balance = BalanceStorage::sub_balance(env, &user, &asset, amount)?;

// Resolve token client
let token_client = match asset {
ReflectorAsset::Stellar => MarketUtils::get_token_client(env)?,
_ => return Err(Error::InvalidInput),
};

// Compute the resulting balance before interacting with the token contract.
let balance = BalanceStorage::checked_sub_balance(env, &user, &asset, amount)?;

// Transfer funds from contract to user (Interactions)
// Note: Contract-to-user transfers in Soroban do not require user auth,
// but the contract address must have sufficient balance.
// If this panics, Soroban rolls back the call and the balance write below is skipped.
token_client.transfer(&env.current_contract_address(), &user, &amount);

// Persist the debit only after the token transfer succeeds.
BalanceStorage::set_balance(env, &balance)?;

// Emit event
EventEmitter::emit_balance_changed(
env,
Expand All @@ -181,49 +185,140 @@ impl BalanceManager {

#[cfg(test)]
mod tests {
extern crate std;

use super::*;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::Env;
use crate::PredictifyHybridClient;
use soroban_sdk::{
testutils::{Address as _, EnvTestConfig},
token::{Client as TokenClient, StellarAssetClient},
Address, Env, Symbol,
};
use std::panic::{catch_unwind, AssertUnwindSafe};

struct BalanceTestSetup {
env: Env,
contract_id: Address,
token_id: Address,
user: Address,
sink: Address,
}

impl BalanceTestSetup {
fn new() -> Self {
let env = Env::default();
let mut env = Env::default();
env.set_config(EnvTestConfig {
capture_snapshot_at_drop: false,
});
env.mock_all_auths();

let token_admin = Address::generate(&env);
let token_contract = env.register_stellar_asset_contract_v2(token_admin);
let token_id = token_contract.address();

let admin = Address::generate(&env);
let user = Address::generate(&env);
BalanceTestSetup { env, user }
let sink = Address::generate(&env);

let contract_id = env.register(crate::PredictifyHybrid, ());
let client = PredictifyHybridClient::new(&env, &contract_id);
client.initialize(&admin, &None, &None);

env.as_contract(&contract_id, || {
env.storage()
.persistent()
.set(&Symbol::new(&env, "TokenID"), &token_id);
});

StellarAssetClient::new(&env, &token_id).mint(&user, &1_000_0000000);

BalanceTestSetup {
env,
contract_id,
token_id,
user,
sink,
}
}

fn client(&self) -> PredictifyHybridClient<'_> {
PredictifyHybridClient::new(&self.env, &self.contract_id)
}

fn token_client(&self) -> TokenClient<'_> {
TokenClient::new(&self.env, &self.token_id)
}
}

#[test]
fn test_deposit_valid_amount() {
let _setup = BalanceTestSetup::new();
let amount = 1_000_000i128;
assert!(amount > 0);
fn test_deposit_credits_balance_after_transfer() {
let setup = BalanceTestSetup::new();
let client = setup.client();
let token_client = setup.token_client();

let balance = client.deposit(&setup.user, &ReflectorAsset::Stellar, &500_0000000);

assert_eq!(balance.amount, 500_0000000);
assert_eq!(
client.get_balance(&setup.user, &ReflectorAsset::Stellar).amount,
500_0000000
);
assert_eq!(token_client.balance(&setup.user), 500_0000000);
assert_eq!(token_client.balance(&setup.contract_id), 500_0000000);
}

#[test]
fn test_deposit_zero_amount() {
let _setup = BalanceTestSetup::new();
let amount = 0i128;
assert_eq!(amount, 0);
fn test_withdraw_exact_balance_reaches_zero() {
let setup = BalanceTestSetup::new();
let client = setup.client();
let token_client = setup.token_client();

client.deposit(&setup.user, &ReflectorAsset::Stellar, &500);
let balance = client.withdraw(&setup.user, &ReflectorAsset::Stellar, &500);

assert_eq!(balance.amount, 0);
assert_eq!(client.get_balance(&setup.user, &ReflectorAsset::Stellar).amount, 0);
assert_eq!(token_client.balance(&setup.user), 1_000_0000000);
assert_eq!(token_client.balance(&setup.contract_id), 0);
}

#[test]
fn test_deposit_negative_amount() {
let _setup = BalanceTestSetup::new();
let amount = -1_000_000i128;
assert!(amount < 0);
fn test_withdraw_over_balance_returns_typed_error_without_mutation() {
let setup = BalanceTestSetup::new();
let client = setup.client();

client.deposit(&setup.user, &ReflectorAsset::Stellar, &100_0000000);

let result = client.try_withdraw(&setup.user, &ReflectorAsset::Stellar, &150_0000000);

assert_eq!(result, Err(Ok(Error::InsufficientBalance)));
assert_eq!(
client.get_balance(&setup.user, &ReflectorAsset::Stellar).amount,
100_0000000
);
}

#[test]
fn test_withdraw_insufficient_balance() {
let _setup = BalanceTestSetup::new();
let requested = 1_000_000i128;
let available = 100_000i128;
assert!(requested > available);
fn test_withdraw_transfer_failure_does_not_leave_phantom_debit() {
let setup = BalanceTestSetup::new();
let client = setup.client();
let token_client = setup.token_client();

client.deposit(&setup.user, &ReflectorAsset::Stellar, &400_0000000);

token_client.transfer(&setup.contract_id, &setup.sink, &400_0000000);
assert_eq!(token_client.balance(&setup.contract_id), 0);

let result = catch_unwind(AssertUnwindSafe(|| {
client.withdraw(&setup.user, &ReflectorAsset::Stellar, &100_0000000);
}));

assert!(result.is_err());
assert_eq!(
client.get_balance(&setup.user, &ReflectorAsset::Stellar).amount,
400_0000000
);
assert_eq!(token_client.balance(&setup.user), 600_0000000);
assert_eq!(token_client.balance(&setup.contract_id), 0);
}
}
8 changes: 4 additions & 4 deletions contracts/predictify-hybrid/src/category_tags_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ fn setup_test() -> (Env, PredictifyHybridClient<'static>, Address) {
let admin = Address::generate(&env);

// Initialize contract
client.initialize(&admin, &Some(2)); // 2% fee
client.initialize(&admin, &Some(2), &None); // 2% fee

(env, client, admin)
}
Expand All @@ -43,7 +43,7 @@ fn create_test_market(
),
feed_id: String::from_str(env, "BTC/USD"),
threshold: 100,
comparison: String::from_str(env, "gte"),
comparison: String::from_str(env, "gt"),
};

client.create_market(
Expand Down Expand Up @@ -342,7 +342,7 @@ impl TokenTestSetup {

// Initialize the contract
let client = PredictifyHybridClient::new(&env, &contract_id);
client.initialize(&admin, &Some(2));
client.initialize(&admin, &Some(2), &None);

// Create users and fund them
let user1 = Address::generate(&env);
Expand Down Expand Up @@ -376,7 +376,7 @@ impl TokenTestSetup {
),
feed_id: String::from_str(&env, "BTC/USD"),
threshold: 100,
comparison: String::from_str(&env, "gte"),
comparison: String::from_str(&env, "gt"),
},
&None,
&86400u64,
Expand Down
2 changes: 1 addition & 1 deletion contracts/predictify-hybrid/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3068,7 +3068,7 @@ mod tests {

// Test invalid fee configuration
let mut invalid_config = config.clone();
invalid_config.fees.platform_fee_percentage = 15; // Too high
invalid_config.fees.platform_fee_percentage = MAX_PLATFORM_FEE_PERCENTAGE + 1;
assert!(ConfigValidator::validate_contract_config(&invalid_config).is_err());

// Test invalid voting configuration
Expand Down
2 changes: 1 addition & 1 deletion contracts/predictify-hybrid/src/disputes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,7 @@ impl DisputeManager {
market_id: market_id.clone(),
stake,
timestamp: env.ledger().timestamp(),
reason,
reason: reason.clone(),
status: DisputeStatus::Active,
};

Expand Down
40 changes: 19 additions & 21 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ mod market_analytics;
mod market_id_generator;
mod markets;
mod metadata_limits;
mod tokens;
#[cfg(test)]
mod metadata_limits_tests;
#[cfg(test)]
Expand Down Expand Up @@ -307,6 +306,25 @@ impl PredictifyHybrid {
.persistent()
.set(&Symbol::new(&env, "platform_fee"), &fee_percentage);

// Seed default runtime configuration so validators and query paths have
// deterministic bounds immediately after deployment.
let default_config = ConfigManager::get_development_config(&env);
ConfigManager::store_config(&env, &default_config)?;

// Seed permissive-but-valid rate limits so admin entrypoints do not
// fail before a custom policy is configured.
crate::rate_limiter::RateLimiter::new(env.clone()).init_rate_limiter(
admin.clone(),
crate::rate_limiter::RateLimitConfig {
voting_limit: 10_000,
dispute_limit: 1_000,
oracle_call_limit: 1_000,
bet_limit: 10_000,
events_per_admin_limit: 1_000,
time_window_seconds: 3_600,
},
).map_err(Error::from)?;

// Initialize allowed assets
if let Some(assets) = allowed_assets {
// Store custom allowed assets
Expand Down Expand Up @@ -6976,23 +6994,3 @@ impl PredictifyHybrid {

#[cfg(any())]
mod test;

fn assert_can_participate(env: &Env, user: &Address, event: &Event) {
if event.is_private {
let is_allowed = event.allowlist.iter().any(|addr| addr == user);
if !is_allowed {
panic!("User not allowlisted for private event");
}
}
}
let event = get_event(&env, event_id);

assert_can_participate(&env, &user, &event);

/// Places a bet on a given event.
///
/// # Panics
/// - If the event is private and the caller is not in the allowlist.
///
/// # Security
/// Enforces event-level access control before any state mutation.
Loading
Loading