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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion contracts/predictify-hybrid/src/audit_trail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ pub enum AuditAction {
FeesWithdrawn,
FeeConfigUpdated,

// Oracle & Config Actions
// Token & Oracle Actions
OracleConfigUpdated,
TokenVerified,
BetLimitsUpdated,

// Resolution & Disputes
Expand Down
165 changes: 150 additions & 15 deletions contracts/predictify-hybrid/src/circuit_breaker.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use soroban_sdk::{contracttype, Address, Env, Map, String, Symbol, Vec};
use soroban_sdk::Storage;

use crate::admin::AdminAccessControl;
use crate::errors::Error;
Expand Down Expand Up @@ -50,6 +51,14 @@ pub struct CircuitBreakerConfig {
pub recovery_timeout: u64, // Time to wait before attempting recovery
pub half_open_max_requests: u32, // Max requests in half-open state
pub auto_recovery_enabled: bool, // Whether to auto-recover
pub half_open_quota: HalfOpenQuota, // Quota-based scheduler for half-open
}

#[derive(Clone, Debug)]
#[contracttype]
pub struct HalfOpenQuota {
pub calls_per_minute: u32,
pub evaluation_window_s: u64,
}

#[derive(Clone, Debug)]
Expand All @@ -71,6 +80,22 @@ pub struct CircuitBreakerState {
pub half_open_since: u64,
}

// Temporary tracking for half-open admission window
#[derive(Clone, Debug)]
#[contracttype]
pub struct HalfOpenWindow {
pub admitted: u32,
pub completed: u32,
pub failures: u32,
pub window_start: u64,
}

#[contracttype]
#[derive(Clone, Debug)]
pub enum CircuitBreakerTempData {
HalfOpenWindow,
}

#[derive(Clone, Debug, PartialEq, Eq)]
#[contracttype]
pub enum PauseScope {
Expand Down Expand Up @@ -132,6 +157,7 @@ impl CircuitBreaker {
recovery_timeout: 300, // 5 minutes recovery timeout
half_open_max_requests: 3, // 3 requests in half-open state
auto_recovery_enabled: true, // Enable auto-recovery
half_open_quota: HalfOpenQuota { calls_per_minute: 3, evaluation_window_s: 60 },
};

let state = CircuitBreakerState {
Expand Down Expand Up @@ -220,6 +246,11 @@ impl CircuitBreaker {
env.storage()
.instance()
.set(&Symbol::new(env, Self::STATE_KEY), state);
// When closing or opening, clear any temporary half-open window tracking
if state.state != BreakerState::HalfOpen {
let key = CircuitBreakerTempData::HalfOpenWindow;
env.storage().temporary().set(&key, &HalfOpenWindow { admitted: 0, completed: 0, failures: 0, window_start: 0 });
}
Ok(())
}

Expand Down Expand Up @@ -310,8 +341,15 @@ impl CircuitBreaker {
}
},
BreakerState::HalfOpen => {
// Use quota-based admission for half-open state where configured.
let config = Self::get_config(env)?;
Ok(state.half_open_requests < config.half_open_max_requests)
if config.half_open_quota.calls_per_minute > 0 {
// Attempt to admit the call (this will increment temporary counters)
let admitted = Self::half_open_admit(env, &config)?;
Ok(admitted)
} else {
Ok(state.half_open_requests < config.half_open_max_requests)
}
}
}
}
Expand Down Expand Up @@ -456,6 +494,68 @@ impl CircuitBreaker {
Ok(false)
}

/// Admission helper for half-open quota scheduling.
/// Returns Ok(true) if the call is admitted, Ok(false) if not admitted (and state may transition).
fn half_open_admit(env: &Env, config: &CircuitBreakerConfig) -> Result<bool, Error> {
let current_time = env.ledger().timestamp();
let key = CircuitBreakerTempData::HalfOpenWindow;

let mut window: HalfOpenWindow = env.storage().temporary().get(&key).unwrap_or(HalfOpenWindow {
admitted: 0,
completed: 0,
failures: 0,
window_start: current_time,
});

// Reset window if expired
if current_time >= window.window_start.saturating_add(config.half_open_quota.evaluation_window_s) {
window.admitted = 0;
window.completed = 0;
window.failures = 0;
window.window_start = current_time;
}

if window.admitted < config.half_open_quota.calls_per_minute {
// Admit this call (count admitted but decision occurs after completion)
window.admitted = window.admitted.saturating_add(1);
env.storage().temporary().set(&key, &window);
env.storage().temporary().extend_ttl(&key, config.half_open_quota.evaluation_window_s as u32 + 86400, config.half_open_quota.evaluation_window_s as u32 + 86400);
return Ok(true);
}

// Quota already admitted for this window; decide based on failures recorded
let mut state = Self::get_state(env)?;
if window.failures == 0 {
// No failures among admitted calls -> close
state.state = BreakerState::Closed;
state.failure_count = 0;
state.half_open_requests = 0;
Self::update_state(env, &state)?;
let _ = Self::emit_circuit_breaker_event(
env,
BreakerAction::Resume,
BreakerCondition::ManualOverride,
&String::from_str(env, "Quota exhausted with no failures: closing circuit"),
None,
);
return Ok(false);
} else {
// There were failures -> reopen
state.state = BreakerState::Open;
state.opened_time = current_time;
state.half_open_requests = 0;
Self::update_state(env, &state)?;
let _ = Self::emit_circuit_breaker_event(
env,
BreakerAction::Trigger,
BreakerCondition::HighErrorRate,
&String::from_str(env, "Quota exhausted with failures: reopening circuit"),
None,
);
return Ok(false);
}
}

// ===== RECOVERY MECHANISMS =====

/// Circuit breaker recovery by admin
Expand Down Expand Up @@ -566,19 +666,33 @@ impl CircuitBreaker {
state.half_open_requests = 0;
state.half_open_since = 0;

let _ = Self::emit_circuit_breaker_event(
env,
BreakerAction::Resume,
BreakerCondition::ManualOverride,
&String::from_str(env, "Auto-recovery: circuit breaker closed"),
None,
);
crate::monitoring::ContractMonitor::emit_pause_transition_hook(
env,
&String::from_str(env, "unpaused"),
None,
&String::from_str(env, "auto_recovery"),
);
// Persist updated window
env.storage().temporary().set(&key, &window);
env.storage().temporary().extend_ttl(&key, config.half_open_quota.evaluation_window_s as u32 + 86400, config.half_open_quota.evaluation_window_s as u32 + 86400);
} else {
// Backwards compatible path
state.half_open_requests += 1;

let config = Self::get_config(env)?;
if state.half_open_requests >= config.half_open_max_requests {
state.state = BreakerState::Closed;
state.failure_count = 0;
state.half_open_requests = 0;

let _ = Self::emit_circuit_breaker_event(
env,
BreakerAction::Resume,
BreakerCondition::ManualOverride,
&String::from_str(env, "Auto-recovery: circuit breaker closed"),
None,
);
crate::monitoring::ContractMonitor::emit_pause_transition_hook(
env,
&String::from_str(env, "unpaused"),
None,
&String::from_str(env, "auto_recovery"),
);
}
}
}

Expand All @@ -597,6 +711,13 @@ impl CircuitBreaker {

// If in half-open state, open the circuit breaker
if state.state == BreakerState::HalfOpen {
// Record failure in temporary half-open window if present
let key = CircuitBreakerTempData::HalfOpenWindow;
let mut window: HalfOpenWindow = env.storage().temporary().get(&key).unwrap_or(HalfOpenWindow { admitted: 0, completed: 0, failures: 0, window_start: current_time });
window.failures = window.failures.saturating_add(1);
window.completed = window.completed.saturating_add(1);
env.storage().temporary().set(&key, &window);

state.state = BreakerState::Open;
state.opened_time = current_time;
state.half_open_requests = 0;
Expand Down Expand Up @@ -800,6 +921,14 @@ impl CircuitBreaker {
return Err(Error::InvalidInput);
}

if config.half_open_quota.calls_per_minute == 0 {
return Err(Error::InvalidInput);
}

if config.half_open_quota.evaluation_window_s == 0 {
return Err(Error::InvalidInput);
}

Ok(())
}

Expand Down Expand Up @@ -881,7 +1010,13 @@ impl CircuitBreakerUtils {
BreakerState::Open => Ok(false),
BreakerState::HalfOpen => {
let config = CircuitBreaker::get_config(env)?;
Ok(state.half_open_requests < config.half_open_max_requests)
if config.half_open_quota.calls_per_minute > 0 {
// Try to admit a call under quota-based scheduling
let admitted = CircuitBreaker::half_open_admit(env, &config)?;
Ok(admitted)
} else {
Ok(state.half_open_requests < config.half_open_max_requests)
}
}
}
}
Expand Down
84 changes: 84 additions & 0 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7114,6 +7114,90 @@ impl PredictifyHybrid {
/// # Errors
///
/// This entrypoint surfaces contract errors via panic in internal calls.
/// Verify SAC token decimals match declared value (admin only).
///
/// This function performs a critical security check on SAC tokens to prevent
/// denomination mistakes that have caused real on-chain losses. It verifies that
/// the token's on-chain decimals() value matches what was declared during registration.
///
/// This can be called:
/// - Automatically during token registration (via add_global_verified/add_event_verified)
/// - Manually by admin as part of periodic audits or security reviews
///
/// # Parameters
///
/// * `env` - The Soroban environment for blockchain operations
/// * `admin` - The administrator address (must be authorized)
/// * `token_contract` - Address of the token contract to verify
/// * `declared_decimals` - The decimals value that was declared during registration
///
/// # Returns
///
/// Returns `Result<(), Error>` where:
/// - `Ok(())` - Decimals match (token is safe)
/// - `Err(Error::TokenDecimalsMismatch)` - Mismatch detected (token rejected)
/// - `Err(Error::Unauthorized)` - Caller is not admin
///
/// # Cross-Contract Call
///
/// This function performs a cross-contract call to the token contract's
/// `decimals()` function using the Soroban token interface.
///
/// # Security Notes
///
/// - Verifies via on-chain decimals() call (cannot be spoofed)
/// - Mismatch indicates potential token misconfiguration
/// - Rejected tokens cannot be used for betting/payouts
/// - All registration paths should use verified variants
///
/// # Example
///
/// ```rust,ignore
/// let token_contract = Address::from_string("GBUQW...");
/// PredictifyHybrid::re_verify_token(&env, &admin, &token_contract, 7)?;
/// // Returns Ok if decimals match, TokenDecimalsMismatch error otherwise
/// ```
///
/// # Errors
///
/// Returns [`Error`] when:
/// - `Error::Unauthorized` - Caller is not the contract admin
/// - `Error::TokenDecimalsMismatch` - On-chain decimals don't match declared value
/// - Other errors from cross-contract call or storage operations
///
/// # Events
///
/// Emits audit trail record of verification attempt (success or failure).
pub fn re_verify_token(
env: Env,
admin: Address,
token_contract: Address,
declared_decimals: u32,
) -> Result<(), Error> {
// Verify admin authorization
Self::require_primary_admin(&env, &admin)?;

// Create temporary asset for verification
let asset = crate::tokens::Asset {
contract: token_contract.clone(),
symbol: Symbol::new(&env, "TEMP"),
decimals: declared_decimals,
};

// Perform decimals verification via cross-contract call
crate::tokens::verify_token_decimals(&env, &asset)?;

// Record verification in audit trail
crate::audit_trail::AuditTrailManager::append_record(
&env,
crate::audit_trail::AuditAction::TokenVerified,
admin.clone(),
Map::new(&env),
);

Ok(())
}

///
/// # Events
///
Expand Down
Loading
Loading