← Back to Blog

Certora Prover: Formal Verification for Solidity Smart Contracts

2026-04-18 certora formal verification cvl solidity security invariants specification 2026

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:

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.



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.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →