BTC Prediction Market — Security Audit

phessophissy/btc-prediction-market · Commit: c94ab89 (2026-02-05) · Audited: February 21, 2026

2
Critical
2
High
3
Medium
2
Low
2
Info

Overview

BTC Prediction Market is a decentralized prediction market built on Stacks that uses Bitcoin block hashes for provably fair settlement. Users create binary or multi-outcome markets, bet STX on outcomes, and the winning outcome is determined by the Bitcoin block hash at a predetermined height. The project includes three contract versions (v1, v2, v3), a SIP-010 reward token (PMT), a frontend, SDK, and CLI tools.

The codebase spans 5 Clarity contracts totaling ~1,464 lines. The contracts claim Clarity 4 features (bitwise operations, tenure-height, get-burn-block-info?) but still use the legacy as-contract instead of Clarity 4's as-contract?.

Priority Score

MetricWeightScoreWeighted
Financial risk33 — DeFi prediction market, holds/transfers STX bet pools9
Deployment likelihood22 — Has frontend, SDK, CLI, mainnet deployment plan4
Code complexity23 — 5 contracts, 1464 lines, multi-version architecture6
User exposure1.51 — 3 stars, 2 forks1.5
Novelty1.53 — Bitcoin-hash-based prediction market, unique settlement4.5
Total25 / 10 = 2.5 ≥ 1.8 ✓

Findings Summary

IDSeverityTitle
C-01CRITICALclaim-winnings sends payout to contract, not user
C-02CRITICALwithdraw-fees drains user bet deposits
H-01HIGHEmergency withdrawal enables rug pull of all user funds
H-02HIGHV2 and V3 are incomplete — missing bet/settle/claim functions
M-01MEDIUMV1 ignores platform-paused flag on betting and market creation
M-02MEDIUMInteger division truncation leaks dust in payout calculation
M-03MEDIUMV1 creation fee bypasses contract — sent directly to deployer
L-01LOWParticipant list silently drops users after 500
L-02LOWNo market cancellation — unsettled markets lock funds forever
I-01INFOUses legacy as-contract instead of Clarity 4 as-contract?
I-02INFOPrediction token (PMT) not integrated with market contracts

Detailed Findings

C-01 claim-winnings sends payout to contract, not user

Location: btc-prediction-market.clarclaim-winnings function

Description: The payout transfer uses an obfuscated pattern to resolve the recipient. Inside as-contract, tx-sender resolves to the contract principal itself, not the calling user. The code constructs (list tx-sender) then indexes into it — this evaluates to the contract's own address. The STX is transferred from the contract back to the contract, meaning winners can never actually receive their funds.

;; Inside as-contract block, tx-sender = contract address
(try! (as-contract (stx-transfer? net-payout tx-sender
  (unwrap! (element-at? (list tx-sender) u0) ERR-TRANSFER-FAILED))))
;;                           ^^^^^^^^^^ = contract address, NOT user

Impact: All winning payouts are permanently locked in the contract. No user can ever claim their winnings. The total-pool STX accumulates with no way to extract it (except via the owner's withdraw-fees — see C-02).

Recommendation: Capture the caller's address before entering as-contract:

(let ((claimer tx-sender))
  ...
  (try! (as-contract (stx-transfer? net-payout tx-sender claimer))))

C-02 withdraw-fees drains user bet deposits

Location: btc-prediction-market.clar, btc-prediction-market-v2.clar, btc-prediction-market-v3.clarwithdraw-fees function

Description: The withdraw-fees function allows the contract owner to transfer any amount of STX from the contract to any recipient. There is no check that the amount is limited to collected fees — the owner can withdraw the entire contract balance, including all user bet deposits from active, unsettled markets.

(define-public (withdraw-fees (amount uint) (recipient principal))
  (begin
    (asserts! (is-eq tx-sender CONTRACT-OWNER) ERR-NOT-AUTHORIZED)
    ;; No check: amount <= actual fees. Can drain everything.
    (as-contract (stx-transfer? amount tx-sender recipient))))

Impact: Contract owner can steal all user deposits at any time. Combined with C-01 (payouts going back to contract), the owner is the only entity that can ever extract funds.

Recommendation: Track fee revenue separately and enforce (asserts! (<= amount (var-get withdrawable-fees)) ERR-INSUFFICIENT-FUNDS). Decrement the tracked fees after withdrawal.

H-01 Emergency withdrawal enables rug pull of all user funds

Location: btc-prediction-market-v2.clar, btc-prediction-market-v3.claremergency-withdraw-all, emergency-withdraw

Description: The owner can unilaterally enable emergency mode and immediately drain all contract funds. There is no timelock, no multi-sig requirement, and no user notification period. Emergency mode is a single boolean flip that enables unrestricted withdrawals.

;; Step 1: Owner flips emergency mode on
(define-public (enable-emergency-mode) ...)

;; Step 2: Owner drains everything (same transaction possible)
(define-public (emergency-withdraw-all)
  (let ((contract-balance (stx-get-balance (as-contract tx-sender))))
    (asserts! (is-eq tx-sender (var-get contract-owner)) ERR-NOT-AUTHORIZED)
    (asserts! (var-get emergency-mode) ERR-NOT-AUTHORIZED)
    (try! (as-contract (stx-transfer? contract-balance tx-sender (var-get contract-owner))))
    ...))

Impact: Owner can rug-pull all user funds instantly with no warning or recourse.

Recommendation: Add a timelock (e.g., 144 Bitcoin blocks ≈ 1 day) between enabling emergency mode and withdrawing. Allow users to claim their positions during the grace period. Consider requiring a multi-sig or DAO vote.

H-02 V2 and V3 are incomplete — missing core functions

Location: btc-prediction-market-v2.clar, btc-prediction-market-v3.clar

Description: V2 and V3 only implement create-binary-market, ownership transfer, and emergency functions. They are entirely missing: place-bet-internal (and all bet-outcome-* functions), settle-market, and claim-winnings. Markets can be created but nobody can bet, settle, or claim. The mainnet deployment plan (deployments/default.mainnet-plan.yaml) deploys all three versions.

Impact: If V2 or V3 are deployed and used, user creation fees (0.1 STX) are collected but markets are non-functional. Funds paid as creation fees are unrecoverable by the creator since markets can never complete.

Recommendation: Either complete V2/V3 with all necessary functions, or remove them from the deployment plan. Do not deploy incomplete contracts that accept user funds.

M-01 V1 ignores platform-paused flag on betting and market creation

Location: btc-prediction-market.clarcreate-binary-market, create-multi-market, place-bet-internal

Description: V1 defines platform-paused and set-platform-paused but never checks the flag in any market creation or betting function. The admin pause mechanism is dead code — setting it to true has no effect on user operations. (V2 and V3 do check it in create-binary-market.)

Impact: Admin cannot halt operations during an incident. If a bug is discovered, there's no way to stop users from continuing to bet.

Recommendation: Add (asserts! (not (var-get platform-paused)) ERR-NOT-AUTHORIZED) to all state-changing functions in V1.

M-02 Integer division truncation leaks dust in payout calculation

Location: btc-prediction-market.clarclaim-winnings

Description: The payout formula (/ (* user-winning-amount total-pool) winning-pool) performs integer division, truncating any remainder. Over many claims, the accumulated rounding error ("dust") remains locked in the contract with no mechanism to recover it.

(gross-payout (if (is-eq winning-pool u0)
                 u0
                 (/ (* user-winning-amount total-pool) winning-pool)))

Impact: Small amounts of STX are permanently locked after every market settlement. While individually insignificant, this accumulates over time. In extreme cases (very small bets relative to pool), users may receive 0 payout due to truncation.

Recommendation: Use a higher-precision intermediate calculation (multiply by 1e6 first, then divide), or implement a "last claimer gets remainder" pattern.

M-03 V1 creation fee bypasses contract — sent directly to deployer

Location: btc-prediction-market.clarcreate-binary-market, create-multi-market

Description: Market creation fees are transferred directly to CONTRACT-OWNER (the deployer's address), not to the contract. The total-fees-collected variable is incremented but tracks phantom revenue — the contract never held these fees. This means withdraw-fees would need to draw from user deposits to honor the tracked amount.

(try! (stx-transfer? MARKET-CREATION-FEE tx-sender CONTRACT-OWNER))
;; Fee goes directly to deployer, never touches the contract
(var-set total-fees-collected (+ (var-get total-fees-collected) MARKET-CREATION-FEE))
;; But this counter claims the contract collected it

Impact: Accounting mismatch. get-total-fees-collected reports a higher number than the contract actually holds, misleading integrators and users.

Recommendation: Either send creation fees to the contract (via as-contract tx-sender) and track them properly, or remove the fee counter when fees go directly to the owner.

L-01 Participant list silently drops users after 500

Location: btc-prediction-market.claradd-participant

Description: The market-participants map stores a (list 500 principal). The add-participant function uses as-max-len? which returns none when the list is full, causing the unwrap! to return false — silently failing. Users who bet after the 500th unique participant are not recorded in the participants list.

(map-set market-participants market-id
  (unwrap! (as-max-len? (append current-participants user) u500) false))
;;                                                               ^^^^^ silent failure

Impact: Late participants are invisible in the participant list. If any off-chain system uses this list for airdrops, notifications, or analytics, it will miss users.

Recommendation: Return an error or use a separate counter-based map for participant tracking that doesn't have a fixed size limit.

L-02 No market cancellation — unsettled markets lock funds forever

Location: All market contracts

Description: There is no function to cancel a market or allow users to withdraw their bets from an unsettled market. If get-burn-block-info? returns none for the settlement height (e.g., the block hash is pruned or unavailable), the market can never be settled and all deposited funds are permanently locked.

Impact: Users lose their deposits if settlement becomes impossible. There is no admin or user-initiated refund mechanism.

Recommendation: Add a cancel-market function (admin-only or after a timeout) that refunds all bettors proportionally. Add a withdraw-bet function for markets that pass a deadline without settlement.

I-01 Uses legacy as-contract instead of Clarity 4 as-contract?

Location: All contracts

Description: Despite claiming to be "Built with Clarity 4 features" and using Clarity 4 builtins like tenure-height and bitwise operations, all contracts use the legacy as-contract instead of the safer as-contract? with explicit asset allowances introduced in Clarity 4.

Impact: Missed opportunity for defense-in-depth. as-contract? with with-stx allowances would limit the blast radius of bugs like C-01 and C-02.

Recommendation: Migrate to (as-contract? (with-stx amount) ...) for all STX transfers. This explicitly declares which assets the contract context can move.

I-02 Prediction token (PMT) not integrated with market contracts

Location: prediction-token.clar

Description: The PMT reward token is fully implemented with mint/burn/batch-reward functions, but no market contract ever calls it. The use-trait sip-010-trait import in v1 is unused. The token exists in isolation with no connection to the prediction market ecosystem.

Impact: Dead code. The token serves no purpose in the current system.

Recommendation: Either integrate PMT rewards into market participation (e.g., mint tokens on bet placement or correct predictions), or remove the token contract to reduce attack surface.

Architecture Notes