From c3715ab091a601fa34cd6da50b830822a108199f Mon Sep 17 00:00:00 2001 From: Heazzy500 <285961681+Heazzy500@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:10:19 +0100 Subject: [PATCH] fix(#406): timelock admin raffle cancellation to prevent rug of ticket buyers An admin could cancel a raffle with sold tickets immediately, returning the prize to the creator while buyers had to individually claim refunds. This adds a 48h timelock (matching the factory's TIMELOCK_DELAY_SECONDS) for admin cancels of raffles with sold tickets. - cancel_raffle now schedules (not executes) an admin cancel when tickets are sold: status stays Active, prize stays locked, and a CancelScheduled event is emitted with the cancel_at timestamp. - New execute_admin_cancel runs the actual cancel only after the timelock elapses (CancelTimelockActive / CancelNotScheduled errors otherwise). - refund_ticket lets holders refund immediately once a cancel is scheduled. - Admin cancels with zero tickets sold remain immediate. - Adds get_pending_cancel getter and wipes PendingAdminCancel in wipe_storage. - Tests cover scheduling, timelock enforcement, immediate refunds, and the no-schedule / zero-ticket paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- contracts/raffle-instance/src/events.rs | 15 ++ contracts/raffle-instance/src/lib.rs | 236 +++++++++++++++++++++++- 2 files changed, 248 insertions(+), 3 deletions(-) diff --git a/contracts/raffle-instance/src/events.rs b/contracts/raffle-instance/src/events.rs index aedbd11f..775f64b4 100644 --- a/contracts/raffle-instance/src/events.rs +++ b/contracts/raffle-instance/src/events.rs @@ -114,6 +114,21 @@ pub struct RaffleCancelled { pub timestamp: u64, } +/// Emitted when an admin schedules a cancellation of a raffle that has already +/// sold tickets. The actual cancel only executes via `execute_admin_cancel` +/// once `cancel_at` has passed. Ticket holders may refund immediately as soon +/// as this event is emitted (#406). +#[derive(Clone)] +#[contractevent] +pub struct CancelScheduled { + pub creator: Address, + pub scheduled_by: Address, + pub tickets_sold: u32, + /// Unix timestamp at which the cancel becomes executable. + pub cancel_at: u64, + pub timestamp: u64, +} + #[derive(Clone)] #[contractevent] pub struct RaffleFailed { diff --git a/contracts/raffle-instance/src/lib.rs b/contracts/raffle-instance/src/lib.rs index af5d819a..b15704cf 100644 --- a/contracts/raffle-instance/src/lib.rs +++ b/contracts/raffle-instance/src/lib.rs @@ -18,7 +18,8 @@ use raffle_shared::{ use self::randomness::{build_vrf_proof_message, OracleSeedWinnerSelection, WinnerSelectionStrategy}; use crate::events::{ - ContractPaused, ContractUnpaused, DrawTriggered, EmergencyWithdrawn, FeesWithdrawn, + CancelScheduled, ContractPaused, ContractUnpaused, DrawTriggered, EmergencyWithdrawn, + FeesWithdrawn, OracleAddressUpdated, PrizeClaimed, PrizeDeposited, PrizeRefunded, ProtocolFeeUpdated, RaffleCancelled, RaffleCreated, RaffleFinalized, RaffleFailed, RaffleStatusChanged, RandomnessFallbackTriggered, RandomnessReceived, RandomnessRequested, SwapDeadlineUpdated, @@ -40,6 +41,11 @@ pub const DEFAULT_SWAP_DEADLINE_SECONDS: u64 = 300; // 5 minutes pub const MAX_SWAP_DEADLINE_SECONDS: u64 = 3_600; // 1 hour /// Emergency withdraw delay (seconds). Set to 90 days. pub const EMERGENCY_WITHDRAW_DELAY_SECONDS: u64 = 90 * 24 * 3600; // 7776000 +/// Notice period before an admin-scheduled cancellation of a raffle that has +/// already sold tickets can be executed. Matches the factory's +/// `TIMELOCK_DELAY_SECONDS` (48 hours) so admins cannot instantly rug ticket +/// buyers (#406). +pub const ADMIN_CANCEL_TIMELOCK_SECONDS: u64 = 172_800; // 48 hours #[contract] pub struct Contract; @@ -109,6 +115,9 @@ pub enum DataKey { CommitEntry(u32), DrawingLock, TicketBuyers, + /// Unix timestamp at which an admin-scheduled cancel becomes executable. + /// Present only while a cancel is pending the timelock (#406). + PendingAdminCancel, } /// A single participant commit recorded during the commit phase of a @@ -192,6 +201,12 @@ pub enum Error { DrawingAlreadyComplete = 61, InvalidEndTime = 62, InvalidAdminAddress = 63, + + // === Admin cancel timelock errors (64-65) === + /// An admin-scheduled cancel was executed before its timelock elapsed (#406). + CancelTimelockActive = 64, + /// `execute_admin_cancel` was called but no cancel is currently scheduled (#406). + CancelNotScheduled = 65, } fn read_raffle(env: &Env) -> Result { @@ -1312,6 +1327,39 @@ impl Contract { return Err(Error::InvalidStatus); } + // #406: An admin must not be able to instantly rug ticket buyers. If an + // admin cancels a raffle that has already sold tickets, the cancel is + // *scheduled* behind a timelock instead of taking effect immediately. + // The prize stays locked (status remains unchanged) until + // `execute_admin_cancel` runs after the notice period, but ticket + // holders may refund right away (see `refund_ticket`). + if reason == CancelReason::AdminCancelled && raffle.tickets_sold > 0 { + let now = env.ledger().timestamp(); + let cancel_at = now + .checked_add(ADMIN_CANCEL_TIMELOCK_SECONDS) + .ok_or(Error::ArithmeticOverflow)?; + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotAuthorized)?; + + env.storage() + .instance() + .set(&DataKey::PendingAdminCancel, &cancel_at); + + CancelScheduled { + creator: raffle.creator.clone(), + scheduled_by: admin, + tickets_sold: raffle.tickets_sold, + cancel_at, + timestamp: now, + } + .publish(&env); + + return Ok(()); + } + raffle.status = RaffleStatus::Cancelled; write_raffle(&env, &raffle); @@ -1327,6 +1375,64 @@ impl Contract { Ok(()) } + /// Executes a previously scheduled admin cancellation (#406). + /// + /// Only succeeds once the timelock set by `cancel_raffle` has elapsed. + /// Calling it earlier returns `CancelTimelockActive`; calling it with no + /// pending schedule returns `CancelNotScheduled`. + pub fn execute_admin_cancel(env: Env) -> Result<(), Error> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotAuthorized)?; + admin.require_auth(); + + let cancel_at: u64 = env + .storage() + .instance() + .get(&DataKey::PendingAdminCancel) + .ok_or(Error::CancelNotScheduled)?; + + let mut raffle = read_raffle(&env)?; + + if raffle.status == RaffleStatus::Finalized + || raffle.status == RaffleStatus::Cancelled + || raffle.status == RaffleStatus::Claimed + { + return Err(Error::InvalidStatus); + } + + let now = env.ledger().timestamp(); + if now < cancel_at { + return Err(Error::CancelTimelockActive); + } + + env.storage() + .instance() + .remove(&DataKey::PendingAdminCancel); + + raffle.status = RaffleStatus::Cancelled; + write_raffle(&env, &raffle); + + RaffleCancelled { + creator: raffle.creator.clone(), + reason: CancelReason::AdminCancelled, + tickets_sold: raffle.tickets_sold, + prize_refunded: raffle.prize_deposited, + timestamp: now, + } + .publish(&env); + + Ok(()) + } + + /// Returns the timestamp at which a scheduled admin cancel becomes + /// executable, or `None` if no cancel is currently scheduled (#406). + pub fn get_pending_cancel(env: Env) -> Option { + env.storage().instance().get(&DataKey::PendingAdminCancel) + } + pub fn refund_prize(env: Env) -> Result<(), Error> { let mut raffle = read_raffle(&env)?; raffle.creator.require_auth(); @@ -1441,9 +1547,19 @@ impl Contract { pub fn refund_ticket(env: Env, ticket_id: u32) -> Result { let raffle = read_raffle(&env)?; + // #406: Ticket holders may refund as soon as an admin cancel is + // *scheduled*, without waiting for the timelock to execute the cancel. + let cancel_scheduled = env + .storage() + .instance() + .has(&DataKey::PendingAdminCancel); + // #258: status check BEFORE require_auth to prevent double-spend on // status transitions that occur between auth and the gate. - if raffle.status != RaffleStatus::Cancelled && raffle.status != RaffleStatus::Failed { + if raffle.status != RaffleStatus::Cancelled + && raffle.status != RaffleStatus::Failed + && !cancel_scheduled + { return Err(Error::InvalidStatus); } @@ -1574,6 +1690,9 @@ impl Contract { .remove(&DataKey::RandomnessRequestId); env.storage().instance().remove(&DataKey::DrawingLock); env.storage().instance().remove(&DataKey::FinishTime); + env.storage() + .instance() + .remove(&DataKey::PendingAdminCancel); // Wipe persistent instance-level keys env.storage() @@ -1930,7 +2049,7 @@ fn do_finalize_with_seed( mod test { use super::*; use raffle_shared::RaffleConfig; - use soroban_sdk::testutils::{Address as _, Ledger as _}; + use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _}; use soroban_sdk::{vec, Address, BytesN, Env, String}; // Deploy a Stellar Asset Contract we control, return (token_client, admin_client). @@ -2164,6 +2283,111 @@ mod test { assert_eq!(client.buy_tickets(&buyer, &1), 1); } + /// #406: Admin cancel of a raffle with sold tickets must be timelocked. + /// Scheduling, then executing within the window, must fail. + #[test] + fn admin_cancel_with_sold_tickets_requires_timelock() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let (client, _admin, _creator, buyer, _factory, _token_mint) = setup_active_raffle(&env); + client.buy_tickets(&buyer, &3); + assert_eq!(client.get_raffle().tickets_sold, 3); + + // No cancel is scheduled yet. + assert!(client.get_pending_cancel().is_none()); + + // Admin cancel only *schedules*: status and prize stay untouched, and + // exactly one event (CancelScheduled) is emitted. + let events_before = env.events().all().len(); + client.cancel_raffle(&raffle_shared::CancelReason::AdminCancelled); + assert_eq!(env.events().all().len() - events_before, 1); + + assert_eq!(client.get_raffle().status, RaffleStatus::Active); + assert!(client.get_raffle().prize_deposited); + + let cancel_at = client.get_pending_cancel().expect("cancel scheduled"); + assert_eq!(cancel_at, 1_000 + ADMIN_CANCEL_TIMELOCK_SECONDS); + + // Executing immediately fails — the timelock is still active. + assert_eq!( + client.try_execute_admin_cancel(), + Err(Ok(Error::CancelTimelockActive)) + ); + + // One second before the deadline still fails. + env.ledger().set_timestamp(cancel_at - 1); + assert_eq!( + client.try_execute_admin_cancel(), + Err(Ok(Error::CancelTimelockActive)) + ); + + // At/after the deadline the cancel finally executes. + env.ledger().set_timestamp(cancel_at); + client.execute_admin_cancel(); + assert_eq!(client.get_raffle().status, RaffleStatus::Cancelled); + assert!(client.get_pending_cancel().is_none()); + } + + /// #406: `execute_admin_cancel` with no scheduled cancel must fail. + #[test] + fn execute_admin_cancel_without_schedule_fails() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let (client, _admin, _creator, buyer, _factory, _token_mint) = setup_active_raffle(&env); + client.buy_tickets(&buyer, &1); + + assert_eq!( + client.try_execute_admin_cancel(), + Err(Ok(Error::CancelNotScheduled)) + ); + } + + /// #406: An admin cancel of a raffle with *zero* tickets sold takes effect + /// immediately — no timelock is needed because there are no buyers to rug. + #[test] + fn admin_cancel_with_no_tickets_is_immediate() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let (client, _admin, _creator, _buyer, _factory, _token_mint) = setup_active_raffle(&env); + + client.cancel_raffle(&raffle_shared::CancelReason::AdminCancelled); + assert_eq!(client.get_raffle().status, RaffleStatus::Cancelled); + assert!(client.get_pending_cancel().is_none()); + } + + /// #406: Ticket holders can refund immediately once a cancel is scheduled, + /// without waiting for the timelock to execute. + #[test] + fn ticket_holders_refund_immediately_after_cancel_scheduled() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let (client, _admin, _creator, buyer, _factory, _token_mint) = setup_active_raffle(&env); + client.buy_tickets(&buyer, &2); + + // While the raffle is Active and no cancel is scheduled, refunds are + // rejected. + assert_eq!( + client.try_refund_ticket(&1), + Err(Ok(Error::InvalidStatus)) + ); + + // Schedule the admin cancel. The raffle is still Active... + client.cancel_raffle(&raffle_shared::CancelReason::AdminCancelled); + assert_eq!(client.get_raffle().status, RaffleStatus::Active); + + // ...yet ticket holders can refund right away, before the timelock. + assert_eq!(client.refund_ticket(&1), MIN_TICKET_PRICE); + assert_eq!(client.refund_ticket(&2), MIN_TICKET_PRICE); + } + #[test] fn test_wipe_storage_removes_all_keys() { let env = Env::default(); @@ -2213,7 +2437,13 @@ mod test { client.buy_tickets(&buyer_a, &3); client.buy_tickets(&buyer_b, &2); + // #406: with tickets sold, an admin cancel is scheduled behind a + // timelock and only takes effect after `execute_admin_cancel`. client.cancel_raffle(&raffle_shared::CancelReason::AdminCancelled); + assert_eq!(client.get_raffle().status, RaffleStatus::Active); + env.ledger() + .set_timestamp(1_000 + ADMIN_CANCEL_TIMELOCK_SECONDS); + client.execute_admin_cancel(); assert_eq!(client.get_raffle().status, RaffleStatus::Cancelled);