Part 2 β Module 8: DeFi Security
Difficulty: Advanced
Estimated reading time: ~45 minutes | Exercises: ~3 hours
π Table of Contents
DeFi-Specific Attack Patterns
- Read-Only Reentrancy
- Cross-Contract Reentrancy in DeFi Compositions
- Frontrunning and MEV
- Precision Loss and Rounding Exploits
- Access Control Vulnerabilities
- Composability Risk
Invariant Testing with Foundry
- Why Invariant Testing Is the Most Powerful DeFi Testing Tool
- Foundry Invariant Testing Setup
- Handler Contracts: The Key to Effective Invariant Testing
Reading Audit Reports
Security Tooling & Audit Preparation
π Quick Reference: Fundamentals You Already Know
DeFi protocols lost over $3.1 billion in the first half of 2025 alone. Roughly 70% of major exploits in 2024 hit contracts that had been professionally audited. The OWASP Smart Contract Top 10 (2025 edition) ranks access control as the #1 vulnerability for the second year running, followed by reentrancy, logic errors, and oracle manipulation β all patterns youβve encountered throughout Part 2.
This module focuses on the DeFi-specific attack patterns and defense methodologies that go beyond general Solidity security. You already know CEI, reentrancy guards, and access control. Here we cover: read-only reentrancy in multi-protocol contexts, the full oracle/flash-loan manipulation taxonomy, invariant testing as the primary DeFi bug-finding tool, how to read audit reports, and security tooling for protocol builders.
These patterns should be second nature. This box is a refresher, not a learning section.
Checks-Effects-Interactions (CEI): Validate β update state β make external calls. The base defense against reentrancy.
Reentrancy guards: nonReentrant modifier (OpenZeppelin or transient storage variant from Part 1). Apply to all state-changing external functions. For cross-contract reentrancy, consider a shared lock.
Access control: OpenZeppelin AccessControl (role-based) or Ownable2Step (two-step transfer). Timelock all admin operations. Use initializer modifier on upgradeable contracts. Multisig threshold should scale with TVL.
Input validation: Validate every parameter of every external/public function. Never pass user-supplied addresses to call() or delegatecall() without validation. Check for zero addresses, zero amounts.
If any of these feel unfamiliar, review Part 1 and the OpenZeppelin documentation before proceeding.
π‘ DeFi-Specific Attack Patterns
π‘ Concept: Read-Only Reentrancy
The most subtle reentrancy variant. No state modification needed β just reading at the wrong time.
The pattern: A contractβs view function reads state that is inconsistent during another contractβs external call. A lending protocol reading a poolβs getRate() during a join/exit operation gets a manipulated price because the pool has transferred tokens but hasnβt updated its accounting yet.
// Balancer pool during join (simplified):
function joinPool() external {
// 1. Transfer tokens from user to pool
token.transferFrom(msg.sender, address(this), amount);
// 2. External callback (e.g., for hooks or nested calls)
// At this point, pool has more tokens but hasn't minted BPT yet
// getRate() returns an inflated rate
// 3. Mint BPT to user
_mint(msg.sender, shares);
// 4. Update internal accounting
}
If a lending protocol calls pool.getRate() during step 2, it gets an inflated price. The attacker deposits the overpriced BPT as collateral and borrows against it.
Real-world impact: Multiple protocols have been hit by read-only reentrancy through Balancer and Curve pool interactions. The Sentiment protocol lost ~$1M in April 2023 to exactly this pattern. See also the Balancer read-only reentrancy advisory.
π Deep Dive: Read-Only Reentrancy β Numeric Walkthrough
Letβs trace exactly how the Sentiment/Balancer exploit works with concrete numbers.
Setup:
- Balancer pool: 1,000 WETH + 1,000,000 USDC (BPT total supply: 10,000)
getRate()= totalPoolValue / BPT supply = ($2M + $1M) / 10,000 = $300 per BPT- Lending protocol accepts BPT as collateral, reads
pool.getRate()for valuation - Attacker holds 100 BPT (worth $30,000 at fair rate)
Step 1: Attacker calls joinPool() to add 500 ETH ($1M) to the Balancer pool
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Inside joinPool():
β Pool receives 500 ETH from attacker via transferFrom
Pool balances now: 1,500 ETH + 1,000,000 USDC
BUT BPT not yet minted β still 10,000 BPT outstanding
β‘ Pool makes an external callback (e.g., ETH receive hook, or nested call)
βββ DURING THE CALLBACK (between β and β’) βββββββββββββββββββββββ
Pool state is INCONSISTENT:
Real pool value: (1,500 Γ $2,000) + $1,000,000 = $4,000,000
BPT supply: 10,000 (unchanged β new BPT not minted yet!)
getRate() = $4,000,000 / 10,000 = $400 per BPT β inflated 33%!
The attacker's callback:
β Deposit 100 BPT into lending protocol as collateral
β Lending protocol reads getRate() β sees $400/BPT
β Collateral valued at: 100 Γ $400 = $40,000
At 150% collateralization, attacker borrows: $40,000 / 1.5 = $26,667
Fair value of 100 BPT: 100 Γ $300 = $30,000
Fair borrowing capacity: $30,000 / 1.5 = $20,000
Excess borrowed: $26,667 - $20,000 = $6,667 stolen
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β’ Pool mints new BPT to attacker β getRate() returns to normal
BPT minted β 10,000 Γ (β1.5 - 1) β 2,247 (single-sided join penalty)
New BPT supply β 12,247 β getRate() β $4M / 12,247 β $327
(Higher than $300 because single-sided join adds value unevenly)
Step 2: Attacker walks away with $6,667 excess borrow
ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The 100 BPT collateral is worth $30,000 at fair price
but backs $26,667 in debt β protocol is under-collateralized.
If BPT price dips even slightly, the position becomes bad debt.
Scale this up 100Γ: 10,000 BPT + larger join β $666,700 stolen.
That's how Sentiment lost ~$1M.
Why nonReentrant on the lending protocol doesnβt help: The lending protocolβs deposit() isnβt being reentered β itβs called for the first time during the callback. Itβs the Balancer pool thatβs in a reentrant state. The lending protocol is just an innocent bystander reading a corrupted view function.
The fix: Before reading getRate(), verify the pool isnβt mid-transaction:
// Call a state-modifying function on Balancer Vault that reverts if locked
// manageUserBalance with empty array is a no-op but checks the reentrancy lock
IVault(balancerVault).manageUserBalance(new IVault.UserBalanceOp[](0));
// If we reach here, the vault isn't in a reentrant state β safe to read
uint256 rate = pool.getRate();
Defense:
- Never trust external
viewfunctions during your own state transitions - Check reentrancy locks on external protocols before reading their rates (Balancer V2 Vault pools have a
getPoolTokensthat reverts if the vault is in a reentrancy state β use it) - Use time-delayed or externally-sourced rates instead of live pool calculations
π‘ Concept: Cross-Contract Reentrancy in DeFi Compositions
When your protocol interacts with multiple external protocols, reentrancy can occur across trust boundaries:
Your Protocol β Aave (supply) β aToken callback β Your Protocol (read stale state)
Your Protocol β Uniswap (swap) β token transfer β receiver fallback β Your Protocol
Defense: Apply nonReentrant globally (not per-function) when your protocol makes external calls that could trigger callbacks. For protocols that interact with many external contracts, a single transient storage lock covering all entry points is the cleanest approach.
π Price Manipulation Taxonomy
This consolidates oracle attacks from Module 3 with flash loan amplification from Module 5:
Category 1: Spot price manipulation via flash loan
- Borrow β swap on DEX β manipulate price β exploit protocol reading spot price β swap back β repay
- Cost: gas only (flash loan is free if profitable)
- Defense: never use DEX spot prices, use Chainlink or TWAP
- Real example: Polter Finance (2024) β flash-loaned BOO tokens, drained SpookySwap pools, deposited minimal BOO valued at $1.37 trillion
Category 2: TWAP manipulation
- Sustain price manipulation across the TWAP window
- Cost: capital Γ time (expensive for deep-liquidity pools with long windows)
- Defense: minimum 30-minute window, use deep-liquidity pools, multi-oracle
Category 3: Donation/balance manipulation
- Transfer tokens directly to a contract to inflate
balanceOf-based calculations - Affects: vault share prices (Module 7 inflation attack), reward calculations, any logic using
balanceOf - Defense: internal accounting, virtual shares/assets
Category 4: ERC-4626 exchange rate manipulation
- Inflate vault token exchange rate, use overvalued vault tokens as collateral
- Venus Protocol lost 86 WETH in February 2025 to exactly this attack
- Resupply protocol exploited via the same vector in 2025
- Defense: time-weighted exchange rates, external oracles for vault tokens, rate caps, virtual shares
Category 5: Governance manipulation via flash loan
- Flash-borrow governance tokens, vote on malicious proposal, return tokens
- Defense: snapshot-based voting (power based on past block), timelocks, quorum requirements
- Most modern governance (OpenZeppelin Governor, Compound Governor Bravo) already uses snapshot voting
π Deep Dive: Flash Loan Attack P&L Walkthrough
Scenario: A lending protocol uses Uniswap V2 spot prices for collateral valuation. An attacker exploits this with a flash loan.
Setup:
- Uniswap V2 ETH/USDC pool: 1,000 ETH + 2,000,000 USDC (spot price = $2,000/ETH)
- Lending protocol: 500,000 USDC available to borrow, requires 150% collateralization
- Attacker starts with: 0 capital (uses flash loan)
The key insight: The attacker needs to inflate the ETH price on Uniswap, so they buy ETH with USDC. Flash-borrowing USDC and swapping it into the pool pushes the ETH/USDC ratio up.
Step 1: Flash borrow 1,500,000 USDC from Balancer (0 fee)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Attacker: 1,500,000 USDC (borrowed) β
β Cost so far: 0 (flash loan is free if repaid) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
Step 2: Swap 1,500,000 USDC β ETH on Uniswap V2
βββββββββββββββββββββββββββββββββββββββββββββββββ
Pool before: 1,000 ETH / 2,000,000 USDC (k = 2,000,000,000)
New USDC in pool: 2,000,000 + 1,500,000 = 3,500,000
New ETH in pool: 2,000,000,000 / 3,500,000 = 571 ETH (k preserved)
ETH received: 1,000 - 571 = 429 ETH
New spot price: 3,500,000 / 571 = $6,130/ETH β inflated 3Γ!
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Attacker: 429 ETH β
β Uniswap spot: $6,130/ETH (was $2,000) β
β Real market price: still ~$2,000/ETH β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
Step 3: Deposit 100 ETH as collateral into lending protocol
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Protocol reads Uniswap spot: 100 Γ $6,130 = $613,000 collateral value
At 150% collateralization: can borrow up to $613,000 / 1.5 = $408,667
Attacker borrows: 400,000 USDC
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Attacker: 329 ETH + 400,000 USDC β
β Lending position: 100 ETH collateral / 400k debtβ
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
Step 4: Swap 329 ETH β USDC on Uniswap (reverse the manipulation)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Pool before: 571 ETH / 3,500,000 USDC
New ETH in pool: 571 + 329 = 900
New USDC in pool: 2,000,000,000 / 900 = 2,222,222
USDC received: 3,500,000 - 2,222,222 = 1,277,778 USDC
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Attacker: 400,000 + 1,277,778 = 1,677,778 USDC β
β Uniswap spot recovering toward ~$2,222/ETH β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
Step 5: Repay flash loan: 1,500,000 USDC
ββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ATTACKER P&L: β
β USDC in hand: 1,677,778 β
β Flash loan repay: -1,500,000 β
β Net profit: +177,778 USDC β
β β
β Plus: 100 ETH locked as collateral, 400k debt β
β Attacker walks away β never repays the loan. β
β After price normalizes: 100 ETH = $200,000 β
β but debt = $400,000 β protocol has $200k bad debtβ
β β
β Total value extracted: ~$178k (kept) + ~$200k β
β (bad debt absorbed by protocol/depositors) β
β Attacker cost: gas only β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
Why this works: The lending protocol trusts Uniswapβs instantaneous spot price as the truth. But spot price is just the ratio of reserves β trivially manipulable with enough capital. The attacker has unlimited capital via flash loans. The entire attack β borrow, swap, deposit, borrow, swap back, repay β executes atomically in a single transaction.
Why Chainlink prevents this: Chainlink prices come from off-chain aggregation of multiple exchanges. A swap on one Uniswap pool doesnβt affect the Chainlink price. Even TWAP oracles resist this because the manipulation must be sustained across the averaging window (expensive for deep-liquidity pools).
π‘ Concept: Frontrunning and MEV
Sandwich attacks: Attacker sees your pending swap in the mempool. They front-run (buy before you, pushing price up), your swap executes at the worse price, they back-run (sell after you, profiting from the difference).
Defense: slippage protection (amountOutMin in Uniswap swaps), private transaction submission (Flashbots Protect, MEV Blocker), deadline parameters.
Just-In-Time (JIT) liquidity: Specific to concentrated liquidity AMMs. An attacker adds concentrated liquidity right before a large swap (capturing fees) and removes it right after. Not a vulnerability per se, but reduces fees going to passive LPs.
Liquidation MEV: When a position becomes liquidatable, MEV searchers race to execute the liquidation (and capture the bonus). For protocol builders: ensure your liquidation mechanism is MEV-aware and that the bonus isnβt so large it incentivizes price manipulation to trigger liquidations.
π‘ Concept: Precision Loss and Rounding Exploits
Integer division in Solidity always truncates (rounds toward zero). In DeFi, this creates two distinct classes of vulnerability:
Class 1: Silent reward loss (truncation to zero)
When a reward pool distributes rewards proportionally, the accumulator update divides reward by total staked:
// VULNERABLE: unscaled accumulator
rewardPerTokenStored += rewardAmount / totalStaked;
// If totalStaked = 1000e18 and rewardAmount = 100 wei:
// 100 / 1000e18 = 0 β TRUNCATED! Rewards lost forever.
This isnβt a one-time bug β it compounds. Every small reward distribution that truncates is value permanently stuck in the contract. Over time, this can represent significant losses, especially for tokens with small decimal precision or high-value-per-unit tokens.
The fix β scale before dividing:
// SAFE: scaled accumulator (Synthetix StakingRewards pattern)
uint256 constant PRECISION = 1e18;
rewardPerTokenStored += rewardAmount * PRECISION / totalStaked;
// Example: 10_000 * 1e18 / 5000e18 = 1e22 / 5e21 = 2 (preserved, not truncated!)
// When calculating earned:
earned = staked[account] * (rewardPerToken - paid) / PRECISION;
// Example: 5000e18 * 2 / 1e18 = 10_000 (full reward recovered)
This is the standard pattern used by Synthetix StakingRewards, Convex, and virtually every production reward distribution contract.
Class 2: Rounding direction exploits
In share-based systems (vaults, lending), rounding direction matters:
- Deposits: round shares DOWN (give user fewer shares β protects vault)
- Withdrawals: round assets DOWN (give user fewer tokens β protects vault)
If rounding favors the user in either direction, they can extract value through repeated small operations:
Deposit 1 wei β receive 1 share (should be 0.7, rounded UP to 1)
Withdraw 1 share β receive 1 token (should be 0.7, rounded UP to 1)
Repeat 1000 times β extract ~300 wei from vault
At scale (or with low-decimal tokens like USDC with 6 decimals), this becomes significant.
The fix β always round against the user:
// ERC-4626 standard: deposit rounds shares DOWN, withdraw rounds assets DOWN
function convertToShares(uint256 assets) public view returns (uint256) {
return assets * totalSupply() / totalAssets(); // rounds down (fewer shares)
}
function convertToAssets(uint256 shares) public view returns (uint256) {
return shares * totalAssets() / totalSupply(); // rounds down (fewer assets)
}
For mulDiv with explicit rounding: use OpenZeppelinβs Math.mulDiv(a, b, c, Math.Rounding.Ceil) when rounding should favor the protocol.
Where precision loss appears in DeFi:
| Protocol Type | Where Truncation Hits | Impact |
|---|---|---|
| Reward pools | reward / totalStaked accumulator | Rewards silently lost |
| Vaults (ERC-4626) | Share/asset conversions | Value extraction via repeated small ops |
| Lending (Aave, Compound) | Interest index updates | Interest can be rounded away for small positions |
| AMMs | Fee collection and distribution | LP fees lost to rounding |
| CDPs (MakerDAO) | art * rate debt calculation | Dust debt that canβt be fully repaid |
Real-world examples:
- Multiple vault protocols have had audit findings for incorrect rounding direction in
deposit()/mint()/withdraw()/redeem() - Aave V3 uses
WadRayMath(1e27 scale factor) specifically to minimize precision loss in interest calculations - MakerDAOβs Vat tracks debt as
art * rate(both in RAY = 1e27) to preserve precision across stability fee accruals
π‘ Concept: Access Control Vulnerabilities
Access control is the #1 vulnerability in the OWASP Smart Contract Top 10 (2024 and 2025). Itβs devastatingly simple and the most common cause of total fund loss in DeFi.
Pattern 1: Missing initializer guard (upgradeable contracts)
Upgradeable contracts use initialize() instead of constructor() (constructors donβt run in proxy context). If initialize() can be called more than once, anyone can re-initialize and claim ownership:
// VULNERABLE: no initialization guard
function initialize(address owner_) external {
owner = owner_; // Can be called repeatedly β attacker overwrites owner
}
// SAFE: OpenZeppelin Initializable pattern
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
function initialize(address owner_) external initializer {
owner = owner_; // initializer modifier ensures this runs only once
}
Critical subtlety: Even with initializer, the implementation contract itself (not the proxy) can be initialized by anyone if you donβt call _disableInitializers() in the constructor. This is a common pattern found in multiple audits β an attacker calls initialize() directly on the implementation contract (bypassing the proxy), becomes the owner of the implementation, and then uses selfdestruct or other privileged functions to compromise the system. The Parity wallet freeze ($150M, 2017) is the most famous example: an unprotected initWallet() on the library contract allowed an attacker to take ownership and self-destruct it, permanently freezing all dependent wallets.
// PRODUCTION PATTERN: disable initializers on implementation
constructor() {
_disableInitializers(); // Prevents anyone from initializing the implementation
}
Pattern 2: Unprotected critical functions
Functions that move funds, change parameters, or pause the protocol must have access control. The pattern is simple, but forgetting it on even one function is catastrophic:
// VULNERABLE: anyone can drain the vault
function emergencyWithdraw() external {
token.transfer(owner, token.balanceOf(address(this)));
}
// SAFE: owner-only access
function emergencyWithdraw() external {
require(msg.sender == owner, "not owner");
token.transfer(owner, token.balanceOf(address(this)));
}
For protocols with multiple roles (admin, guardian, strategist), use OpenZeppelinβs AccessControl with named roles instead of simple owner checks.
Pattern 3: Missing function visibility
In older Solidity versions (< 0.8.0), functions without explicit visibility defaulted to public. Modern Solidity requires explicit visibility, but the lesson still applies: always review that internal helper functions arenβt accidentally external or public.
The OWASP Smart Contract Top 10 access control patterns:
- Unprotected
initialize()β re-initialization overwrites owner - Missing
onlyOwner/ role checks on critical functions tx.originused for authentication (phishable via intermediate contract)- Incorrect role assignment in constructor/initializer
- Missing two-step ownership transfer (single-step transfer to wrong address = permanent lockout)
Defense checklist:
- Every
initialize()usesinitializermodifier (OpenZeppelin Initializable) - Implementation contracts call
_disableInitializers()in constructor - Every fund-moving function has appropriate access control
- Ownership transfer uses two-step pattern (
Ownable2Step) - Never use
tx.originfor authentication - All roles assigned correctly in initializer, verified in tests
π‘ Concept: Composability Risk
DeFiβs composability means your protocol interacts with others in ways you canβt fully predict:
- Your vault accepts aTokens as collateral β aTokens interact with Aave β Aave interacts with Chainlink β Chainlink relies on external data providers
- A flash loan from Balancer funds an operation on your protocol that calls a Curve pool that triggers a reentrancy via a Vyper callback
Defense:
- Document every external dependency and its assumptions
- Consider what happens if any dependency fails, returns unexpected values, or is malicious
- Use interface types (not concrete contracts) and validate return values
- Implement circuit breakers that pause the protocol if unexpected conditions are detected
πΌ Job Market Context
What DeFi teams expect you to know about attack patterns:
-
βWalk me through a read-only reentrancy attack.β
Answer
- Good answer: Explains that a view function reads inconsistent state during an external callβs callback
- Great answer: Gives the Balancer BPT / Sentiment example β pool has received tokens but hasnβt minted BPT yet, so
getRate()is inflated. Mentions that the defense is checking the vaultβs reentrancy lock before reading the rate, and that this class of bug is extremely common in DeFi compositions
-
βHow would you prevent price manipulation in a lending protocol?β
Answer
- Good answer: Use Chainlink instead of spot prices, add staleness checks
- Great answer: Describes the full taxonomy β spot manipulation (flash loan + swap), TWAP manipulation (capital Γ time), donation attacks (
balanceOfinflation), ERC-4626 exchange rate attacks. Explains that defense is layered: primary oracle + TWAP fallback + rate caps + circuit breakers. Mentions that even βsafeβ oracles like Chainlink need staleness checks, L2 sequencer checks, and zero-price validation
-
βWhatβs the most underestimated attack vector in DeFi right now?β
Answer
- Strong answer: Composability risk / cross-protocol interactions. Any time your protocol reads state from another protocol, you inherit their entire attack surface. Read-only reentrancy is one example, but thereβs also governance manipulation, oracle dependency chains, and the risk of external protocol upgrades changing behavior. The defense is documenting every external dependency and its failure modes
Interview red flags:
- β Only knowing about classic reentrancy (state-modifying) but not read-only reentrancy
- β Saying βjust use Chainlinkβ without mentioning staleness checks, L2 sequencer, or multi-oracle patterns
- β Not knowing about flash-loan-amplified attacks (thinking flash loans are just for arbitrage)
Pro tip: In security-focused interviews, employers care less about memorizing every exploit and more about your systematic thinking. Show that you have a mental taxonomy of attack classes and can map any new vulnerability into it. Thatβs what separates a protocol engineer from a developer.
π― Build Exercise: Security Exploits and Defenses
Workspace: workspace/src/part2/module8/exercise1-reentrancy/ β starter files: ReentrancyAttack.sol, DefendedLending.sol, tests: ReadOnlyReentrancy.t.sol
Exercise 1: Read-only reentrancy exploit. Build a mock vault whose getSharePrice() returns an inflated value during a deposit() that makes an external callback. Build a lending protocol that reads this value. Show how an attacker can deposit during the callback to get overvalued collateral. Fix it by checking the vaultβs reentrancy state.
Workspace: workspace/src/part2/module8/exercise2-oracle/ β starter files: OracleAttack.sol, SecureLending.sol, tests: OracleManipulation.t.sol
Exercise 2: Oracle manipulation exploit. Build a vulnerable lending protocol that reads AMM spot prices. Execute a flash loan attack: flash-borrow tokens, swap on the AMM to manipulate the price, deposit collateral into the lending protocol (now overvalued), borrow against the inflated collateral, swap back to restore the price, and repay the flash loan keeping the profit. Then fix the lending protocol to use Chainlink and verify the attack fails.
Workspace: workspace/src/part2/module8/exercise3-invariant/ β starter files: BuggyVault.sol, VaultHandler.sol, tests: VaultInvariant.t.sol
Exercise 3: Invariant testing. Write a handler contract and invariant tests for BuggyVault β a share-based vault with a subtle ordering bug in withdraw(). Implement the handlerβs deposit() and withdraw() with actor management and ghost variable tracking, then write solvency and fairness invariants that automatically find the bug through random call sequences. (See the Invariant Testing section below for full details.)
Workspace: workspace/src/part2/module8/exercise4-precision-loss/ β starter files: RoundingExploit.sol, DefendedRewardPool.sol, tests: PrecisionLoss.t.sol
Exercise 4: Precision loss exploit. A reward pool distributes tokens proportionally, but uses an unscaled accumulator (reward / totalStaked). When totalStaked is large, rewards truncate to zero. Exploit this by staking a tiny amount (1 wei) when youβre the only staker to capture 100% of rewards. Then fix the pool using the Synthetix scaled-accumulator pattern (reward * 1e18 / totalStaked).
Workspace: workspace/src/part2/module8/exercise5-access-control/ β starter files: AccessControlAttack.sol, DefendedVault.sol, tests: AccessControl.t.sol
Exercise 5: Access control exploit. A vault has two bugs: initialize() can be re-called to overwrite the owner, and emergencyWithdraw() has no access control. Exploit both to drain user funds in a single transaction. Then build a defended version with initialization guards and proper owner checks.
π Key Takeaways: DeFi-Specific Attack Patterns
After this section, you should be able to:
- Explain read-only reentrancy: how
viewfunctions can return inconsistent state during callbacks, and why this is the most common βnewβ DeFi exploit pattern - Classify price manipulation attacks into the 5 categories (spot, TWAP, donation, governance, composability) and trace an attack chain that combines flash loan + manipulation + assumption violation
- Identify precision loss vulnerabilities: truncation-to-zero in reward accumulators, rounding direction exploits in share-based systems, and apply the correct rounding direction fix
- Spot access control gaps: missing initializer guards, unprotected critical functions, and explain the Wormhole-style implementation initialization attack
Check your understanding
- Read-only reentrancy: A
viewfunction reads state that is temporarily inconsistent during a callback (e.g., a Curve poolβsget_virtual_price()mid-remove_liquidity). An attacker triggers the callback, calls the view function from a lending protocol that uses it for pricing, and borrows against the inflated value. The fix is reentrancy guards on view functions or checking lock state before trusting external prices. - Price manipulation categories: Spot manipulation (flash loan to move pool price), TWAP manipulation (sustained trading to shift time-weighted average), donation attacks (inflating vault exchange rates), governance manipulation (flash-borrowing governance tokens to pass proposals), and composability exploits (chaining price assumptions across protocols).
- Precision loss vulnerabilities: Truncation-to-zero occurs when reward accumulators divide small rewards by large total supplies, giving 0 per unit. Rounding direction exploits repeatedly round in the userβs favor across many small transactions. The fix is always rounding against the user (ceiling for amounts taken, floor for amounts given) and using sufficient precision scaling.
- Access control gaps: The Wormhole attack exploited an uninitialized implementation contract behind a proxy β anyone could call
initialize()on the implementation directly, set themselves as owner, and upgrade the proxy. Always initialize implementation contracts or use_disableInitializers()in the constructor.
π‘ Invariant Testing with Foundry
π‘ Concept: Why Invariant Testing Is the Most Powerful DeFi Testing Tool
Unit tests verify specific scenarios you think of. Fuzz tests verify single functions with random inputs. Invariant tests verify that properties hold across random sequences of function calls β finding edge cases no human would think to test.
For DeFi protocols, invariants encode the fundamental properties your protocol must maintain:
- βTotal supply of shares equals sum of all balancesβ (ERC-20)
- βSum of all deposits minus withdrawals equals total assetsβ (Vault)
- βNo user can withdraw more than they deposited plus their share of yieldβ (Vault)
- βA position with health factor > 1 cannot be liquidatedβ (Lending)
- βTotal borrowed β€ total suppliedβ (Lending)
- βEvery vault has collateral ratio β₯ minimum OR is being liquidatedβ (CDP)
π§ Foundry Invariant Testing Setup
import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
contract VaultInvariantTest is StdInvariant, Test {
Vault vault;
VaultHandler handler;
function setUp() public {
vault = new Vault(address(token));
handler = new VaultHandler(vault, token);
// Tell Foundry to only call functions on the handler
targetContract(address(handler));
}
// Invariant: total shares value = total assets
function invariant_totalAssetsMatchesShares() public view {
uint256 totalShares = vault.totalSupply();
uint256 totalAssets = vault.totalAssets();
if (totalShares == 0) {
assertEq(totalAssets, 0);
} else {
uint256 totalRedeemable = vault.convertToAssets(totalShares);
assertApproxEqAbs(totalRedeemable, totalAssets, 10); // Allow small rounding
}
}
// Invariant: no individual can withdraw more than their share
function invariant_noFreeTokens() public view {
assertGe(
token.balanceOf(address(vault)),
vault.totalAssets()
);
}
}
π§ Handler Contracts: The Key to Effective Invariant Testing
The handler wraps your protocolβs functions with bounded inputs and realistic constraints:
contract VaultHandler is Test {
Vault vault;
IERC20 token;
// Ghost variables: track cumulative state for invariant checks
uint256 public ghost_totalDeposited;
uint256 public ghost_totalWithdrawn;
// Track actors
address[] public actors;
address currentActor;
modifier useActor(uint256 actorIndexSeed) {
currentActor = actors[bound(actorIndexSeed, 0, actors.length - 1)];
vm.startPrank(currentActor);
_;
vm.stopPrank();
}
function deposit(uint256 amount, uint256 actorSeed) external useActor(actorSeed) {
amount = bound(amount, 1, token.balanceOf(currentActor));
if (amount == 0) return;
token.approve(address(vault), amount);
vault.deposit(amount, currentActor);
ghost_totalDeposited += amount;
}
function withdraw(uint256 amount, uint256 actorSeed) external useActor(actorSeed) {
uint256 maxWithdraw = vault.maxWithdraw(currentActor);
amount = bound(amount, 0, maxWithdraw);
if (amount == 0) return;
vault.withdraw(amount, currentActor, currentActor);
ghost_totalWithdrawn += amount;
}
}
Ghost variables track cumulative state that isnβt stored on-chain β total deposited, total withdrawn, per-user totals. These enable invariants like βtotal deposited - total withdrawn β totalAssets (accounting for yield).β
Actor management simulates multiple users interacting with the protocol. The useActor modifier selects a random user from a pool and pranks as them.
Bounded inputs ensure the fuzzer generates realistic values (not amounts greater than the userβs balance, not zero addresses).
βοΈ Configuration
# foundry.toml
[invariant]
runs = 256 # Number of test sequences
depth = 50 # Number of calls per sequence
fail_on_revert = false # Don't fail on expected reverts
Higher depth = longer call sequences = more likely to find complex multi-step bugs. For production, use runs = 1000+ and depth = 100+.
π» Quick Try: Invariant Testing Catches a Bug
Hereβs a minimal vault with a subtle bug in withdraw(). The invariant test finds it β unit tests wouldnβt:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @notice Minimal vault with a subtle bug β can you spot it?
contract BuggyVault is ERC20("Vault", "vTKN") {
IERC20 public immutable asset;
constructor(IERC20 _asset) { asset = _asset; }
function deposit(uint256 amount) external returns (uint256 shares) {
shares = totalSupply() == 0
? amount
: amount * totalSupply() / asset.balanceOf(address(this));
asset.transferFrom(msg.sender, address(this), amount);
_mint(msg.sender, shares);
}
function withdraw(uint256 shares) external returns (uint256 amount) {
_burn(msg.sender, shares);
// BUG: totalSupply() is now REDUCED β each share redeems more than it should
amount = shares * asset.balanceOf(address(this)) / totalSupply();
asset.transfer(msg.sender, amount);
}
}
Now write a test that catches it:
// BuggyVaultInvariant.t.sol
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {Test} from "forge-std/Test.sol";
contract BuggyVaultHandler is Test {
BuggyVault vault;
MockERC20 token;
address[] public actors;
mapping(address => uint256) public ghost_deposited; // per-actor deposits
mapping(address => uint256) public ghost_withdrawn; // per-actor withdrawals
constructor(BuggyVault _vault, MockERC20 _token) {
vault = _vault;
token = _token;
for (uint256 i = 0; i < 3; i++) {
address actor = makeAddr(string(abi.encodePacked("actor", i)));
actors.push(actor);
token.mint(actor, 100_000e18);
vm.prank(actor);
token.approve(address(vault), type(uint256).max);
}
}
function deposit(uint256 amount, uint256 actorSeed) external {
address actor = actors[bound(actorSeed, 0, actors.length - 1)];
amount = bound(amount, 1e18, token.balanceOf(actor));
if (amount == 0) return;
vm.prank(actor);
vault.deposit(amount);
ghost_deposited[actor] += amount;
}
function withdraw(uint256 shares, uint256 actorSeed) external {
address actor = actors[bound(actorSeed, 0, actors.length - 1)];
uint256 bal = vault.balanceOf(actor);
shares = bound(shares, 0, bal);
if (shares == 0) return;
uint256 balBefore = token.balanceOf(actor);
vm.prank(actor);
vault.withdraw(shares);
uint256 balAfter = token.balanceOf(actor);
ghost_withdrawn[actor] += (balAfter - balBefore);
}
function actorCount() external view returns (uint256) { return actors.length; }
}
contract BuggyVaultInvariantTest is StdInvariant, Test {
BuggyVault vault;
MockERC20 token;
BuggyVaultHandler handler;
function setUp() public {
token = new MockERC20();
vault = new BuggyVault(token);
handler = new BuggyVaultHandler(vault, token);
targetContract(address(handler));
}
/// @dev Fairness: no actor withdraws more than they deposited (no yield in this vault)
function invariant_noActorProfits() public view {
for (uint256 i = 0; i < handler.actorCount(); i++) {
address actor = handler.actors(i);
uint256 withdrawn = handler.ghost_withdrawn(actor);
uint256 deposited = handler.ghost_deposited(actor);
assertLe(
withdrawn,
deposited + 1e18, // allow 1 token rounding
"Fairness violated: actor withdrew more than deposited"
);
}
}
}
Run with forge test --match-contract BuggyVaultInvariantTest. The invariant_noActorProfits test will fail. Hereβs why β trace through a deposit/deposit/withdraw sequence:
Actor A deposits 100e18: shares = 100e18 (first deposit)
Actor B deposits 100e18: shares = 100e18 * 100e18 / 100e18 = 100e18
State: vault balance = 200e18, totalSupply = 200e18, A = 100e18, B = 100e18
Actor A withdraws 100 shares:
_burn(A, 100e18) β totalSupply = 100e18
amount = 100e18 * 200e18 / 100e18 = 200e18 β A drains EVERYTHING!
transfer(A, 200e18) β vault balance = 0
B has 100e18 shares backed by 0 tokens. A stole B's deposit.
The invariant catches it: A deposited 100e18 but withdrew 200e18. Since this is a no-yield vault, no actor should ever profit β withdrawn > deposited is a clear fairness violation.
Why not a conservation invariant? You might be tempted to check vault_balance == total_deposits - total_withdrawals. Thatβs a tautology β if the handler tracks actual token flows, deposits minus withdrawals always equals the balance by construction. The burn-before-calculate bug is a fairness bug (it redistributes value between users) not a conservation bug (no tokens are created or destroyed). Fairness invariants that track per-actor flows are the right tool here.
The fix: Calculate the amount before burning shares:
function withdraw(uint256 shares) external returns (uint256 amount) {
amount = shares * asset.balanceOf(address(this)) / totalSupply();
_burn(msg.sender, shares); // burn AFTER calculating amount
asset.transfer(msg.sender, amount);
}
This is exactly the kind of ordering bug that unit tests miss β youβd have to think of the exact multi-user interleaving. Invariant tests find it automatically by exploring random call sequences.
π What Invariants to Test for Each DeFi Primitive
For a vault/ERC-4626:
- Total assets β₯ sum of all shares Γ share price (no phantom assets)
- After deposit: user shares increase, vault assets increase by same amount
- After withdrawal: user shares decrease, user receives expected assets
- Share price never decreases (if no strategy losses reported)
- Rounding never favors the user
For a lending protocol:
- Total borrowed β€ total supplied
- No user can borrow without sufficient collateral
- Health factor of every position β₯ 1 OR position is flagged for liquidation
- Interest index only increases
- After liquidation: position health factor improves
For an AMM:
- k = x Γ y (constant product) holds after every swap (minus fees)
- LP token supply matches liquidity provided
- Sum of all LP claim values = total pool value
For a CDP/stablecoin:
- Every vault has collateral ratio β₯ minimum OR is being liquidated
- Total stablecoin supply = sum of all vault debt
- Stability fee index only increases
π Deep Dive: Writing Good Invariants β A Mental Model
Coming up with invariants can feel abstract. Hereβs a systematic approach:
Step 1: Ask βWhat must ALWAYS be true?β
Think about your protocol from the perspective of conservation laws:
- Conservation of value: tokens in = tokens out (no creation or destruction)
- Conservation of accounting: internal records match actual balances
- Conservation of solvency: the protocol can always meet its obligations
Step 2: Ask βWhat must NEVER happen?β
Flip it β think about what would be catastrophic:
- A user withdraws more than they deposited + earned
- Total borrowed exceeds total supplied
- A liquidation makes the protocol less solvent
- Share price goes to 0 (or infinity)
Step 3: Map actions to state transitions
For each function in your protocol, trace what changes:
deposit(amount):
BEFORE: totalAssets = X, userShares = S, totalShares = T
AFTER: totalAssets = X+amount, userShares = S+newShares, totalShares = T+newShares
INVARIANT: newShares β€ amount * T / X (rounding down protects vault)
Step 4: Add ghost variables for cumulative tracking
On-chain state only shows the current state. Ghost variables track the history:
ghost_totalDeposited += amount // in handler's deposit()
ghost_totalWithdrawn += amount // in handler's withdraw()
// Invariant: totalAssets β ghost_totalDeposited - ghost_totalWithdrawn + yieldAccrued
Step 5: Think adversarially
What if one actor calls functions in an unexpected order? What if they:
- Deposit 0? Deposit type(uint256).max?
- Withdraw immediately after depositing?
- Deposit, transfer shares to another address, both withdraw?
- Call functions during a callback?
The handlerβs bound() function handles invalid inputs, but the sequence of valid calls is where real bugs hide.
Common invariant testing pitfalls:
- Writing invariants that are too loose (always pass, catch nothing)
- Not having enough actors (single-actor tests miss multi-user edge cases)
- Not tracking ghost variables (canβt verify cumulative properties)
- Setting
depthtoo low (complex bugs need 20+ step sequences)
π― Build Exercise: Invariant Testing
Workspace: workspace/src/part2/module8/exercise3-invariant/ β starter files: BuggyVault.sol, VaultHandler.sol, tests: VaultInvariant.t.sol
Write a comprehensive invariant test suite for your SimpleLendingPool from Module 4:
-
Handler contract with:
supply(),borrow(),repay(),withdraw(),liquidate(),accrueInterest()β all with bounded inputs and actor management -
Invariants:
- Total supplied assets β₯ total borrowed
- Health factor of every borrower is either β₯ 1 or they have no borrow
- Interest indices only increase
- No user can borrow without sufficient collateral
- Sum of all user supply balances β total supply (accounting for interest)
-
Ghost variables: total deposited, total withdrawn, total borrowed, total repaid, total liquidated
-
Run with
depth = 50, runs = 500. If any invariant breaks, you have a bug β fix it and re-run.
π Key Takeaways: Invariant Testing with Foundry
After this section, you should be able to:
- Set up Foundry invariant testing:
StdInvariant,targetContract, handler contracts with bounded inputs,useActormodifier for multi-actor testing - Design handler contracts that define a realistic action space: bounded amounts, valid actor selection, and ghost variables that track cumulative state for invariant assertions
- Write invariant assertions for each protocol type: vaults (
totalAssets β₯ totalSharesscaled), lending (totalDebt β€ totalSupply), AMMs (x * y β₯ k), CDPs (debt β€ ceiling) - Explain why invariant testing finds bugs that unit tests miss: it explores sequences of actions across multiple actors, revealing multi-step exploits
Check your understanding
- Foundry invariant setup: Inherit
StdInvariant, calltargetContract()to point at your handler, and writeinvariant_prefixed functions that assert properties. The fuzzer calls random handler functions in random order fordepthsteps acrossrunsiterations. - Handler design: Handlers define the realistic action space with
bound()to constrain inputs, actor selection viauseActormodifier for multi-user scenarios, and ghost variables (cumulative counters like totalDeposited, totalWithdrawn) that track state independently from the protocol for invariant assertions. - Protocol-specific invariants: Vaults:
totalAssets >= totalShares(scaled by share price). Lending:totalDebt <= totalSupply. AMMs:x * y >= k(only increases, as fees accrue to reserves). CDPs:totalDebt <= debtCeiling. These encode the fundamental correctness properties. - Why invariant testing is superior: Unit tests verify scenarios you anticipate. Invariant tests explore random sequences of operations across multiple actors, finding multi-step exploits like βdeposit, warp time, deposit again, withdraw allβ that no human would think to test explicitly.
π‘ Reading Audit Reports
π‘ Concept: Why This Skill Matters
Audit reports are the densest source of real-world vulnerability knowledge. A single report can contain 10-20 findings, each one a potential exploit pattern you might encounter in your own code. Learning to read them efficiently β understanding severity classifications, root cause analysis, and recommended fixes β is one of the highest-ROI activities for a protocol builder.
π How to Read an Audit Report
Structure of a typical report:
- Executive summary β Protocol description, scope, methodology
- Findings β Sorted by severity: Critical, High, Medium, Low, Informational
- Each finding includes: Description, impact, root cause, proof of concept, recommendation, protocol team response
What to focus on:
- Critical and High findings β these are the exploitable bugs
- The root cause analysis β not just βwhatβ but βwhyβ it happened
- The fix recommendation β how would you have solved it?
- Informational findings β these reveal common anti-patterns and code smell
π How to Study Audit Reports Effectively
-
Read the executive summary and scope first β Understand what the protocol does and which contracts were audited. If the audit covers only core contracts but not periphery/integrations, thatβs a significant limitation. Note the Solidity version, framework, and any unusual architecture choices the auditors call out.
-
Read Critical and High findings deeply β For each one: read the description, then STOP. Before reading the impact/PoC, ask yourself: βHow would I exploit this?β Try to construct the attack mentally. Then read the auditorβs impact assessment and PoC. Compare your thinking to theirs β this builds attacker intuition.
-
Classify each finding into your mental taxonomy β Is it reentrancy? Oracle manipulation? Access control? Logic error? Rounding? Map each finding to the attack patterns from the DeFi-Specific Attack Patterns section. Over time, youβll see the same categories appear across every audit. This is the pattern recognition that makes you faster at finding bugs.
-
Read the fix, then evaluate it β Does the fix address the root cause or just the symptom? Would you have fixed it differently? Sometimes the auditorβs recommendation is a patch, but a better fix involves rearchitecting. Forming your own opinion on fixes is where you develop design judgment.
-
Track informational findings β These arenβt exploitable, but they reveal what auditors consider code smell: missing events, inconsistent naming, unused variables, gas inefficiencies. If you see the same informational finding across multiple audits (you will), itβs a pattern to avoid in your own code.
Donβt get stuck on: Reading every finding in a 50+ finding report. Focus on Critical/High first, skim Medium, read Informational titles only. A single Critical finding teaches more than ten Informational ones.
π Report 1: Aave V3 Core (OpenZeppelin, SigmaPrime)
Source code: aave-v3-core Audits: OpenZeppelin | SigmaPrime β both publicly available.
What to look for:
- How auditors analyze the interest rate model for edge cases
- Findings related to oracle integration and staleness
- Access control findings on protocol governance
- Any findings related to the aToken/debtToken accounting system
Exercise: Read the findings list. For each High/Medium finding, determine:
- Which vulnerability class does it belong to? (from the DeFi-Specific Attack Patterns taxonomy)
- Would your SimpleLendingPool from Module 4 be vulnerable to the same issue?
- If yes, how would you fix it?
π Report 2: A Smaller Protocol With Critical Findings
Recommended options (publicly available):
- Any Cyfrin audit with critical findings (search their blog for audit reports)
- Trail of Bits public audits on GitHub
- Spearbit reports β many DeFi protocol audits available
Pick one report for a protocol similar to what youβve built (lending, AMM, or vault). Read the critical findings.
Exercise: For the most critical finding:
- Reproduce the proof of concept in Foundry (even if simplified)
- Implement the fix
- Write a test that passes before the fix and fails after (regression test)
π Report 3: Immunefi Bug Bounty Writeup
Source: Immunefi Medium (search for βbug bounty writeupβ) or Immunefi Explore
Bug bounty writeups show attacker thinking β the process of discovering a vulnerability, not just the final finding. This is the perspective you need to develop.
Exercise: Read 2-3 writeups. For each:
- What was the initial observation that led to the discovery?
- How did the researcher escalate from βsuspiciousβ to βexploitableβ?
- What defense would have prevented it?
π― Build Exercise: Self-Audit
Take your SimpleLendingPool from Module 4 and apply a structured review:
-
Threat model: List all actors (supplier, borrower, liquidator, oracle, admin). For each, list what they should and shouldnβt be able to do.
-
Trust assumptions: List every external dependency (oracle, token contracts, flash loan providers). For each, describe the failure scenario.
-
Code review checklist:
- All external/public functions have appropriate access control
- CEI pattern followed everywhere (or
nonReentrantapplied) - All oracle integrations include staleness checks, zero-price checks
- No reliance on
balanceOffor critical accounting - Slippage protection on all swaps
- Return values of external calls are checked
π Key Takeaways: Reading Audit Reports
After this section, you should be able to:
- Read an audit report efficiently: structure, severity levels, what to focus on, and how to classify findings into your mental taxonomy of attack patterns
- Apply the βpause and exploitβ technique: after reading a vulnerability description, construct the attack yourself before reading the PoC β this builds attacker intuition
- Conduct a self-audit using the structured methodology: threat model, trust assumption mapping, and systematic checklist
Check your understanding
- Reading audit reports efficiently: Focus on Critical and High findings first β these are the exploitable bugs. Read the root cause analysis, not just the description. Classify each finding into your mental taxonomy (reentrancy, oracle manipulation, access control, etc.) so you recognize the pattern in your own code.
- Pause and exploit technique: After reading a vulnerability description, stop before reading the PoC. Try to construct the attack yourself: what calls, in what order, with what parameters? This builds attacker intuition and trains you to think adversarially when writing your own code.
- Self-audit methodology: Start with a threat model (who are the actors, what can they do, what are their incentives). Map trust assumptions (what external contracts do you trust, what happens if they behave unexpectedly). Then systematically walk through the checklist: CEI pattern, oracle staleness checks, no
balanceOfreliance for accounting, slippage protection, return value checks.
π‘ Security Tooling & Audit Preparation
Static Analysis Tools
Slither β Trail of Bitsβ static analyzer. Detects reentrancy, uninitialized variables, incorrect visibility, unchecked return values, and many more patterns. Run in CI/CD on every commit.
pip install slither-analyzer
slither . --json slither-report.json
Aderyn β Cyfrinβs Rust-based analyzer. Faster than Slither for large codebases, catches Solidity-specific patterns. Good complement to Slither (different detectors).
cargo install aderyn
aderyn .
Both tools produce false positives. The skill is triaging results: understanding which findings are real vulnerabilities vs. informational or stylistic.
π» Quick Try:
Save this vulnerable contract as Vulnerable.sol and run Slither on it:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient");
// Bug: external call before state update (CEI violation)
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "Transfer failed");
balances[msg.sender] -= amount;
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
pip install slither-analyzer # if not installed
slither Vulnerable.sol
Slither should flag the reentrancy in withdraw() β the external call before the state update. See how it identifies the exact vulnerability pattern? Now fix the contract (move balances[msg.sender] -= amount before the call) and re-run Slither to confirm the finding disappears. Thatβs the feedback loop: write code β analyze β fix β verify.
π Formal Verification (Awareness)
Certora Prover β Used by Aave, Compound, and other major protocols. You write properties in CVL (Certora Verification Language), and the prover mathematically verifies they hold for all possible inputs and states β not just random samples like fuzzing, but all of them.
// Certora rule example
rule depositIncreasesBalance {
env e;
uint256 amount;
uint256 balanceBefore = balanceOf(e.msg.sender);
deposit(e, amount);
uint256 balanceAfter = balanceOf(e.msg.sender);
assert balanceAfter >= balanceBefore;
}
Formal verification is expensive ($200,000+ for complex protocols) but provides the highest confidence level. For production DeFi protocols managing significant TVL, itβs increasingly expected. You donβt need to master CVL now, but understand that it exists and what it provides.
β The Security Checklist
Before any deployment:
Code-level:
- All external/public functions have appropriate access control
- CEI pattern followed everywhere (or
nonReentrantapplied) - No external calls to user-supplied addresses without validation
- All arithmetic uses checked math (Solidity β₯0.8.0) or explicit SafeMath
- Return values of external calls are checked
- No reliance on
balanceOffor critical accounting (use internal tracking) - All oracle integrations include staleness checks, zero-price checks, and L2 sequencer checks
- No spot price usage for valuations
- Slippage protection on all swaps
- ERC-4626 vaults: virtual shares or dead shares for inflation attack prevention
- Upgradeable contracts: initializer modifier, storage gap, correct proxy pattern
Testing:
- Unit tests covering all functions and edge cases
- Fuzz tests for all functions with numeric inputs
- Invariant tests encoding protocol-wide properties
- Fork tests against mainnet state
- Negative tests (things that should fail DO fail)
Operational:
- Timelock on all admin functions
- Emergency pause function
- Circuit breakers for anomalous conditions (large withdrawals, price deviations)
- Monitoring/alerting for key state changes
- Incident response plan documented
- Bug bounty program (Immunefi or similar)
π Audit Preparation
Auditors are a final validation, not a substitute for your own security work. Protocols that arrive at audit with comprehensive tests and clear documentation get significantly more value from the audit.
What to prepare:
- Complete documentation of protocol design and intended behavior
- Threat model: who are the actors? What can each actor do? What should each actor NOT be able to do?
- Test suite with coverage report
- Known issues list (things youβve identified but havenβt fixed, or accepted risks)
- Deployment plan (chain, proxy pattern, initialization sequence)
After audit:
- Fix all critical and high findings before deployment
- Re-audit significant code changes (even βminorβ fixes can introduce new vulnerabilities)
- Donβt deploy code that differs from what was audited
π‘ Concept: Building Security-First
The security mindset isnβt a checklist β itβs a way of thinking about code:
Assume hostile inputs. Every parameter is crafted to exploit your contract. Every external call returns something unexpected. Every caller has unlimited capital via flash loans.
Design for failure. What happens when the oracle goes stale? When a strategy loses money? When gas prices spike 100x? When a collateral token is blacklisted? Your protocol should degrade gracefully, not catastrophically.
Minimize trust. Every trust assumption is an attack surface. Trust in oracles β oracle manipulation. Trust in admin keys β compromised keys. Trust in external contracts β composability attacks. Document every trust assumption and ask: what happens if this assumption fails?
Simplify. The most secure protocol is the simplest one that achieves the goal. Every line of code is a potential vulnerability. MakerDAOβs Vat is ~300 lines. Uniswap V2 core is ~400 lines. Compound V3βs Comet is ~4,300 lines. Complexity is the enemy of security.
πΌ Job Market Context
What DeFi teams expect you to know about security tooling and process:
-
βWhat does your security workflow look like before deployment?β
Answer
- Good answer: Unit tests, fuzz tests, Slither, get an audit
- Great answer: Describes a layered approach β unit tests β fuzz tests β invariant tests (with handlers and ghost variables) β static analysis (Slither + Aderyn, triage false positives) β self-audit with threat model β comprehensive documentation β external audit β fix cycle β re-audit changes β bug bounty program. Mentions that the test suite and documentation quality directly affect audit ROI
-
βInvariant testing vs fuzz testing β whatβs the difference and when do you use each?β
Answer
- Good answer: Fuzz tests random inputs to one function; invariant tests random sequences of calls and check properties hold
- Great answer: Fuzz tests verify per-function behavior (
testFuzz_depositReturnsCorrectShares). Invariant tests verify protocol-wide properties across arbitrary call sequences β they find multi-step bugs like βdeposit β accrue β withdraw β accrue β deposit creates phantom assets.β The handler contract is key: it bounds inputs, manages actors, and tracks ghost state. For DeFi, invariant tests are essential because most real exploits involve multi-step interactions, not single-function edge cases
-
βHave you ever found a real bug with invariant testing?β
Answer
- This is a strong signal question. Having a concrete story (even from practice protocols) demonstrates real experience. If you havenβt yet: run invariant tests on your Module 4 and Module 6 exercises with high depth β youβll likely find rounding edge cases or state inconsistencies worth discussing
Hot topics (2025-26):
- AI-assisted auditing (LLM-powered code review as a complement to manual audit)
- Formal verification becoming more accessible (Certora, Halmos)
- Security-as-a-service platforms (continuous monitoring, not just one-time audits)
- MEV-aware protocol design as a first-class security concern
- Cross-chain bridge security (still the largest single-exploit category by dollar value)
Security career paths beyond protocol developer:
- Protocol Security Engineer β Build protocols with security as a core responsibility. Threat modeling, invariant test suites, security-aware architecture. Premium over general Solidity devs (~$180-300k+ for senior roles). Signal: invariant tests in your portfolio, security-first design decisions, audit participation.
- Smart Contract Auditor β Review other teamsβ code. Entry via audit competitions (Code4rena, Sherlock, CodeHawks) β audit firm β independent. Compensation: $200-500k+ annually for competitive auditors. Signal: competition track record, published findings.
- Security Researcher / Bug Hunter β Find vulnerabilities in deployed protocols for bounties ($10k-$10M+ for critical findings). Signal: Immunefi profile, published writeups, responsible disclosure track record.
- Security Tooling Developer β Build static analyzers, formal verification tools, monitoring systems. Companies: Trail of Bits (Slither), Certora (Prover), Cyfrin (Aderyn), Forta, OpenZeppelin. Signal: open-source contributions, research publications.
Every path requires the fundamentals covered in this module β attack pattern taxonomy, invariant testing, audit report reading, and tooling familiarity.
π― Build Exercise: Security Review
Exercise 1: Full security review. Run Slither and Aderyn on your SimpleLendingPool from Module 4 and your SimpleCDP from Module 6. Triage every finding: real vulnerability, informational, or false positive. Fix any real vulnerabilities found.
Exercise 2: Threat model. Write a threat model for your SimpleCDP from Module 6:
- Identify all actors (vault owner, liquidator, PSM arbitrageur, governance)
- For each actor, list what they should be able to do
- For each actor, list what they should NOT be able to do
- Identify the trust assumptions (oracle, governance, collateral token behavior)
- For each trust assumption, describe the failure scenario
Exercise 3: Invariant test your CDP. Apply the Invariant Testing methodology to your SimpleCDP:
- Handler with: openVault, addCollateral, generateStablecoin, repay, withdrawCollateral, liquidate, updateOraclePrice
- Invariants: every vault safe or liquidatable, total stablecoin β€ total vault debt Γ rate, debt ceiling not exceeded
- Run with high depth and runs
π Key Takeaways: Security Tooling & Audit Preparation
After this section, you should be able to:
- Run Slither and Aderyn on a project, interpret their output, and triage false positives from real issues
- Explain when formal verification (Certora Prover, CVL rules) is worth the cost and write a simple CVL rule for a critical invariant
- Walk through the deployment security checklist: code-level requirements (CEI, access control, oracle safety), testing requirements (unit + fuzz + invariant + fork), operational requirements (monitoring, incident response, bug bounty)
- Prepare a codebase for audit: what to provide auditors (documentation, threat model, known issues, test suite) and what to do with the report afterward
Check your understanding
- Slither and Aderyn: Slither (Python, Trail of Bits) detects reentrancy, uninitialized variables, incorrect visibility, and more β run it in CI on every commit. Aderyn (Rust, Cyfrin) is faster on large codebases with different detectors. Use both as complements; triage false positives by understanding why each detector flags a pattern.
- Formal verification value: Certora Prover and CVL rules are worth the cost for core invariants in high-value protocols (lending pools, bridges, vaults holding significant TVL). A simple CVL rule like βtotalSupply equals sum of all balancesβ can catch bugs that no amount of fuzzing would find. The cost is learning CVL and longer verification times.
- Deployment security checklist: Code level: CEI pattern everywhere, access control on all privileged functions, oracle staleness and zero-price checks, no
balanceOfreliance. Testing: unit + fuzz + invariant + fork tests all passing. Operational: monitoring and alerting, incident response plan, bug bounty program, upgrade/pause mechanisms. - Audit preparation: Provide auditors with architecture documentation, threat model, known issues list, and a comprehensive test suite. After the audit, fix all Critical/High findings, document accepted risks for Medium/Low, re-verify fixes donβt introduce new issues, and publish the report for transparency.
π Resources
Vulnerability references:
- OWASP Smart Contract Top 10 (2025)
- Three Sigma β 2024 most exploited DeFi vulnerabilities
- SWC Registry (Smart Contract Weakness Classification)
- Cyfrin β Reentrancy attack guide
Audit reports:
- Trail of Bits public audits
- OpenZeppelin audits
- Cyfrin audit reports
- Spearbit
- Immunefi bug bounty writeups
Testing:
- Foundry invariant testing docs
- RareSkills β Invariant testing tutorial
- Cyfrin β Fuzz testing and invariants guide
Static analysis:
Formal verification:
Practice:
Navigation: β Module 7: Vaults & Yield | Module 9: Integration Capstone β