7 Clarity Anti-Patterns That Kill Smart Contracts

Lessons from 16 audits and 170+ findings on Stacks

16
contracts audited
170+
issues found
7
recurring patterns
40+
critical bugs

๐Ÿ“ข Clarity 4 Update (Epoch 3.3)

These patterns were found auditing contracts written in Clarity 3 and earlier. Clarity 4 (epoch 3.3) introduced as-contract? which directly addresses the #1 anti-pattern below. New contracts should use Clarity 4. Contracts still using as-contract (Clarity 3 or lower) should migrate to as-contract?.

After auditing 16 Clarity smart contracts on Stacks, clear patterns emerge. The same mistakes show up again and again โ€” sometimes subtle, sometimes devastating, always preventable. This post documents the 7 most common anti-patterns we found, with real code from real audits.

Table of Contents

1 The as-contract / tx-sender Rebind

Frequency: Found in 3+ audits. This is the single most common critical bug in Clarity contracts.

Inside an as-contract block, tx-sender rebinds to the contract's own principal. Developers who don't know this write transfer code that either sends funds to the wrong place or does nothing at all.

Pattern A: The Self-Transfer No-Op

From a P2P lending contract โ€” every outbound transfer was broken:

;; โŒ BROKEN (Clarity 3): transfers from contract โ†’ contract (no-op)
(as-contract (stx-transfer? amount tx-sender tx-sender))

;; โœ… Clarity 4 fix: explicit allowances prevent this confusion
(let ((caller tx-sender))
  (as-contract? (with-stx)
    (stx-transfer? amount tx-sender caller)))

The developer intended tx-sender to mean "the user who called this function." But inside as-contract, both the sender and recipient resolve to the contract itself. Every withdrawal, loan disbursement, collateral return, and liquidation payout was silently doing nothing. Five critical functions โ€” all dead:

Pattern B: The Wrong-Sender Transfer

From a multisig wallet โ€” deposits went to the owner, not the contract:

;; โŒ BROKEN (Clarity 3): deposits go to CONTRACT-OWNER's personal address
(try! (stx-transfer? amount tx-sender CONTRACT-OWNER))

;; โŒ BROKEN (Clarity 3): execution pulls from the executor's own balance
(try! (stx-transfer? (get amount tx) tx-sender (get to tx)))

;; โœ… Clarity 4 fix: contract executes with explicit STX allowance
(as-contract? (with-stx)
  (try! (stx-transfer? (get amount tx) tx-sender (get to tx))))

The contract never held any funds. The entire multisig approval process was theater โ€” the owner personally funded every transfer at execution time.

Pattern C: The Stake-to-Owner Funnel

From a bug bounty platform:

;; Nominally "burns" forfeited stakes, actually sends to owner
(define-private (burn-stake (amount uint))
    (as-contract (stx-transfer? amount tx-sender CONTRACT-OWNER))
)

;; โœ… Clarity 4 fix: burn to a real burn address with explicit allowance
(define-private (burn-stake (amount uint))
    (as-contract? (with-stx)
        (stx-transfer? amount tx-sender BURN-ADDRESS))
)

Inside as-contract, tx-sender correctly refers to the contract (the holder of the stakes). But the "burn" function sends to the contract owner, creating a perverse incentive: the owner profits from rejected reports.

Capture tx-sender in a let binding before entering as-contract:
;; โœ… CORRECT: capture the caller, then use as-contract for the contract's authority
(let ((caller tx-sender))
  (as-contract (stx-transfer? amount tx-sender caller))
)

Inside the as-contract block, tx-sender is the contract (the sender), and caller is the original user (the recipient). This is the correct pattern for every outbound transfer from a contract.

๐Ÿ†• Clarity 4 Fix: as-contract?

Clarity 4 (epoch 3.3) eliminates the tx-sender rebind footgun entirely. The new as-contract? replaces as-contract and requires explicit asset allowances โ€” the contract must declare exactly what assets it's allowed to move.

Allowance expressions:

;; โœ… Clarity 4 โ€” safe version with explicit allowances
;; No tx-sender rebind footgun. Contract declares what it can move.
(as-contract? (with-stx)
  (stx-transfer? amount tx-sender recipient))

;; โœ… Clarity 4 โ€” NFT transfer with explicit allowance
(as-contract? (with-nft)
  (nft-transfer? my-nft token-id tx-sender buyer))

;; โœ… Clarity 4 โ€” multiple asset types
(as-contract? (with-stx) (with-ft)
  (begin
    (try! (stx-transfer? fee tx-sender treasury))
    (ft-transfer? reward-token amount tx-sender caller)))

This is safer because:

2 Missing Actual Transfers

Frequency: Found in 4+ audits. Contracts that track balances in maps but never move real assets.

This is subtler than the as-contract bug. The contract does real accounting โ€” incrementing balances, tracking deposits, computing rewards โ€” but never calls stx-transfer? or ft-transfer?. It's a ledger with no bank.

Bounties Calculated, Never Paid

From a bug bounty platform:

;; Bounty is computed and recorded...
(var-set total-bounties-paid (+ (var-get total-bounties-paid) final-bounty))

;; Stakes are returned (this works)...
(try! (return-stake reporter staked))

;; But where's the stx-transfer? for the bounty itself?
;; Nowhere. It doesn't exist.

The contract tracked total-bounties-paid in a data variable. The number went up. But no STX ever moved. The entire economic incentive of the platform was cosmetic.

Bridge Records Transfers, Never Executes Them

From a cross-chain bridge contract:

;; initiate-transfer records the request in a map...
(map-set transfer-requests request-id { 
  sender: tx-sender, amount: amount, status: STATUS-PENDING })

;; AI risk assessment approves it...
(map-set transfer-requests request-id 
  (merge request { status: STATUS-APPROVED }))

;; But there's no function that actually calls stx-transfer?
;; Approved transfers have no on-chain effect.

The bridge had risk scoring, multi-agent validation, daily volume limits โ€” an entire security apparatus protecting transfers that never happen.

NFT Sale Completes Without NFT Transfer

From a social platform with NFT marketplace features:

;; buy-nft records the sale in history, marks listing inactive
;; buyer's STX is transferred to seller โœ“
;; but the NFT itself? Never transferred.
;; The buyer pays and receives nothing.
For every state change that represents value movement, grep your contract for the corresponding stx-transfer?, ft-transfer?, or nft-transfer? call. If the transfer exists only in comments or variable names but not as an actual function call, the contract is broken. Every balance change in a map must correspond to a real token operation.

3 Integer Overflow & Underflow

Frequency: Found in 5+ audits. Clarity uses unsigned 128-bit integers โ€” no negative numbers, and overflow/underflow causes runtime aborts or wrapping.

Underflow Wrapping: Signer Count Goes to Max-Uint

From a multisig wallet:

;; โŒ No check if signer actually exists before decrementing
(var-set signer-count (- (var-get signer-count) u1))

If remove-signer is called for a non-existent signer, the count underflows. In Clarity, u0 - u1 causes a runtime abort (not a wrap to max-uint), which would revert the transaction. But the deeper issue is: there's no check that the signer exists at all, so the intent was clearly wrong.

Refund Underflow Blocks All Refunds

From an AMM prediction market:

;; Refund calculates: user's original shares minus shares they traded
;; But after swaps, traded shares can exceed original deposit
;; Result: underflow โ†’ transaction aborts โ†’ no refund possible

Users who traded in the AMM could never get refunds if the market was cancelled, because the refund calculation assumed shares only decreased โ€” ignoring that AMM swaps could redistribute shares.

Multiplication Overflow in AMM Math

From the same prediction market:

;; mul-down: fixed-point multiplication
;; If a * b overflows u128, the function returns max-uint
;; instead of aborting โ€” silently corrupting all AMM pricing

The mul-down helper was designed to handle overflow "gracefully" by returning u340282366920938463463374607431768211455 (max uint128). But this doesn't fail โ€” it returns a garbage value that propagates through every subsequent calculation, making trades execute at wildly wrong prices.

Division Truncation Locks Dust

From interest calculations and fee computations across multiple contracts:

;; Interest calculation: rounds down for small amounts
(/ (* amount (* rate blocks-elapsed)) u10000)

;; For small loans or short durations: result is 0
;; Borrower pays zero interest

Integer division in Clarity always truncates toward zero. This means small amounts vanish (fees round to zero, interest disappears) and remainders accumulate in the contract as permanently locked dust.

4 Access Control Gaps

Frequency: Found in nearly every audit. The most common sub-patterns:

Anyone Can Call Admin Functions

From a prediction market โ€” the most dangerous instance:

;; โŒ mock-resolve-market: no authorization check at all
;; Anyone can call this to resolve any market in their favor
;; and drain the entire vault

A market resolution function with no access control. Any user could declare themselves the winner and claim all funds.

Anyone Can Cancel

From a jackpot contract:

(define-public (cancel-pot (pot-contract <stackspot-trait>))
  ;; No check on tx-sender at all
  ;; Anyone can cancel any active pot
  ...
)

Immutable Owner, No Transfer

Found in 6+ contracts โ€” the owner is a deploy-time constant with no transfer mechanism:

(define-constant contract-owner tx-sender)

;; No set-owner, no transfer-ownership, no accept-ownership
;; If this key is compromised โ†’ game over, forever
;; If this key is lost โ†’ admin functions locked, forever
;; โœ… Two-step ownership transfer
(define-data-var owner principal tx-sender)
(define-data-var pending-owner (optional principal) none)

(define-public (propose-owner (new-owner principal))
  (begin
    (asserts! (is-eq tx-sender (var-get owner)) ERR-NOT-AUTHORIZED)
    (var-set pending-owner (some new-owner))
    (ok true)))

(define-public (accept-ownership)
  (begin
    (asserts! (is-eq (some tx-sender) (var-get pending-owner)) ERR-NOT-AUTHORIZED)
    (var-set owner tx-sender)
    (var-set pending-owner none)
    (ok true)))

5 Disconnected Multi-Contract Systems

Frequency: Found in 3+ audits. Multi-contract architectures where the contracts reference each other in comments but not in code.

The Island Archipelago

From a DAO governance system with 5 contracts (voting-escrow, proposal-manager, governance-token, timelock, treasury):

;; proposal-manager.clar
(define-public (vote (proposal-id uint) (support bool))
  (let (
    (voter-power u1000000)  ;; โ† hardcoded! Should call governance-token
  )
  ...))

;; Timelock is owner-only (not connected to proposals)
;; Treasury is independent of governance approval
;; Voting escrow can't create locks (see: shadowing bug)

Five contracts that looked like a sophisticated DAO. In reality: the proposal manager never queried the governance token for voting power (hardcoded u1000000 per voter). The timelock could only be triggered by the owner, not by passed proposals. The treasury had no connection to governance votes. Each contract was an island.

Storage Reference Mismatch

From a social platform:

;; Constant says one thing:
(define-constant STORAGE-CONTRACT .storage-v3)

;; Data variable says another:
(define-data-var storage-contract principal
  'STPC6F6C2M7QAXPW66XW4Q0AGXX9HGAX6525RMF8.storage-v3)

;; Actual calls use the constant directly, making the variable dead code

6 define-fungible-token with Zero Max Supply

Frequency: Found in 1 audit, but it's a total system kill.

Tokens That Can Never Exist

From a yield staking platform:

;; โŒ Max supply = 0. ft-mint? will ALWAYS fail.
(define-fungible-token YIELD-ANALYTICS-TOKEN u0)

The second argument to define-fungible-token is the maximum supply. Setting it to u0 means zero tokens can ever be minted. The entire reward system โ€” staking, yield calculation, tier multipliers, lock durations โ€” all of it feeds into a mint that always fails.

The contract had 300+ lines of staking logic, yield multipliers up to 90,000x, governance power calculations โ€” all computing rewards for a token that cannot exist.

Either omit the max supply argument (for unlimited supply) or set it to a meaningful cap:
;; โœ… No cap (unlimited minting, controlled by logic)
(define-fungible-token MY-TOKEN)

;; โœ… Explicit cap
(define-fungible-token MY-TOKEN u1000000000000)

7 Shadowing Builtins

Frequency: Found in 1 audit, but the impact is total contract death.

Infinite Recursion by Accident

From a DAO's voting escrow contract:

(define-private (to-int (value uint))
  (if (< value u9223372036854775807)
    (to-int value)   ;; โ† calls ITSELF, not the built-in to-int
    0))

The developer defined a helper called to-int โ€” the same name as the Clarity built-in that converts uint to int. In Clarity, user-defined functions shadow builtins. So the recursive call to to-int calls the user's function, not the builtin, creating infinite recursion.

Every function that called checkpoint (which called to-int) would hit the runtime recursion limit and abort. This meant: no locks could be created, no amounts could be increased, no unlock times could be extended. The entire voting escrow โ€” the centerpiece of the DAO โ€” was dead on arrival.

;; โœ… Different name, no shadowing
(define-private (safe-to-int (value uint))
  (if (< value u9223372036854775807)
    (to-int value)   ;; โ† now calls the REAL built-in
    0))

The Bigger Picture

These 7 patterns account for the majority of critical and high-severity findings across 16 audits. Some observations:

The common thread: Clarity's safety features (no reentrancy, atomic transactions, no implicit conversions) don't protect you from logic errors. The language prevents entire classes of EVM bugs, but it can't stop you from transferring to the wrong address, forgetting to transfer at all, or naming your function the same as a builtin.

Test your contracts. Audit your contracts. And check for these 7 patterns first.