← Back to Blog

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.

✍ 0xTheBlackPanther 📅 Mar 2026 ⏱ 12 min read 🏷 Formal Verification, Sui, Move, Certora

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.

// Obligation struct in obligation.move public struct Obligation<phantom MarketType> has key, store {
    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:

Sui Prover by Asymptotic Native Move Prover for Sui
Open source on GitHub
Spec blocks inline with Move code
Still maturing for Sui specifically
Local execution only
Certora Sui Prover Certora's prover extended for Move
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:

// Install Certora CLI pip3 install certora-cli

// 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:

// Directory structure sui-move-contract/contracts/
    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:

// spec/Move.toml [package]
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:

// spec/sources/smoke_test.move module spec::smoke_test;

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:

// Run from the spec/ directory certoraSuiProver --server production --prover_version "master"

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:

// Functions in obligation.move that take &mut Obligation accrue_interest // updates debt with new borrow index
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:

  1. field_access — Reading private fields. The emode_group field has no public setter, and its getter is public(package) (not callable from our spec module). Certora's field_access manifest function lets us declare a native accessor that the prover wires directly to the struct field.
  2. 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.
  3. 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

// spec/sources/emode_immutability.move module spec::emode_immutability;

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:

How the parametric rule works For each of the 6 registered target functions:
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:

// Find it
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:

// WRONG
--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:

// WRONG
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:

pip3 install certora-cli --upgrade

Part 4 — The Results

After fixing all the issues, I submitted the final run:

certoraSuiProver --server production --prover_version "master" \
  --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:

  1. No single operation changes emode_group. Deposit, withdraw, borrow, repay, interest accrual — none of them touch the field.
  2. 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.
  3. 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

  1. 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.
  2. field_access returns references. This tripped me up. All field_access native functions must return &T, not T. Dereference with * in your rules.
  3. Rule names use the address, not the module name. The CLI expects 0x0::module::rule, not spec::module::rule.
  4. 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.
  5. 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.
  6. 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.

Protocol: Compound-style lending market on Sui
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.

← Back to Blog