πŸ” Security Audit: market-factory-v18-bias

Contract: SP3N5CN0PE7YRRP29X7K9XG22BT861BRS5BN8HFFA.market-factory-v18-bias
Source: Hiro API (on-chain)
Type: LMSR Prediction Market Factory with Bias/Virtual Liquidity
Clarity Version: Pre-Clarity 4 (uses as-contract, not as-contract?)
Lines: ~900+ Clarity
Auditor: cocoa007
Date: February 24, 2026
Confidence: High β€” single-contract system, all code paths reviewed

Summary

1
Critical
3
High
3
Medium
3
Low
3
Info

Overview

This is a binary LMSR (Logarithmic Market Scoring Rule) prediction market factory deployed on Stacks mainnet. It supports market creation with initial liquidity, YES/NO share trading with fees, bias/virtual liquidity for initial pricing, and admin-controlled resolution. Math operates in STX units with uSTX (1e6) for transfers.

Architecture: Single contract manages all markets via map-indexed state. A single hardcoded ADMIN address controls market lifecycle (create β†’ bias β†’ trade β†’ resolve β†’ redeem β†’ withdraw surplus). Fees split across four recipients (drip, brc20, team, LP) with configurable BPS rates and percentage splits.

Documented Limitations / Trust Assumptions

Findings

Critical C-01: Self-Service Spending Cap Bypass in Auto-Buy Functions

Location

buy-yes-auto, buy-no-auto β€” bump-cap-if-needed call

Description

The buy-yes-auto and buy-no-auto functions accept a user-supplied target-cap parameter and call bump-cap-if-needed, which unconditionally increases the user's spending cap to any value they request. This means any user can set their own cap to u340282366920938463463374607431768211455 (max uint128) and bypass the entire cap system.

The regular buy-yes / buy-no functions enforce the cap but don't allow setting it β€” however, a user simply calls buy-yes-auto once with a huge target-cap, then uses buy-yes freely afterward.

;; User controls target-cap β€” can set to any value
(define-public (buy-yes-auto (m uint) (amount uint) (target-cap uint) (max-cost uint))
  (begin
    ...
    (bump-cap-if-needed m tx-sender target-cap)  ;; user sets own cap!
    ...

(define-private (bump-cap-if-needed (m uint) (who principal) (target-cap uint))
  (let ((cur (default-to u0 (get cap (map-get? user-caps { m: m, user: who })))))
    (if (> target-cap cur)
        (begin (map-set user-caps { m: m, user: who } { cap: target-cap }) true)
        true)))

Impact

The spending cap system is entirely cosmetic. Any user can bypass it. If the cap was intended as a risk management or regulatory control (e.g., limiting maximum exposure per user), it provides zero protection. This is particularly concerning because the task context mentions "aborted txs still set cap state" β€” the cap can be bumped even if the trade itself fails due to slippage or other checks, since bump-cap-if-needed is called before the trade validation.

Recommendation

Remove user-controlled cap setting from auto-buy functions. Caps should only be settable by the admin:

(define-public (set-user-cap (m uint) (who principal) (cap uint))
  (begin
    (try! (only-admin))
    (map-set user-caps { m: m, user: who } { cap: cap })
    (ok true)))

Exploit Test

;; Exploit test for C-01: Self-service cap bypass
(define-public (test-exploit-c01-cap-bypass)
  (let (
    ;; User has no cap set (default u0)
    ;; Call buy-yes-auto with target-cap = max uint
    ;; Cap is now unlimited β€” user can trade without restriction
    (result (buy-yes-auto u1 u1 u340282366920938463463374607431768211455 u999999999999))
  )
    ;; Even if trade fails, cap was already bumped by bump-cap-if-needed
    ;; User's cap is now effectively infinite
    (asserts! (> (get-cap u1 tx-sender) u0) (err u999))
    (ok true)))
High H-01: Admin Can Resolve Markets Dishonestly β€” No Oracle or Dispute Mechanism

Location

resolve function

Description

The resolve function allows the single ADMIN to unilaterally declare any market outcome as "YES" or "NO" with no on-chain verification, oracle feed, dispute period, or community override. The admin can resolve against the actual real-world outcome to profit from positions or harm specific users.

(define-public (resolve (m uint) (result (string-ascii 3)))
  (begin
    (try! (only-admin))  ;; only admin, no oracle
    (asserts! (is-eq (get-status-str m) "open") (err u102))
    (asserts! (or (is-eq result "YES") (is-eq result "NO")) (err u103))
    ;; No verification of actual outcome
    (map-set m-outcome { m: m } { o: result })
    (map-set m-status  { m: m } { s: "resolved" })
    (ok true)))

Impact

Complete trust dependency on a single address. Admin can steal all pool funds by resolving in their favor. Combined with the ability to trade (admin is not excluded from trading), admin can buy shares on one side and resolve that side as winner.

Recommendation

Implement at minimum: (1) a dispute/challenge period after resolution, (2) an oracle integration for verifiable outcomes, or (3) a multisig resolution requirement. Also consider excluding the ADMIN from holding shares.

High H-02: Pre-Clarity 4 as-contract Grants Blanket Asset Access

Location

xfer-out function, all as-contract calls

Description

The contract uses as-contract (pre-Clarity 4) which gives blanket authority to transfer any asset the contract holds. With Clarity 4's as-contract? and explicit allowances (with-stx), the contract could restrict itself to only moving STX, preventing any future exploit path involving other token types.

(define-private (xfer-out (amt uint) (to principal))
  (if (or (is-eq amt u0) (is-eq to (self-principal)))
      (ok true)
      (as-contract (stx-transfer? amt (self-principal) to))))

Impact

If any FTs or NFTs are accidentally sent to the contract, they cannot be restricted from being moved. A future upgrade or interaction could exploit this broader authority.

Recommendation

Migrate to Clarity 4 and use (as-contract? (with-stx) ...) to explicitly limit asset movement to STX only.

High H-03: Admin Can Change Fee Rates and Recipients Mid-Market

Location

set-fees, set-fee-recipients, set-protocol-split

Description

Until lock-fees-config is called, the admin can change fee BPS (up to 100%), fee split percentages, and all four fee recipient addresses at any time. This affects all open markets simultaneously. There is no timelock or notification mechanism. The fees-locked flag is irreversible once set, but there's no guarantee it will ever be set.

(define-public (set-fees (protocol-bps uint) (lp-bps uint))
  (begin
    (try! (only-admin))
    (try! (guard-not-locked))
    ;; Can set up to 10000 bps (100%) each!
    (asserts! (<= protocol-bps u10000) (err u740))
    (asserts! (<= lp-bps       u10000) (err u741))
    ...))

Impact

Admin could set protocol-fee-bps + lp-fee-bps to 20000 (200%), making every trade cost 3x the base amount. Or redirect all fee recipients to a personal wallet. Users currently trading have no protection against mid-flight fee changes.

Recommendation

Cap combined fees to a reasonable maximum (e.g., 1000 bps = 10%). Add a timelock for fee changes. Consider per-market fee snapshots at creation time.

Medium M-01: LMSR Math Overflow β€” exp-fixed Caps at 2^31-1 for Large Inputs

Location

exp-fixed function

Description

For large positive inputs (when k >= 20), exp-fixed returns a hardcoded 2147483647 (2^31-1) instead of the correct exponential value. This affects cost calculations when share quantities are large relative to the liquidity parameter b. The LMSR cost function depends on accurate exp(q/b) values.

(if (>= ku u20)
    (if (< k 0) 0 (to-int u2147483647))  ;; hardcoded cap
    ...)

Impact

For markets where q/b > 20*ln(2) β‰ˆ 13.86, pricing becomes flat/inaccurate. With typical b values around initial-liquidity/ln(2), this means shares exceeding ~14x the initial liquidity (in STX) will hit the cap. Trades in this range will misprice, potentially allowing arbitrage.

Recommendation

Document the safe operating range prominently. Add a guard that rejects trades pushing q/b beyond the accurate range (e.g., q/b < 12).

Medium M-02: ln-general Limited to 8 Normalization Steps

Location

ln-general via ln-norm-step

Description

The natural logarithm implementation uses 8 iterative halving/doubling steps to normalize the input to [0.5, 2.0] range before applying the atanh series. This limits accurate computation to inputs in approximately [2^-8, 2^8] Γ— SCALE = [3906, 256000000]. Values outside this range will produce incorrect results because normalization is incomplete.

Impact

Affects cost-tilde, set-market-bias, and invert-buy-shares when intermediate exp values exceed the normalized range. Could produce incorrect pricing or bias calculations for extreme market states.

Recommendation

Increase normalization steps to 20+ to match the exp-fixed range, or add input validation to reject values outside the accurate domain.

Medium M-03: STX/uSTX Truncation Systematically Favors Buyers

Location

calculate-cost β†’ do-buy conversion path

Description

The cost calculation operates in STX units (integers), and the result is converted to uSTX via stx->ustx (multiply by 1e6). However, cost-tilde returns an integer in STX, discarding sub-STX fractions. For small trades, this truncation means the actual cost in uSTX is systematically rounded down, giving buyers shares at a slight discount. The minimum cost floor is 1 STX (1,000,000 uSTX) regardless of actual LMSR price.

(baseStx (if (> c0 u0) c0 u1))  ;; floor to 1 STX minimum
(baseUstx (stx->ustx baseStx))   ;; loses sub-STX precision

Impact

For each trade, up to 999,999 uSTX (~1 STX) of cost can be lost. Over many small trades, this leaks value from the pool. The per-trade solvency check mitigates catastrophic drain but doesn't prevent gradual erosion.

Recommendation

Perform cost calculation in uSTX units natively, or use ceiling division when converting STX→uSTX for buy costs.

Low L-01: Fee Recipients Set to Contract Address Silently Absorb Fees

Location

xfer-out function

Description

xfer-out returns (ok true) without transferring if the recipient equals the contract's own principal. If admin accidentally (or intentionally) sets a fee recipient to the contract address, fees are silently kept in the contract rather than distributed. These fees then become part of the pool balance, inflating it.

(define-private (xfer-out (amt uint) (to principal))
  (if (or (is-eq amt u0) (is-eq to (self-principal)))
      (ok true)    ;; silently no-ops
      (as-contract (stx-transfer? amt (self-principal) to))))

Impact

Low β€” requires admin misconfiguration. Fees would be retained in the contract pool rather than lost.

Recommendation

Add a check in set-fee-recipients that rejects the contract's own address as a recipient.

Low L-02: No Event Emission for Trades, Resolution, or Fee Changes

Location

All public functions

Description

The contract emits no print events for any state changes. Off-chain indexers and UIs must rely on transaction inspection rather than structured event data. This makes monitoring, auditing, and dispute resolution harder.

Impact

Operational visibility only. No direct security impact, but impedes incident detection.

Recommendation

Add (print { event: "buy", market: m, side: "yes", shares: amount, cost: total, user: tx-sender }) and similar for sells, resolution, and admin actions.

Low L-03: No Timelock on Admin Operations

Location

All admin functions

Description

All admin operations (pause, resolve, fee changes, withdrawal) take effect immediately with no delay or timelock. Users cannot react to adverse admin actions before they take effect.

Impact

Reduces user protection against admin key compromise or malicious admin behavior. Users have no time to exit positions before adverse changes.

Recommendation

Implement a timelock (e.g., 24-48 hours) for sensitive operations like resolution, fee changes, and pausing. Allow users to exit during the delay period.

Info I-01: Bias Lock Is One-Way β€” Cannot Adjust After Setting

Location

set-market-bias

Description

Once set-market-bias is called, m-bias-locked is set to true and can never be changed. The bias also requires zero supply (no trades yet). This is by design but means the admin must get the initial bias right on the first try.

Impact

Operational constraint. An incorrectly set bias requires creating a new market.

Info I-02: No Market Expiry or Deadline Mechanism

Location

Contract-wide

Description

Markets have no expiry block height or timestamp. An unresolved market stays open indefinitely. There is no mechanism for users to force resolution or reclaim funds from a stale market (admin could simply abandon a market).

Impact

Funds could be locked indefinitely if admin becomes unresponsive. Users can still sell shares on open markets, but at LMSR prices (not at 1:1 redemption value).

Info I-03: Solvency Check Is Per-Trade, Not Invariant

Location

do-buy β€” ERR-TRADE-INSOLVENT check

Description

The per-trade solvency check pool >= max(yesSupply, noSupply) * UNIT is correct for binary LMSR and is a good safety mechanism. However, due to the truncation issue in M-03, the pool can gradually erode below the theoretical LMSR solvency level between trades. The check prevents catastrophic insolvency but allows slow leakage.

Impact

Informational β€” the guard is sound for its purpose. The slow leak from truncation is covered in M-03.

Positive Observations

Priority Matrix Score

MetricWeightScoreWeighted
Financial risk33 (DeFi/prediction market)9
Deployment likelihood23 (deployed mainnet)6
Code complexity23 (900+ lines, LMSR math)6
User exposure1.52 (known project)3
Novelty1.53 (LMSR with bias on Stacks)4.5
Total2.85 / 3.0

Clarity version penalty: -0.5 (pre-Clarity 4) β†’ Final: 2.35 βœ… Above 1.8 threshold

Methodology

Manual code review of the on-chain Clarity source fetched from Hiro API. All public/private functions analyzed for access control, math correctness, state consistency, and fund safety. Review focused on LMSR math accuracy, admin privilege scope, spending cap enforcement, fee mechanics, and solvency guarantees per the task specification.