CertiK Prover user guide
Your first specification
Take a small vault contract, describe in plain words what one function should do, and turn each sentence into a clause. Run each step in a playground to see what CertiK Prover finds.
- You will learn
ensures,\old,\result,assignable, the revert clauses,requires, and how to read a counterexample.- Builds on
- Basics
The contract
Vault records a balance for each address. deposit adds to the caller's balance and withdraw subtracts from it. To keep the focus on specifications, this vault only keeps records; it does not move tokens.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Vault {
mapping(address => uint256) public balances;
function deposit(uint256 amount) external {
balances[msg.sender] += amount;
}
function withdraw(uint256 amount) external returns (uint256) {
require(amount <= balances[msg.sender], "insufficient balance");
balances[msg.sender] -= amount;
return balances[msg.sender];
}
}Say what withdraw should do
Before writing any syntax, write the intended behavior as plain sentences. Each sentence becomes one clause in the steps below.
- A successful withdrawal reduces my balance by exactly
amount. - It returns my new balance.
- It changes nothing except my balance.
- It fails if, and only if, I ask for more than I have.
Turn each sentence into a clause
Each step adds one clause to withdraw. Each playground replays the result CertiK Prover produced for that exact code.
What changes:
ensuresand\oldensuresstates a condition that must hold when the function returns successfully. Inside it,\old(e)is the value ofewhen the call started; a name without\oldmeans its value at the end.//@ ensures balances[msg.sender] == \old(balances[msg.sender]) - amount;BeforeMy balance is BCallwithdraw(A)After successMy balance is B − ASpecification arithmetic never overflows or wraps:
\old(balances[msg.sender]) - amountis the exact mathematical difference. Solidity's own overflow checks still apply to the code.What it returns:
\result\resultis the value the function returns. This clause says the returned number equals the balance left after the withdrawal.//@ ensures balances[msg.sender] == \old(balances[msg.sender]) - amount; //@ ensures \result == balances[msg.sender];Try it
A wrong return value
This
withdrawremembers the old balance before subtracting. Does it return the right number?//@ ensures balances[msg.sender] == \old(balances[msg.sender]) - amount; //@ ensures \result == balances[msg.sender]; function withdraw(uint256 amount) external returns (uint256) { require(amount <= balances[msg.sender], "insufficient balance"); uint256 previous = balances[msg.sender]; balances[msg.sender] = previous - amount; return previous; }Select Run CertiK Prover to check this code against its specification.
What may change:
assignableassignablelists the storage the function may modify; everything else must stay exactly as it was. Namingbalances[msg.sender]lets only the caller's entry change, so every other account is protected.//@ ensures balances[msg.sender] == \old(balances[msg.sender]) - amount; //@ ensures \result == balances[msg.sender]; //@ assignable balances[msg.sender];assignablepermits a change; it does not require one. Without anassignableclause, a function that writes state may modify anything. Writeassignable \nothing;for a function that changes no storage.Try it
An unexpected change
This version also keeps a running total of withdrawals. Does the specification allow that?
//@ ensures balances[msg.sender] == \old(balances[msg.sender]) - amount; //@ ensures \result == balances[msg.sender]; //@ assignable balances[msg.sender]; function withdraw(uint256 amount) external returns (uint256) { require(amount <= balances[msg.sender], "insufficient balance"); balances[msg.sender] -= amount; totalWithdrawn += amount; return balances[msg.sender]; }Select Run CertiK Prover to check this code against its specification.
When it fails: revert clauses
ensuresdescribes only successful calls. Three clauses describe when a call reverts. Their conditionPis read in the starting state:Clause Meaning reverts_when PIf Pholds, the call must revert.reverts_only_when PIf the call reverts, Pheld.reverts_when_and_only_when PBoth: the call reverts exactly when Pholds.The vault should fail exactly when the caller asks for more than they have:
//@ ensures balances[msg.sender] == \old(balances[msg.sender]) - amount; //@ ensures \result == balances[msg.sender]; //@ assignable balances[msg.sender]; //@ reverts_when_and_only_when amount > balances[msg.sender]; function withdraw(uint256 amount) external returns (uint256) {Revert clauses describe what the code does; they do not add a check. Here the
requireinwithdrawdoes the reverting. Under Solidity 0.8, arithmetic overflow and underflow revert too, and CertiK Prover accounts for that.Try it
An off-by-one revert
The specification says a withdrawal fails exactly when it asks for more than the balance. Does the code agree?
//@ ensures balances[msg.sender] == \old(balances[msg.sender]) - amount; //@ ensures \result == balances[msg.sender]; //@ assignable balances[msg.sender]; //@ reverts_when_and_only_when amount > balances[msg.sender]; function withdraw(uint256 amount) external returns (uint256) { require(amount < balances[msg.sender], "insufficient balance"); balances[msg.sender] -= amount; return balances[msg.sender]; }Select Run CertiK Prover to check this code against its specification.
What callers guarantee:
requiresEvery clause so far describes the function for all inputs.
requiresis different: it states a condition the caller must guarantee. CertiK Prover assumes it while checking the function, and proves it at every call. It suits internal helpers such as this one, which trusts its callers to check the balance first://@ requires amount <= balances[user]; //@ ensures balances[user] == \old(balances[user]) - amount; //@ assignable balances[user]; function _debit(address user, uint256 amount) internal { unchecked { balances[user] -= amount; } }requiresadds no runtime check. On an external function, nothing in your code calls it, so arequiresthere only narrows the claim to callers that satisfy it. To describe input checks the code performs itself, use revert clauses.Try it
An unguaranteed precondition
_debitrequires the balance to cover the amount. Doeswithdrawguarantee that before calling it?//@ ensures \result == balances[msg.sender]; function withdraw(uint256 amount) external returns (uint256) { _debit(msg.sender, amount); return balances[msg.sender]; } //@ requires amount <= balances[user]; //@ ensures balances[user] == \old(balances[user]) - amount; //@ assignable balances[user]; function _debit(address user, uint256 amount) internal { unchecked { balances[user] -= amount; } }Select Run CertiK Prover to check this code against its specification.
Your turn: specify deposit
Write deposit's specification before opening the answer: it adds amount to the caller's balance, changes nothing else, and reverts exactly when the new balance would overflow.
Show the answer
//@ ensures balances[msg.sender] == \old(balances[msg.sender]) + amount;
//@ assignable balances[msg.sender];
//@ reverts_when_and_only_when balances[msg.sender] + amount > type(uint256).max;
function deposit(uint256 amount) external {
balances[msg.sender] += amount;
}Because specification arithmetic does not overflow, balances[msg.sender] + amount > type(uint256).max states the overflow condition directly.
The complete specification
Here is the vault with both functions specified. CertiK Prover proves every clause.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Vault {
mapping(address => uint256) public balances;
//@ ensures balances[msg.sender] == \old(balances[msg.sender]) + amount;
//@ assignable balances[msg.sender];
//@ reverts_when_and_only_when balances[msg.sender] + amount > type(uint256).max;
function deposit(uint256 amount) external {
balances[msg.sender] += amount;
}
//@ ensures balances[msg.sender] == \old(balances[msg.sender]) - amount;
//@ ensures \result == balances[msg.sender];
//@ assignable balances[msg.sender];
//@ reverts_when_and_only_when amount > balances[msg.sender];
function withdraw(uint256 amount) external returns (uint256) {
require(amount <= balances[msg.sender], "insufficient balance");
balances[msg.sender] -= amount;
return balances[msg.sender];
}
}Read the result
Every check ends in one of four outcomes. Only the first two are proof results.
Passed
Every checked clause holds for every input and starting state the specification allows.
Exit code0Failed
CertiK Prover found a counterexample: concrete values that make a clause false.
Exit code1Inconclusive
The solver could not decide within its time or memory limit. This is neither a pass nor a failure.
Exit code3Error
The file could not be parsed, compiled, or checked. Fix the error first; it says nothing about your specification.
Exit code2Read a counterexample
On the command line, each failure names its kind, marks the clause in the source, and repeats the condition that did not hold. This is the report for the wrong return value above:
+-----------------------------+
| Failure #1: Postcondition |
+-----------------------------+
The verifier found an execution path that can violate an ensures clause.
+--- Source Context ---+
Vault.sol:8
7 | //@ ensures balances[msg.sender] == \old(balances[msg.sender]) - amount;
> 8 | //@ ensures \result == balances[msg.sender];
9 | function withdraw(uint256 amount) external returns (uint256) {
[Reason ]: Postcondition violation
[Failed condition ]: \result == balances[msg.sender]
…
=== Verification Summary: 1 total, 0 passed, 1 failed, 0 errors ===Find the clause
The header gives the kind of failure, and the
>marker shows the line.Read the failed condition
This is the exact condition that can be false.
Rebuild the scenario
Choose starting values the code accepts, follow the code, and compare the outcome with the clause, as the playground explanations do.
Fix the code or the specification
Decide which one is wrong. Never weaken a clause only to make it pass.
A pass is only as strong as its assumptions. If requires clauses contradict each other, no starting state is possible, and every clause passes without anything being checked. Security properties shows this trap in action.
Run the full examples
Every code sample and playground on this page is a complete contract in the CertiK Prover Server Tutorial project, where CertiK Prover runs it on the server. Sign in and open an entry's results to see the real run behind each simulated one.