Kani is a model-checking tool that proves properties about Rust code through symbolic execution — rather than testing with example inputs, it exhaustively explores all possible values that a variable could hold and checks whether an assertion can ever be violated. For Soroban smart contracts, where incorrect arithmetic or logic errors can be irreversible on-chain, formal proofs of critical properties provide a stronger safety guarantee than unit tests alone. Sanctifier integrates Kani through theDocumentation Index
Fetch the complete documentation index at: https://mintlify.com/Centurylong/sanctifier/llms.txt
Use this file to discover all available pages before exploring further.
sanctifier verify command and the fv-kani.yml GitHub Actions workflow, making it straightforward to add formal proofs to your CI pipeline alongside static analysis.
Kani limitations with Soroban host types
Kani requires the source code (or a verified model) of every function it symbolically executes. Soroban contracts communicate with the blockchain environment throughsoroban_sdk::Env, whose methods ultimately call extern "C" functions provided by the Stellar VM host — functions whose bodies are not available at contract compilation time.
This creates hard limits on what Kani can verify directly:
| Type | Limitation for Kani |
|---|---|
Env | Opaque handle; all operations (storage get/set, require_auth, events) are FFI |
Val | Runtime value type; conversion and comparison require host calls |
Symbol | Opaque u32 handle; symbol_short!, comparison, and display need the host |
Address | Opaque handle; require_auth, comparison, and display require host FFI |
String, Bytes, Map, Vec | Host-backed types; all operations delegate to env FFI |
soroban-env-host provides a Rust implementation of the host environment (used for local testing with soroban_sdk::testutils), symbolically executing contracts linked against it is impractical. The host is large and complex, involves memory management and storage emulation, and pulls in many transitive dependencies. For non-trivial contracts, verification against the full host stack is either extremely slow or completely infeasible.
Authentication flows (require_auth) cannot be modelled by Kani. Logic that depends on the content of Address or Symbol values loses semantic meaning if those types are modelled as plain integers.
Core Logic Separation pattern
The practical solution to these limitations is the Core Logic Separation pattern. The idea is to extract the critical business logic of a contract into pure Rust functions — nosoroban_sdk::Env, no host types, only primitive Rust types like i128 and bool — and verify those functions with Kani. The actual contract #[contractimpl] block becomes a thin layer that reads state from env.storage(), delegates to the verified pure functions, and writes results back.
Identify the logic to verify
Focus on arithmetic-heavy or invariant-critical paths: balance transfers, mint/burn operations, initialisation guards, fee calculations. These are the functions where a bug can drain funds or break protocol invariants.
Extract pure functions
Move the core arithmetic into standalone
pub fn functions that take only primitive types (i128, bool, u64, etc.) and return Result. Place them in a dedicated module — conventionally named pure.rs or a kani-poc crate — so they can be imported by both the contract layer and proof harnesses.The contracts/kani-poc crate demonstrates this with a standard token:Keep the contract layer thin
The
#[contractimpl] block should only marshal data: read balances from env.storage(), call the pure functions, and write results back. No logic lives here that is not already proven.Proof harnesses in contracts/kani-poc
The kani-poc crate ships eight proof harnesses that collectively cover the core token operations:
| Harness | Property proven |
|---|---|
verify_initialize_fails_when_already_initialized | initialize_pure(true) always returns Err — a set-up contract can never be re-initialised |
verify_initialize_succeeds_when_not_initialized | initialize_pure(false) always returns Ok — the first call on a fresh contract always succeeds |
verify_initialize_idempotency_guarantee | Exhaustive over all boolean states: double-initialisation is mathematically impossible |
verify_transfer_pure_conservation | Transfer preserves total supply: new_from + new_to == balance_from + balance_to |
verify_transfer_pure_rejects_non_positive_amount | Transfer fails with Err for every amount <= 0 |
verify_transfer_pure_rejects_underflow | Transfer fails when balance_from - amount would underflow i128 |
verify_mint_pure | Mint produces exactly balance + amount for all valid inputs |
verify_burn_pure | Burn produces exactly balance - amount for all valid inputs |
Running Kani
First install the Kani verifier and its backend:Per-harness time budget
For CI, pass--harness-timeout to bound the solver time per harness and timeout as a wall-clock cap on the whole package run:
SMT latency benchmarking
To compare proof strategy cost across strategies, Sanctifier includes an ignored benchmark insanctifier-core:
target/smt-latency-report.json with per-strategy min/avg/max and p95 latency in microseconds, useful for identifying the most expensive proof strategies in your CI budget.
Integration with sanctifier verify
The sanctifier verify command scans for #[sanctify::invariant] attributes, runs the Z3 SMT fast-path for integer equalities and tautologies, and delegates to Kani for more complex properties. It reports one of four statuses per invariant:
| Status | Meaning |
|---|---|
PROVEN | The Z3 SMT solver proved the invariant holds for all inputs |
REFUTED | A counterexample was found — the invariant is violated |
UNKNOWN | The solver could not determine the result within the time budget |
KANI ↗ | The invariant requires Kani for a full proof; use cargo kani |
fv-kani.yml GitHub Actions workflow runs all harnesses across kani-poc-contract, token-invariants, amm-pool, and reentrancy-guard on every push and pull request to main.