-
Notifications
You must be signed in to change notification settings - Fork 51
[Certora] liquidate maxRepaid restores health #1083
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
23 commits
Select commit
Hold shift + click to select a range
768cd6d
[Certora] Prove liquidate at RCF cap restores health (single-collateral)
claude b6ea78b
[Certora] Make liquidateAtCapRestoresHealth tractable (fix SMT timeout)
claude 8f69130
[Certora] Attempt concrete discharge of the maxDebt-drop bound
claude b8ade92
[Certora] Fix type error in MaxDebtDropBound lemma
claude 728d7c5
[Certora] Decompose MaxDebtDropBound into concrete sub-lemmas + ghost…
claude afd9682
[Certora] Harden MaxDebtDropBound composition: isolate nonlinear steps
claude 8cee65e
[Certora] Collapse maxRepaidHealthy proof into a single self-containe…
claude d0084d8
Merge remote-tracking branch 'origin/main' into claude/certora-max-re…
MathisGD b5281c1
clean
MathisGD bdf6467
iterate
MathisGD 6488be7
Merge branch 'main' into claude/certora-max-repaid-healthy
MathisGD 9a6bb8a
Update certora/specs/MaxRepaidHealthy.spec
MathisGD 616c217
[Certora] Apply thumbed-up review comments on MaxRepaidHealthy
claude 3c7d2b5
Merge branch 'main' into claude/certora-max-repaid-healthy
claude 2311373
[Certora] Drop contract-line references from maxRepaidFor's comment
claude ad0e410
Use MulDivAxioms.
jochencertora 1dbe743
Update certora/specs/MaxRepaidHealthy.spec
jhoenicke af63255
Rename cur -> old
jochencertora db75b6e
Merge branch 'main' into claude/certora-max-repaid-healthy
MathisGD b67df3c
[Certora] Re-add rocq/maxRepaidHealthy.v
claude ee0e9a5
Fix formatting for forge fmt and CVL linter
claude f037e30
docs(certora): document MaxRepaidHealthy.spec
claude 2c86395
Apply batched suggestions from code review
MathisGD File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| { | ||
| "files": [ | ||
| "certora/helpers/MidnightWrapper.sol", | ||
| "certora/helpers/Havoc.sol" | ||
| ], | ||
| "parametric_contracts": [ | ||
| "MidnightWrapper" | ||
| ], | ||
| "verify": "MidnightWrapper:certora/specs/MaxRepaidHealthy.spec", | ||
| "solc": "solc-0.8.34", | ||
| "solc_via_ir": true, | ||
| "solc_evm_version": "osaka", | ||
| "optimistic_loop": true, | ||
| "loop_iter": 2, | ||
| "optimistic_hashing": true, | ||
| "hashing_length_bound": 2048, | ||
| "prover_args": [ | ||
| "-destructiveOptimizations twostage", | ||
| "-backendStrategy singleRace", | ||
| "-smt_useLIA false", | ||
| "-smt_useNIA true", | ||
| "-depth 0", | ||
| "-mediumTimeout 60", | ||
| "-timeout 7200", | ||
| "-s [z3:def{randomSeed=1},z3:def{randomSeed=2},z3:def{randomSeed=3},z3:def{randomSeed=4},z3:def{randomSeed=5},z3:def{randomSeed=6},z3:def{randomSeed=7},z3:def{randomSeed=8},z3:def{randomSeed=9},z3:def{randomSeed=10}]" | ||
| ], | ||
| "smt_timeout": 7200, | ||
| "msg": "Midnight MaxRepaidHealthy" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,205 @@ | ||
| // SPDX-License-Identifier: GPL-2.0-or-later | ||
| // Copyright (c) 2026 Morpho Association | ||
|
|
||
| import "BitmapSummaries.spec"; | ||
| import "MulDivAxioms.spec"; | ||
|
|
||
| methods { | ||
| function multicall(bytes[]) external => HAVOC_ALL DELETE; | ||
|
|
||
| function collateral(bytes32 id, address user, uint256) external returns (uint128) envfree; | ||
| function debt(bytes32 id, address user) external returns (uint128) envfree; | ||
| function isHealthyNoBitmap(Midnight.Market, bytes32, address) external returns (bool) envfree; | ||
| function maxRepaidFor(Midnight.Market, bytes32, uint256, address) external returns (uint256) envfree; | ||
|
|
||
| // Assumption: price does not change during the rule (same value in maxRepaidFor, in liquidate and in the | ||
| // post-state isHealthyNoBitmap). Deterministic per oracle address, as in Healthiness.spec. | ||
| function _.price() external => summaryPrice(calledContract) expect(uint256); | ||
|
|
||
| // The three summaries below do not restrict the verified behaviours: | ||
| // - tickToPrice: NONDET havocs the return value, which is an over-approximation (it allows every tick | ||
| // price, including the real one). Tick prices only feed the order-book accounting, never the health | ||
| // computation this rule reasons about, so losing that information costs nothing. | ||
| // - toId: replaces the keccak derivation by a ghost that is only required to be deterministic and | ||
| // injective on the pinned market. Both hold for the real derivation up to hash collisions, which is | ||
| // the standing assumption everywhere ids are summarized (see Healthiness.spec). | ||
| // - storeInCode: NONDET havocs the returned address. The function only mirrors the market into code for | ||
| // cheap retrieval; the position and market storage the rule reads is untouched, so over-approximating | ||
| // the address it returns cannot hide a counterexample. | ||
| function TickLib.tickToPrice(uint256 tick) internal returns (uint256) => NONDET; | ||
| function IdLib.toId(Midnight.Market memory market) internal returns (bytes32) => summaryToId(market); | ||
| function IdLib.storeInCode(Midnight.Market memory) internal returns (address) => NONDET; | ||
|
claude[bot] marked this conversation as resolved.
|
||
|
|
||
| // Summarizing mulDivDown and mulDivUp by unconstrained deterministic ghosts adds no assumption about | ||
| // mulDiv: the ghosts are arbitrary, and the summaries revert on a nondeterministic overflow flag, so | ||
| // every real mulDiv behaviour is still allowed. All the arithmetic the rule actually needs is required | ||
| // explicitly below, one ground instance per rule proved over the concrete mulDiv in MulDiv.spec. | ||
| function UtilsLib.mulDivDown(uint256 x, uint256 y, uint256 d) internal returns (uint256) => summaryMulDivDown(x, y, d); | ||
| function UtilsLib.mulDivUp(uint256 x, uint256 y, uint256 d) internal returns (uint256) => summaryMulDivUp(x, y, d); | ||
|
|
||
| // maxLif is deterministic for each (lltv, liquidationCursor) pair. | ||
| function maxLif(uint256 lltv, uint256 liquidationCursor) internal returns (uint256) => maxLifGhost(lltv, liquidationCursor); | ||
|
|
||
| // Unresolved callbacks and token calls use AUTO/HAVOC_ECF, which models non-reentrant callees. | ||
|
MathisGD marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /// SUMMARY /// | ||
|
|
||
| definition WAD() returns uint256 = 10 ^ 18; | ||
|
|
||
| definition ORACLE_PRICE_SCALE() returns uint256 = 10 ^ 36; | ||
|
|
||
| definition WAD_SQUARED() returns uint256 = 10 ^ 36; | ||
|
|
||
| persistent ghost summaryPrice(address) returns uint256; | ||
|
|
||
| function summaryMulDivDown(uint256 a, uint256 b, uint256 d) returns uint256 { | ||
| if (d == 0 || a * b >= 2 ^ 256) { | ||
| revert(); | ||
| } | ||
| return require_uint256(ghostMulDivDown(a, b, d)); | ||
| } | ||
|
|
||
| function summaryMulDivUp(uint256 a, uint256 b, uint256 d) returns uint256 { | ||
| if (d == 0 || a * b + d - 1 >= 2 ^ 256) { | ||
| revert(); | ||
| } | ||
| return require_uint256(ghostMulDivUp(a, b, d)); | ||
| } | ||
|
|
||
| // Pin every field that contributes to the market id, making the toId summary deterministic and injective. | ||
|
|
||
| persistent ghost address globalMarketLoanToken; | ||
|
|
||
| persistent ghost uint256 globalMarketChainId; | ||
|
|
||
| // Exactly two collaterals is not a restriction on the result. Liquidating touches a single collateral, so the | ||
| // whole contribution of every other collateral to maxDebt enters the reasoning as one arbitrary non-negative | ||
| // value, and one extra collateral with an arbitrary amount, price and LLTV already realizes every such value. | ||
| // The second collateral therefore plays the role of the arbitrary otherCollatContribution of the Rocq proof, | ||
| // and a market with more collaterals is covered by the same argument. | ||
| persistent ghost uint256 globalMarketCollateralLength { | ||
| axiom globalMarketCollateralLength == 2; | ||
| } | ||
|
|
||
| persistent ghost mapping(uint256 => address) globalMarketCollateralOracle; | ||
|
|
||
| persistent ghost mapping(uint256 => address) globalMarketCollateralToken; | ||
|
|
||
| persistent ghost mapping(uint256 => uint256) globalMarketCollateralLLTV; | ||
|
|
||
| persistent ghost mapping(uint256 => uint256) globalMarketCollateralLiquidationCursor; | ||
|
|
||
| persistent ghost maxLifGhost(uint256, uint256) returns uint256; | ||
|
|
||
| persistent ghost uint256 globalMarketMaturity; | ||
|
|
||
| persistent ghost uint256 globalMarketRcfThreshold; | ||
|
|
||
| persistent ghost address globalMarketEnterGate; | ||
|
|
||
| persistent ghost address globalMarketLiquidatorGate; | ||
|
|
||
| persistent ghost bytes32 globalId; | ||
|
|
||
| definition collateralMatches(Midnight.Market market, uint256 index) returns bool = (index < globalMarketCollateralLength => market.collateralParams[index].oracle == globalMarketCollateralOracle[index] && market.collateralParams[index].token == globalMarketCollateralToken[index] && market.collateralParams[index].lltv == globalMarketCollateralLLTV[index] && market.collateralParams[index].liquidationCursor == globalMarketCollateralLiquidationCursor[index]); | ||
|
|
||
| function equalsGlobalMarket(Midnight.Market market) returns (bool) { | ||
| return market.chainId == globalMarketChainId && market.midnight == currentContract && market.loanToken == globalMarketLoanToken && market.collateralParams.length == globalMarketCollateralLength && collateralMatches(market, 0) && collateralMatches(market, 1) && market.maturity == globalMarketMaturity && market.rcfThreshold == globalMarketRcfThreshold && market.enterGate == globalMarketEnterGate && market.liquidatorGate == globalMarketLiquidatorGate; | ||
| } | ||
|
|
||
| function getGlobalMarket() returns (Midnight.Market) { | ||
| Midnight.Market market; | ||
| require equalsGlobalMarket(market), "get global market"; | ||
| return market; | ||
| } | ||
|
|
||
| function summaryToId(Midnight.Market market) returns (bytes32) { | ||
| bytes32 id; | ||
| if (equalsGlobalMarket(market)) { | ||
| require id == globalId, "toId() is deterministic"; | ||
| } else { | ||
| require id != globalId, "toId() is injective"; | ||
| } | ||
| return id; | ||
| } | ||
|
|
||
| /// RULE /// | ||
|
|
||
| // In a two-collateral market, liquidating at the amount computed by maxRepaidFor leaves the position healthy. | ||
| // The call uses normal mode and covers the strictly unhealthy and health-boundary cases. See the | ||
| // globalMarketCollateralLength axiom for why two collaterals is general enough. | ||
| rule liquidateAtCapRestoresHealth(env e, uint256 collateralIndex, address borrower, address receiver, address callback, bytes data) { | ||
| Midnight.Market globalMarket = getGlobalMarket(); | ||
|
|
||
| uint256 collatBefore = collateral(globalId, borrower, collateralIndex); | ||
| uint256 debtBefore = debt(globalId, borrower); | ||
|
|
||
| // This rule checks that using `repaidUnits == maxRepaid` is enough to put the account healthy. This means that the RCF doesn't prevent to put the position back to health. | ||
| uint256 repaidUnits = maxRepaidFor(globalMarket, globalId, collateralIndex, borrower); | ||
|
|
||
| // maxRepaidFor's non-reverting collateral lookup establishes collateralIndex < 2. | ||
| uint256 otherIndex = assert_uint256(1 - collateralIndex); | ||
|
MathisGD marked this conversation as resolved.
|
||
| uint256 otherCollatBefore = collateral(globalId, borrower, otherIndex); | ||
| uint256 otherLltv = globalMarketCollateralLLTV[otherIndex]; | ||
| uint256 otherPrice = summaryPrice(globalMarket.collateralParams[otherIndex].oracle); | ||
|
|
||
| uint256 seizedOut; | ||
| uint256 repaidOut; | ||
| seizedOut, repaidOut = liquidate(e, globalMarket, collateralIndex, 0, repaidUnits, borrower, false, receiver, callback, data); | ||
|
MathisGD marked this conversation as resolved.
|
||
|
|
||
| uint256 collatAfter = assert_uint256(collatBefore - seizedOut); | ||
| bool isHealthyAfter = isHealthyNoBitmap(globalMarket, globalId, borrower); | ||
|
|
||
| /// MAX-DEBT DROP BOUND /// | ||
| // Establish oldContrib - newContrib <= maxDebtDropBound. When it seizes, liquidate computes | ||
| // seizedOut = floor(floor(repaidUnits * lif / WAD) * ORACLE_PRICE_SCALE / price), matching the ghost terms | ||
| // below. Each require is one ground instance of a rule proved in MulDiv.spec. | ||
|
|
||
| uint256 lltv = globalMarketCollateralLLTV[collateralIndex]; | ||
| uint256 lif = maxLifGhost(lltv, globalMarketCollateralLiquidationCursor[collateralIndex]); | ||
| mathint maxSeizedValue = ghostMulDivDown(repaidUnits, lif, WAD()); | ||
| uint256 price = summaryPrice(globalMarket.collateralParams[collateralIndex].oracle); | ||
|
|
||
| // By that same computation, seizedOut == ghostMulDivDown(maxSeizedValue, ORACLE_PRICE_SCALE(), price). | ||
| mathint oldCollatValue = ghostMulDivDown(collatBefore, price, ORACLE_PRICE_SCALE()); | ||
| mathint newCollatValue = ghostMulDivDown(collatAfter, price, ORACLE_PRICE_SCALE()); | ||
|
|
||
| mathint oldContrib = ghostMulDivDown(oldCollatValue, lltv, WAD()); | ||
| mathint newContrib = ghostMulDivDown(newCollatValue, lltv, WAD()); | ||
|
|
||
| mathint lifTimesLltv = lif * lltv; | ||
| mathint maxDebtDropBound = ghostMulDivUp(repaidUnits, lifTimesLltv, WAD_SQUARED()); | ||
|
|
||
| // L1: seizedOut * price <= maxSeizedValue | ||
| require axiomMathMulDivInverseUpDown(maxSeizedValue, ORACLE_PRICE_SCALE(), price), "axiom"; | ||
|
|
||
| // L2: oldCollatValue <= newCollatValue + seizedOut * price | ||
| require axiomMathMulDivAddDownUp(collatAfter, seizedOut, price, ORACLE_PRICE_SCALE()), "axiom"; | ||
|
|
||
| // L3: oldCollatValue <= newCollatValue + maxSeizedValue => oldContrib <= newContrib + maxSeizedValue * lltv | ||
| require axiomMathMulDivDownMonotoneA(oldCollatValue, newCollatValue + maxSeizedValue, lltv, WAD()), "axiom"; | ||
| require axiomMathMulDivAddDownUp(newCollatValue, maxSeizedValue, lltv, WAD()), "axiom"; | ||
|
|
||
| // L4: maxSeizedValue * lltv <= maxDebtDropBound | ||
| require axiomMathMulDivDownUpComposition(repaidUnits, lif, lltv, WAD()), "axiom"; | ||
|
|
||
| // L1-L2 bound the collateral-value decrease by maxSeizedValue. L3 transports that bound through the LLTV | ||
| // contribution, and L4 bounds the composed rounding by maxDebtDropBound. | ||
|
|
||
| /// FINAL HEALTH BOUND /// | ||
| // repaidUnits is ceil(gap * WAD^2 / (WAD^2 - lif * lltv)). The two rounding facts below imply | ||
| // maxDebtDropBound <= repaidUnits - gap. Therefore the new max debt falls by no more than the amount | ||
| // repaid in excess of the old health gap. | ||
| mathint otherCollatValue = ghostMulDivDown(otherCollatBefore, otherPrice, ORACLE_PRICE_SCALE()); | ||
| mathint otherContrib = ghostMulDivDown(otherCollatValue, otherLltv, WAD()); | ||
| mathint maxDebtBefore = oldContrib + otherContrib; | ||
|
|
||
| mathint gap = debtBefore - maxDebtBefore; | ||
| mathint rcfDenominator = WAD_SQUARED() - lifTimesLltv; | ||
| require axiomMathMulDivUpRoundsUp(gap, WAD_SQUARED(), rcfDenominator), "axiom"; | ||
| mathint repaidExcess = repaidUnits - gap; | ||
| require axiomMathMulDivCeilLeOfMulGe(repaidUnits, lifTimesLltv, WAD_SQUARED(), repaidExcess), "axiom"; | ||
|
|
||
| assert isHealthyAfter; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.