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
50 changes: 50 additions & 0 deletions contracts/predict-iq/src/modules/governance.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use crate::errors::ErrorCode;
use crate::types::{
ConfigKey, Guardian, PendingUpgrade, MAJORITY_THRESHOLD_PERCENT, TIMELOCK_DURATION,
UPGRADE_COOLDOWN_DURATION,
ConfigKey, Guardian, PendingUpgrade, GOV_TTL_HIGH_THRESHOLD, GOV_TTL_LOW_THRESHOLD,
MAJORITY_THRESHOLD_PERCENT, TIMELOCK_DURATION,
};
Expand Down Expand Up @@ -164,6 +166,13 @@ pub fn vote_on_guardian_removal(e: &Env, voter: Address, approve: bool) -> Resul
pub fn initiate_upgrade(e: &Env, wasm_hash: BytesN<32>) -> Result<(), ErrorCode> {
crate::modules::admin::require_admin(e)?;

// Validate WASM hash is not empty
if wasm_hash.is_empty() {
return Err(ErrorCode::InvalidWasmHash);
}

require_no_upgrade_collision(e, &wasm_hash)?;

// Check if an upgrade is already pending
if e.storage().persistent().has(&ConfigKey::PendingUpgrade) {
return Err(ErrorCode::NotAuthorized);
Expand All @@ -186,6 +195,43 @@ pub fn initiate_upgrade(e: &Env, wasm_hash: BytesN<32>) -> Result<(), ErrorCode>
Ok(())
}

fn require_no_upgrade_collision(e: &Env, wasm_hash: &String) -> Result<(), ErrorCode> {
if let Some(pending_upgrade) = get_pending_upgrade(e) {
if pending_upgrade.wasm_hash == *wasm_hash {
return Err(ErrorCode::UpgradeAlreadyPending);
}
}

if let Some(rejected_at) = get_upgrade_rejected_at(e, wasm_hash) {
let current_time = e.ledger().timestamp();
let elapsed = current_time.saturating_sub(rejected_at);
if elapsed <= UPGRADE_COOLDOWN_DURATION {
return Err(ErrorCode::UpgradeHashInCooldown);
}
}

Ok(())
}

fn get_upgrade_rejected_at(e: &Env, wasm_hash: &String) -> Option<u64> {
e.storage()
.persistent()
.get(&ConfigKey::UpgradeRejectedAt(wasm_hash.clone()))
}

fn set_upgrade_rejected_at(e: &Env, wasm_hash: &String) {
e.storage().persistent().set(
&ConfigKey::UpgradeRejectedAt(wasm_hash.clone()),
&e.ledger().timestamp(),
);
}

fn clear_upgrade_rejected_at(e: &Env, wasm_hash: &String) {
e.storage()
.persistent()
.remove(&ConfigKey::UpgradeRejectedAt(wasm_hash.clone()));
}

/// Get the currently pending upgrade, if any.
pub fn get_pending_upgrade(e: &Env) -> Option<PendingUpgrade> {
e.storage().persistent().get(&ConfigKey::PendingUpgrade)
Expand Down Expand Up @@ -295,13 +341,17 @@ pub fn execute_upgrade(e: &Env) -> Result<(), ErrorCode> {

// Verify majority vote
if !is_majority_met(e, &pending_upgrade) {
// A failed execution after the timelock is treated as a governance rejection.
set_upgrade_rejected_at(e, &pending_upgrade.wasm_hash);
e.storage().persistent().remove(&ConfigKey::PendingUpgrade);
return Err(ErrorCode::InsufficientVotes);
}

let wasm_hash = pending_upgrade.wasm_hash.clone();

// Clear pending upgrade
e.storage().persistent().remove(&ConfigKey::PendingUpgrade);
clear_upgrade_rejected_at(e, &wasm_hash);

// Execute host-level contract code upgrade.
e.deployer().update_current_contract_wasm(wasm_hash);
Expand Down
159 changes: 159 additions & 0 deletions contracts/predict-iq/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,165 @@ fn test_persistent_state_preserved_on_upgrade() {
assert_eq!(stored_admin, admin);
}

#[test]
fn test_same_hash_cannot_be_reinitiated_while_pending() {
let (e, _admin, _contract_id, client) = setup_test_env();

let guardian = Address::generate(&e);
let mut guardians = Vec::new(&e);
guardians.push_back(types::Guardian {
address: guardian,
voting_power: 1,
});
client.initialize_guardians(&guardians);

let wasm_hash = String::from_str(&e, "repeat_hash");
e.ledger().with_mut(|li| li.timestamp = 1000);

client.initiate_upgrade(&wasm_hash);

let result = client.try_initiate_upgrade(&wasm_hash);
assert_eq!(result, Err(Ok(ErrorCode::UpgradeAlreadyPending)));
}

#[test]
fn test_different_hash_still_blocked_while_another_upgrade_is_pending() {
let (e, _admin, _contract_id, client) = setup_test_env();

let guardian = Address::generate(&e);
let mut guardians = Vec::new(&e);
guardians.push_back(types::Guardian {
address: guardian,
voting_power: 1,
});
client.initialize_guardians(&guardians);

let hash_a = String::from_str(&e, "hash_a");
let hash_b = String::from_str(&e, "hash_b");
e.ledger().with_mut(|li| li.timestamp = 1000);

client.initiate_upgrade(&hash_a);

let result = client.try_initiate_upgrade(&hash_b);
assert_eq!(result, Err(Ok(ErrorCode::NotAuthorized)));
}

#[test]
fn test_rejected_hash_blocked_during_cooldown() {
let (e, _admin, _contract_id, client) = setup_test_env();

let guardian1 = Address::generate(&e);
let guardian2 = Address::generate(&e);
let guardian3 = Address::generate(&e);
let mut guardians = Vec::new(&e);
guardians.push_back(types::Guardian {
address: guardian1.clone(),
voting_power: 1,
});
guardians.push_back(types::Guardian {
address: guardian2,
voting_power: 1,
});
guardians.push_back(types::Guardian {
address: guardian3,
voting_power: 1,
});
client.initialize_guardians(&guardians);

let wasm_hash = String::from_str(&e, "cooldown_hash");
e.ledger().with_mut(|li| li.timestamp = 1000);
client.initiate_upgrade(&wasm_hash);
client.vote_for_upgrade(&guardian1, &true);

e.ledger()
.with_mut(|li| li.timestamp = 1000 + types::TIMELOCK_DURATION + 1);
let execute_result = client.try_execute_upgrade();
assert_eq!(execute_result, Err(Ok(ErrorCode::InsufficientVotes)));

let result = client.try_initiate_upgrade(&wasm_hash);
assert_eq!(result, Err(Ok(ErrorCode::UpgradeHashInCooldown)));
}

#[test]
fn test_rejected_hash_allowed_after_cooldown_expires() {
let (e, _admin, _contract_id, client) = setup_test_env();

let guardian1 = Address::generate(&e);
let guardian2 = Address::generate(&e);
let guardian3 = Address::generate(&e);
let mut guardians = Vec::new(&e);
guardians.push_back(types::Guardian {
address: guardian1.clone(),
voting_power: 1,
});
guardians.push_back(types::Guardian {
address: guardian2,
voting_power: 1,
});
guardians.push_back(types::Guardian {
address: guardian3,
voting_power: 1,
});
client.initialize_guardians(&guardians);

let wasm_hash = String::from_str(&e, "reinit_hash");
e.ledger().with_mut(|li| li.timestamp = 1000);
client.initiate_upgrade(&wasm_hash);
client.vote_for_upgrade(&guardian1, &true);

e.ledger()
.with_mut(|li| li.timestamp = 1000 + types::TIMELOCK_DURATION + 1);
let execute_result = client.try_execute_upgrade();
assert_eq!(execute_result, Err(Ok(ErrorCode::InsufficientVotes)));

e.ledger().with_mut(|li| {
li.timestamp = 1000 + types::TIMELOCK_DURATION + 1 + types::UPGRADE_COOLDOWN_DURATION + 1
});

let result = client.try_initiate_upgrade(&wasm_hash);
assert!(result.is_ok());
}

#[test]
fn test_rejected_hash_still_blocked_at_exact_cooldown_boundary() {
let (e, _admin, _contract_id, client) = setup_test_env();

let guardian1 = Address::generate(&e);
let guardian2 = Address::generate(&e);
let guardian3 = Address::generate(&e);
let mut guardians = Vec::new(&e);
guardians.push_back(types::Guardian {
address: guardian1.clone(),
voting_power: 1,
});
guardians.push_back(types::Guardian {
address: guardian2,
voting_power: 1,
});
guardians.push_back(types::Guardian {
address: guardian3,
voting_power: 1,
});
client.initialize_guardians(&guardians);

let wasm_hash = String::from_str(&e, "boundary_hash");
e.ledger().with_mut(|li| li.timestamp = 1000);
client.initiate_upgrade(&wasm_hash);
client.vote_for_upgrade(&guardian1, &true);

e.ledger()
.with_mut(|li| li.timestamp = 1000 + types::TIMELOCK_DURATION + 1);
let execute_result = client.try_execute_upgrade();
assert_eq!(execute_result, Err(Ok(ErrorCode::InsufficientVotes)));

e.ledger().with_mut(|li| {
li.timestamp = 1000 + types::TIMELOCK_DURATION + 1 + types::UPGRADE_COOLDOWN_DURATION
});

let result = client.try_initiate_upgrade(&wasm_hash);
assert_eq!(result, Err(Ok(ErrorCode::UpgradeHashInCooldown)));
}

// ===================== Conditional/Chained Market Tests (Issue #25) =====================

#[test]
Expand Down
5 changes: 5 additions & 0 deletions contracts/predict-iq/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ pub enum ConfigKey {
GuardianSet,
PendingUpgrade,
UpgradeVotes,
UpgradeRejectedAt(String),
GovernanceToken,
MaxPushPayoutWinners,
PendingGuardianRemoval,
Expand Down Expand Up @@ -151,6 +152,10 @@ pub struct PendingUpgrade {
pub votes_against: Vec<Address>,
}

// Constants for upgrade governance
pub const TIMELOCK_DURATION: u64 = 48 * 60 * 60; // 48 hours in seconds
pub const MAJORITY_THRESHOLD_PERCENT: u32 = 51; // 51% for majority
pub const UPGRADE_COOLDOWN_DURATION: u64 = 7 * 24 * 60 * 60; // 7 days in seconds
/// Issue #13: Default timelock — 48 hours. Overridable via ConfigKey::TimelockDuration.
pub const TIMELOCK_DURATION: u64 = 48 * 60 * 60;
pub const MAJORITY_THRESHOLD_PERCENT: u32 = 51;
Expand Down
Loading