Source Code

;; ============================================
;; AhhbitTracker - On-Chain Habit Tracking with Staking
;; ============================================
;; 
;; A Stacks smart contract for habit accountability through financial commitment.
;; Users stake STX on daily habits and check in every 24 hours to maintain streaks.
;; Missed check-ins result in stake forfeiture to a shared pool.
;;
;; Version: 0.1.0
;; Network: Stacks Mainnet
;; ============================================

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

;; Minimum stake required (in microSTX)
;; 1 STX = 1,000,000 microSTX
;; Minimum: 0.1 STX = 100,000 microSTX
(define-constant MIN-STAKE-AMOUNT u100000)

;; Maximum habit name length
(define-constant MAX-HABIT-NAME-LENGTH u50)

;; Check-in window (in blocks)
;; ~144 blocks per day on Stacks (10 min per block)
(define-constant CHECK-IN-WINDOW u144)

;; Minimum streak required for withdrawal
(define-constant MIN-STREAK-FOR-WITHDRAWAL u7)

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

;; ============================================
;; ERROR CODES
;; ============================================

(define-constant ERR-NOT-AUTHORIZED (err u100))
(define-constant ERR-INVALID-STAKE-AMOUNT (err u101))
(define-constant ERR-INVALID-HABIT-NAME (err u102))
(define-constant ERR-HABIT-NOT-FOUND (err u103))
(define-constant ERR-NOT-HABIT-OWNER (err u104))
(define-constant ERR-ALREADY-CHECKED-IN (err u105))
(define-constant ERR-CHECK-IN-WINDOW-EXPIRED (err u106))
(define-constant ERR-INSUFFICIENT-STREAK (err u107))
(define-constant ERR-HABIT-ALREADY-COMPLETED (err u108))
(define-constant ERR-POOL-INSUFFICIENT-BALANCE (err u109))
(define-constant ERR-TRANSFER-FAILED (err u110))

;; ============================================
;; DATA STRUCTURES
;; ============================================

;; Habit data structure
(define-map habits
  { habit-id: uint }
  {
    owner: principal,
    name: (string-utf8 50),
    stake-amount: uint,
    current-streak: uint,
    last-check-in-block: uint,
    created-at-block: uint,
    is-active: bool,
    is-completed: bool
  }
)

;; User's list of habit IDs
;; Allows querying all habits for a specific user
(define-map user-habits
  { user: principal }
  { habit-ids: (list 100 uint) }
)

;; Global habit ID counter
(define-data-var habit-id-nonce uint u0)

;; Forfeited stakes pool (total available for distribution)
(define-data-var forfeited-pool-balance uint u0)

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

;; Validate habit name
;; Returns true if name is valid (non-empty and within length limit)
(define-private (is-valid-habit-name (name (string-utf8 50)))
  (let
    (
      (name-length (len name))
    )
    (and
      (> name-length u0)
      (<= name-length MAX-HABIT-NAME-LENGTH)
    )
  )
)

;; Generate next habit ID
(define-private (get-next-habit-id)
  (let
    (
      (current-id (var-get habit-id-nonce))
      (next-id (+ current-id u1))
    )
    (var-set habit-id-nonce next-id)
    next-id
  )
)

;; Check if check-in is within valid window
;; Returns true if blocks since last check-in <= CHECK-IN-WINDOW
(define-private (is-check-in-valid (last-check-in-block uint))
  (let
    (
      (blocks-elapsed (- block-height last-check-in-block))
    )
    (<= blocks-elapsed CHECK-IN-WINDOW)
  )
)

;; Check if already checked in today (same block height range)
(define-private (already-checked-in-today (last-check-in-block uint))
  (let
    (
      (blocks-elapsed (- block-height last-check-in-block))
    )
    (< blocks-elapsed u1)
  )
)

;; ============================================
;; PUBLIC FUNCTIONS - HABIT MANAGEMENT
;; ============================================

;; Create a new habit with stake
;; @param name: Habit description (max 50 characters)
;; @param stake-amount: Amount to stake in microSTX (min 0.1 STX)
;; @returns: habit-id on success, error code on failure
(define-public (create-habit (name (string-utf8 50)) (stake-amount uint))
  (let
    (
      (habit-id (get-next-habit-id))
      (caller tx-sender)
    )
    ;; Validate stake amount
    (asserts! (>= stake-amount MIN-STAKE-AMOUNT) ERR-INVALID-STAKE-AMOUNT)
    
    ;; Validate habit name
    (asserts! (is-valid-habit-name name) ERR-INVALID-HABIT-NAME)
    
    ;; Transfer stake from user to contract
    (try! (stx-transfer? stake-amount caller (as-contract tx-sender)))
    
    ;; Store habit data
    (map-set habits
      { habit-id: habit-id }
      {
        owner: caller,
        name: name,
        stake-amount: stake-amount,
        current-streak: u0,
        last-check-in-block: block-height,
        created-at-block: block-height,
        is-active: true,
        is-completed: false
      }
    )
    
    ;; Add habit to user's habit list
    (match (map-get? user-habits { user: caller })
      existing-habits
        (map-set user-habits
          { user: caller }
          { habit-ids: (unwrap! (as-max-len? (append (get habit-ids existing-habits) habit-id) u100) ERR-HABIT-NOT-FOUND) }
        )
      ;; First habit for this user
      (map-set user-habits
        { user: caller }
        { habit-ids: (list habit-id) }
      )
    )
    
    ;; Emit event
    (print {
      event: "habit-created",
      habit-id: habit-id,
      owner: caller,
      stake-amount: stake-amount,
      block: block-height
    })
    
    ;; Return habit ID
    (ok habit-id)
  )
)

;; Daily check-in for habit
;; @param habit-id: ID of the habit to check in
;; @returns: current streak on success, error on failure
(define-public (check-in (habit-id uint))
  (let
    (
      (caller tx-sender)
      (habit (unwrap! (map-get? habits { habit-id: habit-id }) ERR-HABIT-NOT-FOUND))
      (last-check-in (get last-check-in-block habit))
      (current-streak (get current-streak habit))
      (stake-amount (get stake-amount habit))
    )
    ;; Verify caller is habit owner
    (asserts! (is-eq caller (get owner habit)) ERR-NOT-HABIT-OWNER)
    
    ;; Verify habit is still active
    (asserts! (get is-active habit) ERR-HABIT-ALREADY-COMPLETED)
    
    ;; Check if already checked in today
    (asserts! (not (already-checked-in-today last-check-in)) ERR-ALREADY-CHECKED-IN)
    ;; Check if within valid window
    (if (is-check-in-valid last-check-in)
      ;; Valid check-in: increment streak
      (begin
        (map-set habits
          { habit-id: habit-id }
          (merge habit {
            current-streak: (+ current-streak u1),
            last-check-in-block: block-height
          })
        )
        ;; Emit event
        (print {
          event: "habit-checked-in",
          habit-id: habit-id,
          owner: caller,
          new-streak: (+ current-streak u1),
          block: block-height
        })
        (ok (+ current-streak u1))
      )
      ;; Missed window: return error (use slash-habit to forfeit)
      ERR-CHECK-IN-WINDOW-EXPIRED
    )
  )
)

;; Slash a habit that has missed its check-in window
;; Anyone can call this to move the stake to the pool
;; @param habit-id: ID of the habit to slash
;; @returns: ok on success, error on failure
(define-public (slash-habit (habit-id uint))
  (let
    (
      (habit (unwrap! (map-get? habits { habit-id: habit-id }) ERR-HABIT-NOT-FOUND))
      (last-check-in (get last-check-in-block habit))
      (stake-amount (get stake-amount habit))
    )
    ;; Verify habit is still active
    (asserts! (get is-active habit) ERR-HABIT-ALREADY-COMPLETED)
    
    ;; Verify window has actually expired
    (asserts! (not (is-check-in-valid last-check-in)) ERR-NOT-AUTHORIZED)
    
    ;; Move stake to pool
    (var-set forfeited-pool-balance (+ (var-get forfeited-pool-balance) stake-amount))
    
    ;; Mark habit as inactive
    (map-set habits
      { habit-id: habit-id }
      (merge habit {
        current-streak: u0,
        is-active: false
      })
    )
    
    ;; Emit event
    (print {
      event: "habit-slashed",
      habit-id: habit-id,
      slasher: tx-sender,
      amount: stake-amount,
      block: block-height
    })
    
    (ok true)
  )
)

;; Withdraw stake after completing minimum streak
;; @param habit-id: ID of the habit
;; @returns: ok on success with stake amount, error on failure
(define-public (withdraw-stake (habit-id uint))
  (let
    (
      (caller tx-sender)
      (habit (unwrap! (map-get? habits { habit-id: habit-id }) ERR-HABIT-NOT-FOUND))
      (stake-amount (get stake-amount habit))
      (current-streak (get current-streak habit))
    )
    ;; Verify caller is habit owner
    (asserts! (is-eq caller (get owner habit)) ERR-NOT-HABIT-OWNER)
    
    ;; Verify habit is active
    (asserts! (get is-active habit) ERR-HABIT-ALREADY-COMPLETED)
    
    ;; Verify minimum streak requirement
    (asserts! (>= current-streak MIN-STREAK-FOR-WITHDRAWAL) ERR-INSUFFICIENT-STREAK)
    
    ;; Transfer stake back to user
    (try! (as-contract (stx-transfer? stake-amount tx-sender caller)))
    
    ;; Mark habit as completed
    (map-set habits
      { habit-id: habit-id }
      (merge habit {
        is-active: false,
        is-completed: true
      })
    )
    
    ;; Emit event
    (print {
      event: "stake-withdrawn",
      habit-id: habit-id,
      owner: caller,
      amount: stake-amount,
      final-streak: current-streak,
      block: block-height
    })
    
    (ok stake-amount)
  )
)

;; Claim bonus from forfeited pool
;; @param habit-id: ID of a completed habit (proof of eligibility)
;; @returns: bonus amount on success, error on failure
(define-public (claim-bonus (habit-id uint))
  (let
    (
      (caller tx-sender)
      (habit (unwrap! (map-get? habits { habit-id: habit-id }) ERR-HABIT-NOT-FOUND))
      (pool-balance (var-get forfeited-pool-balance))
      ;; Simple bonus calculation: 10% of pool per completed habit
      (bonus-amount (/ pool-balance u10))
    )
    ;; Verify caller is habit owner
    (asserts! (is-eq caller (get owner habit)) ERR-NOT-HABIT-OWNER)
    
    ;; Verify habit is completed
    (asserts! (get is-completed habit) ERR-INSUFFICIENT-STREAK)
    
    ;; Verify pool has sufficient balance
    (asserts! (>= pool-balance bonus-amount) ERR-POOL-INSUFFICIENT-BALANCE)
    
    ;; Transfer bonus from pool to user
    (try! (as-contract (stx-transfer? bonus-amount tx-sender caller)))
    
    ;; Update pool balance
    (var-set forfeited-pool-balance (- pool-balance bonus-amount))
    
    ;; Emit event
    (print {
      event: "bonus-claimed",
      habit-id: habit-id,
      owner: caller,
      amount: bonus-amount,
      remaining-pool: (- pool-balance bonus-amount),
      block: block-height
    })
    
    (ok bonus-amount)
  )
)

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

;; Get habit details
(define-read-only (get-habit (habit-id uint))
  (map-get? habits { habit-id: habit-id })
)

;; Get all habit IDs for a user
(define-read-only (get-user-habits (user principal))
  (default-to 
    { habit-ids: (list) }
    (map-get? user-habits { user: user })
  )
)

;; Get current streak for a habit
(define-read-only (get-habit-streak (habit-id uint))
  (match (map-get? habits { habit-id: habit-id })
    habit (ok (get current-streak habit))
    ERR-HABIT-NOT-FOUND
  )
)

;; Get forfeited pool balance
(define-read-only (get-pool-balance)
  (ok (var-get forfeited-pool-balance))
)

;; Get total habits created
(define-read-only (get-total-habits)
  (ok (var-get habit-id-nonce))
)

;; Get aggregated statistics for a user
(define-read-only (get-user-stats (user principal))
  (match (map-get? user-habits { user: user })
    user-habit-data
      (ok {
        total-habits: (len (get habit-ids user-habit-data)),
        habit-ids: (get habit-ids user-habit-data)
      })
    (ok {
      total-habits: u0,
      habit-ids: (list)
    })
  )
)

;; ============================================
;; CONTRACT INITIALIZATION
;; ============================================

;; Contract is ready for use immediately after deployment
;; No initialization function required

Functions (15)

FunctionAccessArgs
is-valid-habit-nameprivatename: (string-utf8 50
get-next-habit-idprivate
is-check-in-validprivatelast-check-in-block: uint
already-checked-in-todayprivatelast-check-in-block: uint
create-habitpublicname: (string-utf8 50
check-inpublichabit-id: uint
slash-habitpublichabit-id: uint
withdraw-stakepublichabit-id: uint
claim-bonuspublichabit-id: uint
get-habitread-onlyhabit-id: uint
get-user-habitsread-onlyuser: principal
get-habit-streakread-onlyhabit-id: uint
get-pool-balanceread-only
get-total-habitsread-only
get-user-statsread-onlyuser: principal