Source Code

;; halo-sbtc-staking.clar
;; sBTC staking for rewards and credit score boosts
;;
;; Features:
;; - Stake sBTC (or mock-sbtc in testing) to earn rewards
;; - Minimum lock period (default ~1 month = 4320 blocks)
;; - Synthetix-style reward accumulator
;; - Cross-contract: records staking activity in halo-credit for score boost
;; - Admin-funded reward pool
;;
;; Dependencies: halo-sip010-trait, halo-identity, halo-credit

(use-trait ft-trait .halo-sip010-trait.sip-010-trait)

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

(define-constant CONTRACT_OWNER tx-sender)
(define-constant ERR_NOT_AUTHORIZED (err u600))
(define-constant ERR_INVALID_AMOUNT (err u601))
(define-constant ERR_NO_STAKE (err u602))
(define-constant ERR_LOCK_NOT_EXPIRED (err u603))
(define-constant ERR_INSUFFICIENT_STAKE (err u604))
(define-constant ERR_TOKEN_MISMATCH (err u605))
(define-constant ERR_STAKING_TOKEN_NOT_SET (err u606))
(define-constant ERR_INVALID_PARAMS (err u607))
(define-constant ERR_NOT_VERIFIED (err u608))
(define-constant ERR_NO_REWARDS (err u609))

;; Default minimum lock: ~1 month (4320 blocks)
(define-constant DEFAULT_MIN_LOCK u4320)

;; Yield precision (10^12)
(define-constant PRECISION u1000000000000)

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

(define-data-var admin principal CONTRACT_OWNER)
(define-data-var staking-token-principal (optional principal) none)
(define-data-var min-lock-blocks uint DEFAULT_MIN_LOCK)

;; Reward accumulator (Synthetix pattern)
(define-data-var reward-per-token-stored uint u0)
(define-data-var last-update-block uint u0)
(define-data-var reward-rate uint u0)       ;; reward tokens per block
(define-data-var reward-end-block uint u0)  ;; block when current reward period ends
(define-data-var total-staked uint u0)

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

;; User staking data
(define-map staker-data principal {
  staked: uint,                    ;; total staked amount
  staked-at: uint,                 ;; block when first staked (for credit scoring)
  last-stake-block: uint,          ;; block of most recent stake action
  reward-per-token-paid: uint,     ;; snapshot for yield calculation
  rewards-earned: uint             ;; accumulated unclaimed rewards
})

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

;; Min of two uints
(define-private (min-uint (a uint) (b uint))
  (if (<= a b) a b)
)

;; Update global reward accumulator
(define-private (update-reward)
  (let (
    (current-block stacks-block-height)
    (applicable-block (min-uint current-block (var-get reward-end-block)))
    (total (var-get total-staked))
    (stored (var-get reward-per-token-stored))
    (last-block (var-get last-update-block))
  )
    (if (and (> total u0) (> applicable-block last-block))
      (let (
        (elapsed (- applicable-block last-block))
        (new-rewards (* elapsed (var-get reward-rate)))
        (additional (/ (* new-rewards PRECISION) total))
      )
        (var-set reward-per-token-stored (+ stored additional))
        (var-set last-update-block applicable-block)
      )
      (var-set last-update-block applicable-block)
    )
  )
)

;; Update user's reward snapshot (only if user has existing stake)
(define-private (update-user-reward (user principal))
  (match (map-get? staker-data user)
    stake-info (let (
      (staked (get staked stake-info))
      (paid (get reward-per-token-paid stake-info))
      (earned (get rewards-earned stake-info))
      (current-rpt (var-get reward-per-token-stored))
      (new-earned (+ earned (/ (* staked (- current-rpt paid)) PRECISION)))
    )
      (map-set staker-data user
        (merge stake-info {
          rewards-earned: new-earned,
          reward-per-token-paid: current-rpt
        })
      )
    )
    true ;; No stake exists, nothing to update
  )
)

;; Check if user has verified identity
(define-private (is-verified (user principal))
  (is-some (contract-call? .halo-identity get-id-by-wallet user))
)

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

;; Get user's staking data
(define-read-only (get-staker-data (user principal))
  (map-get? staker-data user)
)

;; Get total staked across all users
(define-read-only (get-total-staked)
  (var-get total-staked)
)

;; Get staking configuration
(define-read-only (get-staking-config)
  {
    staking-token: (var-get staking-token-principal),
    min-lock-blocks: (var-get min-lock-blocks),
    total-staked: (var-get total-staked),
    reward-rate: (var-get reward-rate),
    reward-end-block: (var-get reward-end-block),
    reward-per-token-stored: (var-get reward-per-token-stored)
  }
)

;; Get admin
(define-read-only (get-admin)
  (var-get admin)
)

;; Check if user's lock period has expired
(define-read-only (is-lock-expired (user principal))
  (match (map-get? staker-data user)
    stake-info (>= stacks-block-height
                   (+ (get last-stake-block stake-info) (var-get min-lock-blocks)))
    true ;; No stake = not locked
  )
)

;; Get staking duration in blocks
(define-read-only (get-staking-duration (user principal))
  (match (map-get? staker-data user)
    stake-info (if (> stacks-block-height (get staked-at stake-info))
                  (- stacks-block-height (get staked-at stake-info))
                  u0)
    u0
  )
)

;; Get pending rewards for user (view function, no state change)
(define-read-only (get-pending-rewards (user principal))
  (match (map-get? staker-data user)
    stake-info (let (
      (staked (get staked stake-info))
      (paid (get reward-per-token-paid stake-info))
      (earned (get rewards-earned stake-info))
      (stored (var-get reward-per-token-stored))
      (total (var-get total-staked))
      (current-block stacks-block-height)
      (applicable-block (min-uint current-block (var-get reward-end-block)))
      (last-block (var-get last-update-block))
      (current-rpt (if (and (> total u0) (> applicable-block last-block))
        (+ stored (/ (* (* (- applicable-block last-block) (var-get reward-rate)) PRECISION) total))
        stored
      ))
    )
      (+ earned (/ (* staked (- current-rpt paid)) PRECISION))
    )
    u0
  )
)

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

;; Stake sBTC tokens
(define-public (stake-sbtc (token <ft-trait>) (amount uint))
  (let (
    (caller tx-sender)
    (expected-token (unwrap! (var-get staking-token-principal) ERR_STAKING_TOKEN_NOT_SET))
  )
    ;; Validate
    (asserts! (is-eq (contract-of token) expected-token) ERR_TOKEN_MISMATCH)
    (asserts! (> amount u0) ERR_INVALID_AMOUNT)
    (asserts! (is-verified caller) ERR_NOT_VERIFIED)

    ;; Update reward accumulator
    (update-reward)
    (update-user-reward caller)

    ;; Transfer tokens from user to staking contract
    (try! (contract-call? token transfer amount caller (as-contract tx-sender) none))

    ;; Update staker data
    (let (
      (existing (map-get? staker-data caller))
      (current-staked (match existing info (get staked info) u0))
      (staked-at-block (match existing info (get staked-at info) stacks-block-height))
      (current-earned (match existing info (get rewards-earned info) u0))
    )
      (map-set staker-data caller {
        staked: (+ current-staked amount),
        staked-at: staked-at-block,
        last-stake-block: stacks-block-height,
        reward-per-token-paid: (var-get reward-per-token-stored),
        rewards-earned: current-earned
      })
    )

    ;; Update global total
    (var-set total-staked (+ (var-get total-staked) amount))

    ;; Record staking activity in credit contract
    (match (contract-call? .halo-identity get-id-by-wallet caller)
      unique-id (match (contract-call? .halo-credit record-staking-activity
                         unique-id
                         (+ (match (map-get? staker-data caller)
                              info (get staked info)
                              u0)
                            u0) ;; use 0 as fallback, but staker-data was just set
                         (get-staking-duration caller))
        success true
        error true ;; Non-critical: don't fail stake if credit recording fails
      )
      true ;; Non-critical
    )

    (print {
      event: "sbtc-staked",
      user: caller,
      amount: amount,
      total-staked: (var-get total-staked)
    })

    (ok true)
  )
)

;; Unstake sBTC tokens (after lock period)
(define-public (unstake-sbtc (token <ft-trait>) (amount uint))
  (let (
    (caller tx-sender)
    (expected-token (unwrap! (var-get staking-token-principal) ERR_STAKING_TOKEN_NOT_SET))
    (stake-info (unwrap! (map-get? staker-data caller) ERR_NO_STAKE))
  )
    ;; Validate
    (asserts! (is-eq (contract-of token) expected-token) ERR_TOKEN_MISMATCH)
    (asserts! (> amount u0) ERR_INVALID_AMOUNT)
    (asserts! (<= amount (get staked stake-info)) ERR_INSUFFICIENT_STAKE)

    ;; Check lock period expired
    (asserts! (>= stacks-block-height
                  (+ (get last-stake-block stake-info) (var-get min-lock-blocks)))
              ERR_LOCK_NOT_EXPIRED)

    ;; Update reward accumulator
    (update-reward)
    (update-user-reward caller)

    ;; Re-read after reward update
    (let (
      (updated-info (unwrap! (map-get? staker-data caller) ERR_NO_STAKE))
      (new-staked (- (get staked updated-info) amount))
    )
      ;; Transfer tokens back to user
      (try! (as-contract (contract-call? token transfer amount tx-sender caller none)))

      ;; Update staker data
      (map-set staker-data caller
        (merge updated-info {
          staked: new-staked
        })
      )

      ;; Update global total
      (var-set total-staked (- (var-get total-staked) amount))

      (print {
        event: "sbtc-unstaked",
        user: caller,
        amount: amount,
        remaining-staked: new-staked
      })

      (ok true)
    )
  )
)

;; Claim staking rewards
(define-public (claim-rewards (token <ft-trait>))
  (let (
    (caller tx-sender)
    (expected-token (unwrap! (var-get staking-token-principal) ERR_STAKING_TOKEN_NOT_SET))
  )
    (asserts! (is-eq (contract-of token) expected-token) ERR_TOKEN_MISMATCH)

    ;; Update reward accumulator
    (update-reward)
    (update-user-reward caller)

    ;; Read updated data
    (let (
      (stake-info (unwrap! (map-get? staker-data caller) ERR_NO_STAKE))
      (reward (get rewards-earned stake-info))
    )
      (asserts! (> reward u0) ERR_NO_REWARDS)

      ;; Transfer reward tokens
      (try! (as-contract (contract-call? token transfer reward tx-sender caller none)))

      ;; Reset earned rewards
      (map-set staker-data caller
        (merge stake-info { rewards-earned: u0 })
      )

      (print {
        event: "staking-rewards-claimed",
        user: caller,
        amount: reward
      })

      (ok reward)
    )
  )
)

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

;; Set the staking token (sBTC or mock-sbtc)
(define-public (set-staking-token (token-principal principal))
  (begin
    (asserts! (is-eq tx-sender (var-get admin)) ERR_NOT_AUTHORIZED)
    (var-set staking-token-principal (some token-principal))
    (print { event: "staking-token-set", token: token-principal })
    (ok true)
  )
)

;; Fund reward pool (admin deposits reward tokens)
(define-public (fund-reward-pool (token <ft-trait>) (amount uint) (duration-blocks uint))
  (let (
    (expected-token (unwrap! (var-get staking-token-principal) ERR_STAKING_TOKEN_NOT_SET))
  )
    (asserts! (is-eq tx-sender (var-get admin)) ERR_NOT_AUTHORIZED)
    (asserts! (is-eq (contract-of token) expected-token) ERR_TOKEN_MISMATCH)
    (asserts! (> amount u0) ERR_INVALID_AMOUNT)
    (asserts! (> duration-blocks u0) ERR_INVALID_PARAMS)

    ;; Update accumulated rewards before changing rate
    (update-reward)

    ;; Transfer reward tokens to contract
    (try! (contract-call? token transfer amount tx-sender (as-contract tx-sender) none))

    ;; Calculate new rate, rolling over remaining rewards
    (let (
      (current-block stacks-block-height)
      (remaining (if (> (var-get reward-end-block) current-block)
                    (* (var-get reward-rate) (- (var-get reward-end-block) current-block))
                    u0))
      (total-reward (+ amount remaining))
      (new-rate (/ total-reward duration-blocks))
    )
      (var-set reward-rate new-rate)
      (var-set reward-end-block (+ current-block duration-blocks))
      (var-set last-update-block current-block)

      (print {
        event: "reward-pool-funded",
        amount: amount,
        duration-blocks: duration-blocks,
        new-rate: new-rate
      })

      (ok true)
    )
  )
)

;; Set minimum lock period
(define-public (set-min-lock-blocks (new-min uint))
  (begin
    (asserts! (is-eq tx-sender (var-get admin)) ERR_NOT_AUTHORIZED)
    (asserts! (> new-min u0) ERR_INVALID_PARAMS)
    (var-set min-lock-blocks new-min)
    (print { event: "min-lock-updated", new-min: new-min })
    (ok true)
  )
)

;; Transfer admin
(define-public (set-admin (new-admin principal))
  (begin
    (asserts! (is-eq tx-sender (var-get admin)) ERR_NOT_AUTHORIZED)
    (var-set admin new-admin)
    (print { event: "admin-transferred", new-admin: new-admin })
    (ok true)
  )
)

Functions (18)

FunctionAccessArgs
min-uintprivatea: uint, b: uint
update-rewardprivate
update-user-rewardprivateuser: principal
is-verifiedprivateuser: principal
get-staker-dataread-onlyuser: principal
get-total-stakedread-only
get-staking-configread-only
get-adminread-only
is-lock-expiredread-onlyuser: principal
get-staking-durationread-onlyuser: principal
get-pending-rewardsread-onlyuser: principal
stake-sbtcpublictoken: <ft-trait>, amount: uint
unstake-sbtcpublictoken: <ft-trait>, amount: uint
claim-rewardspublictoken: <ft-trait>
set-staking-tokenpublictoken-principal: principal
fund-reward-poolpublictoken: <ft-trait>, amount: uint, duration-blocks: uint
set-min-lock-blockspublicnew-min: uint
set-adminpublicnew-admin: principal