serayd61/stacks-crowdfund · Crowdfunding · February 21, 2026
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.
| ID | Severity | Title |
|---|---|---|
| C-01 | CRITICAL | Contributions sent to hardcoded treasury, not contract |
| C-02 | CRITICAL | claim-funds performs no STX transfer |
| C-03 | CRITICAL | claim-refund performs no STX transfer |
| H-01 | HIGH | Refunds gated by campaign owner — no permissionless path |
| M-01 | MEDIUM | Unlimited deadline extension |
| M-02 | MEDIUM | No minimum campaign duration |
| L-01 | LOW | Milestone system declared but unused |
| L-02 | LOW | Fee calculation is dead code |
| I-01 | INFO | Redundant state variable (total-campaigns mirrors campaign-nonce) |
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.
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.
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.
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.
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.
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.
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 }
)
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.
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.
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.
is-eq current-contribution u0Do 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.