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
15 changes: 15 additions & 0 deletions contracts/predictify-hybrid/src/err.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,12 @@ pub enum Error {
PerLedgerBetCapExceeded = 687,
/// Registry is full.
RegistryFull = 688,
/// Treasury update timelock has not yet expired.
TreasuryUpdateTimelocked = 689,
/// No pending treasury update found.
NoPendingTreasuryUpdate = 690,
/// A pending treasury update already exists.
PendingTreasuryUpdateExists = 691,
}

// ===== ERROR CATEGORIZATION AND RECOVERY SYSTEM =====
Expand Down Expand Up @@ -1613,6 +1619,9 @@ impl Error {
Error::CumulativeExtensionCapHit => "Cumulative extension cap reached; no further extensions allowed",
Error::IllegalMarketStateTransition => "Illegal market state transition attempted",
Error::OracleQuoteOutlier => "Oracle quote is an outlier relative to the rolling median",
Error::TreasuryUpdateTimelocked => "Treasury update timelock has not yet expired",
Error::NoPendingTreasuryUpdate => "No pending treasury update found",
Error::PendingTreasuryUpdateExists => "A pending treasury update already exists",
_ => "An unspecified error occurred.",
}
}
Expand Down Expand Up @@ -1724,6 +1733,9 @@ impl Error {
Error::CumulativeExtensionCapHit => "CUMULATIVE_EXTENSION_CAP_HIT",
Error::IllegalMarketStateTransition => "ILLEGAL_MARKET_STATE_TRANSITION",
Error::OracleQuoteOutlier => "ORACLE_QUOTE_OUTLIER",
Error::TreasuryUpdateTimelocked => "TREASURY_UPDATE_TIMELOCKED",
Error::NoPendingTreasuryUpdate => "NO_PENDING_TREASURY_UPDATE",
Error::PendingTreasuryUpdateExists => "PENDING_TREASURY_UPDATE_EXISTS",
_ => "UNSPECIFIED_ERROR",
}
}
Expand Down Expand Up @@ -1842,6 +1854,9 @@ mod tests {
Error::UpgradeChainMismatch,
Error::ReplayedOverride,
Error::OracleQuoteOutlier,
Error::TreasuryUpdateTimelocked,
Error::NoPendingTreasuryUpdate,
Error::PendingTreasuryUpdateExists,
]
}

Expand Down
72 changes: 72 additions & 0 deletions contracts/predictify-hybrid/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2294,6 +2294,37 @@ pub struct FeeConfigCancelledEvent {
pub timestamp: u64,
}

/// Emitted when a treasury update is queued with a governance time-lock.
/// The treasury is not changed until `now >= eta`.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TreasuryUpdateQueuedEvent {
pub admin: Address,
pub new_treasury: Address,
pub eta: u64,
pub nonce: u64,
pub timestamp: u64,
}

/// Emitted when a queued treasury update is successfully applied.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TreasuryUpdateAppliedEvent {
pub admin: Address,
pub treasury: Address,
pub nonce: u64,
pub timestamp: u64,
}

/// Emitted when a queued treasury update is cancelled by admin.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TreasuryUpdateCancelledEvent {
pub admin: Address,
pub nonce: u64,
pub timestamp: u64,
}

/// Event emitted when a deprecated entrypoint is called.
///
/// This event allows indexers and monitoring tools to track usage of legacy
Expand Down Expand Up @@ -5582,6 +5613,47 @@ impl EventEmitter {
.publish((symbol_short!("fee_ccl"), admin.clone()), event);
}

/// Emit treasury update queued event when a time-locked treasury change is proposed.
pub fn emit_treasury_update_queued(
env: &Env,
admin: &Address,
new_treasury: &Address,
eta: u64,
) {
let event = TreasuryUpdateQueuedEvent {
admin: admin.clone(),
new_treasury: new_treasury.clone(),
eta,
nonce: Self::get_and_increment_nonce(env, symbol_short!("tsu_qd").clone()),
timestamp: env.ledger().timestamp(),
};
env.events()
.publish((symbol_short!("tsu_qd"), admin.clone()), event);
}

/// Emit treasury update applied event when a queued treasury change takes effect.
pub fn emit_treasury_update_applied(env: &Env, admin: &Address, treasury: &Address) {
let event = TreasuryUpdateAppliedEvent {
admin: admin.clone(),
treasury: treasury.clone(),
nonce: Self::get_and_increment_nonce(env, symbol_short!("tsu_apd").clone()),
timestamp: env.ledger().timestamp(),
};
env.events()
.publish((symbol_short!("tsu_apd"), admin.clone()), event);
}

/// Emit treasury update cancelled event when a queued treasury change is cancelled.
pub fn emit_treasury_update_cancelled(env: &Env, admin: &Address) {
let event = TreasuryUpdateCancelledEvent {
admin: admin.clone(),
nonce: Self::get_and_increment_nonce(env, symbol_short!("tsu_ccl").clone()),
timestamp: env.ledger().timestamp(),
};
env.events()
.publish((symbol_short!("tsu_ccl"), admin.clone()), event);
}

/// Emit cumulative dispute stake cap exceeded event.
pub fn emit_dispute_cumulative_stake_cap_exceeded(
env: &Env,
Expand Down
107 changes: 71 additions & 36 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1920,6 +1920,14 @@ impl PredictifyHybrid {
}

/// Set treasury recipient for unclaimed winnings sweeps (admin only).
///
/// **Deprecated**: This function now queues a timelocked treasury update
/// instead of applying it immediately. Use `queue_treasury_update`,
/// `apply_treasury_update`, and `cancel_treasury_update` for the full
/// queue→apply→cancel lifecycle.
///
/// The treasury address will only change after the configured timelock
/// period has elapsed (default: 24 hours).
pub fn set_treasury(env: Env, admin: Address, treasury: Address) {
admin.require_auth();

Expand All @@ -1933,8 +1941,65 @@ impl PredictifyHybrid {
panic_with_error!(env, Error::Unauthorized);
}

recovery::UnclaimedWinningsPolicy::set_treasury(&env, &treasury);
EventEmitter::emit_treasury_updated(&env, &admin, &treasury);
// Route through the timelocked path to prevent instant rotation
match recovery::TreasuryTimelockManager::queue_update(&env, &admin, &treasury) {
Ok(()) => {}
Err(Error::PendingTreasuryUpdateExists) => {
// Cancel the old one and queue the new one
let _ = recovery::TreasuryTimelockManager::cancel_update(&env, &admin);
recovery::TreasuryTimelockManager::queue_update(&env, &admin, &treasury)
.unwrap_or_else(|e| panic_with_error!(env, e));
}
Err(e) => panic_with_error!(env, e),
}
}

/// Queue a treasury update for future activation (admin only).
///
/// The treasury address will only change after the configured timelock
/// period (default: 24 hours) has elapsed. This prevents surprise
/// fee-recipient rotation.
pub fn queue_treasury_update(
env: Env,
admin: Address,
new_treasury: Address,
) -> Result<(), Error> {
Self::require_primary_admin(&env, &admin)?;
recovery::TreasuryTimelockManager::queue_update(&env, &admin, &new_treasury)
}

/// Apply the queued treasury update after the timelock has expired.
///
/// Can be called by the admin once the timelock period has elapsed.
pub fn apply_treasury_update(env: Env, admin: Address) -> Result<(), Error> {
Self::require_primary_admin(&env, &admin)?;
recovery::TreasuryTimelockManager::apply_update(&env, &admin)
}

/// Cancel a pending treasury update before the timelock expires (admin only).
pub fn cancel_treasury_update(env: Env, admin: Address) -> Result<(), Error> {
Self::require_primary_admin(&env, &admin)?;
recovery::TreasuryTimelockManager::cancel_update(&env, &admin)
}

/// Get the pending treasury update, if any (read-only query).
pub fn get_pending_treasury_update(env: Env) -> Option<recovery::PendingTreasuryUpdate> {
recovery::TreasuryTimelockManager::get_pending_update(&env)
}

/// Get the treasury timelock configuration (read-only query).
pub fn get_treasury_timelock_config(env: Env) -> recovery::TreasuryTimelockConfig {
recovery::TreasuryTimelockConfig::get_config(&env)
}

/// Set the treasury timelock delay (admin only).
pub fn set_treasury_timelock_config(
env: Env,
admin: Address,
timelock_seconds: u64,
) -> Result<(), Error> {
Self::require_primary_admin(&env, &admin)?;
recovery::TreasuryTimelockConfig::set_config(&env, &admin, timelock_seconds)
}

/// Sweep unclaimed winning payouts after claim period expiry (admin only).
Expand Down Expand Up @@ -4820,40 +4885,10 @@ impl PredictifyHybrid {
pub fn withdraw_collected_fees(env: Env, admin: Address, amount: i128) -> Result<i128, Error> {
Self::require_primary_admin(&env, &admin)?;

// Get collected fees from storage (using the same key as FeeTracker)
let fees_key = Symbol::new(&env, "tot_fees");
let collected_fees: i128 = env.storage().persistent().get(&fees_key).unwrap_or(0);

if collected_fees == 0 {
return Err(Error::NoFeesToCollect);
}

// Determine withdrawal amount
let withdrawal_amount = if amount == 0 || amount > collected_fees {
collected_fees
} else {
amount
};

// Update collected fees (checked to prevent underflow)
let remaining_fees = collected_fees
.checked_sub(withdrawal_amount)
.ok_or(Error::InvalidInput)?;
env.storage().persistent().set(&fees_key, &remaining_fees);

// Emit fee withdrawal event
EventEmitter::emit_fee_collected(
&env,
&Symbol::new(&env, "withdrawal"),
&admin,
withdrawal_amount,
&String::from_str(&env, "fee_withdrawal"),
);

// In a real implementation, transfer tokens to admin here
// For now, we'll just track the withdrawal

Ok(withdrawal_amount)
// Route through the timelocked fee withdrawal manager to enforce
// the withdrawal schedule (timelock + cap). This prevents the admin
// from bypassing the schedule by calling this entrypoint directly.
fees::FeeWithdrawalManager::withdraw_fees(&env, &admin, amount)
}

/// Extends the deadline of an active market by a specified number of days (admin only).
Expand Down
Loading
Loading