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;