Source Code

;; @clarity-version 2
;; KoloX - Community Savings Platform on Stacks
;; A trustless rotating savings and credit association (ROSCA) contract

;; Error codes
(define-constant ERR-NOT-AUTHORIZED (err u100))
(define-constant ERR-KOLO-NOT-FOUND (err u101))
(define-constant ERR-KOLO-FULL (err u102))
(define-constant ERR-ALREADY-MEMBER (err u103))
(define-constant ERR-NOT-MEMBER (err u104))
(define-constant ERR-ALREADY-PAID (err u105))
(define-constant ERR-WRONG-AMOUNT (err u106))
(define-constant ERR-NOT-STARTED (err u107))
(define-constant ERR-PAYOUT-NOT-READY (err u108))
(define-constant ERR-NOT-YOUR-TURN (err u109))
(define-constant ERR-KOLO-NOT-ACTIVE (err u110))
(define-constant ERR-INVALID-PARAMS (err u111))
(define-constant ERR-CANNOT-JOIN (err u112))

;; Data variables
(define-data-var kolo-nonce uint u0)

;; Frequency constants (in blocks - Stacks block time ~10 minutes)
(define-constant WEEKLY u1008) ;; ~7 days
(define-constant MONTHLY u4320) ;; ~30 days

;; Data maps
(define-map kolos
  uint
  {
    creator: principal,
    name: (string-ascii 50),
    amount: uint,
    frequency: uint,
    max-members: uint,
    current-round: uint,
    start-block: uint,
    total-rounds: uint,
    active: bool,
    created-at: uint
  }
)

(define-map kolo-members
  { kolo-id: uint, user: principal }
  {
    joined-at: uint,
    position: uint,
    total-contributions: uint,
    missed-payments: uint,
    has-received-payout: bool
  }
)

(define-map round-contributions
  { kolo-id: uint, round: uint, user: principal }
  {
    paid: bool,
    amount: uint,
    paid-at: uint
  }
)

(define-map payout-order
  { kolo-id: uint, position: uint }
  principal
)

(define-map member-count
  uint
  uint
)

;; Read-only functions

(define-read-only (get-kolo (kolo-id uint))
  (map-get? kolos kolo-id)
)

(define-read-only (get-member-info (kolo-id uint) (user principal))
  (map-get? kolo-members { kolo-id: kolo-id, user: user })
)

(define-read-only (get-member-count (kolo-id uint))
  (default-to u0 (map-get? member-count kolo-id))
)

(define-read-only (get-payout-recipient (kolo-id uint) (position uint))
  (map-get? payout-order { kolo-id: kolo-id, position: position })
)

(define-read-only (has-paid-current-round (kolo-id uint) (user principal))
  (let
    (
      (kolo (unwrap! (get-kolo kolo-id) false))
      (current-round (get current-round kolo))
    )
    (default-to false 
      (get paid (map-get? round-contributions 
        { kolo-id: kolo-id, round: current-round, user: user }
      ))
    )
  )
)

(define-read-only (get-current-round-recipient (kolo-id uint))
  (let
    (
      (kolo (unwrap! (get-kolo kolo-id) none))
      (current-round (get current-round kolo))
    )
    (get-payout-recipient kolo-id current-round)
  )
)

(define-read-only (is-member (kolo-id uint) (user principal))
  (is-some (get-member-info kolo-id user))
)

(define-read-only (get-round-contribution (kolo-id uint) (round uint) (user principal))
  (map-get? round-contributions { kolo-id: kolo-id, round: round, user: user })
)

;; Private functions

(define-private (is-kolo-creator (kolo-id uint) (user principal))
  (match (get-kolo kolo-id)
    kolo (is-eq (get creator kolo) user)
    false
  )
)

;; Public functions

;; Create a new Kolo
(define-public (create-kolo 
    (name (string-ascii 50))
    (amount uint)
    (frequency uint)
    (max-members uint)
    (start-block uint)
  )
  (let
    (
      (kolo-id (+ (var-get kolo-nonce) u1))
      (current-block block-height)
    )
    ;; Validations
    (asserts! (> amount u0) ERR-INVALID-PARAMS)
    (asserts! (>= max-members u2) ERR-INVALID-PARAMS)
    (asserts! (<= max-members u50) ERR-INVALID-PARAMS)
    (asserts! (>= start-block (+ current-block u144)) ERR-INVALID-PARAMS) ;; At least 1 day ahead
    (asserts! (or (is-eq frequency WEEKLY) (is-eq frequency MONTHLY)) ERR-INVALID-PARAMS)

    ;; Create Kolo
    (map-set kolos kolo-id
      {
        creator: tx-sender,
        name: name,
        amount: amount,
        frequency: frequency,
        max-members: max-members,
        current-round: u0,
        start-block: start-block,
        total-rounds: max-members,
        active: true,
        created-at: current-block
      }
    )

    ;; Creator automatically joins at position 0
    (map-set kolo-members { kolo-id: kolo-id, user: tx-sender }
      {
        joined-at: current-block,
        position: u0,
        total-contributions: u0,
        missed-payments: u0,
        has-received-payout: false
      }
    )

    (map-set payout-order { kolo-id: kolo-id, position: u0 } tx-sender)
    (map-set member-count kolo-id u1)

    ;; Update nonce
    (var-set kolo-nonce kolo-id)

    (ok kolo-id)
  )
)

;; Join an existing Kolo
(define-public (join-kolo (kolo-id uint))
  (let
    (
      (kolo (unwrap! (get-kolo kolo-id) ERR-KOLO-NOT-FOUND))
      (current-members (get-member-count kolo-id))
      (current-block block-height)
    )
    ;; Validations
    (asserts! (get active kolo) ERR-KOLO-NOT-ACTIVE)
    (asserts! (< current-block (get start-block kolo)) ERR-CANNOT-JOIN)
    (asserts! (< current-members (get max-members kolo)) ERR-KOLO-FULL)
    (asserts! (not (is-member kolo-id tx-sender)) ERR-ALREADY-MEMBER)

    ;; Add member
    (map-set kolo-members { kolo-id: kolo-id, user: tx-sender }
      {
        joined-at: current-block,
        position: current-members,
        total-contributions: u0,
        missed-payments: u0,
        has-received-payout: false
      }
    )

    (map-set payout-order { kolo-id: kolo-id, position: current-members } tx-sender)
    (map-set member-count kolo-id (+ current-members u1))

    (ok true)
  )
)

;; Make a contribution for the current round
(define-public (contribute (kolo-id uint))
  (let
    (
      (kolo (unwrap! (get-kolo kolo-id) ERR-KOLO-NOT-FOUND))
      (member (unwrap! (get-member-info kolo-id tx-sender) ERR-NOT-MEMBER))
      (current-round (get current-round kolo))
      (amount (get amount kolo))
      (current-block block-height)
    )
    ;; Validations
    (asserts! (get active kolo) ERR-KOLO-NOT-ACTIVE)
    (asserts! (>= current-block (get start-block kolo)) ERR-NOT-STARTED)
    (asserts! (not (has-paid-current-round kolo-id tx-sender)) ERR-ALREADY-PAID)

    ;; Transfer STX from user to contract
    (try!
      (as-contract
        (stx-transfer? amount tx-sender tx-sender)
      )
    )

    ;; Record contribution
    (map-set round-contributions 
      { kolo-id: kolo-id, round: current-round, user: tx-sender }
      {
        paid: true,
        amount: amount,
        paid-at: current-block
      }
    )

    ;; Update member stats
    (map-set kolo-members { kolo-id: kolo-id, user: tx-sender }
      (merge member { total-contributions: (+ (get total-contributions member) amount) })
    )

    (ok true)
  )
)

;; Trigger payout for current round
(define-public (trigger-payout (kolo-id uint))
  (let
    (
      (kolo (unwrap! (get-kolo kolo-id) ERR-KOLO-NOT-FOUND))
      (current-round (get current-round kolo))
      (recipient-principal (unwrap! (get-payout-recipient kolo-id current-round) ERR-NOT-YOUR-TURN))
      (recipient-member (unwrap! (get-member-info kolo-id recipient-principal) ERR-NOT-MEMBER))
      (amount (get amount kolo))
      (total-members (get-member-count kolo-id))
      (total-payout (* amount total-members))
      (current-block block-height)
    )
    ;; Validations
    (asserts! (get active kolo) ERR-KOLO-NOT-ACTIVE)
    (asserts! (>= current-block (get start-block kolo)) ERR-NOT-STARTED)
    (asserts! (not (get has-received-payout recipient-member)) ERR-ALREADY-PAID)

    ;; Check if all members have paid (simplified - production would need more checks)
    (asserts! (>= current-block (+ (get start-block kolo) (* (get frequency kolo) current-round))) ERR-PAYOUT-NOT-READY)

    ;; Transfer payout to recipient
    (try! (as-contract (stx-transfer? total-payout tx-sender recipient-principal)))

    ;; Update recipient's payout status
    (map-set kolo-members { kolo-id: kolo-id, user: recipient-principal }
      (merge recipient-member { has-received-payout: true })
    )

    ;; Move to next round or complete kolo
    (if (< (+ current-round u1) (get total-rounds kolo))
      (map-set kolos kolo-id (merge kolo { current-round: (+ current-round u1) }))
      (map-set kolos kolo-id (merge kolo { active: false, current-round: (+ current-round u1) }))
    )

    (ok true)
  )
)

;; Emergency function: Creator can cancel before start (refunds all)
(define-public (cancel-kolo (kolo-id uint))
  (let
    (
      (kolo (unwrap! (get-kolo kolo-id) ERR-KOLO-NOT-FOUND))
      (current-block block-height)
    )
    ;; Only creator can cancel
    (asserts! (is-kolo-creator kolo-id tx-sender) ERR-NOT-AUTHORIZED)
    ;; Can only cancel before start
    (asserts! (< current-block (get start-block kolo)) ERR-CANNOT-JOIN)
    ;; Must be active
    (asserts! (get active kolo) ERR-KOLO-NOT-ACTIVE)

    ;; Deactivate kolo
    (map-set kolos kolo-id (merge kolo { active: false }))

    (ok true)
  )
)

;; Read-only helper: Get next payout block
(define-read-only (get-next-payout-block (kolo-id uint))
  (match (get-kolo kolo-id)
    kolo (some (+ (get start-block kolo) (* (get frequency kolo) (+ (get current-round kolo) u1))))
    none
  )
)

;; Initialize contract (optional, for setup)
(begin
  (var-set kolo-nonce u0)
)

Functions (15)

FunctionAccessArgs
get-koloread-onlykolo-id: uint
get-member-inforead-onlykolo-id: uint, user: principal
get-member-countread-onlykolo-id: uint
get-payout-recipientread-onlykolo-id: uint, position: uint
has-paid-current-roundread-onlykolo-id: uint, user: principal
get-current-round-recipientread-onlykolo-id: uint
is-memberread-onlykolo-id: uint, user: principal
get-round-contributionread-onlykolo-id: uint, round: uint, user: principal
is-kolo-creatorprivatekolo-id: uint, user: principal
create-kolopublicname: (string-ascii 50
join-kolopublickolo-id: uint
contributepublickolo-id: uint
trigger-payoutpublickolo-id: uint
cancel-kolopublickolo-id: uint
get-next-payout-blockread-onlykolo-id: uint