Formal Verification of a Sui Move Lending Protocol — Part 2: Proving eMode Borrow Restrictions & Catching a Deposit Limit Bug
How I used Certora's Sui Prover to mathematically prove that only eMode-approved assets can be borrowed — and then turned the prover into a bug-finding weapon that caught a double-subtraction error in the deposit limit check.
Previously: eMode Immutability
In Part 1, I proved the first eMode invariant: once an Obligation is created with an eMode group, that assignment can never change. I verified this across all six obligation-mutating functions using Certora's parametric rules, field accessors, and target sanity checks.
But immutability alone isn't enough. An obligation locked to eMode group 1 (stablecoins) is only safe if the protocol actually enforces that restriction — preventing the user from borrowing assets outside their group. That's the second invariant.
The Second Invariant: eMode Borrow Restrictions
From the protocol's requirements:
Invariant: Only assets from the obligation's eMode group can be borrowed. No operation — other than the designated borrow path — should be able to introduce a new debt type into an obligation.
This is a harder property to verify than immutability. We're not checking that a single field stays constant — we're proving that the size of a nested data structure (the debt table inside the obligation) can only grow through one specific, gated code path.
Understanding the Architecture
The borrow flow in this protocol has a layered architecture. Let me trace it from the entry point down:
→ market::handle_borrow()
→ emode_registry.borrow_mut_emode(obligation.emode_group(), asset_name)
// ABORTS if asset not in emode group
→ obligation.try_borrow_asset(amount, borrow_index)
// Adds new debt type or increases existing debt
The critical gating happens at line 400 of market.move:
obligation.emode_group(),
asset_name,
);
And inside borrow_mut_emode in emode.move:
registry: &mut EModeGroupRegistry,
group: u8,
asset: TypeName,
): &mut EMode {
registry.ensure_group_exists(group);
let group = registry.groups.borrow_mut(group);
assert!(group.assets.supports_type(asset), /* abort */);
group.assets.load_mut_by_type(asset)
}
The assert! on line 275 is the enforcement point. If the asset isn't in the eMode group, the transaction aborts. No new debt is created.
The Verification Strategy
Here's the insight: try_borrow_asset in obligation.move is the only function that can add a new entry to the debt table. It's public(package), meaning only modules within the protocol package can call it. And the only production caller is market::handle_borrow, which gates every borrow through borrow_mut_emode.
So the proof is compositional:
- Step 1 (Formal verification): Prove that no obligation-mutating function except
try_borrow_assetcan increase the debt table size. This coversaccrue_interest,deposit_ctoken,withdraw_ctokens,repay_debt, andunsafe_repay_debt_only. - Step 2 (Code-level argument):
try_borrow_assetispublic(package)and only called fromhandle_borrow, which enforces eMode group membership viaborrow_mut_emode. - Conclusion: New debts can only enter through a path that verifies eMode group membership. The restriction holds.
Part 1 — Writing the eMode Borrow Restriction Spec
The Challenge: Chained Field Accessors
The debt count lives deep inside a nested structure:
→ .debts : WitTable<ObligationDebts, TypeName, Debt>
→ .table : Table<TypeName, Debt>
→ .size : u64
Three levels of nesting. Each level needs its own field_access.
In the eMode immutability spec, we needed one field_access to read Obligation.emode_group. Here we need three chained accessors to traverse from Obligation down to the table size. This was a new pattern I hadn't used before with the Certora Sui Prover.
The Full Spec
use cvlm::manifest::{rule, target, target_sanity, field_access, invoker};
use cvlm::asserts::{cvlm_assert, cvlm_satisfy};
use cvlm::function::Function;
// ---- chained field accessors ----
// Step 1: Obligation → debts (WitTable)
native fun get_debts(
obligation: &protocol::obligation::Obligation<...>,
): &protocol::wit_table::WitTable<...>;
// Step 2: WitTable → table (Table)
native fun get_debts_table(
debts: &protocol::wit_table::WitTable<...>,
): &sui::table::Table<TypeName, Debt>;
// Step 3: Table → size (u64)
native fun get_table_size(
table: &sui::table::Table<TypeName, Debt>,
): &u64;
The invoker and manifest register the same five non-borrow targets (deliberately excluding try_borrow_asset):
// Wire chained accessors
field_access(b"get_debts", b"debts");
field_access(b"get_debts_table", b"table");
field_access(b"get_table_size", b"size");
invoker(b"invoke");
// All obligation-mutating functions EXCEPT try_borrow_asset
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"repay_debt");
target(@protocol, b"obligation", b"unsafe_repay_debt_only");
target_sanity();
rule(b"no_new_debts_without_borrow");
rule(b"debt_count_sanity");
}
The core rule is elegant — read the debt table size before and after any non-borrow operation, and assert it doesn't increase:
obligation: &mut protocol::obligation::Obligation<...>,
target: Function,
) {
let size_before = *get_table_size(get_debts_table(get_debts(obligation)));
// Call any registered target with nondeterministic arguments
invoke(target, obligation);
let size_after = *get_table_size(get_debts_table(get_debts(obligation)));
// Non-borrow operations must never increase the debt count
cvlm_assert(size_after <= size_before);
}
Notice the chained dereference: get_table_size(get_debts_table(get_debts(obligation))). Each call returns a reference that feeds into the next accessor. The prover resolves this chain at verification time, giving it a precise model of the debt count.
Why exclude try_borrow_asset? Because it should be able to add debts — that's its job. We're proving that nothing else can. The eMode gating of try_borrow_asset is enforced by its sole caller, handle_borrow, which we verify through code-level analysis of the public(package) visibility and the borrow_mut_emode assertion.
Part 2 — The Results
I submitted the spec using a .conf file (a trick I learned to avoid shell quoting issues with the Certora CLI):
"files": [],
"rule": [
"0x0::emode_borrow_restriction::no_new_debts_without_borrow"
]
}
All green:
| Target Function | Assertion | Sanity | Result |
|---|---|---|---|
accrue_interest |
PASSED | PASSED | VERIFIED |
deposit_ctoken |
PASSED | PASSED | VERIFIED |
withdraw_ctokens |
PASSED | PASSED | VERIFIED |
repay_debt |
PASSED | PASSED | VERIFIED |
unsafe_repay_debt_only |
PASSED | PASSED | VERIFIED |
debt_count_sanity |
— | SAT | VERIFIED |
What this proves: No obligation-mutating function outside of try_borrow_asset can increase the number of debt types on an obligation. Combined with the code-level fact that try_borrow_asset is only callable through handle_borrow (which enforces eMode group membership), this compositionally proves: only assets from the eMode group can be borrowed.
The Compositional Argument
Let me be precise about why this is a complete proof:
For all f in {accrue_interest, deposit_ctoken, withdraw_ctokens,
repay_debt, unsafe_repay_debt_only}:
debt_count(f(obligation)) ≤ debt_count(obligation)
Code-level (Move visibility + assert):
try_borrow_asset is public(package)
→ Only callable within the protocol package
→ Only caller: market::handle_borrow
→ handle_borrow calls borrow_mut_emode(obligation.emode_group(), asset)
→ borrow_mut_emode asserts group.assets.supports_type(asset)
→ Aborts if asset not in eMode group
Conclusion: New debts can only be created for eMode-approved assets.
Part 3 — Turning the Prover Into a Bug Finder
After verifying the two eMode invariants, I had a working Certora setup and a growing comfort with the tooling. So I asked a different question: instead of proving that code is correct, could I use the prover to find bugs?
The technique is simple but powerful: write a spec that asserts the code is correct, and if the prover finds a violation, you've found a bug.
The Suspect: deposit_limit_breached
During manual review, I noticed something odd in reserve.move:
self.exchange_rate().mul_u64(self.total_supply)
}
public(package) fun deposit_limit_breached(self: &Reserve, increment: u64, limit: u64): bool {
let total_deposit_plus_interest = self.total_deposit_plus_interest();
total_deposit_plus_interest.ceil() + increment - self.cash_reserve.ceil() > limit
}
And the exchange rate:
if (self.total_supply == 0) { return float::from_quotient(1, 1) };
let numerator = self.cash_plus_borrows_minus_reserves();
let denominator = float::from(self.total_supply);
numerator.div(denominator)
}
Where cash_plus_borrows_minus_reserves computes debt + cash - cash_reserve.
Do you see it? Let me trace the math:
total_deposit = exchange_rate × total_supply
= debt + cash - cash_reserve
cash_reserve is already subtracted here
= (debt + cash - cash_reserve) + increment - cash_reserve
= debt + cash - 2 × cash_reserve + increment
cash_reserve subtracted TWICE
The Bug: cash_reserve is subtracted once inside exchange_rate() (via cash_plus_borrows_minus_reserves) and then subtracted again explicitly in deposit_limit_breached. The deposit limit is under-enforced by exactly cash_reserve.
The correct check should be:
total_deposit_plus_interest.ceil() + increment > limit
// BUGGY: subtracts cash_reserve a second time
total_deposit_plus_interest.ceil() + increment - self.cash_reserve.ceil() > limit
The Impact
When cash_reserve is non-zero (which happens whenever the protocol has accumulated interest from the reserve factor), the deposit limit check is too lenient. Users can deposit up to cash_reserve more than the configured limit allows. The larger the accumulated protocol revenue, the wider the gap.
Part 4 — Proving the Bug Exists with Certora
Manual analysis identified the bug, but I wanted mathematical certainty. So I wrote a spec that asserts the two formulas are equivalent — knowing the prover should produce a counterexample proving they're not.
The Spec
use cvlm::manifest::{rule, field_access};
use cvlm::asserts::{cvlm_assert, cvlm_satisfy};
// ---- field accessors for Reserve fields ----
native fun get_cash(reserve: &Reserve<...>): &u64;
native fun get_debt(reserve: &Reserve<...>): &Decimal;
native fun get_cash_reserve(reserve: &Reserve<...>): &Decimal;
native fun get_total_supply(reserve: &Reserve<...>): &u64;
native fun get_decimal_raw(decimal: &Decimal): &u256;
The key insight: I needed a get_decimal_raw accessor to read the inner value field from the Decimal struct. This is another chained accessor — first read the Decimal field from Reserve, then read the raw u256 value from the Decimal.
reserve: &Reserve<...>,
) {
let cash = (*get_cash(reserve) as u256);
let debt_raw = *get_decimal_raw(get_debt(reserve));
let cr_raw = *get_decimal_raw(get_cash_reserve(reserve));
let total_supply = (*get_total_supply(reserve) as u256);
let wad: u256 = 1_000_000_000_000_000_000;
let cash_wad = cash * wad;
// Skip degenerate states
if (total_supply == 0) return;
if (cash_wad + debt_raw < cr_raw) return;
// total_deposit = debt + cash×WAD - cash_reserve
let total_deposit = debt_raw + cash_wad - cr_raw;
// CORRECT: use total_deposit directly
let correct_effective = total_deposit;
// BUGGY: subtract cash_reserve a second time
if (total_deposit < cr_raw) return;
let buggy_effective = total_deposit - cr_raw;
// This SHOULD FAIL when cash_reserve > 0
cvlm_assert(correct_effective == buggy_effective);
}
Running the Prover
"files": [],
"prover_version": "master",
"rule": [
"0x0::deposit_limit_double_sub::deposit_limit_formulas_equivalent",
"0x0::deposit_limit_double_sub::cash_reserve_positive_reachable"
],
"prover_args": ["-smt_groundQuantifiers true"],
"server": "production",
"msg": "deposit_limit_double_sub"
}
The Counterexample
The prover came back in under 2 seconds with a VIOLATED result and a concrete counterexample:
| Rule | Result | Time |
|---|---|---|
deposit_limit_formulas_equivalent |
VIOLATED | 1s |
cash_reserve_positive_reachable |
SAT (VERIFIED) | 1s |
The prover produced this counterexample:
debt.value: 2,
cash_reserve.value: 1,
cash: 0,
total_supply: 1,
}
// Plugging in:
total_deposit = debt + cash×WAD - cash_reserve = 2 + 0 - 1 = 1
correct_effective = 1
buggy_effective = 1 - 1 = 0
1 ≠ 0 → ASSERTION FAILS
The prover mathematically proved that the two formulas diverge whenever cash_reserve > 0. The smallest counterexample is trivial: cash_reserve = 1 makes the buggy formula evaluate to 0 while the correct formula evaluates to 1. The deposit limit is under-enforced by exactly cash_reserve.
The cash_reserve_positive_reachable sanity check passing as SAT confirms this isn't an academic edge case — any protocol with accumulated interest revenue will have a non-zero cash_reserve, which means the bug is active in production.
What This Proves (and What It Doesn't)
Across this engagement, we verified three distinct properties:
- eMode group immutability (Part 1): Proved that
emode_groupcannot change after obligation creation. Verified across all 6 obligation-mutating functions. - eMode borrow restriction (this post): Proved that only the designated borrow path can add new debt types. Combined with code-level analysis of
borrow_mut_emode, this guarantees obligations can only borrow eMode-approved assets. - Deposit limit double-subtraction bug: Used the prover as a bug finder to mathematically confirm that
deposit_limit_breachedsubtractscash_reservetwice, under-enforcing the limit.
An important pattern: The same tooling that proves correctness can also prove incorrectness. Writing a spec that should pass — and seeing it fail with a concrete counterexample — is one of the most powerful ways to confirm a bug exists. It transforms "I think there's a bug" into "the SMT solver mathematically proved the bug exists."
Key Takeaways
- Chained
field_accessworks. You can traverse nested structs (Obligation → WitTable → Table → size) by declaring multiple field accessors. Each one reads one level deep, and you chain them in your rules. - Compositional verification is practical. You don't need to verify everything in one monolithic spec. Prove that only one function can modify a property (formal), then show that function is properly gated (code-level). The combination is rigorous.
- Use
.conffiles for the CLI. The Certora CLI has shell quoting issues with complex--rulearguments. A.conffile (JSON) avoids all of that and is what the prover generates internally anyway. - Formal verification finds real bugs. The deposit limit double-subtraction wasn't hypothetical. The prover found a concrete counterexample in under 2 seconds. Writing specs for "obviously correct" code is how you discover it's not.
prover_argsmatter. For the deposit limit spec, I needed-smt_groundQuantifiers trueto help the SMT solver reason about the arithmetic. Default settings may time out on specs with complex math.- Exclude functions intentionally. In the borrow restriction spec,
try_borrow_assetwas deliberately excluded from the targets. The spec proves the negative — no other function can do whattry_borrow_assetdoes — and the gating logic is verified separately.
Formal verification isn't just about proving that correct code is correct. It's about finding the code that isn't. The prover doesn't get tired, doesn't skip edge cases, and doesn't make assumptions. If there's a counterexample, it will find it.
Tool: Certora Sui Prover (certora-cli v8.8.1)
Invariant 1: eMode borrow restriction — VERIFIED across 5 target functions
Bug found: deposit_limit_breached double-subtraction — VIOLATED (counterexample produced)
Specs: emode_borrow_restriction.move, deposit_limit_double_sub.move
Part 1: eMode Immutability Verification
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. If any information here is outdated or incorrect, please reach out on X so I can update this article accordingly.