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
23 changes: 23 additions & 0 deletions contract/contracts/predifi-contract/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,29 @@ pub const UNRESOLVED_OUTCOME: u32 = u32::MAX;
/// Pools with end_time beyond current_time + MAX_POOL_DURATION are rejected.
pub const MAX_POOL_DURATION: u64 = 31_536_000;

/// Maximum length (in bytes/chars) of a single outcome description.
/// Issue #1122 — bounds outcome descriptions so a pool with N outcomes
/// cannot blow up persistent storage with arbitrarily long strings.
pub const MAX_OUTCOME_DESCRIPTION_LEN: u32 = 128;

/// Minimum length (in bytes/chars) of a single outcome description.
/// Issue #1122 — rejects whitespace-only / empty outcome labels.
pub const MIN_OUTCOME_DESCRIPTION_LEN: u32 = 1;

/// Initial-liquidity safety margin in basis points relative to
/// `max_total_stake` (issue #1131). When a creator sets a non-zero
/// `max_total_stake`, the seeded initial liquidity must be at least
/// `(max_total_stake * INITIAL_LIQUIDITY_SAFETY_MARGIN_BPS) / 10_000`
/// — otherwise the pool can be drained on the first few large bets
/// before the creator's house money meaningfully covers payout risk.
/// 100 bps = 1%.
pub const INITIAL_LIQUIDITY_SAFETY_MARGIN_BPS: u32 = 100;

/// Multisig threshold for the emergency-cancel flow (issue #1119).
/// At least this many distinct operator/admin approvals are required
/// before `emergency_cancel_pool` can finalize a cancellation.
pub const EMERGENCY_CANCEL_MULTISIG_THRESHOLD: u32 = 2;

// ═══════════════════════════════════════════════════════════════════════════
// MONITORING & ALERT THRESHOLDS
// ═══════════════════════════════════════════════════════════════════════════
Expand Down
212 changes: 210 additions & 2 deletions contract/contracts/predifi-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,27 @@ pub enum PredifiError {
InvalidTargetPrice = 201,
/// `close_staking` called before pool.end_time has passed.
StakingStillOpen = 82,
/// A time-window constraint (e.g. resolution window, claim window) is
/// not met for the requested operation.
TimeConstraintError = 84,
/// One of the `outcome_descriptions` exceeds `MAX_OUTCOME_DESCRIPTION_LEN`
/// (issue #1122).
OutcomeDescriptionTooLong = 130,
/// One of the `outcome_descriptions` is empty / shorter than
/// `MIN_OUTCOME_DESCRIPTION_LEN` (issue #1122).
OutcomeDescriptionEmpty = 131,
/// A timestamp that must be in the future is in the past or equal to
/// `env.ledger().timestamp()` (issue #1130).
DeadlineInPast = 132,
/// `initial_liquidity` is less than the required safety margin
/// relative to `max_total_stake` (issue #1131).
InitialLiquidityBelowSafetyMargin = 133,
/// `emergency_cancel_pool` was called but the multisig threshold
/// has not yet been reached (issue #1119).
EmergencyCancelPending = 134,
/// The caller has already approved this emergency-cancel proposal
/// (issue #1119).
EmergencyCancelAlreadyApproved = 135,
/// The contract is currently paused; all state-mutating operations are blocked.
///
/// Callers should check `is_contract_paused()` before submitting a transaction,
Expand Down Expand Up @@ -652,6 +673,14 @@ pub enum DataKey {
/// threshold the referral cut is silently skipped (not paid out).
/// Default: 0 (no threshold — any volume qualifies).
ReferralMinVolumeBps,

// ── Issue #1119: Multi-sig emergency cancellation ───────────────────────
/// Pending emergency-cancel approver set: `EmergencyCancelApprovers(pool_id)` -> `Vec<Address>`.
/// Empty / absent when no emergency cancel is currently pending.
EmergencyCancelApprovers(u64),
/// Optional reason string captured when the first approval is recorded.
/// `EmergencyCancelReason(pool_id)` -> `String`.
EmergencyCancelReason(u64),
}

/// Represents a user's individual stake in a prediction market.
Expand Down Expand Up @@ -1208,7 +1237,9 @@ pub struct ResolutionVoteCastEvent {
pub required_resolutions: u32,
}
mod events;
// pub use events::*; // Unused import
// Re-export so lib.rs can construct events like `ClaimWindowUpdateEvent`
// without redundant `events::` qualifiers at every call site.
pub use events::*;

// ── Issue #1142: Event emission consistency ───────────────────────────────────

Expand Down Expand Up @@ -1389,6 +1420,20 @@ impl PredifiContract {
pool.options_count,
"outcome_descriptions length must equal options_count"
);
// Issue #1122 — bound each outcome description's length to prevent
// unbounded persistent-storage growth and reject empty labels that
// produce a useless UI.
for desc in pool.outcome_descriptions.iter() {
let len = desc.len();
assert!(
len >= MIN_OUTCOME_DESCRIPTION_LEN,
"outcome description must be non-empty"
);
assert!(
len <= MAX_OUTCOME_DESCRIPTION_LEN,
"outcome description exceeds MAX_OUTCOME_DESCRIPTION_LEN bytes"
);
}
}

/// Pure: Check if pool state transition is valid
Expand Down Expand Up @@ -2610,7 +2655,24 @@ pub fn pause(env: Env, admin: Address) {
soroban_sdk::panic_with_error!(&env, PredifiError::InvalidTimestamp);
}

// Validate: end_time must be in the future
// Issue #1130 — staking deadlines must be in the future. End_time
// strictly in the past raises the `DeadlineInPast` protocol error
// (typed, machine-checkable). The legacy `assert!` below still
// catches the boundary case `end_time == current_time` with its
// diagnostic message for backwards-compatible test expectations.
// A `start_time` of 0 is a sentinel for "open immediately"; any
// non-zero start_time must be strictly in the future so a creator
// cannot schedule a pool that opens in the past.
if end_time < current_time {
soroban_sdk::panic_with_error!(&env, PredifiError::DeadlineInPast);
}
if config.start_time > 0 && config.start_time < current_time {
soroban_sdk::panic_with_error!(&env, PredifiError::DeadlineInPast);
}

// Validate: end_time must be in the future (legacy assert kept for
// diagnostic clarity; the DeadlineInPast check above is the
// authoritative protocol error).
assert!(end_time > current_time, "end_time must be in the future");

// Validate: end_time must not exceed MAX_POOL_DURATION from now
Expand Down Expand Up @@ -2653,6 +2715,28 @@ pub fn pause(env: Env, admin: Address) {
"initial_liquidity exceeds maximum allowed value"
);

// Issue #1131 — initial-liquidity safety margin. When the creator
// caps the pool at `max_total_stake > 0`, the seeded liquidity must
// cover at least INITIAL_LIQUIDITY_SAFETY_MARGIN_BPS basis points of
// that cap, so that early high-value predictions cannot drain a
// thinly-seeded pool faster than it can be cancelled.
if config.max_total_stake > 0 && config.initial_liquidity > 0 {
// safety_min = max_total_stake * bps / 10_000 — saturating to
// avoid arithmetic panics on extreme inputs.
let bps = INITIAL_LIQUIDITY_SAFETY_MARGIN_BPS as i128;
let safety_min = config
.max_total_stake
.checked_mul(bps)
.map(|v| v / 10_000)
.unwrap_or(i128::MAX);
if config.initial_liquidity < safety_min {
soroban_sdk::panic_with_error!(
&env,
PredifiError::InitialLiquidityBelowSafetyMargin
);
}
}

// Validate: required_resolutions must be at least 1
assert!(
config.required_resolutions >= 1,
Expand Down Expand Up @@ -3350,6 +3434,130 @@ pub fn pause(env: Env, admin: Address) {
Ok(())
}

/// Multi-sig emergency cancellation — propose or approve (issue #1119).
///
/// Single-signer admin cancellation already exists via `cancel_pool`,
/// but for *emergency* cancellations of pools that already hold
/// non-trivial stake we want sign-off from more than one privileged
/// address. Each call by a distinct admin/operator address records an
/// approval; once `EMERGENCY_CANCEL_MULTISIG_THRESHOLD` distinct
/// approvals are collected, the pool is moved to `Canceled` exactly
/// like the single-signer path.
///
/// # Caller
/// Any address with role 0 (admin) or 1 (operator).
///
/// # Errors
/// - `Unauthorized` if caller lacks admin/operator role.
/// - `PoolNotFound` if `pool_id` does not exist.
/// - `InvalidPoolState` if the pool is not `Active`.
/// - `EmergencyCancelAlreadyApproved` if the caller has already approved.
/// - `EmergencyCancelPending` is *not* returned — pending state is
/// visible via `get_emergency_cancel_approvals`.
pub fn emergency_cancel_pool(
env: Env,
approver: Address,
pool_id: u64,
reason: String,
) -> Result<(), PredifiError> {
Self::require_not_paused(&env)?;
approver.require_auth();

// Only admin (0) or operator (1) may participate in emergency cancel.
let is_privileged = Self::require_role(&env, &approver, 0).is_ok()
|| Self::require_role(&env, &approver, 1).is_ok();
if !is_privileged {
return Err(PredifiError::Unauthorized);
}

Self::enter_reentrancy_guard(&env);

let pool_key = DataKey::Pool(pool_id);
let mut pool: Pool = match env.storage().persistent().get(&pool_key) {
Some(p) => p,
None => {
Self::exit_reentrancy_guard(&env);
return Err(PredifiError::PoolNotFound);
}
};
Self::extend_persistent(&env, &pool_key);

if !Self::is_pool_active(&pool) {
Self::exit_reentrancy_guard(&env);
return Err(PredifiError::InvalidPoolState);
}

// Load existing approvers (or start fresh).
let approvers_key = DataKey::EmergencyCancelApprovers(pool_id);
let mut approvers: Vec<Address> = env
.storage()
.persistent()
.get(&approvers_key)
.unwrap_or_else(|| Vec::new(&env));

// Reject duplicate approval from the same address.
for a in approvers.iter() {
if a == approver {
Self::exit_reentrancy_guard(&env);
return Err(PredifiError::EmergencyCancelAlreadyApproved);
}
}

approvers.push_back(approver.clone());
env.storage().persistent().set(&approvers_key, &approvers);

// Capture the first reason; subsequent approvers reaffirm by
// approving, but the recorded reason is the proposer's.
let reason_key = DataKey::EmergencyCancelReason(pool_id);
if !env.storage().persistent().has(&reason_key) {
env.storage().persistent().set(&reason_key, &reason);
}

// Below threshold → still pending; surface state but do not cancel.
if approvers.len() < EMERGENCY_CANCEL_MULTISIG_THRESHOLD {
Self::exit_reentrancy_guard(&env);
return Ok(());
}

// Threshold reached — cancel the pool exactly like cancel_pool would.
let stored_reason: String = env
.storage()
.persistent()
.get(&reason_key)
.unwrap_or(reason);

pool.state = MarketState::Canceled;
env.storage().persistent().set(&pool_key, &pool);
Self::bump_ttl(&env, &pool_key);
Self::remove_from_active_index(&env, pool_id);

PoolCanceledEvent {
pool_id,
caller: approver.clone(),
reason: stored_reason,
operator: approver,
}
.publish(&env);

// Cleanup pending multisig state once the cancel has executed.
env.storage().persistent().remove(&approvers_key);
env.storage().persistent().remove(&reason_key);

Self::exit_reentrancy_guard(&env);
Ok(())
}

/// Read-only view of pending emergency-cancel approvers for `pool_id`.
/// Returns an empty vec when no proposal is currently pending or once
/// the threshold has been met and the pool has been cancelled (the
/// approvers set is cleared on execution).
pub fn get_emergency_cancel_approvals(env: Env, pool_id: u64) -> Vec<Address> {
env.storage()
.persistent()
.get(&DataKey::EmergencyCancelApprovers(pool_id))
.unwrap_or_else(|| Vec::new(&env))
}

/// Place a prediction on a pool. Cannot predict on canceled or resolved pools.
/// Optional `referrer`: if set, that address will receive a referral cut of the protocol fee
/// when this user claims winnings. Stored only on first prediction for (user, pool_id).
Expand Down
Loading
Loading