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
11 changes: 11 additions & 0 deletions contracts/predictify-hybrid/src/err.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@ pub enum Error {
CBError = 504,
/// Rate limit exceeded. Too many requests in the time window.
RateLimitExceeded = 505,

// ===== UPGRADE ERRORS =====
/// Upgrade WASM hash does not match the expected predecessor in the chain.
/// This prevents out-of-order or forked upgrades.
UpgradeChainMismatch = 600,
}

// ===== ERROR CATEGORIZATION AND RECOVERY SYSTEM =====
Expand Down Expand Up @@ -1428,6 +1433,9 @@ impl Error {
Error::CBOpen => "Circuit breaker is open (operations blocked)",
Error::CBError => "Generic circuit breaker subsystem error",
Error::RateLimitExceeded => "Rate limit exceeded; too many requests in the time window",

// Upgrade errors
Error::UpgradeChainMismatch => "Upgrade WASM hash does not match the expected predecessor",
}
}

Expand Down Expand Up @@ -1522,6 +1530,9 @@ impl Error {
Error::CBOpen => "CIRCUIT_BREAKER_OPEN",
Error::CBError => "CIRCUIT_BREAKER_ERROR",
Error::RateLimitExceeded => "RATE_LIMIT_EXCEEDED",

// Upgrade errors
Error::UpgradeChainMismatch => "UPGRADE_CHAIN_MISMATCH",
}
}
}
Expand Down
48 changes: 48 additions & 0 deletions contracts/predictify-hybrid/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1570,6 +1570,33 @@ pub struct ContractUpgradedEvent {
pub timestamp: u64,
}

/// Event emitted when WASM hash chain verification fails during upgrade.
///
/// This event is critical for security as it indicates an attempt to apply
/// an out-of-order or forked upgrade. The upgrade was rejected because the
/// expected predecessor hash did not match the current contract's WASM hash.
///
/// # Security Implications
///
/// - Prevents downgrade attacks
/// - Blocks forked upgrade chains
/// - Ensures linear upgrade progression
/// - Detects potential compromise scenarios
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct UpgradeChainMismatchEvent {
/// Expected predecessor hash (from upgrade plan)
pub expected_predecessor: soroban_sdk::BytesN<32>,
/// Actual current hash (from contract)
pub actual_current_hash: soroban_sdk::BytesN<32>,
/// Proposed new hash (that was rejected)
pub proposed_new_hash: soroban_sdk::BytesN<32>,
/// Admin who attempted the upgrade
pub admin: Address,
/// Timestamp of the failed attempt
pub timestamp: u64,
}

/// Event emitted when market deadline is extended
///
/// This event tracks market deadline extensions, providing transparency
Expand Down Expand Up @@ -3496,6 +3523,27 @@ impl EventEmitter {
env.events().publish((symbol_short!("rollback"),), event);
}

/// Emit upgrade chain mismatch event when hash verification fails
pub fn emit_upgrade_chain_mismatch_event(
env: &Env,
expected_predecessor: &soroban_sdk::BytesN<32>,
actual_current_hash: &soroban_sdk::BytesN<32>,
proposed_new_hash: &soroban_sdk::BytesN<32>,
admin: &Address,
) {
let event = UpgradeChainMismatchEvent {
expected_predecessor: expected_predecessor.clone(),
actual_current_hash: actual_current_hash.clone(),
proposed_new_hash: proposed_new_hash.clone(),
admin: admin.clone(),
timestamp: env.ledger().timestamp(),
};

Self::store_event(env, &symbol_short!("chain_mismatch"), &event);
env.events()
.publish((symbol_short!("chain_mismatch"), admin.clone()), event);
}

/// Emit upgrade proposal created event
pub fn emit_upgrade_proposal_created_event(
env: &Env,
Expand Down
56 changes: 40 additions & 16 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ extern crate wee_alloc;
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

// Short symbol keys (max length 9 for Soroban compatibility)
const SYM_PLATFORM_FEE: &str = "plat_fee"; // was "platform_fee" (12 chars)
const SYM_ALLOWED_ASSETS: &str = "allowed"; // was "allowed_assets" (14 chars)
const SYM_ADMIN: &str = "Admin"; // kept as is (5 chars)

// Module declarations - all modules enabled
mod admin;
#[cfg(test)]
Expand Down Expand Up @@ -288,7 +293,7 @@ impl PredictifyHybrid {
if env
.storage()
.persistent()
.has(&Symbol::new(&env, "platform_fee"))
.has(&Symbol::new(&env, SYM_PLATFORM_FEE))
{
return Err(Error::InvalidState);
}
Expand All @@ -315,7 +320,7 @@ impl PredictifyHybrid {
// Store platform fee configuration in persistent storage
env.storage()
.persistent()
.set(&Symbol::new(&env, "platform_fee"), &fee_percentage);
.set(&Symbol::new(&env, SYM_PLATFORM_FEE), &fee_percentage);

// Store default contract configuration so validators have deterministic bounds
let mut default_config = crate::config::ConfigManager::get_development_config(&env);
Expand Down Expand Up @@ -364,7 +369,7 @@ impl PredictifyHybrid {
// Store custom allowed assets
env.storage()
.persistent()
.set(&Symbol::new(&env, "allowed_assets"), &assets);
.set(&Symbol::new(&env, SYM_ALLOWED_ASSETS), &assets);
} else {
// Initialize with defaults
crate::tokens::TokenRegistry::initialize_with_defaults(&env);
Expand All @@ -382,7 +387,7 @@ impl PredictifyHybrid {
fn stored_primary_admin(env: &Env) -> Result<Address, Error> {
env.storage()
.persistent()
.get(&Symbol::new(env, "Admin"))
.get(&Symbol::new(env, SYM_ADMIN))
.ok_or(Error::AdminNotSet)
}

Expand Down Expand Up @@ -1839,7 +1844,7 @@ impl PredictifyHybrid {
let stored_admin: Address = env
.storage()
.persistent()
.get(&Symbol::new(&env, "Admin"))
.get(&Symbol::new(&env, SYM_ADMIN))
.unwrap_or_else(|| panic_with_error!(env, Error::AdminNotSet));

if admin != stored_admin {
Expand Down Expand Up @@ -1868,7 +1873,7 @@ impl PredictifyHybrid {
let stored_admin: Address = env
.storage()
.persistent()
.get(&Symbol::new(&env, "Admin"))
.get(&Symbol::new(&env, SYM_ADMIN))
.unwrap_or_else(|| panic_with_error!(env, Error::AdminNotSet));

if admin != stored_admin {
Expand Down Expand Up @@ -1899,7 +1904,7 @@ impl PredictifyHybrid {
let stored_admin: Address = env
.storage()
.persistent()
.get(&Symbol::new(&env, "Admin"))
.get(&Symbol::new(&env, SYM_ADMIN))
.unwrap_or_else(|| panic_with_error!(env, Error::AdminNotSet));

if admin != stored_admin {
Expand All @@ -1925,7 +1930,7 @@ impl PredictifyHybrid {
let stored_admin: Address = env
.storage()
.persistent()
.get(&Symbol::new(&env, "Admin"))
.get(&Symbol::new(&env, SYM_ADMIN))
.ok_or(Error::AdminNotSet)?;

if admin != stored_admin {
Expand Down Expand Up @@ -1959,10 +1964,13 @@ impl PredictifyHybrid {
let fee_percent = crate::config::ConfigManager::get_config(&env)
.map(|cfg| cfg.fees.platform_fee_percentage)
.unwrap_or_else(|_| {
env.storage()
.persistent()
.get(&Symbol::new(&env, "platform_fee"))
.unwrap_or(2)
// Use the short platform fee key (backwards-compat fallback to legacy long keys
// is not possible here because Soroban restricts symbols to <=9 chars).
// If you need to read old on-chain keys created with long symbols,
// perform a storage migration on-chain (one-time) to move legacy values
// under the new short key.
let new_key = Symbol::new(&env, SYM_PLATFORM_FEE);
env.storage().persistent().get(&new_key).unwrap_or(2)
});

if fee_percent < 0 || fee_percent > PERCENTAGE_DENOMINATOR {
Expand Down Expand Up @@ -5968,27 +5976,37 @@ impl PredictifyHybrid {
///
/// - Requires Soroban `require_auth()` from the caller
/// - Requires the caller to match the stored primary admin in persistent storage
/// - Validates WASM hash chain to prevent out-of-order/forked upgrades
/// - Validates version compatibility
/// - Performs safety checks before upgrade
/// - Logs all upgrade attempts for audit trail
///
/// # Parameters
///
/// * `env` - Soroban environment
/// * `admin` - Admin performing the upgrade (must be authorized)
/// * `new_wasm_hash` - Hash of new Wasm bytecode to deploy
/// * `expected_predecessor` - Expected current WASM hash (for chain verification)
///
/// # Example
///
/// ```rust
/// # use soroban_sdk::{Env, Address, BytesN};
/// # let env = Env::default();
/// # let admin = Address::generate(&env);
/// # let new_wasm_hash = BytesN::from_array(&env, &[0u8; 32]);
/// # let new_wasm_hash = BytesN::from_array(&env, &[1u8; 32]);
/// # let current_hash = BytesN::from_array(&env, &[0u8; 32]);
///
/// // Perform upgrade with admin authorization
/// // Perform upgrade with admin authorization and chain verification
/// admin.require_auth();
/// PredictifyHybrid::upgrade_contract(env, admin, new_wasm_hash)?;
/// PredictifyHybrid::upgrade_contract(env, admin, new_wasm_hash, current_hash)?;
/// # Ok::<(), predictify_hybrid::errors::Error>(())
/// ```
///
/// # Errors
///
/// Returns [`Error`] when validation, authorization, storage, or subsystem checks fail.
/// Returns [`Error::UpgradeChainMismatch`] if the expected predecessor does not match the current WASM hash.
///
/// # Events
///
Expand All @@ -5997,9 +6015,15 @@ impl PredictifyHybrid {
env: Env,
admin: Address,
new_wasm_hash: soroban_sdk::BytesN<32>,
expected_predecessor: soroban_sdk::BytesN<32>,
) -> Result<(), Error> {
Self::require_primary_admin(&env, &admin)?;
let result = upgrade_manager::UpgradeManager::upgrade_contract(&env, &admin, new_wasm_hash);
let result = upgrade_manager::UpgradeManager::upgrade_contract(
&env,
&admin,
new_wasm_hash,
expected_predecessor,
);

crate::audit_trail::AuditTrailManager::append_record(
&env,
Expand Down
Loading
Loading