mattglory/Flashstack ·
Commit: 5928466 (2026-02-11) ·
Audited: February 21, 2026
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.
| Metric | Weight | Score | Weighted |
|---|---|---|---|
| Financial risk | 3 | 3 — Flash loans, sBTC minting/burning, DeFi composability | 9 |
| Deployment likelihood | 2 | 2 — Has tests, scripts, multiple iterations (v1→v2→v3) | 4 |
| Code complexity | 2 | 3 — 13 contracts, 1200+ lines, trait-based callback pattern | 6 |
| User exposure | 1.5 | 0 — 0 stars, 1 fork | 0 |
| Novelty | 1.5 | 3 — Flash loans on Stacks, unique mechanism | 4.5 |
| Total (pre-penalty) | 23.5 / 10 = 2.35 | ||
| Clarity v2 penalty | -0.5 | ||
| Final Score | 1.85 ≥ 1.8 ✓ | ||
| ID | Severity | Title |
|---|---|---|
| C-01 | CRITICAL | sBTC token allows unrestricted minting by deployer — infinite token creation |
| C-02 | CRITICAL | Flash-mint repayment check is circumventable — receiver can drain minted tokens |
| H-01 | HIGH | Burned tokens disappear but fees are never collected — protocol earns nothing |
| H-02 | HIGH | Admin has unilateral power to drain all flash-mintable value |
| H-03 | HIGH | Receiver contracts use hardcoded fee calculations — desync with core fee rate |
| M-01 | MEDIUM | Collateral is based on test map, not real PoX-4 — mock never removed |
| M-02 | MEDIUM | Block volume tracking uses block-height not stacks-block-height |
| M-03 | MEDIUM | DEX price-setting functions in receivers have no access control |
| L-01 | LOW | Fee rounding to zero on small amounts — free flash loans |
| L-02 | LOW | No fee upper bound validation allows admin to set 100% fee |
| I-01 | INFO | Clarity v2 — should migrate to v4 for as-contract? and restrict-assets? |
| I-02 | INFO | flashstack-core-v2 is incomplete — missing variables, constants, and admin functions |
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.
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).
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.
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.
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.
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.
block-height not stacks-block-heightLocation: 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.
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.
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)).
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.
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.
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.
flash-mint(amount, receiver)balance-before of its own sBTC balanceamount + fee to the receiver contractreceiver.execute-flash(amount, borrower)amount + fee back to core contractbalance-after >= balance-before + total-owedtotal-owed from its own balancesupply-after == supply-before.as-contract? with explicit asset allowances for all privileged operations.