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.
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.
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.
The borrow flow in this protocol has a layered architecture. Let me trace it from the entry point down:
The critical gating happens at line 400 of market.move:
And inside borrow_mut_emode in emode.move:
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.
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:
try_borrow_asset can increase the debt table size. This covers accrue_interest, deposit_ctoken, withdraw_ctokens, repay_debt, and unsafe_repay_debt_only.try_borrow_asset is public(package) and only called from handle_borrow, which enforces eMode group membership via borrow_mut_emode.The debt count lives deep inside a nested structure:
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 invoker and manifest register the same five non-borrow targets (deliberately excluding try_borrow_asset):
The core rule is elegant — read the debt table size before and after any non-borrow operation, and assert it doesn't increase:
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.
I submitted the spec using a .conf file (a trick I learned to avoid shell quoting issues with the Certora CLI):
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.
Let me be precise about why this is a complete proof:
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.
deposit_limit_breachedDuring manual review, I noticed something odd in reserve.move:
And the exchange rate:
Where cash_plus_borrows_minus_reserves computes debt + cash - cash_reserve.
Do you see it? Let me trace the math:
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:
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.
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 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.
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:
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.
Across this engagement, we verified three distinct properties:
emode_group cannot change after obligation creation. Verified across all 6 obligation-mutating functions.borrow_mut_emode, this guarantees obligations can only borrow eMode-approved assets.deposit_limit_breached subtracts cash_reserve twice, 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."
field_access works. 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..conf files for the CLI. The Certora CLI has shell quoting issues with complex --rule arguments. A .conf file (JSON) avoids all of that and is what the prover generates internally anyway.prover_args matter. For the deposit limit spec, I needed -smt_groundQuantifiers true to help the SMT solver reason about the arithmetic. Default settings may time out on specs with complex math.try_borrow_asset was deliberately excluded from the targets. The spec proves the negative — no other function can do what try_borrow_asset does — 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.
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.