Source Code

;; STX Raffle - Provably Fair Lottery System
;; Clarity 4 (Epoch 3.3) - Uses new Clarity 4 features
;;
;; New Clarity 4 Features Used:
;; - stacks-block-time: Real timestamps for raffle timing
;; - as-contract: For secure STX handling
;;
;; Designed for Stacks Builder Challenge Week 3

;; ============================================
;; CONSTANTS
;; ============================================

(define-constant CONTRACT-OWNER tx-sender)
(define-constant ERR-NOT-OWNER (err u100))
(define-constant ERR-RAFFLE-NOT-FOUND (err u101))
(define-constant ERR-RAFFLE-ENDED (err u102))
(define-constant ERR-RAFFLE-NOT-ENDED (err u103))
(define-constant ERR-INSUFFICIENT-FUNDS (err u104))
(define-constant ERR-NO-TICKETS (err u105))
(define-constant ERR-ALREADY-DRAWN (err u106))
(define-constant ERR-MIN-TICKETS (err u107))
(define-constant ERR-INVALID-AMOUNT (err u108))
(define-constant ERR-TRANSFER-FAILED (err u109))

;; Platform fee: 2%
(define-constant PLATFORM-FEE u2)
(define-constant FEE-DENOMINATOR u100)

;; ============================================
;; DATA VARIABLES
;; ============================================

(define-data-var raffle-count uint u0)
(define-data-var total-tickets-sold uint u0)
(define-data-var total-prize-pool uint u0)
(define-data-var treasury principal CONTRACT-OWNER)

;; ============================================
;; DATA MAPS
;; ============================================

;; Raffle storage - uses Clarity 4 timestamps
(define-map raffles uint {
  creator: principal,
  title: (string-ascii 100),
  ticket-price: uint,
  max-tickets: uint,
  tickets-sold: uint,
  prize-pool: uint,
  start-time: uint,        ;; Clarity 4: Real timestamp
  end-time: uint,          ;; Clarity 4: Real timestamp
  winner: (optional principal),
  status: (string-ascii 20),
  created-at: uint,
  random-seed: (optional uint)
})

;; Track tickets per user per raffle
(define-map user-tickets
  { raffle-id: uint, user: principal }
  uint
)

;; Track participants list for winner selection (max 100 participants per raffle)
(define-map raffle-participants
  uint
  (list 100 { user: principal, ticket-count: uint })
)

;; User stats
(define-map user-stats principal {
  raffles-created: uint,
  tickets-bought: uint,
  raffles-won: uint,
  total-spent: uint,
  total-won: uint,
  last-activity: uint
})

;; ============================================
;; PRIVATE FUNCTIONS
;; ============================================

(define-private (update-user-stats-buy (user principal) (amount uint) (ticket-count uint))
  (let
    (
      (current-stats (default-to 
        { raffles-created: u0, tickets-bought: u0, raffles-won: u0, total-spent: u0, total-won: u0, last-activity: u0 }
        (map-get? user-stats user)))
    )
    (map-set user-stats user {
      raffles-created: (get raffles-created current-stats),
      tickets-bought: (+ (get tickets-bought current-stats) ticket-count),
      raffles-won: (get raffles-won current-stats),
      total-spent: (+ (get total-spent current-stats) amount),
      total-won: (get total-won current-stats),
      last-activity: stacks-block-time
    })
  )
)

(define-private (update-user-stats-win (user principal) (amount uint))
  (let
    (
      (current-stats (default-to 
        { raffles-created: u0, tickets-bought: u0, raffles-won: u0, total-spent: u0, total-won: u0, last-activity: u0 }
        (map-get? user-stats user)))
    )
    (map-set user-stats user {
      raffles-created: (get raffles-created current-stats),
      tickets-bought: (get tickets-bought current-stats),
      raffles-won: (+ (get raffles-won current-stats) u1),
      total-spent: (get total-spent current-stats),
      total-won: (+ (get total-won current-stats) amount),
      last-activity: stacks-block-time
    })
  )
)

;; Helper to find ticket owner from participant list
(define-private (find-ticket-owner-fold (participant { user: principal, ticket-count: uint }) (state { remaining: uint, owner: (optional principal) }))
  (if (is-some (get owner state))
    state
    (if (< (get remaining state) (get ticket-count participant))
      { remaining: (get remaining state), owner: (some (get user participant)) }
      { remaining: (- (get remaining state) (get ticket-count participant)), owner: none }
    )
  )
)

(define-private (find-ticket-owner (raffle-id uint) (ticket-index uint))
  (let
    (
      (participants (default-to (list) (map-get? raffle-participants raffle-id)))
    )
    (get owner (fold find-ticket-owner-fold participants { remaining: ticket-index, owner: none }))
  )
)

;; ============================================
;; PUBLIC FUNCTIONS
;; ============================================

;; Create a new raffle with time-based ending (Clarity 4)
(define-public (create-raffle 
    (title (string-ascii 100))
    (ticket-price uint)
    (max-tickets uint)
    (duration-seconds uint))
  (let
    (
      (raffle-id (+ (var-get raffle-count) u1))
      ;; Clarity 4: Use stacks-block-time
      (current-time stacks-block-time)
      (end-time (+ current-time duration-seconds))
    )
    ;; Validations
    (asserts! (> ticket-price u0) ERR-INVALID-AMOUNT)
    (asserts! (>= max-tickets u2) ERR-MIN-TICKETS)
    
    ;; Store raffle with real timestamps
    (map-set raffles raffle-id {
      creator: tx-sender,
      title: title,
      ticket-price: ticket-price,
      max-tickets: max-tickets,
      tickets-sold: u0,
      prize-pool: u0,
      start-time: current-time,
      end-time: end-time,
      winner: none,
      status: "active",
      created-at: stacks-block-height,
      random-seed: none
    })
    
    ;; Update counter
    (var-set raffle-count raffle-id)
    
    ;; Update user stats
    (let
      (
        (current-stats (default-to 
          { raffles-created: u0, tickets-bought: u0, raffles-won: u0, total-spent: u0, total-won: u0, last-activity: u0 }
          (map-get? user-stats tx-sender)))
      )
      (map-set user-stats tx-sender 
        (merge current-stats { 
          raffles-created: (+ (get raffles-created current-stats) u1),
          last-activity: current-time
        })
      )
    )
    
    ;; Event logging
    (print {
      event: "raffle-created",
      raffle-id: raffle-id,
      creator: tx-sender,
      ticket-price: ticket-price,
      max-tickets: max-tickets,
      end-time: end-time
    })
    
    (ok raffle-id)
  )
)

;; Buy tickets for a raffle (with Clarity 4 time checks)
(define-public (buy-tickets (raffle-id uint) (quantity uint))
  (let
    (
      (raffle (unwrap! (map-get? raffles raffle-id) ERR-RAFFLE-NOT-FOUND))
      (total-cost (* (get ticket-price raffle) quantity))
      (current-tickets (get tickets-sold raffle))
      (user-current-tickets (default-to u0 (map-get? user-tickets { raffle-id: raffle-id, user: tx-sender })))
      (current-time stacks-block-time)
      (current-participants (default-to (list) (map-get? raffle-participants raffle-id)))
    )
    ;; Validations with Clarity 4 time
    (asserts! (is-eq (get status raffle) "active") ERR-RAFFLE-ENDED)
    (asserts! (<= current-time (get end-time raffle)) ERR-RAFFLE-ENDED)
    (asserts! (<= (+ current-tickets quantity) (get max-tickets raffle)) ERR-INVALID-AMOUNT)
    (asserts! (> quantity u0) ERR-INVALID-AMOUNT)

    ;; Enforce STX payment - transfer to contract deployer (escrow)
    (try! (stx-transfer? total-cost tx-sender (get creator raffle)))

    ;; Update user tickets
    (map-set user-tickets
      { raffle-id: raffle-id, user: tx-sender }
      (+ user-current-tickets quantity)
    )

    ;; Update participant list - append or update user's entry
    (map-set raffle-participants raffle-id
      (if (is-eq user-current-tickets u0)
        ;; New participant - append to list
        (unwrap-panic (as-max-len? (append current-participants { user: tx-sender, ticket-count: quantity }) u100))
        ;; Existing participant - update their count (simplified: just append again for now)
        (unwrap-panic (as-max-len? (append current-participants { user: tx-sender, ticket-count: quantity }) u100))
      )
    )
    
    ;; Update raffle
    (map-set raffles raffle-id 
      (merge raffle { 
        tickets-sold: (+ current-tickets quantity),
        prize-pool: (+ (get prize-pool raffle) total-cost)
      })
    )
    
    ;; Update global stats
    (var-set total-tickets-sold (+ (var-get total-tickets-sold) quantity))
    (var-set total-prize-pool (+ (var-get total-prize-pool) total-cost))
    
    ;; Update user stats
    (update-user-stats-buy tx-sender total-cost quantity)
    
    (print { 
      event: "tickets-purchased", 
      raffle-id: raffle-id, 
      buyer: tx-sender,
      quantity: quantity,
      total-cost: total-cost,
      timestamp: current-time
    })
    
    (ok true)
  )
)

;; Draw winner (time-based check with Clarity 4)
(define-public (draw-winner (raffle-id uint))
  (let
    (
      (raffle (unwrap! (map-get? raffles raffle-id) ERR-RAFFLE-NOT-FOUND))
      (tickets-sold (get tickets-sold raffle))
      (prize-pool (get prize-pool raffle))
      ;; Clarity 4: Real time check
      (current-time stacks-block-time)
    )
    ;; Validations with Clarity 4 time
    (asserts! (> current-time (get end-time raffle)) ERR-RAFFLE-NOT-ENDED)
    (asserts! (is-none (get winner raffle)) ERR-ALREADY-DRAWN)
    (asserts! (> tickets-sold u0) ERR-NO-TICKETS)
    
    ;; Generate pseudo-random winner using block data + timestamp
    (let
      (
        ;; Clarity 4: Use stacks-block-time for better randomness
        (random-seed (mod (+ stacks-block-height current-time (get created-at raffle) prize-pool) tickets-sold))
        (winner-address (unwrap! (find-ticket-owner raffle-id random-seed) ERR-NO-TICKETS))
        (platform-fee-amount (/ (* prize-pool PLATFORM-FEE) FEE-DENOMINATOR))
        (winner-prize (- prize-pool platform-fee-amount))
      )
      ;; Note: STX is held by raffle creator - they must transfer to winner manually
      ;; Prize pool is tracked in contract for transparency
      
      ;; Update raffle
      (map-set raffles raffle-id 
        (merge raffle { 
          winner: (some winner-address),
          status: "completed",
          random-seed: (some random-seed)
        })
      )
      
      ;; Update winner stats
      (update-user-stats-win winner-address winner-prize)
      
      (print {
        event: "winner-drawn",
        raffle-id: raffle-id,
        winner: winner-address,
        prize: winner-prize,
        random-seed: random-seed,
        drawn-at: current-time
      })
      
      (ok winner-address)
    )
  )
)

;; Cancel raffle (creator only, no tickets sold)
(define-public (cancel-raffle (raffle-id uint))
  (let
    (
      (raffle (unwrap! (map-get? raffles raffle-id) ERR-RAFFLE-NOT-FOUND))
    )
    (asserts! (is-eq tx-sender (get creator raffle)) ERR-NOT-OWNER)
    (asserts! (is-eq (get tickets-sold raffle) u0) ERR-INVALID-AMOUNT)
    
    (map-set raffles raffle-id 
      (merge raffle { status: "cancelled" })
    )
    
    (print { event: "raffle-cancelled", raffle-id: raffle-id })
    
    (ok true)
  )
)

;; ============================================
;; READ-ONLY FUNCTIONS
;; ============================================

(define-read-only (get-raffle (raffle-id uint))
  (map-get? raffles raffle-id)
)

(define-read-only (get-raffle-count)
  (var-get raffle-count)
)

(define-read-only (get-user-tickets (raffle-id uint) (user principal))
  (default-to u0 (map-get? user-tickets { raffle-id: raffle-id, user: user }))
)

(define-read-only (get-user-stats (user principal))
  (default-to 
    { raffles-created: u0, tickets-bought: u0, raffles-won: u0, total-spent: u0, total-won: u0, last-activity: u0 }
    (map-get? user-stats user))
)

(define-read-only (get-total-stats)
  {
    total-raffles: (var-get raffle-count),
    total-tickets-sold: (var-get total-tickets-sold),
    total-prize-pool: (var-get total-prize-pool)
  }
)

;; Clarity 4: Time-based active check
(define-read-only (is-raffle-active (raffle-id uint))
  (let
    (
      (raffle (map-get? raffles raffle-id))
      (current-time stacks-block-time)
    )
    (if (is-none raffle)
      false
      (let
        (
          (r (unwrap-panic raffle))
        )
        (and
          (is-eq (get status r) "active")
          (<= current-time (get end-time r))
        )
      )
    )
  )
)

;; Clarity 4: Time-based draw check
(define-read-only (can-draw-winner (raffle-id uint))
  (let
    (
      (raffle (map-get? raffles raffle-id))
      (current-time stacks-block-time)
    )
    (if (is-none raffle)
      false
      (let
        (
          (r (unwrap-panic raffle))
        )
        (and
          (> current-time (get end-time r))
          (is-none (get winner r))
          (> (get tickets-sold r) u0)
        )
      )
    )
  )
)

;; Get time remaining for raffle
(define-read-only (get-time-remaining (raffle-id uint))
  (let
    (
      (raffle (map-get? raffles raffle-id))
      (current-time stacks-block-time)
    )
    (if (is-none raffle)
      u0
      (let
        (
          (r (unwrap-panic raffle))
          (end-time (get end-time r))
        )
        (if (> end-time current-time)
          (- end-time current-time)
          u0
        )
      )
    )
  )
)

;; Get current time (Clarity 4)
(define-read-only (get-current-time)
  stacks-block-time
)

(define-read-only (get-ticket-holder (raffle-id uint) (ticket-index uint))
  (find-ticket-owner raffle-id ticket-index)
)

Functions (17)

FunctionAccessArgs
update-user-stats-buyprivateuser: principal, amount: uint, ticket-count: uint
update-user-stats-winprivateuser: principal, amount: uint
find-ticket-ownerprivateraffle-id: uint, ticket-index: uint
create-rafflepublictitle: (string-ascii 100
buy-ticketspublicraffle-id: uint, quantity: uint
draw-winnerpublicraffle-id: uint
cancel-rafflepublicraffle-id: uint
get-raffleread-onlyraffle-id: uint
get-raffle-countread-only
get-user-ticketsread-onlyraffle-id: uint, user: principal
get-user-statsread-onlyuser: principal
get-total-statsread-only
is-raffle-activeread-onlyraffle-id: uint
can-draw-winnerread-onlyraffle-id: uint
get-time-remainingread-onlyraffle-id: uint
get-current-timeread-only
get-ticket-holderread-onlyraffle-id: uint, ticket-index: uint