anti-patterns
7 Clarity Anti-Patterns That Kill Smart Contracts
Lessons from 16 audits and 170+ findings on Stacks
๐ข 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.
- 1. The
as-contract/tx-senderRebind โ the #1 killer - 2. Missing Actual Transfers โ bookkeeping without money
- 3. Integer Overflow & Underflow โ uint math gone wrong
- 4. Access Control Gaps โ anyone can be admin
- 5. Disconnected Multi-Contract Systems โ islands pretending to be a bridge
- 6.
define-fungible-tokenwith Zero Max Supply โ tokens that can't exist - 7. Shadowing Builtins โ infinite recursion by accident
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:
withdraw-fundsโ lenders could never withdraw depositsborrowโ borrowers posted collateral but never received loansrepayโ borrowers repaid debt but collateral was never returnedliquidateโ liquidators paid debt but got no collateralwithdraw-collateralโ collateral permanently locked
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.
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.
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:
with-stxโ allow STX transferswith-ftโ allow fungible token transferswith-nftโ allow NFT transferswith-stackingโ allow stacking operationswith-all-assets-unsafeโ allow everything (use only when necessary)
;; โ
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:
- The contract explicitly declares what assets it's allowed to move โ no accidental drains
- If a contract only needs to move STX, it says
(with-stx)and can't accidentally move NFTs - Auditors can immediately see the asset scope of each
as-contract?block - Contracts still using
as-contract(Clarity 3 or lower) should migrate toas-contract?
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.
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.
- Always check for underflow before subtraction:
(asserts! (>= a b) ERR-UNDERFLOW) - For multiplication, check that the result divided by one operand equals the other:
(asserts! (is-eq (/ result b) a) ERR-OVERFLOW) - For division-sensitive math, multiply first, divide last, and consider using a higher precision intermediate representation
- Never silently return a default on overflow โ always abort
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
- Every public function that modifies state should have an explicit authorization check
- Use a two-step ownership transfer pattern (propose + accept) to prevent transferring to an invalid address
- Consider timelocks for sensitive admin operations
- Use
contract-callerinstead oftx-senderfor authorization when you want to prevent proxy/intermediary contract attacks
;; โ
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
- Every cross-contract dependency should be an actual
contract-call?, not a comment - Write integration tests that exercise the full flow across all contracts
- If contract A's security depends on contract B enforcing an invariant, verify that B actually enforces it
- Use traits to define the interface contract, ensuring compile-time verification of cross-contract calls
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.
;; โ
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.
- Never name your functions the same as Clarity builtins (
to-int,to-uint,not,and,or,map,filter,fold,len, etc.) - Use descriptive prefixes:
safe-to-int,checked-to-int,uint-to-int - If you must wrap a builtin, reference it differently:
;; โ
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
as-contractrebind (#1) and missing transfers (#2) are the most dangerous because they silently succeed. The transaction doesn't abort โ it just doesn't do what you think. Users see "success" while their funds are locked or lost. - Overflow/underflow (#3) at least fails loudly in most cases (Clarity aborts on uint underflow). The exception is when contracts catch the error and return a default โ like the AMM returning max-uint on overflow.
- Access control (#4) is the most common but often the easiest to fix โ a single
asserts!line. - Disconnected systems (#5) reveal a design problem, not just a code bug. No amount of per-function fixes will help if the contracts don't actually talk to each other.
- Zero supply (#6) and shadowing (#7) are rare but total kills โ they render entire contracts non-functional with a single line.
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.