Stacks Crowdfund — Decentralized Fundraising Platform

serayd61/stacks-crowdfund · Crowdfunding · February 21, 2026

1
Contract
342
Lines
9
Findings
3
Critical

Overview

A decentralized crowdfunding platform on Stacks. Users create campaigns with goals and deadlines, others contribute STX, and if the goal is met the owner claims funds (minus a 2% platform fee). If the campaign fails, contributors can request refunds. Includes milestone tracking, creator/backer stats, and deadline extensions.

The contract is fundamentally broken. All contributions are sent directly to a hardcoded external treasury address — the contract never holds any STX. The claim-funds and claim-refund functions update internal bookkeeping but perform zero STX transfers. Every micro-STX contributed is permanently gifted to the treasury with no recovery path.

Findings Summary

IDSeverityTitle
C-01CRITICALContributions sent to hardcoded treasury, not contract
C-02CRITICALclaim-funds performs no STX transfer
C-03CRITICALclaim-refund performs no STX transfer
H-01HIGHRefunds gated by campaign owner — no permissionless path
M-01MEDIUMUnlimited deadline extension
M-02MEDIUMNo minimum campaign duration
L-01LOWMilestone system declared but unused
L-02LOWFee calculation is dead code
I-01INFORedundant state variable (total-campaigns mirrors campaign-nonce)

Detailed Findings

CRITICAL C-01: Contributions Sent to Hardcoded Treasury, Not Contract

The contribute function transfers STX directly to an external address instead of the contract:

(define-constant treasury 'SP2PEBKJ2W1ZDDF2QQ6Y4FXKZEDPT9J9R2NKD9WJB)

;; In contribute:
(try! (stx-transfer? amount tx-sender treasury))

All contributed STX leaves the contract immediately and goes to this hardcoded principal. The contract never holds any funds, making all distribution functions (claim-funds, claim-refund) impossible. This is the root cause of C-02 and C-03.

Fix: Transfer to the contract itself using (stx-transfer? amount tx-sender (as-contract tx-sender)), then have claim functions use (as-contract (stx-transfer? ...)) to pay out.

CRITICAL C-02: claim-funds Performs No STX Transfer

The claim-funds function calculates the owner payout and fee, updates all bookkeeping, then returns a response claiming funds were distributed:

(let (
  (raised (get raised campaign))
  (fee (calculate-fee raised))
  (owner-amount (- raised fee))
)
  ;; ... asserts, map-set, var-set ...
  (ok { campaign-id: campaign-id, claimed: owner-amount, fee: fee })
)

There is no stx-transfer? call anywhere in this function. The campaign owner receives nothing. The response tuple reports a claimed amount that was never sent.

CRITICAL C-03: claim-refund Performs No STX Transfer

Similarly, claim-refund clears the contribution record and decrements the campaign's raised amount, but never sends STX back:

;; Clear contribution
(map-set contributions { campaign-id: campaign-id, contributor: tx-sender } u0)

;; Update campaign raised amount
(map-set campaigns campaign-id 
  (merge campaign { raised: (- (get raised campaign) contribution) }))

(ok { campaign-id: campaign-id, refunded: contribution })

Contributors who call this function lose their contribution record (preventing a second attempt) and receive zero STX. The refunded value in the response is fictional.

HIGH H-01: Refunds Gated by Campaign Owner

claim-refund requires (get refunds-enabled campaign), which can only be set by enable-refunds, callable only by the campaign owner:

(define-public (enable-refunds (campaign-id uint))
  ;; ...
  (asserts! (is-eq (get owner campaign) tx-sender) err-not-campaign-owner)
  ;; ...
)

If a campaign fails and the owner abandons the project (or acts maliciously), contributors have no way to enable refunds. In a proper crowdfunding contract, refunds should be permissionless once the deadline passes and the goal is unmet.

MEDIUM M-01: Unlimited Deadline Extension

extend-deadline allows the campaign owner to add any number of blocks with no cap:

(define-public (extend-deadline (campaign-id uint) (additional-blocks uint))
  ;; ... only checks: is owner, campaign not ended ...
  (map-set campaigns campaign-id 
    (merge campaign { end-block: (+ (get end-block campaign) additional-blocks) })
  )
)

A malicious owner could set additional-blocks to u999999999, preventing the campaign from ever ending. This blocks both fund claiming and refund enabling indefinitely, trapping contributors.

MEDIUM M-02: No Minimum Campaign Duration

create-campaign accepts any positive duration: (asserts! (> duration u0) err-invalid-amount). A campaign with duration=1 starts and expires within essentially the same block, making it impossible for anyone to contribute while still consuming a campaign nonce and emitting creation events.

LOW L-01: Milestone System Declared But Unused

The contract defines a milestones map with title, amount, and completion status, but no public or private function ever reads from or writes to it. This is dead code suggesting an abandoned feature.

(define-map milestones { campaign-id: uint, milestone-id: uint }
  { title: (string-utf8 128), amount: uint, completed: bool }
)

LOW L-02: Fee Calculation Is Dead Code

calculate-fee correctly computes 2% (200 basis points) of a given amount, and claim-funds uses it to split raised funds into owner-amount and fee. But since claim-funds performs no transfer, neither the fee nor the owner payment actually occurs. The treasury already received 100% of contributions directly via contribute.

INFO I-01: Redundant State Variable

total-campaigns and campaign-nonce are both initialized to u0 and both incremented by 1 in create-campaign. They always hold the same value. One could be removed.

Architecture Analysis

The Custody Problem

A crowdfunding contract must hold funds in escrow to function. The standard pattern in Clarity is:

;; Contribution: funds go INTO the contract
(stx-transfer? amount tx-sender (as-contract tx-sender))

;; Payout: contract sends funds OUT
(as-contract (stx-transfer? payout-amount tx-sender campaign-owner))

;; Refund: contract returns funds to contributor
(as-contract (stx-transfer? contribution tx-sender contributor))

This contract skips the escrow entirely and sends all funds to a hardcoded address. The internal bookkeeping (raised, contributions, stats) tracks money that the contract no longer controls.

What Works

Verdict

Do not use. The contract collects real STX into a hardcoded third-party address with zero ability to distribute funds to campaign owners or refund contributors. It functions as a one-way donation mechanism disguised as a crowdfunding platform. The bookkeeping logic is sound, but without a contract custody model, the entire platform is non-functional.

To fix: replace the treasury direct-send with as-contract escrow pattern, add actual stx-transfer? calls to claim-funds and claim-refund, make refunds permissionless after deadline, and cap deadline extensions.