Formal Verification of Smart Contracts â Solidity, Slither and Certora
In this tutorial, you'll learn about Formal Verification of Smart Contracts. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Formal Verification of smart contracts applies mathematical reasoning to Solidity bytecode to prove the absence of reentrancy, arithmetic overflow, and access control vulnerabilities before deployment on Ethereum.
Learning Path
flowchart LR A["Hoare Logic"] --> B["Smart Contract Verification
Slither, Foundry, Certora"] B --> C["Model Checking"] B --> D["Industrial Formal Verification"] style B fill:#f90,color:#fff,stroke-width:2px
What you'll learn: Writing formal specifications for Solidity contracts, using Slither for automated detection, Foundry for property-based fuzzing, and Certora Prover for full Formal Verification.
Why it matters: Smart contract exploits have caused billions in losses. Formal Verification mathematically guarantees that contracts behave correctly for all possible inputs and states.
Real-world use: Durga Antivirus Pro uses Symbolic Execution to verify that smart contract audit findings are reproducible, confirming exploit paths before reporting them to developers.
Prerequisites
Basic Solidity programming, Ethereum fundamentals, and some exposure to Formal Verification concepts.
Why Verify Smart Contracts Formally?
Smart contracts are immutable once deployed, making pre-deployment verification critical. A single bug can lead to irreversible loss of funds. Traditional testing covers only specific input values, while Formal Verification proves properties for all possible states and transactions.
Key properties to verify: no reentrancy, correct arithmetic, proper access control, invariant preservation, and total supply consistency.
Step-by-Step: Static Analysis with Slither
Step 1: Install Slither
pip install slither-analyzer
Step 2: Create a Vulnerable Contract
// VulnerableBank.sol
pragma solidity ^0.8.0;
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent);
balances[msg.sender] -= amount; // Reentrancy: state after call
}
}
Step 3: Run Slither
slither VulnerableBank.sol --detect reentrancy
Expected output:
VulnerableBank.withdraw(uint256) (VulnerableBank.sol#10-16) uses a potential reentrancy via external call:
- (bool sent,) = msg.sender.call{value: amount}("") (VulnerableBank.sol#12)
- balances[msg.sender] -= amount (VulnerableBank.sol#14)
Step-by-Step: Property-Based Testing with Foundry
Step 1: Create a Foundry Project
forge init my-project
cd my-project
Step 2: Write a Contract with Properties
// src/Token.sol
pragma solidity ^0.8.0;
contract Token {
mapping(address => uint256) public balanceOf;
uint256 public totalSupply;
constructor(uint256 _initialSupply) {
balanceOf[msg.sender] = _initialSupply;
totalSupply = _initialSupply;
}
function transfer(address to, uint256 amount) external {
require(balanceOf[msg.sender] >= amount);
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
}
}
Step 3: Write a Formal Invariant Test
// test/Token.t.sol
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "../src/Token.sol";
contract TokenTest is Test {
Token token;
function setUp() public {
token = new Token(1000);
}
function testTotalSupplyInvariant() public {
uint256 supply = token.totalSupply();
vm.assume(token.balanceOf(address(0x1)) <= supply);
// Fuzz: totalSupply must never change
assertEq(token.totalSupply(), supply);
}
function testTransferInvariant(uint256 amount) public {
address alice = address(0x1);
vm.assume(alice != address(0));
vm.assume(amount > 0 && amount <= token.balanceOf(alice));
uint256 aliceBefore = token.balanceOf(alice);
uint256 bobBefore = token.balanceOf(address(0x2));
vm.prank(alice);
token.transfer(address(0x2), amount);
assertEq(token.balanceOf(alice), aliceBefore - amount);
assertEq(token.balanceOf(address(0x2)), bobBefore + amount);
}
}
Step 4: Run Foundry Verification
forge test
Expected output:
[PASS] testTotalSupplyInvariant() (gas: 4213)
[PASS] testTransferInvariant(uint256) (runs: 256, Îŧ: 6132, ~: 6521)
Step-by-Step: Certora Prover
Step 1: Write a Specification Rule
// token.spec
rule total_supply_invariant() {
uint256 supply_before = totalSupply;
storage var store = getStorage();
// Any arbitrary function call
calldataarg txData;
uint256 value;
address sender;
// Invoke the contract
processTransaction(txData, sender, value);
uint256 supply_after = totalSupply;
assert supply_before == supply_after,
"Total supply must never change";
}
rule transfer_consistency(address to, uint256 amount) {
require to != currentContract;
uint256 sender_before = balanceOf[currentContract];
uint256 recipient_before = balanceOf[to];
require sender_before >= amount;
transfer@withrevert(to, amount);
uint256 sender_after = balanceOf[currentContract];
uint256 recipient_after = balanceOf[to];
assert sender_after == sender_before - amount,
"Sender balance decreased correctly";
assert recipient_after == recipient_before + amount,
"Recipient balance increased correctly";
}
Step 2: Run Certora
certoraRun Token.sol --verify Token:token.spec
Common Errors
1. Forgetting to Check Reentrancy After External Calls
State changes after external calls are vulnerable to reentrancy. Follow the checks-effects-interactions pattern: update state before making external calls.
2. Integer Overflow in Solidity < 0.8
Solidity versions before 0.8 do not automatically check for overflow. Use SafeMath or upgrade to 0.8+.
3. Incomplete Access Control Verification
OnlyOwner modifiers can miss edge cases. Verify that every state-changing function has the correct access control using formal specifications.
4. Assuming External Contracts Are Safe
Formal Verification should assume worst case: the external contract can call back into your contract at any point. Model this in your specifications.
5. Insufficient Fuzzing Ranges
Property-based tests with too few runs may miss edge cases. Foundry's default 256 runs may need to be increased for complex contracts.
Practice Questions
Q1: What is a reentrancy attack?
When a contract calls an external contract that calls back into the original contract before the first invocation completes, exploiting stale state.
Q2: How does Slither detect vulnerabilities?
Slither builds a control-flow graph and data-dependency graph, then applies detection rules based on patterns of state changes and external calls.
Q3: What is a formal invariant for a token contract?
Total supply must remain constant across all transfers. No function should create or destroy tokens unless explicitly designed to do so.
Q4: How does Foundry's fuzzing verify properties?
Foundry generates random inputs constrained by vm.assume() and checks that assertions hold across many runs, providing statistical coverage.
Q5: What does the Certora Prover prove that testing cannot?
Certora proves properties hold for ALL possible inputs and states, not just the specific values tested by fuzzers.
Challenge
Write a Solidity vault contract that allows users to deposit and withdraw ERC20 tokens. Use Foundry to write property-based tests proving that: total deposited tokens never exceeds contract balance, no user can withdraw more than they deposited, and the sum of all user balances equals the contract's token balance.
FAQ
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro