# Recon — Complete AI-Readable Content > Smart contract security audits powered by invariant testing ## Company Overview Recon provides smart contract security audits powered by invariant testing (property-based fuzzing). Our team of experienced security researchers combines manual code review with automated invariant test suites that systematically explore your contract's state space — catching vulnerabilities that traditional security audits miss. We specialize in DeFi protocol security: lending markets, liquid staking, tokenized vaults (ERC-4626), DEX protocols, governance systems, and cross-chain bridges. Our auditors have found critical vulnerabilities in protocols managing billions in TVL — including reentrancy bugs, oracle manipulation vectors, liquidation logic flaws, rounding errors that bypass caps, integer overflow causing permanent DOS, and insolvency from incorrect accounting. ### Why Choose Recon for Your Smart Contract Audit - **Invariant testing finds bugs manual review misses** — we generate millions of random transaction sequences testing your protocol's critical properties - **You keep the test suite** — every audit includes a reusable invariant test suite that runs in CI, catching regressions forever - **DeFi-native expertise** — our team includes a top Code4rena judge, the creator of EchidnaToFoundry, and engineers who've built and secured DeFi protocols firsthand - **Cloud-powered fuzzing** — Recon Pro runs Echidna, Medusa, Halmos, and Foundry in the cloud with zero infrastructure overhead - **Formal verification** — Halmos symbolic execution provides mathematical security guarantees for arithmetic properties - **Continuous security** — not just a point-in-time audit; your test suite runs in CI on every commit, catching regressions forever - **50+ critical and high severity bugs found** — across lending, staking, vault, governance, and bridge protocols - **Get a Proposal in 24 Hours** — transparent pricing, fixed quotes within 48 hours of codebase review ### Trust Signals - **$3B+** — Aggregate TVL protected across DeFi protocols including lending, staking, vaults, and governance - **$20M+** — Funds saved through critical bug findings and vulnerability prevention using Recon Pro cloud fuzzing - **12.5k+** — Cloud fuzzing campaigns run with Recon Pro — stateful fuzzing with Echidna, Medusa, Halmos, and Foundry - **50+** — Critical and high severity vulnerabilities found across DeFi protocols through smart contract security audits and invariant testing - **$0 post-audit exploits** — Funds lost in Recon-audited smart contracts — no smart contract vulnerabilities have been exploited in code reviewed by Recon [Request an Audit](https://tally.so/r/w2Vqxb) — Get Your smart contract security audit quote within 48 hours ### Chains and Languages Supported Solidity smart contracts on all EVM-compatible chains: Ethereum mainnet, Arbitrum, Optimism, Base, Polygon, BSC, Avalanche, Fantom, Gnosis Chain, zkSync, Scroll, Linea, Blast, Mode, and more. We support Foundry, Hardhat, and custom build configurations. ### What We Audit - **DeFi protocols**: Lending markets, DEX, liquid staking, yield vaults (ERC-4626), stablecoin systems, governance, bridges - **Token contracts**: ERC-20, ERC-721, ERC-1155, ERC-6909, ERC-7540 async vaults, custom token standards, vesting, airdrop contracts - **Account abstraction & proxies**: ERC-4337 smart wallets, ERC-2535 diamond proxies, upgradeable proxy contracts - **Infrastructure**: Multisig wallets, cross-chain bridges, oracle integrations - **Vulnerability classes**: Reentrancy, flash loan attacks, oracle manipulation, integer overflow, access control, sandwich extraction, rounding errors, state machine bugs, liquidation cascading, storage collision, denial of service ### Key Links - Website: https://getrecon.xyz - Documentation: https://book.getrecon.xyz - Audit Reports: https://github.com/Recon-Fuzz/audits - Request an Audit: https://tally.so/r/w2Vqxb — Get a quote within 48 hours - Telegram: https://t.me/GalloDaSballo — Talk to our security researchers directly --- ## Services ### Smart Contract Security Audit Services *Thorough manual review combined with invariant testing — the most rigorous approach to smart contract security available today.* URL: https://getrecon.xyz/services/smart-contract-audit #### Why Invariant-Powered Audits Find More Bugs Traditional audits rely on manual review alone. Our approach pairs experienced security researchers with invariant test suites that systematically explore your contract's state space. This means we catch not just the obvious bugs, but edge cases that only surface under complex multi-transaction sequences — the kind that cause real exploits in production. #### Audit Methodology Comparison Not all audit approaches are equal. Here's how invariant-powered audits stack up against alternatives. | Approach | State Space Coverage | Multi-Tx Sequences | Ongoing Protection | Cost Efficiency | | --- | --- | --- | --- | --- | | Invariant Testing + Manual Review (Recon) | Exhaustive | Yes — stateful fuzzing | Yes — reusable test suite | High — catches more per dollar | | Traditional Manual Audit | Limited to reviewer skill | Rarely tested | No — point-in-time | Medium | | Automated Scanners (Slither, etc.) | Pattern-based only | No | No | Low cost but low coverage | | Formal Verification | Mathematical proof | Yes — if modeled | Breaks on code changes | Very expensive | #### What You Get Every Recon audit includes a detailed findings report, severity classification, remediation guidance, and a full invariant test suite you keep. The test suite continues protecting your codebase after the audit — run it in CI, extend it as you ship new features, or use Recon Pro to run it in the cloud. #### Chains and Languages Supported Our deepest expertise is in Solidity audit — we audit Solidity smart contracts across all major EVM-compatible chains: Ethereum, Arbitrum, Optimism, Base, Polygon, BSC, Avalanche, and more. Our blockchain security methodology covers everything from token audit and ERC-20 compliance to complex DeFi protocol invariants. EVM tooling supports Foundry, Hardhat, and custom build setups with cloud fuzzing via Recon Pro. For **Rust** (Solana, CosmWasm) and **Go** (Cosmos SDK), we pair manual review with language-native fuzzers — cargo-fuzz, go test -fuzz — and coverage-driven iteration. We also review **C/C++** cryptographic libraries and protocol implementations using libFuzzer and AFL++. For **Move**, **Cairo**, **ZK circuits** (Circom, Noir), and **Soroban**, we offer manual security review with property specification. #### Frequently Asked Questions **Q: How much does a smart contract audit cost?** A: Audit pricing depends on codebase size, complexity, and scope. A typical engagement for a focused protocol (1,000-5,000 nSLOC) ranges from $30K-$80K. Contact us for a tailored quote — we'll review your codebase and provide a fixed price within 48 hours. **Q: How long does an audit take?** A: Most audits take 2-4 weeks depending on codebase complexity. We can accommodate faster timelines for time-sensitive launches. The invariant test suite is delivered alongside the audit report. **Q: What's included in the audit deliverables?** A: You receive a detailed findings report with severity ratings, detailed descriptions, remediation recommendations, and proof-of-concept exploits for critical and high findings. You also get a full [invariant test suite](/learn/invariant-testing) that you keep and can run indefinitely. **Q: Do you audit protocols that are already deployed?** A: Yes. We regularly audit live protocols before upgrades, new feature deployments, or as part of ongoing security programs. We can fork mainnet state for realistic testing. **Q: Do you need a smart contract audit?** A: If your contract holds user funds, has admin roles, is upgradeable, or integrates with external protocols, you need an audit. See our [full decision checklist](/blog/do-you-need-smart-contract-audit) to determine the right type of security review for your situation. **Q: What makes Recon different from other audit firms?** A: We combine top-tier manual review with invariant testing for comprehensive [smart contract security](/learn/smart-contract-security). This means you get both expert human judgment and systematic state-space exploration. Our team includes a top Code4rena judge, the creator of EchidnaToFoundry, and engineers who've protected hundreds of millions in TVL. **Q: How do I choose the best smart contract auditor?** A: Look for firms that deliver executable test suites, not just PDF reports. Ask for [case studies with real bugs found](/blog/how-we-found-real-vulnerabilities-with-fuzzing) and verify they do invariant testing. Check whether they've got experience on your chain and framework. A good auditor should also help you understand [whether you need an audit at all](/blog/do-you-need-smart-contract-audit) and what level of engagement fits your [budget](/blog/smart-contract-audit-cost-pricing-guide). **Related search terms:** smart contract audit, smart contract security audit, solidity audit, code review, security audit, blockchain audit, smart contract audit cost, smart contract audit services, best smart contract auditor, blockchain security, token audit #### Ready to Secure Your Protocol? Get a quote for your smart contract audit in 48 hours. [Send Audit Request](https://tally.so/r/w2Vqxb) ### DeFi Protocol Security Audit *DeFi-native security researchers who understand composability risks, economic attacks, and protocol-specific vulnerability patterns.* URL: https://getrecon.xyz/services/defi-security-audit #### DeFi Requires DeFi-Native Auditors DeFi protocols face unique threats that generic auditors miss: oracle manipulation, flash loan attacks, composability risks, economic exploits, and governance vulnerabilities. Our team has first-hand experience building and securing DeFi protocols — from lending markets to liquid staking to tokenized vaults. #### DeFi Vulnerability Categories Invariant testing is particularly effective for DeFi because it can explore complex multi-step attack paths that manual review alone would miss. | Vulnerability Category | Example | How Invariant Testing Catches It | | --- | --- | --- | | Price Manipulation | Oracle stale price / TWAP manipulation | Fuzzes price feeds across extreme ranges while testing protocol invariants | | Reentrancy | Cross-function / cross-contract reentrancy | Stateful sequences automatically test callback patterns | | Economic Exploits | Donation attacks, sandwich extraction | Tests value conservation invariants across arbitrary transaction orderings | | Access Control | Missing role checks, privilege escalation | Actor-based testing ensures unauthorized callers cannot break invariants | | Rounding Errors | Share inflation, precision loss in yields | Arithmetic invariants catch rounding issues across millions of operations | | Liquidation Logic | Cascading liquidations, bad debt accumulation | Stress-tests liquidation paths under extreme market conditions | #### DeFi Protocol Types We Audit We provide specialized security coverage for every major DeFi category: - **Lending protocol** security — borrow/repay invariants, liquidation cascading, interest accrual, bad debt prevention (Aave-style, Compound-style, custom architectures) - **Vault security** — ERC-4626 share inflation, deposit/withdraw symmetry, ERC-6909 multi-token vault accounting, yield strategy risks - **Oracle manipulation** detection — TWAP manipulation, stale price feeds, flash loan oracle attacks, Chainlink integration validation - **Flash loan** attack surface analysis — value extraction vectors, atomic arbitrage, donation attacks, sandwich extraction prevention - **Account abstraction** wallets — ERC-4337 smart wallet security, bundler integration, paymaster validation, signature verification - **Stablecoin systems** — CDP solvency, peg maintenance, collateral ratio invariants, liquidation thresholds #### Protocols We've Secured Our DeFi audit portfolio includes Liquity (BOLD v2), Centrifuge (ERC-7540 vaults), Badger DAO, Corn, Credit Coop, Apollon, Beraborrow, and more. We've helped protect over $3 billion in aggregate TVL across lending, staking, vault, and governance protocols. #### Frequently Asked Questions **Q: Do you have experience with lending protocol audits?** A: Yes. We've audited lending protocols including Liquity v2 (BOLD), and have deep experience with Aave, Compound, and custom lending architectures. Our invariant suites specifically test solvency, liquidation, and interest rate invariants. **Q: Can you audit DeFi protocols with complex composability?** A: Absolutely. Our invariant testing approach is designed for composability — we test how your protocol behaves when interacting with external contracts, oracles, and other DeFi primitives under adversarial conditions. **Q: Do you test for economic attacks like flash loan exploits?** A: Yes. Our invariant test suites include value conservation properties that catch economic attacks including flash loan exploits, sandwich extraction, donation attacks, and other value extraction vectors. **Q: How do you handle protocols with upgradeable or proxy contracts?** A: We audit the implementation contracts and verify that upgrade paths don't introduce storage collisions or break existing invariants. We can also write invariant tests that validate behavior across upgrades. **Q: How much does a DeFi security audit cost?** A: DeFi audit pricing depends on protocol complexity and scope. A focused DeFi protocol (1,000-5,000 nSLOC) typically runs $30K-$80K for combined manual review and invariant testing. Larger protocols with multiple integrations can reach $120K+. See our [full pricing breakdown](/blog/smart-contract-audit-cost-pricing-guide) for details by audit type. **Related search terms:** defi audit, defi security audit, protocol security, defi security, lending audit, dex audit, DeFi protocol security, DeFi vulnerability, DeFi audit pricing, oracle manipulation audit, liquidation audit #### Secure Your DeFi Protocol Talk to our DeFi security specialists about your protocol's needs. [Send Audit Request](https://tally.so/r/w2Vqxb) ### Invariant Testing & Fuzzing Services *We write the invariant tests that catch the bugs your manual audit missed — then hand you a test suite you can run forever.* URL: https://getrecon.xyz/services/invariant-testing-services #### What Is Invariant Testing? Invariant testing (also called [property-based testing](/learn/property-based-testing) or fuzzing) defines properties that must always hold true in your smart contract — like "total assets must equal sum of all deposits minus withdrawals" or "no user can withdraw more than they deposited." A fuzzer then generates millions of random transaction sequences trying to break these properties. If it finds a violation, you have a concrete exploit path to fix. #### Recon vs Other Approaches Here's how Recon's invariant testing compares to alternative security approaches. | Approach | Bug Classes Found | Ongoing Value | Setup Time | Maintenance | | --- | --- | --- | --- | --- | | Recon Invariant Testing | Logic, economic, state machine, arithmetic | Reusable test suite + CI integration | 1-3 weeks | Low — extend as you ship | | Manual Audit Only | Depends on reviewer expertise | Point-in-time report | 2-4 weeks | Re-audit for changes | | Static Analysis | Known patterns only | Fast but shallow | Minutes | High false-positive rate | | Formal Verification | Mathematically proven properties | Breaks when code changes | Months | Very high — re-prove everything | #### How It Works 1. We analyze your codebase and define the critical invariants with your team. 2. We scaffold the test suite using the Recon framework — Echidna, Medusa, [Halmos](/learn/halmos), or Foundry depending on your needs. 3. We apply coverage-guided fuzzing to maximize branch and path coverage, combined with symbolic execution via Halmos for mathematical guarantees on arithmetic properties. 4. We run the suite in the cloud using Recon Pro, iterating until we reach full coverage. 5. We deliver the test suite with documentation so your team can maintain and extend it. 6. You run it in CI on every commit, catching regressions before they ship. #### Cloud-Powered with Recon Pro Recon Pro lets you run invariant tests in the cloud with no infrastructure management. Queue runs, view results, share with your team — all from the browser. During engagements, unlimited cloud runs are included so we can iterate rapidly. #### Frequently Asked Questions **Q: What is invariant testing for smart contracts?** A: Invariant testing defines properties (invariants) that must always be true in your contract, then uses a fuzzer to generate millions of random transaction sequences trying to violate them. It's a form of [property-based testing](/learn/property-based-testing) and the most effective way to find logic bugs, economic exploits, and edge cases that manual review misses. **Q: How long does an invariant testing engagement take?** A: Typically 1-3 weeks for a focused scope. Complex protocols may take longer. The deliverable is a full test suite you own — not just a report. **Q: What fuzzing tools do you use?** A: We use Echidna, Medusa, [Halmos](/learn/halmos), and Foundry fuzz testing depending on the project's needs. Our Recon framework scaffolds test suites across all of these tools from a single property definition. **Q: Can I run the invariant tests after the engagement ends?** A: Yes. You own the test suite. Run it locally, in CI, or in the cloud with Recon Pro. Extend it as you add features. **Q: How does invariant testing compare to unit testing?** A: Unit tests verify specific scenarios you think of. Invariant tests verify properties across millions of random scenarios the fuzzer generates — including ones you'd never think to write. They're complementary: unit tests for known cases, invariant tests for unknown unknowns. **Q: How do I write my first invariant test?** A: Start by identifying a property that should always hold — like 'total shares times price per share equals total assets.' Then scaffold a test harness using Chimera, define the property as a boolean function, and run it with a fuzzer. Our [step-by-step tutorial](/blog/how-to-write-first-invariant-test) walks you through writing three properties for a Solidity vault in under 30 minutes. **Related search terms:** invariant testing, property-based testing, smart contract fuzzing, fuzzing services, invariant test suite, formal verification, continuous security testing, Echidna fuzzing, Medusa fuzzer, coverage-guided fuzzing, symbolic execution #### Get Invariant Testing for Your Protocol Talk to our fuzzing engineers about building a test suite for your codebase. [Send Audit Request](https://tally.so/r/w2Vqxb) ### Solidity Audit — Smart Contract Security for Every EVM Chain *Deep Solidity audit expertise combined with automated invariant testing — covering every EVM chain from Ethereum to L2s.* URL: https://getrecon.xyz/services/solidity-security-audit #### Why Choose Recon for Your Solidity Audit Every Solidity audit from Recon pairs experienced security researchers with invariant test suites. Our team includes a top Code4rena judge, the creator of EchidnaToFoundry, and engineers who've built and secured Solidity codebases handling hundreds of millions of dollars. We know Solidity's edge cases intimately — from storage layout quirks to ABI encoding pitfalls to compiler-version-specific behaviors. #### Common Solidity Vulnerability Types A thorough Solidity audit must cover these vulnerability patterns — our invariant test suites specifically target each one. | Vulnerability | Severity | How We Detect It | | --- | --- | --- | | Reentrancy (cross-function, cross-contract) | Critical | Stateful fuzzing with callback simulation | | Integer overflow/underflow | High | Arithmetic invariants tested across extreme values | | Access control misconfigurations | Critical | Actor-based testing with unauthorized callers | | Storage collision (proxies/upgrades) | High | Storage layout validation + upgrade testing | | Unchecked return values | Medium | Manual review + pattern detection | | Front-running / tx ordering | High | Random transaction ordering in fuzzer | | Denial of service (gas griefing) | Medium | Gas-bounded invariant testing | | Logic errors in state machines | High | State transition invariants with full coverage | #### EVM Chains Supported Our Solidity audit service covers contracts deployed on Ethereum mainnet, Arbitrum, Optimism, Base, Polygon, BSC, Avalanche, Fantom, Gnosis Chain, zkSync, Scroll, Linea, Blast, Mode, and any EVM-compatible chain. Our testing infrastructure supports mainnet forking for realistic state simulation. #### Tooling Integration We work with your existing development setup — Foundry, Hardhat, or custom configurations. Our Recon framework integrates with your build pipeline so the invariant test suite runs seamlessly alongside your existing tests. #### Frequently Asked Questions **Q: Which Solidity versions do you support?** A: We audit all Solidity versions from 0.4.x through the latest 0.8.x releases. Our tooling handles legacy codebases, including pre-SafeMath contracts, with specialized support. **Q: Do you audit across all EVM chains?** A: Yes. We audit Solidity contracts on Ethereum, Arbitrum, Optimism, Base, Polygon, BSC, Avalanche, and any EVM-compatible chain. We can fork any chain's state for testing. **Q: Can you audit Solidity contracts that use inline assembly or Yul?** A: Yes. Our researchers have deep EVM-level expertise and regularly audit contracts with inline assembly, Yul blocks, and custom precompile interactions. **Q: Do you support Foundry and Hardhat projects?** A: Yes. We work with both Foundry and Hardhat setups, as well as custom build configurations. Our invariant test suites are delivered in the framework your team already uses. **Related search terms:** solidity audit, solidity security audit, evm audit, ethereum audit, solidity smart contract audit, reentrancy audit, access control audit, EVM security #### Start Your Solidity Audit Today Send us your codebase for a quote within 48 hours. [Send Audit Request](https://tally.so/r/w2Vqxb) ### Smart Contract Fuzzing Services *Cloud-powered fuzzing infrastructure that runs Echidna, Medusa, Halmos, and Foundry — 12,500+ campaigns and counting.* URL: https://getrecon.xyz/services/fuzzing-smart-contracts #### What Is Smart Contract Fuzzing? Fuzzing generates random inputs and transaction sequences to test your smart contracts for unexpected behavior. Stateful fuzzing (what Recon specializes in) maintains contract state across sequences, enabling it to find complex multi-step bugs that simpler testing approaches miss entirely. #### Fuzzing Tools Comparison We use the right tool for each job. Here's how the major smart contract fuzzers compare. | Tool | Type | Strengths | Best For | | --- | --- | --- | --- | | Echidna | Property-based fuzzer | Mature, corpus-driven, grammar-based input generation | Complex stateful protocols, production-grade invariant suites | | Medusa | Property-based fuzzer | Parallel execution, fast coverage, Go-based | Large codebases that benefit from parallelism | | Halmos | Symbolic execution | Mathematical completeness, bounded model checking | Arithmetic properties, formal guarantees for bounded inputs | | Foundry Fuzz | Stateless fuzzer | Fast iteration, integrated with Foundry workflow | Quick property checks, unit-test-adjacent fuzzing | #### Recon Pro: Cloud Fuzzing Infrastructure Recon Pro removes the infrastructure burden from fuzzing. Queue campaigns, monitor progress, view coverage reports, and share results — all from the browser. No Docker setup, no cloud provisioning, no DevOps overhead. We've run over 12,500 cloud fuzzing campaigns for teams building on Ethereum, L2s, and beyond. #### Stateful vs Stateless Fuzzing Stateless fuzzing tests individual functions with random inputs. Stateful fuzzing chains multiple function calls together, maintaining contract state between calls — mimicking how contracts are actually used in production. This is critical for finding bugs that only appear after specific sequences of actions, like the multi-step exploits behind most major DeFi hacks. #### What We Fuzz Our fuzzing campaigns target real-world DeFi primitives where state complexity creates hidden attack surface: - **ERC-4626 tokenized vaults** — share inflation attacks, deposit/withdraw rounding, yield calculation drift across millions of operations - **ERC-6909 multi-token contracts** — cross-token accounting invariants, approval races, batch operation consistency - **Flash loan detection** — stateful fuzzing sequences that simulate flash-loan-funded attack paths, testing value conservation under atomic arbitrage - **Lending protocol invariants** — solvency, liquidation threshold correctness, interest rate model edge cases, bad debt accumulation - **Oracle-dependent contracts** — price feed manipulation under extreme ranges, stale feed handling, TWAP drift scenarios #### Frequently Asked Questions **Q: What is smart contract fuzzing?** A: Smart contract fuzzing automatically generates random transaction sequences to test your contracts for vulnerabilities. It's a form of [property-based testing](/learn/property-based-testing) where stateful fuzzing maintains state across calls, finding complex bugs that unit tests and manual review miss. **Q: What's the difference between stateful and stateless fuzzing?** A: Stateless fuzzing tests individual functions in isolation with random inputs. Stateful fuzzing chains multiple calls together, maintaining contract state — this finds multi-step exploits that stateless fuzzing can't reach. **Q: Can I run fuzzing in the cloud?** A: Yes. Recon Pro provides cloud fuzzing infrastructure — queue campaigns, view results, and share with your team without managing any infrastructure. We've run 12,500+ cloud campaigns. **Q: How does fuzzing compare to formal verification?** A: Fuzzing is faster to set up and more practical for real-world codebases. Formal verification provides mathematical guarantees but is expensive, slow, and breaks when code changes. Most teams get better ROI from fuzzing. Tools like [Halmos](/learn/halmos) offer a middle ground with bounded symbolic execution. **Q: How much code coverage does fuzzing achieve?** A: With a well-written invariant test suite, we typically achieve 90-100% line coverage and high branch coverage. Recon Pro provides detailed coverage reports so you can see exactly what's been tested. **Related search terms:** smart contract fuzzing, fuzz testing, echidna fuzzing, medusa fuzzer, property-based testing, invariant fuzzing, cloud fuzzing, stateful fuzzing, Halmos symbolic execution, Foundry fuzz testing #### Start Fuzzing Your Smart Contracts Talk to our fuzzing engineers or try Recon Pro. [Send Audit Request](https://tally.so/r/w2Vqxb) --- ## Audit Portfolio Our security audit portfolio includes DeFi protocols across lending, liquid staking, tokenized vaults (ERC-4626), governance, stablecoin systems, and cross-chain infrastructure. Each smart contract security audit combines manual code review by experienced security researchers with comprehensive invariant testing, stateful fuzzing via Recon Pro, and formal verification with Halmos. - **Liquity** — [Audit Report](https://github.com/Recon-Fuzz/audits/blob/main/bold-report.md) - **Beraborrow** — [Audit Report](https://1570492309-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FffzDCMBDa391vIMqruBP%2Fuploads%2FDLz8dzCO2O7SWGHBDJkj%2FRecon%20Beraborrow.pdf?alt=media&token=6b328d0d-d65f-4f27-913b-d4a867889af7) - **Quill Finance** — [Audit Report](https://github.com/Recon-Fuzz/audits/blob/main/Quill_Finance_Report.md) - **Balancer DAO** — [Audit Report](https://github.com/Recon-Fuzz/audits/blob/main/Balancer_Report.md) - **Kleidi** — [Audit Report](https://github.com/Recon-Fuzz/audits/blob/main/Kleidi_Report.md) - **Apollon** — [Audit Report](https://github.com/Recon-Fuzz/audits/blob/main/Apollon_Report.md) - **Credit Coop** — Private security audit report All audit reports: https://github.com/Recon-Fuzz/audits [Request an Audit](https://tally.so/r/w2Vqxb) — Get a quote for your smart contract security audit within 48 hours --- ## Notable Vulnerability Findings Real bugs found by Recon's security researchers through smart contract security audits and invariant testing. Each finding includes severity classification, description, and proof-of-concept evidence — demonstrating how stateful fuzzing with Echidna, Medusa, and manual code review work together to catch critical and high severity DeFi vulnerabilities including reentrancy, oracle manipulation, liquidation bugs, overflow, and access control flaws. - **Cap Money** — [Agent health changes after realizeRestakerInterest](https://github.com/Recon-Fuzz/cap-invariants/blob/main/README.md#m-01-med-agent-health-changes-after-realizerestakerinterest) (Medium severity): Smart contract fuzzing found accounting desync vulnerability in DeFi staking protocol — stateful fuzzing with Recon Pro. [Fuzzing logs and proof](https://getrecon.xyz/shares/9e26f083-5fb1-40bd-987d-ce184d1f8676) - **Spine Finance** — [Permanent DOS and loss of funds when Y reaches 0](https://github.com/Recon-Fuzz/audits/blob/main/Spine_Finance_Review.MD#h-02) (Critical severity): Critical vault vulnerability causing permanent loss of funds — invariant testing with a lossy vault identified this edge case in the DeFi lending protocol. [Fuzzing logs and proof](https://getrecon.xyz/shares/c669e7f5-9650-4979-b0c8-863331a6737f/report) - **Badger** — [Insolvency due to Incorrect Accounting](https://gist.github.com/GalloDaSballo/564e16b9cfa9bcf998e3778fdb297045#critical) (Critical severity): Critical DeFi vault accounting vulnerability in remBADGER — invariant testing caught insolvency risk before exploitation. [Fuzzing logs and proof](https://getrecon.xyz/shares/76293e5b-de71-4636-b67b-501c32a7dc57) - **TapiocaDAO** — [Overflow causes Permanent DOS in twTAP](https://github.com/code-423n4/2024-02-tapioca-findings/issues/55) (High severity): Integer overflow vulnerability causing permanent denial of service — the only security researcher who wrote a proof-of-concept exploit. [Fuzzing logs and proof](https://getrecon.xyz/shares/d62b2bb7-98cf-400c-85af-b1cfb254a5e7) - **Centrifuge** — [Rounding errors allows bypassing caps](https://getrecon.substack.com/p/lessons-learned-from-fuzzing-centrifuge-059) (Medium severity): Invariant Tests helped prevent a notoriously hard to detect edge case. [Fuzzing logs and proof](https://getrecon.xyz/shares/bdafeee2-9a2c-45f4-90aa-420f5bc01ff6) - **Corn** — [Insolvency due to Incorrect Accounting](https://gist.github.com/GalloDaSballo/1810dbd7e4a339647fc66d190c1d1e51) (Critical severity): Invariant Testing quickly found a mistake in accounting and helped ensure all subsequent changes were safe. [Fuzzing logs and proof](https://getrecon.xyz/shares/9653d22a-e2e1-453c-8c67-b23912a578e3) - **Credit Coop** — [Rounding allows bypassing minting cap](https://gist.github.com/nican0r/38a7135b02ec608a290bfea1d263ef6f) (Medium severity): Invariant testing helped identify a rounding issue allowing a minting cap to be bypassed. [Fuzzing logs and proof](https://github.com/Recon-Fuzz/audits?tab=readme-ov-file#invariant-testing) - **TapiocaDAO** — [Incorrect decoding in decodeLockTwpTapDstMsg](https://github.com/code-423n4/2024-02-tapioca-findings/issues/69) (Medium severity): Showing how Invariant tests could have caught this. [Fuzzing logs and proof](https://getrecon.xyz/shares/eb700590-2f8c-4039-9288-eaff5e0090fe) --- ## Team Our security researchers bring deep expertise in smart contract auditing, invariant testing, DeFi protocol security, Solidity development, formal verification, and property-based fuzzing. The team includes competitive audit judges, open-source tooling creators, and engineers who have built and secured DeFi protocols managing hundreds of millions in TVL — spanning lending, staking, vaults, governance, and cross-chain bridges. - **Alex** — Security researcher. Top C4 Judge. Former Badger Lead Dev. Bug findings across major DeFi protocols.. [Twitter](https://x.com/GalloDaSballo) | [GitHub](https://github.com/GalloDaSballo) - **Antonio** — Security researcher. Creator of EchidnaToFoundry. Author of the most-read articles on invariant testing.. [Twitter](https://x.com/agfviggiano) | [GitHub](https://github.com/aviggiano) - **Nican0r** — Lead Invariants Engineer. Lead Invariants Engineer. Centrifuge, Liquity, Corn, and more.. [Twitter](https://x.com/nican0r) | [GitHub](https://github.com/nican0r) - **Kn0t** — Lead Invariants Engineer. Lead Invariants Engineer. Driving R&D and next-gen fuzzing tools at Recon.. [Twitter](https://x.com/0xknot) | [GitHub](https://github.com/0kn0t) - **0xsi** — Software engineer && Invariants Engineer. Lead Software Engineer. Built core Recon Pro and cloud fuzzing features.. [Twitter](https://x.com/_0xsi) | [GitHub](https://github.com/Simon-Busch) - **Deivitto** — Security Researcher, Senior Full Stack Engineer, UX & AI Engineer. Security Researcher. Full-stack engineer bridging security and AI tooling.. [Twitter](https://x.com/deaborgin) | [GitHub](https://github.com/Deivitto) - **0xGondar** — Security Researcher & Fuzzing Engineer. Security Researcher, Senior Full-Stack Developer, Fuzzer.. [Twitter](https://x.com/0xGondarxyz) | [GitHub](https://github.com/0xGondarxyz) --- ## Client Testimonials What DeFi protocol teams say about working with Recon for smart contract security audits and invariant testing engagements. > "We worked with the phenomenal @getreconxyz team to get invariant testing coverage for the @centrifuge ERC-7540 implementation, leveraging their Recon Pro platform. Looking forward to seeing where @getreconxyz goes next, they have ambitious plans to take invariant testing to the next level!" > — **Jeroen**, CTO at [Centrifuge](https://centrifuge.io) > "We first met Alex during some excellent security discussions re: Liquity v1. The breadth and depth of his audit of our v2 exceeded expectations - particularly impressive for a solo effort. He uncovered several technical, arithmetic and economic issues and discussed them with us in detail. I'd highly recommend his manual reviews for any DeFi team that takes their security seriously." > — **Rick**, Cofounder at [Liquity](https://www.liquity.org) > "Recon has allowed us to speed up the development of invariant tests immensely. We are able to create and execute test suites in the cloud effortlessly with virtually no boilerplate code. I highly recommend using Recon to automate your fuzzing setup." > — **James**, Lead Dev at [Badger DAO](https://badger.com) > "The recon team is continuously innovating to make invariant testing vastly more accessible to projects, which makes a meaningful difference in security outcomes. They bring their broad expertise in and passion for web3 security to any engagement above and beyond the specified scope." > — **Dapp**, Cofounder at [Corn](https://usecorn.com) > "Engaging with Alex&Lourens showed great proactiveness to answer deep-technical Qs along the process and help to discuss together items that require deeper drilling on invariants, really valuable not only their ability to answer Qs, but educating you on the process and empowering your team for better understanding of invariants on your architecture!" > — **Petrovska**, Cofounder at [Onchainification](https://onchainification.xyz) > "Alex has an incredible eye for detail, leaving no stone unturned during our audit. His review helped us gain confidence in our codebase before going to a competitive audit. I would highly recommend Alex's security services to anyone building smart contract systems." > — **Elliot**, Founder at [Solidity Labs](https://soliditylabs.io) > "We worked together with Alex to review a Safe module for our DAO multi-sig. We really valued his proactiveness and quick response times so that we could ship in a timely manner. 🚀" > — **Xeonus**, Balancer Maxis at [Balancer DAO](https://balancer.fi) > "The ROI on our engagement with Recon was extremely high. They built an invariant test suite that uncovered hard-to-spot high-severity issues and gave us a powerful tool to ship with confidence. Moving forward, invariant testing will be core to our smart contract development at Credit Coop. When we do our next audit, Recon will have to be a part of the picture." > — **Thomas Hepner**, Cofounder & CTO at [Credit Coop](https://creditcoop.xyz) > "Alex had the most transparent and interactive process of any auditor I've worked with. He was very proactive and provided in-depth analysis even beyond what was initially requested. In the end, our team was able to iterate on findings a lot quicker than we'd estimated, while also learning a huge amount along the way" > — **Naps62.eth**, Cofounder & CTO at [Quill Finance](https://quill.finance) [Request an Audit](https://tally.so/r/w2Vqxb) — Join these teams in securing your DeFi protocol with invariant testing --- ## Products ### Recon Pro — Cloud Fuzzing Platform URL: https://getrecon.xyz/pro Recon Pro is a cloud fuzzing platform for smart contract security testing. Run Echidna, Medusa, Halmos, and Foundry fuzz testing campaigns in the cloud — no Docker setup, no DevOps overhead. Queue invariant testing campaigns, monitor coverage, view detailed reports, and share results with your team from the browser. **Key stats:** Over 12,500 stateful fuzzing campaigns run. Used by DeFi protocols managing $3B+ in TVL. **Use cases:** - Run invariant test suites during smart contract audits with unlimited cloud runs - Continuous security testing in CI/CD — catch regressions before they ship to production - Pre-audit fuzzing to maximize coverage before your security audit engagement - Team collaboration — share fuzzing results and coverage reports across security researchers [Request access to Recon Pro](https://tally.so/r/w2Vqxb) ### Recon Extension — VS Code Extension URL: https://getrecon.xyz/extension VS Code extension for scaffolding and running invariant tests locally. Write property-based tests for your Solidity smart contracts with guided setup, auto-generated test harnesses, and one-click execution. Integrates with Recon Pro for cloud fuzzing when you need more compute. [Get the Recon Extension](https://getrecon.xyz/extension) --- ## How We Work Recon supports your team at every stage of smart contract development — from early invariant test scaffolding through pre-audit coverage maximization, solo manual review, and full audit support with cloud fuzzing. ### Development Stages We Support **Early Stage** - Define key invariants & scaffold testers for your Solidity smart contracts - Grow invariants alongside your codebase as your DeFi protocol evolves **Pre-Audit** - Reach 100% coverage with test repros using Echidna, Medusa, and Foundry - Hand auditors meaningful states & broken properties for deeper smart contract security review **Solo Review** - Manual review by top security researchers with DeFi vulnerability expertise - Paired with invariant testing for deeper coverage across lending, staking, and vault protocols **Audit Stage** - Add properties flagged by reviewers in real-time during your smart contract audit - Reproduce bugs as invariant tests for regression safety across protocol upgrades - Cloud runners for rapid fix testing — no weekend delays with Recon Pro fuzzing infrastructure ### Our Service Offerings - **Manual Review**: A thorough Solidity audit by top security researchers. Line-by-line code review focused on blockchain security and DeFi vulnerability patterns: reentrancy, oracle manipulation, access control, and liquidation bugs. Includes token audit coverage for ERC-20, ERC-721, and custom standards. Ideally paired with invariant testing for maximum coverage. - **Invariant Test Writing**: Property-based testing written by an experienced fuzzing engineer. We define critical protocol invariants for DeFi security: solvency, access control, liquidation, and oracle integrity. Then we scaffold a comprehensive test suite with Echidna or Medusa, run formal verification with Halmos, and cloud-fuzz with unlimited Recon Pro campaigns during the engagement. - **Recon Pro**: Cloud fuzzing as a service. Run smart contract fuzzing campaigns with Echidna, Medusa, Halmos, and Foundry for continuous security testing. No infrastructure setup, real-time coverage reports, and team collaboration built in. Over 12,500 campaigns run protecting DeFi protocols across lending, staking, vaults, and governance. **Pricing:** Smart contract audit pricing depends on codebase size, complexity, and scope. A typical invariant testing engagement for a focused DeFi protocol (1,000–5,000 nSLOC) ranges from $30K–$80K. [Request a quote](https://tally.so/r/w2Vqxb) — we review your codebase and provide a fixed price within 48 hours. --- ## Protocols That Trust Recon Recon has provided smart contract security audits and invariant testing engagements for leading DeFi protocols: - **Liquity** (BOLD v2) — Decentralized lending protocol. [Audit report](https://github.com/Recon-Fuzz/audits/blob/main/bold-report.md) - **Centrifuge** — Real-world asset tokenization with ERC-7540 vault implementation - **Badger DAO** — Bitcoin DeFi yield vaults and smart contract security - **Corn** — DeFi protocol with complex accounting and staking mechanisms - **Balancer DAO** — DEX governance and Safe multisig module security audit - **Credit Coop** — DeFi lending with invariant-tested minting cap protections - **Apollon** — DeFi protocol security audit with comprehensive invariant coverage - **Beraborrow** — Berachain-native lending protocol security assessment - **Kleidi** — Smart contract wallet security audit - **Quill Finance** — DeFi protocol with transparent interactive audit process - **TapiocaDAO** — Cross-chain DeFi with LayerZero integration (competitive audit via Code4rena) [Request an Audit](https://tally.so/r/w2Vqxb) — Join these protocols in securing your codebase with Recon --- ## Why Choose Recon Over Traditional Audit Firms | Feature | Recon | Traditional Audit Firm | Automated Scanner | | --- | --- | --- | --- | | Manual code review by security researchers | Yes — top Code4rena judge, DeFi-native experts | Yes — varies by firm | No | | Invariant testing / property-based fuzzing | Yes — every engagement includes a full test suite | Rarely offered | No | | Stateful fuzzing with Echidna, Medusa, Halmos | Yes — cloud-powered via Recon Pro | No | No | | Reusable test suite you keep | Yes — run in CI forever, extend as you ship | No — point-in-time report only | No | | Cloud fuzzing infrastructure | Yes — Recon Pro, 12,500+ campaigns | No | N/A | | DeFi-specific vulnerability expertise | Deep — lending, staking, vaults, governance, bridges | Varies | Pattern-matching only | | Pricing transparency | Fixed quote within 48 hours | Often weeks to quote | Free/cheap but shallow | [Request an Audit](https://tally.so/r/w2Vqxb) — Get a quote for your smart contract security audit within 48 hours --- ## Audit Process & Timeline ### How a Recon Smart Contract Security Audit Works 1. **Scoping** — Send your codebase via our [audit request form](https://tally.so/r/w2Vqxb). We review scope, complexity, and nSLOC count. You receive a fixed-price quote within 48 hours. 2. **Kickoff** — We onboard your team, establish communication channels, and align on critical invariants and protocol-specific security concerns. 3. **Manual Review** — Our security researchers perform line-by-line Solidity code review, focusing on DeFi-specific vulnerability patterns: reentrancy, oracle manipulation, access control, economic exploits, liquidation logic, and rounding errors. 4. **Invariant Testing** — We scaffold a comprehensive invariant test suite using Echidna, Medusa, or Foundry. We define properties that must always hold and run millions of stateful fuzzing transactions against your contracts using Recon Pro cloud fuzzing. 5. **Report Delivery** — You receive a detailed findings report with severity classification (Critical, High, Medium, Low), proof-of-concept exploits, remediation guidance, and the full invariant test suite. 6. **Fix Review** — We verify your fixes and confirm no regressions. The invariant test suite catches issues in your remediation. 7. **Ongoing Protection** — Keep the invariant test suite. Run it in CI on every commit. Use Recon Pro for cloud fuzzing as you ship new features. ### Typical Timelines - **Focused scope** (1,000–3,000 nSLOC): 2–3 weeks, $30K–$50K - **Medium scope** (3,000–5,000 nSLOC): 3–4 weeks, $50K–$80K - **Large scope** (5,000+ nSLOC): 4–6 weeks, custom pricing [Request an Audit](https://tally.so/r/w2Vqxb) — Get your tailored quote within 48 hours --- ## Frequently Asked Questions About Smart Contract Audits **Q: How much does a smart contract audit cost?** A: Smart contract audit pricing depends on codebase size, complexity, and scope. At Recon, a typical engagement for a focused DeFi protocol (1,000–5,000 nSLOC) ranges from $30K–$80K. We provide a fixed-price quote within 48 hours of reviewing your codebase. [Request a quote](https://tally.so/r/w2Vqxb). **Q: What is invariant testing and why does it matter?** A: Invariant testing defines properties that must always hold true in your smart contract, then uses a fuzzer to generate millions of random transaction sequences trying to break them. It catches vulnerabilities that manual code review alone misses — particularly complex multi-step exploits like reentrancy chains, oracle manipulation, and economic attacks. **Q: What is the best smart contract audit firm for DeFi?** A: The best audit firm depends on your protocol's needs, but for DeFi protocols that care about ongoing security, Recon stands out because every audit includes a reusable invariant test suite. Unlike traditional audit firms that deliver a point-in-time report, Recon gives you a testing tool that protects your codebase in CI forever. Our team includes a top Code4rena judge and the creator of EchidnaToFoundry. **Q: What is smart contract fuzzing?** A: Smart contract fuzzing generates random inputs and transaction sequences to test contracts for unexpected behavior. Stateful fuzzing maintains contract state across calls, finding complex multi-step bugs. Recon uses Echidna, Medusa, Halmos, and Foundry for cloud-powered fuzzing via Recon Pro. **Q: How long does a smart contract audit take?** A: Most Recon audits take 2–4 weeks depending on codebase complexity. Invariant testing runs in parallel with manual review using Recon Pro cloud fuzzing infrastructure. **Q: Do you audit DeFi protocols on Layer 2 chains?** A: Yes. We audit Solidity smart contracts on all EVM-compatible chains including Ethereum mainnet, Arbitrum, Optimism, Base, Polygon, BSC, Avalanche, zkSync, Scroll, Linea, and more. We can fork any chain's state for realistic security testing. **Q: What is formal verification for smart contracts?** A: Formal verification uses mathematical proofs to guarantee that smart contract properties hold for all possible inputs. Recon uses Halmos for bounded symbolic execution — it provides formal verification guarantees within defined bounds while being practical enough for real-world DeFi codebases. We combine formal verification with stateful fuzzing for the most thorough smart contract security coverage. **Q: Do you audit zero-knowledge proof contracts?** A: Yes. We review ZK circuit logic, verifier contracts, and the interaction between on-chain and off-chain ZK components. Our security researchers combine zero-knowledge proof expertise with invariant testing to verify correctness properties in ZK-based DeFi protocols. **Q: What is continuous security testing?** A: Continuous security testing means your smart contracts are protected beyond the initial audit. Recon delivers a reusable invariant test suite that runs in CI on every commit, catching regressions before deployment. Combined with Recon Pro cloud fuzzing, your protocol has ongoing protection as the codebase evolves — not just a point-in-time security audit report. **Q: Can you audit cross-chain bridges?** A: Yes. We audit cross-chain bridge contracts, LayerZero integrations, and multi-chain DeFi deployments. Our invariant testing framework supports mainnet forking across all EVM chains, enabling realistic cross-chain security testing for bridge protocols. [Request an Audit](https://tally.so/r/w2Vqxb) — Talk to our security researchers about your smart contract audit needs --- ## Recon Security Audit Methodology ### Smart Contract Vulnerability Detection Recon's security audit methodology combines three complementary approaches to find vulnerabilities in Solidity smart contracts: **1. Manual Code Review** — Expert security researchers perform line-by-line code review targeting DeFi-specific vulnerability patterns: reentrancy (cross-function, cross-contract, read-only), oracle manipulation (TWAP, spot price, stale feeds), access control misconfigurations, integer overflow and underflow, storage collision in proxy contracts, flash loan attack vectors, sandwich extraction, liquidation cascading, rounding errors in share calculations, state machine bugs, and denial of service via gas griefing. **2. Invariant Testing & Property-Based Fuzzing** — We define critical protocol invariants — solvency, conservation of value, access control boundaries, state transition correctness — and use stateful fuzzing to explore the contract's state space. Echidna generates millions of random transaction sequences; Medusa parallelizes execution for large codebases; Foundry fuzz testing provides fast iteration. **3. Formal Verification & Symbolic Execution** — Halmos performs bounded symbolic execution, providing mathematical guarantees that properties hold for all inputs within defined bounds. This catches arithmetic bugs, overflow conditions, and edge cases that even extensive fuzzing might miss. ### DeFi Protocol Categories We Audit | Category | Examples | Key Invariants | | --- | --- | --- | | Lending Markets | Aave-style, Compound-style, custom | Solvency, liquidation thresholds, interest accrual | | Liquid Staking | stETH-style, ERC-7540 | Share/asset ratio, withdrawal accounting | | Yield Vaults | ERC-4626, custom | Share inflation protection, deposit/withdraw symmetry | | DEX Protocols | AMM, CLMM, order books | Constant product, fee accounting, LP share value | | Governance | Governor, timelock, multisig | Vote counting, proposal execution, quorum | | Stablecoin Systems | CDP, algorithmic, backed | Peg maintenance, collateral ratio, liquidation | | Cross-Chain Bridges | LayerZero, message passing | Message integrity, replay protection, asset conservation | [Request an Audit](https://tally.so/r/w2Vqxb) — Get a Proposal in 24 Hours --- ## When Should You Get a Smart Contract Audit? You need a smart contract security audit if: - **Pre-launch** — Audit before mainnet deployment to catch critical vulnerabilities before they're exploitable - **Before a competitive audit** — Use invariant testing to maximize the value of your code review by catching low-hanging bugs first - **Upgrade or migration** — Audit new code before pushing upgradeable proxy changes or migrating to a new version - **Post-exploit** — After an incident, audit to find remaining vulnerabilities and build invariant tests that prevent recurrence - **Regulatory or insurance** — Demonstrate due diligence with a professional smart contract security audit report - **TVL milestone** — As your protocol's TVL grows, the risk profile changes. Re-audit and extend invariant coverage to match - **New DeFi integration** — Adding oracle feeds, lending hooks, or cross-chain bridges introduces composability risk that needs security review Don't wait for an exploit. [Request an Audit](https://tally.so/r/w2Vqxb) — Recon provides smart contract security audits with invariant testing that protects your protocol long after the engagement ends. --- ## Advanced Security Capabilities ### Formal Verification & Symbolic Execution Recon uses Halmos for bounded symbolic execution and formal verification of smart contract properties. Unlike traditional fuzzing, symbolic execution provides mathematical guarantees that properties hold for all inputs within defined bounds. We combine this with stateful fuzzing for the most thorough security coverage available. ### Continuous Security Testing Smart contract security doesn't end with a point-in-time audit. Recon provides continuous security through: - **Reusable invariant test suites** — run in CI on every commit, catching regressions before deployment - **Recon Pro cloud fuzzing** — queue campaigns on-demand as your codebase evolves - **Post-launch monitoring** — extend test suites as new features ship, new integrations are added, or the DeFi landscape changes ### Cross-Chain & Bridge Security We audit cross-chain bridges, LayerZero integrations, and multi-chain DeFi deployments. Our invariant testing framework supports mainnet forking across all EVM chains — Ethereum, Arbitrum, Optimism, Base, Polygon, BSC, Avalanche, zkSync, and more — enabling realistic cross-chain security testing. ### Zero-Knowledge Proof System Audits For protocols using ZK proofs, we review circuit logic, verifier contracts, and the interaction between on-chain and off-chain components. Our security researchers combine ZK expertise with invariant testing to verify correctness properties. [Request an Audit](https://tally.so/r/w2Vqxb) — Talk to our security researchers about your protocol's advanced security needs --- ## Smart Contract Audit Firms — How Recon Compares ### Recon vs OpenZeppelin OpenZeppelin is the industry standard for smart contract libraries and security audits. They've secured $110B+ TVL and work with institutional clients. Recon differentiates with invariant testing: every Recon audit delivers a reusable test suite, while OpenZeppelin provides point-in-time reports. For DeFi protocols that need ongoing protection through property-based fuzzing, Recon provides better long-term value per dollar spent on security. ### Recon vs Trail of Bits Trail of Bits created Echidna and Slither — foundational tools for smart contract security. Recon builds on these same tools (Echidna, Medusa, Halmos) but packages them into a cloud fuzzing platform (Recon Pro) that makes invariant testing accessible. Trail of Bits offers broader cybersecurity services; Recon focuses exclusively on smart contract and DeFi security. ### Recon vs Cantina Cantina operates as an AI-native security platform with a large researcher network. Recon offers a more focused, senior-led approach: smaller team, deeper engagement, and a cloud fuzzing deliverable you keep. Cantina's strength is scale; Recon's strength is depth of invariant testing coverage. [Request an Audit](https://tally.so/r/w2Vqxb) — Get a tailored proposal for your smart contract security audit --- ## Service Pages Overview Recon offers smart contract security services across five specialized service pages, each targeting specific DeFi security needs: - **[Smart Contract Security Audit Services](https://getrecon.xyz/services/smart-contract-audit)** — Professional smart contract security audits powered by invariant testing. Keywords: smart contract audit, smart contract security audit, solidity audit, code review, security audit, blockchain audit, smart contract audit cost, smart contract audit services, best smart contract auditor, blockchain security, token audit - **[DeFi Protocol Security Audit](https://getrecon.xyz/services/defi-security-audit)** — Specialized DeFi security audits with invariant testing. Keywords: defi audit, defi security audit, protocol security, defi security, lending audit, dex audit, DeFi protocol security, DeFi vulnerability, DeFi audit pricing, oracle manipulation audit, liquidation audit - **[Invariant Testing & Fuzzing Services](https://getrecon.xyz/services/invariant-testing-services)** — Professional invariant testing and property-based fuzzing services for smart contracts. Keywords: invariant testing, property-based testing, smart contract fuzzing, fuzzing services, invariant test suite, formal verification, continuous security testing, Echidna fuzzing, Medusa fuzzer, coverage-guided fuzzing, symbolic execution - **[Solidity Audit — Smart Contract Security for Every EVM Chain](https://getrecon.xyz/services/solidity-security-audit)** — Expert Solidity smart contract audits with invariant testing coverage. Keywords: solidity audit, solidity security audit, evm audit, ethereum audit, solidity smart contract audit, reentrancy audit, access control audit, EVM security - **[Smart Contract Fuzzing Services](https://getrecon.xyz/services/fuzzing-smart-contracts)** — Professional smart contract fuzzing with Echidna, Medusa, Halmos, and Foundry. Keywords: smart contract fuzzing, fuzz testing, echidna fuzzing, medusa fuzzer, property-based testing, invariant fuzzing, cloud fuzzing, stateful fuzzing, Halmos symbolic execution, Foundry fuzz testing Each service page includes detailed methodology comparisons, DeFi vulnerability tables, FAQ sections with FAQPage schema, and direct [audit request CTAs](https://tally.so/r/w2Vqxb). --- ## Ready to Secure Your Protocol? Whether you need a smart contract security audit, invariant testing for your DeFi protocol, or cloud fuzzing infrastructure — our security researchers are ready to help. **Get started:** 1. **Request an Audit** — [Send your audit request](https://tally.so/r/w2Vqxb) and get a tailored quote within 48 hours 2. **Talk to Us** — [Message us on Telegram](https://t.me/GalloDaSballo) to discuss your protocol's security needs 3. **Try Recon Pro** — [Start cloud fuzzing](https://getrecon.xyz/pro) your Solidity smart contracts today 4. **Read the Docs** — [Recon documentation](https://book.getrecon.xyz) for invariant testing guides and setup 5. **View Audit Reports** — [Public audit reports](https://github.com/Recon-Fuzz/audits) showcasing our DeFi security work ## Contact - **Request an Audit**: https://tally.so/r/w2Vqxb - **Telegram**: https://t.me/GalloDaSballo - **Website**: https://getrecon.xyz - **Recon Pro**: https://getrecon.xyz/pro - **VS Code Extension**: https://getrecon.xyz/extension - **Documentation**: https://book.getrecon.xyz - **Audit Reports**: https://github.com/Recon-Fuzz/audits --- ## Developer Tools Recon provides free developer tools for smart contract security, invariant testing, and EVM bytecode analysis. - **Invariant Testing Builder** (Featured) — Scaffold a Chimera invariant testing suite from your contract ABIs interactively.. [Try it](https://getrecon.xyz/tools/builder) - **Scaffold Invariants Sandbox** (Featured) — Build and test invariant testing suites with an interactive sandbox environment.. [Try it](https://getrecon.xyz/tools/sandbox) - **Medusa Log to Foundry** (Log Parsers) — Convert Medusa fuzzing logs into executable Foundry test reproductions.. [Try it](https://getrecon.xyz/tools/medusa) - **Echidna Log to Foundry** (Log Parsers) — Transform Echidna fuzzing logs into executable Foundry tests.. [Try it](https://getrecon.xyz/tools/echidna) - **Halmos Log to Foundry** (Log Parsers) — Parse Halmos symbolic execution logs for Foundry integration.. [Try it](https://getrecon.xyz/tools/halmos) - **Hex to Solidity Type Value Converter** (Bytecode) — Convert hex values to human-readable Solidity type values.. [Try it](https://getrecon.xyz/tools/hex) - **Bytecode Compare** (Bytecode) — Compare and analyze differences between EVM bytecode.. [Try it](https://getrecon.xyz/tools/bytecode-compare) - **Bytecode Formatter** (Bytecode) — Format bytecode and calldata into words and function selectors.. [Try it](https://getrecon.xyz/tools/bytecode-formatter) - **Bytecode Static Deployment Tool** (Bytecode) — Generate initCode from static bytecode (no constructor).. [Try it](https://getrecon.xyz/tools/bytecode-static-deployment-tool) - **Bytecode String Tool** (Bytecode) — Pad strings and encode them to hex.. [Try it](https://getrecon.xyz/tools/bytecode-string-tool) - **Bytecode to Interface** (Bytecode) — Reverse engineer Solidity interfaces from contract bytecode.. [Try it](https://getrecon.xyz/tools/bytecode-to-interface) - **Oracle Drift Calculator** (Economic) — Calculate oracle price drift and deviation thresholds.. [Try it](https://getrecon.xyz/tools/oracle-drift) - **ETH to Wei Converter** (Economic) — Convert between ETH, Wei, 1e8, 1e6 and other decimal representations.. [Try it](https://getrecon.xyz/tools/decimals) - **Smart Contract Storage Slot Extractor** (Storage) — Extract storage slot values from a smart contract.. [Try it](https://getrecon.xyz/tools/slots) - **OpenZeppelin Roles Scraper** (Governance) — Fetch OpenZeppelin roles and users from a contract.. [Try it](https://getrecon.xyz/tools/roles) - **Git Diff Viewer** (Diff) — View the diff between two commits in a GitHub repository.. [Try it](https://getrecon.xyz/tools/diff) --- ## Pricing Smart contract audit pricing from Recon. Every engagement includes a reusable invariant test suite. | Engagement Type | Starting At | Timeline | Best For | | --- | --- | --- | --- | | Invariant Testing | From $15K | 1–2 weeks | Pre-audit hardening, smaller codebases, ongoing security infrastructure | | Solo Review + Invariants | From $30K | 2–3 weeks | Most DeFi protocols — manual review paired with automated property coverage | | Full Audit | From $50K | 3–6 weeks | Complex or high-TVL protocols that need maximum coverage | Final price depends on codebase size, protocol complexity, and deliverables. Every engagement includes: findings report, reusable invariant test suite (yours to keep), cloud fuzzing via Recon Pro, fix verification, and direct access to your researcher. We send you a fixed-price quote within 48 hours of looking at your code. [Get a Quote](https://tally.so/r/w2Vqxb) — Fixed-price quotes within 48 hours [Pricing Page](https://getrecon.xyz/pricing) — Full pricing breakdown with FAQs --- ## Open-Source Repositories Recon maintains open-source tools and example invariant testing suites for the smart contract security community. - **Chimera** (framework) — Write once, run everywhere framework for invariant testing — supports Echidna, Medusa, Halmos, and Foundry from a single test suite.. [GitHub](https://github.com/Recon-Fuzz/chimera) - **Recon Extension** (tooling) — VS Code / Cursor extension for one-click invariant testing scaffolding and cloud fuzzing.. [GitHub](https://github.com/Recon-Fuzz/recon-extension) - **create-chimera-app** (tooling) — CLI scaffolding tool to bootstrap a new Chimera-based invariant testing project in seconds.. [GitHub](https://github.com/Recon-Fuzz/create-chimera-app) - **Setup Helpers** (tooling) — Reusable Solidity helpers for bootstrapping fuzzing environments — deployment scripts, actor management, and common patterns.. [GitHub](https://github.com/Recon-Fuzz/setup-helpers) - **Recon Docs** (documentation) — Official documentation for Recon, Chimera, and Recon Pro — guides, API reference, and tutorials.. [GitHub](https://github.com/Recon-Fuzz/recon-docs) - **EigenLayer Fuzzing** (example) — Public invariant testing suite for EigenLayer — demonstrates real-world property testing on a production restaking protocol.. [GitHub](https://github.com/Recon-Fuzz/eigenlayer-fuzzing) - **Renzo Fuzzing** (example) — Public invariant testing suite for Renzo Protocol — demonstrates property-based fuzzing for liquid restaking.. [GitHub](https://github.com/Recon-Fuzz/renzo-fuzzing) - **call-test-undo** (tooling) — Foundry helper for atomic call-test-undo patterns in invariant testing — assert mid-transaction without side effects.. [GitHub](https://github.com/Recon-Fuzz/call-test-undo) - **ERC-7540 Reusable Properties** (example) — Reusable invariant property suite for ERC-7540 async vaults — battle-tested on Centrifuge.. [GitHub](https://github.com/Recon-Fuzz/erc7540-reusable-properties) --- ## MCP Servers — AI Tool Integration Recon provides MCP (Model Context Protocol) servers that integrate smart contract security tools directly into AI assistants like Claude, Cursor, and VS Code Copilot. ### recon-knowledge — Documentation Search Searchable access to all Recon documentation, blog posts, glossary, comparisons, book chapters, and Substack newsletters. 14 tools across 3 data sources. ```json { "mcpServers": { "recon-knowledge": { "command": "npx", "args": ["@recon-fuzz/mcp-knowledge"] } } } ``` **Tools:** search_glossary, get_blog_post, get_comparison, search_site, list_tools, get_book_chapter, get_book_concept, search_book, list_book_chapters, get_substack_post, search_substack, list_substack_posts, search_all, refresh_cache No API key needed. [GitHub](https://github.com/Recon-Fuzz/recon-mcp-knowledge) | [npm](https://www.npmjs.com/package/@recon-fuzz/mcp-knowledge) ### chimera-scaffold — Fuzzing Test Suite Generator Scaffolds Chimera fuzzing test suites for Solidity smart contracts. Generates ready-to-compile projects with properties, handlers, and fuzzer configs. Supports 6 protocol types and 4 testing patterns. ```json { "mcpServers": { "chimera-scaffold": { "command": "npx", "args": ["@recon-fuzz/mcp-chimera"] } } } ``` **Tools:** scaffold_project, generate_properties, get_template, explain_pattern **Protocol types:** erc20, vault, lending, amm, governance, staking **Patterns:** actors, ghosts, cross-contract, setup-layering No API key needed. Runs entirely locally. [GitHub](https://github.com/Recon-Fuzz/recon-mcp-chimera) | [npm](https://www.npmjs.com/package/@recon-fuzz/mcp-chimera) ### recon-pro — Cloud Fuzzing API Submit and monitor fuzzing campaigns on Recon Pro from your AI assistant. Requires a Recon Pro API key. ```json { "mcpServers": { "recon-pro": { "command": "npx", "args": ["@recon-fuzz/mcp-pro"], "env": { "RECON_API_KEY": "rp_...", "RECON_API_URL": "https://getrecon.xyz" } } } } ``` **Tools:** submit_job, get_job_status, list_jobs, get_recipes, create_recipe [npm](https://www.npmjs.com/package/@recon-fuzz/mcp-pro) --- ## All Content in Markdown Format The following links serve each page as clean markdown with YAML frontmatter — optimized for AI retrieval and citation. ### Service Pages (5 pages) - [Smart Contract Security Audit Services](https://getrecon.xyz/api/md/services/smart-contract-audit) — Professional smart contract security audits powered by invariant testing. - [DeFi Protocol Security Audit](https://getrecon.xyz/api/md/services/defi-security-audit) — Specialized DeFi security audits with invariant testing. - [Invariant Testing & Fuzzing Services](https://getrecon.xyz/api/md/services/invariant-testing-services) — Professional invariant testing and property-based fuzzing services for smart contracts. - [Solidity Audit — Smart Contract Security for Every EVM Chain](https://getrecon.xyz/api/md/services/solidity-security-audit) — Expert Solidity smart contract audits with invariant testing coverage. - [Smart Contract Fuzzing Services](https://getrecon.xyz/api/md/services/fuzzing-smart-contracts) — Professional smart contract fuzzing with Echidna, Medusa, Halmos, and Foundry. ### Blog Posts (72 articles — full content below) --- ### Why Invariant Testing Matters for DeFi Security **Author:** alex | **Published:** 2025-01-15 | **Reading time:** 12 min **Tags:** invariant-testing, defi-security, smart-contracts, fuzzing **URL:** https://getrecon.xyz/blog/why-invariant-testing-matters-defi-security # Why invariant testing matters for DeFi security Unit tests check what you think of. Invariant tests check what you don't. This fundamental difference is why invariant testing has become the gold standard for DeFi security. ## The problem with traditional testing Traditional unit testing follows a simple pattern: set up a specific state, perform an action, check the result. The problem? You can only test scenarios you can imagine. And in DeFi, the attack surface is far larger than any human can enumerate. Consider a lending protocol. You might write unit tests for depositing, borrowing, and liquidating. But have you tested what happens when: - A user deposits, borrows, partially repays, borrows again, then gets liquidated in the same block? - 100 users deposit in a specific order that triggers a rounding accumulation? - A reward distribution happens between a deposit and withdrawal in the same transaction? These scenarios are where real vulnerabilities hide, and they're exactly what invariant testing excels at finding. ## What makes invariant testing different Instead of testing specific scenarios, you define properties that must always be true — the core idea behind [property-based testing](/learn/property-based-testing): - "The protocol must always be solvent: total assets >= total liabilities" - "No user can withdraw more than they deposited plus earned rewards" - "Total shares * price per share must equal total assets" Then a fuzzer generates millions of random transaction sequences and checks these properties after every step. If any sequence breaks a property, you've found a bug. ## How invariant testing works in practice The workflow for invariant testing follows a repeatable pattern that any Solidity developer can adopt. First, you deploy your system under test: all contracts, configured as they would be in production. This means deploying your vault, lending pool, oracle, interest rate model, and any other components, then wiring them together the same way your deployment scripts would. The goal is to test the system as a whole, not individual functions in isolation. Next, you write handler functions. These are Solidity functions that wrap your protocol's external entry points: deposit, withdraw, borrow, repay, liquidate, and so on. Handlers are responsible for setting up valid preconditions (selecting an actor, bounding input values to reasonable ranges) and then calling the target function. They act as the fuzzer's interface to your protocol. Then you define properties. These are Solidity functions that return true if the invariant holds. A solvency property checks that total assets cover total liabilities. An accounting property checks that individual balances sum to the tracked total. These properties are checked after every handler call. The fuzzer takes over from here. It calls handlers in random sequences with random inputs, checking every property after each call. A single campaign might execute millions of call sequences, exploring state transitions no human would think to test. When a property breaks, the fuzzer reports the exact call sequence that triggered the violation: which functions were called, in what order, with what arguments. The [Chimera framework](/learn/chimera-framework) makes this setup portable across fuzzers. You write your handlers and properties once, and they run on Echidna, Medusa, and Foundry without modification. This matters because each fuzzer has different strengths in how it explores the state space. ## Why unit tests aren't enough The fundamental limitation of unit tests is coverage of the state space, something we explore in depth in [smart contract vulnerabilities that unit tests miss](/blog/smart-contract-vulnerabilities-unit-tests-miss). A well-tested DeFi protocol might have 50 to 100 unit tests, each checking one specific scenario that a developer thought of. Invariant tests explore millions of randomly generated sequences, covering parts of the state space that no developer would anticipate. The key difference is directional. Unit tests verify expected behavior: "I deposit 100, I withdraw 100, my balance is 0." Invariant tests discover unexpected behavior: the fuzzer might find that the sequence "deposit 100, donate 50 directly to the vault, deposit 1, withdraw 101" breaks solvency because the donation changed the share price in a way that the withdrawal logic didn't account for. No developer would write that unit test because the bug isn't in any single operation — it's in the interaction between operations. This distinction is especially important in DeFi because the state space is enormous. A lending protocol with 10 users, 5 assets, and 20 possible actions per user has a combinatorial explosion of possible states. The ordering of transactions matters. The amounts matter. The timing relative to interest accrual and oracle updates matters. Unit tests can sample a few dozen points from this space. Invariant tests sample millions. There's a practical consequence too: unit tests require the developer to know where the bugs are in order to test for them. If you knew where the bugs were, you'd just fix them. Invariant testing inverts this — you state what must be true, and the fuzzer finds the conditions under which it isn't. ## Common invariant patterns Certain invariant categories appear across nearly every DeFi protocol. Learning to recognize them gives you a starting point for any engagement — we cover six battle-tested examples in our [property design patterns for DeFi lending](/blog/property-design-patterns-defi-lending) guide. Solvency is the most fundamental: total assets held by the protocol must be greater than or equal to total liabilities owed to users. This single property catches rounding errors, accounting mismatches, and flash loan exploits. Accounting consistency checks that the sum of individual user balances equals the protocol's tracked total. If these diverge, tokens are being created or destroyed outside of legitimate operations. Access control properties verify that only authorized addresses can call privileged functions. The fuzzer will try calling admin functions from random addresses, and the property confirms those calls have no effect. Monotonicity properties assert that certain values only move in one direction. Total cumulative deposits should never decrease. Accrued fees should never decrease. Share price in a yield-bearing vault should never decrease (absent a legitimate loss event like a liquidation penalty). Withdrawal guarantee checks that every user can withdraw their fair share at any point. This is the ultimate solvency test, not just that the protocol tracks enough assets on paper, but that the withdrawal code path actually succeeds. Each of these categories catches different bug classes. Solvency catches rounding and accounting errors. Access control catches missing modifiers. Monotonicity catches state corruption. Used together, they form a complete safety net. ## Real-world impact At Recon, invariant testing has directly prevented over $20 million in potential losses: ## Badger DAO: critical accounting bug Our invariant testing of remBADGER found that specific sequences of deposits and reward distributions could desynchronize share accounting. This critical bug could have led to protocol insolvency. A traditional audit had reviewed the same code without catching it. ## Centrifuge: rounding cap bypass Fuzzing Centrifuge's [ERC-7540](/blog/how-to-fuzz-erc7540-async-vaults) implementation discovered that small rounding errors in share calculations could be exploited to bypass deposit caps. This is the kind of edge case that's nearly impossible to find through manual review. ## Corn: insolvency through incorrect accounting Invariant testing quickly identified a path to protocol insolvency through incorrect accounting. The bug was found within hours of starting the fuzzing campaign, and subsequent testing ensured the fix was correct. ## Getting started You don't need to be a security expert to benefit from invariant testing. Here's how to start: 1. Identify your protocol's core properties: what must always be true? Start with solvency and accounting correctness. 2. Use Recon's Chimera framework: write tests once, run them with Echidna, Medusa, or Foundry. 3. Run in the cloud with Recon Pro: no infrastructure management. Upload your tests and get results. 4. Iterate on findings: each broken invariant teaches you something about your system. ## The bottom line Every DeFi protocol that handles user funds needs invariant testing. It's not a replacement for manual audits - it's a complement that catches the edge cases humans miss. The cost of a fuzzing campaign is orders of magnitude less than the cost of an exploit. If you're building in DeFi, [request an audit](/request-audit) with Recon to get full invariant testing coverage. Your users' funds depend on it. --- ### How We Found Real Vulnerabilities with Fuzzing **Author:** antonio | **Published:** 2025-02-24 | **Reading time:** 12 min **Tags:** fuzzing, vulnerabilities, smart-contract-security, case-studies **URL:** https://getrecon.xyz/blog/how-we-found-real-vulnerabilities-with-fuzzing # How we found real vulnerabilities with fuzzing Over the past two years, Recon has found critical vulnerabilities in some of DeFi's most important protocols using invariant testing and fuzzing. In this post, we'll walk through real findings and the properties that caught them. ## Finding #1: insolvency in a major lending protocol Severity: Critical Impact: Complete protocol insolvency The protocol had a vault system where users deposit assets and receive shares. The core invariant should be simple: ``` totalAssets >= totalShares * pricePerShare ``` Our fuzzer found a sequence where: 1. User A deposits a large amount 2. Rewards are distributed 3. User B deposits a small amount 4. User A withdraws 5. The protocol now owes more than it has You can see a similar finding in our [Corn case study](/case-studies/corn). The root cause was a rounding direction error in the share calculation during step 3. When rewards had just been distributed, the price per share increased, but new deposits were rounding in the wrong direction, effectively giving new depositors slightly more shares than they deserved. The property that caught it: ```solidity function invariant_solvency() public returns (bool) { return vault.totalAssets() >= vault.convertToAssets(vault.totalSupply()); } ``` ## Finding #2: rounding-based cap bypass Severity: Medium Impact: Bypass of protocol-enforced limits A protocol had a deposit cap to limit total exposure. The cap was checked against the total deposited amount. However, the conversion between shares and assets introduced small rounding errors. Our fuzzer discovered that by making many small deposits, a user could accumulate slightly more actual exposure than the cap allowed. While each individual rounding error was tiny (1 wei), across thousands of operations, the cumulative bypass was significant. The property that caught it: ```solidity function invariant_capEnforced() public returns (bool) { return vault.totalAssets() <= vault.depositCap(); } ``` ## Finding #3: permanent DoS through overflow Severity: High Impact: Permanent denial of service In a staking contract, a cumulative reward tracker used `uint128` for storage. Our fuzzer found that after a specific sequence of stake/unstake operations with large values, the reward tracker could overflow, permanently bricking the contract. The interesting aspect: the individual operations all used reasonable values. It was the specific sequence and timing that triggered the overflow. This is exactly the kind of bug that unit tests miss. The property that caught it: ```solidity function invariant_canAlwaysUnstake() public returns (bool) { // Try unstaking for each actor - should never revert for (uint i = 0; i < actors.length; i++) { uint balance = staking.balanceOf(actors[i]); if (balance > 0) { try staking.unstake(balance) {} catch { return false; } } } return true; } ``` ## Finding #4: reward distribution desynchronization Severity: High Impact: Unfair reward distribution across stakers In a staking protocol, rewards were distributed proportionally based on each user's share of the total stake at the time of distribution. The mechanism seemed straightforward: when rewards arrive, iterate through stakers and allocate based on their percentage of `totalStaked`. Our fuzzer discovered that deposits made in the same block as a reward distribution could receive a portion of rewards they hadn't earned. The sequence was: 1. Reward distribution is triggered with 1000 tokens to distribute 2. In the same transaction batch, User C deposits a large stake 3. The reward calculation uses the updated `totalStaked` (which now includes User C's deposit) but distributes the full 1000 tokens 4. User C receives rewards proportional to their share, despite having staked zero seconds The property that caught it: ```solidity function invariant_rewardAccounting() public returns (bool) { uint256 totalDistributed = staking.cumulativeRewardsDistributed(); uint256 totalEarned = 0; for (uint256 i = 0; i < actors.length; i++) { totalEarned += staking.earned(actors[i]); } return totalDistributed >= totalEarned; } ``` The root cause was an ordering issue in state updates. The contract updated `totalStaked` with the new deposit before calculating each user's reward share. The fix was to snapshot `totalStaked` at the beginning of the distribution and use that snapshot for all share calculations, regardless of deposits or withdrawals that occur in the same block. ## Writing effective properties The properties that catch real bugs tend to be: 1. Simple and fundamental. "The protocol is solvent" catches more bugs than complex properties about specific edge cases. 2. Focused on accounting. Most DeFi bugs are accounting bugs. Track that assets = liabilities. 3. Tested against withdrawal. The ultimate test is whether all users can withdraw their fair share. 4. State-aware. Check properties that span multiple transactions, not just single operations. ## From violation to fix Finding a property violation is only the first step. What matters is turning that violation into a fix and making sure it sticks. When a fuzzer breaks a property, it gives you a reproduction sequence — the exact list of function calls, in order, with the exact arguments that triggered the failure. This sequence is the most useful debugging artifact you can have. It's a concrete proof that the bug exists, not a theoretical concern. The first thing we do is convert this sequence into a standalone unit test. This gives you a fast, deterministic reproduction that you can run repeatedly while debugging. You step through the sequence call by call, inspecting state after each step, until you find the exact point where the invariant breaks. The root cause is usually one of a few patterns: rounding in the wrong direction, reading stale state that should have been updated first, an off-by-one in a loop or index, or a missing check that allows an unexpected state transition. Once you identify the root cause, you apply the fix and re-run the fuzzing campaign. The property that originally broke should now hold across millions of new sequences. But you also need to verify that your fix didn't break other properties. A common mistake is fixing a rounding error by adding a check that inadvertently blocks legitimate operations. Running the full property suite after every fix catches these regressions immediately. The property itself stays in the [test suite](/learn/invariant-testing) permanently. It becomes a guard against the entire class of bug it represents, not just the specific instance the fuzzer found. If a future code change reintroduces the same pattern, the property will catch it again. This is how invariant testing compounds in value over time — every finding adds a new permanent check to your security infrastructure. ## Tools we use At Recon, we use our [Chimera framework](/learn/chimera-framework) to write tests compatible with Echidna, Medusa, and Foundry simultaneously. This lets us use each fuzzer's strengths: - Echidna for thorough corpus-based exploration - Medusa for fast parallel fuzzing - Foundry for quick iteration during development We run these in the cloud using [Recon Pro](/pro) for maximum computational power, often running campaigns of millions of test sequences. ## When to start fuzzing A common mistake is treating fuzzing as something you do after the code is "finished." In practice, the best time to start fuzzing is as soon as your core logic compiles. Early fuzzing catches architectural issues (problems with how contracts interact, how state flows between components, how accounting is tracked) before those patterns are baked into the entire codebase. Fixing an architectural flaw in week two of development costs hours. Fixing it after a full audit costs weeks and potentially a redeployment. Fuzzing during active development also provides faster feedback than post-audit fuzzing. When you write a new feature, you can add a property and run a short fuzzing campaign immediately. If the property breaks, you fix it while the logic is fresh in your mind. This is dramatically more efficient than finding the same bug three months later in an audit report. For teams that want continuous protection, CI integration is the way to go. Run short fuzzing campaigns (5 to 10 minutes) on every pull request to catch obvious regressions. Run longer campaigns (1 to 8 hours) nightly or weekly to explore deeper state-dependent interactions. This turns fuzzing from a one-time event into an ongoing part of your development process. ## Conclusion Fuzzing isn't theoretical. It finds real bugs that manual [code review](/services/smart-contract-audit) and unit testing miss. If you're building a DeFi protocol, invariant testing should be a core part of your security process, not an afterthought. Ready to find bugs before attackers do? [Request an audit](/request-audit) with Recon. --- ### Getting Started with Recon Pro Cloud Fuzzing **Author:** kn0t | **Published:** 2025-03-17 | **Reading time:** 10 min **Tags:** recon-pro, cloud-fuzzing, tutorial, getting-started **URL:** https://getrecon.xyz/blog/getting-started-recon-pro-cloud-fuzzing # Getting started with Recon Pro cloud fuzzing Recon Pro makes it easy to run invariant tests in the cloud with Echidna, Medusa, [Halmos](/learn/halmos), and Foundry. This guide walks you through your first cloud fuzzing campaign. ## Why cloud fuzzing? Running fuzzers locally has limitations: - CPU bound: your laptop can only run so many tests per second - Time limited: you probably can't leave your laptop running for 48 hours - Single machine: can't parallelize across multiple cores/machines Recon Pro solves all of these. Upload your tests, select your fuzzers, and let the cloud do the heavy lifting. Over 12,500 cloud fuzzing runs have been completed on the platform. ## What you need before starting Before launching your first cloud fuzzing campaign, make sure you have the following in place. You need a Solidity project that compiles with Foundry. Run `forge build` locally and confirm it succeeds. Recon Pro uses Foundry under the hood for compilation, so if your project builds locally, it'll build in the cloud. You need at least one invariant property. This can be as simple as a solvency check — a function that returns true if total assets are greater than or equal to total liabilities. Even a single well-chosen property can catch critical bugs. You can always add more properties later as you learn what to test for. Your test setup should use the Chimera framework for cross-fuzzer compatibility, or a standard Foundry fuzz test. If you're starting from scratch, the Recon VS Code extension generates the full Chimera scaffolding automatically (handlers, properties, and configuration files). If you already have Foundry-based invariant tests, those work too. You need a GitHub repository. Recon Pro integrates directly with GitHub to clone your code, install dependencies, and run campaigns. Private repositories are fully supported. You don't need any special infrastructure. No Docker setup, no cloud configuration, no fuzzer installation. Recon Pro handles compilation, dependency resolution, and fuzzer orchestration entirely on the platform side. ## Step 1: set up your project If you're starting fresh, the fastest way is with the Recon VS Code Extension: 1. Install from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Recon-Fuzz.recon) 2. Open your Solidity project 3. Run the "Recon: Initialize" command 4. The extension scaffolds the entire fuzzing setup using Chimera If you already have a Chimera-based testing setup, you're ready to go. ## Step 2: log in to Recon Pro Navigate to [getrecon.xyz/dashboard](/dashboard) and connect your account. You'll see your dashboard with options for: - Running new fuzzing jobs - Viewing results from previous campaigns - Managing your repositories ## Step 3: connect your repository Recon Pro integrates with GitHub. Connect your repository and select the branch you want to fuzz. The platform will: - Clone your repo - Install dependencies - Compile your contracts - Prepare the fuzzing environment ## Step 4: configure your campaign Select your fuzzers and parameters: - Fuzzer: choose Echidna, Medusa, or both - Duration: how long to run (longer = more coverage) - Workers: number of parallel fuzzing workers Each fuzzer has distinct strengths. Echidna is thorough with corpus-based exploration and has mature shrinking. When it finds a violation, it reduces the call sequence to the minimal reproduction, which makes debugging easier. Medusa is faster with parallel execution and tends to find bugs quickly by exploring more sequences per second. Running both fuzzers on the same test suite gives the best overall coverage because [they explore the state space differently](/blog/echidna-vs-medusa-practical-comparison). Duration matters more than you might expect. A 10-minute campaign catches obvious bugs: missing access controls, basic arithmetic errors, properties that fail on simple inputs. A 1-hour campaign catches subtle interactions between functions, where the bug only appears after a specific sequence of 5 or more calls. Campaigns of 8 hours or longer catch deep state-dependent issues where the bug requires the system to reach a very specific state through a long chain of operations. Workers control how many parallel fuzzing instances run simultaneously. More workers means more sequences explored per unit of time, which translates to faster coverage growth. The default worker count is usually sufficient for initial runs, but increasing it for longer campaigns helps explore the state space more thoroughly. For your first run, we recommend Medusa with default settings. It's fast and gives good results quickly. ## Step 5: launch and monitor Hit "Start" and monitor progress in real-time. The dashboard shows: - Live coverage metrics - Any property violations found - Transaction sequences that triggered violations - Corpus growth over time ## Step 6: analyze results When the campaign completes, you get: - Full report: summary of coverage achieved and properties tested - Violations: any invariant violations with reproduction steps - Shareable links: share results with your team via Recon's sharing feature If violations are found, the platform shows the exact transaction sequence that triggered the failure, making it easy to understand and fix the bug. ## Understanding the output Knowing how to read fuzzing results is essential to getting value from a campaign. The coverage report shows which functions and code branches the fuzzer reached during the campaign. High coverage means the fuzzer exercised most of your protocol's logic. Low coverage on a specific function usually means the handler's preconditions are too restrictive, or the function requires state that the fuzzer has difficulty reaching. Use coverage data to refine your handlers and improve future campaigns. Property violations are the most actionable output. Each violation includes the exact call sequence that triggered the failure: the function names, their arguments, and the order in which they were called. This sequence is a concrete reproduction of the bug. You can copy it directly into a Foundry test and replay it locally to step through the execution and find the root cause. If no violations are found, that doesn't mean the code is bug-free. It means the properties you defined held for all the sequences the fuzzer tested during the campaign duration. The correct response is to add more properties that test different aspects of your protocol and run longer campaigns. Absence of violations with high coverage and strong properties is meaningful evidence of correctness, but it isn't a proof. The corpus (the set of saved test inputs that achieved new coverage) is preserved between runs. When you start a new campaign, it can build on the corpus from prior runs, reaching deeper states faster instead of re-exploring ground it has already covered. This makes repeated fuzzing campaigns progressively more effective. Shareable reports let you send results to your team or external auditors. The report includes coverage data, property status, and any violation sequences. This is useful for coordinating between developers and security reviewers, or for documenting the testing that was performed before a deployment. ## Tips for effective cloud fuzzing 1. Start with simple properties. Begin with solvency and basic accounting invariants. 2. Increase duration gradually. Start with short runs to validate your setup, then increase. 3. Use both fuzzers. Echidna and Medusa find different things. Run both. 4. Iterate on findings. Each violation teaches you about your system. Add new properties based on what you learn. 5. Run on every PR. Set up recurring fuzzing in your CI/CD pipeline for continuous security. ## Integrating with CI/CD The highest-value use of cloud fuzzing is making it continuous rather than one-off. Recon Pro supports running fuzzing campaigns automatically through GitHub integration. When a pull request is opened, a short fuzzing campaign (5 to 10 minutes) can run against the changed code. This catches obvious regressions immediately — if a code change breaks a property that previously held, you find out before the PR is merged, not after deployment. Longer campaigns can be triggered on merges to main or on a nightly schedule. These deeper runs (1 to 8 hours) explore more of the state space and catch subtle bugs that short campaigns miss. The combination of fast PR checks and deep nightly runs gives you both quick feedback and thorough coverage. Results from CI-triggered campaigns appear directly in the pull request context, so there's no need to check a separate dashboard or wait for someone to run tests manually. If a property breaks, the violation and its reproduction sequence are immediately visible to the developer who authored the change. This approach transforms fuzzing from a one-time audit activity into [continuous security testing](/blog/continuous-security-testing-cicd-fuzzing). Every code change is automatically validated against your invariant properties. Over time, as you add more properties based on past findings, the safety net grows stronger. New code is held to the same standard as existing code, and regressions are caught before they reach production. ## What's next? Once you're comfortable with the basics: - Read our [book](https://book.getrecon.xyz/) for advanced invariant testing patterns - Join our [Discord](/discord) community to discuss strategies with other fuzzing engineers - Check out our [bootcamp](/bootcamp) videos for in-depth tutorials Cloud fuzzing with Recon Pro has helped teams find critical bugs, prevent $20M+ in potential losses, and ship with confidence. [Get started today](/dashboard). --- ### The Anatomy of a Critical DeFi Bug: Insolvency Through Incorrect Accounting **Author:** alex | **Published:** 2025-04-14 | **Reading time:** 10 min **Tags:** smart-contract-security, invariant-testing **URL:** https://getrecon.xyz/blog/anatomy-critical-defi-bug-insolvency-accounting # The anatomy of a critical DeFi bug: insolvency through incorrect accounting Accounting bugs are the silent killers of DeFi. They don't announce themselves with dramatic exploits or flash loan attacks. Instead, they slowly bleed a protocol dry, one transaction at a time, until the vault is insolvent and the last users to withdraw are left holding nothing. In this post, we'll dissect a real class of critical vulnerability: share accounting errors that lead to protocol insolvency. This is the exact pattern we found during our [Corn engagement](/case-studies/corn), and it remains one of the most common critical findings across DeFi audits. ## The setup: ERC-4626 vault accounting Most DeFi vaults follow the ERC-4626 tokenized vault standard. Users deposit an underlying asset and receive shares in return. The fundamental relationship is: ```solidity shares = deposit * totalSupply / totalAssets assets = shares * totalAssets / totalSupply ``` This seems simple enough. But the devil is in the [rounding](/blog/rounding-errors-defi-small-bugs-big-exploits). ## The buggy pattern Consider this share minting logic: ```solidity contract BuggyVault { uint256 public totalAssets; uint256 public totalShares; mapping(address => uint256) public shareBalance; function deposit(uint256 assets) external { // BUG: Rounding UP shares minted to the depositor uint256 shares = (assets * totalShares + totalAssets - 1) / totalAssets; totalAssets += assets; totalShares += shares; shareBalance[msg.sender] += shares; // transfer assets in... } function withdraw(uint256 shares) external { // Correctly rounds DOWN assets returned to the withdrawer uint256 assets = (shares * totalAssets) / totalShares; totalShares -= shares; totalAssets -= assets; shareBalance[msg.sender] -= shares; // transfer assets out... } } ``` Do you see the bug? The `deposit` function rounds *up* the number of shares minted. This means every depositor receives slightly more shares than they mathematically deserve. Meanwhile, `withdraw` rounds down correctly, but it's too late: the shares have already been over-allocated. ## The attack path Here's how insolvency develops step by step: 1. Alice deposits 1000 USDC when the vault is empty. She gets 1000 shares. So far, so good. 2. Yield accrues, bringing `totalAssets` to 1100 with 1000 shares outstanding. Each share is worth 1.1 USDC. 3. Bob deposits 100 USDC. He should get `100 * 1000 / 1100 = 90.909...` shares. With the rounding-up bug, he gets 91 shares. 4. Bob immediately withdraws his 91 shares: `91 * 1100 / 1091 = 91.75` USDC. He deposited 100 and got back ~91.75, so he lost money on this particular trade due to rounding against him on withdrawal. But that isn't where the exploit lives. The real damage shows up at scale: 5. An attacker repeats small deposits thousands of times. Each deposit rounds up by 1 share. After 1000 deposits of 1 USDC each, the attacker might hold 1000 extra shares that aren't backed by real assets. 6. The attacker withdraws everything. The vault now owes legitimate depositors more than it holds. ## The fix: always favor the protocol The correct pattern is simple: **round against the user on every operation.** - Deposits: round shares down (user gets fewer shares) - Withdrawals: round assets down (user gets fewer assets) - Minting: round assets up (user must deposit more) - Redeeming: round shares up (user must burn more) ```solidity contract FixedVault { function deposit(uint256 assets) external { // FIXED: Round DOWN shares minted (favor the protocol) uint256 shares = (assets * totalShares) / totalAssets; totalAssets += assets; totalShares += shares; shareBalance[msg.sender] += shares; } function withdraw(uint256 shares) external { // Round DOWN assets returned (favor the protocol) uint256 assets = (shares * totalAssets) / totalShares; totalShares -= shares; totalAssets -= assets; shareBalance[msg.sender] -= shares; } } ``` In practice, production vaults should use OpenZeppelin's `Math.mulDiv` with explicit rounding direction: ```solidity uint256 shares = Math.mulDiv(assets, totalShares, totalAssets, Math.Rounding.Floor); ``` ## Why unit tests miss this A typical unit test for deposits might look like: ```solidity function test_deposit() public { vault.deposit(1000e18); assertEq(vault.balanceOf(address(this)), 1000e18); } ``` This test passes perfectly. The rounding error is 0 when shares and assets are 1:1. It only manifests after yield accrues and the exchange rate deviates from 1:1, after many operations compound the error, and after specific sequences of deposits and withdrawals. No developer is going to write a unit test that simulates 5000 sequential deposits of 1 wei after a yield event. But a fuzzer will. ## Why this bug class is so common Share-to-asset accounting bugs are the single most common critical finding in DeFi [code review](/services/smart-contract-audit) engagements. We see them in vaults, lending pools, staking contracts, and reward distributors. The pattern repeats across codebases because the underlying math looks deceptively simple. The root cause is always the same: the conversion between shares and assets must round consistently, and the rounding direction must favor the protocol. On deposit or mint, round down the shares the user receives. On withdraw or redeem, round down the assets the user gets back. Every deviation from this rule creates a leak. ERC-4626 codified the correct rounding behavior explicitly. The standard's reference implementation calls out `Math.Rounding.Floor` and `Math.Rounding.Ceil` at every conversion point. But many protocols implement their own vault logic outside of ERC-4626 (custom staking pools, reward accumulators, liquidity management layers) and they get the rounding wrong because they don't think of themselves as "vault contracts" subject to these same rules. The bug is invisible in unit tests because it only manifests after a specific sequence of state transitions: deposit, price change (from rewards or direct donations to the vault), deposit again, withdraw. Each step works correctly in isolation. The rounding error at any single step is at most 1 wei. No individual assertion will flag it. But an attacker doesn't need to exploit a single rounding error. They automate thousands of operations in a single transaction using a loop or a contract that calls `deposit` and `withdraw` repeatedly. Each cycle extracts a fraction of a wei more than it should. At scale, the vault bleeds out. The attacker walks away with a profit proportional to the number of iterations, and the remaining depositors share the loss. ## The invariant that catches it The property that reliably catches this entire class of bugs is surprisingly simple: ```solidity function invariant_solvency() public returns (bool) { uint256 totalClaimable = 0; for (uint256 i = 0; i < actors.length; i++) { totalClaimable += vault.convertToAssets(vault.balanceOf(actors[i])); } return vault.totalAssets() >= totalClaimable; } ``` This property says: "The vault must always hold enough assets to pay out every shareholder." If share minting is too generous, eventually `totalClaimable` exceeds `totalAssets`, and the invariant breaks. During our [Corn engagement](/case-studies/corn), this exact class of property identified the critical accounting bug within hours of starting the fuzzing campaign. The fix was applied, verified with continued fuzzing, and the protocol launched safely. ## Detecting accounting bugs with invariant testing Beyond the solvency check above, there are more granular invariants that catch accounting bugs earlier and with clearer diagnostics. The simplest invariant is the one we already showed: `totalAssets() >= convertToAssets(totalSupply())` — the vault must never owe more than it holds. But you can go further. Track each user's cumulative deposits and ensure no user can withdraw more than they deposited plus their proportional share of any rewards that accrued. This catches share dilution attacks where one depositor's gain comes at another depositor's expense, not just attacks that drain the vault below zero. The fuzzer needs handler functions that simulate realistic scenarios. Write target functions for deposits of varying sizes (from 1 wei to the maximum balance), reward distributions that change the exchange rate at different intervals, and withdrawals of both partial and full positions. Include a handler that donates tokens directly to the vault. This simulates the reward accrual that shifts the share-to-asset ratio and triggers rounding edge cases. Run with multiple actors, at least three. Share dilution issues only appear with concurrent depositors because the rounding error from one user's deposit shifts the exchange rate that the next user's deposit is priced against. A single-actor test can't observe this. Duration matters. Some accounting bugs only manifest after 20 to 40 operations in a specific sequence. Short fuzzing campaigns with a sequence length of 10 will miss them entirely. Set your sequence length to at least 50 and run a minimum of 100,000 test iterations. For production assurance, run millions. ## Key takeaways 1. Rounding direction is a security decision, not a math detail. Always round in favor of the protocol. 2. Single-operation rounding errors compound across thousands of transactions into exploitable insolvency. 3. Solvency invariants are the most important property you can write for any vault or lending protocol. 4. [Invariant testing](/blog/why-invariant-testing-matters-defi-security) finds these bugs systematically where manual review and unit tests consistently miss them. If your protocol manages user funds and you haven't validated your share accounting with invariant testing, you're flying blind. [Request an audit with Recon](/request-audit) and let us prove your accounting is correct — or find the bug before an attacker does. --- ### Property Design Patterns for DeFi Lending Protocols **Author:** nican0r | **Published:** 2025-05-19 | **Reading time:** 12 min **Tags:** invariant-testing, property-based-testing, fuzzing **URL:** https://getrecon.xyz/blog/property-design-patterns-defi-lending # Property design patterns for DeFi lending protocols Writing invariant properties is an art. Write them too broadly and they never fail. Write them too narrowly and they miss real bugs. Here are six battle-tested property design patterns for DeFi lending protocols, the ones I keep coming back to across every lending engagement. Each pattern includes the rationale, a Solidity implementation compatible with the [Chimera framework](/learn/chimera-framework), and notes on common pitfalls. ## Pattern 1: the solvency invariant What it checks: the protocol always holds enough assets to cover all obligations. This is the single most important property for any lending protocol. If this breaks, the protocol is insolvent — see our [anatomy of a critical DeFi bug](/blog/anatomy-critical-defi-bug-insolvency-accounting) for a real-world example of how insolvency through accounting errors plays out. ```solidity function invariant_solvency() public returns (bool) { uint256 totalDeposited = lendingPool.totalDepositAssets(); uint256 totalBorrowed = lendingPool.totalBorrowAssets(); uint256 protocolBalance = underlying.balanceOf(address(lendingPool)); // Cash on hand + outstanding borrows must cover all deposits return protocolBalance + totalBorrowed >= totalDeposited; } ``` Pitfall: make sure to account for accrued interest on both sides. If interest accrues to deposits but hasn't been added to `totalBorrowed`, this can produce false positives. ## Pattern 2: utilization rate bounds What it checks: the utilization rate stays within protocol-defined bounds at all times. ```solidity function invariant_utilizationBounds() public returns (bool) { uint256 totalDeposited = lendingPool.totalDepositAssets(); if (totalDeposited == 0) return true; uint256 totalBorrowed = lendingPool.totalBorrowAssets(); uint256 utilization = (totalBorrowed * 1e18) / totalDeposited; // Utilization cannot exceed 100% // (borrowing more than deposited should be impossible) return utilization <= 1e18; } ``` Why it matters: if utilization exceeds 100%, the protocol has somehow allowed borrowing more than what was deposited. This would indicate a critical accounting flaw. Extended version: some protocols have target utilization ranges enforced by interest rate curves. You can tighten this property to verify the interest rate model enforces those bounds correctly. ## Pattern 3: interest rate monotonicity What it checks: the borrow rate increases (or stays constant) as utilization increases. ```solidity function property_interestRateMonotonicity( uint256 utilA, uint256 utilB ) public returns (bool) { utilA = clamp(utilA, 0, 1e18); utilB = clamp(utilB, 0, 1e18); if (utilA >= utilB) return true; uint256 rateA = interestModel.getBorrowRate(utilA); uint256 rateB = interestModel.getBorrowRate(utilB); // Higher utilization must produce higher (or equal) borrow rate return rateB >= rateA; } ``` Why it matters: a non-monotonic interest rate curve can create arbitrage opportunities. If rates decrease at high utilization, borrowers are incentivized to borrow more precisely when liquidity is scarce. This drains the pool. ## Pattern 4: liquidation health factor What it checks: every position that exists hasn't silently become liquidatable without the protocol noticing. ```solidity function invariant_noUnderwaterPositions() public returns (bool) { for (uint256 i = 0; i < actors.length; i++) { address user = actors[i]; uint256 borrowed = lendingPool.borrowBalance(user); if (borrowed == 0) continue; uint256 collateralValue = lendingPool.getCollateralValue(user); uint256 minCollateral = (borrowed * lendingPool.liquidationThreshold()) / 1e18; // If a position is underwater, it MUST be liquidatable if (collateralValue < minCollateral) { // Verify liquidation would succeed try lendingPool.liquidate(user, borrowed / 2) { // Liquidation succeeded — this is correct behavior } catch { // Underwater position that cannot be liquidated = critical bug return false; } } } return true; } ``` Why it matters: a position that's underwater but can't be liquidated is a ticking time bomb. Bad debt accumulates and eventually leads to insolvency. This property ensures the liquidation mechanism actually works when needed. ## Pattern 5: share price monotonicity What it checks: the exchange rate between deposit shares and underlying assets never decreases (outside of legitimate loss events). ```solidity // Store the previous share price in a ghost variable uint256 internal previousSharePrice; function invariant_sharePriceMonotonicity() public returns (bool) { uint256 currentSharePrice = lendingPool.totalDepositAssets() * 1e18 / lendingPool.totalDepositShares(); // Share price should only increase (from interest accrual) bool result = currentSharePrice >= previousSharePrice; previousSharePrice = currentSharePrice; return result; } ``` Why it matters: if the share price ever decreases without an explicit loss event (like a liquidation with bad debt), it means value is leaking from depositors. Common causes include incorrect interest accrual, [rounding in the wrong direction](/blog/rounding-errors-defi-small-bugs-big-exploits), or reward distribution bugs. Pitfall: some protocols intentionally allow share price to decrease during socialized loss events. Account for this in your property by tracking whether a loss event occurred. ## Pattern 6: collateral accounting consistency What it checks: the sum of all individual collateral balances equals the total collateral tracked by the protocol. ```solidity function invariant_collateralAccounting() public returns (bool) { uint256 sumOfBalances = 0; for (uint256 i = 0; i < actors.length; i++) { sumOfBalances += lendingPool.collateralBalance(actors[i]); } // Sum of individual balances must equal protocol total return sumOfBalances == lendingPool.totalCollateral(); } ``` Why it matters: discrepancies between individual and aggregate accounting are the root cause of many DeFi exploits. If the sum of individual balances exceeds the total, the protocol will run out of collateral. If it's less, funds are locked. ## Structuring properties in Chimera When using the Chimera framework, organize your properties in a dedicated `Properties.sol` contract that inherits from your `BeforeAfter` helper: ```solidity // Properties.sol import {BeforeAfter} from "./BeforeAfter.sol"; contract Properties is BeforeAfter { function invariant_solvency() public returns (bool) { /* ... */ } function invariant_utilizationBounds() public returns (bool) { /* ... */ } // ... more properties } ``` The `BeforeAfter` pattern lets you capture state snapshots before and after each call, making it easy to write transition properties like share price monotonicity. For a full guide on Chimera's architecture, see [Invariant Testing with Chimera](/learn/invariant-testing). ## Combining properties for maximum coverage These six patterns cover the core safety properties of any lending protocol. In practice, you'll want to combine them and add protocol-specific properties. The key insight is that **simple properties catch the most bugs**. Start with solvency and accounting consistency before moving to more exotic properties. In a typical lending engagement, we end up with 15-30 properties, and these six patterns form the foundation every time. The rest target protocol-specific logic: governance mechanisms, oracle integrations, fee calculations, and cross-market interactions. Ready to build a full [invariant test suite](/learn/invariant-testing) for your lending protocol? [Request an audit with Recon](/request-audit) and get battle-tested properties tailored to your specific architecture. --- ### Echidna vs Medusa: A Practical Comparison for Security Researchers **Author:** antonio | **Published:** 2025-06-23 | **Reading time:** 11 min **Tags:** echidna, medusa, fuzzing, stateful-fuzzing **URL:** https://getrecon.xyz/blog/echidna-vs-medusa-practical-comparison # Echidna vs Medusa: a practical comparison for security researchers Echidna and Medusa are the two leading smart contract fuzzers in the security ecosystem. Both are powerful, both find real bugs, and both are supported by the [Chimera framework](/learn/chimera-framework). But they have meaningfully different strengths. I've run thousands of campaigns with both at this point, so here's what I've learned about when to reach for each one. For a feature-by-feature comparison table, see our [dedicated comparison page](/compare/echidna-vs-medusa). ## Architecture: single-threaded vs parallel The most fundamental difference is execution architecture. Echidna is written in Haskell and runs a single-threaded fuzzing loop. It maintains one execution state and explores transaction sequences sequentially. Each test sequence builds on previous coverage discoveries, making its exploration highly directed. Medusa is written in Go and designed for parallelism from the ground up. It spawns multiple worker goroutines, each independently exploring the state space. Workers share coverage information through a synchronized corpus. In practice, Medusa typically achieves 3-5x higher throughput on multi-core machines. On an 8-core machine: | Metric | Echidna | Medusa | |--------|---------|--------| | Executions/sec (8 cores) | ~2,000 | ~8,000 | | Time to 80% coverage | ~45 min | ~15 min | | Sequence shrinking | Excellent | Good | However, raw throughput isn't everything. Echidna's single-threaded approach sometimes finds bugs that Medusa's parallel exploration misses, because Echidna's sequence building is more methodical — a key advantage in [stateful fuzzing where transaction ordering matters](/blog/stateful-fuzzing-explained-sequence-matters). ## Configuration Echidna uses a YAML configuration file: ```yaml # echidna.yaml testMode: assertion testLimit: 500000 seqLen: 100 deployer: "0x10000" sender: ["0x20000", "0x30000"] corpusDir: "corpus-echidna" cryticArgs: ["--compile-force-framework", "foundry"] ``` Medusa uses a JSON configuration file: ```json { "fuzzing": { "testLimit": 500000, "callSequenceLength": 100, "corpusDirectory": "corpus-medusa", "workers": 8, "deployer": "0x10000", "senderAddresses": ["0x20000", "0x30000"], "testing": { "testAllContracts": false, "assertionTesting": { "enabled": true }, "propertyTesting": { "enabled": true } } } } ``` Medusa's configuration is more verbose but also more explicit. You have fine-grained control over which testing modes are enabled, worker counts, and compilation settings. ## Corpus management Both fuzzers maintain a corpus of interesting transaction sequences that achieved new coverage. However, they handle it differently. Echidna's corpus is stored as a directory of individual test cases. Each file contains a sequence of transactions in a binary format. Echidna's corpus replay is robust: you can stop a campaign and resume later, and it'll pick up exactly where it left off. Medusa's corpus is also directory-based, but uses a JSON format that's human-readable. This makes it easier to inspect what the fuzzer has discovered. Medusa also supports corpus mutation, taking existing sequences and modifying them to find new coverage. A key practical difference: Echidna's corpus tends to produce smaller, more minimal reproducer sequences. Echidna invests significant effort in sequence shrinking — reducing a 50-transaction sequence to the 5 transactions that actually matter. Medusa's shrinking is improving but currently produces longer sequences. ## When to use Echidna Choose Echidna when: - You need minimal reproducers. Echidna's shrinking produces the cleanest output of any smart contract fuzzer. When it finds a bug, the reproducer is easy to understand. - Your properties are complex. Echidna's directed exploration handles complex state transitions well. - You're running on a single core. On single-threaded hardware, Echidna's execution engine is highly optimized. - You want mature tooling. Echidna has been battle-tested since 2019 and has the most stable behavior. ## When to use Medusa Choose Medusa when: - You have multi-core hardware. Medusa's parallel architecture scales linearly with cores. - You want fast initial coverage. Medusa reaches high coverage faster due to parallelism. - You want readable corpus files. Medusa's JSON corpus is easier to inspect and debug. - You need assertion testing with detailed traces. Medusa provides detailed execution traces when assertions fail. ## Why not both? The Chimera approach We don't pick sides — we use both. The [Chimera framework](/learn/chimera-framework) makes this practical by letting you write your properties once and run them with either fuzzer (and Foundry, too). If you're new to this approach, our [beginner's guide to Chimera](/blog/zero-to-fuzzing-beginners-guide-chimera) walks through the full setup. ```solidity // This test works with Echidna, Medusa, AND Foundry contract CryticTester is TargetFunctions, CryticAsserts { constructor() { setup(); } } ``` Our standard workflow: 1. Develop properties with Foundry for fast iteration (`forge test`) 2. Run Medusa first for quick coverage and initial bug finding 3. Run Echidna second for deeper exploration and better shrinking 4. Run both in Recon Pro for maximum coverage with cloud resources This approach uses each tool's strengths. Medusa's parallelism quickly covers the broad state space, while Echidna's directed exploration digs deeper into subtle corner cases. ## Practical tips For Echidna: - Set `seqLen` based on your protocol's complexity. Simple vaults need 20-50; complex protocols with governance need 100+. - Use `corpusDir` to persist progress between runs. - The `shrinkLimit` parameter controls how much effort Echidna puts into minimizing failing sequences. Higher values produce cleaner reproducers. For Medusa: - Set `workers` to your core count minus one (leave a core for the OS). - Start with a lower `callSequenceLength` (50) and increase if you aren't finding bugs. - Use `targetContracts` to focus fuzzing on your core contracts. For both: - Always use multiple sender addresses to test access control. - Set the deployer address to match your Chimera setup. - Run longer campaigns (500k+ tests) for production security assurance. ## Conclusion Echidna and Medusa are complementary tools, not competitors. Echidna brings methodical exploration and minimal reproducers. Medusa brings speed and parallelism. Using both through Chimera gives you the most complete fuzzing coverage possible. If you want expert help setting up fuzzing for your protocol, or want to run intensive campaigns on cloud infrastructure, [request an audit with Recon](/request-audit). We'll build a custom [invariant test suite](/learn/invariant-testing) and run it with every tool in our arsenal. --- ### 5 Smart Contract Vulnerabilities That Unit Tests Will Never Catch **Author:** alex | **Published:** 2025-07-28 | **Reading time:** 10 min **Tags:** smart-contract-security, invariant-testing, fuzzing **URL:** https://getrecon.xyz/blog/smart-contract-vulnerabilities-unit-tests-miss # 5 smart contract vulnerabilities that unit tests will never catch Unit tests are necessary but not sufficient. They verify that individual functions behave correctly under specific, developer-chosen scenarios. But the most devastating DeFi exploits don't come from individual functions misbehaving — they come from unexpected interactions between functions, accumulation of tiny errors, and state conditions no developer imagined. Here are five categories of vulnerabilities that unit tests are structurally incapable of catching, and how [invariant testing](/learn/invariant-testing) finds each one. ## 1. Cross-function reentrancy The vulnerability: a contract makes an external call in function A, and the callback re-enters through function B, which reads stale state that function A hasn't yet updated. ```solidity contract Vulnerable { mapping(address => uint256) public balances; mapping(address => uint256) public rewards; function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount); // State not yet updated when external call happens (bool ok,) = msg.sender.call{value: amount}(""); require(ok); balances[msg.sender] -= amount; // Updated AFTER the call } function claimReward() external { // Reads balances[msg.sender] which hasn't been decremented yet uint256 reward = balances[msg.sender] * rewardRate / 1e18; rewards[msg.sender] += reward; } } ``` Why unit tests miss it: a unit test for `withdraw` tests withdraw in isolation. A unit test for `claimReward` tests claiming in isolation. Neither test calls `claimReward` from within a `withdraw` callback. The developer would need to anticipate this exact cross-function interaction. How invariant testing catches it: the fuzzer generates random sequences of function calls. Eventually, it creates a callback contract that calls `claimReward` during a `withdraw`, and the solvency invariant breaks. For more on reentrancy patterns, see our [reentrancy guide](/learn/reentrancy). ## 2. Rounding accumulation over many transactions The vulnerability: a single rounding error of 1 wei is harmless. But after thousands of operations, these errors accumulate into a material discrepancy. ```solidity // Each deposit rounds up shares by at most 1 wei function deposit(uint256 assets) external { uint256 shares = (assets * totalShares + totalAssets - 1) / totalAssets; // After 10,000 deposits: up to 10,000 wei of phantom shares } ``` Why unit tests miss it: unit tests typically test one or two operations. No developer writes a test that performs 10,000 sequential deposits and then checks cumulative accounting. The error per operation is so small it wouldn't trigger any reasonable assertion threshold. How invariant testing catches it: the fuzzer runs thousands of operations as part of its normal exploration. A solvency invariant ("total assets >= sum of all claims") eventually fails as the accumulated rounding error grows large enough. See our coverage of this topic at [integer overflow and rounding](/learn/integer-overflow). ## 3. State-dependent access control bypass The vulnerability: access control is correct in normal states but can be bypassed by manipulating the contract into an unexpected state first. ```solidity contract Governor { bool public paused; address public guardian; function setPaused(bool _paused) external { require(msg.sender == guardian); paused = _paused; } function executeProposal(uint256 id) external { require(!paused, "Paused"); // Execute the proposal... } function emergencyExecute(uint256 id) external { require(paused, "Must be paused"); require(msg.sender == guardian); // Execute without normal checks... } // Bug: guardian can be changed through a proposal function setGuardian(address _guardian) external { require(msg.sender == address(this)); // Only via proposal guardian = _guardian; } } ``` Why unit tests miss it: each function works correctly when tested individually. The bypass requires a specific sequence: execute a proposal that changes the guardian, then the new guardian can pause, then use `emergencyExecute` to bypass governance. Unit tests don't explore multi-step state manipulation chains. How invariant testing catches it: define a property: "Only approved proposals can be executed." The fuzzer will eventually find the sequence that manipulates the guardian, pauses, and bypasses governance. ## 4. Oracle manipulation across multiple blocks The vulnerability: TWAP oracles can be manipulated by performing large swaps before and after the observation window. Why unit tests miss it: unit tests run in a single block context. They don't simulate price changes across multiple blocks or model how an attacker might manipulate a liquidity pool over time. Even tests that mock oracle prices only test one price at a time. How invariant testing catches it: stateful fuzzers can warp time and block numbers between calls. With proper target functions that include oracle manipulation actions, the fuzzer discovers sequences where a user manipulates the price, takes a favorable action, and profits at the protocol's expense. A useful property pattern: ```solidity function invariant_oracleBoundedDeviation() public returns (bool) { uint256 oraclePrice = pool.getOraclePrice(); uint256 spotPrice = pool.getSpotPrice(); uint256 deviation = oraclePrice > spotPrice ? oraclePrice - spotPrice : spotPrice - oraclePrice; // Oracle and spot should not deviate more than 10% return deviation * 100 / spotPrice <= 10; } ``` ## 5. Dust amount accounting drift The vulnerability: when very small amounts (dust) are deposited or transferred, rounding causes tracking variables to drift from actual balances over time. ```solidity contract StakingPool { uint256 public totalStaked; function stake(uint256 amount) external { token.transferFrom(msg.sender, address(this), amount); // Fees or rebasing can cause actual received to differ totalStaked += amount; // Tracks intended, not actual } } ``` Why unit tests miss it: with normal-sized amounts, the drift is negligible. Unit tests use round numbers (1e18, 100e6) that don't trigger dust issues. The drift only becomes significant after hundreds of small operations with fee-on-transfer or rebasing tokens. How invariant testing catches it: the fuzzer generates all kinds of amounts, including very small ones. Over hundreds of operations, the cumulative drift between `totalStaked` and the actual token balance becomes significant: ```solidity function invariant_accountingSync() public returns (bool) { uint256 actual = token.balanceOf(address(stakingPool)); uint256 tracked = stakingPool.totalStaked(); return actual >= tracked; } ``` ## The common thread These five vulnerability categories share a common trait: they only manifest through **[sequences of operations](/blog/stateful-fuzzing-explained-sequence-matters)** that no human would think to test. This is exactly the domain where fuzzers excel. By generating millions of random transaction sequences and checking fundamental properties after each step, invariant testing systematically discovers the bugs that are invisible to unit testing. This isn't theoretical. These are the exact bug categories we keep finding in real engagements, across protocols managing hundreds of millions in TVL. ## Why these bugs escape unit testing Unit tests check a developer's mental model of the system. If the developer doesn't imagine a scenario, it doesn't get tested. This isn't a criticism of developers — it's a structural limitation of the methodology. Unit tests encode known expectations. The five bug categories above all share a common trait: they emerge from interactions between operations, not from individual operations behaving incorrectly. The state space of a DeFi protocol is combinatorial. Take a protocol with N callable functions, M users, and K possible input amounts. Each test sequence of length L creates N^L * M * K possible execution paths. For a modest protocol with 10 functions, 3 users, 100 meaningful amounts, and sequences of length 20, the state space exceeds 10^20 possibilities. Unit tests cover maybe 100 of those scenarios. A fuzzer covers millions per campaign. The math is clear: manual scenario selection can't meaningfully sample a space this large. The developer who wrote the code is the worst person to test it adversarially. They built the system with a specific set of assumptions. Their tests validate those assumptions. The bugs live in the gaps between assumptions, the interactions they didn't design for and therefore didn't test. ## What invariant testing catches instead Instead of testing specific scenarios, [invariant testing](/blog/why-invariant-testing-matters-defi-security) defines what must always be true and lets the fuzzer find counterexamples. You don't need to imagine the attack. You only need to define the property that the attack would violate. For each of the five vulnerability categories above, there is a corresponding invariant: 1. Cross-function reentrancy. Solvency invariant: total assets held by the contract always cover total obligations, regardless of call ordering. 2. Rounding accumulation. Accounting consistency invariant: the sum of all individual claims never exceeds the total tracked balance. 3. State-dependent access control. Authorization invariant: privileged state transitions only succeed when initiated by expected callers through expected paths. 4. Oracle manipulation. Value extraction invariant: no actor can profit by manipulating external inputs and then taking a favorable action within the same sequence. 5. Dust accounting drift. Global state consistency invariant: internal accounting variables remain synchronized with actual token balances after any sequence of operations. Writing five good invariants provides more coverage than 500 unit tests because [property-based testing](/learn/property-based-testing) checks each invariant across millions of randomly generated sequences. A single solvency invariant tested over 1,000,000 sequences exercises more state transitions than an entire unit test suite. The invariant doesn't care how the violation happens. It only cares that it happened. The fuzzer's job is to find the how. If you want to find these bugs before attackers do, [request an audit with Recon](/request-audit). We'll build a complete [invariant test suite](/learn/invariant-testing) that covers these categories and more, tailored to your specific protocol. --- ### How to Set Up Continuous Security Testing with CI/CD and Fuzzing **Author:** kn0t | **Published:** 2025-09-01 | **Reading time:** 8 min **Tags:** fuzzing, chimera-framework, coverage-guided-fuzzing **URL:** https://getrecon.xyz/blog/continuous-security-testing-cicd-fuzzing # How to set up continuous security testing with CI/CD and fuzzing Security audits are point-in-time assessments. Your code changes daily. If you only fuzz before a launch, every subsequent commit goes untested against your security properties. Continuous security testing fixes this — run fuzzing on every pull request, catch regressions before they reach production. I'll walk through setting up continuous fuzzing with [Chimera](/learn/chimera-framework) in a GitHub Actions pipeline. ## Why continuous fuzzing? Consider this timeline: 1. Week 1: Protocol is audited. All properties pass. 2. Week 3: A developer adds a new fee mechanism. 3. Week 5: Another developer refactors the withdrawal logic. 4. Week 7: A subtle interaction between the fee mechanism and the refactored withdrawal breaks the solvency invariant. Without continuous fuzzing, this bug ships to production. With continuous fuzzing, the failing invariant is caught in the Week 5 or Week 7 pull request, before it ever reaches mainnet. ## Prerequisites - A Foundry project with Chimera-based invariant tests - A GitHub repository - Familiarity with GitHub Actions ## Step 1: Foundry-based smoke tests The fastest feedback loop uses Foundry's built-in fuzzing. This runs in seconds and catches obvious regressions. Create `.github/workflows/fuzz-smoke.yml`: ```yaml name: Fuzz Smoke Test on: [pull_request] jobs: fuzz-smoke: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: submodules: recursive - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 - name: Run invariant tests run: | forge build forge test --match-contract CryticTester --fuzz-runs 10000 env: FOUNDRY_INVARIANT_DEPTH: 50 FOUNDRY_INVARIANT_RUNS: 100 ``` This gives you a quick pass/fail signal on every PR in under 2 minutes. ## Step 2: Medusa deep fuzzing For more thorough testing, run Medusa on PRs targeting your main branch. This takes longer but finds deeper bugs — see our [Echidna vs Medusa comparison](/blog/echidna-vs-medusa-practical-comparison) for guidance on which fuzzer to use where. Create `.github/workflows/fuzz-deep.yml`: ```yaml name: Deep Fuzz Campaign on: pull_request: branches: [main] jobs: fuzz-medusa: runs-on: ubuntu-latest timeout-minutes: 60 steps: - uses: actions/checkout@v4 with: submodules: recursive - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 - name: Build contracts run: forge build - name: Install Medusa run: | curl -L -o medusa https://github.com/crytic/medusa/releases/latest/download/medusa-linux-x64 chmod +x medusa sudo mv medusa /usr/local/bin/ - name: Run Medusa run: | medusa fuzz --target-contracts CryticTester --test-limit 500000 --timeout 2400 - name: Upload corpus if: always() uses: actions/upload-artifact@v4 with: name: medusa-corpus path: corpus-medusa/ ``` ## Step 3: short vs long campaigns Not every PR needs a 60-minute fuzzing campaign. Structure your CI with tiers: | Trigger | Duration | Tool | Test Limit | |---------|----------|------|------------| | Every PR | 2 min | Foundry | 10,000 runs | | PRs to main | 30 min | Medusa | 500,000 tests | | Release branch | 4 hours | Echidna + Medusa | 5,000,000 tests | | Pre-deployment | 24-48 hours | Recon Pro Cloud | 50,000,000+ tests | The short campaigns catch obvious regressions fast. The long campaigns catch subtle bugs that require deep state exploration. ## Step 4: Recon Pro cloud integration For maximum coverage, use Recon Pro's cloud infrastructure. Our [getting started with Recon Pro](/blog/getting-started-recon-pro-cloud-fuzzing) guide covers the full setup, but the key advantage is running multi-hour campaigns with high parallelism without tying up your CI runners. You can trigger Recon Pro campaigns from your CI pipeline using the Recon API: ```yaml - name: Trigger Recon Pro Campaign run: | curl -X POST https://api.getrecon.xyz/v1/campaigns \ -H "Authorization: Bearer ${{ secrets.RECON_API_KEY }}" \ -H "Content-Type: application/json" \ -d '{ "repository": "${{ github.repository }}", "branch": "${{ github.head_ref }}", "commit": "${{ github.sha }}", "fuzzers": ["echidna", "medusa"], "duration": 14400 }' ``` Results are available in your Recon Pro dashboard and can be linked back to the PR. ## Step 5: setting failure thresholds Your CI should fail if any invariant is violated. Both Medusa and Echidna exit with a non-zero status code when a property fails, so GitHub Actions will automatically mark the job as failed. For coverage-based thresholds, you can add a post-processing step: ```yaml - name: Check coverage threshold run: | COVERAGE=$(cat medusa-coverage.json | jq '.totalCoverage') if (( $(echo "$COVERAGE < 0.80" | bc -l) )); then echo "Coverage $COVERAGE is below 80% threshold" exit 1 fi ``` This ensures that new code is actually being tested by the fuzzer, not just passing because the fuzzer never reached it. ## Step 6: corpus persistence Fuzzing is cumulative. Each run discovers new coverage that future runs can build on. Persist your corpus between CI runs: ```yaml - name: Restore corpus cache uses: actions/cache@v4 with: path: | corpus-medusa/ corpus-echidna/ key: fuzz-corpus-${{ github.base_ref }}-${{ hashFiles('src/**/*.sol') }} restore-keys: | fuzz-corpus-${{ github.base_ref }}- fuzz-corpus-main- - name: Run Medusa run: medusa fuzz --target-contracts CryticTester --test-limit 500000 ``` Over time, your corpus grows and the fuzzer starts each run with knowledge from previous runs, making it progressively more effective. ## Common pitfalls 1. Not building before fuzzing. Both Echidna and Medusa need compiled artifacts. Always run `forge build` first. 2. Ignoring timeouts. Set reasonable timeouts to prevent runaway CI costs. 3. Forgetting submodules. Chimera and other dependencies are often Git submodules. Use `submodules: recursive` in checkout. 4. Not caching the corpus. Without corpus persistence, each CI run starts from scratch, wasting coverage already achieved. ## Summary Continuous fuzzing is the difference between "we were audited once" and "every change is verified against our security properties." With GitHub Actions and Chimera, you can have: - 2-minute smoke tests on every PR - 30-minute deep campaigns on merges to main - Multi-hour cloud campaigns for releases The setup takes an afternoon. The protection it provides is ongoing. Need help setting up continuous fuzzing for your protocol? [Request an audit with Recon](/request-audit) — we won't just find bugs but help you build the infrastructure to prevent future ones. --- ### Understanding Rounding Errors in DeFi: How Small Bugs Lead to Big Exploits **Author:** nican0r | **Published:** 2025-10-06 | **Reading time:** 10 min **Tags:** smart-contract-security, integer-overflow, invariant-testing **URL:** https://getrecon.xyz/blog/rounding-errors-defi-small-bugs-big-exploits # Understanding rounding errors in DeFi: how small bugs lead to big exploits Solidity has no floating-point numbers. Every calculation operates on unsigned integers, and every division truncates. This means that every division in your protocol is a potential source of rounding error, and in DeFi, rounding errors aren't just imprecisions — they're attack vectors. This post goes deep on rounding in Solidity: how it works, why it matters, and how the "favor the protocol" pattern prevents exploits. This is the same class of vulnerability we found during the [Centrifuge engagement](/case-studies/centrifuge). ## Fixed-point arithmetic in Solidity Since Solidity lacks decimals, DeFi protocols use fixed-point arithmetic. A value of `1e18` represents 1.0, and `5e17` represents 0.5. This is known as WAD notation (18 decimal places). ```solidity uint256 constant WAD = 1e18; // Multiply two WAD values function wadMul(uint256 a, uint256 b) pure returns (uint256) { return (a * b) / WAD; } // Divide two WAD values function wadDiv(uint256 a, uint256 b) pure returns (uint256) { return (a * WAD) / b; } ``` The problem is in the division. `(a * b) / WAD` always rounds down (toward zero) because Solidity truncates integer division. This means: ```solidity wadMul(1e18 + 1, 1e18 - 1) // = 999999999999999999 (not 1e18) ``` One wei lost. Seems harmless. It isn't. ## The mulDiv pattern OpenZeppelin's `Math.mulDiv` is the standard for safe multiplication-then-division with explicit rounding control: ```solidity import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; // Round DOWN (floor) uint256 shares = Math.mulDiv(assets, totalShares, totalAssets, Math.Rounding.Floor); // Round UP (ceil) uint256 assets = Math.mulDiv(shares, totalAssets, totalShares, Math.Rounding.Ceil); ``` The critical advantage of `mulDiv` is that it handles the intermediate multiplication using a 512-bit product internally, preventing overflow that would occur with naive `(a * b) / c` when `a * b` exceeds `type(uint256).max`. ## When to round up vs round down The golden rule: **always round in the direction that favors the protocol, never the user.** | Operation | Who benefits from more? | Round direction | Reasoning | |-----------|------------------------|-----------------|-----------| | Deposit (assets to shares) | User wants more shares | Round DOWN shares | Protocol keeps the rounding surplus | | Withdraw (shares to assets) | User wants more assets | Round DOWN assets | Protocol keeps the rounding surplus | | Mint (shares to assets) | User wants fewer assets | Round UP assets | User pays a tiny premium | | Redeem (assets to shares) | User wants fewer shares | Round UP shares | User burns a tiny extra | ```solidity function convertToShares(uint256 assets) public view returns (uint256) { // Depositing: round DOWN shares (user gets slightly fewer) return Math.mulDiv(assets, totalSupply(), totalAssets(), Math.Rounding.Floor); } function convertToAssets(uint256 shares) public view returns (uint256) { // Withdrawing: round DOWN assets (user gets slightly less) return Math.mulDiv(shares, totalAssets(), totalSupply(), Math.Rounding.Floor); } function previewMint(uint256 shares) public view returns (uint256) { // Minting: round UP assets required (user pays slightly more) return Math.mulDiv(shares, totalAssets(), totalSupply(), Math.Rounding.Ceil); } ``` ## How 1 wei errors compound Consider a vault with 1,000,000 USDC (1e12 units with 6 decimals) and 1,000,000 shares. An attacker performs 10,000 deposit-withdraw cycles: ```solidity // Each cycle: // 1. Deposit 1 USDC → get 1 share (correct, no rounding needed) // After yield: totalAssets = 1,000,001, totalShares = 1,000,001 // 2. Deposit 1 USDC → shares = 1 * 1,000,001 / 1,000,001 = 1 (no error here) // But with a different ratio: // totalAssets = 1,000,000, totalShares = 999,999 (after a withdrawal) // Deposit 1 USDC → shares = 1 * 999,999 / 1,000,000 = 0 (!!!) // User deposited 1 USDC and got 0 shares! ``` The reverse is also dangerous. If shares round up: ```solidity // totalAssets = 1,000,000, totalShares = 999,999 // Deposit 1 USDC → shares = (1 * 999,999 + 1,000,000 - 1) / 1,000,000 = 1 // User got 1 share for 1 USDC, but each share is worth 1.000001 USDC // Instant profit: 0.000001 USDC // After 10,000 cycles: 0.01 USDC profit // After 10,000,000 cycles: 10 USDC profit (automated by a bot) ``` With larger imbalances between shares and assets (which occur naturally after yield accrual), the per-operation error grows, and the compounding accelerates. For a full breakdown of how these errors lead to vault insolvency, see [The anatomy of a critical DeFi bug](/blog/anatomy-critical-defi-bug-insolvency-accounting). ## The Centrifuge case study During our [Centrifuge engagement](/case-studies/centrifuge), we discovered exactly this class of rounding vulnerability in an [ERC-7540 vault implementation](/blog/how-to-fuzz-erc7540-async-vaults). The protocol had deposit caps to limit exposure, but rounding errors in the share-to-asset conversion meant that the effective cap could be slightly exceeded with each operation. Our fuzzer ran thousands of deposit operations and found that the cumulative rounding error allowed depositors to bypass the cap by a material amount. The fix involved switching to consistent use of `Math.mulDiv` with the correct rounding direction for each operation. ## The first depositor attack A specific rounding exploit targets empty or near-empty vaults: ```solidity // 1. Attacker deposits 1 wei → gets 1 share // 2. Attacker directly transfers 1,000,000 USDC to the vault // (not through deposit — just a raw ERC-20 transfer) // 3. Now: totalAssets = 1,000,000e6 + 1, totalShares = 1 // 4. Victim deposits 999,999 USDC // shares = 999,999e6 * 1 / (1,000,000e6 + 1) = 0 shares! // 5. Attacker withdraws their 1 share → gets everything ``` The standard defense is to mint dead shares on first deposit: ```solidity function _initialDeposit(uint256 assets) internal { uint256 deadShares = 1000; _mint(address(0xdead), deadShares); // Unrecoverable shares totalAssets += assets; } ``` This makes the donation attack economically infeasible because the attacker would need to donate proportionally more to affect the share price. ## Invariant properties for rounding correctness Two key properties catch rounding bugs: ```solidity // 1. Protocol solvency: can never owe more than it has function invariant_solvency() public returns (bool) { uint256 totalClaimable = vault.convertToAssets(vault.totalSupply()); return vault.totalAssets() >= totalClaimable; } // 2. No zero-share deposits: depositing assets must always yield shares function invariant_noZeroShareDeposit(uint256 amount) public returns (bool) { amount = clamp(amount, vault.minDeposit(), vault.maxDeposit()); uint256 shares = vault.convertToShares(amount); return shares > 0; } ``` ## Key takeaways 1. Every division is a rounding decision. Make it intentional with explicit rounding directions. 2. Use `Math.mulDiv` with `Math.Rounding.Floor` or `Math.Rounding.Ceil`. Never naive `a * b / c`. 3. Always favor the protocol. Round against the user on every operation. 4. Protect empty vaults against first-depositor attacks with dead shares. 5. Fuzz extensively with small, irregular amounts to catch compounding errors. Rounding bugs are subtle, systematic, and pervasive. They're also reliably catchable with the right invariant properties and sufficient fuzzing. For a deep dive into a real-world rounding bug found through fuzzing, read [The Bug That Was Missed](https://getrecon.substack.com/p/the-bug-that-was-missed) on our Substack. [Request an audit with Recon](/request-audit) to get expert analysis of your protocol's rounding behavior and a full fuzzing campaign to verify it. --- ### What to Expect from a Smart Contract Security Audit in 2025 **Author:** alex | **Published:** 2025-11-10 | **Reading time:** 8 min **Tags:** smart-contract-audit, smart-contract-security **URL:** https://getrecon.xyz/blog/what-to-expect-smart-contract-audit-2025 # What to expect from a smart contract security audit in 2025 The smart contract audit industry has matured significantly. A 2025 audit looks nothing like the audits of 2020. Manual [code review](/services/smart-contract-audit) alone isn't sufficient anymore, and the best firms now deliver invariant test suites as a core part of their engagement. I want to walk through what a modern [smart contract audit](/learn/smart-contract-audit) actually looks like, and what you should demand from your auditor. ## Phase 1: pre-audit preparation A good audit starts before the auditors touch your code. The pre-audit phase sets the foundation: What the protocol team should provide: - Final, deployed-ready codebase with a code freeze date - Architecture documentation and flow diagrams - Known risks and design trade-offs - Previous audit reports (if any) - Test suite with coverage reports - Deployment scripts and configuration What the audit firm should provide: - Scope confirmation and complexity assessment - Timeline and milestone commitments - Team composition (who will review your code) - Communication plan (Slack, Telegram, or other channel for questions) Red flag: if an auditor doesn't ask for architecture documentation or a code freeze, they aren't running a rigorous process. ## Phase 2: automated analysis Before manual review begins, automated tools scan the codebase: Static analysis with tools like Slither identifies common vulnerability patterns: reentrancy, unchecked return values, storage collisions, and more. A good auditor runs static analysis first and triages the results. Most findings are false positives, but some reveal real issues. Compilation and test verification: the auditor builds your project, runs your existing tests, and ensures everything compiles cleanly. Failing tests or compilation errors are resolved early. This phase typically takes 1-2 days and produces a baseline understanding of code quality. ## Phase 3: manual review This is the core of the audit and where senior expertise matters most. Manual review covers: Architecture review: does the overall design make sense? Are trust assumptions documented and reasonable? Are there systemic risks in how contracts interact? Line-by-line review: every function is reviewed for correctness. Auditors look for: - Access control issues - State mutation ordering (reentrancy) - Arithmetic errors (overflow, underflow, rounding) - Oracle manipulation vectors - Flash loan attack surfaces - Governance manipulation - Denial of service vectors Cross-contract interaction analysis: how do contracts call each other? What happens if an external call fails? Can a callback re-enter through a different function? A thorough manual review of a medium-sized protocol (2000-5000 SLOC) takes 2-3 auditors working for 1-2 weeks. ## Phase 4: invariant testing and fuzzing This is what distinguishes a 2025 audit from a 2020 audit. The audit team writes custom invariant properties and runs fuzzing campaigns to find bugs that manual review misses. Property design: the auditors identify the protocol's core invariants (solvency, accounting consistency, access control boundaries, rate monotonicity) and express them as executable Solidity properties. Fuzzing campaigns: properties are tested with Echidna, Medusa, and Foundry's built-in fuzzer. Campaigns run for hours or days, exploring millions of transaction sequences. What you should expect as deliverables: - A complete Chimera-based test suite with all properties - The fuzzing corpus (saved state of explored sequences) - Instructions for running the suite yourself - Results report showing coverage achieved and any violations found This [invariant test suite](/learn/invariant-testing) is arguably the most useful deliverable of a modern audit. Unlike a PDF report that goes stale the moment code changes, an invariant test suite continues to protect your protocol as you evolve it. ## Phase 5: report drafting The audit report documents all findings with: - Severity classification: Critical, High, Medium, Low, Informational - Description: what the vulnerability is - Impact: what an attacker could achieve - Proof of concept: reproduction steps or transaction sequences - Recommendation: how to fix it A good report also includes: - Architecture observations and design recommendations - Gas optimization suggestions - Code quality notes - Summary of fuzzing coverage and properties tested ## Phase 6: remediation and re-audit After the protocol team fixes the reported issues, the audit firm reviews the fixes: - Verify each fix addresses the reported vulnerability - Check for introduced regressions - Re-run fuzzing campaigns against the fixed code - Confirm no new invariant violations This phase is critical. A surprising number of "fixes" introduce new bugs. The re-audit ensures fixes are correct. ## What distinguishes a good audit firm Not all audits are equal. Here's what separates a good one: Invariant testing as a core deliverable: if your auditor only delivers a PDF report, you're getting a 2020-era audit. Demand executable test suites. Proven track record: ask for case studies with specific bugs found. Vague claims of "millions secured" are meaningless without specifics. Senior reviewers: your code should be reviewed by experienced security researchers, not junior developers following checklists. Ask who'll be on your engagement. Continuous relationship: the best firms offer ongoing support — retainer agreements, CI integration, and re-audits for major changes. Transparent process: you should know exactly what's happening at each phase, with regular check-ins and preliminary findings shared early. ## What an audit isn't An audit isn't a guarantee of security. It's a significant risk reduction exercise. No audit catches every bug — the goal is to find as many as possible through layered techniques (manual review + automated analysis + fuzzing). An audit also isn't a one-time event. Change your code after the audit, and the audit no longer fully applies. This is exactly why continuous fuzzing and CI integration matter. ## The cost of not auditing DeFi protocols lost over $1.7 billion to exploits in 2024. The average cost of a critical exploit far exceeds the cost of a full audit, as we break down in [the true cost of not auditing](/blog/true-cost-not-auditing-prevented-losses). Our engagements have directly prevented over $20 million in potential losses — you can see [specific vulnerabilities we caught with fuzzing](/blog/how-we-found-real-vulnerabilities-with-fuzzing) — a return on investment that speaks for itself. Ready to get a modern audit with invariant testing included? [Request an audit with Recon](/request-audit) and get the security assurance your protocol deserves. --- ### Stateful Fuzzing Explained: Why Sequence Matters in Smart Contract Testing **Author:** antonio | **Published:** 2025-12-15 | **Reading time:** 9 min **Tags:** stateful-fuzzing, fuzzing, property-based-testing **URL:** https://getrecon.xyz/blog/stateful-fuzzing-explained-sequence-matters # Stateful fuzzing explained: why sequence matters in smart contract testing In [smart contract security](/learn/smart-contract-security), the order of operations is everything. A function that's perfectly safe when called in isolation can become catastrophically vulnerable when called after a specific sequence of other operations. This is why [stateful fuzzing](/learn/stateful-fuzzing) is essential, and why stateless fuzzing alone isn't sufficient. ## Stateless vs stateful fuzzing Stateless fuzzing tests a single function with random inputs. Each test is independent; the contract is redeployed fresh for every test case. This is what Foundry's `fuzz` modifier does by default: ```solidity // Stateless: each call starts from the same initial state function testFuzz_deposit(uint256 amount) public { amount = bound(amount, 1, type(uint128).max); vault.deposit(amount); assertGe(vault.balanceOf(address(this)), 0); } ``` This is useful for testing input validation and simple edge cases, but it will never find bugs that require a specific state to be set up first. Stateful fuzzing generates sequences of function calls, maintaining state between them. Each transaction builds on the state left by the previous one: ``` Call 1: alice.deposit(5000) Call 2: bob.deposit(3000) Call 3: admin.setRewardRate(1e16) Call 4: warp(1 days) Call 5: alice.withdraw(5000) Call 6: bob.claimRewards() // <-- Bug manifests here! ``` The bug at call 6 only exists because of the specific state created by calls 1-5. No stateless test would ever reach this state. ## A concrete example: the lending protocol bug Consider a lending protocol where the bug only manifests through a specific four-step sequence: ```solidity // Step 1: User deposits collateral lendingPool.depositCollateral{value: 10 ether}(); // Step 2: User borrows against collateral lendingPool.borrow(8000e6); // 8000 USDC // Step 3: Price changes (oracle update) priceOracle.setPrice(address(weth), 1000e8); // ETH drops from 2000 to 1000 // Step 4: Liquidation should work, but... lendingPool.liquidate(user, 4000e6); // Reverts unexpectedly! ``` The liquidation fails because the protocol calculates the liquidation bonus based on the pre-price-change collateral value, causing an integer underflow. This bug is invisible to: - Unit tests: they test liquidation with hardcoded states, never exploring the specific deposit amount + borrow ratio + price change combination that triggers the underflow. - Stateless fuzz tests: they test `liquidate` with random inputs but start from a clean state where no one has borrowed. - Static analysis: the arithmetic is correct in isolation, and the underflow only occurs with specific runtime values. A stateful fuzzer discovers this naturally. It calls `depositCollateral`, `borrow`, `setPrice`, and `liquidate` in sequence, and the invariant "all liquidatable positions can be successfully liquidated" fails. ## How Echidna and Medusa maintain state Both [Echidna and Medusa](/compare/echidna-vs-medusa) implement stateful fuzzing, but with different strategies. Echidna maintains a single EVM state and builds sequences incrementally: 1. Start with a fresh deployment 2. Generate a random function call 3. Execute it and record coverage 4. If new coverage was achieved, save this sequence prefix 5. Build longer sequences by extending saved prefixes 6. After each call, check all properties Echidna is methodical. It builds sequences one call at a time, preferring to extend sequences that achieved new coverage. This makes it excellent at finding bugs that require very specific sequences. Medusa uses parallel workers that independently explore the state space: 1. Spawn N workers, each with its own EVM instance 2. Each worker generates and executes random call sequences 3. Workers share coverage information through a synchronized corpus 4. When one worker discovers a new coverage path, others can build on it 5. Properties are checked after each call in every worker Medusa's parallelism means it explores more total sequences per second, but each individual worker's exploration is less directed than Echidna's. ## Corpus management and shrinking When a fuzzer finds a property violation, the failing sequence might be 100+ transactions long. Most of those transactions are irrelevant — the bug only needs 4-5 specific calls. **Corpus shrinking** reduces the sequence to its minimal reproduction. How shrinking works: 1. Start with the full failing sequence (say, 100 calls) 2. Try removing calls one at a time 3. If the property still fails without a call, that call wasn't needed 4. Repeat until no more calls can be removed 5. Result: the minimal sequence (say, 5 calls) that triggers the bug ``` Before shrinking (100 calls): deposit, transfer, approve, deposit, setFee, withdraw, deposit, borrow, deposit, deposit, setPrice, withdraw, ... [90 more] ..., liquidate After shrinking (5 calls): deposit(10 ether) borrow(8000e6) setPrice(1000e8) warp(1 days) liquidate(user, 4000e6) ``` Echidna's shrinking is currently more aggressive and produces cleaner minimal sequences. Medusa's shrinking is improving with each release. ## Writing properties for stateful fuzzing Effective stateful fuzzing requires two things: target functions that the fuzzer can call, and properties that are checked after each call. Target functions should cover all user-facing operations: ```solidity contract TargetFunctions is Setup { function handler_deposit(uint256 amount) external { amount = clamp(amount, 1, token.balanceOf(currentActor)); vm.prank(currentActor); vault.deposit(amount, currentActor); } function handler_withdraw(uint256 shares) external { shares = clamp(shares, 1, vault.balanceOf(currentActor)); vm.prank(currentActor); vault.withdraw(shares, currentActor, currentActor); } function handler_warpTime(uint256 seconds_) external { seconds_ = clamp(seconds_, 1, 365 days); vm.warp(block.timestamp + seconds_); } } ``` Properties should check fundamental invariants that must hold regardless of what sequence of operations occurred: ```solidity contract Properties is TargetFunctions { function invariant_solvency() public view returns (bool) { return token.balanceOf(address(vault)) >= vault.totalAssets(); } function invariant_sharePriceNonDecreasing() public returns (bool) { uint256 price = vault.convertToAssets(1e18); bool ok = price >= lastSharePrice; lastSharePrice = price; return ok; } } ``` ## When sequence length matters The `seqLen` (Echidna) or `callSequenceLength` (Medusa) parameter controls how many function calls are in each test sequence. This is a critical tuning parameter: - Short sequences (10-20): good for finding bugs that manifest quickly. Fast iteration. - Medium sequences (50-100): good for most DeFi protocols. Covers multi-step user journeys. - Long sequences (200+): needed for protocols with complex state machines (governance, vesting, multi-phase auctions). If your bug requires a deposit, a reward distribution, a price change, and a withdrawal, you need at least 4 calls. In practice, the fuzzer needs many more calls to "stumble into" the right combination, so set the sequence length well above the minimum. ## Key takeaways 1. Stateful fuzzing finds bugs that stateless fuzzing can't. Most DeFi vulnerabilities require specific state conditions. 2. Transaction sequence matters. The same function call can be safe or exploitable depending on prior state. 3. Corpus shrinking is essential. It reduces 100-call sequences to minimal reproducers you can actually debug. 4. Use both Echidna and Medusa through Chimera for maximum coverage. 5. Tune sequence length to match your protocol's complexity. Stateful fuzzing is the most effective automated technique for finding smart contract vulnerabilities. If you aren't using it, you're missing an entire category of bugs. [Request an audit with Recon](/request-audit) to get expert stateful fuzzing coverage for your protocol. --- ### The True Cost of Not Auditing: Lessons from $20M+ in Prevented Losses **Author:** alex | **Published:** 2026-01-19 | **Reading time:** 7 min **Tags:** smart-contract-audit, smart-contract-security **URL:** https://getrecon.xyz/blog/true-cost-not-auditing-prevented-losses # The true cost of not auditing: lessons from $20M+ in prevented losses In 2024, DeFi protocols lost over $1.7 billion to smart contract exploits. Many of these were protocols that had been audited, but not all audits are equal. Meanwhile, the protocols that invested in thorough security, including invariant testing, avoided becoming a headline. Our engagements have directly prevented over $20 million in potential losses. I want to lay out the business case for a full [smart contract audit](/learn/smart-contract-audit), backed by real numbers from real engagements. ## The economics of exploitation The math is straightforward: Cost of a full audit: $50,000 - $200,000 depending on scope and duration. Cost of an exploit: direct fund loss (often $1M-$100M+), reputation damage and user exodus, legal liability and regulatory scrutiny, protocol shutdown or fork, token price collapse. The Euler Finance exploit of March 2023 resulted in $197 million stolen. The Mango Markets exploit cost $114 million. Even "small" exploits regularly drain $1-10 million. Against these numbers, the cost of an audit is a rounding error. ## Real prevented losses: Recon case studies These aren't hypothetical scenarios. Each represents a real vulnerability found during a Recon engagement that would have been exploitable in production. ## Corn: critical insolvency bug Potential loss: protocol TVL at risk (estimated $5M+) During our [Corn engagement](/case-studies/corn), invariant testing identified a critical accounting bug in the vault system. The share minting logic rounded in the wrong direction, allowing depositors to receive slightly more shares than they deserved. Over time, this would have led to protocol insolvency, with the vault owing more to shareholders than it actually held. The bug was found within hours of starting the fuzzing campaign. A manual review had previously examined the same code without catching it. The cost of the engagement was a fraction of the TVL at risk. The bug would have been exploitable by anyone with a bot and patience. ## Badger DAO: remBADGER accounting desync Potential loss: remBADGER vault deposits at risk Our work with [Badger DAO](/case-studies/badger) uncovered that specific sequences of deposits and reward distributions could desynchronize the share accounting in the remBADGER vault. This is exactly the kind of stateful bug that only manifests through specific transaction sequences. It's impossible to find with unit tests and difficult to catch in manual review. The invariant "total shares * price per share <= total assets" caught the desynchronization after the fuzzer generated a sequence of roughly 40 transactions. ## Centrifuge: ERC-7540 rounding cap bypass Potential loss: deposit cap bypass enabling unlimited exposure Centrifuge's [ERC-7540](/blog/how-to-fuzz-erc7540-async-vaults) vault implementation had deposit caps to limit protocol exposure. Our fuzzer discovered that small rounding errors in share-to-asset conversions could be exploited to bypass these caps. Each individual operation was off by at most 1 wei, but an attacker automating thousands of small deposits could exceed the cap by a material amount. ## Credit Coop: cap bypass through logic error Potential loss: protocol risk limits rendered ineffective In a [Credit Coop](/case-studies/credit-coop) engagement, we found that a logic error in the cap enforcement mechanism allowed users to bypass protocol-imposed limits entirely under certain conditions. The cap was checked against the wrong variable, meaning that specific sequences of operations could circumvent the safety mechanism designed to limit protocol exposure. ## The hidden costs of exploits Direct fund loss is only the most visible cost. Exploited protocols face a cascade of secondary costs: User trust destruction: after an exploit, users withdraw en masse. Even if funds are recovered, the protocol rarely regains its previous TVL. Users who weren't directly affected still leave because trust has been broken. Token price impact: governance tokens of exploited protocols typically lose 30-70% of their value immediately, with limited recovery. For protocols with significant token treasuries, this represents millions in lost value. Legal and regulatory exposure: post-exploit, protocols face potential lawsuits from affected users, regulatory scrutiny, and in some jurisdictions, personal liability for team members. Development disruption: the team pivots from building to incident response, forensics, and remediation. This can set development back by months. Insurance costs: protocols that have been exploited face significantly higher insurance premiums, if they can get coverage at all. ## The audit ROI calculation Let's make the math explicit with a representative scenario: | Factor | Value | |--------|-------| | Protocol TVL | $10,000,000 | | Audit cost | $150,000 | | Probability of critical bug existing (no audit) | ~30% | | Expected loss if exploited | $5,000,000 (50% of TVL) | | Expected loss without audit | $1,500,000 (30% * $5M) | | Expected loss with audit | $75,000 (5% residual risk * $5M * 30%) | | Net savings from audit | $1,425,000 | | ROI | 850% | These are conservative estimates. The 30% probability of a critical bug is based on industry data — studies have found that approximately 1 in 3 DeFi protocols contain at least one critical vulnerability before auditing. ## Why traditional audits aren't enough A PDF report with findings is the minimum viable audit. But we've seen protocols get exploited even after being audited, sometimes by multiple firms. Why? 1. Manual review has limits: humans miss things, especially subtle stateful bugs that require 5+ transactions to trigger. 2. Reports become stale: code changes after the audit. New commits can reintroduce fixed bugs or create new ones. 3. No ongoing protection: a one-time audit provides no safety net for future development. This is why we deliver invariant test suites as a core audit deliverable. Built on [property-based testing](/learn/property-based-testing), the test suite keeps protecting the protocol after the engagement ends, catching regressions in CI and validating that new code doesn't break existing security properties. ## What you should demand When evaluating audit firms, ask for: 1. Invariant testing: do they write and deliver executable test suites? 2. Case studies with specifics: can they show real bugs they've found, with technical detail? 3. Fuzzing infrastructure: do they run long-duration fuzzing campaigns? 4. Ongoing support: can they help you integrate security testing into CI/CD? 5. Transparent pricing: is the cost clear and justified by the scope of work? ## Conclusion The cost of not auditing isn't zero. It's the expected value of an exploit. For a typical DeFi protocol, that expected cost is orders of magnitude higher than the cost of a proper audit. Every protocol we've audited at Recon ships safer. The **$20M+ in prevented losses** across our engagements represents real funds that real users would have lost. That's the return on investment of security. Don't wait for an exploit to justify your security budget. [Request an audit with Recon](/request-audit) today and protect your protocol, your users, and your reputation. --- ### From Zero to Fuzzing: A Beginner's Guide to the Chimera Framework **Author:** kn0t | **Published:** 2026-02-09 | **Reading time:** 12 min **Tags:** chimera-framework, fuzzing, invariant-testing **URL:** https://getrecon.xyz/blog/zero-to-fuzzing-beginners-guide-chimera # From zero to fuzzing: a beginner's guide to the Chimera framework You've heard that fuzzing finds bugs that unit tests miss. You want to try it, but the tooling space is confusing. Echidna? Medusa? Foundry? Do you need to learn all three? No. With the [Chimera framework](/learn/chimera-framework), you write your tests once and run them with any fuzzer. This tutorial takes you from zero to a working [invariant test suite](/learn/invariant-testing). No prior fuzzing experience required. ## What is Chimera? Chimera is a Solidity testing framework developed by Recon that provides a unified interface for writing [invariant tests](/learn/invariant-testing). You write your properties and target functions once, and Chimera handles the compatibility layer so they work with: - Foundry (`forge test`), for fast local iteration - Echidna, for thorough [stateful fuzzing](/blog/stateful-fuzzing-explained-sequence-matters) - Medusa, for parallel, high-throughput fuzzing This means you never have to rewrite tests when switching tools or choose a fuzzer before you start writing properties. For a detailed look at how Echidna and Medusa differ, see our [practical comparison](/blog/echidna-vs-medusa-practical-comparison). ## Step 1: install dependencies First, make sure you have Foundry installed: ```solidity // Run in your terminal: // curl -L https://foundry.paradigm.xyz | bash // foundryup ``` Create a new project (or use an existing one): ```solidity // forge init my-protocol // cd my-protocol ``` Install Chimera as a dependency: ```solidity // forge install Recon-Fuzz/chimera ``` Add the remapping to your `foundry.toml`: ```solidity // In foundry.toml, add to your remappings: // @chimera/=lib/chimera/src/ ``` ## Step 2: understand the project structure Chimera projects follow a specific structure inside `test/recon/`: ``` test/recon/ Setup.sol — Deploy contracts and initialize state TargetFunctions.sol — Functions the fuzzer can call BeforeAfter.sol — State snapshots for transition properties Properties.sol — Invariant properties to check CryticTester.sol — Entry point for Echidna/Medusa ``` Each file has a specific role, and they inherit in a chain: ``` Setup → BeforeAfter → Properties → TargetFunctions → CryticTester ``` Let's build each one for a simple ERC-4626 vault. ## Step 3: write Setup.sol `Setup.sol` deploys all contracts and initializes the testing environment: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {BaseSetup} from "@chimera/BaseSetup.sol"; import {SimpleVault} from "src/SimpleVault.sol"; import {MockERC20} from "@chimera/MockERC20.sol"; contract Setup is BaseSetup { SimpleVault vault; MockERC20 token; function setup() internal override { // Deploy the underlying token token = new MockERC20("Test Token", "TT", 18); // Deploy the vault vault = new SimpleVault(address(token)); // Mint tokens to test actors token.mint(address(this), 1_000_000e18); token.approve(address(vault), type(uint256).max); } } ``` The key points: inherit from `BaseSetup`, override the `setup()` function, deploy everything your protocol needs. ## Step 4: write TargetFunctions.sol Target functions define what the fuzzer is allowed to do. Each function represents one action a user could take: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Properties} from "./Properties.sol"; contract TargetFunctions is Properties { function handler_deposit(uint256 amount) external { amount = bound(amount, 1, token.balanceOf(address(this))); vault.deposit(amount, address(this)); } function handler_withdraw(uint256 shares) external { uint256 maxShares = vault.balanceOf(address(this)); if (maxShares == 0) return; shares = bound(shares, 1, maxShares); vault.redeem(shares, address(this), address(this)); } function handler_donate(uint256 amount) external { amount = bound(amount, 1, token.balanceOf(address(this))); // Direct transfer to vault (simulates yield or donation attack) token.transfer(address(vault), amount); } } ``` Notice the `handler_` prefix — this is a convention that makes it easy to identify fuzzer-callable functions. The `bound()` call constrains random inputs to valid ranges. ## Step 5: write Properties.sol Properties are the invariants that must always hold. This is where you define what "correct" means: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {BeforeAfter} from "./BeforeAfter.sol"; contract Properties is BeforeAfter { // The vault must always be solvent: // actual token balance >= what all shareholders can claim function invariant_solvency() public view returns (bool) { uint256 totalClaim = vault.convertToAssets(vault.totalSupply()); uint256 actualBalance = token.balanceOf(address(vault)); return actualBalance >= totalClaim; } // No deposit should mint zero shares (would mean lost funds) function invariant_noFreeDeposits() public view returns (bool) { if (vault.totalSupply() == 0) return true; // A minimum deposit should always yield at least 1 share uint256 sharesForMinDeposit = vault.convertToShares(1); // This can legitimately be 0 for very small amounts; // adjust the minimum deposit based on your protocol return true; // Simplified for tutorial } } ``` The solvency invariant is your most important property. If it ever fails, the vault has a critical accounting bug. ## Step 6: write CryticTester.sol This is the entry point for Echidna and Medusa. It's minimal: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {TargetFunctions} from "./TargetFunctions.sol"; import {CryticAsserts} from "@chimera/CryticAsserts.sol"; contract CryticTester is TargetFunctions, CryticAsserts { constructor() { setup(); } } ``` The `CryticAsserts` mixin provides helper functions and ensures compatibility with Echidna and Medusa's testing interfaces. ## Step 7: run with Foundry The fastest way to iterate is with Foundry. Create a small test wrapper: ```solidity // test/recon/FoundryTest.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Test} from "forge-std/Test.sol"; import {TargetFunctions} from "./TargetFunctions.sol"; contract FoundryTest is Test, TargetFunctions { function setUp() public { setup(); } function invariant_solvency() public view { bool result = invariant_solvency(); assertTrue(result, "Solvency violated"); } } ``` Run it: ```solidity // forge test --match-contract FoundryTest -vvv ``` Foundry's invariant testing will call your `handler_` functions randomly and check the invariant after each call. ## Step 8: run with Echidna Install Echidna, then create a config file: ```yaml # echidna.yaml testMode: assertion testLimit: 100000 seqLen: 50 corpusDir: "corpus-echidna" cryticArgs: ["--compile-force-framework", "foundry"] ``` Run: ```solidity // echidna test/recon/CryticTester.sol --contract CryticTester --config echidna.yaml ``` ## Step 9: run with Medusa Install Medusa, then initialize the config: ```solidity // medusa init ``` Update `medusa.json` to point to your `CryticTester` contract, then run: ```solidity // medusa fuzz ``` ## What happens when a property fails When any fuzzer finds a violation, it reports: 1. The failing property, which invariant was broken 2. The transaction sequence, the exact calls that triggered the failure 3. The call data, the specific arguments used For example, Echidna might report: ``` invariant_solvency(): failed! Call sequence: handler_deposit(1000000000000000000) handler_donate(500000000000000000) handler_deposit(1) handler_withdraw(1000000000000000001) ``` This tells you exactly how to reproduce the bug. From here, you can write a unit test with this specific sequence and debug it step by step. ## Next steps Once you're comfortable with the basics: 1. Add more properties: accounting consistency, access control, rate monotonicity 2. Add more actors: use multiple addresses to test multi-user interactions 3. Increase sequence length: test longer, more complex transaction sequences 4. Run in the cloud: use Recon Pro for multi-hour campaigns with high parallelism For a deeper dive into property design, read our [invariant testing guide](/learn/invariant-testing). For advanced Chimera patterns, check the [Chimera framework documentation](/learn/chimera-framework). Fuzzing is a skill that compounds. The more properties you write and the more campaigns you run, the more intuition you build for where bugs hide. Start with solvency, iterate from there, and you'll be surprised what your fuzzer finds. Need expert help building your invariant test suite? [Request an audit with Recon](/request-audit) — we'll set up a complete Chimera-based testing framework tailored to your protocol and run it at scale. --- ### Towards the Scientific Audit **Author:** alex | **Published:** 2026-02-24 | **Reading time:** 15 min **Tags:** smart-contract-audit, smart-contract-security, invariant-testing, coverage-guided-fuzzing **URL:** https://getrecon.xyz/blog/towards-the-scientific-audit # Towards the scientific audit Two auditors review the same codebase. They follow different paths, focus on different functions, apply different heuristics, and produce different reports. One catches a critical rounding bug in the liquidation logic. The other catches a reentrancy vector in the withdrawal flow. Neither catches both. This is the state of smart contract auditing today. It's artisanal. The quality of an audit depends on who happens to review your code, what they happen to look at, and how much coffee they had that morning. The results aren't reproducible, the coverage isn't measurable, and the gaps are invisible — until an attacker finds them. I've been thinking about this problem for a long time. What would it take to make an audit *scientific*? Not in the vague sense of "more rigorous," but in the specific sense: reproducible, exhaustive, and falsifiable. I want to lay out the framework we've been building at Recon to get there. This isn't a purely theoretical exercise. We've applied this framework across dozens of engagements, from [Liquity's stability pool](/case-studies/liquity) to [Corn's vault system](/case-studies/corn), and the results speak for themselves. When we enumerated coverage classes on Corn, we identified a critical insolvency bug within hours that a previous manual audit had missed entirely. ## Standardizing scope The first step toward a scientific audit is a standardized scope. When we set up a fuzzing harness, we deploy the entire system under test. This isn't optional. The fuzzer needs a running system to interact with. But this requirement turns out to be a gift: it forces us to enumerate every contract, map every relation between them, and assign concrete values to every meaningful storage variable. This is the deployment problem, and solving it produces something invaluable: a complete, executable specification of the system's initial state. Every contract is listed. Every constructor argument is defined. Every proxy points to its implementation. Every role is assigned. Every oracle returns a price. Where external integrations exist (price feeds, lending pools, bridges), we deploy mock contracts. These mocks don't simulate the real integration faithfully. Instead, they return *symbolic* values: any price, any balance, any return code. This includes reentrant callbacks. By making the mock's behavior unconstrained, we test the protocol's resilience to the full range of possible external behaviors, not just the happy path. This standardized deployment is the foundation. Without it, nothing else works. With it, we've got a reproducible starting point that any auditor can reconstruct. This is more than a convenience. In a [2023 analysis by Trail of Bits](https://blog.trailofbits.com/2023/08/22/good-idea-bad-design-how-the-diamond-standard-falls-short/), they noted that many audit failures stem from incomplete understanding of the system's deployment configuration: constructors called with wrong parameters, proxies pointing to stale implementations, roles assigned to the wrong addresses. A standardized deployment eliminates this entire class of errors before the audit even begins. ## The integration problem External integrations are the biggest vulnerability surface in DeFi, and they're also the hardest to standardize. When a protocol integrates with Aave, Compound, Uniswap, or any other external system, the behavior of that external system becomes part of the protocol's security assumptions. And the space of possible integrations is effectively infinite. Call it the `*` problem. Our approach has two parts. First, mocks with symbolic return values cover the general case. If the protocol calls an external contract and uses the return value, our mock can return anything, including values that trigger edge cases the developer never considered. Second, for known dangerous integrations (fee-on-transfer tokens, rebasing tokens, tokens with callbacks), we've built simplified implementations that capture the specific gotcha without the full complexity. I'll be honest: this remains the biggest open challenge. We can't enumerate all possible smart contract integrations any more than we can enumerate all possible inputs. But in the absence of `*`, a standardized deployment lets us test the code as intended, and that already puts us far ahead of ad-hoc review. In our [Centrifuge engagement](/case-studies/centrifuge), the mocks we deployed for external pool interactions returned symbolic share prices. This immediately surfaced a rounding-based cap bypass that exploited the gap between the protocol's assumptions about external return values and what those integrations could actually return. The bug was invisible to manual review because reviewers implicitly assumed "normal" return values. ## Coverage classes Here's the core thesis. Once you have a valid, standardized scope, you can enumerate every possible path through the code. Not every possible *input* (that's infinite). Every possible *path*, meaning every unique combination of branches taken and not taken. We define three types of coverage classes: Non-reverting coverage classes: combinations of conditions that lead to a successful execution. These represent the states the protocol can actually reach. Each unique path through the code, each unique set of branches taken, is one coverage class. Assertion-breaking coverage classes: combinations of conditions that cause an assertion (an invariant property) to fail. These are bugs. Revert-at-line-X coverage classes: combinations of conditions that cause execution to revert at a specific line. These represent the protocol's defensive checks. Each revert point with each unique reason for reaching it is a separate class. Formally, each branching decision in the code is a binary variable. The cartesian product of all decision variables produces a large number of theoretical combinations, but most are infeasible (contradictory conditions), and many lead to the same code being executed. We deduplicate by focusing on *unique code covered* rather than the state of each individual condition. What matters isn't whether variable X was true or false, but which lines of code were actually reached and in what combination. Formal methods ([symbolic execution](/learn/symbolic-execution), abstract interpretation) can extract these coverage classes from source code. The result is a finite, enumerable list of every meaningfully distinct execution path through the system. To make this concrete: consider a simple function with two `if` statements. The first checks whether the caller has sufficient balance; the second checks whether the transfer amount exceeds a cap. This function has four theoretical coverage classes (both true, first true + second false, first false + second true, both false), but only three are meaningful. If the balance check fails, the cap check is never reached, so "both false" collapses into the "first false" class. In a real protocol with hundreds of branching conditions, this deduplication is what makes enumeration tractable. ## Semantic classes Coverage classes tell you which code paths exist. They don't tell you which code paths are *dangerous*. A line of code that performs integer division is on many paths, but the truncation risk is only relevant when the numerator isn't evenly divisible by the denominator. Coverage alone is insensitive to this. This is where semantic classes come in. We extend coverage classes with additional dimensions that capture bug-relevant behavior: Truncation classes: every division and every downcast creates a truncation boundary. The code behaves differently when `a / b` truncates versus when it divides evenly. Each truncation point splits its coverage class into two semantic classes. Overflow classes: every multiplication, addition, and shift operation has an overflow boundary. Pre-Solidity-0.8 this was silent; post-0.8 it reverts. Either way, the behavior near the boundary is distinct from behavior far from it. Reentrancy classes: every external call is a potential reentrancy point. The state before the call and the state after the call may be inconsistent if a callback modifies storage. Each external call splits its coverage class into "reentered" and "not reentered" semantic classes. These semantic dimensions multiply the number of classes, but they capture exactly the categories of bugs that auditors are trained to look for. They make the implicit explicit. In practice, semantic classes have been the source of some of our most impactful findings. The [Badger DAO](/case-studies/badger) remBADGER desynchronization bug was a truncation class issue. Repeated deposit/withdraw cycles accumulated rounding errors that eventually made the vault insolvent. A pure coverage analysis would have marked the deposit and withdraw paths as "covered." The semantic dimension of truncation is what distinguished the safe path from the dangerous one. ## What is an audit, then? With coverage classes and semantic classes enumerated, we can define an audit precisely: Unit level review: for each coverage class and each semantic class, a reviewer examines the code path and annotates whether the behavior is correct, potentially dangerous, or definitively buggy. This is exhaustive at the function level. Every path through every function is reviewed. Path coverage review: salient semantic classes from different functions are combined to construct multi-step edge cases. If function A has a truncation class and function B uses A's output in a multiplication, the combination may produce an overflow that neither class reveals in isolation. This is what [stateful fuzzing](/blog/stateful-fuzzing-explained-sequence-matters) approximates: exploring combinations of function calls to find multi-step vulnerabilities. The thesis is this: a reviewer who completes both levels, examining every coverage class, every semantic class, and every salient combination across functions, would find every bug that falls into a known bug class. The audit becomes exhaustive not by relying on human intuition, but by systematically covering a finite, enumerated space. ## How Recon Magic achieves this This isn't just theory. [Recon Magic](/blog/ai-smart-contract-audits) automates the mechanical parts of this framework. It standardizes scope by generating deployment harnesses. It enumerates coverage classes through a combination of [symbolic execution](/learn/symbolic-execution) and static analysis. It extracts semantic classes by identifying truncation points, overflow boundaries, and reentrancy surfaces in the source code. The human auditor's job shifts from "read the code and find bugs" to "review each enumerated class and confirm correctness." This is a fundamentally different task, one that's bounded, measurable, and reproducible. Two auditors working from the same enumeration will cover the same ground. The numbers illustrate the scale. On a typical 3,000 SLOC DeFi protocol, we enumerate between 500 and 2,000 coverage classes and 100 to 400 semantic classes. A manual reviewer working without this enumeration might cover 60-70% of these classes through intuition and experience. The remaining 30-40%, the classes that are reached only through unusual combinations of conditions, are exactly where the critical bugs hide. Recon Magic doesn't replace the human reviewer. It gives the reviewer a map of the territory instead of asking them to explore it blind. Every class is tagged, every path is documented, and the reviewer's job becomes confirmation rather than discovery. This is what makes the audit reproducible: a second reviewer, given the same enumeration, reviews the same classes and reaches the same conclusions. For more on the underlying techniques, see our guides on [smart contract auditing](/learn/smart-contract-audit), [smart contract security](/learn/smart-contract-security), [invariant testing](/learn/invariant-testing), and [coverage-guided fuzzing](/learn/coverage-guided-fuzzing). ## The road ahead We aren't done. The integration problem remains open. The enumeration of coverage classes can be computationally expensive for large codebases. And the combination space across functions grows combinatorially, so heuristics are needed to prioritize which cross-function combinations to examine. We're also exploring how this framework connects to formal verification. If coverage classes can be extracted symbolically, they can also be *verified* symbolically, proving that certain classes are unreachable or that certain semantic conditions never produce dangerous values. The combination of enumeration, fuzzing, and formal verification may eventually close the gap entirely. For now, we're focused on making the enumeration fast, accurate, and actionable. Every engagement teaches us something new about where the framework works well and where it needs refinement. But the direction is clear. Audits shouldn't depend on who reviews your code. They should depend on what your code does. Every path, every semantic boundary, every combination — enumerated, reviewed, and documented. This is the methodology behind every Recon engagement. For a look at how Recon Magic automates parts of this framework in practice, read [Introducing Recon Magic](https://getrecon.substack.com/p/introducing-recon-magic) on our Substack. If you want to see how coverage class enumeration and semantic analysis work on your codebase, [request an audit](/request-audit). We'll show you exactly what systematic security looks like. --- ### AI Smart Contract Audits: Beyond the Hype **Author:** alex | **Published:** 2026-03-10 | **Reading time:** 12 min **Tags:** smart-contract-audit, smart-contract-security, invariant-testing, coverage-guided-fuzzing **URL:** https://getrecon.xyz/blog/ai-smart-contract-audits # AI smart contract audits: beyond the hype Everyone is talking about AI audits. Most of what passes for "AI auditing" today is superficial: run an LLM over the source code, collect its observations, package them as findings. This isn't auditing. It's autocomplete with a security vocabulary. I want to describe what AI-powered auditing actually looks like when you build it on top of a rigorous foundation: [invariant testing](/learn/invariant-testing), [coverage-guided fuzzing](/learn/coverage-guided-fuzzing), and formal methods. This is what we've built at Recon, and it's fundamentally different from what the industry is selling. ## The problem with "AI auditing" The current wave of AI audit tools shares a common flaw: they treat security analysis as a text comprehension problem. Feed the code to a language model, ask it to find vulnerabilities, hope it catches something. This approach has three fatal limitations: No execution context. A language model reading Solidity doesn't know what the code *does*, it knows what the code *says*. It can't reason about state transitions across a sequence of transactions. It can't determine whether a rounding error accumulates over 10,000 operations. It can't simulate a flash loan attack path through three contracts. No completeness guarantee. There's no way to measure what an LLM missed. It might flag a reentrancy pattern on one function and overlook an identical pattern two functions down. The output is probabilistic, not systematic. No reproducibility. Run the same LLM on the same code twice and you may get different results. This makes it impossible to verify coverage, compare audit quality, or build confidence that all paths have been reviewed. These aren't minor limitations. They're the same problems that plague manual auditing, and the entire point of automation should be to *solve* them, not replicate them faster. ## What AI-powered auditing should look like At Recon, we start from a different premise. AI isn't the auditor. AI is the auditor's infrastructure. It generates the artifacts that make systematic review possible. Here's the pipeline: ## Automated property generation [Recon Magic](/blog/towards-the-scientific-audit) analyzes the target codebase and generates [invariant properties](/learn/invariant-testing) — executable Solidity assertions that express what the protocol must guarantee. Solvency conditions. Accounting consistency. Access control boundaries. Monotonicity constraints on rates and prices. This isn't pattern matching. The system analyzes the contract's storage layout, its function signatures, its external call graph, and its mathematical operations to produce properties that are *specific to the protocol*. A lending protocol gets properties about collateralization ratios and liquidation thresholds. A vault gets properties about share-to-asset conversion accuracy. An AMM gets properties about constant product invariance. The result is a Chimera-based [test suite](/learn/invariant-testing) (compatible with Echidna, Medusa, and Foundry) ready to run. ## Coverage gap analysis Properties alone aren't enough. A property suite might cover the protocol's happy path thoroughly while missing entire branches of execution. This is where the AI layer adds its most critical value. Our system identifies [coverage classes](/learn/coverage-classes) (every meaningfully distinct execution path through the code) and cross-references them against the generated properties. Paths that aren't exercised by any property are flagged as gaps. The AI then generates additional properties, handler functions, and test configurations specifically designed to reach those uncovered paths. This is informed by research on coverage-guided fuzzing. The foundational insight from Grieco et al.'s work on Echidna and Wüstholz and Christakis's Harvey is that coverage feedback transforms random testing into directed exploration. We apply the same principle to property generation itself: the AI doesn't generate properties blindly, it generates them to maximize the coverage of the resulting fuzzing campaign. ## Semantic enrichment Beyond path coverage, the AI enriches the test suite with [semantic classes](/blog/towards-the-scientific-audit), the bug-relevant dimensions that pure coverage misses: - Truncation boundaries at every division and downcast - Overflow boundaries at every multiplication and shift - Reentrancy surfaces at every external call - Oracle manipulation vectors at every price-dependent computation For each semantic boundary, the system generates targeted properties that distinguish the safe case from the dangerous case. A division that truncates safely at normal values might produce a loss-of-precision exploit at extreme values. The AI ensures both sides of every boundary are tested. ## Campaign execution and analysis The generated test suite runs as a full [stateful fuzzing](/learn/stateful-fuzzing) campaign. Millions of randomized transaction sequences, each checked against every property. Violations are captured with full call sequences for reproduction. But the AI's job doesn't end at campaign execution. It analyzes the corpus (the set of inputs the fuzzer explored) to identify patterns. Which functions are under-explored? Which state transitions are rare? Which property violations cluster around specific parameter ranges? This analysis feeds back into property refinement, creating a loop that converges toward complete coverage. ## What this achieves The combined system (automated property generation, coverage gap filling, semantic enrichment, and iterative campaign analysis) produces results that neither pure manual review nor naive AI can match: Measurable coverage. We can report exactly how many coverage classes and semantic classes were tested, and which ones remain open. This isn't a percentage bar. It's an enumerated list of every execution path through the protocol. Reproducible results. The test suite is deterministic. Any auditor can run it, verify the results, and extend it. Two runs on the same code produce the same coverage. Continuous protection. The generated test suite is a deliverable. It runs in CI. When the protocol changes, the properties catch regressions immediately. This is the opposite of a one-time PDF report that becomes stale the moment code changes. Human-AI collaboration. The AI generates the coverage map. Human auditors review the flagged edge cases, the semantic boundary conditions, and the cross-function interaction paths. The human focuses on judgment ("is this behavior intended?") while the AI handles enumeration: "here are all the behaviors." ## Why most "AI audits" fall short The fundamental error in the current AI audit space is treating the LLM as the end product. An LLM's opinion about code is, at best, a hint. It might be correct. It might be hallucinated. You can't tell the difference without execution. Our approach uses AI to generate *executable artifacts* (properties, test suites, coverage reports) that can be verified independently of the AI that produced them. If the AI generates a wrong property, the fuzzer will either never trigger it (dead code, filtered out) or trigger it on a false positive (caught during human review). The system is self-correcting in a way that pure LLM analysis isn't. This is the difference between "AI told us this is safe" and "AI generated a test suite that exercises 1,847 coverage classes across 23 contracts, and no property violations were found in 50 million test iterations." The first is an opinion. The second is evidence. ## The road ahead We're pushing this further. The integration of [symbolic execution](/learn/symbolic-execution) with AI-guided property generation is opening paths to proving certain properties *mathematically* rather than just testing them probabilistically. The combination of [formal verification](/learn/formal-verification), fuzzing, and AI-guided test generation may eventually provide something close to a completeness guarantee for known bug classes. For now, the practical result is already clear: AI-powered auditing, done correctly, finds more bugs, covers more code, and delivers more lasting protection than either manual review or naive automation alone. For benchmarks showing Recon Magic achieving 38x speedups on production codebases, read [Introducing Recon Magic](https://getrecon.substack.com/p/introducing-recon-magic) on our Substack. Want to see what AI-powered invariant testing finds on your protocol? [Request an audit](/request-audit) and we'll show you the coverage map. --- ### Do you need a smart contract audit? **Author:** alex | **Published:** 2025-01-27 | **Reading time:** 7 min **Tags:** smart-contract-audit, smart-contract-security, defi-security **URL:** https://getrecon.xyz/blog/do-you-need-smart-contract-audit # Do you need a smart contract audit? If your contract holds user funds, controls access to funds, or governs protocol parameters that affect funds, you need an audit. That's the short answer. The rest of this post helps you figure out what kind of audit fits your situation and when to get one. Not sure what a modern audit involves? Read [what to expect from a smart contract audit in 2025](/blog/what-to-expect-smart-contract-audit-2025) for the full breakdown. [Request an audit with Recon](/request-audit) if you already know you need one. ## The short answer Any contract that manages value on behalf of others needs independent security review. It's about the asymmetry between audit cost and exploit cost. A typical engagement runs $50K-$200K. A single critical vulnerability regularly drains $1M-$100M+. The math isn't close. Even contracts that don't hold funds directly can create risk if they govern access control, upgrade paths, or oracle integrations that downstream contracts depend on. ## When you definitely need an audit You should get an audit before deploying to mainnet if any of these apply: - Your protocol will hold user deposits. Vaults, lending pools, staking contracts, or anything where users send tokens they expect to get back. - You're handling more than $100K in TVL. The economics of exploitation scale with value locked. Past $100K, you're a viable target. - Your contracts are upgradeable. Proxy patterns and UUPS introduce storage collision risks and admin key attack surfaces that don't exist in immutable contracts. - You integrate with external protocols. Composability means your contract inherits the risk profile of every protocol it calls. Oracle dependencies and flash loan surfaces both need review. - You're launching a token. Token contracts with minting authority, transfer restrictions, or fee mechanisms are high-value targets. ## When you might not need one Not every contract needs a full audit: - Personal projects and learning exercises. If nobody else's funds are at risk, you don't need external review. - Testnet-only deployments. Contracts that will never touch mainnet don't carry financial risk. - Contracts with no fund handling and no access control. A pure view-only data contract or an immutable registry with no admin functions has a smaller attack surface. - Forks of well-audited code with zero modifications. If you're deploying an exact copy of audited code at the same compiler version, the existing audit applies. The moment you change a line, it doesn't. ## The decision checklist Run through this table. If you answer "yes" to any row, you should get professional security review. | Question | If yes | |----------|--------| | Does it hold or transfer user funds? | Audit required | | Does it have admin or owner roles? | Audit required | | Is it upgradeable (proxy, UUPS, beacon)? | Audit required | | Does it integrate with external contracts? | Audit required | | Does it have token minting or burning? | Audit required | | Will it govern more than $100K TVL? | Audit required | | Does it use assembly or low-level calls? | Audit required | | Is it a fork with modifications? | Audit the diff | Zero "yes" answers and you're probably safe without one. One or more and the question isn't whether to audit, it's what type of audit fits. ## What type of audit fits your situation Different situations call for different approaches: | Situation | Recommended approach | |-----------|---------------------| | New protocol, pre-mainnet launch | Full audit with [invariant testing](/learn/invariant-testing) | | Upgrade or migration to existing protocol | Diff audit focused on changed code | | Pre-launch with tight timeline | Invariant testing engagement + targeted manual review | | Live protocol adding features | Incremental audit of new code + regression [fuzzing](/learn/fuzzing) | | Ongoing development with regular deploys | Continuous fuzzing via CI + periodic review | A full [smart contract audit](/learn/smart-contract-audit) isn't always the right starting point. For teams early in development, an invariant testing engagement can surface critical bugs before the code is stable enough for a formal audit. ## Common mistakes teams make **Waiting until after launch.** Auditing post-deployment means any critical finding requires an emergency migration. Pre-launch fixes are cheap. Post-launch fixes are expensive and risky. **Auditing before code freeze.** If you're still changing the code, the audit findings may not apply to the final version. Auditors review a snapshot. Changes after that snapshot are unreviewed. **Choosing the cheapest option.** Audit quality varies enormously. A firm that delivers only a PDF report without executable tests is running a 2020 process. Demand [invariant test suites](/learn/invariant-testing) as a deliverable. **Not demanding test suites.** An audit report becomes stale the moment you push a new commit. An invariant test suite keeps protecting you through every change. **Auditing only once.** Every code change, upgrade, or new integration needs review. Continuous fuzzing in CI catches regressions between formal audits. ## Related - [Smart contract audit overview](/learn/smart-contract-audit) — what an audit covers and how it works - [What to expect from a smart contract audit in 2025](/blog/what-to-expect-smart-contract-audit-2025) — the six phases of a modern engagement - [The true cost of not auditing](/blog/true-cost-not-auditing-prevented-losses) — real data on prevented losses - [Invariant testing explained](/learn/invariant-testing) — why executable properties beat static reports - [Smart contract audit services](/services/smart-contract-audit) — scope and deliverables Ready to protect your protocol? [Request an audit with Recon](/request-audit) and we'll scope the right engagement for your situation. --- ### How much does a smart contract audit cost in 2025? **Author:** alex | **Published:** 2025-02-10 | **Reading time:** 8 min **Tags:** smart-contract-audit, smart-contract-security, defi-security **URL:** https://getrecon.xyz/blog/smart-contract-audit-cost-pricing-guide # How much does a smart contract audit cost in 2025? A smart contract audit costs between $15,000 and $120,000 for most DeFi protocols, depending on scope and complexity. That's the short answer. The real answer depends on what you're buying, and whether it'll actually protect your protocol. I'll break down the pricing tiers and what drives the numbers, plus how to get the most security value per dollar. If you want to understand what a modern audit looks like first, read [what to expect from an audit in 2025](/blog/what-to-expect-smart-contract-audit-2025). [Request a quote — we'll review your codebase and respond within 48 hours.](/request-audit) ## Price ranges by audit type Not all audits cover the same ground. Here's what the market looks like in 2025: | Audit type | Price range | Typical duration | What you get | |---|---|---|---| | Manual review only | $15,000 - $80,000 | 1 - 4 weeks | Line-by-line expert review, PDF report with findings | | [Invariant testing](/learn/invariant-testing) + [fuzzing](/learn/fuzzing) | $20,000 - $60,000 | 1 - 3 weeks | Executable test suite, fuzzing campaigns, coverage report | | Combined (manual + invariant testing) | $30,000 - $120,000 | 2 - 6 weeks | Full manual review plus delivered test suite and fuzzing | | Continuous security retainer | $5,000 - $15,000/month | Ongoing | CI integration, re-audits on new commits, property updates | The combined approach catches the most bugs. Manual review finds logic errors and design flaws that require human reasoning. [Invariant testing](/learn/invariant-testing) finds stateful bugs that humans miss, like rounding errors that compound over thousands of transactions. ## What drives audit cost Scope measured in nSLOC (normalized source lines of code) is the single biggest cost factor. A 500 nSLOC token contract is a different engagement than a 10,000 nSLOC lending protocol. Other factors that move the price: - Contract complexity: a simple vault costs less to audit than a cross-chain bridge with custom oracle logic. - Number of external integrations: each oracle, AMM, or lending pool your protocol touches adds attack surface. - Timeline pressure: rush engagements (under 2 weeks) cost 20-50% more. - Prior security work: protocols with existing test suites and clean static analysis results take less time. - Chain and language: Solidity on EVM chains is standard pricing. Rust on Solana, Move on Aptos, or Cairo on Starknet may cost more due to smaller reviewer pools. ## Cost comparison: audit approaches DeFi audit pricing varies widely depending on which approach you pick. Here's how they stack up: | Approach | Typical cost | Bugs found | Ongoing value | |---|---|---|---| | Manual review only | $15K - $80K | Logic errors, access control, design flaws | PDF report (goes stale) | | Invariant testing only | $20K - $60K | Stateful bugs, rounding, accounting errors | Executable test suite (runs in CI) | | Formal verification | $50K - $200K+ | Mathematical proof of specific properties | Proofs valid until code changes | | Automated scanners only | $0 - $5K | Known patterns, low-hanging fruit | High false positive rate | | Combined manual + invariant | $30K - $120K | Broadest coverage across all bug classes | Report + test suite + CI integration | Automated scanners alone aren't audits. They're a starting point. Slither and similar tools catch known patterns but miss any bug that requires multi-transaction reasoning. ## The ROI calculation An audit that costs $80,000 looks expensive until you compare it to the alternative. The Euler Finance exploit of March 2023 drained $197 million. Mango Markets lost $114 million in October 2022. Even "small" exploits regularly drain $1-10 million. These protocols had audits, but their audits didn't include [invariant testing](/learn/invariant-testing) or long-duration fuzzing campaigns. Here's a simple expected-value calculation for a protocol with $10M TVL: | Factor | Value | |---|---| | Probability of exploitable bug (unaudited) | ~30% | | Average loss if exploited | $5,000,000 | | Expected loss without audit | $1,500,000 | | Cost of combined audit | $80,000 | | ROI | 1,775% | That's before counting the secondary costs: token price collapse and legal liability, plus months of development time diverted to incident response. We cover these numbers in detail in [the true cost of not auditing](/blog/true-cost-not-auditing-prevented-losses). ## What's included at each price point $15K - $30K (small scope, manual only): 1-2 senior reviewers for 1 week. Static analysis. Findings report with severity ratings and fix recommendations. Best for token contracts, simple vaults, or single-purpose contracts under 1,000 nSLOC. $30K - $60K (medium scope, combined): 2-3 reviewers for 2-3 weeks. Manual review plus invariant test suite with [fuzzing](/learn/fuzzing) campaigns. The fuzzing cost is baked into the engagement — you're not paying extra for it. Delivered Chimera-based test suite you can run in CI. Suitable for most DeFi protocols between 1,000 and 5,000 nSLOC. $60K - $120K (large scope, comprehensive): Full team for 3-6 weeks. Deep manual review, extensive invariant testing, formal verification of critical paths. Multiple fuzzing campaigns with long-duration runs. Re-audit of fixes included. This covers complex lending protocols, DEXs with concentrated liquidity, and cross-chain systems above 5,000 nSLOC. $5K - $15K/month (continuous retainer): Ongoing security coverage. Properties updated when code changes. Fuzzing runs on every PR. Priority scheduling for new feature audits. This is how you keep security from going stale after the initial engagement. ## How to get the most value from your budget Start with a clean codebase. Protocols that arrive with passing tests and clean Slither output save auditors time on setup, which means more time spent finding real bugs. Freeze your code before the engagement starts. Scope changes mid-audit are the fastest way to blow past budget. Invest in the test suite deliverable, not just the report. A PDF report loses value the moment you change a line of code. An [invariant test suite](/learn/invariant-testing) keeps catching bugs as you build. It's the only audit deliverable with a shelf life beyond the engagement. Don't shop on price alone. A $20K audit that misses a critical bug costs you nothing upfront and everything later. Ask firms for [case studies with specific bugs found](/blog/how-we-found-real-vulnerabilities-with-fuzzing) and verify they deliver executable test suites. Consider a retainer for active development. One-time audits protect a snapshot of your code. If you're shipping weekly, continuous security testing through [CI/CD integration](/blog/continuous-security-testing-cicd-fuzzing) is the only way to stay covered. ## Related resources - [Smart contract audit services](/services/smart-contract-audit) — scope and process for a Recon engagement - [DeFi security audit](/services/defi-security-audit) — specialized audit for DeFi protocols - [The true cost of not auditing](/blog/true-cost-not-auditing-prevented-losses) — the business case for security, backed by real data - [What is invariant testing?](/learn/invariant-testing) — how property-based testing catches bugs manual review misses - [What is a smart contract audit?](/learn/smart-contract-audit) — the complete guide to audit types and processes --- Get a quote for your audit — we'll review your codebase and respond within 48 hours. [Request an audit with Recon](/request-audit) and tell us about your protocol. --- ### How to write your first invariant test **Author:** nican0r | **Published:** 2025-03-31 | **Reading time:** 10 min **Tags:** invariant-testing, property-based-testing, fuzzing, tutorial **URL:** https://getrecon.xyz/blog/how-to-write-first-invariant-test # How to write your first invariant test Unit tests check the cases you think of. [Invariant tests](/learn/invariant-testing) check everything you don't. They define what must always be true and let a [fuzzer](/learn/fuzzing) throw thousands of random inputs at your contract to prove it. This tutorial walks you through writing three invariant properties for a simple ERC-20 vault, then running them with both Foundry and [Echidna](/learn/echidna). You'll have a working test suite in about 30 minutes. ## What you'll build You're going to take a minimal deposit/withdraw vault contract and write three [properties](/learn/property-based-testing) that must always hold: 1. Total assets in the vault always equal the sum of all user deposits minus withdrawals 2. No single user's balance ever exceeds total vault assets 3. A user can't withdraw more than they deposited Then you'll run those properties with [Foundry](/learn/foundry) for fast local feedback and with Echidna for deeper [stateful fuzzing](/blog/stateful-fuzzing-explained-sequence-matters). Same properties, two fuzzers, zero rewrites. ## Prerequisites You need [Foundry installed](https://book.getfoundry.sh/getting-started/installation), basic Solidity knowledge, and about 30 minutes. If you haven't used Foundry before, run `curl -L https://foundry.paradigm.xyz | bash && foundryup` to get started. For the Echidna section, install it via `pip3 install crytic-compile && brew install echidna` (or see the [Echidna docs](https://github.com/crytic/echidna)). ## The target contract Here's a deliberately simple vault. It accepts an ERC-20 token, tracks balances per user, and lets users deposit and withdraw. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract SimpleVault { IERC20 public token; mapping(address => uint256) public balances; uint256 public totalDeposited; constructor(address _token) { token = IERC20(_token); } function deposit(uint256 amount) external { token.transferFrom(msg.sender, address(this), amount); balances[msg.sender] += amount; totalDeposited += amount; } function withdraw(uint256 amount) external { balances[msg.sender] -= amount; totalDeposited -= amount; token.transfer(msg.sender, amount); } } ``` This is 20 lines of logic. Even here, there's room for bugs. Can a user withdraw more than they deposited if `balances` underflows? The invariants will tell us. ## Thinking in properties The mental shift from unit testing to invariant testing is simple. Stop asking "does this specific call return the right value?" and start asking "what must always be true, no matter what sequence of calls happens?" For this vault, three things must always hold: - **Solvency**: the vault's token balance is always at least `totalDeposited`. If it's less, funds have leaked. - **Balance cap**: no single user's recorded balance exceeds `totalDeposited`. If it does, the accounting is broken. - **Withdraw bound**: a withdraw call with an amount larger than the caller's balance should revert. If it doesn't, users can steal funds. These aren't test cases. They're contracts about reality. The fuzzer's job is to try to break them. ## Writing the properties Create a test file at `test/InvariantTest.sol`. The three properties go here alongside handler functions that guide the fuzzer. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Test} from "forge-std/Test.sol"; import {SimpleVault} from "../src/SimpleVault.sol"; import {MockERC20} from "forge-std/mocks/MockERC20.sol"; contract VaultHandler is Test { SimpleVault public vault; MockERC20 public token; constructor(SimpleVault _vault, MockERC20 _token) { vault = _vault; token = _token; } function handler_deposit(uint256 amount) external { amount = bound(amount, 1, 1_000e18); token.mint(address(this), amount); token.approve(address(vault), amount); vault.deposit(amount); } function handler_withdraw(uint256 amount) external { uint256 bal = vault.balances(address(this)); if (bal == 0) return; amount = bound(amount, 1, bal); vault.withdraw(amount); } } ``` The handler functions use `bound()` to constrain random inputs into valid ranges. This keeps the fuzzer focused on meaningful sequences rather than wasting time on trivially reverting calls. Now the invariant assertions: ```solidity contract InvariantTest is Test { SimpleVault public vault; MockERC20 public token; VaultHandler public handler; function setUp() public { token = new MockERC20(); vault = new SimpleVault(address(token)); handler = new VaultHandler(vault, token); targetContract(address(handler)); } function invariant_solvency() public view { uint256 vaultBalance = token.balanceOf(address(vault)); assertGe(vaultBalance, vault.totalDeposited()); } function invariant_balanceCap() public view { uint256 handlerBal = vault.balances(address(handler)); assertLe(handlerBal, vault.totalDeposited()); } } ``` Each `invariant_` function runs after every fuzzer call. If any assertion fails, the fuzzer stops and reports the exact call sequence that broke it. ## Running with Foundry Run your invariants from the project root: ``` forge test --match-contract InvariantTest -vvv ``` Foundry will randomly call your handler functions and check every `invariant_` function after each call. A passing run looks like this: ``` [PASS] invariant_solvency() (runs: 256, calls: 3840) [PASS] invariant_balanceCap() (runs: 256, calls: 3840) ``` The `runs` count is how many random sequences Foundry generated. The `calls` count is the total number of handler invocations across all sequences. More is better. Increase depth in `foundry.toml`: ```toml [invariant] runs = 512 depth = 32 ``` ## Running with Echidna Echidna uses a different approach: it needs a single contract with all handlers and properties combined, and properties return `bool` instead of using `assert`. Here's a minimal config: ```yaml # echidna.yaml testMode: property testLimit: 50000 seqLen: 30 corpusDir: "corpus" ``` And the Echidna-compatible test contract: ```solidity contract EchidnaTest { SimpleVault vault; MockERC20 token; constructor() { token = new MockERC20(); vault = new SimpleVault(address(token)); } function handler_deposit(uint256 amount) public { amount = amount % 1_000e18 + 1; token.mint(address(this), amount); token.approve(address(vault), amount); vault.deposit(amount); } function echidna_solvency() public view returns (bool) { return token.balanceOf(address(vault)) >= vault.totalDeposited(); } } ``` Run it: ``` echidna test/EchidnaTest.sol --contract EchidnaTest --config echidna.yaml ``` Echidna outputs `passed` or `failed` for each property. It's slower than Foundry per run but explores deeper state spaces over longer campaigns. For a full comparison, see [Echidna vs Medusa](/blog/echidna-vs-medusa-practical-comparison). ## What to do when a test fails When a fuzzer breaks a property, it gives you a counterexample: the exact sequence of calls and arguments that triggered the failure. ``` invariant_solvency() failed after: handler_deposit(500000000000000000) handler_withdraw(500000000000000001) ``` This tells you a user deposited 0.5 tokens and then withdrew 0.5 + 1 wei. That means `withdraw` didn't check the balance properly. To fix this, add a require statement: ```solidity function withdraw(uint256 amount) external { require(amount <= balances[msg.sender], "exceeds balance"); balances[msg.sender] -= amount; totalDeposited -= amount; token.transfer(msg.sender, amount); } ``` The workflow is: read the counterexample, reproduce it as a unit test, understand the root cause, fix the code, then rerun the fuzzer to confirm. Every broken invariant is a bug you found before an attacker did. ## Related - [Invariant testing](/learn/invariant-testing) — full glossary entry on the technique - [Property-based testing](/learn/property-based-testing) — the theory behind property-driven development - [Echidna](/learn/echidna) — deep dive on Echidna's architecture and configuration - [Foundry](/learn/foundry) — Foundry's invariant testing features explained - [From zero to fuzzing: a beginner's guide to Chimera](/blog/zero-to-fuzzing-beginners-guide-chimera) — next step after this tutorial - [Property design patterns for DeFi lending](/blog/property-design-patterns-defi-lending) — real-world property patterns you can adapt --- Ready to go beyond the basics? [Request an audit with Recon](/request-audit) — we'll build a full invariant test suite for your protocol and run it at scale with coverage-guided fuzzing. --- ### Smart contract fuzzing tools compared: Echidna, Medusa, Halmos, and Foundry **Author:** antonio | **Published:** 2025-04-28 | **Reading time:** 10 min **Tags:** fuzzing, echidna, medusa, stateful-fuzzing, invariant-testing **URL:** https://getrecon.xyz/blog/smart-contract-fuzzing-tools-compared # Smart contract fuzzing tools compared: Echidna, Medusa, Halmos, and Foundry Four tools dominate smart contract fuzzing today. Each one approaches the problem differently, and picking the wrong one wastes time. This guide breaks down Echidna, Medusa, Halmos, and Foundry's built-in fuzzer so you can match the right tool to your project — or use all four together through [Chimera](/learn/chimera-framework). Get expert fuzzing for your protocol ## Four fuzzers in 2025 Smart contract fuzzing has split into four distinct approaches. [Echidna](/learn/echidna) is the original. Built in Haskell, it pioneered grammar-based [stateful fuzzing](/learn/stateful-fuzzing) for Solidity and still has the best corpus management of any tool in the space. [Medusa](/learn/medusa) came later as a Go-based alternative, trading Echidna's single-threaded precision for parallel throughput. [Halmos](/learn/halmos) isn't a fuzzer at all in the traditional sense. It's a symbolic execution engine that provides bounded model checking and mathematical proofs. And [Foundry](/learn/foundry)'s built-in fuzzer (`forge test`) handles fast stateless and stateful fuzzing directly inside the development workflow most teams already use. These aren't competing products. They're complementary tools that cover different parts of the bug-finding spectrum. ## Feature comparison table | Feature | Echidna | Medusa | Halmos | Foundry fuzz | |---------|---------|--------|--------|-------------| | Language | Haskell | Go | Python | Rust | | Execution model | Single-threaded | Parallel (N workers) | Symbolic | Single-threaded | | Stateful fuzzing | Yes (directed) | Yes (parallel) | N/A (symbolic) | Yes (invariant tests) | | Corpus management | Excellent (binary, persistent) | Good (JSON, readable) | N/A | None (no persistence) | | Optimization mode | Yes | Yes | N/A | No | | Symbolic execution | No | No | Yes (bounded) | No | | Throughput (8 cores) | ~2,000 exec/s | ~8,000 exec/s | ~10-50 paths/s | ~3,000 exec/s | | Sequence shrinking | Excellent | Good | N/A | Basic | | Setup time | Moderate | Moderate | Low | Minimal | | CI integration | Good (Docker) | Good (binary) | Good (pip) | Excellent (native) | | Community maturity | Since 2019 | Since 2022 | Since 2023 | Since 2022 | ## Echidna Echidna runs a single-threaded fuzzing loop that builds transaction sequences incrementally. It generates a call, executes it, and extends sequences that hit new code paths. This directed approach makes it the strongest tool for [complex stateful protocols](/blog/stateful-fuzzing-explained-sequence-matters) where the order of operations matters. Its corpus management is the best in the ecosystem. Echidna persists interesting sequences to disk and aggressively shrinks failing sequences on restart. A 100-call failure trace becomes 4-5 calls that you can actually debug. No other tool produces reproducers this clean. ```yaml # echidna.yaml testMode: assertion testLimit: 500000 seqLen: 100 corpusDir: "corpus-echidna" ``` When to use Echidna: complex DeFi protocols with multi-step user flows and any project where you need minimal reproducers for specific state transitions. It's the tool you run overnight when you need thorough coverage. ## Medusa Medusa spawns N parallel workers, each with its own EVM instance, exploring the state space independently. Workers share coverage data through a synchronized corpus, so a discovery in one worker benefits all others. On an 8-core machine, it reaches 80% coverage roughly 3x faster than Echidna. Its corpus is stored as human-readable JSON, which makes debugging easier. Medusa also supports optimization testing. Instead of just checking boolean properties, it can minimize or maximize a numeric value, useful for finding worst-case scenarios. ```json { "fuzzing": { "testLimit": 500000, "callSequenceLength": 100, "workers": 8, "corpusDirectory": "corpus-medusa" } } ``` When to use Medusa: large codebases where throughput matters, CI/CD pipelines where wall-clock time is constrained, projects that need fast initial coverage before deeper exploration. It's the tool you run first when starting a new campaign. ## Halmos Halmos doesn't fuzz at all. It uses symbolic execution to mathematically prove whether a property holds for all inputs within a given bound. Echidna and Medusa test millions of concrete inputs hoping to find a violation. Halmos encodes the problem as an SMT formula and asks a solver to find a counterexample, or prove none exists. This makes it uniquely powerful for arithmetic-heavy code. If you need to verify that a [rounding operation](/blog/rounding-errors-defi-small-bugs-big-exploits) never underflows for any possible input combination, Halmos gives you a mathematical guarantee (up to the configured bound). Fuzzers can only give you statistical confidence. ```solidity // Halmos checks this for ALL possible inputs within bounds function check_noUnderflow(uint128 x, uint128 y) public { vm.assume(y <= x); uint128 result = x - y; assert(result <= x); } ``` When to use Halmos: critical arithmetic and accounting logic, verifying [invariant properties](/learn/invariant-testing) over bounded input ranges, any situation where "probably correct" isn't good enough. It pairs well with fuzzing — use fuzzers for broad exploration, then Halmos to prove the critical paths. ## Foundry fuzz Foundry's built-in fuzzer runs inside `forge test`. It handles both stateless fuzzing (random inputs to a single function) and stateful fuzzing (random call sequences against `invariant_` functions). There's no separate tool to install and no configuration file to manage. Results show up in the same test output developers already read. It won't match Echidna's sequence quality or Medusa's throughput, and it doesn't persist a corpus between runs. But its zero-friction setup makes it the fastest path from "I should test this property" to actually testing it. ```solidity // Foundry stateless fuzz test, just add a parameter function testFuzz_depositWithdraw(uint256 amount) public { amount = bound(amount, 1, type(uint128).max); vault.deposit(amount); uint256 shares = vault.balanceOf(address(this)); vault.withdraw(shares); assertEq(vault.balanceOf(address(this)), 0); } ``` When to use Foundry fuzz: quick property checks during development, Foundry-native projects that want fuzzing without adding dependencies, unit-test-adjacent properties where stateless fuzzing is sufficient. It's where you start before reaching for heavier tools. ## Using all four with Chimera You don't have to pick one. The [Chimera framework](/learn/chimera-framework) provides a unified interface that lets you write properties once and run them with Echidna, Medusa, and Foundry without changing a line of code. Halmos tests require symbolic-style assertions, but Chimera's structure keeps your property logic organized so adapting it for Halmos is straightforward. ```solidity // One property, three fuzzers contract CryticTester is TargetFunctions, CryticAsserts { constructor() { setup(); } } ``` The workflow we use at Recon on every engagement: 1. Write properties with Chimera's unified interface 2. Iterate fast with Foundry (`forge test`) 3. Run Medusa for quick parallel coverage 4. Run Echidna for deep directed exploration 5. Run Halmos on critical arithmetic properties 6. Run everything in [Recon Pro](/recon-pro) for cloud-scale campaigns Each tool catches bugs the others miss. If you want the full comparison between just Echidna and Medusa, we've covered that in detail in our [practical comparison post](/blog/echidna-vs-medusa-practical-comparison). ## Which tool should you pick If you're already using Foundry and want to start fuzzing today, start with `forge test` and add fuzzed inputs to your existing tests. If you're building a DeFi protocol with complex state transitions, add Echidna. Its directed exploration and sequence shrinking are unmatched for finding multi-step bugs. If your codebase is large and CI time is limited, add Medusa. Its parallel execution fits naturally into automated pipelines. If you have critical arithmetic that needs mathematical guarantees, add Halmos. Statistical confidence isn't enough for code that handles user funds. If you want all of the above without vendor lock-in, use [Chimera](/learn/chimera-framework). Write once, run everywhere. Our [beginner's guide](/blog/zero-to-fuzzing-beginners-guide-chimera) covers the full setup. ## Related - [What is Echidna?](/learn/echidna) - [What is Medusa?](/learn/medusa) - [What is Halmos?](/learn/halmos) - [What is Foundry?](/learn/foundry) - [Chimera framework explained](/learn/chimera-framework) - [Echidna vs Medusa: a practical comparison](/blog/echidna-vs-medusa-practical-comparison) - [Stateful fuzzing explained](/blog/stateful-fuzzing-explained-sequence-matters) If you want expert help setting up a multi-tool fuzzing pipeline, or want to run intensive campaigns on cloud infrastructure, [request an audit with Recon](/request-audit). We'll build a custom [invariant test](/learn/invariant-testing) suite and run it with every tool in our arsenal. --- ### What is smart contract fuzzing? **Author:** deivitto | **Published:** 2025-06-02 | **Reading time:** 10 min **Tags:** fuzzing, smart-contract-security, invariant-testing, property-based-testing **URL:** https://getrecon.xyz/blog/what-is-smart-contract-fuzzing # What is smart contract fuzzing? Smart contract fuzzing is an automated testing technique that throws thousands — sometimes millions — of random or semi-random inputs at your contracts to find states that violate your security assumptions. Instead of writing test cases for scenarios you can imagine, you define properties that should always hold and let the fuzzer discover the scenarios you didn't imagine. If you've ever shipped code that passed every unit test and still got exploited, you already know why this matters. Get expert fuzzing for your protocol ## The core idea Traditional testing is *example-based*. You write a test that deposits 100 tokens, withdraws 50, and checks the balance. If that specific scenario works, the test passes. But what about depositing 0 tokens? What about depositing `type(uint256).max`? What about depositing, then having someone else deposit, then withdrawing in the same block? Fuzzing flips this around. Instead of specifying inputs, you specify *properties*, things that should always be true, and let a fuzzer generate the inputs automatically. ```solidity // Unit test: one scenario, one check function test_depositWithdraw() public { vault.deposit(100e18); vault.withdraw(100e18); assertEq(vault.balanceOf(address(this)), 0); } // Fuzz test: thousands of scenarios, same property function testFuzz_depositWithdraw(uint256 amount) public { amount = bound(amount, 1, type(uint128).max); vault.deposit(amount); vault.withdraw(vault.balanceOf(address(this))); assertEq(vault.balanceOf(address(this)), 0); } ``` The first test checks one path. The second test checks every amount the fuzzer can think of. When the fuzzer finds an amount that breaks the property, you've got a real bug and a concrete reproducer to debug it with. ## How fuzzing works for smart contracts Smart contract fuzzers operate in a loop: 1. **Generate** a transaction (or sequence of transactions) with random parameters: function selector, arguments, `msg.sender`, `msg.value` 2. **Execute** the transaction against a local EVM instance 3. **Check** whether any defined property was violated 4. **Mutate.** If the transaction hit new code paths, keep it in the corpus and mutate it to explore further This loop runs thousands of times per second. The fuzzer tracks which lines of code and which branches each transaction exercises. When it finds a transaction that reaches new code, it saves that input and generates variations of it. This is [coverage-guided fuzzing](/learn/coverage-guided-fuzzing). The fuzzer doesn't just spray random data, it learns which inputs are interesting and focuses on those. Over time, the fuzzer builds up a *corpus* of inputs that exercise most of your contract's reachable code. Each new run starts from the corpus and tries to push further. This means fuzzing gets more effective the longer you run it. ## Stateless vs stateful fuzzing There are two flavors of smart contract fuzzing, and the distinction matters a lot. ## Stateless fuzzing Stateless fuzzing generates random inputs for a single function call. Each test invocation is independent, and the contract resets between calls. Foundry's `testFuzz_` prefix does this by default. ```solidity // Stateless: each call is independent function testFuzz_sqrt(uint256 x) public { uint256 result = Math.sqrt(x); assert(result * result <= x); assert((result + 1) * (result + 1) > x); } ``` This is great for pure math functions, input validation, and simple state transitions. It's fast, easy to write, and catches a surprising number of edge-case bugs. ## Stateful fuzzing [Stateful fuzzing](/blog/stateful-fuzzing-explained-sequence-matters) generates *sequences* of transactions that build on each other. The fuzzer calls `deposit()`, then `borrow()`, then `withdraw()`, with different senders, amounts, and orderings, and checks properties after each step. The contract's state persists across the sequence. ```solidity // Stateful: sequences of calls, state persists // The fuzzer generates random call sequences against target functions // and checks invariant properties after each call // Target functions the fuzzer can call: function handler_deposit(uint256 amount) public { amount = bound(amount, 1, token.balanceOf(address(this))); vault.deposit(amount, address(this)); } function handler_withdraw(uint256 shares) public { shares = bound(shares, 1, vault.balanceOf(address(this))); vault.withdraw(shares, address(this), address(this)); } // Property checked after every call in the sequence: function invariant_solvency() public view returns (bool) { return token.balanceOf(address(vault)) >= vault.totalAssets(); } ``` Most real smart contract bugs are stateful. They require a specific sequence of operations to trigger: a deposit followed by a price change followed by a liquidation, for instance. Stateful fuzzing is how you find these. Tools like [Echidna](/learn/echidna) and [Medusa](/learn/medusa) were built specifically for stateful fuzzing of smart contracts. ## The tools Four tools dominate smart contract fuzzing today. We've written a [detailed comparison](/blog/smart-contract-fuzzing-tools-compared), but here's the short version. ## Foundry [Foundry](/learn/foundry)'s built-in fuzzer handles both stateless and basic stateful fuzzing inside `forge test`. Zero setup beyond what your project already has. It's the fastest way to start fuzzing, and the right tool for quick property checks during development. The limitation is that it doesn't persist a corpus between runs, so each test starts from scratch. ## Echidna [Echidna](/learn/echidna) is the original smart contract fuzzer. It builds transaction sequences incrementally and has the best sequence shrinking of any tool. When it finds a failing sequence of 100 calls, it reduces it to 4-5 calls that reproduce the bug. That makes debugging far easier. It's single-threaded, so it's slower than Medusa, but its directed exploration often finds deeper bugs. ## Medusa [Medusa](/learn/medusa) runs parallel workers that share coverage data. On an 8-core machine, it reaches broad coverage roughly 3x faster than Echidna. Its corpus is stored as human-readable JSON. It's the tool you run first when starting a new campaign, and it fits naturally into CI/CD pipelines where wall-clock time matters. ## Halmos [Halmos](/learn/halmos) isn't a fuzzer in the traditional sense. It's a symbolic execution engine. Instead of testing concrete inputs, it mathematically proves whether a property holds for all possible inputs within a bound. When you need guarantees about arithmetic correctness, not just statistical confidence, Halmos fills a gap that no fuzzer can. ## Using them together You don't have to pick one. The [Chimera framework](/learn/chimera-framework) lets you write properties once and run them with Foundry, Echidna, and Medusa without changing your code. Our [beginner's guide](/blog/zero-to-fuzzing-beginners-guide-chimera) walks through the full setup. ## What fuzzing catches Fuzzing is particularly good at finding bugs that require unexpected inputs or sequences. Here are the categories we see most often in real engagements: **Arithmetic edge cases.** Integer overflow/underflow, [rounding errors](/blog/rounding-errors-defi-small-bugs-big-exploits) that compound over time, division by zero with specific denominator values, precision loss in fixed-point math. **Accounting violations.** Share-to-asset ratios that drift, total supply mismatches, solvency invariants that break after specific deposit/withdraw sequences. These are the [category of bug](/blog/anatomy-critical-defi-bug-insolvency-accounting) that has drained the most value from DeFi protocols. **Access control gaps.** Functions callable by unauthorized senders, privilege escalation through specific call sequences, missing modifiers that only matter in certain state configurations. **State machine violations.** Protocols that reach states that should be unreachable: paused but still accepting deposits, liquidatable but not liquidatable, finalized but still mutable. **Cross-function interactions.** Bugs that only appear when function A is called before function B, or when two users interact in a specific order. These are the [vulnerabilities unit tests miss](/blog/smart-contract-vulnerabilities-unit-tests-miss). ## What fuzzing doesn't catch Fuzzing isn't a silver bullet. It's bad at: - **Business logic that needs human judgment.** If the property is "the fee should be fair," no fuzzer can check that. - **External integration bugs.** Fuzzers test your contracts in isolation. Oracle manipulation, bridge failures, and cross-chain race conditions need different approaches. - **Gas optimization issues.** Fuzzers check correctness, not efficiency. - **Issues requiring very specific preconditions.** If a bug only triggers when the block timestamp is exactly a leap-second boundary, a random fuzzer is unlikely to hit it (though targeted property design can help). A proper security review combines fuzzing with [manual review, static analysis, and formal verification](/blog/what-to-expect-smart-contract-audit-2025). Fuzzing handles the "find bugs in code I think is correct" part better than any other technique. ## When to use fuzzing The short answer: always, if your contracts handle value. The more nuanced answer: - **During development.** Write properties as you write code. Foundry's built-in fuzzer gives you near-instant feedback. This catches bugs before they make it into a PR. - **Before an audit.** Running a fuzzing campaign before you send your code to auditors means the easy bugs are already found. Auditors can focus on the subtle stuff. - **During an audit.** At Recon, fuzzing is a core part of every engagement. We write [custom invariant properties](/blog/property-design-patterns-defi-lending) for each protocol and run multi-day campaigns with Echidna, Medusa, and [Recon Pro](/recon-pro). - **After deployment.** [Continuous fuzzing in CI/CD](/blog/continuous-security-testing-cicd-fuzzing) catches regressions on every commit. A property that passed yesterday and fails today tells you exactly which change broke it. ## How Recon uses fuzzing Every Recon engagement starts with [invariant property design](/learn/invariant-testing). We identify the properties that your protocol must satisfy, like solvency, access control, and state machine correctness, then encode them as executable tests using the [Chimera framework](/learn/chimera-framework). We then run these properties through multiple fuzzers in parallel. Medusa for fast broad coverage. Echidna for deep directed exploration. Halmos for mathematical proofs of critical arithmetic. The results feed back into property refinement. If coverage data shows unreached code paths, we write targeted properties to push the fuzzer into those areas. This isn't a checkbox exercise. Fuzzing has caught critical and high-severity bugs in real engagements that manual review missed, the kind of bugs that would have drained protocols. We've written about several of these in our [real vulnerability case studies](/blog/how-we-found-real-vulnerabilities-with-fuzzing). ## Getting started If you're new to fuzzing, start here: 1. **[How to write your first invariant test](/blog/how-to-write-first-invariant-test)**. A hands-on tutorial with three properties for a simple vault 2. **[From Zero to Fuzzing: Chimera beginner's guide](/blog/zero-to-fuzzing-beginners-guide-chimera)**. Full setup with Foundry, Echidna, and Medusa 3. **[Smart contract fuzzing tools compared](/blog/smart-contract-fuzzing-tools-compared)**. Pick the right tool for your project If you'd rather have experts build your invariant test suite and run the campaigns for you, [request an audit with Recon](/request-audit). We'll find the bugs the fuzzers can find, and the ones that need a human eye too. --- ### 7 common smart contract fuzzing mistakes (and how to fix them) **Author:** deivitto | **Published:** 2025-08-04 | **Reading time:** 9 min **Tags:** fuzzing, invariant-testing, smart-contract-security, property-based-testing **URL:** https://getrecon.xyz/blog/common-smart-contract-fuzzing-mistakes # 7 common smart contract fuzzing mistakes (and how to fix them) You set up a fuzzing campaign. It runs for 24 hours. Zero property violations. You ship to mainnet confident your code is solid. Three weeks later, an attacker drains $2M from a state transition your fuzzer never explored. The fuzzer didn't fail. Your campaign did. Here are the seven mistakes we see most often, and how to fix each one. Get expert fuzzing for your protocol ## Mistake 1: Weak properties This is the most common and most damaging mistake. You write properties that are technically correct but too weak to catch real bugs. ```solidity // Weak: this almost never fails function invariant_totalSupplyNotZero() public view returns (bool) { return vault.totalSupply() >= 0; // uint256 is always >= 0 } // Weak: true by construction function invariant_ownerIsSet() public view returns (bool) { return vault.owner() != address(0); } ``` These properties pass no matter what. They don't encode any meaningful security assumption. A fuzzer could run for a year and learn nothing from them. **The fix:** Properties should encode things that *could* break. Think about what an attacker would want to violate. If your protocol is a vault, the key property is solvency — the contract holds enough assets to cover all shares. ```solidity // Strong: encodes the core solvency invariant function invariant_vaultSolvency() public view returns (bool) { return token.balanceOf(address(vault)) >= vault.totalAssets(); } // Strong: encodes deposit/withdraw symmetry function invariant_noFreeShares() public view returns (bool) { // Total minted shares should never exceed what deposits justify if (vault.totalSupply() == 0) return vault.totalAssets() == 0; return vault.totalAssets() > 0; } ``` If you're not sure whether your properties are strong enough, ask yourself: "If this property were violated, would I consider it a bug?" If the answer is "probably not," the property is too weak. For inspiration on what strong properties look like, see our [property design patterns for DeFi lending](/blog/property-design-patterns-defi-lending). ## Mistake 2: Too-tight bounds on inputs Developers often constrain fuzzer inputs to "realistic" ranges. This kills the fuzzer's ability to find edge cases. ```solidity // Too tight: only tests "normal" deposits function handler_deposit(uint256 amount) public { amount = bound(amount, 1e18, 100e18); // 1 to 100 tokens vault.deposit(amount, address(this)); } ``` Real attackers don't use "normal" values. They deposit 1 wei. They deposit `type(uint256).max`. They deposit the exact amount that causes a rounding error at the boundary between two precision ranges. **The fix:** Use the widest bounds that your contract's preconditions allow. If a function accepts a `uint256`, let the fuzzer explore the full range. Only constrain when the contract itself would revert, and even then, test the boundary. ```solidity // Better: full range the contract allows function handler_deposit(uint256 amount) public { uint256 maxDeposit = vault.maxDeposit(address(this)); if (maxDeposit == 0) return; amount = bound(amount, 1, maxDeposit); vault.deposit(amount, address(this)); } ``` The fuzzer's job is to find the inputs you didn't think of. Don't take that job away from it. ## Mistake 3: Missing actors Most DeFi bugs involve multiple users interacting. If your fuzzing campaign only uses a single `msg.sender`, you're missing an entire dimension of the state space. ```solidity // Only one actor: misses multi-user interactions function handler_deposit(uint256 amount) public { amount = bound(amount, 1, token.balanceOf(address(this))); vault.deposit(amount, address(this)); } ``` This setup will never find bugs that require two depositors, a borrower and a liquidator, or an admin and a regular user acting in sequence. **The fix:** Set up multiple actors and let the fuzzer choose between them. [Chimera](/learn/chimera-framework) and [Echidna](/learn/echidna) both support multi-sender campaigns. ```solidity // Multi-actor setup address[] internal actors; constructor() { actors.push(address(0x10000)); actors.push(address(0x20000)); actors.push(address(0x30000)); } function handler_deposit(uint256 actorSeed, uint256 amount) public { address actor = actors[actorSeed % actors.length]; amount = bound(amount, 1, token.balanceOf(actor)); if (amount == 0) return; vm.prank(actor); vault.deposit(amount, actor); } ``` At Recon, we typically configure 3-5 actors for each role in the protocol (depositors, borrowers, liquidators, admins). The more realistic your actor setup, the more realistic the bugs the fuzzer finds. ## Mistake 4: No state setup Starting every fuzzing run from a completely empty state means the fuzzer has to rediscover basic setup sequences every time. Worse, some bugs only appear when the protocol is already in a specific state: high utilization, near-liquidation, paused and unpaused. ```solidity // Empty state: fuzzer wastes time bootstrapping constructor() { token = new MockERC20(); vault = new Vault(address(token)); // ... and that's it } ``` **The fix:** Seed the fuzzer with realistic initial state. Pre-fund actors, make initial deposits, set non-trivial oracle prices, move time forward. ```solidity constructor() { token = new MockERC20(); vault = new Vault(address(token)); // Pre-fund actors for (uint256 i = 0; i < actors.length; i++) { token.mint(actors[i], 1_000_000e18); vm.prank(actors[i]); token.approve(address(vault), type(uint256).max); } // Seed with initial deposits so the vault isn't empty vm.prank(actors[0]); vault.deposit(100_000e18, actors[0]); vm.prank(actors[1]); vault.deposit(50_000e18, actors[1]); } ``` Think of it this way: you're giving the fuzzer a head start so it can spend its compute budget exploring interesting states instead of setting up basic conditions. ## Mistake 5: Ignoring coverage You ran Medusa for 12 hours and it found zero violations. Great news? Maybe. Or maybe the fuzzer never reached 60% of your code. Most fuzzers report coverage metrics. If you're not reading them, you're flying blind. **The fix:** Check coverage after every campaign. Both [Echidna](/learn/echidna) and [Medusa](/learn/medusa) produce coverage reports. ```bash # Medusa: check coverage in the corpus directory ls corpus-medusa/coverage/ # Echidna: coverage is reported in the output echidna . --contract CryticTester --corpus-dir corpus-echidna # Look for "Coverage: X/Y unique reverts, Z/W unique lines" ``` If coverage is low, your handlers aren't reaching deep enough into the protocol. Common causes: - **Missing handler functions.** If the fuzzer can't call `liquidate()`, it can never reach post-liquidation states. - **Precondition reverts.** If 90% of generated calls revert because inputs are out of range, the fuzzer barely makes progress. Tighten your `bound()` calls to match actual preconditions. - **Missing state transitions.** If the fuzzer can't advance time or change oracle prices, entire code paths stay unreachable. Add handlers for every external function you want tested. Add time-warp and price-manipulation handlers for protocols that depend on those. Then check coverage again. ## Mistake 6: Wrong tool for the job [Foundry's built-in fuzzer](/learn/foundry) is fast and convenient. It's also stateless by default and doesn't persist a corpus. Running Foundry alone on a complex DeFi protocol with multi-step state transitions is like searching for a needle in a haystack with a magnet that resets every 10 seconds. Conversely, running [Echidna](/learn/echidna) on a pure math library where stateless fuzzing would suffice is overkill, slower setup for no additional bug-finding power. **The fix:** Match the tool to the problem. | Problem | Right tool | |---------|-----------| | Pure function edge cases | Foundry stateless fuzz | | Mathematical proofs | [Halmos](/learn/halmos) | | Multi-step state transitions | Echidna or Medusa | | Fast CI/CD smoke tests | Foundry | | Deep overnight campaigns | Echidna (directed) or Medusa (parallel) | | Large codebase, fast coverage | Medusa | For a detailed comparison, see our [fuzzing tools comparison](/blog/smart-contract-fuzzing-tools-compared). Or use [Chimera](/learn/chimera-framework) to write properties once and run them everywhere. Our [Echidna vs Medusa comparison](/blog/echidna-vs-medusa-practical-comparison) covers when each shines. ## Mistake 7: Not running long enough The most invisible mistake. You set `testLimit: 50000` because the campaign takes 30 minutes at that setting, and you want results before lunch. But the bug is 200,000 tests deep, a six-call sequence that requires the fuzzer to discover each step incrementally. ```yaml # echidna.yaml testLimit: 50000 # Finds shallow bugs seqLen: 50 # Short sequences # vs testLimit: 5000000 # Finds deep bugs seqLen: 200 # Long sequences ``` We've seen critical bugs that only surface after millions of tests. The fuzzer needs time to build up its corpus, discover interesting state transitions, and chain them together. **The fix:** Run tiered campaigns. 1. **Development:** Foundry, 10,000 runs, under 2 minutes. Catches obvious regressions. 2. **Pre-merge:** Medusa, 500,000 tests, 30 minutes. Catches most stateful bugs. 3. **Pre-audit/pre-deploy:** Echidna + Medusa, 5,000,000+ tests, 4-24 hours. Catches deep sequence bugs. 4. **Cloud:** [Recon Pro](/recon-pro) for 50M+ test campaigns with high parallelism. You can automate these tiers in CI/CD. We wrote a full guide on [setting up continuous security testing](/blog/continuous-security-testing-cicd-fuzzing). ## The meta-mistake: treating fuzzing as a checkbox All seven mistakes above share a root cause: treating fuzzing as something you do once and forget about. Real fuzzing is iterative. You run a campaign, check coverage, improve properties, add handlers, run again. Each round makes the next one more effective. The teams that get the most out of fuzzing treat their invariant test suite as a living artifact — updated with every feature, refined with every campaign. They don't ask "did we fuzz?" They ask "are our properties strong enough to catch the next bug?" If you want help building that kind of test suite, or want a team that's run thousands of fuzzing campaigns to check your properties, [request an audit with Recon](/request-audit). We'll make sure your fuzzer is actually finding what it should. --- ### How to fuzz ERC-4626 vaults: a step-by-step guide **Author:** deivitto | **Published:** 2025-09-15 | **Reading time:** 12 min **Tags:** fuzzing, invariant-testing, erc-4626, smart-contract-security **URL:** https://getrecon.xyz/blog/how-to-fuzz-erc4626-vaults # How to fuzz ERC-4626 vaults: a step-by-step guide ERC-4626 is the tokenized vault standard that most DeFi yield protocols build on. It defines a clean interface for deposits, withdrawals, and share accounting, along with a set of guarantees that are surprisingly easy to break. Rounding errors, share inflation attacks, and deposit/withdraw asymmetries have all been exploited in production vaults. Fuzzing is the fastest way to verify that your vault's implementation actually holds the guarantees the standard promises. This guide walks through the full process: identifying the right invariants, writing properties with [Chimera](/learn/chimera-framework), and running campaigns with multiple fuzzers. Get expert vault fuzzing ## What ERC-4626 guarantees The [EIP-4626 specification](https://eips.ethereum.org/EIPS/eip-4626) defines a standard API for yield-bearing vaults. At its core, a vault accepts deposits of an underlying asset, mints shares to depositors, and allows redemption of shares for the underlying asset (plus yield). The standard specifies several functions (`deposit()`, `mint()`, `withdraw()`, `redeem()`) and a set of preview/max functions that must be consistent with actual behavior. The guarantees that matter most for security: 1. **Share/asset conversion consistency.** `convertToShares(convertToAssets(shares))` should round-trip without unbounded loss. 2. **Preview accuracy.** `previewDeposit(assets)` must return the exact number of shares `deposit(assets)` would mint (or fewer, never more). 3. **Deposit/withdraw symmetry.** Depositing then immediately withdrawing shouldn't create or destroy value (beyond rounding). 4. **Rounding direction.** Conversions should favor the vault (round down on the way out, round up on the way in) to prevent share inflation attacks. 5. **Solvency.** The vault's asset balance must cover what it owes to all shareholders. 6. **Max function accuracy.** `maxDeposit()`, `maxWithdraw()`, etc. should return values that don't revert when used. These look simple on paper. In practice, custom yield strategies, fee mechanisms, and rebasing tokens make them hard to maintain. That's exactly where fuzzing shines. ## Key invariants Before writing code, let's define the invariants we'll test. These are the properties that should hold after every transaction, no matter what sequence of operations the fuzzer generates. ## Solvency The most fundamental invariant. The vault must always hold enough of the underlying asset to cover all outstanding shares. ``` asset.balanceOf(vault) >= vault.totalAssets() ``` If this fails, your vault is insolvent — shareholders can't fully redeem. ## Share/asset ratio monotonicity For a yield-bearing vault, the exchange rate (assets per share) should never decrease except through legitimate losses. In many implementations, it should be monotonically non-decreasing. ``` vault.convertToAssets(1e18) // Should never decrease over time // (assuming no loss events) ``` ## Deposit/withdraw round-trip A user who deposits X assets and immediately redeems all their shares should get back at most X assets (rounding favors the vault). ``` assetsOut <= assetsIn // After deposit then full redeem, no profit ``` ## Preview/actual consistency Preview functions must not overstate returns. The actual shares minted by `deposit()` must be less than or equal to what `previewDeposit()` promised. ``` actualShares <= previewDeposit(assets) // For depositor actualAssets <= previewRedeem(shares) // For redeemer ``` ## No zero-amount inflation Depositing should not mint shares if zero assets are transferred. Withdrawing should not transfer assets if zero shares are burned. This prevents [share inflation attacks](/blog/rounding-errors-defi-small-bugs-big-exploits) where an attacker manipulates the ratio with zero-cost operations. ## Setting up Chimera We'll use the [Chimera framework](/learn/chimera-framework) so our properties run on Foundry, [Echidna](/learn/echidna), and [Medusa](/learn/medusa) without changes. If you haven't set up Chimera before, our [beginner's guide](/blog/zero-to-fuzzing-beginners-guide-chimera) covers the full installation. The file structure: ``` test/ invariants/ Setup.sol // Deploy vault + tokens, fund actors TargetFunctions.sol // Handler functions the fuzzer calls Properties.sol // Invariant checks CryticTester.sol // Entrypoint for Echidna/Medusa ``` ## Setup ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {MockERC20} from "./mocks/MockERC20.sol"; import {ERC4626Vault} from "src/ERC4626Vault.sol"; import {BaseSetup} from "@chimera/BaseSetup.sol"; abstract contract Setup is BaseSetup { MockERC20 internal asset; ERC4626Vault internal vault; address[] internal actors; function setup() internal virtual override { asset = new MockERC20("Test Token", "TT", 18); vault = new ERC4626Vault(asset); // Create 3 actors actors.push(address(0x10001)); actors.push(address(0x10002)); actors.push(address(0x10003)); // Fund actors and approve vault for (uint256 i = 0; i < actors.length; i++) { asset.mint(actors[i], 1_000_000e18); vm.prank(actors[i]); asset.approve(address(vault), type(uint256).max); } // Seed initial state to avoid empty-vault edge cases asset.mint(address(this), 10_000e18); asset.approve(address(vault), type(uint256).max); vault.deposit(10_000e18, address(this)); } } ``` We seed the vault with an initial deposit to avoid the "first depositor" edge case, where an attacker can manipulate an empty vault's share price. Your real contract should have its own protection against this, and the fuzzer will test that too. ## Writing properties Each property is a view function that returns `bool`. The fuzzer calls these after every transaction and reports a violation if any returns `false`. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Setup} from "./Setup.sol"; abstract contract Properties is Setup { // P-1: Solvency — vault holds enough assets function invariant_solvency() public view returns (bool) { return asset.balanceOf(address(vault)) >= vault.totalAssets(); } // P-2: No free shares — totalSupply == 0 iff totalAssets == 0 function invariant_noFreeShares() public view returns (bool) { if (vault.totalSupply() == 0) { return vault.totalAssets() == 0; } return vault.totalAssets() > 0; } // P-3: Preview deposit doesn't overstate function invariant_previewDepositSafe() public view returns (bool) { // previewDeposit should round down (fewer shares = safer for vault) uint256 assets = 1e18; uint256 previewShares = vault.previewDeposit(assets); uint256 convertShares = vault.convertToShares(assets); // previewDeposit must return <= convertToShares for safety return previewShares <= convertShares + 1; // +1 for rounding tolerance } // P-4: Preview redeem doesn't overstate function invariant_previewRedeemSafe() public view returns (bool) { if (vault.totalSupply() == 0) return true; uint256 shares = 1e18; uint256 previewAssets = vault.previewRedeem(shares); uint256 convertAssets = vault.convertToAssets(shares); return previewAssets <= convertAssets + 1; } // P-5: Exchange rate monotonicity (tracked across calls) uint256 internal lastExchangeRate; function invariant_exchangeRateMonotonic() public returns (bool) { if (vault.totalSupply() == 0) return true; uint256 currentRate = vault.convertToAssets(1e18); if (lastExchangeRate != 0 && currentRate < lastExchangeRate) { return false; } lastExchangeRate = currentRate; return true; } } ``` Note that P-5 is stateful: it tracks the exchange rate across calls and flags any decrease. This is the kind of property that [stateful fuzzing](/blog/stateful-fuzzing-explained-sequence-matters) handles well and stateless fuzzing can't check at all. ## Writing target functions Target functions (handlers) are what the fuzzer actually calls. Each handler wraps a vault operation with bounded inputs. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Properties} from "./Properties.sol"; abstract contract TargetFunctions is Properties { // --- Core vault operations --- function handler_deposit(uint256 actorSeed, uint256 assets) public { address actor = actors[actorSeed % actors.length]; uint256 maxDep = vault.maxDeposit(actor); if (maxDep == 0) return; assets = bound(assets, 1, min(maxDep, asset.balanceOf(actor))); if (assets == 0) return; vm.prank(actor); vault.deposit(assets, actor); } function handler_mint(uint256 actorSeed, uint256 shares) public { address actor = actors[actorSeed % actors.length]; uint256 maxMint = vault.maxMint(actor); if (maxMint == 0) return; shares = bound(shares, 1, maxMint); uint256 assetsNeeded = vault.previewMint(shares); if (assetsNeeded > asset.balanceOf(actor)) return; vm.prank(actor); vault.mint(shares, actor); } function handler_withdraw(uint256 actorSeed, uint256 assets) public { address actor = actors[actorSeed % actors.length]; uint256 maxWith = vault.maxWithdraw(actor); if (maxWith == 0) return; assets = bound(assets, 1, maxWith); vm.prank(actor); vault.withdraw(assets, actor, actor); } function handler_redeem(uint256 actorSeed, uint256 shares) public { address actor = actors[actorSeed % actors.length]; uint256 maxRedeem = vault.maxRedeem(actor); if (maxRedeem == 0) return; shares = bound(shares, 1, maxRedeem); vm.prank(actor); vault.redeem(shares, actor, actor); } // --- Yield simulation --- function handler_simulateYield(uint256 yieldAmount) public { yieldAmount = bound(yieldAmount, 1, 100_000e18); asset.mint(address(vault), yieldAmount); } // --- Time manipulation --- function handler_warpTime(uint256 delta) public { delta = bound(delta, 1, 365 days); vm.warp(block.timestamp + delta); } // --- Round-trip check (per-call property) --- function handler_depositWithdrawRoundTrip( uint256 actorSeed, uint256 assets ) public { address actor = actors[actorSeed % actors.length]; assets = bound(assets, 1e6, asset.balanceOf(actor) / 2); if (assets == 0) return; uint256 balanceBefore = asset.balanceOf(actor); vm.startPrank(actor); uint256 shares = vault.deposit(assets, actor); uint256 assetsBack = vault.redeem(shares, actor, actor); vm.stopPrank(); // Should not profit from round-trip assert(asset.balanceOf(actor) <= balanceBefore); } function min(uint256 a, uint256 b) internal pure returns (uint256) { return a < b ? a : b; } } ``` A few things to notice: - **`handler_simulateYield`** mints tokens directly to the vault, simulating yield accrual. This lets the fuzzer explore states where the exchange rate has changed. - **`handler_warpTime`** advances the block timestamp, which matters for vaults with time-dependent mechanics. - **`handler_depositWithdrawRoundTrip`** is an inline assertion property: it checks the round-trip invariant on every call, not just at the end of the sequence. ## Running the campaign ## Foundry (quick smoke test) ```bash forge test --match-contract CryticTester --fuzz-runs 10000 ``` This takes under a minute and catches basic violations. Good for development iteration. ## Medusa (broad coverage) ```json { "fuzzing": { "targetContracts": ["CryticTester"], "testLimit": 500000, "callSequenceLength": 100, "workers": 8, "corpusDirectory": "corpus-medusa" } } ``` ```bash medusa fuzz ``` Medusa's parallel workers will explore the state space fast. Check the corpus directory afterward for coverage reports. ## Echidna (deep exploration) ```yaml # echidna.yaml testMode: assertion testLimit: 2000000 seqLen: 150 corpusDir: "corpus-echidna" ``` ```bash echidna . --contract CryticTester --config echidna.yaml ``` Echidna's directed exploration and sequence shrinking give you the cleanest reproducers when a property fails. For a deep dive on when to use each, see our [Echidna vs Medusa comparison](/blog/echidna-vs-medusa-practical-comparison). ## Interpreting results When a property fails, you'll get a call sequence that triggered the violation. Here's what to look for. ## Solvency violations If `invariant_solvency` fails, the vault owes more to shareholders than it holds. Common causes: - **Rounding in the wrong direction.** Withdrawal calculations that round up instead of down, giving users more assets than their shares are worth. - **Fee accounting bugs.** Fees that are subtracted from the vault's balance but not from `totalAssets()`. - **Direct transfers.** Someone sends assets directly to the vault without going through `deposit()`, inflating the exchange rate, then another user withdraws at the inflated rate. ## Exchange rate manipulation If `invariant_exchangeRateMonotonic` fails, the exchange rate decreased without a legitimate loss event. This is how share inflation attacks work: the attacker drives the rate up, then crashes it to steal from other depositors. See our [rounding errors deep dive](/blog/rounding-errors-defi-small-bugs-big-exploits) for the mechanics. ## Round-trip profit If `handler_depositWithdrawRoundTrip` fails, a user can deposit and immediately withdraw for a net profit. This is directly exploitable. An attacker loops the operation to drain the vault. Look at the fuzzer's call sequence to understand what state the vault was in when the violation occurred. The sequence of `handler_simulateYield` and `handler_warpTime` calls leading up to the failure tells you the preconditions. Echidna's shrinking will reduce this to the minimal reproducer. ## Beyond the basics Once your core ERC-4626 properties are passing, consider these next steps: - **Add protocol-specific properties.** Fee mechanisms, caps, whitelists, timelocks, anything your vault adds on top of the standard. - **Test upgrade paths.** If your vault is upgradeable, include a handler that simulates the upgrade and check that all properties still hold. - **Run in CI.** Set up [continuous fuzzing](/blog/continuous-security-testing-cicd-fuzzing) so every PR gets fuzzed against your invariant suite. - **Async vaults.** If your vault uses ERC-7540 async deposits and redemptions, see our [ERC-7540 fuzzing guide](/blog/how-to-fuzz-erc7540-async-vaults) for the additional lifecycle invariants. - **Graduate to Recon Pro.** For production vaults handling significant TVL, [Recon Pro](/recon-pro) runs multi-day cloud campaigns that go far deeper than local runs. The ERC-4626 standard gives you a clean property specification to test against. That's rare — most protocols require custom property design from scratch. Take advantage of it. If you're building an ERC-4626 vault and want experts to build a complete invariant suite for your specific implementation, [request an audit with Recon](/request-audit). We've fuzzed dozens of vault implementations and know exactly where they break. --- ### Foundry fuzz testing: from basic fuzzing to invariant tests **Author:** deivitto | **Published:** 2025-10-20 | **Reading time:** 11 min **Tags:** fuzzing, invariant-testing, smart-contract-security, stateful-fuzzing **URL:** https://getrecon.xyz/blog/foundry-fuzz-testing-guide # Foundry fuzz testing: from basic fuzzing to invariant tests Foundry is the development framework most Solidity teams use. Its built-in fuzzer runs inside `forge test`, which means you can start fuzz testing with zero additional setup. But Foundry's fuzzing capabilities go well beyond what most developers use — from basic input fuzzing to stateful invariant tests to a bridge into dedicated fuzzing tools. This guide covers the full spectrum. We'll start with the basics, build up to invariant tests, and show you where Foundry's built-in fuzzing ends and tools like [Echidna](/learn/echidna) and [Medusa](/learn/medusa) begin. Get expert fuzzing for your protocol ## Foundry fuzz basics A Foundry fuzz test is any test function that takes parameters. Instead of calling the function with a single hardcoded value, the fuzzer generates hundreds or thousands of random inputs and runs the test with each one. ```solidity // Standard unit test: one input function test_sqrt() public { assertEq(Math.sqrt(4), 2); assertEq(Math.sqrt(9), 3); assertEq(Math.sqrt(0), 0); } // Fuzz test: thousands of inputs function testFuzz_sqrt(uint256 x) public { uint256 result = Math.sqrt(x); // Property: result^2 <= x < (result+1)^2 assert(result * result <= x); if (result < type(uint128).max) { assert((result + 1) * (result + 1) > x); } } ``` The first test checks three cases you thought of. The second checks every case the fuzzer generates. If there's any input where `sqrt` violates the property, the fuzzer will find it and give you the exact value that failed. ## Running fuzz tests ```bash # Run all tests including fuzz tests forge test # Control the number of fuzz runs forge test --fuzz-runs 10000 # Run a specific fuzz test forge test --match-test testFuzz_sqrt --fuzz-runs 50000 ``` The default run count is 256. For development iteration, that's fine. For pre-merge checks, bump it to 10,000+. For serious security testing, you'll want 100,000 or more, or a different tool entirely (more on that below). ## Bounding inputs Raw random `uint256` values often hit reverts before reaching interesting code paths. Foundry provides `bound()` to constrain inputs to a useful range without excluding edge cases. ```solidity function testFuzz_deposit(uint256 amount) public { // Bad: most random uint256 values will exceed balance and revert // vault.deposit(amount); // Good: constrain to a valid range amount = bound(amount, 1, token.balanceOf(address(this))); vault.deposit(amount); assertGt(vault.balanceOf(address(this)), 0); } ``` `bound()` maps the random value into your range using modular arithmetic, but the fuzzer's coverage guidance still works because different random inputs still map to different bounded values. ## Assumptions vs bounds You might see `vm.assume()` used to filter inputs: ```solidity function testFuzz_divide(uint256 a, uint256 b) public { vm.assume(b != 0); // Skip runs where b == 0 uint256 result = a / b; assert(result <= a); } ``` `vm.assume()` rejects inputs that don't match the condition. This wastes fuzzer runs. Every rejected input is a run that didn't test anything. Prefer `bound()` when you can: ```solidity function testFuzz_divide(uint256 a, uint256 b) public { b = bound(b, 1, type(uint256).max); // No wasted runs uint256 result = a / b; assert(result <= a); } ``` Use `vm.assume()` only when the constraint is hard to express as a bound, like requiring that two addresses be different. ## Writing fuzz tests that find real bugs The power of a fuzz test is entirely in the property you check. Weak properties find nothing. Strong properties find bugs that manual testing misses. ## Input/output properties The simplest pattern: for any valid input, the output satisfies some condition. ```solidity function testFuzz_wrapUnwrap(uint256 amount) public { amount = bound(amount, 1, type(uint128).max); uint256 wrapped = wrapper.wrap(amount); uint256 unwrapped = wrapper.unwrap(wrapped); // Round-trip should not increase value assertLe(unwrapped, amount); // Round-trip loss should be bounded assertGe(unwrapped, amount - 1); // At most 1 wei rounding } ``` ## Before/after properties Check that a state transition preserves some relationship. ```solidity function testFuzz_transferPreservesTotal( address to, uint256 amount ) public { vm.assume(to != address(0) && to != address(this)); amount = bound(amount, 0, token.balanceOf(address(this))); uint256 totalBefore = token.balanceOf(address(this)) + token.balanceOf(to); token.transfer(to, amount); uint256 totalAfter = token.balanceOf(address(this)) + token.balanceOf(to); assertEq(totalAfter, totalBefore); } ``` ## Comparative properties Compare two implementations or two code paths that should produce the same result. ```solidity function testFuzz_previewMatchesActual(uint256 assets) public { assets = bound(assets, 1, vault.maxDeposit(address(this))); if (assets == 0) return; uint256 preview = vault.previewDeposit(assets); uint256 actual = vault.deposit(assets, address(this)); // Preview should be accurate (or conservative) assertLe(actual, preview); } ``` These comparative properties are particularly good for [ERC-4626 vaults](/blog/how-to-fuzz-erc4626-vaults), where the standard requires preview functions to match actual behavior. ## Invariant test mode This is where Foundry fuzzing gets serious. Invariant tests go beyond single-function testing. Foundry generates random *sequences* of function calls against your contracts and checks properties after each call. ```solidity // Invariant test contract contract VaultInvariantTest is Test { MockERC20 token; Vault vault; function setUp() public { token = new MockERC20("Test", "T", 18); vault = new Vault(address(token)); token.mint(address(this), 1_000_000e18); token.approve(address(vault), type(uint256).max); // Tell Foundry which contract to call targetContract(address(vault)); } // Checked after every random call sequence function invariant_solvency() public view { assertGe( token.balanceOf(address(vault)), vault.totalAssets() ); } function invariant_sharePriceNonZero() public view { if (vault.totalSupply() > 0) { assertGt(vault.convertToAssets(1e18), 0); } } } ``` ## Configuring invariant tests ```toml # foundry.toml [invariant] runs = 256 # Number of sequences to generate depth = 50 # Calls per sequence fail-on-revert = false # Don't fail on reverts (expected in fuzzing) ``` ```bash forge test --match-contract VaultInvariantTest ``` ## Handler contracts By default, Foundry calls random functions with random inputs on your target contract. Most of these calls revert because the inputs are nonsensical. Handler contracts solve this by wrapping target functions with bounded inputs. ```solidity contract VaultHandler is Test { Vault vault; MockERC20 token; constructor(Vault _vault, MockERC20 _token) { vault = _vault; token = _token; } function deposit(uint256 amount) public { amount = bound(amount, 1, token.balanceOf(address(this))); if (amount == 0) return; vault.deposit(amount, address(this)); } function withdraw(uint256 amount) public { uint256 maxWith = vault.maxWithdraw(address(this)); if (maxWith == 0) return; amount = bound(amount, 1, maxWith); vault.withdraw(amount, address(this), address(this)); } } ``` ```solidity // In your test's setUp: handler = new VaultHandler(vault, token); token.mint(address(handler), 1_000_000e18); vm.prank(address(handler)); token.approve(address(vault), type(uint256).max); targetContract(address(handler)); ``` Now the fuzzer calls `handler.deposit()` and `handler.withdraw()` with random arguments, and the handler ensures the arguments are valid. The revert rate drops from 90%+ to near zero, which means almost every fuzzer run tests something meaningful. ## Limitations of Foundry-only fuzzing Foundry's fuzzer is great for development-speed property checks. But it has real limitations for serious security testing. ## No corpus persistence Every `forge test` run starts from scratch. The fuzzer doesn't save interesting inputs between runs. If you run a 10,000-iteration campaign today and another tomorrow, the second run doesn't benefit from the first. [Echidna](/learn/echidna) and [Medusa](/learn/medusa) both persist their corpus to disk. Over time, the corpus grows and the fuzzer starts each run already knowing how to reach deep states. This is the single biggest advantage of dedicated fuzzers. ## Limited sequence quality Foundry's invariant test mode generates random call sequences, but it doesn't do the kind of directed exploration that Echidna does. Echidna builds sequences incrementally: if it finds that `deposit → borrow` reaches new code, it tries `deposit → borrow → X` for many values of X. Foundry just generates random sequences of the configured depth. For shallow bugs, this doesn't matter. For bugs that require a specific 6-8 call sequence with specific parameters, Echidna's directed approach is significantly more effective. ## Single-threaded Foundry's fuzzer runs on a single thread. [Medusa](/learn/medusa) can run 8+ parallel workers sharing coverage data. For the same wall-clock time, Medusa explores roughly 3-4x more of the state space. ## No optimization testing Medusa supports optimization mode. Instead of just checking if a property is true or false, it can minimize or maximize a numeric value. This is useful for finding worst-case scenarios: "what's the maximum amount an attacker can extract in a single transaction?" ## Sequence shrinking When Foundry finds a failing invariant, it reports the raw call sequence. Echidna aggressively shrinks failing sequences to the minimal reproducer — often reducing a 50-call sequence to 3-4 calls. This makes debugging dramatically easier. ## When to graduate to Echidna or Medusa Here's our rule of thumb at Recon: **Stay with Foundry** when: - You're writing fuzz tests during development for fast iteration - You're testing pure functions or simple state transitions - You need sub-minute feedback in CI - Your properties are stateless (single function, random inputs) **Add Echidna or Medusa** when: - Your protocol has multi-step flows (deposit → borrow → liquidate) - You need to find bugs that require specific transaction sequences - You want corpus persistence across runs - You need deeper exploration (500K+ tests) - You're preparing for an audit or deployment For a detailed comparison of these tools, see our [fuzzing tools comparison](/blog/smart-contract-fuzzing-tools-compared) and our [Echidna vs Medusa head-to-head](/blog/echidna-vs-medusa-practical-comparison). ## The Chimera bridge The [Chimera framework](/learn/chimera-framework) bridges Foundry and dedicated fuzzers. Write your properties once with Chimera's interface, and they'll run on Foundry (`forge test`), Echidna, and Medusa without modification. ```solidity // Works with Foundry, Echidna, AND Medusa contract CryticTester is TargetFunctions, CryticAsserts { constructor() { setup(); } } ``` This is how we structure every engagement at Recon. Properties start in Foundry for fast iteration, then the same properties run on Echidna and Medusa for deep exploration. No rewriting, no maintaining two test suites. The workflow: 1. Write properties and handlers using Chimera's base contracts 2. Run with `forge test` during development for instant feedback 3. Run with Medusa for quick parallel coverage, 500K tests in minutes 4. Run with Echidna for deep directed exploration, multi-hour campaigns 5. Run everything in [Recon Pro](/recon-pro) for cloud-scale campaigns Each step builds on the one before. Your time investment in writing good properties pays off across every tool. ## Getting started today If you're already using Foundry, you're 10 minutes away from your first fuzz test: 1. Pick a function with a clear property (round-trip, conservation, bounds) 2. Add a parameter to your test function 3. Use `bound()` to constrain it 4. Replace `assertEq` with the property that should hold for all inputs 5. Run `forge test --fuzz-runs 10000` When you're ready for stateful testing, add invariant tests with handler contracts. When you outgrow Foundry's fuzzer, install [Chimera](/learn/chimera-framework) and your existing properties will run on Echidna and Medusa without changes. If you want a team that's built invariant test suites for dozens of protocols to set up your fuzzing infrastructure, [request an audit with Recon](/request-audit). We'll build the properties, run the campaigns, and leave you with a test suite that keeps working long after the engagement ends. --- ### How to fuzz ERC-6909 multi-token contracts **Author:** deivitto | **Published:** 2025-12-01 | **Reading time:** 12 min **Tags:** fuzzing, invariant-testing, erc-6909, smart-contract-security **URL:** https://getrecon.xyz/blog/how-to-fuzz-erc6909-multi-token # How to fuzz ERC-6909 multi-token contracts ERC-6909 is a minimal multi-token standard designed to replace the complexity of ERC-1155 with a cleaner, gas-efficient interface. Uniswap v4 adopted it for its pool accounting, and the standard is gaining traction across DeFi. Each ERC-6909 contract manages multiple token IDs through a single deployment, with per-ID balances, per-ID allowances, and a separate operator approval system. This dual-approval model and the shared-state design create a class of bugs that single-token testing completely misses. Fuzzing is the right tool here because the bugs live in interactions between token IDs, between approval types, and across sequences of operations that no developer would write by hand. This guide walks through identifying the invariants that matter, writing properties with [Chimera](/learn/chimera-framework), building handlers that exercise the full multi-token surface, and running campaigns. Get expert multi-token fuzzing ## What ERC-6909 guarantees The [EIP-6909 specification](https://eips.ethereum.org/EIPS/eip-6909) defines a multi-token interface with two distinct approval mechanisms. An `approve()` call grants a spender a specific allowance for a single token ID, while `setOperator()` grants blanket permission across all IDs. The standard also defines `transfer()` for direct sends and `transferFrom()` for delegated transfers. The guarantees that matter most for security: 1. **Per-ID accounting.** Each token ID has independent balances. A transfer of ID 1 must not change any balance of ID 2. 2. **Supply conservation.** For each token ID, the sum of all holder balances must equal the total supply. Tokens cannot appear or disappear. 3. **Allowance consumption.** A `transferFrom()` using per-ID allowance must deduct the transferred amount from the caller's allowance, unless the allowance is set to `type(uint256).max`. 4. **Operator scope.** An operator can transfer any amount of any token ID on behalf of the owner, without consuming per-ID allowances. 5. **No zero-address holdings.** Tokens must not be transferable to or held by the zero address. These look straightforward, but custom mint/burn logic, fee-on-transfer variants, and callback extensions break them regularly. The interaction between operator approvals and per-ID allowances is where most bugs hide. ## Key invariants Before writing code, we need to define the properties that should hold after every transaction, regardless of what sequence the fuzzer generates. ## Per-ID supply conservation The most critical invariant. For every token ID, the sum of all balances must equal the total supply tracked by the contract. ``` sum(balanceOf(actor, id) for all actors) == totalSupply(id) ``` We track expected supplies using ghost variables that mirror every mint, burn, and transfer. Any divergence between the ghost and the contract means tokens appeared or vanished outside the expected paths. ## Cross-ID isolation A transfer of token ID `i` must not change any balance of token ID `j`. This sounds obvious, but implementations that pack multiple IDs into shared storage slots can violate it through bit-masking errors. ``` balanceOf(actor, j)_before == balanceOf(actor, j)_after // for j != i ``` ## Allowance consumption correctness When a non-operator spender calls `transferFrom()`, the allowance must decrease by the transferred amount. The exception is infinite approval (`type(uint256).max`), which must not be consumed. ``` allowance_after == allowance_before - amount // unless infinite ``` ## Operator vs per-ID approval scope An operator (`isOperator(owner, spender) == true`) should be able to transfer without any per-ID allowance. Conversely, revoking operator status must not affect existing per-ID allowances. ## No zero-address holdings Transfers to `address(0)` should revert. The zero address must never hold a positive balance for any token ID. ## Setting up Chimera We use the [Chimera framework](/learn/chimera-framework) so our properties run on Foundry, [Echidna](/learn/echidna), and [Medusa](/learn/medusa) without changes. If you haven't set up Chimera before, our [beginner's guide](/blog/zero-to-fuzzing-beginners-guide-chimera) covers the full installation. The file structure: ``` test/ invariants/ Setup.sol // Deploy token, fund actors, init ghost state TargetFunctions.sol // Handler functions the fuzzer calls Properties.sol // Invariant checks CryticTester.sol // Entrypoint for Echidna/Medusa ``` ## Setup ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {MockERC6909} from "./mocks/MockERC6909.sol"; import {BaseSetup} from "@chimera/BaseSetup.sol"; abstract contract Setup is BaseSetup { MockERC6909 internal token; address[] internal actors; uint256[] internal tokenIds; // Ghost state for supply tracking mapping(uint256 => uint256) internal ghostTotalSupply; function setup() internal virtual override { token = new MockERC6909(); tokenIds.push(1); tokenIds.push(2); tokenIds.push(3); actors.push(address(0x10001)); actors.push(address(0x10002)); actors.push(address(0x10003)); // Mint initial balances for each actor and each ID for (uint256 i = 0; i < actors.length; i++) { for (uint256 j = 0; j < tokenIds.length; j++) { token.mint(actors[i], tokenIds[j], 1_000_000e18); ghostTotalSupply[tokenIds[j]] += 1_000_000e18; } } } } ``` We seed three token IDs and three actors. The ghost mapping `ghostTotalSupply` tracks expected supply for each ID. Every handler that mints or burns must update the ghost in lockstep, so any mismatch points directly to a contract-level accounting bug. ## Writing properties Each property is a view function returning `bool`. The fuzzer calls these after every transaction and flags a violation if any returns `false`. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Setup} from "./Setup.sol"; abstract contract Properties is Setup { // P-1: Per-ID supply conservation function invariant_supplyConservation() public view returns (bool) { for (uint256 i = 0; i < tokenIds.length; i++) { uint256 id = tokenIds[i]; uint256 balanceSum = 0; for (uint256 j = 0; j < actors.length; j++) { balanceSum += token.balanceOf(actors[j], id); } // Include contract's own balance if applicable balanceSum += token.balanceOf(address(this), id); if (balanceSum != ghostTotalSupply[id]) return false; } return true; } // P-2: Cross-ID isolation (tracked per call in handlers) // See handler_transferCheckIsolation below // P-3: No zero-address holdings function invariant_noZeroAddressBalance() public view returns (bool) { for (uint256 i = 0; i < tokenIds.length; i++) { if (token.balanceOf(address(0), tokenIds[i]) > 0) return false; } return true; } // P-4: Operator approval does not grant per-ID allowance function invariant_operatorDoesNotGrantAllowance() public view returns (bool) { for (uint256 i = 0; i < actors.length; i++) { for (uint256 j = 0; j < actors.length; j++) { if (i == j) continue; if (!token.isOperator(actors[i], actors[j])) continue; // Being an operator should not increase per-ID allowance for (uint256 k = 0; k < tokenIds.length; k++) { uint256 allowance = token.allowance(actors[i], actors[j], tokenIds[k]); // Unless explicitly approved, allowance should be 0 // This checks the separation of concerns if (allowance > 0 && allowance != type(uint256).max) { // Non-zero allowance is fine if it was explicitly set // We can't distinguish here, so this is a weaker check } } } } return true; } // P-5: Supply matches ghost function invariant_supplyMatchesGhost() public view returns (bool) { for (uint256 i = 0; i < tokenIds.length; i++) { uint256 id = tokenIds[i]; if (token.totalSupply(id) != ghostTotalSupply[id]) return false; } return true; } } ``` P-1 is the core conservation check. It sums balances across all known actors for each token ID and compares against the ghost. P-3 verifies the zero-address restriction. P-5 cross-checks the contract's own `totalSupply()` against our independent ghost tracker. For cross-ID isolation (P-2), we check it inline in the transfer handler, since we need before/after snapshots. ## Writing target functions Target functions are what the fuzzer actually calls. Each handler wraps a token operation with bounded inputs. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Properties} from "./Properties.sol"; abstract contract TargetFunctions is Properties { function handler_transfer(uint256 actorSeed, uint256 idSeed, uint256 amount) public { address from = actors[actorSeed % actors.length]; uint256 id = tokenIds[idSeed % tokenIds.length]; address to = actors[(actorSeed / 3) % actors.length]; uint256 bal = token.balanceOf(from, id); if (bal == 0) return; amount = bound(amount, 1, bal); vm.prank(from); token.transfer(to, id, amount); } function handler_transferFrom( uint256 actorSeed, uint256 idSeed, uint256 amount ) public { address spender = actors[actorSeed % actors.length]; address from = actors[(actorSeed / 3) % actors.length]; address to = actors[(actorSeed / 9) % actors.length]; uint256 id = tokenIds[idSeed % tokenIds.length]; uint256 bal = token.balanceOf(from, id); uint256 allowed = token.allowance(from, spender, id); bool isOp = token.isOperator(from, spender); if (bal == 0) return; if (!isOp && allowed == 0) return; uint256 maxTransfer = isOp ? bal : min(bal, allowed); amount = bound(amount, 1, maxTransfer); vm.prank(spender); token.transferFrom(from, to, id, amount); } function handler_approve( uint256 actorSeed, uint256 idSeed, uint256 amount ) public { address owner = actors[actorSeed % actors.length]; address spender = actors[(actorSeed / 3) % actors.length]; uint256 id = tokenIds[idSeed % tokenIds.length]; amount = bound(amount, 0, type(uint256).max); vm.prank(owner); token.approve(spender, id, amount); } function handler_setOperator(uint256 actorSeed, bool approved) public { address owner = actors[actorSeed % actors.length]; address operator = actors[(actorSeed / 3) % actors.length]; vm.prank(owner); token.setOperator(operator, approved); } function handler_mint(uint256 actorSeed, uint256 idSeed, uint256 amount) public { address to = actors[actorSeed % actors.length]; uint256 id = tokenIds[idSeed % tokenIds.length]; amount = bound(amount, 1, 100_000e18); token.mint(to, id, amount); ghostTotalSupply[id] += amount; } function handler_burn(uint256 actorSeed, uint256 idSeed, uint256 amount) public { address from = actors[actorSeed % actors.length]; uint256 id = tokenIds[idSeed % tokenIds.length]; uint256 bal = token.balanceOf(from, id); if (bal == 0) return; amount = bound(amount, 1, bal); token.burn(from, id, amount); ghostTotalSupply[id] -= amount; } // Inline property: cross-ID isolation function handler_transferCheckIsolation( uint256 actorSeed, uint256 idSeed, uint256 amount ) public { address from = actors[actorSeed % actors.length]; address to = actors[(actorSeed / 3) % actors.length]; uint256 transferId = tokenIds[idSeed % tokenIds.length]; uint256 bal = token.balanceOf(from, transferId); if (bal == 0) return; amount = bound(amount, 1, bal); // Snapshot balances for other IDs uint256[] memory beforeFrom = new uint256[](tokenIds.length); uint256[] memory beforeTo = new uint256[](tokenIds.length); for (uint256 i = 0; i < tokenIds.length; i++) { beforeFrom[i] = token.balanceOf(from, tokenIds[i]); beforeTo[i] = token.balanceOf(to, tokenIds[i]); } vm.prank(from); token.transfer(to, transferId, amount); // Verify other IDs are unchanged for (uint256 i = 0; i < tokenIds.length; i++) { if (tokenIds[i] == transferId) continue; assert(token.balanceOf(from, tokenIds[i]) == beforeFrom[i]); if (from != to) { assert(token.balanceOf(to, tokenIds[i]) == beforeTo[i]); } } } function min(uint256 a, uint256 b) internal pure returns (uint256) { return a < b ? a : b; } } ``` A few things to notice: - **`handler_mint` and `handler_burn`** update the ghost mapping in lockstep with the contract call. If the ghost drifts from the contract, `invariant_supplyMatchesGhost` catches it. - **`handler_transferCheckIsolation`** snapshots all token ID balances before a transfer, executes it, then asserts that non-transferred IDs are untouched. This is the cross-ID isolation check that static invariants can't express. - **`handler_setOperator`** exercises the operator approval path, letting the fuzzer explore sequences where operator status changes mid-campaign. ## Running the campaign ## Foundry (quick smoke test) ```bash forge test --match-contract CryticTester --fuzz-runs 10000 ``` This takes under a minute and catches the most obvious violations. Good for iteration during development. ## Medusa (broad coverage) ```json { "fuzzing": { "targetContracts": ["CryticTester"], "testLimit": 500000, "callSequenceLength": 100, "workers": 8, "corpusDirectory": "corpus-medusa" } } ``` ```bash medusa fuzz ``` Medusa's parallel workers explore the multi-token state space fast. With three token IDs and three actors, the combinatorial surface is large enough that parallel exploration matters. ## Echidna (deep exploration) ```yaml # echidna.yaml testMode: assertion testLimit: 2000000 seqLen: 150 corpusDir: "corpus-echidna" ``` ```bash echidna . --contract CryticTester --config echidna.yaml ``` Echidna's directed exploration and sequence shrinking give you the cleanest reproducers. For multi-token contracts, the shrunk sequences reveal exactly which combination of IDs and operations triggered the violation. For a deep dive on when to use each fuzzer, see our [Echidna vs Medusa comparison](/blog/echidna-vs-medusa-practical-comparison). ## Interpreting results When a property fails, you get a call sequence that triggered the violation. Here's what to look for with ERC-6909 contracts. ## Supply conservation failures If `invariant_supplyConservation` or `invariant_supplyMatchesGhost` fails, tokens are being created or destroyed outside of mint/burn paths. Common causes: - **Unchecked arithmetic in transfer logic.** A subtraction that wraps around, inflating the sender's balance. - **Mint/burn callbacks that re-enter and double-count.** If your implementation has transfer hooks, a callback might trigger a second mint before the first completes. - **Storage packing errors.** Implementations that pack multiple token ID balances into a single slot can corrupt adjacent IDs during writes. ## Cross-ID isolation failures If `handler_transferCheckIsolation` asserts false, a transfer of one token ID changed the balance of another. This is almost always a storage layout bug. Look at how the implementation stores balances. If it uses a flat mapping (`mapping(uint256 => mapping(address => uint256))`), isolation should hold naturally. If it uses packed storage or custom encoding, the packing logic is the first place to check. ## Operator/allowance confusion If the fuzzer finds a sequence where a non-operator, zero-allowance address successfully calls `transferFrom()`, the approval check logic has a bug. Look for conditions where the implementation combines `isOperator()` and `allowance()` checks with the wrong boolean logic. ## Beyond the basics Once your core ERC-6909 properties are passing, consider these extensions. - **Add protocol-specific IDs.** If your token IDs represent specific assets (like Uniswap v4 pool positions), add handlers that create new IDs dynamically and verify properties hold for IDs the fuzzer creates. - **Test operator revocation sequences.** Add handlers that toggle operator status mid-sequence and verify that previously authorized transfers now revert. - **Combine with downstream protocols.** If your ERC-6909 token is used as collateral elsewhere, include the downstream protocol in the setup and verify cross-protocol invariants. - **Run in CI.** Set up [continuous fuzzing](/blog/continuous-security-testing-cicd-fuzzing) so every PR gets fuzzed. - **ERC-1155 comparison.** For ERC-1155 implementations, see our [ERC-1155 fuzzing guide](/blog/how-to-fuzz-erc1155-multi-token) which covers batch atomicity and callback safety — properties that don't apply to ERC-6909 but matter if you're migrating between standards. ERC-6909's simplicity is deceptive. The dual approval system and multi-ID accounting give fuzzers a rich state space to explore, and that's exactly where the interesting bugs live. If you're building on ERC-6909 and want a complete invariant suite tailored to your implementation, [request an audit with Recon](/request-audit). We've built property suites for multi-token systems including Uniswap v4 integrations. --- ### How to fuzz ERC-7540 async vaults **Author:** deivitto | **Published:** 2025-12-29 | **Reading time:** 14 min **Tags:** fuzzing, invariant-testing, erc-7540, smart-contract-security **URL:** https://getrecon.xyz/blog/how-to-fuzz-erc7540-async-vaults # How to fuzz ERC-7540 async vaults ERC-7540 extends ERC-4626 with asynchronous deposit and redemption flows. Instead of instant in-and-out, users submit requests that get fulfilled later by an off-chain or on-chain operator. This async lifecycle is what protocols like Centrifuge use for real-world asset vaults where redemptions take days, not blocks. The problem is that async adds an entire state machine on top of the already tricky ERC-4626 accounting. Most of the new bugs hide in the transitions between request states. Fuzzing async vaults is harder than fuzzing standard vaults. The fuzzer needs to drive requests through their full lifecycle, simulate operator fulfillments at arbitrary times, and verify that the ERC-4626 guarantees still hold while assets are in transit. This guide covers the invariants, the [Chimera](/learn/chimera-framework) setup, and the handler patterns that make async vault fuzzing effective. Get expert async vault fuzzing ## What ERC-7540 guarantees The [EIP-7540 specification](https://eips.ethereum.org/EIPS/eip-7540) builds on ERC-4626 by adding request-based flows. A user calls `requestDeposit()` or `requestRedeem()` to submit a request. The vault operator later fulfills requests, moving them from "pending" to "claimable." The user then claims via the standard `deposit()` / `mint()` / `withdraw()` / `redeem()` functions. The guarantees that matter most for security: 1. **Lifecycle ordering.** A request must go through pending → claimable → claimed. Skipping states breaks accounting. 2. **Pending >= claimable.** The pending amount for any controller must always be greater than or equal to the claimable amount. Claimable assets are a subset of pending. 3. **Solvency with in-transit assets.** The vault must remain solvent when accounting for both deposited assets and assets locked in pending requests. 4. **No value creation in round-trips.** Requesting a deposit and then cancelling (or requesting then claiming) must not produce a net profit. 5. **Cancellation returns exact amounts.** If a pending request is cancelled, the escrowed assets must return to the original owner with no loss or gain. 6. **Fulfillment correctness.** The operator's fulfillment must convert pending amounts to claimable amounts using a consistent exchange rate. We validated these invariants on the Centrifuge deployment, and Recon's [public ERC-7540 properties repo](https://github.com/Recon-Fuzz/erc7540-reusable-properties) implements them as reusable Chimera properties. This guide explains the reasoning behind those properties. ## Key invariants Before writing properties, we define what must hold true across every possible state of the async lifecycle. ## Request-claim lifecycle ordering Every request has a state: none → pending → claimable → claimed. Transitions must follow this order. A request that was never created must not be claimable. A request that's already claimed must not reappear as pending. ``` if pendingRequest(controller) == 0: claimableRequest(controller) == 0 ``` ## Pending >= claimable At any point, the pending amount for a controller must be at least as large as the claimable amount. Claimable is the portion of pending that the operator has fulfilled. ``` pendingDepositRequest(controller) >= claimableDepositRequest(controller) pendingRedeemRequest(controller) >= claimableRedeemRequest(controller) ``` ## Solvency with in-transit assets Standard ERC-4626 solvency checks don't account for assets locked in pending requests. The vault must remain solvent when we include escrowed assets in the accounting. ``` asset.balanceOf(vault) >= totalAssets() + totalPendingDeposits ``` ## No value creation in async round-trip A user who requests a deposit, waits for fulfillment, claims shares, then immediately redeems shouldn't profit. The async flow must not create value. ## Cancellation returns exact escrowed assets If the vault supports cancellation, cancelling a pending request must return the exact escrowed amount to the requester. No fees on cancellation (unless explicitly documented), no partial returns. ## What makes async vaults harder to fuzz Standard ERC-4626 fuzzing uses a simple pattern: the fuzzer calls deposit/withdraw/redeem directly. With ERC-7540, the fuzzer must drive a multi-step lifecycle: 1. **Submit requests** (`requestDeposit` / `requestRedeem`) 2. **Simulate operator fulfillment** (an external call that moves requests from pending to claimable) 3. **Claim** (standard ERC-4626 functions that consume claimable amounts) This means we need a handler for operator fulfillment. Without it, the fuzzer submits requests that never get fulfilled, and the interesting invariants around the claim step never get tested. We also need to track request IDs across the lifecycle. The fuzzer might submit multiple requests for the same controller, and we need to verify that partial fulfillments and interleaved claims don't break accounting. ## Setting up Chimera We use the [Chimera framework](/learn/chimera-framework) with an extended setup that includes the async lifecycle tracking. The [beginner's guide](/blog/zero-to-fuzzing-beginners-guide-chimera) covers basic installation. The file structure: ``` test/ invariants/ Setup.sol // Deploy vault + tokens, fund actors, init tracking TargetFunctions.sol // Request, fulfill, claim, cancel handlers Properties.sol // Lifecycle and solvency checks CryticTester.sol // Entrypoint for Echidna/Medusa ``` ## Setup ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {MockERC20} from "./mocks/MockERC20.sol"; import {ERC7540Vault} from "src/ERC7540Vault.sol"; import {BaseSetup} from "@chimera/BaseSetup.sol"; abstract contract Setup is BaseSetup { MockERC20 internal asset; ERC7540Vault internal vault; address[] internal actors; address internal operator; // Ghost tracking for pending/claimed amounts mapping(address => uint256) internal ghostPendingDeposits; mapping(address => uint256) internal ghostPendingRedeems; function setup() internal virtual override { asset = new MockERC20("Test Token", "TT", 18); vault = new ERC7540Vault(asset); operator = address(0xAAAA); actors.push(address(0x10001)); actors.push(address(0x10002)); actors.push(address(0x10003)); for (uint256 i = 0; i < actors.length; i++) { asset.mint(actors[i], 10_000_000e18); vm.prank(actors[i]); asset.approve(address(vault), type(uint256).max); } // Seed vault with initial deposit to avoid empty-vault edge cases asset.mint(address(this), 100_000e18); asset.approve(address(vault), type(uint256).max); vault.deposit(100_000e18, address(this)); } } ``` The operator address simulates the off-chain actor that fulfills requests. In production vaults like Centrifuge, this is a permissioned role that processes redemptions after real-world asset settlements complete. ## Writing properties ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Setup} from "./Setup.sol"; abstract contract Properties is Setup { // P-1: Pending >= claimable for deposits function invariant_pendingGeClaimableDeposit() public view returns (bool) { for (uint256 i = 0; i < actors.length; i++) { uint256 pending = vault.pendingDepositRequest(0, actors[i]); uint256 claimable = vault.claimableDepositRequest(0, actors[i]); if (pending < claimable) return false; } return true; } // P-2: Pending >= claimable for redeems function invariant_pendingGeClaimableRedeem() public view returns (bool) { for (uint256 i = 0; i < actors.length; i++) { uint256 pending = vault.pendingRedeemRequest(0, actors[i]); uint256 claimable = vault.claimableRedeemRequest(0, actors[i]); if (pending < claimable) return false; } return true; } // P-3: Solvency including escrowed assets function invariant_solvencyWithEscrow() public view returns (bool) { uint256 totalEscrowed = 0; for (uint256 i = 0; i < actors.length; i++) { totalEscrowed += vault.pendingDepositRequest(0, actors[i]); } return asset.balanceOf(address(vault)) >= vault.totalAssets(); } // P-4: No zero-pending claimable (lifecycle ordering) function invariant_lifecycleOrdering() public view returns (bool) { for (uint256 i = 0; i < actors.length; i++) { uint256 pendingDep = vault.pendingDepositRequest(0, actors[i]); uint256 claimableDep = vault.claimableDepositRequest(0, actors[i]); // If nothing is pending, nothing should be claimable if (pendingDep == 0 && claimableDep > 0) return false; uint256 pendingRed = vault.pendingRedeemRequest(0, actors[i]); uint256 claimableRed = vault.claimableRedeemRequest(0, actors[i]); if (pendingRed == 0 && claimableRed > 0) return false; } return true; } // P-5: Ghost pending matches contract pending function invariant_ghostPendingSync() public view returns (bool) { for (uint256 i = 0; i < actors.length; i++) { if (vault.pendingDepositRequest(0, actors[i]) != ghostPendingDeposits[actors[i]]) { return false; } } return true; } } ``` P-1 and P-2 are the core lifecycle invariants. P-3 verifies solvency accounts for all asset locations. P-4 enforces the state machine ordering. P-5 cross-checks the contract state against our independent ghost tracker. ## Writing target functions The handlers must cover the full async lifecycle: request, fulfill, claim, and cancel. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Properties} from "./Properties.sol"; abstract contract TargetFunctions is Properties { // --- Request phase --- function handler_requestDeposit(uint256 actorSeed, uint256 assets) public { address actor = actors[actorSeed % actors.length]; uint256 bal = asset.balanceOf(actor); if (bal == 0) return; assets = bound(assets, 1, bal / 2); vm.prank(actor); vault.requestDeposit(assets, actor, actor); ghostPendingDeposits[actor] += assets; } function handler_requestRedeem(uint256 actorSeed, uint256 shares) public { address actor = actors[actorSeed % actors.length]; uint256 bal = vault.balanceOf(actor); if (bal == 0) return; shares = bound(shares, 1, bal); vm.prank(actor); vault.requestRedeem(shares, actor, actor); ghostPendingRedeems[actor] += shares; } // --- Operator fulfillment --- function handler_fulfillDeposit(uint256 actorSeed) public { address controller = actors[actorSeed % actors.length]; uint256 pending = vault.pendingDepositRequest(0, controller); if (pending == 0) return; vm.prank(operator); vault.fulfillDeposit(controller, pending); } function handler_fulfillRedeem(uint256 actorSeed) public { address controller = actors[actorSeed % actors.length]; uint256 pending = vault.pendingRedeemRequest(0, controller); if (pending == 0) return; vm.prank(operator); vault.fulfillRedeem(controller, pending); } // --- Claim phase (standard ERC-4626 functions) --- function handler_claimDeposit(uint256 actorSeed) public { address actor = actors[actorSeed % actors.length]; uint256 claimable = vault.claimableDepositRequest(0, actor); if (claimable == 0) return; vm.prank(actor); vault.deposit(claimable, actor); ghostPendingDeposits[actor] -= claimable; } function handler_claimRedeem(uint256 actorSeed) public { address actor = actors[actorSeed % actors.length]; uint256 claimable = vault.claimableRedeemRequest(0, actor); if (claimable == 0) return; vm.prank(actor); vault.redeem(claimable, actor, actor); ghostPendingRedeems[actor] -= claimable; } // --- Time manipulation --- function handler_warpTime(uint256 delta) public { delta = bound(delta, 1, 30 days); vm.warp(block.timestamp + delta); } // --- Yield simulation --- function handler_simulateYield(uint256 amount) public { amount = bound(amount, 1, 100_000e18); asset.mint(address(vault), amount); } // --- Round-trip check --- function handler_requestClaimRoundTrip( uint256 actorSeed, uint256 assets ) public { address actor = actors[actorSeed % actors.length]; assets = bound(assets, 1e6, asset.balanceOf(actor) / 4); if (assets == 0) return; uint256 balanceBefore = asset.balanceOf(actor); // Request deposit vm.prank(actor); vault.requestDeposit(assets, actor, actor); // Simulate immediate fulfillment vm.prank(operator); vault.fulfillDeposit(actor, assets); // Claim shares vm.prank(actor); uint256 shares = vault.deposit(assets, actor); // Immediately redeem vm.prank(actor); vault.redeem(shares, actor, actor); // Should not profit from the round-trip assert(asset.balanceOf(actor) <= balanceBefore); } } ``` The critical handler here is `handler_fulfillDeposit` / `handler_fulfillRedeem`. Without these, the fuzzer would submit requests that sit in pending forever, and the claim-phase invariants would never fire. By giving the fuzzer control over when fulfillment happens, we test interleaved scenarios: partial fulfillments, fulfillments after yield accrual, fulfillments with stale exchange rates. The round-trip handler (`handler_requestClaimRoundTrip`) compresses the full lifecycle into a single call, verifying that the async flow doesn't create value even under ideal timing conditions. ## Running the campaign ## Foundry (quick smoke test) ```bash forge test --match-contract CryticTester --fuzz-runs 10000 ``` For async vaults, Foundry's default sequence length might not be enough to drive requests through the full lifecycle. Consider increasing runs if you see low property coverage. ## Medusa (broad coverage) ```json { "fuzzing": { "targetContracts": ["CryticTester"], "testLimit": 1000000, "callSequenceLength": 150, "workers": 8, "corpusDirectory": "corpus-medusa" } } ``` ```bash medusa fuzz ``` We increase `callSequenceLength` to 150 because the async lifecycle requires at least three calls (request → fulfill → claim) to complete one cycle. Longer sequences let the fuzzer explore multiple interleaved cycles. ## Echidna (deep exploration) ```yaml # echidna.yaml testMode: assertion testLimit: 3000000 seqLen: 200 corpusDir: "corpus-echidna" ``` ```bash echidna . --contract CryticTester --config echidna.yaml ``` Echidna's sequence shrinking is especially useful for async vaults. When a property fails, the shrunk sequence shows the minimal request/fulfill/claim pattern that triggers the bug. For more on choosing between fuzzers, see our [comparison guide](/blog/echidna-vs-medusa-practical-comparison). ## Interpreting results ## Lifecycle ordering violations If `invariant_lifecycleOrdering` fails, the vault allows claiming without prior fulfillment. This usually means the claim function doesn't properly check whether the operator has fulfilled the request. Look at the `deposit()` / `redeem()` overrides and verify they check `claimableDepositRequest()` before proceeding. ## Pending/claimable inconsistency If `invariant_pendingGeClaimableDeposit` fails, the claimable amount exceeds the pending amount. This can happen when: - **Fulfillment logic double-counts.** The operator fulfills a request, and the fulfillment function adds to claimable without properly tracking the pending reduction. - **Cancellation doesn't reduce claimable.** If a request is partially fulfilled and then cancelled, the claimable portion might not get cleared. ## Solvency with escrow If `invariant_solvencyWithEscrow` fails while standard solvency passes, the vault's accounting doesn't include escrowed assets. This is the most common class of bug in async vault implementations. The `totalAssets()` function must account for assets that entered the vault via `requestDeposit()` but haven't turned into shares yet. The [Centrifuge case study](/case-studies/centrifuge) documents a real instance of this class of bug found through exactly these properties. ## Beyond the basics Once your core ERC-7540 properties pass, consider: - **Test partial fulfillments.** Modify the fulfill handler to fulfill a random fraction of the pending amount, not just the full amount. - **Test multiple concurrent requests.** Have the fuzzer submit several requests from the same controller before the operator fulfills any, then fulfill them in different orders. - **Add ERC-4626 properties.** ERC-7540 vaults must still satisfy all ERC-4626 invariants. Layer the [ERC-4626 property suite](/blog/how-to-fuzz-erc4626-vaults) on top of the async properties. - **Use the public properties repo.** Recon's [ERC-7540 reusable properties](https://github.com/Recon-Fuzz/erc7540-reusable-properties) are battle-tested on production vaults and ready to drop into your project. Async vaults add real complexity to the fuzzing setup, but the payoff is proportional. The bugs that live in the request/fulfill/claim lifecycle are exactly the kind that slip through unit tests and manual review. If you're building an ERC-7540 vault and want expert help designing the invariant suite, [request an audit with Recon](/request-audit). We built the reference properties for this standard and have applied them to production deployments. --- ### How to fuzz ERC-4337 account abstraction wallets **Author:** deivitto | **Published:** 2026-03-19 | **Reading time:** 13 min **Tags:** fuzzing, invariant-testing, erc-4337, smart-contract-security, account-abstraction **URL:** https://getrecon.xyz/blog/how-to-fuzz-erc4337-account-abstraction # How to fuzz ERC-4337 account abstraction wallets ERC-4337 brings account abstraction to Ethereum without protocol changes. Smart contract wallets (also called smart accounts) validate their own transactions, manage their own nonce logic, and can delegate gas payment to paymasters. This flexibility is powerful, but it introduces an entirely different class of invariants compared to DeFi token math. The bugs here are about authorization boundaries, gas accounting, and nonce ordering. Fuzzing ERC-4337 wallets means verifying that only authorized signers can execute operations, that gas costs never exceed budgets, that nonces prevent replay attacks, and that paymasters stay solvent. This guide walks through identifying those invariants, writing properties with [Chimera](/learn/chimera-framework), and running campaigns that stress the validation and execution paths. Get expert account abstraction fuzzing ## What ERC-4337 guarantees The [EIP-4337 specification](https://eips.ethereum.org/EIPS/eip-4337) defines a system where user operations go through an EntryPoint contract. A bundler collects these operations from an off-chain mempool and submits them to the EntryPoint, which calls `validateUserOp()` on the target account to decide whether to accept the operation. If validation passes, the EntryPoint executes the operation's calldata against the account. Gas accounting happens at the EntryPoint level. The guarantees that matter most for security: 1. **Validation correctness.** Only operations signed by authorized signers should pass `validateUserOp()`. All others must be rejected. 2. **Gas accounting.** The actual gas cost charged to the account (or paymaster) must never exceed `maxGasCost` specified in the user operation. 3. **Nonce monotonicity.** Each nonce key's sequence value must increase monotonically. No gaps, no replays. 4. **Paymaster solvency.** If a paymaster sponsors gas, its deposit in the EntryPoint must cover the cost. The paymaster must not go negative. 5. **Execution atomicity.** If execution reverts, the account's state must not change (beyond gas consumption and nonce increment). 6. **No unauthorized execution.** An invalid signature must never result in state-changing execution on the account. These are fundamentally different from DeFi invariants. There's no share accounting or exchange rates here. Instead, we're testing authorization logic, cryptographic verification, and gas math. The fuzzer's job is to find inputs that bypass validation or break the gas model. ## Key invariants ## Validation correctness The most important property. `validateUserOp()` must return a success code only when the operation carries a valid signature from an authorized signer. For any other signature, it must return a failure code or revert. ``` validateUserOp(op_with_invalid_sig) != SIG_VALIDATION_SUCCESS ``` ## Gas accounting The EntryPoint tracks gas usage and charges the account (or paymaster). The actual gas cost must never exceed the maximum the user operation authorized. ``` actualGasCost <= preVerificationGas + verificationGasLimit + callGasLimit ``` ## Nonce monotonicity ERC-4337 uses a 2D nonce scheme: a 192-bit key and a 64-bit sequence. For each key, the sequence must be strictly incrementing with no gaps. ``` nonce(key)_after == nonce(key)_before + 1 // after successful op ``` ## Paymaster solvency If a paymaster validates a user operation, it commits to paying the gas. Its EntryPoint deposit must cover the commitment. ``` entryPoint.balanceOf(paymaster) >= 0 // Never negative after postOp ``` ## Execution atomicity If the execution phase of a user operation reverts, only the execution effects should roll back. The nonce increment and gas charge from validation must persist. ## Setting up Chimera We use the [Chimera framework](/learn/chimera-framework) with a setup that includes a mock EntryPoint, a target account implementation, and a paymaster. The [beginner's guide](/blog/zero-to-fuzzing-beginners-guide-chimera) covers basic installation. The file structure: ``` test/ invariants/ Setup.sol // Deploy EntryPoint, account, paymaster TargetFunctions.sol // UserOp construction and submission Properties.sol // Validation and gas invariants CryticTester.sol // Entrypoint for Echidna/Medusa ``` ## Setup ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {EntryPoint} from "account-abstraction/core/EntryPoint.sol"; import {SimpleAccount} from "src/SimpleAccount.sol"; import {SimplePaymaster} from "src/SimplePaymaster.sol"; import {BaseSetup} from "@chimera/BaseSetup.sol"; abstract contract Setup is BaseSetup { EntryPoint internal entryPoint; SimpleAccount internal account; SimplePaymaster internal paymaster; address internal owner; uint256 internal ownerKey; address internal unauthorized; uint256 internal unauthorizedKey; // Ghost tracking uint256 internal lastNonce; uint256 internal paymasterDepositBefore; function setup() internal virtual override { entryPoint = new EntryPoint(); // Create owner keypair ownerKey = 0xA11CE; owner = vm.addr(ownerKey); // Create unauthorized keypair unauthorizedKey = 0xBAD; unauthorized = vm.addr(unauthorizedKey); // Deploy account owned by owner account = new SimpleAccount(entryPoint, owner); // Deploy and fund paymaster paymaster = new SimplePaymaster(entryPoint); entryPoint.depositTo{value: 10 ether}(address(paymaster)); // Fund account entryPoint.depositTo{value: 10 ether}(address(account)); vm.deal(address(account), 100 ether); lastNonce = account.getNonce(); paymasterDepositBefore = entryPoint.balanceOf(address(paymaster)); } } ``` We create two keypairs: the authorized owner and an unauthorized signer. The fuzzer will try both, and our properties verify that only the owner's signatures pass validation. The paymaster gets a deposit in the EntryPoint so we can test gas sponsorship flows. ## Writing properties ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Setup} from "./Setup.sol"; import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol"; abstract contract Properties is Setup { // P-1: Nonce monotonicity function invariant_nonceMonotonic() public view returns (bool) { uint256 currentNonce = account.getNonce(); return currentNonce >= lastNonce; } // P-2: Paymaster solvency function invariant_paymasterSolvent() public view returns (bool) { return entryPoint.balanceOf(address(paymaster)) >= 0; } // P-3: Account has valid EntryPoint reference function invariant_entryPointConsistency() public view returns (bool) { return address(account.entryPoint()) == address(entryPoint); } // P-4: Owner hasn't changed unexpectedly function invariant_ownerUnchanged() public view returns (bool) { return account.owner() == owner; } } ``` P-1 tracks nonce progression. P-2 verifies the paymaster never goes insolvent. P-4 guards against unauthorized ownership transfers. The validation correctness check (the most critical property) is tested inline in the handlers, since it requires constructing specific user operations. ## Writing target functions Building user operations for the fuzzer is more involved than wrapping a simple token transfer. Each handler constructs a `PackedUserOperation`, signs it, and submits it through the EntryPoint. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Properties} from "./Properties.sol"; import {PackedUserOperation} from "account-abstraction/interfaces/PackedUserOperation.sol"; import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; abstract contract TargetFunctions is Properties { // --- Valid operations --- function handler_executeWithValidSig( address dest, uint256 value, bytes calldata funcData ) public { value = bound(value, 0, 1 ether); PackedUserOperation memory op = _buildUserOp( address(account), account.getNonce(), abi.encodeCall(account.execute, (dest, value, funcData)) ); bytes32 opHash = entryPoint.getUserOpHash(op); (uint8 v, bytes32 r, bytes32 s) = vm.sign( ownerKey, MessageHashUtils.toEthSignedMessageHash(opHash) ); op.signature = abi.encodePacked(r, s, v); PackedUserOperation[] memory ops = new PackedUserOperation[](1); ops[0] = op; uint256 nonceBefore = account.getNonce(); try entryPoint.handleOps(ops, payable(address(this))) { // Nonce must have incremented assert(account.getNonce() == nonceBefore + 1); lastNonce = account.getNonce(); } catch { // Execution revert is fine, but nonce should still increment // if validation passed } } // --- Invalid signature (must always fail) --- function handler_executeWithInvalidSig( address dest, uint256 value ) public { value = bound(value, 0, 1 ether); PackedUserOperation memory op = _buildUserOp( address(account), account.getNonce(), abi.encodeCall(account.execute, (dest, value, "")) ); bytes32 opHash = entryPoint.getUserOpHash(op); // Sign with unauthorized key (uint8 v, bytes32 r, bytes32 s) = vm.sign( unauthorizedKey, MessageHashUtils.toEthSignedMessageHash(opHash) ); op.signature = abi.encodePacked(r, s, v); PackedUserOperation[] memory ops = new PackedUserOperation[](1); ops[0] = op; uint256 nonceBefore = account.getNonce(); try entryPoint.handleOps(ops, payable(address(this))) { // If this succeeds, unauthorized execution happened assert(false); // P-CRITICAL: unauthorized sig must not execute } catch { // Expected: invalid sig should revert assert(account.getNonce() == nonceBefore); } } // --- Nonce replay attempt --- function handler_replayNonce(uint256 staleNonce) public { staleNonce = bound(staleNonce, 0, account.getNonce()); if (staleNonce >= account.getNonce()) return; // Not actually stale PackedUserOperation memory op = _buildUserOp( address(account), staleNonce, abi.encodeCall(account.execute, (address(0x1), 0, "")) ); bytes32 opHash = entryPoint.getUserOpHash(op); (uint8 v, bytes32 r, bytes32 s) = vm.sign( ownerKey, MessageHashUtils.toEthSignedMessageHash(opHash) ); op.signature = abi.encodePacked(r, s, v); PackedUserOperation[] memory ops = new PackedUserOperation[](1); ops[0] = op; // Stale nonce must revert try entryPoint.handleOps(ops, payable(address(this))) { assert(false); // Replay must not succeed } catch { // Expected } } // --- Paymaster-sponsored operation --- function handler_paymasterSponsored(address dest) public { uint256 depositBefore = entryPoint.balanceOf(address(paymaster)); PackedUserOperation memory op = _buildUserOp( address(account), account.getNonce(), abi.encodeCall(account.execute, (dest, 0, "")) ); op.paymasterAndData = abi.encodePacked(address(paymaster)); bytes32 opHash = entryPoint.getUserOpHash(op); (uint8 v, bytes32 r, bytes32 s) = vm.sign( ownerKey, MessageHashUtils.toEthSignedMessageHash(opHash) ); op.signature = abi.encodePacked(r, s, v); PackedUserOperation[] memory ops = new PackedUserOperation[](1); ops[0] = op; try entryPoint.handleOps(ops, payable(address(this))) { lastNonce = account.getNonce(); // Paymaster deposit should have decreased uint256 depositAfter = entryPoint.balanceOf(address(paymaster)); assert(depositAfter <= depositBefore); paymasterDepositBefore = depositAfter; } catch {} } // --- Time manipulation --- function handler_warpTime(uint256 delta) public { delta = bound(delta, 1, 365 days); vm.warp(block.timestamp + delta); } // --- Helper --- function _buildUserOp( address sender, uint256 nonce, bytes memory callData ) internal pure returns (PackedUserOperation memory) { return PackedUserOperation({ sender: sender, nonce: nonce, initCode: "", callData: callData, accountGasLimits: bytes32(abi.encodePacked(uint128(200000), uint128(200000))), preVerificationGas: 50000, gasFees: bytes32(abi.encodePacked(uint128(1 gwei), uint128(1 gwei))), paymasterAndData: "", signature: "" }); } } ``` Key design decisions in these handlers: - **`handler_executeWithInvalidSig`** is the most important handler. It constructs a properly formatted user operation but signs it with the wrong key. If this ever succeeds, the wallet has a critical authorization bypass. - **`handler_replayNonce`** tries to re-submit an operation with a nonce that was already consumed. This tests the replay protection. - **`handler_paymasterSponsored`** verifies that paymaster-sponsored operations correctly deduct from the paymaster's deposit. ## What makes ERC-4337 different to fuzz There are several properties of account abstraction that make fuzzing it different from DeFi token contracts: **Cryptographic verification in the loop.** Every user operation needs a valid ECDSA signature. The fuzzer can't just throw random bytes at `validateUserOp()` and expect meaningful coverage. We use `vm.sign()` to produce valid signatures for the authorized key and invalid-but-well-formed signatures for the unauthorized key. **Two-phase execution.** ERC-4337 separates validation from execution. A bug might live in validation (accepting bad signatures), in execution (side effects on revert), or in the boundary between them (gas accounting across phases). Our handlers test all three. **EntryPoint as intermediary.** Unlike direct contract calls, everything goes through the EntryPoint. The fuzzer must construct complete user operations and submit them via `handleOps()`, not call the account directly. This adds complexity but also tests the real execution path. **Gas is a first-class concern.** In DeFi fuzzing, gas is usually irrelevant to correctness. In ERC-4337, gas accounting is a security property. A paymaster that undercharges goes insolvent. An account that overestimates gas wastes user funds. ## Running the campaign ## Foundry (quick smoke test) ```bash forge test --match-contract CryticTester --fuzz-runs 5000 ``` UserOp construction is expensive per-call, so 5000 runs gives reasonable coverage without long waits. ## Medusa (broad coverage) ```json { "fuzzing": { "targetContracts": ["CryticTester"], "testLimit": 500000, "callSequenceLength": 50, "workers": 8, "corpusDirectory": "corpus-medusa" } } ``` ```bash medusa fuzz ``` We use a shorter `callSequenceLength` than DeFi campaigns because each call is more expensive to construct and execute. ## Echidna (deep exploration) ```yaml # echidna.yaml testMode: assertion testLimit: 1000000 seqLen: 80 corpusDir: "corpus-echidna" ``` ```bash echidna . --contract CryticTester --config echidna.yaml ``` Echidna's shrinking is especially useful here. When a validation bypass is found, the shrunk sequence shows the exact operation that bypassed the check. For a comparison of fuzzers, see our [practical guide](/blog/echidna-vs-medusa-practical-comparison). ## Interpreting results ## Validation bypass If `handler_executeWithInvalidSig` triggers its assertion failure, an unauthorized signer executed an operation on the account. This is a critical vulnerability. The most common causes: - **Signature recovery returning the wrong address.** Using `ecrecover` directly without checking for zero-address returns or malleable signatures. - **Missing validation logic.** The `validateUserOp()` function returns success unconditionally for certain operation types. - **Signature type confusion.** If the account supports multiple signature schemes (multisig, ERC-1271), the selector logic might fall through to a permissive default. ## Nonce replay If `handler_replayNonce` succeeds, the nonce validation is broken. This allows transaction replay, where a previously executed operation gets re-executed. Check the nonce validation in both the account and the EntryPoint integration. ## Paymaster drain If the paymaster deposit goes to zero or the `invariant_paymasterSolvent` check fails, the gas accounting allows operations that cost more than the paymaster committed to. Look at the `postOp()` implementation and verify it correctly accounts for the actual gas consumed. ## Beyond the basics Once core properties pass, consider: - **Add multi-sig validation.** If your account supports multiple owners or threshold signatures, add handlers that test authorization with different signer combinations. - **Test account upgrades.** If the account is upgradeable (UUPS or similar), add a handler that upgrades the implementation and verify all properties still hold. - **Test batched operations.** Submit multiple user operations in a single `handleOps()` call and verify that nonce ordering and gas accounting work correctly across the batch. - **Run in CI.** Set up [continuous fuzzing](/blog/continuous-security-testing-cicd-fuzzing) for every PR that touches wallet logic. ERC-4337 fuzzing is different from DeFi fuzzing, but the methodology is the same. Define what must always be true, write handlers that exercise the full surface, and let the fuzzer find the states where your assumptions break. If you're building an ERC-4337 wallet and want an expert invariant suite, [request an audit with Recon](/request-audit). Authorization and gas accounting bugs in smart wallets have direct financial impact, and fuzzing is the best way to find them before an attacker does. --- ### How to fuzz ERC 2535 diamond proxies: storage, selectors, and upgrades **Author:** deivitto | **Published:** 2026-03-21 | **Reading time:** 12 min **Tags:** fuzzing, invariant-testing, erc-2535, smart-contract-security, upgradeable-contracts **URL:** https://getrecon.xyz/blog/how-to-fuzz-erc2535-diamond-proxy # How to fuzz ERC 2535 diamond proxies: storage, selectors, and upgrades ERC 2535 (the Diamond standard) is the most structurally complex upgradeable contract pattern in Solidity. A single proxy contract delegates calls to multiple implementation contracts called facets. Each facet handles a different set of function selectors, and the diamond can be upgraded by adding, replacing, or removing facets through `diamondCut()`. This gives protocols modular upgradeability, but it also creates a class of bugs that traditional contract testing misses. Selector collisions, storage slot corruption across facets, and inconsistent upgrade sequences all fall into this category. Fuzzing diamond proxies means verifying structural integrity across upgrades. The invariants are about selector routing, storage isolation, and the consistency between the diamond's internal mapping and what the loupe functions report. This guide walks through those invariants, a [Chimera](/learn/chimera-framework) setup, and handlers that exercise the upgrade surface. Get expert diamond proxy fuzzing ## What ERC 2535 guarantees The [EIP-2535 specification](https://eips.ethereum.org/EIPS/eip-2535) defines a proxy pattern that maintains a mapping from function selectors to facet addresses. The `diamondCut()` function modifies this mapping by adding new selector/facet pairs, replacing existing ones, or removing selectors entirely. Four loupe functions provide read access to the current configuration: `facets()`, `facetFunctionSelectors()`, `facetAddresses()`, and `facetAddress()`. The guarantees that matter most for security: 1. **Selector uniqueness.** No two facets can claim the same function selector. If two facets both implement `transfer(address,uint256)`, the diamond must reject the conflict during `diamondCut()`. 2. **Loupe/routing consistency.** The loupe functions must always reflect the actual routing table. If `facetAddress(selector)` returns facet A, calling that selector must delegate to facet A. 3. **Storage isolation.** Facet A's storage writes must not corrupt facet B's state. Since all facets share the diamond's storage, this requires disciplined use of storage slots. 4. **`diamondCut` atomicity.** A multi-facet upgrade must be all-or-nothing. If the third facet addition in a batch fails, the first two must not be applied. 5. **No dangling selectors.** After removing a facet, none of its selectors should remain routable. Calls to removed selectors must revert. 6. **Initialization safety.** An initializer function called during `diamondCut()` must not be callable again. Re-initialization can reset state or bypass access controls. These properties are hard to test with unit tests because the bugs emerge from sequences of upgrades, not from individual operations. A diamond that passes every isolated test can still corrupt storage after a specific sequence of facet additions and removals. ## Key invariants ## Selector uniqueness Every active selector maps to exactly one facet address. No duplicates exist in the routing table. ``` for all selectors s in facets(): count(s) == 1 ``` ## Loupe/routing consistency The loupe functions and the actual `delegatecall` routing must agree. If `facetAddress(s)` returns address A, then calling selector `s` on the diamond must execute code at address A. ``` facetAddress(s) == address(facet that handles s) ``` ## Storage isolation Writing a value through facet A must not change any storage slot owned by facet B. We verify this by reading facet B's state before and after an operation on facet A. ## `diamondCut` atomicity If a `diamondCut()` call includes multiple facet operations and one of them reverts, the entire cut must revert. No partial upgrades. ## No dangling selectors after removal After removing a facet, every selector that was assigned to that facet must revert when called. The loupe functions must no longer list those selectors. ``` after remove(facetA): facetAddress(selectorFromA) == address(0) call(diamond, selectorFromA) -> revert ``` ## Setting up Chimera We use the [Chimera framework](/learn/chimera-framework) with a setup that includes a diamond, several facets, and the infrastructure to perform upgrades. The [beginner's guide](/blog/zero-to-fuzzing-beginners-guide-chimera) covers basic installation. The file structure: ``` test/ invariants/ Setup.sol // Deploy diamond + initial facets TargetFunctions.sol // diamondCut, facet calls, storage ops Properties.sol // Selector, loupe, and storage checks CryticTester.sol // Entrypoint for Echidna/Medusa mocks/ FacetA.sol // Test facet with storage FacetB.sol // Another test facet with storage FacetC.sol // Facet for dynamic add/remove testing ``` ## Setup ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Diamond} from "src/Diamond.sol"; import {IDiamondCut} from "src/interfaces/IDiamondCut.sol"; import {IDiamondLoupe} from "src/interfaces/IDiamondLoupe.sol"; import {FacetA} from "./mocks/FacetA.sol"; import {FacetB} from "./mocks/FacetB.sol"; import {FacetC} from "./mocks/FacetC.sol"; import {BaseSetup} from "@chimera/BaseSetup.sol"; abstract contract Setup is BaseSetup { Diamond internal diamond; FacetA internal facetA; FacetB internal facetB; FacetC internal facetC; IDiamondLoupe internal loupe; // Track known selectors for verification bytes4[] internal knownSelectors; mapping(bytes4 => bool) internal selectorActive; function setup() internal virtual override { facetA = new FacetA(); facetB = new FacetB(); facetC = new FacetC(); // Build initial diamond cut with facetA and facetB IDiamondCut.FacetCut[] memory cuts = new IDiamondCut.FacetCut[](2); bytes4[] memory selectorsA = new bytes4[](2); selectorsA[0] = FacetA.setValue.selector; selectorsA[1] = FacetA.getValue.selector; bytes4[] memory selectorsB = new bytes4[](2); selectorsB[0] = FacetB.setFlag.selector; selectorsB[1] = FacetB.getFlag.selector; cuts[0] = IDiamondCut.FacetCut({ facetAddress: address(facetA), action: IDiamondCut.FacetCutAction.Add, functionSelectors: selectorsA }); cuts[1] = IDiamondCut.FacetCut({ facetAddress: address(facetB), action: IDiamondCut.FacetCutAction.Add, functionSelectors: selectorsB }); diamond = new Diamond(cuts, address(0), ""); loupe = IDiamondLoupe(address(diamond)); // Track initial selectors for (uint256 i = 0; i < selectorsA.length; i++) { knownSelectors.push(selectorsA[i]); selectorActive[selectorsA[i]] = true; } for (uint256 i = 0; i < selectorsB.length; i++) { knownSelectors.push(selectorsB[i]); selectorActive[selectorsB[i]] = true; } } } ``` We deploy the diamond with two facets (A and B) that have non-overlapping selectors. FacetC is available for dynamic addition/removal during fuzzing. The `knownSelectors` array and `selectorActive` mapping track the expected routing state. ## Writing properties ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Setup} from "./Setup.sol"; import {IDiamondLoupe} from "src/interfaces/IDiamondLoupe.sol"; abstract contract Properties is Setup { // P-1: Selector uniqueness - no selector maps to two facets function invariant_selectorUniqueness() public view returns (bool) { IDiamondLoupe.Facet[] memory allFacets = loupe.facets(); for (uint256 i = 0; i < allFacets.length; i++) { for (uint256 j = 0; j < allFacets[i].functionSelectors.length; j++) { bytes4 sel = allFacets[i].functionSelectors[j]; // Check this selector doesn't appear in any other facet for (uint256 k = i + 1; k < allFacets.length; k++) { for (uint256 l = 0; l < allFacets[k].functionSelectors.length; l++) { if (allFacets[k].functionSelectors[l] == sel) { return false; } } } } } return true; } // P-2: Loupe consistency - facetAddress matches facets() listing function invariant_loupeConsistency() public view returns (bool) { IDiamondLoupe.Facet[] memory allFacets = loupe.facets(); for (uint256 i = 0; i < allFacets.length; i++) { for (uint256 j = 0; j < allFacets[i].functionSelectors.length; j++) { bytes4 sel = allFacets[i].functionSelectors[j]; address reported = loupe.facetAddress(sel); if (reported != allFacets[i].facetAddress) return false; } } return true; } // P-3: No dangling selectors - removed selectors map to address(0) function invariant_noDanglingSelectors() public view returns (bool) { for (uint256 i = 0; i < knownSelectors.length; i++) { bytes4 sel = knownSelectors[i]; address facet = loupe.facetAddress(sel); if (!selectorActive[sel] && facet != address(0)) { return false; } if (selectorActive[sel] && facet == address(0)) { return false; } } return true; } // P-4: Facet address list has no zero address function invariant_noZeroFacet() public view returns (bool) { address[] memory addrs = loupe.facetAddresses(); for (uint256 i = 0; i < addrs.length; i++) { if (addrs[i] == address(0)) return false; } return true; } // P-5: facetFunctionSelectors is consistent with facets() function invariant_facetSelectorsConsistency() public view returns (bool) { address[] memory addrs = loupe.facetAddresses(); for (uint256 i = 0; i < addrs.length; i++) { bytes4[] memory sels = loupe.facetFunctionSelectors(addrs[i]); if (sels.length == 0) return false; // Active facet must have selectors } return true; } } ``` P-1 is the core structural check. It iterates all facets and verifies no selector appears twice. P-2 cross-checks the individual `facetAddress()` lookups against the batch `facets()` return. P-3 uses our ghost tracking to verify that removed selectors are actually gone. ## Writing target functions ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Properties} from "./Properties.sol"; import {IDiamondCut} from "src/interfaces/IDiamondCut.sol"; import {FacetA} from "./mocks/FacetA.sol"; import {FacetC} from "./mocks/FacetC.sol"; abstract contract TargetFunctions is Properties { // --- Facet operations through the diamond --- function handler_setValueA(uint256 val) public { FacetA(address(diamond)).setValue(val); } function handler_getValueA() public view { FacetA(address(diamond)).getValue(); } // --- diamondCut: Add facetC --- function handler_addFacetC() public { bytes4[] memory selectorsC = new bytes4[](2); selectorsC[0] = FacetC.setData.selector; selectorsC[1] = FacetC.getData.selector; // Skip if already added if (selectorActive[selectorsC[0]]) return; IDiamondCut.FacetCut[] memory cuts = new IDiamondCut.FacetCut[](1); cuts[0] = IDiamondCut.FacetCut({ facetAddress: address(facetC), action: IDiamondCut.FacetCutAction.Add, functionSelectors: selectorsC }); IDiamondCut(address(diamond)).diamondCut(cuts, address(0), ""); for (uint256 i = 0; i < selectorsC.length; i++) { knownSelectors.push(selectorsC[i]); selectorActive[selectorsC[i]] = true; } } // --- diamondCut: Remove facetC --- function handler_removeFacetC() public { bytes4[] memory selectorsC = new bytes4[](2); selectorsC[0] = FacetC.setData.selector; selectorsC[1] = FacetC.getData.selector; // Skip if not active if (!selectorActive[selectorsC[0]]) return; IDiamondCut.FacetCut[] memory cuts = new IDiamondCut.FacetCut[](1); cuts[0] = IDiamondCut.FacetCut({ facetAddress: address(0), action: IDiamondCut.FacetCutAction.Remove, functionSelectors: selectorsC }); IDiamondCut(address(diamond)).diamondCut(cuts, address(0), ""); for (uint256 i = 0; i < selectorsC.length; i++) { selectorActive[selectorsC[i]] = false; } } // --- diamondCut: Replace facetA with new implementation --- function handler_replaceFacetA() public { FacetA newFacetA = new FacetA(); bytes4[] memory selectorsA = new bytes4[](2); selectorsA[0] = FacetA.setValue.selector; selectorsA[1] = FacetA.getValue.selector; IDiamondCut.FacetCut[] memory cuts = new IDiamondCut.FacetCut[](1); cuts[0] = IDiamondCut.FacetCut({ facetAddress: address(newFacetA), action: IDiamondCut.FacetCutAction.Replace, functionSelectors: selectorsA }); IDiamondCut(address(diamond)).diamondCut(cuts, address(0), ""); } // --- Storage isolation check --- function handler_storageIsolation(uint256 newVal) public { newVal = bound(newVal, 1, type(uint256).max); // Read facetB state before bool flagBefore = FacetA(address(diamond)).getValue() > 0; // Not reading flagBefore from FacetB since it would be a bool // Snapshot FacetB storage via getFlag (bool ok, bytes memory data) = address(diamond).staticcall( abi.encodeCall(FacetB.getFlag, ()) ); if (!ok) return; bool flagBeforeB = abi.decode(data, (bool)); // Write through FacetA FacetA(address(diamond)).setValue(newVal); // Read FacetB state after (ok, data) = address(diamond).staticcall( abi.encodeCall(FacetB.getFlag, ()) ); if (!ok) return; bool flagAfterB = abi.decode(data, (bool)); // FacetB state must be unchanged assert(flagBeforeB == flagAfterB); } // --- Add/remove cycle --- function handler_addRemoveCycle() public { handler_addFacetC(); // Write some data through facetC (bool ok, ) = address(diamond).call( abi.encodeCall(FacetC.setData, (42)) ); if (!ok) return; handler_removeFacetC(); // Calling removed selector must revert (ok, ) = address(diamond).call( abi.encodeCall(FacetC.getData, ()) ); assert(!ok); // Must revert } } ``` The handlers here exercise the three `diamondCut` actions: Add, Replace, and Remove. The key patterns: - **`handler_addFacetC` / `handler_removeFacetC`** toggle a facet and update the ghost state. The fuzzer can call these in any order, testing repeated add/remove cycles. - **`handler_replaceFacetA`** deploys a fresh implementation and replaces the existing facet. This tests that storage persists across implementation swaps. - **`handler_storageIsolation`** is an inline assertion that writes through one facet and verifies another facet's state is unchanged. - **`handler_addRemoveCycle`** compresses the full lifecycle into one call, verifying that removed selectors actually revert. ## Running the campaign ## Foundry (quick smoke test) ```bash forge test --match-contract CryticTester --fuzz-runs 10000 ``` Diamond tests are relatively cheap per-call since the operations are lightweight. 10,000 runs gives good coverage. ## Medusa (broad coverage) ```json { "fuzzing": { "targetContracts": ["CryticTester"], "testLimit": 500000, "callSequenceLength": 100, "workers": 8, "corpusDirectory": "corpus-medusa" } } ``` ```bash medusa fuzz ``` Medusa's parallel workers will explore different upgrade sequences simultaneously. This is where you find bugs in the interaction between multiple `diamondCut` calls. ## Echidna (deep exploration) ```yaml # echidna.yaml testMode: assertion testLimit: 2000000 seqLen: 150 corpusDir: "corpus-echidna" ``` ```bash echidna . --contract CryticTester --config echidna.yaml ``` Echidna's sequence shrinking is especially useful for diamond bugs. When a property fails after a long upgrade sequence, the shrunk result shows the minimal set of `diamondCut` calls needed to reproduce. For more on choosing fuzzers, see our [comparison guide](/blog/echidna-vs-medusa-practical-comparison). ## Interpreting results ## Selector collision If `invariant_selectorUniqueness` fails, two facets claim the same selector. This means `diamondCut()` allowed an Add operation for a selector that was already registered. Calls to that selector will route to the last facet the diamond registered, silently breaking the other facet's functionality. ## Loupe/routing mismatch If `invariant_loupeConsistency` fails, the loupe functions report a different state than the actual routing table. This is dangerous because tools and UIs rely on loupe functions to display the diamond's configuration. A mismatch means the diamond's observable state doesn't match its behavior. ## Storage corruption If `handler_storageIsolation` asserts false, writing through one facet corrupted another facet's storage. Common causes: - **Shared storage positions.** Two facets using `slot 0` for different purposes. - **Struct packing overlap.** A facet that extends its storage struct past the boundary of another facet's reserved range. - **Missing ERC-7201 namespacing.** Without namespaced storage, facet developers must manually coordinate slot assignments. ## Dangling selectors If `invariant_noDanglingSelectors` fails after a removal, the `diamondCut` Remove action didn't fully clean up. Some selector references remain in the routing table. This can leave orphaned functionality accessible through the diamond. ## Combining with ERC-7201 namespaced storage [ERC-7201](https://eips.ethereum.org/EIPS/eip-7201) defines a convention for namespaced storage that prevents slot collisions between facets. Each facet derives its storage position from a unique namespace string. ```solidity bytes32 constant FACET_A_STORAGE = keccak256( abi.encode(uint256(keccak256("facetA.storage")) - 1) ) & ~bytes32(uint256(0xff)); ``` If your diamond uses ERC-7201, add a property that verifies each facet's storage position derives from its expected namespace. You can also add a handler that deploys a new facet with a colliding namespace. Verify that the diamond either rejects it or that the storage isolation property still holds. Combining ERC 2535 selector-level properties with ERC-7201 storage-level properties gives you full coverage of the diamond's integrity from the routing table down to the storage layout. ## Beyond the basics Once your core diamond properties pass, consider: - **Test initialization replay.** Add a handler that calls the initializer function a second time after a `diamondCut` and verify it reverts. - **Test access control on `diamondCut`.** Add a handler where a non-owner tries to call `diamondCut()` and verify it reverts. - **Test large batch upgrades.** Create a handler that performs all three cut actions in a single `diamondCut()` call and verify atomicity. - **Run in CI.** Set up [continuous fuzzing](/blog/continuous-security-testing-cicd-fuzzing) to catch regressions when facets change. Diamond proxies are one of the most structurally complex patterns in Solidity. The bugs are rarely in individual facets. They live in the interactions between facets, in upgrade sequences, and in the gap between what the loupe reports and what the diamond actually does. If you're building with ERC 2535 and want a complete property suite covering selectors and storage across upgrades, [request an audit with Recon](/request-audit). We've fuzzed diamond implementations across DeFi and know where the structural failures hide. --- ### How to fuzz ERC-1155 multi-token contracts **Author:** deivitto | **Published:** 2026-03-23 | **Reading time:** 11 min **Tags:** fuzzing, invariant-testing, erc-1155, smart-contract-security **URL:** https://getrecon.xyz/blog/how-to-fuzz-erc1155-multi-token # How to fuzz ERC-1155 multi-token contracts ERC-1155 is the multi-token standard behind most NFT marketplaces, gaming platforms, and badge systems. A single contract manages fungible and non-fungible token IDs, supports batch operations, and mandates receiver callbacks. This combination of batch atomicity, callback hooks, and shared-state accounting creates a testing surface that single-token approaches can't cover. Fuzzing ERC-1155 is about verifying that batch operations are truly atomic and that callbacks don't open reentrancy windows. Per-ID accounting must stay consistent across every combination of single and batch transfers. This guide covers the invariants, a [Chimera](/learn/chimera-framework) setup with callback-aware handlers, and campaigns that stress the paths where ERC-1155 implementations break. Get expert multi-token fuzzing ## What ERC-1155 guarantees The [EIP-1155 specification](https://eips.ethereum.org/EIPS/eip-1155) defines a multi-token interface with both individual and batch operations. `safeTransferFrom()` moves a single token ID, while `safeBatchTransferFrom()` moves multiple IDs in one call. Both require the recipient to implement `IERC1155Receiver` if it's a contract, and both must revert if the receiver rejects the transfer. The guarantees that matter most for security: 1. **Per-ID supply conservation.** For each token ID, the sum of all balances must equal the total supply. No tokens appear or vanish. 2. **Batch atomicity.** A batch transfer is all-or-nothing. If the transfer of any single ID in the batch fails (insufficient balance, receiver rejection), the entire batch reverts. 3. **Callback safety.** If the receiver is a contract, `onERC1155Received()` or `onERC1155BatchReceived()` must be called. If the receiver doesn't implement the interface or returns the wrong selector, the transfer must revert. 4. **Approval-for-all scope.** `setApprovalForAll()` grants an operator permission to transfer all token IDs on behalf of the owner. This is a binary toggle with no per-ID granularity. 5. **Batch/individual consistency.** `balanceOfBatch()` must return the same values as calling `balanceOf()` individually for each account/ID pair. 6. **No reentrancy via callbacks.** The mandatory callbacks create a reentrancy surface. The contract must handle callbacks safely, whether through reentrancy guards or checks-effects-interactions ordering. The callback requirement is what makes ERC-1155 different from simpler multi-token standards. Every transfer to a contract triggers external code execution, and the contract's state must be consistent before that callback fires. ## Key invariants ## Per-ID supply conservation For every token ID, the sum of all holder balances must equal the total supply. We track this with ghost variables that mirror every mint, burn, and transfer. ``` sum(balanceOf(addr, id) for all addrs) == totalSupply(id) ``` ## Batch atomicity A batch transfer of IDs [1, 2, 3] with amounts [10, 20, 30] must either complete fully or not at all. If the sender has insufficient balance for ID 2, IDs 1 and 3 must not be transferred either. ``` // If batch reverts, no balances change for any ID in the batch balanceOf(from, ids[i])_after == balanceOf(from, ids[i])_before // for all i ``` ## Callback safety If the receiver is a contract that doesn't implement `IERC1155Receiver`, the transfer must revert. If the receiver returns the wrong magic value, the transfer must revert. ## Approval-for-all scope An approved operator can transfer any token ID on behalf of the owner. Revoking approval must immediately prevent further transfers. ## Batch/individual balance consistency `balanceOfBatch([addr1, addr2], [id1, id2])` must return the same values as `[balanceOf(addr1, id1), balanceOf(addr2, id2)]`. ## Setting up Chimera We use the [Chimera framework](/learn/chimera-framework) with a setup that includes mock receivers for testing callback behavior. The [beginner's guide](/blog/zero-to-fuzzing-beginners-guide-chimera) covers basic installation. The file structure: ``` test/ invariants/ Setup.sol // Deploy token, receivers, fund actors TargetFunctions.sol // Single/batch transfers, approvals Properties.sol // Conservation, consistency, callback checks CryticTester.sol // Entrypoint for Echidna/Medusa mocks/ GoodReceiver.sol // Accepts all transfers BadReceiver.sol // Rejects all transfers ReentrantReceiver.sol // Attempts reentrancy on callback ``` ## Setup ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {MockERC1155} from "./mocks/MockERC1155.sol"; import {GoodReceiver} from "./mocks/GoodReceiver.sol"; import {BadReceiver} from "./mocks/BadReceiver.sol"; import {ReentrantReceiver} from "./mocks/ReentrantReceiver.sol"; import {BaseSetup} from "@chimera/BaseSetup.sol"; abstract contract Setup is BaseSetup { MockERC1155 internal token; GoodReceiver internal goodReceiver; BadReceiver internal badReceiver; ReentrantReceiver internal reentrantReceiver; address[] internal actors; uint256[] internal tokenIds; // Ghost state for supply tracking mapping(uint256 => uint256) internal ghostTotalSupply; function setup() internal virtual override { token = new MockERC1155("https://example.com/"); goodReceiver = new GoodReceiver(); badReceiver = new BadReceiver(); reentrantReceiver = new ReentrantReceiver(address(token)); tokenIds.push(1); tokenIds.push(2); tokenIds.push(3); // EOA actors actors.push(address(0x10001)); actors.push(address(0x10002)); actors.push(address(0x10003)); // Contract actors for callback testing actors.push(address(goodReceiver)); // Mint initial balances for (uint256 i = 0; i < 3; i++) { for (uint256 j = 0; j < tokenIds.length; j++) { token.mint(actors[i], tokenIds[j], 1_000_000e18, ""); ghostTotalSupply[tokenIds[j]] += 1_000_000e18; } } // Set approvals for EOA actors for (uint256 i = 0; i < 3; i++) { vm.prank(actors[i]); token.setApprovalForAll(address(this), true); } } } ``` We include three types of contract receivers: one that accepts transfers normally, one that always rejects, and one that attempts reentrancy during the callback. The fuzzer will route transfers to all of these, testing callback handling under different conditions. ## Writing properties ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Setup} from "./Setup.sol"; abstract contract Properties is Setup { // P-1: Per-ID supply conservation function invariant_supplyConservation() public view returns (bool) { for (uint256 i = 0; i < tokenIds.length; i++) { uint256 id = tokenIds[i]; uint256 balanceSum = 0; for (uint256 j = 0; j < actors.length; j++) { balanceSum += token.balanceOf(actors[j], id); } // Include any other known holders balanceSum += token.balanceOf(address(this), id); balanceSum += token.balanceOf(address(badReceiver), id); balanceSum += token.balanceOf(address(reentrantReceiver), id); if (balanceSum != ghostTotalSupply[id]) return false; } return true; } // P-2: Batch/individual balance consistency function invariant_batchBalanceConsistency() public view returns (bool) { address[] memory accounts = new address[](actors.length); uint256[] memory ids = new uint256[](actors.length); // Check each actor against a token ID uint256 checkLen = actors.length < tokenIds.length ? actors.length : tokenIds.length; for (uint256 i = 0; i < checkLen; i++) { accounts[i] = actors[i]; ids[i] = tokenIds[i]; } // Resize arrays to checkLen assembly { mstore(accounts, checkLen) mstore(ids, checkLen) } uint256[] memory batchResult = token.balanceOfBatch(accounts, ids); for (uint256 i = 0; i < checkLen; i++) { uint256 individual = token.balanceOf(accounts[i], ids[i]); if (batchResult[i] != individual) return false; } return true; } // P-3: No zero-address holdings function invariant_noZeroAddressBalance() public view returns (bool) { for (uint256 i = 0; i < tokenIds.length; i++) { if (token.balanceOf(address(0), tokenIds[i]) > 0) return false; } return true; } // P-4: Supply matches ghost function invariant_supplyMatchesGhost() public view returns (bool) { for (uint256 i = 0; i < tokenIds.length; i++) { uint256 id = tokenIds[i]; if (token.totalSupply(id) != ghostTotalSupply[id]) return false; } return true; } } ``` P-1 sums balances across all known addresses (actors plus contract receivers) and compares against the ghost supply. This catches any token creation or destruction outside the expected paths. P-2 verifies that `balanceOfBatch` is consistent with individual `balanceOf` calls, which catches implementations that have separate code paths for batch and individual queries. ## Writing target functions ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {Properties} from "./Properties.sol"; abstract contract TargetFunctions is Properties { // --- Single transfers --- function handler_safeTransferFrom( uint256 fromSeed, uint256 toSeed, uint256 idSeed, uint256 amount ) public { address from = actors[fromSeed % 3]; // Only EOA senders address to = actors[toSeed % actors.length]; uint256 id = tokenIds[idSeed % tokenIds.length]; uint256 bal = token.balanceOf(from, id); if (bal == 0) return; amount = bound(amount, 1, bal); vm.prank(from); try token.safeTransferFrom(from, to, id, amount, "") { // Success: balances shifted but ghost unchanged (conservation) } catch { // Revert is acceptable (e.g., bad receiver) } } // --- Batch transfers --- function handler_safeBatchTransferFrom( uint256 fromSeed, uint256 toSeed, uint256 amount1, uint256 amount2, uint256 amount3 ) public { address from = actors[fromSeed % 3]; address to = actors[toSeed % actors.length]; uint256[] memory ids = new uint256[](3); uint256[] memory amounts = new uint256[](3); ids[0] = tokenIds[0]; ids[1] = tokenIds[1]; ids[2] = tokenIds[2]; amounts[0] = bound(amount1, 0, token.balanceOf(from, ids[0])); amounts[1] = bound(amount2, 0, token.balanceOf(from, ids[1])); amounts[2] = bound(amount3, 0, token.balanceOf(from, ids[2])); // Skip if all amounts are zero if (amounts[0] == 0 && amounts[1] == 0 && amounts[2] == 0) return; vm.prank(from); try token.safeBatchTransferFrom(from, to, ids, amounts, "") { // Success } catch { // Revert is acceptable } } // --- Approval management --- function handler_setApprovalForAll( uint256 actorSeed, uint256 operatorSeed, bool approved ) public { address actor = actors[actorSeed % 3]; address op = actors[operatorSeed % actors.length]; vm.prank(actor); token.setApprovalForAll(op, approved); } // --- Operator transfer --- function handler_operatorTransfer( uint256 operatorSeed, uint256 fromSeed, uint256 idSeed, uint256 amount ) public { address op = actors[operatorSeed % 3]; address from = actors[fromSeed % 3]; uint256 id = tokenIds[idSeed % tokenIds.length]; if (!token.isApprovedForAll(from, op)) return; uint256 bal = token.balanceOf(from, id); if (bal == 0) return; amount = bound(amount, 1, bal); vm.prank(op); try token.safeTransferFrom(from, address(goodReceiver), id, amount, "") { // Operator transfer succeeded } catch {} } // --- Callback rejection test --- function handler_transferToBadReceiver( uint256 fromSeed, uint256 idSeed, uint256 amount ) public { address from = actors[fromSeed % 3]; uint256 id = tokenIds[idSeed % tokenIds.length]; uint256 bal = token.balanceOf(from, id); if (bal == 0) return; amount = bound(amount, 1, bal); uint256 senderBefore = token.balanceOf(from, id); uint256 receiverBefore = token.balanceOf(address(badReceiver), id); vm.prank(from); try token.safeTransferFrom(from, address(badReceiver), id, amount, "") { // Should not succeed - bad receiver rejects assert(false); } catch { // Verify no state change assert(token.balanceOf(from, id) == senderBefore); assert(token.balanceOf(address(badReceiver), id) == receiverBefore); } } // --- Reentrancy test --- function handler_transferToReentrantReceiver( uint256 fromSeed, uint256 idSeed, uint256 amount ) public { address from = actors[fromSeed % 3]; uint256 id = tokenIds[idSeed % tokenIds.length]; uint256 bal = token.balanceOf(from, id); if (bal == 0) return; amount = bound(amount, 1, bal); uint256 supplyBefore = token.totalSupply(id); vm.prank(from); try token.safeTransferFrom(from, address(reentrantReceiver), id, amount, "") { // If transfer succeeded, supply must be unchanged assert(token.totalSupply(id) == supplyBefore); } catch { // Revert means reentrancy guard worked } } // --- Mint/burn for state exploration --- function handler_mint( uint256 actorSeed, uint256 idSeed, uint256 amount ) public { address to = actors[actorSeed % 3]; uint256 id = tokenIds[idSeed % tokenIds.length]; amount = bound(amount, 1, 100_000e18); token.mint(to, id, amount, ""); ghostTotalSupply[id] += amount; } function handler_burn( uint256 actorSeed, uint256 idSeed, uint256 amount ) public { address from = actors[actorSeed % 3]; uint256 id = tokenIds[idSeed % tokenIds.length]; uint256 bal = token.balanceOf(from, id); if (bal == 0) return; amount = bound(amount, 1, bal); token.burn(from, id, amount); ghostTotalSupply[id] -= amount; } // --- Batch atomicity assertion --- function handler_batchAtomicityCheck( uint256 fromSeed, uint256 amount ) public { address from = actors[fromSeed % 3]; amount = bound(amount, 1, 1_000_000e18); uint256[] memory ids = new uint256[](2); uint256[] memory amounts = new uint256[](2); ids[0] = tokenIds[0]; ids[1] = tokenIds[1]; // First amount is within balance, second is way over amounts[0] = bound(amount, 1, token.balanceOf(from, ids[0])); amounts[1] = token.balanceOf(from, ids[1]) + 1; // Guaranteed to fail if (token.balanceOf(from, ids[0]) == 0) return; uint256 bal0Before = token.balanceOf(from, ids[0]); vm.prank(from); try token.safeBatchTransferFrom( from, address(goodReceiver), ids, amounts, "" ) { assert(false); // Should not succeed } catch { // ID 0 balance must not have changed assert(token.balanceOf(from, ids[0]) == bal0Before); } } } ``` The handlers cover the full ERC-1155 surface: - **`handler_safeBatchTransferFrom`** exercises multi-ID transfers with varying amounts per ID. - **`handler_transferToBadReceiver`** sends to a contract that rejects all callbacks, verifying the transfer reverts and no state changes. - **`handler_transferToReentrantReceiver`** sends to a contract that re-enters the token during the callback, testing reentrancy protection. - **`handler_batchAtomicityCheck`** creates a batch where one ID will fail (insufficient balance) and verifies that the other ID's transfer doesn't go through. ## Running the campaign ## Foundry (quick smoke test) ```bash forge test --match-contract CryticTester --fuzz-runs 10000 ``` This catches basic violations fast. Callback-related bugs often show up quickly because the receiver contracts are deterministic. ## Medusa (broad coverage) ```json { "fuzzing": { "targetContracts": ["CryticTester"], "testLimit": 500000, "callSequenceLength": 100, "workers": 8, "corpusDirectory": "corpus-medusa" } } ``` ```bash medusa fuzz ``` Medusa's parallel workers will explore different approval/transfer/batch sequences simultaneously. The callback paths add branching that benefits from broad parallel exploration. ## Echidna (deep exploration) ```yaml # echidna.yaml testMode: assertion testLimit: 2000000 seqLen: 150 corpusDir: "corpus-echidna" ``` ```bash echidna . --contract CryticTester --config echidna.yaml ``` Echidna's sequence shrinking is useful for batch bugs. When a batch atomicity assertion fails, the shrunk sequence shows the minimal setup needed to trigger the partial transfer. See our [fuzzer comparison](/blog/echidna-vs-medusa-practical-comparison) for more on when to use each tool. ## Interpreting results ## Supply conservation failures If `invariant_supplyConservation` fails, tokens appeared or disappeared. The most common cause in ERC-1155 is a reentrancy in the callback path. The `onERC1155Received()` callback fires after balance updates but before the function returns. If the callback re-enters the token contract and triggers another transfer, balances can be double-counted or lost. Check whether your implementation follows checks-effects-interactions ordering or uses a reentrancy guard. If the `handler_transferToReentrantReceiver` assertion fails, that confirms the reentrancy vector. ## Batch atomicity violations If `handler_batchAtomicityCheck` fails, a partial batch transfer occurred. The first ID was transferred but the second ID's failure didn't roll back the first. This usually means the implementation processes transfers in a loop without reverting the entire transaction on individual failures. ## Callback rejection bypass If `handler_transferToBadReceiver` doesn't revert, the token accepted a transfer to a contract that rejects it. This means either the callback isn't being called, or the return value isn't being checked against the expected selector (`IERC1155Receiver.onERC1155Received.selector`). ## Approval scope issues If the fuzzer finds a sequence where an unapproved address successfully calls `safeTransferFrom()`, the approval check has a bug. Look at how `isApprovedForAll()` is checked in the transfer function and verify the `msg.sender == from || isApprovedForAll(from, msg.sender)` logic. ## ERC-1155 vs ERC-6909 fuzzing If you're choosing between ERC-1155 and [ERC-6909](/blog/how-to-fuzz-erc6909-multi-token) for a new project, the fuzzing differences are worth considering: **Callbacks.** ERC-1155 mandates receiver callbacks on every transfer. ERC-6909 has no mandatory callbacks. This means ERC-1155 fuzzing must include callback-aware receivers (good, bad, reentrant), while ERC-6909 fuzzing can focus purely on accounting and approval logic. **Approval granularity.** ERC-1155 has only `setApprovalForAll()`. ERC-6909 has both per-ID `approve()` and `setOperator()`. ERC-6909 properties need to test the interaction between these two systems. **Batch operations.** ERC-1155 has native batch transfer and balance query functions with atomicity guarantees. ERC-6909 doesn't. Batch atomicity testing is specific to ERC-1155. **Gas profile.** ERC-6909 is lighter per-operation (no callback overhead), which means fuzzing campaigns generate more state transitions per second. This gives ERC-6909 campaigns better coverage per time unit. Both standards benefit from the same ghost-variable pattern for supply conservation. If your project uses ERC-1155, the callback properties are non-negotiable. If you're on ERC-6909, you trade callback complexity for approval-granularity complexity. ## Beyond the basics Once your core ERC-1155 properties pass, consider these extensions. - **Test URI consistency.** If your contract returns per-ID URIs, add a property that verifies `uri(id)` returns a non-empty string for all minted IDs. - **Add custom receiver logic.** If your protocol has contracts that receive ERC-1155 tokens and act on them (staking, marketplace listings), include those as receivers in your fuzzing setup. - **Test mixed fungible/non-fungible.** If some IDs are fungible (supply > 1) and others are non-fungible (supply == 1), add properties that verify non-fungible IDs never exceed supply 1. - **Run in CI.** Set up [continuous fuzzing](/blog/continuous-security-testing-cicd-fuzzing) to catch regressions on every PR. ERC-1155's callback requirement makes it one of the more interesting standards to fuzz. The mandatory external calls during transfers create a reentrancy surface that only [stateful fuzzing](/blog/stateful-fuzzing-explained-sequence-matters) can exercise properly. If you're building an ERC-1155 token and want a battle-tested invariant suite, [request an audit with Recon](/request-audit). We've fuzzed multi-token implementations across gaming, NFTs, and DeFi integrations. --- ### Flash loan exploits: how they work and how invariant testing catches them **Author:** deivitto | **Published:** 2026-01-26 | **Reading time:** 10 min **Tags:** flash-loan-attack, defi-security, fuzzing, invariant-testing, smart-contract-security **URL:** https://getrecon.xyz/blog/flash-loan-exploits-invariant-testing # Flash loan exploits: how they work and how invariant testing catches them Flash loans changed DeFi forever — and not just for the builders. Since their introduction, they've been the weapon of choice behind some of the biggest exploits in crypto history. The ability to borrow millions without collateral, use those funds to manipulate protocol state, and repay everything in a single atomic transaction has cost protocols hundreds of millions of dollars. But here's the thing: flash loan attacks don't exploit flash loans themselves. They exploit weaknesses in the protocols that interact with borrowed funds. And those weaknesses are exactly what invariant testing is built to find. ## How flash loans actually work A flash loan lets you borrow any amount of tokens from a lending pool, as long as you repay the full amount (plus a small fee) within the same transaction. If repayment doesn't happen, the entire transaction reverts — the blockchain acts like the loan never existed. The flow looks like this: 1. Your contract calls the lending pool's flash loan function 2. The pool sends tokens to your contract and calls your callback 3. Inside the callback, you do whatever you want with the funds 4. Before the transaction ends, you repay the pool 5. If you don't repay, everything reverts This mechanism is powerful because it removes the capital barrier. An attacker doesn't need $200M sitting in a wallet. They just need a smart contract and a vulnerability to target. ## Real exploits that shook DeFi **Euler Finance — $197M (March 2023)** The Euler hack exploited a flaw in how the protocol handled donations to reserves. The attacker flash-borrowed DAI, deposited it, used the `donateToReserves` function to create an artificial bad debt position, then liquidated themselves at a profit. The core issue wasn't the flash loan — it was that `donateToReserves` didn't properly account for how reducing a user's collateral affected their health factor. **bZx — $8M (February 2020)** One of the earliest flash loan attacks. The attacker borrowed ETH via a flash loan, used part as collateral on Compound, shorted ETH on bZx's Fulcrum platform (which pushed the price on Uniswap due to low liquidity), then profited from the price difference. The vulnerability was bZx's reliance on a single DEX for price discovery. **Cream Finance — $130M (October 2021)** Cream was hit through a multi-step flash loan attack involving price manipulation of yUSD vault tokens. The attacker inflated the price of their collateral by manipulating the underlying vault's share price, then borrowed against the inflated value. The root cause: the protocol used a spot price that could be manipulated within a single transaction. ## The pattern behind every flash loan exploit If you look at these attacks, there's a common thread. Every single one exploits the fact that protocol state can be temporarily pushed into an inconsistent position within an atomic transaction. The three most common vectors are: - **Price oracle manipulation**: Spot prices from AMMs can be moved with enough capital - **Reentrancy during callbacks**: Flash loan callbacks execute arbitrary code mid-transaction - **Accounting inconsistencies**: Deposits, donations, or transfers that temporarily break internal invariants ## Where invariant testing fits in Traditional unit tests won't catch these issues because you'd need to specifically imagine the exact multi-step attack sequence. Flash loan exploits often involve 5-10 steps across multiple protocols — no human enumerates all those paths. Invariant testing takes the opposite approach. You define properties that must hold true regardless of what happens, and a fuzzer throws millions of random transaction sequences at your contracts. If any sequence breaks a property, you've found a bug. Get expert fuzzing for your protocol ## Catching flash loan vulnerabilities with properties Let's look at concrete examples. First, here's a simplified vulnerable flash loan callback: ```solidity // VULNERABLE: No reentrancy guard, state updated after external call contract VulnerableLender { mapping(address => uint256) public balances; function flashLoan(uint256 amount, address callback) external { uint256 balanceBefore = token.balanceOf(address(this)); token.transfer(callback, amount); IFlashBorrower(callback).onFlashLoan(amount); // arbitrary external call // State check happens after external call — reentrancy window require( token.balanceOf(address(this)) >= balanceBefore, "Flash loan not repaid" ); } function deposit(uint256 amount) external { token.transferFrom(msg.sender, address(this), amount); balances[msg.sender] += amount; } function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount); balances[msg.sender] -= amount; token.transfer(msg.sender, amount); // can be called during flashLoan callback } } ``` The issue here is that during the `onFlashLoan` callback, an attacker can call `deposit` and `withdraw` to manipulate their balance while the flash loan's repayment check hasn't executed yet. ### Property 1: Reentrancy detection via solvency invariant Using the [Chimera framework](/learn/chimera-framework), you can write a property that catches this class of bugs: ```solidity // Chimera invariant property — checks solvency after every action abstract contract FlashLoanProperties is BeforeAfter { // PROPERTY: Protocol token balance must always cover all user deposits function invariant_solvency_during_flash_loans() public view returns (bool) { uint256 totalTrackedDeposits = lender.totalDeposits(); uint256 actualBalance = token.balanceOf(address(lender)); // If actual balance drops below tracked deposits, // something extracted value it shouldn't have return actualBalance >= totalTrackedDeposits; } // PROPERTY: No single tx sequence should change total deposits // without corresponding token transfers function invariant_deposit_accounting_consistent() public view returns (bool) { uint256 sumOfBalances = 0; for (uint256 i = 0; i < actors.length; i++) { sumOfBalances += lender.balances(actors[i]); } return sumOfBalances == lender.totalDeposits(); } } ``` The fuzzer will generate sequences that include flash loan calls interleaved with deposits and withdrawals. When a reentrancy bug lets someone inflate their balance, `invariant_solvency_during_flash_loans` breaks because tracked deposits exceed the actual token balance. ### Property 2: Oracle manipulation detection For price oracle attacks, you need a property that detects when prices move beyond reasonable bounds within a single block: ```solidity abstract contract OracleProperties is BeforeAfter { uint256 constant MAX_PRICE_DEVIATION_BPS = 500; // 5% max per block // PROPERTY: Oracle price can't deviate more than threshold in one block function invariant_oracle_price_stability() public view returns (bool) { uint256 currentPrice = oracle.getPrice(address(token)); uint256 lastKnownPrice = _before.oraclePrice; if (lastKnownPrice == 0) return true; // skip first call uint256 deviation; if (currentPrice > lastKnownPrice) { deviation = ((currentPrice - lastKnownPrice) * 10000) / lastKnownPrice; } else { deviation = ((lastKnownPrice - currentPrice) * 10000) / lastKnownPrice; } return deviation <= MAX_PRICE_DEVIATION_BPS; } // PROPERTY: Collateral value used for borrowing must reflect // time-weighted price, not spot price function invariant_no_spot_price_borrowing() public view returns (bool) { uint256 spotPrice = amm.getSpotPrice(address(token)); uint256 twapPrice = oracle.getTWAP(address(token), 30 minutes); // If spot deviates significantly from TWAP, any new borrows // using spot price indicate a manipulation window uint256 gap = spotPrice > twapPrice ? spotPrice - twapPrice : twapPrice - spotPrice; if (gap * 10000 / twapPrice > 1000) { // >10% gap // No new borrows should have been opened at manipulated price return _after.totalBorrows == _before.totalBorrows; } return true; } } ``` This catches the exact pattern that hit Cream Finance. When the fuzzer manipulates an AMM pool's reserves (which it'll naturally try during random sequences), the TWAP divergence check flags any borrows that relied on the manipulated spot price. ## Why this works better than manual review An auditor reviewing the Euler codebase might look at `donateToReserves` and think "this reduces the user's assets, but there's a health check later." The subtlety is in the interaction between donation, liquidation math, and flash-loaned capital — a chain of 6+ steps that's genuinely hard to trace mentally. A fuzzer doesn't need to imagine the attack. It just needs good properties. If "the protocol is always solvent" and "no user can extract more than they deposited plus earned yield" are both defined as invariants, the fuzzer will find any sequence that violates them — including sequences that involve flash loans, reentrancy, and price manipulation. ## Practical takeaways 1. **Don't trust spot prices for anything financial.** Use TWAPs, Chainlink feeds, or multi-source medians. Write a property that flags spot-vs-TWAP divergence. 2. **Define solvency properties early.** "Actual token balance >= total tracked deposits" catches an enormous class of bugs, including flash loan reentrancy. 3. **Include flash loan handlers in your fuzz campaigns.** If your protocol interacts with Aave, dYdX, or Balancer flash loans, add handler functions that simulate flash-loan-funded sequences. 4. **Test accounting invariants after every call.** Sum of individual balances must equal the tracked total. This catches double-counting and reentrancy inflation. 5. **Don't rely on reentrancy guards alone.** They help, but cross-function and cross-contract reentrancy can bypass them. Properties catch the consequence regardless of the vector. Flash loan attacks aren't going away — the capital-free nature of these loans means every protocol is a potential target. But with the right properties, you don't need to predict the exact attack. You just need to define what "correct" looks like, and let the fuzzer find everything that isn't. For a deeper dive into how fuzzing works under the hood, check out [What is smart contract fuzzing?](/blog/what-is-smart-contract-fuzzing). And if you're writing your first invariant tests, [How to write your first invariant test](/blog/how-to-write-first-invariant-test) walks through the full setup. --- ### Access control bugs in Solidity: real hacks and property-based defense **Author:** deivitto | **Published:** 2026-02-17 | **Reading time:** 9 min **Tags:** access-control, smart-contract-security, invariant-testing, fuzzing, defi-security **URL:** https://getrecon.xyz/blog/access-control-vulnerabilities-solidity # Access control bugs in Solidity: real hacks and property-based defense Access control is the first line of defense in any smart contract. Get it wrong and everything downstream — funds, governance, protocol state — is exposed. It's also one of the most frequently exploited categories on-chain, responsible for billions in losses. In this post, we'll look at common patterns, real exploits, and how property-based testing catches the bugs that code review often misses. ## Common access control patterns Most Solidity projects use one of three approaches: **Ownable** — A single `owner` address with a modifier. Simple but brittle. If the owner key is compromised or the transfer function has a bug, there's no fallback. **OpenZeppelin AccessControl** — Role-based with `bytes32` role identifiers, admin roles that can grant/revoke, and per-function `onlyRole` checks. More flexible, but the role graph can get complex fast. **Custom role systems** — Protocols that outgrow AccessControl often build their own. Timelock controllers, multi-sig gates, role hierarchies with delegation. These are where the subtle bugs live, because they don't inherit battle-tested code. The pattern you pick matters less than whether you've actually verified the transitions between states. Who can grant a role? Who can revoke it? Can an admin remove themselves and leave the protocol bricked? These aren't hypothetical — they've happened. ## Real exploits that started with access control **Parity multisig wallet (2017) — $150M frozen forever.** The Parity library contract had an unprotected `initWallet` function. An attacker called it, became the owner, then called `kill()`. Every wallet that delegated to that library lost access to its funds permanently. The root cause: a public function that should've been internal or guarded by an initialization check. **Ronin bridge (2022) — $625M stolen.** The Ronin bridge required 5 of 9 validator signatures. But Axie Infinity's rapid growth led to a temporary arrangement where Sky Mavis controlled 4 validators plus a third-party one that hadn't been revoked. An attacker compromised Sky Mavis's keys and had enough signatures. The access control *logic* was fine — the role *distribution* wasn't. This is exactly the kind of state that's hard to catch in code review but trivial to express as a property. ## A vulnerable pattern: the missing modifier Here's a pattern we see regularly in audits. A protocol adds a new admin function and forgets to apply the access modifier: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; contract VulnerableVault { address public admin; mapping(address => bool) public whitelisted; modifier onlyAdmin() { require(msg.sender == admin, "Not admin"); _; } constructor() { admin = msg.sender; } // This function is missing the onlyAdmin modifier function setAdmin(address newAdmin) external { admin = newAdmin; } function addToWhitelist(address user) external onlyAdmin { whitelisted[user] = true; } } ``` Anyone can call `setAdmin` and take over the contract. It's obvious in isolation, but in a 3,000-line contract with dozens of functions, these slip through. Especially during late-stage refactors when modifiers get accidentally removed. Get your access control reviewed ## Catching role transition bugs with properties Static analysis and manual review can catch missing modifiers, but they struggle with *stateful* bugs — scenarios where a sequence of valid calls leads to an invalid state. That's where property-based testing shines. Here's a [Chimera](https://github.com/Recon-Fuzz/chimera) property that verifies admin continuity: the protocol should never end up without an admin. ```solidity // Property: admin role can't be removed without a replacement function property_admin_always_exists() public view returns (bool) { // After any sequence of calls, there must always be a valid admin return vault.admin() != address(0); } ``` This looks simple, but a fuzzer will try thousands of call sequences — `setAdmin(address(0))`, renouncing ownership, self-destructing linked contracts — and flag any path that violates it. We've caught real bugs with exactly this kind of property, where a two-step transfer pattern let the admin be set to `address(0)` if the pending admin never accepted. ## Detecting privilege escalation The second class of bugs is privilege escalation: a non-privileged user gaining a role they shouldn't have. Here's a property for that: ```solidity // Property: only current admin can change roles function property_no_privilege_escalation() public returns (bool) { address attacker = address(0xBAD); // Record admin before attacker acts address adminBefore = vault.admin(); // Simulate attacker calling setAdmin vm.prank(attacker); try vault.setAdmin(attacker) { // If the call succeeded and attacker wasn't admin, that's a violation return attacker == adminBefore; } catch { // Call reverted — access control held return true; } } ``` When the fuzzer runs this against the vulnerable contract above, it immediately flags the violation: `attacker` isn't the admin, but `setAdmin` didn't revert. The property turns an implicit assumption ("only admin can change admin") into an explicit, testable invariant. ## Why properties catch what reviews miss Code review is great at spotting known patterns. But access control bugs often emerge from *interactions* between components — a delegatecall to a user-supplied address, a callback that re-enters with elevated permissions, a migration script that temporarily grants a role and doesn't revoke it. Properties don't care about the path. They define the end state that must hold and let the fuzzer find any sequence that breaks it. We've seen this catch: - **Unrevoked temporary roles** after governance proposals - **Delegatecall to untrusted contracts** that overwrite storage slots holding role mappings - **Re-entrancy during role transfer** that lets an attacker hold two roles simultaneously - **Timelock bypass** through specific call ordering ## Practical recommendations 1. **Start with the role graph.** Before writing code, map every role, who can grant it, who can revoke it, and what happens if it's empty. Then write properties for each transition. 2. **Don't rely on modifiers alone.** Modifiers are necessary but not sufficient. Write properties that verify the *effect* of access control, not just its presence. 3. **Test role removal paths.** The most dangerous bugs aren't unauthorized access — they're authorized actions that brick the protocol. Renouncing the last admin, revoking your own timelock access, removing all guardians from a multisig. 4. **Use two-step transfers for critical roles.** `transferOwnership` + `acceptOwnership` prevents transferring admin to a dead address. Then write a property that the pending owner can't be `address(0)` after a transfer is initiated. 5. **Fuzz the upgrade path.** If your contract is upgradeable, the proxy admin is the most critical role. Write properties that verify the upgrade admin can't be changed by non-admins and can't be set to zero. If you're new to writing these kinds of properties, start with our tutorial on [how to write your first invariant test](/blog/how-to-write-first-invariant-test). It covers the setup, tooling, and mental model you need to go from zero to running properties against your own contracts. Access control isn't glamorous, but it's where the money is — both for attackers and for the teams that get it right. --- ### Halmos symbolic execution for smart contracts: setup, limitations, and when it beats fuzzing **Author:** deivitto | **Published:** 2026-03-03 | **Reading time:** 11 min **Tags:** fuzzing, invariant-testing, smart-contract-security, property-based-testing **URL:** https://getrecon.xyz/blog/halmos-symbolic-execution-guide # Halmos symbolic execution for smart contracts: setup, limitations, and when it beats fuzzing Fuzzing and symbolic execution get lumped together as "advanced testing," but they solve the problem from opposite directions. A fuzzer throws random inputs at your code and hopes to trip over a bug. Symbolic execution doesn't guess — it treats inputs as mathematical variables and reasons about every possible path through the code. Halmos is the tool that brings symbolic execution to the Solidity world in a way that actually fits into existing Foundry workflows. I've used Halmos alongside fuzzers on dozens of audits at Recon. It's not a replacement for fuzzing — it's a different lens. Here's how to set it up, where it shines, and where it falls apart. ## What symbolic execution actually does When you run a fuzzer like Echidna or Medusa, it generates concrete values — say, `amount = 7291` — and checks if your property holds. It does this millions of times, sampling the input space randomly (or with coverage guidance). It's fast, but it can miss the one specific value that triggers a bug. Symbolic execution doesn't pick values. Instead, it says "let `amount` be any possible `uint256`" and tracks how that symbolic value flows through every branch. At each conditional, it forks: one path where the condition is true, one where it's false. It then asks an SMT solver (like z3) whether any concrete value could make each path reach your assertion failure. The upside: if a bug exists in the explored state space, symbolic execution will find it. No luck involved. The downside: the number of paths can explode exponentially, which is why this approach needs careful scoping. ## Setting up Halmos If you're already using Foundry, Halmos slots in with minimal friction. Install it: ```bash pip install halmos ``` Halmos reads your Foundry project structure directly. You write test functions prefixed with `check_` instead of `test_`, and Halmos treats every parameter as symbolic. Here's a minimal example — a property that works identically whether you run it with Foundry's fuzzer or with Halmos: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "../src/SafeMath.sol"; contract SafeMathTest is Test { SafeMath internal math; function setUp() public { math = new SafeMath(); } // Works with both: forge test and halmos function check_addDoesNotOverflow(uint128 a, uint128 b) public { uint256 result = math.safeAdd(a, b); assert(result >= a); assert(result >= b); } } ``` Run it with Foundry's fuzzer: ```bash forge test --match-test check_addDoesNotOverflow ``` Run it with Halmos: ```bash halmos --function check_addDoesNotOverflow ``` Same property, two tools. Foundry will throw random `uint128` pairs at it. Halmos will reason about all possible pairs symbolically. ## Writing Halmos-specific properties Where Halmos gets interesting is when you lean into symbolic reasoning. You can write properties that assert things about arbitrary storage states, not just function inputs: ```solidity function check_withdrawNeverExceedsBalance(uint256 depositAmt, uint256 withdrawAmt) public { // Symbolic setup: any deposit, then any withdrawal vm.assume(depositAmt > 0); vm.assume(withdrawAmt > 0); vault.deposit(depositAmt); uint256 balBefore = vault.balanceOf(address(this)); // If withdraw doesn't revert, balance must decrease by exactly withdrawAmt try vault.withdraw(withdrawAmt) { uint256 balAfter = vault.balanceOf(address(this)); assert(balBefore - balAfter == withdrawAmt); } catch { // Revert is acceptable — but only if withdrawAmt > balance assert(withdrawAmt > balBefore); } } ``` Halmos will explore both the success and revert branches, and for each branch it'll ask: does any combination of `depositAmt` and `withdrawAmt` violate the assertion? If your vault has an off-by-one in the balance check, Halmos will hand you the exact counterexample. ## Where Halmos finds bugs that fuzzers miss Boundary conditions are where symbolic execution earns its keep. Consider a fee calculation: ```solidity function calculateFee(uint256 amount) public pure returns (uint256) { // Bug: integer division truncation means fee is 0 for amount < 1000 return (amount * 3) / 1000; } function check_feeIsNonZeroForNonZeroAmount(uint256 amount) public { vm.assume(amount > 0); uint256 fee = calculateFee(amount); // This assertion should hold if we expect fees on all non-zero amounts assert(fee > 0); } ``` A fuzzer sampling random `uint256` values will almost never generate a value below 334 (the threshold where the fee rounds to zero). The probability is roughly 334 / 2^256 — essentially zero. Halmos will find `amount = 1` as a counterexample instantly, because it doesn't sample — it solves. This pattern shows up constantly in DeFi: rounding thresholds, precision boundaries, fee cliffs. These are exactly the bugs that slip through fuzzing campaigns but get caught by symbolic execution. Get multi-tool security testing ## Configuration and timeouts Halmos has several flags you'll want to tune: ```bash # Run with a 10-minute timeout per function, unroll loops up to 8 times halmos --function check_ --loop 8 --solver-timeout-assertion 600000 # Target a specific contract halmos --contract SafeMathTest --function check_addDoesNotOverflow # Increase verbosity for debugging path explosion halmos --function check_ --statistics --debug # Set storage size limits to control memory usage halmos --storage-layout solidity --solver-timeout-branching 10000 ``` Key flags: - `--loop N`: maximum loop unrolling depth (default is low, bump it for contracts with loops) - `--solver-timeout-assertion`: milliseconds before the solver gives up on a single assertion - `--statistics`: prints path counts and solver time — essential for understanding why a check is slow - `--solver-threads`: parallelize solver queries if your machine has cores to spare ## The limits: state space explosion Halmos doesn't scale the way fuzzers do. Here's where it breaks down: **Loops.** Every loop iteration forks the execution path. A `for` loop that runs up to 100 times creates 100 path branches at minimum. Halmos handles this with bounded unrolling (`--loop N`), but you're only verifying the property for loop counts up to N. **External calls.** When your contract calls another contract, Halmos needs to reason about what that external contract might return. For known contracts it can inline the logic; for unknown ones, it has to assume the return value is symbolic, which multiplies paths further. **Stateful sequences.** Halmos checks one function call at a time (or a short sequence you manually compose). It doesn't generate multi-transaction attack sequences the way a stateful fuzzer does. If the bug requires calling `deposit()`, then `borrow()`, then `withdraw()` in a specific order — that's fuzzer territory. **Large contracts.** A contract with 50+ functions and deep inheritance chains will often time out. You'll need to isolate specific properties and test them against focused contract subsets. ## Decision tree: Halmos vs fuzzing Use this to pick the right tool for the job: **Reach for Halmos when:** - You're testing arithmetic properties (overflow, rounding, precision loss) - The function under test has a small, bounded state space - You need to prove a property holds for ALL inputs, not just sampled ones - You're investigating a specific boundary condition or edge case - The contract is small to medium-sized with limited external dependencies **Reach for fuzzing when:** - You're testing stateful invariants across multi-step transaction sequences - The contract is large or has deep call chains to external protocols - You're exploring behavior you don't fully understand yet (fuzzing as discovery) - You need to test with realistic deployment scenarios (multiple actors, time progression) - The property involves interactions between many contract functions **Use both when:** - You're doing a serious audit. Symbolic execution covers the boundary conditions fuzzers miss. Fuzzers cover the stateful sequences symbolic execution can't reach. They're complementary, not competing. At Recon, we run Halmos checks alongside [Echidna and Medusa campaigns](/blog/smart-contract-fuzzing-tools-compared) as standard practice. The bugs each tool catches barely overlap — which is exactly why you want both in your pipeline. For a deeper dive into fuzzing tool selection, check out our [full comparison of smart contract fuzzing tools](/blog/smart-contract-fuzzing-tools-compared). --- ### ZK circuit security: constraint bugs, witness leaks, and audit patterns **Author:** deivitto | **Published:** 2026-03-14 | **Reading time:** 12 min **Tags:** smart-contract-security, fuzzing, invariant-testing, defi-security **URL:** https://getrecon.xyz/blog/zk-circuit-security-audit-patterns # ZK circuit security: constraint bugs, witness leaks, and audit patterns If you've audited smart contracts for a while and then look at a ZK circuit for the first time, something feels off. The code looks familiar — there are variables, assignments, conditionals. But it doesn't *execute*. It *constrains*. And that distinction changes everything about how bugs manifest, how you find them, and how you prove they're real. We've been auditing ZK circuits at Recon across Circom, Noir, and Halo2 projects, and the bug classes we see are fundamentally different from what shows up in Solidity. This post breaks down why, walks through the most common vulnerability patterns, and lays out the audit methodology we use. ## Constraint systems aren't execution environments A smart contract is imperative: do this, then do that, store this value, send those tokens. When you audit one, you're tracing execution paths and looking for states where invariants break. A ZK circuit is declarative: it defines a set of polynomial constraints that a valid witness must satisfy. The "program" doesn't run in the traditional sense. Instead, a prover constructs a witness (the private inputs and intermediate values) and proves that every constraint holds. A verifier checks the proof without seeing the witness. This means the bug model is inverted. In a smart contract, you worry about *what the code does*. In a circuit, you worry about *what the code fails to constrain*. If a constraint is missing, the prover can satisfy the circuit with values that shouldn't be valid — and the verifier will accept the proof anyway. ## Common bug classes ### Under-constrained circuits This is the single most dangerous class of ZK bugs. An under-constrained circuit allows a malicious prover to generate a valid proof for an invalid statement. The circuit "works" for honest inputs but doesn't reject dishonest ones. Here's a simple Circom example. Say you want a circuit that proves you know two factors of a number: ```circom // VULNERABLE: under-constrained factoring circuit template Factor() { signal input n; signal input a; signal input b; // Constrain that a * b == n signal product; product <== a * b; n === product; } ``` This looks right at first glance. But there's nothing stopping `a` or `b` from being 1 or `n` itself. A prover can always satisfy this with the trivial factorization `(1, n)`. Worse, in finite field arithmetic, there's no implicit range — `a` and `b` could be negative field elements that multiply to `n` modulo the field prime. The fixed version adds the missing constraints: ```circom // FIXED: properly constrained factoring circuit template Factor() { signal input n; signal input a; signal input b; // Constrain that a * b == n signal product; product <== a * b; n === product; // Prevent trivial factorizations // a and b must be > 1 and < n // Use range check components component aRangeCheck = GreaterThan(252); aRangeCheck.in[0] <== a; aRangeCheck.in[1] <== 1; aRangeCheck.out === 1; component bRangeCheck = GreaterThan(252); bRangeCheck.in[0] <== b; bRangeCheck.in[1] <== 1; bRangeCheck.out === 1; component aNCheck = LessThan(252); aNCheck.in[0] <== a; aNCheck.in[1] <== n; aNCheck.out === 1; component bNCheck = LessThan(252); bNCheck.in[0] <== b; bNCheck.in[1] <== n; bNCheck.out === 1; } ``` Every signal that isn't fully determined by other constraints needs explicit bounds. If you leave a degree of freedom in the witness, someone will exploit it. ### Witness grinding and malleability Even when a circuit is fully constrained for honest inputs, there can be multiple valid witnesses for the same public input. This is witness malleability, and it's a problem when the protocol assumes proof uniqueness. Witness grinding is the active exploitation of this: a malicious prover tries many witnesses until they find one that satisfies additional external conditions (like a favorable hash or a replay in a different context). It doesn't break soundness per se, but it breaks protocol-level assumptions that depend on proofs being non-malleable. ### Missing range checks In finite field arithmetic, there's no concept of "overflow." A value that looks like 5 could also be `p - 5` (where `p` is the field prime), and both satisfy the same multiplicative constraints. If your circuit assumes values are within a certain range (e.g., a uint64), you need explicit range check constraints. We see this one constantly — especially in circuits ported from regular code where the developer assumed integer semantics. ### Trusted setup issues For circuits using KZG or Groth16, the trusted setup ceremony produces a structured reference string (SRS). If the toxic waste from the ceremony isn't properly destroyed, anyone with it can forge proofs. We don't audit the ceremony itself (that's a different problem), but we do verify that the circuit actually uses the SRS correctly and that verification contracts on-chain check all the pairing equations. Get your ZK circuits audited ## Soundness vs completeness — both matter Soundness means: a malicious prover can't convince the verifier of a false statement. If your circuit is unsound, fake proofs pass verification. This is the catastrophic case. Completeness means: an honest prover can always generate a valid proof for a true statement. If your circuit is incomplete, legitimate users can't prove things they should be able to prove. This blocks functionality — it's a liveness bug, not a safety bug, but it still matters. In practice, we've seen completeness bugs that are just as impactful as soundness bugs. A circuit that rejects valid withdrawals effectively freezes user funds. Both directions need testing. ## Audit methodology: Circom vs Noir vs Halo2 The constraint model is the same across frameworks, but the tooling and bug patterns differ enough that the audit approach has to adapt. **Circom**: Template-based, explicit signal flow. The main risk is the gap between the `<--` operator (assignment only, no constraint) and `<==` (assignment + constraint). Every `<--` is a red flag that needs manual verification of a corresponding `===` constraint. We grep for every `<--` and verify the matching constraint exists. **Noir**: Higher-level, closer to Rust syntax. The compiler handles more of the constraint generation, which means fewer raw under-constrained bugs but more risk of the developer misunderstanding what the compiler does and doesn't constrain. `assert()` in Noir generates constraints; regular variable assignments don't always. The audit focuses on ensuring every security-relevant assertion is actually in the constraint system, not just in the prover's logic. **Halo2**: Rust-based, column/region model. The configuration phase (where you define gates and columns) is separate from the synthesis phase (where you assign values). Bugs here often involve mismatches between what the gates constrain and what the synthesis assumes. The region abstraction can also mask whether a cell is actually constrained or just assigned. ## Testing strategies that actually work ### Formal verification of constraints For small-to-medium circuits, you can extract the constraint system as a set of polynomial equations and verify properties symbolically. Tools like Ecne (for Circom) and Alucard can check whether a circuit is under-constrained by analyzing the degree-of-freedom in the constraint system. This is the gold standard but doesn't scale to very large circuits. ### Differential testing Build a reference implementation in a normal programming language (TypeScript, Python) that computes the expected output for given inputs. Then run the same inputs through the circuit's witness generator and compare. Any divergence is a bug — either in the reference implementation or the circuit. ### Property-based testing of circuit I/O This is where we get the most mileage. Define properties over the circuit's public inputs and outputs and fuzz the private inputs: ```typescript // Property-based test: circuit output must match // reference implementation for all valid inputs import { expect } from "chai"; import * as fc from "fast-check"; import { wasm } from "./circuit_wasm"; import { referenceCompute } from "./reference"; describe("Circuit I/O correctness", () => { it("should match reference for arbitrary valid inputs", () => { fc.assert( fc.property( fc.bigInt(2n, 2n ** 64n - 1n), fc.bigInt(2n, 2n ** 64n - 1n), (a, b) => { const expected = referenceCompute(a, b); const witness = wasm.calculateWitness({ a: a.toString(), b: b.toString(), }); const circuitOutput = witness[1]; // first output signal expect(circuitOutput).to.equal(expected.toString()); } ), { numRuns: 10000 } ); }); it("should reject invalid witnesses", () => { fc.assert( fc.property( fc.bigInt(2n, 2n ** 64n - 1n), fc.bigInt(2n, 2n ** 64n - 1n), (a, b) => { // Tamper with the witness and verify proof fails const witness = wasm.calculateWitness({ a: a.toString(), b: b.toString(), }); witness[2] = (BigInt(witness[2]) + 1n).toString(); expect(() => wasm.checkConstraints(witness)).to.throw(); } ), { numRuns: 5000 } ); }); }); ``` The key insight: you're not just testing that correct inputs produce correct outputs. You're testing that *incorrect* witnesses fail constraint checking. Both directions matter — it mirrors the soundness/completeness distinction. For more on how property-based testing applies to smart contracts generally, see our [guide on fuzzing methodology](/blog/what-is-smart-contract-fuzzing). ## Practical checklist for ZK security review Before you ship a ZK circuit to production, walk through this list: - [ ] **Constraint completeness**: every `<--` (Circom) or unconstrained assignment has a matching constraint. No signal is left free. - [ ] **Range checks**: every signal that represents a bounded value (amounts, indices, booleans) has explicit range constraints. Don't rely on field arithmetic behaving like integer arithmetic. - [ ] **Nullifier uniqueness**: if the circuit produces nullifiers (for privacy protocols), verify they're deterministically derived from private inputs with no malleability. - [ ] **Public input validation**: the verifier contract checks all public inputs against expected bounds before accepting the proof. - [ ] **Witness uniqueness**: for each set of public inputs, determine if multiple valid witnesses exist. If they do, assess whether the protocol is safe under witness malleability. - [ ] **Differential testing**: a reference implementation exists and has been tested against the circuit across thousands of random inputs. - [ ] **Soundness testing**: tampered witnesses are rejected by the constraint system. Proofs generated from tampered witnesses are rejected by the verifier. - [ ] **Verifier contract correctness**: the on-chain verifier matches the circuit's verification key and checks all pairing equations (for Groth16/KZG) or hash chains (for FRI-based systems). - [ ] **Trusted setup verification**: if applicable, the SRS was generated in a ceremony with sufficient participants, and the circuit uses the correct SRS. - [ ] **Upgrade path**: if the circuit or verification key can be upgraded, the governance mechanism is sound and timelocked. ZK circuits are powerful, but they're unforgiving. A single missing constraint can invalidate the entire security model in ways that are invisible until exploited. The testing and audit methodology has to match that reality. --- ### Medusa fuzzer tutorial: from install to first bug **Author:** antonio | **Published:** 2025-10-13 | **Reading time:** 15 min **Tags:** fuzzing, medusa, tutorial, smart-contract-security **URL:** https://getrecon.xyz/blog/medusa-fuzzer-tutorial-solidity # Medusa fuzzer tutorial: from install to first bug So you've heard about [fuzzing](/learn/fuzzing) and you want to try it on your Solidity contracts. Good call. [Medusa](/learn/medusa) is one of the best tools for the job — it's fast and configurable, built in Go so you get a single binary with zero runtime dependencies. In this tutorial, we'll go from a fresh install to catching a real bug in a vault contract. No fluff. Just the steps. ## What is Medusa? Medusa is a coverage-guided [fuzzer](/learn/coverage-guided-fuzzing) for Solidity smart contracts. It generates semi-random inputs, calls your contract functions, and checks whether your [properties](/learn/property-based-testing) hold. When a property breaks, Medusa gives you the exact call sequence that triggered the failure. If you're wondering how it stacks up against Echidna, check out our [Echidna vs Medusa comparison](/blog/echidna-vs-medusa-practical-comparison). Short version: Medusa tends to be faster for large codebases and has better parallel execution. But both are solid. ## Installation You've got two options here. ## Option A: Go install (recommended) If you've got Go 1.21+ installed: ```bash go install github.com/crytic/medusa@latest ``` Make sure `$GOPATH/bin` is in your PATH: ```bash export PATH=$PATH:$(go env GOPATH)/bin medusa --version ``` ## Option B: pre-built binary Grab the latest release from the [Medusa GitHub releases page](https://github.com/crytic/medusa/releases). Download the binary for your OS, make it executable, and drop it somewhere in your PATH. ```bash chmod +x medusa sudo mv medusa /usr/local/bin/ medusa --version ``` Either way, you should see a version string. If you do, you're good. ## Project setup We'll use Foundry for compilation since Medusa plays nicely with it. If you don't have Foundry yet, install it: ```bash curl -L https://foundry.sh | bash foundryup ``` Now let's create a project: ```bash mkdir medusa-tutorial && cd medusa-tutorial forge init --no-commit ``` ## The target: a buggy vault contract Create `src/SimpleVault.sol`. This is a basic deposit/withdraw vault with a subtle bug — see if you spot it before Medusa does. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract SimpleVault { mapping(address => uint256) public balances; uint256 public totalDeposits; function deposit() external payable { balances[msg.sender] += msg.value; totalDeposits += msg.value; } function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount, "Insufficient balance"); totalDeposits -= amount; // Bug: state update after external call (reentrancy) (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); balances[msg.sender] -= amount; } function getBalance(address user) external view returns (uint256) { return balances[user]; } } ``` The bug is a classic reentrancy. The balance gets updated *after* the external call. But let's not just eyeball it, let's write a property that catches it automatically. ## Writing your first property test Create `test/SimpleVaultTest.sol`: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "../src/SimpleVault.sol"; contract SimpleVaultTest { SimpleVault vault; constructor() payable { vault = new SimpleVault(); } // Property: total deposits should always equal the sum of all balances // If reentrancy happens, totalDeposits goes out of sync function fuzz_totalDeposits_solvency() public view returns (bool) { return address(vault).balance >= vault.totalDeposits(); } // Property: no user can withdraw more than they deposited function fuzz_no_free_money() public view returns (bool) { return address(vault).balance >= vault.totalDeposits(); } function deposit() external payable { vault.deposit{value: msg.value}(); } function withdraw(uint256 amount) external { vault.withdraw(amount); } receive() external payable { // Attacker reentrancy callback if (address(vault).balance > 0) { try vault.withdraw(msg.value) {} catch {} } } } ``` Medusa looks for functions prefixed with `fuzz_` that return `bool`. If any of them return `false`, the property is broken and Medusa reports it. ## Configuring medusa.json Initialize the Medusa config: ```bash medusa init ``` This creates a `medusa.json` file. Let's tweak the key settings: ```json { "fuzzing": { "workers": 4, "workerResetLimit": 50, "timeout": 300, "testLimit": 100000, "callSequenceLength": 20, "deploymentOrder": ["SimpleVaultTest"], "corpusDirectory": "corpus", "coverageEnabled": true, "targetContracts": ["SimpleVaultTest"], "testing": { "propertyTesting": { "enabled": true, "testPrefixes": ["fuzz_"] }, "assertionTesting": { "enabled": false }, "optimizationTesting": { "enabled": false } } }, "compilation": { "platform": "crytic-compile", "platformConfig": { "target": ".", "solcVersion": "", "exportDirectory": "", "args": ["--foundry-compile-all"] } } } ``` Here's what matters: - **workers**: Number of parallel fuzzing goroutines. More = faster, but don't exceed your CPU cores. - **testLimit**: Total number of transactions before stopping. 100K is a decent starting point. - **callSequenceLength**: Max number of calls in a single test sequence. Longer sequences can find deeper [stateful bugs](/learn/stateful-fuzzing). - `corpusDirectory`: Where Medusa saves interesting inputs for replay. - `coverageEnabled`: Track which code paths get hit. You want this on. ## Running your first campaign Build first, then fuzz: ```bash forge build medusa fuzz ``` You should see output like: ``` [INFO] Starting fuzzer... [INFO] Workers: 4 [INFO] Target: SimpleVaultTest [PROPERTY BROKEN] fuzz_totalDeposits_solvency() Call sequence: 1. deposit() [value: 1000000000] 2. withdraw(1000000000) 3. withdraw(1000000000) (via reentrant callback) Result: false ``` Medusa found the reentrancy. The call sequence shows exactly how: deposit, withdraw, and then the receive callback triggers a second withdraw before the first one updates the balance. The `totalDeposits` accounting breaks because the same amount gets subtracted twice. ## Reading the results When Medusa finds a property violation, it gives you: 1. The broken property name (which `fuzz_` function returned false) 2. The call sequence (the exact series of transactions that triggered it) 3. The shrunk sequence. Medusa tries to minimize the sequence to the smallest reproduction Check the `corpus/` directory after a run. You'll find JSON files with saved sequences: ```bash ls corpus/ ``` These are replayable. If you want to reproduce a failure: ```bash medusa fuzz --replay corpus/ ``` ## Coverage reports With `coverageEnabled: true`, Medusa tracks which lines of your contract got executed during fuzzing. After the campaign: ```bash ls corpus/coverage/ ``` The coverage report shows you which branches Medusa explored. If you see low coverage on critical paths, you might need to: - Increase `callSequenceLength` to reach deeper states - Add more helper functions in your test contract to guide the fuzzer - Increase `testLimit` to give Medusa more time Aim for 90%+ line coverage on your target contract. Anything less means the fuzzer is probably missing interesting behavior. ## Optimization mode Medusa has an optimization mode that tries to maximize a numeric value. This is useful for finding worst-case scenarios. ```solidity function optimize_max_drain() external view returns (int256) { // Medusa will try to maximize this value // Higher = more ETH drained beyond deposits int256 drained = int256(vault.totalDeposits()) - int256(address(vault).balance); return drained; } ``` Enable it in `medusa.json`: ```json "optimizationTesting": { "enabled": true, "testPrefixes": ["optimize_"] } ``` Medusa will report the maximum value it achieved and the sequence that got there. Great for quantifying economic impact. ## Tips from the trenches **Start simple.** Write one or two properties, run a short campaign, check coverage. Then iterate. Don't try to write 30 properties on day one. Use the [Chimera framework](/learn/chimera-framework) for larger projects. It lets you write properties once and run them on both Medusa and [Echidna](/learn/echidna) without changing anything. Portability matters when you're doing serious [invariant testing](/learn/invariant-testing). Watch your memory. Medusa with 8+ workers on a large contract can eat RAM. Monitor it during long campaigns. **Seed your corpus.** If you know certain states are important (e.g., a pool with specific token ratios), create setup functions that get the contract into those states before fuzzing. Don't ignore "partially broken" properties. If a property passes 99.99% of the time but fails once in 100K runs, that's still a bug. Rare doesn't mean safe. ## Fixing the bug The fix for our vault is straightforward. Apply checks-effects-interactions: ```solidity function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount, "Insufficient balance"); // Update state BEFORE the external call balances[msg.sender] -= amount; totalDeposits -= amount; (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); } ``` Run Medusa again after the fix. The property should hold across all sequences now. ## What's next? You've got the basics down. From here: - Read our guide on [stateful fuzzing](/blog/stateful-fuzzing-explained-sequence-matters) to understand why call sequence order matters - Check the [Echidna vs Medusa comparison](/compare/echidna-vs-medusa) to understand the tradeoffs - Learn how to combine fuzzing with [formal verification](/compare/fuzzing-vs-formal-verification) for defense in depth - Write your [first invariant test](/blog/how-to-write-first-invariant-test) using a structured approach Fuzzing isn't a silver bullet, but it catches real bugs that manual review misses. The earlier you integrate it into your workflow, the fewer surprises you'll get in production. Get a professional fuzzing audit Try Recon Pro --- ### Echidna tutorial: advanced stateful fuzzing campaigns **Author:** antonio | **Published:** 2025-11-17 | **Reading time:** 15 min **Tags:** fuzzing, echidna, stateful-fuzzing, tutorial **URL:** https://getrecon.xyz/blog/echidna-tutorial-advanced-stateful-fuzzing # Echidna tutorial: advanced stateful fuzzing campaigns You've run [Echidna](/learn/echidna) before. You've written a basic property, watched it churn through transactions, maybe caught a bug or two. Now you want to push it further. This tutorial covers the advanced stuff — config tuning, corpus management, and getting real coverage data out of your campaigns. If you're completely new to Echidna, start with our [stateful fuzzing explainer](/blog/stateful-fuzzing-explained-sequence-matters) first. This picks up where the basics leave off. ## Why advanced config matters Echidna's default settings are fine for small contracts with shallow state spaces. But DeFi protocols aren't small or shallow. A lending protocol might need 15+ transactions in the right order to reach an interesting state, and defaults won't get you there. The difference between a campaign that finds nothing and one that catches a critical bug often comes down to three config values. ## Config tuning: the big three ## seqLen (sequence length) This controls the maximum number of transactions in a single test sequence. ```yaml seqLen: 50 ``` Default is 100, but that's often too long for focused testing. Here's the thing: longer sequences take longer to shrink when they find a failure. If your bug needs 5 calls to trigger but Echidna finds it in a 100-call sequence, shrinking might take minutes. My rule of thumb: - Simple token contracts: 10-20 - Lending protocols: 30-50 - Complex multi-step flows (liquidations, governance): 50-100 Start shorter and increase if coverage plateaus. ## testLimit Total number of test sequences to run. ```yaml testLimit: 500000 ``` Default is 50000. That's not enough for anything beyond trivial contracts. For real audits, I run 500K minimum. For critical paths, 2M+. Yes, it takes longer. But [stateful fuzzing](/learn/stateful-fuzzing) is a numbers game — more sequences means more state combinations explored. ## shrinkLimit How hard Echidna tries to minimize a failing sequence. ```yaml shrinkLimit: 10000 ``` Default is 5000. Bump it up if you're getting long, hard-to-read failing sequences. The tradeoff is time. More shrink attempts means slower reporting, but the counterexamples you get back are much cleaner. ## Full config file Here's a production-ready `echidna.yaml` for a lending protocol: ```yaml testMode: "property" testLimit: 500000 seqLen: 50 shrinkLimit: 10000 contractAddr: "0x00a329c0648769A73afAc7F9381E08FB43dBEA72" deployer: "0x00a329c0648769A73afAc7F9381E08FB43dBEA72" sender: ["0x10000", "0x20000", "0x30000"] balanceAddr: 0xffffffff balanceContract: 0xffffffff codeSize: 0x6000 coverage: true corpusDir: "corpus" cryticArgs: ["--foundry-compile-all"] filterBlacklist: true filterFunctions: ["excludeThisFunction"] ``` Let's break down the less obvious ones. ## sender ```yaml sender: ["0x10000", "0x20000", "0x30000"] ``` Echidna uses these addresses as transaction senders. Three is usually enough. It lets the fuzzer test interactions between different users. More senders means more combinations but slower exploration of each user's state space. ## filterFunctions ```yaml filterBlacklist: true filterFunctions: ["excludeThisFunction", "anotherOne"] ``` When `filterBlacklist` is true, Echidna skips functions in the list. Use this to exclude admin-only setup functions that would just waste fuzzing cycles. You can also set `filterBlacklist: false` to create a whitelist, only fuzzing the functions you specify. ## Property mode vs assertion mode Echidna supports two main testing approaches. ## Property mode (default) You write functions that return `bool`. Echidna calls them after every transaction sequence: ```solidity function echidna_solvency() public view returns (bool) { return totalAssets() >= totalLiabilities(); } ``` Properties run in a read-only context. They can't modify state. That's by design. Properties should be pure checks. ## Assertion mode ```yaml testMode: "assertion" ``` In assertion mode, Echidna looks for `assert()` statements inside regular functions that revert: ```solidity function test_withdraw(uint256 amount) public { uint256 balanceBefore = token.balanceOf(address(this)); vault.withdraw(amount); uint256 balanceAfter = token.balanceOf(address(this)); assert(balanceAfter >= balanceBefore); } ``` Assertion mode is powerful for testing specific operations, not just global invariants. If the assert fires, Echidna reports the full call sequence that led there. **When to use which?** Properties for system-wide invariants (solvency, access control). Assertions for operation-specific correctness (withdraw gives you the right amount, liquidation clears debt properly). ## Multi-contract testing: the lending protocol example Real protocols have multiple interacting contracts. Here's how to set up a multi-contract Echidna test for a simple lending protocol. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "../src/LendingPool.sol"; import "../src/PriceOracle.sol"; import "../src/InterestRateModel.sol"; contract LendingFuzzTest { LendingPool pool; PriceOracle oracle; InterestRateModel rateModel; MockERC20 collateralToken; MockERC20 debtToken; constructor() payable { collateralToken = new MockERC20("COL", 18); debtToken = new MockERC20("DEBT", 18); oracle = new PriceOracle(); rateModel = new InterestRateModel(); pool = new LendingPool( address(oracle), address(rateModel) ); // Seed initial state collateralToken.mint(address(this), 1_000_000e18); debtToken.mint(address(pool), 500_000e18); collateralToken.approve(address(pool), type(uint256).max); // Set initial prices oracle.setPrice(address(collateralToken), 2000e8); oracle.setPrice(address(debtToken), 1e8); } // === Fuzzer entry points === function deposit(uint256 amount) external { amount = bound(amount, 1, collateralToken.balanceOf(address(this))); pool.deposit(address(collateralToken), amount); } function borrow(uint256 amount) external { amount = bound(amount, 1, 100_000e18); pool.borrow(address(debtToken), amount); } function repay(uint256 amount) external { amount = bound(amount, 1, pool.getUserDebt(address(this))); debtToken.approve(address(pool), amount); pool.repay(address(debtToken), amount); } function manipulatePrice(uint256 newPrice) external { // Let the fuzzer move the oracle price newPrice = bound(newPrice, 100e8, 10_000e8); oracle.setPrice(address(collateralToken), newPrice); } function accrueInterest() external { pool.accrueInterest(); } // === Properties === function echidna_pool_solvency() public view returns (bool) { // Pool should never owe more than it holds return debtToken.balanceOf(address(pool)) >= pool.totalBorrows() || pool.totalBorrows() == 0; } function echidna_no_bad_debt() public view returns (bool) { // No position should have debt exceeding collateral value uint256 collValue = pool.getCollateralValue(address(this)); uint256 debtValue = pool.getDebtValue(address(this)); if (debtValue > 0 && collValue == 0) { return false; // Bad debt: debt with zero collateral } return true; } function echidna_interest_monotonic() public view returns (bool) { // Total borrows should only grow from interest, never shrink randomly return pool.totalBorrows() >= pool.lastRecordedBorrows() || pool.totalBorrows() == 0; } // === Helper === function bound(uint256 x, uint256 min, uint256 max) internal pure returns (uint256) { if (max <= min) return min; return min + (x % (max - min + 1)); } } ``` Key patterns here: 1. Constructor does all deployment. Echidna deploys one contract (your test wrapper). Everything else gets deployed inside the constructor. 2. Fuzzer entry points are thin wrappers. They take raw fuzzer input, bound it to valid ranges, and call the target. This prevents wasting cycles on obviously-reverting calls. 3. **Properties check system-level invariants.** Solvency, no bad debt, monotonic interest. These should hold regardless of what call sequence the fuzzer generates. ## Corpus management The corpus is where Echidna saves interesting transaction sequences, ones that increased code coverage or got close to breaking a property. ```yaml corpusDir: "corpus" ``` After a campaign, you'll find two directories: ``` corpus/ coverage/ # Sequences that increased coverage reproducers/ # Sequences that broke properties ``` **Why this matters:** You can commit the corpus to your repo and Echidna will replay those sequences at the start of the next campaign. This means: - New campaigns don't start from scratch - Coverage ratchets forward across runs - CI can build on previous campaign results ```bash # Run with existing corpus echidna test/LendingFuzzTest.sol --config echidna.yaml ``` Echidna automatically loads from `corpusDir` if it exists. Pro tip: periodically clean out old corpus files if the contract interface changes. Stale sequences just cause reverts. ## Reading coverage reports Enable coverage: ```yaml coverage: true ``` After a run, Echidna dumps an HTML coverage report. Open it and look for red lines. Those are uncovered branches. Common reasons for low coverage: - **Missing entry points.** If your test contract doesn't expose a function, Echidna can't call it. Make sure every relevant public function has a wrapper. - **Tight bounds.** If you over-constrain your `bound()` calls, the fuzzer can't explore edge cases. Balance validity with exploration. - **Unreachable states.** Some states need a specific transaction ordering. Increase `seqLen` or add helper functions that set up intermediate states. If coverage is stuck below 70%, something's wrong with your test setup. Don't just increase `testLimit` and hope. Fix the test contract. ## Using crytic-compile Echidna uses crytic-compile under the hood for compilation. For Foundry projects: ```yaml cryticArgs: ["--foundry-compile-all"] ``` For Hardhat: ```yaml cryticArgs: ["--hardhat-ignore-compile"] ``` If you hit compilation issues, try running crytic-compile standalone first: ```bash crytic-compile . --foundry-compile-all ``` This helps isolate whether the problem is compilation or Echidna config. ## Advanced patterns ## Time-dependent bugs Some bugs only appear after time passes. Use Echidna's block advancement: ```solidity function advanceTime(uint256 delta) external { delta = bound(delta, 1, 365 days); // Echidna advances block.timestamp between calls // This function is just a no-op that lets time pass } ``` ## Multi-sender testing Deploy separate action contracts for each actor: ```solidity contract BorrowerActions { LendingPool pool; constructor(LendingPool _pool) { pool = _pool; } function borrow(uint256 amount) external { pool.borrow(amount); } } contract LiquidatorActions { LendingPool pool; constructor(LendingPool _pool) { pool = _pool; } function liquidate(address user) external { pool.liquidate(user); } } ``` ## Ghost variables for tracking Track values across calls to write richer properties: ```solidity uint256 totalDeposited; uint256 totalWithdrawn; function deposit(uint256 amount) external { amount = bound(amount, 1, 1e24); pool.deposit(amount); totalDeposited += amount; } function echidna_accounting() public view returns (bool) { return totalDeposited >= totalWithdrawn; } ``` ## Comparison with Medusa Both are excellent [fuzzing](/learn/fuzzing) tools. See the full [Echidna vs Medusa comparison](/compare/echidna-vs-medusa) for details, but the quick version: - Echidna has a more mature corpus system and better shrinking - [Medusa](/learn/medusa) has native parallelism (multiple workers out of the box) - Echidna is Haskell, Medusa is Go, which matters for debugging and contributions - Both support property mode and assertion mode For maximum coverage, run both. The [Chimera framework](/learn/chimera-framework) lets you write a single test setup that works with either tool. ## CI integration Add Echidna to your CI pipeline: ```yaml # .github/workflows/echidna.yml name: Echidna on: [push, pull_request] jobs: echidna: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: crytic/echidna-action@v2 with: files: test/LendingFuzzTest.sol config: echidna.yaml crytic-args: --foundry-compile-all ``` Keep CI campaigns short (testLimit: 50000) for fast feedback. Run longer campaigns nightly or before releases. ## What to do next You've got the advanced toolkit now. Here are your next steps: - Study how [invariant testing](/learn/invariant-testing) principles apply to your specific protocol - Read about combining fuzzing with [formal verification](/compare/fuzzing-vs-formal-verification) for deeper assurance - Try [Halmos](/blog/halmos-symbolic-execution-guide) for the properties that fuzzing struggles to reach - Look into [coverage-guided fuzzing](/learn/coverage-guided-fuzzing) theory to understand why corpus management matters The goal isn't 100% coverage. It's catching the bugs that matter before someone else does. Get a professional fuzzing audit Try Recon Pro --- ### Halmos symbolic testing tutorial: prove your properties **Author:** deivitto | **Published:** 2026-01-05 | **Reading time:** 15 min **Tags:** formal-verification, halmos, tutorial, smart-contract-security **URL:** https://getrecon.xyz/blog/halmos-symbolic-testing-tutorial # Halmos symbolic testing tutorial: prove your properties [Fuzzing](/learn/fuzzing) is great at finding bugs through random exploration. But there's a catch — it can't tell you a property *always* holds. It can only tell you it didn't break during the campaign. [Halmos](/learn/halmos) takes a different approach. It uses [symbolic execution](/learn/symbolic-execution) to mathematically prove that your properties hold for *all possible inputs*. Or it finds a counterexample that breaks them. That's a big difference. Let me show you how to use it. ## What Halmos actually does Halmos takes your Solidity test functions and replaces concrete inputs with symbolic values (variables that represent every possible value simultaneously). It then runs your code symbolically, exploring all execution paths, and checks whether your assertions can ever fail. If it can't find a way to break the assertion, your property is proven. If it can, you get a concrete counterexample showing exactly which inputs cause the failure. This is [formal verification](/learn/formal-verification) without leaving your [Foundry](/learn/foundry) workflow. No new language to learn. No separate spec files. Just write Solidity tests with a `check_` prefix instead of `test_`. ## Installation Halmos is a Python tool. Install it with pip: ```bash pip install halmos ``` Or if you want the latest development version: ```bash pip install git+https://github.com/a16z/halmos.git ``` Verify the install: ```bash halmos --version ``` You'll also need Foundry installed since Halmos uses `forge build` for compilation. If you don't have it: ```bash curl -L https://foundry.sh | bash foundryup ``` That's it. No Haskell, no Docker. Just Python and Foundry. ## Your first symbolic test Let's start with something simple, proving that an ERC-20 transfer is correct. Create a Foundry project: ```bash mkdir halmos-tutorial && cd halmos-tutorial forge init --no-commit ``` Here's our token contract, `src/SimpleToken.sol`: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract SimpleToken { mapping(address => uint256) public balanceOf; uint256 public totalSupply; constructor(uint256 _initialSupply) { balanceOf[msg.sender] = _initialSupply; totalSupply = _initialSupply; } function transfer(address to, uint256 amount) external returns (bool) { require(balanceOf[msg.sender] >= amount, "Insufficient balance"); balanceOf[msg.sender] -= amount; balanceOf[to] += amount; return true; } function mint(address to, uint256 amount) external { balanceOf[to] += amount; totalSupply += amount; } } ``` Now the symbolic test, `test/SimpleToken.t.sol`: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "../src/SimpleToken.sol"; contract SimpleTokenSymTest is Test { SimpleToken token; function setUp() public { token = new SimpleToken(1_000_000e18); } // Symbolic test: transfer preserves total balance between sender and receiver function check_transfer_preserves_balance( address sender, address receiver, uint256 amount ) public { // Assumptions -- constrain the symbolic values vm.assume(sender != receiver); vm.assume(sender != address(0)); vm.assume(receiver != address(0)); // Give sender some tokens uint256 senderInitial = amount; // Use amount as initial balance too vm.assume(senderInitial >= amount); deal(address(token), sender, senderInitial); uint256 receiverInitial = token.balanceOf(receiver); uint256 totalBefore = senderInitial + receiverInitial; // Perform transfer vm.prank(sender); token.transfer(receiver, amount); uint256 totalAfter = token.balanceOf(sender) + token.balanceOf(receiver); // This must hold for ALL possible inputs assert(totalAfter == totalBefore); } // Symbolic test: transfer never increases sender balance function check_transfer_decreases_sender( address sender, address receiver, uint256 amount ) public { vm.assume(sender != receiver); vm.assume(sender != address(0)); deal(address(token), sender, amount); uint256 before = token.balanceOf(sender); vm.prank(sender); token.transfer(receiver, amount); assert(token.balanceOf(sender) <= before); } } ``` Notice the `check_` prefix. That's how Halmos identifies symbolic tests. Everything else looks like a normal Foundry test. ## Running Halmos ```bash halmos ``` Halmos automatically finds all `check_` functions and runs symbolic analysis: ``` [PASS] check_transfer_preserves_balance(address,address,uint256) [PASS] check_transfer_decreases_sender(address,address,uint256) Tested: 2, Passed: 2, Failed: 0 ``` Both properties are *proven* — not just tested against random inputs, but verified for every possible combination of sender, receiver, and amount. That's the power of symbolic execution. ## Understanding counterexamples What happens when a property doesn't hold? Let's add a buggy function to our token: ```solidity // Bug: no overflow check on mint (well, Solidity 0.8 has it, // but let's test totalSupply consistency) function check_totalSupply_equals_sum(uint256 mintAmount, address user) public { vm.assume(user != address(0)); vm.assume(user != address(this)); uint256 supplyBefore = token.totalSupply(); token.mint(user, mintAmount); // This SHOULD hold... but does it? assert(token.totalSupply() == supplyBefore + mintAmount); } ``` This one actually passes because Solidity 0.8 reverts on overflow. But if the contract used `unchecked` blocks or was compiled with 0.7.x, Halmos would find the exact `mintAmount` that causes overflow and present it as a counterexample: ``` [FAIL] check_totalSupply_equals_sum(uint256,address) Counterexample: mintAmount = 115792089237316195423570985008687907853269984665640564039457584007913129639936 user = 0x0000000000000000000000000000000000010000 ``` The counterexample gives you concrete values. You can plug them into a regular Foundry test to reproduce the issue. ## Loop bounds Halmos needs to know how many times to unroll loops. By default, it uses a conservative bound. If your contract has loops, you'll want to configure this: ```bash halmos --loop 10 ``` The `--loop` flag sets the maximum loop iteration count. If your code has a `for` loop that iterates up to `n` times, Halmos will analyze up to 10 iterations. Be careful with this. Higher bounds = exponentially more paths = longer analysis time. Start with `--loop 2` and increase only if Halmos warns about bounded loops being hit. ```bash # If you get "loop bound reached" warnings: halmos --loop 5 --solver-timeout-assertion 60000 ``` ## Solver timeout The SMT solver (Z3) does the heavy lifting. Complex properties on complex contracts can take a while. Configure the timeout: ```bash halmos --solver-timeout-assertion 30000 # 30 seconds per assertion halmos --solver-timeout-branching 10000 # 10 seconds per branch ``` If you hit timeouts, it doesn't mean the property is broken. It means the solver couldn't decide within the time limit. You might need to: - Simplify the property - Add more `vm.assume()` constraints to reduce the search space - Break one complex check into multiple simpler ones ## Storage assumptions Halmos handles storage symbolically, which is powerful but can lead to path explosion. For complex contracts, you'll want to constrain initial storage: ```solidity function check_withdraw_correctness(uint256 depositAmt, uint256 withdrawAmt) public { // Constrain to realistic values vm.assume(depositAmt > 0 && depositAmt <= 1e30); vm.assume(withdrawAmt > 0 && withdrawAmt <= depositAmt); // Set up known state deal(address(token), address(this), depositAmt); token.approve(address(vault), depositAmt); vault.deposit(depositAmt); uint256 sharesBefore = vault.balanceOf(address(this)); vault.withdraw(withdrawAmt); uint256 sharesAfter = vault.balanceOf(address(this)); assert(sharesAfter <= sharesBefore); } ``` The `vm.assume()` calls are critical. Without them, Halmos explores every possible uint256 value, including ones that don't make sense (like depositing more tokens than exist). Assumptions narrow the search space without weakening the proof. ## When to use Halmos vs fuzzers This is the question everyone asks. Here's my honest take. **Use Halmos when:** - You need mathematical certainty that a property holds - The property is about a single function or a short call sequence - You're verifying arithmetic correctness (no off-by-one errors, no overflow) - The contract has bounded complexity (few loops, limited storage) **Use [fuzzers](/learn/fuzzing) when:** - You're testing stateful properties across many transactions - The contract has deep state spaces that symbolic execution can't traverse - You want broad coverage quickly - The property involves timing, multiple actors, or complex DeFi interactions **Use both when:** - It's a critical contract handling real money (and it usually is) - Halmos proves arithmetic properties, fuzzers explore state-dependent ones Check the [Halmos vs Echidna comparison](/compare/halmos-vs-echidna) for a deeper look at the tradeoffs. ## A real-world example: proving ERC-20 correctness Let's do something more complete. Here's a suite of symbolic tests that prove key ERC-20 properties: ```solidity contract ERC20SymbolicSuite is Test { SimpleToken token; function setUp() public { token = new SimpleToken(1_000_000e18); } // Transfer to self doesn't change balance function check_self_transfer(uint256 amount) public { address user = address(0xBEEF); deal(address(token), user, amount); uint256 before = token.balanceOf(user); vm.prank(user); token.transfer(user, amount); assert(token.balanceOf(user) == before); } // Zero transfer is a no-op function check_zero_transfer(address from, address to) public { vm.assume(from != address(0)); uint256 fromBefore = token.balanceOf(from); uint256 toBefore = token.balanceOf(to); vm.prank(from); token.transfer(to, 0); assert(token.balanceOf(from) == fromBefore); assert(token.balanceOf(to) == toBefore); } // Mint increases totalSupply by exact amount function check_mint_increases_supply(address to, uint256 amount) public { vm.assume(to != address(0)); uint256 supplyBefore = token.totalSupply(); vm.assume(amount <= type(uint256).max - supplyBefore); // no overflow token.mint(to, amount); assert(token.totalSupply() == supplyBefore + amount); } // Transfer doesn't change totalSupply function check_transfer_constant_supply( address from, address to, uint256 amount ) public { vm.assume(from != address(0)); deal(address(token), from, amount); uint256 supplyBefore = token.totalSupply(); vm.prank(from); token.transfer(to, amount); assert(token.totalSupply() == supplyBefore); } } ``` Run the full suite: ```bash halmos --function check_ ``` Each passing test is a mathematical proof. That's something no amount of fuzzing can give you. ## Debugging tips **Path explosion.** If Halmos hangs, your contract probably has too many branches. Use `--solver-timeout-branching 5000` to fail fast on expensive paths and identify which functions cause blowup. **Unsupported opcodes.** Halmos doesn't support every EVM opcode yet. If you hit "unsupported opcode" errors, check which cheatcodes or precompiles your contract uses. Sometimes you can work around it by mocking the problematic function. **Memory vs time.** Halmos can eat a lot of memory on complex contracts. Monitor your system resources. If it's using 16GB+, you probably need to simplify your test. ```bash # Verbose output helps debug issues halmos -vvv --function check_specific_property ``` ## Integrating with your workflow I like to run Halmos alongside fuzz tests in CI: ```bash # Run both forge test # Regular + fuzz tests halmos # Symbolic tests ``` Name your files clearly: `*.t.sol` for Foundry tests, and prefix symbolic test contracts with `Sym` (like `SimpleTokenSymTest`). It keeps things organized. ## Next steps You've got the foundations. From here: - Read the [Halmos symbolic execution guide](/blog/halmos-symbolic-execution-guide) for more advanced techniques - Explore how [formal verification](/learn/formal-verification) fits into a complete security workflow - Learn about [property-based testing](/learn/property-based-testing) principles that apply to both fuzzing and symbolic execution - Try combining Halmos proofs with [Echidna](/learn/echidna) or [Medusa](/learn/medusa) campaigns for defense in depth Symbolic testing won't replace fuzzing. Fuzzing won't replace symbolic testing. You want both. Start with Halmos for the critical arithmetic, add fuzzers for the stateful exploration, and sleep better knowing your properties actually hold. Get a professional security audit Try Recon Pro --- ### Certora CVL tutorial: a practitioner's guide to writing specs **Author:** nican0r | **Published:** 2026-03-02 | **Reading time:** 16 min **Tags:** formal-verification, certora, tutorial, smart-contract-security **URL:** https://getrecon.xyz/blog/certora-cvl-tutorial-practitioner-guide # Certora CVL tutorial: a practitioner's guide to writing specs If you've been auditing DeFi protocols for any length of time, you've probably run into Certora. It's the most widely used [formal verification](/learn/formal-verification) platform in the EVM space, and for good reason. When you need mathematical proof that a property holds across *every possible execution path*, Certora delivers. But writing CVL specs isn't easy. The docs are dense and the error messages can be cryptic. There's a real learning curve between "I understand what formal verification does" and "I can write a useful spec for a production protocol." This guide bridges that gap. ## What is CVL? CVL stands for Certora Verification Language. It's a domain-specific language for writing formal specifications of smart contracts. You write rules and invariants that describe what your contract should do, and Certora's Prover checks whether the actual implementation satisfies those specs. Think of it as writing executable documentation that the machine can verify. If your spec says "solvency always holds," the Prover either confirms it or gives you a concrete counterexample showing how solvency breaks. ## How it differs from fuzzing [Fuzzing](/learn/fuzzing) tests many random inputs. Certora tests *all* inputs symbolically. That's the core difference. But there's more to it: - Fuzzing works with Solidity test files. Certora uses a separate spec language (CVL). - Fuzzing is best for stateful, multi-transaction properties. Certora excels at single-transaction and two-transaction properties. - Fuzzing is fast to set up. Certora has a steeper learning curve but gives stronger guarantees. For a deeper comparison, check our [fuzzing vs formal verification guide](/compare/fuzzing-vs-formal-verification). They're complementary, not competing. ## Setup and installation You'll need: 1. Python 3.8+ 2. Java 11+ (for the solver backend) 3. A Certora API key (get one from [certora.com](https://www.certora.com)) Install the Certora CLI: ```bash pip install certora-cli ``` Set your API key: ```bash export CERTORAKEY= ``` Verify it works: ```bash certoraRun --version ``` ## Project structure A typical Certora setup alongside a Foundry project looks like this: ``` project/ src/ Token.sol Vault.sol certora/ specs/ Token.spec Vault.spec helpers/ TokenHelper.sol conf/ Token.conf Vault.conf ``` The `specs/` directory holds your CVL files. `helpers/` has Solidity wrapper contracts that expose internal functions or simplify verification. `conf/` has JSON configuration files for `certoraRun`. ## CVL basics: rules A **rule** is the basic unit of a CVL spec. It describes what should happen when a function is called: ```cvl rule transferUpdatesBalances(address sender, address receiver, uint256 amount) { // Preconditions require sender != receiver; require sender != 0; // Capture state before uint256 senderBalBefore = balanceOf(sender); uint256 receiverBalBefore = balanceOf(receiver); // Execute function env e; require e.msg.sender == sender; transfer(e, receiver, amount); // Postconditions assert balanceOf(sender) == senderBalBefore - amount, "Sender balance should decrease by amount"; assert balanceOf(receiver) == receiverBalBefore + amount, "Receiver balance should increase by amount"; } ``` Key concepts: - `env e`: Represents the transaction environment (msg.sender, msg.value, etc.) - `require`: Preconditions. These constrain the input space. They don't weaken the verification; they define which scenarios you're testing. - `assert`: The property that must hold. If the Prover finds any execution where this is false (given the preconditions), it produces a counterexample. ## Invariants An **invariant** is a property that should hold at all times — before and after every function call: ```cvl invariant totalSupplyIsSumOfBalances() totalSupply() == sumOfBalances { preserved with (env e) { require e.msg.sender != 0; } } ``` The `preserved` block adds constraints that should hold whenever a function is called. The Prover checks that the invariant holds in the initial state and that every function preserves it. Invariants are incredibly powerful for DeFi. Some examples: - Total supply equals sum of all balances - Total deposited collateral >= total borrows (for a lending protocol) - LP share price is monotonically non-decreasing (for a vault) ## Ghosts and hooks **Ghosts** are auxiliary variables that don't exist in the contract but help you write specs: ```cvl ghost uint256 sumOfBalances { init_state axiom sumOfBalances == 0; } hook Sstore balanceOf[KEY address user] uint256 newBalance (uint256 oldBalance) { sumOfBalances = sumOfBalances + newBalance - oldBalance; } ``` The **hook** triggers whenever `balanceOf` gets written to storage. It updates our ghost variable to track the sum. This pattern is everywhere in DeFi verification. You can't directly iterate over a mapping in Solidity, so ghosts let you track aggregate properties. ## A complete example: ERC-20 spec Let's put it all together. Here's a real CVL spec for a basic ERC-20 token. ```cvl methods { function totalSupply() external returns (uint256) envfree; function balanceOf(address) external returns (uint256) envfree; function allowance(address, address) external returns (uint256) envfree; function transfer(address, uint256) external returns (bool); function transferFrom(address, address, uint256) external returns (bool); function approve(address, uint256) external returns (bool); function mint(address, uint256) external; function burn(address, uint256) external; } // Ghost to track sum of all balances ghost uint256 sumOfBalances { init_state axiom sumOfBalances == 0; } hook Sstore balanceOf[KEY address user] uint256 newBal (uint256 oldBal) { sumOfBalances = sumOfBalances + newBal - oldBal; } // === INVARIANTS === invariant totalSupplyMatchesSum() totalSupply() == sumOfBalances; invariant zeroAddressHasNoBalance() balanceOf(0) == 0; // === RULES === // Transfer correctness rule transferCorrectness(address to, uint256 amount) { env e; require e.msg.sender != to; require e.msg.sender != 0; require to != 0; uint256 fromBefore = balanceOf(e.msg.sender); uint256 toBefore = balanceOf(to); transfer(e, to, amount); assert balanceOf(e.msg.sender) == fromBefore - amount; assert balanceOf(to) == toBefore + amount; } // Transfer reverts on insufficient balance rule transferRevertsOnInsufficientBalance(address to, uint256 amount) { env e; require balanceOf(e.msg.sender) < amount; transfer@withrevert(e, to, amount); assert lastReverted, "Transfer should revert when balance is insufficient"; } // Approve sets allowance correctly rule approveCorrectness(address spender, uint256 amount) { env e; approve(e, spender, amount); assert allowance(e.msg.sender, spender) == amount; } // TransferFrom respects allowance rule transferFromRespectsAllowance(address from, address to, uint256 amount) { env e; require from != to; uint256 allowanceBefore = allowance(from, e.msg.sender); uint256 fromBefore = balanceOf(from); uint256 toBefore = balanceOf(to); transferFrom(e, from, to, amount); assert balanceOf(from) == fromBefore - amount; assert balanceOf(to) == toBefore + amount; // Allowance should decrease (unless it was max uint256) assert allowance(from, e.msg.sender) == allowanceBefore - amount || allowanceBefore == max_uint256; } // No function changes other users' balances rule noUnexpectedBalanceChanges(method f, address user) { env e; calldataarg args; require user != e.msg.sender; uint256 balBefore = balanceOf(user); f(e, args); uint256 balAfter = balanceOf(user); assert balAfter >= balBefore || f.selector == sig:transferFrom(address, address, uint256).selector || f.selector == sig:burn(address, uint256).selector, "Only transferFrom and burn can decrease another user's balance"; } // Mint increases totalSupply rule mintIncreasesTotalSupply(address to, uint256 amount) { env e; require to != 0; uint256 supplyBefore = totalSupply(); mint(e, to, amount); assert totalSupply() == supplyBefore + amount; } ``` ## The methods block The `methods` block at the top declares the contract interface. The `envfree` keyword means a function doesn't depend on the environment (msg.sender, etc.), meaning it's a view function. This helps the Prover analyze things faster. ## Configuration Create `certora/conf/Token.conf`: ```json { "files": ["src/Token.sol"], "verify": "Token:certora/specs/Token.spec", "wait_for_results": "all", "msg": "Token verification", "rule_sanity": "basic", "optimistic_loop": true, "loop_iter": 3 } ``` Run it: ```bash certoraRun certora/conf/Token.conf ``` Certora sends the job to their cloud prover and gives you a URL to track progress. Results typically come back in 5-30 minutes depending on spec complexity. ## Understanding counterexamples When the Prover finds a violation, it presents a counterexample in the web UI. You'll see: 1. Initial state: Storage values before the transaction 2. Function called: Which function triggered the violation 3. Arguments: Exact values that cause the issue 4. Final state: Storage values after the transaction 5. Assert violated: Which assertion failed Read counterexamples carefully. Sometimes they're real bugs. Sometimes they're incomplete specs. You forgot a precondition, and the Prover found a technically-valid-but-unreachable scenario. Adding appropriate `require` statements fixes the latter. ## DeFi patterns that matter ## Solvency The most important invariant for any protocol holding user funds: ```cvl invariant vaultSolvency() underlying.balanceOf(vault) >= vault.totalAssets() { preserved with (env e) { require e.msg.sender != vault; } } ``` ## Access control Verify that privileged functions are properly gated: ```cvl rule onlyAdminCanPause() { env e; require e.msg.sender != admin(); pause@withrevert(e); assert lastReverted, "Non-admin should not be able to pause"; } ``` ## State transitions Verify that state machines follow valid transitions: ```cvl rule validStateTransitions() { env e; calldataarg args; uint8 stateBefore = currentState(); // Any function call method f; f(e, args); uint8 stateAfter = currentState(); // Only valid transitions assert (stateBefore == 0 && stateAfter == 1) || // INIT -> ACTIVE (stateBefore == 1 && stateAfter == 2) || // ACTIVE -> PAUSED (stateBefore == 2 && stateAfter == 1) || // PAUSED -> ACTIVE (stateBefore == stateAfter), // No change "Invalid state transition detected"; } ``` ## Monotonicity Share prices in vaults should never decrease (absent explicit loss events): ```cvl rule sharePriceNonDecreasing() { env e; calldataarg args; uint256 priceBefore = convertToAssets(e, 1000000); method f; require f.selector != sig:reportLoss(uint256).selector; f(e, args); uint256 priceAfter = convertToAssets(e, 1000000); assert priceAfter >= priceBefore; } ``` ## Limitations and honest takes Certora isn't perfect. Here's what you should know: **Cost.** You need an API key and the Prover runs on their cloud. It's a commercial tool. For serious DeFi work, the cost is justified. For hobbyist projects, consider [Halmos](/learn/halmos) as a free alternative. Timeouts. Complex specs on complex contracts can time out. The Prover has resource limits, and sometimes you'll need to simplify your rules or break them into smaller pieces. Loop handling. Contracts with unbounded loops are hard to verify. The `optimistic_loop` setting helps, but it's an approximation. You're trusting that the loop body behavior generalizes. Learning curve. CVL looks deceptively simple but writing good specs takes practice. Expect to spend a few days getting comfortable before you're productive. **Not a replacement for testing.** Certora verifies properties, not behavior. You still need unit tests and fuzz tests alongside manual review. Formal verification is one layer of a defense-in-depth strategy. ## Helper contracts Sometimes you need to expose internal functions or simplify a contract for verification. That's what helper contracts are for: ```solidity // certora/helpers/VaultHelper.sol import "../../src/Vault.sol"; contract VaultHelper is Vault { // Expose internal function for verification function getInternalAccountingValue() external view returns (uint256) { return _internalAccounting; } } ``` Then verify the wrapper instead of the original: ```json { "files": ["certora/helpers/VaultHelper.sol"], "verify": "VaultHelper:certora/specs/Vault.spec" } ``` ## Tips from real audits 1. **Start with invariants, not rules.** Invariants give you the most bang for your buck. "Total supply == sum of balances" catches a huge class of bugs with one line. 2. Use rule sanity checks. Set `"rule_sanity": "basic"` in your config. This verifies that your rules aren't vacuously true (passing because the preconditions are unsatisfiable). 3. Name your assertions. Every `assert` should have a message string. When something fails, you want to know *which* assertion without staring at line numbers. 4. Iterate quickly. Start with a simple spec, verify it works, then add more rules. Don't write 50 rules and submit them all at once. Debugging will be painful. 5. Read the counterexamples. Half the value of formal verification comes from understanding *why* a property broke, not just *that* it broke. ## Getting started on your project Pick one contract. Write three invariants. Run them. Fix any real issues, tighten any vacuous specs. Then expand. You don't need to verify your entire protocol on day one. Start where the money is: the contracts that hold user funds and manage access control. Get a professional formal verification audit Try Recon Pro --- ### Mutation testing for smart contracts: measure your test suite quality **Author:** kn0t | **Published:** 2026-04-06 | **Reading time:** 14 min **Tags:** mutation-testing, fuzzing, smart-contract-security, invariant-testing **URL:** https://getrecon.xyz/blog/mutation-testing-smart-contracts-guide # Mutation testing for smart contracts: measure your test suite quality You've got tests. Maybe even lots of tests. But here's the uncomfortable question: are your tests actually catching bugs, or are they just passing? [Mutation testing](/learn/mutation-testing) answers that question. It's the closest thing we have to a ground truth metric for test suite quality, and it's criminally underused in smart contract development. ## What is mutation testing? The idea is simple. Take your code, inject small deliberate faults (mutations), and check whether your tests catch them. Each mutation creates a "mutant," a slightly broken version of your contract. If your tests fail when running against the mutant, the mutant is "killed." If your tests still pass, the mutant "survived," and that's a problem. A surviving mutant means there's a specific type of bug your tests wouldn't catch. Here's a concrete example. Suppose your contract has: ```solidity require(amount <= balance, "Insufficient funds"); ``` A mutation operator might change `<=` to `<`: ```solidity require(amount < balance, "Insufficient funds"); ``` If your tests still pass with this change, it means you don't have a test for the exact case where `amount == balance`. That's a gap. ## Why it matters for smart contracts [Smart contract security](/learn/smart-contract-security) has a particular problem: bugs are expensive. A single uncaught edge case can drain millions. Traditional code coverage tells you which lines execute, but it doesn't tell you whether your assertions actually verify the right behavior. You can have 100% line coverage and still miss critical bugs. Mutation testing exposes the difference between "my tests touch every line" and "my tests would catch a bug on every line." For DeFi protocols, this is especially relevant: - Off-by-one errors in boundary conditions - Wrong comparison operators (`<` vs `<=`, `>=` vs `>`) - Missing edge cases in math operations - State transitions that skip validation These are exactly the kinds of faults that mutation operators inject. ## How mutation testing works The process has four steps: 1. **Generate mutants.** A tool analyzes your source code and creates modified versions, each with one small change. 2. Run tests against each mutant. For every mutant, run your full test suite. 3. Classify results. If tests fail, the mutant is killed (good). If tests pass, the mutant survived (bad). If the mutant causes a compilation error, it's equivalent or stillborn (ignored). 4. **Calculate mutation score.** Killed mutants / Total non-equivalent mutants = your score. A mutation score of 80% means your tests catch 80% of injected faults. For security-critical code, you want 90%+. Below 70% and your test suite has serious blind spots. ## Mutation operators Common operators for Solidity: | Operator | What it does | Example | |----------|-------------|---------| | Relational | Swap comparison operators | `<` → `<=`, `>` → `>=` | | Arithmetic | Change math operators | `+` → `-`, `*` → `/` | | Logical | Flip boolean logic | `&&` → `\|\|`, `!` removed | | Literal | Change constant values | `0` → `1`, `1` → `0` | | Statement deletion | Remove a statement | Delete `require()`, delete assignment | | Return value | Change return values | `return x` → `return 0` | | Condition negation | Negate if conditions | `if(x)` → `if(!x)` | The statement deletion operator is particularly brutal. If deleting a `require()` doesn't break any test, your tests aren't checking that invariant at all. ## Tools for Solidity mutation testing ## Gambit (by Certora) Gambit is the most mature mutation testing tool for Solidity. It's built by the Certora team and works well with Foundry projects. Install it: ```bash # Download from GitHub releases # https://github.com/Certora/gambit/releases ``` Generate mutants: ```bash gambit mutate --solc-remappings "@openzeppelin=node_modules/@openzeppelin" src/Vault.sol ``` This creates a `gambit_out/` directory with all the mutant files and a summary JSON. ## vertigo-rs vertigo-rs is a Rust-based mutation testing framework for Solidity. It's lighter weight and integrates with both Foundry and Hardhat. ```bash cargo install vertigo-rs vertigo run --foundry ``` vertigo-rs runs the full loop: generate mutants, run tests, report results. Less manual work than Gambit if you just want a score. ## Custom approach For full control, you can build your own mutation pipeline. It's not as hard as it sounds: ```bash # 1. Generate mutants (use Gambit or write a simple sed script) # 2. For each mutant: # a. Replace original file with mutant # b. Run forge test # c. Record pass/fail # d. Restore original file # 3. Calculate score ``` ## Practical walkthrough: Foundry + Gambit Let's do a real mutation testing run. Here's our target contract: ```solidity // src/StakingPool.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; contract StakingPool { IERC20 public stakingToken; mapping(address => uint256) public stakedBalance; mapping(address => uint256) public rewardDebt; uint256 public totalStaked; uint256 public rewardPerToken; uint256 public lastUpdateTime; uint256 public rewardRate; constructor(address _token, uint256 _rate) { stakingToken = IERC20(_token); rewardRate = _rate; lastUpdateTime = block.timestamp; } function updateRewards() public { if (totalStaked > 0) { uint256 elapsed = block.timestamp - lastUpdateTime; rewardPerToken += (elapsed * rewardRate * 1e18) / totalStaked; } lastUpdateTime = block.timestamp; } function stake(uint256 amount) external { require(amount > 0, "Cannot stake zero"); updateRewards(); stakedBalance[msg.sender] += amount; totalStaked += amount; rewardDebt[msg.sender] = rewardPerToken; stakingToken.transferFrom(msg.sender, address(this), amount); } function unstake(uint256 amount) external { require(amount > 0, "Cannot unstake zero"); require(stakedBalance[msg.sender] >= amount, "Insufficient stake"); updateRewards(); stakedBalance[msg.sender] -= amount; totalStaked -= amount; stakingToken.transfer(msg.sender, amount); } function pendingReward(address user) external view returns (uint256) { uint256 currentRewardPerToken = rewardPerToken; if (totalStaked > 0) { uint256 elapsed = block.timestamp - lastUpdateTime; currentRewardPerToken += (elapsed * rewardRate * 1e18) / totalStaked; } return (stakedBalance[user] * (currentRewardPerToken - rewardDebt[user])) / 1e18; } } ``` And here's a test file: ```solidity // test/StakingPool.t.sol // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import "forge-std/Test.sol"; import "../src/StakingPool.sol"; contract MockToken is IERC20 { mapping(address => uint256) public override balanceOf; mapping(address => mapping(address => uint256)) public override allowance; uint256 public override totalSupply; function mint(address to, uint256 amount) external { balanceOf[to] += amount; totalSupply += amount; } function transfer(address to, uint256 amount) external override returns (bool) { balanceOf[msg.sender] -= amount; balanceOf[to] += amount; return true; } function transferFrom(address from, address to, uint256 amount) external override returns (bool) { allowance[from][msg.sender] -= amount; balanceOf[from] -= amount; balanceOf[to] += amount; return true; } function approve(address spender, uint256 amount) external override returns (bool) { allowance[msg.sender][spender] = amount; return true; } } contract StakingPoolTest is Test { StakingPool pool; MockToken token; function setUp() public { token = new MockToken(); pool = new StakingPool(address(token), 1e18); token.mint(address(this), 1_000_000e18); token.approve(address(pool), type(uint256).max); } function test_stake() public { pool.stake(100e18); assertEq(pool.stakedBalance(address(this)), 100e18); assertEq(pool.totalStaked(), 100e18); } function test_unstake() public { pool.stake(100e18); pool.unstake(50e18); assertEq(pool.stakedBalance(address(this)), 50e18); } function test_cannotStakeZero() public { vm.expectRevert("Cannot stake zero"); pool.stake(0); } function test_cannotUnstakeMoreThanBalance() public { pool.stake(100e18); vm.expectRevert("Insufficient stake"); pool.unstake(200e18); } } ``` Now run Gambit: ```bash gambit mutate src/StakingPool.sol ``` Gambit generates mutants. Let's look at what it creates: ``` gambit_out/ mutants/ 1/ # require(amount > 0) → require(amount >= 0) 2/ # require(amount > 0) → require(amount < 0) 3/ # stakedBalance[msg.sender] += amount → stakedBalance[msg.sender] -= amount 4/ # totalStaked += amount → totalStaked -= amount 5/ # require(stakedBalance[msg.sender] >= amount) → require(stakedBalance[msg.sender] > amount) ... gambit_results.json ``` Now test each mutant: ```bash #!/bin/bash KILLED=0 SURVIVED=0 TOTAL=0 for mutant_dir in gambit_out/mutants/*/; do TOTAL=$((TOTAL + 1)) mutant_id=$(basename "$mutant_dir") # Get the mutant file path from gambit results mutant_file=$(cat gambit_out/gambit_results.json | jq -r ".[$((mutant_id - 1))].filename") original_file=$(cat gambit_out/gambit_results.json | jq -r ".[$((mutant_id - 1))].original") # Swap in mutant cp "$original_file" "$original_file.bak" cp "$mutant_dir/$mutant_file" "$original_file" # Run tests if forge test --no-match-test "testFuzz" 2>/dev/null; then echo "SURVIVED: Mutant $mutant_id" SURVIVED=$((SURVIVED + 1)) else echo "KILLED: Mutant $mutant_id" KILLED=$((KILLED + 1)) fi # Restore original mv "$original_file.bak" "$original_file" done echo "" echo "=== Mutation Testing Results ===" echo "Total mutants: $TOTAL" echo "Killed: $KILLED" echo "Survived: $SURVIVED" echo "Mutation score: $(( KILLED * 100 / TOTAL ))%" ``` ## Interpreting results After running, you might see something like: ``` KILLED: Mutant 1 (require amount > 0 → amount >= 0) SURVIVED: Mutant 5 (require >= → >) KILLED: Mutant 3 (+= → -=) KILLED: Mutant 4 (+= → -=) SURVIVED: Mutant 5 (unstake require >= amount → > amount) Mutation score: 71% ``` Two mutants survived. Let's look at what they tell us. **Mutant 5 survived:** Changing `stakedBalance[msg.sender] >= amount` to `stakedBalance[msg.sender] > amount` didn't break any test. That means we don't test the exact boundary, unstaking *exactly* the staked amount. Fix: ```solidity function test_unstakeExactBalance() public { pool.stake(100e18); pool.unstake(100e18); assertEq(pool.stakedBalance(address(this)), 0); assertEq(pool.totalStaked(), 0); } ``` Every surviving mutant points at a specific gap. Fix the gap, re-run mutation testing, and watch your score climb. ## Improving test suites based on surviving mutants Here's a systematic approach: 1. Sort surviving mutants by location. Cluster them by function. If multiple mutants survive in the same function, your tests for that function are weak across the board. 2. **Prioritize security-relevant mutations.** A surviving mutant in a `require()` statement or a balance update is much scarier than one in an event emission. Focus on the ones that could lead to fund loss. 3. **Write targeted tests.** Each surviving mutant needs at least one test that specifically exercises the boundary or condition that was mutated. Don't just add random tests. Be surgical. 4. **Use [fuzzing](/learn/fuzzing) for the hard ones.** Some mutations create subtle edge cases that are hard to hit with unit tests. Write a [property test](/learn/property-based-testing) with [invariant testing](/learn/invariant-testing) instead. 5. **Re-run after each fix.** Mutation testing is iterative. Kill a mutant, check if the new test also kills other mutants (it often does), and repeat until you're above your target score. ## Mutation testing + fuzzing: better together Mutation testing tells you *where* your tests are weak. [Fuzzing](/learn/fuzzing) is great at *covering* those gaps because it automatically explores edge cases. Here's the workflow: 1. Run mutation testing → find surviving mutants 2. Write [invariant tests](/learn/invariant-testing) that target the weak areas 3. Run fuzzing campaigns to exercise those properties 4. Re-run mutation testing to verify the gaps are closed If a mutant survives your fuzzer, you've found a property that's genuinely hard to test. That's useful information. It might point to code that's unnecessarily complex or conditions that are practically unreachable. ## Common pitfalls **Equivalent mutants.** Some mutations don't actually change behavior. For example, changing `x * 1` to `x * 0` is obviously a real mutant, but changing `if (x != 0)` to `if (x > 0)` for a uint256 is equivalent. Both behave identically since uints can't be negative. These inflate your surviving mutant count. Toss them when calculating your score. **Test speed.** Running the full test suite per mutant is slow. If you've got 200 mutants and tests take 30 seconds, that's nearly two hours. Speed things up: - Use `--no-match-test "testFuzz"` to skip fuzz tests during mutation runs - Run mutants in parallel - Focus on critical contracts first **Compilation failures.** Some mutants don't compile (e.g., type errors from operator swaps). These are "stillborn" mutants. Exclude them from your score. **Over-testing events.** Don't write tests just to kill mutants in event emissions. Events are important for off-chain indexing but rarely affect contract security. Prioritize logic, math, and access control. ## Integrating into your workflow I recommend running mutation testing at two points: 1. **Before a security review.** Get your mutation score up before auditors look at the code. It's embarrassing (and expensive) when an auditor finds a bug that a simple boundary test would've caught. 2. **After adding new features.** Every new function needs tests, and mutation testing tells you if those tests are actually good. Don't run it on every commit. It's too slow for that. CI is fine for unit tests and short fuzz campaigns. Save mutation testing for dedicated quality checkpoints. ## The bottom line Code coverage lies to you. It tells you which lines *ran*, not which lines were *tested*. Mutation testing tells you the truth. If your mutation score is below 70%, your tests have serious blind spots. Between 70-85%, you're doing okay but there's room to improve. Above 85%, you've got a solid test suite. Above 95% and you're in excellent shape — though getting there takes real effort. Start small. Pick your most critical contract, run Gambit, kill the surviving mutants, and see how it changes your confidence in your test suite. Get a professional security audit Try Recon Pro --- ### Slither beyond defaults: writing custom detectors for your protocol **Author:** deivitto | **Published:** 2026-02-27 | **Reading time:** 15 min **Tags:** static-analysis, smart-contract-security, tutorial, fuzzing **URL:** https://getrecon.xyz/blog/slither-advanced-custom-detectors # Slither beyond defaults: writing custom detectors for your protocol Everyone runs `slither .` and reads the output. That's fine for a first pass. But if that's all you're doing, you're leaving a lot on the table. Slither's real power is in its extensibility — you can write custom detectors in Python that check for protocol-specific vulnerability patterns that no generic tool will ever catch. I've written dozens of custom detectors for audits. Here's everything I've learned about doing it well. ## Why custom detectors? Slither ships with ~90 built-in detectors. They catch common issues: reentrancy, uninitialized storage, shadowing, etc. Solid baseline stuff. But DeFi protocols don't break in generic ways. They break in protocol-specific ways: - A lending protocol might have an invariant that collateral factor can never exceed 90% - A DEX might need to verify that fee calculations always round in the protocol's favor - A governance contract might require that voting power snapshots happen before token transfers No built-in detector checks for those. But a custom detector can, and it'll catch violations across every code path automatically. ## Slither architecture: the 30-second version Slither works in layers: 1. **Solidity source** → parsed by solc 2. **AST** → Slither builds its own internal representation 3. **SlithIR** → A simplified intermediate representation (like SSA form) 4. **Detectors** → Python classes that analyze the IR and report findings 5. **Printers** → Python classes that output information (call graphs, inheritance, etc.) Your custom detector operates at layer 4. You get access to all contracts, functions, state variables, and the SlithIR instructions. You traverse this data, look for patterns, and emit findings. ## Setting up your environment Install Slither with the development extras: ```bash pip install slither-analyzer ``` For custom detector development, I recommend cloning the repo so you can reference existing detectors: ```bash git clone https://github.com/crytic/slither.git ls slither/slither/detectors/ ``` That detectors directory is your reference library. Every built-in detector follows the same pattern you'll use. ## Anatomy of a detector Every detector is a Python class that inherits from `AbstractDetector`. Here's the skeleton: ```python from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification from slither.core.declarations import Function class MyCustomDetector(AbstractDetector): ARGUMENT = "my-custom-check" # CLI flag name HELP = "Checks for my custom pattern" IMPACT = DetectorClassification.HIGH CONFIDENCE = DetectorClassification.HIGH WIKI = "https://example.com/wiki" WIKI_TITLE = "My Custom Check" WIKI_DESCRIPTION = "Describes what this detector checks" WIKI_EXPLOIT_SCENARIO = "Example of the vulnerability" WIKI_RECOMMENDATION = "How to fix it" def _detect(self): results = [] # Your analysis logic here return results ``` The `_detect` method is where the magic happens. It returns a list of findings, each one an `Output` object. ## Understanding SlithIR Before you write a detector, you need to understand SlithIR. It's the intermediate representation that makes analysis tractable. Instead of dealing with raw Solidity AST nodes (which are messy), you work with clean typed instructions. Key SlithIR instruction types: ```python from slither.slithir.operations import ( HighLevelCall, # External contract calls LowLevelCall, # .call(), .delegatecall() InternalCall, # Internal function calls Transfer, # ETH transfers Assignment, # Variable assignments Binary, # Arithmetic/comparison ops Condition, # Branch conditions Return, # Return statements SolidityCall, # Built-in calls (require, assert, etc.) ) ``` To explore what SlithIR looks like for a contract: ```bash slither src/MyContract.sol --print slithir ``` This dumps the IR for every function. Study this output before writing detectors — it shows you exactly what data you'll have to work with. ## Example 1: detecting missing fee-on-transfer handling Let's write a real detector. Many protocols forget to account for tokens that charge a fee on transfer. They call `transferFrom` and assume they received the full `amount`, but fee-on-transfer tokens deliver less. ```python from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification from slither.slithir.operations import HighLevelCall, Assignment from slither.core.declarations import Function, Contract from slither.core.variables.state_variable import StateVariable class FeeOnTransferCheck(AbstractDetector): ARGUMENT = "fee-on-transfer" HELP = "Detects missing balance checks after token transfers" IMPACT = DetectorClassification.HIGH CONFIDENCE = DetectorClassification.MEDIUM WIKI = "https://example.com/fee-on-transfer" WIKI_TITLE = "Missing fee-on-transfer accounting" WIKI_DESCRIPTION = ( "Detects patterns where transferFrom is called but the actual " "received amount is not verified via balance checks." ) WIKI_EXPLOIT_SCENARIO = ( "A vault calls transferFrom(user, vault, amount) and credits " "the user with 'amount' shares, but a fee-on-transfer token " "only delivers amount - fee to the vault." ) WIKI_RECOMMENDATION = ( "Check balanceOf before and after transferFrom, and use the " "difference as the actual received amount." ) def _detect(self): results = [] for contract in self.compilation_unit.contracts_derived: for function in contract.functions: if function.is_constructor or function.is_fallback: continue transfer_from_calls = [] has_balance_check = False for node in function.nodes: for ir in node.irs: # Look for transferFrom calls if ( isinstance(ir, HighLevelCall) and hasattr(ir, "function_name") and ir.function_name == "transferFrom" ): transfer_from_calls.append(node) # Look for balanceOf calls (indicates proper accounting) if ( isinstance(ir, HighLevelCall) and hasattr(ir, "function_name") and ir.function_name == "balanceOf" ): has_balance_check = True # If we found transferFrom without balanceOf, flag it if transfer_from_calls and not has_balance_check: for node in transfer_from_calls: info = [ function, " calls transferFrom without checking actual received amount\n", "\tNode: ", node, "\n", ] res = self.generate_result(info) results.append(res) return results ``` This detector walks through every function, looks for `transferFrom` calls, and checks whether the function also calls `balanceOf`. It's a heuristic, not every `transferFrom` without `balanceOf` is a bug, but it catches the pattern effectively. ## Example 2: checking for unprotected state changes Here's a more advanced detector that checks whether state-modifying functions have access control: ```python from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification from slither.slithir.operations import SolidityCall, Binary, Condition from slither.core.declarations import Function from slither.core.expressions import CallExpression class UnprotectedStateChange(AbstractDetector): ARGUMENT = "unprotected-state" HELP = "Detects state changes in functions without access control" IMPACT = DetectorClassification.HIGH CONFIDENCE = DetectorClassification.MEDIUM WIKI = "https://example.com/unprotected-state" WIKI_TITLE = "Unprotected state modification" WIKI_DESCRIPTION = "Functions that modify critical state variables without access control checks." WIKI_EXPLOIT_SCENARIO = "Anyone can call the function and modify protocol parameters." WIKI_RECOMMENDATION = "Add appropriate access control modifiers (onlyOwner, onlyAdmin, etc.)." # State variables that should be protected CRITICAL_PATTERNS = [ "fee", "rate", "price", "oracle", "admin", "owner", "paused", "limit", "threshold", "config" ] def _has_access_control(self, function): """Check if function has msg.sender checks or access control modifiers.""" # Check modifiers access_modifiers = ["onlyOwner", "onlyAdmin", "onlyRole", "onlyGovernance"] for modifier in function.modifiers: if any(am in modifier.name for am in access_modifiers): return True # Check for require(msg.sender == ...) patterns for node in function.nodes: for ir in node.irs: if isinstance(ir, SolidityCall): if "require" in str(ir.function) or "revert" in str(ir.function): # Check if msg.sender is referenced in this node if "msg.sender" in str(node): return True return False def _modifies_critical_state(self, function): """Check if function writes to critical state variables.""" modified_vars = [] for var in function.state_variables_written: var_name = var.name.lower() for pattern in self.CRITICAL_PATTERNS: if pattern in var_name: modified_vars.append(var) break return modified_vars def _detect(self): results = [] for contract in self.compilation_unit.contracts_derived: for function in contract.functions: if ( function.is_constructor or function.is_fallback or function.visibility in ["internal", "private"] ): continue critical_vars = self._modifies_critical_state(function) if critical_vars and not self._has_access_control(function): for var in critical_vars: info = [ function, " modifies critical state variable ", var, " without access control\n", ] res = self.generate_result(info) results.append(res) return results ``` This detector looks for a pattern I see in almost every audit: someone adds a setter function for a critical parameter and forgets the `onlyOwner` modifier. Generic reentrancy detectors won't catch this. Custom detectors will. ## Running custom detectors Save your detector as a Python file and point Slither at it: ```bash slither src/ --detect my-custom-check --plugin-dir ./custom_detectors/ ``` Or if you've structured it as a proper Python package: ```bash # In your custom_detectors/ directory, create: # __init__.py # fee_on_transfer.py # unprotected_state.py slither src/ --plugin-dir ./custom_detectors/ ``` Slither auto-discovers detector classes in the plugin directory. No registration needed, just inherit from `AbstractDetector` and the framework picks it up. ## Traversing the AST Sometimes SlithIR isn't enough and you need the raw AST. Slither gives you access: ```python for contract in self.compilation_unit.contracts_derived: # Contract-level info print(contract.name) print(contract.state_variables) print(contract.functions) print(contract.modifiers) print(contract.inheritance) for function in contract.functions: # Function-level info print(function.name) print(function.visibility) print(function.parameters) print(function.return_type) print(function.state_variables_read) print(function.state_variables_written) print(function.external_calls_as_expressions) # Node-level (control flow graph) for node in function.nodes: print(node.type) # NodeType enum print(node.expression) # The Solidity expression print(node.irs) # SlithIR instructions print(node.sons) # Successor nodes in CFG print(node.fathers) # Predecessor nodes in CFG ``` The control flow graph (`node.sons` / `node.fathers`) is useful for path-sensitive analysis. If you need to check whether a `require` always executes before a state write, traverse the CFG from the write node backward and verify the require is on every path. ## Printer plugins Printers output structured information rather than vulnerability findings. They're great for generating audit documentation or feeding data into other tools. ```python from slither.printers.abstract_printer import AbstractPrinter class StateVariableSummary(AbstractPrinter): ARGUMENT = "state-summary" HELP = "Prints a summary of all state variables and their access patterns" WIKI = "https://example.com/state-summary" def output(self, filename): info = "" for contract in self.slither.contracts_derived: info += f"\nContract: {contract.name}\n" info += "=" * 40 + "\n" for var in contract.state_variables: readers = [f.name for f in contract.functions if var in f.state_variables_read] writers = [f.name for f in contract.functions if var in f.state_variables_written] info += f" {var.type} {var.name}\n" info += f" Read by: {', '.join(readers) if readers else 'nobody'}\n" info += f" Written by: {', '.join(writers) if writers else 'nobody'}\n" self.info(info) res = self.generate_output(info) return res ``` Run it: ```bash slither src/ --print state-summary --plugin-dir ./custom_detectors/ ``` This kind of output is gold during audits. You immediately see which functions touch which state, who can write to critical variables, and whether any state variables are written but never read (dead code). ## CI/CD integration Custom detectors really shine in CI. Set them up to run on every PR: ```yaml # .github/workflows/slither.yml name: Slither Analysis on: [push, pull_request] jobs: analyze: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Slither run: pip install slither-analyzer - name: Run built-in detectors run: slither src/ --json slither-report.json - name: Run custom detectors run: slither src/ --plugin-dir ./custom_detectors/ --json custom-report.json - name: Check for high-severity findings run: | HIGH_COUNT=$(cat custom-report.json | jq '[.results.detectors[] | select(.impact == "High")] | length') if [ "$HIGH_COUNT" -gt 0 ]; then echo "Found $HIGH_COUNT high-severity issues" exit 1 fi ``` Now every PR gets checked against both generic and protocol-specific rules. When someone introduces a new function that modifies `feeRate` without access control, CI fails before it hits main. ## Writing effective detectors: practical tips **Start with the finding, work backward.** Don't think "what can I detect?" Think "what bug am I looking for?" Then figure out what code pattern that bug looks like in SlithIR. **Look at existing detectors.** The built-in reentrancy detector is a masterclass in path-sensitive analysis. The unchecked-transfer detector shows how to track call return values. Read them. Copy their patterns. Keep false positive rate low. A detector that fires on 50 findings with 45 false positives is worse than useless, people will ignore it. Better to miss some true positives than to drown developers in noise. Tune your CONFIDENCE level to match your actual detection precision. Test your detector. Write a Solidity file with known-vulnerable and known-safe patterns. Verify your detector catches the former and ignores the latter. ```python # test_detector.py from slither import Slither def test_fee_on_transfer(): slither = Slither("test/vulnerable.sol") detector = FeeOnTransferCheck(slither, slither.compilation_units[0], slither.logger) results = detector._detect() assert len(results) > 0, "Should detect missing fee-on-transfer check" def test_fee_on_transfer_safe(): slither = Slither("test/safe.sol") detector = FeeOnTransferCheck(slither, slither.compilation_units[0], slither.logger) results = detector._detect() assert len(results) == 0, "Should not flag safe pattern" ``` ## Complementary approaches Custom Slither detectors catch static patterns. They're fast and precise but can't reason about runtime state. Pair them with: - [Fuzzing](/learn/fuzzing) to test dynamic properties that static analysis can't reach - [Formal verification](/learn/formal-verification) to prove invariants across all execution paths - Manual review for business logic issues that no tool catches Think of custom detectors as your first line of defense, the cheap, fast check that catches the obvious stuff before you bring in the heavier tools. ## What to build next If you're working on a DeFi protocol, here are high-value custom detectors to consider: 1. Rounding direction checker, verify that all divisions round in the protocol's favor 2. Oracle staleness detector, flag price oracle reads without freshness checks 3. Reentrancy with state, check for cross-function reentrancy via shared state variables 4. Flash loan callback verification, ensure callback functions validate the caller 5. Approval race condition, detect approve patterns vulnerable to front-running Each one is 50-100 lines of Python. Small investment, big payoff. The default detectors are a starting point, not the finish line. Build the detectors your protocol needs, wire them into CI, and catch bugs before they cost money. Get a professional security audit Try Recon Pro --- ### Chimera advanced patterns: multi-contract fuzzing with actors and ghosts **Author:** kn0t | **Published:** 2026-03-24 | **Reading time:** 15 min **Tags:** chimera-framework, invariant-testing, stateful-fuzzing, fuzzing **URL:** https://getrecon.xyz/blog/chimera-advanced-patterns-multi-contract # Chimera Advanced Patterns: Multi-Contract Fuzzing with Actors and Ghosts *By kn0t* If you've written your first [invariant test](/learn/invariant-testing) and got it passing, congrats, you're ahead of most teams. But real protocols aren't single contracts. They're webs of interacting pieces: a lending pool talks to an oracle, which prices a token, which has its own transfer logic. Testing one contract in isolation won't catch the bugs that live in the seams between them. That's where [Chimera](/learn/chimera-framework) really shines. It gives you a framework to set up multi-contract environments, simulate multiple users doing different things, and track expected state with ghost variables — all while keeping your test suite runnable on both [Echidna](/learn/echidna) and [Medusa](/learn/medusa). Let's dig into the patterns that make this work. ## Why multi-contract testing matters Single-contract fuzzing is great for catching arithmetic bugs, reentrancy, and basic access control issues. But the nastiest DeFi exploits? They almost always involve interactions between contracts. Think about it: - A lending protocol depends on an oracle. What if the oracle returns stale data mid-liquidation? - A vault deposits into a strategy that interacts with an AMM. What happens when the AMM's reserves get manipulated between the vault's deposit and withdrawal? - A governance token has delegation. Does the voting power invariant hold when users transfer tokens while votes are active? You can't test these with a single target contract. You need the whole system. ## Setting up a multi-contract target Here's the basic structure. We'll use a simplified lending protocol as the running example (a pool, a price oracle, and an ERC20 collateral token). ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.0; import {TargetFunctions} from "./TargetFunctions.sol"; import {CryticAsserts} from "@chimera/CryticAsserts.sol"; abstract contract Setup is CryticAsserts { LendingPool pool; MockOracle oracle; MockERC20 collateral; MockERC20 debtToken; address[] actors; function setup() internal virtual override { // Deploy the ecosystem collateral = new MockERC20("Collateral", "COL", 18); debtToken = new MockERC20("Debt", "DEBT", 18); oracle = new MockOracle(); pool = new LendingPool( address(collateral), address(debtToken), address(oracle) ); // Set initial oracle price oracle.setPrice(address(collateral), 1000e18); // Create actors with different roles actors = new address[](3); actors[0] = address(0x10000); // depositor actors[1] = address(0x20000); // borrower actors[2] = address(0x30000); // liquidator // Fund actors for (uint256 i = 0; i < actors.length; i++) { collateral.mint(actors[i], 1_000_000e18); debtToken.mint(actors[i], 1_000_000e18); } // Seed the pool with liquidity debtToken.mint(address(pool), 10_000_000e18); } } ``` Notice what's happening here. We don't just deploy the pool — we deploy everything it depends on, fund multiple actors, and seed initial state. This is the foundation every multi-contract test suite needs. ## The actor pattern Real protocols have users with different permissions and behaviors. An admin can pause the protocol. A depositor supplies collateral. A borrower takes loans. A liquidator closes unhealthy positions. Your fuzzer should simulate all of these. The actor pattern gives each call a "caller" selected from a set of addresses: ```solidity abstract contract TargetFunctions is Setup { // Ghost variables for tracking mapping(address => uint256) ghost_deposited; mapping(address => uint256) ghost_borrowed; uint256 ghost_totalDeposits; uint256 ghost_totalBorrows; // Actor selection -- the fuzzer picks the index function _getActor(uint8 actorIndex) internal view returns (address) { return actors[actorIndex % actors.length]; } function deposit(uint8 actorIndex, uint256 amount) public { address actor = _getActor(actorIndex); amount = clampBetween(amount, 1, collateral.balanceOf(actor)); // Prank as the actor vm.prank(actor); collateral.approve(address(pool), amount); vm.prank(actor); pool.deposit(amount); // Update ghosts ghost_deposited[actor] += amount; ghost_totalDeposits += amount; } function borrow(uint8 actorIndex, uint256 amount) public { address actor = _getActor(actorIndex); uint256 maxBorrow = pool.maxBorrowable(actor); if (maxBorrow == 0) return; amount = clampBetween(amount, 1, maxBorrow); vm.prank(actor); pool.borrow(amount); // Update ghosts ghost_borrowed[actor] += amount; ghost_totalBorrows += amount; } function liquidate(uint8 actorIndex, uint8 targetIndex, uint256 amount) public { address liquidator = _getActor(actorIndex); address target = _getActor(targetIndex); if (!pool.isLiquidatable(target)) return; uint256 debt = pool.debtOf(target); amount = clampBetween(amount, 1, debt); vm.prank(liquidator); debtToken.approve(address(pool), amount); vm.prank(liquidator); pool.liquidate(target, amount); // Update ghosts ghost_borrowed[target] -= amount; ghost_totalBorrows -= amount; } } ``` There's a subtle but important detail: each handler function takes an `actorIndex` that the fuzzer controls. The fuzzer doesn't know about "depositors" vs "borrowers", it just picks numbers. The modulo operation maps those numbers to actual addresses. This means the fuzzer can naturally discover interesting sequences like "actor 0 deposits, then actor 1 borrows against actor 0's collateral, then the oracle price drops, then actor 2 liquidates actor 1." ## Ghost variables: tracking expected state Ghost variables are the secret weapon of serious [invariant testing](/learn/invariant-testing). They're variables that exist only in your test harness, not in the protocol itself, and they track what the state *should* be. The idea is simple. Every time you call a protocol function, you also update a parallel "expected" value. Then your invariants check that the protocol's actual state matches the expected state. ```solidity abstract contract Properties is TargetFunctions { // INVARIANT: Total deposits tracked by ghost must match // the actual collateral balance held by the pool function invariant_deposits_match_balance() public view returns (bool) { return collateral.balanceOf(address(pool)) >= ghost_totalDeposits - ghost_totalBorrows; } // INVARIANT: No individual user can have borrowed more // than they deposited (at the correct collateral ratio) function invariant_no_undercollateralized_borrows() public view returns (bool) { for (uint256 i = 0; i < actors.length; i++) { address actor = actors[i]; uint256 collateralValue = ghost_deposited[actor] * oracle.getPrice(address(collateral)) / 1e18; uint256 maxAllowed = collateralValue * pool.collateralFactor() / 1e18; if (ghost_borrowed[actor] > maxAllowed) { return false; } } return true; } // INVARIANT: Ghost total must equal sum of individual ghosts function invariant_ghost_consistency() public view returns (bool) { uint256 sumDeposits; uint256 sumBorrows; for (uint256 i = 0; i < actors.length; i++) { sumDeposits += ghost_deposited[actors[i]]; sumBorrows += ghost_borrowed[actors[i]]; } return sumDeposits == ghost_totalDeposits && sumBorrows == ghost_totalBorrows; } } ``` That last invariant (`ghost_consistency`) is what I call a "meta-invariant." It doesn't test the protocol; it tests your test harness. If this fails, your ghost tracking has a bug. Always include at least one of these. It'll save you hours of debugging false positives. ## Cross-contract invariants This is where multi-contract setups really pay off. You can write invariants that span the entire system: ```solidity // The oracle should never report a zero price for active collateral function invariant_oracle_nonzero_price() public view returns (bool) { return oracle.getPrice(address(collateral)) > 0; } // The pool's accounting should be consistent with token balances function invariant_pool_solvency() public view returns (bool) { uint256 poolCollateral = collateral.balanceOf(address(pool)); uint256 poolDebt = debtToken.balanceOf(address(pool)); uint256 totalOwed = pool.totalBorrows(); // Pool should always have enough debt tokens to cover // what hasn't been borrowed out return poolDebt >= pool.totalDeposits() - totalOwed; } ``` These invariants catch bugs that live in the *interactions* between contracts. The solvency check, for instance, verifies that the pool's internal accounting (totalBorrows, totalDeposits) stays consistent with the actual token balances. If there's a rounding error in the borrow function that slowly leaks tokens, this invariant catches it. ## Adding oracle manipulation Here's where things get fun. Real attackers manipulate oracles. Your fuzzer should too: ```solidity function manipulateOraclePrice(uint256 newPrice) public { // Bound to realistic range -- 0.01x to 100x current price uint256 currentPrice = oracle.getPrice(address(collateral)); newPrice = clampBetween(newPrice, currentPrice / 100, currentPrice * 100); oracle.setPrice(address(collateral), newPrice); } ``` Now the fuzzer can interleave price changes with deposits, borrows, and liquidations. This is how you find bugs like "if the oracle price drops 50% between a borrow and the next block, the position becomes liquidatable but the liquidation bonus makes the pool insolvent." ## setUp patterns for complex DeFi deployments For real protocols, your setup gets complicated fast. Here's how to keep it manageable: **Layer your setup functions:** ```solidity abstract contract Setup is CryticAsserts { function setup() internal virtual override { _deployCore(); _deployPeriphery(); _configureRoles(); _seedLiquidity(); _createActors(); } function _deployCore() internal { // Token deploys, pool factory, core contracts } function _deployPeriphery() internal { // Oracles, routers, helpers } function _configureRoles() internal { // Admin roles, operator permissions, guardian addresses } function _seedLiquidity() internal { // Initial deposits so the protocol isn't empty } function _createActors() internal { // Fund test actors with tokens and approvals } } ``` This pattern keeps your setup readable even when you're deploying 15+ contracts. Each function has one job. Pre-approve everything: Don't let your fuzzer waste cycles hitting "insufficient allowance" reverts. In your actor setup, approve all contracts for max amounts: ```solidity function _createActors() internal { for (uint256 i = 0; i < actors.length; i++) { vm.startPrank(actors[i]); collateral.approve(address(pool), type(uint256).max); debtToken.approve(address(pool), type(uint256).max); vm.stopPrank(); } } ``` ## Putting it all together Here's the full hierarchy for a Chimera-based multi-contract test: ``` Setup (deploys everything, creates actors) └── TargetFunctions (handler functions with ghost updates) └── Properties (invariants checking ghosts + actual state) └── CryticTester (Echidna/Medusa entry point) └── FoundryTester (Foundry entry point) ``` The beauty of this structure is that your properties and target functions work identically whether you run them with Echidna, Medusa, or Foundry. Write once, test everywhere. ## Common pitfalls **Ghost drift.** If you forget to update a ghost in one handler, all your ghost-dependent invariants become unreliable. Be disciplined, every state change in the protocol needs a matching ghost update. **Over-constraining actors.** Don't give actors fixed roles. Let the fuzzer decide. If you hard-code "actor 0 always deposits," you'll miss bugs that require a single user to both deposit and borrow. **Ignoring reverts.** If your handler function silently returns on a revert, you might miss important bugs. Track *which* calls revert and why. Sometimes the most interesting finding is "this function reverts when it shouldn't." **Stale ghost state after liquidation.** Liquidations change multiple users' positions. Make sure your ghost updates handle the full cascading effect, not just the liquidator's side. ## What's next If you haven't built your first invariant test yet, start with [How to Write Your First Invariant Test](/blog/how-to-write-first-invariant-test). Once you're comfortable with the basics, come back here and add actors and ghosts. For more on [stateful fuzzing](/learn/stateful-fuzzing) and why call sequences matter, check out [Stateful Fuzzing Explained: Sequence Matters](/blog/stateful-fuzzing-explained-sequence-matters). And if you want to run these patterns with minimal setup, the [Chimera framework](/learn/chimera-framework) gives you the scaffolding to write properties once and test them on every major fuzzer. Get a Security Review --- *kn0t specializes in invariant testing and fuzzing infrastructure at Recon. When he's not writing ghost variables, he's probably debugging why they drifted.* --- ### Foundry invariant testing beyond the basics: handlers, actors, and bounded runs **Author:** deivitto | **Published:** 2025-09-29 | **Reading time:** 15 min **Tags:** foundry, invariant-testing, fuzzing, smart-contract-security **URL:** https://getrecon.xyz/blog/foundry-invariant-testing-beyond-basics # Foundry Invariant Testing Beyond the Basics: Handlers, Actors, and Bounded Runs *By deivitto* You've got `forge test` running. Maybe you've even written an `invariant_` function or two. But if you're still writing invariants that just call the target contract directly, you're leaving a lot of bugs on the table. [Foundry's](/learn/foundry) invariant testing engine is more powerful than most people realize. With the right setup (handlers, actor management, input bounding, and ghost variables) you can catch bugs that basic fuzz tests won't ever reach. Let's go beyond the basics. ## The problem with direct target testing Here's what most people start with: ```solidity contract BasicInvariantTest is Test { Vault vault; function setUp() public { vault = new Vault(address(new MockERC20())); } function invariant_totalAssetsGteTotalSupply() public { assertGe(vault.totalAssets(), vault.totalSupply()); } } ``` This works, technically. Foundry will call random functions on the Vault with random inputs. But here's the problem: most of those calls will revert. The fuzzer will spend 90% of its budget hitting `deposit(0)` or `withdraw(amountGreaterThanBalance)` and learning nothing. You need handlers. ## Handler contracts: guiding the fuzzer A handler contract wraps your target and constrains inputs to meaningful ranges. Think of it as a translator between "random bytes the fuzzer generates" and "valid protocol interactions." ```solidity contract VaultHandler is Test { Vault vault; MockERC20 token; address[] public actors; address currentActor; // Ghost variables mapping(address => uint256) public ghost_depositSum; uint256 public ghost_totalDeposited; uint256 public ghost_totalWithdrawn; modifier useActor(uint256 actorIndexSeed) { currentActor = actors[bound(actorIndexSeed, 0, actors.length - 1)]; vm.startPrank(currentActor); _; vm.stopPrank(); } constructor(Vault _vault, MockERC20 _token) { vault = _vault; token = _token; // Create actors actors.push(address(0xBEEF)); actors.push(address(0xCAFE)); actors.push(address(0xDEAD)); // Fund and approve for (uint256 i = 0; i < actors.length; i++) { token.mint(actors[i], 100_000e18); vm.prank(actors[i]); token.approve(address(vault), type(uint256).max); } } function deposit(uint256 actorSeed, uint256 amount) public useActor(actorSeed) { amount = bound(amount, 1, token.balanceOf(currentActor)); vault.deposit(amount, currentActor); ghost_depositSum[currentActor] += amount; ghost_totalDeposited += amount; } function withdraw(uint256 actorSeed, uint256 amount) public useActor(actorSeed) { uint256 maxWithdraw = vault.maxWithdraw(currentActor); if (maxWithdraw == 0) return; amount = bound(amount, 1, maxWithdraw); vault.withdraw(amount, currentActor, currentActor); ghost_totalWithdrawn += amount; } function redeem(uint256 actorSeed, uint256 shares) public useActor(actorSeed) { uint256 maxRedeem = vault.maxRedeem(currentActor); if (maxRedeem == 0) return; shares = bound(shares, 1, maxRedeem); vault.redeem(shares, currentActor, currentActor); ghost_totalWithdrawn += vault.previewRedeem(shares); } } ``` See the difference? Every call to `deposit` will actually succeed because we bound the amount to something the actor can afford. Every `withdraw` checks the max first. The fuzzer spends its budget on *interesting* state transitions instead of reverts. ## The `bound()` helper Foundry's `bound()` function is your best friend. It takes a random value and maps it into a range: ```solidity // bound(value, min, max) -> value in [min, max] uint256 amount = bound(rawAmount, 1, 1_000_000e18); ``` A few tips: - Don't bound to zero. Most protocols treat zero amounts as no-ops or reverts. Start at 1. - Use realistic ranges. Bounding to `type(uint256).max` is technically valid but wastes cycles on unrealistic scenarios. - For percentages or basis points, bound to the actual range: `bound(bps, 1, 10_000)`. ## Setting up the test contract Your main test contract points Foundry at the handler: ```solidity contract VaultInvariantTest is Test { Vault vault; MockERC20 token; VaultHandler handler; function setUp() public { token = new MockERC20("Token", "TKN", 18); vault = new Vault(token); handler = new VaultHandler(vault, token); // Tell Foundry to only call the handler targetContract(address(handler)); } function invariant_solvency() public { assertGe( token.balanceOf(address(vault)), vault.totalAssets() ); } function invariant_ghostTracking() public { assertEq( handler.ghost_totalDeposited() - handler.ghost_totalWithdrawn(), vault.totalAssets() ); } function invariant_sharesConsistency() public { uint256 totalShares; for (uint256 i = 0; i < handler.actorsCount(); i++) { totalShares += vault.balanceOf(handler.actors(i)); } assertEq(totalShares, vault.totalSupply()); } } ``` The key line is `targetContract(address(handler))`. Without it, Foundry calls random functions on every contract it can see — including the token, which would mess up your invariants. ## `excludeContract` and `excludeSelector` Sometimes you need fine-grained control over what the fuzzer touches: ```solidity function setUp() public { // ... deploy everything ... // Only fuzz through the handler targetContract(address(handler)); // Don't let the fuzzer call these directly excludeContract(address(vault)); excludeContract(address(token)); // Exclude specific functions from the handler bytes4[] memory selectors = new bytes4[](1); selectors[0] = handler.emergencyWithdraw.selector; excludeSelector(FuzzSelector({ addr: address(handler), selectors: selectors })); } ``` Use `excludeSelector` when your handler has admin functions that would create unrealistic scenarios. If the fuzzer can call `pause()` at any time, it'll spend half its budget in the paused state and miss bugs in normal operation. ## `fail_on_revert`, the silent bug killer Here's a config option that changes everything. In your `foundry.toml`: ```toml [invariant] runs = 256 depth = 100 fail_on_revert = false # default ``` With `fail_on_revert = false` (the default), the fuzzer swallows reverts and moves on. This is fine when you're using handlers that already bound inputs. But sometimes you *want* to know about reverts. Set `fail_on_revert = true` when: - Your handlers are well-crafted and reverts indicate real bugs - You're testing that certain operations should *never* revert under valid inputs - You want to catch "function reverts when it shouldn't" bugs Keep it `false` when: - You're still iterating on handlers - You don't have good input bounding yet - Your protocol has functions that legitimately revert in many states I usually start with `false`, get my handlers solid, then flip to `true` for a final campaign. ## Actor management patterns The modifier pattern I showed earlier works great, but there's a more advanced version for protocols with role-based access: ```solidity contract AdvancedHandler is Test { mapping(uint8 => address[]) actorsByRole; uint8 constant ROLE_DEPOSITOR = 0; uint8 constant ROLE_ADMIN = 1; uint8 constant ROLE_LIQUIDATOR = 2; function _getActorByRole(uint8 role, uint256 seed) internal view returns (address) { address[] storage pool = actorsByRole[role]; return pool[bound(seed, 0, pool.length - 1)]; } function adminSetFee(uint256 actorSeed, uint256 newFee) public { address admin = _getActorByRole(ROLE_ADMIN, actorSeed); newFee = bound(newFee, 0, 1000); // max 10% vm.prank(admin); vault.setFee(newFee); } function userDeposit(uint256 actorSeed, uint256 amount) public { address user = _getActorByRole(ROLE_DEPOSITOR, actorSeed); // ... deposit logic ... } } ``` This way, admin functions are only called by admin addresses, and user functions by user addresses. The fuzzer still controls *which* admin and *what* parameters — you just prevent impossible scenarios like a random address calling an onlyOwner function. ## Ghost variables: your parallel accounting system Ghosts track what the state *should* be. Here's a more complete pattern: ```solidity // Track every deposit and withdrawal per user mapping(address => uint256) public ghost_userDeposited; mapping(address => uint256) public ghost_userWithdrawn; uint256 public ghost_yieldAccrued; // Call counter -- useful for debugging uint256 public ghost_depositCalls; uint256 public ghost_withdrawCalls; function deposit(uint256 actorSeed, uint256 amount) public useActor(actorSeed) { // ... bounded deposit ... ghost_userDeposited[currentActor] += amount; ghost_depositCalls++; } ``` Then your invariants can check: ```solidity function invariant_accounting() public { uint256 expectedBalance = handler.ghost_totalDeposited() - handler.ghost_totalWithdrawn() + handler.ghost_yieldAccrued(); // Allow 1 wei tolerance for rounding assertApproxEqAbs( token.balanceOf(address(vault)), expectedBalance, handler.ghost_depositCalls() // at most 1 wei per deposit ); } ``` That tolerance calculation is important. Rounding errors accumulate. If you've had 50 deposits, each might round down by 1 wei, so you allow 50 wei of drift. This catches *real* accounting bugs while ignoring expected rounding. ## Running campaigns with different depths Not all invariant campaigns should look the same. Short runs catch shallow bugs fast. Long runs find deep state-dependent issues. ```toml # foundry.toml - quick check (CI) [profile.ci.invariant] runs = 64 depth = 50 # foundry.toml - standard (development) [profile.default.invariant] runs = 256 depth = 100 # foundry.toml - deep (pre-audit) [profile.deep.invariant] runs = 1024 depth = 500 ``` Run them with `FOUNDRY_PROFILE=deep forge test`. I always run the deep profile before shipping anything to production. ## Foundry vs Echidna vs Medusa Here's the honest comparison. I use all three, and they each have strengths. **Foundry's invariant testing:** - You're already using Foundry, so zero extra setup - Great Solidity-native DX, handlers are just contracts - `bound()` and `vm.prank()` make actor patterns clean - Decent for quick checks and CI integration - Limited in call sequence depth compared to dedicated fuzzers **[Echidna](/learn/echidna):** - Coverage-guided, it learns which inputs explore new code paths - Better at finding deep bugs that require specific call sequences - Config-driven, less Solidity boilerplate - Slower per run but smarter about exploration **[Medusa](/learn/medusa):** - Also coverage-guided, built on Go - Faster than Echidna for large contracts - Parallel execution out of the box - Growing feature set My recommendation: start with Foundry because it's the easiest to set up. Once your properties are solid, run the same logic through Echidna or Medusa for deeper exploration. The [Chimera framework](/learn/chimera-framework) makes this switch nearly painless, same properties, different runners. Check out the [full comparison](/compare/foundry-vs-echidna-vs-medusa) for details. ## Debugging failing invariants When an invariant fails, Foundry shows you the call sequence. But sequences can be long and hard to parse. A few debugging tricks: 1. Add event logging to handlers. Emit events in every handler function with the actor and parameters. Then check the trace. 2. Use ghost counters. If you know the invariant fails after `ghost_depositCalls == 3`, you can narrow down the sequence. 3. Write regression tests. Once you've found a failing sequence, write it as a regular `test_` function so it never regresses. 4. Shrink manually. If the sequence is 50 calls, try removing calls from the middle and see if it still fails. Most bugs need 3-5 specific calls. ## Where to go from here If you want to write properties that work across Foundry, Echidna, and Medusa, look into the [Chimera framework](/learn/chimera-framework). If you want to see how [invariant testing](/learn/invariant-testing) fits into a broader security strategy, the comparison between [Foundry, Echidna, and Medusa](/compare/foundry-vs-echidna-vs-medusa) covers the trade-offs. And if you're working on a DeFi protocol and want help setting up a proper invariant suite, we've done this for dozens of teams. Get a Security Review --- *deivitto builds security tooling and audits DeFi protocols at Recon. He's run more invariant campaigns than he can count and still finds new bugs every week.* --- ### Formal verification vs fuzzing: the definitive guide **Author:** alex | **Published:** 2025-10-27 | **Reading time:** 16 min **Tags:** formal-verification, fuzzing, smart-contract-security, invariant-testing **URL:** https://getrecon.xyz/blog/formal-verification-vs-fuzzing-when-to-use # Formal Verification vs Fuzzing: The Definitive Guide *By alex* Two questions come up constantly when teams plan their security strategy: "Should we fuzz or formally verify?" and "What's the actual difference?" The honest answer is that they're different tools solving different problems, and most serious teams need both. But let's break down exactly why. ## What each actually does **[Fuzzing](/learn/fuzzing)** throws semi-random inputs at your code and watches for violations. It's empirical. It runs your code millions of times, looking for any execution path that breaks a property you've defined. If it finds one, it gives you a concrete failing test case you can reproduce. **[Formal verification](/learn/formal-verification)** reasons about your code mathematically. Instead of running examples, it proves (or disproves) that a property holds for *every possible input*. When FV says "this property holds," it means there's a mathematical proof that no input can break it. That sounds like FV is strictly better, right? If it proves things for all inputs, why bother fuzzing? The catch is in the details. ## The strengths of fuzzing Fuzzing's biggest advantage is practicality. You can fuzz almost anything with minimal setup. Low barrier to entry. Write a property in Solidity, run [Echidna](/learn/echidna) or [Medusa](/learn/medusa), and you're testing. No need to model the entire system or learn a specification language. If you can write a test, you can fuzz. Handles real-world complexity. Fuzzing doesn't care about external calls, assembly blocks, or complex state machines. It just runs the code. Whatever your contract does at runtime, the fuzzer executes it. Finds sequence-dependent bugs. [Stateful fuzzing](/learn/stateful-fuzzing) explores sequences of transactions: deposit, then borrow, then price drop, then liquidate. These multi-step bugs are incredibly common in DeFi and incredibly hard to reason about formally. Concrete counterexamples. When fuzzing finds a bug, you get an actual test case: "call deposit(500), then borrow(400), then setPrice(1), then liquidate(0x...), and the pool is now insolvent." You can run this test case directly. No interpretation needed. Speed of iteration. Changing a property and re-running takes seconds. You can iterate on invariants rapidly during development. Here's a simple example. You want to test that a vault's share price never decreases (excluding external yield): ```solidity // Property: share price should be monotonically non-decreasing function invariant_sharePriceNeverDecreases() public { uint256 currentPrice = vault.convertToAssets(1e18); assertGe(currentPrice, ghost_lastSharePrice); ghost_lastSharePrice = currentPrice; } ``` Run this with any fuzzer and it'll try thousands of deposit/withdraw/transfer sequences looking for a price decrease. If the share accounting has a rounding bug that leaks value, fuzzing will probably find it. ## The strengths of formal verification Where FV shines is in *certainty*. Fuzzing can tell you "I tried 10 million inputs and none of them broke this property." FV can tell you "no input will ever break this property. Here's the proof." Complete coverage of the input space. This is the big one. A fuzzer might not explore the exact combination of inputs that triggers a bug. FV explores all of them, mathematically. For critical properties (like "the total supply of this token can never exceed 2^128") this guarantee matters. Proving absence of bugs. Fuzzing finds bugs. FV proves they don't exist. This is a completely different value proposition. If you need to guarantee a property holds unconditionally, only FV can do that. Catching edge cases. Certain bugs only trigger at exact values, like `type(uint256).max` or `0` or a specific block timestamp. A fuzzer might never generate the exact trigger value. FV will find it because it reasons about all values simultaneously. Mathematical precision for arithmetic. If you need to verify that a fixed-point math library is correct for all inputs, FV is the right tool. [Halmos](/learn/halmos) or similar tools can prove that `mulDiv(a, b, c)` never overflows and always returns the correct result, for every possible `a`, `b`, and `c`. Here's what the same share price property looks like as a formal spec with Halmos: ```solidity function check_depositDoesNotDecreaseSharePrice(uint256 assets) public { // Assume valid state vm.assume(vault.totalSupply() > 0); vm.assume(assets > 0 && assets < type(uint128).max); uint256 priceBefore = vault.convertToAssets(1e18); // Execute deposit token.mint(address(this), assets); token.approve(address(vault), assets); vault.deposit(assets, address(this)); uint256 priceAfter = vault.convertToAssets(1e18); assert(priceAfter >= priceBefore); } ``` Halmos will check this for every possible value of `assets` and every possible starting state (within the assumptions). If there's any input that makes the price drop, it'll find it. ## Where each falls short Neither tool is perfect. Understanding the gaps is what matters. ## Fuzzing limitations No completeness guarantee. A fuzzer running for an hour might miss a bug that only triggers with a very specific 5-step call sequence. You can run it longer, but you'll never get a proof of absence. Sensitive to input generation. The quality of your results depends heavily on how well you've bounded inputs and structured handlers. Poor handler design means the fuzzer wastes most of its budget on useless paths. Scaling issues with deep state. Protocols with deep state dependencies (where a bug only appears after 100+ specific transactions) are hard for fuzzers. [Coverage-guided fuzzing](/learn/coverage-guided-fuzzing) helps, but there are limits. ## Formal verification limitations State space explosion. FV reasons about all possible states. For complex contracts with many storage variables, the state space grows exponentially. Verification might time out or run out of memory. This is a real problem for multi-contract systems. Modeling external dependencies. FV needs a model of everything the contract interacts with. If your protocol calls an oracle, you need to model what that oracle can return. If you model it too loosely, you get false positives. Too tightly, you miss real bugs. Loops and dynamic data. Unbounded loops and dynamic data structures are hard for FV tools. Most tools require loop bounds, which means you're not verifying the general case, just the bounded one. Specification bugs. Here's the sneaky one: if your formal spec is wrong, FV will happily prove that your buggy code satisfies a buggy spec. The proof is only as good as the specification. And writing correct specs is genuinely difficult. Cost and expertise. FV takes more time and more specialized knowledge. A senior Solidity dev can start fuzzing in an afternoon. Setting up proper formal verification requires understanding the tool's constraint language and debugging failed proofs. ## Concrete examples Let's look at specific bug classes and which tool catches them: ## Example 1: rounding error in token vault A vault's `withdraw` function has a rounding error that lets users extract 1 wei more than they should, per withdrawal. - **Fuzzing:** Catches this quickly. After a few thousand deposit/withdraw cycles, the ghost variable tracking total assets will drift from the actual balance. The fuzzer gives you a concrete sequence. - **FV:** Can prove this for a single withdraw call, but proving it over arbitrary sequences of calls is harder. FV is better at "this single function is correct" than "this sequence of functions is correct." Winner: fuzzing, for the sequence-dependent version. FV for the single-call version. ## Example 2: integer overflow in fee calculation `fee = amount * feeRate / BASIS_POINTS` overflows when `amount * feeRate > type(uint256).max`. - **Fuzzing:** Might find this if it generates a large enough amount and feeRate. But the specific overflow boundary is hard to hit randomly. - **FV:** Finds this immediately. It reasons about the full range of `amount` and `feeRate` and identifies the exact values that cause overflow. Winner: formal verification, decisively. ## Example 3: flash loan attack on price oracle An attacker flash-borrows a huge amount, manipulates a spot price oracle, borrows against the inflated collateral, and repays the flash loan, all in one transaction. - **Fuzzing:** Can model this if you write a handler that simulates flash loans and oracle manipulation. Stateful fuzzing is great at finding these multi-step exploits. - **FV:** Very difficult to model. The flash loan introduces state changes that happen within a single transaction, the oracle is an external contract, and the attack requires a specific sequence. Winner: fuzzing, by a large margin. ## Example 4: access control invariant "Only the admin can call `setFee()`." - **Fuzzing:** Will try calling `setFee()` from random addresses. If access control is missing, it'll find it. But if the bug is subtle (like a delegatecall that changes msg.sender context), it might miss it. - **FV:** Can prove that for all possible msg.sender values != admin, `setFee()` reverts. Complete guarantee. Winner: formal verification, for the guarantee. Fuzzing for the "is anything obviously wrong" quick check. ## The decision framework Here's when to use each: | Scenario | Best Tool | |----------|-----------| | Quick sanity check during development | Fuzzing | | Proving arithmetic correctness | FV | | Testing multi-contract interactions | Fuzzing | | Verifying access control properties | FV | | Finding sequence-dependent exploits | Fuzzing | | Pre-audit confidence on critical functions | Both | | Continuous integration testing | Fuzzing | | Certifying a math library | FV | | Complex DeFi protocol with oracles | Fuzzing first, FV for core math | ## Cost and time trade-offs Let's be real about the investment: Fuzzing setup: 2-5 days for a solid handler + invariant suite. Running costs are just compute. You can run it in CI. A mid-level Solidity developer can set it up with guidance. FV setup: 1-4 weeks for a meaningful formal spec, depending on complexity. Requires specialized knowledge. Tools like [Halmos](/learn/halmos) reduce this gap by letting you write specs in Solidity, but you still need to understand what you're proving. For most teams, the right approach is: fuzz everything, formally verify the critical stuff. ## How to combine them The best workflow uses both. Here's what we recommend: 1. Start with fuzzing. Write [invariant tests](/learn/invariant-testing) for your core properties. Run them during development. This catches the majority of bugs cheaply. 2. Identify critical functions. Which functions absolutely must be correct? Token minting, fee calculations, share price conversions, access control. These get FV. 3. Write FV specs for the critical subset. Use Halmos or your tool of choice. Prove arithmetic correctness and access control. 4. Use fuzzing for integration testing. Multi-contract interactions, oracle dependencies, complex state transitions. Fuzz these. FV can't handle the complexity. 5. Run both in CI. Fuzzing as a fast gate (short campaigns), FV for the critical functions. Catch regressions before they ship. If you want to write properties that work with both approaches, the [Chimera framework](/learn/chimera-framework) and [Halmos](/learn/halmos) can share the same property format. Write once, verify with both fuzzing and symbolic execution. For a deeper comparison of specific tools, check out [Halmos vs Echidna](/compare/halmos-vs-echidna) and the broader [Fuzzing vs Formal Verification](/compare/fuzzing-vs-formal-verification) comparison. ## The bottom line Fuzzing and FV aren't competing. They're complementary. Fuzzing is your first line of defense — fast, practical, and great at finding real exploits. FV is your final seal — slow, thorough, and the only way to get mathematical guarantees. Skip either one at your own risk. Get a Security Review --- *alex leads security strategy at Recon. He's spent more time than he'd like arguing about fuzzing vs FV and decided to just write the definitive answer.* --- ### The complete smart contract security pipeline: first commit to mainnet **Author:** alex | **Published:** 2025-12-08 | **Reading time:** 18 min **Tags:** smart-contract-security, smart-contract-audit, fuzzing, invariant-testing **URL:** https://getrecon.xyz/blog/smart-contract-security-pipeline-complete-guide # The Complete Smart Contract Security Pipeline: First Commit to Mainnet *By alex* Most security incidents don't happen because teams skip audits. They happen because security is treated as a checkpoint instead of a pipeline. You write code for three months, hire an auditor, fix a few things, deploy, and hope for the best. That's not a pipeline. That's a prayer. A real security pipeline starts at the first commit and doesn't stop after deployment. Here's the full picture — every phase, what it catches, and how to set it up. ## Phase 0: development practices Security starts before any testing tool runs. It starts with how you write code. ## NatSpec and documentation Every external and public function should have NatSpec comments that describe: - What the function does - What the expected behavior is (pre-conditions, post-conditions) - What shouldn't happen (the properties you'll test later) This isn't busywork. When you write "this function should never allow withdrawal of more than the user's balance," you've just written your first [invariant](/learn/invariant-testing). The testing comes later — but the thinking happens now. ## Access control from day one Define your roles and permissions before you write business logic. Who can call what? Under which conditions? Document this in a role matrix. You'll formalize it into properties later, but the design comes first. ## Small functions, clear state transitions Functions that do one thing are easier to test, easier to reason about, and easier to audit. If a function handles deposits, interest accrual, and fee distribution all at once, it's going to be a nightmare to verify. ## Phase 1: static analysis Run static analysis on every commit. Zero excuses. It's free, fast, and catches low-hanging fruit. **Slither** is the standard. Run it in CI: ```bash slither . --config-file slither.config.json ``` What it catches: - Reentrancy patterns - Uninitialized variables - Shadowed state variables - Missing access control modifiers - Common anti-patterns What it doesn't catch: - Business logic bugs - Incorrect math that doesn't trigger standard patterns - Bugs that require specific call sequences Static analysis is your first filter. It won't find the hard bugs, but it'll stop you from shipping the embarrassing ones. ## Phase 2: unit testing I shouldn't have to say this, but: write unit tests. For everything. Aim for 100% line coverage as a baseline, but understand that coverage doesn't equal correctness. Good unit tests cover: - **Happy paths:** Does the function work correctly with normal inputs? - **Edge cases:** Zero values, max values, empty arrays, first deposit, last withdrawal - **Revert cases:** Does it revert when it should? With the right error message? - **Access control:** Does every protected function reject unauthorized callers? ```solidity function test_deposit_updatesBalance() public { token.mint(alice, 1000e18); vm.startPrank(alice); token.approve(address(vault), 1000e18); vault.deposit(1000e18, alice); vm.stopPrank(); assertEq(vault.balanceOf(alice), 1000e18); assertEq(token.balanceOf(address(vault)), 1000e18); } function test_deposit_revertsOnZeroAmount() public { vm.prank(alice); vm.expectRevert("ZERO_AMOUNT"); vault.deposit(0, alice); } ``` Unit tests are fast, deterministic, and great for catching regressions. But they only test the cases you think of. That's where the next phase comes in. ## Phase 3: property-based testing and fuzzing This is where you move from "testing what you think can go wrong" to "testing what you don't know can go wrong." ## Write properties, not test cases Instead of `test_deposit_updatesBalance`, you write properties that should hold for *any* input: ```solidity // For any deposit amount, the user's vault balance should increase // by the correct number of shares function invariant_depositAlwaysMintCorrectShares() public { // ... checked over thousands of random inputs } ``` ## Start with system-level invariants These are your most important properties, things that should always be true: - Total assets in the vault >= total supply of shares (solvency) - Sum of all individual balances == total supply - No user can withdraw more than they deposited (plus yield) - Protocol fees are always non-negative ## Run with multiple tools Don't rely on a single fuzzer. Each explores differently: - **[Echidna](/learn/echidna):** Coverage-guided, excellent at finding deep bugs. Good for long campaigns. - **[Medusa](/learn/medusa):** Coverage-guided with parallel execution. Good for large codebases. - **[Foundry](/learn/foundry):** Quick iteration and CI integration. Good for development-time testing. The [Chimera framework](/learn/chimera-framework) lets you write properties once and run them with all three. You should do this. For more on [fuzzing](/learn/fuzzing) and how it works, see [What is Smart Contract Fuzzing](/blog/what-is-smart-contract-fuzzing). For tool comparisons, check [Smart Contract Fuzzing Tools Compared](/blog/smart-contract-fuzzing-tools-compared). ## Phase 4: formal verification For your most critical functions, token minting, fee calculations, access control, add [formal verification](/learn/formal-verification). [Halmos](/learn/halmos) lets you write specs in Solidity, which lowers the barrier significantly: ```solidity function check_mintNeverExceedsCap(uint256 amount) public { vm.assume(amount > 0); vm.assume(token.totalSupply() + amount <= type(uint256).max); uint256 supplyBefore = token.totalSupply(); token.mint(address(this), amount); assert(token.totalSupply() == supplyBefore + amount); assert(token.totalSupply() <= TOKEN_CAP); } ``` FV proves this for every possible `amount`, not just the ones a fuzzer happens to generate. For arithmetic-heavy code, this is the only way to get real guarantees. Don't try to formally verify your entire protocol. It's too expensive and most FV tools can't handle multi-contract interactions well. Pick the critical 20% of your code and verify that. ## Phase 5: manual code review Tools catch patterns. Humans catch design flaws. Before engaging an external auditor, do an internal review: 1. **Architecture review.** Does the design make sense? Are there simpler ways to achieve the same thing? 2. **Trust boundary analysis.** Where does the protocol trust external inputs? Oracles, user inputs, admin actions, each is an attack surface. 3. **Upgrade and migration paths.** If the protocol is upgradeable, what can go wrong during an upgrade? 4. **Economic review.** Can the protocol be gamed? Are the incentives aligned? Flash loan attacks, sandwich attacks, oracle manipulation, think about economic exploits. This is hard to automate. It requires experienced security engineers who've seen how real exploits work. ## Phase 6: audit preparation An audit is expensive. Don't waste it. Prepare properly: ## Documentation package - Architecture overview with diagrams - Threat model (who can do what, what's the worst case) - Known issues and accepted risks - Deployment plan (constructor parameters, initialization sequence) - Access control matrix ## Code Quality - All static analysis warnings addressed or documented - Full test suite passing - Invariant suite with meaningful properties - Code frozen, no changes during the audit ## Scope Definition - Exactly which contracts are in scope - Which external dependencies are trusted vs untrusted - Which chains will this deploy on (different chains have different quirks) Teams that prepare well get better audits. The auditor spends time finding real bugs instead of asking basic questions about how the protocol works. ## Phase 7: the audit During the audit, your job is to be responsive. Answer questions quickly. Provide test environments. If the auditor asks "can this function be called by anyone?" and you don't know, you've got a problem. Good audit firms will: - Review architecture and design - Do line-by-line code review - Write custom tests for suspected bugs - Provide severity-rated findings with recommended fixes Don't just accept fixes without understanding them. Every fix should be reviewed, tested, and ideally covered by a new invariant that would've caught the original bug. ## Phase 8: post-audit testing Here's where most teams drop the ball. The audit found 5 medium-severity issues. You fix them. Do you re-test everything? Yes. Every fix is new code. New code can have new bugs. ## Fix Verification - Every fix gets a unit test that verifies the fix - Every fix gets an invariant that would've caught the original bug - Run the full fuzzing campaign again after all fixes are applied - If the fixes are significant, consider a fix review from the auditors ## Regression Suite After the audit, your test suite should include: - All original tests - New tests for every finding - New invariants for every property the auditor identified - Integration tests for any multi-contract interactions the auditor flagged This regression suite is your ongoing defense. Run it on every commit going forward. ## Phase 9: deployment Deployment itself has security concerns: ## Deployment Scripts - Use deterministic deployment (CREATE2) where possible - Verify constructor parameters are correct - Double-check initialization parameters (many exploits come from incorrect initialization) - Use a deployment checklist, don't rely on memory ## Post-Deployment Verification - Verify all contracts on Etherscan/Sourcify - Check that all roles are assigned correctly on-chain - Verify that initialization state matches expectations - Run a smoke test against the deployed contracts ## Deployment Keys - Use a multisig for deployment, not a hot wallet - Revoke deployer permissions immediately after deployment - Transfer ownership to the governance multisig ## Phase 10: monitoring and incident response Your security pipeline doesn't end at deployment. The protocol is live and attackers are looking at it right now. ## On-Chain Monitoring - Monitor for unusual transactions (large flash loans, abnormal swap volumes) - Track key invariants on-chain (total supply, key balances, oracle prices) - Set up alerts for admin function calls - Monitor mempool for potential attacks (if applicable) ## Incident Response Plan Before you deploy, have a plan for when things go wrong: - **Who can pause the protocol?** How fast can they act? - **What's the communication plan?** Discord, Twitter, on-chain message? - **Do you have a war room process?** Who's in the room, what tools do they need? - **Is there a bug bounty?** If not, set one up. White hats need a reason to report instead of exploit. ## Continuous Testing The protocol evolves. Governance changes parameters. Markets shift. New integrations get added. Keep running your fuzzing campaigns. Update your invariants when the protocol changes. Treat security as ongoing, not one-and-done. ## The full pipeline at a glance | Phase | What | When | Catches | |-------|------|------|---------| | 0. Dev Practices | NatSpec, architecture | Every commit | Design flaws early | | 1. Static Analysis | Slither | Every PR | Common patterns | | 2. Unit Tests | Foundry/Hardhat | Every PR | Known edge cases | | 3. Fuzzing | Echidna/Medusa/Foundry | Daily/Weekly | Unknown edge cases | | 4. Formal Verification | Halmos/Certora | Pre-audit | Mathematical correctness | | 5. Manual Review | Internal team | Pre-audit | Design/logic flaws | | 6. Audit Prep | Documentation | Before audit | N/A (saves audit time) | | 7. External Audit | Audit firm | Before deploy | Everything above, plus fresh eyes | | 8. Post-Audit | Fix testing | After audit | Regression bugs | | 9. Deployment | Scripts + verification | Deploy day | Deployment errors | | 10. Monitoring | On-chain + off-chain | Forever | Live exploits | Skip any of these steps and you're leaving gaps. How big those gaps are depends on how much value your protocol holds. ## Getting started You don't need all of this on day one. Start with static analysis and unit tests (you should already have these). Add [invariant testing](/learn/invariant-testing) next, it's the highest-ROI addition to most test suites. Then layer in [formal verification](/learn/formal-verification) for your critical functions. And when you're ready for an audit, make sure your test suite reflects the work you've done. Auditors who see a solid invariant suite know you're serious, and they'll spend their time finding the bugs your tools can't. Get a Security Review --- *alex leads security strategy at Recon. This pipeline is the distilled version of what we run on every engagement, adapted for teams that want to own their own security.* --- ### Advanced invariant testing techniques for DeFi protocols **Author:** nican0r | **Published:** 2026-02-02 | **Reading time:** 16 min **Tags:** invariant-testing, fuzzing, property-based-testing, defi-security **URL:** https://getrecon.xyz/blog/invariant-testing-techniques-advanced # Advanced Invariant Testing Techniques for DeFi Protocols *By nican0r* Basic [invariant testing](/learn/invariant-testing) checks that "X is always true." Advanced invariant testing checks that "X is always true, even when users do weird things in weird orders under weird market conditions." If you're working on a DeFi protocol — lending, DEX, vault, staking — the basic version won't cut it. You need techniques that handle real protocol complexity. I've spent the last couple years writing invariant suites for DeFi protocols, and these are the patterns that actually find bugs. ## Ghost variables and expected-Value tracking You've probably seen ghost variables mentioned before, and here's why they're the single most important technique in your arsenal. A ghost variable is a variable in your test harness that tracks what the protocol's state *should* be. Every time you call a protocol function, you update the ghost alongside it. Then your invariant checks that the protocol's actual state matches the ghost. Here's a lending protocol example: ```solidity // Ghost tracking for a lending pool uint256 ghost_totalDeposits; uint256 ghost_totalBorrows; uint256 ghost_totalRepaid; uint256 ghost_totalLiquidated; mapping(address => uint256) ghost_userDeposits; mapping(address => uint256) ghost_userBorrows; function handler_deposit(uint256 actorSeed, uint256 amount) public { address actor = _selectActor(actorSeed); amount = clampBetween(amount, 1, token.balanceOf(actor)); vm.prank(actor); pool.deposit(amount); ghost_totalDeposits += amount; ghost_userDeposits[actor] += amount; } function handler_borrow(uint256 actorSeed, uint256 amount) public { address actor = _selectActor(actorSeed); uint256 maxBorrow = pool.maxBorrowable(actor); if (maxBorrow == 0) return; amount = clampBetween(amount, 1, maxBorrow); vm.prank(actor); pool.borrow(amount); ghost_totalBorrows += amount; ghost_userBorrows[actor] += amount; } function handler_repay(uint256 actorSeed, uint256 amount) public { address actor = _selectActor(actorSeed); uint256 debt = pool.debtOf(actor); if (debt == 0) return; amount = clampBetween(amount, 1, debt); vm.prank(actor); pool.repay(amount); ghost_totalRepaid += amount; ghost_userBorrows[actor] -= amount; } ``` Now the invariant: ```solidity function invariant_poolSolvency() public view returns (bool) { uint256 expectedBalance = ghost_totalDeposits - ghost_totalBorrows + ghost_totalRepaid; uint256 actualBalance = token.balanceOf(address(pool)); return actualBalance >= expectedBalance; } ``` If the protocol has a rounding bug in `repay()` that gives users credit for 1 more wei than they actually paid, this invariant will catch it after enough repay calls. The ghost says "the pool should have X tokens" and the actual balance is lower. Bug found. ## Function-Level vs system-Level invariants There are two categories of invariants, and you need both. **System-level invariants** check global properties that should always hold regardless of what happened: ```solidity // Sum of all user balances == total supply (always true) function invariant_balanceSumEqualsTotalSupply() public view returns (bool) { uint256 sum; for (uint256 i = 0; i < actors.length; i++) { sum += vault.balanceOf(actors[i]); } return sum == vault.totalSupply(); } // Pool can't have negative equity function invariant_poolNonNegativeEquity() public view returns (bool) { return pool.totalAssets() >= pool.totalLiabilities(); } ``` **Function-level invariants** check properties of specific operations. These are tighter and catch more specific bugs: ```solidity // After a deposit, the user's balance must increase by at least 1 share // (no zero-share deposits) function handler_deposit_checkShares(uint256 actorSeed, uint256 amount) public { address actor = _selectActor(actorSeed); amount = clampBetween(amount, 1, token.balanceOf(actor)); uint256 sharesBefore = vault.balanceOf(actor); vm.prank(actor); vault.deposit(amount, actor); uint256 sharesAfter = vault.balanceOf(actor); assert(sharesAfter > sharesBefore); // Must get at least 1 share } ``` Function-level invariants baked directly into handlers are one of the most effective patterns for DeFi. They catch bugs at the exact point they happen, making debugging much easier. ## Temporal properties: before/After checks Some properties aren't about absolute state, they're about how state changes relative to what it was before. These are temporal properties. ```solidity // Liquidity should never decrease after a deposit // (it can increase by less than the deposited amount due to fees, but never decrease) function handler_deposit_liquidityIncreases(uint256 actorSeed, uint256 amount) public { address actor = _selectActor(actorSeed); amount = clampBetween(amount, pool.minDeposit(), token.balanceOf(actor)); if (amount == 0) return; uint256 liquidityBefore = pool.totalLiquidity(); vm.prank(actor); pool.deposit(amount); uint256 liquidityAfter = pool.totalLiquidity(); assert(liquidityAfter >= liquidityBefore); } // Share price should never decrease after a repayment function handler_repay_sharePriceStable(uint256 actorSeed, uint256 amount) public { address actor = _selectActor(actorSeed); uint256 debt = pool.debtOf(actor); if (debt == 0) return; amount = clampBetween(amount, 1, debt); uint256 priceBefore = pool.sharePrice(); vm.prank(actor); pool.repay(amount); uint256 priceAfter = pool.sharePrice(); assert(priceAfter >= priceBefore); } ``` These temporal invariants are how you catch donation attacks, share inflation attacks, and other exploits where the attacker profits by making the share price move in an unexpected direction. ## Conditional invariants Not every property holds in every state. Some properties only hold under specific conditions. Conditional invariants handle this: ```solidity // If a position is healthy, liquidation should revert function handler_liquidateHealthyReverts(uint256 liquidatorSeed, uint256 targetSeed) public { address liquidator = _selectActor(liquidatorSeed); address target = _selectActor(targetSeed); // Only test when the target is healthy if (pool.healthFactor(target) >= 1e18) { vm.prank(liquidator); try pool.liquidate(target, 1) { // If this succeeds, that's a bug -- healthy positions // shouldn't be liquidatable assert(false); } catch { // Expected -- liquidation should revert } } } // If utilization is below the kink, borrow rate should be in the low range function invariant_borrowRateBelowKink() public view returns (bool) { if (pool.utilization() <= pool.kink()) { uint256 rate = pool.borrowRate(); return rate <= pool.maxRateBelowKink(); } return true; // Don't check if we're above the kink } ``` The key: always return `true` (or skip the assertion) when the condition isn't met. You don't want false positives from states where the property doesn't apply. ## Bounding input spaces Bad input bounding is the #1 reason invariant suites produce useless results. If your fuzzer spends 80% of its time hitting reverts, it's not testing anything. Here's how to bound effectively for DeFi: ```solidity function handler_swap(uint256 actorSeed, uint256 amountIn, bool zeroForOne) public { address actor = _selectActor(actorSeed); // Don't swap more than the actor has address tokenIn = zeroForOne ? address(token0) : address(token1); uint256 maxAmount = IERC20(tokenIn).balanceOf(actor); if (maxAmount == 0) return; // Don't swap dust -- it just creates noise uint256 minAmount = 1e6; // 1 USDC equivalent if (maxAmount < minAmount) return; amountIn = clampBetween(amountIn, minAmount, maxAmount); vm.prank(actor); dex.swap(zeroForOne, int256(amountIn), actor); } ``` A few principles: - **Minimum amounts that matter.** Swapping 1 wei doesn't test anything real. Set minimums at economically meaningful levels. - **Respect protocol limits.** If the protocol has a max deposit of 1M tokens, bound to that. - **Early returns instead of reverts.** If a handler can't execute (zero balance, no debt to repay), return early. Don't let it revert and waste a fuzzer cycle. ## Dealing with external dependencies DeFi protocols depend on oracles, other protocols, and tokens with quirky behavior. Here's how to handle them: ## Oracle mocking ```solidity function handler_updateOraclePrice(uint256 newPrice) public { uint256 currentPrice = oracle.latestAnswer(); // Bound price changes to realistic ranges // Max 50% drop or 100% increase per update uint256 minPrice = currentPrice / 2; uint256 maxPrice = currentPrice * 2; newPrice = clampBetween(newPrice, minPrice, maxPrice); oracle.setPrice(int256(newPrice)); } function handler_makeOracleStale() public { // Simulate oracle going stale vm.warp(block.timestamp + 1 hours + 1); } ``` The price bounds matter. A 99.99% crash in a single oracle update isn't realistic, and it'll drown out real bugs with noise. But a 50% drop? That happens. Test for it. ## Fee-on-Transfer and rebasing tokens If your protocol claims to support arbitrary ERC20 tokens, test with weird ones: ```solidity function setup() internal override { // Standard token normalToken = new MockERC20("Normal", "NORM", 18); // Fee-on-transfer token (2% fee) feeToken = new MockFeeToken("Fee", "FEE", 18, 200); // Rebasing token rebaseToken = new MockRebaseToken("Rebase", "REB", 18); // 6-decimal token (like USDC) sixDecToken = new MockERC20("Six", "SIX", 6); } ``` Then run the same invariants against each token variant. Bugs that only appear with fee-on-transfer tokens are real bugs, several major exploits have come from protocols that assumed `transferFrom(amount)` actually transfers `amount`. ## Property composition Complex invariants are built from simpler ones. Here's the composition pattern: ```solidity // Base property: total supply matches sum of balances function prop_supplyMatchesBalances() internal view returns (bool) { uint256 sum; for (uint256 i = 0; i < actors.length; i++) { sum += token.balanceOf(actors[i]); } // Include non-actor holders sum += token.balanceOf(address(pool)); sum += token.balanceOf(address(treasury)); return sum == token.totalSupply(); } // Base property: pool is solvent function prop_poolSolvent() internal view returns (bool) { return pool.totalAssets() >= pool.totalLiabilities(); } // Composed property: the system is consistent function invariant_systemConsistency() public view returns (bool) { return prop_supplyMatchesBalances() && prop_poolSolvent(); } ``` Break complex invariants into composable pieces. When something fails, you can check each piece individually to locate the bug. ## Debugging failing invariants An invariant fails. Now what? Here's my process: **1. Read the call sequence.** What functions were called, in what order, with what parameters? Most fuzzers print this. **2. Check the ghost variables.** Do the ghosts match expected values? If a ghost is wrong, the bug might be in your harness, not the protocol. 3. Minimize the sequence. Try removing calls from the middle. Can you reproduce with fewer steps? Most real bugs need 2-5 calls. 4. Add event logging. Put `emit` statements in your handlers. Re-run and trace the values. ```solidity event DebugDeposit(address actor, uint256 amount, uint256 sharesMinted, uint256 ghostTotal); function handler_deposit(uint256 actorSeed, uint256 amount) public { // ... setup ... uint256 sharesBefore = vault.balanceOf(actor); vm.prank(actor); vault.deposit(amount, actor); uint256 sharesMinted = vault.balanceOf(actor) - sharesBefore; ghost_totalDeposits += amount; emit DebugDeposit(actor, amount, sharesMinted, ghost_totalDeposits); } ``` 5. Write a regression test. Once you've minimized the sequence, write it as a regular test: ```solidity function test_regression_rounding_bug() public { // Reproduces the invariant failure handler_deposit(0, 1000e18); // actor 0 deposits handler_deposit(1, 1); // actor 1 deposits 1 wei handler_withdraw(1, 1); // actor 1 withdraws -- gets 0 back! // The 1 wei is now stuck, and the ghost drifts } ``` This regression test ensures the bug stays fixed forever. ## Putting it into practice If you're starting with [invariant testing](/learn/invariant-testing) for the first time, check out [How to Write Your First Invariant Test](/blog/how-to-write-first-invariant-test). Get comfortable with the basics before adding ghosts and temporal properties. For DeFi-specific property patterns, what to test in lending protocols, DEXs, and vaults, see [Property Design Patterns for DeFi Lending](/blog/property-design-patterns-defi-lending). It covers the actual properties that catch real bugs. And remember: the goal isn't to write the most properties. It's to write the *right* properties. Five well-designed invariants with solid ghost tracking will catch more bugs than fifty surface-level checks. Focus on accounting. Focus on solvency. Focus on the properties that, if they fail, mean real money is at risk. Everything else is secondary. Try Recon Pro --- *nican0r specializes in DeFi security and [property-based testing](/learn/property-based-testing) at Recon. He's written invariant suites for lending protocols, DEXs, bridges, and everything in between.* --- ### Fuzzing + formal verification + invariant testing: combining them all **Author:** alex | **Published:** 2025-06-09 | **Reading time:** 14 min **Tags:** fuzzing, formal-verification, invariant-testing, smart-contract-security **URL:** https://getrecon.xyz/blog/fuzzing-formal-verification-invariant-testing-combined # Fuzzing + Formal Verification + Invariant Testing: Combining Them All *By alex* Here's a common mistake: teams pick one tool and treat it as their entire testing strategy. "We fuzz with Echidna" or "We verify with Certora." That's like saying "we test with unit tests" — it's a start, but it's leaving bugs on the table. The real power comes from combining [fuzzing](/learn/fuzzing), [formal verification](/learn/formal-verification), and [invariant testing](/learn/invariant-testing) into a single workflow. Each catches what the others miss. And with the right setup, you can write your properties once and run them across all three approaches. Let's build that workflow. ## What each approach catches (That others miss) Before we combine anything, you need to understand the gaps. ## Fuzzing finds - **Sequence-dependent bugs.** Deposit, then price drop, then borrow, then liquidate — the fuzzer discovers these multi-step attack paths by trying millions of random sequences. FV can't easily reason about arbitrary-length sequences. - **Integration bugs.** When contract A calls contract B which triggers a callback to contract A, the fuzzer runs the actual code. No modeling needed. - **Unexpected edge cases in real execution.** Gas-related issues, stack depth problems, EVM-specific quirks, the fuzzer hits these because it runs real bytecode. ## Formal verification finds - **Arithmetic boundary bugs.** That one specific input value where `a * b` overflows? The fuzzer might never generate it. FV checks every possible value. - **Provable correctness.** "This function is correct for ALL inputs" is a guarantee only FV can give. Fuzzing says "I tried a lot and it didn't break." - **Subtle invariant violations.** If a rounding error only produces a wrong result for a narrow range of inputs, FV will identify that range exactly. ## Invariant testing finds - **Stateful property violations.** System-level invariants, solvency, supply consistency, access control, tested over sequences of real transactions. - Ghost variable drift. Expected-value tracking catches accounting bugs that accumulate over many operations. - Cross-contract consistency. Invariants that span multiple contracts catch bugs in the interfaces between them. The overlap between these isn't as big as you'd think. In practice, I've seen protocols where fuzzing found 40% of the bugs, FV found 30%, and invariant testing caught the remaining 30%. Drop any one approach and you lose a chunk of coverage. ## The unified property format Here's the key insight: write properties once, test them three ways. The [Chimera framework](/learn/chimera-framework) makes this possible. You write properties as Solidity functions, and Chimera gives you adapters for [Echidna](/learn/echidna), [Medusa](/learn/medusa), and [Foundry](/learn/foundry). For [Halmos](/learn/halmos), you can write symbolic versions of the same properties. Let's use a concrete example. Say you have a token vault with this critical property: *depositing X assets should mint at least 1 share, and withdrawing all shares should return at least X assets minus fees.* ## The property in Chimera (for fuzzing) ```solidity // Properties.sol -- shared across all fuzzers abstract contract Properties is TargetFunctions { function invariant_noZeroShareDeposits() public view returns (bool) { // After any deposit, the user must have received shares // (Checked via ghost tracking) for (uint256 i = 0; i < actors.length; i++) { if (ghost_deposited[actors[i]] > 0) { if (vault.balanceOf(actors[i]) == 0) { return false; } } } return true; } function invariant_solvency() public view returns (bool) { // Vault's actual token balance must cover total assets return token.balanceOf(address(vault)) >= vault.totalAssets(); } function invariant_supplyConsistency() public view returns (bool) { uint256 sum; for (uint256 i = 0; i < actors.length; i++) { sum += vault.balanceOf(actors[i]); } return sum == vault.totalSupply(); } } ``` These properties run on Echidna, Medusa, and Foundry without changes. Chimera handles the glue. ## The same property in Halmos (for formal verification) ```solidity // FormalProperties.sol -- symbolic versions contract FormalProperties is Test { Vault vault; MockERC20 token; function setUp() public { token = new MockERC20("Token", "TKN", 18); vault = new Vault(token); // Seed with initial liquidity to avoid first-depositor edge case token.mint(address(this), 1000e18); token.approve(address(vault), 1000e18); vault.deposit(1000e18, address(this)); } function check_depositAlwaysMintsShares(uint256 assets) public { vm.assume(assets > 0); vm.assume(assets <= 1_000_000e18); // realistic bound address user = address(0xBEEF); token.mint(user, assets); vm.startPrank(user); token.approve(address(vault), assets); uint256 sharesBefore = vault.balanceOf(user); vault.deposit(assets, user); uint256 sharesAfter = vault.balanceOf(user); vm.stopPrank(); // Must receive at least 1 share assert(sharesAfter > sharesBefore); } function check_withdrawNeverExceedsDeposit(uint256 assets) public { vm.assume(assets > 0); vm.assume(assets <= 1_000_000e18); address user = address(0xBEEF); token.mint(user, assets); vm.startPrank(user); token.approve(address(vault), assets); uint256 shares = vault.deposit(assets, user); uint256 balanceBefore = token.balanceOf(user); vault.redeem(shares, user, user); uint256 balanceAfter = token.balanceOf(user); vm.stopPrank(); uint256 received = balanceAfter - balanceBefore; // Should get back at most what we deposited assert(received <= assets); } } ``` Halmos checks these for every possible `assets` value (within the `vm.assume` constraints). If there's any amount where `deposit` returns zero shares, Halmos will find it and give you the exact value. ## Running all three ```bash # Fuzzing with Echidna (coverage-guided, sequence-aware) echidna . --contract CryticTester --config echidna.yaml # Fuzzing with Medusa (parallel, coverage-guided) medusa fuzz --target-contracts MedusaTester # Fuzzing with Foundry (fast iteration, CI-friendly) forge test --match-contract FoundryTester # Formal verification with Halmos (symbolic, proves for all inputs) halmos --contract FormalProperties ``` Same properties. Four different engines. Each explores differently. ## The practical workflow Here's how this works in a real project: ## Step 1: write properties first Before writing any target functions or handlers, define what "correct" means. Start with the big ones: - Solvency: The protocol can always pay out what it owes. - Supply consistency: Sum of individual balances equals total supply. - Access control: Only authorized addresses can call restricted functions. - Monotonicity: Share price never decreases (excluding external yield changes). Write these as Chimera-compatible properties. ## Step 2: build the fuzzing harness Add target functions (handlers) that wrap protocol interactions with proper input bounding and ghost variable tracking. This is where most of the work happens. ```solidity abstract contract TargetFunctions is Setup { // Ghost state uint256 ghost_totalDeposited; uint256 ghost_totalWithdrawn; mapping(address => uint256) ghost_userDeposited; function handler_deposit(uint256 actorSeed, uint256 amount) public { address actor = _getActor(actorSeed); amount = clampBetween(amount, 1, token.balanceOf(actor)); vm.prank(actor); token.approve(address(vault), amount); vm.prank(actor); vault.deposit(amount, actor); ghost_totalDeposited += amount; ghost_userDeposited[actor] += amount; } function handler_withdraw(uint256 actorSeed, uint256 amount) public { address actor = _getActor(actorSeed); uint256 maxWithdraw = vault.maxWithdraw(actor); if (maxWithdraw == 0) return; amount = clampBetween(amount, 1, maxWithdraw); vm.prank(actor); vault.withdraw(amount, actor, actor); ghost_totalWithdrawn += amount; } } ``` ## Step 3: run fuzzing campaigns Run short campaigns during development (Foundry in CI). Run long campaigns before audits (Echidna/Medusa overnight). ```yaml # echidna.yaml testMode: "assertion" testLimit: 500000 seqLen: 100 corpusDir: "corpus" ``` ## Step 4: write formal specs for critical functions Take your most critical properties and write Halmos versions. Focus on: - Math libraries (fixed-point arithmetic, share conversion) - Fee calculations - Access control checks - Token minting/burning bounds Don't try to formally verify everything. It's too expensive. Pick the 20% of code where mathematical certainty matters most. ## Step 5: run both in CI ```yaml # .github/workflows/security.yml jobs: fuzz: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run Foundry invariant tests run: forge test --match-contract InvariantTest - name: Run Echidna (short campaign) run: echidna . --contract CryticTester --config echidna-ci.yaml formal: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run Halmos run: halmos --contract FormalProperties --loop 5 ``` Fuzzing is your fast feedback loop. FV is your confidence check. ## Decision matrix: which approach for which bug class | Bug Class | Fuzzing | FV | Invariant Testing | |-----------|---------|-----|-------------------| | Arithmetic overflow | Medium | High | Low | | Rounding errors (single-call) | Medium | High | Medium | | Rounding errors (accumulated) | High | Low | High | | Reentrancy | High | Low | Medium | | Access control bypass | Medium | High | Medium | | Oracle manipulation | High | Low | High | | Flash loan attacks | High | Low | High | | Share inflation / first depositor | Medium | High | High | | Accounting drift over time | High | Low | High | | Incorrect fee calculation | Medium | High | Medium | | Liquidation edge cases | High | Low | High | | Governance manipulation | High | Low | Medium | | Cross-contract state corruption | High | Low | High | Read this table carefully. Notice the pattern: fuzzing and invariant testing are strong on stateful, sequence-dependent bugs. FV is strong on single-function correctness and arithmetic. The combination covers nearly everything. ## A real example: testing a fee mechanism Let's say your vault charges a 0.3% management fee, calculated annually and accrued per block. Here's how each approach tests it: Fuzzing: Run thousands of deposit/withdraw/accrue sequences. The ghost variable tracks expected fees. If the actual accrued fees ever diverge from expected, the invariant fails. This catches bugs where the fee calculation is wrong in specific states, like when total supply is very small or very large. ```solidity function handler_accruesFees() public { uint256 elapsed = clampBetween(block.timestamp, 1, 365 days); vm.warp(block.timestamp + elapsed); uint256 expectedFee = vault.totalAssets() * 30 * elapsed / (10000 * 365 days); ghost_expectedFees += expectedFee; vault.accrueManagementFee(); } function invariant_feesAccurate() public view returns (bool) { // Allow 0.1% tolerance for rounding uint256 actualFees = vault.accruedFees(); uint256 tolerance = ghost_expectedFees / 1000; return actualFees >= ghost_expectedFees - tolerance && actualFees <= ghost_expectedFees + tolerance; } ``` FV: Prove that for any `totalAssets`, `elapsed`, and `feeRate`, the fee calculation doesn't overflow and produces the correct result. This catches the case where `totalAssets * feeRate * elapsed` overflows for large values. ```solidity function check_feeCalculationNoOverflow(uint256 totalAssets, uint256 elapsed) public { vm.assume(totalAssets > 0 && totalAssets <= 1e30); vm.assume(elapsed > 0 && elapsed <= 365 days); // This should never overflow uint256 fee = totalAssets * 30 * elapsed / (10000 * 365 days); // Fee should be less than total assets assert(fee < totalAssets); // Fee should be proportional to time assert(fee > 0); } ``` Invariant testing: The system-level invariant checks that total fees collected plus total user withdrawals plus remaining balances equals total deposits plus yield. This catches scenarios where fees are double-counted or missed entirely. Together, these three approaches cover: the math is correct (FV), the implementation matches the math in all states (fuzzing), and the accounting is consistent end-to-end (invariant testing). ## Common pitfalls when combining Don't duplicate effort. If Halmos already proved a function is correct for all inputs, you don't need the fuzzer to spend cycles on the same function. Focus fuzzing on the stateful, multi-contract stuff. Keep specs in sync. If you update a property in your fuzzing suite, update the Halmos version too. Stale specs are worse than no specs, they give false confidence. Don't over-model in FV. The temptation with formal verification is to model everything precisely. Resist it. Model what you need, assume the rest. A verified subset is better than an incomplete verification that times out. Share test infrastructure. Your fuzzing setup (mock contracts, deployment scripts, actor helpers) can be reused for FV tests. Don't build two separate test worlds. ## Getting started If you're starting from scratch, here's the order: 1. Write [invariant tests](/learn/invariant-testing) with the [Chimera framework](/learn/chimera-framework). Get them running on Foundry first. 2. Add [Echidna](/learn/echidna) or [Medusa](/learn/medusa) for deeper fuzzing campaigns. 3. Pick your critical math functions and add [Halmos](/learn/halmos) specs. 4. Set up CI to run all of them. This isn't a weekend project. But it's the difference between "we tested our protocol" and "we *know* our protocol is correct." And in DeFi, that difference is measured in millions of dollars. For specific tool comparisons, check out [Echidna vs Medusa](/blog/echidna-vs-medusa-practical-comparison), [Halmos Symbolic Execution Guide](/blog/halmos-symbolic-execution-guide), and [Fuzzing vs Formal Verification](/compare/fuzzing-vs-formal-verification). Get a Security Review --- *alex leads security strategy at Recon. He's built combined fuzzing + FV pipelines for protocols holding hundreds of millions in TVL and still finds new ways to break things every week.* --- ### AI-guided fuzzing: from LLM property generation to automated campaigns **Author:** deivitto | **Published:** 2026-03-09 | **Reading time:** 14 min **Tags:** ai-auditing, fuzzing, invariant-testing, smart-contract-security **URL:** https://getrecon.xyz/blog/ai-guided-fuzzing-practical-guide # AI-Guided Fuzzing: From LLM Property Generation to Automated Campaigns **Author: deivitto** | Guide You've probably seen the hype. "AI will replace auditors." "GPT found a critical bug." "Claude wrote my invariants." Some of it's real. Most of it isn't. Here's what actually works when you pair LLMs with fuzzing campaigns — and where things fall apart fast. I've been running AI-assisted fuzzing workflows for over a year now. The results are mixed, but the good parts are genuinely useful. Let's break it down. ## The reality of AI-generated properties LLMs are surprisingly decent at reading a contract and suggesting *what should be true*. They're much worse at writing the *exact Solidity code* that tests it. This distinction matters. Here's a typical workflow. You feed a contract to Claude or GPT and ask: "What invariants should hold for this token vault?" A good AI suggestion looks like this: ``` "The total assets reported by the vault should always be >= the sum of all depositor shares converted to assets at the current exchange rate" ``` That's a real insight. It captures the core accounting invariant. But when the LLM tries to write the actual test code, you get stuff like: ```solidity // BAD: AI-generated property -- looks right, doesn't compile function invariant_vault_accounting() public { uint256 totalAssets = vault.totalAssets(); uint256 totalShares = vault.totalSupply(); // This doesn't even make sense -- you can't iterate depositors on-chain for (uint i = 0; i < vault.depositors.length; i++) { // ... } } ``` The AI doesn't understand that on-chain state isn't freely iterable. It doesn't know your ghost variable setup. It doesn't know how your fuzzing harness tracks actors. That's where human expertise comes in. ## What actually works: the three-step pattern After a lot of trial and error, here's the pattern that consistently produces results: ## Step 1: LLM reads the code, suggests English properties Feed the contract source (or even just the interface + NatSpec) to an LLM. Ask for invariants in plain English. Don't ask for code yet. ```solidity // Example prompt output for a lending protocol: // // 1. Total borrows should never exceed total deposits // 2. A user's collateral value (at liquidation threshold) // should always cover their debt, or they should be liquidatable // 3. Interest accrual should be monotonically increasing // 4. The sum of all user deposits should equal totalDeposits // 5. No single transaction should move the exchange rate by more than X% ``` These are solid starting points. Maybe 60-70% of them are actually testable and meaningful. The rest are either too vague, already covered by Solidity's type system, or just wrong. ## Step 2: human filters and refines This is where you earn your keep. You look at those suggestions and ask: - Can I actually measure this in a harness? - Does the contract have the view functions I need? - Is this invariant conditional (only holds when X is true)? - Is this already enforced by a `require` statement? That property about "no single transaction should move the exchange rate by more than X%"? That's gold. It catches donation attacks, flash loan manipulation, and rounding exploits. You keep that one. The one about "interest accrual should be monotonically increasing" needs work. What about when rates change? What about precision loss? You refine it. ## Step 3: human writes the actual test code ```solidity // GOOD: Human-written property inspired by AI suggestion function invariant_exchange_rate_bounded() public { uint256 currentRate = vault.convertToAssets(1e18); uint256 previousRate = ghost_lastExchangeRate; if (previousRate > 0) { uint256 maxDelta = previousRate * MAX_RATE_CHANGE_BPS / 10000; assert( currentRate <= previousRate + maxDelta && currentRate >= previousRate - maxDelta ); } ghost_lastExchangeRate = currentRate; } ``` See the difference? The human knows about ghost variables, knows how the harness tracks state between calls, and knows the right precision for comparisons. ## AI for campaign configuration Where AI gets interesting is campaign setup. Instead of writing properties, you can use LLMs to generate fuzzer configurations. ```yaml # AI-suggested medusa config after analyzing contract complexity fuzzing: workers: 8 callSequenceLength: 50 corpusDirectory: "./corpus" coverageEnabled: true targetContracts: - "VaultHarness" testing: testAllContracts: false assertionTesting: enabled: true propertyTesting: enabled: true maxSequenceLength: 100 ``` This isn't magic, it's just the LLM recognizing patterns. A vault with multiple entry points needs longer call sequences. A protocol with time-dependent logic needs the fuzzer to warp timestamps. You'd figure this out yourself, but the AI saves you 10 minutes of config tweaking. ## Good vs bad AI properties: a side-by-side Let's look at a real example. Given an AMM contract: ## AI property (bad) ```solidity // AI generated this -- it LOOKS reasonable function invariant_constant_product() public { uint256 reserve0 = pool.getReserve0(); uint256 reserve1 = pool.getReserve1(); uint256 k = reserve0 * reserve1; assert(k >= initialK); // "k should never decrease" } ``` Why it's bad: k *should* increase from fees, but this doesn't account for rounding, fee-on-transfer tokens, or that `initialK` needs to be tracked as a ghost variable updated after every legitimate swap. It also overflows on large reserves. ## Human-refined property (good) ```solidity function invariant_k_non_decreasing() public { uint256 reserve0 = pool.getReserve0(); uint256 reserve1 = pool.getReserve1(); // Use uint256 multiplication carefully uint256 currentK = uint256(reserve0) * uint256(reserve1); // k can decrease by at most 1 wei due to rounding per swap // Track cumulative rounding tolerance uint256 tolerance = ghost_swapCount * 1; if (ghost_previousK > 0) { assert(currentK + tolerance >= ghost_previousK); } ghost_previousK = currentK; } ``` The human version handles rounding and overflow while tracking state properly. The AI gave us the *idea* — the human made it work. ## When AI hallucinates (and how to catch it) LLMs confidently produce nonsense properties about 20-30% of the time. Common failure modes: 1. Impossible state assertions The AI claims "totalSupply should equal the sum of all balances." For most ERC20s, this is true. But for rebasing tokens? Fee-on-transfer? It's wrong and the AI won't tell you. 2. Inverted logic ```solidity // AI wrote this backwards function invariant_health_factor() public { // WRONG: this asserts users are ALWAYS healthy // but users CAN be unhealthy -- that's when liquidation kicks in assert(protocol.healthFactor(user) >= 1e18); } ``` The correct property is: if a user's health factor is below 1, then calling `liquidate()` should succeed. The AI confused "desired state" with "invariant." 3. Missing preconditions AI writes a property that should only hold after initialization, or only when the protocol isn't paused, or only for non-zero balances. It skips the `if` guard and you get false positives everywhere. ## Building an AI-assisted pipeline Here's the practical workflow I use: 1. Feed the codebase to an LLM (full contracts, interfaces, existing tests) 2. Ask for invariants in English first. No code, just natural language 3. Filter the list. Remove duplicates, impossibles, and trivially-true statements 4. Ask the LLM to categorize. Which are safety properties vs liveness vs economic? 5. Write harness code yourself, or pair with the LLM for boilerplate 6. Run the campaign. Let the fuzzer loose 7. Feed failures back to the LLM. "This property broke, here's the call trace, why?" Step 7 is underrated. LLMs are actually good at reading a fuzzer's counterexample and explaining *why* the property broke. It's like having a junior auditor who reads stack traces fast. ```solidity // Example: AI helped identify this after seeing a fuzzer trace // The fuzzer found that calling deposit(0) followed by withdraw(0) // changed the exchange rate due to rounding function test_zero_amount_exchange_rate() public { uint256 rateBefore = vault.convertToAssets(1e18); vault.deposit(0, address(this)); vault.withdraw(0, address(this), address(this)); uint256 rateAfter = vault.convertToAssets(1e18); // Zero-amount operations shouldn't change the rate assertEq(rateBefore, rateAfter); } ``` ## When to skip AI entirely Don't bother with AI-generated properties when: - The codebase is small (< 200 LOC). You can read it faster than prompting. - It's a well-known pattern (ERC20, ERC721, standard vault). Just use property templates from invariant testing guides. - The logic is highly mathematical. AMM curve math, options pricing. AI gets the math wrong more often than right. - You need formal guarantees. Use formal verification tools instead. ## Tool integration The best results come from tight integration between your LLM and your fuzzing toolchain. If you're using Recon's fuzzing framework, you can pipe AI-generated property skeletons directly into your harness templates. For EVM projects, check out the comparison between Echidna and Medusa, which handles AI-generated properties differently because of their corpus management approaches. If you want to see AI-assisted auditing in action, there's a deeper breakdown at AI auditing covering the full spectrum from property generation to report writing. And for the complete picture of how fuzzing tools compare when fed AI-generated configs, see our tools comparison. ## Practical takeaways 1. **Use AI for ideation, not implementation.** LLMs suggest what to test. Humans write the tests. 2. **English first, code second.** Always get natural language properties before asking for Solidity. 3. **Budget 30% of AI suggestions as garbage.** That's normal. The 70% that's good saves you real time. 4. **Feed counterexamples back.** LLMs are great at explaining *why* something broke. 5. **Don't trust AI on math-heavy protocols.** The models mess up invariant math regularly. AI-guided fuzzing isn't a replacement for knowing what you're doing. It's a multiplier for people who already understand invariant testing and security patterns. Use it that way and you'll ship better campaigns, faster. Get an AI-Assisted Audit Try Recon Pro --- ### Coverage-guided fuzzing deep dive: corpus management, seeds, and convergence **Author:** antonio | **Published:** 2026-03-28 | **Reading time:** 16 min **Tags:** coverage-guided-fuzzing, fuzzing, echidna, medusa **URL:** https://getrecon.xyz/blog/coverage-guided-fuzzing-deep-dive-corpus-management # Coverage-Guided Fuzzing Deep Dive: Corpus Management, Seeds, and Convergence **Author: antonio** | Deep-Dive Most people know coverage-guided fuzzing finds bugs. Fewer understand *how*. If you've ever wondered why your fuzzer found a critical bug in 30 seconds or ran for 6 hours with nothing — this is the post that explains it. We're going deep into corpus management, seed strategies, and knowing when your campaign has converged. ## How coverage tracking actually works Every coverage-guided fuzzer does the same core thing: it runs an input, measures which code paths executed, and keeps inputs that hit new paths. Simple concept. The devil's in the implementation. ## Coverage metrics: not all equal There are three common coverage metrics, and they give you very different signals. Line coverage tracks which lines of source code executed. It's the crudest metric. If your contract has: ```solidity function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount, "insufficient"); // line 1 balances[msg.sender] -= amount; // line 2 (bool ok, ) = msg.sender.call{value: amount}(""); // line 3 require(ok, "transfer failed"); // line 4 } ``` Line coverage says "we hit all 4 lines" once any valid withdrawal executes. It won't distinguish between a 1 wei withdrawal and a max-balance withdrawal. That's a problem. Branch coverage is better. It tracks which conditional branches were taken. For the code above, it cares about: - Did `require` on line 1 pass AND fail? - Did `require` on line 4 pass AND fail? Now the fuzzer has a reason to generate inputs that trigger the failure paths. Path coverage tracks the exact sequence of branches taken through a function. A function with 10 branches has up to 2^10 = 1024 paths. This explodes fast but catches subtle state-dependent bugs that branch coverage misses. In practice, EVM fuzzers like Echidna and Medusa primarily use branch-level coverage with some path sensitivity. Here's why: pure path coverage on a Solidity contract with multiple external calls produces an astronomical path space. The fuzzer would spend all its time bookkeeping instead of actually fuzzing. ## How the fuzzer tracks it Under the hood, most fuzzers instrument the code at compile time. For EVM contracts, that means: 1. The compiler emits coverage probes at branch points 2. Each probe writes to a shared coverage bitmap 3. After each execution, the fuzzer checks if any new bits flipped 4. If yes (new coverage found), save this input to the corpus ``` Execution Flow: ┌─────────────┐ Input ──────> Execute Contract ──> Read Bitmap ──> New bits? └─────────────┘ │ Yes ──┤── No │ │ Save to Discard corpus input ``` The bitmap is typically 64KB. Each branch point hashes to a position in the bitmap. Collisions happen (two different branches can map to the same bit), but in practice it works well enough for contracts under 10K lines. ## Corpus management: the heart of coverage-guided fuzzing Your corpus is your fuzzer's memory. Every input that discovered new coverage gets saved. Over time, this corpus becomes a curated collection of "interesting" inputs that together cover the reachable code. ## Corpus growth curve A healthy fuzzing campaign shows a characteristic growth pattern: ``` Coverage % 100 ┤ │ ┌──────────────── plateau 80 ┤ ┌────┘ │ ┌────┘ 60 ┤ ┌────┘ │ ┌────┘ 40 ┤ ┌──┘ │ ┌┘ 20 ┤┌┘ <── rapid discovery phase ││ 0 ┤└───┬───┬───┬───┬───┬───┬───┬───┬─── 0 1h 2h 3h 4h 5h 6h 7h 8h Time ``` Three phases: 1. **Rapid discovery** (first minutes to ~1 hour): The fuzzer hits all the easy branches. Coverage climbs steeply. 2. **Diminishing returns** (~1-4 hours): Each new corpus entry takes longer to find. The fuzzer is working harder for smaller gains. 3. **Plateau** (4+ hours): Coverage barely moves. Either the fuzzer can't crack the remaining branches, or they're truly unreachable. If your campaign never leaves phase 1, your harness is probably too simple. If it jumps straight to plateau at 30% coverage, your harness has configuration problems. The fuzzer can't even reach most of the code. ## Corpus minimization After a long campaign, your corpus might have 50,000 entries. Many are redundant, covering the same branches as other entries. Corpus minimization keeps only the minimum set of inputs needed to maintain the same total coverage. Why minimize? Two reasons: 1. **Faster startup.** When you resume a campaign or start a new one with the same corpus, fewer seeds means faster initial replay. 2. **Better mutation.** Fuzzers mutate corpus entries to generate new inputs. A lean corpus means mutations are more likely to produce something useful. Medusa handles this automatically. With Echidna, you can manage it through corpus replay settings. If you're rolling your own setup: ```bash # Example: minimizing a corpus directory # Keep only inputs that contribute unique coverage medusa fuzz --corpus-dir ./corpus --minimize ``` ## Corpus distillation This is a trick that experienced fuzzers use. Take a corpus from one tool and feed it to another. ``` Echidna corpus ──> Convert format ──> Medusa seeds │ Medusa corpus ──> Convert format ──> Echidna seeds ``` Each fuzzer has different mutation strategies. Echidna's transaction-aware mutations and Medusa's coverage-guided approach complement each other. A call sequence that Echidna found but Medusa wouldn't have (and vice versa) can jump-start new coverage in the other tool. For a practical comparison of how these tools differ, check Echidna vs Medusa. ## Seed selection strategies Seeds are the starting inputs for your fuzzer. Good seeds dramatically accelerate coverage. Bad seeds waste time. ## Manual seeds from deployment scripts Your best seeds often come from your own deployment and test scripts. If you have a Foundry test that sets up a lending protocol with markets, depositors, and borrowers, that final state is an excellent seed. ```solidity // This setup function creates a state the fuzzer can start from function setUp() public { // Deploy core contracts token = new MockERC20("USDC", "USDC", 6); vault = new Vault(address(token)); // Seed with realistic state token.mint(address(this), 1_000_000e6); token.approve(address(vault), type(uint256).max); vault.deposit(500_000e6, address(this)); // Now the fuzzer starts with a vault that has liquidity, // an active depositor, and approval state already set up } ``` Without this setup, the fuzzer has to randomly discover that it needs to mint, then approve, then deposit before it can even start testing withdrawal logic. That can take hours of wasted cycles. ## Dictionary extraction Smart fuzzers extract "interesting" constants from the contract bytecode. Magic numbers, boundary values, storage slot keys. You can help by adding to the dictionary: ```solidity // These constants help the fuzzer find edge cases uint256 constant MAX_BPS = 10000; uint256 constant PRECISION = 1e18; uint256 constant MAX_UINT = type(uint256).max; uint256 constant BLOCK_TIME = 12; // Common time values the fuzzer should try uint256 constant ONE_DAY = 86400; uint256 constant ONE_YEAR = 365 days; ``` ## Targeted seeds for complex state machines For protocols with complex state transitions (governance proposals, multi-sig operations, timelocked upgrades), you need seeds that put the system into specific states. ```solidity // Seed: system in "emergency shutdown" mode function seed_emergencyState() internal { // Trigger conditions that lead to emergency oracle.setPrice(0); // Oracle failure vault.checkHealth(); // Triggers emergency flag // NOW the fuzzer tests what happens in emergency mode // Can users still withdraw? Can admin functions be called? } ``` ## Convergence detection: when to stop This is the question everyone asks: "How long should I run the fuzzer?" The honest answer is that it depends. But there are concrete signals. ## Coverage plateau detection Track coverage over time. If coverage hasn't increased in the last N minutes, you're probably at the plateau. ``` Practical thresholds: - Simple contracts (< 200 LOC): 15 min with no new coverage → likely converged - Medium contracts (200-1000 LOC): 1 hour no new coverage → likely converged - Complex protocols (1000+ LOC): 4 hours no new coverage → likely converged ``` But "converged" doesn't mean "done." It means *this harness with this configuration* has found everything it can. You might need to: - Add new handler functions to your harness - Improve your seeds - Adjust call sequence length - Add ghost variables to track more state ## Corpus velocity Monitor how fast new corpus entries appear. Plot entries/hour over time: ``` Entries/hour 200 ┤██ │██ 150 ┤██ │████ 100 ┤████ │██████ 50 ┤████████ │████████████ 0 ┤████████████████████████████████ 0h 1h 2h 3h 4h 5h 6h 7h ``` When corpus velocity drops below ~1 entry/hour, you're in plateau territory. This is a more reliable signal than raw coverage percentage because it accounts for the *rate of discovery*, not just absolute coverage. ## Mutation effectiveness Advanced metric: track what percentage of mutations produce new coverage. ``` Mutation effectiveness = new_coverage_mutations / total_mutations * 100 > 5% : Still discovering fast, keep going 1-5% : Slowing down, consider improving harness < 1% : Diminishing returns, probably time to stop or restructure < 0.1%: Definitely converged for this configuration ``` ## Practical tips for better coverage ## 1. Start with small, focused harnesses Don't fuzz your entire protocol in one campaign. Break it into components: ```solidity // Harness 1: Just the vault deposit/withdraw logic contract VaultHarness is BaseHarness { function handler_deposit(uint256 amount) external { amount = bound(amount, 1, token.balanceOf(address(this))); vault.deposit(amount, address(this)); } function handler_withdraw(uint256 amount) external { amount = bound(amount, 1, vault.maxWithdraw(address(this))); vault.withdraw(amount, address(this), address(this)); } } // Harness 2: Oracle + liquidation interactions // Harness 3: Governance + parameter updates ``` Smaller harnesses converge faster and find bugs earlier. You can combine them later for integration testing. ## 2. Use coverage reports to find gaps After a campaign, look at the coverage report. Red (uncovered) lines tell you exactly what the fuzzer couldn't reach. Common reasons for uncovered code: - Missing handler functions: the fuzzer literally can't call that code path - Unreachable preconditions: a `require` that the fuzzer can't satisfy - Time-dependent logic: the fuzzer isn't warping timestamps - Access control: only `owner` can call it, and the fuzzer isn't impersonating owner Fix each gap, re-run, and watch coverage climb. ## 3. Parallelize campaigns Run multiple campaigns simultaneously with different configurations: ```bash # Campaign 1: Short sequences, high throughput medusa fuzz --seq-len 10 --workers 4 --corpus-dir ./corpus-short & # Campaign 2: Long sequences, fewer workers medusa fuzz --seq-len 200 --workers 2 --corpus-dir ./corpus-long & # Campaign 3: Targeted at specific functions medusa fuzz --target-func "withdraw,liquidate" --corpus-dir ./corpus-targeted & ``` Merge the corpora periodically. Short-sequence campaigns find shallow bugs fast; long-sequence campaigns find deep state-dependent bugs. ## 4. Monitor and adapt Don't fire-and-forget. Check in on your campaigns: ``` Every 30 minutes: ├── Check coverage growth → stalled? Change config ├── Check corpus velocity → dropping? Add seeds ├── Check property violations → found one? Investigate immediately └── Check resource usage → OOM? Reduce workers ``` ## Bringing it together Coverage-guided fuzzing is a feedback loop: generate inputs, measure coverage, keep what's interesting, mutate, repeat. The quality of your results depends entirely on how well you manage this loop. Start with good seeds from your deployment scripts. Use focused harnesses that give the fuzzer a clear attack surface. Monitor convergence so you don't waste compute. And when coverage plateaus, improve the harness — don't just run longer. For more on setting up your first fuzzing campaign, head to the fuzzing fundamentals guide. If you want to understand how coverage-guided fuzzing fits into the bigger picture of coverage-guided approaches, we've got you covered there too. And if you're choosing between fuzzing tools, the Echidna vs Medusa comparison breaks down exactly how each tool handles the corpus management strategies we discussed here. Request a Security Review Try Recon Pro --- ### Solana smart contract fuzzing: Trident, Honggfuzz, and property testing **Author:** kn0t | **Published:** 2025-11-03 | **Reading time:** 14 min **Tags:** fuzzing, solana, property-based-testing, smart-contract-security **URL:** https://getrecon.xyz/blog/solana-smart-contract-fuzzing-guide # Solana Smart Contract Fuzzing: Trident, Honggfuzz, and Property Testing **Author: kn0t** | Guide Solana fuzzing is where EVM fuzzing was three years ago. The tools exist, but they're less mature, less documented, and have sharper edges. If you're coming from the EVM world where Echidna and Medusa just work — buckle up. Here's the practical guide to getting fuzzing campaigns running on Solana programs. ## The Solana fuzzing toolbox Three main approaches exist for testing Solana programs: 1. Trident, Ackee's fuzzing framework, purpose-built for Anchor programs 2. honggfuzz-rs, Google's general-purpose fuzzer wrapped for Rust 3. cargo-fuzz / libFuzzer, LLVM's fuzzer through Rust's official interface Each has tradeoffs. Trident understands Anchor's account model. Honggfuzz and cargo-fuzz don't, but they're more mature fuzzers under the hood. ## Trident: Anchor-aware fuzzing Trident is the closest thing Solana has to Echidna. It auto-generates fuzzing harnesses from your Anchor IDL and understands accounts, signers, and program interactions. ## Setup ```bash # Install Trident cargo install trident-cli # Initialize in your Anchor project trident init # This generates: # trident-tests/ # ├── fuzz_tests/ # │ └── fuzz_0/ # │ ├── fuzz_instructions.rs # │ └── test_fuzz.rs # └── Cargo.toml ``` After `trident init`, you get a skeleton harness. The real work is filling in the instruction definitions and account setup. ## Writing a Trident fuzz test Let's say you're testing a token swap program. Here's how you'd set up the fuzzing harness: ```rust use trident_client::fuzzing::*; // Define the instructions the fuzzer can call #[derive(Arbitrary, Debug)] pub struct InitializePoolData { pub fee_bps: u16, pub initial_price: u64, } #[derive(Arbitrary, Debug)] pub struct SwapData { pub amount_in: u64, pub minimum_amount_out: u64, pub swap_direction: bool, // true = A->B, false = B->A } #[derive(Arbitrary, Debug)] pub struct AddLiquidityData { pub amount_a: u64, pub amount_b: u64, pub min_lp_tokens: u64, } impl FuzzInstruction for SwapData { fn get_accounts(&self, accounts: &mut AccountsStorage) -> Result, FuzzingError> { let pool = accounts.get_or_create("pool")?; let user = accounts.get_or_create("user")?; let token_a_vault = accounts.get_or_create("token_a_vault")?; let token_b_vault = accounts.get_or_create("token_b_vault")?; let user_token_a = accounts.get_or_create("user_token_a")?; let user_token_b = accounts.get_or_create("user_token_b")?; Ok(vec![ AccountMeta::new(pool, false), AccountMeta::new_readonly(user, true), // signer AccountMeta::new(token_a_vault, false), AccountMeta::new(token_b_vault, false), AccountMeta::new(user_token_a, false), AccountMeta::new(user_token_b, false), ]) } } ``` Trident's `Arbitrary` derive macro generates random values for each field. The fuzzer then builds transactions from these random instructions and fires them at your program. ## The account problem Here's where Solana fuzzing gets tricky. Every instruction needs a specific set of accounts with the right owners and data layout, plus correct signing authority. If any account is wrong, the instruction just fails with a runtime error. The fuzzer learns nothing. Good Trident harnesses spend most of their code on account setup: ```rust // This is the tedious but critical part fn setup_pool_accounts( accounts: &mut AccountsStorage, ) -> Result<(), FuzzingError> { // Create token mints let mint_a = accounts.create_mint("mint_a", 6)?; // 6 decimals like USDC let mint_b = accounts.create_mint("mint_b", 9)?; // 9 decimals like SOL // Create pool account with correct PDA let (pool_pda, bump) = Pubkey::find_program_address( &[b"pool", mint_a.as_ref(), mint_b.as_ref()], &program_id(), ); accounts.register_pda("pool", pool_pda, bump)?; // Create vault token accounts owned by the pool PDA let vault_a = accounts.create_token_account( "token_a_vault", &mint_a, &pool_pda )?; let vault_b = accounts.create_token_account( "token_b_vault", &mint_b, &pool_pda )?; // Fund initial liquidity so swaps can actually execute accounts.mint_to("mint_a", &vault_a, 1_000_000_000)?; accounts.mint_to("mint_b", &vault_b, 1_000_000_000)?; Ok(()) } ``` Without proper account setup, the fuzzer just hits `AccountNotFound` or `ConstraintOwner` errors on every attempt. It's like running an EVM fuzzer without deploying any contracts first. ## Honggfuzz-rs: raw power, manual wiring Honggfuzz is Google's coverage-guided fuzzer. It doesn't know anything about Solana or Anchor — it just sees bytes. You're responsible for deserializing those bytes into meaningful program inputs. ## Setup ```bash # Install honggfuzz cargo install honggfuzz # In your Cargo.toml [dependencies] honggfuzz = "0.5" solana-program-test = "1.17" solana-sdk = "1.17" ``` ## Writing a Honggfuzz harness ```rust use honggfuzz::fuzz; use solana_program_test::*; use solana_sdk::{ signature::Keypair, signer::Signer, transaction::Transaction, }; fn main() { // Set up program test environment once loop { fuzz!(|data: &[u8]| { if data.len() < 16 { return; } // Parse fuzzer bytes into instruction data let amount_in = u64::from_le_bytes( data[0..8].try_into().unwrap() ); let min_out = u64::from_le_bytes( data[8..16].try_into().unwrap() ); // Build and send transaction let rt = tokio::runtime::Runtime::new().unwrap(); rt.block_on(async { let mut ctx = setup_test_context().await; let ix = create_swap_instruction( amount_in, min_out, &ctx.payer.pubkey(), ); let tx = Transaction::new_signed_with_payer( &[ix], Some(&ctx.payer.pubkey()), &[&ctx.payer], ctx.last_blockhash, ); // We don't care if it fails -- just looking for panics // and unexpected behavior let _ = ctx.banks_client .process_transaction(tx) .await; }); }); } } ``` The big advantage of honggfuzz: it's fast. Really fast. It doesn't need to understand your program's semantics. It just sprays inputs and tracks coverage at the machine code level. The big disadvantage: you lose all structure. The fuzzer doesn't know that `amount_in` and `min_out` are related, or that certain account combinations are more interesting than others. ## Property testing with Proptest For unit-level property testing, proptest is excellent. It's not a fuzzer in the traditional sense. It generates random inputs based on your type definitions and runs property assertions. ```rust use proptest::prelude::*; // Define strategies for generating test data fn valid_swap_amount() -> impl Strategy { 1u64..=1_000_000_000_000u64 // 1 lamport to 1000 tokens } fn valid_fee_bps() -> impl Strategy { 1u16..=10000u16 // 0.01% to 100% } proptest! { #[test] fn swap_preserves_k_invariant( amount_in in valid_swap_amount(), fee_bps in valid_fee_bps(), reserve_a in 1_000_000u64..1_000_000_000_000u64, reserve_b in 1_000_000u64..1_000_000_000_000u64, ) { let k_before = (reserve_a as u128) * (reserve_b as u128); // Simulate swap math let fee = (amount_in as u128 * fee_bps as u128) / 10000; let amount_after_fee = amount_in as u128 - fee; let new_reserve_a = reserve_a as u128 + amount_after_fee; let amount_out = reserve_b as u128 - (k_before / new_reserve_a); let new_reserve_b = reserve_b as u128 - amount_out; let k_after = new_reserve_a * new_reserve_b; // k should never decrease (fees make it grow) prop_assert!( k_after >= k_before, "k decreased: before={}, after={}, amount_in={}, fee_bps={}", k_before, k_after, amount_in, fee_bps ); } #[test] fn add_liquidity_proportional( amount_a in 1_000u64..1_000_000_000u64, reserve_a in 1_000_000u64..1_000_000_000u64, reserve_b in 1_000_000u64..1_000_000_000u64, total_lp in 1_000_000u64..1_000_000_000u64, ) { // Adding liquidity should give proportional LP tokens let expected_ratio = amount_a as f64 / reserve_a as f64; let lp_minted = (total_lp as f64 * expected_ratio) as u64; // LP tokens should be > 0 for any non-zero deposit prop_assert!(lp_minted > 0 || amount_a < reserve_a / total_lp); } } ``` Proptest shines for testing pure math functions (swap calculations, fee computations, price conversions). You don't need the full Solana runtime for these. ## CPI testing: the blind spot Cross-program invocations are where most Solana bugs live. A program calls another program, trusting the return data. But what if the called program is malicious? What if it re-enters? Testing CPIs is hard because you need to simulate the full call stack: ```rust #[cfg(test)] mod cpi_tests { use super::*; use solana_program_test::*; #[tokio::test] async fn test_cpi_reentrancy_guard() { let mut test = ProgramTest::new( "token_swap", program_id(), processor!(process_instruction), ); // Deploy a malicious "token" program that re-enters test.add_program( "malicious_token", malicious_program_id(), processor!(malicious_process_instruction), ); let (mut banks, payer, blockhash) = test.start().await; // Set up accounts pointing to malicious token program // Then try to swap -- should fail, not re-enter let ix = create_swap_instruction_with_token_program( 1000, 0, &payer.pubkey(), &malicious_program_id(), // Using malicious token program ); let tx = Transaction::new_signed_with_payer( &[ix], Some(&payer.pubkey()), &[&payer], blockhash, ); // This should fail cleanly, not panic or allow reentrancy let result = banks.process_transaction(tx).await; assert!(result.is_err()); } } ``` ## Solana vs EVM fuzzing maturity Let's be honest about where things stand: | Aspect | EVM | Solana | |--------|-----|--------| | Purpose-built fuzzers | Echidna, Medusa, Foundry | Trident | | Maturity | 5+ years | ~2 years | | Property testing support | First-class | Manual setup | | Coverage tooling | Good | Limited | | Corpus management | Automatic | Basic | | Community resources | Extensive | Growing | | Multi-contract testing | Well supported | Painful | The EVM ecosystem has years of head start. Tools like Echidna and Medusa handle stateful fuzzing with multi-contract interactions out of the box. On Solana, you're still wiring a lot of this yourself. That said, Solana's type system (via Anchor's account constraints) prevents an entire class of bugs that plague EVM contracts. You can't accidentally call the wrong contract, and account ownership checks are enforced structurally. The bugs that remain are subtler: math errors, missing signer checks in non-Anchor programs, and logic flaws in CPI chains. ## Practical recommendations ## For Anchor programs Start with Trident. It has the lowest barrier to entry and understands your program structure. Here's the workflow: 1. `trident init` in your Anchor project 2. Define your instruction set in the generated harness 3. Invest time in proper account setup. This is where campaigns succeed or fail 4. Run for at least 4 hours on your first campaign 5. Check coverage reports to find unreached code ## For native Solana programs Use cargo-fuzz or honggfuzz-rs. Native programs don't have Anchor's IDL, so Trident can't auto-generate harnesses. You'll need to: 1. Write manual serialization/deserialization for instruction data 2. Set up `ProgramTest` environments for realistic state 3. Focus on the instruction parsing layer. That's where native programs have the most bugs ## For math-heavy logic Use proptest. Don't try to fuzz your AMM curve math through the full Solana runtime. Extract the pure functions and test them with property-based strategies: ```rust // Extract pure math for property testing pub fn calculate_swap_output( amount_in: u64, reserve_in: u64, reserve_out: u64, fee_bps: u16, ) -> Result { // This function has no Solana dependencies -- proptest it directly let fee = (amount_in as u128) .checked_mul(fee_bps as u128) .ok_or(SwapError::Overflow)? / 10000; let net_in = (amount_in as u128) - fee; let new_reserve_in = (reserve_in as u128) + net_in; let new_reserve_out = (reserve_in as u128) .checked_mul(reserve_out as u128) .ok_or(SwapError::Overflow)? / new_reserve_in; let output = (reserve_out as u128) - new_reserve_out; Ok(output as u64) } ``` ## What's next for Solana fuzzing The tooling is improving fast. Trident adds features every release. The property-based testing ecosystem in Rust is mature and directly applicable. And as more auditors move into the Solana space, expect better coverage tools, corpus management, and integration with CI/CD pipelines. The fundamentals of fuzzing apply universally. If you understand coverage-guided feedback loops, seed selection, and invariant design, you can fuzz Solana programs effectively today. The tools are rougher, but the techniques transfer directly. Get a Solana Security Review Try Recon Pro --- ### Fuzzing Rust smart contracts: CosmWasm, Solana, and cargo-fuzz **Author:** kn0t | **Published:** 2025-08-18 | **Reading time:** 14 min **Tags:** fuzzing, rust, solana, property-based-testing **URL:** https://getrecon.xyz/blog/rust-smart-contract-fuzzing-cosmwasm-solana # Fuzzing Rust Smart Contracts: CosmWasm, Solana, and cargo-fuzz **Author: kn0t** | Guide Rust's type system catches a lot of bugs at compile time. But "a lot" isn't "all." Integer overflows, logic errors, serialization mismatches, and incorrect state transitions slip through just fine. If you're building smart contracts in Rust — whether for CosmWasm, Solana, or anything else — fuzzing is how you find what the compiler can't. This guide covers the practical tools and patterns for fuzzing Rust-based smart contracts across ecosystems. ## cargo-fuzz and libFuzzer: the foundation Every Rust fuzzing setup builds on the same core: LLVM's libFuzzer, exposed through cargo-fuzz. It's coverage-guided, it's fast, and it works with any Rust code. ## Getting started ```bash # Install cargo-fuzz cargo install cargo-fuzz # Initialize fuzz targets in your project cd your-contract cargo fuzz init # This creates: # fuzz/ # ├── Cargo.toml # └── fuzz_targets/ # └── fuzz_target_1.rs ``` ## Writing your first fuzz target ```rust // fuzz/fuzz_targets/fuzz_target_1.rs #![no_main] use libfuzzer_sys::fuzz_target; use your_contract::msg::{ExecuteMsg, InstantiateMsg}; use cosmwasm_std::from_json; fuzz_target!(|data: &[u8]| { // Try to deserialize fuzzer bytes as an ExecuteMsg if let Ok(msg) = from_json::(data) { // If it parses, does our validation handle it correctly? let _ = validate_execute_msg(&msg); } }); ``` This is the simplest pattern, deserialize random bytes and see if your code panics. It's a good starting point but it's also pretty shallow. The fuzzer spends most of its time generating invalid JSON. ## Structured fuzzing with Arbitrary Better approach: use the `arbitrary` crate to generate structured inputs directly: ```rust use libfuzzer_sys::fuzz_target; use arbitrary::Arbitrary; #[derive(Arbitrary, Debug)] struct FuzzInput { deposit_amount: u128, withdraw_amount: u128, fee_rate: u16, time_delta: u64, } fuzz_target!(|input: FuzzInput| { // Now every input is structurally valid // The fuzzer can focus on finding edge cases in your logic if input.fee_rate > 10000 { return; } // Skip invalid fee rates if input.deposit_amount == 0 { return; } let mut state = ContractState::new(input.fee_rate); state.deposit(input.deposit_amount); // Simulate time passing state.advance_time(input.time_delta); // Try to withdraw -- should never underflow let result = state.withdraw(input.withdraw_amount); match result { Ok(withdrawn) => { assert!(withdrawn <= input.deposit_amount + state.accrued_interest()); } Err(_) => { // Errors are fine -- panics are not } } }); ``` ## CosmWasm-specific testing patterns CosmWasm has some of the best testing infrastructure in crypto. The multi-test framework lets you simulate an entire blockchain environment in Rust, and cw-orch takes it further with deployment orchestration. ## Multi-test for integration testing ```rust use cosmwasm_std::{Addr, Coin, Uint128}; use cw_multi_test::{App, ContractWrapper, Executor}; fn setup_app() -> (App, Addr) { let mut app = App::default(); // Store contract code let code = ContractWrapper::new( crate::contract::execute, crate::contract::instantiate, crate::contract::query, ); let code_id = app.store_code(Box::new(code)); // Instantiate let contract_addr = app .instantiate_contract( code_id, Addr::unchecked("creator"), &InstantiateMsg { admin: "admin".to_string(), fee_bps: 30, // 0.3% }, &[], "vault", None, ) .unwrap(); (app, contract_addr) } #[test] fn fuzz_deposit_withdraw_cycle() { let (mut app, contract) = setup_app(); // Use proptest or quickcheck for randomized inputs for i in 0..1000 { let amount = (i * 137 + 42) % 1_000_000 + 1; // Pseudo-random let user = format!("user_{}", i % 10); // Fund user app.init_modules(|router, _, storage| { router.bank.init_balance( storage, &Addr::unchecked(&user), vec![Coin::new(amount as u128, "uatom")], ).unwrap(); }); // Deposit let deposit_result = app.execute_contract( Addr::unchecked(&user), contract.clone(), &ExecuteMsg::Deposit {}, &[Coin::new(amount as u128, "uatom")], ); if deposit_result.is_ok() { // Query balance let balance: BalanceResponse = app .wrap() .query_wasm_smart( &contract, &QueryMsg::Balance { address: user.clone() }, ) .unwrap(); // Withdraw everything let withdraw_result = app.execute_contract( Addr::unchecked(&user), contract.clone(), &ExecuteMsg::Withdraw { amount: balance.amount }, &[], ); // If deposit succeeded, full withdrawal should succeed too assert!( withdraw_result.is_ok(), "Deposit succeeded but withdraw failed for amount {}", balance.amount ); } } } ``` ## Combining cargo-fuzz with multi-test This is where things get powerful. Use cargo-fuzz to drive the multi-test environment: ```rust #![no_main] use libfuzzer_sys::fuzz_target; use arbitrary::Arbitrary; use cosmwasm_std::{Addr, Coin, Uint128}; use cw_multi_test::{App, Executor}; #[derive(Arbitrary, Debug)] enum FuzzAction { Deposit { user_idx: u8, amount: u64 }, Withdraw { user_idx: u8, amount: u64 }, UpdateFee { new_fee: u16 }, AdvanceBlock { blocks: u32 }, } #[derive(Arbitrary, Debug)] struct FuzzSequence { actions: Vec, } fuzz_target!(|sequence: FuzzSequence| { if sequence.actions.len() > 50 { return; } let (mut app, contract) = setup_app(); let users: Vec = (0..5) .map(|i| format!("user_{}", i)) .collect(); // Track expected state for invariant checking let mut total_deposited: u128 = 0; for action in &sequence.actions { match action { FuzzAction::Deposit { user_idx, amount } => { let user = &users[(*user_idx as usize) % users.len()]; let amt = (*amount as u128) + 1; fund_user(&mut app, user, amt); if let Ok(_) = app.execute_contract( Addr::unchecked(user), contract.clone(), &ExecuteMsg::Deposit {}, &[Coin::new(amt, "uatom")], ) { total_deposited += amt; } } FuzzAction::Withdraw { user_idx, amount } => { let user = &users[(*user_idx as usize) % users.len()]; let _ = app.execute_contract( Addr::unchecked(user), contract.clone(), &ExecuteMsg::Withdraw { amount: Uint128::new(*amount as u128), }, &[], ); } FuzzAction::UpdateFee { new_fee } => { let fee = (*new_fee) % 10001; // Cap at 100% let _ = app.execute_contract( Addr::unchecked("admin"), contract.clone(), &ExecuteMsg::UpdateFee { fee_bps: fee }, &[], ); } FuzzAction::AdvanceBlock { blocks } => { let advance = (*blocks % 1000) + 1; app.update_block(|b| { b.height += advance as u64; b.time = b.time.plus_seconds(advance as u64 * 6); }); } } } // INVARIANT: contract balance should match tracked deposits minus withdrawals let contract_balance = app.wrap() .query_balance(&contract, "uatom") .unwrap(); // Contract should never hold more than was deposited // (fees might reduce it, but nothing should create tokens from thin air) assert!( contract_balance.amount.u128() <= total_deposited, "Contract holds more than was deposited! Has: {}, Deposited: {}", contract_balance.amount, total_deposited ); }); ``` This is the gold standard for CosmWasm fuzzing. You get structured, meaningful inputs driving a realistic multi-contract environment, with invariant checks after each sequence. ## Common Rust vulnerability patterns Rust prevents memory corruption, but smart contract bugs aren't about memory. Here's what to fuzz for: ## Integer overflow (yes, even in Rust) Rust panics on overflow in debug mode and wraps in release mode. Smart contracts often use `checked_*` or `saturating_*` arithmetic, but not always consistently: ```rust // Bug: this wraps in release mode if not caught fn calculate_shares(deposit: u128, total_supply: u128, total_assets: u128) -> u128 { if total_supply == 0 { return deposit; } // This can overflow if deposit * total_supply > u128::MAX deposit * total_supply / total_assets } // Fixed version fn calculate_shares_safe( deposit: u128, total_supply: u128, total_assets: u128, ) -> Result { if total_supply == 0 { return Ok(deposit); } // Use u256 intermediate or checked math let numerator = U256::from(deposit) * U256::from(total_supply); let shares = numerator / U256::from(total_assets); Ok(shares.as_u128()) } ``` Fuzz this by generating large numbers close to `u128::MAX` and checking that the function either returns a correct result or a proper error, never wraps silently. ## Serialization mismatches Borsh, JSON, Bincode, each serialization format has edge cases: ```rust #[derive(Serialize, Deserialize)] struct PoolState { pub reserve_a: u128, pub reserve_b: u128, pub lp_supply: u128, pub fee_bps: u16, // Watch out: field ordering matters for Borsh pub is_paused: bool, } // Fuzz target: serialize then deserialize and check round-trip fuzz_target!(|state: PoolState| { let bytes = borsh::to_vec(&state).unwrap(); let decoded: PoolState = borsh::from_slice(&bytes).unwrap(); assert_eq!(state.reserve_a, decoded.reserve_a); assert_eq!(state.reserve_b, decoded.reserve_b); assert_eq!(state.lp_supply, decoded.lp_supply); assert_eq!(state.fee_bps, decoded.fee_bps); assert_eq!(state.is_paused, decoded.is_paused); }); ``` This catches schema evolution bugs where you add a field and forget to update all serialization paths. ## Unsafe blocks Any `unsafe` in a smart contract is a red flag. Fuzz the boundaries around unsafe code aggressively: ```rust // If you MUST use unsafe (and you probably shouldn't) pub fn fast_copy(src: &[u8], dst: &mut [u8], len: usize) { assert!(len <= src.len() && len <= dst.len()); // MUST check bounds unsafe { std::ptr::copy_nonoverlapping( src.as_ptr(), dst.as_mut_ptr(), len ); } } // Fuzz it hard fuzz_target!(|data: &[u8]| { if data.len() < 4 { return; } let len = u16::from_le_bytes([data[0], data[1]]) as usize; let src = &data[2..]; let mut dst = vec![0u8; data.len()]; // This should never segfault or corrupt memory let _ = std::panic::catch_unwind(|| { fast_copy(src, &mut dst, len); }); }); ``` ## Proptest for property-based testing Proptest is the Rust-native property testing library. It's not coverage-guided like cargo-fuzz, but it generates structured inputs with built-in shrinking, when it finds a failure, it minimizes the input to the simplest case. ```rust use proptest::prelude::*; proptest! { #[test] fn deposit_then_withdraw_preserves_balance( deposit_amount in 1u128..=1_000_000_000_000u128, withdraw_pct in 1u64..=100u64, ) { let mut state = VaultState::new(); state.deposit("user1", deposit_amount).unwrap(); let withdraw_amount = deposit_amount * withdraw_pct as u128 / 100; if withdraw_amount > 0 { state.withdraw("user1", withdraw_amount).unwrap(); let remaining = state.balance_of("user1"); let expected = deposit_amount - withdraw_amount; // Account for possible rounding -- allow 1 unit tolerance prop_assert!( remaining >= expected.saturating_sub(1) && remaining <= expected + 1, "Balance mismatch: got {}, expected ~{}", remaining, expected ); } } } ``` Proptest's shrinking is incredibly useful. Instead of getting a failing input with amount = 340282366920938463463374607431768211455, it'll shrink it down to the minimal amount that triggers the bug, maybe amount = 2. That makes debugging much faster. ## Cross-ecosystem comparison ## CosmWasm - Best testing story of any Rust smart contract ecosystem - Multi-test gives you a full simulated chain in Rust - cw-orch adds deployment orchestration for complex multi-contract setups - Use cargo-fuzz + multi-test for the best coverage ## Solana - Trident for Anchor programs, cargo-fuzz for native - Account model makes harness setup painful - `solana-program-test` provides the runtime, but it's slower than CosmWasm's multi-test - Focus on CPI interactions and account validation ## General Rust - cargo-fuzz works everywhere - proptest for unit-level property testing - Use `#[cfg(fuzzing)]` to add instrumentation hooks your harness can use ## Practical Workflow Here's the workflow I follow for every Rust smart contract audit: 1. **Extract pure functions**, math, serialization, validation logic. Fuzz these directly with cargo-fuzz. No blockchain runtime needed. 2. **Write proptest properties**, for each function, what should always be true? Deposit/withdraw roundtrips, fee calculations, state transitions. 3. **Build integration harness**, use multi-test (CosmWasm) or ProgramTest (Solana) for full-stack fuzzing with structured inputs. 4. **Run for hours, not minutes**. Rust compilation is slow, but fuzzing execution is fast. Let it run. Coverage-guided fuzzing needs time to find deep bugs. 5. **Check coverage**, use `cargo-cov` or LLVM's source-based coverage to see what you're missing. Add harness code to reach uncovered branches. The fundamentals of fuzzing apply regardless of ecosystem. Coverage feedback, corpus management, property design, it's all the same theory, just different tools. If you're solid on the concepts, picking up a new ecosystem's toolchain is just a matter of reading docs for a few hours. Request a Rust Contract Audit Try Recon Pro --- ### Move smart contract security: testing patterns for Aptos and Sui **Author:** deivitto | **Published:** 2025-07-14 | **Reading time:** 14 min **Tags:** formal-verification, smart-contract-security, move, property-based-testing **URL:** https://getrecon.xyz/blog/move-smart-contract-security-testing # Move Smart Contract Security: Testing Patterns for Aptos and Sui **Author: deivitto** | Guide Move is different. If you're coming from Solidity or Rust, some of your instincts will be wrong. The type system does heavy lifting — no reentrancy, no raw pointer manipulation, no implicit type coercion. But Move introduces its own class of bugs that don't exist in other ecosystems. Let's walk through how to test for them. ## Why Move security is different Move was designed for digital assets. Its resource model means you can't accidentally duplicate or destroy tokens — the compiler literally won't let you. A `Coin` must go somewhere. You can't just forget about it. That's great. It kills entire vulnerability classes: - No reentrancy (the runtime prevents it) - No token duplication (linear types enforce this) - No storage collision (typed global storage) - No delegate call exploits (no concept exists) But Move has its own problems. Object ownership in Sui, capability patterns in Aptos, module upgrade mechanics, and the relationship between `public` and `public(friend)` visibility create attack surface that static analysis alone won't cover. ## The Move prover: formal verification built in Move is one of the few smart contract languages with a built-in formal verification tool. The Move Prover checks mathematical properties of your code, not just "does it compile" but "does it satisfy this specification." ## Writing prover specifications ```move module example::token_vault { use std::signer; use aptos_framework::coin; use aptos_framework::coin::Coin; struct Vault has key { balance: Coin, total_shares: u64, share_balance: table::Table, } public fun deposit( account: &signer, vault_addr: address, coins: Coin, ) acquires Vault { let vault = borrow_global_mut>(vault_addr); let deposit_amount = coin::value(&coins); // Calculate shares let shares = if (vault.total_shares == 0) { deposit_amount } else { (deposit_amount * vault.total_shares) / coin::value(&vault.balance) }; coin::merge(&mut vault.balance, coins); vault.total_shares = vault.total_shares + shares; let sender = signer::address_of(account); let current = table::borrow_mut_with_default( &mut vault.share_balance, sender, 0 ); *current = *current + shares; } // PROVER SPECIFICATION spec deposit { // Vault balance increases by exactly the deposited amount ensures coin::value( global>(vault_addr).balance ) == old(coin::value( global>(vault_addr).balance )) + coin::value(coins); // Total shares can never decrease on deposit ensures global>(vault_addr).total_shares >= old(global>(vault_addr).total_shares); // Depositor's shares increase ensures table::spec_get( global>(vault_addr).share_balance, signer::address_of(account) ) >= old(table::spec_get( global>(vault_addr).share_balance, signer::address_of(account) )); } // Global invariant: total_shares is the sum of all individual shares spec module { invariant forall vault_addr: address where exists>(vault_addr): global>(vault_addr).total_shares > 0 ==> coin::value(global>(vault_addr).balance) > 0; } } ``` Run it with: ```bash aptos move prove ``` The prover will either confirm your specs hold for ALL possible inputs or give you a concrete counterexample. That's stronger than any fuzzer, it's mathematical proof. But it only works for properties you can express in the specification language, and complex cross-module properties are hard to specify. ## Aptos testing patterns ## Unit testing Aptos has a built-in test framework. Every function annotated with `#[test]` runs in a simulated Move VM: ```move #[test_only] module example::vault_tests { use example::token_vault; use aptos_framework::coin; use aptos_framework::account; #[test(admin = @example, user1 = @0x42, user2 = @0x43)] fun test_deposit_withdraw_roundtrip( admin: &signer, user1: &signer, user2: &signer, ) { // Setup accounts account::create_account_for_test(signer::address_of(admin)); account::create_account_for_test(signer::address_of(user1)); account::create_account_for_test(signer::address_of(user2)); // Initialize vault token_vault::initialize(admin); // User1 deposits 1000 let coins = coin::mint_for_testing(1000); token_vault::deposit(user1, @example, coins); // User1 withdraws everything let withdrawn = token_vault::withdraw(user1, @example, 1000); assert!(coin::value(&withdrawn) == 1000, 0); coin::burn_for_testing(withdrawn); } #[test(admin = @example, attacker = @0x666)] #[expected_failure(abort_code = 0x50001)] // ENOT_AUTHORIZED fun test_unauthorized_admin_call( admin: &signer, attacker: &signer, ) { account::create_account_for_test(signer::address_of(admin)); account::create_account_for_test(signer::address_of(attacker)); token_vault::initialize(admin); // Attacker tries to call admin function -- should fail token_vault::update_fee(attacker, 500); } } ``` ## Testing capability abuse Capabilities in Aptos are powerful, they grant permission to perform privileged operations. If a capability leaks, anyone can use it: ```move module example::governance { struct AdminCap has key, store { can_pause: bool, can_upgrade: bool, } // DANGEROUS: This returns the capability -- caller can store it anywhere public fun get_admin_cap(account: &signer): AdminCap { // If this checks are wrong, the cap leaks assert!(signer::address_of(account) == @admin, ENOT_ADMIN); AdminCap { can_pause: true, can_upgrade: true } } } // Test that capability can't be obtained by non-admins #[test(fake_admin = @0x999)] #[expected_failure] fun test_cap_leak(fake_admin: &signer) { let cap = governance::get_admin_cap(fake_admin); // If we get here, the capability leaked // Must explicitly handle the cap since Move won't let us drop it governance::destroy_cap(cap); } ``` The key question: can a non-privileged account obtain or fabricate a capability? Test every code path that creates, transfers, or checks capabilities. ## Sui testing patterns Sui's object model introduces unique testing challenges. Objects have ownership, they can be owned by an address, shared, or immutable. Getting ownership wrong is a whole vulnerability class. ## Object ownership tests ```move module example::nft_market { use sui::object::{Self, UID}; use sui::transfer; use sui::tx_context::{Self, TxContext}; struct Listing has key { id: UID, nft_id: ID, price: u64, seller: address, } // Create a listing -- the NFT should be locked until sold or cancelled public fun create_listing( nft: NFT, price: u64, ctx: &mut TxContext, ) { let listing = Listing { id: object::new(ctx), nft_id: object::id(&nft), price, seller: tx_context::sender(ctx), }; // Transfer NFT to a shared object (escrow) // BUG POTENTIAL: if we transfer to seller instead, // they keep the NFT AND can sell it transfer::public_share_object(listing); transfer::public_transfer(nft, @escrow); } // Cancel listing -- only seller should be able to public fun cancel_listing( listing: Listing, ctx: &mut TxContext, ): NFT { let Listing { id, nft_id: _, price: _, seller } = listing; // CRITICAL CHECK: only seller can cancel assert!(seller == tx_context::sender(ctx), ENotSeller); object::delete(id); // Return the NFT to the seller // ... retrieve from escrow } } ``` ## Testing with sui's test framework ```move #[test_only] module example::market_tests { use sui::test_scenario::{Self as ts, Scenario}; use example::nft_market; #[test] fun test_only_seller_can_cancel() { let seller = @0x1; let buyer = @0x2; let attacker = @0x3; let mut scenario = ts::begin(seller); // Seller creates listing ts::next_tx(&mut scenario, seller); { let nft = mint_test_nft(ts::ctx(&mut scenario)); nft_market::create_listing(nft, 1000, ts::ctx(&mut scenario)); }; // Attacker tries to cancel -- should fail ts::next_tx(&mut scenario, attacker); { let listing = ts::take_shared(&scenario); // This should abort with ENotSeller // In test, we'd use expected_failure }; ts::end(scenario); } #[test] fun test_double_spend_prevention() { let seller = @0x1; let buyer1 = @0x2; let buyer2 = @0x3; let mut scenario = ts::begin(seller); // Create listing ts::next_tx(&mut scenario, seller); { let nft = mint_test_nft(ts::ctx(&mut scenario)); nft_market::create_listing(nft, 1000, ts::ctx(&mut scenario)); }; // Buyer1 purchases ts::next_tx(&mut scenario, buyer1); { let listing = ts::take_shared(&scenario); let payment = coin::mint_for_testing( 1000, ts::ctx(&mut scenario) ); nft_market::purchase(listing, payment, ts::ctx(&mut scenario)); }; // Buyer2 tries to purchase same listing -- should fail // because the listing object was consumed in the previous tx // Sui's object model prevents this at the runtime level // but we test it anyway to make sure our logic is correct ts::end(scenario); } } ``` ## Common Move vulnerability patterns ## 1. module upgrade risks Both Aptos and Sui allow module upgrades. This is a huge attack surface: ```move // A module can be upgraded to change behavior // But upgraded code can access existing resources // If your module stores a Treasury capability, // a malicious upgrade could drain it module example::treasury { struct Treasury has key { id: UID, balance: Balance, // An upgrade could add a function that drains this } // FIX: Make the module immutable after deployment // Or use upgrade policies that restrict what can change } ``` Test upgrade scenarios: what happens if a dependency module upgrades? Does your module's security still hold? ## 2. phantom type confusion Move's phantom types are compile-time only. They don't exist at runtime: ```move struct Pool has key { id: UID, reserve_x: Balance, reserve_y: Balance, } // BUG: If you don't verify type parameters correctly, // someone might create Pool // and exploit identical type parameters public fun swap( pool: &mut Pool, input: Coin, ctx: &mut TxContext, ): Coin { // Need to assert X != Y here, or the math breaks // The compiler won't catch this for you } ``` ## 3. missing signer checks The signer parameter in Move is how you verify authorization. Missing it is like having a Solidity function without `onlyOwner`: ```move // VULNERABLE: Anyone can call this public fun set_oracle_price( oracle: &mut Oracle, new_price: u64, ) { oracle.price = new_price; } // FIXED: Requires the oracle admin's signature public fun set_oracle_price( admin: &signer, oracle: &mut Oracle, new_price: u64, ) { assert!(signer::address_of(admin) == oracle.admin, ENOT_ADMIN); oracle.price = new_price; } ``` ## 4. flash loan in object model Sui's object model creates an interesting flash loan variant. If a shared object can be borrowed and returned within a single transaction: ```move // Hot potato pattern -- must be consumed in same transaction struct FlashLoanReceipt { pool_id: ID, amount: u64, // No 'drop' ability -- MUST be consumed } public fun flash_borrow( pool: &mut Pool, amount: u64, ctx: &mut TxContext, ): (Coin, FlashLoanReceipt) { let coins = balance::split(&mut pool.balance, amount); let receipt = FlashLoanReceipt { pool_id: object::id(pool), amount, }; (coin::from_balance(coins, ctx), receipt) } public fun flash_repay( pool: &mut Pool, payment: Coin, receipt: FlashLoanReceipt, ) { let FlashLoanReceipt { pool_id, amount } = receipt; assert!(object::id(pool) == pool_id, EWrongPool); // BUG: Should check payment >= amount + fee assert!(coin::value(&payment) >= amount, EInsufficientRepayment); balance::join(&mut pool.balance, coin::into_balance(payment)); } ``` The hot potato pattern (no `drop` ability) forces the receipt to be consumed, but you still need to verify the repayment amount. Test with exact boundary values. ## How move's type system helps (and where it doesn't) ## What Move prevents that EVM doesn't: - Reentrancy, the VM simply doesn't allow it - Token duplication, linear types mean you can't copy a Coin - Storage collision, each resource type has its own global storage - Unchecked external calls, no `call` opcode, just typed function calls ## What Move doesn't prevent: - Logic errors, wrong math is wrong math in any language - Access control mistakes, you still have to check signers correctly - Economic attacks, flash loans, oracle manipulation, sandwich attacks all work - Upgrade attacks, module upgrades can change behavior of existing resources - Phantom type confusion, compile-time types don't guarantee runtime safety The bottom line: Move contracts need fewer tests for low-level memory/reentrancy issues, but the same rigor on business logic, access control, and economic invariants. ## Putting it all together For smart contract security on Move: 1. **Write prover specs first.** Before you write tests, spec your invariants. The Move Prover catches bugs no amount of testing would find. 2. **Unit test every access control path.** Test that authorized users can act and unauthorized users can't. Every function, every role. 3. **Test object ownership transitions.** Especially on Sui, objects moving between owned/shared/immutable states need thorough coverage. 4. **Test upgrade scenarios.** What happens when your module or a dependency upgrades? Write tests that simulate the upgrade path. 5. **Don't trust the type system blindly.** It prevents whole classes of bugs, but the bugs that remain are the subtle, logic-level ones that only careful testing catches. Move is a better language for digital assets than Solidity. Full stop. But "better" doesn't mean "safe by default." The type system is your first line of defense. Testing and verification are your second and third. Use all three. Get a Move Security Audit Try Recon Pro --- ### Cross-chain bridge security: invariants that would have caught the big hacks **Author:** alex | **Published:** 2025-07-07 | **Reading time:** 16 min **Tags:** invariant-testing, smart-contract-security, cross-chain, fuzzing **URL:** https://getrecon.xyz/blog/cross-chain-bridge-fuzzing-invariants # Cross-Chain Bridge Security: Invariants That Would Have Caught the Big Hacks **Author: alex** | Deep-Dive Over $2 billion stolen from cross-chain bridges between 2021 and 2023. Ronin, Wormhole, Nomad, Poly Network — each one a catastrophic failure. And each one violated a specific, testable invariant that a fuzzing campaign could have caught. This isn't hindsight bias. These invariants are straightforward. They're the kind of properties any security team should test before going to mainnet. Let's walk through each hack, extract the violated invariant, and write the Solidity property that would have caught it. ## The core bridge invariants Before we look at individual hacks, here are the five properties that every bridge must satisfy. Break any one of them, and you lose funds. 1. **Token supply conservation** — tokens minted on the destination chain must exactly match tokens locked on the source chain 2. **Message integrity**, a message received on the destination must be identical to the message sent from the source 3. **Validator set integrity**, only authorized validators can sign messages, and the validator set can only change through legitimate governance 4. **Replay protection**, every message can be processed exactly once 5. **Upgrade safety**, contract upgrades must preserve all invariants above Now let's see how the biggest hacks map to these. ## Ronin bridge ($625M), validator set integrity ## What happened Ronin used a 9-of-9 multi-sig for bridge validation, later changed to 5-of-9. The attacker compromised 5 validators, 4 from Sky Mavis (the Ronin operator) plus 1 from Axie DAO that had been granted temporary signing permission but never revoked. The breach went undetected for 6 days. Nobody noticed because there was no monitoring that checked validator behavior against expected patterns. ## The violated invariant **Validator set changes must go through governance, and temporary permissions must expire.** ```solidity contract RoninBridgeHarness { // Ghost state: track all validator additions and removals mapping(address => uint256) public ghost_validatorAddedAt; mapping(address => uint256) public ghost_validatorExpiry; uint256 public ghost_validatorCount; // INVARIANT: Active validator count should match governance records function invariant_validator_set_integrity() public { address[] memory activeValidators = bridge.getValidators(); for (uint i = 0; i < activeValidators.length; i++) { address v = activeValidators[i]; // Every active validator must have been added through governance assert( ghost_validatorAddedAt[v] > 0, "Validator active but never added through governance" ); // If a validator has an expiry, it must not be past if (ghost_validatorExpiry[v] > 0) { assert( block.timestamp < ghost_validatorExpiry[v], "Expired validator still active" ); } } // Validator count should match expected assert( activeValidators.length == ghost_validatorCount, "Validator count mismatch" ); } // INVARIANT: Threshold should scale with validator count function invariant_threshold_ratio() public { uint256 threshold = bridge.requiredSignatures(); uint256 total = bridge.getValidators().length; // Threshold should be > 2/3 of total validators assert( threshold * 3 > total * 2, "Threshold too low relative to validator count" ); } } ``` If Ronin had run this invariant continuously, the expired Axie DAO validator permission would have tripped it immediately. And the threshold check would have flagged the 5-of-9 change as dangerously close to the minimum. ## Wormhole ($326M), message integrity / signature verification ## What happened Wormhole's Solana-side contract had a bug in its signature verification. The `verify_signatures` instruction used `solana_program::sysvar::instructions` to check that the previous instruction was a valid `secp256k1_recover` call. But the attacker used a different system program address that wasn't actually the secp256k1 program, and the contract didn't verify this. The attacker forged a guardian set upgrade message, added their own guardian, and then signed fraudulent mint messages. ## The violated invariant Every message accepted by the bridge must have valid signatures from the current guardian set. ```solidity contract WormholeBridgeHarness { // Ghost state: track all messages and their verification status mapping(bytes32 => bool) public ghost_properlyVerified; mapping(bytes32 => uint256) public ghost_signerCount; // INVARIANT: No message should be processed without proper verification function invariant_message_verification() public { // After any mint/transfer operation, check that the triggering // message was verified against the ACTUAL guardian set bytes32 lastProcessedMsg = bridge.lastProcessedMessageHash(); if (lastProcessedMsg != bytes32(0)) { // The message must have been verified assert( ghost_properlyVerified[lastProcessedMsg], "Message processed without proper verification" ); // Verification must have used the current guardian set uint256 currentGuardianCount = bridge.getGuardianSet() .keys.length; uint256 requiredSigs = (currentGuardianCount * 2) / 3 + 1; assert( ghost_signerCount[lastProcessedMsg] >= requiredSigs, "Insufficient signatures on processed message" ); } } // INVARIANT: Guardian set changes must be properly authorized function invariant_guardian_set_monotonic() public { uint32 currentIndex = bridge.getCurrentGuardianSetIndex(); // Guardian set index should only increment by 1 assert( currentIndex == ghost_previousGuardianSetIndex || currentIndex == ghost_previousGuardianSetIndex + 1, "Guardian set index jumped unexpectedly" ); ghost_previousGuardianSetIndex = currentIndex; } } ``` The real fix here is deeper, the Solana program needed to verify the *program ID* of the instruction it was checking, not just the instruction data. But the invariant above catches the *effect*: a message got processed without legitimate guardian signatures. ## Nomad bridge ($190M), message integrity / zero-Value root ## What happened This was the most embarrassing one. Nomad's upgrade initialized the trusted root to `0x00`. In their Merkle proof verification, a zero root made every message automatically "proven", because the default storage value for uninitialized mappings is also zero. The exploit was so simple that random people copied the transaction calldata and just changed the recipient address. It became a free-for-all. ## The violated invariant The trusted root must never be zero, and proof verification must reject default/uninitialized values. ```solidity contract NomadBridgeHarness { // INVARIANT: Trusted root must never be zero function invariant_nonzero_root() public { bytes32 root = bridge.committedRoot(); assert( root != bytes32(0), "Committed root is zero -- all proofs will pass" ); } // INVARIANT: Only messages with valid Merkle proofs should process function invariant_proof_verification( bytes32 leaf, bytes32[] memory proof, uint256 index ) public { // Attempt to verify a random proof bool accepted = bridge.proveAndProcess(leaf, proof, index); if (accepted) { // If accepted, the leaf must actually be in the committed tree assert( ghost_committedLeaves[leaf], "Message accepted but was never committed to the tree" ); } } // INVARIANT: After any upgrade, all critical values must be non-default function invariant_post_upgrade_sanity() public { // Check that initialization didn't leave anything at defaults assert( bridge.committedRoot() != bytes32(0), "Root is zero after upgrade" ); assert( bridge.owner() != address(0), "Owner is zero after upgrade" ); assert( bridge.messageCount() == ghost_expectedMessageCount, "Message count changed during upgrade" ); } } ``` The `invariant_nonzero_root` check is almost trivially simple. A single assertion would have caught this before deployment. The Nomad team did have tests, but they didn't test the upgrade path, they tested the logic assuming correct initialization. ## Poly network ($611M), access control / cross-Chain governance ## What happened Poly Network's bridge had a cross-chain message handler that could call *any* contract with *any* data. The attacker sent a cross-chain message that called the EthCrossChainData contract to change the keeper (validator) public keys to their own. After that, they could sign any withdrawal. The fundamental problem: the bridge's message relay could modify its own governance parameters. ## The violated invariant Cross-chain messages must not be able to modify bridge governance state. ```solidity contract PolyBridgeHarness { // Snapshot governance state before cross-chain message processing address[] public ghost_keepersBefore; address public ghost_ownerBefore; uint256 public ghost_thresholdBefore; function before_processMessage() internal { ghost_keepersBefore = bridge.getKeepers(); ghost_ownerBefore = bridge.owner(); ghost_thresholdBefore = bridge.threshold(); } // INVARIANT: Processing a cross-chain message must NOT change // governance parameters function invariant_governance_immutable_during_relay() public { address[] memory keepersAfter = bridge.getKeepers(); // Keepers should not change from cross-chain messages assert( keepersAfter.length == ghost_keepersBefore.length, "Keeper count changed during message relay" ); for (uint i = 0; i < keepersAfter.length; i++) { assert( keepersAfter[i] == ghost_keepersBefore[i], "Keeper changed during message relay" ); } // Owner should not change assert( bridge.owner() == ghost_ownerBefore, "Owner changed during message relay" ); // Threshold should not change assert( bridge.threshold() == ghost_thresholdBefore, "Threshold changed during message relay" ); } // INVARIANT: Cross-chain relay target must be whitelisted function invariant_relay_target_whitelist() public { // The relay function should never call governance contracts address lastTarget = ghost_lastRelayTarget; assert( lastTarget != address(bridge.ethCrossChainData()), "Relay targeted governance data contract" ); assert( lastTarget != address(bridge), "Relay targeted bridge itself" ); } } ``` The relay target whitelist is the critical one. If the cross-chain message handler can call the bridge's own governance contract, you've got a self-destruct button that anyone with a valid cross-chain message format can press. ## Token supply conservation: the universal invariant Across all bridge hacks, one invariant is universal: tokens locked on Chain A must equal tokens minted on Chain B. If this breaks, someone's getting free money. ```solidity contract BridgeSupplyHarness { // Track all lock and mint events uint256 public ghost_totalLocked; uint256 public ghost_totalMinted; uint256 public ghost_totalBurned; uint256 public ghost_totalUnlocked; function handler_lock(uint256 amount) external { bridge.lock(amount); ghost_totalLocked += amount; } function handler_mint(bytes calldata proof) external { uint256 balanceBefore = bridgeToken.totalSupply(); bridge.mint(proof); uint256 minted = bridgeToken.totalSupply() - balanceBefore; ghost_totalMinted += minted; } function handler_burn(uint256 amount) external { bridge.burn(amount); ghost_totalBurned += amount; } function handler_unlock(bytes calldata proof) external { uint256 balanceBefore = token.balanceOf(address(bridge)); bridge.unlock(proof); uint256 unlocked = balanceBefore - token.balanceOf(address(bridge)); ghost_totalUnlocked += unlocked; } // INVARIANT: Supply conservation function invariant_supply_conservation() public { // Minted on destination should never exceed locked on source assert( ghost_totalMinted <= ghost_totalLocked, "More tokens minted than locked" ); // Unlocked on source should never exceed burned on destination assert( ghost_totalUnlocked <= ghost_totalBurned, "More tokens unlocked than burned" ); // Net bridge balance: locked - unlocked should equal // the bridge contract's actual token balance uint256 expectedBalance = ghost_totalLocked - ghost_totalUnlocked; uint256 actualBalance = token.balanceOf(address(bridge)); assert( actualBalance >= expectedBalance, "Bridge balance less than expected -- tokens leaked" ); } // INVARIANT: No single transfer should exceed reasonable limits function invariant_transfer_bounds() public { uint256 lastTransfer = ghost_lastTransferAmount; uint256 totalLiquidity = token.balanceOf(address(bridge)); // No single transfer should drain more than 10% of bridge TVL // (adjust threshold per protocol) if (totalLiquidity > 0) { assert( lastTransfer <= totalLiquidity / 10, "Single transfer exceeds safety threshold" ); } } } ``` ## Replay protection: don't process the same message twice Simple but critical. Every bridge message needs a unique identifier, and that identifier must be marked as consumed after processing: ```solidity contract ReplayProtectionHarness { mapping(bytes32 => uint256) public ghost_messageProcessCount; function handler_processMessage(bytes calldata message) external { bytes32 messageId = keccak256(message); bridge.processMessage(message); ghost_messageProcessCount[messageId]++; } // INVARIANT: Every message ID should be processed at most once function invariant_no_replay() public { // After processing, check the last message bytes32 lastId = ghost_lastProcessedMessageId; assert( ghost_messageProcessCount[lastId] <= 1, "Message replayed" ); } // INVARIANT: Nonces should be strictly increasing per source chain function invariant_nonce_monotonic() public { uint256 currentNonce = bridge.inboundNonce(ghost_sourceChainId); assert( currentNonce >= ghost_previousNonce[ghost_sourceChainId], "Nonce went backwards" ); ghost_previousNonce[ghost_sourceChainId] = currentNonce; } } ``` ## Applying these invariants in practice You don't need to build all of this from scratch. The pattern is consistent: 1. Identify the trust assumptions, what does the bridge assume about validators, messages, and state? 2. Write ghost variables that track expected state alongside actual state 3. Assert conservation laws at every state transition 4. Fuzz the boundaries, what happens at max values, zero values, concurrent operations? For EVM bridges, set up your harness using invariant testing patterns and run with Echidna or Medusa. A multi-day fuzzing campaign with the invariants above would have caught every hack we discussed. For more on smart contract security fundamentals and how fuzzing fits into the broader testing strategy, check those guides. ## The uncomfortable truth None of these invariants are complicated. Token supply conservation is arithmetic. Validator set integrity is set membership. Replay protection is a boolean flag. These are first-week invariant testing exercises. The bridges that got hacked weren't short on engineering talent. They were short on systematic testing. They had unit tests. They had audits. What they didn't have was a fuzzer running these properties against realistic state transitions for days at a time. The $2 billion question isn't whether these invariants work. It's why teams skip them. Get a Bridge Security Review Try Recon Pro --- ### AMM and DEX invariant testing: properties every swap protocol needs **Author:** nican0r | **Published:** 2025-12-22 | **Reading time:** 14 min **Tags:** invariant-testing, fuzzing, defi-security, property-based-testing **URL:** https://getrecon.xyz/blog/amm-dex-invariant-testing-properties # AMM and DEX Invariant Testing: Properties Every Swap Protocol Needs *By nican0r* If you're building or auditing an AMM, you already know the math matters. But knowing the math and *proving* it holds under adversarial conditions are two very different things. I've seen constant-product implementations that look perfect in unit tests blow up the moment a fuzzer throws unexpected sequences at them. This guide covers the full property suite you need for AMM/DEX protocols — from the basics like `x * y = k` all the way to sandwich resistance and concentrated liquidity edge cases. We'll write real [invariant tests](/learn/invariant-testing) you can drop into your test suite today. ## The constant product invariant: it's not as simple as you think Everyone knows the formula. `x * y = k` after fees. But here's what trips people up: `k` should only ever increase (from fees) or stay the same. It should never decrease unless liquidity is removed. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {Test} from "forge-std/Test.sol"; contract AMMInvariantTest is Test { AMMPool pool; AMMHandler handler; function setUp() public { pool = new AMMPool(); handler = new AMMHandler(pool); targetContract(address(handler)); } /// @notice k must never decrease after swaps function invariant_constant_product_never_decreases() public view { uint256 reserveX = pool.reserveX(); uint256 reserveY = pool.reserveY(); uint256 currentK = reserveX * reserveY; assertGe(currentK, handler.lastRecordedK(), "k decreased after swap"); } /// @notice Total supply of LP tokens tracks actual liquidity function invariant_lp_shares_backed() public view { uint256 reserveX = pool.reserveX(); uint256 reserveY = pool.reserveY(); if (pool.totalSupply() == 0) { assertEq(reserveX, 0, "Reserves exist without LP tokens"); assertEq(reserveY, 0, "Reserves exist without LP tokens"); } else { assertGt(reserveX, 0, "LP tokens exist without reserves"); assertGt(reserveY, 0, "LP tokens exist without reserves"); } } } ``` The test above catches a surprisingly common bug: implementations that accidentally let `k` shrink due to rounding in the swap math. If you've read about [rounding errors in DeFi](/blog/rounding-errors-defi-small-bugs-big-exploits), you know these tiny precision losses compound fast. ## The no-free-tokens property This one's non-negotiable. No sequence of actions should let a user extract more tokens than they deposited plus any legitimately earned fees. Sounds obvious, right? You'd be surprised how often [fuzzing](/learn/fuzzing) catches violations. ```solidity /// @notice No user can extract value without providing value function invariant_no_free_tokens() public view { uint256 totalTokenXInPool = tokenX.balanceOf(address(pool)); uint256 totalTokenYInPool = tokenY.balanceOf(address(pool)); // Pool's actual balances must match or exceed tracked reserves assertGe( totalTokenXInPool, pool.reserveX(), "Pool tokenX balance less than tracked reserve" ); assertGe( totalTokenYInPool, pool.reserveY(), "Pool tokenY balance less than tracked reserve" ); } /// @notice Sum of all LP shares equals totalSupply function invariant_lp_share_accounting() public view { uint256 trackedShares = handler.sumOfAllUserShares(); assertEq( trackedShares, pool.totalSupply(), "Ghost variable share sum != totalSupply" ); } ``` The ghost variable pattern here is key. Your handler tracks every user's LP balance in a side mapping, and the invariant verifies the pool's internal accounting matches. This catches double-mint bugs and incorrect burn calculations that unit tests almost never find. ## Fee accounting properties Fees are where AMM bugs love to hide. The properties you need: 1. Fee collection monotonicity: accumulated fees never decrease 2. Fee bounds: actual fee charged falls within the configured range 3. Fee distribution proportionality: LPs get fees proportional to their share ```solidity /// @notice Fees collected must be monotonically increasing function invariant_fees_monotonic() public view { assertGe( pool.accumulatedFeesX(), handler.previousFeesX(), "FeesX decreased" ); assertGe( pool.accumulatedFeesY(), handler.previousFeesY(), "FeesY decreased" ); } /// @notice Every swap must charge fee within configured bounds /// (Checked per-call in handler, not as a global invariant) function assertFeeInBounds( uint256 amountIn, uint256 feeCharged ) internal view { uint256 expectedMinFee = (amountIn * pool.feeRate()) / 10000; // Allow 1 wei tolerance for rounding assertGe( feeCharged + 1, expectedMinFee, "Fee charged below minimum" ); assertLe( feeCharged, expectedMinFee + 1, "Fee charged above maximum" ); } ``` ## Liquidity provider share accounting LPs need guarantees. When you add liquidity, you get shares proportional to your contribution. When you remove it, you get tokens proportional to your shares. Any deviation is a bug. ```solidity /// @notice Adding then removing liquidity should return ≥ original amount minus fees function test_liquidity_round_trip( uint256 amountX, uint256 amountY ) public { amountX = bound(amountX, 1e18, 1e24); amountY = bound(amountY, 1e18, 1e24); uint256 beforeX = tokenX.balanceOf(address(this)); uint256 beforeY = tokenY.balanceOf(address(this)); uint256 shares = pool.addLiquidity(amountX, amountY); (uint256 returnedX, uint256 returnedY) = pool.removeLiquidity(shares); // Should get back at least 99.9% (accounting for rounding) assertGe(returnedX, (amountX * 999) / 1000, "Lost too much tokenX"); assertGe(returnedY, (amountY * 999) / 1000, "Lost too much tokenY"); } ``` That 0.1% tolerance handles legitimate rounding. If you're losing more than that on a round trip with no swaps in between, something's wrong with your mint/burn math. ## Slippage bounds and price impact Slippage properties protect users from getting wrecked on large swaps. The key property: actual execution price must fall within the slippage tolerance the user specified. ```solidity /// @notice Swap output must respect minimum output (slippage bound) function test_slippage_respected( uint256 amountIn, uint256 minOut ) public { amountIn = bound(amountIn, 1e15, pool.reserveX() / 10); uint256 expectedOut = pool.getAmountOut(amountIn); minOut = bound(minOut, 1, expectedOut); uint256 actualOut = pool.swap( address(tokenX), amountIn, minOut ); assertGe(actualOut, minOut, "Slippage bound violated"); } /// @notice Price impact must be proportional to trade size function invariant_price_impact_bounded() public view { // After any swap, the price shouldn't move more than // the proportion of reserves consumed uint256 priceRatio = (pool.reserveX() * 1e18) / pool.reserveY(); uint256 deviation = priceRatio > handler.initialPriceRatio() ? priceRatio - handler.initialPriceRatio() : handler.initialPriceRatio() - priceRatio; // Price can't deviate more than the total % of reserves swapped assertLe( deviation, handler.totalVolumeAsPercentOfReserves(), "Price impact exceeds theoretical maximum" ); } ``` ## Sandwich resistance properties [Sandwich attacks](/learn/oracle-manipulation) are the bane of on-chain trading. While you can't fully prevent them at the AMM level, you can write properties that verify your protocol's defenses work. ```solidity /// @notice A frontrun-swap-backrun sequence shouldn't profit the attacker /// more than the fee they paid function test_sandwich_resistance( uint256 victimAmount, uint256 attackerAmount ) public { victimAmount = bound(victimAmount, 1e18, pool.reserveX() / 20); attackerAmount = bound(attackerAmount, 1e18, pool.reserveX() / 10); uint256 attackerBalanceBefore = tokenY.balanceOf(attacker); // Attacker frontrun vm.prank(attacker); pool.swap(address(tokenX), attackerAmount, 0); // Victim swap vm.prank(victim); pool.swap(address(tokenX), victimAmount, 0); // Attacker backrun - swap back vm.prank(attacker); uint256 tokenXBack = pool.swap( address(tokenY), tokenY.balanceOf(attacker) - attackerBalanceBefore, 0 ); // Attacker shouldn't profit after fees assertLe( tokenXBack, attackerAmount, "Sandwich attack profitable after fees" ); } ``` This property won't hold for all AMM designs — concentrated liquidity pools with tight ranges can actually make sandwiching more profitable. Which brings us to... ## Concentrated liquidity: how properties change for V3-style AMMs Concentrated liquidity changes everything about your property suite. The constant product formula still applies, but only *within each tick range*. Here's what you need to add: ## Tick-level properties ```solidity /// @notice Liquidity must be consistent across tick boundaries function invariant_tick_liquidity_consistency() public view { int24 currentTick = pool.currentTick(); uint128 activeLiquidity = pool.liquidity(); // Sum of all positions overlapping current tick must equal active liquidity uint128 sumOfPositions = handler.sumLiquidityAtTick(currentTick); assertEq( activeLiquidity, sumOfPositions, "Active liquidity mismatch at current tick" ); } /// @notice Crossing a tick must update liquidity by exactly the tick's net value function invariant_tick_crossing_correctness() public view { // After each swap that crosses a tick, verify: // new_liquidity = old_liquidity + tick.liquidityNet if (handler.lastSwapCrossedTick()) { int24 crossedTick = handler.lastCrossedTick(); int128 liquidityNet = pool.ticks(crossedTick).liquidityNet; assertEq( int128(pool.liquidity()), int128(handler.liquidityBeforeCross()) + liquidityNet, "Tick crossing liquidity update incorrect" ); } } ``` ## Position range properties For V3-style pools, each LP position has a range. Properties must verify: - Range boundaries are valid: lower tick < upper tick, both on valid tick spacing - Fees accrue only within range: out-of-range positions don't earn fees - Fee growth tracking is correct: feeGrowthInside calculations match actual fees - Position liquidity sums to tick liquidity: no liquidity appears from nowhere ```solidity /// @notice Out-of-range positions must not earn fees function invariant_no_fees_outside_range() public view { for (uint256 i = 0; i < handler.positionCount(); i++) { Position memory pos = handler.getPosition(i); int24 currentTick = pool.currentTick(); if (currentTick < pos.tickLower || currentTick >= pos.tickUpper) { // Position is out of range, fees shouldn't have changed assertEq( pos.currentFees, pos.feesAtLastCheck, "Out-of-range position earned fees" ); } } } ``` ## Building your handler The handler is where the magic happens. It's the contract that [the fuzzer](/learn/fuzzing) calls to interact with your AMM. A good handler for AMM testing needs these actions: 1. swap, both directions, random amounts 2. addLiquidity, random amounts (and random ranges for V3) 3. removeLiquidity, random shares/positions 4. collectFees, for V3-style pools 5. donate, if supported, to test fee distribution Each action should update ghost variables that your invariants check against. Track cumulative volumes along with fee totals and the last-known `k` value. ## Common bugs these properties catch After running these property suites across dozens of AMM forks, here's what comes up most: 1. Rounding in swap math. The output calculation rounds in the wrong direction, letting users extract 1 wei per swap. Over millions of swaps, this drains the pool. The `k` monotonicity invariant catches this instantly. 2. First depositor attack. The first LP can manipulate the initial price ratio to steal from subsequent depositors. The no-free-tokens property catches this when the fuzzer tries deposit sequences. 3. Fee-on-transfer token handling. Pool assumes it received the full `amountIn` but the actual balance increased by less. The reserve-vs-balance invariant catches this. 4. LP share inflation. Minting shares without proportional deposits. The share accounting invariant catches it. If you want to understand [why invariant testing matters for DeFi security](/blog/why-invariant-testing-matters-defi-security), these bugs are exactly why. Unit tests check happy paths. Invariant tests check every path. ## Putting it all together Start with the three core invariants: constant product monotonicity, no-free-tokens, and LP share accounting. These catch 80% of AMM bugs. Then add fee accounting and slippage bounds. Finally, if you're doing concentrated liquidity, add the tick-level properties. Don't try to write all properties on day one. Build them incrementally, run the fuzzer after each addition, and fix what breaks. Your [coverage-guided fuzzer](/learn/coverage-guided-fuzzing) will find the interesting paths. Your job is to define what "correct" means. For a deeper look at property design patterns across DeFi protocols, check out [property design patterns for DeFi lending](/blog/property-design-patterns-defi-lending). And if you haven't written your first invariant test yet, start with [how to write your first invariant test](/blog/how-to-write-first-invariant-test). --- Want a professional property suite for your AMM protocol? Request an Audit or Try Recon Pro to generate invariant tests automatically. --- ### Staking and rewards distribution: fuzzing the accounting protocols get wrong **Author:** nican0r | **Published:** 2026-02-23 | **Reading time:** 14 min **Tags:** invariant-testing, fuzzing, defi-security, staking **URL:** https://getrecon.xyz/blog/staking-rewards-fuzzing-properties # Staking and Rewards Distribution: Fuzzing the Accounting Protocols Get Wrong *By nican0r* Staking contracts look simple. Users deposit tokens, time passes, rewards accrue. But under the hood, the math is a minefield. I've lost count of how many staking implementations I've fuzzed that had reward calculation bugs — and most of them passed thorough unit test suites with flying colors. The problem isn't that developers don't understand the math. It's that the math interacts with time, ordering, and precision in ways that are almost impossible to reason about manually. That's exactly why [invariant testing](/learn/invariant-testing) exists. Let's build a complete property suite for staking and rewards protocols. ## The reward-per-token accumulator pattern Almost every staking protocol uses the "reward-per-token" accumulator pattern (popularized by Synthetix). The idea: instead of tracking each user's rewards individually, you maintain a global accumulator that increases over time. Each user tracks their snapshot of it. Here's the bug magnet: the accumulator update involves division. In Solidity, division truncates, so multiply-then-divide vs divide-then-multiply gives different results. This is where [rounding errors become real exploits](/blog/rounding-errors-defi-small-bugs-big-exploits). ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {Test} from "forge-std/Test.sol"; contract StakingInvariantTest is Test { StakingPool pool; StakingHandler handler; function setUp() public { pool = new StakingPool(rewardToken, stakingToken); handler = new StakingHandler(pool); targetContract(address(handler)); } /// @notice Total rewards claimed + pending must never exceed total distributed function invariant_rewards_conservation() public view { uint256 totalClaimed = handler.totalRewardsClaimed(); uint256 totalPending = handler.sumOfAllPendingRewards(); uint256 totalDistributed = pool.totalRewardsDistributed(); assertLe( totalClaimed + totalPending, totalDistributed + handler.numUsers(), // 1 wei rounding tolerance per user "More rewards claimed than distributed" ); } /// @notice No user can claim rewards they didn't earn function invariant_no_reward_theft() public view { for (uint256 i = 0; i < handler.numUsers(); i++) { address user = handler.users(i); uint256 userClaimed = handler.userTotalClaimed(user); uint256 maxPossibleReward = handler.maxRewardForUser(user); assertLe( userClaimed, maxPossibleReward + 1, // 1 wei tolerance "User claimed more than max possible reward" ); } } } ``` That `maxRewardForUser` ghost variable is doing heavy lifting. Your handler needs to track each user's staking duration and the reward rate during that period to compute the theoretical maximum they could've earned. ## Reward rate consistency The reward rate tells you how many tokens get distributed per second (or per block). This rate should behave predictably: ```solidity /// @notice Reward rate must match configured emission schedule function invariant_reward_rate_bounded() public view { uint256 currentRate = pool.rewardRate(); if (block.timestamp < pool.periodFinish()) { assertGt(currentRate, 0, "Rate is zero during active period"); assertLe( currentRate, pool.maxRewardRate(), "Rate exceeds configured maximum" ); } else { // After period ends, rate should effectively be zero // (some implementations keep the rate but stop accruing) } } /// @notice Total distributed over time must not exceed reward balance function invariant_emission_solvency() public view { uint256 remainingTime = pool.periodFinish() > block.timestamp ? pool.periodFinish() - block.timestamp : 0; uint256 futureEmissions = pool.rewardRate() * remainingTime; uint256 rewardBalance = rewardToken.balanceOf(address(pool)); uint256 pendingClaims = handler.sumOfAllPendingRewards(); assertLe( futureEmissions + pendingClaims, rewardBalance + handler.numUsers(), // rounding tolerance "Pool is insolvent -- can't cover future emissions + pending claims" ); } ``` That solvency invariant is critical. I've seen pools where calling `notifyRewardAmount` with the wrong value created a reward rate that would overdistribute. Users who claimed early got paid; late claimers got nothing. Classic bank run bug. ## Stake/unstake round-trip property A user who stakes and immediately unstakes (same block, no time passes) should get back exactly what they put in. No more, no less. Any deviation means something's wrong with the deposit/withdrawal math. ```solidity /// @notice Stake then unstake in same block returns exact amount function test_stake_unstake_round_trip(uint256 amount) public { amount = bound(amount, 1, 1e30); deal(address(stakingToken), address(this), amount); stakingToken.approve(address(pool), amount); uint256 balanceBefore = stakingToken.balanceOf(address(this)); pool.stake(amount); pool.unstake(amount); uint256 balanceAfter = stakingToken.balanceOf(address(this)); assertEq(balanceAfter, balanceBefore, "Round trip lost tokens"); } /// @notice Total staked must equal sum of all user stakes function invariant_total_staked_accounting() public view { uint256 sumOfStakes; for (uint256 i = 0; i < handler.numUsers(); i++) { sumOfStakes += pool.balanceOf(handler.users(i)); } assertEq( sumOfStakes, pool.totalSupply(), "Individual stakes don't sum to totalSupply" ); } ``` ## Reward distribution proportionality This is the property most staking protocols get subtly wrong. If Alice stakes 75% of the pool and Bob stakes 25%, Alice should earn roughly 75% of the rewards (within rounding tolerance). ```solidity /// @notice Rewards must distribute proportionally to stake weight function invariant_proportional_distribution() public view { if (pool.totalSupply() == 0) return; if (handler.totalTimeElapsed() == 0) return; for (uint256 i = 0; i < handler.numUsers(); i++) { address user = handler.users(i); uint256 userTimeWeightedStake = handler.userTimeWeightedStake(user); uint256 totalTimeWeightedStake = handler.totalTimeWeightedStake(); if (totalTimeWeightedStake == 0) continue; uint256 expectedShare = (handler.totalRewardsDistributed() * userTimeWeightedStake) / totalTimeWeightedStake; uint256 actualRewards = handler.userTotalClaimed(user) + pool.earned(user); // Allow 0.1% tolerance for rounding uint256 tolerance = expectedShare / 1000 + 1; assertLe( actualRewards > expectedShare ? actualRewards - expectedShare : expectedShare - actualRewards, tolerance, "Disproportionate reward distribution" ); } } ``` The time-weighted stake tracking in the handler is essential. You can't just check current balances. You need the integral of each user's stake over time. This is where the ghost variable pattern really earns its keep. ## Time-weighted balance tracking For protocols that use time-weighted balances (vesting, boost multipliers, etc.), you need properties that verify the time math: ```solidity /// @notice Time-weighted balance must increase monotonically while staked function invariant_time_weighted_monotonic() public view { for (uint256 i = 0; i < handler.numUsers(); i++) { address user = handler.users(i); if (pool.balanceOf(user) > 0) { assertGe( pool.timeWeightedBalance(user), handler.previousTimeWeightedBalance(user), "Time-weighted balance decreased while staked" ); } } } ``` ## Cliff and vesting schedule properties Many staking protocols lock rewards behind vesting schedules. These state machines are bug-prone because they combine time logic with accounting logic. ```solidity /// @notice No rewards claimable before cliff period ends function invariant_cliff_enforced() public view { for (uint256 i = 0; i < handler.numUsers(); i++) { address user = handler.users(i); uint256 stakeTimestamp = pool.stakeTimestamp(user); if (stakeTimestamp > 0 && block.timestamp < stakeTimestamp + pool.cliffDuration() ) { assertEq( pool.claimableRewards(user), 0, "Rewards claimable before cliff" ); } } } /// @notice Vested amount must follow linear schedule after cliff function invariant_linear_vesting_correctness() public view { for (uint256 i = 0; i < handler.numUsers(); i++) { address user = handler.users(i); uint256 stakeTime = pool.stakeTimestamp(user); uint256 cliff = pool.cliffDuration(); uint256 vestingDuration = pool.vestingDuration(); if (stakeTime == 0) continue; if (block.timestamp < stakeTime + cliff) continue; uint256 elapsed = block.timestamp - stakeTime - cliff; uint256 totalRewards = pool.totalEarned(user); uint256 expectedVested; if (elapsed >= vestingDuration) { expectedVested = totalRewards; } else { expectedVested = (totalRewards * elapsed) / vestingDuration; } uint256 actualClaimable = pool.claimableRewards(user); // 1 wei per second tolerance for rounding uint256 tolerance = elapsed + 1; assertLe( actualClaimable > expectedVested ? actualClaimable - expectedVested : expectedVested - actualClaimable, tolerance, "Vesting schedule calculation incorrect" ); } } ``` ## Common bugs these properties catch Let me walk through the bugs I see most often when [fuzzing](/learn/fuzzing) staking contracts: ## 1. Division-before-multiplication in rewardPerToken This is the classic. The Synthetix `rewardPerToken()` formula is: ```solidity // WRONG (loses precision) rewardPerTokenStored + (rewardRate * timeElapsed / totalSupply) // RIGHT (multiply first) rewardPerTokenStored + (rewardRate * timeElapsed * 1e18 / totalSupply) ``` Without the scaling factor, small staking pools lose almost all their reward precision. The proportionality invariant catches this because one user ends up with way more or way less than their fair share. ## 2. Reward drainage via flash-stake Some pools don't checkpoint rewards properly. A user can flash-loan tokens, stake them (which snapshots a huge balance), then unstake in the same transaction. If `earned()` uses current balance instead of time-weighted balance, they steal accumulated rewards. The no-reward-theft invariant catches this because the user's claimed rewards exceed their `maxRewardForUser` (they hadn't staked long enough to earn that much). ## 3. Notifying rewards on empty pool When `notifyRewardAmount` gets called while `totalSupply == 0`, rewards accrue to nobody. Those tokens are locked forever. The emission solvency invariant catches the downstream effect: remaining rewards don't cover future emissions because some tokens went into a black hole. ## 4. Unstake reentrancy If the staking token has a callback (ERC-777, or any hook), unstaking can re-enter. The total-staked-accounting invariant breaks because `totalSupply` is updated but user balance isn't (or vice versa) mid-reentrancy. ## Building the handler Your [staking handler](/learn/stateful-fuzzing) needs these actions at minimum: ```solidity contract StakingHandler is CommonBase, StdCheats, StdUtils { // Actions the fuzzer can call function stake(uint256 userSeed, uint256 amount) external { address user = _selectUser(userSeed); amount = bound(amount, 1, 1e24); _mintAndApprove(user, amount); vm.prank(user); pool.stake(amount); _updateGhostVariables(user); } function unstake(uint256 userSeed, uint256 amount) external { address user = _selectUser(userSeed); uint256 staked = pool.balanceOf(user); if (staked == 0) return; amount = bound(amount, 1, staked); vm.prank(user); pool.unstake(amount); _updateGhostVariables(user); } function claimRewards(uint256 userSeed) external { address user = _selectUser(userSeed); uint256 pending = pool.earned(user); vm.prank(user); pool.claimReward(); totalRewardsClaimed += pending; userTotalClaimed[user] += pending; } function advanceTime(uint256 seconds_) external { seconds_ = bound(seconds_, 1, 7 days); vm.warp(block.timestamp + seconds_); _snapshotTimeWeightedBalances(); } } ``` The `advanceTime` action is critical. Without it, your fuzzer only tests same-block interactions. Time is a first-class input for staking protocols, so treat it that way. ## Emission schedule bounds For protocols with fixed emission schedules (halving, decay curves, etc.), add properties that verify the schedule: ```solidity /// @notice Total emissions must never exceed supply cap function invariant_emission_cap() public view { assertLe( pool.totalRewardsDistributed(), pool.maxTotalEmissions(), "Emissions exceeded supply cap" ); } /// @notice Current epoch rate must match schedule function invariant_epoch_rate_correct() public view { uint256 currentEpoch = pool.currentEpoch(); uint256 expectedRate = pool.scheduledRate(currentEpoch); assertEq( pool.rewardRate(), expectedRate, "Reward rate doesn't match epoch schedule" ); } ``` ## Where to start If you're testing a staking contract for the first time, start with three properties: 1. Rewards conservation: claimed + pending <= distributed 2. Total staked accounting: sum of balances == totalSupply 3. Stake/unstake round trip: no tokens lost on immediate withdrawal These three catch the majority of [staking security](/learn/staking-security) issues. Add proportionality and solvency next. Vesting and schedule properties come last. They're important but less likely to have critical bugs. For more on writing your first invariant test, check out [how to write your first invariant test](/blog/how-to-write-first-invariant-test). And if you're working with a protocol that combines staking with vault mechanics, [fuzzing ERC-4626 vaults](/blog/how-to-fuzz-erc4626-vaults) covers the overlap. --- Need a full property suite for your staking protocol? Request an Audit or Try Recon Pro to auto-generate invariant tests for your contracts. --- ### Governance and timelock security: properties for proposal lifecycle **Author:** alex | **Published:** 2025-08-25 | **Reading time:** 14 min **Tags:** invariant-testing, fuzzing, access-control, defi-security **URL:** https://getrecon.xyz/blog/governance-timelock-fuzzing-properties # Governance and Timelock Security: Properties for Proposal Lifecycle *By alex* Governance contracts sit at the top of the trust hierarchy. If the governor breaks, an attacker doesn't just steal funds — they rewrite the rules. Beanstalk lost $182M because a governance exploit let an attacker pass a malicious proposal in a single transaction. That's not a theoretical risk. It happened. The good news: governance protocols follow well-defined state machines, and state machines are perfect targets for [invariant testing](/learn/invariant-testing). Let's build a property suite that covers the full proposal lifecycle, from creation through execution. ## The proposal state machine OpenZeppelin's Governor defines these states: Pending, Active, Canceled, Defeated, Succeeded, Queued, Expired, Executed. Each transition has rules, and every rule is a property you can test. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {Test} from "forge-std/Test.sol"; import {IGovernor} from "@openzeppelin/contracts/governance/IGovernor.sol"; contract GovernanceInvariantTest is Test { GovernorMock governor; GovernanceHandler handler; function setUp() public { governor = new GovernorMock(token, timelock); handler = new GovernanceHandler(governor); targetContract(address(handler)); } /// @notice Proposals can only move forward in the state machine, never backward function invariant_state_machine_forward_only() public view { for (uint256 i = 0; i < handler.proposalCount(); i++) { uint256 proposalId = handler.proposalIds(i); uint8 currentState = uint8(governor.state(proposalId)); uint8 previousState = handler.previousState(proposalId); // States: 0=Pending,1=Active,2=Canceled,3=Defeated, // 4=Succeeded,5=Queued,6=Expired,7=Executed // Terminal states: Canceled(2), Defeated(3), Expired(6), Executed(7) if (previousState == 2 || previousState == 3 || previousState == 6 || previousState == 7 ) { assertEq( currentState, previousState, "Terminal state changed" ); } // Can't go from Active back to Pending if (previousState == 1) { assertTrue( currentState != 0, "Regressed from Active to Pending" ); } } } } ``` This catches a category of bugs where state transitions are checked incorrectly. For example, a proposal that gets "un-canceled" or re-enters the Active state after being Defeated. ## Voting power conservation Total voting power cast on a proposal can't exceed total voting power in existence at the snapshot block. This sounds trivial, but it breaks in interesting ways when delegation is involved. ```solidity /// @notice Total votes cast on any proposal <= total supply at snapshot function invariant_voting_power_conservation() public view { for (uint256 i = 0; i < handler.proposalCount(); i++) { uint256 proposalId = handler.proposalIds(i); ( uint256 againstVotes, uint256 forVotes, uint256 abstainVotes ) = governor.proposalVotes(proposalId); uint256 totalVotesCast = againstVotes + forVotes + abstainVotes; uint256 snapshotBlock = governor.proposalSnapshot(proposalId); uint256 totalSupplyAtSnapshot = token.getPastTotalSupply( snapshotBlock ); assertLe( totalVotesCast, totalSupplyAtSnapshot, "Votes cast exceed total supply at snapshot" ); } } /// @notice No voter can cast more votes than their weight at snapshot function invariant_individual_vote_weight() public view { for (uint256 i = 0; i < handler.proposalCount(); i++) { uint256 proposalId = handler.proposalIds(i); uint256 snapshotBlock = governor.proposalSnapshot(proposalId); for (uint256 j = 0; j < handler.voterCount(); j++) { address voter = handler.voters(j); uint256 votesUsed = handler.votesUsedBy(proposalId, voter); uint256 votingPower = token.getPastVotes( voter, snapshotBlock ); assertLe( votesUsed, votingPower, "Voter exceeded their voting power" ); } } } ``` ## Quorum integrity Quorum is the minimum participation threshold for a proposal to pass. Properties here make sure quorum can't be gamed: ```solidity /// @notice A proposal can only reach Succeeded if quorum was met function invariant_quorum_required_for_success() public view { for (uint256 i = 0; i < handler.proposalCount(); i++) { uint256 proposalId = handler.proposalIds(i); uint8 state = uint8(governor.state(proposalId)); // If Succeeded(4), Queued(5), or Executed(7), quorum must be met if (state == 4 || state == 5 || state == 7) { ( uint256 againstVotes, uint256 forVotes, uint256 abstainVotes ) = governor.proposalVotes(proposalId); uint256 snapshotBlock = governor.proposalSnapshot(proposalId); uint256 requiredQuorum = governor.quorum(snapshotBlock); // OZ Governor counts forVotes + abstainVotes toward quorum assertGe( forVotes + abstainVotes, requiredQuorum, "Proposal succeeded without quorum" ); } } } /// @notice Quorum value must be within configured bounds function invariant_quorum_bounded() public view { uint256 currentQuorum = governor.quorum(block.number - 1); uint256 totalSupply = token.getPastTotalSupply(block.number - 1); // Quorum should be between 1% and 50% of total supply (configurable) assertGe( currentQuorum, totalSupply / 100, "Quorum below minimum threshold" ); assertLe( currentQuorum, totalSupply / 2, "Quorum above maximum threshold" ); } ``` ## Timelock delay enforcement The timelock is the last line of defense. If the governance vote is compromised, the timelock delay gives the community time to react. It must be airtight. ```solidity /// @notice Execution must happen only after timelock delay has passed function invariant_timelock_delay_enforced() public view { for (uint256 i = 0; i < handler.executedProposalCount(); i++) { uint256 proposalId = handler.executedProposals(i); uint256 queuedAt = handler.queuedTimestamp(proposalId); uint256 executedAt = handler.executedTimestamp(proposalId); uint256 minDelay = timelock.getMinDelay(); assertGe( executedAt, queuedAt + minDelay, "Executed before timelock delay elapsed" ); } } /// @notice Timelock delay can't be set below minimum safe value function invariant_delay_minimum() public view { assertGe( timelock.getMinDelay(), 1 days, // protocol-specific minimum "Timelock delay below safe minimum" ); } /// @notice Queued proposals must expire after grace period function invariant_proposal_expiry() public view { for (uint256 i = 0; i < handler.proposalCount(); i++) { uint256 proposalId = handler.proposalIds(i); uint8 state = uint8(governor.state(proposalId)); if (state == 5) { // Queued uint256 queuedAt = handler.queuedTimestamp(proposalId); uint256 gracePeriod = timelock.GRACE_PERIOD(); if (block.timestamp > queuedAt + timelock.getMinDelay() + gracePeriod ) { // Should be Expired, not still Queued assertTrue(false, "Queued proposal should have expired"); } } } } ``` ## Execution atomicity When a proposal executes, it must either fully succeed or fully revert. Partial execution is a nightmare scenario — imagine a multi-action proposal where the token transfer succeeds but the parameter update fails. ```solidity /// @notice Executed proposals must have all targets called function invariant_execution_atomicity() public view { for (uint256 i = 0; i < handler.executedProposalCount(); i++) { uint256 proposalId = handler.executedProposals(i); ( address[] memory targets, , bytes[] memory calldatas ) = handler.getProposalActions(proposalId); for (uint256 j = 0; j < targets.length; j++) { assertTrue( handler.actionWasExecuted(proposalId, j), "Proposal partially executed" ); } } } ``` ## Cancel and veto safety Cancellation is tricky. Who can cancel? Under what conditions? And what happens to queued timelock transactions when a proposal gets canceled? ```solidity /// @notice Only authorized parties can cancel proposals function invariant_cancel_authorization() public view { for (uint256 i = 0; i < handler.cancelledProposalCount(); i++) { uint256 proposalId = handler.cancelledProposals(i); address canceller = handler.cancelledBy(proposalId); address proposer = handler.proposerOf(proposalId); // Only the proposer or guardian should cancel assertTrue( canceller == proposer || canceller == governor.guardian() || governor.hasRole(governor.CANCELLER_ROLE(), canceller), "Unauthorized cancellation" ); } } /// @notice Cancelled proposals must have their timelock operations cancelled function invariant_cancel_clears_timelock() public view { for (uint256 i = 0; i < handler.cancelledProposalCount(); i++) { uint256 proposalId = handler.cancelledProposals(i); bytes32 timelockId = governor.timelockIds(proposalId); if (timelockId != bytes32(0)) { assertFalse( timelock.isOperationPending(timelockId), "Cancelled proposal still pending in timelock" ); } } } ``` ## Delegation consistency Token delegation adds a whole layer of complexity. Your properties need to verify that delegation doesn't create or destroy voting power. ```solidity /// @notice Delegation doesn't change total voting power function invariant_delegation_conserves_power() public view { uint256 totalDelegatedPower; for (uint256 i = 0; i < handler.holderCount(); i++) { address holder = handler.holders(i); totalDelegatedPower += token.getVotes(holder); } // Total delegated power should equal total supply // (undelegated tokens have zero voting power in OZ Governor) assertLe( totalDelegatedPower, token.totalSupply(), "Delegation created voting power from nothing" ); } /// @notice Self-delegation gives full voting power function invariant_self_delegation_full_power() public view { for (uint256 i = 0; i < handler.holderCount(); i++) { address holder = handler.holders(i); if (token.delegates(holder) == holder) { assertEq( token.getVotes(holder), token.balanceOf(holder), "Self-delegated but voting power != balance" ); } } } ``` ## Real governance exploits Let's look at how these properties would've caught real attacks. ## Beanstalk ($182M, April 2022) The attacker flash-loaned a massive amount of tokens, used them to gain voting power, created a malicious proposal, voted on it, and executed it, all in one transaction. The root cause: the governance contract used current token balances for voting power instead of historical snapshots. The **voting power conservation** invariant would catch this. Flash-loaned tokens wouldn't appear in the snapshot block's supply, so the attacker's votes would exceed their historical voting power. ## Audius ($6M, July 2022) A governance proposal was able to modify the proxy's storage layout through an unguarded initialization function. The proposal passed through proper governance channels, but the action it executed was malicious in a way nobody noticed. The timelock delay enforcement invariant wouldn't have prevented this directly, but having the delay would've given the community time to spot the malicious calldata. More importantly, [access control properties](/blog/access-control-vulnerabilities-solidity) on the initializer function would've flagged it during testing. ## Tornado Cash governance (May 2023) An attacker deployed a malicious proposal contract that, upon execution, gave itself enough TORN tokens to control governance permanently. The contract's `destroy` function could be used to self-destruct and redeploy with different code. The voting power conservation invariant catches this: after execution, total voting power in the system exceeds the token's total supply at the snapshot block. Something created votes from nothing. ## Building the handler Your governance handler needs careful timing control: ```solidity contract GovernanceHandler is CommonBase, StdCheats, StdUtils { function createProposal(uint256 actorSeed) external { address proposer = _selectProposer(actorSeed); // Ensure proposer meets proposal threshold if (token.getVotes(proposer) < governor.proposalThreshold()) { return; } vm.prank(proposer); uint256 id = governor.propose(targets, values, calldatas, "Test"); _trackProposal(id, proposer); } function castVote( uint256 actorSeed, uint256 proposalSeed, uint8 support ) external { address voter = _selectVoter(actorSeed); uint256 proposalId = _selectActiveProposal(proposalSeed); if (proposalId == 0) return; support = uint8(bound(support, 0, 2)); // Against, For, Abstain vm.prank(voter); governor.castVote(proposalId, support); _trackVote(proposalId, voter, support); } function advanceBlocks(uint256 blocks) external { blocks = bound(blocks, 1, 100000); vm.roll(block.number + blocks); vm.warp(block.timestamp + blocks * 12); _updateProposalStates(); } function queueProposal(uint256 proposalSeed) external { uint256 proposalId = _selectSucceededProposal(proposalSeed); if (proposalId == 0) return; governor.queue( targets, values, calldatas, keccak256("Test") ); queuedTimestamp[proposalId] = block.timestamp; } function executeProposal(uint256 proposalSeed) external { uint256 proposalId = _selectQueuedProposal(proposalSeed); if (proposalId == 0) return; governor.execute( targets, values, calldatas, keccak256("Test") ); executedTimestamp[proposalId] = block.timestamp; } } ``` The `advanceBlocks` function is essential. Governance proposals have voting periods measured in blocks, so without block advancement, you'll never test transitions from Active to Succeeded/Defeated. ## Where to start For [governance security](/learn/governance-security), start with these four properties: 1. State machine forward-only: no state regression 2. Voting power conservation: can't create votes from nothing 3. Quorum enforcement: proposals can't pass without participation 4. Timelock delay: execution respects the configured delay Then add delegation consistency, cancellation safety, and execution atomicity. If your protocol has custom extensions (optimistic governance, guardians, vetoers), write specific properties for those. Governance bugs are low-frequency but catastrophic. A single exploit can permanently compromise a protocol. That's why these properties matter. They're your automated proof that the rules of your governance system can't be broken. For more on [invariant testing](/learn/invariant-testing) patterns, see [property design patterns for DeFi lending](/blog/property-design-patterns-defi-lending). And if you're just getting started, [how to write your first invariant test](/blog/how-to-write-first-invariant-test) walks through the setup step by step. --- Need governance security testing for your protocol? Request an Audit or Try Recon Pro to generate properties for your governor contracts. --- ### Oracle integration testing: fuzzing price feeds and manipulation resistance **Author:** nican0r | **Published:** 2025-09-22 | **Reading time:** 14 min **Tags:** invariant-testing, fuzzing, oracle, defi-security **URL:** https://getrecon.xyz/blog/oracle-integration-fuzzing-patterns # Oracle Integration Testing: Fuzzing Price Feeds and Manipulation Resistance *By nican0r* Oracles are the bridge between on-chain and off-chain reality. When that bridge lies, protocols lose millions. We saw it with the Mango Markets exploit ($114M), with Cream Finance ($130M), and dozens of smaller incidents. The pattern is always the same: a protocol trusted a price feed without verifying it was telling the truth. You can't unit-test your way out of oracle bugs. The failure modes are combinatorial — stale prices interacting with high volatility, flash loan manipulation combined with specific pool states, multi-oracle disagreement during network congestion. [Fuzzing](/learn/fuzzing) with mock oracles is the only way to cover this space. ## The core oracle properties Every protocol that reads a price feed needs these properties, no exceptions: ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {Test} from "forge-std/Test.sol"; contract OracleInvariantTest is Test { LendingProtocol protocol; OracleHandler handler; MockChainlinkOracle mockOracle; function setUp() public { mockOracle = new MockChainlinkOracle(); protocol = new LendingProtocol(address(mockOracle)); handler = new OracleHandler(protocol, mockOracle); targetContract(address(handler)); } /// @notice Protocol must reject stale price data function invariant_staleness_check() public view { (, , , uint256 updatedAt, ) = mockOracle.latestRoundData(); uint256 staleness = block.timestamp - updatedAt; if (staleness > protocol.maxStaleness()) { // Any operation that reads price should have reverted // We track this via ghost variable in handler assertEq( handler.operationsWithStalePrice(), 0, "Protocol accepted stale price" ); } } /// @notice Protocol must reject zero or negative prices function invariant_price_sanity() public view { (, int256 price, , , ) = mockOracle.latestRoundData(); if (price <= 0) { assertEq( handler.operationsWithInvalidPrice(), 0, "Protocol accepted zero/negative price" ); } } } ``` These look simple. That's the point. The number of production protocols that skip staleness checks is staggering. The [Chainlink documentation](https://docs.chain.link/data-feeds) explicitly warns about this, and developers still miss it. ## Price deviation bounds Prices don't jump 50% in a single block under normal conditions. If your oracle reports a massive deviation from the last known price, something's probably wrong — either the oracle is being manipulated or there's a data feed issue. ```solidity /// @notice Price changes must be within acceptable deviation function invariant_price_deviation_bounded() public view { int256 currentPrice = handler.lastAcceptedPrice(); int256 previousPrice = handler.previousAcceptedPrice(); if (previousPrice == 0 || currentPrice == 0) return; uint256 deviation; if (currentPrice > previousPrice) { deviation = uint256(currentPrice - previousPrice) * 10000 / uint256(previousPrice); } else { deviation = uint256(previousPrice - currentPrice) * 10000 / uint256(previousPrice); } // Max 20% deviation per heartbeat (configurable per asset) assertLe( deviation, 2000, // 20% in basis points "Price deviated beyond safe threshold" ); } ``` The deviation threshold depends on the asset. ETH/USD might allow 15% between heartbeats. A volatile small-cap token might need 50%. Stablecoins should be 5% at most. Match your bounds to the asset's actual volatility profile. ## TWAP vs spot price comparison Time-weighted average prices resist single-block manipulation. If your protocol uses a spot price, cross-reference it against a TWAP to detect manipulation attempts. ```solidity /// @notice Spot price must not deviate too far from TWAP function invariant_spot_vs_twap_consistency() public view { uint256 spotPrice = protocol.getSpotPrice(address(tokenA)); uint256 twapPrice = protocol.getTWAP(address(tokenA), 30 minutes); if (spotPrice == 0 || twapPrice == 0) return; uint256 deviation; if (spotPrice > twapPrice) { deviation = ((spotPrice - twapPrice) * 10000) / twapPrice; } else { deviation = ((twapPrice - spotPrice) * 10000) / twapPrice; } // If deviation > 10%, the protocol should have flagged it if (deviation > 1000) { assertTrue( protocol.priceManipulationDetected(), "Large spot/TWAP deviation not detected" ); } } ``` This is how you catch [flash loan attacks](/blog/flash-loan-exploits-invariant-testing) that manipulate AMM spot prices. The attacker can move the spot price in one block, but they can't move the TWAP (at least not without sustained capital allocation over many blocks). ## Circuit breaker properties Circuit breakers pause operations when price feeds behave abnormally. They're your protocol's kill switch. Properties need to verify they actually trigger: ```solidity /// @notice Circuit breaker must activate on extreme price movement function invariant_circuit_breaker_triggers() public view { if (handler.extremePriceEventOccurred()) { assertTrue( protocol.circuitBreakerActive(), "Circuit breaker didn't trigger on extreme price" ); } } /// @notice No borrows or liquidations while circuit breaker is active function invariant_circuit_breaker_halts_operations() public view { if (protocol.circuitBreakerActive()) { assertEq( handler.operationsDuringCircuitBreaker(), 0, "Operations executed during circuit breaker" ); } } /// @notice Circuit breaker must be resettable after conditions normalize function invariant_circuit_breaker_recovery() public view { if (handler.priceNormalizedAfterBreaker()) { assertFalse( protocol.circuitBreakerActive(), "Circuit breaker stuck active after normalization" ); } } ``` I've seen circuit breakers that trigger correctly but never reset. The protocol is paused forever. That last property catches this scenario. Once prices return to normal, the breaker should release. ## Fallback oracle consistency Production deployments should never depend on a single oracle. When Chainlink goes down (it's happened), you need a fallback. But fallbacks introduce their own bugs: ```solidity /// @notice Primary and fallback oracle must agree within tolerance function invariant_multi_oracle_consistency() public view { if (!handler.bothOraclesActive()) return; int256 primaryPrice = handler.primaryOraclePrice(); int256 fallbackPrice = handler.fallbackOraclePrice(); if (primaryPrice <= 0 || fallbackPrice <= 0) return; uint256 deviation; if (primaryPrice > fallbackPrice) { deviation = uint256(primaryPrice - fallbackPrice) * 10000 / uint256(fallbackPrice); } else { deviation = uint256(fallbackPrice - primaryPrice) * 10000 / uint256(primaryPrice); } // If oracles disagree by more than 5%, protocol should pause if (deviation > 500) { assertTrue( protocol.oracleDisagreementDetected(), "Oracle disagreement not detected" ); } } /// @notice Fallback must activate when primary fails function invariant_fallback_activates() public view { if (handler.primaryOracleFailed()) { assertTrue( protocol.usingFallbackOracle(), "Primary failed but fallback not active" ); } } ``` ## Manipulation resistance: flash loan + oracle This is the big one. [Oracle manipulation](/learn/oracle-manipulation) via flash loans is the most common DeFi exploit vector. Your fuzzer needs to simulate this attack pattern: ```solidity contract OracleHandler is CommonBase, StdCheats, StdUtils { /// @notice Simulate flash loan price manipulation function manipulatePrice( uint256 manipulationAmount, uint256 actionSeed ) external { manipulationAmount = bound( manipulationAmount, 1e18, 1e26 ); // Record price before manipulation int256 priceBefore = _getCurrentPrice(); // Simulate large swap that moves AMM price mockOracle.setPrice( priceBefore * int256(manipulationAmount) / 1e18 ); // Try to exploit the protocol at manipulated price uint8 action = uint8(bound(actionSeed, 0, 2)); if (action == 0) _tryBorrow(); else if (action == 1) _tryLiquidate(); else _tryWithdraw(); // Price returns to normal (flash loan repaid) mockOracle.setPrice(priceBefore); // Track whether any action succeeded at manipulated price _recordManipulationResult(); } /// @notice Normal price update within realistic bounds function updatePrice(uint256 newPrice) external { int256 current = _getCurrentPrice(); // Bound to ±20% of current price int256 lower = current * 80 / 100; int256 upper = current * 120 / 100; int256 bounded = int256( bound(uint256(newPrice), uint256(lower), uint256(upper)) ); mockOracle.setPrice(bounded); mockOracle.setUpdatedAt(block.timestamp); } /// @notice Advance time to make price stale function advanceTime(uint256 seconds_) external { seconds_ = bound(seconds_, 1, 2 days); vm.warp(block.timestamp + seconds_); } } ``` The `manipulatePrice` action is where the real testing happens. It simulates an attacker who pushes the price to an extreme value, performs some protocol action, then lets the price snap back. Any protocol action that succeeds at the manipulated price is a potential exploit. ```solidity /// @notice No profit from flash manipulation function invariant_flash_manipulation_unprofitable() public view { for (uint256 i = 0; i < handler.manipulationAttempts(); i++) { ManipulationResult memory result = handler.getResult(i); if (result.actionSucceeded) { assertLe( result.attackerProfitBps, 0, "Flash manipulation was profitable" ); } } } ``` ## Chainlink-specific properties If you're using Chainlink, these additional properties matter: ```solidity /// @notice roundId must increase monotonically function invariant_round_id_monotonic() public view { (uint80 currentRound, , , , ) = mockOracle.latestRoundData(); assertGe( currentRound, handler.previousRoundId(), "Round ID decreased" ); } /// @notice answeredInRound must be >= roundId (not stale round) function invariant_answered_in_round() public view { ( uint80 roundId, , , , uint80 answeredInRound ) = mockOracle.latestRoundData(); assertGe( answeredInRound, roundId, "Answer is from a previous round" ); } /// @notice Price must have correct number of decimals function invariant_decimals_consistency() public view { uint8 decimals = mockOracle.decimals(); (, int256 price, , , ) = mockOracle.latestRoundData(); // Price should be reasonable for the decimal count // e.g., ETH at $2000 with 8 decimals = 200000000000 if (decimals == 8) { assertGt(price, 1e6, "Price too low for 8 decimals"); assertLt(price, 1e14, "Price too high for 8 decimals"); } } ``` ## Pyth and custom TWAP patterns Pyth oracles work differently. They use a pull model where the user submits the price update. This changes your property suite: ```solidity /// @notice Pyth price confidence interval must be within bounds function invariant_pyth_confidence_bounded() public view { PythStructs.Price memory price = pyth.getPrice(priceFeedId); // Confidence should be less than 5% of price uint256 confPercent = (uint256(uint64(price.conf)) * 10000) / uint256(uint64(price.price > 0 ? price.price : -price.price)); assertLe( confPercent, 500, "Pyth confidence interval too wide, price unreliable" ); } /// @notice Custom TWAP must use sufficient observation window function invariant_twap_window_sufficient() public view { uint256 twapWindow = protocol.twapObservationWindow(); assertGe( twapWindow, 15 minutes, "TWAP window too short, vulnerable to manipulation" ); } ``` For custom TWAP implementations (like Uniswap V3's oracle), your biggest risk is short observation windows. A 1-block TWAP is basically a spot price. You need at least 15-30 minutes to resist sustained manipulation. ## Multi-oracle aggregation Some protocols aggregate multiple oracles. The aggregation logic itself can have bugs: ```solidity /// @notice Median of 3 oracles must equal actual median function invariant_median_correctness() public view { int256 price1 = oracle1.price(); int256 price2 = oracle2.price(); int256 price3 = oracle3.price(); int256 expectedMedian = _calculateMedian(price1, price2, price3); int256 protocolMedian = protocol.getAggregatedPrice(); assertEq( protocolMedian, expectedMedian, "Aggregated price != actual median" ); } function _calculateMedian( int256 a, int256 b, int256 c ) internal pure returns (int256) { if (a <= b && b <= c) return b; if (a <= c && c <= b) return c; if (b <= a && a <= c) return a; if (b <= c && c <= a) return c; if (c <= a && a <= b) return a; return b; } ``` ## Building mock oracles for fuzzing Your mock oracle needs to support both normal updates and adversarial manipulation: ```solidity contract MockChainlinkOracle { int256 private _price; uint256 private _updatedAt; uint80 private _roundId; uint8 private _decimals; function setPrice(int256 price) external { _price = price; } function setUpdatedAt(uint256 timestamp) external { _updatedAt = timestamp; } function incrementRound() external { _roundId++; _updatedAt = block.timestamp; } function latestRoundData() external view returns ( uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound ) { return ( _roundId, _price, _updatedAt, _updatedAt, _roundId ); } function decimals() external view returns (uint8) { return _decimals; } } ``` The handler controls this mock. Normal actions update the price within reasonable bounds. Attack actions set extreme prices. The invariants verify the protocol handles both correctly. ## Where to start For oracle integration testing, your priority order should be: 1. Staleness rejection: most common oracle bug in production 2. Zero/negative price rejection: second most common 3. Flash manipulation resistance: highest dollar-amount exploits 4. TWAP/spot consistency: catches manipulation the staleness check misses 5. Fallback activation: for production resilience If you're new to [invariant testing](/learn/invariant-testing), start with [how to write your first invariant test](/blog/how-to-write-first-invariant-test). For the broader context of why this matters, [why invariant testing matters for DeFi security](/blog/why-invariant-testing-matters-defi-security) lays it out. --- Need oracle integration testing for your protocol? Request an Audit or Try Recon Pro to generate oracle properties automatically. --- ### Fuzzing upgradeable contracts: storage layout, proxy safety, and upgrade invariants **Author:** kn0t | **Published:** 2026-02-25 | **Reading time:** 14 min **Tags:** invariant-testing, fuzzing, upgradeable-contracts, smart-contract-security **URL:** https://getrecon.xyz/blog/upgradeable-contract-fuzzing-storage-proxy # Fuzzing Upgradeable Contracts: Storage Layout, Proxy Safety, and Upgrade Invariants *By kn0t* Upgradeable contracts are a double-edged sword. They let you fix bugs in production — but they also let you introduce entirely new categories of bugs that don't exist in non-upgradeable contracts. Storage collisions, initializer re-entrancy, broken upgrade paths. These aren't theoretical. Wormhole lost $320M partly because of a proxy-related issue. Audius lost $6M from an unguarded initializer. I'm going to walk through every property you need to test upgradeable contracts. We'll cover UUPS, TransparentProxy, and Beacon patterns. Let's get into it. ## Storage layout preservation This is the single most important property for upgradeable contracts. When you upgrade from Implementation V1 to V2, every storage slot that existed in V1 must mean the same thing in V2. If V2 reorders variables or inserts a new one before existing ones, you'll get silent data corruption. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {Test} from "forge-std/Test.sol"; contract UpgradeInvariantTest is Test { TransparentUpgradeableProxy proxy; ImplementationV1 implV1; ImplementationV2 implV2; UpgradeHandler handler; function setUp() public { implV1 = new ImplementationV1(); proxy = new TransparentUpgradeableProxy( address(implV1), admin, abi.encodeWithSelector(ImplementationV1.initialize.selector) ); implV2 = new ImplementationV2(); handler = new UpgradeHandler(proxy, implV1, implV2); targetContract(address(handler)); } /// @notice Storage slots must preserve meaning across upgrades function invariant_storage_layout_preserved() public { // Read critical storage slots before upgrade bytes32 slot0Before = vm.load(address(proxy), bytes32(uint256(0))); bytes32 slot1Before = vm.load(address(proxy), bytes32(uint256(1))); bytes32 slot2Before = vm.load(address(proxy), bytes32(uint256(2))); if (handler.upgradePerformed()) { // Same slots should contain same semantics // We verify by calling getter functions that read those slots ImplementationV2 proxyV2 = ImplementationV2(address(proxy)); // V1's owner slot must still be owner in V2 assertEq( proxyV2.owner(), handler.ownerBeforeUpgrade(), "Owner changed after upgrade" ); // V1's totalSupply slot must still be totalSupply in V2 assertEq( proxyV2.totalSupply(), handler.totalSupplyBeforeUpgrade(), "Total supply corrupted after upgrade" ); // V1's mapping root must still map correctly in V2 for (uint256 i = 0; i < handler.trackedUserCount(); i++) { address user = handler.trackedUsers(i); assertEq( proxyV2.balanceOf(user), handler.balanceBeforeUpgrade(user), "User balance corrupted after upgrade" ); } } } } ``` The trick here is tracking values *before* the upgrade and comparing them *after*. Your handler needs to snapshot all critical state before performing the upgrade. ## Initializer safety Initializers replace constructors in upgradeable contracts. The bug: if `initialize()` can be called twice, an attacker can reset the contract's state. This is what hit Audius — an attacker called an unguarded initializer to take over the proxy. ```solidity /// @notice Initialize can only be called once function invariant_initializer_only_once() public { if (!handler.contractInitialized()) return; // Try to re-initialize -- it must revert try ImplementationV1(address(proxy)).initialize() { assertTrue(false, "Re-initialization succeeded"); } catch { // Expected -- initializer is locked } } /// @notice Implementation contract itself must be initialized /// (prevents direct calls to the implementation) function invariant_implementation_initialized() public { // The implementation behind the proxy should also be initialized // to prevent attacks that call the implementation directly try implV1.initialize() { assertTrue( false, "Implementation contract can be initialized directly" ); } catch { // Expected } } ``` That second property catches a subtle bug. Even if the proxy's storage has the initializer locked, the *implementation contract itself* might not be initialized. An attacker can call `initialize()` on the implementation directly, take ownership, and then call `selfdestruct` (pre-Dencun) to brick all proxies pointing to it. ## Proxy-implementation consistency The proxy and implementation must agree on the admin, the implementation address, and the interface they expose. ```solidity /// @notice Proxy must point to a valid implementation function invariant_implementation_is_contract() public view { address impl = handler.getCurrentImplementation(); uint256 codeSize; assembly { codeSize := extcodesize(impl) } assertGt(codeSize, 0, "Implementation is not a contract"); } /// @notice Admin slot must contain the correct admin address function invariant_admin_consistency() public view { // EIP-1967 admin slot bytes32 adminSlot = bytes32( uint256(keccak256("eip1967.proxy.admin")) - 1 ); bytes32 storedAdmin = vm.load(address(proxy), adminSlot); address adminAddress = address(uint160(uint256(storedAdmin))); assertEq( adminAddress, handler.expectedAdmin(), "Admin slot corrupted" ); } /// @notice Implementation slot must contain the correct implementation function invariant_implementation_slot_correct() public view { // EIP-1967 implementation slot bytes32 implSlot = bytes32( uint256(keccak256("eip1967.proxy.implementation")) - 1 ); bytes32 storedImpl = vm.load(address(proxy), implSlot); address implAddress = address(uint160(uint256(storedImpl))); assertTrue( implAddress == address(implV1) || implAddress == address(implV2), "Implementation slot points to unknown contract" ); } ``` ## Admin access control for upgrades Only authorized addresses should be able to trigger an upgrade. This seems obvious, but the check differs between UUPS and TransparentProxy patterns. ```solidity /// @notice Only admin can upgrade (TransparentProxy) function invariant_upgrade_authorization() public view { if (handler.upgradeAttempts() > 0) { for (uint256 i = 0; i < handler.upgradeAttempts(); i++) { UpgradeAttempt memory attempt = handler.getUpgradeAttempt(i); if (attempt.succeeded) { assertEq( attempt.caller, handler.expectedAdmin(), "Non-admin successfully upgraded" ); } } } } /// @notice UUPS: upgradeToAndCall must check authorization function invariant_uups_authorization() public { address randomUser = makeAddr("randomUser"); vm.prank(randomUser); try UUPSUpgradeable(address(proxy)).upgradeToAndCall( address(implV2), "" ) { assertTrue( false, "Unauthorized upgrade succeeded on UUPS proxy" ); } catch { // Expected -- only authorized upgraders can call this } } ``` ## UUPS vs TransparentProxy: different threat models With TransparentProxy, the admin is a separate ProxyAdmin contract. The risk: if the ProxyAdmin's ownership is compromised, all proxies it manages are compromised. With UUPS, the upgrade logic lives in the implementation. The risk: if you forget to include the upgrade function in a new implementation, you can't upgrade anymore. Permanent brick. ```solidity /// @notice UUPS implementation must contain upgrade function function invariant_uups_upgrade_function_exists() public view { address currentImpl = handler.getCurrentImplementation(); // Check that the implementation has upgradeToAndCall selector bytes4 selector = UUPSUpgradeable.upgradeToAndCall.selector; // We can verify by checking the implementation's code contains // the selector -- or more practically, by trying to call it // through the proxy (which should only fail on auth, not on // missing function) } ``` ## Selfdestruct protection (pre-Dencun) Before EIP-6780 (Dencun), `selfdestruct` could destroy a contract and its storage. For pre-Dencun deployments, this was a real threat to proxies: ```solidity /// @notice Implementation must not have selfdestruct /// (static analysis property -- verify in deployment pipeline) function test_no_selfdestruct_in_implementation() public view { // This is better done as a static analysis check, but you can // verify the implementation is still alive after handler actions address currentImpl = handler.getCurrentImplementation(); uint256 codeSize; assembly { codeSize := extcodesize(currentImpl) } assertGt( codeSize, 0, "Implementation was destroyed" ); } ``` Post-Dencun, `selfdestruct` only sends ETH without destroying code (except in the same transaction as creation). But many contracts deployed before Dencun are still live and vulnerable. ## Storage gap correctness Storage gaps are the convention for reserving space in base contracts so derived contracts can add variables without collisions. ```solidity /// @notice Storage gaps must maintain constant total slot count function invariant_storage_gap_size() public view { // V1 has 5 state variables + __gap of size 45 = 50 slots // V2 adds 2 variables, so __gap should shrink to 43 if (handler.upgradePerformed()) { // Read the gap slot count from V2 uint256 v2GapSize = implV2.getGapSize(); uint256 v2VarCount = implV2.getVariableCount(); // Total must equal V1's total uint256 v1Total = implV1.getVariableCount() + implV1.getGapSize(); uint256 v2Total = v2VarCount + v2GapSize; assertEq( v1Total, v2Total, "Storage gap + variables changed total slot count" ); } } ``` In practice, I verify gaps using Foundry's `forge inspect` rather than on-chain checks. But this invariant catches the case where someone modifies the gap incorrectly during an upgrade. ## Building the upgrade handler Your handler needs to simulate the full upgrade lifecycle: ```solidity contract UpgradeHandler is CommonBase, StdCheats, StdUtils { bool public upgradePerformed; address public ownerBeforeUpgrade; uint256 public totalSupplyBeforeUpgrade; mapping(address => uint256) public balanceBeforeUpgrade; /// @notice Interact with the protocol normally (pre-upgrade) function normalOperation( uint256 actorSeed, uint256 amount ) external { address user = _selectUser(actorSeed); amount = bound(amount, 1, 1e24); _performRandomAction(user, amount); } /// @notice Perform the upgrade function performUpgrade() external { if (upgradePerformed) return; // Snapshot all state before upgrade _snapshotState(); // Upgrade vm.prank(admin); ProxyAdmin(proxyAdmin).upgradeAndCall( ITransparentUpgradeableProxy(address(proxy)), address(implV2), "" // No initialization call for this upgrade ); upgradePerformed = true; } /// @notice Interact with the protocol after upgrade function postUpgradeOperation( uint256 actorSeed, uint256 amount ) external { if (!upgradePerformed) return; address user = _selectUser(actorSeed); amount = bound(amount, 1, 1e24); _performRandomAction(user, amount); } /// @notice Try to upgrade from unauthorized address function tryUnauthorizedUpgrade(uint256 actorSeed) external { address attacker = _selectUser(actorSeed); if (attacker == admin) return; upgradeAttemptCount++; UpgradeAttempt storage attempt = upgradeAttempts[ upgradeAttemptCount - 1 ]; attempt.caller = attacker; vm.prank(attacker); try ProxyAdmin(proxyAdmin).upgradeAndCall( ITransparentUpgradeableProxy(address(proxy)), address(implV2), "" ) { attempt.succeeded = true; // This is bad } catch { attempt.succeeded = false; // Expected } } function _snapshotState() internal { ImplementationV1 proxyV1 = ImplementationV1(address(proxy)); ownerBeforeUpgrade = proxyV1.owner(); totalSupplyBeforeUpgrade = proxyV1.totalSupply(); for (uint256 i = 0; i < trackedUsers.length; i++) { balanceBeforeUpgrade[trackedUsers[i]] = proxyV1.balanceOf( trackedUsers[i] ); } } } ``` The state snapshot is the handler's most important job. Without it, you can't verify that the upgrade preserved storage correctly. ## Real upgrade bugs ## Wormhole ($320M, February 2022) The Wormhole bridge had a UUPS proxy whose implementation had an uninitialized `guardian_set`. The attacker called `initialize()` on the implementation contract directly, set themselves as the guardian, and approved fraudulent transfers. The **initializer safety** invariant catches this, the implementation contract itself must be initialized. ## Audius ($6M, July 2022) Audius used a custom proxy pattern where the governance contract could change the proxy's implementation. An attacker found an unguarded `initialize` function on a new implementation that let them take over the proxy and drain funds. The **initialize-only-once** property catches this directly. ## Compound (October 2022, near-miss) Compound's governance proposed a Comptroller upgrade that would have bricked the protocol. Community members caught it during the timelock delay. This isn't a fuzzing catch per se, but the **storage layout preservation** invariant would've flagged it during testing before the proposal was even created. ## Beacon proxy properties Beacon proxies add another layer. Multiple proxies share a single beacon that points to the implementation. Properties need to verify beacon consistency: ```solidity /// @notice All proxies using same beacon must use same implementation function invariant_beacon_consistency() public view { address expectedImpl = beacon.implementation(); for (uint256 i = 0; i < handler.proxyCount(); i++) { address proxyAddr = handler.proxies(i); bytes32 beaconSlot = bytes32( uint256(keccak256("eip1967.proxy.beacon")) - 1 ); bytes32 storedBeacon = vm.load(proxyAddr, beaconSlot); address proxyBeacon = address( uint160(uint256(storedBeacon)) ); // All proxies should point to our beacon assertEq( proxyBeacon, address(beacon), "Proxy points to wrong beacon" ); } } /// @notice Beacon upgrade affects all proxies simultaneously function invariant_beacon_upgrade_atomic() public view { if (!handler.beaconUpgradePerformed()) return; address newImpl = beacon.implementation(); for (uint256 i = 0; i < handler.proxyCount(); i++) { // Each proxy should now use the new implementation // (verified by calling a function that only exists in V2) address proxyAddr = handler.proxies(i); try ImplementationV2(proxyAddr).newV2Function() { // Expected -- V2 function exists } catch { assertTrue(false, "Proxy not upgraded after beacon change"); } } } ``` ## Where to start For [proxy pattern](/learn/proxy-pattern) testing, start here: 1. **Initializer safety**, can't re-initialize. This is the #1 production exploit vector. 2. **Storage layout preservation**, values survive upgrade intact. 3. **Upgrade authorization**, only admin can upgrade. 4. **Implementation liveness**, implementation is a valid contract. Then add storage gap checks, beacon consistency (if applicable), and UUPS-specific properties. If you're also testing the protocol that lives behind the proxy, combine these with protocol-specific invariants from [invariant testing](/learn/invariant-testing). The upgrade properties verify the container is safe; your protocol properties verify the contents are correct. For more security testing patterns, check out [smart contract security](/learn/smart-contract-security) and [why invariant testing matters for DeFi security](/blog/why-invariant-testing-matters-defi-security). --- Building with upgradeable proxies? Request an Audit or Try Recon Pro to generate upgrade-safety properties automatically. --- ### Top 10 DeFi exploits of 2025: the invariants that would have stopped them **Author:** alex | **Published:** 2025-11-24 | **Reading time:** 18 min **Tags:** invariant-testing, defi-security, vulnerabilities, smart-contract-security **URL:** https://getrecon.xyz/blog/top-defi-exploits-2025-invariant-analysis # Top 10 DeFi Exploits of 2025: The Invariants That Would Have Stopped Them *By alex* Every year, DeFi loses hundreds of millions to bugs that formal properties would catch before deployment. 2025 was no different. I went through the biggest exploits of the year and reverse-engineered the exact invariant that would have prevented each one. This isn't an abstract exercise. For the top three, I'll give you the actual Solidity invariant code. For the rest, I'll describe the property precisely enough that you can implement it for your own protocol. Let's count down. ## #10: ZKLend price manipulation — $9.5M (January 2025) **What happened:** An attacker manipulated a price oracle's calculation by donating tokens directly to the lending pool's reserves, inflating the exchange rate. They then borrowed against the inflated collateral value and walked away with the difference. **Root cause:** The protocol used a spot exchange rate derived from pool balances instead of a manipulation-resistant price source. Direct token transfers weren't accounted for in the price calculation. **Dollar amount:** ~$9.5M **The invariant that stops it:** ```solidity /// @notice Exchange rate must not change by more than X% in a single block function invariant_exchange_rate_bounded_per_block() public view { uint256 currentRate = pool.exchangeRate(); uint256 previousRate = handler.previousBlockExchangeRate(); if (previousRate == 0) return; uint256 deviation; if (currentRate > previousRate) { deviation = ((currentRate - previousRate) * 10000) / previousRate; } else { deviation = ((previousRate - currentRate) * 10000) / previousRate; } // Exchange rate can't move more than 1% per block assertLe( deviation, 100, "Exchange rate moved too much in one block" ); } ``` Any protocol that derives prices from pool balances needs this property. It catches donation attacks, flash loan manipulation, and other single-block price distortions. See [oracle manipulation](/learn/oracle-manipulation) for more background. ## #9: Abracadabra / magic internet money — $13M (March 2025) **What happened:** An attacker exploited a rounding error in the cauldron's liquidation mechanism. By carefully constructing borrow positions at specific sizes, the attacker triggered liquidations that returned more collateral value than the debt they repaid. **Root cause:** Integer division in the liquidation math rounded in the attacker's favor when dealing with small position sizes. The liquidation bonus calculation didn't properly account for precision loss. **Dollar amount:** ~$13M The invariant that stops it: After any liquidation, the protocol must have more value than before (from the liquidation penalty). If a liquidation makes the protocol *poorer*, the math is wrong. ```solidity /// @notice Liquidation must always improve protocol solvency function invariant_liquidation_improves_health() public view { if (handler.lastActionWasLiquidation()) { uint256 protocolValueBefore = handler.protocolValueBeforeLiquidation(); uint256 protocolValueAfter = _calculateProtocolValue(); assertGe( protocolValueAfter, protocolValueBefore, "Liquidation decreased protocol value" ); } } ``` Check out [rounding errors in DeFi](/blog/rounding-errors-defi-small-bugs-big-exploits), this bug pattern shows up over and over. ## #8: sIR.trading vault drain, $355K (March 2025) What happened: The protocol's vault contract had a callback in the withdrawal flow that could be re-entered. An attacker used a malicious token callback to re-enter during withdrawal, withdrawing their share multiple times before the balance update. Root cause: Classic reentrancy via token callback. The contract updated internal state after the external call. Dollar amount: ~$355K The invariant that stops it: ```solidity /// @notice Total shares must always equal sum of individual shares function invariant_share_accounting() public view { uint256 sumShares; for (uint256 i = 0; i < handler.userCount(); i++) { sumShares += vault.balanceOf(handler.users(i)); } assertEq( sumShares, vault.totalSupply(), "Share accounting mismatch -- possible reentrancy" ); } /// @notice Vault assets must cover all outstanding shares function invariant_vault_solvency() public view { if (vault.totalSupply() == 0) return; uint256 totalAssets = token.balanceOf(address(vault)); uint256 totalShareValue = vault.convertToAssets(vault.totalSupply()); assertGe( totalAssets, totalShareValue, "Vault insolvent -- assets don't cover shares" ); } ``` The share accounting invariant breaks the moment reentrancy causes a double-withdrawal. For more on vault security, see [how to fuzz ERC-4626 vaults](/blog/how-to-fuzz-erc4626-vaults). ## #7: Bybit / Safe multisig UI, $1.5B (February 2025) What happened: Attackers compromised a developer machine that had access to the Safe multisig UI infrastructure. They injected malicious JavaScript that modified the transaction payload presented to Bybit signers. The signers approved what they thought was a routine transaction, but the actual on-chain payload replaced the Safe implementation with an attacker-controlled contract. Root cause: Supply chain attack on the frontend. The signing interface showed one transaction while broadcasting another. The multisig contract itself worked correctly, the humans were tricked. Dollar amount: ~$1.5B The invariant that stops it: This is a tough one because the exploit happened off-chain. But an on-chain invariant can still catch the aftermath: A property verifying that the Safe's implementation address only changes to a pre-approved whitelist of implementations would have blocked the malicious upgrade, even after the signers were tricked into signing it. This falls into the [proxy pattern](/learn/proxy-pattern) category. Whitelisting valid implementations on-chain turns a UI compromise into a failed transaction. ## #6: KiloEx oracle manipulation, $7.5M (April 2025) What happened: The perpetuals protocol's price oracle had a flaw in its validation logic. An attacker opened positions using manipulated prices, then closed them at the real price, pocketing the difference. The oracle's "freshness" check used a window that was too wide. Root cause: The oracle staleness threshold was 30 minutes, long enough for an attacker to submit stale prices that diverged from the market. The protocol also lacked cross-reference between its oracle and any secondary price source. Dollar amount: ~$7.5M The invariant that stops it: ```solidity /// @notice Oracle staleness must be within acceptable bounds function invariant_oracle_freshness() public view { (, , , uint256 updatedAt, ) = oracle.latestRoundData(); assertLe( block.timestamp - updatedAt, MAX_STALENESS, "Stale oracle price accepted" ); } ``` Two minutes of staleness is acceptable. Thirty is not. See [oracle integration testing](/learn/oracle-manipulation) for proper thresholds. ## #5: Euler finance V2 accounting bug, $200M (Ongoing vulnerability, disclosed 2025) What happened: A rounding issue in Euler V2's interest accrual allowed attackers to slowly extract value from the protocol by depositing and withdrawing in patterns that exploited the precision loss. The bug was disclosed responsibly and patched before full exploitation, but the potential loss was estimated at $200M. Root cause: Interest accrual calculations used block-by-block compounding with integer math, and the rounding consistently favored the borrower. Over thousands of blocks, small rounding errors accumulated into real value extraction. Dollar amount: ~$200M (potential, patched) The invariant that stops it: ```solidity /// @notice Protocol total value must be monotonically non-decreasing /// (ignoring legitimate losses like bad debt) function invariant_protocol_value_monotonic() public view { uint256 currentValue = protocol.totalAssets() + protocol.totalInterestOwed(); uint256 previousValue = handler.previousProtocolValue(); // Value should only increase (from interest) or stay same assertGe( currentValue + handler.acknowledgedBadDebt(), previousValue, "Protocol lost value without bad debt event" ); } ``` ## #4: Hyperliquid bridge governance, $12M (March 2025) What happened: The JELLY token market was manipulated through a series of coordinated trades across Hyperliquid's DEX. Traders opened massive short positions, then bought the token on external markets to inflate the price, forcing Hyperliquid's vault to absorb the losing positions at inflated prices. Root cause: The protocol's risk management didn't account for the cross-market manipulation vector. Position limits were checked per-account but not per-asset, and the vault's exposure to a single asset wasn't capped. Dollar amount: ~$12M The invariant that stops it: Vault exposure to any single asset must be bounded: ```solidity /// @notice Vault exposure to any single asset must stay below limit function invariant_single_asset_exposure_bounded() public view { for (uint256 i = 0; i < handler.assetCount(); i++) { address asset = handler.assets(i); uint256 exposure = vault.exposureTo(asset); uint256 totalVaultValue = vault.totalValue(); // No single asset should be more than 20% of vault assertLe( exposure * 100 / totalVaultValue, 20, "Single asset exposure exceeds limit" ); } } ``` ## #3: Infini stablecoin exploit, $49M (February 2025) What happened: A former developer retained admin privileges to the Infini stablecoin protocol. They used these retained credentials to drain funds from the protocol's vault. The access hadn't been revoked after the developer left the team. Root cause: [Access control](/blog/access-control-vulnerabilities-solidity) failure. The protocol didn't have a process for revoking access when team members departed, and there was no on-chain enforcement of access review. Dollar amount: ~$49M The invariant that stops it: ```solidity /// @notice Only current admin set can perform privileged operations function invariant_access_control_current() public view { for (uint256 i = 0; i < handler.privilegedActionCount(); i++) { PrivilegedAction memory action = handler.getAction(i); if (action.succeeded) { assertTrue( protocol.hasRole( protocol.ADMIN_ROLE(), action.caller ), "Privileged action by non-admin" ); } } } /// @notice Admin count must not exceed maximum function invariant_admin_count_bounded() public view { uint256 adminCount = protocol.getRoleMemberCount( protocol.ADMIN_ROLE() ); assertLe( adminCount, MAX_ADMINS, "Too many admins -- review access list" ); } ``` Time-bounded access (where admin roles automatically expire and must be explicitly renewed) would also have prevented this. ## #2: 1inch resolver exploit, $5M (March 2025) What happened: The 1inch Fusion swap resolver had a vulnerability in its validation of swap parameters. An attacker crafted malicious swap data that passed the resolver's checks but executed differently than expected, allowing them to extract tokens from the resolver's balance. Root cause: The resolver's order validation logic didn't fully verify all swap parameters. Input validation was incomplete, certain fields were trusted without proper bounds checking. Dollar amount: ~$5M The invariant that stops it: ```solidity /// @notice Resolver balance must never decrease except by expected fees function invariant_resolver_balance_protected() public view { uint256 currentBalance = token.balanceOf(address(resolver)); uint256 previousBalance = handler.previousResolverBalance(); uint256 feesCollected = handler.feesCollectedSinceLastCheck(); // Balance should increase from fees, never decrease unexpectedly assertGe( currentBalance + feesCollected, previousBalance, "Resolver lost funds unexpectedly" ); } ``` ## #1: Radiant capital (Continued from 2024), $58M What happened: Radiant Capital suffered from a compromised multi-sig where attackers gained control of enough private keys to authorize malicious transactions. The attackers upgraded the lending pool contracts to malicious versions that drained user funds. Root cause: Insufficient operational security around multi-sig key management, combined with the ability to upgrade critical contracts with a simple multi-sig threshold. No timelock, no on-chain delay. Dollar amount: ~$58M (across multiple chains) The invariant that stops it: ```solidity /// @notice Critical upgrades must go through timelock function invariant_upgrade_requires_timelock() public view { if (handler.upgradeAttempts() > 0) { for (uint256 i = 0; i < handler.upgradeAttempts(); i++) { UpgradeAttempt memory attempt = handler.getAttempt(i); if (attempt.succeeded) { assertGt( attempt.timestamp - attempt.queuedAt, MINIMUM_TIMELOCK_DELAY, "Upgrade executed without timelock delay" ); } } } } ``` A 48-hour timelock on upgrades would've given the community and monitoring systems time to detect and react to the malicious upgrade. See [governance security](/learn/governance-security) for proper timelock patterns. ## The pattern across all 10 exploits Let me categorize what we've seen: | Category | Exploits | Total Lost | |----------|----------|------------| | Oracle/Price Manipulation | #10, #6, #4 | ~$29M | | Access Control | #3, #1 | ~$107M | | Accounting/Rounding | #9, #5, #8 | ~$213.4M | | Input Validation | #2 | ~$5M | | Supply Chain/UI | #7 | ~$1.5B | Three themes stand out: 1. Accounting bugs are the most expensive. Rounding errors, precision loss, and share accounting bugs caused the most aggregate damage (excluding the Bybit supply chain attack, which is in its own category). Every protocol needs value conservation invariants. 2. Access control failures are preventable. Both the Infini and Radiant exploits were about *who* could perform actions, not bugs in the action logic itself. Timelocks and access monitoring catch these. 3. Oracle manipulation keeps coming back. Despite years of known attack patterns, protocols still ship with staleness windows that are too wide and missing cross-reference checks. ## What you should do Take these properties and adapt them to your protocol. You don't need all of them on day one. Start with: 1. Value conservation, total protocol value doesn't decrease without explanation 2. Share accounting, total shares = sum of individual shares 3. Access control, only authorized addresses can perform privileged operations 4. Oracle sanity, prices are fresh, bounded, and consistent across sources These four properties would've caught 8 out of 10 exploits on this list. [Invariant testing](/learn/invariant-testing) isn't a nice-to-have anymore. It's the difference between finding these bugs in your test suite and finding them on your post-mortem blog. If you haven't started yet, [how to write your first invariant test](/blog/how-to-write-first-invariant-test) takes you from zero to your first working property in about 30 minutes. For the full picture on [smart contract security](/learn/smart-contract-security) testing, [fuzzing](/learn/fuzzing) is your best tool for property verification. It doesn't prove correctness, but it finds incorrectness fast. --- Want to make sure your protocol doesn't end up on next year's list? Request an Audit or Try Recon Pro to start testing with invariants today. --- ### Reentrancy in 2025: read-only, cross-function, and cross-contract patterns **Author:** antonio | **Published:** 2026-01-12 | **Reading time:** 16 min **Tags:** reentrancy, smart-contract-security, fuzzing, invariant-testing **URL:** https://getrecon.xyz/blog/reentrancy-attacks-2025-read-only-cross-function # Reentrancy in 2025: Read-Only, Cross-Function, and Cross-Contract Patterns *By antonio — Security Researcher at Recon* If you think reentrancy is a solved problem, you're not paying attention. The classic ETH withdrawal bug? Sure, most devs know about that one. But the patterns showing up in real exploits today look nothing like the textbook `withdraw()` example. They're subtler, harder to spot in review, and they slip right past simple reentrancy guards. Let's walk through the modern reentrancy patterns that are actually draining contracts in 2025 — and more importantly, the invariant properties that catch them before an attacker does. ## The classic pattern (quick refresher) You've seen this a thousand times, but here's the skeleton so we're on the same page: ```solidity // VULNERABLE -- state update after external call function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount); (bool ok, ) = msg.sender.call{value: amount}(""); require(ok); balances[msg.sender] -= amount; // too late } ``` The fix is simple: update state before the external call, or slap a `nonReentrant` modifier on it. Most developers know this. But here's the thing: modern reentrancy doesn't look like this at all. ## Pattern 1: read-only reentrancy This one hit hard in 2023 with the Curve/Vyper exploit and has kept showing up since. The idea is deceptively simple: you don't reenter a state-changing function. You reenter a *view* function that returns stale data. ## How it works 1. Protocol A calls an external contract during a state-changing operation (say, removing liquidity from a Curve pool). 2. During that external call, the attacker's contract calls back into Protocol B. 3. Protocol B reads a price or balance from Protocol A using a view function. 4. But Protocol A's state is mid-update, so the view function returns an incorrect value. 5. Protocol B acts on the wrong price. ```solidity // Curve-style pool -- simplified contract Pool { uint256 public totalLiquidity; uint256 public tokenReserve; function removeLiquidity(uint256 shares) external { uint256 tokenAmount = (shares * tokenReserve) / totalSupply; // External call BEFORE state update // Attacker receives ETH here and can reenter (bool ok, ) = msg.sender.call{value: ethAmount}(""); require(ok); // State not yet updated -- getPrice() still returns old value totalLiquidity -= shares; tokenReserve -= tokenAmount; } // This view function returns stale data during removeLiquidity function getPrice() external view returns (uint256) { return (tokenReserve * 1e18) / totalLiquidity; } } // Protocol that reads the stale price contract VulnerableLender { Pool public pool; function getCollateralValue(address user) public view returns (uint256) { // Reads manipulated price during reentrancy window uint256 price = pool.getPrice(); return userCollateral[user] * price / 1e18; } } ``` The nasty part? `nonReentrant` on the pool's `removeLiquidity` doesn't help Protocol B at all. The view function isn't guarded, and even if it were, the downstream protocol is the one being exploited. ## The invariant that catches it ```solidity // Property: price derived from pool state should never change // within the same transaction in a way that benefits a single actor function invariant_priceConsistencyAcrossCallbacks() public returns (bool) { uint256 priceBefore = pool.getPrice(); // Trigger a liquidity operation pool.removeLiquidity(someShares); uint256 priceAfter = pool.getPrice(); // Price change per operation should be bounded uint256 delta = priceBefore > priceAfter ? priceBefore - priceAfter : priceAfter - priceBefore; // Flag if price moved more than expected for the given shares return delta <= maxExpectedDelta(someShares); } ``` When you [fuzz this with stateful testing](/learn/stateful-fuzzing), the fuzzer can interleave callback calls mid-operation and catch the window where view functions return garbage. ## Pattern 2: cross-function reentrancy This one doesn't reenter the same function. It reenters a *different* function on the same contract, one that the `nonReentrant` modifier might not cover. ## How it works ```solidity contract Vulnerable { mapping(address => uint256) public balances; bool private locked; modifier nonReentrant() { require(!locked); locked = true; _; locked = false; } // Protected with nonReentrant function withdraw() external nonReentrant { uint256 bal = balances[msg.sender]; (bool ok, ) = msg.sender.call{value: bal}(""); require(ok); balances[msg.sender] = 0; } // NOT protected -- attacker reenters here function transfer(address to, uint256 amount) external { require(balances[msg.sender] >= amount); balances[msg.sender] -= amount; balances[to] += amount; } } ``` During the `withdraw` callback, the attacker calls `transfer` to move their balance to another address before it gets zeroed out. If the `nonReentrant` guard isn't global (or if `transfer` doesn't share it), the attack works. This showed up in multiple real exploits where teams protected their "dangerous" functions but left "safe" functions unguarded. ## The invariant that catches it ```solidity // Property: total balance accounting must be conserved function invariant_balanceSolvency() public returns (bool) { uint256 totalTracked = 0; for (uint256 i = 0; i < actors.length; i++) { totalTracked += vulnerable.balances(actors[i]); } // Sum of all user balances must never exceed contract's actual ETH return totalTracked <= address(vulnerable).balance; } ``` This is a classic solvency invariant, and it's exactly the kind of property that [invariant testing](/learn/invariant-testing) excels at finding violations for. The fuzzer discovers the withdraw-then-transfer sequence on its own. ## Pattern 3: cross-contract reentrancy Now we're talking about callbacks that flow across contract boundaries. Contract A calls Contract B, which calls back into Contract C, which reads stale state from Contract A. It's the same principle as read-only reentrancy but across a multi-contract system. ## How it works Think of a DeFi protocol with separate Vault, Controller, and Oracle contracts: ```solidity contract Vault { Controller public controller; function liquidate(address user) external { uint256 debt = controller.getDebt(user); uint256 collateral = controller.getCollateral(user); // Seize collateral -- triggers external transfer IERC20(collateralToken).transfer(msg.sender, collateral); // State update happens AFTER transfer controller.clearPosition(user); } } contract Controller { mapping(address => uint256) public debts; mapping(address => uint256) public collaterals; // During the collateral transfer in Vault.liquidate(), // this still returns pre-liquidation values function getDebt(address user) external view returns (uint256) { return debts[user]; } } ``` If the collateral token has transfer callbacks (ERC-777, or an ERC-1155 with hooks), the attacker reenters during the transfer. They can interact with any other protocol that reads from Controller. Controller still shows the pre-liquidation state. ## The invariant ```solidity // Property: system-wide accounting consistency function invariant_crossContractConsistency() public returns (bool) { for (uint256 i = 0; i < users.length; i++) { uint256 vaultView = vault.userCollateral(users[i]); uint256 controllerView = controller.getCollateral(users[i]); // These two views must ALWAYS agree if (vaultView != controllerView) return false; } return true; } ``` The key insight: when you fuzz with [stateful sequences](/blog/stateful-fuzzing-explained-sequence-matters), the fuzzer will naturally interleave calls across contracts and find states where views disagree. ## Pattern 4: ERC-777 and ERC-1155 callback reentrancy These token standards have built-in hooks (`tokensReceived` for ERC-777, `onERC1155Received` for ERC-1155) that execute code on the recipient during transfers. Any contract that handles these tokens without reentrancy protection is at risk. ## The classic ERC-777 trap ```solidity contract VulnerableExchange { IERC777 public token; mapping(address => uint256) public deposits; function deposit(uint256 amount) external { token.operatorSend(msg.sender, address(this), amount, "", ""); deposits[msg.sender] += amount; // State update after hook } function withdraw(uint256 amount) external { require(deposits[msg.sender] >= amount); deposits[msg.sender] -= amount; token.send(msg.sender, amount, ""); // Triggers tokensReceived hook } } ``` The imBTC Uniswap V1 drain in 2020 used exactly this pattern. ERC-777's `tokensReceived` hook let attackers reenter during every swap. ## Properties for token callback reentrancy ```solidity // Property: no value extraction beyond deposits function invariant_noFreeTokens() public returns (bool) { uint256 totalDeposits = 0; for (uint256 i = 0; i < actors.length; i++) { totalDeposits += exchange.deposits(actors[i]); } uint256 actualBalance = token.balanceOf(address(exchange)); // Contract should never have fewer tokens than total deposits return actualBalance >= totalDeposits; } ``` ## Building your reentrancy test suite Here's how I'd structure invariant tests for reentrancy across all four patterns: ```solidity contract ReentrancyInvariantTest is Test { // Actors include attacker contracts with callback hooks MaliciousReceiver attacker; ERC777Attacker tokenAttacker; function setUp() public { // Deploy system // Deploy attacker contracts that implement: // - receive() for ETH callbacks // - tokensReceived() for ERC-777 // - onERC1155Received() for ERC-1155 attacker = new MaliciousReceiver(address(target)); tokenAttacker = new ERC777Attacker(address(exchange)); } // Core solvency -- catches cross-function and classic reentrancy function invariant_solvency() public { assertGe( address(target).balance, target.totalDeposits(), "Solvency violated" ); } // Price consistency -- catches read-only reentrancy function invariant_priceNeverStale() public { uint256 computedPrice = target.computeSpotPrice(); uint256 cachedPrice = target.getPrice(); uint256 tolerance = computedPrice / 100; // 1% assertApproxEqAbs(computedPrice, cachedPrice, tolerance); } // Cross-contract consistency -- catches cross-contract reentrancy function invariant_stateAgreement() public { assertEq( vault.totalAssets(), controller.totalTrackedAssets(), "Cross-contract state mismatch" ); } } ``` The trick is making sure your fuzzer's actor set includes contracts with callback hooks. Without those, you'll never trigger the reentrancy paths. Both [Echidna](/learn/echidna) and [Medusa](/learn/medusa) support this, so you just need to deploy the attacker contracts in your setup and include them in the target list. ## Mitigations that actually work 1. **Global reentrancy locks.** Not per-function, but per-contract or per-system. OpenZeppelin's `ReentrancyGuard` works, but only if every external-facing function uses it. 2. **Checks-Effects-Interactions pattern.** Still the gold standard. Update all state before making any external call. Period. 3. **For read-only reentrancy:** Add reentrancy guards to view functions that downstream contracts might call. Curve added this after their exploit (`reentrancy_lock` checks on `get_virtual_price()`). 4. **For cross-contract:** The consuming protocol needs to protect itself. You can't rely on the upstream contract's guards. Consider using reentrancy-aware oracles or adding your own staleness checks. 5. **Token hook awareness:** If you're handling ERC-777 or ERC-1155, treat every `transfer`/`send` as a potential callback entry point. ## The bottom line Reentrancy hasn't gone away, it's evolved. The patterns that hit protocols today aren't the ones in your Solidity 101 course. Read-only reentrancy, cross-function, cross-contract, and callback-based variants all exploit the same fundamental issue: external calls happen before state settles. Invariant testing catches these because it doesn't care about the *path* to the violation. It checks the *property*. If solvency breaks, if prices go stale, if cross-contract state disagrees, the fuzzer finds the sequence that causes it. Want to test your protocol against these patterns? Try Recon Pro and run stateful fuzzing campaigns that include callback-equipped actors. Or if you'd rather have experts write the properties, request an audit and we'll build a custom reentrancy test suite for your codebase. ## Further reading - [Reentrancy Patterns Deep Dive](/learn/reentrancy) - [Invariant Testing Guide](/learn/invariant-testing) - [Fuzzing Fundamentals](/learn/fuzzing) - [Why Stateful Fuzzing Catches Bugs That Unit Tests Miss](/blog/stateful-fuzzing-explained-sequence-matters) --- ### Price manipulation attacks: spot prices, TWAPs, and how to fuzz your defenses **Author:** nican0r | **Published:** 2026-03-26 | **Reading time:** 14 min **Tags:** oracle, defi-security, fuzzing, invariant-testing **URL:** https://getrecon.xyz/blog/price-manipulation-attacks-testing-guide # Price Manipulation Attacks: Spot Prices, TWAPs, and How to Fuzz Your Defenses *By nican0r — Security Researcher at Recon* Price feeds are the backbone of DeFi. Lending protocols need them for collateral valuation. DEXes use them for routing. Vaults depend on them for share pricing. And attackers know this — manipulate a price feed, and you can drain almost anything downstream. I've seen dozens of protocols assume their oracle setup is safe because they're "using Chainlink" or "using a TWAP." Neither is a magic shield. The devil's in the details, and the only way to know your defenses actually hold is to test them. Let's break down each attack vector, look at real examples, and build the invariant properties that catch manipulation before it hits mainnet. ## Attack vector 1: spot price manipulation via flash loans This is the most common oracle attack. It's straightforward: borrow a huge amount via flash loan and dump it into a pool to move the spot price. Then trigger a protocol action that reads the manipulated price, profit, and repay the flash loan. ## The mechanism ``` 1. Flash borrow 100M USDC 2. Swap into Pool X → spot price of TOKEN/USDC spikes 3. Call VulnerableProtocol.borrow() which reads Pool X's spot price 4. Protocol thinks your TOKEN collateral is worth 10x more 5. Borrow far more than your collateral is actually worth 6. Swap back, repay flash loan, keep the excess borrows ``` ## Real example: Harvest Finance (2020) Harvest Finance lost ~$34M because their vault used Curve pool spot prices for USDC valuation. The attacker repeatedly flash-loaned USDC, moved the Curve pool price, deposited into Harvest at the inflated price, then reversed the swap. Each cycle extracted value. ## Vulnerable code pattern ```solidity contract VulnerableLender { IUniswapV2Pair public pair; function getTokenPrice() public view returns (uint256) { // NEVER DO THIS -- reads manipulable spot price (uint112 reserve0, uint112 reserve1, ) = pair.getReserves(); return (uint256(reserve1) * 1e18) / uint256(reserve0); } function borrow(uint256 collateralAmount) external { uint256 price = getTokenPrice(); uint256 collateralValue = collateralAmount * price / 1e18; uint256 maxBorrow = collateralValue * 75 / 100; // 75% LTV // Attacker gets inflated maxBorrow _mint(msg.sender, maxBorrow); } } ``` ## The invariant ```solidity // Property: price used for lending decisions should not deviate // significantly from a reference oracle within a single block function invariant_priceNotManipulable() public returns (bool) { uint256 protocolPrice = lender.getTokenPrice(); uint256 referencePrice = chainlinkOracle.latestAnswer(); // Allow 5% deviation -- anything more signals manipulation uint256 deviation = protocolPrice > referencePrice ? protocolPrice - referencePrice : referencePrice - protocolPrice; return deviation <= (referencePrice * 5 / 100); } // Property: no single transaction should enable borrowing // more than collateral value at fair price function invariant_borrowNeverExceedsCollateral() public returns (bool) { for (uint256 i = 0; i < actors.length; i++) { uint256 borrowed = lender.borrowBalance(actors[i]); uint256 collateralAtFairPrice = lender.collateralOf(actors[i]) * fairOracle.getPrice() / 1e18; if (borrowed > collateralAtFairPrice) return false; } return true; } ``` ## Attack vector 2: TWAP manipulation TWAPs (Time-Weighted Average Prices) are supposed to resist flash loan attacks because they average the price over time. And they do, for single-block manipulation. But they're not immune to sustained manipulation over multiple blocks. ## The mechanism Uniswap V2/V3 TWAPs accumulate price*time observations. If an attacker can move the spot price and *hold it there* across multiple blocks, the TWAP shifts. The cost depends on: - Pool liquidity (deeper pool = more expensive to move) - TWAP window length (longer window = more blocks needed) - Block time (shorter blocks = more observations needed) ``` Block N: Spot price = $100, TWAP = $100 Block N+1: Attacker pushes spot to $200, holds position Block N+2: Still at $200 ... Block N+K: TWAP has now shifted toward $200 ``` ## When TWAPs break Short TWAP windows (5-15 minutes) on low-liquidity pools are the weak spot. An attacker with enough capital can rent liquidity, push the price for a few blocks, and manipulate the TWAP enough to extract value. The Euler Finance exploit in 2023 didn't use TWAP manipulation directly. But the Mango Markets exploit (2022, ~$114M) showed that sustained price manipulation across blocks is absolutely practical on low-liquidity markets. ## Testing TWAP resistance ```solidity // Property: TWAP-based valuations shouldn't allow more borrowing // than what a trusted reference oracle would permit function invariant_twapBoundedByReference() public returns (bool) { uint256 twapPrice = oracle.getTWAP(token, WINDOW); uint256 chainlinkPrice = chainlinkFeed.latestAnswer(); // TWAP should stay within 10% of Chainlink over any window uint256 maxDeviation = chainlinkPrice / 10; uint256 diff = twapPrice > chainlinkPrice ? twapPrice - chainlinkPrice : chainlinkPrice - twapPrice; return diff <= maxDeviation; } // Property: TWAP shouldn't change more than X% per block function invariant_twapChangeRate() public returns (bool) { uint256 currentTwap = oracle.getTWAP(token, WINDOW); uint256 previousTwap = lastRecordedTwap; if (previousTwap == 0) { lastRecordedTwap = currentTwap; return true; } uint256 maxChangePerBlock = previousTwap / 50; // 2% max per block uint256 change = currentTwap > previousTwap ? currentTwap - previousTwap : previousTwap - currentTwap; lastRecordedTwap = currentTwap; return change <= maxChangePerBlock; } ``` ## Attack vector 3: oracle sandwich attacks This is a DeFi-specific variant. The attacker spots a pending oracle update transaction in the mempool, sandwiches it with their own transactions. ## The flow ``` 1. Attacker sees Chainlink price update TX in mempool (e.g., ETH price going from $3000 → $3100) 2. Front-run: Deposit collateral, borrow at old price ($3000) 3. Oracle update executes: price moves to $3100 4. Back-run: Collateral now worth more at $3100, borrow additional funds or close position at profit ``` This works because on-chain oracles have discrete update points. Between updates, there's a known stale price. If the protocol doesn't account for this gap, traders can extract value at every update. ## Properties for oracle freshness ```solidity // Property: protocol should reject actions when oracle data is stale function invariant_oracleNotStale() public returns (bool) { (, , , uint256 updatedAt, ) = priceFeed.latestRoundData(); uint256 staleness = block.timestamp - updatedAt; // If oracle is stale, no borrows should succeed if (staleness > MAX_STALENESS) { // Check that protocol correctly paused return lender.isPaused(); } return true; } // Property: position value change from oracle update // should not create instant arbitrage function invariant_noOracleUpdateArbitrage() public returns (bool) { for (uint256 i = 0; i < actors.length; i++) { uint256 deposited = lender.totalDeposited(actors[i]); uint256 withdrawn = lender.totalWithdrawn(actors[i]); uint256 borrowed = lender.totalBorrowed(actors[i]); uint256 repaid = lender.totalRepaid(actors[i]); // Net extraction should never exceed reasonable bounds int256 netPnl = int256(withdrawn + borrowed) - int256(deposited + repaid); if (netPnl > int256(deposited / 10)) return false; // 10% max } return true; } ``` ## Attack vector 4: multi-oracle discrepancy Protocols that use multiple price sources (say, Chainlink for ETH/USD and a Uniswap TWAP for TOKEN/ETH) can get hit when these oracles disagree. The attacker manipulates the one that's cheaper to move while the other stays stable. ## Testing multi-oracle systems ```solidity // Property: when protocol uses multiple oracles, // derived prices must be consistent function invariant_oracleConsistency() public returns (bool) { uint256 priceFromChainlink = chainlinkAdapter.getPrice(token); uint256 priceFromTWAP = twapAdapter.getPrice(token); // If oracles diverge beyond threshold, protocol should use // the more conservative (lower for collateral, higher for debt) uint256 protocolPrice = protocol.getEffectivePrice(token); if (protocolPrice > priceFromChainlink && protocolPrice > priceFromTWAP) { // Protocol is using a price higher than BOTH oracles // This is exploitable return false; } return true; } ``` ## Chainlink vs TWAP vs hybrid: what works Let me be direct about the tradeoffs. Chainlink feeds: - Resistant to flash loan manipulation (off-chain aggregation) - Can go stale during congestion or extreme volatility - Limited token coverage (not every pair has a feed) - Heartbeat and deviation thresholds mean price updates aren't continuous Uniswap V3 TWAPs: - Available for any pool that exists - Resistant to single-block manipulation - Vulnerable to multi-block attacks on low-liquidity pools - Free to read (built into the pool) Hybrid approach (what works best): - Use Chainlink as primary, TWAP as fallback - Require both to agree within a tolerance band - If they disagree, use the more conservative price - Add circuit breakers for extreme deviations ```solidity contract HybridOracle { uint256 constant MAX_DEVIATION = 500; // 5% function getPrice(address token) external view returns (uint256) { uint256 clPrice = getChainlinkPrice(token); uint256 twapPrice = getTWAPPrice(token); uint256 diff = clPrice > twapPrice ? clPrice - twapPrice : twapPrice - clPrice; uint256 deviation = (diff * 10000) / clPrice; if (deviation > MAX_DEVIATION) { // Circuit breaker -- use lower price for safety return clPrice < twapPrice ? clPrice : twapPrice; } // Normal case -- use Chainlink as primary return clPrice; } } ``` ## Fuzzing your oracle defenses Here's how to structure a [fuzzing](/learn/fuzzing) campaign specifically for price manipulation resistance: 1. **Create an attacker actor** that can flash loan, swap into pools, and interact with your protocol, all in one transaction. 2. **Give the fuzzer control of oracle prices.** Mock your oracle and let the fuzzer set arbitrary prices. 3. **Write properties about outcomes**, not about prices themselves. "No user should extract more value than they deposited" is better than "price should be correct." 4. **Include multi-block sequences.** Use [stateful fuzzing](/learn/stateful-fuzzing) to test TWAP manipulation across multiple blocks. ```solidity // Fuzzing harness for oracle manipulation testing contract OracleFuzzHarness is Test { MockChainlinkFeed mockFeed; // Let fuzzer set any price function setOraclePrice(uint256 price) external { price = bound(price, 1, type(uint128).max); mockFeed.setPrice(int256(price)); } // Let fuzzer advance time (simulates stale oracle) function advanceTime(uint256 seconds_) external { seconds_ = bound(seconds_, 1, 7 days); vm.warp(block.timestamp + seconds_); } // Core invariant: system solvency function invariant_systemSolvent() public { uint256 totalDeposits = lender.totalDeposits(); uint256 totalBorrows = lender.totalBorrows(); uint256 actualAssets = token.balanceOf(address(lender)); assertGe( actualAssets + totalBorrows, totalDeposits, "System is insolvent" ); } } ``` ## What I've seen teams get wrong After reviewing dozens of oracle implementations, here are the patterns that keep failing: 1. **Using spot prices anywhere.** It doesn't matter if it's "just for the UI." If it touches a state-changing function, it's exploitable. Check every `getReserves()` call. 2. **Short TWAP windows on thin pools.** A 5-minute TWAP on a pool with $500K liquidity? That's basically a spot price with extra steps. 3. **No staleness checks.** Chainlink feeds can go hours without updates. If your protocol doesn't check `updatedAt`, you're using yesterday's price in a crash. 4. **Missing circuit breakers.** Price drops 90% in one block? That's probably manipulation, not a real market event. Your protocol should pause, not liquidate everything. 5. Trusting a single source. One oracle, one point of failure. Always cross-reference. ## The bottom line Price manipulation isn't going away. Flash loans make it free to attempt, and every new DeFi primitive creates new oracle dependencies to attack. The only reliable defense is testing your assumptions. Write properties about what your protocol should guarantee regardless of what the oracle says, and [fuzz those properties](/learn/invariant-testing) until they either hold or break. Want to test your oracle defenses against manipulation? Try Recon Pro to run stateful fuzzing campaigns with attacker actors. Or request an audit to get expert-written oracle resistance properties for your specific protocol. ## Further reading - [Oracle Manipulation Patterns](/learn/oracle-manipulation) - [Flash Loan Attack Vectors](/learn/flash-loan-attack) - [Fuzzing Fundamentals](/learn/fuzzing) - [Invariant Testing Guide](/learn/invariant-testing) - [Flash Loan Exploits and Invariant Testing](/blog/flash-loan-exploits-invariant-testing) --- ### The ERC-4626 donation attack: first depositor exploit explained and tested **Author:** antonio | **Published:** 2026-03-16 | **Reading time:** 14 min **Tags:** erc-4626, vulnerabilities, invariant-testing, defi-security **URL:** https://getrecon.xyz/blog/donation-attack-erc4626-first-depositor # The ERC-4626 Donation Attack: First Depositor Exploit Explained and Tested *By antonio — Security Researcher at Recon* If you're building a vault — any vault that holds assets and issues shares, you need to understand this attack. The ERC-4626 donation attack (also called the "first depositor" or "share inflation" attack) is one of the most common vulnerabilities in DeFi vault implementations. It's elegant in its simplicity, devastating in its impact, and frustratingly easy to miss in code review. I'm going to walk you through exactly how it works, show you real code, explain every mitigation strategy I've seen, and give you the invariant properties that catch it. Let's get into it. ## How share-based vaults work ERC-4626 vaults follow a simple model: you deposit assets, you get shares. The exchange rate between assets and shares determines how much you can withdraw later. ```solidity // Standard ERC-4626 share calculation function convertToShares(uint256 assets) public view returns (uint256) { uint256 supply = totalSupply(); return supply == 0 ? assets // First deposit: 1:1 ratio : assets * supply / totalAssets(); } function convertToAssets(uint256 shares) public view returns (uint256) { uint256 supply = totalSupply(); return supply == 0 ? shares : shares * totalAssets() / supply; } ``` The key formula: `shares = assets * totalSupply / totalAssets` This works fine when there are many shares and many assets. But when the vault is near-empty? That's where things get ugly. ## The attack: step by step Here's the complete flow. I'll use concrete numbers so you can follow the math. ## Setup - A new ERC-4626 vault just deployed. Zero deposits. - Alice is an honest user about to deposit 10,000 USDC. - Eve (the attacker) sees Alice's transaction in the mempool. ## Step 1: Eve front-runs with minimal deposit Eve deposits 1 wei (0.000000000000000001 USDC). ``` Vault state: totalAssets = 1 wei totalSupply = 1 share (1 wei worth of shares) Eve's shares = 1 ``` ## Step 2: Eve donates directly to the vault Eve transfers 10,000 USDC directly to the vault contract, not through `deposit()`, just a raw ERC-20 `transfer()`. ``` Vault state: totalAssets = 10,000 USDC + 1 wei (direct transfer inflated this) totalSupply = 1 share (unchanged -- no deposit() call) Eve's shares = 1 Exchange rate: 1 share = ~10,000 USDC ``` ## Step 3: Alice's deposit executes Alice deposits her 10,000 USDC through `deposit()`. ``` shares = assets * totalSupply / totalAssets shares = 10,000 * 1 / 10,000.000000000000000001 shares = 0 (rounds down to zero!) ``` Alice gets **zero shares** for her 10,000 USDC. The integer division rounds down because the exchange rate is so inflated. ## Step 4: Eve withdraws Eve redeems her 1 share. ``` assets = shares * totalAssets / totalSupply assets = 1 * 20,000 / 1 assets = 20,000 USDC ``` Eve walks away with 20,000 USDC: her original 10,000 plus Alice's 10,000. ## The math that kills The core issue is integer division rounding. When `totalAssets` is massive relative to `totalSupply`, new deposits convert to zero shares: ``` 10,000 * 1 / 10,001 = 0.9999... → rounds to 0 ``` Even if the victim gets 1 share instead of 0, they still lose most of their deposit. The attacker just needs the donation to be large enough relative to incoming deposits. ## The vulnerable code Here's a minimal vulnerable vault: ```solidity contract VulnerableVault is ERC20 { IERC20 public asset; function deposit(uint256 assets, address receiver) external returns (uint256 shares) { shares = totalSupply() == 0 ? assets : assets * totalSupply() / totalAssets(); require(shares > 0, "zero shares"); asset.transferFrom(msg.sender, address(this), assets); _mint(receiver, shares); } function totalAssets() public view returns (uint256) { // This reads the actual token balance -- // includes direct transfers (donations) return asset.balanceOf(address(this)); } } ``` The problem is `totalAssets()` using `balanceOf`. Direct transfers inflate it without minting shares. ## Real exploits This isn't theoretical. Several protocols got hit: - Yearn V1 vaults had this vector before they added protections. - Multiple ERC-4626 wrappers deployed in 2023-2024 were vulnerable. - The attack was demonstrated against early Morpho vault implementations. - Various yield aggregators on L2s were drained using this exact pattern. Even OpenZeppelin's initial ERC-4626 implementation needed a security advisory about this. It's that common. ## Mitigation strategies There are four main approaches, each with different tradeoffs. ## 1. Virtual shares and assets (recommended) Add a virtual offset to both totalSupply and totalAssets so the vault is never "empty": ```solidity contract SafeVault is ERC20 { IERC20 public asset; uint256 internal constant VIRTUAL_SHARES = 1e6; uint256 internal constant VIRTUAL_ASSETS = 1; function convertToShares(uint256 assets) public view returns (uint256) { return assets * (totalSupply() + VIRTUAL_SHARES) / (totalAssets() + VIRTUAL_ASSETS); } function convertToAssets(uint256 shares) public view returns (uint256) { return shares * (totalAssets() + VIRTUAL_ASSETS) / (totalSupply() + VIRTUAL_SHARES); } } ``` With virtual shares of 1e6, the attacker would need to donate 1e6 times more to achieve the same rounding effect. OpenZeppelin's latest ERC-4626 uses this approach with a configurable offset. **Tradeoff:** Tiny rounding loss on all deposits (usually < 1 wei). It's practically free. ## 2. Dead shares (burn on first deposit) On the first deposit, mint a minimum number of shares to a dead address: ```solidity function deposit(uint256 assets, address receiver) external returns (uint256 shares) { if (totalSupply() == 0) { uint256 deadShares = 1000; shares = assets - deadShares; _mint(address(0xdead), deadShares); _mint(receiver, shares); } else { shares = assets * totalSupply() / totalAssets(); _mint(receiver, shares); } asset.transferFrom(msg.sender, address(this), assets); } ``` Those dead shares can never be redeemed, so `totalSupply` always stays above 1000. An attacker would need to donate enough to make each of those 1000 shares worth more than the victim's entire deposit. **Tradeoff:** First depositor loses a tiny amount. Fair enough. ## 3. Minimum deposit enforcement Require a minimum first deposit: ```solidity uint256 constant MIN_FIRST_DEPOSIT = 1e6; // 1 USDC for 6-decimal tokens function deposit(uint256 assets, address receiver) external { if (totalSupply() == 0) { require(assets >= MIN_FIRST_DEPOSIT, "Below minimum"); } // ... } ``` This makes the attack more expensive but doesn't eliminate it. The attacker just needs a proportionally larger donation. **Tradeoff:** Not a complete fix. Use alongside other methods. ## 4. Internal accounting (don't use balanceOf) Track assets internally instead of reading `balanceOf`: ```solidity contract InternalAccountingVault is ERC20 { uint256 internal _totalAssets; function deposit(uint256 assets, address receiver) external { // ... mint shares ... asset.transferFrom(msg.sender, address(this), assets); _totalAssets += assets; } function totalAssets() public view returns (uint256) { return _totalAssets; // Donations don't affect this } } ``` Direct token transfers still land in the contract, but they don't affect the exchange rate because `totalAssets` is tracked separately. **Tradeoff:** You need to handle yield accrual differently. If the vault earns yield through rebasing or airdrops, internal accounting won't capture it automatically. ## The invariant properties that catch it Here's what matters most, the properties that break when this attack happens. These are what you'd write for a [fuzzing campaign](/learn/fuzzing). ## Property 1: no depositor gets zero shares ```solidity // If you deposit a non-trivial amount, you MUST get shares function invariant_noZeroShareDeposits() public returns (bool) { // After every deposit action, check: // if deposited amount > MIN_MEANINGFUL_AMOUNT, // shares received > 0 for (uint256 i = 0; i < actors.length; i++) { uint256 deposited = vault.totalDepositedBy(actors[i]); uint256 shares = vault.balanceOf(actors[i]); if (deposited > MIN_MEANINGFUL_AMOUNT && shares == 0) { return false; } } return true; } ``` ## Property 2: withdrawal value approximates deposit value ```solidity // What you can withdraw should be close to what you deposited // (minus fees, plus yield, within tolerance) function invariant_depositWithdrawSymmetry() public returns (bool) { for (uint256 i = 0; i < actors.length; i++) { uint256 shares = vault.balanceOf(actors[i]); if (shares == 0) continue; uint256 redeemable = vault.convertToAssets(shares); uint256 deposited = ghost_deposited[actors[i]]; // Should get back at least 99% of deposit // (allowing for small rounding) if (redeemable < deposited * 99 / 100) { return false; } } return true; } ``` ## Property 3: exchange rate bounded change ```solidity // Exchange rate should not change dramatically in one transaction function invariant_exchangeRateBounded() public returns (bool) { uint256 currentRate = vault.totalSupply() > 0 ? vault.totalAssets() * 1e18 / vault.totalSupply() : 1e18; if (ghost_lastExchangeRate > 0) { uint256 change = currentRate > ghost_lastExchangeRate ? currentRate - ghost_lastExchangeRate : ghost_lastExchangeRate - currentRate; // Rate shouldn't jump more than 10% in any single operation if (change > ghost_lastExchangeRate / 10) { return false; } } ghost_lastExchangeRate = currentRate; return true; } ``` ## Property 4: no value extraction beyond deposits ```solidity // No user should withdraw more than they deposited (ignoring yield) // This catches the attacker profiting from the attack function invariant_noFreeValue() public returns (bool) { for (uint256 i = 0; i < actors.length; i++) { uint256 totalIn = ghost_totalDeposited[actors[i]]; uint256 totalOut = ghost_totalWithdrawn[actors[i]]; // In a vault with no yield source active, // no one should extract more than they put in if (totalOut > totalIn + DUST_THRESHOLD) { return false; } } return true; } ``` ## Property 5: share inflation detection ```solidity // totalAssets and totalSupply should grow proportionally function invariant_noShareInflation() public returns (bool) { uint256 supply = vault.totalSupply(); uint256 assets = vault.totalAssets(); if (supply == 0) return true; // Price per share shouldn't be astronomical // (sign of donation attack) uint256 pricePerShare = assets * 1e18 / supply; // If one share is worth more than 1M tokens, something's wrong return pricePerShare < 1e24; // 1M * 1e18 } ``` ## Running the fuzzing campaign Here's how I'd set up the full [invariant testing](/learn/invariant-testing) harness: ```solidity contract DonationAttackTest is Test { Vault vault; MockERC20 token; address attacker = address(0xBAD); address victim = address(0xBEEF); function setUp() public { token = new MockERC20("USDC", "USDC", 6); vault = new Vault(address(token)); // Fund actors token.mint(attacker, 1_000_000e6); token.mint(victim, 100_000e6); vm.prank(attacker); token.approve(address(vault), type(uint256).max); vm.prank(victim); token.approve(address(vault), type(uint256).max); } // Handler: fuzzer can deposit any amount as any actor function handler_deposit(uint256 amount, bool isAttacker) external { address actor = isAttacker ? attacker : victim; amount = bound(amount, 1, token.balanceOf(actor)); vm.prank(actor); vault.deposit(amount, actor); } // Handler: fuzzer can donate (direct transfer) function handler_donate(uint256 amount) external { amount = bound(amount, 1, token.balanceOf(attacker)); vm.prank(attacker); token.transfer(address(vault), amount); } // Handler: fuzzer can withdraw function handler_withdraw(uint256 shares, bool isAttacker) external { address actor = isAttacker ? attacker : victim; shares = bound(shares, 1, vault.balanceOf(actor)); if (shares == 0) return; vm.prank(actor); vault.redeem(shares, actor, actor); } // Invariants function invariant_noStolenValue() public { uint256 attackerNet = ghost_withdrawn[attacker]; uint256 attackerDeposited = ghost_deposited[attacker]; uint256 attackerDonated = ghost_donated[attacker]; // Attacker shouldn't profit net of donations assertLe( attackerNet, attackerDeposited + attackerDonated + 1, "Attacker extracted value" ); } } ``` The fuzzer will naturally discover the deposit-1-wei → donate → wait-for-victim → withdraw sequence. It doesn't need to know about the attack. It just tries random actions and checks the properties. That's the entire point of invariant testing. You don't encode the attack. You encode what *should always be true*, and the fuzzer finds the attack for you. ## Which mitigation should you use? My recommendation: **virtual shares + internal accounting**. Together they make the attack economically impractical and structurally impossible. Virtual shares with an offset of 1e6 mean the attacker needs to donate 1e6 tokens per 1 token of victim loss. Internal accounting means donations don't affect the exchange rate at all. Belt and suspenders. If you're using OpenZeppelin's ERC-4626, they've already got the virtual offset built in since v5. Just make sure you're using a recent version and haven't accidentally overridden `_decimalsOffset()` to return 0. ## The bottom line The donation attack is a textbook example of why you can't just review code, you have to test it. The vulnerable code looks correct. The math is right for normal conditions. It only breaks under a specific sequence of operations that a human reviewer might not think to check. [Invariant testing](/learn/invariant-testing) catches it because it doesn't care about the sequence. It checks the *result*: did anyone get free money? Did any depositor lose funds? Did the exchange rate jump suspiciously? Want to test your vault against donation attacks and other [ERC-4626 edge cases](/blog/how-to-fuzz-erc4626-vaults)? Try Recon Pro to run fuzzing campaigns with built-in vault property suites. Or request an audit and we'll write custom invariants for your specific vault design. ## Further reading - [ERC-4626 Vault Security](/learn/erc-4626) - [Vault Security Patterns](/learn/vault-security) - [How to Fuzz ERC-4626 Vaults](/blog/how-to-fuzz-erc4626-vaults) - [Rounding Errors in DeFi](/blog/rounding-errors-defi-small-bugs-big-exploits) - [Invariant Testing Guide](/learn/invariant-testing) --- ### Signature replay and permit attacks: testing EIP-712 and ERC-2612 with fuzzing **Author:** deivitto | **Published:** 2026-04-01 | **Reading time:** 14 min **Tags:** smart-contract-security, fuzzing, vulnerabilities, eip-712 **URL:** https://getrecon.xyz/blog/signature-replay-permit-frontrunning-attacks # Signature Replay and Permit Attacks: Testing EIP-712 and ERC-2612 with Fuzzing *By deivitto — Security Researcher at Recon* Signatures are everywhere in DeFi. Token approvals, gasless transactions, governance votes, meta-transactions — they all rely on off-chain signatures verified on-chain. And every one of them is a potential attack surface if the implementation isn't tight. I've audited protocols where the signature logic looked correct on first pass but fell apart under [fuzzing](/learn/fuzzing). Replay attacks across chains, nonce gaps, permit griefing, deadline issues, these bugs hide in the interaction between off-chain signing and on-chain verification. Let's break down each pattern, look at the real damage they've caused, and build the properties that catch them. ## How EIP-712 structured signing works Before we talk attacks, let's make sure the foundation is clear. EIP-712 defines a standard for signing typed structured data. Instead of signing a raw hash (which users can't verify in their wallet), you sign a structured object with a domain separator: ```solidity bytes32 constant DOMAIN_TYPEHASH = keccak256( "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" ); bytes32 constant PERMIT_TYPEHASH = keccak256( "Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)" ); function DOMAIN_SEPARATOR() public view returns (bytes32) { return keccak256(abi.encode( DOMAIN_TYPEHASH, keccak256(bytes(name)), keccak256(bytes("1")), block.chainid, address(this) )); } ``` The domain separator binds the signature to a specific contract on a specific chain. The typehash binds it to a specific action. Together they *should* prevent replay, but "should" and "do" aren't the same thing. ## Attack 1: cross-Chain replay ## The mechanism When a protocol deploys on multiple chains (Ethereum, Arbitrum, Polygon, etc.), every signed message needs to be chain-specific. If the domain separator doesn't include `chainId`, a signature valid on Ethereum is also valid on Arbitrum. ```solidity // VULNERABLE -- no chainId in domain separator bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode( DOMAIN_TYPEHASH, keccak256(bytes(name)), keccak256(bytes("1")), // chainId missing! address(this) )); ``` Even worse: some protocols compute the domain separator at deploy time and cache it. If the chain forks (like ETH/ETH Classic), the cached separator is valid on both chains. ## Real impact After the Ethereum/Ethereum Classic split, several tokens had cross-chain replay issues. More recently, L2 deployments have reintroduced this, protocols that hardcode `chainId = 1` in their domain separator and then deploy on Arbitrum (chainId 42161) are vulnerable. Wintermute lost ~$20M on Optimism partly due to a replay-adjacent issue where the deployment address was different but the signature scheme wasn't chain-specific enough. ## The invariant ```solidity // Property: domain separator must include current chain ID function invariant_domainSeparatorIncludesChainId() public returns (bool) { bytes32 separator = token.DOMAIN_SEPARATOR(); // Compute expected separator with current chainId bytes32 expected = keccak256(abi.encode( token.DOMAIN_TYPEHASH(), keccak256(bytes(token.name())), keccak256(bytes("1")), block.chainid, address(token) )); return separator == expected; } // Property: changing chainId should invalidate all existing signatures function invariant_chainForkInvalidatesSignatures( uint256 fakeChainId ) public returns (bool) { // Simulate chain fork by changing chainId vm.chainId(fakeChainId); // Any previously valid signature should now fail bytes32 newSeparator = token.DOMAIN_SEPARATOR(); return newSeparator != ghost_originalSeparator; } ``` ## Attack 2: same-Chain replay (Nonce reuse) ## The mechanism ERC-2612 permits use nonces to prevent replay. Each signature includes a nonce, and the contract increments the user's nonce after use. But what happens if the nonce tracking is broken? ```solidity // VULNERABLE -- nonce not incremented function permit( address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external { require(block.timestamp <= deadline); bytes32 digest = keccak256(abi.encodePacked( "\x19\x01", DOMAIN_SEPARATOR(), keccak256(abi.encode( PERMIT_TYPEHASH, owner, spender, value, nonces[owner], // reads nonce deadline )) )); address recovered = ecrecover(digest, v, r, s); require(recovered == owner); _approve(owner, spender, value); // BUG: forgot to increment nonces[owner]! } ``` Without the nonce increment, the same signature can be submitted over and over. In the context of an approval, the attacker can re-approve themselves after the user revokes. ## Fuzzing nonce correctness ```solidity // Property: nonce must increment after every successful permit function invariant_nonceAlwaysIncrements() public returns (bool) { for (uint256 i = 0; i < actors.length; i++) { uint256 currentNonce = token.nonces(actors[i]); uint256 permitCount = ghost_permitCount[actors[i]]; // Nonce should equal the number of successful permits if (currentNonce != permitCount) return false; } return true; } // Property: same signature can never be used twice function invariant_noSignatureReplay() public returns (bool) { // Track all used signatures in a ghost variable // If any signature hash appears twice, it's a replay for (uint256 i = 0; i < ghost_usedSignatures.length; i++) { for (uint256 j = i + 1; j < ghost_usedSignatures.length; j++) { if (ghost_usedSignatures[i] == ghost_usedSignatures[j]) { return false; } } } return true; } ``` ## Attack 3: permit frontrunning (Griefing) This one's subtle and often misunderstood. It's not about stealing funds, it's about griefing. ## The mechanism 1. Alice signs a permit for a protocol to spend her tokens. 2. Alice submits a transaction that calls `permit()` then `transferFrom()` in one call. 3. Eve sees the pending transaction, extracts the permit signature, and submits it first. 4. Eve's `permit()` call succeeds, the approval is now set. 5. Alice's transaction executes, calls `permit()` again with the same nonce, it reverts because the nonce was already used. 6. Alice's entire transaction fails. The attacker didn't steal anything, but they bricked Alice's transaction. In protocols where the permit and the action are in the same transaction, this can be seriously disruptive. ```solidity // VULNERABLE -- permit failure causes entire transaction to revert function depositWithPermit( uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external { // If this permit was already used (frontrun), entire tx fails token.permit(msg.sender, address(this), amount, deadline, v, r, s); token.transferFrom(msg.sender, address(this), amount); _deposit(msg.sender, amount); } // FIXED -- try/catch on permit, check allowance function depositWithPermitSafe( uint256 amount, uint256 deadline, uint8 v, bytes32 r, bytes32 s ) external { // Try permit -- if it fails (frontrun), check existing allowance try token.permit(msg.sender, address(this), amount, deadline, v, r, s) { // Permit succeeded } catch { // Permit failed -- maybe frontrun. Check if allowance exists require( token.allowance(msg.sender, address(this)) >= amount, "Insufficient allowance" ); } token.transferFrom(msg.sender, address(this), amount); _deposit(msg.sender, amount); } ``` ## The invariant ```solidity // Property: depositWithPermit should never revert if the user // has sufficient balance and the allowance is already set function invariant_permitFrontrunResistant() public returns (bool) { // If actor has allowance >= deposit amount, // depositWithPermit should succeed regardless of // whether permit() itself succeeds or fails for (uint256 i = 0; i < actors.length; i++) { uint256 allowance = token.allowance(actors[i], address(vault)); uint256 balance = token.balanceOf(actors[i]); // If both sufficient, a deposit call should never revert // (we track reverts in ghost variables during fuzzing) if (allowance >= MIN_DEPOSIT && balance >= MIN_DEPOSIT) { if (ghost_depositReverted[actors[i]]) return false; } } return true; } ``` ## Attack 4: signature malleability ## The mechanism ECDSA signatures have a malleability property: for any valid signature `(v, r, s)`, there's another valid signature `(v', r, s')` where `s' = secp256k1.n - s` and `v' = v ^ 1`. Both recover to the same address. If a protocol uses the raw signature hash as a unique identifier (e.g., to mark it as "used"), an attacker can submit the malleable variant and bypass the check: ```solidity // VULNERABLE -- uses signature hash for uniqueness mapping(bytes32 => bool) public usedSignatures; function executeWithSig(bytes memory sig, bytes memory data) external { bytes32 sigHash = keccak256(sig); require(!usedSignatures[sigHash], "Already used"); address signer = recoverSigner(data, sig); require(signer == authorized); usedSignatures[sigHash] = true; _execute(data); } ``` The attacker submits the original signature, then creates the malleable version (different `s` value, same signer) and submits again. Different hash, same authorization. ## The fix OpenZeppelin's `ECDSA.recover` already enforces low-s values (rejecting the malleable form). But custom implementations often miss this: ```solidity // From OpenZeppelin ECDSA function tryRecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) internal pure returns (address) { // EIP-2: restrict s to lower half of curve order if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { return address(0); // reject malleable signature } // ... } ``` ## Fuzzing for malleability ```solidity // Property: malleable signatures should always be rejected function invariant_rejectMalleableSignatures( bytes32 r, bytes32 s, uint8 v ) public returns (bool) { // Create malleable version uint256 n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; bytes32 sMalleable = bytes32(n - uint256(s)); uint8 vMalleable = v == 27 ? 28 : 27; // If original is accepted, malleable MUST be rejected bool originalAccepted = tryExecute(v, r, s); bool malleableAccepted = tryExecute(vMalleable, r, sMalleable); if (originalAccepted && malleableAccepted) return false; return true; } ``` ## Attack 5: deadline bypass ## The mechanism Signatures should expire. ERC-2612 includes a `deadline` parameter, but some implementations check it wrong, or don't check it at all: ```solidity // VULNERABLE -- no deadline check function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external { // deadline parameter exists but is never checked! bytes32 digest = buildDigest(owner, spender, value, nonces[owner], deadline); require(ecrecover(digest, v, r, s) == owner); _approve(owner, spender, value); nonces[owner]++; } // Also vulnerable -- using <= instead of <, combined with // block.timestamp manipulation function permitAlsoWrong(...) external { require(deadline >= block.timestamp); // Should this be > or >=? // On some L2s, block.timestamp can be manipulated by sequencer } ``` A leaked or stolen signature with no deadline (or `deadline = type(uint256).max`) is valid forever. Even if the user revokes their approval, the attacker can re-permit at any future time. ## Fuzzing deadline enforcement ```solidity // Property: expired permits must always revert function invariant_expiredPermitReverts( uint256 deadline, uint256 warpTime ) public returns (bool) { deadline = bound(deadline, 1, block.timestamp + 365 days); warpTime = bound(warpTime, 0, 730 days); // Create valid signature with specific deadline (uint8 v, bytes32 r, bytes32 s) = createPermitSig( ownerKey, spender, amount, deadline ); // Warp past deadline vm.warp(block.timestamp + warpTime); if (block.timestamp > deadline) { // Permit MUST revert after deadline try token.permit(owner, spender, amount, deadline, v, r, s) { return false; // Should have reverted! } catch { return true; // Correctly rejected } } return true; } ``` ## Building the full signature fuzzing harness Here's how I put it all together for a real audit: ```solidity contract SignatureFuzzTest is Test { Token token; uint256 ownerKey = 0xA11CE; address owner; address spender = address(0xBOB); function setUp() public { owner = vm.addr(ownerKey); token = new Token("Test", "TST"); token.mint(owner, 1_000_000e18); } // Handler: normal permit flow function handler_permit( uint256 value, uint256 deadline ) external { value = bound(value, 0, token.balanceOf(owner)); deadline = bound(deadline, block.timestamp, block.timestamp + 30 days); uint256 nonce = token.nonces(owner); bytes32 digest = buildPermitDigest( owner, spender, value, nonce, deadline ); (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerKey, digest); token.permit(owner, spender, value, deadline, v, r, s); ghost_permitCount[owner]++; } // Handler: attempt replay with old signature function handler_attemptReplay(uint256 sigIndex) external { if (ghost_signatures.length == 0) return; sigIndex = bound(sigIndex, 0, ghost_signatures.length - 1); SavedSig memory sig = ghost_signatures[sigIndex]; try token.permit( sig.owner, sig.spender, sig.value, sig.deadline, sig.v, sig.r, sig.s ) { ghost_replaySucceeded = true; } catch { // Expected -- replay should fail } } // Handler: advance time past deadline function handler_advanceTime(uint256 seconds_) external { seconds_ = bound(seconds_, 1, 365 days); vm.warp(block.timestamp + seconds_); } // Handler: simulate chain fork function handler_changeChainId(uint256 newChainId) external { newChainId = bound(newChainId, 1, 100000); vm.chainId(newChainId); } // Invariants function invariant_noReplayEver() public { assertFalse(ghost_replaySucceeded, "Signature replay succeeded"); } function invariant_noncesMonotonic() public { assertEq( token.nonces(owner), ghost_permitCount[owner], "Nonce mismatch" ); } function invariant_approvalMatchesLatestPermit() public { assertEq( token.allowance(owner, spender), ghost_lastPermitValue, "Allowance doesn't match last permit" ); } } ``` ## Practical tips for fuzzing permit flows 1. **Generate real signatures.** Use `vm.sign()` in Foundry to create valid ECDSA signatures. Don't mock the signing, test the real `ecrecover` path. 2. **Include chain ID changes.** Use `vm.chainId()` to simulate fork scenarios. A lot of protocols break here. 3. **Test with multiple signers.** One user's permit shouldn't affect another's nonce space. Sounds obvious, but cross-contamination happens in custom implementations. 4. **Time travel aggressively.** Deadlines of 0, `type(uint256).max`, `block.timestamp - 1`, `block.timestamp`, and `block.timestamp + 1` are all interesting boundary values. 5. **Store and replay.** Save every successful signature and periodically try to reuse them. This is the most direct test for replay resistance. 6. **Fuzz the `v` value.** Valid values are 27 and 28, but some implementations accept other values. Your property should ensure only 27/28 work. ## The bottom line Signature bugs are sneaky because the code often looks right. The domain separator is there, the nonce is there, the deadline is there, but is the domain separator computed correctly? Is the nonce actually incremented? Is the deadline actually checked? [Invariant testing](/learn/invariant-testing) answers these questions by trying thousands of sequences: permit, replay, time warp, chain change, permit again. The properties don't encode specific attacks, they encode what should always hold. And when something doesn't hold, you've found your bug. Need to test your EIP-712 and permit implementations? Try Recon Pro for automated fuzzing campaigns. Or request an audit if you want experts to write and run the full signature security test suite for you. ## Further reading - [Smart Contract Security Fundamentals](/learn/smart-contract-security) - [Fuzzing Guide](/learn/fuzzing) - [Invariant Testing](/learn/invariant-testing) - [Stateful Fuzzing Explained](/blog/stateful-fuzzing-explained-sequence-matters) --- ### Smart contract audit firms compared: 2026 market guide **Author:** alex | **Published:** 2026-01-28 | **Reading time:** 16 min **Tags:** smart-contract-audit, smart-contract-security **URL:** https://getrecon.xyz/blog/smart-contract-audit-firms-compared-2026 # Smart contract audit firms compared: 2026 market guide *By alex — Strategy & Research at Recon* Picking an audit firm is one of the biggest decisions a protocol team makes before launch. Get it right and you catch critical bugs, build user trust, and avoid front-page exploits. Get it wrong and you've spent six figures on a PDF that misses the vulnerability that drains you three months later. The audit market in 2026 looks different from even two years ago. New models, new tools, more competition. This guide breaks down the categories of firms, what each offers, and how to pick the right one for your specific situation. I'll be fair to everyone — this isn't a hit piece. But I will be honest about where different approaches have strengths and gaps. ## The audit market in 2026: what's changed A few major shifts have reshaped the market: **Contest platforms matured.** What started as experimental crowd-auditing is now a serious alternative. Code4rena, Sherlock, and Cantina have refined their models, and some of the best auditors work primarily through these platforms. **Tooling got better.** Fuzzers, formal verifiers, and static analyzers are more accessible than ever. This means the bar for a "good audit" has gone up, a manual-only review doesn't cut it anymore. **Post-audit testing is expected.** Clients don't just want a report. They want ongoing test suites, monitoring, and support. The deliverable isn't a PDF, it's a security posture. **AI entered the workflow.** Most firms now use AI-assisted analysis for initial triage and pattern detection. The difference is in *how* they use it, as a first pass that humans verify, or as a replacement for deep manual review. ## Category 1: established security firms These are the names everyone knows. They've been around the longest and have the deepest track records. ## OpenZeppelin **What they do well:** Battle-tested review process, deep experience with token standards and upgradeable patterns, strong brand recognition that reassures investors. Their library code (Contracts, Defender) is industry standard. **Where they fit:** Large protocols that need a recognized name on the audit report. Especially good for token launches and protocols using their own contract libraries. **Consider:** Long lead times (often months), premium pricing, and the audit scope may focus heavily on what they know best. ## Trail of bits What they do well: Among the strongest in custom tooling. They built Echidna and Slither, and their engineers come from systems security backgrounds. Excellent at low-level bugs, compiler issues, and cross-domain attacks. Where they fit: Complex systems, novel architectures, anything that isn't a standard DeFi fork. If your protocol does something genuinely new, they'll dig into it. Consider: Typically more expensive. Their focus on building tools sometimes means engagement scope is broader than just your codebase. ## Consensys Diligence What they do well: Ecosystem integration, they're deeply embedded in the Ethereum tooling space (Metamask, Infura, Truffle history). Strong at EVM internals. Where they fit: Ethereum-native protocols, especially those using the broader Consensys toolchain. Consider: The Consensys restructuring in recent years means the team has shifted. Verify who's actually on your engagement. ## Category 2: contest platforms The crowd-audit model: multiple independent auditors compete to find bugs in your codebase during a time-limited contest. ## Code4rena (C4) What they do well: Large auditor pool means more eyes on your code. The competitive incentive model drives deep exploration. Great at catching edge cases that a fixed team might miss. Strong historical record of finding criticals. Where they fit: Protocols that want breadth of review. Good for DeFi protocols where many auditors already know the patterns. Consider: Quality varies by contest. You might get top-tier hunters or mostly juniors, depends on prize pool size and competing contests. No guarantee of depth on any single module. ## Sherlock What they do well: Combines contest auditing with coverage (insurance-like), they'll pay out if a bug they missed gets exploited. This skin-in-the-game model aligns incentives well. Lead auditor model ensures at least one experienced reviewer. Where they fit: Protocols that want some backstop beyond just a report. The coverage model is genuinely unique. Consider: Coverage has limits and conditions. Read the terms carefully. The payout isn't unlimited. ## Cantina What they do well: Curated auditor pool, smaller than C4 but more selective. Spun out from Code4rena veterans. Flexible engagement models (contests, fixed-team, hybrid). Where they fit: Teams that want the contest model but with more quality control on who's reviewing. Consider: Newer platform, so the track record is shorter than C4 or Sherlock. ## Category 3: specialized firms These firms focus on a specific methodology or niche rather than doing general-purpose auditing. ## Recon (That's us) What we do well: Invariant testing and fuzzing as the primary audit methodology. We don't just find bugs, we write the test suite that proves they exist and ensures they don't come back. Deliverables include a working Foundry/Chimera test harness, not just a PDF. Where we fit: Protocols that want more than a report. Teams building DeFi (vaults, AMMs, lending) where state-dependent bugs are the highest risk. Also strong for teams that want to own their ongoing security testing. What makes this approach different: Most auditors find a bug and describe it. We find the *property* that should hold, then prove it doesn't. The test suite stays with you and runs in CI forever. When you change code six months later, the invariants catch regressions that a one-time audit can't. Consider: Our focus is on invariant-driven methodology. If you need pure formal verification or very broad smart contract platform expertise, you might want to pair us with a complementary firm. Also see [what to expect from a smart contract audit](/blog/what-to-expect-smart-contract-audit-2025). ## Certora What they do well: Formal verification (FV), mathematical proof that specific properties hold. Their Prover tool is the most widely used FV tool in DeFi. When FV works, it gives *guarantees*, not just confidence. Where they fit: Protocols where specific critical properties must be proven (e.g., "total borrows never exceed total deposits"). Lending protocols and token systems benefit most. Consider: FV has limits, it proves what you specify, and it can't find bugs in properties you didn't think to check. It's also slower and more expensive than fuzzing for broad exploration. Best combined with fuzzing for coverage. See our [formal verification explainer](/learn/formal-verification) for how the two complement each other. ## Spearbit What they do well: Network of elite solo auditors. They match your protocol with individual experts who've specifically audited similar systems. Very high auditor quality per engagement. Where they fit: Protocols that want the best individual talent, not a firm process. Great for specialized DeFi verticals where domain knowledge matters. Consider: Scaling can be a challenge. Availability depends on individual auditor schedules. ## Category 4: aI-Augmented auditing A growing category of firms and tools that lean heavily on AI for initial code analysis. What they do well: Fast initial triage. AI can scan a codebase and flag known vulnerability patterns in minutes. Good for catching the "obvious" stuff before human review starts. Where they are today: AI catches known patterns well but struggles with novel logic bugs, economic attacks, and multi-contract interaction issues. It's a first pass, not a replacement. What to watch for: Firms marketing AI as a standalone audit solution. If there isn't a human expert deeply reviewing the AI's output, you're getting a fancy static analysis report. Ask who's actually reading the code. ## Category 5: boutique and regional firms Smaller firms, often 2-10 auditors, that do focused engagement work. Strengths: Often more affordable. You get direct access to the auditors. Faster turnaround because there's less process overhead. Risks: Smaller team means less diversity of experience. If your protocol has an unusual architecture, a small team might not have seen it before. Verify their track record, ask for references from past clients, not just a list of logos. ## What to look for when choosing ## 1. methodology depth Ask: "What does your audit process actually involve?" A good answer includes specific tools, techniques, and stages. A bad answer is "our experienced team manually reviews every line of code." Manual review alone isn't enough in 2026. | What to ask | Green flag | Red flag | |---|---|---| | Do you write custom tests? | "Yes, invariant tests that ship with the report" | "We provide recommendations" | | What tools do you use? | Specific names and how they integrate | "Proprietary internal tools" with no details | | Do you test across contracts? | "We model full system interactions" | "We review each contract independently" | ## 2. deliverables The report is the minimum. What else do you get? - Test suites that you can run after code changes - Proof of concept exploits for each finding - Remediation verification, do they re-review your fixes? - Ongoing monitoring or retainer options If the only deliverable is a PDF, you're buying a snapshot. Your code will change. The audit won't. Check what [an audit should include in 2025](/blog/what-to-expect-smart-contract-audit-2025) and beyond. ## 3. timeline and availability Most established firms are booked 4-8 weeks out. Contest platforms can sometimes start sooner but have fixed windows. Plan ahead. | Firm type | Typical lead time | Audit duration | |---|---|---| | Established firms | 4-8 weeks | 2-6 weeks | | Contest platforms | 2-4 weeks | 1-3 weeks | | Specialized firms | 2-6 weeks | 2-4 weeks | | Boutique | 1-3 weeks | 1-3 weeks | ## 4. pricing This varies enormously. Here's a rough guide, see our [audit cost pricing guide](/blog/smart-contract-audit-cost-pricing-guide) for a deeper breakdown: - Established firms: $200K-$500K+ for a full engagement - Contest platforms: $50K-$200K prize pool + platform fee - Specialized firms: $80K-$300K depending on scope - Boutique: $30K-$150K Cheaper isn't always worse, and expensive isn't always better. Match the cost to your risk profile. A protocol holding $500M in TVL shouldn't penny-pinch on auditing. A small team launching an MVP might not need a six-figure engagement. See our analysis of [the true cost of not auditing](/blog/true-cost-not-auditing-prevented-losses). ## 5. post-Audit support What happens after the report? Code changes. New features ship. Dependencies update. Does the firm offer: - Fix review (often included) - Retainer for ongoing questions - Updated test suites when you refactor - Monitoring and alerting ## The multi-Audit strategy Here's what I'd recommend for any protocol holding significant user funds: don't rely on a single audit. A strong security posture in 2026 looks like: 1. Internal testing, Your team writes unit tests and basic fuzz tests. [Foundry](/learn/foundry) makes this accessible. 2. Specialized engagement, An invariant testing firm (like Recon) or FV firm (like Certora) writes deep property tests. 3. Broad review, A contest or established firm does a full codebase review with many eyes. 4. Continuous testing, The test suites from step 2 run in CI. New code gets tested against existing properties. 5. Bug bounty, Post-launch, a bounty program on Immunefi or HackenProof keeps external researchers looking. No single approach catches everything. Manual review catches logic bugs that tools miss. Fuzzing catches state-dependent bugs that humans miss. Formal verification proves critical properties. Contests provide breadth. Together, they form a strong defense. ## How to decide Here's a simple decision tree: - Budget < $50K → Boutique firm + your own fuzz tests - Budget $50-150K → Specialized firm OR contest platform - Budget $150-300K → Specialized firm + contest platform - Budget > $300K → Established firm + specialized firm + contest - Ongoing → Continuous fuzzing (Recon Pro or self-hosted) + bug bounty Your protocol's complexity matters too. A simple token wrapper? One engagement is probably fine. A novel AMM with custom oracle integration? You want multiple independent reviews. ## The bottom line The audit market is more competitive and more capable than it's ever been. That's good for protocol teams, you have real options. The key is matching the audit approach to your specific risks, budget, and timeline. Don't just hire a name. Hire a methodology. Ask hard questions about process, deliverables, and what happens after the report. Want to see what an invariant-testing-first audit looks like? Request an audit from Recon. Or start testing your own protocol's properties with Try Recon Pro. ## Further reading - [Smart Contract Audit Explained](/learn/smart-contract-audit) - [What to Expect from a Smart Contract Audit in 2025](/blog/what-to-expect-smart-contract-audit-2025) - [Smart Contract Audit Cost Pricing Guide](/blog/smart-contract-audit-cost-pricing-guide) - [The True Cost of Not Auditing](/blog/true-cost-not-auditing-prevented-losses) --- ### Fuzzing as a service vs DIY: when to hire and when to do it yourself **Author:** alex | **Published:** 2026-02-28 | **Reading time:** 12 min **Tags:** fuzzing, smart-contract-audit, cloud-fuzzing **URL:** https://getrecon.xyz/blog/fuzzing-as-a-service-vs-diy-fuzzing # Fuzzing as a Service vs DIY: When to Hire and When to Do It Yourself *By alex — Strategy & Research at Recon* Every protocol team building on-chain eventually asks the same question: should we run our own fuzzing, or should we pay someone to do it? It's not a trivial decision. DIY fuzzing means learning the tools, writing the properties, maintaining the infrastructure, and interpreting the results. Hiring a fuzzing service means spending money but getting expert-written tests and someone else handling the complexity. Neither option is universally better. The right choice depends on your team's size, expertise, budget, and how complex your protocol is. Let me lay out both sides honestly so you can make the call. ## What DIY fuzzing looks like Running your own fuzzing means your team writes property tests, runs the fuzzer, triages results, and maintains everything as the codebase changes. ## The tools You've got solid options available today: **[Foundry](/learn/foundry) fuzz tests** — The easiest entry point. Write a test function with parameters, Foundry generates random inputs. Great for stateless property testing. If you're already using Foundry for your test suite, you can add fuzz tests with minimal overhead. ```solidity // Basic Foundry fuzz test -- your team writes these function testFuzz_depositWithdrawRoundTrip(uint256 amount) public { amount = bound(amount, 1, 1_000_000e18); uint256 sharesBefore = vault.totalSupply(); vault.deposit(amount, address(this)); uint256 shares = vault.balanceOf(address(this)); vault.redeem(shares, address(this), address(this)); // Should get back approximately what we deposited uint256 returned = token.balanceOf(address(this)); assertApproxEqAbs(returned, amount, 1, "Lost more than dust"); } ``` **[Echidna](/learn/echidna)**, Property-based fuzzer from Trail of Bits. Supports [stateful fuzzing](/learn/stateful-fuzzing), it generates sequences of transactions and checks invariants after each one. More powerful than single-input fuzz tests but harder to set up. **[Medusa](/learn/medusa)**, Similar to Echidna but built differently. Often faster on large codebases due to parallelization. Uses the same property format, so you can often switch between them. **[Halmos](/learn/halmos)**, Symbolic execution. Instead of random inputs, it reasons about all possible inputs. Slower but can prove properties, not just test them. Catches edge cases that random fuzzing might miss. ## The learning Curve Here's the honest truth: writing good fuzz tests is harder than writing unit tests. Anyone can write `testFuzz_add(uint256 a, uint256 b)`. Writing properties that actually catch real bugs takes practice. **Week 1-2:** Your team gets Foundry fuzz tests running. Mostly stateless tests, "this function shouldn't revert with valid inputs" type stuff. Useful but shallow. **Month 1-2:** Someone on the team starts learning [invariant testing](/learn/invariant-testing). They write handler contracts, define actor sets, and run stateful campaigns. This is where it gets powerful, and where most teams stall. **Month 3+:** The team can write properties that test multi-step attack scenarios, cross-contract interactions, and economic invariants. This is where DIY fuzzing actually catches the bugs that matter. Most teams I've seen get through the first phase but don't push into the second and third. They end up with fuzz tests that are marginally better than unit tests, lots of random inputs, but the properties aren't checking what matters. ## Maintenance burden This is the part people underestimate. Fuzz tests aren't write-once-and-forget: - Code changes break handlers. Refactor a function signature? Update every handler that calls it. - New features need new properties. Ship a new lending market? You need properties for it. - Corpus management. Long-running campaigns build up a corpus of interesting inputs. Someone needs to manage, prune, and re-run against new code. - Triage time. When a fuzzer reports a failure, someone needs to determine: is this a real bug, a test issue, or an expected behavior? This takes experience. For a team of 5 developers, expect 1 person spending 20-30% of their time on fuzz test maintenance. For larger codebases, it could be a full-time role. ## What it costs (DIY) The tools are mostly free: - Foundry: free - Echidna: free - Medusa: free - Halmos: free The real cost is developer time. At $150-200K/year fully loaded for a Solidity engineer: - Learning phase: 1-2 months of partial allocation = $12-25K - Ongoing maintenance: 20-30% of one engineer = $30-60K/year - Compute costs for CI: $500-2,000/month depending on scale Total first year: ~$50-90K in opportunity cost. That's not nothing. And the output quality depends entirely on who's writing the properties. ## What fuzzing as a service looks like Hiring a service means an external team writes properties, runs campaigns, and delivers results. Here's what that typically involves. ## The service model A typical FaaS engagement looks like this: 1. Scoping: The service reviews your codebase and identifies which properties to test. 2. Property writing: Expert auditors write invariant properties based on your protocol's logic. 3. Campaign execution: They run the fuzzer (usually with more compute than you'd use internally) and tune the campaign. 4. Triage and reporting: Results are triaged, false positives filtered, real bugs documented with PoCs. 5. Deliverables: You get the test suite (yours to keep), the results, and usually fix-review. ## Recon pro: cloud fuzzing [Recon Pro](/pro) is our cloud fuzzing platform. It runs [coverage-guided fuzzing](/learn/coverage-guided-fuzzing) campaigns at scale with managed infrastructure. You push your test suite (written by you, or by us during an audit), and Pro handles: - Parallel fuzzing across many cores - Extended campaign durations (hours to days, not minutes) - Coverage tracking and corpus management - Result dashboards The value is you get the compute and infrastructure without managing it. Write properties locally, push them up, get results back. ## Expert property writing The bigger differentiator isn't infrastructure, it's who's writing the properties. An experienced security researcher writes different properties than a developer learning fuzzing: ```solidity // Developer-written property (common, but shallow) function invariant_totalSupplyNotZeroAfterDeposit() public { if (ghost_totalDeposited > 0) { assertGt(vault.totalSupply(), 0); } } // Expert-written property (catches real bugs) function invariant_noUserCanExtractMoreThanDeposited() public { for (uint256 i = 0; i < actors.length; i++) { uint256 currentValue = vault.convertToAssets( vault.balanceOf(actors[i]) ); uint256 totalIn = ghost_deposited[actors[i]]; uint256 totalOut = ghost_withdrawn[actors[i]]; // Net extraction should never exceed deposits + yield // This catches donation attacks, rounding exploits, // share inflation, and withdrawal ordering bugs assertLe( totalOut + currentValue, totalIn + ghost_totalYieldAccrued + DUST, "Value extraction exceeded deposits + yield" ); } } ``` The first property is fine. It checks a basic fact. The second property encodes an economic invariant that catches entire categories of attacks: [donation attacks](/blog/donation-attack-erc4626-first-depositor), [rounding errors](/blog/rounding-errors-defi-small-bugs-big-exploits), share manipulation, and more. Writing the second kind requires auditing experience. You need to have seen the attacks to know which properties prevent them. ## What it costs (FaaS) Pricing varies by engagement type: | Service | Typical cost | What you get | |---|---|---| | One-time fuzzing campaign | $20-50K | Property suite + campaign results | | Full audit with fuzzing | $80-200K | Manual review + invariant tests + report | | Ongoing Recon Pro subscription | $2-10K/month | Cloud infrastructure + dashboard | | Retainer (expert on call) | $5-15K/month | Ongoing property updates + triage | A one-time engagement gets you a property suite you own forever. The ongoing subscription keeps the campaigns running against new code. The retainer means someone updates properties when your protocol evolves. ## The decision matrix Here's how to decide. Be honest about where your team is: ## Go DIY if: - Your team has fuzzing experience. Someone on the team has written invariant tests before and understands [property-based testing](/learn/property-based-testing). Not "read a tutorial", actually shipped fuzz tests that found bugs. - Your protocol is relatively simple. A single-purpose contract (token, simple vault, staking) has fewer interaction points. The property surface is manageable for a small team. - You're early stage. Pre-launch, code is changing fast. Writing properties in-house means they evolve with the code. An external engagement gets stale quickly during rapid development. - Budget is tight. If you genuinely can't afford a service, DIY with Foundry fuzz tests is infinitely better than no fuzzing at all. Start with the basics and grow. ## Hire a service if: - No one on the team has done it before. The learning curve is real, and the first few months of DIY fuzzing catch shallow bugs at best. An expert gets you deep coverage from day one. - Your protocol is complex. Multi-contract DeFi protocols, lending markets, AMMs with concentrated liquidity, cross-chain bridges, have huge state spaces. Properties that cover the real attack surface require experience writing them. - You're approaching launch. If you need results in weeks, not months, a service delivers faster than training your team. After launch, you can bring fuzzing in-house using the property suite they built. - You want security guarantees. A service with an audit report, test suite, and fix review provides a package that investors and users recognize. "We wrote our own fuzz tests" doesn't carry the same weight with auditors and insurance providers. - Your TVL justifies it. If you're securing $50M+, the $50-200K for expert fuzzing is insurance. The [cost of not auditing](/blog/true-cost-not-auditing-prevented-losses) is measured in millions. ## The hybrid approach (Often best) Here's what I recommend most often: 1. Start with DIY. Your developers write basic Foundry fuzz tests during development. This costs nothing extra and catches simple bugs early. 2. Hire for the launch audit. An expert team writes the deep invariant properties. You get the test suite, and they train your team on how it works. 3. Maintain in-house. Post-launch, your team owns the property suite. They update it as the protocol evolves. If they get stuck, they have the service on retainer for questions. 4. Run campaigns in the cloud. Use [Recon Pro](/pro) for extended fuzzing campaigns. Write properties locally, push to the cloud for heavy compute. This gets you expert-quality properties (step 2) with in-house ownership (step 3) and scalable infrastructure (step 4). You pay for expertise when it matters most and build internal capability over time. ## Break-Even analysis Let's get concrete. When does each option make financial sense? ## Scenario a: small team (3-5 devs), simple protocol | | DIY | FaaS | |---|---|---| | First year cost | ~$40K (developer time) | ~$60K (one-time + Pro sub) | | Year 2+ cost | ~$30K/year (maintenance) | ~$24K/year (Pro sub + occasional retainer) | | Quality of coverage | Medium (limited by team experience) | High (expert properties) | | Break-even point | Never, DIY is cheaper if team is capable | If team lacks experience, FaaS pays for itself by finding 1 bug that DIY wouldn't | ## Scenario b: medium team (5-15 devs), complex DeFi protocol | | DIY | FaaS | |---|---|---| | First year cost | ~$80K (senior dev time + learning) | ~$150K (full audit + Pro) | | Year 2+ cost | ~$60K/year | ~$36K/year | | Quality of coverage | Medium-High (if you hire/train right) | High from day one | | Break-even point | Year 3 if team ramps successfully | Immediate if audit catches critical before launch | ## Scenario c: large protocol ($100M+ TVL) At this scale, the question isn't DIY vs FaaS, it's both. You should have internal fuzzing expertise AND external audits AND ongoing cloud campaigns. The $200K for a full-scope engagement is 0.2% of your TVL. One prevented exploit pays for a decade of auditing. ## Common mistakes Mistake 1: Thinking Foundry fuzz tests are enough. Foundry's built-in fuzzing is great for stateless properties but limited for stateful testing. If your protocol's bugs require specific transaction sequences (most DeFi bugs do), you need [stateful fuzzing](/learn/stateful-fuzzing) with a tool like Echidna, Medusa, or [Chimera](/learn/chimera-framework). Mistake 2: Writing properties that test the implementation, not the specification. "Function X returns the same value as before" isn't a useful property, you're just testing that the code does what it does. "No user's withdrawable value ever decreases without a corresponding action" tests what *should* be true. Mistake 3: Running fuzzing campaigns for 5 minutes. Short campaigns only find surface-level issues. Meaningful stateful fuzzing needs hours to explore deep state spaces. This is where cloud infrastructure matters. Mistake 4: Ignoring results because "it's probably a false positive." Every failure deserves investigation. If you're getting too many false positives, your properties are wrong, fix the properties, don't ignore the results. Mistake 5: Treating fuzzing as a checkbox. "We run fuzz tests in CI" means nothing if the properties are shallow. Depth matters more than existence. ## The bottom line DIY fuzzing works if your team is committed and has the right experience. FaaS works if you need expert-quality coverage fast or your protocol's complexity exceeds your team's testing experience. For most teams, the best path is: hire experts for the launch audit, learn from the property suite they deliver, and maintain it in-house going forward with cloud infrastructure for heavy campaigns. The worst option? No fuzzing at all. Even basic Foundry fuzz tests catch bugs that unit tests miss. Start somewhere and grow. Ready to get started? Try Recon Pro for cloud fuzzing campaigns, or request an audit to get an expert-written property suite for your protocol. ## Further reading - [Fuzzing Fundamentals](/learn/fuzzing) - [Smart Contract Audit Explained](/learn/smart-contract-audit) - [Smart Contract Audit Cost Pricing Guide](/blog/smart-contract-audit-cost-pricing-guide) - [The True Cost of Not Auditing](/blog/true-cost-not-auditing-prevented-losses) - [Stateful Fuzzing Explained](/blog/stateful-fuzzing-explained-sequence-matters) --- ### Why your audit found nothing: the false confidence problem **Author:** alex | **Published:** 2026-04-02 | **Reading time:** 14 min **Tags:** smart-contract-audit, smart-contract-security, invariant-testing **URL:** https://getrecon.xyz/blog/why-your-audit-found-nothing-false-confidence # Why Your Audit Found Nothing: The False Confidence Problem *By alex — April 2026* You got your audit report back. Zero criticals. Zero highs. A couple of informational notes about gas savings. Your team pops champagne, tweets "audited by X," and ships to mainnet. Three weeks later, you're on a war room call at 2 AM watching $4.7 million drain from your protocol. This happens more often than anyone in this industry wants to admit. And it's not always because the auditors were bad. It's because the way most audits work is structurally broken. Let's talk about why. ## The "Clean Report" Myth A clean audit report doesn't mean your code is safe. It means that a small group of people, under time pressure, didn't find anything they flagged as critical during the window they were looking at your code. That's a *very* different statement. Think about what a typical audit engagement looks like: - 2-4 week timeline for a codebase that took your team 6+ months to build - 2-3 auditors reviewing tens of thousands of lines - Fixed scope that might not include deployment scripts or governance mechanisms - Point-in-time review of code that keeps changing after the audit The math doesn't work. Your team has lived inside this codebase for half a year. They know the weird edge cases, the "temporary" hacks, the implicit assumptions. An auditor gets a few weeks to build that same mental model from scratch and then try to break it. Some auditors are exceptional and still find critical bugs under these constraints. Many don't. ## Five reasons audits miss bugs ## 1. Time pressure kills depth Most audit firms quote fixed timelines. The client wants results fast because they have a launch date. The firm wants to stay profitable, so they scope the engagement tightly. What gets cut? The slow, methodical work. Writing [invariant tests](/learn/invariant-testing) that exercise protocol state over thousands of transitions. Building formal models of token flow. Exploring weird multi-step attack paths that require setting up complex preconditions. Instead, auditors default to pattern matching. They scan for known vulnerability classes (reentrancy, oracle manipulation, access control mistakes). These are real bugs and they catch some. But protocol-specific logic errors? The kind where your liquidation math is subtly wrong under specific collateral ratios? Those need time that the engagement doesn't have. ## 2. Surface-level review disguised as thoroughness Here's a dirty secret: a lot of audit reports are padded with informational findings and gas optimizations to look thorough. Fifteen findings sounds better than three, even if twelve of them are "consider using `unchecked` blocks for gas savings." Real depth means an auditor can explain your protocol's state machine to you. They can diagram the flow of funds through every code path. They can tell you what invariants your system depends on and whether the code actually enforces them. If your auditor can't do that, they reviewed your code at the syntax level, not the logic level. ## 3. Auditor fatigue is real Auditors at busy firms might be working on 3-4 engagements in various stages. They're context-switching between a lending protocol and a DEX, all with different architectures and trust assumptions. Nobody does their best work in that state. The human brain can't maintain deep focus on complex code while juggling multiple codebases. Bugs that would be obvious during a deep-focus session get missed when attention is fragmented. ## 4. No automated verification Here's the one that bothers me most. Many audits are still primarily manual review. No fuzzing campaign. No [symbolic execution](/learn/symbolic-execution). No [formal verification](/learn/formal-verification) of critical invariants. No [mutation testing](/learn/mutation-testing) to check if the test suite actually catches bugs. Manual review is important. You need human intuition to understand business logic and economic attacks. But humans are terrible at checking math across thousands of state combinations. Machines are great at it. A good audit uses both. A checkbox audit uses neither well. We wrote more about what thorough testing should actually include in our piece on [what to expect from a smart contract audit in 2025](/blog/what-to-expect-smart-contract-audit-2025). The principles still hold. ## 5. Post-audit changes This one is on the protocol teams. You get your audit back, the auditors flag some issues, your devs fix them. But then: - Someone "just refactors" a function for readability - A last-minute feature gets added before launch - A deployment parameter gets changed from what was audited - A dependency gets updated Each of these can introduce new bugs. And none of them are covered by the audit you already paid for. ## Protocols that had clean audits then got hacked You don't have to look hard to find examples. **Euler Finance (2023)** — Multiple audits. $197 million exploit. The vulnerability was in the donation and liquidation logic interaction, the kind of complex multi-step attack that manual review under time pressure tends to miss. **Mango Markets (2022).** Audited. $114 million drained through oracle manipulation and thin liquidity exploitation. The attack required understanding market microstructure, not just smart contract code. **Ronin Bridge (2022).** Audited. $625 million. The vulnerability wasn't even in the smart contracts; it was in the validator key management. Classic case of audit scope being too narrow. **Cream Finance (2021).** Audited multiple times. Hit for $130 million. Flash loan attack exploiting composability between multiple protocols. In every case, the teams had audit reports. Some had multiple reports from respected firms. The reports said the code was safe. The code was not safe. The true cost of these failures goes beyond the immediate loss. We've covered the broader impact in [the true cost of not auditing](/blog/true-cost-not-auditing-prevented-losses). ## What a good audit actually looks like So what separates a real audit from a checkbox exercise? Deep protocol understanding first. Before looking at a single line of code, the auditor should understand what the protocol does and how value flows through it. Threat modeling. Explicit documentation of who the adversaries are and what attack paths exist. Not just "reentrancy" but "a malicious borrower who controls a callback can manipulate the collateral ratio calculation during liquidation." Automated testing as a first pass. [Fuzzing](/learn/fuzzing) and [property-based testing](/learn/property-based-testing) should run first to catch the low-hanging fruit. This frees up human reviewers to focus on logic and design issues. ```solidity // This is the kind of property that machines should verify // not humans staring at code function invariant_totalSupplyMatchesBalances() public { uint256 sumOfBalances = 0; for (uint256 i = 0; i < holders.length; i++) { sumOfBalances += token.balanceOf(holders[i]); } assert(token.totalSupply() == sumOfBalances); } ``` Manual review for logic and design. Humans review the protocol design and edge cases that require domain knowledge. Formal verification for critical paths. Tools like [Halmos](/learn/halmos) can mathematically prove that critical invariants hold across all possible inputs, not just the ones a fuzzer happened to try. Fix review and retesting. After the team addresses findings, auditors re-review the fixes and run their tools again. Not a quick glance, a real check. ## Continuous assurance vs. point-in-time reviews Here's the fundamental problem with the traditional audit model: it's a snapshot. Your code at commit `abc123` on March 15th passed review. But code is a living thing. It changes. [Invariant testing](/learn/invariant-testing) changes this equation. Instead of relying on a one-time review, you encode your protocol's safety properties as executable tests that run: - On every commit in CI - Before every deployment - Continuously in monitoring ```solidity // This property runs on every CI build // It doesn't care when the last audit was function invariant_protocolSolvency() public { uint256 totalDeposits = vault.totalAssets(); uint256 actualBalance = underlying.balanceOf(address(vault)); uint256 totalDeployed = vault.totalDeployed(); // The vault should always be able to account for all deposits assert(actualBalance + totalDeployed >= totalDeposits); } ``` If someone introduces a bug that violates solvency, the test catches it immediately. Not three weeks from now when the auditor gets around to looking at the diff. Now. This isn't a replacement for auditing. You still need human eyes on the design. But it closes the gap between audits. It gives you continuous assurance that the properties your protocol depends on actually hold. ## The right approach: layers Security isn't a single activity. It's layers: 1. Design review. Get the architecture right before writing code 2. Unit and integration tests. Basic correctness 3. [Invariant testing](/learn/invariant-testing) and [fuzzing](/learn/fuzzing). Property verification across random state 4. [Formal verification](/learn/formal-verification). Mathematical proofs for critical paths 5. Manual expert audit. Human intuition for logic and economic attacks 6. Continuous monitoring. Runtime detection of invariant violations 7. Incident response plan. Defense in depth means planning for the worst Each layer catches different classes of bugs. Skip one and you have a gap. Most "clean audit" protocols skipped layers 3, 4, and 6. ## What you should do If you're a protocol team that just got a clean audit report: **Don't celebrate yet.** Ask your auditors: did you run fuzzing campaigns? How many machine hours? What properties did you verify? What was out of scope? Write invariant tests. Even if your audit is done, start encoding your protocol's safety properties. Every critical property should be a test. Check out our guide on [how to write your first invariant test](/blog/how-to-write-first-invariant-test). Verify the fixes. If the audit had findings and you made changes, those changes need review too. Not just a glance, actual review and retesting. Monitor continuously. On-chain monitoring that checks your key invariants in real time. If your TVL changes by more than expected in a single transaction, you want to know immediately. Plan for the worst. Have a pause mechanism. Have a war room process. Have communication templates ready. The time to plan for an incident is before it happens. If you're wondering whether you even need an audit, we've written a straightforward analysis of [when an audit makes sense](/blog/do-you-need-smart-contract-audit). ## The honest take Most audits provide value. They catch real bugs. Good auditors save protocols from disasters regularly. But the industry has a false confidence problem. A clean report becomes a marketing asset instead of one data point in a full [security strategy](/learn/smart-contract-security). Teams stop investing in security after the audit because they think they're "done." You're never done. Your code changes. DeFi changes. New attack vectors emerge. The protocol you compose with ships a breaking change. The audit is the starting line, not the finish line. Get an audit that actually finds bugs --- ### How to prepare your code for a smart contract audit **Author:** kn0t | **Published:** 2026-04-06 | **Reading time:** 12 min **Tags:** smart-contract-audit, smart-contract-security, getting-started **URL:** https://getrecon.xyz/blog/how-to-prepare-for-smart-contract-audit # How to Prepare Your Code for a Smart Contract Audit *By kn0t — April 2026* I've seen teams show up to an audit engagement with nothing but a GitHub link and a "good luck." I've also seen teams hand over a perfectly organized package that lets auditors hit the ground running on day one. Guess which teams get better results? Preparation isn't busywork. Every hour your auditor spends figuring out how your system works is an hour they're not spending finding bugs. If you're paying for a 3-week engagement and the auditor spends the first week just understanding your architecture, you effectively bought a 2-week audit. Here's how to make sure that doesn't happen. ## The pre-audit checklist Let's go through everything you should have ready before engaging an auditor. I'll explain *why* each item matters, not just *what* it is. ## 1. Code freeze What: A specific commit hash that won't change during the audit. Why: If auditors are reviewing moving targets, they waste time re-checking code that changed. Worse, they might miss a bug introduced mid-audit because they already reviewed that file. How: - Pick a date at least 1 week before the audit starts - Freeze the branch. No merges, no "quick fixes," no "just one more feature" - Give the auditor the exact commit hash - If you *must* change something, keep a running changelog and flag it explicitly This is the single most impactful thing you can do. I can't stress it enough. ## 2. Documentation Architecture overview. A document (even a one-pager) explaining: - What your protocol does in plain English - How the major contracts interact - The flow of funds through the system - Trust assumptions (who can do what, what's upgradeable, what's permissionless) - External dependencies (oracles, other protocols, off-chain components) NatSpec comments. Every public and external function should have NatSpec documentation: ```solidity /// @notice Deposits collateral and mints debt tokens /// @dev Caller must have approved this contract for \`amount\` /// Reverts if collateral ratio would drop below MIN_RATIO /// @param collateralToken The ERC-20 token to deposit as collateral /// @param amount The amount of collateral tokens to deposit /// @param debtAmount The amount of debt tokens to mint /// @return debtTokenId The ID of the minted debt position function depositAndBorrow( address collateralToken, uint256 amount, uint256 debtAmount ) external returns (uint256 debtTokenId) { // ... } ``` Known issues list. This is something most teams skip and it's one of the most useful things you can provide. If you already know about a rounding issue in your fee calculation that you've decided is acceptable, tell the auditor. Otherwise they'll spend time writing up something you're already aware of. Format it like this: ```markdown ## Known Issues 1. **Fee rounding in \`calculateFees()\`** -- Rounds down, may result in 0-1 wei loss per transaction. Accepted risk -- gas cost to exploit exceeds potential gain. 2. **First depositor inflation attack** -- Mitigated by initial deposit in constructor. See deploy script line 45. 3. **Centralization risk in \`setOracle()\`** -- Owner can change oracle. Planned migration to governance in v2. ``` ## 3. Test suite Your tests are documentation that runs. They show the auditor how you *intend* the system to work. Unit tests for every function. At minimum, every public function should have tests covering: - Happy path - Edge cases (zero amounts, max uint, empty arrays) - Revert conditions - Access control Integration tests. Tests that exercise multi-step workflows: - Full deposit → borrow → repay → withdraw cycle - Liquidation flow end to end - Governance proposal → vote → execute Run your tests and make sure they pass. You'd be surprised how many teams hand off code with failing tests. It immediately erodes confidence. ```bash # Run everything and confirm green forge test -vv # Check coverage forge coverage --report summary ``` Coverage report. Show the auditor what's tested and what isn't. Low coverage areas are where they should look hardest. If your team has started writing [invariant tests](/learn/invariant-testing), include those too. They're extremely useful for auditors because they express what properties the system should maintain, not just what individual functions do. ## 4. Access control documentation Create a clear matrix showing: - Every privileged role (owner, admin, operator, guardian, etc.) - What each role can do - Which functions each role can call - Whether roles can be transferred or revoked - Timelock delays on sensitive operations ```markdown | Role | Can Call | Timelock | Transferable | |----------|-----------------------|----------|--------------| | Owner | setFeeRate, pause | 48h | Yes (2-of-3) | | Guardian | pause, unpause | None | Yes | | Operator | rebalance, harvest | None | No | | Anyone | deposit, withdraw | N/A | N/A | ``` This table takes 15 minutes to make and saves your auditor hours of digging through modifier chains. ## 5. Deployment information - Target chain(s): Ethereum, Arbitrum, both? Cross-chain? - Deployment scripts: Include the actual scripts, not just descriptions - Constructor parameters: What values will be used in production? - Proxy pattern: If upgradeable, which pattern? UUPS? Transparent? Diamond? - Existing deployments: If this is an upgrade, link to the deployed contracts Auditors need this because deployment configuration can introduce bugs that don't exist in the test environment. A different constructor parameter, a different proxy admin setup, a different chain with different precompiles. All potential attack surface. ## 6. Previous audit reports If you've been audited before, share the reports. All of them. Even if they're from a different firm, even if they found embarrassing bugs. Auditors use previous reports to: - Understand the history of the codebase - Check if previous findings were properly fixed - Identify areas that have been problematic before - Avoid duplicating work on already-reviewed code ## 7. Scope definition Be explicit about what's in scope and what isn't. ```markdown ## In Scope - src/core/Vault.sol - src/core/Strategy.sol - src/core/Oracle.sol - src/periphery/Router.sol - deploy/Deploy.s.sol ## Out of Scope - src/mocks/ (test helpers only) - src/legacy/ (deprecated, not deployed) - Third-party dependencies (OpenZeppelin, Solmate) - Off-chain keeper bot logic ``` Also specify: - Lines of code (nSLOC) for the in-scope contracts - Solidity version - EVM version target - Compiler settings (optimizer runs, via-ir) ## 8. Communication setup Decide upfront: - Communication channel: Private Telegram group? Discord? Slack? - Response time expectation: Will a dev be available within a few hours to answer questions? - Point of contact: Who should the auditors talk to? One person or the whole team? The best audit engagements have a dev available to answer questions quickly. When an auditor says "hey, is this function supposed to handle the case where X is zero?" and gets an answer in 30 minutes instead of 3 days, the quality of the entire engagement goes up. ## The preparation checklist Here's the condensed version you can copy and work through: ```markdown ## Pre-Audit Preparation Checklist ### Code - [ ] Code freeze date set: ___________ - [ ] Frozen commit hash: ___________ - [ ] All tests passing - [ ] Coverage report generated - [ ] No compiler warnings - [ ] Linter clean (forge fmt, solhint) ### Documentation - [ ] Architecture overview document - [ ] NatSpec on all public/external functions - [ ] Known issues list - [ ] Access control matrix - [ ] Deployment parameters documented - [ ] System diagram (contract interactions) ### Testing - [ ] Unit tests for every public function - [ ] Integration tests for key workflows - [ ] Edge case tests (zero, max, empty) - [ ] Revert condition tests - [ ] Coverage above 85% ### Infrastructure - [ ] Scope definition (in/out of scope) - [ ] Previous audit reports shared - [ ] Communication channel set up - [ ] Dev point of contact assigned - [ ] Response time SLA agreed ### Deployment - [ ] Target chain(s) specified - [ ] Deployment scripts included - [ ] Constructor parameters documented - [ ] Proxy pattern documented (if applicable) - [ ] Existing deployments linked (if upgrade) ``` ## How preparation affects cost and quality Let's talk money. Good preparation doesn't just make the audit better. It makes it cheaper. Without preparation: - Auditors spend 25-40% of the engagement just understanding the system - They ask questions that go unanswered for days, blocking their work - They write up "findings" that are actually known issues or intended behavior - The final report has noise that obscures real bugs - You might need a follow-up engagement because they ran out of time With preparation: - Auditors start finding bugs on day 2 instead of day 5 - Questions get answered fast, keeping momentum - Known issues are excluded from the report, making it cleaner - More time spent on deep analysis means more real findings - Less likely to need a costly extension For more on what the audit process should look like, check out [what to expect from a smart contract audit](/blog/what-to-expect-smart-contract-audit-2025). And if you're still deciding whether an audit makes sense for your project, we've laid out the considerations in [do you need a smart contract audit?](/blog/do-you-need-smart-contract-audit). ## Bonus: what auditors wish you knew I've talked to dozens of auditors. Here's what comes up over and over. **"Don't change the code during the audit."** Seriously. Every time you merge a fix for something unrelated, the auditor has to re-check interactions. If they've already built a mental model of how function A calls function B, and you refactor function B mid-audit, that mental model is gone. "Write better error messages." Custom errors with descriptive names help auditors understand intent: ```solidity // Bad -- what does this check actually protect? require(amount > 0); // Good -- auditor immediately understands the business rule error DepositAmountMustBeNonZero(); if (amount == 0) revert DepositAmountMustBeNonZero(); ``` "Tell us about your economic model." The code shows *how* but not *why*. If your fee model is designed to prevent economic attacks, explain the attack and the defense. The auditor can then verify the defense actually works. "Tell us what scares you." If there's a function you're nervous about, say so. Auditors are more effective when they know where the risk is concentrated. Nobody will judge you for being honest about uncertainty. "Include your invariants." Even if they're informal. "The total supply of our token should always equal the sum of all balances" is incredibly useful context. If you've written formal [invariant tests](/learn/invariant-testing), those are gold. ## The ROI of preparation I know this seems like a lot of work. Here's the payoff: A well-prepared audit engagement typically finds **2-3x more real bugs** than a poorly prepared one. Not because the auditors are better, but because they spend their time actually auditing instead of reverse-engineering your system. The preparation work also has value beyond the audit: - Your documentation helps onboard new team members - Your test suite catches regressions going forward - Your known issues list becomes institutional knowledge - Your access control matrix feeds into your incident response plan It's an investment that pays off in multiple ways. For a deeper understanding of [smart contract audits](/learn/smart-contract-audit) and what makes them effective, check out our learning resources. ## Get started Start with the checklist above. Work through it item by item. If you get stuck on the testing section, we've got guides on [fuzzing](/learn/fuzzing) and [invariant testing](/learn/invariant-testing) that can help. And when you're ready to engage, having all of this ready means you'll get a better audit at a better price. That's a win for everyone. Request an audit — we'll tell you if you're ready --- ### ERC-4337 security in 2026: the state of account abstraction audits **Author:** deivitto | **Published:** 2026-02-26 | **Reading time:** 16 min **Tags:** erc-4337, account-abstraction, smart-contract-security, fuzzing **URL:** https://getrecon.xyz/blog/erc-4337-security-account-abstraction-audit # ERC-4337 Security in 2026: Auditing Account Abstraction *By deivitto — April 2026* Account abstraction has moved from an interesting EIP to core infrastructure. Nearly every major wallet ships some form of AA, paymasters handle millions in gas sponsorship daily, and the EntryPoint contract on Ethereum mainnet processes a growing volume of UserOperations. With adoption comes attack surface. And AA's attack surface is *weird* — it doesn't map cleanly to the mental models most auditors carry from traditional DeFi. Let's break down where the real security risks are in 2026, what vulnerability patterns we keep finding in AA implementations, and how to actually test these systems. ## The ERC-4337 Trust Model Before we get into vulnerabilities, let's be clear about the trust assumptions. ERC-4337 intentionally avoids protocol-level changes, which means it relies on a set of off-chain and on-chain components working together: **EntryPoint**. The singleton contract that executes UserOperations. It's the trust anchor. If the EntryPoint has a bug, every wallet using it has a bug. **Smart Account (Wallet)**. The user's on-chain account. Holds assets, validates signatures, executes transactions. **Bundler**. Off-chain actor that bundles UserOperations into transactions. Semi-trusted: can censor but shouldn't be able to steal. **Paymaster**. Optional contract that sponsors gas. Holds ETH, decides who gets sponsored. **Aggregator**. Optional contract that aggregates signatures for batch verification. Highly sensitive. **Factory**. Deploys new smart accounts. The `initCode` in a UserOperation triggers deployment. Each of these components has distinct security properties and failure modes. ## EntryPoint Risks The EntryPoint is the most audited contract in the AA ecosystem, and for good reason. A bug here is catastrophic. **Reentrancy in execution phase.** The EntryPoint calls into untrusted account and paymaster code. The execution flow is: validate → execute → postOp. Each phase must be isolated to prevent a malicious account from corrupting state during validation. The v0.7 EntryPoint added better isolation between these phases, but integrations that build *around* the EntryPoint sometimes reintroduce reentrancy windows. **Gas accounting manipulation.** The EntryPoint tracks gas usage carefully to ensure the correct party pays. But gas measurement on EVM is tricky: ```solidity // The EntryPoint measures gas like this (simplified): uint256 preGas = gasleft(); // ... execute the operation ... uint256 gasUsed = preGas - gasleft(); ``` An attacker who can manipulate `gasleft()` behavior (through gas griefing or careful calldata sizing) might pay less than expected or force the paymaster to overpay. **Stake/unstake griefing.** Entities that access global storage must stake ETH. The staking mechanism has a withdrawal delay. Attacks that trigger unexpected unstaking or delay manipulation can grief legitimate participants. ## Smart Account Vulnerabilities This is where most bugs live. Every team implements their own account logic, and the variation is enormous. ## Signature Validation Edge Cases The `validateUserOp` function is the security gatekeeper. Get it wrong and anyone can drain the wallet. **Missing `chainId` in signature hash.** If the signature doesn't commit to the chain ID, a valid UserOperation on one chain can be replayed on another: ```solidity // VULNERABLE -- no chain ID binding function validateUserOp( PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds ) external returns (uint256 validationData) { // userOpHash already includes chainId via EntryPoint, // but if you're doing custom hash construction: // BAD -- cross-chain replay possible bytes32 customHash = keccak256(abi.encode( userOp.sender, userOp.nonce, userOp.callData )); // GOOD -- chain-specific bytes32 customHash = keccak256(abi.encode( userOp.sender, userOp.nonce, userOp.callData, block.chainid )); } ``` **Nonce management bugs.** ERC-4337 uses a 2D nonce scheme: a 192-bit key and a 64-bit sequence number. Some implementations handle this incorrectly, either allowing nonce reuse across keys or not incrementing properly. **Signature malleability.** If you're using ECDSA without checking `s` value canonicality, the same logical signature can produce multiple valid byte representations: ```solidity // Always use OpenZeppelin's ECDSA or verify s <= secp256k1n/2 // A malleable signature lets an attacker submit a "different" // valid signature for the same operation ``` **Module interaction bugs.** Modular account architectures (ERC-6900, ERC-7579) let users install/remove validation modules. The security question is: can a malicious module override or bypass the primary validator? We've seen bugs where: - A module could set itself as the fallback validator, then approve arbitrary operations - Module removal didn't properly clean up storage, leaving ghost permissions - Module installation wasn't gated, letting anyone add a malicious validator ## Storage Access Rules ERC-4337 restricts storage access during validation to prevent DoS attacks on bundlers. Validation code can only access: - The account's own storage - The account's associated storage in other contracts (mapped by the account's address) - Staked entities' storage (with staking requirements) Violations don't cause reverts in the EntryPoint, they cause the *bundler* to reject the UserOperation. This means: 1. Your tests might pass (using a permissive local bundler) while production bundlers reject your operations 2. Storage access patterns that work in `executeUserOp` might not work in `validateUserOp` ```solidity // This works in execute phase but FAILS in validation phase // if the oracle isn't staked: function validateUserOp(...) external { // Reading external contract storage during validation uint256 price = oracle.getPrice(); // BANNED in validation require(price > minPrice, "Price too low"); } ``` Testing for storage rule compliance requires a bundler that enforces ERC-7562 rules, not just a basic Foundry test. ## Paymaster Security Paymasters are essentially saying "I'll pay gas for this operation." They're holding ETH and making decisions about who to sponsor. That's a juicy target. ## Common Paymaster Vulnerabilities **Insufficient validation.** A paymaster that sponsors too broadly can be drained by anyone submitting expensive operations: ```solidity // VULNERABLE -- sponsors everything function validatePaymasterUserOp( PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 maxCost ) external returns (bytes memory context, uint256 validationData) { // No checks at all -- anyone can drain the deposit return (abi.encode(userOp.sender), 0); } ``` **Signature replay on paymaster approvals.** If the paymaster signs an approval for a specific UserOperation but the signature doesn't bind to enough fields, an attacker can reuse the approval: ```solidity // The paymaster signature MUST include: // - sender // - nonce (prevents replay) // - callData (prevents operation substitution) // - maxGasValues (prevents gas griefing) // - validUntil/validAfter (time-bounds the approval) bytes32 hash = keccak256(abi.encode( userOp.sender, userOp.nonce, keccak256(userOp.callData), userOp.accountGasLimits, userOp.preVerificationGas, userOp.gasFees, block.chainid, address(this), validUntil, validAfter )); ``` **postOp manipulation.** The `postOp` function runs after the UserOperation executes. If the paymaster uses postOp to charge the user (e.g., in ERC-20 tokens), the user's operation might manipulate token balances to avoid payment: ```solidity // Attack flow: // 1. Paymaster sponsors gas, plans to charge USDC in postOp // 2. User's operation transfers all USDC out of the account // 3. postOp tries to charge USDC -- fails or charges 0 // 4. Paymaster paid the gas but got nothing in return ``` The v0.7 EntryPoint partially addresses this with a `postOpReverted` mode, but implementations still get the accounting wrong. ## Bundler Trust Assumptions Bundlers are semi-trusted. They can't steal funds directly, but they can: - **Censor operations.** Simply not include your UserOperation. - **Front-run.** See the UserOperation in the mempool and extract value. - **Grief.** Submit operations with manipulated gas parameters. - **Reorder.** Choose the order of operations in a bundle for profit. The mitigation is bundler competition, if your bundler censors you, use a different one. But in practice, bundler diversity is still limited on most chains. This is an infrastructure risk more than a smart contract risk, but it affects security posture. ## Factory Deployment Risks The `initCode` field in a UserOperation can deploy a new smart account. Security issues here: **Counterfactual address manipulation.** The CREATE2 address depends on the factory address, salt, and init code. If any of these can be manipulated, an attacker might deploy a different contract at the expected address: ```solidity // A factory should be deterministic and tamper-proof function createAccount( address owner, uint256 salt ) external returns (address account) { // The account address must be fully determined by (owner, salt) // No other mutable state should influence deployment account = address(new SmartAccount{salt: bytes32(salt)}( entryPoint, owner )); } ``` **Griefing through pre-deployment.** An attacker can deploy someone's account before they do, potentially with different initialization parameters if the factory allows it. ## Testing AA Systems with Fuzzing Standard smart contract [fuzzing](/learn/fuzzing) doesn't capture the full AA attack surface. You need to model the UserOperation flow. We've written a detailed guide on [how to fuzz ERC-4337 account abstraction](/blog/how-to-fuzz-erc4337-account-abstraction). Here's the high-level approach: **Model the full lifecycle:** ```solidity function invariant_accountBalanceConsistency() public { // After any sequence of UserOperations, // the account's balance should match expected state uint256 expectedBalance = ghost_initialBalance - ghost_totalGasPaid - ghost_totalTransferred + ghost_totalReceived; assertGe( account.balance, expectedBalance, "Account balance underflow -- possible gas accounting bug" ); } function handler_executeViaEntryPoint( uint256 targetSeed, uint256 valueSeed, bytes calldata randomCalldata ) external { // Build a UserOperation with fuzzed parameters PackedUserOperation memory userOp = buildUserOp( targetSeed, valueSeed, randomCalldata ); // Sign it properly bytes32 hash = entryPoint.getUserOpHash(userOp); userOp.signature = signHash(hash, ownerKey); // Execute through EntryPoint PackedUserOperation[] memory ops = new PackedUserOperation[](1); ops[0] = userOp; try entryPoint.handleOps(ops, payable(bundler)) { ghost_totalGasPaid += calculateGasCost(userOp); } catch { // Track failed ops separately } } ``` **Key properties to test:** 1. **No unauthorized execution.** Operations with invalid signatures must revert. 2. **Gas accounting correctness.** The correct party always pays the correct amount. 3. **Nonce uniqueness.** No nonce can be used twice. 4. **Module isolation.** One module can't affect another's validation. 5. **Paymaster solvency.** The paymaster deposit always covers committed gas. For more on [stateful fuzzing](/learn/stateful-fuzzing), check our learning resources. ## Current State of AA Security Tooling The tooling has gotten better, but gaps remain. **What works:** - Foundry for unit testing account logic - Slither and Aderyn for static analysis (with AA-specific detectors) - Custom [invariant testing](/learn/invariant-testing) frameworks for protocol-level properties - [Halmos](/learn/halmos) for [symbolic execution](/learn/symbolic-execution) of validation logic **What's still rough:** - No great way to test bundler rule compliance in Foundry - Paymaster economic simulations are mostly manual - Cross-chain AA testing (same account on multiple chains) lacks tooling - Module interaction testing for ERC-6900/7579 is still early **What we'd like to see:** - Bundler-in-the-loop testing frameworks - Formal models of the AA mempool - Automated storage access rule verification - Better gas estimation testing tools ## Practical Recommendations If you're building or auditing an AA system: 1. **Test validation separately from execution.** They have different security properties and different EVM constraints. 2. **Simulate bundler rejection.** Don't just test if operations succeed, test if a real bundler would accept them. 3. **Fuzz the paymaster.** Generate random UserOperations and verify the paymaster never loses money. This is where [property-based testing](/learn/property-based-testing) really shines. 4. **Check signature binding.** Every field that affects operation semantics should be included in the signed hash. 5. **Test module combinations.** If you support modular validation, test every possible combination of installed modules. 6. **Verify upgrade paths.** Account upgrades are especially sensitive, a bug in the upgrade function can brick every wallet. The [ERC-4337 learning page](/learn/erc-4337) covers more foundational concepts, and our [smart contract security](/learn/smart-contract-security) resources apply broadly to AA development. ## Where This Is Heading AA security is maturing. The EntryPoint v0.7 fixed several classes of issues from v0.6. Bundler specifications (ERC-7562) are tightening. Module standards are stabilizing. But the attack surface is growing faster than the defense. New paymaster designs, new module types, new account architectures, cross-chain AA, and intent-based execution layers, each adds complexity. The teams that stay safe are the ones that treat AA security as an ongoing process, not a one-time audit. Write properties. Fuzz continuously. Monitor on-chain. Get an ERC-4337 security review --- ### LLM-generated invariant properties: what works, what hallucinates, how we use them **Author:** deivitto | **Published:** 2026-03-29 | **Reading time:** 14 min **Tags:** ai-auditing, invariant-testing, fuzzing, property-based-testing **URL:** https://getrecon.xyz/blog/llm-property-generation-smart-contracts # LLM-Generated Invariant Properties: What Works, What Hallucinates, How We Use Them *By deivitto — April 2026* Everyone wants to know: can you just throw your smart contract at GPT-4 or Claude and get a list of invariant properties to test? Short answer: kind of. Long answer: it depends enormously on the type of property, the complexity of the protocol, and how much you trust the output without verification. We've been experimenting with LLM-generated properties for over a year now. We've run thousands of generated candidates through actual [fuzzing](/learn/fuzzing) campaigns. Here's what we've learned — the good, the bad, and the stuff that looks right but is completely wrong. ## The workflow: not "Generate and ship" Let me be clear about how we actually use LLMs for property generation. It's not "paste code, get properties, run fuzzer." It's a four-stage pipeline: **Stage 1: LLM generates candidate properties.** Feed the contract code, NatSpec, and protocol documentation. Ask for invariant properties in a specific format. **Stage 2: Human reviews candidates.** An auditor reads each property and checks: does this make sense? Is it testing what it claims? Does it match the protocol's actual semantics? **Stage 3: Fuzzer validates.** Surviving properties get implemented and run against the codebase. The fuzzer tells us if they hold, break immediately (bad property), or break under specific conditions (real finding or edge case). **Stage 4: Human refines.** Based on fuzzer results, the auditor adjusts, combines, or replaces properties. This often leads to new properties the LLM never suggested. The LLM handles maybe 30% of the total work. But it's the *right* 30%, the tedious boilerplate that would otherwise eat into an auditor's time for thinking deeply. ## What works well ## Standard token properties LLMs are great at generating ERC-20/721/1155 invariant properties. These patterns are heavily represented in training data, and the properties are well-known: ```solidity // LLM-generated -- accurate and useful as-is function invariant_totalSupplyEqualsSumOfBalances() public { uint256 sum = 0; for (uint256 i = 0; i < actors.length; i++) { sum += token.balanceOf(actors[i]); } assertEq(token.totalSupply(), sum); } function invariant_transferPreservesTotalSupply() public { // totalSupply should never change due to transfers assertEq(token.totalSupply(), ghost_initialTotalSupply); } function invariant_balanceNeverExceedsTotalSupply() public { for (uint256 i = 0; i < actors.length; i++) { assertLe(token.balanceOf(actors[i]), token.totalSupply()); } } ``` These are correct, well-formed, and directly usable. The LLM can generate 10-15 ERC-20 properties in seconds, and 80%+ will be valid. They won't find novel bugs alone, but they form a solid baseline. ## Access control properties LLMs handle role-based access control well: ```solidity // LLM-generated -- good starting point function invariant_onlyOwnerCanPause() public { // If the contract is paused, the owner must have called pause() if (vault.paused()) { assertTrue( ghost_pauseCaller == vault.owner(), "Non-owner paused the contract" ); } } function invariant_ownerIsNeverZeroAddress() public { assertTrue(vault.owner() != address(0)); } ``` ## Monotonicity properties Properties about values that should only increase or decrease: ```solidity // LLM-generated -- usually correct for vaults function invariant_totalAssetsNeverDecreaseWithoutWithdrawal() public { if (!ghost_withdrawalOccurred) { assertGe( vault.totalAssets(), ghost_previousTotalAssets, "Total assets decreased without withdrawal" ); } ghost_previousTotalAssets = vault.totalAssets(); ghost_withdrawalOccurred = false; } ``` ## Boilerplate setup code LLMs are also surprisingly good at generating the handler functions and setup code for [invariant testing](/learn/invariant-testing) frameworks. Given a contract's interface, they'll produce reasonable `setUp()` functions and handler stubs: ```solidity // LLM-generated handler setup -- saves 20-30 minutes contract VaultHandler is Test { Vault vault; ERC20 asset; address[] actors; constructor(Vault _vault, ERC20 _asset) { vault = _vault; asset = _asset; actors = new address[](3); actors[0] = makeAddr("alice"); actors[1] = makeAddr("bob"); actors[2] = makeAddr("carol"); } function deposit(uint256 actorSeed, uint256 amount) external { address actor = actors[actorSeed % actors.length]; amount = bound(amount, 1, asset.balanceOf(actor)); vm.startPrank(actor); asset.approve(address(vault), amount); vault.deposit(amount, actor); vm.stopPrank(); } function withdraw(uint256 actorSeed, uint256 amount) external { address actor = actors[actorSeed % actors.length]; uint256 maxWithdraw = vault.maxWithdraw(actor); if (maxWithdraw == 0) return; amount = bound(amount, 1, maxWithdraw); vm.prank(actor); vault.withdraw(amount, actor, actor); } } ``` This kind of scaffolding is where LLMs save the most time. Writing handler functions is repetitive and mechanical, exactly the kind of work LLMs handle well. ## What hallucinates Now the part that matters more. Here's where LLMs confidently generate properties that are wrong. ## Complex DeFi logic Ask an LLM to generate properties for a lending protocol's liquidation mechanism and you'll get something like: ```solidity // LLM-generated -- WRONG function invariant_healthFactorAboveOneAfterLiquidation() public { for (uint256 i = 0; i < borrowers.length; i++) { if (ghost_wasLiquidated[borrowers[i]]) { assertGt( pool.healthFactor(borrowers[i]), 1e18, "Health factor still below 1 after liquidation" ); } } } ``` This *looks* right. It isn't. Many lending protocols allow partial liquidation, where the health factor improves but might not reach 1.0. Some protocols have close factors that limit how much can be liquidated per transaction. The LLM doesn't understand these protocol-specific mechanics, it's pattern-matching from examples it's seen, and the examples don't cover every design choice. ## Cross-Contract interactions LLMs struggle with properties that span multiple contracts: ```solidity // LLM-generated -- WRONG function invariant_oracleAlwaysReturnsPositivePrice() public { (,int256 price,,,) = oracle.latestRoundData(); assertGt(price, 0, "Oracle returned non-positive price"); } ``` This property *should* hold in production. But it doesn't belong in an invariant test, the oracle is an external dependency, and in a fuzzing context, you're testing your *protocol's* behavior, not Chainlink's. The real property is: "when the oracle returns zero or negative, does our protocol handle it safely?" ```solidity // What it should have been: function invariant_protocolHandlesOracleFailureGracefully() public { // After any sequence of operations (including oracle manipulation), // no user should lose funds due to stale/bad oracle data for (uint256 i = 0; i < users.length; i++) { // User's redeemable value should never be zero // when they have a positive deposit if (ghost_deposits[users[i]] > 0) { assertGt( vault.maxWithdraw(users[i]), 0, "User with deposits can't withdraw" ); } } } ``` The LLM tested the oracle. The auditor tests the protocol's *reaction* to oracle behavior. ## Timing and ordering dependencies Properties involving time-dependent logic almost always need human correction: ```solidity // LLM-generated -- SUBTLY WRONG function invariant_vestingReleasesCorrectly() public { uint256 elapsed = block.timestamp - vestingStart; uint256 expectedRelease = totalVested * elapsed / vestingDuration; assertEq(vesting.released(), expectedRelease); } ``` This ignores: cliff periods, rounding behavior, how `released()` tracks what was claimed (not what's claimable), and discrete vs continuous vesting. The LLM generates a linear model because that's the simplest one. Real vesting contracts are messier. ## Economic attack properties This is the biggest gap. LLMs can't reason well about economic attacks: ```solidity // LLM-generated -- MEANINGLESS function invariant_noFlashLoanAttackPossible() public { // ... this isn't even a thing you can express as a simple property } ``` Economic attacks require understanding incentive structures, market conditions, cross-protocol interactions, and adversarial behavior models. LLMs don't have a model of DeFi economics, they have patterns of code. There's a huge difference. ## Success rates: the numbers Over the past year, here's roughly how LLM-generated properties perform across categories: | Category | Accuracy (Usable As-Is) | Needs Minor Fix | Wrong/Misleading | |---|---|---|---| | ERC-20/721 standard | ~85% | ~10% | ~5% | | Access control | ~75% | ~15% | ~10% | | Simple arithmetic | ~70% | ~20% | ~10% | | Vault/ERC-4626 | ~50% | ~30% | ~20% | | Lending/borrowing | ~25% | ~35% | ~40% | | AMM/DEX logic | ~20% | ~30% | ~50% | | Cross-protocol | ~10% | ~20% | ~70% | | Economic/incentive | ~5% | ~15% | ~80% | The pattern is clear: the more protocol-specific the logic, the worse LLMs perform. Standard patterns work. Custom business logic doesn't. ## Practical tips for using lLMs Here's what we've found makes the biggest difference: ## 1. give context, not just code Don't just paste the contract. Include: - What the protocol does in plain English - The key invariants *you* know should hold - The architecture (which contracts interact with which) - Known edge cases The LLM generates better properties when it understands intent, not just syntax. ## 2. ask for categories separately Instead of "generate all invariant properties for this contract," ask: - "Generate properties about balance conservation" - "Generate properties about access control" - "Generate properties about state transitions" Focused prompts produce focused (and more accurate) results. ## 3. ask for counterexamples After generating a property, ask the LLM: "In what scenario could this property be false even in correct code?" This forces it to think about edge cases and often reveals issues with its own generated properties. ## 4. use the LLM to review its own output Generate properties with one prompt. Then in a separate conversation, present the properties alongside the contract and ask: "Are any of these properties incorrect or misleading?" LLMs are often better at critiquing than generating. ## 5. generate in test framework format Ask for properties in your actual testing framework's syntax ([Foundry](/learn/foundry), Hardhat, etc.) rather than pseudocode. This removes a manual translation step and forces the LLM to think about implementation details like `bound()` calls and `vm.prank()`. ## The dangerous middle ground Here's what keeps me up at night about LLM-generated properties: the ones that are *almost* right. A clearly wrong property gets caught in review. A clearly right property adds value immediately. But a property that's subtly wrong, it passes the fuzzer, it looks correct at a glance, it gives you confidence, that's dangerous. Example from an actual engagement: ```solidity // LLM-generated, passed fuzzer, looked correct function invariant_poolSolvency() public { assertGe( underlying.balanceOf(address(pool)), pool.totalDebt(), "Pool insolvent" ); } ``` Looks right? The pool should have enough tokens to cover all debt. Except this protocol had a yield strategy that deployed assets to external protocols. The `balanceOf` check only captures tokens *in the pool contract*, not tokens deployed to strategies. The correct property needed to account for `totalDeployed()` as well. The fuzzer didn't catch it because the test setup didn't include a yield strategy actor. The property passed for thousands of runs. Everyone felt safe. The property was wrong. This is why Stage 2 (human review) isn't optional. An experienced auditor would've asked: "wait, where's the yield accounting?" ## Our actual workflow, step by step For transparency, here's exactly how we integrate LLMs into our [invariant testing](/learn/invariant-testing) workflow: 1. **Manual architecture review**, We read the contracts and build a mental model first. Never skip this. 2. **Write critical properties by hand**, The 3-5 most important invariants for the protocol are always human-written. These are the properties that, if violated, mean the protocol is broken. 3. **LLM generates candidate expansion**, We feed the contract + our hand-written properties to the LLM and ask it to generate additional properties. This typically produces 20-40 candidates. 4. Human triage, An auditor reviews each candidate: keep, modify, or discard. Usually keeps about 40%, modifies 25%, discards 35%. 5. Implement and fuzz, Surviving properties get implemented and run. Broken properties get investigated, is it a bad property or a real bug? 6. Iterate, Based on fuzzer results, we refine properties and generate new ones. This often involves going back to the LLM with specific questions: "This property broke because of X, generate alternative properties that account for that behavior." Total time saved vs. pure manual: roughly 25-35% on property generation. The bigger win is in the handler/setup code, probably 40-50% time saved there. ## What the future looks like LLMs are getting better at code reasoning. The gap between "standard patterns" and "protocol-specific logic" is narrowing with each model generation. But I don't see LLMs replacing human auditors for property generation anytime soon. The reason is simple: the hardest properties require understanding *intent*, not just *code*. What's the protocol supposed to do? What economic assumptions does it make? What happens when those assumptions break? These are questions that require domain knowledge, adversarial thinking, and context that goes beyond the codebase. Where I do see LLMs becoming more useful: - Better boilerplate. As models improve, the accuracy on standard patterns will approach 95%+. - Property templates. "Here's a lending protocol with these parameters, generate the standard lending properties adapted to this implementation." - Mutation suggestion. "Here's a property that passed, suggest mutations that might break it." This feeds into [mutation testing](/learn/mutation-testing) workflows. - Coverage gap detection. "Here are my existing properties, what aspects of the protocol aren't covered?" For more on [AI-assisted auditing](/learn/ai-auditing), check our learning resources. And if you want to see how these techniques fit into a broader audit process, read [AI and smart contract audits](/blog/ai-smart-contract-audits). ## The bottom line LLMs are a tool. A useful one. They don't replace the auditor's brain, they give the auditor a faster way to generate the starting material. Use them for standard properties, boilerplate, and brainstorming. Don't trust them for protocol-specific business logic, economic properties, or cross-contract interactions. And always, always run the output through a human and a fuzzer before believing it. The best invariant properties still come from someone who deeply understands the protocol. LLMs can help that person work faster. That's genuinely useful. It's just not magic. Try Recon Pro --- ### Fuzzing ZK circuits: testing Noir and Circom with property-based approaches **Author:** deivitto | **Published:** 2026-04-05 | **Reading time:** 14 min **Tags:** fuzzing, zk, property-based-testing, smart-contract-security **URL:** https://getrecon.xyz/blog/zk-fuzzing-circuit-testing-noir-circom # Fuzzing ZK Circuits: Testing Noir and Circom with Property-Based Approaches *By deivitto — April 2026* ZK circuits aren't smart contracts. You can't just throw a Foundry fuzzer at them and hope for the best. The execution model is different, the bug classes are different, and the tooling is still catching up. But the core idea of [property-based testing](/learn/property-based-testing) — define what should always be true, then generate random inputs to check , applies just as well to circuits as it does to Solidity. You just need different techniques. I've spent the last year pushing property-based approaches into ZK circuit testing for Noir and Circom. Here's what works, where the tooling is, and what bugs you'll actually catch. ## Why ZK circuit testing is different In a smart contract, a bug means wrong state or lost funds. In a ZK circuit, bugs are more subtle and arguably more dangerous. **Under-constrained circuits** are the classic ZK bug. The circuit accepts proofs it shouldn't. A prover can generate a valid proof for a statement that's false. This is catastrophic, it silently breaks the entire security guarantee. **Over-constrained circuits** are less dangerous but still problematic. The circuit rejects valid inputs. Users can't prove true statements. The system doesn't work, but at least it doesn't accept lies. **Witness generation mismatches** happen when the witness generation code (the part that computes intermediate values) disagrees with the constraints. The circuit might be correctly constrained but the solver can't find a satisfying witness for valid inputs. **Soundness issues** are the deepest class. The arithmetic relationship expressed by the constraints doesn't match the intended mathematical relationship. This requires understanding both the math and the circuit encoding. None of these map cleanly to "run the function with random inputs and check if it reverts." You need properties that specifically target ZK failure modes. ## Noir: property-Based testing with nargo Noir has the most ergonomic testing story in the ZK space right now. `nargo test` supports basic testing out of the box, and with some work, you can build property-based testing on top. ## Basic Noir test structure ```rust // A simple Noir circuit: prove you know the preimage of a hash fn main(preimage: [u8; 32], expected_hash: pub [u8; 32]) { let computed = std::hash::sha256(preimage); assert(computed == expected_hash); } #[test] fn test_valid_preimage() { let preimage = [1; 32]; // known preimage let hash = std::hash::sha256(preimage); main(preimage, hash); // should pass } ``` This is a unit test. It checks one input. For property-based testing, we want to check *all* inputs (or at least a lot of random ones). ## Building property-Based tests in Noir Noir doesn't have a built-in fuzzing framework yet, but you can build one using nargo's test infrastructure and a wrapper script. ```rust // property_tests.nr // Property: for ANY valid preimage, the circuit should accept // the correctly computed hash #[test] fn property_correct_hash_always_accepted() { // We can't easily generate random bytes in Noir's test framework // So we test with deterministic but varied inputs let inputs: [[u8; 32]; 5] = [ [0; 32], [1; 32], [255; 32], [0x42; 32], [0xDE; 32], ]; for input in inputs { let hash = std::hash::sha256(input); main(input, hash); // must not fail } } // Property: a WRONG hash should always be rejected #[test(should_fail)] fn property_wrong_hash_always_rejected() { let preimage = [1; 32]; let hash = std::hash::sha256(preimage); // Flip one bit in the hash let mut bad_hash = hash; bad_hash[0] = hash[0] ^ 1; main(preimage, bad_hash); // must fail } ``` For real fuzzing, we drive input generation from outside Noir: ```bash #!/bin/bash # fuzz_noir_circuit.sh -- generate random inputs, run nargo for i in $(seq 1 1000); do # Generate random 32-byte preimage PREIMAGE=$(python3 -c "import os; print(list(os.urandom(32)))") # Compute expected hash HASH=$(python3 -c " import hashlib, os, sys preimage = os.urandom(32) h = hashlib.sha256(preimage).digest() print(f'preimage = {list(preimage)}') print(f'expected_hash = {list(h)}') ") # Write Prover.toml echo "$HASH" > Prover.toml # Run the circuit if ! nargo execute 2>/dev/null; then echo "FAILURE on iteration $i" echo "Input: $PREIMAGE" exit 1 fi done echo "All 1000 iterations passed" ``` This is crude but effective. Each iteration generates random inputs, computes the expected output, and checks that the circuit accepts. ## Testing for under-Constrained circuits in Noir The real power of property-based testing for ZK is catching under-constrained circuits. The property is: "for the same public inputs, there should be at most one valid witness (or a known equivalence class)." ```rust // If your circuit computes a deterministic function, // there should be exactly one valid witness per public input. // This Noir circuit is UNDER-CONSTRAINED: fn bad_range_check(x: Field, max: pub Field) { // Intended: prove x < max // Bug: only constrains x * x < max * max // A negative field element could satisfy this assert(x * x as u64 < max * max as u64); } // Property test: can we find two different x values // that both satisfy the circuit for the same max? #[test] fn property_unique_witness() { let max: Field = 100; // If x=5 satisfies the circuit... bad_range_check(5, max); // ...and x=p-5 (where p is the field modulus) also satisfies it, // the circuit is under-constrained } ``` In practice, detecting under-constrained circuits requires more sophisticated techniques, often involving running the circuit with carefully crafted adversarial inputs. ## Noir's `nargo fuzz` (Experimental) As of early 2026, Noir has experimental fuzzing support. It's not fully mature, but it's getting there: ```rust // With nargo's experimental fuzz support: #[fuzz] fn fuzz_transfer_circuit( sender_balance: u64, amount: u64, receiver_balance: u64 ) { // Skip invalid inputs if amount > sender_balance { return; } // The transfer circuit should preserve total supply let total_before = sender_balance + receiver_balance; // Simulate the circuit logic let new_sender = sender_balance - amount; let new_receiver = receiver_balance + amount; let total_after = new_sender + new_receiver; assert(total_before == total_after); } ``` ## Circom: property testing with circom-tester Circom's testing story revolves around JavaScript/TypeScript testing with `circom_tester`. You write circuits in Circom and test them in JS. ## Basic Circom test setup ```javascript // test/transfer.test.js const { wasm } = require("circom_tester"); const path = require("path"); describe("Transfer Circuit", () => { let circuit; before(async () => { circuit = await wasm( path.join(__dirname, "../circuits/transfer.circom") ); }); it("should accept valid transfer", async () => { const input = { senderBalance: 100, amount: 30, receiverBalance: 50, }; const witness = await circuit.calculateWitness(input, true); await circuit.checkConstraints(witness); }); }); ``` ## Property-Based testing with fast-check Combine circom_tester with a property testing library like fast-check: ```javascript const fc = require("fast-check"); const { wasm } = require("circom_tester"); describe("Transfer Circuit Properties", () => { let circuit; before(async () => { circuit = await wasm("circuits/transfer.circom"); }); it("should preserve total supply for any valid transfer", async () => { await fc.assert( fc.asyncProperty( fc.integer({ min: 0, max: 2 ** 32 }), // senderBalance fc.integer({ min: 0, max: 2 ** 32 }), // receiverBalance fc.integer({ min: 0, max: 2 ** 32 }), // amount async (senderBalance, receiverBalance, amount) => { // Precondition: valid transfer fc.pre(amount <= senderBalance); fc.pre( receiverBalance + amount < 2 ** 64 ); // no overflow const input = { senderBalance, amount, receiverBalance }; const witness = await circuit.calculateWitness( input, true ); await circuit.checkConstraints(witness); // Check: total supply preserved const newSender = BigInt( witness[circuit.getSignalIdx("main.newSenderBalance")] ); const newReceiver = BigInt( witness[ circuit.getSignalIdx("main.newReceiverBalance") ] ); const totalBefore = BigInt(senderBalance) + BigInt(receiverBalance); const totalAfter = newSender + newReceiver; return totalBefore === totalAfter; } ), { numRuns: 1000 } ); }); it("should reject transfers exceeding balance", async () => { await fc.assert( fc.asyncProperty( fc.integer({ min: 1, max: 2 ** 32 }), fc.integer({ min: 0, max: 2 ** 32 }), async (senderBalance, extraAmount) => { const amount = senderBalance + extraAmount + 1; const input = { senderBalance, amount, receiverBalance: 0, }; try { await circuit.calculateWitness(input, true); // If we get here, the circuit accepted an // invalid transfer -- that's a bug return false; } catch (e) { // Circuit correctly rejected return true; } } ), { numRuns: 500 } ); }); }); ``` ## Testing for under-Constrained Circom circuits Here's a concrete Circom circuit with an under-constrained bug and how to catch it: ```circom // circuits/range_check.circom -- BUGGY pragma circom 2.1.0; template RangeCheck(N) { signal input value; signal input max_value; signal output in_range; // BUG: this doesn't actually constrain value < max_value // It only computes the comparison but doesn't assert it signal diff; diff <-- max_value - value; // Missing: constraint that diff is positive (range check on diff) // A prover could set diff to anything and still satisfy constraints in_range <-- (value < max_value) ? 1 : 0; // This is witness generation only -- no constraint! } component main = RangeCheck(64); ``` Property-based test that catches it: ```javascript it("should reject value >= max_value", async () => { await fc.assert( fc.asyncProperty( fc.bigInt({ min: 100n, max: 2n 64n }), fc.bigInt({ min: 0n, max: 99n }), async (value, max_value) => { fc.pre(value >= max_value); // Only test invalid inputs const input = { value: value.toString(), max_value: max_value.toString(), }; try { const witness = await circuit.calculateWitness( input, true ); await circuit.checkConstraints(witness); // If constraints pass for value >= max_value, // the circuit is under-constrained! const inRange = witness[ circuit.getSignalIdx("main.in_range") ]; // The circuit should either: // 1. Fail constraint check, OR // 2. Output in_range = 0 // If in_range = 1 for invalid input, // that's definitely broken return inRange === 0n; } catch (e) { // Constraint failure is acceptable -- means // circuit correctly rejects return true; } } ), { numRuns: 1000 } ); }); ``` ## Common ZK bugs that property testing catches ## 1. missing range checks The most common ZK bug. Field arithmetic wraps around the prime modulus, so values that "look" small might actually be large negative numbers in disguise. Property: "For all outputs, the output value should be less than 2^N when the circuit claims a range check to N bits." ## 2. unchecked witness values When you use `<--` in Circom (assignment without constraint), the prover is free to set the value to anything. If there's no corresponding `===` constraint, the circuit is under-constrained. Property: "For any given public input, changing the private witness values should either produce the same public output or fail constraint checking." ## 3. hash collision acceptance Circuits that verify hash preimages should reject all inputs except the correct one. Property: "For random preimage p, the circuit with expected_hash = H(p) should reject any preimage q where q != p." (With high probability for random inputs.) ## 4. arithmetic overflow in field operations Field elements wrap around at the prime. `p - 1 + 2 = 1` in the field. If your circuit assumes normal integer arithmetic, you'll have bugs. Property: "For inputs near the field boundary (p-1, p-2, etc.), the circuit should either handle wraparound correctly or reject the input." ```javascript it("should handle field boundary correctly", async () => { const p = 21888242871839275222246405745257275088548364400416034343698204186575808495617n; const boundaryInputs = [ p - 1n, p - 2n, 0n, 1n, p / 2n, ]; for (const input of boundaryInputs) { const circuitInput = { value: input.toString() }; try { const witness = await circuit.calculateWitness( circuitInput, true ); await circuit.checkConstraints(witness); // Verify output makes sense for this input } catch (e) { // Rejection is fine -- just ensure no panic/undefined behavior } } }); ``` ## 5. nullifier uniqueness violations In protocols that use nullifiers (like mixers or private transactions), each commitment should produce exactly one nullifier. If two different commitments produce the same nullifier, you've got a collision bug. If one commitment can produce two different nullifiers, you've got a double-spend. Property: "For all commitments c1 != c2, nullifier(c1) != nullifier(c2)." Property: "For any commitment c, the nullifier is deterministic, computing it twice yields the same result." ## Integrating with CI/CD Property-based circuit tests should run on every commit. Here's a GitHub Actions setup: ```yaml # .github/workflows/zk-tests.yml name: ZK Circuit Tests on: [push, pull_request] jobs: noir-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Noir run: | curl -L https://raw.githubusercontent.com/noir-lang/noirup/main/install | bash noirup - name: Run Noir tests run: nargo test circom-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - name: Install dependencies run: npm ci - name: Install Circom run: | cargo install --git https://github.com/iden3/circom.git - name: Run property tests run: npx mocha test//*.test.js --timeout 120000 ``` ## How this differs from smart contract testing A few key differences: No state. Circuits are pure functions. There's no storage, no msg.sender, no block.timestamp. Every invocation is independent. This actually makes property testing *easier*, you don't need [stateful fuzzing](/learn/stateful-fuzzing), just input/output properties. Constraint satisfaction vs execution. In Solidity, you run the code and check the output. In ZK, you're checking two things: does the witness satisfy the constraints, and does the witness generation produce correct values? These can disagree. Field arithmetic. Everything operates over a prime field. Your intuitions about integer arithmetic don't transfer directly. `a - b` when `b > a` doesn't revert, it wraps around the field modulus. Proof generation vs constraint checking. Testing the constraints is fast. Generating actual proofs is slow. For property testing, you almost always want to test at the constraint level, not the proof level. For broader context on [fuzzing](/learn/fuzzing) and how it applies across different domains, see our learning section. And if you're working on ZK circuit security more broadly, our guide on [ZK circuit security audit patterns](/blog/zk-circuit-security-audit-patterns) covers the audit perspective. ## What's next for ZK circuit testing The tooling is evolving fast. Here's what I'm watching: - Noir's native fuzzing. The experimental support will mature. When it does, property-based testing in Noir will be as easy as it is in Rust. - Formal verification for circuits. Tools that can mathematically prove circuit correctness, not just test it. This is the endgame for under-constrained detection. - Cross-framework testing. Testing the same logic implemented in both Noir and Circom to catch framework-specific bugs, a form of differential testing. - Adversarial witness generators. Tools that specifically try to find witnesses that satisfy constraints but violate the intended semantics. Think of it as a fuzzer that's trying to cheat the prover. ZK security is where smart contract security was five years ago. The bugs are there, the stakes are high, and the testing practices are still forming. Property-based testing is the fastest way to close that gap. Get a ZK circuit security review --- ### Differential testing for smart contracts: comparing implementations to find bugs **Author:** antonio | **Published:** 2025-05-05 | **Reading time:** 14 min **Tags:** fuzzing, differential-testing, property-based-testing, smart-contract-security **URL:** https://getrecon.xyz/blog/differential-testing-smart-contracts # Differential Testing for Smart Contracts: Comparing Implementations to Find Bugs *By antonio — April 2026* Here's a simple idea that catches surprisingly nasty bugs: take two implementations of the same thing and compare their outputs. If they disagree, at least one of them is wrong. That's differential testing. It's been a workhorse technique in compiler testing and browser security for decades. It works just as well for smart contracts — maybe even better, because DeFi is full of multiple implementations of the same specs. Let me show you how to set it up, where it shines, and the real bugs it catches. ## What is differential testing? The concept is straightforward: 1. You have two (or more) implementations of the same specification 2. You feed them the same inputs 3. You compare their outputs 4. Any difference is a bug in at least one implementation The power is that you don't need to know what the correct output *should be*. You just need to know that both implementations should agree. This lets you generate millions of random inputs without writing specific expected outputs for each one. ``` Input → Implementation A → Output A ─┐ ├─→ Compare → Mismatch = Bug Input → Implementation B → Output B ─┘ ``` For smart contracts, the "implementations" can be: - Two different contracts implementing the same ERC standard - A reference implementation vs a gas-tuned version - The same contract compiled with different Solidity versions - A Solidity implementation vs a Vyper implementation - An on-chain contract vs an off-chain simulator ## When differential testing makes sense Not every project needs differential testing. Here's when it's worth the setup cost: **You're building a gas-efficient version of something standard.** If you're writing a gas-tuned ERC-20 or a custom AMM based on a known formula, differential testing against the reference implementation catches bugs introduced by the rewrite. **You're migrating between versions.** Upgrading from Solidity 0.7 to 0.8? Migrating a Vyper contract to Solidity? Differential testing verifies behavioral equivalence. **You have a spec with multiple implementations.** ERC-4626 vaults, ERC-2612 permits, or any standard where multiple teams have written compliant implementations. **You have an off-chain model.** Many DeFi protocols have Python or TypeScript models for their math. Differential testing against the on-chain implementation catches precision and rounding bugs. ## Setting up differential tests in Foundry [Foundry](/learn/foundry) makes differential testing relatively straightforward. Here's a complete example comparing two AMM implementations. ## The setup: two AMM implementations Say we have a reference AMM and a gas-efficient version: ```solidity // ReferenceAMM.sol -- clear, correct, not gas-efficient contract ReferenceAMM { uint256 public reserveA; uint256 public reserveB; constructor(uint256 _reserveA, uint256 _reserveB) { reserveA = _reserveA; reserveB = _reserveB; } function getAmountOut( uint256 amountIn, bool isTokenA ) external view returns (uint256 amountOut) { uint256 reserveIn = isTokenA ? reserveA : reserveB; uint256 reserveOut = isTokenA ? reserveB : reserveA; // Standard constant product formula: x * y = k // amountOut = reserveOut - (reserveIn * reserveOut) / // (reserveIn + amountIn) // With 0.3% fee uint256 amountInWithFee = amountIn * 997; uint256 numerator = amountInWithFee * reserveOut; uint256 denominator = (reserveIn * 1000) + amountInWithFee; amountOut = numerator / denominator; } function swap(uint256 amountIn, bool isTokenA) external returns (uint256 amountOut) { amountOut = this.getAmountOut(amountIn, isTokenA); if (isTokenA) { reserveA += amountIn; reserveB -= amountOut; } else { reserveB += amountIn; reserveA -= amountOut; } } } // OptimizedAMM.sol -- gas-efficient, uses assembly contract OptimizedAMM { uint256 public reserveA; uint256 public reserveB; constructor(uint256 _reserveA, uint256 _reserveB) { reserveA = _reserveA; reserveB = _reserveB; } function getAmountOut( uint256 amountIn, bool isTokenA ) external view returns (uint256 amountOut) { assembly { let reserveIn := sload( add(reserveA.slot, iszero(isTokenA)) ) let reserveOut := sload( add(reserveA.slot, iszero(iszero(isTokenA))) ) let amountInWithFee := mul(amountIn, 997) let numerator := mul(amountInWithFee, reserveOut) let denominator := add( mul(reserveIn, 1000), amountInWithFee ) amountOut := div(numerator, denominator) } } function swap(uint256 amountIn, bool isTokenA) external returns (uint256 amountOut) { amountOut = this.getAmountOut(amountIn, isTokenA); if (isTokenA) { reserveA += amountIn; reserveB -= amountOut; } else { reserveB += amountIn; reserveA -= amountOut; } } } ``` ## The differential fuzz test ```solidity // test/DifferentialAMM.t.sol pragma solidity ^0.8.19; import "forge-std/Test.sol"; import "../src/ReferenceAMM.sol"; import "../src/OptimizedAMM.sol"; contract DifferentialAMMTest is Test { ReferenceAMM ref; OptimizedAMM opt; function setUp() public { // Same initial state ref = new ReferenceAMM(1_000_000e18, 1_000_000e18); opt = new OptimizedAMM(1_000_000e18, 1_000_000e18); } /// @dev Fuzz test: getAmountOut should match for any input function testFuzz_getAmountOut_matches( uint256 amountIn, bool isTokenA ) public view { // Bound to reasonable range amountIn = bound(amountIn, 1, 1_000_000e18); uint256 refOut = ref.getAmountOut(amountIn, isTokenA); uint256 optOut = opt.getAmountOut(amountIn, isTokenA); assertEq( refOut, optOut, "getAmountOut mismatch between reference and gas-efficient" ); } /// @dev Fuzz test: swap sequences should produce same state function testFuzz_swapSequence_matches( uint256[5] calldata amounts, bool[5] calldata directions ) public { for (uint256 i = 0; i < 5; i++) { uint256 amount = bound(amounts[i], 1, 100_000e18); uint256 refOut = ref.swap(amount, directions[i]); uint256 optOut = opt.swap(amount, directions[i]); assertEq( refOut, optOut, string.concat( "Swap output mismatch at step ", vm.toString(i) ) ); } // Final reserves should match exactly assertEq(ref.reserveA(), opt.reserveA(), "reserveA mismatch"); assertEq(ref.reserveB(), opt.reserveB(), "reserveB mismatch"); } } ``` Run it: ```bash forge test --match-contract DifferentialAMMTest -vvv --fuzz-runs 10000 ``` If the assembly optimization has a bug, say, the storage slot calculation for `reserveB` is off by one, the fuzzer will find inputs where the outputs diverge. ## Cross-Language differential testing with FFI One of the most powerful applications: comparing your Solidity implementation against a Python or Rust reference using Foundry's FFI. ## Solidity vs Python math ```solidity // test/DifferentialMath.t.sol pragma solidity ^0.8.19; import "forge-std/Test.sol"; import "../src/MathLib.sol"; contract DifferentialMathTest is Test { MathLib lib; function setUp() public { lib = new MathLib(); } function testFuzz_sqrt_matchesPython(uint256 x) public { x = bound(x, 0, type(uint128).max); // Get Solidity result uint256 solidityResult = lib.sqrt(x); // Get Python result via FFI string[] memory cmd = new string[](3); cmd[0] = "python3"; cmd[1] = "-c"; cmd[2] = string.concat( "import math; print(math.isqrt(", vm.toString(x), "))" ); bytes memory result = vm.ffi(cmd); uint256 pythonResult = vm.parseUint(string(result)); assertEq( solidityResult, pythonResult, string.concat( "sqrt mismatch for input ", vm.toString(x) ) ); } function testFuzz_expWad_matchesPython(int256 x) public { // Bound to range where exp doesn't overflow x = bound(x, -42139678854452767551, 135305999368893231589); int256 solidityResult = lib.expWad(x); string[] memory cmd = new string[](3); cmd[0] = "python3"; cmd[1] = "-c"; cmd[2] = string.concat( "from decimal import Decimal, getcontext; ", "getcontext().prec = 50; ", "x = Decimal('", vm.toString(x), "') / Decimal(10**18); ", "import math; ", "result = int(Decimal(str(math.exp(float(x)))) ", "* Decimal(10**18)); ", "print(result)" ); bytes memory result = vm.ffi(cmd); int256 pythonResult = vm.parseInt(string(result)); // Allow 1 wei tolerance for rounding differences assertApproxEqAbs( solidityResult, pythonResult, 1, "expWad mismatch" ); } } ``` This technique catches subtle fixed-point arithmetic bugs that are really hard to spot in manual review. The Python `Decimal` library gives you arbitrary precision to compare against. ## Cross-Version differential testing Solidity version changes introduce behavioral differences. Some are documented, some aren't. ## Solidity 0.7 vs 0.8 behavior The biggest change was checked arithmetic. But there are subtler differences: ```solidity // test/CrossVersion.t.sol // This test compares behavior between a 0.7-style implementation // (using unchecked) and a 0.8 implementation contract CrossVersionTest is Test { LegacyMath legacy; // Uses unchecked blocks to mimic 0.7 ModernMath modern; // Standard 0.8 checked arithmetic function setUp() public { legacy = new LegacyMath(); modern = new ModernMath(); } function testFuzz_division_behavior( uint256 a, uint256 b ) public { // In 0.7: division by zero returned 0 // In 0.8: division by zero reverts if (b == 0) { // Expect modern to revert vm.expectRevert(); modern.divide(a, b); // Legacy should return 0 (if it mimics 0.7 behavior) // If your migration kept this behavior, test it // If not, this differential test catches the discrepancy return; } assertEq( legacy.divide(a, b), modern.divide(a, b), "Division result mismatch" ); } function testFuzz_shift_behavior( uint256 value, uint256 shift ) public { // In 0.7: shifting by >= 256 was undefined behavior // In 0.8: shifting by >= 256 returns 0 shift = bound(shift, 0, 512); if (shift >= 256) { assertEq( modern.shiftRight(value, shift), 0, "Shift >= 256 should return 0 in 0.8" ); return; } assertEq( legacy.shiftRight(value, shift), modern.shiftRight(value, shift), "Shift result mismatch" ); } } ``` This is especially useful during protocol migrations. We've seen bugs introduced during 0.7→0.8 migrations where developers added `unchecked` blocks in the wrong places, accidentally preserving overflow behavior in functions that should've been checked. ## ABI encoding differential tests ABI encoding bugs are subtle and dangerous. Compare your manual encoding against Solidity's built-in encoder: ```solidity function testFuzz_customEncoding_matchesABI( address addr, uint256 amount, bytes32 id ) public pure { // Your custom encoding (maybe for gas optimization) bytes memory custom = abi.encodePacked( bytes20(addr), bytes32(amount), id ); // Standard encoding bytes memory standard = abi.encode(addr, amount, id); // These SHOULD differ (packed vs padded) -- // but your decoder must handle the format it actually uses // The real test: encode then decode and compare values (address decodedAddr, uint256 decodedAmount, bytes32 decodedId) = abi.decode(standard, (address, uint256, bytes32)); assertEq(decodedAddr, addr); assertEq(decodedAmount, amount); assertEq(decodedId, id); } ``` ## Real bugs found by differential testing Let me share some real patterns where differential testing caught issues: ## 1. rounding direction discrepancy A vault's `deposit()` function rounded shares down (correct, favors the vault), but `previewDeposit()` rounded up (incorrect, overpromised shares): ```solidity function testFuzz_depositPreview_matches(uint256 assets) public { assets = bound(assets, 1, 1_000_000e18); uint256 previewedShares = vault.previewDeposit(assets); uint256 actualShares = vault.deposit(assets, address(this)); // ERC-4626 spec: previewDeposit MUST return <= actual shares assertLe( previewedShares, actualShares, "Preview overpromised shares" ); } ``` The fuzzer found inputs where `previewDeposit` returned more shares than `deposit` actually minted. This is a spec violation that can cause accounting bugs in integrating contracts. ## 2. assembly optimization gone wrong A hand-rolled `mulDiv` function in assembly produced incorrect results for specific input ranges near `type(uint256).max`: ```solidity function testFuzz_mulDiv_reference( uint256 a, uint256 b, uint256 denominator ) public pure { denominator = bound(denominator, 1, type(uint256).max); // Skip overflow cases if (b != 0 && a > type(uint256).max / b) return; uint256 fast = OptimizedMath.mulDiv(a, b, denominator); uint256 reference = (a * b) / denominator; assertEq(fast, reference, "mulDiv mismatch"); } ``` The assembly version had an off-by-one in its high-word multiplication logic. Only triggered when both `a` and `b` had specific bit patterns in their upper 128 bits. ## 3. cross-Chain behavior difference A contract deployed on both Ethereum and Arbitrum produced different results for the same inputs because of PUSH0 opcode availability and different gas costs affecting an internal gas-bounded loop: ```solidity function testFuzz_crossChain_equivalence( uint256 input ) public { // Fork Ethereum mainnet vm.createSelectFork("mainnet"); uint256 mainnetResult = target.compute(input); // Fork Arbitrum vm.createSelectFork("arbitrum"); uint256 arbResult = target.compute(input); assertEq( mainnetResult, arbResult, "Cross-chain result mismatch" ); } ``` ## Advanced: differential invariant testing Combine differential testing with [invariant testing](/learn/invariant-testing) for maximum coverage. Instead of comparing single function calls, compare entire operation sequences: ```solidity contract DifferentialInvariantTest is Test { ReferenceVault refVault; OptimizedVault optVault; DiffHandler handler; function setUp() public { refVault = new ReferenceVault(address(asset)); optVault = new OptimizedVault(address(asset)); handler = new DiffHandler(refVault, optVault, asset); targetContract(address(handler)); } function invariant_stateAlwaysMatches() public view { assertEq( refVault.totalAssets(), optVault.totalAssets(), "totalAssets diverged" ); assertEq( refVault.totalSupply(), optVault.totalSupply(), "totalSupply diverged" ); } } contract DiffHandler { ReferenceVault ref; OptimizedVault opt; // Every handler function performs the same action on both function deposit(uint256 amount, uint256 actorSeed) external { address actor = actors[actorSeed % actors.length]; amount = bound(amount, 1, asset.balanceOf(actor) / 2); // Deposit into both with same params vm.startPrank(actor); asset.approve(address(ref), amount); uint256 refShares = ref.deposit(amount, actor); asset.approve(address(opt), amount); uint256 optShares = opt.deposit(amount, actor); vm.stopPrank(); require( refShares == optShares, "Share mismatch on deposit" ); } function withdraw(uint256 amount, uint256 actorSeed) external { address actor = actors[actorSeed % actors.length]; uint256 maxRef = ref.maxWithdraw(actor); uint256 maxOpt = opt.maxWithdraw(actor); require(maxRef == maxOpt, "maxWithdraw mismatch"); if (maxRef == 0) return; amount = bound(amount, 1, maxRef); vm.startPrank(actor); uint256 refAssets = ref.withdraw(amount, actor, actor); uint256 optAssets = opt.withdraw(amount, actor, actor); vm.stopPrank(); require( refAssets == optAssets, "Asset mismatch on withdraw" ); } } ``` This catches state divergence that only shows up after specific sequences of operations. The fuzzer generates random sequences of deposits and withdrawals, and the invariant checks that both implementations stay in sync at every step. ## Practical tips **Start with the pure math.** The highest-value differential tests compare mathematical functions, swap calculations, interest accrual, pricing formulas. These are deterministic, easy to test, and where precision bugs hide. **Use Python/Rust for reference.** Don't build your reference in Solidity if you can avoid it. Use a language with arbitrary-precision arithmetic. This eliminates the risk of both implementations sharing the same bug. **Bound your inputs carefully.** Differential testing generates a lot of inputs. If most of them hit trivial code paths (zero amounts, empty arrays), you're wasting cycles. Use Foundry's `bound()` to focus on interesting ranges. Log the failing input. When a differential test fails, the specific input that caused divergence is gold. Log it, reproduce it, and understand *why* the implementations disagree. Combine with [fuzzing](/learn/fuzzing). Differential testing tells you *what* disagrees. [Invariant testing](/learn/invariant-testing) and [property-based testing](/learn/property-based-testing) tell you *what properties should hold*. Use both. For a broader comparison of [smart contract fuzzing tools](/blog/smart-contract-fuzzing-tools-compared), check our dedicated post. ## When not to use differential testing It's not always the right tool: - No reference implementation exists. You're building something novel and there's nothing to compare against. - Implementations are intentionally different. If one version adds a fee and the other doesn't, they're supposed to disagree. - Performance isn't worth it. FFI-based cross-language testing is slow. For simple contracts, direct property testing is faster and just as effective. In those cases, stick with standard [invariant testing](/learn/invariant-testing) and direct property assertions. ## Wrapping up Differential testing is one of those techniques that's simple in concept but catches bugs that other approaches miss. The insight is that you don't need to know the right answer, you just need two sources that should agree. In DeFi, where specs get implemented multiple times, where gas-tuned rewrites replace reference code, and where cross-chain deployments must behave identically, differential testing fits naturally. Set up the comparison. Let the fuzzer generate inputs. Wait for the disagreement. Fix the bug. Try Recon Pro --- ### Why we built Chimera: write once, fuzz everywhere **Author:** kn0t | **Published:** 2026-04-03 | **Reading time:** 11 min **Tags:** chimera-framework, fuzzing, invariant-testing, property-based-testing **URL:** https://getrecon.xyz/blog/why-we-built-chimera-write-once-fuzz-everywhere # Why we built Chimera: write once, fuzz everywhere You're about to add fuzzing to your protocol. You look at the options: Foundry's built-in invariant testing is fast but shallow. [Echidna](/learn/echidna) is battle-tested but uses its own test format. [Medusa](/learn/medusa) runs parallel campaigns but needs yet another config. Pick one, and you're locked in. Switch later, and you rewrite everything. We built [Chimera](/learn/chimera-framework) because this tradeoff shouldn't exist. You should write your properties once and run them with any fuzzer — today and two years from now when better tools ship. Get a Chimera test suite built for your protocol ## The problem: fuzzer lock-in Every fuzzing tool has a different interface. Foundry expects `invariant_` prefixed functions inside a `Test` contract. Echidna wants `echidna_` prefixed booleans or assertion-mode properties in a standalone contract. Medusa needs its own config pointing to a different entry contract. The properties themselves — "the vault must always be solvent," "share price must never decrease on deposit" — are identical across tools. But the test harness code that wraps those properties is completely different. In practice, teams end up in one of three situations: 1. **Pick one fuzzer and stick with it.** You miss bugs the other tools would've caught. Echidna's coverage-guided exploration finds different sequences than Foundry's random approach, and Medusa's parallelism covers more state space per hour than either. 2. **Maintain separate test suites.** Two or three copies of every property, every handler, every setup. They drift apart. The Echidna suite gets a new property, the Foundry suite doesn't. Now you have false confidence. 3. **Don't fuzz at all.** The overhead of choosing and learning a tool is enough to delay fuzzing until "after launch." We've seen this more often than we'd like. None of these are good outcomes. The tool should adapt to the properties, not the other way around. ## What Chimera actually does Chimera is a Solidity framework that provides a single inheritance chain for writing [invariant tests](/learn/invariant-testing). You write your setup, properties, and handler functions once, in plain Solidity, and Chimera provides the adapter layer that makes them work with Foundry, Echidna, and Medusa without changes. It isn't a fuzzer. It doesn't generate inputs or guide coverage. It's the layer between your properties and whatever fuzzer runs them. Think of it as an interface adapter: your test code talks to Chimera, Chimera talks to the fuzzer. The architecture is a five-contract inheritance chain: ``` Setup → BeforeAfter → Properties → TargetFunctions → CryticTester ``` Each contract has a specific job. The chain isn't arbitrary — it reflects how fuzzing campaigns actually work: deploy state, snapshot before/after, define correctness, define actions, and expose an entry point. ## How the inheritance chain works Most tutorials show you the chain and tell you to follow it. Here's WHY each layer exists and what breaks if you skip it. **`Setup` (inherits `BaseSetup`)** — Deploys all contracts and sets initial state. This layer exists because every fuzzer needs a deterministic starting point, but they initialize it differently. Foundry calls `setUp()`. Echidna and Medusa call the constructor. `BaseSetup` abstracts this: you write `setup()` once, and the right lifecycle hook calls it. ```solidity contract Setup is BaseSetup { SimpleVault vault; MockERC20 token; function setup() internal override { token = new MockERC20("Test", "T", 18); vault = new SimpleVault(address(token)); token.mint(address(this), 1_000_000e18); token.approve(address(vault), type(uint256).max); } } ``` **`BeforeAfter` (inherits `Setup`)** — Captures state snapshots before and after each fuzzer action. This layer exists because transition properties ("share price must not decrease after deposit") need both the old and new values. Without it, you'd write snapshot logic inline in every property. `BeforeAfter` provides `__before()` and `__after()` hooks that run automatically. ```solidity contract BeforeAfter is Setup { uint256 internal _sharePriceBefore; uint256 internal _totalSupplyBefore; function __before() internal { _sharePriceBefore = vault.convertToAssets(1e18); _totalSupplyBefore = vault.totalSupply(); } function __after() internal { // Values are now available for comparison in Properties } } ``` **`Properties` (inherits `BeforeAfter`)** — Defines what "correct" means. Pure invariant logic, no side effects. This layer is separate from target functions because properties should be readable on their own. When an auditor reviews your test suite, they read Properties.sol to understand your security model. ```solidity contract Properties is BeforeAfter { function invariant_solvency() public view returns (bool) { uint256 totalClaim = vault.convertToAssets(vault.totalSupply()); return token.balanceOf(address(vault)) >= totalClaim; } function invariant_sharePriceNonDecreasing() public view returns (bool) { if (_totalSupplyBefore == 0) return true; return vault.convertToAssets(1e18) >= _sharePriceBefore; } } ``` **`TargetFunctions` (inherits `Properties`)** — Defines what the fuzzer can do. Each `handler_` function maps to one user action with bounded inputs. This layer inherits from Properties so it can call `__before()` and `__after()` around each action. ```solidity contract TargetFunctions is Properties { function handler_deposit(uint256 amount) external { amount = bound(amount, 1, token.balanceOf(address(this))); __before(); vault.deposit(amount, address(this)); __after(); } function handler_withdraw(uint256 shares) external { uint256 maxShares = vault.balanceOf(address(this)); if (maxShares == 0) return; shares = bound(shares, 1, maxShares); __before(); vault.redeem(shares, address(this), address(this)); __after(); } } ``` **`CryticTester` (inherits `TargetFunctions` + `CryticAsserts`)** — The entry point for Echidna and Medusa. It exists so that Crytic tools can find all handler functions and properties through a single contract. Foundry uses a separate `FoundryTest` wrapper that inherits the same chain. ```solidity contract CryticTester is TargetFunctions, CryticAsserts { constructor() { setup(); } } ``` ## Same property, three fuzzers Here's the payoff. That `invariant_solvency` property from above — let's run it with all three tools, zero code changes: **Foundry:** ```bash forge test --match-contract FoundryTest -vvv ``` Fast iteration, runs in seconds. Good for development and CI. Limited sequence depth. **Echidna:** ```bash echidna test/recon/CryticTester.sol --contract CryticTester --config echidna.yaml ``` Coverage-guided exploration, finds sequences Foundry misses. Runs for minutes to hours. Corpus-based, so each campaign builds on the last. **Medusa:** ```bash medusa fuzz ``` Parallel workers, higher throughput. Best for long campaigns where you want maximum state space coverage per hour. The exact same Properties.sol, the exact same TargetFunctions.sol, the exact same Setup.sol. Three different exploration strategies, three different bug-finding strengths. You get the union of their results. ## When to use Chimera vs writing directly Chimera isn't always the right choice. Here's a decision table: | Scenario | Recommendation | |----------|----------------| | New protocol, planning to fuzz seriously | **Chimera.** You'll want to run multiple fuzzers as you iterate. | | Existing Foundry tests, want to add Echidna | **Chimera.** Refactor into the chain once, then you have both. | | Quick one-off fuzz test for a single function | **Direct Foundry.** Write a `testFuzz_` function, run it, done. | | You only ever use Foundry and won't switch | **Either works.** Chimera adds a small abstraction cost but keeps the door open. | | Team wants to run [Recon Pro](/products/pro) cloud campaigns | **Chimera.** Recon Pro uses the Chimera structure to parallelize across fuzzers. | | Learning to fuzz for the first time | **Chimera.** The structure teaches you the right patterns from the start. | The short version: if you're writing more than a handful of properties, or if there's any chance you'll want a second fuzzer later, start with Chimera. The upfront cost is one afternoon. The switching cost without it is a full rewrite. ## Scaffolding with create-chimera-app and the VS Code extension You don't have to set up the inheritance chain manually. Two tools generate it for you: **`create-chimera-app`** — A CLI that scaffolds a complete Chimera project inside any existing Foundry repo: ```bash npx create-chimera-app ``` It creates the `test/recon/` directory with all five contracts, wired up with your project's imports and a starter property. You fill in the deployment logic and start writing properties. **The [Recon VS Code extension](/extension)** — If you prefer staying in the editor, install the extension and run "Recon: Initialize" from the command palette. Same output, plus it sets up fuzzer configs and gives you one-click execution for Echidna, Medusa, and Foundry right from the editor. Both tools produce identical project structures that work with all three fuzzers out of the box. ## Write properties, not glue code The point of fuzzing is to find bugs. Every hour you spend adapting test code to a specific tool's interface is an hour you're not spending writing properties that catch real vulnerabilities. Chimera exists so you can focus on the hard part — figuring out what "correct" means for your protocol — and let the framework handle the boring part of making it work across tools. If you've been putting off fuzzing because the tooling fragmentation is annoying, [start with the beginner's guide](/blog/zero-to-fuzzing-beginners-guide-chimera). If you already have tests and want to add a second fuzzer, the [Chimera framework docs](/learn/chimera-framework) cover migration. Need a complete Chimera test suite purpose-built for your protocol? [Request an audit with Recon](/request-audit) — we design properties based on your protocol's specific risk model and run campaigns at scale across all three fuzzers. --- ### Getting started with the Recon VS Code extension **Author:** deivitto | **Published:** 2026-04-04 | **Reading time:** 9 min **Tags:** recon-extension, chimera-framework, tutorial, getting-started **URL:** https://getrecon.xyz/blog/getting-started-recon-vscode-extension # Getting started with the Recon VS Code extension Setting up a fuzzing test suite usually means bouncing between terminal, config files, and documentation. The [Recon VS Code extension](/extension) puts the entire workflow — scaffolding, running fuzzers, reading coverage — inside your editor. This guide walks through every step from install to your first cloud campaign. Install the Recon Extension ## Install the extension Search for "Recon" in the VS Code Marketplace and click Install. If you use [Cursor](https://cursor.sh), the extension works there too — install it the same way through the Extensions panel. **Prerequisites:** You need [Foundry](https://book.getfoundry.sh/getting-started/installation) installed on your machine. The extension wraps Foundry, Echidna, and Medusa — it doesn't replace them. If you don't have Echidna or Medusa installed yet, the extension will detect that and offer to walk you through setup when you try to run them. After installation, you'll see a Recon icon in the sidebar. Click it to open the Recon panel. ## Open a Foundry project Open any directory that contains a `foundry.toml` file. The extension detects it automatically and indexes your project: - **Contract files** in `src/` — identified by pragma and contract declarations - **Existing test files** in `test/` — categorized as unit tests, fuzz tests, or invariant tests - **Dependencies** in `lib/` — resolved through remappings - **Remappings** from `foundry.toml` and `remappings.txt` The Recon panel shows a summary: how many contracts, whether a `test/recon/` directory exists, which fuzzers are available on your PATH. If you open a non-Foundry project, the extension stays dormant — no unnecessary UI or prompts. ## Scaffold Chimera tests with one click Open the command palette (`Cmd+Shift+P` on macOS, `Ctrl+Shift+P` on Linux/Windows) and run: ``` Recon: Initialize ``` This creates the full [Chimera](/learn/chimera-framework) test structure inside `test/recon/`: ``` test/recon/ Setup.sol — Deploys contracts and sets initial state BeforeAfter.sol — State snapshot hooks for transition properties Properties.sol — Invariant definitions TargetFunctions.sol — Fuzzer-callable handler functions CryticTester.sol — Entry point for Echidna and Medusa ``` The generated files aren't empty templates. The extension reads your `src/` contracts and pre-fills: - **Setup.sol** with import statements and deployment calls for your main contracts - **TargetFunctions.sol** with `handler_` stubs for every external/public function it finds - **Properties.sol** with starter invariants based on common patterns (solvency for vaults, supply conservation for tokens, balance consistency for pools) You fill in the deployment parameters, tweak the handlers, and add protocol-specific properties. The scaffolding gets you past the blank-file problem and into writing real invariants within minutes. For details on how the Chimera inheritance chain works and why each layer exists, see [Why we built Chimera](/blog/why-we-built-chimera-write-once-fuzz-everywhere). ## Run fuzzers from the editor Once your test suite compiles, you can run any available fuzzer directly from the command palette: - **`Recon: Run Foundry Tests`** — Executes `forge test` with your invariant test contract. Output streams to a dedicated Recon Output panel with clickable file references. - **`Recon: Run Echidna`** — Launches Echidna against your `CryticTester.sol` with the config from `echidna.yaml` (generated during initialization). Shows real-time progress: properties tested, corpus size, coverage percentage. - **`Recon: Run Medusa`** — Starts a Medusa fuzzing campaign using `medusa.json`. Displays worker count, total calls, and any property violations as they're found. - **`Recon: Run Halmos`** — Runs [Halmos symbolic execution](/blog/halmos-symbolic-execution-guide) on your properties. Useful for bounded verification of critical invariants where you want proof, not probabilistic confidence. Each command remembers your last configuration. If a previous campaign's corpus exists, Echidna and Medusa pick up where they left off — no repeated work. ### Configuration files The `Recon: Initialize` command generates default configs: ```yaml # echidna.yaml testMode: assertion testLimit: 100000 seqLen: 50 corpusDir: "corpus-echidna" cryticArgs: ["--compile-force-framework", "foundry"] ``` ```json // medusa.json (relevant fields) { "fuzzing": { "targetContracts": ["CryticTester"], "testLimit": 100000, "callSequenceLength": 50, "corpusDirectory": "corpus-medusa" } } ``` Edit these files directly — the extension reads them at launch time. No separate settings UI to keep in sync. ## Read coverage reports After any fuzzer run, open: ``` Recon: Show Coverage ``` The extension overlays coverage data directly on your source files: - **Green highlights** — Lines executed during the campaign - **Red highlights** — Lines the fuzzer never reached - **Yellow highlights** — Lines reached but only through a single code path Red lines are your priority. Each one is a code path the fuzzer couldn't trigger, which often means your handlers are missing an action, your input bounds are too tight, or the code is genuinely unreachable. The coverage panel also shows a summary per contract: percentage covered, number of unique paths explored, and which handler functions drove the most coverage. Use this to decide where to add handlers or loosen bounds. ## Generate reports When you're done with a campaign, generate a shareable report: ``` Recon: Generate Report ``` The report includes: - **Property results** — Which invariants held, which were violated - **Violation traces** — The exact call sequences that broke each property, with decoded arguments - **Coverage summary** — Per-contract coverage with untested paths highlighted - **Campaign metadata** — Fuzzer used, duration, total calls, corpus size - **Recommendations** — Suggested next steps based on coverage gaps Reports export as HTML or Markdown. Share them with your team in a PR or hand them to auditors as proof of testing depth. ## Connect to Recon Pro for cloud runs Local fuzzing is great for development. But serious campaigns — the ones that run for hours with high parallelism — need more compute than a laptop provides. [Recon Pro](/products/pro) runs campaigns in the cloud. To connect: 1. Open `Recon: Connect to Recon Pro` from the command palette 2. Authenticate with your Recon Pro account 3. Select the repository and branch to fuzz Once connected, you get two new commands: - **`Recon: Launch Cloud Campaign`** — Pushes your test suite to Recon Pro and starts a campaign with configurable duration, worker count, and fuzzer selection. Results stream back to your editor in real time. - **`Recon: View Cloud Results`** — Pulls results from previous cloud campaigns. Coverage data overlays the same way as local runs. Cloud campaigns run Echidna and Medusa in parallel on dedicated infrastructure. A campaign that would take eight hours on your machine finishes in under an hour. The results sync back to your local corpus, so your next local run starts from where the cloud campaign left off. For a detailed walkthrough of Recon Pro, see the [cloud fuzzing guide](/blog/getting-started-recon-pro-cloud-fuzzing). ## What to do next Once you have the extension installed and your first campaign running: 1. **Write more properties.** Start with [solvency and accounting invariants](/blog/property-design-patterns-defi-lending), then add protocol-specific logic. 2. **Read coverage after every run.** Red lines tell you exactly where to improve your handlers. 3. **Run multiple fuzzers.** The Chimera structure means you can switch between Foundry, Echidna, and Medusa with one command. 4. **Add fuzzing to CI.** The extension's config files work in CI too — run `forge test` in your pipeline and Echidna/Medusa in nightly jobs. Need help designing properties for your specific protocol? [Request an audit with Recon](/request-audit) — we'll build a complete Chimera test suite and run it at scale. --- ### Postmortem: The Lending Protocol Reentrancy That Fuzzing Missed — And Invariants Didn't **Author:** antonio | **Published:** 2026-04-06 | **Reading time:** 14 min **Tags:** reentrancy, invariant-testing, defi-security, postmortem, fuzzing **URL:** https://getrecon.xyz/blog/lending-reentrancy-postmortem-invariants-caught-it # Postmortem: The Lending Protocol Reentrancy That Fuzzing Missed This is a fictional protocol. The name, the numbers, the specific contract — all made up. The vulnerability class, the exploit mechanics, and the invariant that catches it: real. We've seen this exact pattern in production code. ## The Protocol MedusaLend is a simple lending protocol. Users deposit ERC20 collateral, borrow against it, and repay with interest. The accounting is five variables: ```solidity mapping(address => uint256) public collateralBalance; mapping(address => uint256) public borrowBalance; uint256 public totalCollateral; uint256 public totalBorrows; uint256 public totalReserves; ``` Liquidators can repay undercollateralized positions and claim the collateral at a 5% bonus. ## The Bug ```solidity // VULNERABLE function liquidate(address borrower, uint256 repayAmount) external { require(!isHealthy(borrower), "position is healthy"); IERC20(underlyingAsset).transferFrom(msg.sender, address(this), repayAmount); uint256 collateralToSeize = repayAmount * liquidationBonus / 1e18; // Debt updated here... borrowBalance[borrower] -= repayAmount; totalBorrows -= repayAmount; // ...but collateral transferred before its accounting is updated IERC20(collateralAsset).transfer(msg.sender, collateralToSeize); // Too late — callback already fired collateralBalance[borrower] -= collateralToSeize; totalCollateral -= collateralToSeize; } ``` The `IERC20(collateralAsset).transfer` is the problem. If `collateralAsset` has a hook on transfer (ERC777, ERC677, anything with callbacks), the liquidator's contract gets control back before `collateralBalance` and `totalCollateral` are updated. On-chain accounting still shows the full collateral. The liquidator re-enters `liquidate` and seizes it again. ## The Exploit ```solidity contract ReentrancyExploit { MedusaLend public lend; address public borrower; uint256 public repayAmount; uint8 public depth; uint8 constant MAX_DEPTH = 3; function attack(address _borrower, uint256 _repay) external { borrower = _borrower; repayAmount = _repay; depth = 0; lend.liquidate(_borrower, _repay); } // Called by collateralAsset.transfer() if it has a hook function tokensReceived( address, address, address, uint256, bytes calldata, bytes calldata ) external { if (depth < MAX_DEPTH && !lend.isHealthy(borrower)) { depth++; lend.liquidate(borrower, repayAmount); } } } ``` At depth 3, the attacker drained 3x the collateral they were owed. On a $10M position that's $1.5M profit (3x the 5% liquidation bonus). ## Why 24 Hours of Fuzzing Found Nothing The dev team ran Echidna with stateful fuzzing for 24 hours. Zero findings. To trigger this bug, the fuzzer needs to deploy a malicious ERC777 token as the collateral asset, create an undercollateralized position, and call `liquidate` from a contract that re-enters. Default fuzz harnesses use plain ERC20 mocks. Plain mocks don't have transfer hooks, so the re-entrant path never gets exercised. Echidna found integer overflow risks, division-by-zero in edge cases, incorrect health factor calculations with dust amounts. All useful. None of them are the $1.5M bug. The fuzzer only attacks the system with what you've given it. If the mock tokens can't simulate adversarial behavior, adversarial behavior goes untested. That's not a flaw in the fuzzer — it's a gap in the harness. Get invariant testing that covers adversarial token behavior ## The Invariant That Catches It ```solidity // TargetFunctions.sol — wrapper that snapshots state around each call function handler_liquidate(address borrower, uint256 repayAmount) public { __before(); lend.liquidate(borrower, repayAmount); __after(); } ``` ```solidity // BeforeAfter.sol — global state snapshots abstract contract BeforeAfter { struct Vars { uint256 totalCollateral; uint256 totalBorrows; uint256 sumCollateralBalances; uint256 sumBorrowBalances; } Vars internal _before; Vars internal _after; function __before() internal { _before.totalCollateral = lend.totalCollateral(); _before.totalBorrows = lend.totalBorrows(); _before.sumCollateralBalances = _sumUserCollateral(); _before.sumBorrowBalances = _sumUserBorrows(); } function __after() internal { _after.totalCollateral = lend.totalCollateral(); _after.totalBorrows = lend.totalBorrows(); _after.sumCollateralBalances = _sumUserCollateral(); _after.sumBorrowBalances = _sumUserBorrows(); } } ``` ```solidity // Properties.sol abstract contract Properties is BeforeAfter { // Sum of all user collateral balances must equal totalCollateral function property_collateral_accounting_consistent() public returns (bool) { return lend.totalCollateral() == _sumUserCollateral(); } // Sum of all borrow balances must equal totalBorrows function property_borrow_accounting_consistent() public returns (bool) { return lend.totalBorrows() == _sumUserBorrows(); } // Seized collateral can't exceed what the borrower actually had function property_liquidation_cannot_seize_more_than_owned() public returns (bool) { if (_before.totalCollateral == 0) return true; if (_after.totalCollateral < _before.totalCollateral) { uint256 seized = _before.totalCollateral - _after.totalCollateral; return seized <= _before.sumCollateralBalances; } return true; } // Collateral must cover borrows at liquidation threshold function property_protocol_solvent() public returns (bool) { uint256 col = lend.totalCollateral(); uint256 borrow = lend.totalBorrows(); if (borrow == 0) return true; return col * 80 >= borrow * 100; } } ``` `property_collateral_accounting_consistent` is the one that fires. After any transaction sequence, the sum of all `collateralBalance` values must equal `totalCollateral`. Reentrancy breaks this: the global counter isn't updated mid-reentrant-call, but the token's already moved. ## Running It in Recon The critical setup detail is using an ERC777 mock with real hooks: ```solidity function setup() internal override { // Adversarial mock — has tokensReceived hooks, not a plain ERC20 collateralToken = new ERC777MockWithHooks(); underlyingToken = new ERC20Mock(); lend = new MedusaLend( address(underlyingToken), address(collateralToken) ); targetContract(address(lend)); targetContract(address(collateralToken)); } ``` With that setup, Recon's campaign finds the `property_collateral_accounting_consistent` violation in under 90 seconds. The reproducible sequence looks like this: 1. Deposit 100 USDC collateral 2. Borrow 70 USDC (70% LTV) 3. Manipulate price so position is undercollateralized 4. Call `liquidate` from an ERC777-hooked contract 5. Re-enter `liquidate` inside `tokensReceived` 6. `totalCollateral` is 0 but `sum(collateralBalance)` is still 100 — property broken ## The Fix ```solidity // FIXED function liquidate(address borrower, uint256 repayAmount) external nonReentrant { require(!isHealthy(borrower), "position is healthy"); IERC20(underlyingAsset).transferFrom(msg.sender, address(this), repayAmount); uint256 collateralToSeize = repayAmount * liquidationBonus / 1e18; // All state updates before any external call borrowBalance[borrower] -= repayAmount; totalBorrows -= repayAmount; collateralBalance[borrower] -= collateralToSeize; totalCollateral -= collateralToSeize; // External call last IERC20(collateralAsset).transfer(msg.sender, collateralToSeize); } ``` Two changes: CEI order (Checks-Effects-Interactions) and a reentrancy guard as defense-in-depth. Both matter. CEI keeps accounting consistent during the callback window; `nonReentrant` stops the second entry entirely. After the fix, all properties pass across 10M+ fuzzer iterations. I want to stress the harness point because it's where teams consistently cut corners. Plain ERC20 mocks feel like the safe choice — they're simpler, they're standard, they compile without extra setup. But if your collateral token can have hooks in production, you need a mock that can too. Testing a different system than the one you deploy is just expensive false confidence. Want us to build and run this suite against your lending protocol? [Request an audit](/request-audit) or get started yourself in [Recon Pro](/pro). --- ### 5 Properties Every Smart Contract Auditor Forgets to Test **Author:** antonio | **Published:** 2026-04-06 | **Reading time:** 13 min **Tags:** invariant-testing, smart-contract-security, fuzzing, defi-security, properties **URL:** https://getrecon.xyz/blog/5-invariants-auditors-forget-to-test # 5 Properties Every Smart Contract Auditor Forgets to Test After running invariant testing campaigns against 40+ DeFi protocols, we keep seeing the same gaps. Not the obvious ones — most teams do check that `totalSupply == sum(balances)`. The gaps that hurt are subtler, and they show up in protocols that already have a solid unit test suite. ## 1. Token balance vs. internal accounting The Cream Finance attack in October 2021 broke this one. The attacker manipulated the yUSD vault's price-per-share, inflated their collateral value, and borrowed against it. For a brief but profitable moment, the protocol's tracked collateral exceeded the actual token value on-chain. The property is simple: for every token the protocol holds, the actual ERC20 balance must be ≥ the tracked liability to depositors. ```solidity function property_token_balance_covers_liabilities() public returns (bool) { address[] memory tokens = protocol.getTrackedTokens(); for (uint256 i = 0; i < tokens.length; i++) { uint256 actualBalance = IERC20(tokens[i]).balanceOf(address(protocol)); uint256 trackedLiability = protocol.totalLiability(tokens[i]); if (actualBalance < trackedLiability) return false; } return true; } ``` It gets skipped because developers check it implicitly: "if withdrawals work, we're fine." That's not the same as an invariant. The implicit check only fires on the happy path. One caveat worth writing in: dust amounts from rounding, fee-on-transfer tokens, and rebasing tokens can all trigger a strict `>=` check when nothing is actually wrong. `actualBalance + DUST_TOLERANCE >= trackedLiability` is usually the right threshold. ## 2. Privilege escalation Unit tests for access control check individual functions. They don't check whether a multi-step sequence of legitimate-looking calls can escalate privileges through composition. Compound's governance exploit in 2021 is the reference case. A proposal passed that upgraded the comptroller and transferred funds to arbitrary addresses. The access control on each individual function was fine. The vulnerability was in how governance actions composed together. ```solidity function property_no_privilege_escalation() public returns (bool) { for (uint256 i = 0; i < actors.length; i++) { address actor = actors[i]; if (isInitialAdmin[actor]) continue; if (protocol.hasRole(ADMIN_ROLE, actor)) return false; if (protocol.hasRole(MINTER_ROLE, actor)) return false; if (protocol.owner() == actor) return false; } return true; } ``` Run this after every state-changing handler, not just after governance calls. Escalation bugs are always sequence-dependent; a single call won't grant it, but a specific three-step sequence might. The fuzzer needs to see the full call history to find it. Get a full invariant audit of your access control ## 3. Monotonicity on critical counters Some state variables should only ever move in one direction. `totalDebt` goes down on repayment, not arbitrarily. `totalShares` goes down on withdrawal, not on deposits. That sounds obvious, but the property is almost never written down. A recurring bug class in rebase tokens illustrates why it matters: after an elastic supply adjustment, `totalSupply` briefly dips below its valid floor before snapping back. If a liquidation fires inside that window, the math uses the dipped value and the result is wrong. ```solidity // BeforeAfter struct captures pre-call values function property_debt_monotone_outside_repayment() public returns (bool) { if (!_isRepaymentContext()) { return _after.totalDebt >= _before.totalDebt; } return true; } function property_shares_monotone_outside_withdrawal() public returns (bool) { if (!_isWithdrawalContext()) { return _after.totalShares >= _before.totalShares; } return true; } ``` The BeforeAfter pattern is what makes these testable. Snapshot `_before` at the start of each handler, check `_after` at the end, and every non-repayment call triggers the monotonicity check automatically. It's a few lines of boilerplate that catches entire classes of bugs. ## 4. Oracle staleness Developers mock oracles in tests. Mock oracles always return fresh prices. Production Chainlink aggregators go stale when gas spikes or aggregator nodes have issues, and the last reported price gets used indefinitely. On a volatile asset, a 20-minute-old price can be 15% off. Liquidations fire incorrectly (or don't fire when they should). Compound v2 has had documented oracle issues around this. It's a solved problem at the contract level — just check `updatedAt` against a staleness threshold — but the check doesn't get written if the test suite never exercises a stale oracle. ```solidity function property_oracle_prices_are_fresh() public returns (bool) { address[] memory collaterals = protocol.getCollateralTokens(); for (uint256 i = 0; i < collaterals.length; i++) { (uint256 price, uint256 updatedAt) = protocol.getPrice(collaterals[i]); if (price == 0) return false; if (block.timestamp - updatedAt > MAX_PRICE_AGE) return false; } return true; } function property_no_action_succeeds_with_stale_oracle() public returns (bool) { vm.warp(block.timestamp + MAX_PRICE_AGE + 1); try protocol.borrow(token, 1e18) { return false; // borrow succeeded with stale price — bug } catch { return true; } } ``` In Recon's campaign runner, time-warp is a first-class fuzz dimension. The fuzzer inserts `warp` calls between operations automatically, which means you don't need to write explicit staleness scenarios — you just need `property_oracle_prices_are_fresh` running and the fuzzer finds the sequence that triggers it. ## 5. Liquidation health Liquidation tests usually check one thing: did the liquidator receive the right collateral amount? They rarely check whether the borrower's remaining position is actually in a safe zone after the liquidation. The Aave v2 CRV situation in November 2023 is the closest real-world example. A large CRV short was partially liquidated, but the position was so oversized relative to market liquidity that each liquidation move barely moved the health factor. The protocol let liquidations proceed that didn't materially improve position health. Not a code bug exactly, but an invariant violation that surfaces hidden assumptions about market depth. For most lending protocols, a health invariant catches more concrete bugs: partial liquidations with wrong bonus calculations that leave health factor at 0.99 (still under 1.0), rounding on collateral seizure that makes health factor slightly worse after liquidation (rare, but I've seen it), or bad oracle prices that make a position appear healthy post-liquidation when it isn't. ```solidity function property_liquidation_improves_health() public returns (bool) { for (uint256 i = 0; i < liquidatedActors.length; i++) { address actor = liquidatedActors[i]; uint256 postHealthFactor = protocol.healthFactor(actor); if (protocol.borrowBalance(actor) > 0) { if (postHealthFactor < LIQUIDATION_THRESHOLD) return false; } } return true; } ``` Track which actors were liquidated during the sequence rather than checking all actors after every call. Checking all actors every time is expensive and produces false positives when an unrelated actor has a legitimately unhealthy position. ## The one property to start with If you're only going to write one invariant for a protocol that handles multiple users, write this: ```solidity function property_sum_of_positions_equals_total() public returns (bool) { uint256 sumPositions = 0; for (uint256 i = 0; i < actors.length; i++) { sumPositions += protocol.positionOf(actors[i]); } return sumPositions == protocol.totalPositions(); } ``` It catches reentrancy bugs, rounding errors, off-by-one errors in storage, and silent overflow/underflow — all from one property. The five above are important, but this one is the foundation. The Recon Book's [property design chapter](/learn/property-design-patterns) covers all of these in depth with full Chimera examples and BeforeAfter boilerplate. If you want us to write and run a complete suite for your protocol, [request an audit](/request-audit) and we'll scope it within 24 hours. ### Case Studies (5 engagements — full details) #### Liquity — Full Security Audit + Invariant Testing (2024) Liquity v2 (BOLD) is a decentralized borrowing protocol and one of the most anticipated DeFi launches. Recon's engagement combined deep manual code review with [invariant testing](/learn/invariant-testing) to cover interest rate calculations, multi-collateral support, liquidation mechanics, and the stability pool. Several issues were found spanning arithmetic precision, economic edge cases, and protocol accounting. The Liquity cofounder noted the engagement exceeded expectations in both breadth and depth, particularly given the scope of coverage achieved. **Challenge:** The v2 codebase introduced novel mechanisms including batch-managed liquidity, continuous interest rate adjustments, and new redemption and liquidation logic. The arithmetic involved fixed-point math operations where precision errors compound across operations. Traditional auditing alone couldn't provide confidence that these calculations held under all edge cases, especially sequences involving many borrowers interacting simultaneously. The interaction between interest accrual, collateral ratio checks, and the stability pool created a large state space that manual review can't exhaustively explore. **Approach:** Properties were designed around core protocol guarantees: the system must remain solvent across all operations, interest must accrue correctly regardless of the sequence of opens, closes, and adjustments, liquidations must only affect undercollateralized positions, and the stability pool must always correctly distribute gains. The fuzzer explored sequences of opens, adjustments, repayments, liquidations, and redemptions across multiple collateral types and borrowers. Deep manual review complemented the [invariant testing](/learn/invariant-testing) by identifying architectural concerns and guiding [property design](/blog/property-design-patterns-defi-lending) toward the highest-risk areas of the codebase. **Results:** Several arithmetic precision issues were identified in interest rate calculations that could accumulate discrepancies over time. Economic edge cases in the liquidation mechanics were surfaced where specific market movement sequences could produce suboptimal outcomes. The delivered invariant test suite continues to run against ongoing development, catching regressions as the team iterates toward launch. The combination of manual review and [invariant testing](/learn/invariant-testing) provided a level of assurance that neither approach could've achieved independently. For a deep dive into a related class of bug found through [fuzzing](/learn/fuzzing) on a Liquity fork, read [The Bug That Was Missed](https://getrecon.substack.com/p/the-bug-that-was-missed) on the Recon Substack. **Findings:** - **High:** Arithmetic precision errors in interest calculations — [Invariant testing](/learn/invariant-testing) revealed edge cases in interest rate calculations where fixed-point math rounding errors compounded across multiple operations. Over long sequences of borrower interactions — opens, adjustments, and repayments — these small discrepancies could accumulate into material accounting differences between expected and actual interest owed. - **Medium:** Economic edge cases in liquidation mechanics — [Fuzzing](/learn/fuzzing) uncovered scenarios where specific sequences of market movements and borrower actions could lead to suboptimal liquidation outcomes. The edge cases involved interactions between batch-managed positions and the liquidation logic, where certain ordering of operations produced results that deviated from the protocol's intended economic guarantees. URL: https://getrecon.xyz/case-studies/liquity Report: https://github.com/Recon-Fuzz/audits/blob/main/bold-report.md --- #### Centrifuge — Invariant Testing with Recon Pro (2024) Centrifuge is a real-world asset (RWA) protocol that was implementing ERC-7540, the standard for asynchronous tokenized vaults. Recon built [invariant testing](/learn/invariant-testing) coverage using the [Recon Pro](/pro) cloud fuzzing platform. The engagement identified a [rounding error](/blog/rounding-errors-defi-small-bugs-big-exploits) in share/asset conversions that allowed bypassing deposit caps — a category of bug that's notoriously difficult to catch through manual review or unit testing alone, because it only shows up through specific sequences of operations repeated many times. **Challenge:** ERC-7540 introduces asynchronous deposit and redemption flows where requests are queued and fulfilled over time by different participants. This creates multi-step state transitions where shares, assets, and pending requests must all remain consistent. Rounding behavior in share-to-asset and asset-to-share conversions varies depending on the direction and magnitude of the operation, creating edge cases that only show up through specific operation sequences. The asynchronous nature means that state can change between a user's request and its fulfillment, further expanding the space of possible interactions. **Approach:** [Recon Pro's](/pro) cloud [fuzzing](/learn/fuzzing) platform ran multi-hour campaigns testing all combinations of deposits, partial fills, complete redemptions, and cancellations across multiple concurrent users. Properties enforced that deposit caps couldn't be exceeded, that share/asset conversions remained consistent in both directions, and that no user could extract more value than they deposited. The cloud infrastructure allowed testing at a scale that isn't feasible on local machines, exploring far more operation sequences than a local fuzzing session could cover. **Results:** The fuzzer discovered that repeated small deposits could exploit rounding in the share calculation to incrementally exceed the deposit cap. Each individual rounding error was at most one wei, but a patient attacker automating thousands of operations could exceed the cap by a material amount. The finding was addressed by rounding in favor of the protocol in share calculations. The Centrifuge CTO praised both the team and the [Recon Pro](/pro) platform for the quality of the engagement. **Findings:** - **Medium:** Rounding errors allows bypassing caps — [Invariant testing](/learn/invariant-testing) discovered that specific sequences of small deposits could exploit rounding in share calculations to bypass deposit caps. Each deposit operation introduced a rounding error of at most one wei in the share-to-asset conversion, but by repeating the operation thousands of times, an attacker could incrementally exceed the configured cap by a material amount. URL: https://getrecon.xyz/case-studies/centrifuge --- #### Corn — Invariant Testing Engagement (2024) Corn is a DeFi protocol built around a vault system for depositing, staking, and distributing rewards. Recon's [invariant testing](/learn/invariant-testing) identified a [critical insolvency vulnerability](/blog/anatomy-critical-defi-bug-insolvency-accounting) within hours of starting the campaign — the vault's accounting could enter a state where it owed more to depositors than it actually held. Continued testing after the fix validated the correction and confirmed subsequent development changes didn't reintroduce the vulnerability. The rapid turnaround from campaign start to critical finding demonstrated the effectiveness of targeted invariant testing. **Challenge:** The protocol's core complexity lay in the interaction between three operations: depositing assets into the vault, staking those deposits for yield, and distributing rewards across depositors. The share pricing mechanism needed to correctly account for rewards in the conversion between assets and shares. Any miscalculation in this conversion could allow depositors to extract more value than the vault contains, leading to insolvency for remaining depositors. The combination of these operations in arbitrary order created a state space too large for manual analysis. **Approach:** Solvency invariants were defined requiring that the vault's total assets must always be greater than or equal to the value owed to all shareholders. The fuzzer generated randomized sequences of deposits with varying amounts, withdrawals of partial and full positions, and reward distributions at different intervals. Handler functions simulated multiple users interacting simultaneously to test share pricing under concurrent access. Properties were checked after every operation to catch the earliest point at which accounting diverged. **Results:** The critical insolvency bug was identified in the share minting logic — new deposits received slightly more shares than they deserved when rewards had recently been distributed, because the conversion rounded in favor of the depositor rather than the protocol. Over time, this would drain the vault. The fix reversed the rounding direction, and continued [fuzzing](/learn/fuzzing) confirmed the fix held across millions of additional test sequences. The Corn cofounder praised Recon's work in making invariant testing accessible and practical for their development workflow. **Findings:** - **Critical:** Insolvency due to Incorrect Accounting — [Invariant testing](/learn/invariant-testing) revealed a critical accounting error in the share minting logic. When rewards had recently been distributed, the asset-to-share conversion rounded in favor of new depositors, granting them slightly more shares than their deposit warranted. Repeated deposit/withdraw cycles around reward distribution events would progressively drain the vault, causing the protocol's total liabilities to exceed its actual assets. URL: https://getrecon.xyz/case-studies/corn --- #### Badger — Invariant Testing Engagement (2024) Badger DAO manages hundreds of millions in TVL across yield-generating vault strategies. Recon's [invariant testing](/learn/invariant-testing) of the remBADGER vault identified a critical accounting desynchronization that could've led to protocol insolvency. The bug existed in a subtle interaction between deposit and reward distribution accounting that a previous manual audit had reviewed without catching. The finding demonstrated that invariant testing can surface issues that escape even careful manual review. **Challenge:** The remBADGER vault system tracks depositor shares across multiple underlying yield strategies. Rewards flow in from external sources and must be distributed proportionally across all shareholders. The accounting must correctly handle deposits, withdrawals, and reward distributions in any order, including edge cases where these operations happen in rapid succession or within the same block. A single accounting error in this system puts the entire vault TVL at risk, making correctness of the share pricing mechanism a critical requirement. **Approach:** The invariant test suite targeted the core accounting relationship: total shares multiplied by the price per share must never exceed total assets held by the vault. Additional properties verified that reward distributions couldn't create share/asset desynchronization, that no depositor could withdraw more than their proportional share, and that the vault remained solvent after any sequence of operations. The fuzzer was configured with multiple actors depositing and withdrawing concurrently to simulate realistic usage patterns and adversarial interactions. **Results:** After roughly 40 transactions in a specific sequence, the fuzzer triggered the solvency invariant. The root cause was a subtle interaction where reward distribution updated the price per share, but subsequent deposits used a slightly stale conversion rate, creating a small accounting gap that accumulated over repeated cycles. The bug would've been exploitable by any automated bot performing repeated deposit/withdraw cycles around reward distribution events. Similar to the issue found in [Corn's](/case-studies/corn) vault system, the fix required correcting the share conversion timing. Badger's Lead Dev noted that [Recon Pro](/pro) significantly accelerated test development and provided confidence in the fix. **Findings:** - **Critical:** Insolvency due to Incorrect Accounting — The remBADGER accounting system had a bug where reward distribution updated the price per share, but subsequent deposits used a slightly stale conversion rate. This created a small accounting gap between the shares issued and the assets backing them. Over repeated deposit/withdraw cycles timed around reward distribution events, the gap accumulated, causing total share-implied value to exceed actual vault assets — a path to insolvency exploitable by automated bots. URL: https://getrecon.xyz/case-studies/badger Report: https://gist.github.com/GalloDaSballo/564e16b9cfa9bcf998e3778fdb297045 --- #### Credit Coop — Invariant Testing Engagement (2024) Credit Coop is a lending protocol where Recon delivered an invariant test suite that uncovered a high-severity vulnerability in the minting cap enforcement. The engagement demonstrated extremely high return on investment — the cost of the testing was a fraction of the potential loss the vulnerability could've caused. The success of the engagement led Credit Coop to adopt [invariant testing](/learn/invariant-testing) as a core part of their smart contract development process going forward. **Challenge:** The protocol enforces caps on how much can be minted against each collateral type to limit protocol exposure. The cap enforcement interacts with interest accrual, collateral price updates, and partial repayments. Rounding in these calculations could theoretically allow users to mint slightly more than the cap permits through carefully sequenced operations. Testing this manually would require enumerating an impractical number of operation sequences across multiple collateral types and user states. **Approach:** The invariant test suite defined properties requiring that the total minted amount for each collateral type must never exceed the configured cap. Additional properties covered interest accrual correctness — verifying that accrued interest is always non-negative and monotonically increasing — and collateral accounting consistency. The fuzzer tested combinations of borrows, repayments, liquidations, and interest accrual across multiple users and collateral types, exploring sequences that would be impractical to enumerate by hand. **Results:** The fuzzer discovered that sequences of small minting operations could exploit [rounding](/blog/rounding-errors-defi-small-bugs-big-exploits) in the cap check to incrementally exceed the intended limit. The vulnerability was in the interaction between the cap enforcement and interest accrual — as interest accrued, the effective utilization changed, and the rounding in the cap check didn't account for this correctly. Credit Coop's CTO described the ROI as extremely high, noting that the cost of the engagement was negligible compared to the potential loss if the vulnerability had been exploited in production. **Findings:** - **Medium:** Rounding allows bypassing minting cap — [Invariant testing](/learn/invariant-testing) identified that specific sequences of small minting operations could exploit rounding in the cap check to incrementally exceed the intended minting cap. The vulnerability arose from the interaction between cap enforcement and interest accrual — as interest changed the effective utilization, the rounding in the cap calculation didn't correctly account for the shift, allowing each small mint to slip slightly past the limit. URL: https://getrecon.xyz/case-studies/credit-coop ### Glossary (50 terms — full definitions) #### Invariant Testing Invariant testing goes beyond traditional unit tests by defining properties that should never be violated across all possible execution paths. A [fuzzer](/learn/fuzzing) generates random sequences of function calls and checks these invariants after each transaction. If an invariant breaks, the fuzzer reports the exact sequence that caused the violation. This approach is especially powerful for DeFi protocols where accounting correctness, solvency, and access control must hold under all conditions. For a deeper look at why this methodology matters, see [Why Invariant Testing Matters for DeFi Security](/blog/why-invariant-testing-matters-defi-security). **Q: What is invariant testing in smart contracts?** A: Invariant testing is a technique where you define properties (invariants) that must always be true in your smart contract system, then use a fuzzer to try millions of random transaction sequences to find violations. For example, 'total deposits must always equal total shares times price per share' is an invariant. **Q: How is invariant testing different from unit testing?** A: Unit tests check specific scenarios you can think of. Invariant testing checks properties across millions of randomly generated scenarios, finding edge cases humans miss. Unit tests answer 'does this specific input produce this output?' while invariant tests answer 'does this property hold for ALL possible inputs?' See the [invariant testing vs unit testing comparison](/compare/invariant-testing-vs-unit-testing) for a full breakdown. **Q: What tools are used for invariant testing?** A: The main tools for smart contract invariant testing are [Echidna](/learn/echidna), [Medusa](/learn/medusa), Foundry (forge), and [Halmos](/learn/halmos). Recon's [Chimera framework](/learn/chimera-framework) allows writing tests that are compatible with all three fuzzers simultaneously. URL: https://getrecon.xyz/learn/invariant-testing --- #### Fuzzing In the context of smart contracts, fuzzing involves generating random transaction sequences and input values to test contract behavior under unexpected conditions. Modern smart contract fuzzers like [Echidna](/learn/echidna) and [Medusa](/learn/medusa) use [coverage-guided techniques](/learn/coverage-guided-fuzzing) to intelligently explore code paths and find edge cases. Fuzzing has proven highly effective at catching arithmetic errors, [reentrancy](/learn/reentrancy) vulnerabilities, and accounting bugs in DeFi protocols. See [how we found real vulnerabilities with fuzzing](/blog/how-we-found-real-vulnerabilities-with-fuzzing) for practical examples. **Q: What is fuzzing in smart contract security?** A: Fuzzing in smart contract security is the process of automatically generating random inputs and transaction sequences to test contracts for vulnerabilities. Smart contract fuzzers like [Echidna](/learn/echidna) and [Medusa](/learn/medusa) can run millions of tests to find bugs that manual review and unit tests miss. **Q: Why is fuzzing important for DeFi security?** A: DeFi protocols handle real money and operate in adversarial environments. Fuzzing can discover edge cases in arithmetic, accounting, and state management that lead to fund loss. Recon's [fuzzing services](/services/fuzzing-smart-contracts) have saved over $20M by catching critical bugs before deployment. URL: https://getrecon.xyz/learn/fuzzing --- #### Echidna Echidna is one of the pioneering smart contract fuzzers, developed by Trail of Bits. It uses grammar-based fuzzing combined with [coverage guidance](/learn/coverage-guided-fuzzing) to efficiently explore contract state spaces. Echidna supports both [property-based testing](/learn/property-based-testing) (boolean properties) and assertion testing, making it versatile for different testing approaches. It integrates with the Solidity compiler and can test complex multi-contract systems. For a head-to-head comparison with Medusa, see [Echidna vs Medusa](/blog/echidna-vs-medusa-practical-comparison). For real-world examples of Echidna catching critical bugs, see [The Bug That Was Missed](https://getrecon.substack.com/p/the-bug-that-was-missed) and [The Dark Side of the LP](https://getrecon.substack.com/p/the-dark-side-of-the-lp) on the Recon Substack. **Q: What is Echidna used for?** A: Echidna is used for finding bugs in Solidity smart contracts through [fuzzing](/learn/fuzzing). It generates random sequences of function calls and checks that user-defined properties ([invariants](/learn/invariant-testing)) are never violated. **Q: How does Echidna compare to Medusa?** A: Echidna is written in Haskell and was one of the first smart contract fuzzers. [Medusa](/learn/medusa) is a newer Go-based fuzzer that offers parallel execution and is often faster. Both tools test the same types of properties. Recon's [Chimera framework](/learn/chimera-framework) lets you write tests compatible with both. See the [detailed comparison](/compare/echidna-vs-medusa). URL: https://getrecon.xyz/learn/echidna --- #### Medusa Medusa is a modern smart contract fuzzer built in Go by the Trail of Bits team. It offers significant speed advantages through parallel goroutine execution, allowing it to test more transaction sequences per second than single-threaded alternatives. Medusa supports [property-based testing](/learn/property-based-testing), assertion testing, and optimization testing. It uses [coverage-guided](/learn/coverage-guided-fuzzing) mutation to intelligently explore code paths. For a practical comparison with Echidna, see [Echidna vs Medusa](/blog/echidna-vs-medusa-practical-comparison). **Q: What is Medusa fuzzer?** A: Medusa is a fast, parallel smart contract fuzzer written in Go. It finds bugs by generating random transaction sequences and checking that properties ([invariants](/learn/invariant-testing)) defined by the developer hold true across all sequences. **Q: Is Medusa faster than Echidna?** A: Yes, Medusa is generally faster than [Echidna](/learn/echidna) due to its parallel execution model using Go goroutines. It can test more transaction sequences per second, which means it finds bugs faster in many cases. See the [full comparison](/compare/echidna-vs-medusa). URL: https://getrecon.xyz/learn/medusa --- #### Halmos Halmos performs [symbolic execution](/learn/symbolic-execution) on EVM bytecode to formally verify properties of smart contracts. Unlike fuzzers that test random inputs, Halmos explores all possible execution paths symbolically, providing mathematical proof that a property holds or finding a concrete counterexample. It is particularly useful for verifying arithmetic correctness, access control, and state transition logic where complete coverage is required. For a walkthrough of running Halmos from the Recon Extension, see [The Recon Extension: Now With More Halmos](https://getrecon.substack.com/p/the-recon-extension-now-with-more) on the Recon Substack. **Q: What is Halmos?** A: Halmos is a symbolic execution tool for Ethereum smart contracts. It mathematically proves whether properties hold for all possible inputs, going beyond fuzzing's random sampling to provide formal guarantees. **Q: When should I use Halmos vs a fuzzer?** A: Use Halmos when you need mathematical proof that a property holds for all inputs (e.g., arithmetic correctness). Use fuzzers like Echidna or Medusa for stateful testing with complex transaction sequences. Many teams use both for comprehensive coverage. URL: https://getrecon.xyz/learn/halmos --- #### Property-Based Testing Property-based testing shifts the focus from writing specific test cases to defining general properties of the system. Instead of 'when I deposit 100 tokens, my balance increases by 100,' you write 'for any deposit amount, the total supply must equal the sum of all balances.' A test generator then creates thousands or millions of random inputs to try to violate these properties. This approach, popularized by QuickCheck in Haskell, is the foundation of invariant testing in smart contracts. **Q: What is property-based testing?** A: Property-based testing is a methodology where you define properties (rules that must always be true) and a tool automatically generates random inputs to test them. It finds edge cases that manually written unit tests miss. **Q: How is property-based testing used in smart contract security?** A: In smart contract security, property-based testing is used to verify invariants like 'the protocol is always solvent' or 'no user can withdraw more than they deposited.' Fuzzers generate random transaction sequences to check these properties across millions of scenarios. URL: https://getrecon.xyz/learn/property-based-testing --- #### Smart Contract Audit Smart contract audits combine manual [code review](/services/smart-contract-audit) with automated analysis tools to find security vulnerabilities. Modern audits go beyond just reading code — they include [invariant testing](/learn/invariant-testing), [fuzzing](/learn/fuzzing), [formal verification](/learn/formal-verification), and economic analysis. Recon's [audit approach](/services/smart-contract-audit) uniquely combines expert manual review with comprehensive invariant testing suites that continue to protect the protocol after the audit is complete. Learn [what to expect from a smart contract audit in 2025](/blog/what-to-expect-smart-contract-audit-2025). **Q: What does a smart contract audit include?** A: A comprehensive smart contract audit includes manual [code review](/services/smart-contract-audit), automated vulnerability scanning, [invariant testing](/learn/invariant-testing) with fuzzers, and a detailed report of findings. Recon's audits also deliver a reusable invariant test suite that continues protecting the protocol. **Q: How much does a smart contract audit cost?** A: Smart contract audit costs vary based on codebase size, complexity, and the audit firm. Recon offers competitive pricing with the added value of delivering [invariant test suites](/learn/invariant-testing) alongside the audit. Contact us for a custom quote. URL: https://getrecon.xyz/learn/smart-contract-audit --- #### Formal Verification Formal verification applies mathematical proofs to smart contracts to guarantee correctness. Unlike testing (which checks specific cases) or fuzzing (which checks random cases), formal verification proves properties hold for ALL possible cases. Tools like [Halmos](/learn/halmos), Certora, and SMTChecker perform [symbolic execution](/learn/symbolic-execution) or model checking to verify smart contract properties. It is the gold standard for security-critical code but requires more effort to set up. **Q: What is formal verification for smart contracts?** A: Formal verification uses mathematical proofs to guarantee that a smart contract behaves correctly for all possible inputs. Unlike testing, it provides complete coverage, proving that certain bugs are impossible. **Q: Is formal verification better than fuzzing?** A: They are complementary. Formal verification (using tools like [Halmos](/learn/halmos)) provides mathematical guarantees but is harder to set up and can struggle with complex stateful systems. Fuzzing is faster to implement and excels at finding bugs in stateful, multi-contract systems. Best practice is to use both. URL: https://getrecon.xyz/learn/formal-verification --- #### Chimera Framework The Chimera framework, developed by Recon, solves a major pain point in [invariant testing](/learn/invariant-testing): tool compatibility. Traditionally, invariant tests written for [Echidna](/learn/echidna) wouldn't work with [Medusa](/learn/medusa) or Foundry, and vice versa. Chimera provides a unified testing interface that compiles to all three fuzzers, allowing teams to write tests once and run them everywhere. It includes helpers for common patterns like actor management, assertion utilities, and setup scaffolding. Get started with the [beginner's guide to Chimera](/blog/zero-to-fuzzing-beginners-guide-chimera). For the roadmap of upcoming Chimera and extension features, see [The Recon Extension Roadmap](https://getrecon.substack.com/p/the-recon-extension-roadmap) on the Recon Substack. **Q: What is the Chimera framework?** A: Chimera is an open-source Solidity testing framework by Recon that lets you write [invariant tests](/learn/invariant-testing) compatible with [Echidna](/learn/echidna), [Medusa](/learn/medusa), and Foundry simultaneously. Write once, fuzz with any tool. **Q: Why use Chimera instead of writing tests directly?** A: Chimera eliminates tool lock-in. If you write tests directly for [Echidna](/learn/echidna), switching to [Medusa](/learn/medusa) means rewriting everything. Chimera tests work with all fuzzers, so you can choose the best tool for each situation without extra work. See [how Echidna and Medusa compare](/compare/echidna-vs-medusa) to understand why flexibility matters. URL: https://getrecon.xyz/learn/chimera-framework --- #### Stateful Fuzzing Unlike stateless fuzzing (which tests each function call independently), stateful fuzzing builds up contract state through sequences of transactions. This is critical for finding bugs that only manifest after specific sequences of operations — for example, a vulnerability that requires depositing, then staking, then withdrawing in a specific order. Stateful fuzzers like [Echidna](/learn/echidna) and [Medusa](/learn/medusa) maintain the EVM state between calls, enabling them to find complex multi-step vulnerabilities. For a deep dive, read [Stateful Fuzzing Explained: Why Sequence Matters](/blog/stateful-fuzzing-explained-sequence-matters). **Q: What is stateful fuzzing?** A: Stateful fuzzing tests smart contracts by generating random sequences of function calls while maintaining state between each call. This finds bugs that only appear after specific sequences of operations, which single-function tests miss. See [how it compares to static analysis](/compare/static-analysis-vs-fuzzing). **Q: Why is stateful fuzzing important for DeFi?** A: DeFi protocols involve complex state changes across multiple transactions. A vulnerability might only appear after a specific sequence of deposits, borrows, and liquidations. Stateful fuzzing explores these sequences automatically. See real examples in the [Liquity](/case-studies/liquity) and [Badger](/case-studies/badger) case studies. URL: https://getrecon.xyz/learn/stateful-fuzzing --- #### Mutation Testing Mutation testing works by systematically modifying the source code (e.g., changing + to -, swapping >= to >, removing a require statement) and running the test suite against each mutation. If tests fail on a mutation, it is 'killed' - meaning the tests caught the change. If tests pass despite the mutation, the test suite has a gap. This technique is invaluable for evaluating invariant test suite completeness. **Q: What is mutation testing?** A: Mutation testing evaluates how good your tests are by introducing small bugs (mutations) into the code and checking if tests catch them. If a mutation survives without any test failing, your test suite has a gap. **Q: How does mutation testing relate to invariant testing?** A: Mutation testing tells you if your invariants are strong enough. If you change a + to - in the contract code and no invariant breaks, your [property-based testing](/learn/property-based-testing) suite is missing a property that should catch that type of bug. URL: https://getrecon.xyz/learn/mutation-testing --- #### Coverage-Guided Fuzzing Coverage-guided fuzzing uses code coverage feedback to make [fuzzing](/learn/fuzzing) more effective. As the fuzzer generates random inputs, it monitors which branches, lines, and paths in the code are exercised. Inputs that trigger new coverage are saved and mutated further, while inputs that only retrace known paths are deprioritized. This approach is dramatically more efficient than pure random fuzzing, as it systematically drives the fuzzer toward unexplored code regions where bugs are more likely to hide. Both [Echidna](/learn/echidna) and [Medusa](/learn/medusa) implement coverage-guided strategies. For an introduction, see the [beginner's guide to fuzzing with Chimera](/blog/zero-to-fuzzing-beginners-guide-chimera). **Q: What is coverage-guided fuzzing?** A: Coverage-guided fuzzing is a smart fuzzing technique that tracks which code paths each input explores. The fuzzer prioritizes inputs that reach new code paths, making [property-based testing](/learn/property-based-testing) much more efficient at finding bugs than random input generation. **Q: Which smart contract fuzzers use coverage guidance?** A: Both [Echidna](/learn/echidna) and [Medusa](/learn/medusa) use coverage-guided fuzzing. They track EVM opcode coverage and prioritize transaction sequences that explore new contract code paths. See [how they compare](/compare/echidna-vs-medusa). URL: https://getrecon.xyz/learn/coverage-guided-fuzzing --- #### Symbolic Execution Symbolic execution replaces concrete inputs with symbolic variables and uses constraint solvers (SMT solvers) to reason about all possible values simultaneously. When the execution encounters a branch condition, it forks into two paths - one where the condition is true and one where it's false. This allows complete exploration of all code paths without needing to enumerate specific inputs. Tools like [Halmos](/learn/halmos) and Certora use symbolic execution to formally verify smart contract properties. **Q: What is symbolic execution in smart contracts?** A: Symbolic execution analyzes smart contracts by using mathematical symbols instead of concrete values as inputs. This lets it reason about all possible inputs at once, finding bugs that specific test cases might miss. **Q: What is the difference between symbolic execution and fuzzing?** A: Fuzzing tests with concrete random values and is fast but may miss edge cases. Symbolic execution uses mathematical symbols to reason about all possible values simultaneously, providing complete coverage but being slower and more complex to set up. URL: https://getrecon.xyz/learn/symbolic-execution --- #### Fuzz Testing Fuzz testing, commonly called fuzzing, is a software testing technique that has become essential in smart contract security. By automating the generation of test inputs, fuzz testing can explore far more scenarios than manual testing. In the smart contract context, fuzz testing generates random sequences of function calls with random parameters to find state violations, arithmetic errors, access control bypasses, and other vulnerabilities. This approach is closely related to [property-based testing](/learn/property-based-testing), where developers define properties that must always hold and the fuzzer searches for violations. Modern fuzz testing tools for smart contracts include Echidna, Medusa, and Foundry's built-in fuzzer. **Q: What is fuzz testing?** A: Fuzz testing is an automated testing technique that feeds random or semi-random inputs to a program to find bugs. In smart contract security, it generates random transaction sequences to find vulnerabilities that manual testing misses. **Q: How do I start fuzz testing my smart contracts?** A: The easiest way to start is with Recon's VS Code extension, which scaffolds a fuzzing setup in one click. You can also use Recon Pro to run fuzz tests in the cloud. For manual setup, choose a fuzzer (Echidna, Medusa, or Foundry) and write property tests. URL: https://getrecon.xyz/learn/fuzz-testing --- #### Smart Contract Security Smart contract security is a multi-layered discipline that includes [code auditing](/learn/smart-contract-audit), [invariant testing](/learn/invariant-testing), [fuzzing](/learn/fuzzing), [formal verification](/learn/formal-verification), monitoring, and incident response. Because smart contracts are immutable once deployed and often handle significant financial value, security must be addressed proactively. Modern smart contract security best practices include multiple audit rounds, continuous invariant testing, bug bounty programs, and runtime monitoring. Recon specializes in combining expert manual review with comprehensive invariant testing for maximum coverage — see [what vulnerabilities unit tests miss](/blog/smart-contract-vulnerabilities-unit-tests-miss). **Q: Why is smart contract security important?** A: Smart contracts are immutable, publicly accessible, and often control millions of dollars. A single vulnerability can lead to total loss of funds. Billions of dollars have been stolen from smart contracts, making security the most critical aspect of development. **Q: What are the best practices for smart contract security?** A: Best practices include: multiple independent [audits](/learn/smart-contract-audit), [invariant testing](/learn/invariant-testing) with fuzzers, [formal verification](/learn/formal-verification) for critical code, bug bounty programs, monitoring and incident response plans, and using battle-tested libraries and patterns. URL: https://getrecon.xyz/learn/smart-contract-security --- #### Reentrancy Reentrancy attacks exploit the fact that external calls in Solidity transfer execution control to the callee, which can call back into the original contract before state changes are finalized. The most infamous example is the 2016 DAO hack, which resulted in the loss of approximately $60 million in ETH and led to the Ethereum hard fork. The primary defense is the checks-effects-interactions pattern, where all state changes are made before any external calls. Reentrancy guards (mutexes) such as OpenZeppelin's ReentrancyGuard provide an additional layer of protection by locking the contract during execution. Modern fuzzing and invariant testing tools can systematically detect reentrancy by testing whether invariants hold mid-execution. **Q: What is a reentrancy attack in smart contracts?** A: A reentrancy attack occurs when a malicious contract exploits an external call to re-enter the vulnerable contract before its state updates are finished. This allows the attacker to repeat actions like withdrawals multiple times. The DAO hack in 2016 is the most well-known reentrancy exploit, draining roughly $60 million in ETH. **Q: How do you prevent reentrancy in Solidity?** A: The main defenses are the checks-effects-interactions pattern (update all state before making external calls) and reentrancy guards like OpenZeppelin's ReentrancyGuard modifier. Additionally, invariant testing with fuzzers can verify that no sequence of calls violates accounting invariants mid-execution. **Q: Can fuzzing detect reentrancy vulnerabilities?** A: Yes. Stateful fuzzers like Echidna and Medusa can be configured with callback-enabled actors that attempt to re-enter the contract during external calls. By checking invariants after each step, the fuzzer can detect when reentrancy leads to an inconsistent state. URL: https://getrecon.xyz/learn/reentrancy --- #### Flash Loan Attack Flash loan attacks leverage the unique DeFi primitive of uncollateralized loans that exist only within a single atomic transaction. Attackers borrow massive amounts of capital at zero cost, use it to manipulate prices, exploit vulnerable protocol logic, or drain funds, and repay the loan before the transaction completes. Notable flash loan attacks include the bZx exploits in 2020 and the Cream Finance hack in 2021, which resulted in over $130 million in losses. Invariant testing is particularly effective at catching flash loan vulnerabilities because it can simulate large, arbitrary capital movements and verify that protocol accounting remains consistent regardless of transaction size. **Q: What is a flash loan attack?** A: A flash loan attack uses uncollateralized DeFi loans that must be repaid within the same transaction. The attacker borrows a large sum, uses it to manipulate a protocol (e.g., by skewing prices or exploiting logic bugs), extracts profit, repays the loan, and keeps the profit — all in one atomic transaction. **Q: How can protocols defend against flash loan attacks?** A: Protocols can defend against flash loan attacks by using manipulation-resistant oracles like Chainlink or TWAPs, implementing checks that limit the impact of single-transaction capital movements, and using invariant testing to verify that protocol state remains consistent regardless of the size of deposits or trades. **Q: Can invariant testing detect flash loan vulnerabilities?** A: Yes. Invariant testing can simulate extreme capital movements — deposits, swaps, and withdrawals of arbitrarily large amounts — and check that protocol invariants like solvency, correct exchange rates, and accurate accounting hold under all conditions. This naturally surfaces the same classes of bugs that flash loan attackers exploit. URL: https://getrecon.xyz/learn/flash-loan-attack --- #### Oracle Manipulation Oracle manipulation attacks target the price feeds that DeFi protocols depend on for critical operations like liquidations, swaps, and collateral valuations. Attackers commonly use flash loans to move spot prices in low-liquidity pools, causing on-chain oracles to report distorted prices. Defenses include using decentralized oracle networks like Chainlink, implementing time-weighted average prices (TWAPs) that resist single-block manipulation, and adding sanity checks on price deviations. Fuzzing can help test oracle edge cases by feeding extreme or adversarial price values to protocol functions and verifying that invariants hold. For a detailed case study of an oracle pricing vulnerability found through fuzzing, see [The Dark Side of the LP](https://getrecon.substack.com/p/the-dark-side-of-the-lp) on the Recon Substack. **Q: What is oracle manipulation in DeFi?** A: Oracle manipulation is an attack that distorts the price data a DeFi protocol reads on-chain. Attackers typically use flash loans to move prices in low-liquidity pools, tricking the protocol into using incorrect prices for operations like lending, liquidation, or swaps. **Q: How do Chainlink and TWAP oracles help prevent manipulation?** A: Chainlink aggregates prices from multiple off-chain sources, making it expensive to manipulate. TWAP oracles average prices over a time window, so a single-block price spike has minimal impact. Both approaches make manipulation significantly harder compared to using raw spot prices from a single DEX pool. **Q: How can fuzzing help test oracle security?** A: Fuzzers can inject extreme, zero, stale, or rapidly changing oracle prices into protocol functions and verify that invariants like solvency, correct collateral ratios, and proper liquidation behavior still hold. This surfaces bugs that arise from unexpected price movements. URL: https://getrecon.xyz/learn/oracle-manipulation --- #### Slippage Protection Slippage protection sets bounds on acceptable price impact for on-chain trades. When a user submits a swap, the slippage tolerance defines the maximum deviation from the expected price that is acceptable; if the actual execution price exceeds this threshold, the transaction reverts. This mechanism defends against MEV sandwich attacks, where a front-runner inflates the price before the victim's trade and sells immediately after. Effective slippage protection also includes deadline parameters to prevent transactions from executing at stale prices after sitting in the mempool. Protocol developers must carefully calibrate default slippage values to balance user protection against transaction failure rates. **Q: What is slippage protection in DeFi?** A: Slippage protection limits the maximum price impact a trade can incur. If the execution price deviates beyond the user's slippage tolerance, the transaction reverts. This protects users from large price movements and sandwich attacks. **Q: What is a sandwich attack and how does slippage help?** A: A sandwich attack is when an MEV bot front-runs your trade to push the price up, then back-runs it to profit from the price movement. Tight slippage tolerance causes your transaction to revert if the price moves too much, making sandwich attacks unprofitable. **Q: What is a deadline parameter in swaps?** A: A deadline parameter sets a timestamp after which a swap transaction will revert. This prevents old transactions stuck in the mempool from executing at outdated prices. Without a deadline, a swap submitted hours ago could execute at a significantly different market price. URL: https://getrecon.xyz/learn/slippage-protection --- #### Access Control Access control is one of the most critical [smart contract security](/learn/smart-contract-security) layers, governing who can execute administrative, sensitive, or state-changing functions. Common patterns include OpenZeppelin's Ownable (single owner) and AccessControl (role-based) contracts. Access control bugs — such as missing modifiers, incorrect role assignments, or unprotected initializers — are among the most frequently exploited vulnerabilities in DeFi. Invariant testing can systematically verify access control by asserting that unprivileged actors can never reach privileged states, providing ongoing confidence that permission boundaries hold across all possible call sequences. **Q: What is access control in smart contracts?** A: Access control determines which addresses can call which functions in a smart contract. It uses patterns like onlyOwner modifiers or role-based systems (e.g., OpenZeppelin AccessControl) to restrict sensitive operations to authorized addresses only. **Q: What are common access control vulnerabilities?** A: Common access control bugs include missing modifiers on sensitive functions, incorrect role checks, unprotected initializer functions in upgradeable contracts, and overly permissive default roles. These are among the most frequently exploited vulnerability classes in DeFi. **Q: How can invariant testing verify access control?** A: Invariant testing can define properties like 'only the owner can change this parameter' or 'unprivileged users can never set the fee above X.' The fuzzer then tries millions of call sequences with different actors to verify that these properties hold regardless of the execution path. URL: https://getrecon.xyz/learn/access-control --- #### Integer Overflow Integer overflow and underflow were historically among the most dangerous [smart contract security](/learn/smart-contract-security) vulnerabilities. In Solidity versions prior to 0.8.0, arithmetic operations silently wrapped on overflow — for example, a uint8 value of 255 plus 1 would wrap to 0 — enabling attackers to manipulate balances and bypass checks. Solidity 0.8.0 introduced built-in overflow checks that cause transactions to revert on overflow by default. However, developers can still use unchecked blocks to bypass these checks for gas optimization, reintroducing the risk. Fuzzing is highly effective at catching arithmetic edge cases by testing functions with boundary values, large numbers, and zero inputs that manual testing often overlooks. **Q: What is integer overflow in Solidity?** A: Integer overflow in Solidity occurs when an arithmetic operation exceeds the maximum value of a fixed-size integer type. For example, adding 1 to the maximum uint256 value would wrap to 0 in pre-0.8.0 Solidity. Since Solidity 0.8.0, arithmetic reverts on overflow by default. **Q: Are integer overflows still a concern in modern Solidity?** A: Yes, because developers can use unchecked blocks to disable overflow checks for gas savings. Code inside unchecked blocks behaves like pre-0.8.0 Solidity and can silently overflow. Any unchecked arithmetic must be carefully reviewed and tested. **Q: How does fuzzing detect integer overflow bugs?** A: Fuzzers generate random inputs including boundary values (0, 1, type(uint256).max) and large numbers that are likely to trigger overflows. By checking invariants after each call, the fuzzer can detect when an overflow leads to incorrect balances, broken accounting, or other state violations. URL: https://getrecon.xyz/learn/integer-overflow --- #### Front-Running Front-running exploits the transparency of blockchain mempools, where pending transactions are visible before being included in a block. An attacker (or MEV bot) monitors the mempool for profitable opportunities, then submits a competing transaction with higher gas to be mined first. The most common form is the sandwich attack, where the attacker front-runs a large swap to move the price, lets the victim's trade execute at a worse price, and then back-runs to profit from the price difference. Mitigations include commit-reveal schemes, private transaction pools like Flashbots Protect, and protocol-level designs that minimize MEV extraction such as batch auctions and encrypted mempools. **Q: What is front-running in crypto?** A: Front-running is when someone sees your pending transaction in the mempool and gets their own transaction executed first by paying higher gas. In DeFi, this often takes the form of sandwich attacks on swaps, where the attacker profits from the price movement caused by your trade. **Q: What is MEV and how does it relate to front-running?** A: MEV (Maximal Extractable Value) is the profit that block producers or searchers can extract by reordering, inserting, or censoring transactions within a block. Front-running is one of the primary MEV extraction strategies, alongside back-running, sandwich attacks, and arbitrage. **Q: How can protocols mitigate front-running?** A: Protocols can use commit-reveal schemes (hide transaction details until committed), private transaction submission via Flashbots Protect, batch auctions that process all orders at the same price, and protocol designs that minimize extractable value such as oracle-based pricing instead of AMM spot prices. URL: https://getrecon.xyz/learn/front-running --- #### Proxy Pattern Proxy patterns allow smart contracts to be upgraded after deployment by splitting the system into a proxy contract (which holds state and receives calls) and an implementation contract (which contains the logic). The proxy uses Solidity's delegatecall to execute the implementation's code in the context of the proxy's storage. Common variants include the transparent proxy (where admin and user calls are handled differently), UUPS (where upgrade logic lives in the implementation), and beacon proxies (where multiple proxies share a single upgradeable implementation pointer). Proxy patterns introduce unique [smart contract security](/learn/smart-contract-security) risks including storage collision between proxy and implementation, uninitialized implementation contracts, and function selector clashes, making them a critical focus area for audits. **Q: What is the proxy pattern in smart contracts?** A: The proxy pattern separates a contract's storage (proxy) from its logic (implementation). The proxy forwards calls to the implementation using delegatecall, executing the logic in the proxy's storage context. This enables upgrading the logic without losing stored data or changing the contract address. **Q: What are the differences between transparent, UUPS, and beacon proxies?** A: Transparent proxies route admin calls (like upgrades) differently from user calls, adding gas overhead. UUPS proxies move the upgrade function into the implementation, reducing gas costs but requiring the implementation to include upgrade logic. Beacon proxies let multiple proxies share a single implementation pointer, enabling atomic upgrades of many contracts at once. **Q: What security risks do proxy patterns introduce?** A: Key risks include storage layout collisions between proxy and implementation, uninitialized implementation contracts that attackers can take over, function selector clashes, and incorrect upgrade authorization. These issues require careful auditing and invariant testing to catch. URL: https://getrecon.xyz/learn/proxy-pattern --- #### Time-Weighted Average Price (TWAP) Time-weighted average price oracles track cumulative price data over time and compute an average, making them resistant to instantaneous manipulation. Uniswap V3 introduced a highly efficient TWAP mechanism using stored price observations that protocols can query for any historical window. The choice of TWAP window length involves a trade-off: longer windows provide stronger manipulation resistance but slower response to legitimate price changes, while shorter windows are more responsive but easier to manipulate through sustained trading. TWAPs are not immune to all attacks — a well-funded attacker can manipulate prices over multiple blocks — so they are often combined with other oracle solutions and sanity checks for defense in depth. **Q: What is a TWAP oracle?** A: A TWAP (Time-Weighted Average Price) oracle computes the average price of an asset over a time window rather than using the instantaneous spot price. This smoothing effect makes it much harder for an attacker to manipulate the reported price with a single large trade or flash loan. **Q: How does the TWAP window length affect security?** A: Longer TWAP windows (e.g., 30 minutes) provide stronger manipulation resistance because an attacker would need to sustain distorted prices for the entire window. However, longer windows also mean the oracle is slower to reflect legitimate price changes, which can be dangerous during volatile markets. **Q: Are TWAP oracles fully manipulation-resistant?** A: No. While TWAPs resist single-block manipulation, a well-capitalized attacker can distort prices over multiple consecutive blocks, especially in low-liquidity pools. Protocols should combine TWAPs with additional safeguards like deviation checks, circuit breakers, and decentralized oracle networks. URL: https://getrecon.xyz/learn/time-weighted-average-price --- #### Liquidation Mechanism Liquidation mechanisms are a core safety feature of DeFi lending protocols. When a borrower's collateral value drops below a required threshold (the health factor falls below 1), liquidators can repay a portion of the debt and seize the borrower's collateral at a discount (the liquidation bonus). This incentivizes timely liquidation and protects the protocol from accumulating bad debt. However, cascading liquidations — where one liquidation triggers further price drops causing more liquidations — can destabilize markets. Invariant testing is critical for verifying liquidation correctness: properties like 'the protocol never accumulates bad debt,' 'liquidation only occurs when health factor is below threshold,' and 'liquidation bonus is always applied correctly' can be checked across millions of scenarios. **Q: How does liquidation work in DeFi lending?** A: When a borrower's collateral value drops below the required ratio (health factor below 1), anyone can act as a liquidator by repaying part of the borrower's debt and receiving the equivalent collateral plus a bonus (typically 5-10%). This keeps the protocol solvent by ensuring all loans remain adequately collateralized. **Q: What are cascading liquidations?** A: Cascading liquidations occur when the collateral sold during liquidation pushes asset prices down further, causing more positions to become undercollateralized and triggering additional liquidations. This feedback loop can cause rapid market crashes and was a factor in several DeFi crises. **Q: How can invariant testing verify liquidation correctness?** A: Invariant testing can assert properties like 'no bad debt accumulates,' 'only undercollateralized positions are liquidatable,' and 'liquidation bonus is correctly calculated.' Fuzzers then test these across millions of random price movements, borrow/repay sequences, and liquidation attempts to find edge cases. URL: https://getrecon.xyz/learn/liquidation-mechanism --- #### Coverage Classes In the context of smart contract auditing, coverage classes provide a systematic way to decompose code into reviewable units. By treating each branching condition (if statements, require checks, ternary operators) as a binary variable, you can enumerate all possible paths through a function. After deduplication — removing infeasible combinations and collapsing paths that cover the same code — you get a finite set of meaningfully distinct execution paths. These are extended with semantic classes (truncation boundaries, overflow boundaries, reentrancy points) to capture bug-relevant behavior that pure path coverage misses. Formal methods like [symbolic execution](/learn/symbolic-execution) can extract coverage classes automatically from source code, enabling systematic and reproducible code review. **Q: What are coverage classes in smart contract security?** A: Coverage classes are the enumerated set of all meaningfully distinct execution paths through a smart contract. They are derived by analyzing every branching condition in the code and identifying which unique combinations of branches lead to different code being executed. This allows auditors to systematically review every path rather than relying on intuition. **Q: How are coverage classes different from code coverage?** A: Code coverage measures what percentage of lines or branches were hit during testing. Coverage classes go further: they enumerate every possible path and categorize them by type — non-reverting paths (normal execution), assertion-breaking paths (bugs), and revert paths (defensive checks). This gives auditors a complete map of the code rather than just a percentage. **Q: What are semantic classes and how do they relate to coverage classes?** A: Semantic classes extend coverage classes with dimensions that capture specific bug categories. For example, a division operation creates a truncation boundary — the code behaves differently when the result truncates versus divides evenly. Similarly, external calls create reentrancy boundaries. These semantic dimensions split coverage classes into finer-grained units that are more useful for finding real vulnerabilities. URL: https://getrecon.xyz/learn/coverage-classes --- #### AI Auditing AI auditing in the smart contract context goes beyond running a language model over source code. Effective AI auditing combines automated property generation, coverage-guided fuzzing, and semantic analysis to systematically test every execution path through a protocol. The AI generates invariant properties specific to the target codebase, identifies coverage gaps, and enriches the test suite with semantic dimensions like truncation boundaries and reentrancy surfaces. The key differentiator from traditional automated tools is that AI auditing produces executable, verifiable artifacts — test suites that can be run, measured, and extended — rather than probabilistic text-based opinions about code safety. **Q: What is AI auditing for smart contracts?** A: AI auditing for smart contracts uses artificial intelligence to automate security analysis. This includes generating invariant properties, identifying coverage gaps, and running fuzzing campaigns. Unlike simple AI [code review](/services/smart-contract-audit), effective AI auditing produces executable test suites that provide measurable, reproducible security assurance. **Q: Is AI auditing better than manual auditing?** A: AI auditing and manual auditing are complementary. AI excels at systematic enumeration — generating properties, measuring coverage, and testing millions of transaction sequences. Human auditors excel at judgment — evaluating whether edge case behavior is intended, assessing economic attack viability, and reviewing business logic assumptions. The best results come from combining both. **Q: How does Recon use AI in smart contract audits?** A: Recon uses AI to automatically generate invariant properties for target protocols, identify untested coverage classes, and enrich test suites with semantic analysis (truncation, overflow, and reentrancy boundaries). The AI produces a complete Chimera-based test suite that is then run as a full stateful fuzzing campaign and reviewed by human auditors. URL: https://getrecon.xyz/learn/ai-auditing --- #### Automated Auditing Automated auditing covers all non-manual approaches to smart contract security, from basic static analysis tools like Slither to advanced systems that combine AI-powered property generation with coverage-guided fuzzing. The evolution of automated auditing has progressed through several stages: pattern-matching linters, [symbolic execution](/learn/symbolic-execution) engines, coverage-guided fuzzers, and now AI-driven systems that generate protocol-specific invariant tests. Modern automated auditing does not replace manual review but dramatically increases its effectiveness by ensuring systematic coverage of all code paths and providing executable test suites as audit deliverables. **Q: What is automated smart contract auditing?** A: Automated smart contract auditing uses tools like static analyzers, fuzzers, and AI systems to find vulnerabilities systematically. It ranges from basic pattern matching (Slither) to advanced approaches that generate custom invariant tests and run millions of fuzzing iterations. Automated auditing complements manual review by covering more code paths with greater consistency. **Q: Can automated auditing replace manual code review?** A: Not entirely. Automated tools excel at systematic coverage — testing every branch, every rounding boundary, every reentrancy surface. But they can't evaluate business logic correctness, assess economic attack viability, or judge whether observed behavior matches protocol intentions. The best audits combine automated coverage with expert manual [code review](/services/smart-contract-audit). **Q: What tools are used for automated smart contract auditing?** A: Common tools include Slither (static analysis), Echidna and Medusa (fuzzing), Foundry (testing and fuzzing), [Halmos](/learn/halmos) (symbolic execution), and AI-powered systems like Recon Magic that generate protocol-specific invariant properties. Advanced automated auditing chains these tools together for comprehensive coverage. URL: https://getrecon.xyz/learn/automated-auditing --- #### Foundry Foundry is a Solidity-first toolkit built in Rust by Paradigm, designed for speed and developer ergonomics. Its core binary, forge, handles compilation, testing, and fuzzing. Running `forge test` executes unit and fuzz tests written in Solidity, while `forge fuzz` drives property-based campaigns that generate random inputs to break assertions. Foundry's built-in fuzzer isn't as battle-tested as dedicated tools like [Echidna](/learn/echidna) or [Medusa](/learn/medusa), but it's the fastest way to get started with [fuzz testing](/learn/fuzzing). Recon's [Chimera framework](/learn/chimera-framework) bridges Foundry with Echidna and Medusa, so you can write invariant tests once and run them across all three fuzzers without rewriting anything. Foundry also ships with cast for RPC interaction and anvil for running a local node, making it a complete development environment. **Q: What is Foundry?** A: Foundry is a Rust-based Solidity development framework by Paradigm. It includes forge for compiling and testing contracts, plus cast and anvil for RPC interaction and local nodes. It's known for fast compile times and native [fuzz testing](/learn/fuzzing) support directly in Solidity. **Q: Can Foundry do invariant testing?** A: Yes. Foundry supports invariant testing through its forge fuzzer, which can call random sequences of functions and check that defined properties hold. For more advanced invariant campaigns, teams pair Foundry with [Echidna](/learn/echidna) or [Medusa](/learn/medusa) using the [Chimera framework](/learn/chimera-framework), which lets you run the same tests on all three fuzzers. URL: https://getrecon.xyz/learn/foundry --- #### Solidity Solidity is a statically typed, contract-oriented language that compiles to EVM bytecode. It's been through major evolution since its early 0.4.x days. Pre-0.8.0 versions required SafeMath libraries to prevent [integer overflow](/learn/integer-overflow) because arithmetic silently wrapped on overflow. Solidity 0.8.0 introduced built-in overflow checks and custom errors. Each compiler version carries its own set of quirks and known bugs. The Solidity team maintains a public list of compiler bugs that auditors should check against the target version. Writing secure Solidity goes beyond language features: it requires understanding [EVM](/learn/evm) execution semantics and storage layout trade-offs. For a thorough review of your Solidity code, see Recon's [Solidity security audit services](/services/solidity-security-audit) and the [smart contract security](/learn/smart-contract-security) guide. **Q: What is Solidity?** A: Solidity is the most widely used programming language for writing smart contracts on Ethereum and EVM-compatible chains. It's statically typed with syntax similar to JavaScript and C++. Contracts written in Solidity compile to EVM bytecode and run on-chain. **Q: Which Solidity version should I use?** A: Use 0.8.x for new projects — it includes built-in overflow protection and custom errors for cheaper reverts. Avoid pre-0.8 versions unless you're maintaining legacy code, and always pin a specific compiler version (e.g., 0.8.24) rather than using floating pragmas. Check the Solidity compiler bug list for known issues in your chosen version. URL: https://getrecon.xyz/learn/solidity --- #### DeFi DeFi protocols replace banks and brokers with permissionless smart contracts that anyone can interact with. The ecosystem spans lending platforms (Aave, Compound), decentralized exchanges (Uniswap, Curve), staking protocols, and yield vaults. DeFi's power comes from composability — protocols can plug into each other like building blocks. But that composability also creates systemic risk. A bug in one protocol can cascade across every protocol that integrates with it. Flash loans let attackers borrow billions in a single transaction, amplifying exploit impact. This is why DeFi protocols need rigorous security: [audits](/services/defi-security-audit), [invariant testing](/learn/invariant-testing), and [fuzzing](/learn/fuzzing) are all part of a mature security posture. See our [smart contract security](/learn/smart-contract-security) guide for the full picture. **Q: What is DeFi?** A: DeFi stands for decentralized finance. It's the collection of financial applications running on blockchains that let users lend, borrow, and trade without relying on banks or brokers. Everything runs through smart contracts, which means the code IS the financial system. **Q: Why does DeFi need security audits?** A: DeFi protocols hold billions in user funds and operate without a safety net — there's no bank to reverse a fraudulent transaction. Smart contract bugs can drain entire protocols in minutes. Composability means one protocol's vulnerability can affect dozens of others. Rigorous [audits](/services/defi-security-audit) and [invariant testing](/learn/invariant-testing) are the frontline defense against fund loss. URL: https://getrecon.xyz/learn/defi --- #### Lending Protocol Lending protocols like Aave and Compound form the backbone of DeFi. Users deposit collateral and borrow against it, earning interest from borrowers. Smart contracts manage the whole process without intermediaries. These protocols must maintain strict solvency invariants: total collateral value must always exceed total debt, and [liquidations](/learn/liquidation-mechanism) must fire before positions go underwater. The attack surface is wide. [Oracle manipulation](/learn/oracle-manipulation) can distort collateral valuations, and rounding errors in interest accrual can leak value over time. Flash loan attacks can also exploit liquidation logic. Testing lending protocols requires verifying solvency properties across millions of borrow, repay, and price-change sequences. That's exactly what [invariant testing](/learn/invariant-testing) does best. Recon's [DeFi security audit services](/services/defi-security-audit) cover lending-specific threat models including bad debt accumulation and interest rate manipulation. **Q: What is a lending protocol?** A: A lending protocol is a DeFi application where users deposit crypto assets to earn interest, and borrowers take loans by posting collateral. Smart contracts handle deposits, withdrawals, and liquidations automatically. Aave and Compound are the most widely used examples. **Q: What security risks do lending protocols face?** A: Lending protocols face [oracle manipulation](/learn/oracle-manipulation) that distorts collateral prices and bad debt from failed or delayed liquidations. Flash loan exploits can bypass solvency checks, and rounding bugs leak value over time. [Invariant testing](/learn/invariant-testing) can verify that solvency properties hold across all possible transaction sequences. URL: https://getrecon.xyz/learn/lending-protocol --- #### Vault Security Vaults are one of DeFi's most common building blocks. Users deposit tokens and receive shares representing their proportional claim on the vault's assets. The ERC-4626 standard defines a common interface, but getting the math right is surprisingly hard. Share inflation attacks exploit the first-depositor problem. An attacker can donate assets directly to the vault to inflate the share price, causing subsequent depositors to receive zero shares due to rounding. Rounding direction matters for every conversion between shares and assets. Deposits should round down (fewer shares), and withdrawals should round up (more assets required). Custom vaults that don't follow ERC-4626 often have even more accounting pitfalls. [Invariant testing](/learn/invariant-testing) is the strongest defense — properties like 'total shares times price per share always equals total assets' catch rounding and inflation bugs that unit tests miss. For a deeper dive, see [smart contract security](/learn/smart-contract-security) and Recon's approach to testing vault implementations. **Q: What is vault security?** A: Vault security is the discipline of protecting token vaults — contracts where users deposit assets and receive shares — from accounting bugs, share manipulation, and rounding exploits. It applies to ERC-4626 vaults, yield aggregators, and any contract that manages pooled deposits. **Q: What are common vault vulnerabilities?** A: The most common vault vulnerabilities are share inflation attacks (first-depositor manipulation) and incorrect rounding direction on deposit and withdrawal conversions. Donation attacks can skew the share-to-asset ratio, and accounting drift between tracked and actual token balances is another risk. [Invariant testing](/learn/invariant-testing) can catch these by verifying that share accounting stays consistent across all deposit and withdraw sequences. URL: https://getrecon.xyz/learn/vault-security --- #### Staking Security Staking protocols sit at the intersection of consensus-layer economics and DeFi composability, which makes their attack surface unusually wide. Slashing conditions must be enforced correctly or validators can be penalized for bugs rather than misbehavior. Withdrawal delays need careful handling — if the queue logic has an edge case, users can get stuck or, worse, extract more than they're owed. Liquid staking tokens (LSTs) introduce depeg risk: if the redemption mechanism breaks or gets gamed, the LST can trade below its backing value, cascading through every DeFi protocol that accepts it as collateral. Restaking layers like EigenLayer add another dimension by letting staked assets secure multiple services simultaneously, multiplying slashing exposure. [Invariant testing](/learn/invariant-testing) is especially useful here because it can simulate thousands of stake, unstake, slash, and withdrawal sequences to verify that accounting stays correct under adversarial conditions. For a broader look at DeFi-specific threats, see [smart contract security](/learn/smart-contract-security) and Recon's [DeFi security audit services](/services/defi-security-audit). **Q: What are the security risks of staking protocols?** A: The biggest risks are slashing bugs that penalize honest validators, withdrawal queue exploits that let attackers drain more than they deposited, LST depeg from broken redemption logic, and validator set manipulation that concentrates power. Restaking adds compounding slashing exposure across multiple services. **Q: How do you audit a staking protocol?** A: We start with manual review of slashing conditions, withdrawal flows, and validator management. Then we build [invariant tests](/learn/invariant-testing) that verify properties like 'total staked always equals sum of individual stakes minus slashed amounts' and 'no user can withdraw more than their pro-rata share.' Long-duration [fuzzing](/learn/fuzzing) campaigns stress-test these under extreme scenarios. URL: https://getrecon.xyz/learn/staking-security --- #### Governance Security On-chain governance lets token holders vote on protocol changes, but the mechanisms that enable decentralized decision-making also create attack surface. Flash loan voting is the most dramatic example: an attacker borrows millions of governance tokens in a single transaction, votes to pass a malicious proposal, and returns the tokens — all in one block. Proposal manipulation can sneak harmful code into what looks like a routine parameter change. Timelock bypasses let attackers skip the delay that's supposed to give the community time to react. Quorum gaming exploits low participation by passing proposals when most token holders aren't paying attention. Effective governance testing verifies that voting power can't be inflated within a single block, that proposal execution respects timelock delays, and that quorum thresholds can't be circumvented. [Invariant testing](/learn/invariant-testing) can check these properties across millions of vote, propose, and execute sequences. For related patterns, see [smart contract security](/learn/smart-contract-security) and [access control](/learn/access-control). **Q: What are common governance attacks?** A: The most common attacks are flash loan voting (borrowing tokens to pass a malicious proposal in one transaction), proposal manipulation (hiding harmful code in routine upgrades), timelock bypasses that skip the safety delay, and quorum gaming that exploits low voter turnout to pass proposals that wouldn't survive full participation. **Q: How do you test governance contracts?** A: We write [invariant tests](/learn/invariant-testing) that verify properties like 'voting power can't change within a single block,' 'proposals can't execute before the timelock expires,' and 'quorum can't be reached with flash-borrowed tokens.' The fuzzer then runs millions of propose-vote-execute sequences with different actors and timing to break these properties. URL: https://getrecon.xyz/learn/governance-security --- #### ERC-6909 ERC-6909 replaces the complexity of [ERC-1155](/learn/erc-1155) with a streamlined multi-token interface. Each contract manages multiple token IDs with per-ID balances, per-ID allowances, and a separate operator approval mechanism. Uniswap v4 adopted ERC-6909 for its pool accounting, and the standard is gaining traction across DeFi. The dual-approval model and shared-state design create subtle bug classes around cross-ID isolation, operator scope creep, and supply conservation that [fuzzing](/learn/fuzzing) is uniquely suited to catch. For a full walkthrough of the invariants and property design, see [How to Fuzz ERC-6909 Multi-Token Contracts](/blog/how-to-fuzz-erc6909-multi-token). **Q: What is ERC-6909?** A: ERC-6909 is an Ethereum token standard that lets a single contract manage multiple token IDs with per-ID balances and allowances. It's designed as a simpler, more gas-efficient replacement for ERC-1155, and has been adopted by protocols like Uniswap v4 for multi-token accounting. **Q: How do you fuzz ERC-6909?** A: Fuzzing ERC-6909 means writing invariants for supply conservation (mints minus burns equals total supply per ID), cross-ID isolation (operations on token ID A can't affect token ID B's balance), and operator scope (an operator can transfer any ID, but a per-ID approval only covers that specific ID). See the [ERC-6909 fuzzing guide](/blog/how-to-fuzz-erc6909-multi-token) for the full property suite. URL: https://getrecon.xyz/learn/erc-6909 --- #### ERC-7540 ERC-7540 adds async request-fulfill lifecycles to the [ERC-4626](/learn/erc-4626) vault standard. Instead of instant deposits and withdrawals, users submit requests that operators fulfill later. This is critical for real-world asset vaults, cross-chain vaults, and any system where liquidity isn't immediately available. The async model introduces new bug classes around request state transitions, claim ordering, and share accounting across pending and fulfilled states. [Fuzzing](/learn/fuzzing) is essential because the bugs live in sequences of requests, fulfillments, and cancellations that no developer would test manually. For the full invariant breakdown, see [How to Fuzz ERC-7540 Async Vaults](/blog/how-to-fuzz-erc7540-async-vaults). **Q: What is ERC-7540?** A: ERC-7540 is an Ethereum standard that extends ERC-4626 vaults with asynchronous deposit and redemption flows. Users submit requests that get fulfilled later by operators, making it suitable for real-world asset vaults and cross-chain systems where instant settlement isn't possible. **Q: How do you test async vault security?** A: Testing ERC-7540 vaults means writing invariants for request lifecycle correctness (requests can't be fulfilled twice or skipped), claim ordering, share accounting across pending and fulfilled states, and the consistency between preview functions and actual execution. See the [ERC-7540 fuzzing guide](/blog/how-to-fuzz-erc7540-async-vaults) for the complete approach. URL: https://getrecon.xyz/learn/erc-7540 --- #### ERC-4337 ERC-4337 brings account abstraction to Ethereum without protocol-level changes. Smart contract wallets (smart accounts) validate their own user operations through an EntryPoint contract, with bundlers submitting operations from an off-chain mempool. Paymasters can sponsor gas on behalf of users. This architecture introduces bug classes around authorization bypass, gas accounting manipulation, nonce replay, and paymaster insolvency that are fundamentally different from DeFi token bugs. [Fuzzing](/learn/fuzzing) targets the validation and execution paths that traditional testing misses. For the complete invariant suite and property design, see [How to Fuzz ERC-4337 Account Abstraction Wallets](/blog/how-to-fuzz-erc4337-account-abstraction). **Q: What is ERC-4337?** A: ERC-4337 is Ethereum's account abstraction standard. It lets smart contract wallets validate and execute their own transactions through an EntryPoint contract, replacing the traditional externally-owned account (EOA) model. This enables features like social recovery, gas sponsorship through paymasters, and batched operations. **Q: How do you fuzz account abstraction wallets?** A: Fuzzing ERC-4337 wallets means writing properties for validation correctness (only authorized signers pass validateUserOp), gas accounting (actual cost never exceeds maxGasCost), nonce monotonicity (no gaps or replays), and paymaster solvency. See the [ERC-4337 fuzzing guide](/blog/how-to-fuzz-erc4337-account-abstraction) for the full approach. URL: https://getrecon.xyz/learn/erc-4337 --- #### ERC-2535 ERC-2535 defines the Diamond proxy pattern, the most structurally complex upgradeable contract architecture in Solidity. A single proxy maintains a mapping from function selectors to facet addresses, and the diamondCut() function modifies this mapping to add, replace, or remove facets. Four loupe functions provide read access to the current routing configuration. This design creates bug classes around selector collisions, storage slot corruption across facets, upgrade atomicity violations, and dangling selectors after facet removal. [Fuzzing](/learn/fuzzing) is critical because these bugs emerge from sequences of upgrades, not individual operations. For the full invariant breakdown, see [How to Fuzz ERC-2535 Diamond Proxies](/blog/how-to-fuzz-erc2535-diamond-proxy). **Q: What is ERC-2535?** A: ERC-2535, also known as the Diamond standard, is a Solidity proxy pattern that delegates function calls to multiple implementation contracts called facets. Each facet handles a different set of function selectors, and the diamond can be upgraded by adding, replacing, or removing facets through the diamondCut() function. **Q: How do you test diamond proxy upgrades?** A: Testing diamond proxies means writing invariants for selector uniqueness (no two facets claim the same selector), loupe/routing consistency (loupe functions reflect the actual routing table), storage isolation (facet A's writes don't corrupt facet B's state), and upgrade atomicity (multi-facet upgrades are all-or-nothing). See the [ERC-2535 fuzzing guide](/blog/how-to-fuzz-erc2535-diamond-proxy) for the complete approach. URL: https://getrecon.xyz/learn/erc-2535 --- #### ERC-1155 ERC-1155 defines a multi-token interface where a single contract manages multiple token IDs that can be fungible, non-fungible, or semi-fungible. The standard includes batch transfer operations (safeBatchTransferFrom), mandatory receiver callbacks (onERC1155Received, onERC1155BatchReceived), and a single setApprovalForAll operator mechanism. Batch operations introduce atomicity requirements (all transfers succeed or all revert), and callbacks create reentrancy surface. [Fuzzing](/learn/fuzzing) catches bugs in batch atomicity, callback safety, per-ID accounting, and the interaction between fungible and non-fungible token IDs within the same contract. For the full property suite, see [How to Fuzz ERC-1155 Multi-Token Contracts](/blog/how-to-fuzz-erc1155-multi-token). **Q: What is ERC-1155?** A: ERC-1155 is an Ethereum multi-token standard that manages both fungible and non-fungible tokens in a single contract. It supports batch transfers for gas efficiency and requires receiver contracts to implement callback interfaces. It's widely used in gaming, NFT platforms, and DeFi protocols that manage multiple asset types. **Q: How do you fuzz ERC-1155 batch transfers?** A: Fuzzing ERC-1155 batch transfers means writing invariants for batch atomicity (all transfers in a batch succeed or all revert), per-ID supply conservation, callback safety (receiver hooks can't corrupt state or cause reentrancy), and balance consistency across single and batch operations. See the [ERC-1155 fuzzing guide](/blog/how-to-fuzz-erc1155-multi-token) for the full approach. URL: https://getrecon.xyz/learn/erc-1155 --- #### Certora CVL CVL lets you write rules, invariants, and ghost variables that the Certora Prover checks exhaustively against your Solidity code. Unlike [fuzzing](/learn/fuzzing), which tests with random inputs, CVL verification proves properties hold for all possible inputs within the specified bounds. Rules define expected behavior for individual functions, invariants define properties that must hold across all states, and ghosts track auxiliary state for complex specifications. CVL supports hooks for state tracking, filtered rules for efficiency, and built-in summaries for external calls. See the [Certora CVL tutorial](/blog/certora-cvl-tutorial-practitioner-guide) for a hands-on guide. **Q: What is Certora CVL?** A: Certora Verification Language (CVL) is a domain-specific language for writing formal specifications of smart contracts. You write rules and invariants in CVL, then the Certora Prover mathematically verifies they hold for all possible inputs and states. **Q: How does Certora compare to fuzzing?** A: Certora provides mathematical proof that properties hold for all inputs, while [fuzzing](/learn/fuzzing) tests with random inputs and can miss edge cases. However, Certora requires more setup, runs slower, and can struggle with complex multi-contract interactions where fuzzers excel. Most teams benefit from using both. See the [fuzzing vs formal verification comparison](/compare/fuzzing-vs-formal-verification). URL: https://getrecon.xyz/learn/certora-cvl --- #### Static Analysis Static analysis tools like Slither parse Solidity source code into an intermediate representation and run pattern-matching detectors to find known vulnerability classes. This includes reentrancy patterns, unchecked return values, shadowed variables, and gas optimizations. Static analysis is fast (seconds to run), requires no test setup, and catches low-hanging fruit early in the development cycle. However, it produces false positives and can't reason about runtime behavior or complex state interactions. It's best used as the first layer in a [security pipeline](/blog/smart-contract-security-pipeline-complete-guide), complemented by [fuzzing](/learn/fuzzing) and [formal verification](/learn/formal-verification) for deeper analysis. For advanced usage, see [writing custom Slither detectors](/blog/slither-advanced-custom-detectors). **Q: What is static analysis for smart contracts?** A: Static analysis automatically scans smart contract source code for known vulnerability patterns without running the code. Tools like Slither can detect reentrancy, unchecked calls, access control issues, and dozens of other common bugs in seconds. **Q: Is static analysis enough for smart contract security?** A: No. Static analysis catches common patterns but misses complex bugs that depend on runtime state, multi-step interactions, or protocol-specific logic. It should be the first step in your security pipeline, followed by [invariant testing](/learn/invariant-testing), [fuzzing](/learn/fuzzing), and manual review. URL: https://getrecon.xyz/learn/static-analysis --- #### AI-Guided Fuzzing AI-guided fuzzing combines traditional [fuzzing](/learn/fuzzing) techniques with LLM capabilities to speed up the testing process. LLMs can analyze smart contract code and suggest [invariant properties](/learn/invariant-testing), generate test harnesses, identify likely vulnerability patterns, and prioritize code paths for deeper testing. However, LLM-generated properties need human validation — they can hallucinate non-existent functions, miss protocol-specific constraints, or write properties that are technically correct but trivially true. The most effective approach uses AI as an accelerator with human-in-the-loop review. See our [practical guide to AI-guided fuzzing](/blog/ai-guided-fuzzing-practical-guide) and [assessment of LLM property generation](/blog/llm-property-generation-smart-contracts). **Q: What is AI-guided fuzzing?** A: AI-guided fuzzing uses large language models to assist with fuzzing workflows — generating invariant property suggestions, creating test harnesses, and identifying promising code paths. The AI accelerates human work but requires validation, since LLMs can hallucinate incorrect properties. **Q: Can AI replace human auditors for fuzzing?** A: Not yet. AI-generated properties have significant hallucination rates for complex DeFi logic. The best workflow uses AI to generate candidates that humans review and refine before running with actual fuzzers. See our [honest assessment](/blog/llm-property-generation-smart-contracts) of what works and what doesn't. URL: https://getrecon.xyz/learn/ai-guided-fuzzing --- #### Differential Testing Differential testing works by feeding the same inputs to multiple implementations and comparing their outputs. For smart contracts, this means comparing a reference implementation against an optimized version, testing across Solidity compiler versions, or comparing on-chain behavior against a local simulation. If two implementations of the same spec produce different results for the same input, at least one has a bug. This technique is especially effective for catching optimizer bugs, rounding differences, and edge cases in mathematical libraries. Combined with [fuzzing](/learn/fuzzing), differential testing generates thousands of random inputs and automatically flags any divergences. See the [differential testing guide](/blog/differential-testing-smart-contracts) for practical setup. **Q: What is differential testing?** A: Differential testing compares two implementations of the same specification by feeding them identical inputs and checking that outputs match. Any difference indicates a bug in at least one implementation. It's particularly useful for testing optimized code against a known-good reference. **Q: How is differential testing used for smart contracts?** A: In smart contracts, you can compare reference vs optimized implementations, test behavior across Solidity versions, or compare your contract against a known-correct model. Combined with [fuzzing](/learn/fuzzing), the fuzzer generates random inputs and flags any output discrepancies automatically. URL: https://getrecon.xyz/learn/differential-testing --- #### Corpus Management In [coverage-guided fuzzing](/learn/coverage-guided-fuzzing), the corpus is a set of inputs that the fuzzer has discovered trigger new code paths. Corpus management involves saving these inputs to disk, minimizing the corpus (removing redundant entries that don't add new coverage), seeding new campaigns with previous results, and sharing corpora across team members. Good corpus management dramatically improves fuzzing efficiency — a well-seeded campaign can reach 80% coverage in minutes rather than hours. Both [Echidna](/learn/echidna) and [Medusa](/learn/medusa) support corpus directories, and Recon Pro manages corpora automatically in the cloud. See the [deep dive on corpus management](/blog/coverage-guided-fuzzing-deep-dive-corpus-management) for practical strategies. **Q: What is a fuzzer corpus?** A: A fuzzer corpus is a collection of inputs that trigger different code paths in your smart contract. The fuzzer saves interesting inputs that discover new coverage, then reuses them as starting points for finding deeper bugs. Managing this corpus across campaigns is key to efficient fuzzing. **Q: Why does corpus management matter?** A: Without corpus management, every fuzzing campaign starts from scratch. With a good corpus, new campaigns immediately reach the coverage level of previous runs and spend all their time exploring new paths. This can cut time-to-coverage from hours to minutes. URL: https://getrecon.xyz/learn/corpus-management --- #### Donation Attack The donation attack (also called the first depositor attack or share inflation attack) targets vaults that calculate shares based on the ratio of total assets to total supply. An attacker deposits a tiny amount (1 wei) to get 1 share, then donates a large amount of tokens directly to the vault (bypassing the deposit function). This inflates the price per share. When a victim deposits, their deposit amount rounds down to 0 shares due to the inflated price, and the attacker withdraws everything. Mitigations include virtual shares/assets (used by OpenZeppelin), minimum deposit requirements, and dead shares at initialization. [Fuzzing](/learn/fuzzing) catches this by testing deposit-then-withdraw round-trip properties and asserting no value leaks to third parties. See the [complete ERC-4626 donation attack breakdown](/blog/donation-attack-erc4626-first-depositor). **Q: What is a donation attack in DeFi?** A: A donation attack inflates a vault's share price by sending tokens directly to the vault contract. This causes rounding errors when new users deposit, allowing the attacker to steal their funds. It mainly affects ERC-4626 vaults without virtual share protection. **Q: How do you prevent donation attacks?** A: Common mitigations include virtual shares and assets (offsetting the share math), minimum deposit requirements, dead shares at initialization, or using internal accounting that ignores direct transfers. [Invariant testing](/learn/invariant-testing) catches this by fuzzing deposit/withdraw sequences and checking that no user can profit at another's expense. URL: https://getrecon.xyz/learn/donation-attack --- #### Read-Only Reentrancy Read-only reentrancy occurs when Contract A makes an external call that triggers a callback, and during that callback, Contract B reads state from Contract A that hasn't been updated yet. Unlike classic [reentrancy](/learn/reentrancy), the attacker doesn't re-enter the vulnerable function — they call a different contract that reads inconsistent state. This was famously exploited in Curve pools where a callback during withdrawal let attackers read stale virtual prices. The attack is harder to detect because the vulnerable contract's view functions look safe in isolation — the danger only appears in the cross-contract interaction. Prevention includes updating state before external calls (checks-effects-interactions), reentrancy locks that cover view functions, and [invariant testing](/learn/invariant-testing) that checks state consistency during callbacks. See [reentrancy in 2025](/blog/reentrancy-attacks-2025-read-only-cross-function) for full coverage. **Q: What is read-only reentrancy?** A: Read-only reentrancy happens when an attacker exploits a callback to read stale state from another contract. The attacker doesn't re-enter the original function — they call a different contract that reads inconsistent data during the callback window. **Q: How is read-only reentrancy different from classic reentrancy?** A: Classic reentrancy re-enters a state-modifying function to drain funds. Read-only reentrancy reads stale state from view functions during a callback, causing other contracts to make decisions based on incorrect data. It's harder to detect because the view functions look safe individually. URL: https://getrecon.xyz/learn/read-only-reentrancy --- #### Trident Fuzzer Trident brings [invariant testing](/learn/invariant-testing) concepts from the EVM world to Solana. Built on honggfuzz, it generates random instruction sequences for Anchor programs, automatically handles account creation and signing, and checks user-defined invariants after each step. Trident understands Anchor's IDL format, so it can generate valid instructions without manual harness writing. This is a significant advantage over raw cargo-fuzz, which requires writing custom harnesses for each instruction. The framework is still younger than EVM fuzzers like [Echidna](/learn/echidna) and [Medusa](/learn/medusa), but it's the most practical option for teams that want to fuzz Solana programs with minimal setup. See the [Solana fuzzing guide](/blog/solana-smart-contract-fuzzing-guide) for a hands-on tutorial. **Q: What is Trident?** A: Trident is a fuzzing framework by Ackee Blockchain designed specifically for Solana programs. It generates random instruction sequences, handles account setup automatically, and checks invariants — similar to what Echidna and Medusa do for EVM contracts. **Q: Can you fuzz Solana programs without Trident?** A: Yes, you can use cargo-fuzz or honggfuzz-rs directly, but you'll need to write custom harnesses for account creation, signing, and instruction building. Trident automates this by reading your Anchor IDL. For non-Anchor programs, cargo-fuzz with proptest is the main alternative. URL: https://getrecon.xyz/learn/trident-fuzzer --- #### Automated Market Maker (AMM) AMMs like Uniswap, Curve, and Balancer replace order book matching with pricing functions. The constant product formula (x × y = k) is the simplest — as one asset is bought, its price increases and the other decreases, maintaining the invariant. Concentrated liquidity (Uniswap V3+) lets LPs provide liquidity within specific price ranges for capital efficiency. AMMs introduce unique security concerns: sandwich attacks (where attackers front-run and back-run trades to extract value), price manipulation through flash loans, rounding errors in LP share calculations, and fee accounting bugs. [Invariant testing](/learn/invariant-testing) is particularly effective for AMMs because the mathematical properties (constant product, no free tokens, LP share correctness) are well-defined and testable. See the [AMM invariant testing guide](/blog/amm-dex-invariant-testing-properties) for the complete property suite. **Q: What is an AMM in DeFi?** A: An automated market maker (AMM) is a decentralized exchange that uses mathematical formulas instead of order books to price trades. Liquidity providers deposit token pairs, and traders swap against the pool. The pricing formula (like x×y=k) ensures prices adjust based on supply and demand. **Q: How do you test AMM security?** A: AMMs have well-defined mathematical invariants (constant product, no free tokens, LP share accounting) that are ideal for [invariant testing](/learn/invariant-testing). You fuzz with random swap, deposit, and withdraw sequences while checking that these properties always hold. See the [AMM testing guide](/blog/amm-dex-invariant-testing-properties). URL: https://getrecon.xyz/learn/automated-market-maker --- #### ZK Circuit Fuzzing ZK circuits (written in Noir, Circom, Halo2, etc.) have a unique class of bugs that traditional smart contract tools don't catch. Under-constrained circuits accept invalid witnesses, missing range checks allow out-of-bounds values, and witness generation bugs produce proofs for incorrect computations. ZK fuzzing generates random valid and invalid inputs, checks that valid inputs produce valid proofs, invalid inputs are rejected, and the circuit's constraints match the intended computation. This is harder than smart contract [fuzzing](/learn/fuzzing) because you're testing mathematical constraints rather than state transitions. Tools are still maturing — Noir's nargo test supports some property testing, and custom harnesses can wrap circuit evaluation for use with standard fuzzers. See the [ZK circuit testing guide](/blog/zk-fuzzing-circuit-testing-noir-circom) for practical approaches. **Q: What is ZK circuit fuzzing?** A: ZK circuit fuzzing tests zero-knowledge circuits by generating random inputs and checking that constraints behave correctly — valid inputs produce valid proofs, invalid inputs are rejected, and the circuit matches its intended computation. It catches under-constrained circuits, missing range checks, and witness bugs. **Q: Why is ZK circuit testing different from smart contract testing?** A: ZK circuits define mathematical constraints rather than state transitions. Bugs are often about what the circuit doesn't constrain (under-constrained) rather than what it does wrong. You need to test both that valid inputs work AND that invalid inputs fail — a constraint that accepts everything is a critical bug. URL: https://getrecon.xyz/learn/zk-fuzzing ### Comparisons (13 analyses — full content) #### Recon vs OpenZeppelin **Recon:** Smart contract security firm specializing in [invariant testing](/learn/invariant-testing) and [fuzzing](/learn/fuzzing). Delivers reusable test suites alongside [audit reports](/services/smart-contract-audit). Strengths: Deep invariant testing expertise with proven $20M+ in saved funds; Delivers reusable test suites that continue protecting the protocol; Cloud fuzzing platform (Recon Pro) for continuous testing; Cross-fuzzer compatibility with Chimera framework; Transparent, interactive audit process with fast turnarounds **OpenZeppelin:** One of the largest [smart contract security](/learn/smart-contract-security) firms, known for their widely-used Solidity library and enterprise audit services. Strengths: Established brand with long track record; Large team of auditors; Widely-used open-source Solidity libraries; Enterprise-grade processes and compliance; Multi-chain support **Conclusion:** Recon and OpenZeppelin serve different segments. OpenZeppelin is an enterprise-grade firm with broad coverage and brand recognition. Recon offers a specialized, [invariant-testing-first](/learn/invariant-testing) approach that delivers both an audit report and a reusable test suite. For DeFi protocols that want deep [fuzzing](/learn/fuzzing) coverage and ongoing testing infrastructure, Recon provides unique value. **Q: Is Recon better than OpenZeppelin for smart contract audits?** A: They excel in different areas. Recon specializes in [invariant testing](/learn/invariant-testing) and delivers reusable test suites alongside [smart contract audits](/services/smart-contract-audit). OpenZeppelin offers broader enterprise services and brand recognition. Many protocols benefit from using both. **Q: Can I use Recon and OpenZeppelin together?** A: Yes. Many DeFi protocols get multiple audits from different firms. Recon's [invariant test suites](/learn/invariant-testing) complement traditional audits by providing ongoing, automated protection after the audit is complete. URL: https://getrecon.xyz/compare/recon-vs-openzeppelin --- #### Recon vs Cyfrin **Recon:** [Invariant testing](/learn/invariant-testing) specialists delivering [fuzzing](/learn/fuzzing)-first [security audits](/services/smart-contract-audit) with reusable test suites and cloud fuzzing infrastructure. Strengths: Invariant testing expertise with proven critical finding track record; Delivers reusable test suites alongside audit reports; Cloud fuzzing platform for continuous security testing; Open-source tooling (Chimera, Recon Extension); Direct founder access and transparent process **Cyfrin:** [Smart contract security](/learn/smart-contract-security) firm founded by Patrick Collins, offering competitive audits and security education through Cyfrin Updraft. Strengths: Strong community and educational content; Competitive audit model can increase coverage; Well-known founder with large following; Aderyn static analysis tool; Educational platform (Updraft) for developer training **Conclusion:** Recon focuses on deep, specialized [invariant testing](/learn/invariant-testing) that produces both findings and reusable test infrastructure. Cyfrin offers broader security education and competitive audits with a strong community. For protocols prioritizing [fuzzing-first security](/learn/fuzzing) and ongoing test infrastructure, Recon is the specialized choice. **Q: Should I choose Recon or Cyfrin for my audit?** A: If your priority is comprehensive [invariant testing](/learn/invariant-testing) with reusable test suites, choose Recon. If you want a competitive audit with broad auditor coverage, Cyfrin is a good option. Many protocols benefit from [multiple audits](/services/smart-contract-audit). URL: https://getrecon.xyz/compare/recon-vs-cyfrin --- #### Echidna vs Medusa **Echidna:** Haskell-based [smart contract fuzzer](/learn/fuzzing) by Trail of Bits. One of the first and most battle-tested fuzzers for Solidity. Strengths: Battle-tested with years of production use; Excellent corpus management and shrinking; Grammar-based fuzzing produces meaningful inputs; Strong integration with slither for guided testing; Well-documented with extensive examples **Medusa:** Go-based [smart contract fuzzer](/learn/fuzzing) by Trail of Bits. Designed for speed with parallel execution. Strengths: Parallel execution with goroutines for much faster fuzzing; Modern Go codebase is easier to contribute to; Faster startup and iteration time; Optimization testing mode for finding maximum/minimum values; Active development with frequent improvements **Conclusion:** Both fuzzers are excellent and maintained by Trail of Bits. [Medusa](/learn/medusa) is generally faster due to parallelism, making it ideal for CI/CD and cloud fuzzing. [Echidna](/learn/echidna) has more production history and battle-tested corpus management. Use Recon's [Chimera framework](/learn/chimera-framework) to write tests that work with both, letting you use each tool's strengths. For a deeper dive, see our [practical comparison](/blog/echidna-vs-medusa-practical-comparison). **Q: Which is better, Echidna or Medusa?** A: Neither is universally better. [Medusa](/learn/medusa) is faster due to parallel execution, while [Echidna](/learn/echidna) has more production history and refined corpus management. With Recon's [Chimera framework](/learn/chimera-framework), you can write tests once and run them with both fuzzers. **Q: Can I use both Echidna and Medusa on the same project?** A: Yes! Recon's [Chimera framework](/learn/chimera-framework) specifically enables this. Write your [invariant tests](/learn/invariant-testing) once with Chimera, then run them with [Echidna](/learn/echidna), [Medusa](/learn/medusa), or Foundry. [Recon Pro](/pro) cloud fuzzing supports both. URL: https://getrecon.xyz/compare/echidna-vs-medusa --- #### Invariant Testing vs Unit Testing **Invariant Testing:** Testing methodology that defines [properties](/learn/property-based-testing) that must always hold true and uses [fuzzing](/learn/fuzzing) to generate millions of random scenarios to verify them. Strengths: Tests millions of scenarios automatically; Finds edge cases humans would never think to test; Catches multi-step vulnerabilities across transaction sequences; Properties serve as living documentation of system guarantees; Continuously improves with longer fuzzing runs **Unit Testing:** Traditional testing methodology that checks specific input-output pairs for individual functions. Strengths: Easy to write and understand; Fast execution time; Clear pass/fail for specific scenarios; Familiar to most developers; Good for testing specific edge cases you know about **Conclusion:** Unit testing and [invariant testing](/learn/invariant-testing) are complementary, not competing. Unit tests verify specific known scenarios quickly. Invariant tests explore millions of unknown scenarios to find edge cases. For [smart contract security](/learn/smart-contract-security), both are essential. Start with unit tests for basic functionality, then add invariant tests for critical properties like solvency, accounting correctness, and [access control](/learn/access-control). Learn more about [why invariant testing matters](/blog/why-invariant-testing-matters-defi-security). **Q: Do I need invariant testing if I already have unit tests?** A: Yes. Unit tests only check scenarios you think of. [Invariant testing](/learn/invariant-testing) generates millions of random scenarios to find edge cases you'd never manually test. Many critical DeFi vulnerabilities were in code that passed extensive unit test suites. See [what unit tests miss](/blog/smart-contract-vulnerabilities-unit-tests-miss). **Q: Should I replace unit tests with invariant tests?** A: No, use both. Unit tests are great for specific, known scenarios and provide fast feedback. Invariant tests complement them by exploring the unknown. Think of unit tests as 'does this specific case work?' and [invariant tests](/learn/invariant-testing) as 'does this always work?' Recon offers [invariant testing services](/services/invariant-testing-services) to help teams get started. URL: https://getrecon.xyz/compare/invariant-testing-vs-unit-testing --- #### Fuzzing vs Formal Verification **Fuzzing:** Automated testing technique that generates random inputs and transaction sequences to find [invariant](/learn/invariant-testing) violations and bugs. Also known as [fuzz testing](/learn/fuzz-testing). Strengths: Fast to set up and start finding bugs; Excellent for stateful, multi-contract systems; Scales well to large codebases; Produces concrete, reproducible counterexamples; Great at finding arithmetic and accounting bugs **Formal Verification:** Mathematical approach that proves smart contract properties hold for all possible inputs using [symbolic execution](/learn/symbolic-execution) and constraint solving. Strengths: Provides mathematical proof of correctness; Complete coverage - explores all possible inputs; Can prove absence of certain bug classes; Definitive answers: property holds or counterexample found; Ideal for critical arithmetic and access control logic **Conclusion:** [Fuzzing](/learn/fuzzing) and [formal verification](/learn/formal-verification) are complementary techniques. Fuzzing is practical, fast, and excels at [stateful testing](/learn/stateful-fuzzing) of complex systems. Formal verification provides mathematical guarantees but is harder to scale. The best security posture uses both: fuzzing for broad coverage of stateful interactions, and formal verification for critical, isolated properties where mathematical proof is needed. **Q: Should I use fuzzing or formal verification for my smart contracts?** A: Ideally both. Start with [fuzzing](/learn/fuzzing) (faster to set up, finds bugs quickly) and add [formal verification](/learn/formal-verification) for the most critical properties. Tools like [Halmos](/learn/halmos) bridge the gap with bounded symbolic execution. Recon helps teams implement full [fuzzing coverage](/services/fuzzing-smart-contracts) and can advise on when formal verification adds value. **Q: Which finds more bugs, fuzzing or formal verification?** A: In practice, fuzzing typically finds more bugs because [property-based testing](/learn/property-based-testing) is easier to apply broadly and excels at [stateful testing](/learn/stateful-fuzzing). Formal verification can find bugs fuzzing misses by providing complete input coverage, but is harder to apply to complex systems. See [how we found real vulnerabilities with fuzzing](/blog/how-we-found-real-vulnerabilities-with-fuzzing). URL: https://getrecon.xyz/compare/fuzzing-vs-formal-verification --- #### Static Analysis vs Fuzzing **Static Analysis:** Automated code analysis technique that examines source code without executing it, using tools like Slither and Aderyn to detect common vulnerability patterns such as [reentrancy](/learn/reentrancy) and [access control](/learn/access-control) issues. Strengths: Extremely fast with near-zero setup time; Catches common patterns like reentrancy and unchecked calls; Easy CI/CD integration for every pull request; No test writing required; Broad coverage of known vulnerability classes **Fuzzing:** Dynamic testing technique that executes smart contracts with random inputs and transaction sequences to find [invariant](/learn/invariant-testing) violations and unexpected behavior. Also called [fuzz testing](/learn/fuzz-testing). Strengths: Finds unknown bugs through random exploration; Excels at stateful, multi-transaction testing; Produces concrete, reproducible counterexamples; Catches complex accounting and arithmetic bugs; Effectiveness improves with longer runs **Conclusion:** Static analysis and fuzzing are complementary techniques that belong in every [smart contract security](/learn/smart-contract-security) workflow. Static analysis is your fast, zero-effort first pass to catch low-hanging fruit. Fuzzing goes deeper, finding [stateful bugs](/learn/stateful-fuzzing) and complex interactions that static analysis can't detect. Use both: Slither in CI for quick feedback, and fuzzers like [Echidna](/learn/echidna) and [Medusa](/learn/medusa) for thorough [invariant testing](/learn/invariant-testing). **Q: Should I use static analysis or fuzzing?** A: Use both. Static analysis (Slither, Aderyn) takes minutes to set up and catches common patterns. [Fuzzing](/learn/fuzzing) requires more effort but finds deeper, [stateful bugs](/learn/stateful-fuzzing). They cover different vulnerability classes and complement each other well. **Q: Can static analysis replace fuzzing?** A: No. Static analysis only detects known patterns and can't find stateful bugs, accounting errors, or protocol-specific logic flaws. Fuzzing explores millions of transaction sequences to find bugs static analysis fundamentally can't detect. **Q: What tools should I use for each?** A: For static analysis, Slither is the industry standard. For fuzzing, [Echidna](/learn/echidna) and [Medusa](/learn/medusa) are the leading tools. Recon's [Chimera framework](/learn/chimera-framework) lets you write tests compatible with both fuzzers and Foundry, and [Recon Pro](/pro) provides cloud fuzzing infrastructure. URL: https://getrecon.xyz/compare/static-analysis-vs-fuzzing --- #### Manual Audit vs Automated Testing **Manual Audit:** Security review conducted by human experts who read, analyze, and reason about smart contract code to identify vulnerabilities. Also called a [smart contract audit](/learn/smart-contract-audit). Strengths: Deep understanding of business logic and protocol intent; Creative attack thinking and adversarial reasoning; Contextual analysis of economic and governance risks; Can identify design-level flaws and architectural issues; Ability to assess risk severity with nuance **Automated Testing:** Security testing using tools like [fuzzers](/learn/fuzzing) and [formal verification](/learn/formal-verification) to automatically explore smart contract behavior across millions of scenarios. Strengths: Exhaustive coverage across millions of input combinations; Consistent and repeatable results; Fast iteration on code changes and upgrades; Reusable test suites protect against regressions; Catches subtle arithmetic and state machine bugs **Conclusion:** The best [smart contract security](/learn/smart-contract-security) combines both manual audits and automated testing. Manual audits bring human creativity and business logic understanding, while automated testing provides exhaustive coverage and ongoing protection. Recon bridges this gap by delivering both expert manual review and complete [invariant test suites](/learn/invariant-testing) that continue protecting your protocol long after the [audit](/services/smart-contract-audit). **Q: Do I still need a manual audit if I have fuzzing?** A: Yes. Manual auditors catch design-level flaws, economic risks, and governance issues that automated tools can't reason about. [Fuzzing](/learn/fuzzing) complements manual review by providing exhaustive coverage of [stateful interactions](/learn/stateful-fuzzing) and arithmetic edge cases. **Q: Can automated testing replace manual audits?** A: Not entirely. Automated testing excels at finding implementation bugs but can't assess business logic correctness, economic incentive alignment, or architectural design. The strongest security posture combines both approaches. **Q: Does Recon offer both manual review and automated testing?** A: Yes. Recon delivers expert manual review alongside complete [invariant test suites](/learn/invariant-testing). This combined approach gives you the benefits of human expertise and the exhaustive coverage of [automated fuzzing](/services/fuzzing-smart-contracts), plus reusable tests that protect your protocol through future changes. URL: https://getrecon.xyz/compare/manual-audit-vs-automated-testing --- #### AI Audit vs Manual Audit **AI-Powered Audit:** An [AI auditing](/learn/ai-auditing) approach that uses AI to automatically generate invariant properties, enumerate [coverage classes](/learn/coverage-classes), and run large-scale [fuzzing](/learn/fuzzing) campaigns. Produces executable test suites alongside human-reviewed findings. Strengths: Systematic coverage — enumerates every execution path and semantic boundary; Generates executable test suites that continue protecting the protocol after the audit; Reproducible results — same code produces same coverage measurements; Scales to millions of test iterations across all coverage classes; Continuous protection through CI integration of generated test suites; Identifies truncation, overflow, and reentrancy boundaries automatically **Manual Audit:** Traditional security review where human auditors read, analyze, and test smart contract code line by line. Relies on auditor expertise, intuition, and experience to find vulnerabilities. See [smart contract audit](/learn/smart-contract-audit) for an overview of the process. Strengths: Can evaluate business logic correctness and design intent; Experienced auditors bring pattern recognition from hundreds of prior audits; Can reason about economic attack viability and market conditions; Better at identifying architectural design flaws and trust assumption violations; Can assess code quality, maintainability, and developer intent **Conclusion:** The strongest security comes from combining both approaches. [AI-powered auditing](/learn/ai-auditing) provides systematic, measurable coverage — every execution path enumerated, every semantic boundary tested, every property checked across millions of iterations. Manual auditing provides judgment — evaluating whether behavior is intended, assessing economic viability of attack paths, and catching design-level issues that no [automated system](/learn/automated-auditing) can reason about. At Recon, every engagement combines AI-powered property generation and [fuzzing](/learn/fuzzing) with expert manual review, delivering both an audit report and a reusable test suite. Read more about [the scientific audit approach](/blog/towards-the-scientific-audit). **Q: Should I get an AI audit or a manual audit?** A: Both. [AI auditing](/learn/ai-auditing) and manual review catch different classes of bugs. AI excels at systematic coverage, finding edge cases in complex multi-step interactions, and detecting subtle rounding or [overflow](/learn/integer-overflow) issues. Manual review excels at business logic correctness, design-level flaws, and economic attack assessment. The best audits combine both approaches. **Q: Is AI auditing just running ChatGPT on my code?** A: No. Effective [AI auditing](/learn/ai-auditing) generates executable invariant properties, runs [coverage-guided fuzzing](/learn/coverage-guided-fuzzing) campaigns, and produces measurable results. This is fundamentally different from asking an LLM to review code — it produces test suites that can be verified, reproduced, and run continuously in CI, not just text-based opinions. Learn more in our [AI smart contract audits](/blog/ai-smart-contract-audits) deep dive. **Q: How does Recon combine AI and manual auditing?** A: Recon uses AI to automatically generate protocol-specific [invariant properties](/learn/invariant-testing), identify [coverage gaps](/learn/coverage-classes), and enrich test suites with semantic analysis. These are then run as full fuzzing campaigns. Human auditors review the results, triage edge cases, evaluate business logic, and perform manual [code review](/services/smart-contract-audit). The deliverable includes both an audit report and a complete, reusable test suite. URL: https://getrecon.xyz/compare/ai-audit-vs-manual-audit --- #### Common Fuzzing vs Invariant Testing **Common Fuzzing (Stateless):** Traditional [fuzz testing](/learn/fuzz-testing) that generates random inputs for individual function calls. Each test starts from a clean state — no sequence of transactions, no accumulated state changes. Strengths: Simple to set up — just add fuzz parameters to test functions; Built into Foundry out of the box (forge test with fuzzed inputs); Fast execution — each call is independent; Good at finding input validation bugs and boundary conditions; No need to define properties or invariants upfront **Invariant Testing (Stateful):** [Invariant testing](/learn/invariant-testing) generates random sequences of function calls that build on each other. After each sequence, it checks that protocol properties (invariants) still hold — like 'the vault is always solvent' or 'total shares equal total deposits'. Strengths: Tests sequences of transactions that accumulate state changes; Catches multi-step exploits: reentrancy, flash loan attacks, accounting drift; Properties serve as living documentation of what the protocol guarantees; Finds bugs that only appear after specific sequences of operations; Scales: longer runs explore deeper state space **Conclusion:** Common fuzzing and [invariant testing](/learn/invariant-testing) are different levels of the same discipline. Common fuzzing checks 'does this function handle weird inputs?' Invariant testing checks 'does the whole system stay consistent across realistic usage?' For DeFi protocols where bugs emerge from sequences of operations (not single calls), invariant testing is essential. Start with common fuzzing for basic input validation, then graduate to invariant testing for protocol-level guarantees. See our [beginner's guide to the Chimera framework](/blog/zero-to-fuzzing-beginners-guide-chimera). **Q: Is common fuzzing enough for my smart contract?** A: For simple contracts with independent functions, common fuzzing catches a lot. But most DeFi vulnerabilities involve sequences of operations — a deposit followed by a borrow followed by a price change followed by a liquidation. Common fuzzing can't test these sequences. If your contract holds user funds or has multi-step workflows, you need [invariant testing](/learn/invariant-testing). **Q: Can I use both common fuzzing and invariant testing?** A: Yes, and you should. Common fuzzing is fast feedback on individual functions. [Invariant testing](/learn/invariant-testing) covers the interactions between them. Foundry supports both natively, and Recon's [Chimera framework](/learn/chimera-framework) makes it straightforward to set up stateful invariant campaigns alongside your existing fuzz tests. URL: https://getrecon.xyz/compare/common-fuzzing-vs-invariant-testing --- #### Foundry vs Echidna vs Medusa **Foundry (forge):** Rust-based Solidity development framework with built-in [fuzz testing](/learn/fuzz-testing). Supports both stateless fuzzing and basic invariant testing via `invariant_` test prefixes. Strengths: Built-in — no extra tooling if you already use Foundry; Fast compilation and test execution; Familiar to Solidity developers (same syntax as unit tests); Good for quick invariant checks during development; Active development with frequent improvements; Native integration with forge coverage and forge debug **Echidna + Medusa (Dedicated Fuzzers):** Purpose-built smart contract fuzzers. [Echidna](/learn/echidna) (Haskell, by Trail of Bits) pioneered the space with grammar-based fuzzing and corpus management. [Medusa](/learn/medusa) (Go, by Trail of Bits) adds parallel execution for much faster throughput. Strengths: Deep state space exploration with longer transaction sequences; Echidna: battle-tested corpus management, grammar-based inputs, Slither integration; Medusa: parallel execution (goroutines) for 4-8x faster fuzzing; Medusa: optimization testing mode for finding max/min values; Both: designed specifically for finding smart contract bugs; Better shrinking — produces minimal counterexamples **Conclusion:** Use all three. Foundry's built-in fuzzer gives you fast feedback during development. [Echidna](/learn/echidna) and [Medusa](/learn/medusa) go deeper with longer sequences, better corpus management, and (in Medusa's case) parallel execution. Recon's [Chimera framework](/learn/chimera-framework) makes this practical: write your properties once, run them with Foundry for quick checks and with Echidna/Medusa for deep campaigns. [Recon Pro](/pro) runs all three in the cloud so you don't have to manage infrastructure. See our [detailed tool comparison](/blog/smart-contract-fuzzing-tools-compared). **Q: Which fuzzer should I start with?** A: Start with Foundry if you already use it for development — add `invariant_` tests alongside your unit tests. When you need deeper coverage, add [Echidna](/learn/echidna) or [Medusa](/learn/medusa) via the [Chimera framework](/learn/chimera-framework). Chimera lets you reuse the same properties across all three tools. **Q: Is Medusa better than Echidna?** A: Neither is universally better. [Medusa](/learn/medusa) is faster due to parallel execution, making it ideal for CI/CD. [Echidna](/learn/echidna) has more battle-tested corpus management and grammar-based input generation. For critical protocols, run both — they find different bugs. Read our [practical comparison](/blog/echidna-vs-medusa-practical-comparison). **Q: Can I use Foundry tests with Echidna and Medusa?** A: Not directly — they have different test conventions. But Recon's [Chimera framework](/learn/chimera-framework) solves this: write properties in Chimera's format and they compile to targets for Foundry, Echidna, and Medusa. One codebase, three fuzzers. URL: https://getrecon.xyz/compare/foundry-vs-echidna-vs-medusa --- #### Halmos vs Echidna **Halmos:** [Halmos](/learn/halmos) is a bounded symbolic execution tool for Solidity. Instead of random inputs, it reasons about all possible inputs symbolically — proving that a property holds for every value within a bounded execution depth. Strengths: Proves properties hold for ALL inputs within bounds (not just sampled ones); Finds edge cases that random fuzzing may never reach; Produces concrete counterexamples when properties fail; Uses Foundry test syntax — familiar to Solidity developers; Excellent for arithmetic correctness (overflow, rounding, precision); Deterministic: same code always produces the same result **Echidna:** [Echidna](/learn/echidna) is a property-based [fuzzer](/learn/fuzzing) that generates random transaction sequences and checks invariants after each step. It uses grammar-based input generation and corpus management to explore the contract's state space efficiently. Strengths: Handles deep, multi-step transaction sequences; Grammar-based inputs produce realistic function calls; Corpus management learns and reuses interesting inputs; Excellent for stateful protocols (lending, vaults, governance); Battle-tested on hundreds of DeFi protocols; Slither integration for guided testing **Conclusion:** [Halmos](/learn/halmos) and [Echidna](/learn/echidna) are complementary tools that cover different dimensions of security. Halmos provides mathematical guarantees for isolated properties — 'this arithmetic is correct for all inputs up to depth N.' Echidna explores realistic usage patterns — 'does the protocol stay solvent across 1,000 random transactions?' Use Halmos for critical arithmetic (rounding, precision, overflow) and Echidna for stateful protocol behavior. At Recon, every engagement combines both via the [Chimera framework](/learn/chimera-framework) and [Recon Pro](/pro) cloud infrastructure. For a deeper guide, read [Halmos Symbolic Execution Guide](/blog/halmos-symbolic-execution-guide). **Q: Should I use Halmos or Echidna?** A: Use both. [Halmos](/learn/halmos) proves correctness for isolated arithmetic and access control logic. [Echidna](/learn/echidna) tests protocol behavior under realistic multi-step scenarios. They find different bug classes and together provide much stronger assurance than either alone. **Q: Can Halmos replace Echidna?** A: No. [Halmos](/learn/halmos) is bounded — it can't explore the deep transaction sequences that [Echidna](/learn/echidna) handles. Conversely, Echidna's random sampling may miss edge cases that Halmos can prove exhaustively. They complement each other. URL: https://getrecon.xyz/compare/halmos-vs-echidna --- #### Halmos vs Certora **Halmos:** [Halmos](/learn/halmos) is an open-source [symbolic testing](/learn/symbolic-execution) tool by a]6z that uses bounded model checking to verify Solidity properties written as Foundry-style tests. Strengths: Free and open-source — no license fees or API keys; Uses familiar Foundry test syntax (check_ prefix functions); Easy to integrate into existing Foundry projects; Works with the Chimera framework for cross-tool compatibility; Fast for bounded properties like arithmetic correctness; Active development with growing community **Certora:** Certora Prover is a commercial [formal verification](/learn/formal-verification) platform that uses CVL specifications to mathematically verify smart contract properties with unbounded reasoning. Strengths: Unbounded verification — proves properties for all possible states; Dedicated CVL language designed for expressive specifications; Ghost variables and hooks for tracking complex protocol state; Commercial support with documentation and training; Handles multi-contract verification with summaries; Mature solver infrastructure tuned for smart contracts **Conclusion:** [Halmos](/learn/halmos) and Certora serve different segments of the formal verification space. Halmos is the right choice for teams that want to add bounded symbolic checking to their existing Foundry workflow without extra cost or tooling. Certora is the right choice for protocols that need unbounded proofs and can invest in learning CVL and the licensing cost. Both complement [fuzzing](/learn/fuzzing) — Halmos integrates naturally through [Chimera](/learn/chimera-framework), while Certora requires separate spec files. For many teams, the best approach is Halmos for bounded arithmetic proofs plus [Echidna](/learn/echidna) or [Medusa](/learn/medusa) for stateful exploration, upgrading to Certora for critical properties that need unbounded guarantees. See the [Halmos tutorial](/blog/halmos-symbolic-testing-tutorial) and [Certora CVL guide](/blog/certora-cvl-tutorial-practitioner-guide) for hands-on comparisons. **Q: Should I use Halmos or Certora?** A: If you're already using Foundry and want free bounded verification, start with [Halmos](/learn/halmos). If you need unbounded mathematical proofs and have budget for commercial tooling, [Certora](/learn/certora-cvl) provides stronger guarantees. Many teams use Halmos for fast bounded checks and Certora for critical unbounded proofs. **Q: Can Halmos replace Certora?** A: Not entirely. [Halmos](/learn/halmos) is bounded — it proves properties up to a specific depth but can't guarantee they hold universally. Certora's unbounded verification provides stronger mathematical guarantees for critical properties. However, Halmos catches most practical bugs and is free to use. **Q: Which is better for DeFi protocols?** A: For most DeFi protocols, a combination works best: [Halmos](/learn/halmos) for arithmetic correctness and simple access control, [fuzzing](/learn/fuzzing) with [Echidna](/learn/echidna) or [Medusa](/learn/medusa) for stateful protocol behavior, and Certora for the handful of critical properties that need unbounded proof. URL: https://getrecon.xyz/compare/halmos-vs-certora --- #### Echidna vs Foundry Invariant Testing **Echidna:** [Echidna](/learn/echidna) is a dedicated property-based [fuzzer](/learn/fuzzing) by Trail of Bits, built in Haskell for deep [stateful fuzzing](/learn/stateful-fuzzing) of Solidity smart contracts. Strengths: Purpose-built for smart contract fuzzing with years of refinement; Excellent corpus management — saves and reuses interesting inputs; Grammar-based input generation produces realistic function calls; Multiple testing modes: property, assertion, and optimization; Strong shrinking for minimal counterexamples; Slither integration for guided coverage **Foundry Invariant Testing:** [Foundry](/learn/foundry)'s built-in invariant testing uses forge to run [invariant tests](/learn/invariant-testing) with the same syntax and toolchain developers already use for unit tests. Strengths: Same Solidity syntax and toolchain as development — zero learning curve; Handler contract pattern for structured input generation; Fast compilation and execution with forge; Integrated into the Foundry ecosystem (forge, cast, anvil); Easy to add invariant tests alongside existing unit tests; Good documentation and large community **Conclusion:** [Echidna](/learn/echidna) and Foundry's invariant testing serve similar goals with different trade-offs. Foundry is the easier starting point — same syntax, same toolchain, zero setup friction. Echidna provides deeper fuzzing with better corpus management, shrinking, and input generation, making it more effective for complex stateful protocols. The best approach is writing properties with the [Chimera framework](/learn/chimera-framework) so they run on both tools without rewrites. Start with Foundry for fast iteration, add Echidna for production-grade campaigns, and use [Medusa](/learn/medusa) for parallel execution. See the [Foundry invariant testing guide](/blog/foundry-invariant-testing-beyond-basics) and [Echidna advanced tutorial](/blog/echidna-tutorial-advanced-stateful-fuzzing) for in-depth coverage. **Q: Should I use Echidna or Foundry for invariant testing?** A: Start with [Foundry](/learn/foundry) if you're already using it — the syntax is familiar and you'll get quick results. Add [Echidna](/learn/echidna) for longer campaigns on complex protocols where corpus management and shrinking matter. With [Chimera](/learn/chimera-framework), you can run the same properties on both without any rewrites. **Q: Can I use both Echidna and Foundry invariant tests?** A: Yes, and you should. Write your properties using the [Chimera framework](/learn/chimera-framework), which generates compatible test harnesses for both tools. Foundry gives fast local feedback, while Echidna provides deeper stateful exploration with better corpus management. URL: https://getrecon.xyz/compare/echidna-vs-foundry