Source Code

;; title: WitStac
;; version: 1.0.0
;; summary: On-chain trivia game on Stacks - answer questions, earn WIT tokens, climb the leaderboard.
;; description:
;;   WitStac is a fully on-chain trivia game built in Clarity.
;;   Players commit a SHA-256 hash of their answer, then reveal the plaintext.
;;   Correct first-time answers earn WIT token rewards (mock STX for dev/testnet).
;;   Streaks unlock multipliers. All history is permanently on-chain.

;; ============================================================
;; Constants - Owner & Game Config
;; ============================================================

(define-constant contract-owner   tx-sender)
(define-constant commitment-window u100) ;; blocks a commitment stays valid (~16 hrs on Stacks)

;; Default difficulty tier rewards (WIT micro-units, 1 WIT = 1_000_000)
;; Easy=0.5 WIT, Medium=1 WIT, Hard=2.5 WIT, Expert=5 WIT
;; These are used by get-difficulty-reward below.
(define-constant reward-easy   u500000)
(define-constant reward-medium u1000000)
(define-constant reward-hard   u2500000)
(define-constant reward-expert u5000000)


;; Error codes
(define-constant err-not-owner            (err u200))
(define-constant err-question-not-found   (err u201))
(define-constant err-question-inactive    (err u202))
(define-constant err-no-commitment        (err u203))
(define-constant err-commitment-expired   (err u204))
(define-constant err-hash-mismatch        (err u205))
(define-constant err-invalid-difficulty   (err u207))
(define-constant err-already-committed    (err u210))

;; ============================================================
;; Data Variables
;; ============================================================

(define-data-var question-count      uint u0)
(define-data-var reward-pool-balance uint u0)

;; ============================================================
;; Data Maps
;; ============================================================

;; Questions map
(define-map questions
  { question-id: uint }
  {
    text:        (string-utf8 512),
    answer-hash: (buff 32),
    category:    (string-ascii 32),
    difficulty:  uint,
    reward:      uint,
    active:      bool
  })

;; Attempts per player per question
(define-map attempts
  { player: principal, question-id: uint }
  {
    total-attempts:     uint,
    correct:            bool,
    last-attempt-block: uint
  })

;; Commitments: stores hashed answer before reveal
(define-map commitments
  { player: principal, question-id: uint }
  {
    answer-hash:  (buff 32),
    block-height: uint
  })

;; Player global stats
(define-map player-stats
  { player: principal }
  {
    total-attempts: uint,
    total-correct:  uint,
    total-earned:   uint,   ;; cumulative WIT earned (micro-units)
    current-streak: uint
  })

;; Leaderboard
(define-map leaderboard
  { player: principal }
  {
    score:           uint,
    correct-answers: uint,
    total-attempts:  uint,
    current-streak:  uint,
    best-streak:     uint,
    total-earned:    uint
  })

;; ============================================================
;; Private Helper Functions
;; ============================================================


;; Map difficulty (1-4) to its default base reward
(define-private (get-difficulty-reward (difficulty uint))
  (if (is-eq difficulty u1) reward-easy
    (if (is-eq difficulty u2) reward-medium
      (if (is-eq difficulty u3) reward-hard
        reward-expert))))

;; Streak multiplier in basis points (10000 = 1.0x, 12500 = 1.25x, etc.)
;;   streak 0-2:  1x    (10000 bp)
;;   streak 3-4:  1.25x (12500 bp)
;;   streak 5-9:  1.5x  (15000 bp)
;;   streak 10-19: 2x   (20000 bp)
;;   streak 20+:  3x    (30000 bp)
(define-private (get-streak-multiplier (streak uint))
  (if (< streak u3)   u10000
    (if (< streak u5)  u12500
      (if (< streak u10) u15000
        (if (< streak u20) u20000
          u30000)))))

;; Apply multiplier: reward * basis-points / 10000
(define-private (apply-multiplier (reward uint) (basis-points uint))
  (/ (* reward basis-points) u10000))

;; Score points for a correct answer: base-reward / attempt-number
;; First attempt = full points; later attempts = fewer points
(define-private (calc-points (base-reward uint) (attempt-num uint))
  (if (is-eq attempt-num u0) base-reward
    (/ base-reward attempt-num)))

;; Ensure a player-stats record exists; initialise with zeros if missing
(define-private (ensure-player-stats (player principal))
  (if (is-none (map-get? player-stats { player: player }))
    (begin
      (map-set player-stats { player: player }
        { total-attempts: u0, total-correct: u0, total-earned: u0, current-streak: u0 })
      true)
    true))

;; Ensure a leaderboard record exists; initialise with zeros if missing
(define-private (ensure-leaderboard (player principal))
  (if (is-none (map-get? leaderboard { player: player }))
    (begin
      (map-set leaderboard { player: player }
        { score: u0, correct-answers: u0, total-attempts: u0,
          current-streak: u0, best-streak: u0, total-earned: u0 })
      true)
    true))

;; Update player-stats after a reveal attempt
(define-private (update-player-stats-after-reveal
    (player     principal)
    (is-correct bool)
    (earned     uint))
  (let ((s (unwrap-panic (map-get? player-stats { player: player }))))
    (map-set player-stats { player: player }
      {
        total-attempts: (+ (get total-attempts s) u1),
        total-correct:  (if is-correct (+ (get total-correct s) u1) (get total-correct s)),
        total-earned:   (+ (get total-earned s) earned),
        current-streak: (if is-correct (+ (get current-streak s) u1) u0)
      })))

;; Update leaderboard after a reveal attempt
(define-private (update-leaderboard-after-reveal
    (player     principal)
    (is-correct bool)
    (points     uint)
    (earned     uint))
  (let ((e (unwrap-panic (map-get? leaderboard { player: player }))))
    (let (
      (new-streak (if is-correct (+ (get current-streak e) u1) u0))
      (new-best   (if (> new-streak (get best-streak e)) new-streak (get best-streak e)))
    )
      (map-set leaderboard { player: player }
        {
          score:           (+ (get score e) points),
          correct-answers: (if is-correct (+ (get correct-answers e) u1) (get correct-answers e)),
          total-attempts:  (+ (get total-attempts e) u1),
          current-streak:  new-streak,
          best-streak:     new-best,
          total-earned:    (+ (get total-earned e) earned)
        }))))

;; (payout logic is inlined directly in reveal-answer so as-contract works correctly)

;; ============================================================
;; Public Functions - Admin
;; ============================================================

;; Add a new trivia question (owner only)
(define-public (add-question
    (text        (string-utf8 512))
    (answer-hash (buff 32))
    (category    (string-ascii 32))
    (difficulty  uint)
    (reward      uint))
  (begin
    (asserts! (is-eq tx-sender contract-owner) err-not-owner)
    (asserts! (and (>= difficulty u1) (<= difficulty u4)) err-invalid-difficulty)
    (let (
      (new-id      (+ (var-get question-count) u1))
      (final-reward (if (is-eq reward u0) (get-difficulty-reward difficulty) reward))
    )
      (var-set question-count new-id)
      (map-set questions { question-id: new-id }
        {
          text:        text,
          answer-hash: answer-hash,
          category:    category,
          difficulty:  difficulty,
          reward:      final-reward,
          active:      true
        })
      (ok new-id))))

;; Retire a question - marks inactive; attempt history is preserved (owner only)
(define-public (retire-question (question-id uint))
  (begin
    (asserts! (is-eq tx-sender contract-owner) err-not-owner)
    (let ((q (unwrap! (map-get? questions { question-id: question-id })
                      err-question-not-found)))
      (map-set questions { question-id: question-id }
        (merge q { active: false }))
      (ok true))))

;; ============================================================
;; Public Functions - Reward Pool
;; ============================================================

;; Fund the reward pool by minting WIT tokens into this contract.
;; Anyone can call this to top up the pool.
;; contract-caller inside wit-token will be .witstac automatically (cross-contract call).
(define-public (fund-reward-pool (amount uint))
  (let ((contract-address (as-contract tx-sender)))
    (try! (contract-call? .wit-token mint amount contract-address))
    (var-set reward-pool-balance (+ (var-get reward-pool-balance) amount))
    (ok (var-get reward-pool-balance))))

;; ============================================================
;; Public Functions - Game: Commit-Reveal
;; ============================================================

;; STEP 1: Commit
;; Player submits SHA-256 hash of their plaintext answer.
;; This prevents mempool snooping (front-running).
(define-public (commit-answer
    (question-id uint)
    (answer-hash (buff 32)))
  (let ((q (unwrap! (map-get? questions { question-id: question-id })
                    err-question-not-found)))
    (asserts! (get active q) err-question-inactive)
    ;; Block if an unexpired commitment already exists for this player+question
    (match (map-get? commitments { player: tx-sender, question-id: question-id })
      existing
        (asserts!
          (> block-height (+ (get block-height existing) commitment-window))
          err-already-committed)
      true)
    (map-set commitments { player: tx-sender, question-id: question-id }
      { answer-hash: answer-hash, block-height: block-height })
    (ok true)))

;; STEP 2: Reveal
;; Player submits plaintext answer. Contract hashes it and compares.
;; Returns (ok true) if correct, (ok false) if incorrect.
;; WIT reward paid only on the first correct answer per question per address.
(define-public (reveal-answer
    (question-id uint)
    (answer      (buff 128)))
  (let (
    (q            (unwrap! (map-get? questions { question-id: question-id })
                           err-question-not-found))
    (commitment   (unwrap! (map-get? commitments { player: tx-sender, question-id: question-id })
                           err-no-commitment))
    (attempt-data (default-to
                    { total-attempts: u0, correct: false, last-attempt-block: u0 }
                    (map-get? attempts { player: tx-sender, question-id: question-id })))
  )
    ;; Question must be active
    (asserts! (get active q) err-question-inactive)
    ;; Commitment must not be expired
    (asserts!
      (<= block-height (+ (get block-height commitment) commitment-window))
      err-commitment-expired)

    ;; Ensure player records are initialised
    (ensure-player-stats tx-sender)
    (ensure-leaderboard tx-sender)

    (let (
      (new-attempt-num      (+ (get total-attempts attempt-data) u1))
      (already-correct      (get correct attempt-data))
      (stats                (unwrap-panic (map-get? player-stats { player: tx-sender })))
      (current-streak       (get current-streak stats))
      ;; SHA-256 hash of the revealed plaintext answer
      (revealed-hash        (sha256 answer))
      ;; Verify commitment hash matches what player just revealed
      (hash-matches-commit  (is-eq (get answer-hash commitment) revealed-hash))
      ;; Verify revealed answer matches the stored question answer
      (is-correct           (and hash-matches-commit (is-eq (get answer-hash q) revealed-hash)))
    )
      ;; Commitment hash must match revealed answer (anti-tampering check)
      (asserts! hash-matches-commit err-hash-mismatch)

      ;; Remove the used commitment regardless of correctness
      (map-delete commitments { player: tx-sender, question-id: question-id })

      ;; Update attempt record
      (map-set attempts { player: tx-sender, question-id: question-id }
        {
          total-attempts:     new-attempt-num,
          correct:            (or already-correct is-correct),
          last-attempt-block: block-height
        })

      (let (
        (base-reward  (get reward q))
        (multiplier   (get-streak-multiplier current-streak))
        ;; Final reward applies streak multiplier (only relevant if correct)
        (final-reward (if is-correct (apply-multiplier base-reward multiplier) u0))
        ;; Points = base-reward / attempt-number (full for first try, less for retries)
        (points       (if is-correct (calc-points base-reward new-attempt-num) u0))
        ;; Payout only on very first correct answer per question
        (earned       (if (and is-correct (not already-correct)) final-reward u0))
      )
        ;; Update stats and leaderboard
        (update-player-stats-after-reveal tx-sender is-correct earned)
        (update-leaderboard-after-reveal  tx-sender is-correct points earned)

        ;; Pay WIT reward (first correct attempt only)
        (if (and is-correct (not already-correct))
          (if (>= (var-get reward-pool-balance) final-reward)
            (let ((recipient tx-sender))
              (begin
                (var-set reward-pool-balance (- (var-get reward-pool-balance) final-reward))
                (try! (as-contract (contract-call? .wit-token transfer final-reward tx-sender recipient none)))
                (ok true)))
            ;; Pool too low - win recorded, payout deferred
            (ok true))
          (ok is-correct))))))

;; ============================================================
;; Read-Only Functions
;; ============================================================

;; Get question metadata - answer-hash is never returned to callers
(define-read-only (get-question (question-id uint))
  (match (map-get? questions { question-id: question-id })
    q (ok {
        text:       (get text q),
        category:   (get category q),
        difficulty: (get difficulty q),
        reward:     (get reward q),
        active:     (get active q)
      })
    (err err-question-not-found)))

;; Get a player's attempt record for a specific question
(define-read-only (get-attempt (player principal) (question-id uint))
  (map-get? attempts { player: player, question-id: question-id }))

;; Get a player's global stats
(define-read-only (get-player-stats (player principal))
  (map-get? player-stats { player: player }))

;; Get total number of questions ever added
(define-read-only (get-question-count)
  (ok (var-get question-count)))

;; Get current WIT reward pool balance (micro-units)
(define-read-only (get-reward-pool-balance)
  (ok (var-get reward-pool-balance)))

;; Get leaderboard entry for a player
(define-read-only (get-leaderboard-entry (player principal))
  (map-get? leaderboard { player: player }))

;; Check if a player has already answered a question correctly
(define-read-only (has-answered-correctly (player principal) (question-id uint))
  (match (map-get? attempts { player: player, question-id: question-id })
    a (get correct a)
    false))

;; Get a player's current answer streak
(define-read-only (get-streak (player principal))
  (match (map-get? player-stats { player: player })
    s (get current-streak s)
    u0))

;; Get the active commitment for a player+question pair (if any)
(define-read-only (get-commitment (player principal) (question-id uint))
  (map-get? commitments { player: player, question-id: question-id }))

;; ============================================================
;; Utility Testing Functions
;; ============================================================

(define-data-var test-counter uint u0)

(define-public (increment)
  (begin
    (var-set test-counter (+ (var-get test-counter) u1))
    (ok (var-get test-counter))
  )
)

(define-public (decrement)
  (begin
    (asserts! (> (var-get test-counter) u0) (err u0))
    (var-set test-counter (- (var-get test-counter) u1))
    (ok (var-get test-counter))
  )
)

Functions (24)

FunctionAccessArgs
get-difficulty-rewardprivatedifficulty: uint
get-streak-multiplierprivatestreak: uint
apply-multiplierprivatereward: uint, basis-points: uint
calc-pointsprivatebase-reward: uint, attempt-num: uint
ensure-player-statsprivateplayer: principal
ensure-leaderboardprivateplayer: principal
update-player-stats-after-revealprivateplayer: principal, is-correct: bool, earned: uint
update-leaderboard-after-revealprivateplayer: principal, is-correct: bool, points: uint, earned: uint
add-questionpublictext: (string-utf8 512
retire-questionpublicquestion-id: uint
fund-reward-poolpublicamount: uint
commit-answerpublicquestion-id: uint, answer-hash: (buff 32
reveal-answerpublicquestion-id: uint, answer: (buff 128
get-questionread-onlyquestion-id: uint
get-attemptread-onlyplayer: principal, question-id: uint
get-player-statsread-onlyplayer: principal
get-question-countread-only
get-reward-pool-balanceread-only
get-leaderboard-entryread-onlyplayer: principal
has-answered-correctlyread-onlyplayer: principal, question-id: uint
get-streakread-onlyplayer: principal
get-commitmentread-onlyplayer: principal, question-id: uint
incrementpublic
decrementpublic