diff --git a/contract/contracts/access-control/src/lib.rs b/contract/contracts/access-control/src/lib.rs index 243413ba..ab68cce5 100644 --- a/contract/contracts/access-control/src/lib.rs +++ b/contract/contracts/access-control/src/lib.rs @@ -59,10 +59,10 @@ impl AccessControl { /// * `admin` - The address to be appointed as the initial super admin. /// /// # Errors - /// * Panics with `"AlreadyInitialized"` if the contract has already been initialized. + /// * Panics with `"AlreadyInitializedOrConfigNotSet"` if the contract has already been initialized. pub fn init(env: Env, admin: Address) { if env.storage().instance().has(&DataKey::Admin) { - soroban_sdk::panic_with_error!(&env, PrediFiError::AlreadyInitialized); + soroban_sdk::panic_with_error!(&env, PrediFiError::AlreadyInitializedOrConfigNotSet); } env.storage().instance().set(&DataKey::Admin, &admin); // Also grant the Admin role to the admin address. @@ -126,7 +126,7 @@ impl AccessControl { /// /// # Errors /// * `Unauthorized` - If the caller is not the super admin. - /// * `RoleNotFound` - If the user doesn't have the specified role. + /// * `InsufficientPermissions` - If the user doesn't have the specified role. pub fn revoke_role( env: Env, admin_caller: Address, @@ -145,7 +145,7 @@ impl AccessControl { .persistent() .has(&DataKey::Role(user.clone(), role.clone())) { - return Err(PrediFiError::RoleNotFound); + return Err(PrediFiError::InsufficientPermissions); } env.storage() @@ -178,7 +178,7 @@ impl AccessControl { /// /// # Errors /// * `Unauthorized` - If the caller is not the super admin. - /// * `RoleNotFound` - If the `from` address doesn't have the specified role. + /// * `InsufficientPermissions` - If the `from` address doesn't have the specified role. pub fn transfer_role( env: Env, admin_caller: Address, @@ -198,7 +198,7 @@ impl AccessControl { .persistent() .has(&DataKey::Role(from.clone(), role.clone())) { - return Err(PrediFiError::RoleNotFound); + return Err(PrediFiError::InsufficientPermissions); } env.storage() diff --git a/contract/contracts/predifi-contract/src/integration_test.rs b/contract/contracts/predifi-contract/src/integration_test.rs index 0fe8f5d9..4eef2bfb 100644 --- a/contract/contracts/predifi-contract/src/integration_test.rs +++ b/contract/contracts/predifi-contract/src/integration_test.rs @@ -2,7 +2,10 @@ use super::*; use crate::test_utils::TokenTestContext; -use soroban_sdk::{testutils::Address as _, Address, Env}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, +}; mod dummy_access_control { use soroban_sdk::{contract, contractimpl, Address, Env, Symbol}; @@ -27,7 +30,9 @@ mod dummy_access_control { const ROLE_ADMIN: u32 = 0; const ROLE_OPERATOR: u32 = 1; -fn setup_integration(env: &Env) -> ( +fn setup_integration( + env: &Env, +) -> ( PredifiContractClient<'static>, TokenTestContext, Address, // Admin @@ -79,7 +84,8 @@ fn test_full_market_lifecycle() { // Total stake = 100 + 200 + 300 = 600 assert_eq!(token_ctx.token.balance(&client.address), 600); - // 3. Resolve Pool + // 3. Resolve Pool (advance time past end_time=1000) + env.ledger().with_mut(|li| li.timestamp = 1001); client.resolve_pool(&operator, &pool_id, &1u32); // Outcome 1 wins // 4. Claim Winnings @@ -110,13 +116,16 @@ fn test_multi_user_betting_and_balance_verification() { let (client, token_ctx, _admin, operator, _treasury) = setup_integration(&env); // 5 users - let users: soroban_sdk::Vec
= soroban_sdk::Vec::from_array(&env, [ - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - ]); + let users: soroban_sdk::Vec
= soroban_sdk::Vec::from_array( + &env, + [ + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ], + ); for user in users.iter() { token_ctx.mint(&user, 5000); @@ -131,7 +140,7 @@ fn test_multi_user_betting_and_balance_verification() { // U3: 1500 on 3 // U4: 500 on 1 // Total 1: 1500, Total 2: 1000, Total 3: 1500. Total Stake: 4000. - + client.place_prediction(&users.get(0).unwrap(), &pool_id, &500, &1); client.place_prediction(&users.get(1).unwrap(), &pool_id, &1000, &2); client.place_prediction(&users.get(2).unwrap(), &pool_id, &500, &1); @@ -140,7 +149,8 @@ fn test_multi_user_betting_and_balance_verification() { assert_eq!(token_ctx.token.balance(&client.address), 4000); - // Resolve to Outcome 3 + // Resolve to Outcome 3 (advance time past end_time=2000) + env.ledger().with_mut(|li| li.timestamp = 2001); client.resolve_pool(&operator, &pool_id, &3u32); // Winner: U3 @@ -179,16 +189,18 @@ fn test_market_resolution_multiple_winners() { // U2: 300 on 1 // U3: 500 on 2 // Total 1: 500, Total 2: 500. Total Stake: 1000. - + client.place_prediction(&user1, &pool_id, &200, &1); client.place_prediction(&user2, &pool_id, &300, &1); client.place_prediction(&user3, &pool_id, &500, &2); + // Advance time past end_time=1500, then resolve + env.ledger().with_mut(|li| li.timestamp = 1501); client.resolve_pool(&operator, &pool_id, &1u32); // Outcome 1 wins // U1 Winnings: (200 / 500) * 1000 = 400 // U2 Winnings: (300 / 500) * 1000 = 600 - + let w1 = client.claim_winnings(&user1, &pool_id); let w2 = client.claim_winnings(&user2, &pool_id); diff --git a/contract/contracts/predifi-contract/src/lib.rs b/contract/contracts/predifi-contract/src/lib.rs index 17a93bf3..2db960c3 100644 --- a/contract/contracts/predifi-contract/src/lib.rs +++ b/contract/contracts/predifi-contract/src/lib.rs @@ -1,5 +1,6 @@ #![no_std] +use predifi_errors::PrediFiError; use soroban_sdk::IntoVal; use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, Symbol, Vec}; @@ -67,22 +68,19 @@ impl PredifiContract { ) } - fn require_role(env: &Env, user: &Address, role: u32) { - let config: Config = env - .storage() - .instance() - .get(&DataKey::Config) - .expect("Config not set"); + 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) { - panic!("Unauthorized: missing required role"); + return Err(PrediFiError::Unauthorized); } + Ok(()) } - fn get_config(env: &Env) -> Config { + fn get_config(env: &Env) -> Result { env.storage() .instance() .get(&DataKey::Config) - .expect("Config not set") + .ok_or(PrediFiError::NotInitialized) } /// Initialize the contract. Idempotent — safe to call multiple times. @@ -99,25 +97,36 @@ 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) { + pub fn set_fee_bps(env: Env, admin: Address, fee_bps: u32) -> Result<(), PrediFiError> { admin.require_auth(); - Self::require_role(&env, &admin, 0); - let mut config = Self::get_config(&env); + Self::require_role(&env, &admin, 0)?; + + if fee_bps > 10000 { + return Err(PrediFiError::InvalidFeeBps); + } + + 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) { + pub fn set_treasury(env: Env, admin: Address, treasury: Address) -> Result<(), PrediFiError> { admin.require_auth(); - Self::require_role(&env, &admin, 0); - let mut config = Self::get_config(&env); + Self::require_role(&env, &admin, 0)?; + 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) -> u64 { + pub fn create_pool(env: Env, end_time: u64, token: Address) -> Result { + if end_time <= env.ledger().timestamp() { + return Err(PrediFiError::TimeConstraintError); + } + let pool_id: u64 = env .storage() .instance() @@ -133,43 +142,74 @@ impl PredifiContract { }; env.storage().instance().set(&DataKey::Pool(pool_id), &pool); - env.storage() - .instance() - .set(&DataKey::PoolIdCounter, &(pool_id + 1)); + env.storage().instance().set( + &DataKey::PoolIdCounter, + &(pool_id + .checked_add(1) + .ok_or(PrediFiError::ArithmeticError)?), + ); - pool_id + Ok(pool_id) } /// Resolve a pool with a winning outcome. Caller must have Operator role (1). - pub fn resolve_pool(env: Env, operator: Address, pool_id: u64, outcome: u32) { + pub fn resolve_pool( + env: Env, + operator: Address, + pool_id: u64, + outcome: u32, + ) -> Result<(), PrediFiError> { operator.require_auth(); - Self::require_role(&env, &operator, 1); + Self::require_role(&env, &operator, 1)?; let mut pool: Pool = env .storage() .instance() .get(&DataKey::Pool(pool_id)) - .expect("Pool not found"); + .ok_or(PrediFiError::PoolNotFound)?; - assert!(!pool.resolved, "Pool already resolved"); + if pool.resolved { + return Err(PrediFiError::PoolAlreadyResolved); + } + + if env.ledger().timestamp() < pool.end_time { + return Err(PrediFiError::PoolExpiryError); + } pool.resolved = true; pool.outcome = outcome; env.storage().instance().set(&DataKey::Pool(pool_id), &pool); + Ok(()) } /// Place a prediction on a pool. - pub fn place_prediction(env: Env, user: Address, pool_id: u64, amount: i128, outcome: u32) { + pub fn place_prediction( + env: Env, + user: Address, + pool_id: u64, + amount: i128, + outcome: u32, + ) -> Result<(), PrediFiError> { user.require_auth(); + if amount <= 0 { + return Err(PrediFiError::InvalidPredictionAmount); + } + let mut pool: Pool = env .storage() .instance() .get(&DataKey::Pool(pool_id)) - .expect("Pool not found"); + .ok_or(PrediFiError::PoolNotFound)?; + + if pool.resolved { + return Err(PrediFiError::PoolAlreadyResolved); + } - assert!(!pool.resolved, "Pool already resolved"); + 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); @@ -182,15 +222,21 @@ impl PredifiContract { ); // Update total pool stake - pool.total_stake = pool.total_stake.checked_add(amount).expect("overflow"); + pool.total_stake = pool + .total_stake + .checked_add(amount) + .ok_or(PrediFiError::ArithmeticError)?; 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, &(current_stake + amount)); + .set(&outcome_key, &new_outcome_stake); // Index prediction for pagination let count: u32 = env @@ -204,25 +250,31 @@ 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) -> i128 { + pub fn claim_winnings(env: Env, user: Address, pool_id: u64) -> Result { user.require_auth(); let pool: Pool = env .storage() .instance() .get(&DataKey::Pool(pool_id)) - .expect("Pool not found"); + .ok_or(PrediFiError::PoolNotFound)?; - assert!(pool.resolved, "Pool not resolved"); - assert!( - !env.storage() - .instance() - .has(&DataKey::HasClaimed(user.clone(), pool_id)), - "Already claimed" - ); + if !pool.resolved { + return Err(PrediFiError::PoolNotResolved); + } + + if env + .storage() + .instance() + .has(&DataKey::HasClaimed(user.clone(), pool_id)) + { + return Err(PrediFiError::AlreadyClaimed); + } // Mark as claimed immediately to prevent re-entrancy env.storage() @@ -237,11 +289,11 @@ impl PredifiContract { let prediction = match prediction { Some(p) => p, - None => return 0, + None => return Ok(0), }; if prediction.outcome != pool.outcome { - return 0; + return Ok(0); } // Share = (user_stake / winning_stake) * total_pool @@ -252,20 +304,20 @@ impl PredifiContract { .unwrap_or(0); if winning_stake == 0 { - return 0; + return Ok(0); } let winnings = prediction .amount .checked_mul(pool.total_stake) - .expect("overflow") + .ok_or(PrediFiError::ArithmeticError)? .checked_div(winning_stake) - .expect("division by zero"); + .ok_or(PrediFiError::ArithmeticError)?; let token_client = token::Client::new(&env, &pool.token); token_client.transfer(&env.current_contract_address(), &user, &winnings); - winnings + Ok(winnings) } /// Get a paginated list of a user's predictions. @@ -323,6 +375,6 @@ impl PredifiContract { } } +mod integration_test; mod test; mod test_utils; -mod integration_test; diff --git a/contract/contracts/predifi-contract/src/test.rs b/contract/contracts/predifi-contract/src/test.rs index 0301cda4..26f5bd63 100644 --- a/contract/contracts/predifi-contract/src/test.rs +++ b/contract/contracts/predifi-contract/src/test.rs @@ -2,7 +2,10 @@ #![allow(deprecated)] use super::*; -use soroban_sdk::{testutils::Address as _, token, Address, Env}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token, Address, Env, +}; mod dummy_access_control { use soroban_sdk::{contract, contractimpl, Address, Env, Symbol}; @@ -73,9 +76,9 @@ fn test_claim_winnings() { let env = Env::default(); env.mock_all_auths(); - let (_, client, token_address, _token, token_admin_client, _, operator) = setup(&env); + let (_, client, token_address, token, token_admin_client, _, operator) = setup(&env); let _contract_id = env.register(PredifiContract, ()); // get contract address for balance check - // Re-derive contract address from client + // Re-derive contract address from client let contract_addr = client.address.clone(); let user1 = Address::generate(&env); @@ -90,6 +93,9 @@ fn test_claim_winnings() { assert_eq!(token.balance(&contract_addr), 200); + // Advance time past pool end_time + env.ledger().with_mut(|li| li.timestamp = 101); + client.resolve_pool(&operator, &pool_id, &1u32); // User1 wins: (100 / 100) * 200 = 200 @@ -104,7 +110,7 @@ fn test_claim_winnings() { } #[test] -#[should_panic(expected = "Already claimed")] +#[should_panic(expected = "Error(Contract, #60)")] fn test_double_claim() { let env = Env::default(); env.mock_all_auths(); @@ -116,6 +122,10 @@ fn test_double_claim() { let pool_id = client.create_pool(&100u64, &token_address); client.place_prediction(&user1, &pool_id, &100, &1); + + // Advance time past pool end_time + env.ledger().with_mut(|li| li.timestamp = 101); + client.resolve_pool(&operator, &pool_id, &1u32); client.claim_winnings(&user1, &pool_id); @@ -123,7 +133,7 @@ fn test_double_claim() { } #[test] -#[should_panic(expected = "Pool not resolved")] +#[should_panic(expected = "Error(Contract, #22)")] fn test_claim_unresolved() { let env = Env::default(); env.mock_all_auths(); @@ -141,7 +151,7 @@ fn test_claim_unresolved() { } #[test] -#[should_panic(expected = "Unauthorized: missing required role")] +#[should_panic(expected = "Error(Contract, #10)")] fn test_unauthorized_set_fee_bps() { let env = Env::default(); env.mock_all_auths(); @@ -153,7 +163,7 @@ fn test_unauthorized_set_fee_bps() { } #[test] -#[should_panic(expected = "Unauthorized: missing required role")] +#[should_panic(expected = "Error(Contract, #10)")] fn test_unauthorized_set_treasury() { let env = Env::default(); env.mock_all_auths(); @@ -166,7 +176,7 @@ fn test_unauthorized_set_treasury() { } #[test] -#[should_panic(expected = "Unauthorized: missing required role")] +#[should_panic(expected = "Error(Contract, #10)")] fn test_unauthorized_resolve_pool() { let env = Env::default(); env.mock_all_auths(); @@ -275,7 +285,12 @@ fn test_multiple_pools_independent() { client.place_prediction(&user1, &pool_a, &100, &1); client.place_prediction(&user2, &pool_b, &100, &1); + // Advance time past pool_a end_time + env.ledger().with_mut(|li| li.timestamp = 101); client.resolve_pool(&operator, &pool_a, &1u32); + + // Advance time past pool_b end_time + env.ledger().with_mut(|li| li.timestamp = 201); client.resolve_pool(&operator, &pool_b, &2u32); // user2 loses let w1 = client.claim_winnings(&user1, &pool_a); diff --git a/contract/contracts/predifi-contract/src/test_utils.rs b/contract/contracts/predifi-contract/src/test_utils.rs index 1dfc52b2..baa92eac 100644 --- a/contract/contracts/predifi-contract/src/test_utils.rs +++ b/contract/contracts/predifi-contract/src/test_utils.rs @@ -11,11 +11,12 @@ pub struct TokenTestContext { impl TokenTestContext { pub fn deploy(env: &Env, admin: &Address) -> Self { let token_contract = env.register_stellar_asset_contract_v2(admin.clone()); - let token = token::Client::new(env, &token_contract); - let token_admin = token::StellarAssetClient::new(env, &token_contract); + let token_address = token_contract.address(); + let token = token::Client::new(env, &token_address); + let token_admin = token::StellarAssetClient::new(env, &token_address); Self { - token_address: token_contract, + token_address, token, admin: token_admin, } diff --git a/contract/contracts/predifi-contract/test_snapshots/test/test_claim_winnings.1.json b/contract/contracts/predifi-contract/test_snapshots/test/test_claim_winnings.1.json index 51e3380b..0e18a717 100644 --- a/contract/contracts/predifi-contract/test_snapshots/test/test_claim_winnings.1.json +++ b/contract/contracts/predifi-contract/test_snapshots/test/test_claim_winnings.1.json @@ -248,7 +248,7 @@ "ledger": { "protocol_version": 23, "sequence_number": 0, - "timestamp": 0, + "timestamp": 101, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", "base_reserve": 0, "min_persistent_entry_ttl": 4096, diff --git a/contract/contracts/predifi-contract/test_snapshots/test/test_double_claim.1.json b/contract/contracts/predifi-contract/test_snapshots/test/test_double_claim.1.json index 837d247c..7fb65f68 100644 --- a/contract/contracts/predifi-contract/test_snapshots/test/test_double_claim.1.json +++ b/contract/contracts/predifi-contract/test_snapshots/test/test_double_claim.1.json @@ -152,7 +152,7 @@ "ledger": { "protocol_version": 23, "sequence_number": 0, - "timestamp": 0, + "timestamp": 101, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", "base_reserve": 0, "min_persistent_entry_ttl": 4096, diff --git a/contract/contracts/predifi-errors/README.md b/contract/contracts/predifi-errors/README.md new file mode 100644 index 00000000..20a5d4c9 --- /dev/null +++ b/contract/contracts/predifi-errors/README.md @@ -0,0 +1,226 @@ +# PrediFi Errors + +A comprehensive error handling crate for PrediFi smart contracts on Soroban. + +## Overview + +This crate provides a well-structured error system with granular error codes, categorization, and frontend-friendly utilities. The error codes use gap-based numbering to allow future additions without breaking existing client-side error mappings. + +## Features + +- **95+ Specific Error Variants**: Covering all aspects of the prediction market protocol +- **Gap-Based Numbering**: Error codes organized in ranges (e.g., 1-5, 10-15) for future extensibility +- **Error Categorization**: Logical grouping of errors for analytics and debugging +- **Recoverability Detection**: Distinguish between user-fixable and system errors +- **Display Trait**: Human-readable error messages for logging +- **Frontend Integration**: Easy error code extraction for UI error handling + +## Error Categories + +| Range | Category | Description | +|-------|----------|-------------| +| 1-5 | Initialization | Contract setup and configuration | +| 10-15 | Authorization | Access control and permissions | +| 20-30 | Pool State | Pool lifecycle management | +| 40-50 | Prediction | Betting and prediction placement | +| 60-70 | Claiming | Reward claiming | +| 80-85 | Timestamp | Time validation | +| 90-100 | Validation | General data validation | +| 110-118 | Arithmetic | Mathematical operations | +| 120-129 | Storage | Data persistence | +| 130-145 | Granular Validation | Specific input validation | +| 150-159 | Token | Token transfers and interactions | +| 160-169 | Oracle | Oracle and resolution | +| 170-179 | Reward | Reward calculations | +| 180-189 | Admin | Emergency and admin operations | +| 190-199 | Rate Limiting | Spam prevention | + +## Usage + +### Basic Error Handling + +```rust +use predifi_errors::PrediFiError; + +fn validate_pool_state(pool: &Pool) -> Result<(), PrediFiError> { + if pool.is_resolved { + return Err(PrediFiError::PoolAlreadyResolved); + } + + if pool.end_time < env.ledger().timestamp() { + return Err(PrediFiError::PoolExpired); + } + + Ok(()) +} +``` + +### Error Metadata + +```rust +let error = PrediFiError::InvalidPredictionAmount; + +// Get numeric error code (for frontend) +let code = error.code(); // 42 + +// Get error category (for analytics) +let category = error.category(); // "prediction" + +// Get human-readable message (for logging) +let message = error.as_str(); // "Invalid prediction amount" + +// Check if user can recover (for UI feedback) +let recoverable = error.is_recoverable(); // true +``` + +### Frontend Integration + +```typescript +// Example TypeScript error handling +interface ErrorResponse { + code: number; + category: string; + message: string; + recoverable: boolean; +} + +function handleContractError(errorCode: number): string { + const errorMap: Record = { + 42: "Please enter a valid prediction amount", + 43: "The pool has closed for predictions", + 44: "Insufficient balance to place this prediction", + // ... more mappings + }; + + return errorMap[errorCode] || "An unexpected error occurred"; +} +``` + +## Error Handling Best Practices + +### 1. Use Specific Errors + +```rust +// ❌ Too generic +return Err(PrediFiError::InvalidData); + +// ✅ Specific and actionable +return Err(PrediFiError::InvalidPredictionAmount); +``` + +### 2. Validate Early + +```rust +pub fn place_prediction( + env: Env, + user: Address, + pool_id: u64, + outcome: u32, + amount: i128, +) -> Result<(), PrediFiError> { + // Validate inputs first + if amount <= 0 { + return Err(PrediFiError::AmountIsZero); + } + + // Then check state + let pool = get_pool(&env, pool_id)?; + if pool.is_resolved { + return Err(PrediFiError::PoolAlreadyResolved); + } + + // Finally perform operation + // ... +} +``` + +### 3. Handle Arithmetic Safely + +```rust +// Use checked arithmetic and return specific errors +let total = stake_a + .checked_add(stake_b) + .ok_or(PrediFiError::AdditionOverflow)?; + +let fee = amount + .checked_mul(fee_bps as i128) + .ok_or(PrediFiError::MultiplicationOverflow)? + .checked_div(10000) + .ok_or(PrediFiError::DivisionByZero)?; +``` + +### 4. Maintain State Consistency + +```rust +// Check for state inconsistencies +if pool.total_stake != pool.outcome_stakes.iter().sum() { + return Err(PrediFiError::StakeInconsistency); +} +``` + +## Adding New Errors + +When adding new error variants: + +1. Choose an appropriate range based on the error category +2. Use the next available number in that range +3. Add a descriptive doc comment +4. Update the `as_str()` method with a clear message +5. Update the `category()` method if needed +6. Consider if the error is recoverable and update `is_recoverable()` if needed + +Example: + +```rust +pub enum PrediFiError { + // ... existing errors ... + + // -- New Category (200-209) ---------------------------------------- + /// Description of the new error. + NewErrorVariant = 200, +} + +impl PrediFiError { + pub fn as_str(&self) -> &'static str { + match self { + // ... existing matches ... + Self::NewErrorVariant => "Clear error message", + } + } + + pub const fn category(&self) -> &'static str { + match self { + // ... existing matches ... + Self::NewErrorVariant => "new_category", + } + } +} +``` + +## Testing + +```rust +#[test] +fn test_error_codes() { + assert_eq!(PrediFiError::NotInitialized.code(), 1); + assert_eq!(PrediFiError::Unauthorized.code(), 10); + assert_eq!(PrediFiError::PoolNotFound.code(), 20); +} + +#[test] +fn test_error_categories() { + assert_eq!(PrediFiError::NotInitialized.category(), "initialization"); + assert_eq!(PrediFiError::Unauthorized.category(), "authorization"); + assert_eq!(PrediFiError::ArithmeticOverflow.category(), "arithmetic"); +} + +#[test] +fn test_error_recoverability() { + assert!(!PrediFiError::StorageCorrupted.is_recoverable()); + assert!(PrediFiError::InvalidPredictionAmount.is_recoverable()); +} +``` + +## License + +This crate is part of the PrediFi project. diff --git a/contract/contracts/predifi-errors/src/errors.rs b/contract/contracts/predifi-errors/src/errors.rs index 7b9f0b3e..009fa849 100644 --- a/contract/contracts/predifi-errors/src/errors.rs +++ b/contract/contracts/predifi-errors/src/errors.rs @@ -4,45 +4,43 @@ use soroban_sdk::contracterror; /// The error type covers all cases across Predifi contracts. /// Gap-based numbering allows future error codes to be added without /// renumbering existing ones or breaking client-side mappings. +/// +/// Note: Soroban limits the number of error variants to 32. +/// This enum is optimized to stay within that limit while providing +/// comprehensive error coverage through consolidated error variants. #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] pub enum PrediFiError { - // ── Initialization & Configuration (1–5) ───────────────────────────────── + // -- Initialization & Configuration (1-2) ---------------------------------- /// Contract has not been initialized yet. NotInitialized = 1, - /// Contract has already been initialized. - AlreadyInitialized = 2, + /// Contract has already been initialized or config not set. + AlreadyInitializedOrConfigNotSet = 2, - // ── Authorization & Access Control (10–15) ──────────────────────────────── + // -- Authorization & Access Control (10-11) ------------------------------- /// The caller is not authorized to perform this action. Unauthorized = 10, - /// The specified role was not found. - RoleNotFound = 11, - /// The caller does not have the required permissions. - InsufficientPermissions = 12, + /// The specified role was not found or insufficient permissions. + InsufficientPermissions = 11, - // ── Pool State (20–30) ──────────────────────────────────────────────────── + // -- Pool State (20-26) --------------------------------------------------- /// The specified pool was not found. PoolNotFound = 20, /// The pool has already been resolved. PoolAlreadyResolved = 21, /// The pool has not been resolved yet. PoolNotResolved = 22, - /// The pool has already expired. - PoolExpired = 23, - /// The pool has not expired yet. - PoolNotExpired = 24, + /// The pool expiry state is invalid for this operation. + PoolExpiryError = 23, /// The pool is not in a valid state for this operation. - InvalidPoolState = 25, - /// The outcome value is invalid. - InvalidOutcome = 26, - /// The resolution window has expired (too late to resolve). - ResolutionWindowExpired = 27, - /// The number of options provided is invalid. - InvalidOptionsCount = 28, - - // ── Prediction & Betting (40–50) ────────────────────────────────────────── + InvalidPoolState = 24, + /// The outcome value is invalid or out of bounds. + InvalidOutcome = 25, + /// State inconsistency or invalid options count detected. + StateError = 26, + + // -- Prediction & Betting (40-44) ----------------------------------------- /// The user has no prediction for this pool. PredictionNotFound = 40, /// The user has already placed a prediction on this pool. @@ -51,108 +49,212 @@ pub enum PrediFiError { InvalidPredictionAmount = 42, /// Cannot place prediction after pool end time. PredictionTooLate = 43, - /// The user has insufficient balance for this prediction. - InsufficientBalance = 44, + /// The user has insufficient balance or stake limit violation. + InsufficientBalanceOrStakeLimit = 44, - // ── Claiming & Reward (60–70) ───────────────────────────────────────────── + // -- Claiming & Reward (60-62) -------------------------------------------- /// The user has already claimed winnings for this pool. AlreadyClaimed = 60, /// The user did not win this pool. NotAWinner = 61, - /// Critical error: winning stake is zero but should not be. - WinningStakeZero = 62, + /// Reward calculation failed or payout exceeds pool balance. + RewardError = 62, - // ── Timestamp & Time Validation (80–85) ─────────────────────────────────── - /// The provided timestamp is invalid. + // -- Timestamp & Time Validation (80-81) ---------------------------------- + /// The provided timestamp is invalid or time constraints not met. InvalidTimestamp = 80, - /// The end time must be in the future. - EndTimeMustBeFuture = 81, - /// The end time is too far in the future. - EndTimeTooFar = 82, + /// The end time or resolution time constraints are not met. + TimeConstraintError = 81, - // ── Data & Validation (90–100) ──────────────────────────────────────────── + // -- Data & Validation (90-94) ------------------------------------------- /// The provided data is invalid. InvalidData = 90, - /// The provided address is invalid. - InvalidAddress = 91, - /// The provided token address is invalid. - InvalidToken = 92, - /// The pagination offset is out of bounds. - InvalidOffset = 93, - /// The pagination limit is invalid (e.g., zero or too large). - InvalidLimit = 94, - - // ── Arithmetic & Calculation (110–115) ──────────────────────────────────── - /// An arithmetic overflow occurred. - ArithmeticOverflow = 110, - /// An arithmetic underflow occurred. - ArithmeticUnderflow = 111, - /// Division by zero attempted. - DivisionByZero = 112, - - // ── Storage & State (120–125) ───────────────────────────────────────────── - /// The storage key was not found. - StorageKeyNotFound = 120, - /// Storage is corrupted or in an invalid state. - StorageCorrupted = 121, + /// The provided address or token is invalid. + InvalidAddressOrToken = 91, + /// The pagination offset or limit is invalid. + InvalidPagination = 92, + /// The fee basis points exceed the maximum allowed value (10000). + InvalidFeeBps = 93, + /// Metadata, label, or duplicate labels error. + MetadataError = 94, + + // -- Arithmetic & Calculation (110-111) ------------------------------------ + /// An arithmetic overflow, underflow, or division by zero occurred. + ArithmeticError = 110, + /// The calculated fee exceeds the total amount. + FeeExceedsAmount = 111, + + // -- Storage & State (120-122) --------------------------------------------- + /// The storage key was not found or storage is corrupted. + StorageError = 120, + /// The pool's total stake or index is inconsistent. + ConsistencyError = 121, + /// A balance mismatch was detected in the contract account. + BalanceMismatch = 122, + + // -- Token & Transfer (150-151) -------------------------------------------- + /// Token transfer, approval, or contract call failed. + TokenError = 150, + /// Withdrawal or treasury transfer failed. + WithdrawalOrTreasuryError = 151, + + // -- Oracle & Resolution (160-161) ----------------------------------------- + /// Oracle error or stale data detected. + OracleError = 160, + /// Resolution error or unauthorized resolver. + ResolutionError = 161, + + // -- Emergency & Admin (180) ----------------------------------------------- + /// Contract pause, emergency, version, or upgrade error. + AdminError = 180, + + // -- Rate Limiting & Spam Prevention (190) --------------------------------- + /// Rate limit exceeded, cooldown not elapsed, or suspicious activity. + RateLimitOrSuspiciousActivity = 190, } impl PrediFiError { + /// Returns the numeric error code for this error. + /// Useful for frontend error handling and logging. + pub const fn code(&self) -> u32 { + *self as u32 + } + + /// Returns the error category as a string. + /// Useful for grouping errors in logs and analytics. + pub const fn category(&self) -> &'static str { + match self { + Self::NotInitialized | Self::AlreadyInitializedOrConfigNotSet => "initialization", + Self::Unauthorized | Self::InsufficientPermissions => "authorization", + Self::PoolNotFound + | Self::PoolAlreadyResolved + | Self::PoolNotResolved + | Self::PoolExpiryError + | Self::InvalidPoolState + | Self::InvalidOutcome + | Self::StateError => "pool_state", + Self::PredictionNotFound + | Self::PredictionAlreadyExists + | Self::InvalidPredictionAmount + | Self::PredictionTooLate + | Self::InsufficientBalanceOrStakeLimit => "prediction", + Self::AlreadyClaimed | Self::NotAWinner | Self::RewardError => "claiming", + Self::InvalidTimestamp | Self::TimeConstraintError => "timestamp", + Self::InvalidData + | Self::InvalidAddressOrToken + | Self::InvalidPagination + | Self::InvalidFeeBps + | Self::MetadataError => "validation", + Self::ArithmeticError | Self::FeeExceedsAmount => "arithmetic", + Self::StorageError | Self::ConsistencyError | Self::BalanceMismatch => "storage", + Self::TokenError | Self::WithdrawalOrTreasuryError => "token", + Self::OracleError | Self::ResolutionError => "oracle", + Self::AdminError => "admin", + Self::RateLimitOrSuspiciousActivity => "rate_limiting", + } + } + + /// Returns whether this error is recoverable by the user. + /// Non-recoverable errors typically indicate system issues or bugs. + pub const fn is_recoverable(&self) -> bool { + match self { + // Non-recoverable: system/contract issues + Self::NotInitialized + | Self::AlreadyInitializedOrConfigNotSet + | Self::StorageError + | Self::ConsistencyError + | Self::BalanceMismatch + | Self::RewardError + | Self::StateError + | Self::AdminError => false, + // Recoverable: user can fix by changing input or waiting + _ => true, + } + } + /// Returns a human-readable description of the error. pub fn as_str(&self) -> &'static str { match self { // Initialization & Configuration - PrediFiError::NotInitialized => "Contract not initialized", - PrediFiError::AlreadyInitialized => "Contract already initialized", + Self::NotInitialized => "Contract not initialized", + Self::AlreadyInitializedOrConfigNotSet => { + "Contract already initialized or treasury/access control not set" + } // Authorization & Access Control - PrediFiError::Unauthorized => "Unauthorized access", - PrediFiError::RoleNotFound => "Role not found", - PrediFiError::InsufficientPermissions => "Insufficient permissions", + Self::Unauthorized => "Unauthorized access", + Self::InsufficientPermissions => "Role not found or insufficient permissions", // Pool State - PrediFiError::PoolNotFound => "Pool not found", - PrediFiError::PoolAlreadyResolved => "Pool already resolved", - PrediFiError::PoolNotResolved => "Pool not resolved", - PrediFiError::PoolExpired => "Pool has expired", - PrediFiError::PoolNotExpired => "Pool has not expired", - PrediFiError::InvalidPoolState => "Invalid pool state", - PrediFiError::InvalidOutcome => "Invalid outcome", - PrediFiError::ResolutionWindowExpired => "Resolution window has expired", - PrediFiError::InvalidOptionsCount => "Invalid options count", + Self::PoolNotFound => "Pool not found", + Self::PoolAlreadyResolved => "Pool already resolved", + Self::PoolNotResolved => "Pool not resolved", + Self::PoolExpiryError => "Pool expiry state is invalid for this operation", + Self::InvalidPoolState => "Invalid pool state", + Self::InvalidOutcome => "Invalid outcome or outcome index out of bounds", + Self::StateError => "State inconsistency or invalid options count detected", // Prediction & Betting - PrediFiError::PredictionNotFound => "Prediction not found", - PrediFiError::PredictionAlreadyExists => "Prediction already exists", - PrediFiError::InvalidPredictionAmount => "Invalid prediction amount", - PrediFiError::PredictionTooLate => "Cannot predict after pool end time", - PrediFiError::InsufficientBalance => "Insufficient balance", + Self::PredictionNotFound => "Prediction not found", + Self::PredictionAlreadyExists => "Prediction already exists", + Self::InvalidPredictionAmount => { + "Invalid prediction amount (zero, negative, or invalid)" + } + Self::PredictionTooLate => "Cannot predict after pool end time", + Self::InsufficientBalanceOrStakeLimit => { + "Insufficient balance or stake below minimum/exceeds maximum" + } // Claiming & Rewards - PrediFiError::AlreadyClaimed => "Already claimed", - PrediFiError::NotAWinner => "User did not win", - PrediFiError::WinningStakeZero => "Critical: winning stake is zero", + Self::AlreadyClaimed => "Already claimed", + Self::NotAWinner => "User did not win", + Self::RewardError => { + "Reward calculation failed, winning stake is zero, or payout exceeds pool" + } // Timestamp & Time Validation - PrediFiError::InvalidTimestamp => "Invalid timestamp", - PrediFiError::EndTimeMustBeFuture => "End time must be in the future", - PrediFiError::EndTimeTooFar => "End time too far in the future", + Self::InvalidTimestamp => "Invalid timestamp or time constraints not met", + Self::TimeConstraintError => "End time or resolution time constraints are not met", // Data & Validation - PrediFiError::InvalidData => "Invalid data", - PrediFiError::InvalidAddress => "Invalid address", - PrediFiError::InvalidToken => "Invalid token", - PrediFiError::InvalidOffset => "Invalid offset", - PrediFiError::InvalidLimit => "Invalid limit", + Self::InvalidData => "Invalid data", + Self::InvalidAddressOrToken => "Invalid address or token", + Self::InvalidPagination => "Invalid pagination offset or limit", + Self::InvalidFeeBps => "Invalid fee basis points (max 10000)", + Self::MetadataError => "Metadata, label invalid/too long, or duplicate labels detected", // Arithmetic & Calculation - PrediFiError::ArithmeticOverflow => "Arithmetic overflow", - PrediFiError::ArithmeticUnderflow => "Arithmetic underflow", - PrediFiError::DivisionByZero => "Division by zero", + Self::ArithmeticError => "Arithmetic overflow, underflow, or division by zero", + Self::FeeExceedsAmount => "Calculated fee exceeds total amount", // Storage & State - PrediFiError::StorageKeyNotFound => "Storage key not found", - PrediFiError::StorageCorrupted => "Storage corrupted", + Self::StorageError => "Storage key not found or storage corrupted", + Self::ConsistencyError => "Pool stake or index inconsistency detected", + Self::BalanceMismatch => "Contract balance mismatch", + + // Token & Transfer + Self::TokenError => "Token transfer, approval, or contract call failed", + Self::WithdrawalOrTreasuryError => "Withdrawal or treasury transfer failed", + + // Oracle & Resolution + Self::OracleError => "Oracle not set, invalid response, or stale data", + Self::ResolutionError => { + "Resolution error, duplicate attempt, data mismatch, or unauthorized resolver" + } + + // Emergency & Admin + Self::AdminError => "Contract pause, emergency, version mismatch, or upgrade error", + + // Rate Limiting & Spam Prevention + Self::RateLimitOrSuspiciousActivity => { + "Rate limit exceeded, cooldown not elapsed, or suspicious activity" + } } } } + +impl core::fmt::Display for PrediFiError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} diff --git a/contract/contracts/predifi-errors/src/lib.rs b/contract/contracts/predifi-errors/src/lib.rs index 95d2742c..e9481021 100644 --- a/contract/contracts/predifi-errors/src/lib.rs +++ b/contract/contracts/predifi-errors/src/lib.rs @@ -1,5 +1,57 @@ #![no_std] +//! # PrediFi Errors +//! +//! This crate provides a comprehensive error handling system for PrediFi smart contracts. +//! +//! ## Features +//! +//! - **Granular Error Codes**: Specific error variants for validation failures, arithmetic +//! overflows, state inconsistencies, and more +//! - **Gap-Based Numbering**: Error codes are organized in ranges (e.g., 1-5 for initialization, +//! 10-15 for authorization) allowing future additions without breaking existing mappings +//! - **Error Categorization**: Errors are grouped into logical categories for better organization +//! - **Frontend-Friendly**: Includes helper methods for error codes, categories, and recoverability +//! - **Display Implementation**: Human-readable error messages for logging and debugging +//! +//! ## Error Categories +//! +//! - **Initialization (1-5)**: Contract setup and configuration errors +//! - **Authorization (10-15)**: Access control and permission errors +//! - **Pool State (20-30)**: Pool lifecycle and state management errors +//! - **Prediction (40-50)**: Betting and prediction placement errors +//! - **Claiming (60-70)**: Reward claiming errors +//! - **Timestamp (80-85)**: Time validation errors +//! - **Validation (90-100)**: General data validation errors +//! - **Arithmetic (110-118)**: Mathematical operation errors +//! - **Storage (120-129)**: Data persistence and consistency errors +//! - **Granular Validation (130-145)**: Specific input validation errors +//! - **Token (150-159)**: Token transfer and interaction errors +//! - **Oracle (160-169)**: Oracle and resolution errors +//! - **Reward (170-179)**: Reward calculation errors +//! - **Admin (180-189)**: Emergency and administrative errors +//! - **Rate Limiting (190-199)**: Spam prevention errors +//! +//! ## Usage Example +//! +//! ```rust,ignore +//! use predifi_errors::PrediFiError; +//! +//! fn validate_amount(amount: i128) -> Result<(), PrediFiError> { +//! if amount <= 0 { +//! return Err(PrediFiError::AmountIsZero); +//! } +//! Ok(()) +//! } +//! +//! // Get error details +//! let error = PrediFiError::AmountIsZero; +//! let code = error.code(); // 130 +//! let category = error.category(); // "granular_validation" +//! let message = error.as_str(); // "Amount cannot be zero" +//! let recoverable = error.is_recoverable(); // true +//! ``` + pub mod errors; pub use errors::PrediFiError;