FlashStack Protocol — Flash Loan Security Audit

mattglory/Flashstack · Commit: 5928466 (2026-02-11) · Audited: February 21, 2026

2
Critical
3
High
3
Medium
2
Low
2
Info

Overview

FlashStack is an atomic flash-minting protocol for sBTC on the Stacks blockchain. Users with STX locked in PoX-4 can flash-mint sBTC (up to 1/3 of their locked STX value), execute arbitrary receiver callbacks, and repay within the same transaction. The protocol includes a core flash-mint engine, a custom sBTC token with mint/burn privileges, a receiver trait interface, and 10+ receiver implementations for arbitrage, liquidation, leverage looping, collateral swaps, and yield optimization.

The codebase spans 13 Clarity contracts totaling ~1,200 lines. All contracts use Clarity v2 (epoch 2.5). The architecture follows the EIP-3156 flash loan pattern adapted for Clarity's single-transaction execution model.

Priority Score

MetricWeightScoreWeighted
Financial risk33 — Flash loans, sBTC minting/burning, DeFi composability9
Deployment likelihood22 — Has tests, scripts, multiple iterations (v1→v2→v3)4
Code complexity23 — 13 contracts, 1200+ lines, trait-based callback pattern6
User exposure1.50 — 0 stars, 1 fork0
Novelty1.53 — Flash loans on Stacks, unique mechanism4.5
Total (pre-penalty)23.5 / 10 = 2.35
Clarity v2 penalty-0.5
Final Score1.85 ≥ 1.8 ✓

Findings Summary

IDSeverityTitle
C-01CRITICALsBTC token allows unrestricted minting by deployer — infinite token creation
C-02CRITICALFlash-mint repayment check is circumventable — receiver can drain minted tokens
H-01HIGHBurned tokens disappear but fees are never collected — protocol earns nothing
H-02HIGHAdmin has unilateral power to drain all flash-mintable value
H-03HIGHReceiver contracts use hardcoded fee calculations — desync with core fee rate
M-01MEDIUMCollateral is based on test map, not real PoX-4 — mock never removed
M-02MEDIUMBlock volume tracking uses block-height not stacks-block-height
M-03MEDIUMDEX price-setting functions in receivers have no access control
L-01LOWFee rounding to zero on small amounts — free flash loans
L-02LOWNo fee upper bound validation allows admin to set 100% fee
I-01INFOClarity v2 — should migrate to v4 for as-contract? and restrict-assets?
I-02INFOflashstack-core-v2 is incomplete — missing variables, constants, and admin functions

Detailed Findings

C-01 sBTC token allows unrestricted minting by deployer — infinite token creation

Location: sbtc-token.clar, mint function

Description: The sBTC token's mint function has a dual authorization path: contract-caller == flash-minter OR tx-sender == CONTRACT-OWNER. The tx-sender check means the deployer can mint unlimited sBTC at any time by calling mint directly, completely outside the flash-loan mechanism. This is not just an admin privilege — it's a backdoor that undermines the entire flash-loan invariant (tokens should only exist during flash execution).

(define-public (mint (amount uint) (recipient principal))
  (begin
    (asserts! (or (is-eq contract-caller (var-get flash-minter))
                   (is-eq tx-sender CONTRACT-OWNER))  ;; <-- deployer can mint anytime
              ERR-NOT-AUTHORIZED)
    (ft-mint? sbtc amount recipient)
  )
)

Impact: The deployer can mint infinite sBTC to any address, inflating supply without collateral. If sBTC has any real value (pegged or traded), this is a rug-pull vector. Even the burn function has the same backdoor — the deployer can burn anyone's tokens.

Recommendation: Remove tx-sender == CONTRACT-OWNER from both mint and burn. Only the flash-minter contract should be able to mint/burn. For initial setup, use a one-time setup function or constructor pattern.

C-02 Flash-mint repayment check is circumventable — receiver can drain minted tokens

Location: flashstack-core.clar, flash-mint function

Description: The repayment verification checks that the contract's own balance increased by total-owed after the callback. However, the minted tokens go to the receiver contract, not the core contract. The receiver can transfer tokens to the core contract's balance AND keep the remainder. Since total-owed = amount + fee and the protocol mints exactly total-owed to the receiver, the receiver must transfer all of it back — but the check only verifies the core contract balance delta, not that the receiver's balance is zero.

;; Mint amount + fee to receiver
(try! (contract-call? .sbtc-token mint total-owed receiver-principal))

;; Execute callback
(match (contract-call? receiver execute-flash amount borrower)
  success (begin
    (let (
      (balance-after (unwrap! (as-contract (contract-call? .sbtc-token get-balance tx-sender)) ...))
    )
      ;; Only checks core balance increased
      (asserts! (>= balance-after (+ balance-before total-owed)) ERR-REPAY-FAILED)
      ;; Then burns total-owed from core
      (try! (as-contract (contract-call? .sbtc-token burn total-owed tx-sender)))
    )))

Impact: A malicious receiver can: (1) receive total-owed tokens, (2) transfer total-owed to the core contract (passing the check), (3) but also mint additional tokens or accumulate from previous flash loans. More critically, if a receiver is whitelisted but has a re-entrancy path or secondary callback, it could manipulate the balance check. The fundamental issue is that the invariant should verify total supply unchanged, not just core balance.

Recommendation: Instead of checking balance delta, check that the total sBTC supply after the callback equals the supply before the callback. This is the canonical flash-loan invariant: supply-after == supply-before (the minted tokens must be fully returned for burning).

H-01 Burned tokens disappear but fees are never collected — protocol earns nothing

Location: flashstack-core.clar, flash-mint

Description: The protocol mints amount + fee, then burns amount + fee (the full total-owed) from the core contract. The fee is burned along with the principal — it's destroyed, not collected. The total-fees-collected variable increments as a counter, but no sBTC is ever retained by the protocol or transferred to an admin/treasury.

;; Burns the entire total-owed, including the fee
(try! (as-contract (contract-call? .sbtc-token burn total-owed tx-sender)))

Impact: The protocol has zero revenue. The fee mechanism is purely cosmetic. If the protocol is meant to generate revenue for maintenance, development, or stakers, it currently cannot.

Recommendation: Burn only amount and transfer fee to a treasury address. Alternatively, keep the fee in the core contract for later withdrawal by admin.

H-02 Admin has unilateral power to drain all flash-mintable value

Location: flashstack-core.clar, admin functions; sbtc-token.clar, set-flash-minter

Description: The admin (single EOA) can: (1) set-admin to transfer ownership, (2) set-fee up to 1% per loan, (3) set-max-single-loan and set-max-block-volume to arbitrary values, (4) add-approved-receiver to whitelist a malicious receiver, (5) set-test-stx-locked to fake any collateral. Combined with C-01 (deployer mint), a compromised admin key means total loss of protocol integrity.

Impact: Single point of failure. Admin key compromise = total protocol compromise.

Recommendation: Implement timelocked admin actions, multisig requirement, or at minimum separate roles for different admin functions. Remove set-test-stx-locked before any deployment.

H-03 Receiver contracts use hardcoded fee calculations — desync with core fee rate

Location: example-arbitrage-receiver.clar, liquidation-receiver.clar, snp-flashstack-receiver.clar, and others

Description: Most receiver contracts hardcode the fee calculation as (/ (* amount u5) u10000) (5 basis points). Only test-receiver.clar and snp-flashstack-receiver-v3.clar correctly query the core contract's fee rate via get-fee-basis-points. If the admin changes the fee rate, these hardcoded receivers will calculate the wrong repayment amount and either fail (if fee increased) or overpay (if fee decreased).

;; Hardcoded in most receivers:
(fee (/ (* amount u5) u10000))

;; Correct approach (only in test-receiver and v3):
(fee-bp (unwrap! (contract-call? .flashstack-core get-fee-basis-points) ...))
(fee (/ (* amount fee-bp) u10000))

Impact: Fee desync causes transaction failures or fund leakage. If fee is raised, existing receivers revert (denial of service). If lowered, receivers overpay (wasted funds).

Recommendation: All receivers should query the fee dynamically from the core contract, as test-receiver and snp-flashstack-receiver-v3 already do.

M-01 Collateral is based on test map, not real PoX-4 — mock never removed

Location: flashstack-core.clar, get-stx-locked and set-test-stx-locked

Description: The production PoX-4 integration is commented out and replaced with a test-locked-stx map that the admin can set arbitrarily. The comment says "TODO: Remove this before mainnet deployment" but it's the only active implementation. There's no compile-time or deploy-time guard against shipping the test version.

Impact: If deployed as-is, collateral checks are meaningless — admin can grant flash-mint access to anyone by setting fake locked STX values.

Recommendation: Implement a deployment checklist. Use Clarinet deployment plans to ensure the production version is deployed. Consider a feature flag pattern that disables test functions after initialization.

M-02 Block volume tracking uses block-height not stacks-block-height

Location: flashstack-core.clar, flash-mint circuit breaker

Description: The per-block volume limit uses block-height (the keyword, which in Clarity v2 refers to the burn chain block height). Since Stacks 2.1+, stacks-block-height should be used for Stacks-specific logic. Using Bitcoin block height for rate limiting means the volume window is ~10 minutes instead of the expected Stacks block time, which can vary.

(let (
  (current-block-volume (default-to u0 (map-get? block-loan-volume block-height)))
  ...
)

Impact: Rate limiting may be more or less restrictive than intended depending on Bitcoin vs Stacks block time divergence.

Recommendation: Use stacks-block-height for Stacks-specific rate limiting.

M-03 DEX price-setting functions in receivers have no access control

Location: example-arbitrage-receiver.clar (set-dex-a-price, set-dex-b-price), dex-aggregator-receiver.clar (set-alex-price, set-velar-price, set-bitflow-price)

Description: These public functions allow anyone to set the simulated DEX prices. While labeled as "for testing," they're public functions on deployed contracts with no access control.

(define-public (set-dex-a-price (price uint))
  (begin
    (asserts! (> price u0) ERR-ARBITRAGE-FAILED)
    (ok (var-set dex-a-price price))  ;; Anyone can call
  )
)

Impact: If these receivers are deployed alongside the core contract, anyone can manipulate the price feeds that guide arbitrage decisions.

Recommendation: Add admin-only checks or remove test functions before deployment.

L-01 Fee rounding to zero on small amounts — free flash loans

Location: flashstack-core.clar, fee calculation

Description: The fee formula (/ (* amount 5) 10000) rounds down to zero for amounts less than 2,000 sats (0.00002 sBTC). This enables free flash loans for small amounts.

Impact: Minimal financial impact given the small amounts involved, but it creates a free-rider vector for high-frequency small flash loans.

Recommendation: Enforce a minimum fee: (max u1 (/ (* amount fee-bp) u10000)).

L-02 No fee upper bound validation — admin can set punitive fees

Location: flashstack-core.clar, set-fee

Description: The fee validation caps at 100 basis points (1%), but uses ERR-UNAUTHORIZED as the error code instead of a dedicated ERR-INVALID-FEE. The cap itself is reasonable but the error message is misleading.

Impact: Minor UX issue — confusing error when fee validation fails.

Recommendation: Use ERR-INVALID-AMOUNT for fee validation errors.

I-01 Clarity v2 — should migrate to v4 for enhanced security builtins

Description: All contracts use Clarity v2 (epoch 2.5). Clarity v4 introduces as-contract? with explicit asset allowances and restrict-assets?, which would significantly improve the security of the flash-mint pattern. The current as-contract usage in the core contract (for balance checks and burns) would benefit from explicit asset restrictions.

Recommendation: Upgrade to Clarity v4 and use as-contract? with with-ft allowances for all sBTC operations.

I-02 flashstack-core-v2 is incomplete — missing variables, constants, and admin functions

Description: The flashstack-core-v2.clar file contains only the flash-mint function but references undefined variables (flash-fee-basis-points, admin, total-flash-mints, etc.) and constants (MIN-COLLATERAL-RATIO). It also uses the flash-receiver trait but doesn't re-import it. This file will not compile.

Recommendation: Either complete v2 or remove it to avoid confusion. The active contracts in Clarinet.toml don't include it, but its presence in the repo is misleading.

Architecture Analysis

Flash Loan Flow

  1. Borrower calls flash-mint(amount, receiver)
  2. Core checks: pause state, whitelist, circuit breakers, collateral (PoX-4 locked STX)
  3. Core records balance-before of its own sBTC balance
  4. Core mints amount + fee to the receiver contract
  5. Core calls receiver.execute-flash(amount, borrower)
  6. Receiver executes strategy (arbitrage, liquidation, etc.)
  7. Receiver transfers amount + fee back to core contract
  8. Core checks balance-after >= balance-before + total-owed
  9. Core burns total-owed from its own balance

What Works Well

Recommendations (Priority Order)

  1. Remove deployer mint/burn backdoor (C-01) — This is the highest-priority fix. The sBTC token should only be mintable/burnable by the flash-minter contract.
  2. Use supply-based repayment check (C-02) — Replace balance delta check with total supply invariant: supply-after == supply-before.
  3. Separate fee from burn (H-01) — Burn only the principal amount; retain or transfer the fee to a treasury.
  4. Remove test collateral map (M-01) — Enable the real PoX-4 integration before any deployment.
  5. Upgrade to Clarity v4 (I-01) — Use as-contract? with explicit asset allowances for all privileged operations.