Source Code

;; halo-credit.clar
;; On-chain credit scoring and payment history tracking
;;
;; Score Range: 300 (base) to 850 (max)
;; Components:
;;   - Payment History (35%) - max 192 pts
;;   - Circle Completion (20%) - max 110 pts
;;   - Volume (15%) - max 82 pts
;;   - Tenure (10%) - max 55 pts
;;   - Consistency (10%) - max 55 pts
;;   - Staking Activity (10%) - max 55 pts
;;
;; Only authorized contracts (e.g., halo-circle, halo-sbtc-staking) can record

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

(define-constant CONTRACT_OWNER tx-sender)
(define-constant ERR_NOT_AUTHORIZED (err u300))
(define-constant ERR_NOT_FOUND (err u301))
(define-constant ERR_INVALID_SCORE (err u302))
(define-constant ERR_HISTORY_FULL (err u303))

;; Score bounds
(define-constant MIN_SCORE u300)
(define-constant MAX_SCORE u850)
(define-constant INITIAL_SCORE u300)
(define-constant MAX_EARNED u550)

;; Score component weights (percentages, must sum to 100)
(define-constant PAYMENT_HISTORY_WEIGHT u35)
(define-constant CIRCLE_COMPLETION_WEIGHT u20)
(define-constant VOLUME_WEIGHT u15)
(define-constant TENURE_WEIGHT u10)
(define-constant CONSISTENCY_WEIGHT u10)
(define-constant STAKING_WEIGHT u10)

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

(define-data-var admin principal CONTRACT_OWNER)
(define-data-var authorized-contracts (list 10 principal) (list))

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

;; Unique ID -> Credit Score Data
(define-map credit-scores (buff 32) {
  score: uint,
  total-payments: uint,
  on-time-payments: uint,
  late-payments: uint,
  circles-completed: uint,
  circles-defaulted: uint,
  total-volume: uint,
  first-activity: uint,
  last-updated: uint,
  sbtc-staked: uint,
  staking-duration-blocks: uint
})

;; Unique ID -> Payment History (last 100 payments)
(define-map payment-history (buff 32) (list 100 {
  circle-id: uint,
  round: uint,
  amount: uint,
  on-time: bool,
  block: uint
}))

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

;; Get credit score only
(define-read-only (get-credit-score (unique-id (buff 32)))
  (match (map-get? credit-scores unique-id)
    score-data (ok (get score score-data))
    (ok INITIAL_SCORE)
  )
)

;; Get full credit data
(define-read-only (get-credit-data (unique-id (buff 32)))
  (map-get? credit-scores unique-id)
)

;; Get payment history
(define-read-only (get-payment-history (unique-id (buff 32)))
  (default-to (list) (map-get? payment-history unique-id))
)

;; Get score by wallet address (convenience for SDK/external protocols)
(define-read-only (get-score-by-wallet (wallet principal))
  (match (contract-call? .halo-identity get-id-by-wallet wallet)
    unique-id (match (map-get? credit-scores unique-id)
      score-data (ok (get score score-data))
      (ok INITIAL_SCORE)
    )
    (ok INITIAL_SCORE)
  )
)

;; Get full credit data by wallet
(define-read-only (get-credit-data-by-wallet (wallet principal))
  (match (contract-call? .halo-identity get-id-by-wallet wallet)
    unique-id (map-get? credit-scores unique-id)
    none
  )
)

;; Check if caller is authorized
(define-read-only (is-authorized (caller principal))
  (or (is-eq caller (var-get admin))
      (is-some (index-of? (var-get authorized-contracts) caller)))
)

;; Get score tier label
(define-read-only (get-score-tier (score uint))
  (if (>= score u750) "Excellent"
    (if (>= score u650) "Good"
      (if (>= score u550) "Fair"
        "Poor"
      )
    )
  )
)

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

;; Get authorized contracts list
(define-read-only (get-authorized-contracts)
  (var-get authorized-contracts)
)

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

;; Record a payment (called by authorized contracts like halo-circle)
(define-public (record-payment
  (unique-id (buff 32))
  (circle-id uint)
  (round uint)
  (amount uint)
  (on-time bool)
)
  (let (
    (caller contract-caller)
  )
    ;; Verify caller is authorized
    (asserts! (is-authorized caller) ERR_NOT_AUTHORIZED)

    (let (
      (current-data (get-or-create-credit-data unique-id))
      (new-total (+ (get total-payments current-data) u1))
      (new-on-time (if on-time
                      (+ (get on-time-payments current-data) u1)
                      (get on-time-payments current-data)))
      (new-late (if on-time
                   (get late-payments current-data)
                   (+ (get late-payments current-data) u1)))
      (new-volume (+ (get total-volume current-data) amount))
      (new-score (calculate-score
                   new-on-time
                   new-late
                   new-total
                   (get circles-completed current-data)
                   (get circles-defaulted current-data)
                   new-volume
                   (get first-activity current-data)
                   (get-staking-tier
                     (get sbtc-staked current-data)
                     (get staking-duration-blocks current-data))))
    )
      ;; Update credit data
      (map-set credit-scores unique-id {
        score: new-score,
        total-payments: new-total,
        on-time-payments: new-on-time,
        late-payments: new-late,
        circles-completed: (get circles-completed current-data),
        circles-defaulted: (get circles-defaulted current-data),
        total-volume: new-volume,
        first-activity: (get first-activity current-data),
        last-updated: stacks-block-height,
        sbtc-staked: (get sbtc-staked current-data),
        staking-duration-blocks: (get staking-duration-blocks current-data)
      })

      ;; Add to payment history
      (add-payment-record unique-id circle-id round amount on-time)

      (print {
        event: "payment-recorded",
        unique-id: unique-id,
        circle-id: circle-id,
        round: round,
        on-time: on-time,
        new-score: new-score
      })

      (ok new-score)
    )
  )
)

;; Record circle completion (called by authorized contracts)
(define-public (record-circle-completion (unique-id (buff 32)) (completed-successfully bool))
  (let (
    (caller contract-caller)
  )
    ;; Verify caller is authorized
    (asserts! (is-authorized caller) ERR_NOT_AUTHORIZED)

    (let (
      (current-data (get-or-create-credit-data unique-id))
      (new-completed (if completed-successfully
                        (+ (get circles-completed current-data) u1)
                        (get circles-completed current-data)))
      (new-defaulted (if completed-successfully
                        (get circles-defaulted current-data)
                        (+ (get circles-defaulted current-data) u1)))
      (new-score (calculate-score
                   (get on-time-payments current-data)
                   (get late-payments current-data)
                   (get total-payments current-data)
                   new-completed
                   new-defaulted
                   (get total-volume current-data)
                   (get first-activity current-data)
                   (get-staking-tier
                     (get sbtc-staked current-data)
                     (get staking-duration-blocks current-data))))
    )
      (map-set credit-scores unique-id
        (merge current-data {
          score: new-score,
          circles-completed: new-completed,
          circles-defaulted: new-defaulted,
          last-updated: stacks-block-height
        })
      )

      (print {
        event: "circle-completion-recorded",
        unique-id: unique-id,
        success: completed-successfully,
        new-score: new-score
      })

      (ok new-score)
    )
  )
)

;; Record staking activity (called by halo-sbtc-staking)
(define-public (record-staking-activity
  (unique-id (buff 32))
  (sbtc-amount uint)
  (duration-blocks uint)
)
  (let (
    (caller contract-caller)
  )
    ;; Verify caller is authorized
    (asserts! (is-authorized caller) ERR_NOT_AUTHORIZED)

    (let (
      (current-data (get-or-create-credit-data unique-id))
      (staking-tier (get-staking-tier sbtc-amount duration-blocks))
      (new-score (calculate-score
                   (get on-time-payments current-data)
                   (get late-payments current-data)
                   (get total-payments current-data)
                   (get circles-completed current-data)
                   (get circles-defaulted current-data)
                   (get total-volume current-data)
                   (get first-activity current-data)
                   staking-tier))
    )
      (map-set credit-scores unique-id
        (merge current-data {
          score: new-score,
          sbtc-staked: sbtc-amount,
          staking-duration-blocks: duration-blocks,
          last-updated: stacks-block-height
        })
      )

      (print {
        event: "staking-activity-recorded",
        unique-id: unique-id,
        sbtc-amount: sbtc-amount,
        duration-blocks: duration-blocks,
        staking-tier: staking-tier,
        new-score: new-score
      })

      (ok new-score)
    )
  )
)

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

;; Get or create default credit data
(define-private (get-or-create-credit-data (unique-id (buff 32)))
  (default-to {
    score: INITIAL_SCORE,
    total-payments: u0,
    on-time-payments: u0,
    late-payments: u0,
    circles-completed: u0,
    circles-defaulted: u0,
    total-volume: u0,
    first-activity: stacks-block-height,
    last-updated: stacks-block-height,
    sbtc-staked: u0,
    staking-duration-blocks: u0
  } (map-get? credit-scores unique-id))
)

;; Add payment to history list
(define-private (add-payment-record
  (unique-id (buff 32))
  (circle-id uint)
  (round uint)
  (amount uint)
  (on-time bool)
)
  (let (
    (history (get-payment-history unique-id))
    (new-record {
      circle-id: circle-id,
      round: round,
      amount: amount,
      on-time: on-time,
      block: stacks-block-height
    })
  )
    (match (as-max-len? (append history new-record) u100)
      updated-history (begin
        (map-set payment-history unique-id updated-history)
        true
      )
      false  ;; History full, silently skip (non-critical)
    )
  )
)

;; Calculate credit score from all factors
;; Returns a score between MIN_SCORE (300) and MAX_SCORE (850)
(define-private (calculate-score
  (on-time uint)
  (late uint)
  (total-payments uint)
  (completed uint)
  (defaulted uint)
  (volume uint)
  (first-activity uint)
  (staking-tier uint)
)
  (let (
    ;; 1. Payment History (35% weight) - max 192 pts
    (payment-ratio (if (> total-payments u0)
                      (/ (* on-time u100) total-payments)
                      u100))
    (payment-score (/ (* payment-ratio PAYMENT_HISTORY_WEIGHT MAX_EARNED) u10000))

    ;; 2. Circle Completion (20% weight) - max 110 pts
    (total-circles (+ completed defaulted))
    (completion-ratio (if (> total-circles u0)
                         (/ (* completed u100) total-circles)
                         u100))
    (completion-score (/ (* completion-ratio CIRCLE_COMPLETION_WEIGHT MAX_EARNED) u10000))

    ;; 3. Volume (15% weight) - max 82 pts
    (volume-tier (get-volume-tier volume))
    (volume-score (/ (* volume-tier VOLUME_WEIGHT MAX_EARNED) u10000))

    ;; 4. Tenure (10% weight) - max 55 pts
    (blocks-active (if (> stacks-block-height first-activity)
                      (- stacks-block-height first-activity)
                      u0))
    (tenure-tier (get-tenure-tier blocks-active))
    (tenure-score (/ (* tenure-tier TENURE_WEIGHT MAX_EARNED) u10000))

    ;; 5. Consistency (10% weight) - max 55 pts
    (consistency-tier (if (is-eq late u0) u100
                         (if (< late u3) u50
                           u25)))
    (consistency-score (/ (* consistency-tier CONSISTENCY_WEIGHT MAX_EARNED) u10000))

    ;; 6. Staking Activity (10% weight) - max 55 pts
    (staking-score (/ (* staking-tier STAKING_WEIGHT MAX_EARNED) u10000))

    ;; Total = base + earned (capped at MAX_SCORE)
    (total (+ MIN_SCORE
             (+ payment-score
               (+ completion-score
                 (+ volume-score
                   (+ tenure-score
                     (+ consistency-score staking-score)))))))
  )
    (if (> total MAX_SCORE) MAX_SCORE total)
  )
)

;; Volume tier: returns 0-100 based on total volume in microSTX
(define-private (get-volume-tier (volume uint))
  (if (> volume u500000000000) u100    ;; 500,000+ STX
    (if (> volume u100000000000) u90   ;; 100,000+ STX
    (if (> volume u10000000000) u75    ;; 10,000+ STX
    (if (> volume u1000000000) u60     ;; 1,000+ STX
    (if (> volume u100000000) u45      ;; 100+ STX
    (if (> volume u10000000) u30       ;; 10+ STX
    u15))))))                          ;; < 10 STX
)

;; Tenure tier: returns 0-100 based on blocks active
(define-private (get-tenure-tier (blocks uint))
  (let (
    (months (/ blocks u4320))  ;; ~4320 blocks per month on Stacks
  )
    (if (> months u12) u100
      (if (> months u6) u75
      (if (> months u3) u50
      u25)))
  )
)

;; Staking tier: combined amount + duration score (0-100)
;; Amount tiers (0-100): >1 BTC=100, >0.1=80, >0.01=60, >0.001=40, >0=20, 0=0
;; Duration modifier (0-100): >12mo=100, >6mo=80, >3mo=60, >1mo=40, >0=20
;; Combined: (amount_tier * duration_modifier) / 100
(define-private (get-staking-tier (sbtc-amount uint) (duration-blocks uint))
  (if (is-eq sbtc-amount u0) u0
    (let (
      ;; Amount tier (sBTC has 8 decimals: 1 BTC = 100000000)
      (amount-tier
        (if (> sbtc-amount u100000000) u100       ;; > 1 BTC
          (if (> sbtc-amount u10000000) u80        ;; > 0.1 BTC
            (if (> sbtc-amount u1000000) u60        ;; > 0.01 BTC
              (if (> sbtc-amount u100000) u40        ;; > 0.001 BTC
                u20)))))                              ;; > 0
      ;; Duration in months (~4320 blocks per month)
      (duration-months (/ duration-blocks u4320))
      (duration-modifier
        (if (> duration-months u12) u100
          (if (> duration-months u6) u80
            (if (> duration-months u3) u60
              (if (> duration-months u1) u40
                u20)))))
    )
      (/ (* amount-tier duration-modifier) u100)
    )
  )
)

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

;; Authorize a contract to record payments/completions
(define-public (authorize-contract (contract principal))
  (begin
    (asserts! (is-eq tx-sender (var-get admin)) ERR_NOT_AUTHORIZED)
    (let (
      (current (var-get authorized-contracts))
    )
      (asserts! (is-none (index-of? current contract)) ERR_INVALID_SCORE) ;; Already authorized
      (var-set authorized-contracts
        (unwrap! (as-max-len? (append current contract) u10) ERR_NOT_AUTHORIZED))
      (print { event: "contract-authorized", contract: contract })
      (ok true)
    )
  )
)

;; Transfer admin role
(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 (19)

FunctionAccessArgs
get-credit-scoreread-onlyunique-id: (buff 32
get-credit-dataread-onlyunique-id: (buff 32
get-payment-historyread-onlyunique-id: (buff 32
get-score-by-walletread-onlywallet: principal
get-credit-data-by-walletread-onlywallet: principal
is-authorizedread-onlycaller: principal
get-score-tierread-onlyscore: uint
get-adminread-only
get-authorized-contractsread-only
record-paymentpublicunique-id: (buff 32
record-circle-completionpublicunique-id: (buff 32
record-staking-activitypublicunique-id: (buff 32
get-or-create-credit-dataprivateunique-id: (buff 32
add-payment-recordprivateunique-id: (buff 32
get-volume-tierprivatevolume: uint
get-tenure-tierprivateblocks: uint
get-staking-tierprivatesbtc-amount: uint, duration-blocks: uint
authorize-contractpubliccontract: principal
set-adminpublicnew-admin: principal