Source Code

;; PULSE - Daily Ritual dApp Smart Contract
;; Clarity 4 Implementation for Stacks Blockchain
;; 
;; Security Considerations:
;; - No loops to prevent DoS attacks
;; - All inputs are validated
;; - Uses stacks-block-height for time tracking (immutable)
;; - Principal-based access control
;; - No unbounded data structures

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

;; Error codes
(define-constant ERR-NOT-AUTHORIZED (err u100))
(define-constant ERR-ALREADY-CHECKED-IN (err u101))
(define-constant ERR-USER-NOT-FOUND (err u102))
(define-constant ERR-INVALID-QUEST-ID (err u103))
(define-constant ERR-QUEST-ALREADY-COMPLETED (err u104))
(define-constant ERR-STREAK-BROKEN (err u105))
(define-constant ERR-INVALID-AMOUNT (err u106))
(define-constant ERR-INSUFFICIENT-BALANCE (err u107))
(define-constant ERR-COOLDOWN-ACTIVE (err u108))
(define-constant ERR-MILESTONE-NOT-REACHED (err u109))

;; Contract owner
(define-constant CONTRACT-OWNER tx-sender)

;; Time constants (in blocks, ~10 min per block on Stacks)
;; 1 day ~ 144 blocks
(define-constant BLOCKS-PER-DAY u144)
;; Streak grace period: 2 days
(define-constant STREAK-GRACE-PERIOD u288)
;; Combo window: 30 blocks (~5 hours)
(define-constant COMBO-WINDOW u30)

;; Quest IDs (1-10)
(define-constant QUEST-DAILY-CHECKIN u1)
(define-constant QUEST-RELAY-SIGNAL u2)
(define-constant QUEST-UPDATE-ATMOSPHERE u3)
(define-constant QUEST-NUDGE-FRIEND u4)
(define-constant QUEST-MINT-HOUR-BADGE u5)
(define-constant QUEST-COMMIT-MESSAGE u6)
(define-constant QUEST-STAKE-STREAK u7)
(define-constant QUEST-CLAIM-MILESTONE u8)
(define-constant QUEST-PREDICT-PULSE u9)
(define-constant QUEST-OPEN-CAPSULE u10)

;; Points per quest
(define-constant POINTS-DAILY-CHECKIN u50)
(define-constant POINTS-RELAY-SIGNAL u100)
(define-constant POINTS-UPDATE-ATMOSPHERE u30)
(define-constant POINTS-NUDGE-FRIEND u40)
(define-constant POINTS-MINT-HOUR-BADGE u60)
(define-constant POINTS-COMMIT-MESSAGE u20)
(define-constant POINTS-STAKE-STREAK u200)
(define-constant POINTS-CLAIM-MILESTONE u500)
(define-constant POINTS-PREDICT-PULSE u80)
(define-constant POINTS-OPEN-CAPSULE u1000)

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

;; User profile data
(define-map user-profiles
    principal
    {
        total-points: uint,
        current-streak: uint,
        longest-streak: uint,
        last-checkin-block: uint,
        total-checkins: uint,
        level: uint,
        staked-amount: uint,
        joined-block: uint
    }
)

;; Daily quest completion tracking (user + day-number -> quest completion bitmap)
(define-map daily-quests
    { user: principal, day: uint }
    {
        completed-quests: uint,  ;; Bitmap of completed quests (bits 1-10)
        first-quest-block: uint, ;; For combo tracking
        combo-activated: bool
    }
)

;; User messages (for commit-message quest)
(define-map user-messages
    { user: principal, message-id: uint }
    {
        content: (string-utf8 280),
        stacks-block-height: uint
    }
)

;; Message counter per user
(define-map user-message-count principal uint)

;; Nudge tracking (who nudged whom today)
(define-map nudges
    { nudger: principal, nudged: principal, day: uint }
    bool
)

;; Predictions for predict-pulse quest
(define-map predictions
    { user: principal, day: uint }
    {
        predicted-activity: uint,
        prediction-block: uint
    }
)

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

;; Global statistics
(define-data-var total-users uint u0)
(define-data-var total-checkins uint u0)
(define-data-var total-points-distributed uint u0)

;; Contract pause state
(define-data-var contract-paused bool false)

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

;; Get current day number from genesis (based on block height)
(define-private (get-current-day)
    (/ stacks-block-height BLOCKS-PER-DAY)
)

;; Check if a specific quest is completed in the bitmap
(define-private (is-quest-completed (bitmap uint) (quest-id uint))
    (> (bit-and bitmap (pow u2 quest-id)) u0)
)

;; Set a quest as completed in bitmap
(define-private (set-quest-completed (bitmap uint) (quest-id uint))
    (bit-or bitmap (pow u2 quest-id))
)

;; Calculate streak multiplier (1x to 3x based on streak length)
(define-private (get-streak-multiplier (streak uint))
    (if (<= streak u7)
        u1
        (if (<= streak u30)
            u2
            u3
        )
    )
)

;; Check if streak is still valid
(define-private (is-streak-valid (last-checkin uint))
    (if (is-eq last-checkin u0)
        true
        (<= (- stacks-block-height last-checkin) STREAK-GRACE-PERIOD)
    )
)

;; Initialize user if not exists
(define-private (ensure-user-exists (user principal))
    (match (map-get? user-profiles user)
        existing-profile true
        (begin
            (map-set user-profiles user {
                total-points: u0,
                current-streak: u0,
                longest-streak: u0,
                last-checkin-block: u0,
                total-checkins: u0,
                level: u1,
                staked-amount: u0,
                joined-block: stacks-block-height
            })
            (var-set total-users (+ (var-get total-users) u1))
            true
        )
    )
)

;; Add points to user with streak multiplier
(define-private (add-points (user principal) (base-points uint))
    (let (
        (profile (unwrap-panic (map-get? user-profiles user)))
        (multiplier (get-streak-multiplier (get current-streak profile)))
        (points-to-add (* base-points multiplier))
        (new-total (+ (get total-points profile) points-to-add))
    )
        (map-set user-profiles user (merge profile { 
            total-points: new-total,
            level: (+ u1 (/ new-total u1000))
        }))
        (var-set total-points-distributed (+ (var-get total-points-distributed) points-to-add))
        points-to-add
    )
)

;; Update streak on check-in
(define-private (update-streak (user principal))
    (let (
        (profile (unwrap-panic (map-get? user-profiles user)))
        (last-checkin (get last-checkin-block profile))
        (current-streak (get current-streak profile))
        (is-consecutive (and 
            (> last-checkin u0)
            (<= (- stacks-block-height last-checkin) BLOCKS-PER-DAY)
            (> (- stacks-block-height last-checkin) u0)
        ))
        (new-streak (if is-consecutive
            (+ current-streak u1)
            u1
        ))
        (new-longest (if (> new-streak (get longest-streak profile))
            new-streak
            (get longest-streak profile)
        ))
    )
        (map-set user-profiles user (merge profile {
            current-streak: new-streak,
            longest-streak: new-longest,
            last-checkin-block: stacks-block-height,
            total-checkins: (+ (get total-checkins profile) u1)
        }))
        new-streak
    )
)

;; Check if daily combo is achieved (quests 1, 3, 6 within combo window)
(define-private (check-daily-combo (user principal) (day uint))
    (let (
        (daily-data (unwrap-panic (map-get? daily-quests { user: user, day: day })))
        (completed (get completed-quests daily-data))
        (first-block (get first-quest-block daily-data))
    )
        ;; Check if quests 1, 3, and 6 are completed
        (and
            (is-quest-completed completed QUEST-DAILY-CHECKIN)
            (is-quest-completed completed QUEST-UPDATE-ATMOSPHERE)
            (is-quest-completed completed QUEST-COMMIT-MESSAGE)
            ;; And within combo window
            (<= (- stacks-block-height first-block) COMBO-WINDOW)
            ;; And not already claimed
            (not (get combo-activated daily-data))
        )
    )
)

;; ============================================
;; PUBLIC FUNCTIONS - QUESTS
;; ============================================

;; Quest 1: Daily Check-In
(define-public (daily-checkin)
    (let (
        (user tx-sender)
        (day (get-current-day))
    )
        ;; Ensure contract not paused
        (asserts! (not (var-get contract-paused)) ERR-NOT-AUTHORIZED)
        
        ;; Initialize user if needed
        (ensure-user-exists user)
        
        ;; Check if already checked in today
        (let (
            (daily-data (default-to 
                { completed-quests: u0, first-quest-block: stacks-block-height, combo-activated: false }
                (map-get? daily-quests { user: user, day: day })
            ))
        )
            (asserts! (not (is-quest-completed (get completed-quests daily-data) QUEST-DAILY-CHECKIN)) 
                ERR-ALREADY-CHECKED-IN)
            
            ;; Update daily quests
            (map-set daily-quests { user: user, day: day } {
                completed-quests: (set-quest-completed (get completed-quests daily-data) QUEST-DAILY-CHECKIN),
                first-quest-block: (if (is-eq (get completed-quests daily-data) u0) 
                    stacks-block-height 
                    (get first-quest-block daily-data)
                ),
                combo-activated: (get combo-activated daily-data)
            })
            
            ;; Update streak
            (update-streak user)
            
            ;; Add points
            (let ((points-earned (add-points user POINTS-DAILY-CHECKIN)))
                ;; Update global stats
                (var-set total-checkins (+ (var-get total-checkins) u1))
                
                (ok {
                    points-earned: points-earned,
                    day: day,
                    quest-id: QUEST-DAILY-CHECKIN
                })
            )
        )
    )
)

;; Quest 2: Relay Signal (pass the torch to another timezone)
(define-public (relay-signal)
    (let (
        (user tx-sender)
        (day (get-current-day))
    )
        (asserts! (not (var-get contract-paused)) ERR-NOT-AUTHORIZED)
        (ensure-user-exists user)
        
        (let (
            (daily-data (default-to 
                { completed-quests: u0, first-quest-block: stacks-block-height, combo-activated: false }
                (map-get? daily-quests { user: user, day: day })
            ))
        )
            (asserts! (not (is-quest-completed (get completed-quests daily-data) QUEST-RELAY-SIGNAL)) 
                ERR-QUEST-ALREADY-COMPLETED)
            
            (map-set daily-quests { user: user, day: day } (merge daily-data {
                completed-quests: (set-quest-completed (get completed-quests daily-data) QUEST-RELAY-SIGNAL)
            }))
            
            (let ((points-earned (add-points user POINTS-RELAY-SIGNAL)))
                (ok {
                    points-earned: points-earned,
                    day: day,
                    quest-id: QUEST-RELAY-SIGNAL
                })
            )
        )
    )
)

;; Quest 3: Update Atmosphere
(define-public (update-atmosphere (weather-code uint))
    (let (
        (user tx-sender)
        (day (get-current-day))
    )
        (asserts! (not (var-get contract-paused)) ERR-NOT-AUTHORIZED)
        ;; Validate weather code (0-10 range)
        (asserts! (<= weather-code u10) ERR-INVALID-QUEST-ID)
        (ensure-user-exists user)
        
        (let (
            (daily-data (default-to 
                { completed-quests: u0, first-quest-block: stacks-block-height, combo-activated: false }
                (map-get? daily-quests { user: user, day: day })
            ))
        )
            (asserts! (not (is-quest-completed (get completed-quests daily-data) QUEST-UPDATE-ATMOSPHERE)) 
                ERR-QUEST-ALREADY-COMPLETED)
            
            (map-set daily-quests { user: user, day: day } (merge daily-data {
                completed-quests: (set-quest-completed (get completed-quests daily-data) QUEST-UPDATE-ATMOSPHERE)
            }))
            
            (let ((points-earned (add-points user POINTS-UPDATE-ATMOSPHERE)))
                (ok {
                    points-earned: points-earned,
                    day: day,
                    quest-id: QUEST-UPDATE-ATMOSPHERE,
                    weather-code: weather-code
                })
            )
        )
    )
)

;; Quest 4: Nudge Friend
(define-public (nudge-friend (friend principal))
    (let (
        (user tx-sender)
        (day (get-current-day))
    )
        (asserts! (not (var-get contract-paused)) ERR-NOT-AUTHORIZED)
        ;; Can't nudge yourself
        (asserts! (not (is-eq user friend)) ERR-NOT-AUTHORIZED)
        ;; Friend must exist
        (asserts! (is-some (map-get? user-profiles friend)) ERR-USER-NOT-FOUND)
        ;; Haven't already nudged this friend today
        (asserts! (is-none (map-get? nudges { nudger: user, nudged: friend, day: day })) ERR-QUEST-ALREADY-COMPLETED)
        
        (ensure-user-exists user)
        
        ;; Record the nudge
        (map-set nudges { nudger: user, nudged: friend, day: day } true)
        
        (let (
            (daily-data (default-to 
                { completed-quests: u0, first-quest-block: stacks-block-height, combo-activated: false }
                (map-get? daily-quests { user: user, day: day })
            ))
        )
            (map-set daily-quests { user: user, day: day } (merge daily-data {
                completed-quests: (set-quest-completed (get completed-quests daily-data) QUEST-NUDGE-FRIEND)
            }))
            
            (let ((points-earned (add-points user POINTS-NUDGE-FRIEND)))
                (ok {
                    points-earned: points-earned,
                    day: day,
                    quest-id: QUEST-NUDGE-FRIEND,
                    friend: friend
                })
            )
        )
    )
)

;; Quest 6: Commit Message
(define-public (commit-message (message (string-utf8 280)))
    (let (
        (user tx-sender)
        (day (get-current-day))
        (message-count (default-to u0 (map-get? user-message-count user)))
    )
        (asserts! (not (var-get contract-paused)) ERR-NOT-AUTHORIZED)
        (ensure-user-exists user)
        
        ;; Store the message
        (map-set user-messages { user: user, message-id: message-count } {
            content: message,
            stacks-block-height: stacks-block-height
        })
        (map-set user-message-count user (+ message-count u1))
        
        (let (
            (daily-data (default-to 
                { completed-quests: u0, first-quest-block: stacks-block-height, combo-activated: false }
                (map-get? daily-quests { user: user, day: day })
            ))
        )
            (map-set daily-quests { user: user, day: day } (merge daily-data {
                completed-quests: (set-quest-completed (get completed-quests daily-data) QUEST-COMMIT-MESSAGE)
            }))
            
            (let ((points-earned (add-points user POINTS-COMMIT-MESSAGE)))
                (ok {
                    points-earned: points-earned,
                    day: day,
                    quest-id: QUEST-COMMIT-MESSAGE,
                    message-id: message-count
                })
            )
        )
    )
)

;; Quest 9: Predict Pulse (predict tomorrow's activity level)
(define-public (predict-pulse (predicted-level uint))
    (let (
        (user tx-sender)
        (day (get-current-day))
    )
        (asserts! (not (var-get contract-paused)) ERR-NOT-AUTHORIZED)
        ;; Validate prediction (1-10 scale)
        (asserts! (and (>= predicted-level u1) (<= predicted-level u10)) ERR-INVALID-AMOUNT)
        (ensure-user-exists user)
        
        ;; Store prediction
        (map-set predictions { user: user, day: day } {
            predicted-activity: predicted-level,
            prediction-block: stacks-block-height
        })
        
        (let (
            (daily-data (default-to 
                { completed-quests: u0, first-quest-block: stacks-block-height, combo-activated: false }
                (map-get? daily-quests { user: user, day: day })
            ))
        )
            (map-set daily-quests { user: user, day: day } (merge daily-data {
                completed-quests: (set-quest-completed (get completed-quests daily-data) QUEST-PREDICT-PULSE)
            }))
            
            (let ((points-earned (add-points user POINTS-PREDICT-PULSE)))
                (ok {
                    points-earned: points-earned,
                    day: day,
                    quest-id: QUEST-PREDICT-PULSE,
                    prediction: predicted-level
                })
            )
        )
    )
)

;; Claim Daily Combo Bonus
(define-public (claim-daily-combo)
    (let (
        (user tx-sender)
        (day (get-current-day))
    )
        (asserts! (not (var-get contract-paused)) ERR-NOT-AUTHORIZED)
        (asserts! (is-some (map-get? user-profiles user)) ERR-USER-NOT-FOUND)
        
        (let ((daily-data (unwrap! (map-get? daily-quests { user: user, day: day }) ERR-USER-NOT-FOUND)))
            ;; Check combo conditions
            (asserts! (check-daily-combo user day) ERR-INVALID-QUEST-ID)
            
            ;; Mark combo as claimed
            (map-set daily-quests { user: user, day: day } (merge daily-data {
                combo-activated: true
            }))
            
            ;; Award combo bonus (2x standard points)
            (let ((bonus-points (add-points user u200)))
                (ok {
                    bonus-points: bonus-points,
                    day: day,
                    combo-type: "daily-triple"
                })
            )
        )
    )
)

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

;; Get user profile
(define-read-only (get-user-profile (user principal))
    (map-get? user-profiles user)
)

;; Get user's daily quest status
(define-read-only (get-daily-quest-status (user principal) (day uint))
    (map-get? daily-quests { user: user, day: day })
)

;; Get current day
(define-read-only (get-day)
    (get-current-day)
)

;; Get global stats
(define-read-only (get-global-stats)
    {
        total-users: (var-get total-users),
        total-checkins: (var-get total-checkins),
        total-points-distributed: (var-get total-points-distributed)
    }
)

;; Check if user has completed a specific quest today
(define-read-only (has-completed-quest-today (user principal) (quest-id uint))
    (let (
        (day (get-current-day))
        (daily-data (map-get? daily-quests { user: user, day: day }))
    )
        (match daily-data
            data (is-quest-completed (get completed-quests data) quest-id)
            false
        )
    )
)

;; Get user's message
(define-read-only (get-user-message (user principal) (message-id uint))
    (map-get? user-messages { user: user, message-id: message-id })
)

;; Check if combo is available
(define-read-only (is-combo-available (user principal))
    (let ((day (get-current-day)))
        (match (map-get? daily-quests { user: user, day: day })
            daily-data (check-daily-combo user day)
            false
        )
    )
)

;; ============================================
;; ADMIN FUNCTIONS
;; ============================================

;; Pause/unpause contract
(define-public (set-contract-paused (paused bool))
    (begin
        (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-AUTHORIZED)
        (var-set contract-paused paused)
        (ok true)
    )
)

;; Check if contract is paused
(define-read-only (is-paused)
    (var-get contract-paused)
)

Functions (25)

FunctionAccessArgs
get-current-dayprivate
is-quest-completedprivatebitmap: uint, quest-id: uint
set-quest-completedprivatebitmap: uint, quest-id: uint
get-streak-multiplierprivatestreak: uint
is-streak-validprivatelast-checkin: uint
ensure-user-existsprivateuser: principal
add-pointsprivateuser: principal, base-points: uint
update-streakprivateuser: principal
check-daily-comboprivateuser: principal, day: uint
daily-checkinpublic
relay-signalpublic
update-atmospherepublicweather-code: uint
nudge-friendpublicfriend: principal
commit-messagepublicmessage: (string-utf8 280
predict-pulsepublicpredicted-level: uint
claim-daily-combopublic
get-user-profileread-onlyuser: principal
get-daily-quest-statusread-onlyuser: principal, day: uint
get-dayread-only
get-global-statsread-only
has-completed-quest-todayread-onlyuser: principal, quest-id: uint
get-user-messageread-onlyuser: principal, message-id: uint
is-combo-availableread-onlyuser: principal
set-contract-pausedpublicpaused: bool
is-pausedread-only