← Back to Blog

Certora Prover: Formal Verification for Solidity Contracts

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

Smart contract vulnerabilities frequently stem from unhandled edge cases—untested combinations of protocol state and user inputs that lead to catastrophic protocol drains. While static analysis matches syntax patterns and fuzzers test randomized input samples, formal verification mathematically proves whether contract properties hold across all possible execution states and inputs simultaneously.

Certora Prover is a leading formal verification tool for Solidity smart contracts. Top-tier DeFi protocols including Aave, Compound, Uniswap, Balancer, and Maker incorporate Certora Prover into their security workflows to verify core invariants before deploying contracts holding significant value.


Formal Verification vs. Fuzzing vs. Static Analysis

Smart contract security analysis tools operate across distinct paradigms, offering different mathematical guarantees and performance tradeoffs. While static analysis relies on rule-based syntax checks and fuzzing uses probabilistic sampling, formal verification leverages SMT solvers to mathematically prove that smart contract properties hold across all possible inputs and reachable states simultaneously.

Formally verifying smart contracts requires authoring detailed property specifications in domain-specific languages and managing computational complexity, as SMT solvers can time out on unbounded loops or complex non-linear arithmetic.

Consequently, production security workflows pair automated fuzzing during active development with formal verification prior to deployment to validate high-stakes protocol invariants.


How Certora Prover Works

Certora Prover consumes two primary inputs: the compiled Solidity smart contract and a CVL specification file. Certora Verification Language (CVL) is a declarative language used to express invariants, rules, and environment assumptions regarding contract execution.

+-------------------+      +-------------------+
|  Solidity Source  |      | CVL Specification |
+---------+---------+      +---------+---------+
          |                          |
          v                          v
+----------------------------------------------+
|                Certora Prover                |
|  (Bytecode Translation & SMT Model Checking) |
+-----------------------+----------------------+
                        |
        +---------------+---------------+
        |                               |
        v                               v
+---------------+               +---------------+
| Proof Success |               | Counterexample|
|  (Verified)   |               |  (Violated)   |
+---------------+               +---------------+

The prover translates contract bytecode into a control flow graph and formulates verification conditions for the SMT solver. If a property fails, the solver generates a concrete counterexample detailing the exact call sequence, storage state, and parameter values that trigger the violation.

Core CVL Constructs

The toolchain is managed via the certora-cli package using CLI tools such as certoraRun. Computation is offloaded to Certora's cloud infrastructure, returning results to local terminals and interactive web dashboards.


Production Security Applications

Top DeFi protocols deploy formal verification to enforce critical accounting invariants:


Writing a Basic CVL Specification

Consider a standard ERC-20 token contract and a specification verifying token transfer balance updates.

Contract Implementation (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;
    }
}

CVL Specification (ERC20.spec)

// ERC20.spec — Certora Verification Language specification

methods {
    // Function declarations and environment properties
    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 must remain unchanged during transfer execution
rule totalSupplyUnchangedOnTransfer(address to, uint256 amount) {
    uint256 supplyBefore = totalSupply();

    env e;
    transfer(e, to, amount);

    uint256 supplyAfter = totalSupply();

    assert supplyAfter == supplyBefore,
        "transfer altered total supply";
}

// Rule: sender balance must decrease appropriately on transfer
rule senderBalanceDecreasesOnTransfer(address to, uint256 amount) {
    env e;
    uint256 senderBefore = balanceOf(e.msg.sender);

    transfer(e, to, amount);

    uint256 senderAfter = balanceOf(e.msg.sender);

    assert senderAfter <= senderBefore,
        "sender balance increased following transfer";
}

Formalizing Invariants: Solvency Verification & Account Safety

Invariants enforce global state conditions. The prover verifies that an invariant holds immediately after constructor execution and remains intact after every state-changing function call.

Vulnerable Protocol Implementation (LendingPool.sol)

Consider the following flawed lending pool logic containing two critical collateral validation bugs:

// VULNERABLE CODE — Faulty collateral checks during borrow and withdraw
function borrow(uint256 amount) external {
    // CRITICAL BUG: Only checks requested amount against collateral, ignoring existing borrows!
    require(deposits[msg.sender] >= amount, "insufficient collateral");
    require(totalBorrows + amount <= totalDeposits, "exceeds capacity");
    borrows[msg.sender] += amount;
    totalBorrows += amount;
}

function withdraw(uint256 amount) external {
    require(deposits[msg.sender] >= amount, "insufficient deposit");
    // CRITICAL BUG: Only checks global totals! Ignores individual borrows[msg.sender].
    require(totalDeposits - amount >= totalBorrows, "undercollateralized");
    deposits[msg.sender] -= amount;
    totalDeposits -= amount;
}

In this vulnerable implementation:
1. In withdraw, a user can deposit 100 tokens, borrow 100 tokens, and then call withdraw(100) to reclaim collateral without repaying their debt as long as global deposits exceed global borrows.
2. In borrow, a user with 100 tokens deposited (deposits[msg.sender] = 100) can call borrow(80) twice in succession. The second call passes 100 >= 80, leaving the user with 160 total debt against 100 collateral and generating bad debt.

Fixed Protocol Implementation (LendingPool.sol)

To fix these vulnerabilities, borrow must verify cumulative debt (borrows[msg.sender] + amount <= deposits[msg.sender]), and withdraw must verify remaining collateral (deposits[msg.sender] - amount >= borrows[msg.sender]).

Note on Token Interactions: The contract below uses a simplified internal state accounting model to isolate core protocol logic for verification. Production implementations must incorporate external ERC-20 token transfers (IERC20(token).transferFrom and IERC20(token).transfer).

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @notice Simplified lending pool for formal verification demonstration.
/// @dev Internal ledger model; real implementations require IERC20 transfer/transferFrom logic.
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 {
        // FIXED: Enforce that cumulative borrows plus new borrow do not exceed collateral
        require(borrows[msg.sender] + amount <= deposits[msg.sender], "active borrows exceed collateral");
        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");
        // FIXED: Enforce individual account collateral requirement against existing debt
        require(deposits[msg.sender] - amount >= borrows[msg.sender], "active borrows require collateral");
        require(totalDeposits - amount >= totalBorrows, "undercollateralized");
        deposits[msg.sender] -= amount;
        totalDeposits -= amount;
    }
}

Limitations of Global Invariants & Ghost Variables

A common pitfall in formal verification is relying exclusively on global invariants, which can induce a false sense of security. While global invariants verify aggregate state metrics, they cannot detect per-user account balance violations. Complete specification suites require targeted per-user accounting rules alongside ghost variables and storage hooks.

In the vulnerable version of LendingPool.sol, the global invariant invariant globalSolvency() totalBorrows() <= totalDeposits(); evaluates to PASSED. This occurs because the global sum totalBorrows <= totalDeposits remains mathematically true during a collateral drain attack (e.g., total deposits drop from 200 to 100 while total borrows remain 100). The global invariant passes even though individual users suffer bad debt extraction.

To detect per-account insolvency, we specify a per-user rule: userSolvencyPreserved.

If borrow only checks deposits[msg.sender] >= amount (neglecting borrows[msg.sender]), Certora Prover running userSolvencyPreserved will FAIL and output a concrete counterexample:
- Initial State: deposits(u) = 100, borrows(u) = 80.
- Call: borrow(e, 50). Precondition require deposits(u) >= borrows(u) (100 >= 80) holds.
- Post-Execution State: borrows(u) = 130, violating assert deposits(u) >= borrows(u) (100 >= 130).

When borrow is updated to require(borrows[msg.sender] + amount <= deposits[msg.sender]), the prover verifies that userSolvencyPreserved holds across all reachable states and methods.

CVL Specification (LendingPool.spec)

methods {
    function totalDeposits() external returns (uint256) envfree;
    function totalBorrows() external returns (uint256) envfree;
    function deposits(address) external returns (uint256) envfree;
    function borrows(address) external returns (uint256) envfree;
    function deposit(uint256 amount) external;
    function borrow(uint256 amount) external;
    function repay(uint256 amount) external;
    function withdraw(uint256 amount) external;
}

// Global Invariant: Total protocol borrows must never exceed total deposits
invariant globalSolvency()
    totalBorrows() <= totalDeposits();

// Per-User Rule: Every individual user must remain collateralized after any state update
rule userSolvencyPreserved(address u, method f) filtered { f -> !f.isView && !f.isEnvFree } {
    require deposits(u) >= borrows(u);

    env e;
    calldataarg args;
    f(e, args);

    assert deposits(u) >= borrows(u),
        "user collateral fell below active borrows";
}

// Ghost Variable: Tracking total deposits independently via storage writes
ghost mathint ghostTotalDeposits {
    init_state axiom ghostTotalDeposits == 0;
}

// Storage Hook: Intercept writes to deposits mapping and update ghost state safely using mathint
hook Sstore deposits[KEY address user] uint256 new_val (uint256 old_val) {
    ghostTotalDeposits = ghostTotalDeposits + to_mathint(new_val) - to_mathint(old_val);
}

// Invariant: Verify contract totalDeposits matches storage hook ghost variable
invariant ghostDepositsMatchesTotal()
    ghostTotalDeposits == to_mathint(totalDeposits());

[!NOTE]
Direct mapping index notation such as currentContract.deposits[user] is invalid syntax in CVL. To query contract mapping values in a spec, declare the public getter function in the methods block (e.g., function deposits(address) external returns (uint256) envfree;) and call deposits(user), or utilize hook Sload / hook Sstore directives to track mapping modifications via ghost variables.


Parametric Rules: Exhaustive Function Verification

Parametric rules test properties across contract methods without requiring individual rule blocks for each function. By default, CVL parametric rules iterate over all public and external methods. However, state-changing parametric rules must filter out view functions and envfree getters to prevent compilation errors and redundant checks.

Correcting method f Filtering and envfree Binding

In CVL, the method f parameter binds to all public and external functions declared on the contract, including view, pure, and envfree getter methods. The prover does not automatically filter out read-only functions or spec-declared environment-free methods.

It is critical to distinguish between isView and isEnvFree in CVL:
- isView checks whether the underlying Solidity contract method is declared view or pure (non-state-modifying).
- isEnvFree checks whether the method was declared envfree inside the CVL methods block.

If a method is declared as envfree in the methods block (such as totalDeposits() or deposits(address)), invoking f(e, args) with an environment variable e causes a compile-time type error in Certora Prover because envfree methods cannot accept environment arguments (e.msg.sender, e.block.timestamp). Relying solely on !f.isView is conceptually imprecise and incomplete when filtering functions for rules that pass environment variables e.

Furthermore, in Certora Verification Language (CVL2), method filter clauses must use curly braces { ... } rather than parentheses ( ... ). Using filtered (f -> ...) results in a parsing SyntaxError during certoraRun.

To prevent compiler errors and avoid redundant checks on view methods, combine both properties using CVL2 filter syntax: filtered { f -> !f.isView && !f.isEnvFree }.

// Corrected Parametric Rule: Filter out view/pure methods and envfree getters using CVL2 syntax
rule noFunctionBreaksSolvency(method f) filtered { f -> !f.isView && !f.isEnvFree } {
    require totalBorrows() <= totalDeposits();

    env e;
    calldataarg args;
    f(e, args);

    assert totalBorrows() <= totalDeposits(),
        "state-changing function broke protocol solvency invariant";
}

The filtered { f -> !f.isView && !f.isEnvFree } clause ensures that both read-only Solidity methods and envfree spec functions are excluded from f, allowing the rule to compile cleanly while testing all state-changing entry points.


Vacuity Detection with assert and satisfy

A rule is vacuous if its require preconditions exclude all possible valid execution paths. In such cases, the rule passes trivially because the prover never reaches the assertions, creating a false sense of security.

rule vacuousExample(uint256 amount) {
    // Unsatisfiable precondition (256-bit max value comparison)
    require amount > 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff;

    env e;
    transfer(e, e.msg.sender, amount);

    assert balanceOf(e.msg.sender) == 0; // Never evaluated
}

To guard against vacuity, CVL provides the satisfy keyword. While assert verifies that a condition holds across all valid execution traces, satisfy verifies that at least one execution trace reaches the statement.

CVL supports using both assert and satisfy statements within the same rule:

rule transferAccountingVerification(address to, uint256 amount) {
    env e;
    uint256 senderBefore = balanceOf(e.msg.sender);

    require e.msg.sender != to;
    require balanceOf(e.msg.sender) >= amount;

    transfer(e, to, amount);

    uint256 senderAfter = balanceOf(e.msg.sender);

    // Reachability Check: Proves a valid trace exists where balance decreases
    satisfy senderAfter < senderBefore;

    // Safety Assertion: Proves balance decreases by exact transfer amount across all traces
    assert senderAfter == senderBefore - amount, 
        "sender balance not decremented correctly";
}

If the satisfy condition fails during verification, Certora Prover alerts you that the rule's preconditions are overly restrictive, preventing vacuous assert passes.


Limitations and Operational Constraints

Understanding the boundaries of formal verification ensures appropriate specification design:

  1. State Explosion: Complex non-linear math, deep call graphs, and expansive storage arrays increase solver search space, potentially causing timeouts. Large properties should be decomposed into targeted rules.
  2. Loop Unrolling Bounds: Unbounded loops cannot be evaluated exhaustively by SMT solvers. Loops require explicit unrolling limits using the --loop_iter flag, which restricts verification guarantees to the configured iteration threshold.
  3. External Call Modeling: Calls to external contracts require explicit behavior modeling using non-deterministic (NONDET) summary definitions or explicit mocks.
  4. Specification Accuracy: Formal proofs validate code against the provided specification. Logical omissions or incorrect assumptions within CVL rules result in valid proofs for incorrect properties.

Certora Prover vs. Foundry Fuzzing

Dimension Certora Prover Foundry Fuzzing
Coverage Scope Exhaustive (All inputs within model) Sampled (Randomized execution iterations)
Specification Overhead High (Requires dedicated CVL specs) Low (Extends existing standard tests)
Execution Speed Minutes to hours per rule Seconds to minutes
Success Output Mathematical proof No counterexample found in N runs
Counterexample Format Full concrete state & call trace Minimized input payload
Loop Handling Bounded unrolling required Native loop execution
External Calls Requires spec summaries / mocks Supports live RPC mainnet forking
Primary Utility High-stakes invariant proofs Rapid iteration & edge-case discovery

Setup and Integration Guide

1. Installation and Version Verification

Install the package via pip. Note that the executable entry point is certoraRun:

pip install certora-cli
certoraRun --version

2. Executing Verification Runs

Set your API credentials and run certoraRun:

export CERTORAKEY=your_api_key_here

certoraRun contracts/LendingPool.sol \
    --verify LendingPool:specs/LendingPool.spec \
    --solc solc \
    --loop_iter 3 \
    --msg "Solvency invariant check"

3. Continuous Integration (GitHub Actions)

Integrate verification runs into automated pull request checks:

# .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 compiler manager
        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 solc \
            --loop_iter 3 \
            --msg "CI verification PR #${{ github.event.pull_request.number }}"

Pre-Deployment Checklist


Frequently Asked Questions

What is the difference between assert and satisfy in CVL?

assert verifies that a condition holds across all valid execution traces, while satisfy verifies that at least one execution trace can reach the statement. Combining both prevents vacuous rule passes.

How does method f handle envfree functions in parametric rules?

In CVL, method f matches all public and external methods. Calling f(e, args) on an envfree function causes a type error because envfree functions cannot accept an environment parameter e. While isView checks whether a Solidity function is view or pure, isEnvFree checks whether it is declared envfree in the CVL methods block. To prevent type errors and exclude read-only methods, filter parametric rules using CVL2 syntax: filtered { f -> !f.isView && !f.isEnvFree }.

Can a global solvency invariant guarantee overall contract safety?

No. Global invariants like totalBorrows <= totalDeposits only verify aggregate totals. Bad debt or collateral draining can occur at the individual user level (e.g., consecutive borrows exceeding deposit balance) while global totals remain balanced. You must pair global invariants with per-user rules and ghost variables.

How do I access a Solidity mapping in CVL?

Direct mapping indexing (e.g., currentContract.deposits[user]) is invalid syntax in CVL. Declare the public getter function in the methods block (e.g., function deposits(address) external returns (uint256) envfree;) and call deposits(user), or track mapping storage updates using hook Sload / hook Sstore directives paired with ghost variables.


Security Coverage Comparison

Combining automated scanning with formal verification yields comprehensive security coverage across development phases:

Vulnerability / Analysis Class ContractScan Static Analysis Certora Formal Verification
Reentrancy Anti-patterns Yes (AST pattern analysis) Provable via call-graph specifications
Access Control Configuration Yes (Role & owner verification) Provable via parametric rules
Unchecked Math & Overflows Yes (Solidity 0.8+ / SafeMath checks) Provable via arithmetic rules
Token Accounting Integrity Partial (Pattern detection) Provable via balance invariants
Protocol Solvency Invariants No Yes (Core verification capability)
Multi-transaction Logic Errors No Yes (Ghost state & sequence rules)
Known CVE Signature Matching Yes (Rule library matching) Not Applicable
Unique Protocol Logic Bugs Partial (Heuristic analysis) Yes (Spec-driven model checking)

Pre-Verification Scanning Workflow

Resolving syntax issues and common vulnerabilities prior to formal verification minimizes iteration cycles. Executing automated scans against contracts using ContractScan identifies standard reentrancy vectors and access control flaws before investing time in CVL spec development.



Important Notes

This article is provided for educational and informational purposes only and does not constitute financial, legal, or formal security audit advice. Security analysis depends on accurate specifications and underlying tool modeling assumptions. Always engage independent professional security auditors prior to deploying smart contracts to production networks.

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