← All posts
Smart Contract Methodology Immunefi

How I Audited a $100K Bug Bounty Target and Found Nothing (And Why That's Worth Publishing)

I downloaded 13 Clarity 4 contracts from Zest Protocol V2's Immunefi bug bounty program, generated 8 hypotheses, and refuted the 3 highest-priority ones at the code level. Here's the methodology behind a high-quality negative result.

· 14 min read · By Matt Gale (ghostinthecode)

I downloaded 13 Clarity 4 contracts from github.com/Zest-Protocol/zest-v2-contracts, generated 8 hypotheses about where bugs could hide, and tested the 3 highest-priority ones against the actual contract source. All three were refuted by line-numbered code evidence. The single Medium finding is operational, not a code defect. The rest are Low-severity style notes.

This is a writeup of that methodology, and an argument for why "no finding found" is still worth publishing.

The Target: Zest Protocol V2

Zest Protocol is a lending market on Stacks, with a public Immunefi bug bounty program. The V2 release adds 6 ERC-4626-style yield vaults on top of the original lending market, plus a DAO executor with a 7-day upgrade timelock and a Pyth oracle integration. Total program bounty: up to $100K per Critical finding.

Stacks / Clarity is a less-trodden bug class than EVM / Solidity. There's less public methodology and fewer competitor reviewers. That asymmetry was part of why I picked the target — high expected learning rate per hour of work.

The Methodology

Standard review approaches fall into three buckets: (1) full coverage review (read every function), (2) invariant/fuzzing (write properties, let a fuzzer find violations), (3) hypothesis-driven review (pre-generate the bug classes you think are most likely, then probe each). I used #3.

From Zest's public architecture (lending market + 6 vaults + DAO multisig + Pyth oracle), I generated 8 hypotheses covering classic DeFi bug classes:

  1. First-depositor inflation — the classic OpenZeppelin-documented attack on any ERC-4626 vault without MINIMUM-LIQUIDITY
  2. Liquidation dust accountingliquidate() accepting u1 or zero without reverting
  3. Multisig threshold bypass — DAO multisig initialized with threshold=0
  4. Reentrancy
  5. Missing access control
  6. Arithmetic overflow / division by zero
  7. Oracle price manipulation
  8. Hidden upgradeability

I tested the first 3 (highest historical exploit frequency on lending protocols). For each, I read the affected function end-to-end and traced the code path that would need to be exploitable for the bug to fire.

What this review is NOT

It is not a fuzzing pass (I didn't execute the contracts), not full code coverage (I didn't read every internal helper), not an invariant test (I didn't prove mathematical safety properties), and not a replacement for Zest's 4 prior audits (those were in-scope full reviews). It's a one-reviewer spot-check against pre-generated hypotheses — useful, but bounded.

Hypothesis 1: First-Depositor Inflation (REFUTED)

The attack: Deposit 1 share, donate a large amount of underlying to the vault, then redeem your share to capture a disproportionate slice of the donation. The standard fix is to lock MINIMUM-LIQUIDITY dead shares on initialization.

The test: I read the initialize function in all 6 vault contracts (vault-sbtc.clar, vault-stx.clar, vault-ststx.clar, vault-ststxbtc.clar, vault-usdc.clar, vault-usdh.clar) and verified each one locks minimum liquidity at a true burn principal.

Result: refuted. Every vault implements the protection correctly:

;; vault-sbtc.clar, line 36
(define-constant MINIMUM-LIQUIDITY u1000)

;; vault-sbtc.clar, lines 39, 496-511
(define-constant NULL-ADDRESS
  (unwrap-panic (principal-construct?
    (if is-in-mainnet 0x16 0x1a)
    0x0000000000000000000000000000000000000000)))

(define-public (initialize)
  (begin
    (asserts! (not (var-get initialized)) ERR-ALREADY-INITIALIZED)
    (var-set initialized true)
    (try! (deposit MINIMUM-LIQUIDITY u0 NULL-ADDRESS))
    ...))

Same pattern in vault-stx.clar:35, vault-ststx.clar:36, vault-ststxbtc.clar:36, vault-usdc.clar:36, vault-usdh.clar:36. The NULL-ADDRESS is a true burn principal — 0x0000…0000 on mainnet (version byte 0x16) — so the 1000 dead shares are unrecoverable.

After initialize(): total supply = 1000 (locked at NULL), total assets = 1000. An attacker who is the very first user deposits X; their shares = mul-div-down(X, 1000, 1000) = X. They then donate a large D. If they redeem 1 share: (1 * (1000+D)) / 1001 ≈ (1000+D)/1001. They capture at most ~0.1% of the donation — not the whole thing. The protection is correct and ~99.9% effective.

But there's an operational caveat (Medium)

MINIMUM-LIQUIDITY is u1000 even though underlying tokens have differing decimals (STX = 6, sBTC = 8). For sBTC, 1000 base units is dust (~100 satoshi). The protection is symbolic in that sense — it only works because the deployment script calls initialize() atomically with the vault deployment. If a malicious deployer reaches any vault uninitialized, the inflation attack works with no protection. The code is correct; the deployment runbook is the load-bearing piece.

Hypothesis 2: liquidate() Dust Accounting (REFUTED)

The hypothesis: If the liquidate function's remaining-debt-to-repay parameter defaults to u1, an attacker could pass that value (or omit the parameter) and drain a borrower's collateral through repeated near-zero liquidations.

The test: I read market.clar:1405-1608 (the full liquidate function) and traced the remaining-debt-to-repay variable from definition to every use site.

Result: refuted. remaining-debt-to-repay is not a function parameter. It's a let-binding computed on line 1500:

;; market.clar:1499-1509
(coll-remaining (- user-coll-balance coll-final-raw))
(remaining-debt-to-repay
  (if (> coll-remaining u0)
    (let ((rem-coll-usd (normalize (* coll-remaining coll-price) coll-decimals false))
          (rem-debt-usd (div-bps-down rem-coll-usd (+ BPS liq-penalty-max)))
          (rem-debt-tokens (mul-div-down rem-debt-usd (pow u10 debt-decimals) debt-price))
          (rem-borrow-index (get index (unwrap-panic (get-cached-indexes debt-aid))))
          (rem-scaled (mul-div-down rem-debt-tokens INDEX-PRECISION rem-borrow-index)))
      (mul-div-up rem-scaled rem-borrow-index INDEX-PRECISION))
    u1))
(coll-final (if (is-eq remaining-debt-to-repay u0) user-coll-balance coll-final-raw))

The u1 value is a sentinel: when partial liquidation consumes all available collateral, the code intentionally sets remaining-debt-to-repay = u1 to flow into the "leave dust for socialization" branch. The subsequent assertions prevent any near-zero liquidation from finalizing:

;; market.clar:1511-1516
(asserts! (not (is-liquidation-paused debt-aid)) ERR-LIQUIDATION-PAUSED)
(asserts! (is-eq contract-caller tx-sender) ERR-AUTHORIZATION)
(asserts! (> debt-amount u0) ERR-AMOUNT-ZERO)
(asserts! (> debt-to-repay u0) ERR-ZERO-LIQUIDATION-AMOUNTS)
(asserts! (> coll-final u0) ERR-ZERO-LIQUIDATION-AMOUNTS)
(asserts! (>= coll-final min-collateral-expected) ERR-SLIPPAGE)

If coll-final-raw == 0 (an illiquid position with no collateral), line 1515 reverts with ERR-ZERO-LIQUIDATION-AMOUNTS. The u1 sentinel never reaches a state-modifying branch.

The recon premise was based on a default-parameter interpretation

Reading the code shows remaining-debt-to-repay is a private computation, not a caller-controllable value. The hypothesis was wrong because I assumed the wrong code structure. The code itself is correct.

Hypothesis 3: DAO Multisig threshold=0 Bypass (REFUTED)

The hypothesis: If dao-multisig.clar initializes with threshold=0, then set-threshold can be called to set it to 0 permanently. With threshold=0, any single signer can execute arbitrary proposals.

The test: I read dao-multisig.clar:38-39 (the data-var declarations) and traced all write paths to threshold: init, set-threshold, remove-signer, add-signer, execute.

Result: refuted. The data-var declarations use u0 as default, but every write path enforces threshold > 0:

;; dao-multisig.clar:127-140 — init
(define-public (init (signer-list (list 20 principal)) (new-threshold uint))
  (begin
    (asserts!
      (and
        (is-eq DEPLOYER tx-sender)
        (> new-threshold u0)              ;; <-- rejects threshold = 0
        (<= new-threshold (len signer-list))
        (is-eq (var-get threshold) u0))   ;; <-- only while still uninit
      ERR-SANITY-SIGNER)
    (map set-signer signer-list)
    (var-set threshold new-threshold)
    (var-set signer-count (len signer-list))
    (ok true)))

set-threshold (line 186-203) enforces the same invariant. remove-signer (line 164-184) prevents last-signer removal that would drop below threshold. execute (line 320-343) enforces that the approval count meets or exceeds the current threshold, AND that the approval set consists of distinct signers (single signer cannot self-approve multiple times because index-of rejects duplicates).

All three hardening checks reject the inits and transitions the recon was probing. The only theoretical concern is that init itself is restricted to tx-sender == DEPLOYER — a one-time trust assumption on the deployer. Standard for Clarity.

What I Didn't Find: The 8-Category Matrix

After the three priority refutations, I worked through the remaining 5 hypotheses. Here's the summary matrix:

#HypothesisVerdictSeverity
1 First-depositor inflation Refuted (code correct; operational caveat) Medium
2 liquidate() dust accounting Refuted (u1 is a sentinel, not a parameter) None
3 Multisig threshold=0 bypass Refuted (all write paths enforce threshold > 0) None
4 Reentrancy Covered by Clarity SIP-033 + app-level guards in all 6 vaults Low
5 Missing access control Comprehensive — DAO-gated, trait-pattern, proxy auth None
6 Arithmetic overflow / div-by-zero Safe under Clarity 4 (128-bit uint, 1e12 INDEX-PRECISION) None
7 Oracle price manipulation Mitigated (confidence + staleness + DAO-set feed); residual MEV surface Low
8 Hidden upgradeability None for market/vaults. DAO executor has 7-day timelock. None

What I Did Find: 1 Medium + 4 Low

Even with no Critical or High findings, the review surfaced useful observations ranked by severity:

#FindingSeverityExploitability
1 initialize() must be called atomically at deploy, or first-depositor attack works on uninitialized vault. No on-chain guarantee that init is called. Medium Requires misconfigured deployment. Code is correct; operationally fragile.
2 collateral-remove and repay lack explicit is-eq contract-caller tx-sender check. Clarity's reentrancy model mitigates the risk, but defense-in-depth would tighten this. Low Style-only.
3 Pyth oracle MEV surface — liquidators can pick from 3 fresh price feeds per call. Low Sophisticated liquidators only; bounded by max-confidence-ratio + max-staleness.
4 Underlying asset registry has no rate-limit / sanity bounds on oracle max-staleness configuration. DAO can theoretically set a 30-day staleness window. Low Social/governance risk. DAO misconfig could enable slow-burn exploits.
5 Vault redeem reverts with ERR-INSUFFICIENT-LIQUIDITY when assets < deposit requests. Standard but documented as user-impact. Low UX friction during insolvency; not exploitable.

Why "No Finding" Is Worth Publishing

A high-quality negative result is a useful artifact for three reasons.

For the protocol team — it documents what an outside adversary tested, and what they didn't find. This shortens the next attacker's learning curve and lets the team focus their defense on what's not yet been probed.

For the Immunefi community — bounty submissions with detailed methodology attract higher-quality future submissions from other researchers. The floor of the research community rises.

For the auditor's record — public, defensible reviews are a multiplier on professional reputation. Anyone can claim "I audit smart contracts"; few can point to a published methodology review with line-numbered evidence.

The pattern across my blog: I publish what I find and what I don't find. Both are evidence. Both are defensible. Both are useful.

Recommendations to Zest

  1. Operational (continue review): Verify the deployment script for each vault calls initialize() in the same tx as the contract deployment. Confirm there is no "deploy but skip init" code path.
  2. Code-quality hardening (Low): Add (asserts! (is-eq contract-caller tx-sender) ERR-AUTHORIZATION) to collateral-remove and repay for consistency with the rest of the protocol.
  3. Tests to publish: A unit test for the inflation attack (post-init, deposits 0, donates large amount, attempts to drain) — this would directly confirm whether the MINIMUM-LIQUIDITY protection is sufficient.
  4. Pyth oracle monitoring: Set up community alerting on max-confidence-ratio and per-asset max-staleness governance proposals. A DAO proposal to relax these would be a soft signal of protocol risk.
  5. Continue review at first set-threshold governance event. The current V2 code is mature, but a follow-up at the first governance-proposed set-threshold event would be wise.

Methodology Appendix (Truncated)

The 8 hypotheses I generated cover ~85% of historical bug classes for lending protocols (per the Immunefi Severity Classification System and the Rekt.News top-10 most-exploited DeFi protocols). The remaining ~15% (governance attacks, economic invariant violations, cross-protocol composability) require longer-running simulations and were not in scope for this 1-day review.

A complete audit of Zest V2 would require:

This review is not a substitute for those activities. It is a methodology check and a sanity-validation pass — the kind of work that fits in a day, gives the protocol team a useful artifact, and lets the reviewer publish defensible evidence of what was probed.

Want Me To Run This Against YOUR AI Agent?

The methodology here — generate hypotheses, trace the code path, write line-numbered evidence — is the same shape I use in AI security audits. AI agents have similar structural bugs: missing access control, arithmetic edge cases, oracle-manipulation analogs (prompt injection, indirect prompt injection via tool outputs, exfiltration via crafted outputs).

If you want to know if your AI agent has the equivalent of liquidate()'s dust accounting or Zest's initialize()-must-be-called-atomic operational fragility, I built a tool for it:

→ Snapshot Audit $149 → Free AI Prompt Tester

Book a Free 15-Min AI Security Audit

Same methodology, applied to your AI agent in real-time. If we find a HIGH-severity issue, you know you need a paid audit. If we don't, you get a 15-minute consult for free.

Schedule the call →

← Back to all posts