Formal Verification of a Sui Move Lending Protocol Using Certora Prover
How I mathematically proved that an eMode group assignment is immutable after obligation creation in a Sui DeFi lending protocol — using Certora's Sui Prover, parametric rules, and field accessors.
The Protocol: A Lending Market on Sui
The target was a Compound-style lending protocol built in Move on Sui. Users deposit collateral, borrow assets, earn interest — the usual DeFi primitives. But this protocol had an additional layer of sophistication: eMode groups (efficiency mode), similar to Aave V3's e-mode.
Each eMode group defines a curated set of correlated assets (e.g., stablecoins, or LST variants) with optimized risk parameters — higher collateral factors, tighter liquidation thresholds, and dedicated borrow caps. When a user enters the market, their Obligation is assigned an eMode group, and all their subsequent borrows and deposits are restricted to assets within that group.
id: UID,
lending_market_id: ID,
debts: WitTable<ObligationDebts, TypeName, Debt>,
ctokens: CTokenTable,
emode_group: u8, // ← This must NEVER change }
The protocol's security depends on a critical invariant: once an Obligation is created with an eMode group, that assignment must be permanent. If a user could switch eMode groups mid-lifecycle, they could borrow assets from one risk tier using collateral parameters from another — effectively bypassing the protocol's risk engine.
The Invariant
From the client's requirements:
Invariant: Users cannot switch eMode once obligations are created. This must hold under all operations — including flash loans with re-entrant deposit/borrow in a single Programmable Transaction Block (PTB).
This isn't something you can test with unit tests or fuzzing. A unit test checks specific scenarios. A fuzzer explores random paths. But neither can guarantee that no possible sequence of operations across all possible inputs will ever violate the property. For that, you need formal verification.
Background: My Formal Verification Journey
This wasn't my first formal verification engagement. I previously did a formal verification audit with Pashov Audit Group for the SpiceNet Delegate contracts. That project was in Solidity, so we used Certora's EVM prover — a mature, well-documented tool with years of battle-testing on Ethereum protocols.
Move on Sui was a different story. The ecosystem for formal verification tooling was much thinner. After digging around, I found two options:
Open source on GitHub
Spec blocks inline with Move code
Still maturing for Sui specifically
Local execution only
Parametric rules across all functions
Cloud-based with web dashboard
Supports field_access, summaries, ghost state
Production-ready infrastructure
I went with Certora's Sui Prover. The parametric rule support was the deciding factor — I could write a single rule that automatically verifies the invariant against every obligation-mutating function, rather than manually writing a separate check for each one. The cloud-based execution and web dashboard for inspecting counterexamples were bonuses.
Part 1 — Setting Up Certora for Sui Move
Prerequisites
The setup requires Python 3.9+, Java 21+, the Sui CLI, and a Certora account. Installation is straightforward:
// Set your Certora key
export CERTORAKEY=<your_key>
// Verify prerequisites
python3 --version // ≥ 3.9
java --version // ≥ 21
sui --version // latest
Creating the Spec Package
Certora specs live in a separate Move package that imports both the contract under verification and the cvlm specification library. I created a spec/ directory alongside the protocol:
protocol/ // The lending protocol
spec/ // Certora specs (NEW)
Move.toml
sources/
smoke_test.move
emode_immutability.move
The Move.toml wires together the protocol, Certora's CVLM library, and Sui platform summaries:
name = "spec"
edition = "2024.beta"
[dependencies]
protocol = { local = "../protocol" }
cvlm = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "cvlm", rev = "main" }
certora_sui_summaries = { git = "https://github.com/Certora/cvl-move-proto.git", subdir = "certora_sui_summaries", rev = "main" }
[addresses]
spec = "0x0"
Smoke Test
Before writing any real invariants, I created a minimal smoke test to verify the toolchain works end-to-end:
use cvlm::manifest::rule;
use cvlm::asserts::cvlm_satisfy;
public fun cvlm_manifest() {
rule(b"trivial");
}
public fun trivial() {
cvlm_satisfy(true);
}
Build locally with sui move build, then submit to the Certora cloud prover:
Tip: The Certora CLI binary installs to your Python user bin directory (e.g., ~/Library/Python/3.9/bin/certoraSuiProver). Add it to your PATH or use the full path. Note: the binary name is certoraSuiProver without the .py extension, despite what the docs say.
Part 2 — Writing the eMode Immutability Invariant
Understanding the Attack Surface
Before writing any spec, I needed to identify every function that takes a mutable reference to Obligation. These are the only functions that could potentially mutate emode_group:
deposit_ctoken // adds collateral tokens
withdraw_ctokens // removes collateral tokens
try_borrow_asset // creates or increases debt
repay_debt // decreases debt
unsafe_repay_debt_only // direct debt decrease (no interest check)
These six functions are the complete attack surface. If none of them modify emode_group, then no operation — including any composition of operations within a PTB or flash loan callback — can change it.
The Spec: Three Key Concepts
The Certora spec uses three powerful features from the CVLM library:
field_access— Reading private fields. Theemode_groupfield has no public setter, and its getter ispublic(package)(not callable from our spec module). Certora'sfield_accessmanifest function lets us declare a native accessor that the prover wires directly to the struct field.invoker+target— Parametric rules. Instead of writing 6 separate rules (one per function), we register all 6 functions as targets and declare an invoker. The prover then generates sub-rules for every (rule × target) combination automatically.target_sanity— Vacuity checks. A rule that passes because its preconditions are unsatisfiable is vacuously true — it proves nothing.target_sanity()auto-generates sanity checks that confirm each target is actually reachable.
The Full Spec
use cvlm::manifest::{rule, target, target_sanity, field_access, invoker};
use cvlm::asserts::{cvlm_assert, cvlm_satisfy};
use cvlm::function::Function;
// ---- field accessor ----
// Certora wires this to Obligation.emode_group.
// MUST return a reference (&u8), not a value (u8).
native fun get_emode_group(
obligation: &protocol::obligation::Obligation<protocol::obligation::MainMarket>,
): &u8;
// ---- invoker ----
// Calls any registered target with nondeterministic args.
native fun invoke(
target: Function,
obligation: &mut protocol::obligation::Obligation<protocol::obligation::MainMarket>,
);
// ---- manifest ----
public fun cvlm_manifest() {
field_access(b"get_emode_group", b"emode_group");
invoker(b"invoke");
// Register all &mut Obligation functions
target(@protocol, b"obligation", b"accrue_interest");
target(@protocol, b"obligation", b"deposit_ctoken");
target(@protocol, b"obligation", b"withdraw_ctokens");
target(@protocol, b"obligation", b"try_borrow_asset");
target(@protocol, b"obligation", b"repay_debt");
target(@protocol, b"obligation", b"unsafe_repay_debt_only");
target_sanity();
rule(b"emode_never_changes");
rule(b"emode_sanity");
}
// ---- rules ----
/// INVARIANT: emode_group must be unchanged after any operation.
public fun emode_never_changes(
obligation: &mut protocol::obligation::Obligation<...>,
target: Function,
) {
let emode_before = *get_emode_group(obligation);
invoke(target, obligation);
let emode_after = *get_emode_group(obligation);
cvlm_assert(emode_before == emode_after);
}
/// Sanity: non-zero emode_group is reachable.
public fun emode_sanity(
obligation: &protocol::obligation::Obligation<...>,
) {
let emode = *get_emode_group(obligation);
cvlm_satisfy(emode > 0);
}
Let me break down what happens when the prover runs this:
1. Create an Obligation with arbitrary state (nondeterministic)
2. Read emode_group → store as emode_before
3. Call the target function with all possible arguments
4. Read emode_group again → store as emode_after
5. Assert emode_before == emode_after
If the prover finds ANY input combination where the assertion fails,
it produces a counterexample. If no counterexample exists across
the entire state space, the invariant is proved.
Part 3 — Debugging the Prover
Getting to a passing result wasn't a straight line. Here are the issues I hit and how I resolved them:
Issue 1: certoraSuiProver.py not found
The Certora docs reference certoraSuiProver.py, but the actual installed binary is certoraSuiProver (no .py extension). It installs to your Python user scripts directory:
pip3 show -f certora-cli | grep certoraSui
// Add to PATH
export PATH="$HOME/Library/Python/3.9/bin:$PATH"
Issue 2: Rule names must be fully qualified with address
When filtering rules with --rule, you can't use spec::module::rule_name. Certora expects the on-chain address:
--rule "spec::emode_immutability::emode_never_changes"
// CORRECT
--rule "0x0::emode_immutability::emode_never_changes"
Issue 3: field_access must return a reference type
This was the most subtle error. The prover returned:
ERROR: Field accessor function 0x0::emode_immutability::get_emode_group must return a reference type.
The fix: field_access native functions must return &T, not T. I changed the return type from u8 to &u8 and added dereference operators (*) in the rules:
native fun get_emode_group(...): u8;
let emode = get_emode_group(obligation);
// CORRECT
native fun get_emode_group(...): &u8;
let emode = *get_emode_group(obligation);
Issue 4: CLI version mismatch
With certora-cli v8.6.3, jobs were failing silently in ~2 seconds. Upgrading to v8.8.1 resolved prover compatibility issues:
Part 4 — The Results
After fixing all the issues, I submitted the final run:
--rule "0x0::emode_immutability::emode_never_changes" \
"0x0::emode_immutability::emode_sanity"
The results came back all green:
| Target Function | Assertion | Sanity | Result |
|---|---|---|---|
accrue_interest |
PASSED | PASSED | VERIFIED |
deposit_ctoken |
PASSED | PASSED | VERIFIED |
withdraw_ctokens |
PASSED | PASSED | VERIFIED |
try_borrow_asset |
PASSED | PASSED | VERIFIED |
repay_debt |
PASSED | PASSED | VERIFIED |
unsafe_repay_debt_only |
PASSED | PASSED | VERIFIED |
emode_sanity |
— | SAT | VERIFIED |
What "VERIFIED" means: The Certora prover mathematically proved that no possible sequence of calls to any obligation-mutating function — with any possible arguments, in any possible state — can change the emode_group field. This isn't test coverage. It's exhaustive formal verification across the entire state space.
The rule_not_vacuous_tac (sanity) checks passing for every target confirms these aren't vacuous proofs — the prover actually explored meaningful, reachable states. And emode_sanity passing as SAT confirms that obligations with non-zero eMode groups exist, so we're not verifying dead code.
What This Proves (and What It Doesn't)
This verification mathematically guarantees that:
- No single operation changes emode_group. Deposit, withdraw, borrow, repay, interest accrual — none of them touch the field.
- No composition of operations changes it either. Since each individual operation preserves the invariant, any sequence of operations in a PTB (including flash loan callbacks) also preserves it. Compositionality is free.
- The proof is not vacuous. The sanity checks confirm the prover explored reachable states with non-trivial eMode assignments.
What it doesn't prove: This verification covers the obligation-level functions. It does not cover potential bugs in the eMode assignment at creation time (e.g., could a user create an obligation with an invalid eMode group?). That's a separate invariant to verify.
Key Takeaways for Move Security Researchers
- Certora now supports Sui Move. The tooling is new but functional. If you've used Certora for Solidity, the mental model transfers — rules, targets, parametric verification — with Move-specific syntax differences.
field_accessreturns references. This tripped me up. Allfield_accessnative functions must return&T, notT. Dereference with*in your rules.- Rule names use the address, not the module name. The CLI expects
0x0::module::rule, notspec::module::rule. - Parametric rules are the killer feature. One rule verifying an invariant across ALL functions is more valuable than six individual rules. It's future-proof — if someone adds a 7th obligation-mutating function, just add one
target()line. - Keep your CLI version current. Older CLI versions can fail silently with non-descriptive errors. Always upgrade to the latest:
pip3 install certora-cli --upgrade. - Formal verification complements manual auditing. It doesn't replace reading the code. I found the protocol's architecture, identified the invariant, and understood the attack surface through manual review. Formal verification then proved what manual review suspected.
Formal verification on Sui Move is no longer theoretical — it's practical, accessible, and powerful. If you're auditing lending protocols, the exchange rate, cToken supply, and eMode invariants are the first properties worth proving.
Tool: Certora Sui Prover (certora-cli v8.8.1)
Invariant verified: emode_group immutability after obligation creation
Result: Mathematically proved across 6 target functions + sanity checks
Spec source: spec/sources/emode_immutability.move
Follow: @thepantherplus
Disclaimer: This article is for educational purposes only. The protocol name and specific details have been generalized. The formal verification results shown are from a real engagement, but this article does not constitute security advice. Formal verification proves properties about a mathematical model of the code — it does not guarantee the absence of all bugs. Always combine formal methods with manual review, fuzzing, and testing. The Certora Sui Prover is relatively new tooling; verify its current capabilities and limitations before relying on it for production audits. If any information here is outdated or incorrect, please reach out on X so I can update this article accordingly.