Source Code

;; StackUp Streak V2
;;
;; - Daily claim tracking (deterministic day from block height)
;; - Multiple milestone NFT badges
;; - Token URIs via a local SIP-009 trait
;;
;; Notes:
;; - This is a new contract (immutable once deployed). Deploy as `streak-v3-5`.
;; - Set CONTRACT_OWNER to the deploying principal before mainnet deployment.
;;
;; We intentionally do NOT `impl-trait` a separate SIP-009 trait contract here.
;; Some deployment UIs incorrectly flag a `define-trait` that includes a `transfer`
;; signature as a "token transfer outside a function". The contract still exposes
;; the SIP-009-compatible functions (`get-token-uri`, `get-owner`, `transfer`).

;; Owner is captured at deploy time (the contract deployer).
(define-data-var contract-owner principal tx-sender)

(define-constant ERR_ALREADY_CLAIMED u100)
(define-constant ERR_NOT_OWNER u101)
(define-constant ERR_BADGE_ALREADY_MINTED u102)
(define-constant ERR_NOT_TOKEN_OWNER u103)
(define-constant ERR_BADGE_NOT_CONFIGURED u104)
(define-constant ERR_STREAK_TOO_LOW u105)

(define-constant BLOCKS_PER_DAY u144)

;; Fixed milestones (can be expanded in a future version).
(define-constant BADGE_KIND_1 u1)
(define-constant BADGE_KIND_3 u3)
(define-constant BADGE_KIND_7 u7)
(define-constant BADGE_KIND_14 u14)
(define-constant BADGE_KIND_30 u30)

(define-map last-claim-day principal uint)
(define-map streak principal uint)

;; Milestone bookkeeping
(define-map badge-claimed (tuple (user principal) (kind uint)) bool)
(define-map badge-token-id (tuple (user principal) (kind uint)) uint)
(define-map badge-uri uint (string-ascii 256))

(define-map token-kind uint uint)

(define-data-var next-token-id uint u1)
(define-non-fungible-token badge uint)

;; Admin-configurable milestone days (kinds) that should be auto-minted by `claim`.
;; Note: list length is capped by the type (20) to keep execution costs bounded.
(define-data-var milestones (list 20 uint) (list BADGE_KIND_1 BADGE_KIND_3 BADGE_KIND_7 BADGE_KIND_14 BADGE_KIND_30))

;; Optional fee (in uSTX) for paid minting (independent of streak).
;; Default is 0 (free).
(define-data-var mint-fee uint u0)
(define-data-var fee-recipient principal tx-sender)
(define-map mint-fee-by-kind uint uint)

(define-data-var fold-user principal tx-sender)
(define-data-var fold-streak uint u0)

(define-private (assert-owner)
  (if (is-eq tx-sender (var-get contract-owner))
      (ok true)
      (err ERR_NOT_OWNER)
  )
)

(define-public (set-contract-owner (new-owner principal))
  (match (assert-owner)
    ok-val (begin
      (var-set contract-owner new-owner)
      (ok true))
    err-code (err err-code)
  )
)

(define-public (set-milestones (new-milestones (list 20 uint)))
  (match (assert-owner)
    ok-val (begin
      (var-set milestones new-milestones)
      (ok true))
    err-code (err err-code)
  )
)

(define-public (set-mint-fee (new-fee uint))
  (match (assert-owner)
    ok-val (begin
      (var-set mint-fee new-fee)
      (ok true))
    err-code (err err-code)
  )
)

(define-public (set-mint-fee-kind (kind uint) (fee uint))
  (match (assert-owner)
    ok-val (begin
      (map-set mint-fee-by-kind kind fee)
      (ok true))
    err-code (err err-code)
  )
)

(define-public (set-fee-recipient (recipient principal))
  (match (assert-owner)
    ok-val (begin
      (var-set fee-recipient recipient)
      (ok true))
    err-code (err err-code)
  )
)

(define-read-only (get-current-day)
  (/ stacks-block-height BLOCKS_PER_DAY)
)

(define-read-only (get-milestones)
  (ok (var-get milestones))
)

(define-read-only (get-mint-fee)
  (ok (var-get mint-fee))
)

(define-read-only (get-mint-fee-kind (kind uint))
  (ok (default-to (var-get mint-fee) (map-get? mint-fee-by-kind kind)))
)

(define-read-only (get-fee-recipient)
  (ok (var-get fee-recipient))
)

(define-read-only (get-streak (user principal))
  (default-to u0 (map-get? streak user))
)

(define-read-only (get-last-claim-day (user principal))
  (default-to u0 (map-get? last-claim-day user))
)

;; Backwards-compatible: "has-badge" means "has the 7-day badge"
(define-read-only (has-badge (user principal))
  (default-to false (map-get? badge-claimed { user: user, kind: BADGE_KIND_7 }))
)

(define-read-only (has-badge-kind (user principal) (kind uint))
  (default-to false (map-get? badge-claimed { user: user, kind: kind }))
)

(define-read-only (get-badge-token-id (user principal) (kind uint))
  (map-get? badge-token-id { user: user, kind: kind })
)

(define-read-only (get-badge-uri (kind uint))
  (map-get? badge-uri kind)
)

(define-read-only (get-badge-kind (token-id uint))
  (map-get? token-kind token-id)
)

(define-public (set-badge-uri (kind uint) (uri (string-ascii 256)))
  (match (assert-owner)
    ok-val (begin
        (map-set badge-uri kind uri)
        (ok true)
      )
    err-code (err err-code)
  )
)

(define-private (mint-badge (user principal) (kind uint))
  (let ((already (default-to false (map-get? badge-claimed { user: user, kind: kind }))))
    (if already
        (err ERR_BADGE_ALREADY_MINTED)
        (let ((token-id (var-get next-token-id)))
          (var-set next-token-id (+ token-id u1))
          (map-set badge-claimed { user: user, kind: kind } true)
          (map-set badge-token-id { user: user, kind: kind } token-id)
          (map-set token-kind token-id kind)
          (match (nft-mint? badge token-id user)
            minted (ok (some token-id))
            err-code (err err-code)
          )
        )
    )
  )
)

(define-private (is-kind-configured (kind uint))
  (is-some (map-get? badge-uri kind))
)

(define-private (maybe-mint (user principal) (new-streak uint) (kind uint))
  ;; Avoid throwing ERR_BADGE_ALREADY_MINTED by checking the claimed map first.
  (let ((already (default-to false (map-get? badge-claimed { user: user, kind: kind }))))
    (if (or already (< new-streak kind) (not (is-kind-configured kind)))
        (ok none)
        (mint-badge user kind)
    )
  )
)

(define-public (mint-badge-kind (kind uint))
  (let (
        (current-streak (default-to u0 (map-get? streak tx-sender)))
       )
    (if (not (is-kind-configured kind))
        (err ERR_BADGE_NOT_CONFIGURED)
        (if (< current-streak kind)
            (err ERR_STREAK_TOO_LOW)
            (match (mint-badge tx-sender kind)
              token-id (ok token-id)
              err-code (err err-code)
            )
        )
    )
  )
)

(define-private (mint-step (kind uint) (acc (response (optional uint) uint)))
  ;; `fold` passes (item, accumulator). We store per-call context in data-vars
  ;; because Clarity functions can't close over local `let` bindings.
  (match acc last
    (let ((user (var-get fold-user))
          (new-streak (var-get fold-streak)))
      (match (maybe-mint user new-streak kind)
        minted (ok (if (is-some minted) minted last))
        err-code (err err-code)
      )
    )
    err-code (err err-code)
  )
)

(define-private (mint-earned-badges (user principal) (new-streak uint))
  ;; Tries all configured milestones; returns the last minted token-id (if any).
  (begin
    (var-set fold-user user)
    (var-set fold-streak new-streak)
    (fold mint-step (var-get milestones) (ok none))
  )
)

;; Paid minting (optional). This ignores streak and only requires the badge URI to be configured.
;; Useful for "special" badges you want to sell or give away without changing the streak logic.
(define-public (mint-paid-kind (kind uint))
  (let ((fee (default-to (var-get mint-fee) (map-get? mint-fee-by-kind kind))))
    (begin
      (asserts! (is-kind-configured kind) (err ERR_BADGE_NOT_CONFIGURED))
      (if (> fee u0)
          (try! (stx-transfer? fee tx-sender (var-get fee-recipient)))
          true)
      (match (mint-badge tx-sender kind)
        token-id (ok token-id)
        err-code (err err-code)
      )
    )
  )
)

;; Note: paid mints send STX directly to `fee-recipient`, so there is no contract
;; balance to withdraw. If you need contract custody, use `streak-v3-3`.

(define-public (claim)
  (let (
        (current-day (get-current-day))
        (last-day-opt (map-get? last-claim-day tx-sender))
        (current-streak (default-to u0 (map-get? streak tx-sender)))
       )
    (match last-day-opt last-day
      (if (is-eq current-day last-day)
          (err ERR_ALREADY_CLAIMED)
          (let (
                (new-streak (if (is-eq (+ last-day u1) current-day)
                                (+ current-streak u1)
                                u1))
               )
            (map-set last-claim-day tx-sender current-day)
            (map-set streak tx-sender new-streak)
            (let ((mint-result (mint-earned-badges tx-sender new-streak)))
              (match mint-result
                token-id
                (ok {
                  streak: new-streak,
                  day: current-day,
                  badge-minted: (is-some token-id),
                  token-id: token-id
                })
                err-code
                (err err-code)
              )
            )
          )
      )
      (let ((new-streak u1))
        (map-set last-claim-day tx-sender current-day)
        (map-set streak tx-sender new-streak)
        (let ((mint-result (mint-earned-badges tx-sender new-streak)))
          (match mint-result
            token-id
            (ok {
              streak: new-streak,
              day: current-day,
              badge-minted: (is-some token-id),
              token-id: token-id
            })
            err-code
            (err err-code)
          )
        )
      )
    )
  )
)

;; -----------------------
;; SIP-009 Trait Functions
;; -----------------------

(define-read-only (get-last-token-id)
  (let ((next (var-get next-token-id)))
    (if (is-eq next u1)
        (ok u0)
        (ok (- next u1))
    )
  )
)

(define-read-only (get-owner (token-id uint))
  (ok (nft-get-owner? badge token-id))
)

(define-read-only (get-token-uri (token-id uint))
  (match (map-get? token-kind token-id) kind
    (ok (map-get? badge-uri kind))
    (ok none)
  )
)

(define-public (transfer (token-id uint) (sender principal) (recipient principal))
  (if (is-eq tx-sender sender)
      (match (nft-transfer? badge token-id sender recipient)
        transferred (ok true)
        err-code (err err-code)
      )
      (err ERR_NOT_TOKEN_OWNER)
  )
)

Functions (30)

FunctionAccessArgs
assert-ownerprivate
set-contract-ownerpublicnew-owner: principal
set-milestonespublicnew-milestones: (list 20 uint
set-mint-feepublicnew-fee: uint
set-mint-fee-kindpublickind: uint, fee: uint
set-fee-recipientpublicrecipient: principal
get-current-dayread-only
get-milestonesread-only
get-mint-feeread-only
get-mint-fee-kindread-onlykind: uint
get-fee-recipientread-only
get-streakread-onlyuser: principal
get-last-claim-dayread-onlyuser: principal
has-badgeread-onlyuser: principal
has-badge-kindread-onlyuser: principal, kind: uint
get-badge-token-idread-onlyuser: principal, kind: uint
get-badge-uriread-onlykind: uint
get-badge-kindread-onlytoken-id: uint
set-badge-uripublickind: uint, uri: (string-ascii 256
mint-badgeprivateuser: principal, kind: uint
is-kind-configuredprivatekind: uint
maybe-mintprivateuser: principal, new-streak: uint, kind: uint
mint-badge-kindpublickind: uint
mint-earned-badgesprivateuser: principal, new-streak: uint
mint-paid-kindpublickind: uint
claimpublic
get-last-token-idread-only
get-ownerread-onlytoken-id: uint
get-token-uriread-onlytoken-id: uint
transferpublictoken-id: uint, sender: principal, recipient: principal