stackmart-tips
Audit #29: StackMart Tips (SMT)
SIP-010 fungible token with allowances, staking, governance, blacklist, pause, and fee mechanisms
| Contract | SP34MN3DMM07BNAWYJSHTS4B08T8JRVK8AT810X1B.stackmart-tips |
| Token | StackMart Tips (SMT) — 1B supply, 6 decimals |
| Source | Verified on-chain via Hiro API (/v2/contracts/source) |
| Clarity Version | Not specified (pre-Clarity 4) |
| Lines of Code | ~350 |
| Confidence | 🟢 HIGH — single contract, all code reviewed, findings verified against source |
| Date | February 24, 2026 |
Overview
StackMart Tips (SMT) is a SIP-010 fungible token deployed on Stacks mainnet. It goes far beyond a basic token — it bundles allowance/approve mechanics (ERC-20 style), staking with reward tracking, on-chain governance proposals, a blacklist/pause system, transfer fees, and emergency withdrawal. The contract suffers from a fundamental architectural flaw: it maintains two independent balance systems (a token-balances map and Clarity's native define-fungible-token) that are never synchronized, rendering most token operations broken or inconsistent.
Architecture
- Dual balance tracking: Manual
token-balancesmap + nativedefine-fungible-token smt-token - Allowance system: ERC-20 style approve/transfer-from with increase/decrease/revoke
- Staking: Soft staking — tracks staked amounts in a map but tokens remain transferable
- Governance: Proposal creation, voting weighted by token balance
- Admin controls: Pause, blacklist, fee rate, emergency mode — all owner-only
Priority Score
| Metric | Score | Weight | Weighted |
|---|---|---|---|
| Financial risk | 3 (token + staking + fees) | 3 | 9 |
| Deployment likelihood | 3 (deployed mainnet) | 2 | 6 |
| Code complexity | 2 (~350 lines, multiple subsystems) | 2 | 4 |
| User exposure | 1 (unknown project) | 1.5 | 1.5 |
| Novelty | 2 (all-in-one token pattern) | 1.5 | 3 |
| Final Score | 2.35 / 3.0 (−0.5 Clarity version penalty → 1.85 ✅) | ||
Findings
C-01: Dual balance system — initial supply exists only in map, not in fungible token
Location: Contract initialization + transfer
Description: The contract initializes the owner's balance in the token-balances map but never calls ft-mint? to create actual fungible token balances. The transfer function checks the map balance but then calls ft-transfer?, which operates on the ft balance (which is zero). This means the entire initial supply of 1 billion tokens is permanently untransferable.
;; Sets map balance — but ft balance remains 0
(map-set token-balances contract-owner total-supply)
;; Defines ft with max supply — but mints nothing
(define-fungible-token smt-token total-supply)
;; transfer checks map balance (passes) then calls ft-transfer (FAILS — 0 ft balance)
(define-public (transfer (amount uint) (from principal) (to principal) ...)
(let ((from-balance (default-to u0 (map-get? token-balances from))))
(asserts! (>= from-balance amount) err-insufficient-balance)
(try! (ft-transfer? smt-token amount from to)) ;; <— always fails for initial supply
...
Impact: The initial 1B token allocation to the deployer is locked. transfer will always revert because the ft balance is zero. Only tokens created via mint (which calls ft-mint?) can be transferred — but those minted tokens won't appear in get-balance (which reads the map).
Recommendation: Remove the manual token-balances map entirely. Use only define-fungible-token with ft-mint? for the initial allocation, and ft-get-balance for balance queries.
;; Exploit test for C-01
(define-public (test-exploit-c01-transfer-fails)
;; contract-owner has 1B in map but 0 in ft
;; transfer will pass the map check but fail on ft-transfer?
(let ((result (transfer u1000000 contract-owner 'SP000000000000000000002Q6VF78 none)))
;; result is (err ...) because ft balance is 0
(asserts! (is-err result) (err u999))
(ok true)))
C-02: Balance map never updated by mint/burn/ft-transfer — get-balance returns stale data
Location: mint, burn, transfer, get-balance
Description: The get-balance read-only function reads from token-balances map, but mint uses ft-mint?, burn uses ft-burn?, and transfer uses ft-transfer? — none of which update the map. After any mint/burn/transfer, get-balance returns incorrect values. This breaks SIP-010 compliance and any downstream contract or UI relying on balance queries.
;; get-balance reads the MAP (never updated after init)
(define-read-only (get-balance (who principal))
(ok (default-to u0 (map-get? token-balances who))))
;; mint updates FT only — map is stale
(define-public (mint (amount uint) (recipient principal))
(begin
(try! (ft-mint? smt-token amount recipient)) ;; ft balance updated
;; token-balances map NOT updated
(ok true)))
Impact: All balance queries return wrong values. Governance voting uses get-balance, so vote weights are wrong. Staking checks use get-balance, so staking limits are wrong. Any integration relying on SIP-010 get-balance will malfunction.
Recommendation: Replace get-balance with ft-get-balance and remove the manual map entirely:
(define-read-only (get-balance (who principal))
(ok (ft-get-balance smt-token who)))
H-01: Pause mechanism is dead code — never checked in any operation
Location: contract-paused, pause-contract, unpause-contract
Description: The contract defines a contract-paused variable with pause/unpause functions, but no function (transfer, mint, burn, stake, etc.) ever checks is-paused. The pause mechanism has zero effect on contract behavior.
(define-data-var contract-paused bool false)
;; Owner can set this... but nothing reads it
(define-public (pause-contract)
(begin
(asserts! (is-eq tx-sender contract-owner) err-owner-only)
(var-set contract-paused true)
(ok true)))
;; transfer does NOT check is-paused
(define-public (transfer (amount uint) (from principal) (to principal) (memo (optional (buff 34))))
(begin
(asserts! (or (is-eq from tx-sender) (is-eq from contract-caller)) err-not-token-owner)
;; NO pause check here
...
Impact: The owner cannot pause the contract in case of an exploit or emergency. The pause/unpause functions give a false sense of security.
Recommendation: Add (asserts! (not (var-get contract-paused)) (err u104)) as the first check in transfer, mint, burn, transfer-from, stake-tokens, and unstake-tokens.
H-02: Blacklist mechanism is dead code — never enforced
Location: blacklisted-addresses, blacklist-address, unblacklist-address
Description: Same pattern as the pause: the blacklist map exists and the owner can add/remove addresses, but no function checks is-blacklisted before executing. Blacklisted addresses can transfer, mint, burn, stake, vote, and use all functions normally.
Impact: Blacklisting has no effect. If this feature is meant for regulatory compliance (e.g., sanctioned addresses), it provides zero protection.
Recommendation: Add blacklist checks to transfer, transfer-from, and other user-facing functions for both sender and recipient.
H-03: Transfer fee mechanism defined but never applied
Location: transfer-fee-rate, fee-recipient, calculate-fee, transfer
Description: The contract defines a fee rate (default 1%), fee recipient, calculate-fee function, and set-transfer-fee-rate admin function — but transfer never deducts or sends fees. The entire fee system is dead code.
;; Fee infrastructure exists...
(define-data-var transfer-fee-rate uint u100) ;; 1%
(define-read-only (calculate-fee (amount uint))
(/ (* amount (var-get transfer-fee-rate)) u10000))
;; ...but transfer ignores it entirely
(define-public (transfer (amount uint) (from principal) (to principal) ...)
;; No fee deduction anywhere
Impact: No fees are collected despite the contract advertising fee functionality. If an admin changes the fee rate expecting it to take effect, nothing happens.
Recommendation: Either implement fee deduction in transfer (deduct fee from amount, send fee to fee-recipient) or remove the dead fee code.
M-01: Staking does not lock tokens — staked tokens remain fully transferable
Location: stake-tokens, unstake-tokens
Description: The staking mechanism only updates a staked-balances map — it never moves, locks, or escrows the tokens. A user can stake their entire balance, then immediately transfer all their tokens to another address. The staked balance map becomes a phantom entry with no economic backing.
(define-public (stake-tokens (amount uint))
(let ((current-balance (unwrap! (get-balance tx-sender) err-insufficient-balance))
(current-staked (default-to u0 (map-get? staked-balances tx-sender))))
;; Only updates a map — tokens never move
(map-set staked-balances tx-sender (+ current-staked amount))
(var-set total-staked (+ (var-get total-staked) amount))
(ok true)))
Impact: Staking provides no economic security. The total-staked counter can be inflated arbitrarily. Any rewards based on staked amounts would be exploitable.
Recommendation: Transfer staked tokens to the contract (escrow) or implement a lock mechanism that prevents transfer of staked amounts.
M-02: Governance votes use current balance — double voting via transfer
Location: vote-on-proposal
Description: Vote weight is determined by the voter's current get-balance at the time of voting, with no snapshot mechanism. A token holder can vote with address A, transfer tokens to address B, and vote again — effectively doubling their voting power.
(define-public (vote-on-proposal (proposal-id uint) (vote-for bool))
(let ((voter-balance (unwrap! (get-balance tx-sender) err-insufficient-balance)))
;; Uses CURRENT balance — no snapshot
(if vote-for
(map-set proposals proposal-id (merge proposal {votes-for: (+ (get votes-for proposal) voter-balance)}))
...
Impact: Any token holder can multiply their voting power by splitting tokens across multiple addresses and voting from each. Governance outcomes can be manipulated.
Recommendation: Implement balance snapshots at proposal creation block height, or use a token-locking mechanism during the voting period.
M-03: Approve race condition (classic ERC-20 allowance issue)
Location: approve
Description: The approve function directly sets the allowance to a new value. If an owner changes an allowance from N to M, the spender can front-run the transaction: spend N tokens, then have the new allowance of M — effectively spending N+M total.
(define-public (approve (spender principal) (amount uint))
(begin
;; Directly overwrites — no check of current allowance
(map-set token-allowances {owner: tx-sender, spender: spender} amount)
(ok true)))
Impact: Spenders can extract more tokens than intended when allowances are changed.
Recommendation: Recommend users use increase-allowance/decrease-allowance instead of approve, or require setting to zero before changing to a non-zero value.
L-01: approve allows setting allowance for zero-amount (inconsistent validation)
Location: approve
Description: The approve function requires (> amount u0), but to revoke an allowance (set to zero), users must use the separate revoke-allowance function. This is inconsistent — increase-allowance and decrease-allowance also reject zero amounts. Meanwhile, approve with amount 0 would be the standard way to reset an allowance in ERC-20 patterns.
Impact: Minor UX friction. Users expecting ERC-20 semantics (approve(spender, 0)) will get an error.
Recommendation: Allow approve to accept u0 to clear allowances, or document the revoke-allowance pattern clearly.
L-02: Emergency withdraw is just a transfer from the owner — no special capability
Location: emergency-withdraw
Description: The emergency withdraw function calls (transfer amount tx-sender recipient none) — it can only transfer tokens the owner already holds. It provides no capability to recover tokens from other addresses or the contract. Given that transfer already works for the owner, this function adds nothing beyond the emergency-mode gate.
(define-public (emergency-withdraw (amount uint) (recipient principal))
(begin
(asserts! (var-get emergency-mode) err-emergency-only)
(asserts! (is-eq tx-sender contract-owner) err-owner-only)
;; Just a normal transfer from owner
(try! (transfer amount tx-sender recipient none))
(ok true)))
Impact: The emergency mechanism provides false assurance — it cannot rescue stuck tokens or handle actual emergencies.
Recommendation: If emergency recovery is desired, implement the ability to transfer tokens from the contract's own balance or implement a migration mechanism.
I-01: Pre-Clarity 4 — no asset allowance restrictions available
Description: The contract does not specify a Clarity version and predates Clarity 4. While this contract doesn't use as-contract, future upgrades involving contract-held assets should use Clarity 4's as-contract? with explicit with-ft/with-stx allowances.
Recommendation: If redeploying, target Clarity 4 for improved safety builtins.
I-02: Significant SIP-010 deviations — non-standard extensions
Description: While the contract implements the sip-010-trait, it adds extensive non-standard functionality: allowances, staking, governance, blacklisting, pause, fees, batch transfers, and emergency mode. These extensions are not part of the SIP-010 standard and may confuse integrators. The transfer function also authorizes based on contract-caller in addition to tx-sender, which is SIP-010 compliant but worth noting.
Recommendation: Consider separating staking and governance into dedicated contracts for cleaner SIP-010 compliance.
I-03: Owner has extensive centralized control
Description: The contract owner (tx-sender at deploy time) has unchecked authority to: mint unlimited tokens (up to the 1B ft cap), pause/unpause, blacklist any address, set transfer fees up to 10%, change the fee recipient, enable emergency mode, and withdraw tokens. There is no multisig, timelock, or governance check on any admin function.
Impact: Users must fully trust the deployer. The owner can mint tokens to dilute holders, set fees to extract value, or blacklist addresses (if the blacklist were enforced).
Recommendation: Consider implementing a timelock for sensitive operations, or transition admin functions to the governance system.
Positive Observations
- Implements SIP-010 trait: The contract correctly declares
impl-traitfor the SIP-010 standard. - Amount validation: All public functions check
(> amount u0)to reject zero-amount operations. - Batch transfer: The
batch-transferfunction uses a fold pattern that short-circuits on first failure — correct error propagation. - Fee rate cap:
set-transfer-fee-rateenforces a maximum of 1000 basis points (10%), preventing extreme fee extraction. - Governance deduplication: The voting system tracks individual votes to prevent the same address from voting twice on the same proposal.
- Print events: All state-changing functions emit print events for off-chain indexing.
Summary
This contract has fundamental architectural flaws that render it largely non-functional. The dual balance tracking system (C-01, C-02) means the initial 1B token allocation cannot be transferred, and all balance queries return incorrect data after any mint/burn operation. Beyond the broken balance system, three major subsystems — pause, blacklist, and fees — are defined but never enforced (H-01, H-02, H-03), providing zero functionality despite their code presence. The staking system (M-01) doesn't actually lock tokens, and governance (M-02) is vulnerable to vote manipulation.
This contract should not be used in production. A rewrite is recommended that: (1) removes the manual token-balances map and relies solely on define-fungible-token + ft-get-balance; (2) integrates pause/blacklist/fee checks into the actual transfer flow; (3) implements real token locking for staking; and (4) adds balance snapshots for governance voting.