Fuzzing runs one million random inputs against your contract. One million inputs pass. You ship. Then a user submits a specific combination your fuzzer never tried — and the protocol drains.
This is not a hypothetical. It is the class of failure that formal verification is designed to prevent. Instead of sampling from a space of possible inputs, formal verification proves, mathematically, that a property holds for every possible input at once. If the proof passes, no input exists that can break it.
Certora Prover is the leading formal verification tool for Solidity. Aave, Compound, Uniswap, Balancer, and Maker all use it to verify critical invariants before deploying contracts that hold billions of dollars. This guide explains how it works and how to use it.
Formal Verification vs. Fuzzing vs. Static Analysis
These three tools operate at different levels of the same problem.
Static analysis (Slither, Semgrep) reads your source code without executing it. It matches patterns: "this function calls an external contract before updating state — potential reentrancy." Static analysis is fast, runs in seconds, and catches well-known antipatterns. It cannot tell you whether your business logic is correct.
Fuzzing (Foundry, Echidna) executes your contract repeatedly with randomized inputs and checks whether properties hold. A fuzzer with 10,000 runs covers 10,000 points in a potentially infinite input space. It is probabilistic. A bug that only appears when amount == 1 and timestamp % 7 == 0 and user == address(0x1234...) may never be found. Fuzzers are good at finding common bugs quickly but cannot provide guarantees.
Formal verification encodes your contract's behavior as a mathematical model and uses a solver (an SMT solver, in Certora's case) to search exhaustively across all possible states and inputs. Instead of testing whether your invariant holds for 10,000 inputs, it proves it holds for all inputs simultaneously. If the prover cannot find a counterexample, none exists.
The tradeoff is cost. Writing a formal specification takes skill and time. The Certora Prover can time out on complex contracts. Loops require manual bounds. External calls require assumptions. Fuzzing is cheaper to set up and gives fast feedback; formal verification provides mathematical certainty on the properties you care about most.
The right answer is both. Use fuzzing during development for fast feedback. Use formal verification before deployment for the properties that cannot be wrong.
What Certora Prover Does
Certora Prover takes two inputs: your Solidity contract and a CVL specification file. CVL (Certora Verification Language) is a domain-specific language for expressing rules and invariants about contract behavior.
The prover compiles your contract to a mathematical representation and then checks whether your CVL rules are satisfied for every possible state the contract can reach. If a rule is violated, the prover returns a counterexample: the exact state and inputs that break it.
Key CVL constructs:
- Rules: Conditional assertions about a single function call. "If a user calls
transfer, their balance decreases byamount." - Invariants: Properties that must hold in every reachable state. "The sum of all balances always equals total supply."
- Parametric rules: Rules that apply to every function in the contract simultaneously, without naming them individually.
- Ghost variables: Auxiliary variables that track state across multiple calls, allowing you to express properties that span transactions.
- Hooks: Attach logic to storage reads and writes, enabling precise tracking of state changes.
Certora Prover is accessed via the certora-cli Python package and the certoraRun command. The prover itself runs on Certora's cloud infrastructure — you send your contract and spec, the prover works remotely, and results appear in your terminal and in a web dashboard.
Real-World Use Cases
Aave uses Certora to verify that the health factor calculation used for liquidations is consistent across all code paths and that borrower positions cannot become incorrectly liquidatable through rounding errors.
Compound verifies that the interest rate model correctly updates accrued interest before any state-changing operation, and that no sequence of borrow and repay calls can leave the protocol insolvent.
Uniswap v4 engaged Certora to verify invariants on the new hooks system — that hooks cannot be called in an order that violates the pool's accounting invariants, and that fee accumulation is monotonic.
The pattern across all three is the same: formal verification is applied to the highest-stakes invariants — the ones where a violation means a protocol drains rather than misbehaves slightly.
Writing Your First CVL Spec: ERC-20 Transfer Rule
Start with a simple rule: a transfer call should never increase the caller's balance.
Contract (ERC20.sol):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract ERC20 {
mapping(address => uint256) public balanceOf;
uint256 public totalSupply;
function transfer(address to, uint256 amount) public returns (bool) {
require(balanceOf[msg.sender] >= amount, "insufficient balance");
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
}
Specification (ERC20.spec):
// ERC20.spec — Certora Verification Language specification
methods {
// Declare the functions we will reference in rules
function transfer(address to, uint256 amount) external returns (bool);
function balanceOf(address account) external returns (uint256) envfree;
function totalSupply() external returns (uint256) envfree;
}
// Rule: totalSupply never changes on transfer
rule totalSupplyUnchangedOnTransfer(address to, uint256 amount) {
uint256 supplyBefore = totalSupply();
env e;
transfer(e, to, amount);
uint256 supplyAfter = totalSupply();
assert supplyAfter == supplyBefore,
"transfer changed totalSupply";
}
// Rule: sender balance never increases on transfer
rule senderBalanceDecreasesOnTransfer(address to, uint256 amount) {
address sender = calledBy();
uint256 senderBefore = balanceOf(sender);
env e;
require e.msg.sender == sender;
transfer(e, to, amount);
uint256 senderAfter = balanceOf(sender);
assert senderAfter <= senderBefore,
"sender balance increased after transfer";
}
Running the prover:
pip install certora-cli
export CERTORAKEY=your_api_key_here
certoraRun ERC20.sol \
--verify ERC20:ERC20.spec \
--solc solc8.20 \
--msg "ERC20 transfer rules"
The prover checks both rules against every possible state the contract can be in and every valid input to transfer. If the contract has a bug — say, a branch where totalSupply increments on transfer — the prover returns the exact inputs that trigger it.
CVL Invariant: Solvency Check for a Lending Protocol
Invariants differ from rules in that they must hold in every reachable state, not just after a specific function call. The prover checks that the invariant holds after the constructor, and that every function preserves it.
Contract (LendingPool.sol):
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract LendingPool {
uint256 public totalDeposits;
uint256 public totalBorrows;
mapping(address => uint256) public deposits;
mapping(address => uint256) public borrows;
function deposit(uint256 amount) external {
deposits[msg.sender] += amount;
totalDeposits += amount;
}
function borrow(uint256 amount) external {
require(totalBorrows + amount <= totalDeposits, "exceeds capacity");
borrows[msg.sender] += amount;
totalBorrows += amount;
}
function repay(uint256 amount) external {
require(borrows[msg.sender] >= amount, "excess repayment");
borrows[msg.sender] -= amount;
totalBorrows -= amount;
}
function withdraw(uint256 amount) external {
require(deposits[msg.sender] >= amount, "insufficient deposit");
require(totalDeposits - amount >= totalBorrows, "undercollateralized");
deposits[msg.sender] -= amount;
totalDeposits -= amount;
}
}
Specification (LendingPool.spec):
methods {
function totalDeposits() external returns (uint256) envfree;
function totalBorrows() external returns (uint256) envfree;
function deposit(uint256 amount) external;
function borrow(uint256 amount) external;
function repay(uint256 amount) external;
function withdraw(uint256 amount) external;
}
// Invariant: the protocol is always solvent
invariant solvency()
totalBorrows() <= totalDeposits()
{
preserved withdraw(uint256 amount) with (env e) {
require deposits[e.msg.sender] >= amount;
}
}
The preserved block tells the prover what assumptions are valid when checking that withdraw preserves the invariant. Without it, the prover would need to reason about all possible values of deposits[msg.sender], which may cause it to time out.
If you introduce a bug — say, removing the require(totalDeposits - amount >= totalBorrows) check from withdraw — the prover returns a counterexample showing the exact deposit and borrow amounts where the invariant breaks after a withdrawal.
Parametric Rules: Prove a Property Across All Functions
A parametric rule applies to every function in the contract simultaneously. You do not name the function — the prover substitutes each function in turn and checks the rule for all of them.
This is useful for properties that should hold regardless of which function was called: "the protocol's solvency invariant is never broken, no matter what function is called."
// Parametric rule: no function can make totalBorrows exceed totalDeposits
rule noFunctionBreaksSolvency(method f) {
require totalBorrows() <= totalDeposits();
env e;
calldataarg args;
f(e, args);
assert totalBorrows() <= totalDeposits(),
"function broke solvency invariant";
}
The method f parameter and calldataarg args pattern tell the prover to check this rule for every non-view function in the contract. This is more thorough than writing one rule per function — it also catches functions you forgot to specify individually.
Parametric rules are especially valuable for upgradeable contracts where new functions may be added. Writing the spec in parametric form means the rule automatically applies to any new function introduced in a future upgrade.
Vacuity: When a Passing Rule Proves Nothing
A vacuous rule is one that passes because its preconditions are always false, not because the property genuinely holds. The prover cannot reach the assert statement because the require conditions eliminate every possible state.
Example of a vacuous rule:
rule vacuousRule(uint256 amount) {
require amount > type(uint256).max; // impossible: nothing satisfies this
env e;
transfer(e, msg.sender, amount);
assert balanceOf(msg.sender) == 0; // this assert is never checked
}
This rule passes, but it proves nothing. The prover verifies that "for all states where amount > type(uint256).max, the assert holds" — and since no such states exist, the verification trivially succeeds.
CVL provides the satisfy keyword to detect vacuity. A satisfy statement asserts that at least one execution path reaches it. If the prover cannot satisfy the statement, the rule is vacuous.
rule transferReducesSenderBalance(address to, uint256 amount) {
uint256 senderBefore = balanceOf(calledBy());
env e;
require e.msg.sender != to; // sender and recipient are different
require balanceOf(e.msg.sender) >= amount; // sender has enough
transfer(e, to, amount);
uint256 senderAfter = balanceOf(e.msg.sender);
// Check that this execution path is reachable
satisfy senderAfter < senderBefore;
assert senderAfter == senderBefore - amount,
"sender balance not reduced correctly";
}
If the satisfy check fails, the prover tells you the rule's preconditions eliminate all valid states, and you know the rule is vacuous. Run satisfy checks on every non-trivial rule before trusting its results.
Limitations
Formal verification is not a silver bullet. Understanding what Certora Prover cannot do is as important as understanding what it can.
State explosion. As contract complexity grows, the prover's underlying SMT solver may time out. Complex arithmetic, large data structures, and deeply nested control flow all increase solving time. Practical specifications often break large properties into smaller, more tractable rules.
Loops require manual bounds. The prover cannot reason about loops of unbounded length. You must specify a loop_iter bound, telling the prover to unroll the loop a fixed number of times. This means the proof only holds for iterations up to that bound, not for arbitrary loop lengths.
certoraRun MyContract.sol \
--verify MyContract:MyContract.spec \
--loop_iter 3 # unroll loops up to 3 times
External calls require assumptions. When your contract calls an external contract, the prover cannot verify the external contract's behavior. You must model it with a NONDET (nondeterministic) assumption or a mock. If you assume the external call is benign when it isn't, the proof holds on a false premise.
Probabilistic properties are not provable. Formal verification handles deterministic logic. Properties like "this transaction is unlikely to be front-run" or "the price is probably close to the market rate" are outside the scope of the prover.
Specification correctness is on you. A spec that does not accurately encode what you care about produces proofs about the wrong properties. Writing a correct CVL specification requires understanding both the contract's intended behavior and the verification tool's semantics.
Certora vs. Foundry Fuzzing
Both tools find bugs that static analysis misses. They approach the problem differently.
| Dimension | Certora Prover | Foundry Fuzzing |
|---|---|---|
| Coverage | All inputs (exhaustive) | Sampled inputs (probabilistic) |
| Setup effort | High (CVL specification required) | Low (extend existing tests) |
| Execution time | Minutes to hours per rule | Seconds to minutes |
| Output on success | Mathematical proof | "No counterexample found in N runs" |
| Counterexample quality | Exact violating state | Minimized failing input |
| Loops | Requires manual bounds | Handles naturally |
| External calls | Requires mocking | Can fork mainnet |
| Best for | Critical invariants, protocol solvency | Broad coverage, fast iteration |
| When to use | Pre-deployment, high-stakes properties | Throughout development |
The key difference is what a passing result means. When Foundry fuzzes 1,000,000 inputs and finds no violation, you have evidence that the property likely holds for common cases. When Certora Prover verifies a rule, you have a mathematical proof that no counterexample exists within the model.
These tools are complementary. Use Foundry to catch bugs fast and iterate quickly during development. Use Certora before deployment to prove the properties that cannot be wrong — solvency, token accounting correctness, access control enforcement on critical paths.
Getting Started
Install the CLI:
pip install certora-cli
certora-cli --version
Get an API key by registering at certora.com. Academic users and open-source projects can apply for free access.
Run the prover:
export CERTORAKEY=your_api_key_here
certoraRun contracts/LendingPool.sol \
--verify LendingPool:specs/LendingPool.spec \
--solc solc8.20 \
--msg "solvency invariant check" \
--loop_iter 3
CI integration with GitHub Actions:
# .github/workflows/certora.yml
name: Certora Formal Verification
on:
pull_request:
branches: [main]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install certora-cli
run: pip install certora-cli
- name: Install solc
run: |
pip install solc-select
solc-select install 0.8.20
solc-select use 0.8.20
- name: Run Certora Prover
env:
CERTORAKEY: ${{ secrets.CERTORAKEY }}
run: |
certoraRun contracts/LendingPool.sol \
--verify LendingPool:specs/LendingPool.spec \
--solc solc8.20 \
--loop_iter 3 \
--msg "CI verification on PR ${{ github.event.pull_request.number }}"
Add CERTORAKEY as a repository secret. The workflow runs on every pull request targeting main, blocking merges if verification fails.
What ContractScan + Certora Covers
Automated scanning and formal verification address different parts of the security problem. Running both gives you layered coverage.
| Vulnerability Class | ContractScan Automated Scan | Certora Formal Verification |
|---|---|---|
| Reentrancy patterns | Yes — static detection | Provable for specific call graphs |
| Access control misconfiguration | Yes — role and ownership checks | Provable with parametric rules |
| Integer overflow / underflow | Yes — Solidity 0.8+ and unsafe math detection | Provable with arithmetic rules |
| Token accounting correctness | Partial — pattern-based | Provable with invariants |
| Protocol solvency | No | Yes — core use case |
| Invariant violations across function sequences | No | Yes — parametric rules |
| Known vulnerability signatures | Yes — updated rule library | Not applicable |
| Logic bugs in novel protocol designs | Partial — AI-assisted analysis | Yes — spec-driven |
| Flash loan interaction risks | Partial | Requires external call modeling |
| Upgrade storage collision | Yes — layout diff analysis | Provable with storage rules |
ContractScan catches broad patterns quickly — in under a minute across your entire codebase. Certora proves specific, critical properties exhaustively. The right workflow is: run ContractScan first to identify and fix obvious issues, then invest specification effort with Certora on the properties that matter most.
Run Pre-Checks Before Certora Verification
Writing a CVL specification for a contract with known bugs is wasted effort. Certora works best when applied to contracts that have already passed automated static analysis and fuzzing.
Run automated pre-checks at https://contract-scanner.raccoonworld.xyz before starting Certora verification. ContractScan identifies reentrancy, access control issues, integer math bugs, and token accounting problems in seconds. Fix those first, then write your CVL spec against a clean baseline.
Related Reading
- Foundry Invariant Fuzzing for Smart Contract Security
- Echidna Smart Contract Fuzzer: A Practical Security Testing Guide
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.