From da4ce075419ab1ea89252bcbf3f3835b66242e96 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Tue, 5 May 2026 15:16:22 +0200 Subject: [PATCH 01/53] modifs to prove countBits <= 10 --- certora/helpers/Utils.sol | 4 ++++ certora/specs/Bitmap.spec | 11 +++++++++++ certora/specs/BitmapSummaries.spec | 13 +++++++++++++ certora/specs/CollateralBitmap.spec | 9 +++++++++ 4 files changed, 37 insertions(+) diff --git a/certora/helpers/Utils.sol b/certora/helpers/Utils.sol index 9961c2f4f..c54f11f31 100644 --- a/certora/helpers/Utils.sol +++ b/certora/helpers/Utils.sol @@ -27,6 +27,10 @@ contract Utils { return UtilsLib.msb(bitmap); } + function countBits(uint128 bitmap) external pure returns (uint256) { + return UtilsLib.countBits(bitmap); + } + function emptyOffer() external pure returns (Offer memory) { Offer memory offer; return offer; diff --git a/certora/specs/Bitmap.spec b/certora/specs/Bitmap.spec index d7b93f08a..9d54ec811 100644 --- a/certora/specs/Bitmap.spec +++ b/certora/specs/Bitmap.spec @@ -5,6 +5,7 @@ methods { function setBit(uint128 bitmap, uint256 bit) external returns (uint128) envfree; function clearBit(uint128 bitmap, uint256 bit) external returns (uint128) envfree; function msb(uint128 bitmap) external returns (uint256) envfree; + function countBits(uint128 bitmap) external returns (uint256) envfree; } /// RULES /// @@ -12,6 +13,7 @@ methods { rule zeroBitmapEmpty(uint256 bit) { bool isBitSet = getBit(0, bit); assert !isBitSet, "zero bitmap has no bit set"; + assert countBits(0) == 0, "zero bitmap has count zero"; } rule getBitmapOutOfRange(uint128 bitmap, uint256 bit) { @@ -25,13 +27,17 @@ rule setBitSetsBit(uint128 bitmap, uint256 bit) { require otherBit < 128, "bitmap is limited to 128 bits"; bool otherBefore = getBit(bitmap, otherBit); + bool wasSet = getBit(bitmap, bit); + uint256 countBefore = countBits(bitmap); uint128 bitmapAfter = setBit(bitmap, bit); bool otherAfter = getBit(bitmapAfter, otherBit); bool bitAfter = getBit(bitmapAfter, bit); + uint256 countAfter = countBits(bitmapAfter); assert bitAfter, "setBit sets the bit"; assert otherBit != bit => otherBefore == otherAfter, "setBit doesn't change other bits"; + assert countAfter == countBefore + (wasSet ? 0 : 1), "setBit increments count when bit was clear"; } rule clearBitClearsBit(uint128 bitmap, uint256 bit) { @@ -40,13 +46,17 @@ rule clearBitClearsBit(uint128 bitmap, uint256 bit) { require otherBit < 128, "bitmap is limited to 128 bits"; bool otherBefore = getBit(bitmap, otherBit); + bool wasSet = getBit(bitmap, bit); + uint256 countBefore = countBits(bitmap); uint128 bitmapAfter = clearBit(bitmap, bit); bool otherAfter = getBit(bitmapAfter, otherBit); bool bitAfter = getBit(bitmapAfter, bit); + uint256 countAfter = countBits(bitmapAfter); assert !bitAfter, "clearBit clears the bit"; assert otherBit != bit => otherBefore == otherAfter, "clearBit doesn't change other bits"; + assert countAfter == countBefore - (wasSet ? 1 : 0), "clearBit decrements count when bit was set"; } rule msbReturnsLargestSetBit(uint128 bitmap) { @@ -58,3 +68,4 @@ rule msbReturnsLargestSetBit(uint128 bitmap) { assert bitmap != 0 => getBit(bitmap, msbBit); assert bitmap != 0 && getBit(bitmap, otherBit) => otherBit <= msbBit; } + diff --git a/certora/specs/BitmapSummaries.spec b/certora/specs/BitmapSummaries.spec index 0041faec6..4e6dfd68a 100644 --- a/certora/specs/BitmapSummaries.spec +++ b/certora/specs/BitmapSummaries.spec @@ -4,6 +4,7 @@ methods { function UtilsLib.setBit(uint128 bitmap, uint256 bit) internal returns (uint128) => summarySetBit(bitmap, bit); function UtilsLib.clearBit(uint128 bitmap, uint256 bit) internal returns (uint128) => summaryClearBit(bitmap, bit); function UtilsLib.msb(uint128 bitmap) internal returns (uint256) => summaryMsb(bitmap); + function UtilsLib.countBits(uint128 bitmap) internal returns (uint256) => summaryCountBitsWrapper(bitmap); } /// SUMMARIES /// @@ -13,6 +14,17 @@ persistent ghost summaryGetBit(uint128, uint256) returns bool { axiom forall uint256 bit. !summaryGetBit(0, bit); } +persistent ghost summaryCountBits(uint128) returns mathint { + // see Bitmap.spec + axiom summaryCountBits(0) == 0; + // sanity bounds to be able to require_uint256(summaryCountBits(bitmap)) + axiom forall uint128 b. 0 <= summaryCountBits(b) && summaryCountBits(b) <= 128; +} + +function summaryCountBitsWrapper(uint128 bitmap) returns uint256 { + return require_uint256(summaryCountBits(bitmap)); +} + function summarySetBit(uint128 bitmap, uint256 bit) returns (uint128) { uint128 result; assert bit < 128; @@ -26,6 +38,7 @@ function summaryClearBit(uint128 bitmap, uint256 bit) returns (uint128) { assert bit < 128; require !summaryGetBit(result, bit), "see Bitmap.spec"; require forall uint256 otherBit. otherBit != bit && otherBit < 128 => summaryGetBit(result, otherBit) == summaryGetBit(bitmap, otherBit), "see Bitmap.spec"; + require summaryCountBits(result) == summaryCountBits(bitmap) - (summaryGetBit(bitmap, bit) ? 1 : 0), "see Bitmap.spec"; return result; } diff --git a/certora/specs/CollateralBitmap.spec b/certora/specs/CollateralBitmap.spec index 13e62d52c..fb10acecc 100644 --- a/certora/specs/CollateralBitmap.spec +++ b/certora/specs/CollateralBitmap.spec @@ -27,6 +27,8 @@ methods { /// SUMMARY /// +definition MAX_COLLATERALS_PER_BORROWER() returns uint256 = 10; + persistent ghost summaryMulDivDown(uint256, uint256, uint256) returns uint256 { /* proved in mulDivZero in MulDiv.spec */ axiom forall uint256 b. forall uint256 d. d > 0 => summaryMulDivDown(0, b, d) == 0; @@ -38,6 +40,11 @@ persistent ghost summaryMulDivUp(uint256, uint256, uint256) returns uint256; strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint256 collateralIndex) collateralIndex < 128 => (collateral(id, user, collateralIndex) != 0 <=> summaryGetBit(currentContract.position[id][user].activatedCollaterals, collateralIndex)); +// Check that the number of activated collaterals never exceeds MAX_COLLATERALS_PER_BORROWER. +// This bounds the while-loop iterations in isHealthy() and liquidate(). +strong invariant atMostMaxCollateralsBitsSet(bytes32 id, address user) + summaryCountBits(currentContract.position[id][user].activatedCollaterals) <= MAX_COLLATERALS_PER_BORROWER(); + // This shows that the real isHealthy returns true if and only if the isHealthy function // that does not use collateral bitmap returns true. We also check that the latter function // does not revert if isHealthy does not revert. @@ -55,3 +62,5 @@ rule isHealthyEquivalent(Midnight.Obligation obligation, bytes32 id, address bor assert !lastReverted; assert isHealthy1 == isHealthy2; } + + From 33e2dd75e4a56dac944bc595f8546ee85ec42741 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Tue, 5 May 2026 15:24:14 +0200 Subject: [PATCH 02/53] linter --- certora/specs/Bitmap.spec | 1 - certora/specs/BitmapSummaries.spec | 3 ++- certora/specs/CollateralBitmap.spec | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/certora/specs/Bitmap.spec b/certora/specs/Bitmap.spec index 9d54ec811..c4cf23b01 100644 --- a/certora/specs/Bitmap.spec +++ b/certora/specs/Bitmap.spec @@ -68,4 +68,3 @@ rule msbReturnsLargestSetBit(uint128 bitmap) { assert bitmap != 0 => getBit(bitmap, msbBit); assert bitmap != 0 && getBit(bitmap, otherBit) => otherBit <= msbBit; } - diff --git a/certora/specs/BitmapSummaries.spec b/certora/specs/BitmapSummaries.spec index 4e6dfd68a..ed91658e3 100644 --- a/certora/specs/BitmapSummaries.spec +++ b/certora/specs/BitmapSummaries.spec @@ -16,7 +16,8 @@ persistent ghost summaryGetBit(uint128, uint256) returns bool { persistent ghost summaryCountBits(uint128) returns mathint { // see Bitmap.spec - axiom summaryCountBits(0) == 0; + axiom summaryCountBits(0) == 0; + // sanity bounds to be able to require_uint256(summaryCountBits(bitmap)) axiom forall uint128 b. 0 <= summaryCountBits(b) && summaryCountBits(b) <= 128; } diff --git a/certora/specs/CollateralBitmap.spec b/certora/specs/CollateralBitmap.spec index fb10acecc..d8f141731 100644 --- a/certora/specs/CollateralBitmap.spec +++ b/certora/specs/CollateralBitmap.spec @@ -62,5 +62,3 @@ rule isHealthyEquivalent(Midnight.Obligation obligation, bytes32 id, address bor assert !lastReverted; assert isHealthy1 == isHealthy2; } - - From de7d87f16b910f8385acd7081fcd698e79cea787 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Wed, 6 May 2026 10:55:59 +0200 Subject: [PATCH 03/53] update --- certora/specs/CollateralBitmap.spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certora/specs/CollateralBitmap.spec b/certora/specs/CollateralBitmap.spec index 16768afd3..cd5e16afc 100644 --- a/certora/specs/CollateralBitmap.spec +++ b/certora/specs/CollateralBitmap.spec @@ -43,7 +43,7 @@ strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint25 // Check that the number of activated collaterals never exceeds MAX_COLLATERALS_PER_BORROWER. // This bounds the while-loop iterations in isHealthy() and liquidate(). strong invariant atMostMaxCollateralsBitsSet(bytes32 id, address user) - summaryCountBits(currentContract.position[id][user].activatedCollaterals) <= MAX_COLLATERALS_PER_BORROWER(); + summaryCountBits(currentContract.position[id][user].collateralBitmap) <= MAX_COLLATERALS_PER_BORROWER(); // This shows that the real isHealthy returns true if and only if the isHealthy function // that does not use collateral bitmap returns true. We also check that the latter function From 9e68d3a127950b628e52b7d8527c0ed201e407d3 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Wed, 6 May 2026 14:01:40 +0200 Subject: [PATCH 04/53] addition --- certora/specs/BitmapSummaries.spec | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/certora/specs/BitmapSummaries.spec b/certora/specs/BitmapSummaries.spec index ed91658e3..df8cdfdfc 100644 --- a/certora/specs/BitmapSummaries.spec +++ b/certora/specs/BitmapSummaries.spec @@ -20,6 +20,9 @@ persistent ghost summaryCountBits(uint128) returns mathint { // sanity bounds to be able to require_uint256(summaryCountBits(bitmap)) axiom forall uint128 b. 0 <= summaryCountBits(b) && summaryCountBits(b) <= 128; + + // consistency: any set bit implies a positive count + axiom forall uint128 b. forall uint256 bit. summaryGetBit(b, bit) => summaryCountBits(b) >= 1; } function summaryCountBitsWrapper(uint128 bitmap) returns uint256 { @@ -31,6 +34,7 @@ function summarySetBit(uint128 bitmap, uint256 bit) returns (uint128) { assert bit < 128; require summaryGetBit(result, bit), "see Bitmap.spec"; require forall uint256 otherBit. otherBit != bit && otherBit < 128 => summaryGetBit(result, otherBit) == summaryGetBit(bitmap, otherBit), "see Bitmap.spec"; + require summaryCountBits(result) == summaryCountBits(bitmap) + (summaryGetBit(bitmap, bit) ? 0 : 1), "see Bitmap.spec"; return result; } From 551fee7e09a99c4fa36ce55ddda16ea8626bcede Mon Sep 17 00:00:00 2001 From: lilCertora Date: Thu, 7 May 2026 11:54:47 +0200 Subject: [PATCH 05/53] Update certora/specs/BitmapSummaries.spec Co-authored-by: Quentin Garchery Signed-off-by: lilCertora --- certora/specs/BitmapSummaries.spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certora/specs/BitmapSummaries.spec b/certora/specs/BitmapSummaries.spec index df8cdfdfc..27c1f4fa5 100644 --- a/certora/specs/BitmapSummaries.spec +++ b/certora/specs/BitmapSummaries.spec @@ -14,7 +14,7 @@ persistent ghost summaryGetBit(uint128, uint256) returns bool { axiom forall uint256 bit. !summaryGetBit(0, bit); } -persistent ghost summaryCountBits(uint128) returns mathint { +persistent ghost summaryCountBits(uint128) returns uint256 { // see Bitmap.spec axiom summaryCountBits(0) == 0; From c7ed9d21fa452d86906fa3201895f36db6997d29 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Thu, 7 May 2026 13:38:13 +0200 Subject: [PATCH 06/53] rule addition in bitmap.spec --- certora/specs/Bitmap.spec | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/certora/specs/Bitmap.spec b/certora/specs/Bitmap.spec index c4cf23b01..063b4de47 100644 --- a/certora/specs/Bitmap.spec +++ b/certora/specs/Bitmap.spec @@ -59,6 +59,17 @@ rule clearBitClearsBit(uint128 bitmap, uint256 bit) { assert countAfter == countBefore - (wasSet ? 1 : 0), "clearBit decrements count when bit was set"; } +rule countBitsAtMost128(uint128 bitmap) { + uint256 count = countBits(bitmap); + assert count <= 128; +} + +rule countBitsPositiveWhenBitSet(uint128 bitmap, uint256 bit) { + require getBit(bitmap, bit), "bit is set"; + uint256 count = countBits(bitmap); + assert count > 0; +} + rule msbReturnsLargestSetBit(uint128 bitmap) { uint256 msbBit = msb(bitmap); uint256 otherBit; From 0adfc02f33e9c376d058a8666b7ad13533ae80df Mon Sep 17 00:00:00 2001 From: lilCertora Date: Sun, 10 May 2026 22:02:14 +0200 Subject: [PATCH 07/53] review modifs --- certora/specs/BitmapSummaries.spec | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/certora/specs/BitmapSummaries.spec b/certora/specs/BitmapSummaries.spec index 27c1f4fa5..5e04eb1cf 100644 --- a/certora/specs/BitmapSummaries.spec +++ b/certora/specs/BitmapSummaries.spec @@ -4,7 +4,7 @@ methods { function UtilsLib.setBit(uint128 bitmap, uint256 bit) internal returns (uint128) => summarySetBit(bitmap, bit); function UtilsLib.clearBit(uint128 bitmap, uint256 bit) internal returns (uint128) => summaryClearBit(bitmap, bit); function UtilsLib.msb(uint128 bitmap) internal returns (uint256) => summaryMsb(bitmap); - function UtilsLib.countBits(uint128 bitmap) internal returns (uint256) => summaryCountBitsWrapper(bitmap); + function UtilsLib.countBits(uint128 bitmap) internal returns (uint256) => summaryCountBits(bitmap); } /// SUMMARIES /// @@ -18,17 +18,13 @@ persistent ghost summaryCountBits(uint128) returns uint256 { // see Bitmap.spec axiom summaryCountBits(0) == 0; - // sanity bounds to be able to require_uint256(summaryCountBits(bitmap)) - axiom forall uint128 b. 0 <= summaryCountBits(b) && summaryCountBits(b) <= 128; + // see Bitmap.spec + axiom forall uint128 b. summaryCountBits(b) <= 128; - // consistency: any set bit implies a positive count + // see Bitmap.spec axiom forall uint128 b. forall uint256 bit. summaryGetBit(b, bit) => summaryCountBits(b) >= 1; } -function summaryCountBitsWrapper(uint128 bitmap) returns uint256 { - return require_uint256(summaryCountBits(bitmap)); -} - function summarySetBit(uint128 bitmap, uint256 bit) returns (uint128) { uint128 result; assert bit < 128; From 879852e2ceff9d9d61a8e3c1b6bd4d70a37db960 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Mon, 11 May 2026 11:28:59 +0200 Subject: [PATCH 08/53] add spec and conf --- certora/confs/LiquidationLiveness.conf | 23 ++++++ certora/specs/LiquidationLiveness.spec | 103 +++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 certora/confs/LiquidationLiveness.conf create mode 100644 certora/specs/LiquidationLiveness.spec diff --git a/certora/confs/LiquidationLiveness.conf b/certora/confs/LiquidationLiveness.conf new file mode 100644 index 000000000..947bd61aa --- /dev/null +++ b/certora/confs/LiquidationLiveness.conf @@ -0,0 +1,23 @@ +{ + "files": [ + "certora/helpers/Utils.sol", + "src/Midnight.sol" + ], + "verify": "Midnight:certora/specs/LiquidationLiveness.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": [ + "-splitParallel true", + "-destructiveOptimizations twostage", + "-mediumTimeout 60", + "-timeout 3600", + "-s", + "[z3:def{randomSeed=1},z3:def{randomSeed=2},z3:def{randomSeed=3},z3:def{randomSeed=4},z3:def{randomSeed=5}]" + ], + "msg": "Liquidation Liveness" +} diff --git a/certora/specs/LiquidationLiveness.spec b/certora/specs/LiquidationLiveness.spec new file mode 100644 index 000000000..40a0f1841 --- /dev/null +++ b/certora/specs/LiquidationLiveness.spec @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +import "BitmapSummaries.spec"; + +using Utils as Utils; + +methods { + function multicall(bytes[]) external => HAVOC_ALL DELETE; + + function isHealthy(Midnight.Obligation, bytes32, address) external returns (bool) envfree; + function obligationCreated(bytes32 id) external returns (bool) envfree; + function liquidationLocked(bytes32 id, address user) external returns (bool) envfree; + function Utils.hashObligation(Midnight.Obligation) external returns (bytes32) envfree; + + // Oracle: deterministic per oracle address so isHealthy() and liquidate()'s loop see the same prices. + function _.price() external => oraclePrice(calledContract) expect(uint256); + + // Deterministic toId so the obligation argument links to stored state. + function IdLib.toId(Midnight.Obligation memory obligation, uint256, address) internal returns (bytes32) => summaryToId(obligation); + function IdLib.storeInCode(Midnight.Obligation memory, uint256) internal returns (address) => NONDET; + + // PTA workaround for take(). + function UtilsLib.hashOffer(Midnight.Offer memory) internal returns (bytes32) => NONDET; + function UtilsLib.isLeaf(bytes32, bytes32, bytes32[] memory) internal returns (bool) => NONDET; + function TickLib.tickToPrice(uint256) internal returns (uint256) => NONDET; + + // Transfers: assumed well-behaved (no revert) — focus is on the protocol's own logic. + function SafeTransferLib.safeTransfer(address, address, uint256) internal => NONDET; + function SafeTransferLib.safeTransferFrom(address, address, address, uint256) internal => NONDET; +} + +/// HELPERS /// + +definition WAD() returns uint256 = 10 ^ 18; + +// Oracle prices are bounded by max_uint128, matching the uint128 collateral amounts. +persistent ghost oraclePrice(address) returns uint256 { + axiom forall address oracle. oraclePrice(oracle) <= max_uint128; +} + +function summaryToId(Midnight.Obligation obligation) returns (bytes32) { + return Utils.hashObligation(obligation); +} + +/// Debts can always be liquidated if expired +rule liquidateExpiredDoesNotRevert(env e, Midnight.Obligation obligation, address borrower, bytes data) { + bytes32 id = summaryToId(obligation); + + require data.length == 0, "no callback data"; + require obligationCreated(id), "obligation must be created"; + require obligation.liquidatorGate == 0, "no liquidator gate"; + require obligation.collateralParams.length > 0, "obligation has at least one collateral (touchObligation invariant)"; + require obligation.collateralParams.length <= 10, "loop bound to keep verification tractable"; + require !liquidationLocked(id, borrower), "liquidation not locked at transaction start"; + require currentContract.position[id][borrower].debt > 0, "borrower has debt to enter the bad-debt branch"; + require currentContract.position[id][borrower].debt <= currentContract.obligationState[id].totalUnits, "debt bounded by totalUnits (Midnight.spec)"; + require currentContract.obligationState[id].lossFactor < max_uint128, "lossFactor not saturated"; + require currentContract.obligationState[id].totalUnits > 0, "totalUnits > 0 (implied by debt > 0 above)"; + // touchObligation enforces maxLif == maxLif(lltv, cursor); the formula yields a value in [WAD, 2*WAD]. + require forall uint256 i. i < obligation.collateralParams.length => obligation.collateralParams[i].maxLif > 0 && obligation.collateralParams[i].maxLif <= 2 * WAD(), "WAD <= maxLif <= 2*WAD (touchObligation invariant)"; + // touchObligation's maxLif formula requires lltv < WAD; lltv = WAD gives maxLif = WAD. + require forall uint256 i. i < obligation.collateralParams.length => obligation.collateralParams[i].lltv <= WAD(), "lltv <= WAD (touchObligation invariant)"; + // depositCollateral/withdrawCollateral only set/clear bits for valid indices. + require forall uint256 bit. summaryGetBit(currentContract.position[id][borrower].collateralBitmap, bit) => bit < obligation.collateralParams.length, "bitmap bits within collateralParams bounds"; + require e.msg.value == 0, "Midnight is not payable"; + // Expiration requirement + require e.block.timestamp > obligation.maturity, "obligation has expired"; + + address zero = 0; + liquidate@withrevert(e, obligation, 0, 0, 0, borrower, borrower, zero, data); + + assert !lastReverted; +} + +/// Debts can always be liquidated if unhealthy +rule liquidateUnhealthyDoesNotRevert(env e, Midnight.Obligation obligation, address borrower, bytes data) { + bytes32 id = summaryToId(obligation); + + require data.length == 0, "no callback data"; + require obligationCreated(id), "obligation must be created"; + require obligation.liquidatorGate == 0, "no liquidator gate"; + require obligation.collateralParams.length > 0, "obligation has at least one collateral (touchObligation invariant)"; + require obligation.collateralParams.length <= 10, "loop bound to keep verification tractable"; + require !liquidationLocked(id, borrower), "liquidation not locked at transaction start"; + require currentContract.position[id][borrower].debt > 0, "borrower has debt to enter the bad-debt branch"; + require currentContract.position[id][borrower].debt <= currentContract.obligationState[id].totalUnits, "debt bounded by totalUnits (Midnight.spec)"; + require currentContract.obligationState[id].lossFactor < max_uint128, "lossFactor not saturated"; + require currentContract.obligationState[id].totalUnits > 0, "totalUnits > 0"; + // touchObligation enforces maxLif == maxLif(lltv, cursor); the formula yields a value in [WAD, 2*WAD]. + require forall uint256 i. i < obligation.collateralParams.length => obligation.collateralParams[i].maxLif > 0 && obligation.collateralParams[i].maxLif <= 2 * WAD(), "WAD <= maxLif <= 2*WAD (touchObligation invariant)"; + // touchObligation's maxLif formula requires lltv < WAD; lltv = WAD gives maxLif = WAD. + require forall uint256 i. i < obligation.collateralParams.length => obligation.collateralParams[i].lltv <= WAD(), "lltv <= WAD (touchObligation invariant)"; + // depositCollateral/withdrawCollateral only set/clear bits for valid indices. + require forall uint256 bit. summaryGetBit(currentContract.position[id][borrower].collateralBitmap, bit) => bit < obligation.collateralParams.length, "bitmap bits within collateralParams bounds"; + require e.msg.value == 0, "Midnight is not payable"; + // Unhealthy requirement + require !isHealthy(obligation, id, borrower), "borrower is unhealthy (debt > maxDebt under shared mulDiv/oracle ghosts)"; + + address zero = 0; + liquidate@withrevert(e, obligation, 0, 0, 0, borrower, borrower, zero, data); + + assert !lastReverted; +} From 6e1763a1e3cb30dc30e52190b15bc513da4ace1f Mon Sep 17 00:00:00 2001 From: lilCertora Date: Mon, 11 May 2026 11:32:02 +0200 Subject: [PATCH 09/53] Revert unrelated files to main --- certora/helpers/Utils.sol | 4 ---- certora/specs/Bitmap.spec | 21 --------------------- certora/specs/BitmapSummaries.spec | 14 -------------- certora/specs/CollateralBitmap.spec | 7 ------- 4 files changed, 46 deletions(-) diff --git a/certora/helpers/Utils.sol b/certora/helpers/Utils.sol index 079afcf56..977cccc3d 100644 --- a/certora/helpers/Utils.sol +++ b/certora/helpers/Utils.sol @@ -28,10 +28,6 @@ contract Utils { return UtilsLib.msb(bitmap); } - function countBits(uint128 bitmap) external pure returns (uint256) { - return UtilsLib.countBits(bitmap); - } - function emptyOffer() external pure returns (Offer memory) { Offer memory offer; return offer; diff --git a/certora/specs/Bitmap.spec b/certora/specs/Bitmap.spec index 063b4de47..d7b93f08a 100644 --- a/certora/specs/Bitmap.spec +++ b/certora/specs/Bitmap.spec @@ -5,7 +5,6 @@ methods { function setBit(uint128 bitmap, uint256 bit) external returns (uint128) envfree; function clearBit(uint128 bitmap, uint256 bit) external returns (uint128) envfree; function msb(uint128 bitmap) external returns (uint256) envfree; - function countBits(uint128 bitmap) external returns (uint256) envfree; } /// RULES /// @@ -13,7 +12,6 @@ methods { rule zeroBitmapEmpty(uint256 bit) { bool isBitSet = getBit(0, bit); assert !isBitSet, "zero bitmap has no bit set"; - assert countBits(0) == 0, "zero bitmap has count zero"; } rule getBitmapOutOfRange(uint128 bitmap, uint256 bit) { @@ -27,17 +25,13 @@ rule setBitSetsBit(uint128 bitmap, uint256 bit) { require otherBit < 128, "bitmap is limited to 128 bits"; bool otherBefore = getBit(bitmap, otherBit); - bool wasSet = getBit(bitmap, bit); - uint256 countBefore = countBits(bitmap); uint128 bitmapAfter = setBit(bitmap, bit); bool otherAfter = getBit(bitmapAfter, otherBit); bool bitAfter = getBit(bitmapAfter, bit); - uint256 countAfter = countBits(bitmapAfter); assert bitAfter, "setBit sets the bit"; assert otherBit != bit => otherBefore == otherAfter, "setBit doesn't change other bits"; - assert countAfter == countBefore + (wasSet ? 0 : 1), "setBit increments count when bit was clear"; } rule clearBitClearsBit(uint128 bitmap, uint256 bit) { @@ -46,28 +40,13 @@ rule clearBitClearsBit(uint128 bitmap, uint256 bit) { require otherBit < 128, "bitmap is limited to 128 bits"; bool otherBefore = getBit(bitmap, otherBit); - bool wasSet = getBit(bitmap, bit); - uint256 countBefore = countBits(bitmap); uint128 bitmapAfter = clearBit(bitmap, bit); bool otherAfter = getBit(bitmapAfter, otherBit); bool bitAfter = getBit(bitmapAfter, bit); - uint256 countAfter = countBits(bitmapAfter); assert !bitAfter, "clearBit clears the bit"; assert otherBit != bit => otherBefore == otherAfter, "clearBit doesn't change other bits"; - assert countAfter == countBefore - (wasSet ? 1 : 0), "clearBit decrements count when bit was set"; -} - -rule countBitsAtMost128(uint128 bitmap) { - uint256 count = countBits(bitmap); - assert count <= 128; -} - -rule countBitsPositiveWhenBitSet(uint128 bitmap, uint256 bit) { - require getBit(bitmap, bit), "bit is set"; - uint256 count = countBits(bitmap); - assert count > 0; } rule msbReturnsLargestSetBit(uint128 bitmap) { diff --git a/certora/specs/BitmapSummaries.spec b/certora/specs/BitmapSummaries.spec index 5e04eb1cf..0041faec6 100644 --- a/certora/specs/BitmapSummaries.spec +++ b/certora/specs/BitmapSummaries.spec @@ -4,7 +4,6 @@ methods { function UtilsLib.setBit(uint128 bitmap, uint256 bit) internal returns (uint128) => summarySetBit(bitmap, bit); function UtilsLib.clearBit(uint128 bitmap, uint256 bit) internal returns (uint128) => summaryClearBit(bitmap, bit); function UtilsLib.msb(uint128 bitmap) internal returns (uint256) => summaryMsb(bitmap); - function UtilsLib.countBits(uint128 bitmap) internal returns (uint256) => summaryCountBits(bitmap); } /// SUMMARIES /// @@ -14,23 +13,11 @@ persistent ghost summaryGetBit(uint128, uint256) returns bool { axiom forall uint256 bit. !summaryGetBit(0, bit); } -persistent ghost summaryCountBits(uint128) returns uint256 { - // see Bitmap.spec - axiom summaryCountBits(0) == 0; - - // see Bitmap.spec - axiom forall uint128 b. summaryCountBits(b) <= 128; - - // see Bitmap.spec - axiom forall uint128 b. forall uint256 bit. summaryGetBit(b, bit) => summaryCountBits(b) >= 1; -} - function summarySetBit(uint128 bitmap, uint256 bit) returns (uint128) { uint128 result; assert bit < 128; require summaryGetBit(result, bit), "see Bitmap.spec"; require forall uint256 otherBit. otherBit != bit && otherBit < 128 => summaryGetBit(result, otherBit) == summaryGetBit(bitmap, otherBit), "see Bitmap.spec"; - require summaryCountBits(result) == summaryCountBits(bitmap) + (summaryGetBit(bitmap, bit) ? 0 : 1), "see Bitmap.spec"; return result; } @@ -39,7 +26,6 @@ function summaryClearBit(uint128 bitmap, uint256 bit) returns (uint128) { assert bit < 128; require !summaryGetBit(result, bit), "see Bitmap.spec"; require forall uint256 otherBit. otherBit != bit && otherBit < 128 => summaryGetBit(result, otherBit) == summaryGetBit(bitmap, otherBit), "see Bitmap.spec"; - require summaryCountBits(result) == summaryCountBits(bitmap) - (summaryGetBit(bitmap, bit) ? 1 : 0), "see Bitmap.spec"; return result; } diff --git a/certora/specs/CollateralBitmap.spec b/certora/specs/CollateralBitmap.spec index 824e8478f..6cbf7e917 100644 --- a/certora/specs/CollateralBitmap.spec +++ b/certora/specs/CollateralBitmap.spec @@ -26,8 +26,6 @@ methods { /// SUMMARY /// -definition MAX_COLLATERALS_PER_BORROWER() returns uint256 = 10; - persistent ghost summaryMulDivDown(uint256, uint256, uint256) returns uint256 { /* proved in mulDivZero in MulDiv.spec */ axiom forall uint256 b. forall uint256 d. d > 0 => summaryMulDivDown(0, b, d) == 0; @@ -39,11 +37,6 @@ persistent ghost summaryMulDivUp(uint256, uint256, uint256) returns uint256; strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint256 collateralIndex) collateralIndex < 128 => (collateral(id, user, collateralIndex) != 0 <=> summaryGetBit(currentContract.position[id][user].collateralBitmap, collateralIndex)); -// Check that the number of activated collaterals never exceeds MAX_COLLATERALS_PER_BORROWER. -// This bounds the while-loop iterations in isHealthy() and liquidate(). -strong invariant atMostMaxCollateralsBitsSet(bytes32 id, address user) - summaryCountBits(currentContract.position[id][user].collateralBitmap) <= MAX_COLLATERALS_PER_BORROWER(); - // This shows that the real isHealthy returns true if and only if the isHealthy function // that does not use collateral bitmap returns true. We also check that the latter function // does not revert if isHealthy does not revert. From d952ce4fbef1bcb28dbab7d3a01e174e2f0f9951 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Sat, 16 May 2026 10:04:26 +0200 Subject: [PATCH 10/53] update --- certora/confs/LiquidateLiveness.conf | 30 +++ certora/specs/LiquidateLiveness.spec | 275 +++++++++++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 certora/confs/LiquidateLiveness.conf create mode 100644 certora/specs/LiquidateLiveness.spec diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf new file mode 100644 index 000000000..88b6289ed --- /dev/null +++ b/certora/confs/LiquidateLiveness.conf @@ -0,0 +1,30 @@ +{ + "files": [ + "certora/helpers/Utils.sol", + "src/Midnight.sol" + ], + "verify": "Midnight:certora/specs/LiquidateLiveness.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, + "smt_timeout": 600, + "prover_args": [ + "-depth 8", + "-mediumTimeout 30", + "-destructiveOptimizations twostage", + "-dontStopAtFirstSplitTimeout true", + "-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},z3:def{randomSeed=11},z3:def{randomSeed=12},z3:def{randomSeed=13},z3:def{randomSeed=14},z3:def{randomSeed=15},z3:def{randomSeed=16},z3:def{randomSeed=17},z3:def{randomSeed=18},z3:def{randomSeed=19},z3:def{randomSeed=20}]" + ], + "rule": [ + "liquidateZeroZeroNoRevert", + "liquidatableCanBeLiquidatedSeizeAll", + "liquidatableCanBeLiquidatedRepayAll", + "liquidatableCanBeLiquidatedOneUnit" + ], + "rule_sanity": "none", + "msg": "Midnight Liquidate Liveness" +} diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec new file mode 100644 index 000000000..ae8f793a5 --- /dev/null +++ b/certora/specs/LiquidateLiveness.spec @@ -0,0 +1,275 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +import "BitmapSummaries.spec"; + +using Utils as Utils; + +methods { + function multicall(bytes[]) external => HAVOC_ALL DELETE; + + /// ENVFREE VIEWS /// + function debtOf(bytes32 id, address user) external returns (uint256) envfree; + function collateral(bytes32 id, address user, uint256 index) external returns (uint128) envfree; + function collateralBitmap(bytes32 id, address user) external returns (uint128) envfree; + function liquidationLocked(bytes32 id, address user) external returns (bool) envfree; + function isHealthy(Midnight.Market, bytes32, address) external returns (bool) envfree; + function totalUnits(bytes32 id) external returns (uint256) envfree; + function withdrawable(bytes32 id) external returns (uint256) envfree; + function Utils.hashMarket(Midnight.Market) external returns (bytes32) envfree; + + /// ORACLE: deterministic, non-reverting price per oracle address. + function _.price() external => summaryPrice(calledContract) expect(uint256); + + /// Skip touchMarket's first-time validation: we want a pre-existing market, and the validation cannot be triggered by liquidate alone in the liveness scenario. + function touchMarket(Midnight.Market memory market) internal returns (bytes32) => summaryToId(market); + function IdLib.toId(Midnight.Market memory market, uint256, address) internal returns (bytes32) => summaryToId(market); + + /// TOKEN TRANSFERS: well-behaved (no revert, no return-false). The converse is in Reverts.spec. + function SafeTransferLib.safeTransfer(address, address, uint256) internal => NONDET; + function SafeTransferLib.safeTransferFrom(address, address, address, uint256) internal => NONDET; + + /// MULDIV with tight rounding axioms (proved 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); +} + +/// CONSTANTS /// + +definition WAD() returns uint256 = 10 ^ 18; +definition ORACLE_PRICE_SCALE() returns uint256 = 10 ^ 36; +definition MAX_UINT128() returns mathint = (1 << 128) - 1; +definition MAX_TIMESTAMP() returns mathint = 1 << 64; +/// Mirrors TIME_TO_MAX_LIF from src/libraries/ConstantsLib.sol. +definition TIME_TO_MAX_LIF() returns uint256 = 15 * 60; + +/// SUMMARIES /// + +function summaryToId(Midnight.Market market) returns bytes32 { + return Utils.hashMarket(market); +} + +persistent ghost summaryPrice(address) returns uint256; + +// Tight bounds proven in MulDiv.spec (mulDivDownRoundsDown, mulDivDownTightBound). +// The monotonicity axiom is derivable from the tight bound but kept explicit so the solver +// doesn't have to divide by `d` (NIA pain point) when bounding `maxDebt += .mulDivDown(lltv, WAD)`. +persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { + axiom forall uint256 a. forall uint256 b. forall uint256 d. + d > 0 => ghostMulDivDown(a, b, d) * d <= a * b; + axiom forall uint256 a. forall uint256 b. forall uint256 d. + d > 0 => (ghostMulDivDown(a, b, d) + 1) * d > a * b; + axiom forall uint256 a. forall uint256 b. forall uint256 d. + d > 0 && b <= d => ghostMulDivDown(a, b, d) <= a; +} + +// Tight bounds proven in MulDiv.spec (mulDivUpRoundsUp, mulDivUpTightBound). +persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { + axiom forall uint256 a. forall uint256 b. forall uint256 d. + d > 0 => ghostMulDivUp(a, b, d) * d >= a * b; + axiom forall uint256 a. forall uint256 b. forall uint256 d. + d > 0 && ghostMulDivUp(a, b, d) > 0 => (ghostMulDivUp(a, b, d) - 1) * d < a * b; +} + +function summaryMulDivDown(uint256 x, uint256 y, uint256 d) returns uint256 { + if (d == 0) { + revert(); + } + return ghostMulDivDown(x, y, d); +} + +function summaryMulDivUp(uint256 x, uint256 y, uint256 d) returns uint256 { + if (d == 0) { + revert(); + } + return ghostMulDivUp(x, y, d); +} + +/// INVARIANT (proven in CollateralBitmap.spec; assumed here via requireInvariant) /// + +strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint256 collateralIndex) + collateralIndex < 128 => (collateral(id, user, collateralIndex) != 0 <=> summaryGetBit(currentContract.position[id][user].collateralBitmap, collateralIndex)); + +/// HELPERS /// + +/// Per-collateral validity (lltv, maxLif, ExactMath bounds) and LIVENESS bounds (oracle > 0, C_i * P_i fits in uint128). +function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 i) { + uint256 lltv = market.collateralParams[i].lltv; + uint256 maxLif = market.collateralParams[i].maxLif; + require lltv > 0 && lltv <= WAD(), "valid lltv"; + require maxLif >= WAD(), "valid maxLif"; + require lltv < WAD() => to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "ExactMath strict: lltv * maxLif <= WAD*(WAD-1) when lltv 0, "good oracle price"; + require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * MAX_UINT128(), "collateral value fits in uint128"; +} + +/// Two-activated-collateral market with bitmap == 3 (bits 0 and 1 set); matches `loop_iter: 2`. +function dualCollateralSetup(Midnight.Market market, bytes32 id, address borrower) { + require market.collateralParams.length == 2, "two-collateral market"; + require collateralBitmap(id, borrower) == 3, "bitmap is exactly 3 (bits 0 and 1 set)"; + + // Ghost consistency with the real bitmap value 3. + require summaryGetBit(3, 0) && summaryGetBit(3, 1), "ghost: bits 0 and 1 are set"; + require forall uint256 i. i >= 2 => !summaryGetBit(3, i), "ghost: no other bit is set"; + + validCollateralAt(market, id, borrower, 0); + validCollateralAt(market, id, borrower, 1); +} + +/// Common environment / market preconditions +function wellBehavedEnv(env e, Midnight.Market market) { + require e.msg.value == 0, "no value sent"; + require market.liquidatorGate == 0, "no liquidator gate (see Reverts.spec)"; + require to_mathint(e.block.timestamp) < MAX_TIMESTAMP(), "timestamp bounded"; + require to_mathint(market.maturity) < MAX_TIMESTAMP(), "maturity bounded"; +} + +/// Midnight.spec `totalUnitsEqualsSumNegativeDebtPlusWithdrawable` -> totalUnits >= per-borrower debt. +/// The withdrawable bound is a LIVENESS limit (not currently in Midnight.sol's LIVENESS list). +function feasibleLossAccounting(bytes32 id, address borrower) { + require totalUnits(id) >= debtOf(id, borrower), "totalUnits >= borrower debt (Midnight.spec totalUnitsEqualsSumNegativeDebtPlusWithdrawable)"; + require to_mathint(withdrawable(id)) + to_mathint(debtOf(id, borrower)) <= MAX_UINT128(), "withdrawable + debt <= MAX_UINT128 (withdrawable += repaidUnits won't overflow)"; +} + +/// Pins the contract's `lif` (Midnight.sol:625-627) to `maxLif`. Two regimes give lif = maxLif: +/// - the borrower is unhealthy (the ternary's true branch picks `_maxLif`); +/// - the borrower is healthy and at least TIME_TO_MAX_LIF past maturity (the min-clamp picks `_maxLif`). +/// This excludes the [maturity, maturity + TIME_TO_MAX_LIF) window for *still-healthy* borrowers. +function pinLifToMaxLif(env e, Midnight.Market market, bool healthy) { + require !healthy + || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), + "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; +} + + +/// Replicates the contract's `repaidUnits = seizedAssets * P / SCALE * WAD / lif` +/// for Strategy A (seizedAssets = collat) when `lif = maxLif` (see pinLifToMaxLif). +function strategyARepaidUnitsAtMaxLif(Midnight.Market market, uint128 collat) returns uint256 { + address oracle = market.collateralParams[0].oracle; + uint256 maxLif = market.collateralParams[0].maxLif; + uint256 step1 = ghostMulDivUp(collat, summaryPrice(oracle), ORACLE_PRICE_SCALE()); + return ghostMulDivUp(step1, WAD(), maxLif); +} + +/// RULES /// + +/// Sanity baseline: liquidate(0, 0, ...) does not revert on any liquidatable position. +/// Only realizes bad debt; useful as a baseline to confirm the well-behaved environment is correctly set up. +rule liquidateZeroZeroNoRevert(env e, Midnight.Market market, address borrower, address receiver) { + bytes32 id = summaryToId(market); + + dualCollateralSetup(market, id, borrower); + wellBehavedEnv(e, market); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); + + require debtOf(id, borrower) > 0, "borrower has debt"; + require !liquidationLocked(id, borrower), "not locked"; + require e.block.timestamp > market.maturity || !isHealthy(market, id, borrower), "expired or unhealthy"; + feasibleLossAccounting(id, borrower); + + bytes data; + liquidate@withrevert(e, market, 0, 0, 0, borrower, receiver, 0, data); + assert !lastReverted, "liquidate(0, 0) on a liquidatable position must succeed"; +} + +rule liquidatableCanBeLiquidatedSeizeAll(env e, Midnight.Market market, address borrower, address receiver) { + bytes32 id = summaryToId(market); + + dualCollateralSetup(market, id, borrower); + wellBehavedEnv(e, market); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); + feasibleLossAccounting(id, borrower); + + require !liquidationLocked(id, borrower), "not locked"; + uint256 debt = debtOf(id, borrower); + require debt > 0, "borrower has debt"; + + bool healthy = isHealthy(market, id, borrower); + require e.block.timestamp > market.maturity || !healthy, "expired or unhealthy"; + + /// RCF bypass for the pre-maturity unhealthy regime (post-maturity has RCF deactivated). + require e.block.timestamp > market.maturity + || market.rcfThreshold == max_uint256 + || market.collateralParams[0].lltv == WAD(), "RCF check bypassed (pre-maturity)"; + + pinLifToMaxLif(e, market, healthy); + + /// `collat > 0` follows from the index-0 invariant + `summaryGetBit(3, 0)`. + uint128 collat = collateral(id, borrower, 0); + + /// Strategy A applicable: the contract-computed repaidUnits (with lif = maxLif) fits in debt. + require strategyARepaidUnitsAtMaxLif(market, collat) <= debt, "Strategy A applicable"; + + bytes data; + liquidate@withrevert(e, market, 0, collat, 0, borrower, receiver, 0, data); + assert !lastReverted, "seize-all must succeed when computed repaid <= debt"; +} + +rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address borrower, address receiver) { + bytes32 id = summaryToId(market); + + dualCollateralSetup(market, id, borrower); + wellBehavedEnv(e, market); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); + feasibleLossAccounting(id, borrower); + + require !liquidationLocked(id, borrower), "not locked"; + uint256 debt = debtOf(id, borrower); + require debt > 0, "borrower has debt"; + + bool healthy = isHealthy(market, id, borrower); + require e.block.timestamp > market.maturity || !healthy, "expired or unhealthy"; + + require e.block.timestamp > market.maturity + || market.rcfThreshold == max_uint256 + || market.collateralParams[0].lltv == WAD(), "RCF check bypassed (pre-maturity)"; + + pinLifToMaxLif(e, market, healthy); + + /// `collat > 0` follows from the index-0 invariant + `summaryGetBit(3, 0)`. + uint128 collat = collateral(id, borrower, 0); + + /// Strategy B applicable: Strategy A would over-repay, so the contract's choice is repay-all. + require strategyARepaidUnitsAtMaxLif(market, collat) > debt, "Strategy B applicable"; + + bytes data; + liquidate@withrevert(e, market, 0, 0, debt, borrower, receiver, 0, data); + assert !lastReverted, "repay-all must succeed when seize-all would over-repay"; +} + +/// Witness for "some debt can be repaid": pass `repaidUnits = 1` (the minimum positive amount). +/// Covers the regimes uncovered by the seize-all/repay-all rules: +/// - pre-maturity unhealthy with finite rcfThreshold and lltv < WAD (RCF caps the per-call repay), +/// - post-maturity healthy in [maturity, maturity + TIME_TO_MAX_LIF) (ramped lif). +/// Does NOT pin lif and does NOT bypass RCF: works for any lif in [WAD, maxLif]. +/// LIVENESS limit: requires collat 0 large enough to absorb the worst-case 1-unit seizure. +rule liquidatableCanBeLiquidatedOneUnit(env e, Midnight.Market market, address borrower, address receiver) { + bytes32 id = summaryToId(market); + + dualCollateralSetup(market, id, borrower); + wellBehavedEnv(e, market); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); + feasibleLossAccounting(id, borrower); + + require !liquidationLocked(id, borrower), "not locked"; + require debtOf(id, borrower) > 0, "borrower has debt"; + require e.block.timestamp > market.maturity || !isHealthy(market, id, borrower), "expired or unhealthy"; + + /// LIVENESS: `seizedAssets = mulDivDown(mulDivDown(1, lif, WAD), SCALE, P) <= maxLif * SCALE / (WAD * P)` + /// for any `lif in [WAD, maxLif]`. Bound this by `collat 0` so the seizure fits. + address oracle = market.collateralParams[0].oracle; + uint128 collat = collateral(id, borrower, 0); + uint256 maxLif = market.collateralParams[0].maxLif; + require to_mathint(maxLif) * to_mathint(ORACLE_PRICE_SCALE()) + <= to_mathint(collat) * to_mathint(WAD()) * to_mathint(summaryPrice(oracle)), + "LIVENESS: collat 0 absorbs the 1-unit seizure at maxLif"; + + bytes data; + liquidate@withrevert(e, market, 0, 0, 1, borrower, receiver, 0, data); + assert !lastReverted, "repaying 1 unit must succeed on any liquidatable position (via collateral 0)"; +} \ No newline at end of file From f3f7de4e5a606db4236480bc067d62033f5a57ec Mon Sep 17 00:00:00 2001 From: lilCertora Date: Sat, 16 May 2026 10:04:53 +0200 Subject: [PATCH 11/53] delete old --- certora/specs/LiquidationLiveness.spec | 103 ------------------------- 1 file changed, 103 deletions(-) delete mode 100644 certora/specs/LiquidationLiveness.spec diff --git a/certora/specs/LiquidationLiveness.spec b/certora/specs/LiquidationLiveness.spec deleted file mode 100644 index 40a0f1841..000000000 --- a/certora/specs/LiquidationLiveness.spec +++ /dev/null @@ -1,103 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later - -import "BitmapSummaries.spec"; - -using Utils as Utils; - -methods { - function multicall(bytes[]) external => HAVOC_ALL DELETE; - - function isHealthy(Midnight.Obligation, bytes32, address) external returns (bool) envfree; - function obligationCreated(bytes32 id) external returns (bool) envfree; - function liquidationLocked(bytes32 id, address user) external returns (bool) envfree; - function Utils.hashObligation(Midnight.Obligation) external returns (bytes32) envfree; - - // Oracle: deterministic per oracle address so isHealthy() and liquidate()'s loop see the same prices. - function _.price() external => oraclePrice(calledContract) expect(uint256); - - // Deterministic toId so the obligation argument links to stored state. - function IdLib.toId(Midnight.Obligation memory obligation, uint256, address) internal returns (bytes32) => summaryToId(obligation); - function IdLib.storeInCode(Midnight.Obligation memory, uint256) internal returns (address) => NONDET; - - // PTA workaround for take(). - function UtilsLib.hashOffer(Midnight.Offer memory) internal returns (bytes32) => NONDET; - function UtilsLib.isLeaf(bytes32, bytes32, bytes32[] memory) internal returns (bool) => NONDET; - function TickLib.tickToPrice(uint256) internal returns (uint256) => NONDET; - - // Transfers: assumed well-behaved (no revert) — focus is on the protocol's own logic. - function SafeTransferLib.safeTransfer(address, address, uint256) internal => NONDET; - function SafeTransferLib.safeTransferFrom(address, address, address, uint256) internal => NONDET; -} - -/// HELPERS /// - -definition WAD() returns uint256 = 10 ^ 18; - -// Oracle prices are bounded by max_uint128, matching the uint128 collateral amounts. -persistent ghost oraclePrice(address) returns uint256 { - axiom forall address oracle. oraclePrice(oracle) <= max_uint128; -} - -function summaryToId(Midnight.Obligation obligation) returns (bytes32) { - return Utils.hashObligation(obligation); -} - -/// Debts can always be liquidated if expired -rule liquidateExpiredDoesNotRevert(env e, Midnight.Obligation obligation, address borrower, bytes data) { - bytes32 id = summaryToId(obligation); - - require data.length == 0, "no callback data"; - require obligationCreated(id), "obligation must be created"; - require obligation.liquidatorGate == 0, "no liquidator gate"; - require obligation.collateralParams.length > 0, "obligation has at least one collateral (touchObligation invariant)"; - require obligation.collateralParams.length <= 10, "loop bound to keep verification tractable"; - require !liquidationLocked(id, borrower), "liquidation not locked at transaction start"; - require currentContract.position[id][borrower].debt > 0, "borrower has debt to enter the bad-debt branch"; - require currentContract.position[id][borrower].debt <= currentContract.obligationState[id].totalUnits, "debt bounded by totalUnits (Midnight.spec)"; - require currentContract.obligationState[id].lossFactor < max_uint128, "lossFactor not saturated"; - require currentContract.obligationState[id].totalUnits > 0, "totalUnits > 0 (implied by debt > 0 above)"; - // touchObligation enforces maxLif == maxLif(lltv, cursor); the formula yields a value in [WAD, 2*WAD]. - require forall uint256 i. i < obligation.collateralParams.length => obligation.collateralParams[i].maxLif > 0 && obligation.collateralParams[i].maxLif <= 2 * WAD(), "WAD <= maxLif <= 2*WAD (touchObligation invariant)"; - // touchObligation's maxLif formula requires lltv < WAD; lltv = WAD gives maxLif = WAD. - require forall uint256 i. i < obligation.collateralParams.length => obligation.collateralParams[i].lltv <= WAD(), "lltv <= WAD (touchObligation invariant)"; - // depositCollateral/withdrawCollateral only set/clear bits for valid indices. - require forall uint256 bit. summaryGetBit(currentContract.position[id][borrower].collateralBitmap, bit) => bit < obligation.collateralParams.length, "bitmap bits within collateralParams bounds"; - require e.msg.value == 0, "Midnight is not payable"; - // Expiration requirement - require e.block.timestamp > obligation.maturity, "obligation has expired"; - - address zero = 0; - liquidate@withrevert(e, obligation, 0, 0, 0, borrower, borrower, zero, data); - - assert !lastReverted; -} - -/// Debts can always be liquidated if unhealthy -rule liquidateUnhealthyDoesNotRevert(env e, Midnight.Obligation obligation, address borrower, bytes data) { - bytes32 id = summaryToId(obligation); - - require data.length == 0, "no callback data"; - require obligationCreated(id), "obligation must be created"; - require obligation.liquidatorGate == 0, "no liquidator gate"; - require obligation.collateralParams.length > 0, "obligation has at least one collateral (touchObligation invariant)"; - require obligation.collateralParams.length <= 10, "loop bound to keep verification tractable"; - require !liquidationLocked(id, borrower), "liquidation not locked at transaction start"; - require currentContract.position[id][borrower].debt > 0, "borrower has debt to enter the bad-debt branch"; - require currentContract.position[id][borrower].debt <= currentContract.obligationState[id].totalUnits, "debt bounded by totalUnits (Midnight.spec)"; - require currentContract.obligationState[id].lossFactor < max_uint128, "lossFactor not saturated"; - require currentContract.obligationState[id].totalUnits > 0, "totalUnits > 0"; - // touchObligation enforces maxLif == maxLif(lltv, cursor); the formula yields a value in [WAD, 2*WAD]. - require forall uint256 i. i < obligation.collateralParams.length => obligation.collateralParams[i].maxLif > 0 && obligation.collateralParams[i].maxLif <= 2 * WAD(), "WAD <= maxLif <= 2*WAD (touchObligation invariant)"; - // touchObligation's maxLif formula requires lltv < WAD; lltv = WAD gives maxLif = WAD. - require forall uint256 i. i < obligation.collateralParams.length => obligation.collateralParams[i].lltv <= WAD(), "lltv <= WAD (touchObligation invariant)"; - // depositCollateral/withdrawCollateral only set/clear bits for valid indices. - require forall uint256 bit. summaryGetBit(currentContract.position[id][borrower].collateralBitmap, bit) => bit < obligation.collateralParams.length, "bitmap bits within collateralParams bounds"; - require e.msg.value == 0, "Midnight is not payable"; - // Unhealthy requirement - require !isHealthy(obligation, id, borrower), "borrower is unhealthy (debt > maxDebt under shared mulDiv/oracle ghosts)"; - - address zero = 0; - liquidate@withrevert(e, obligation, 0, 0, 0, borrower, borrower, zero, data); - - assert !lastReverted; -} From 26bcbdd8215bd950b5a51704e885341af7fd643a Mon Sep 17 00:00:00 2001 From: lilCertora Date: Sat, 16 May 2026 10:05:10 +0200 Subject: [PATCH 12/53] delete old conf --- certora/confs/LiquidationLiveness.conf | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 certora/confs/LiquidationLiveness.conf diff --git a/certora/confs/LiquidationLiveness.conf b/certora/confs/LiquidationLiveness.conf deleted file mode 100644 index 947bd61aa..000000000 --- a/certora/confs/LiquidationLiveness.conf +++ /dev/null @@ -1,23 +0,0 @@ -{ - "files": [ - "certora/helpers/Utils.sol", - "src/Midnight.sol" - ], - "verify": "Midnight:certora/specs/LiquidationLiveness.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": [ - "-splitParallel true", - "-destructiveOptimizations twostage", - "-mediumTimeout 60", - "-timeout 3600", - "-s", - "[z3:def{randomSeed=1},z3:def{randomSeed=2},z3:def{randomSeed=3},z3:def{randomSeed=4},z3:def{randomSeed=5}]" - ], - "msg": "Liquidation Liveness" -} From 315c3a234d0ce4584410706c228d61a56089ee1c Mon Sep 17 00:00:00 2001 From: lilCertora Date: Sat, 16 May 2026 10:15:12 +0200 Subject: [PATCH 13/53] linter + modifs --- certora/confs/LiquidateLiveness.conf | 16 ++++++++-------- certora/specs/LiquidateLiveness.spec | 14 +++++--------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 88b6289ed..19502e4e1 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -11,7 +11,7 @@ "loop_iter": 2, "optimistic_hashing": true, "hashing_length_bound": 2048, - "smt_timeout": 600, + "smt_timeout": 7200, "prover_args": [ "-depth 8", "-mediumTimeout 30", @@ -19,12 +19,12 @@ "-dontStopAtFirstSplitTimeout true", "-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},z3:def{randomSeed=11},z3:def{randomSeed=12},z3:def{randomSeed=13},z3:def{randomSeed=14},z3:def{randomSeed=15},z3:def{randomSeed=16},z3:def{randomSeed=17},z3:def{randomSeed=18},z3:def{randomSeed=19},z3:def{randomSeed=20}]" ], - "rule": [ - "liquidateZeroZeroNoRevert", - "liquidatableCanBeLiquidatedSeizeAll", - "liquidatableCanBeLiquidatedRepayAll", - "liquidatableCanBeLiquidatedOneUnit" - ], + "rule": [ + "liquidateZeroZeroNoRevert", + "liquidatableCanBeLiquidatedSeizeAll", + "liquidatableCanBeLiquidatedRepayAll", + "liquidatableCanBeLiquidatedOneUnit" + ], "rule_sanity": "none", - "msg": "Midnight Liquidate Liveness" + "msg": "Midnight Liquidate Liveness without sanity" } diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index ae8f793a5..b2e976be5 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -171,7 +171,7 @@ rule liquidateZeroZeroNoRevert(env e, Midnight.Market market, address borrower, bytes data; liquidate@withrevert(e, market, 0, 0, 0, borrower, receiver, 0, data); - assert !lastReverted, "liquidate(0, 0) on a liquidatable position must succeed"; + assert !lastReverted; } rule liquidatableCanBeLiquidatedSeizeAll(env e, Midnight.Market market, address borrower, address receiver) { @@ -205,7 +205,7 @@ rule liquidatableCanBeLiquidatedSeizeAll(env e, Midnight.Market market, address bytes data; liquidate@withrevert(e, market, 0, collat, 0, borrower, receiver, 0, data); - assert !lastReverted, "seize-all must succeed when computed repaid <= debt"; + assert !lastReverted; } rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address borrower, address receiver) { @@ -238,15 +238,13 @@ rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address bytes data; liquidate@withrevert(e, market, 0, 0, debt, borrower, receiver, 0, data); - assert !lastReverted, "repay-all must succeed when seize-all would over-repay"; + assert !lastReverted; } /// Witness for "some debt can be repaid": pass `repaidUnits = 1` (the minimum positive amount). /// Covers the regimes uncovered by the seize-all/repay-all rules: /// - pre-maturity unhealthy with finite rcfThreshold and lltv < WAD (RCF caps the per-call repay), /// - post-maturity healthy in [maturity, maturity + TIME_TO_MAX_LIF) (ramped lif). -/// Does NOT pin lif and does NOT bypass RCF: works for any lif in [WAD, maxLif]. -/// LIVENESS limit: requires collat 0 large enough to absorb the worst-case 1-unit seizure. rule liquidatableCanBeLiquidatedOneUnit(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); @@ -265,11 +263,9 @@ rule liquidatableCanBeLiquidatedOneUnit(env e, Midnight.Market market, address b address oracle = market.collateralParams[0].oracle; uint128 collat = collateral(id, borrower, 0); uint256 maxLif = market.collateralParams[0].maxLif; - require to_mathint(maxLif) * to_mathint(ORACLE_PRICE_SCALE()) - <= to_mathint(collat) * to_mathint(WAD()) * to_mathint(summaryPrice(oracle)), - "LIVENESS: collat 0 absorbs the 1-unit seizure at maxLif"; + require to_mathint(maxLif) * to_mathint(ORACLE_PRICE_SCALE()) <= to_mathint(collat) * to_mathint(WAD()) * to_mathint(summaryPrice(oracle)), "LIVENESS: collat 0 absorbs the 1-unit seizure at maxLif"; bytes data; liquidate@withrevert(e, market, 0, 0, 1, borrower, receiver, 0, data); - assert !lastReverted, "repaying 1 unit must succeed on any liquidatable position (via collateral 0)"; + assert !lastReverted; } \ No newline at end of file From 1abd9710bad4c9a5dd6a17c56125bb868c91ff7d Mon Sep 17 00:00:00 2001 From: lilCertora Date: Sat, 16 May 2026 10:21:40 +0200 Subject: [PATCH 14/53] linter spec --- certora/specs/LiquidateLiveness.spec | 36 +++++++++++----------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index b2e976be5..4434e1ed9 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -36,9 +36,13 @@ methods { /// CONSTANTS /// definition WAD() returns uint256 = 10 ^ 18; + definition ORACLE_PRICE_SCALE() returns uint256 = 10 ^ 36; + definition MAX_UINT128() returns mathint = (1 << 128) - 1; + definition MAX_TIMESTAMP() returns mathint = 1 << 64; + /// Mirrors TIME_TO_MAX_LIF from src/libraries/ConstantsLib.sol. definition TIME_TO_MAX_LIF() returns uint256 = 15 * 60; @@ -54,20 +58,15 @@ persistent ghost summaryPrice(address) returns uint256; // The monotonicity axiom is derivable from the tight bound but kept explicit so the solver // doesn't have to divide by `d` (NIA pain point) when bounding `maxDebt += .mulDivDown(lltv, WAD)`. persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { - axiom forall uint256 a. forall uint256 b. forall uint256 d. - d > 0 => ghostMulDivDown(a, b, d) * d <= a * b; - axiom forall uint256 a. forall uint256 b. forall uint256 d. - d > 0 => (ghostMulDivDown(a, b, d) + 1) * d > a * b; - axiom forall uint256 a. forall uint256 b. forall uint256 d. - d > 0 && b <= d => ghostMulDivDown(a, b, d) <= a; + axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivDown(a, b, d) * d <= a * b; + axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => (ghostMulDivDown(a, b, d) + 1) * d > a * b; + axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivDown(a, b, d) <= a; } // Tight bounds proven in MulDiv.spec (mulDivUpRoundsUp, mulDivUpTightBound). persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { - axiom forall uint256 a. forall uint256 b. forall uint256 d. - d > 0 => ghostMulDivUp(a, b, d) * d >= a * b; - axiom forall uint256 a. forall uint256 b. forall uint256 d. - d > 0 && ghostMulDivUp(a, b, d) > 0 => (ghostMulDivUp(a, b, d) - 1) * d < a * b; + axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivUp(a, b, d) * d >= a * b; + axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && ghostMulDivUp(a, b, d) > 0 => (ghostMulDivUp(a, b, d) - 1) * d < a * b; } function summaryMulDivDown(uint256 x, uint256 y, uint256 d) returns uint256 { @@ -138,13 +137,10 @@ function feasibleLossAccounting(bytes32 id, address borrower) { /// - the borrower is healthy and at least TIME_TO_MAX_LIF past maturity (the min-clamp picks `_maxLif`). /// This excludes the [maturity, maturity + TIME_TO_MAX_LIF) window for *still-healthy* borrowers. function pinLifToMaxLif(env e, Midnight.Market market, bool healthy) { - require !healthy - || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), - "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; + require !healthy || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; } - -/// Replicates the contract's `repaidUnits = seizedAssets * P / SCALE * WAD / lif` +/// Replicates the contract's `repaidUnits = seizedAssets * P / SCALE * WAD / lif` /// for Strategy A (seizedAssets = collat) when `lif = maxLif` (see pinLifToMaxLif). function strategyARepaidUnitsAtMaxLif(Midnight.Market market, uint128 collat) returns uint256 { address oracle = market.collateralParams[0].oracle; @@ -191,9 +187,7 @@ rule liquidatableCanBeLiquidatedSeizeAll(env e, Midnight.Market market, address require e.block.timestamp > market.maturity || !healthy, "expired or unhealthy"; /// RCF bypass for the pre-maturity unhealthy regime (post-maturity has RCF deactivated). - require e.block.timestamp > market.maturity - || market.rcfThreshold == max_uint256 - || market.collateralParams[0].lltv == WAD(), "RCF check bypassed (pre-maturity)"; + require e.block.timestamp > market.maturity || market.rcfThreshold == max_uint256 || market.collateralParams[0].lltv == WAD(), "RCF check bypassed (pre-maturity)"; pinLifToMaxLif(e, market, healthy); @@ -224,9 +218,7 @@ rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address bool healthy = isHealthy(market, id, borrower); require e.block.timestamp > market.maturity || !healthy, "expired or unhealthy"; - require e.block.timestamp > market.maturity - || market.rcfThreshold == max_uint256 - || market.collateralParams[0].lltv == WAD(), "RCF check bypassed (pre-maturity)"; + require e.block.timestamp > market.maturity || market.rcfThreshold == max_uint256 || market.collateralParams[0].lltv == WAD(), "RCF check bypassed (pre-maturity)"; pinLifToMaxLif(e, market, healthy); @@ -268,4 +260,4 @@ rule liquidatableCanBeLiquidatedOneUnit(env e, Midnight.Market market, address b bytes data; liquidate@withrevert(e, market, 0, 0, 1, borrower, receiver, 0, data); assert !lastReverted; -} \ No newline at end of file +} From 7f68b3a5f7e864779a152aef823a525b07e27104 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Sun, 17 May 2026 12:04:14 +0200 Subject: [PATCH 15/53] simplification --- certora/confs/LiquidateLiveness.conf | 6 ++--- certora/specs/LiquidateLiveness.spec | 39 ++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 19502e4e1..e1f544ed7 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -21,10 +21,10 @@ ], "rule": [ "liquidateZeroZeroNoRevert", - "liquidatableCanBeLiquidatedSeizeAll", "liquidatableCanBeLiquidatedRepayAll", + "liquidatableCanBeLiquidatedSeizeAll", "liquidatableCanBeLiquidatedOneUnit" ], - "rule_sanity": "none", - "msg": "Midnight Liquidate Liveness without sanity" + "rule_sanity": "basic", + "msg": "Midnight Liquidate Liveness all rules (with sanity)" } diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 4434e1ed9..dca368aaa 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -64,15 +64,22 @@ persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { } // Tight bounds proven in MulDiv.spec (mulDivUpRoundsUp, mulDivUpTightBound). +// The monotonicity axiom (b <= d => result <= a) gives the solver an LIA shortcut +// for the common pattern mulDivUp(_, WAD, maxLif) where WAD <= maxLif. persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivUp(a, b, d) * d >= a * b; axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && ghostMulDivUp(a, b, d) > 0 => (ghostMulDivUp(a, b, d) - 1) * d < a * b; + axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivUp(a, b, d) <= a; } +/// Case-analysis on the common deterministic patterns (y == d, x == d, zero inputs). function summaryMulDivDown(uint256 x, uint256 y, uint256 d) returns uint256 { if (d == 0) { revert(); } + if (x == 0 || y == 0) return 0; + if (y == d) return x; + if (x == d) return y; return ghostMulDivDown(x, y, d); } @@ -80,6 +87,9 @@ function summaryMulDivUp(uint256 x, uint256 y, uint256 d) returns uint256 { if (d == 0) { revert(); } + if (x == 0 || y == 0) return 0; + if (y == d) return x; + if (x == d) return y; return ghostMulDivUp(x, y, d); } @@ -117,6 +127,19 @@ function dualCollateralSetup(Midnight.Market market, bytes32 id, address borrowe validCollateralAt(market, id, borrower, 1); } +/// Single-collateral market with bitmap == 1 (only bit 0 set). +/// Cuts ghost instantiations roughly in half vs dualCollateralSetup by halving the +/// health-check and bad-debt loops. Used for the NIA-heavier rules (SeizeAll, OneUnit). +function singleCollateralSetup(Midnight.Market market, bytes32 id, address borrower) { + require market.collateralParams.length == 1, "single-collateral market"; + require collateralBitmap(id, borrower) == 1, "bitmap is exactly 1 (bit 0 set)"; + + require summaryGetBit(1, 0), "ghost: bit 0 is set"; + require forall uint256 i. i >= 1 => !summaryGetBit(1, i), "ghost: no other bit is set"; + + validCollateralAt(market, id, borrower, 0); +} + /// Common environment / market preconditions function wellBehavedEnv(env e, Midnight.Market market) { require e.msg.value == 0, "no value sent"; @@ -142,11 +165,13 @@ function pinLifToMaxLif(env e, Midnight.Market market, bool healthy) { /// Replicates the contract's `repaidUnits = seizedAssets * P / SCALE * WAD / lif` /// for Strategy A (seizedAssets = collat) when `lif = maxLif` (see pinLifToMaxLif). +/// Uses `summaryMulDivUp` (not raw `ghostMulDivUp`) so the helper sees the same +/// case-analyzed values as the contract path. function strategyARepaidUnitsAtMaxLif(Midnight.Market market, uint128 collat) returns uint256 { address oracle = market.collateralParams[0].oracle; uint256 maxLif = market.collateralParams[0].maxLif; - uint256 step1 = ghostMulDivUp(collat, summaryPrice(oracle), ORACLE_PRICE_SCALE()); - return ghostMulDivUp(step1, WAD(), maxLif); + uint256 step1 = summaryMulDivUp(collat, summaryPrice(oracle), ORACLE_PRICE_SCALE()); + return summaryMulDivUp(step1, WAD(), maxLif); } /// RULES /// @@ -173,10 +198,9 @@ rule liquidateZeroZeroNoRevert(env e, Midnight.Market market, address borrower, rule liquidatableCanBeLiquidatedSeizeAll(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); - dualCollateralSetup(market, id, borrower); + singleCollateralSetup(market, id, borrower); wellBehavedEnv(e, market); requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); feasibleLossAccounting(id, borrower); require !liquidationLocked(id, borrower), "not locked"; @@ -191,7 +215,7 @@ rule liquidatableCanBeLiquidatedSeizeAll(env e, Midnight.Market market, address pinLifToMaxLif(e, market, healthy); - /// `collat > 0` follows from the index-0 invariant + `summaryGetBit(3, 0)`. + /// `collat > 0` follows from the index-0 invariant + `summaryGetBit(1, 0)`. uint128 collat = collateral(id, borrower, 0); /// Strategy A applicable: the contract-computed repaidUnits (with lif = maxLif) fits in debt. @@ -225,7 +249,7 @@ rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address /// `collat > 0` follows from the index-0 invariant + `summaryGetBit(3, 0)`. uint128 collat = collateral(id, borrower, 0); - /// Strategy B applicable: Strategy A would over-repay, so the contract's choice is repay-all. + /// Repay-all require strategyARepaidUnitsAtMaxLif(market, collat) > debt, "Strategy B applicable"; bytes data; @@ -240,10 +264,9 @@ rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address rule liquidatableCanBeLiquidatedOneUnit(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); - dualCollateralSetup(market, id, borrower); + singleCollateralSetup(market, id, borrower); wellBehavedEnv(e, market); requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); feasibleLossAccounting(id, borrower); require !liquidationLocked(id, borrower), "not locked"; From ebc1bd75ab7990e5cc6581ed90074f91784d3fc1 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Sun, 17 May 2026 21:22:22 +0200 Subject: [PATCH 16/53] split confs --- certora/confs/LiquidateLiveness_Hard.conf | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 certora/confs/LiquidateLiveness_Hard.conf diff --git a/certora/confs/LiquidateLiveness_Hard.conf b/certora/confs/LiquidateLiveness_Hard.conf new file mode 100644 index 000000000..3701e2fc8 --- /dev/null +++ b/certora/confs/LiquidateLiveness_Hard.conf @@ -0,0 +1,28 @@ +{ + "files": [ + "certora/helpers/Utils.sol", + "src/Midnight.sol" + ], + "verify": "Midnight:certora/specs/LiquidateLiveness.spec", + "solc": "solc-0.8.34", + "solc_via_ir": true, + "solc_evm_version": "osaka", + "optimistic_loop": true, + "loop_iter": 1, + "optimistic_hashing": true, + "hashing_length_bound": 2048, + "smt_timeout": 7200, + "prover_args": [ + "-depth 5", + "-mediumTimeout 60", + "-destructiveOptimizations twostage", + "-dontStopAtFirstSplitTimeout true", + "-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},z3:def{randomSeed=11},z3:def{randomSeed=12},z3:def{randomSeed=13},z3:def{randomSeed=14},z3:def{randomSeed=15},z3:def{randomSeed=16},z3:def{randomSeed=17},z3:def{randomSeed=18},z3:def{randomSeed=19},z3:def{randomSeed=20}]" + ], + "rule": [ + "liquidatableCanBeLiquidatedSeizeAll", + "liquidatableCanBeLiquidatedOneUnit" + ], + "rule_sanity": "basic", + "msg": "Midnight Liquidate Liveness SeizeAll and OneUnit (single collateral, with sanity)" +} From 8d36ffb6b78e72e2f0f5e9106ec52640547c13dc Mon Sep 17 00:00:00 2001 From: lilCertora Date: Sun, 17 May 2026 21:22:36 +0200 Subject: [PATCH 17/53] split confs --- certora/confs/LiquidateLiveness.conf | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index e1f544ed7..707e2d437 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -13,18 +13,18 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - "-depth 8", - "-mediumTimeout 30", - "-destructiveOptimizations twostage", - "-dontStopAtFirstSplitTimeout true", + "-depth 10", + "-mediumTimeout 60", + "-tinyTimeout 30", + "-destructiveOptimizations twostage", + "-dontStopAtFirstSplitTimeout true", "-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},z3:def{randomSeed=11},z3:def{randomSeed=12},z3:def{randomSeed=13},z3:def{randomSeed=14},z3:def{randomSeed=15},z3:def{randomSeed=16},z3:def{randomSeed=17},z3:def{randomSeed=18},z3:def{randomSeed=19},z3:def{randomSeed=20}]" ], "rule": [ "liquidateZeroZeroNoRevert", - "liquidatableCanBeLiquidatedRepayAll", "liquidatableCanBeLiquidatedSeizeAll", - "liquidatableCanBeLiquidatedOneUnit" + "liquidatableCanBeLiquidatedOneUnit", ], "rule_sanity": "basic", - "msg": "Midnight Liquidate Liveness all rules (with sanity)" + "msg": "Midnight Liquidate Liveness all rules" } From 231771bbdd5d6342ec375170672d740fa2129a65 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Sun, 17 May 2026 21:39:41 +0200 Subject: [PATCH 18/53] modif conf --- certora/confs/LiquidateLiveness.conf | 18 +++++++-------- certora/confs/LiquidateLiveness_Hard.conf | 28 ----------------------- 2 files changed, 8 insertions(+), 38 deletions(-) delete mode 100644 certora/confs/LiquidateLiveness_Hard.conf diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 707e2d437..73f9ba82e 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -13,18 +13,16 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - "-depth 10", - "-mediumTimeout 60", - "-tinyTimeout 30", - "-destructiveOptimizations twostage", - "-dontStopAtFirstSplitTimeout true", + "-depth 10", + "-mediumTimeout 60", + "-tinyTimeout 30", + "-destructiveOptimizations twostage", + "-dontStopAtFirstSplitTimeout true", "-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},z3:def{randomSeed=11},z3:def{randomSeed=12},z3:def{randomSeed=13},z3:def{randomSeed=14},z3:def{randomSeed=15},z3:def{randomSeed=16},z3:def{randomSeed=17},z3:def{randomSeed=18},z3:def{randomSeed=19},z3:def{randomSeed=20}]" ], - "rule": [ - "liquidateZeroZeroNoRevert", - "liquidatableCanBeLiquidatedSeizeAll", - "liquidatableCanBeLiquidatedOneUnit", + "split_rules": [ + "liquidatableCanBeLiquidatedRepayAll" ], "rule_sanity": "basic", - "msg": "Midnight Liquidate Liveness all rules" + "msg": "Liquidate Liveness rules" } diff --git a/certora/confs/LiquidateLiveness_Hard.conf b/certora/confs/LiquidateLiveness_Hard.conf deleted file mode 100644 index 3701e2fc8..000000000 --- a/certora/confs/LiquidateLiveness_Hard.conf +++ /dev/null @@ -1,28 +0,0 @@ -{ - "files": [ - "certora/helpers/Utils.sol", - "src/Midnight.sol" - ], - "verify": "Midnight:certora/specs/LiquidateLiveness.spec", - "solc": "solc-0.8.34", - "solc_via_ir": true, - "solc_evm_version": "osaka", - "optimistic_loop": true, - "loop_iter": 1, - "optimistic_hashing": true, - "hashing_length_bound": 2048, - "smt_timeout": 7200, - "prover_args": [ - "-depth 5", - "-mediumTimeout 60", - "-destructiveOptimizations twostage", - "-dontStopAtFirstSplitTimeout true", - "-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},z3:def{randomSeed=11},z3:def{randomSeed=12},z3:def{randomSeed=13},z3:def{randomSeed=14},z3:def{randomSeed=15},z3:def{randomSeed=16},z3:def{randomSeed=17},z3:def{randomSeed=18},z3:def{randomSeed=19},z3:def{randomSeed=20}]" - ], - "rule": [ - "liquidatableCanBeLiquidatedSeizeAll", - "liquidatableCanBeLiquidatedOneUnit" - ], - "rule_sanity": "basic", - "msg": "Midnight Liquidate Liveness SeizeAll and OneUnit (single collateral, with sanity)" -} From 7a8f46b441b9d5d0179619db1e9a8fddf3f38128 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Mon, 18 May 2026 22:47:55 +0200 Subject: [PATCH 19/53] single and dual collateral setup --- certora/confs/LiquidateLiveness.conf | 23 +++++++++------ certora/specs/LiquidateLiveness.spec | 43 +++++++++------------------- 2 files changed, 27 insertions(+), 39 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 73f9ba82e..b7b8edf2e 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -13,16 +13,21 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - "-depth 10", - "-mediumTimeout 60", - "-tinyTimeout 30", - "-destructiveOptimizations twostage", - "-dontStopAtFirstSplitTimeout true", - "-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},z3:def{randomSeed=11},z3:def{randomSeed=12},z3:def{randomSeed=13},z3:def{randomSeed=14},z3:def{randomSeed=15},z3:def{randomSeed=16},z3:def{randomSeed=17},z3:def{randomSeed=18},z3:def{randomSeed=19},z3:def{randomSeed=20}]" - ], + "-mediumTimeout", + "60", + "-destructiveOptimizations", + "twostage", + "-dontStopAtFirstSplitTimeout", + "true", + "-s", + "[cvc5:nonlin,cvc5:nonlin{randomSeed=2},cvc5:nonlin{randomSeed=3},cvc5:nonlin{randomSeed=4},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}]", + "-split", + "false" + ], "split_rules": [ - "liquidatableCanBeLiquidatedRepayAll" + "liquidatableCanBeLiquidatedOneUnit", + "liquidatableCanBeLiquidatedRepayAll", + "liquidatableCanBeLiquidatedSeizeAll", ], - "rule_sanity": "basic", "msg": "Liquidate Liveness rules" } diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index dca368aaa..8c092118c 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -43,7 +43,6 @@ definition MAX_UINT128() returns mathint = (1 << 128) - 1; definition MAX_TIMESTAMP() returns mathint = 1 << 64; -/// Mirrors TIME_TO_MAX_LIF from src/libraries/ConstantsLib.sol. definition TIME_TO_MAX_LIF() returns uint256 = 15 * 60; /// SUMMARIES /// @@ -54,18 +53,14 @@ function summaryToId(Midnight.Market market) returns bytes32 { persistent ghost summaryPrice(address) returns uint256; -// Tight bounds proven in MulDiv.spec (mulDivDownRoundsDown, mulDivDownTightBound). -// The monotonicity axiom is derivable from the tight bound but kept explicit so the solver -// doesn't have to divide by `d` (NIA pain point) when bounding `maxDebt += .mulDivDown(lltv, WAD)`. +// Axioms bounds proven in MulDiv.spec (mulDivDownRoundsDown, mulDivDownTightBound). persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivDown(a, b, d) * d <= a * b; axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => (ghostMulDivDown(a, b, d) + 1) * d > a * b; axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivDown(a, b, d) <= a; } -// Tight bounds proven in MulDiv.spec (mulDivUpRoundsUp, mulDivUpTightBound). -// The monotonicity axiom (b <= d => result <= a) gives the solver an LIA shortcut -// for the common pattern mulDivUp(_, WAD, maxLif) where WAD <= maxLif. +// Axioms bounds proven in MulDiv.spec (mulDivUpRoundsUp, mulDivUpTightBound). persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivUp(a, b, d) * d >= a * b; axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && ghostMulDivUp(a, b, d) > 0 => (ghostMulDivUp(a, b, d) - 1) * d < a * b; @@ -77,9 +72,6 @@ function summaryMulDivDown(uint256 x, uint256 y, uint256 d) returns uint256 { if (d == 0) { revert(); } - if (x == 0 || y == 0) return 0; - if (y == d) return x; - if (x == d) return y; return ghostMulDivDown(x, y, d); } @@ -87,9 +79,6 @@ function summaryMulDivUp(uint256 x, uint256 y, uint256 d) returns uint256 { if (d == 0) { revert(); } - if (x == 0 || y == 0) return 0; - if (y == d) return x; - if (x == d) return y; return ghostMulDivUp(x, y, d); } @@ -106,12 +95,12 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 maxLif = market.collateralParams[i].maxLif; require lltv > 0 && lltv <= WAD(), "valid lltv"; require maxLif >= WAD(), "valid maxLif"; - require lltv < WAD() => to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "ExactMath strict: lltv * maxLif <= WAD*(WAD-1) when lltv to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; + require to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * to_mathint(WAD()), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; address oracle = market.collateralParams[i].oracle; - require summaryPrice(oracle) > 0, "good oracle price"; - require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * MAX_UINT128(), "collateral value fits in uint128"; + require summaryPrice(oracle) > 0, "Oracle returns a positive price "; // @todo is it needed as we have uint256 ? + require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(), "collateral value fits in uint128"; } /// Two-activated-collateral market with bitmap == 3 (bits 0 and 1 set); matches `loop_iter: 2`. @@ -128,8 +117,6 @@ function dualCollateralSetup(Midnight.Market market, bytes32 id, address borrowe } /// Single-collateral market with bitmap == 1 (only bit 0 set). -/// Cuts ghost instantiations roughly in half vs dualCollateralSetup by halving the -/// health-check and bad-debt loops. Used for the NIA-heavier rules (SeizeAll, OneUnit). function singleCollateralSetup(Midnight.Market market, bytes32 id, address borrower) { require market.collateralParams.length == 1, "single-collateral market"; require collateralBitmap(id, borrower) == 1, "bitmap is exactly 1 (bit 0 set)"; @@ -144,29 +131,25 @@ function singleCollateralSetup(Midnight.Market market, bytes32 id, address borro function wellBehavedEnv(env e, Midnight.Market market) { require e.msg.value == 0, "no value sent"; require market.liquidatorGate == 0, "no liquidator gate (see Reverts.spec)"; - require to_mathint(e.block.timestamp) < MAX_TIMESTAMP(), "timestamp bounded"; - require to_mathint(market.maturity) < MAX_TIMESTAMP(), "maturity bounded"; + require e.block.timestamp < MAX_TIMESTAMP(), "timestamp bounded"; + require market.maturity < MAX_TIMESTAMP(), "maturity bounded"; } -/// Midnight.spec `totalUnitsEqualsSumNegativeDebtPlusWithdrawable` -> totalUnits >= per-borrower debt. -/// The withdrawable bound is a LIVENESS limit (not currently in Midnight.sol's LIVENESS list). +// Anti overflow requirements for loss accounting function feasibleLossAccounting(bytes32 id, address borrower) { require totalUnits(id) >= debtOf(id, borrower), "totalUnits >= borrower debt (Midnight.spec totalUnitsEqualsSumNegativeDebtPlusWithdrawable)"; - require to_mathint(withdrawable(id)) + to_mathint(debtOf(id, borrower)) <= MAX_UINT128(), "withdrawable + debt <= MAX_UINT128 (withdrawable += repaidUnits won't overflow)"; + require to_mathint(withdrawable(id)) + to_mathint(debtOf(id, borrower)) <= MAX_UINT128(), "withdrawable += repaidUnits won't overflow"; } -/// Pins the contract's `lif` (Midnight.sol:625-627) to `maxLif`. Two regimes give lif = maxLif: -/// - the borrower is unhealthy (the ternary's true branch picks `_maxLif`); -/// - the borrower is healthy and at least TIME_TO_MAX_LIF past maturity (the min-clamp picks `_maxLif`). -/// This excludes the [maturity, maturity + TIME_TO_MAX_LIF) window for *still-healthy* borrowers. +/// Pins the contract's `lif` (Midnight.sol:625-627) to `maxLif` +/// 1. Borrower is unhealthy and 2. Borrower is healthy and at least TIME_TO_MAX_LIF past maturity +/// Excluding [maturity, maturity + TIME_TO_MAX_LIF) window for still-healthy borrowers but covered by liquidatableCanBeLiquidatedOneUnit function pinLifToMaxLif(env e, Midnight.Market market, bool healthy) { require !healthy || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; } /// Replicates the contract's `repaidUnits = seizedAssets * P / SCALE * WAD / lif` /// for Strategy A (seizedAssets = collat) when `lif = maxLif` (see pinLifToMaxLif). -/// Uses `summaryMulDivUp` (not raw `ghostMulDivUp`) so the helper sees the same -/// case-analyzed values as the contract path. function strategyARepaidUnitsAtMaxLif(Midnight.Market market, uint128 collat) returns uint256 { address oracle = market.collateralParams[0].oracle; uint256 maxLif = market.collateralParams[0].maxLif; From 642fe745ebb38c4a4174f1c57387b078c37d8d9e Mon Sep 17 00:00:00 2001 From: lilCertora Date: Wed, 20 May 2026 15:58:15 +0200 Subject: [PATCH 20/53] try --- certora/confs/LiquidateLiveness.conf | 39 +++++--- certora/specs/LiquidateLiveness.spec | 143 ++++++++++++++++++--------- 2 files changed, 122 insertions(+), 60 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index b7b8edf2e..42896dbe0 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -13,21 +13,32 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - "-mediumTimeout", - "60", - "-destructiveOptimizations", - "twostage", - "-dontStopAtFirstSplitTimeout", - "true", - "-s", - "[cvc5:nonlin,cvc5:nonlin{randomSeed=2},cvc5:nonlin{randomSeed=3},cvc5:nonlin{randomSeed=4},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}]", - "-split", - "false" - ], + "-mediumTimeout", + "60", + "-destructiveOptimizations", + "twostage", + "-s", + "[cvc5:nonlin,cvc5:nonlin{randomSeed=2},cvc5:nonlin{randomSeed=3},cvc5:nonlin{randomSeed=4},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}]", + "-dontStopAtFirstSplitTimeout", + "true", + "-backendStrategy", + "singleRace", + "-smt_useLIA", + "false", + "-smt_useNIA", + "true" + ], + "exclude_rule": [ + "nonZeroCollateralsAreActivated" + ], "split_rules": [ - "liquidatableCanBeLiquidatedOneUnit", + "liquidateZeroZeroNoRevert", "liquidatableCanBeLiquidatedRepayAll", - "liquidatableCanBeLiquidatedSeizeAll", + "liquidatableCanBeLiquidatedSeizeAllPostMaturityDual", + "liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual", + "liquidatableCanBeLiquidatedOneUnitPreMaturityLltvFullDual", + "liquidatableCanBeLiquidatedOneUnitRampedLifDual", + "liquidatableCanBeLiquidatedOneUnitPinnedLifDual" ], - "msg": "Liquidate Liveness rules" + "msg": "Liquidate Liveness" } diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 8c092118c..39951d487 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -104,11 +104,12 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, } /// Two-activated-collateral market with bitmap == 3 (bits 0 and 1 set); matches `loop_iter: 2`. +/// Proofs in this spec only formally cover 2-collateral markets. A length-1 generalization +/// was tested and regressed `RepayAll` and both `RcfMaxNoBadDebt` rules; see commit history. function dualCollateralSetup(Midnight.Market market, bytes32 id, address borrower) { require market.collateralParams.length == 2, "two-collateral market"; require collateralBitmap(id, borrower) == 3, "bitmap is exactly 3 (bits 0 and 1 set)"; - // Ghost consistency with the real bitmap value 3. require summaryGetBit(3, 0) && summaryGetBit(3, 1), "ghost: bits 0 and 1 are set"; require forall uint256 i. i >= 2 => !summaryGetBit(3, i), "ghost: no other bit is set"; @@ -116,17 +117,6 @@ function dualCollateralSetup(Midnight.Market market, bytes32 id, address borrowe validCollateralAt(market, id, borrower, 1); } -/// Single-collateral market with bitmap == 1 (only bit 0 set). -function singleCollateralSetup(Midnight.Market market, bytes32 id, address borrower) { - require market.collateralParams.length == 1, "single-collateral market"; - require collateralBitmap(id, borrower) == 1, "bitmap is exactly 1 (bit 0 set)"; - - require summaryGetBit(1, 0), "ghost: bit 0 is set"; - require forall uint256 i. i >= 1 => !summaryGetBit(1, i), "ghost: no other bit is set"; - - validCollateralAt(market, id, borrower, 0); -} - /// Common environment / market preconditions function wellBehavedEnv(env e, Midnight.Market market) { require e.msg.value == 0, "no value sent"; @@ -178,12 +168,13 @@ rule liquidateZeroZeroNoRevert(env e, Midnight.Market market, address borrower, assert !lastReverted; } -rule liquidatableCanBeLiquidatedSeizeAll(env e, Midnight.Market market, address borrower, address receiver) { +rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); - singleCollateralSetup(market, id, borrower); + dualCollateralSetup(market, id, borrower); wellBehavedEnv(e, market); requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); feasibleLossAccounting(id, borrower); require !liquidationLocked(id, borrower), "not locked"; @@ -193,25 +184,45 @@ rule liquidatableCanBeLiquidatedSeizeAll(env e, Midnight.Market market, address bool healthy = isHealthy(market, id, borrower); require e.block.timestamp > market.maturity || !healthy, "expired or unhealthy"; - /// RCF bypass for the pre-maturity unhealthy regime (post-maturity has RCF deactivated). require e.block.timestamp > market.maturity || market.rcfThreshold == max_uint256 || market.collateralParams[0].lltv == WAD(), "RCF check bypassed (pre-maturity)"; pinLifToMaxLif(e, market, healthy); - /// `collat > 0` follows from the index-0 invariant + `summaryGetBit(1, 0)`. + /// `collat > 0` follows from the index-0 invariant + `summaryGetBit(3, 0)`. uint128 collat = collateral(id, borrower, 0); - /// Strategy A applicable: the contract-computed repaidUnits (with lif = maxLif) fits in debt. - require strategyARepaidUnitsAtMaxLif(market, collat) <= debt, "Strategy A applicable"; + /// Repay-all + require strategyARepaidUnitsAtMaxLif(market, collat) > debt, "Strategy B applicable"; bytes data; - liquidate@withrevert(e, market, 0, collat, 0, borrower, receiver, 0, data); + liquidate@withrevert(e, market, 0, 0, debt, borrower, receiver, 0, data); assert !lastReverted; } -rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address borrower, address receiver) { - bytes32 id = summaryToId(market); +/// DUAL-SETUP HELPERS /// + +/// Common preamble for the dual-setup variants of OneUnit. +/// Sets up a 2-collateral market with bitmap == 3 and the LIVENESS bound on collateral 0 +/// (worst-case `lif = maxLif` absorbing the 1-unit seizure). +function oneUnitDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) { + dualCollateralSetup(market, id, borrower); + wellBehavedEnv(e, market); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); + feasibleLossAccounting(id, borrower); + + require !liquidationLocked(id, borrower), "not locked"; + require debtOf(id, borrower) > 0, "borrower has debt"; + + address oracle = market.collateralParams[0].oracle; + uint128 collat = collateral(id, borrower, 0); + uint256 maxLif = market.collateralParams[0].maxLif; + require to_mathint(maxLif) * to_mathint(ORACLE_PRICE_SCALE()) <= to_mathint(collat) * to_mathint(WAD()) * to_mathint(summaryPrice(oracle)), "LIVENESS: collat 0 absorbs the 1-unit seizure at maxLif"; +} +/// Common preamble for the dual-setup variants of SeizeAll. +/// Returns `collat[0]` so the rule can pass it as `seizedAssets` to `liquidate`. +function seizeAllDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) returns uint128 { dualCollateralSetup(market, id, borrower); wellBehavedEnv(e, market); requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); @@ -222,46 +233,86 @@ rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address uint256 debt = debtOf(id, borrower); require debt > 0, "borrower has debt"; + uint128 collat = collateral(id, borrower, 0); + require strategyARepaidUnitsAtMaxLif(market, collat) <= debt, "Strategy A applicable"; + return collat; +} + +/// SEIZEALL DUAL VARIANTS /// + +/// Post-maturity. RCF is deactivated entirely (Midnight.sol:635), so the nonlinear `maxRepaid` +/// formula is never evaluated. +rule liquidatableCanBeLiquidatedSeizeAllPostMaturityDual(env e, Midnight.Market market, address borrower, address receiver) { + bytes32 id = summaryToId(market); + uint128 collat = seizeAllDualPreamble(e, market, id, borrower); + bool healthy = isHealthy(market, id, borrower); - require e.block.timestamp > market.maturity || !healthy, "expired or unhealthy"; + require e.block.timestamp > market.maturity, "post-maturity"; + pinLifToMaxLif(e, market, healthy); - require e.block.timestamp > market.maturity || market.rcfThreshold == max_uint256 || market.collateralParams[0].lltv == WAD(), "RCF check bypassed (pre-maturity)"; + bytes data; + liquidate@withrevert(e, market, 0, collat, 0, borrower, receiver, 0, data); + assert !lastReverted; +} +/// Pre-maturity unhealthy, `lltv[0] == WAD`. RCF `maxRepaid` collapses to `type(uint256).max` +/// (Midnight.sol:638-640), so the first RCF disjunct holds trivially. +rule liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual(env e, Midnight.Market market, address borrower, address receiver) { + bytes32 id = summaryToId(market); + uint128 collat = seizeAllDualPreamble(e, market, id, borrower); + + bool healthy = isHealthy(market, id, borrower); + require !healthy, "unhealthy"; + require e.block.timestamp <= market.maturity, "pre-maturity"; + require market.collateralParams[0].lltv == WAD(), "lltv == WAD => RCF denominator vanishes"; pinLifToMaxLif(e, market, healthy); - /// `collat > 0` follows from the index-0 invariant + `summaryGetBit(3, 0)`. - uint128 collat = collateral(id, borrower, 0); + bytes data; + liquidate@withrevert(e, market, 0, collat, 0, borrower, receiver, 0, data); + assert !lastReverted; +} - /// Repay-all - require strategyARepaidUnitsAtMaxLif(market, collat) > debt, "Strategy B applicable"; +/// ONEUNIT DUAL VARIANTS /// +/// Witness for "some debt can be repaid" with `repaidUnits = 1` on a 2-collateral market. +/// Split by lif/RCF regime so each sub-case is SMT-tractable. + +/// Pre-maturity unhealthy, `lltv[0] == WAD`. RCF `maxRepaid` collapses to `type(uint256).max`. +rule liquidatableCanBeLiquidatedOneUnitPreMaturityLltvFullDual(env e, Midnight.Market market, address borrower, address receiver) { + bytes32 id = summaryToId(market); + oneUnitDualPreamble(e, market, id, borrower); + + require e.block.timestamp <= market.maturity, "pre-maturity"; + require !isHealthy(market, id, borrower), "unhealthy"; + require market.collateralParams[0].lltv == WAD(), "lltv == WAD => RCF denominator vanishes"; bytes data; - liquidate@withrevert(e, market, 0, 0, debt, borrower, receiver, 0, data); + liquidate@withrevert(e, market, 0, 0, 1, borrower, receiver, 0, data); assert !lastReverted; } -/// Witness for "some debt can be repaid": pass `repaidUnits = 1` (the minimum positive amount). -/// Covers the regimes uncovered by the seize-all/repay-all rules: -/// - pre-maturity unhealthy with finite rcfThreshold and lltv < WAD (RCF caps the per-call repay), -/// - post-maturity healthy in [maturity, maturity + TIME_TO_MAX_LIF) (ramped lif). -rule liquidatableCanBeLiquidatedOneUnit(env e, Midnight.Market market, address borrower, address receiver) { +/// Post-maturity, healthy, `timestamp in [maturity, maturity + TIME_TO_MAX_LIF)`. +/// `lif` ramps symbolically in `[WAD, maxLif)`. No RCF check (post-maturity). +rule liquidatableCanBeLiquidatedOneUnitRampedLifDual(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); + oneUnitDualPreamble(e, market, id, borrower); - singleCollateralSetup(market, id, borrower); - wellBehavedEnv(e, market); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); - feasibleLossAccounting(id, borrower); + require e.block.timestamp > market.maturity, "post-maturity"; + require to_mathint(e.block.timestamp) < to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "in ramped window"; + require isHealthy(market, id, borrower), "healthy (otherwise lif is pinned)"; - require !liquidationLocked(id, borrower), "not locked"; - require debtOf(id, borrower) > 0, "borrower has debt"; - require e.block.timestamp > market.maturity || !isHealthy(market, id, borrower), "expired or unhealthy"; + bytes data; + liquidate@withrevert(e, market, 0, 0, 1, borrower, receiver, 0, data); + assert !lastReverted; +} - /// LIVENESS: `seizedAssets = mulDivDown(mulDivDown(1, lif, WAD), SCALE, P) <= maxLif * SCALE / (WAD * P)` - /// for any `lif in [WAD, maxLif]`. Bound this by `collat 0` so the seizure fits. - address oracle = market.collateralParams[0].oracle; - uint128 collat = collateral(id, borrower, 0); - uint256 maxLif = market.collateralParams[0].maxLif; - require to_mathint(maxLif) * to_mathint(ORACLE_PRICE_SCALE()) <= to_mathint(collat) * to_mathint(WAD()) * to_mathint(summaryPrice(oracle)), "LIVENESS: collat 0 absorbs the 1-unit seizure at maxLif"; +/// Post-maturity, `lif = maxLif` (constant). Either unhealthy or `timestamp >= maturity + TIME_TO_MAX_LIF`. +rule liquidatableCanBeLiquidatedOneUnitPinnedLifDual(env e, Midnight.Market market, address borrower, address receiver) { + bytes32 id = summaryToId(market); + oneUnitDualPreamble(e, market, id, borrower); + + require e.block.timestamp > market.maturity, "post-maturity"; + bool healthy = isHealthy(market, id, borrower); + pinLifToMaxLif(e, market, healthy); bytes data; liquidate@withrevert(e, market, 0, 0, 1, borrower, receiver, 0, data); From d7963116e90987c8a0f6fb4ae7b394cb89f9b6e0 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Thu, 21 May 2026 18:39:29 +0200 Subject: [PATCH 21/53] cleaning 1 --- certora/confs/LiquidateLiveness.conf | 2 +- certora/specs/LiquidateLiveness.spec | 209 ++++++++++++++------------- 2 files changed, 113 insertions(+), 98 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 42896dbe0..3df707920 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -40,5 +40,5 @@ "liquidatableCanBeLiquidatedOneUnitRampedLifDual", "liquidatableCanBeLiquidatedOneUnitPinnedLifDual" ], - "msg": "Liquidate Liveness" + "msg": "Liquidate Liveness test" } diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 39951d487..0e880a626 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -89,18 +89,21 @@ strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint25 /// HELPERS /// -/// Per-collateral validity (lltv, maxLif, ExactMath bounds) and LIVENESS bounds (oracle > 0, C_i * P_i fits in uint128). +/// Per-collateral validity (lltv, maxLif, ExactMath bounds) and LIVENESS bounds `C_i * P_i <= ORACLE_PRICE_SCALE * WAD * MAX_UINT128`. function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 i) { uint256 lltv = market.collateralParams[i].lltv; uint256 maxLif = market.collateralParams[i].maxLif; require lltv > 0 && lltv <= WAD(), "valid lltv"; require maxLif >= WAD(), "valid maxLif"; + //require lltv < WAD() => lltv * maxLif <= WAD() * (WAD() - 1), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; require lltv < WAD() => to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; + // @todo check if not needed require to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * to_mathint(WAD()), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; address oracle = market.collateralParams[i].oracle; - require summaryPrice(oracle) > 0, "Oracle returns a positive price "; // @todo is it needed as we have uint256 ? - require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(), "collateral value fits in uint128"; + //require collateral(id, borrower, i) * summaryPrice(oracle) <= ORACLE_PRICE_SCALE() * WAD() * MAX_UINT128(), "collateral value fits in uint128"; + require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(), "..."; + } /// Two-activated-collateral market with bitmap == 3 (bits 0 and 1 set); matches `loop_iter: 2`. @@ -117,29 +120,8 @@ function dualCollateralSetup(Midnight.Market market, bytes32 id, address borrowe validCollateralAt(market, id, borrower, 1); } -/// Common environment / market preconditions -function wellBehavedEnv(env e, Midnight.Market market) { - require e.msg.value == 0, "no value sent"; - require market.liquidatorGate == 0, "no liquidator gate (see Reverts.spec)"; - require e.block.timestamp < MAX_TIMESTAMP(), "timestamp bounded"; - require market.maturity < MAX_TIMESTAMP(), "maturity bounded"; -} - -// Anti overflow requirements for loss accounting -function feasibleLossAccounting(bytes32 id, address borrower) { - require totalUnits(id) >= debtOf(id, borrower), "totalUnits >= borrower debt (Midnight.spec totalUnitsEqualsSumNegativeDebtPlusWithdrawable)"; - require to_mathint(withdrawable(id)) + to_mathint(debtOf(id, borrower)) <= MAX_UINT128(), "withdrawable += repaidUnits won't overflow"; -} - -/// Pins the contract's `lif` (Midnight.sol:625-627) to `maxLif` -/// 1. Borrower is unhealthy and 2. Borrower is healthy and at least TIME_TO_MAX_LIF past maturity -/// Excluding [maturity, maturity + TIME_TO_MAX_LIF) window for still-healthy borrowers but covered by liquidatableCanBeLiquidatedOneUnit -function pinLifToMaxLif(env e, Midnight.Market market, bool healthy) { - require !healthy || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; -} - /// Replicates the contract's `repaidUnits = seizedAssets * P / SCALE * WAD / lif` -/// for Strategy A (seizedAssets = collat) when `lif = maxLif` (see pinLifToMaxLif). +/// for Strategy A (seizedAssets = collat) when `lif = maxLif` function strategyARepaidUnitsAtMaxLif(Midnight.Market market, uint128 collat) returns uint256 { address oracle = market.collateralParams[0].oracle; uint256 maxLif = market.collateralParams[0].maxLif; @@ -147,95 +129,78 @@ function strategyARepaidUnitsAtMaxLif(Midnight.Market market, uint128 collat) re return summaryMulDivUp(step1, WAD(), maxLif); } -/// RULES /// - -/// Sanity baseline: liquidate(0, 0, ...) does not revert on any liquidatable position. -/// Only realizes bad debt; useful as a baseline to confirm the well-behaved environment is correctly set up. -rule liquidateZeroZeroNoRevert(env e, Midnight.Market market, address borrower, address receiver) { - bytes32 id = summaryToId(market); - +/// Common preamble used by every dual-setup rule below: 2-collateral market with +/// bitmap == 3, well-behaved env, both collaterals activated, feasible loss +/// accounting, not locked, and positive debt. +function commonDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) { dualCollateralSetup(market, id, borrower); - wellBehavedEnv(e, market); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); - - require debtOf(id, borrower) > 0, "borrower has debt"; + + require e.msg.value == 0, "no value sent"; + require market.liquidatorGate == 0, "no liquidator gate (see Reverts.spec)"; + require e.block.timestamp < MAX_TIMESTAMP(), "timestamp bounded"; + require market.maturity < MAX_TIMESTAMP(), "maturity bounded"; + + uint256 _debt = debtOf(id, borrower); + require totalUnits(id) >= _debt, "totalUnits >= borrower debt (Midnight.spec totalUnitsEqualsSumNegativeDebtPlusWithdrawable)"; + require to_mathint(withdrawable(id)) + to_mathint(debtOf(id, borrower)) <= MAX_UINT128(), "withdrawable += repaidUnits won't overflow"; + require !liquidationLocked(id, borrower), "not locked"; - require e.block.timestamp > market.maturity || !isHealthy(market, id, borrower), "expired or unhealthy"; - feasibleLossAccounting(id, borrower); - - bytes data; - liquidate@withrevert(e, market, 0, 0, 0, borrower, receiver, 0, data); - assert !lastReverted; + require _debt > 0, "borrower has debt"; } -rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address borrower, address receiver) { - bytes32 id = summaryToId(market); +/// On top of `commonDualPreamble`, adds the LIVENESS bound on collateral 0 +/// (worst-case `lif = maxLif` absorbs the 1-unit seizure). Used by OneUnit variants. +function oneUnitDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) { + commonDualPreamble(e, market, id, borrower); - dualCollateralSetup(market, id, borrower); - wellBehavedEnv(e, market); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); - feasibleLossAccounting(id, borrower); - - require !liquidationLocked(id, borrower), "not locked"; - uint256 debt = debtOf(id, borrower); - require debt > 0, "borrower has debt"; - bool healthy = isHealthy(market, id, borrower); - require e.block.timestamp > market.maturity || !healthy, "expired or unhealthy"; - - require e.block.timestamp > market.maturity || market.rcfThreshold == max_uint256 || market.collateralParams[0].lltv == WAD(), "RCF check bypassed (pre-maturity)"; - - pinLifToMaxLif(e, market, healthy); - - /// `collat > 0` follows from the index-0 invariant + `summaryGetBit(3, 0)`. + address oracle = market.collateralParams[0].oracle; uint128 collat = collateral(id, borrower, 0); - - /// Repay-all - require strategyARepaidUnitsAtMaxLif(market, collat) > debt, "Strategy B applicable"; - - bytes data; - liquidate@withrevert(e, market, 0, 0, debt, borrower, receiver, 0, data); - assert !lastReverted; + uint256 maxLif = market.collateralParams[0].maxLif; + require maxLif * ORACLE_PRICE_SCALE() <= collat * WAD() * summaryPrice(oracle), "LIVENESS: collat 0 absorbs the 1-unit seizure at maxLif"; } -/// DUAL-SETUP HELPERS /// +/// On top of `commonDualPreamble`, restricts to Strategy A (seizing all of +/// collateral 0 at maxLif doesn't fully repay). Returns `collat[0]` so the rule +/// can pass it as `seizedAssets` to `liquidate`. Used by SeizeAll variants. +function seizeAllDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) returns uint128 { + commonDualPreamble(e, market, id, borrower); -/// Common preamble for the dual-setup variants of OneUnit. -/// Sets up a 2-collateral market with bitmap == 3 and the LIVENESS bound on collateral 0 -/// (worst-case `lif = maxLif` absorbing the 1-unit seizure). -function oneUnitDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) { - dualCollateralSetup(market, id, borrower); - wellBehavedEnv(e, market); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); - feasibleLossAccounting(id, borrower); - - require !liquidationLocked(id, borrower), "not locked"; - require debtOf(id, borrower) > 0, "borrower has debt"; - address oracle = market.collateralParams[0].oracle; uint128 collat = collateral(id, borrower, 0); - uint256 maxLif = market.collateralParams[0].maxLif; - require to_mathint(maxLif) * to_mathint(ORACLE_PRICE_SCALE()) <= to_mathint(collat) * to_mathint(WAD()) * to_mathint(summaryPrice(oracle)), "LIVENESS: collat 0 absorbs the 1-unit seizure at maxLif"; + require strategyARepaidUnitsAtMaxLif(market, collat) <= debtOf(id, borrower), "Strategy A applicable"; + return collat; } -/// Common preamble for the dual-setup variants of SeizeAll. -/// Returns `collat[0]` so the rule can pass it as `seizedAssets` to `liquidate`. -function seizeAllDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) returns uint128 { +/// RULES /// + +/// Sanity baseline: liquidate(0, 0, ...) does not revert on any liquidatable position. +/// Only realizes bad debt; useful as a baseline to confirm the well-behaved environment is correctly set up. +rule liquidateZeroZeroNoRevert(env e, Midnight.Market market, address borrower, address receiver) { + bytes32 id = summaryToId(market); + dualCollateralSetup(market, id, borrower); - wellBehavedEnv(e, market); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); + + require e.msg.value == 0, "no value sent"; + require market.liquidatorGate == 0, "no liquidator gate (see Reverts.spec)"; + require e.block.timestamp < MAX_TIMESTAMP(), "timestamp bounded"; + require market.maturity < MAX_TIMESTAMP(), "maturity bounded"; + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); - feasibleLossAccounting(id, borrower); + require debtOf(id, borrower) > 0, "borrower has debt"; require !liquidationLocked(id, borrower), "not locked"; - uint256 debt = debtOf(id, borrower); - require debt > 0, "borrower has debt"; + require e.block.timestamp > market.maturity || !isHealthy(market, id, borrower), "expired or unhealthy"; + require totalUnits(id) >= debtOf(id, borrower), "totalUnits >= borrower debt (Midnight.spec totalUnitsEqualsSumNegativeDebtPlusWithdrawable)"; + require withdrawable(id) + debtOf(id, borrower) <= MAX_UINT128(), "withdrawable += repaidUnits won't overflow"; - uint128 collat = collateral(id, borrower, 0); - require strategyARepaidUnitsAtMaxLif(market, collat) <= debt, "Strategy A applicable"; - return collat; + bytes data; + liquidate@withrevert(e, market, 0, 0, 0, borrower, receiver, 0, data); + assert !lastReverted; } /// SEIZEALL DUAL VARIANTS /// @@ -248,7 +213,7 @@ rule liquidatableCanBeLiquidatedSeizeAllPostMaturityDual(env e, Midnight.Market bool healthy = isHealthy(market, id, borrower); require e.block.timestamp > market.maturity, "post-maturity"; - pinLifToMaxLif(e, market, healthy); + require !healthy || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; bytes data; liquidate@withrevert(e, market, 0, collat, 0, borrower, receiver, 0, data); @@ -262,10 +227,9 @@ rule liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual(env e, Midnight. uint128 collat = seizeAllDualPreamble(e, market, id, borrower); bool healthy = isHealthy(market, id, borrower); - require !healthy, "unhealthy"; require e.block.timestamp <= market.maturity, "pre-maturity"; require market.collateralParams[0].lltv == WAD(), "lltv == WAD => RCF denominator vanishes"; - pinLifToMaxLif(e, market, healthy); + require !healthy || e.block.timestamp >= market.maturity + TIME_TO_MAX_LIF(), "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; bytes data; liquidate@withrevert(e, market, 0, collat, 0, borrower, receiver, 0, data); @@ -312,9 +276,60 @@ rule liquidatableCanBeLiquidatedOneUnitPinnedLifDual(env e, Midnight.Market mark require e.block.timestamp > market.maturity, "post-maturity"; bool healthy = isHealthy(market, id, borrower); - pinLifToMaxLif(e, market, healthy); + require !healthy || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; bytes data; liquidate@withrevert(e, market, 0, 0, 1, borrower, receiver, 0, data); assert !lastReverted; } + + +/////////////////// + + +function wellBehavedEnv(env e, Midnight.Market market) { + require e.msg.value == 0, "no value sent"; + require market.liquidatorGate == 0, "no liquidator gate (see Reverts.spec)"; + require e.block.timestamp < MAX_TIMESTAMP(), "timestamp bounded"; + require market.maturity < MAX_TIMESTAMP(), "maturity bounded"; +} + +function feasibleLossAccounting(bytes32 id, address borrower) { + require totalUnits(id) >= debtOf(id, borrower), "totalUnits >= borrower debt (Midnight.spec totalUnitsEqualsSumNegativeDebtPlusWithdrawable)"; + require to_mathint(withdrawable(id)) + to_mathint(debtOf(id, borrower)) <= MAX_UINT128(), "withdrawable += repaidUnits won't overflow"; +} + +function pinLifToMaxLif(env e, Midnight.Market market, bool healthy) { + require !healthy || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; +} + +rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address borrower, address receiver) { + bytes32 id = summaryToId(market); + + dualCollateralSetup(market, id, borrower); + wellBehavedEnv(e, market); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); + feasibleLossAccounting(id, borrower); + + require !liquidationLocked(id, borrower), "not locked"; + uint256 debt = debtOf(id, borrower); + require debt > 0, "borrower has debt"; + + bool healthy = isHealthy(market, id, borrower); + require e.block.timestamp > market.maturity || !healthy, "expired or unhealthy"; + + require e.block.timestamp > market.maturity || market.rcfThreshold == max_uint256 || market.collateralParams[0].lltv == WAD(), "RCF check bypassed (pre-maturity)"; + + pinLifToMaxLif(e, market, healthy); + + /// `collat > 0` follows from the index-0 invariant + `summaryGetBit(3, 0)`. + uint128 collat = collateral(id, borrower, 0); + + /// Repay-all + require strategyARepaidUnitsAtMaxLif(market, collat) > debt, "Strategy B applicable"; + + bytes data; + liquidate@withrevert(e, market, 0, 0, debt, borrower, receiver, 0, data); + assert !lastReverted; +} \ No newline at end of file From d86ee7b991e566f724257cc0b42f0aaa5b04d739 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Thu, 21 May 2026 18:44:40 +0200 Subject: [PATCH 22/53] linter --- certora/specs/LiquidateLiveness.spec | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 0e880a626..acc7ce931 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -95,15 +95,17 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 maxLif = market.collateralParams[i].maxLif; require lltv > 0 && lltv <= WAD(), "valid lltv"; require maxLif >= WAD(), "valid maxLif"; + //require lltv < WAD() => lltv * maxLif <= WAD() * (WAD() - 1), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; require lltv < WAD() => to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; + // @todo check if not needed require to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * to_mathint(WAD()), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; address oracle = market.collateralParams[i].oracle; + //require collateral(id, borrower, i) * summaryPrice(oracle) <= ORACLE_PRICE_SCALE() * WAD() * MAX_UINT128(), "collateral value fits in uint128"; require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(), "..."; - } /// Two-activated-collateral market with bitmap == 3 (bits 0 and 1 set); matches `loop_iter: 2`. @@ -134,16 +136,16 @@ function strategyARepaidUnitsAtMaxLif(Midnight.Market market, uint128 collat) re /// accounting, not locked, and positive debt. function commonDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) { dualCollateralSetup(market, id, borrower); - + require e.msg.value == 0, "no value sent"; require market.liquidatorGate == 0, "no liquidator gate (see Reverts.spec)"; require e.block.timestamp < MAX_TIMESTAMP(), "timestamp bounded"; require market.maturity < MAX_TIMESTAMP(), "maturity bounded"; - + uint256 _debt = debtOf(id, borrower); require totalUnits(id) >= _debt, "totalUnits >= borrower debt (Midnight.spec totalUnitsEqualsSumNegativeDebtPlusWithdrawable)"; require to_mathint(withdrawable(id)) + to_mathint(debtOf(id, borrower)) <= MAX_UINT128(), "withdrawable += repaidUnits won't overflow"; - + require !liquidationLocked(id, borrower), "not locked"; require _debt > 0, "borrower has debt"; } @@ -153,7 +155,7 @@ function commonDualPreamble(env e, Midnight.Market market, bytes32 id, address b function oneUnitDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) { commonDualPreamble(e, market, id, borrower); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); address oracle = market.collateralParams[0].oracle; @@ -168,7 +170,7 @@ function oneUnitDualPreamble(env e, Midnight.Market market, bytes32 id, address function seizeAllDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) returns uint128 { commonDualPreamble(e, market, id, borrower); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); uint128 collat = collateral(id, borrower, 0); @@ -189,7 +191,7 @@ rule liquidateZeroZeroNoRevert(env e, Midnight.Market market, address borrower, require market.liquidatorGate == 0, "no liquidator gate (see Reverts.spec)"; require e.block.timestamp < MAX_TIMESTAMP(), "timestamp bounded"; require market.maturity < MAX_TIMESTAMP(), "maturity bounded"; - + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); require debtOf(id, borrower) > 0, "borrower has debt"; @@ -283,10 +285,8 @@ rule liquidatableCanBeLiquidatedOneUnitPinnedLifDual(env e, Midnight.Market mark assert !lastReverted; } - /////////////////// - function wellBehavedEnv(env e, Midnight.Market market) { require e.msg.value == 0, "no value sent"; require market.liquidatorGate == 0, "no liquidator gate (see Reverts.spec)"; @@ -332,4 +332,4 @@ rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address bytes data; liquidate@withrevert(e, market, 0, 0, debt, borrower, receiver, 0, data); assert !lastReverted; -} \ No newline at end of file +} From e0e8d03fddaf9b0a2ce54b57ec7800a0cc174c6d Mon Sep 17 00:00:00 2001 From: lilCertora Date: Fri, 22 May 2026 11:23:58 +0200 Subject: [PATCH 23/53] fix --- certora/specs/LiquidateLiveness.spec | 59 +++++++++++++++++----------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index acc7ce931..002f4317e 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -58,6 +58,8 @@ persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivDown(a, b, d) * d <= a * b; axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => (ghostMulDivDown(a, b, d) + 1) * d > a * b; axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivDown(a, b, d) <= a; + axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivDown(a, d, d) == a; + axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivDown(0, a, d) == 0 && ghostMulDivDown(a, 0, d) == 0; } // Axioms bounds proven in MulDiv.spec (mulDivUpRoundsUp, mulDivUpTightBound). @@ -65,6 +67,10 @@ persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivUp(a, b, d) * d >= a * b; axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && ghostMulDivUp(a, b, d) > 0 => (ghostMulDivUp(a, b, d) - 1) * d < a * b; axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivUp(a, b, d) <= a; + axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(a, d, d) == a; + axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(0, a, d) == 0 && ghostMulDivUp(a, 0, d) == 0; + // Monotonicity in first arg — often needed to relate helper to contract: + axiom forall uint256 a1. forall uint256 a2. forall uint256 b. forall uint256 d. d > 0 && a1 <= a2 => ghostMulDivUp(a1, b, d) <= ghostMulDivUp(a2, b, d); } /// Case-analysis on the common deterministic patterns (y == d, x == d, zero inputs). @@ -95,17 +101,15 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 maxLif = market.collateralParams[i].maxLif; require lltv > 0 && lltv <= WAD(), "valid lltv"; require maxLif >= WAD(), "valid maxLif"; - //require lltv < WAD() => lltv * maxLif <= WAD() * (WAD() - 1), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; require lltv < WAD() => to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; - // @todo check if not needed require to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * to_mathint(WAD()), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; address oracle = market.collateralParams[i].oracle; - //require collateral(id, borrower, i) * summaryPrice(oracle) <= ORACLE_PRICE_SCALE() * WAD() * MAX_UINT128(), "collateral value fits in uint128"; require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(), "..."; + } /// Two-activated-collateral market with bitmap == 3 (bits 0 and 1 set); matches `loop_iter: 2`. @@ -200,8 +204,12 @@ rule liquidateZeroZeroNoRevert(env e, Midnight.Market market, address borrower, require totalUnits(id) >= debtOf(id, borrower), "totalUnits >= borrower debt (Midnight.spec totalUnitsEqualsSumNegativeDebtPlusWithdrawable)"; require withdrawable(id) + debtOf(id, borrower) <= MAX_UINT128(), "withdrawable += repaidUnits won't overflow"; + /// Route via the maturity path when available; otherwise via the unhealthy path. The contract's NotLiquidatable + /// check (Midnight.sol:616-620) gates each path: healthyPath=true ⇒ requires timestamp > maturity; + /// healthyPath=false ⇒ requires originalDebt > maxDebt (i.e., !isHealthy). + bool healthyPath = e.block.timestamp > market.maturity; bytes data; - liquidate@withrevert(e, market, 0, 0, 0, borrower, receiver, 0, data); + liquidate@withrevert(e, market, 0, 0, 0, borrower, healthyPath, receiver, 0, data); assert !lastReverted; } @@ -213,12 +221,17 @@ rule liquidatableCanBeLiquidatedSeizeAllPostMaturityDual(env e, Midnight.Market bytes32 id = summaryToId(market); uint128 collat = seizeAllDualPreamble(e, market, id, borrower); - bool healthy = isHealthy(market, id, borrower); - require e.block.timestamp > market.maturity, "post-maturity"; - require !healthy || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; + /// healthyPath=true ramps lif (Midnight.sol:641-643): lif = min(maxLif, WAD + (maxLif-WAD)·Δt/TIME_TO_MAX_LIF). + /// Require Δt ≥ TIME_TO_MAX_LIF unconditionally so that lif resolves to maxLif regardless of `healthy`; this is + /// what makes the helper `strategyARepaidUnitsAtMaxLif` match the contract's `repaidUnits` computation. + /// (The unhealthy + within-ramp-window post-maturity case is intentionally out of scope here.) + require to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: post-maturity by at least TIME_TO_MAX_LIF"; + /// healthyPath=true: the contract gates NotLiquidatable on `timestamp > maturity` only (Midnight.sol:618), so both + /// the healthy and unhealthy branches go through here without the unhealthy-only `debt > maxDebt` gate; this also + /// skips the RCF check inside `if (!healthyPath)` (Midnight.sol:651). bytes data; - liquidate@withrevert(e, market, 0, collat, 0, borrower, receiver, 0, data); + liquidate@withrevert(e, market, 0, collat, 0, borrower, true, receiver, 0, data); assert !lastReverted; } @@ -228,13 +241,14 @@ rule liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual(env e, Midnight. bytes32 id = summaryToId(market); uint128 collat = seizeAllDualPreamble(e, market, id, borrower); - bool healthy = isHealthy(market, id, borrower); require e.block.timestamp <= market.maturity, "pre-maturity"; + require !isHealthy(market, id, borrower), "unhealthy (required by healthyPath=false branch, Midnight.sol:618)"; require market.collateralParams[0].lltv == WAD(), "lltv == WAD => RCF denominator vanishes"; - require !healthy || e.block.timestamp >= market.maturity + TIME_TO_MAX_LIF(), "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; + /// healthyPath=false: pre-maturity liquidation goes through the unhealthy branch; lif = maxLif since + /// healthyPath=false (Midnight.sol:641-643). RCF check is trivialized by lltv == WAD ⇒ maxRepaid = max uint256. bytes data; - liquidate@withrevert(e, market, 0, collat, 0, borrower, receiver, 0, data); + liquidate@withrevert(e, market, 0, collat, 0, borrower, false, receiver, 0, data); assert !lastReverted; } @@ -252,7 +266,7 @@ rule liquidatableCanBeLiquidatedOneUnitPreMaturityLltvFullDual(env e, Midnight.M require market.collateralParams[0].lltv == WAD(), "lltv == WAD => RCF denominator vanishes"; bytes data; - liquidate@withrevert(e, market, 0, 0, 1, borrower, receiver, 0, data); + liquidate@withrevert(e, market, 0, 0, 1, borrower, false, receiver, 0, data); assert !lastReverted; } @@ -266,8 +280,9 @@ rule liquidatableCanBeLiquidatedOneUnitRampedLifDual(env e, Midnight.Market mark require to_mathint(e.block.timestamp) < to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "in ramped window"; require isHealthy(market, id, borrower), "healthy (otherwise lif is pinned)"; + /// healthyPath=true: post-maturity gating and no RCF check (see SeizeAllPostMaturityDual). bytes data; - liquidate@withrevert(e, market, 0, 0, 1, borrower, receiver, 0, data); + liquidate@withrevert(e, market, 0, 0, 1, borrower, true, receiver, 0, data); assert !lastReverted; } @@ -280,8 +295,9 @@ rule liquidatableCanBeLiquidatedOneUnitPinnedLifDual(env e, Midnight.Market mark bool healthy = isHealthy(market, id, borrower); require !healthy || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; + /// healthyPath=true: post-maturity gating and no RCF check (see SeizeAllPostMaturityDual). bytes data; - liquidate@withrevert(e, market, 0, 0, 1, borrower, receiver, 0, data); + liquidate@withrevert(e, market, 0, 0, 1, borrower, true, receiver, 0, data); assert !lastReverted; } @@ -303,6 +319,7 @@ function pinLifToMaxLif(env e, Midnight.Market market, bool healthy) { require !healthy || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; } +/// Post-maturity ⇒ use healthyPath=true, which skips the RCF check entirely. rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); @@ -317,19 +334,15 @@ rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address require debt > 0, "borrower has debt"; bool healthy = isHealthy(market, id, borrower); - require e.block.timestamp > market.maturity || !healthy, "expired or unhealthy"; - - require e.block.timestamp > market.maturity || market.rcfThreshold == max_uint256 || market.collateralParams[0].lltv == WAD(), "RCF check bypassed (pre-maturity)"; - - pinLifToMaxLif(e, market, healthy); + require e.block.timestamp > market.maturity, "post-maturity"; + require !healthy || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif"; - /// `collat > 0` follows from the index-0 invariant + `summaryGetBit(3, 0)`. uint128 collat = collateral(id, borrower, 0); - - /// Repay-all require strategyARepaidUnitsAtMaxLif(market, collat) > debt, "Strategy B applicable"; bytes data; - liquidate@withrevert(e, market, 0, 0, debt, borrower, receiver, 0, data); + // healthyPath=true so RCF check is skipped (it lives inside `if (!healthyPath)`) + liquidate@withrevert(e, market, 0, 0, debt, borrower, true, receiver, 0, data); assert !lastReverted; } + From de4b676285c7ff137fd85686d659c47a38731f5f Mon Sep 17 00:00:00 2001 From: lilCertora Date: Fri, 22 May 2026 14:29:34 +0200 Subject: [PATCH 24/53] linter --- certora/specs/LiquidateLiveness.spec | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 002f4317e..2a4d35c9c 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -69,6 +69,7 @@ persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivUp(a, b, d) <= a; axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(a, d, d) == a; axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(0, a, d) == 0 && ghostMulDivUp(a, 0, d) == 0; + // Monotonicity in first arg — often needed to relate helper to contract: axiom forall uint256 a1. forall uint256 a2. forall uint256 b. forall uint256 d. d > 0 && a1 <= a2 => ghostMulDivUp(a1, b, d) <= ghostMulDivUp(a2, b, d); } @@ -101,15 +102,17 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 maxLif = market.collateralParams[i].maxLif; require lltv > 0 && lltv <= WAD(), "valid lltv"; require maxLif >= WAD(), "valid maxLif"; + //require lltv < WAD() => lltv * maxLif <= WAD() * (WAD() - 1), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; require lltv < WAD() => to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; + // @todo check if not needed require to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * to_mathint(WAD()), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; address oracle = market.collateralParams[i].oracle; + //require collateral(id, borrower, i) * summaryPrice(oracle) <= ORACLE_PRICE_SCALE() * WAD() * MAX_UINT128(), "collateral value fits in uint128"; require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(), "..."; - } /// Two-activated-collateral market with bitmap == 3 (bits 0 and 1 set); matches `loop_iter: 2`. @@ -341,8 +344,8 @@ rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address require strategyARepaidUnitsAtMaxLif(market, collat) > debt, "Strategy B applicable"; bytes data; + // healthyPath=true so RCF check is skipped (it lives inside `if (!healthyPath)`) liquidate@withrevert(e, market, 0, 0, debt, borrower, true, receiver, 0, data); assert !lastReverted; } - From 675548e6d23b7af125dd30c235303785d52357dc Mon Sep 17 00:00:00 2001 From: lilCertora Date: Mon, 25 May 2026 16:55:13 +0200 Subject: [PATCH 25/53] cleaning --- certora/confs/LiquidateLiveness.conf | 74 ++++++++++++---------------- certora/specs/LiquidateLiveness.spec | 41 +++------------ 2 files changed, 39 insertions(+), 76 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 3df707920..0233e655f 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -1,44 +1,32 @@ { - "files": [ - "certora/helpers/Utils.sol", - "src/Midnight.sol" - ], - "verify": "Midnight:certora/specs/LiquidateLiveness.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, - "smt_timeout": 7200, - "prover_args": [ - "-mediumTimeout", - "60", - "-destructiveOptimizations", - "twostage", - "-s", - "[cvc5:nonlin,cvc5:nonlin{randomSeed=2},cvc5:nonlin{randomSeed=3},cvc5:nonlin{randomSeed=4},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}]", - "-dontStopAtFirstSplitTimeout", - "true", - "-backendStrategy", - "singleRace", - "-smt_useLIA", - "false", - "-smt_useNIA", - "true" - ], - "exclude_rule": [ - "nonZeroCollateralsAreActivated" - ], - "split_rules": [ - "liquidateZeroZeroNoRevert", - "liquidatableCanBeLiquidatedRepayAll", - "liquidatableCanBeLiquidatedSeizeAllPostMaturityDual", - "liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual", - "liquidatableCanBeLiquidatedOneUnitPreMaturityLltvFullDual", - "liquidatableCanBeLiquidatedOneUnitRampedLifDual", - "liquidatableCanBeLiquidatedOneUnitPinnedLifDual" - ], - "msg": "Liquidate Liveness test" -} + "files": [ + "certora/helpers/Utils.sol", + "src/Midnight.sol" + ], + "verify": "Midnight:certora/specs/LiquidateLiveness.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, + "smt_timeout": 7200, + "prover_args": [ + " -destructiveOptimizations twostage -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}]" + ], + "build_cache": false, + "exclude_rule": [ + "nonZeroCollateralsAreActivated" + ], + "split_rules": [ + "liquidateZeroZeroNoRevert", + "liquidatableCanBeLiquidatedRepayAll", + "liquidatableCanBeLiquidatedSeizeAllPostMaturityDual", + "liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual", + "liquidatableCanBeLiquidatedOneUnitPreMaturityLltvFullDual", + "liquidatableCanBeLiquidatedOneUnitRampedLifDual", + "liquidatableCanBeLiquidatedOneUnitPinnedLifDual" + ], + "msg": "Liquidate Liveness" +} \ No newline at end of file diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 2a4d35c9c..d107d2fe1 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -103,15 +103,11 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, require lltv > 0 && lltv <= WAD(), "valid lltv"; require maxLif >= WAD(), "valid maxLif"; - //require lltv < WAD() => lltv * maxLif <= WAD() * (WAD() - 1), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; require lltv < WAD() => to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; - - // @todo check if not needed require to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * to_mathint(WAD()), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; address oracle = market.collateralParams[i].oracle; - //require collateral(id, borrower, i) * summaryPrice(oracle) <= ORACLE_PRICE_SCALE() * WAD() * MAX_UINT128(), "collateral value fits in uint128"; require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(), "..."; } @@ -155,6 +151,9 @@ function commonDualPreamble(env e, Midnight.Market market, bytes32 id, address b require !liquidationLocked(id, borrower), "not locked"; require _debt > 0, "borrower has debt"; + + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); } /// On top of `commonDualPreamble`, adds the LIVENESS bound on collateral 0 @@ -162,9 +161,6 @@ function commonDualPreamble(env e, Midnight.Market market, bytes32 id, address b function oneUnitDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) { commonDualPreamble(e, market, id, borrower); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); - address oracle = market.collateralParams[0].oracle; uint128 collat = collateral(id, borrower, 0); uint256 maxLif = market.collateralParams[0].maxLif; @@ -177,9 +173,6 @@ function oneUnitDualPreamble(env e, Midnight.Market market, bytes32 id, address function seizeAllDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) returns uint128 { commonDualPreamble(e, market, id, borrower); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); - uint128 collat = collateral(id, borrower, 0); require strategyARepaidUnitsAtMaxLif(market, collat) <= debtOf(id, borrower), "Strategy A applicable"; return collat; @@ -199,6 +192,7 @@ rule liquidateZeroZeroNoRevert(env e, Midnight.Market market, address borrower, require e.block.timestamp < MAX_TIMESTAMP(), "timestamp bounded"; require market.maturity < MAX_TIMESTAMP(), "maturity bounded"; + // idx 0 not needed as seizedAssets = repaidUnits = 0 requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); require debtOf(id, borrower) > 0, "borrower has debt"; @@ -306,35 +300,14 @@ rule liquidatableCanBeLiquidatedOneUnitPinnedLifDual(env e, Midnight.Market mark /////////////////// -function wellBehavedEnv(env e, Midnight.Market market) { - require e.msg.value == 0, "no value sent"; - require market.liquidatorGate == 0, "no liquidator gate (see Reverts.spec)"; - require e.block.timestamp < MAX_TIMESTAMP(), "timestamp bounded"; - require market.maturity < MAX_TIMESTAMP(), "maturity bounded"; -} - -function feasibleLossAccounting(bytes32 id, address borrower) { - require totalUnits(id) >= debtOf(id, borrower), "totalUnits >= borrower debt (Midnight.spec totalUnitsEqualsSumNegativeDebtPlusWithdrawable)"; - require to_mathint(withdrawable(id)) + to_mathint(debtOf(id, borrower)) <= MAX_UINT128(), "withdrawable += repaidUnits won't overflow"; -} - -function pinLifToMaxLif(env e, Midnight.Market market, bool healthy) { - require !healthy || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; -} - /// Post-maturity ⇒ use healthyPath=true, which skips the RCF check entirely. rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); - dualCollateralSetup(market, id, borrower); - wellBehavedEnv(e, market); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); - feasibleLossAccounting(id, borrower); + //uint128 collat = seizeAllDualPreamble(e, market, id, borrower); + commonDualPreamble(e, market, id, borrower); - require !liquidationLocked(id, borrower), "not locked"; uint256 debt = debtOf(id, borrower); - require debt > 0, "borrower has debt"; bool healthy = isHealthy(market, id, borrower); require e.block.timestamp > market.maturity, "post-maturity"; @@ -349,3 +322,5 @@ rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address liquidate@withrevert(e, market, 0, 0, debt, borrower, true, receiver, 0, data); assert !lastReverted; } + +// @todo Pre-maturity, lltv < WAD, RCF actually engaged is not covered — only the lltv == WAD collapse case. Worth a TODO comment. \ No newline at end of file From c75cf2c865628fbe34306fce3bd310c9cfc82436 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Mon, 25 May 2026 16:56:03 +0200 Subject: [PATCH 26/53] linter --- certora/specs/LiquidateLiveness.spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index d107d2fe1..a9b276abe 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -323,4 +323,4 @@ rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address assert !lastReverted; } -// @todo Pre-maturity, lltv < WAD, RCF actually engaged is not covered — only the lltv == WAD collapse case. Worth a TODO comment. \ No newline at end of file +// @todo Pre-maturity, lltv < WAD, RCF actually engaged is not covered — only the lltv == WAD collapse case. Worth a TODO comment. From 7add224d2c221782df7e84d9419f9f384ee1c689 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Mon, 25 May 2026 17:02:33 +0200 Subject: [PATCH 27/53] cleaning --- certora/confs/LiquidateLiveness.conf | 62 ++++++++++++++-------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 0233e655f..a3ee32592 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -1,32 +1,32 @@ { - "files": [ - "certora/helpers/Utils.sol", - "src/Midnight.sol" - ], - "verify": "Midnight:certora/specs/LiquidateLiveness.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, - "smt_timeout": 7200, - "prover_args": [ - " -destructiveOptimizations twostage -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}]" - ], - "build_cache": false, - "exclude_rule": [ - "nonZeroCollateralsAreActivated" - ], - "split_rules": [ - "liquidateZeroZeroNoRevert", - "liquidatableCanBeLiquidatedRepayAll", - "liquidatableCanBeLiquidatedSeizeAllPostMaturityDual", - "liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual", - "liquidatableCanBeLiquidatedOneUnitPreMaturityLltvFullDual", - "liquidatableCanBeLiquidatedOneUnitRampedLifDual", - "liquidatableCanBeLiquidatedOneUnitPinnedLifDual" - ], - "msg": "Liquidate Liveness" -} \ No newline at end of file + "files": [ + "certora/helpers/Utils.sol", + "src/Midnight.sol" + ], + "verify": "Midnight:certora/specs/LiquidateLiveness.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, + "smt_timeout": 7200, + "prover_args": [ + " -destructiveOptimizations twostage -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}]" + ], + "build_cache": false, + "exclude_rule": [ + "nonZeroCollateralsAreActivated" + ], + "split_rules": [ + "liquidateZeroZeroNoRevert", + "liquidatableCanBeLiquidatedRepayAll", + "liquidatableCanBeLiquidatedSeizeAllPostMaturityDual", + "liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual", + "liquidatableCanBeLiquidatedSeizeAllPreMaturityRcfEngagedDual", + "liquidatableCanBeLiquidatedOneUnitPreMaturityDual", + "liquidatableCanBeLiquidatedOneUnitPostMaturityDual" + ], + "msg": "Liquidate Liveness" +} From 2354089d49a2680a4040c5ee5aa36b518767b225 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Mon, 25 May 2026 17:57:14 +0200 Subject: [PATCH 28/53] cleaning 2 --- certora/confs/LiquidateLiveness.conf | 61 ++++++++++++++-------------- certora/specs/LiquidateLiveness.spec | 46 ++++----------------- 2 files changed, 39 insertions(+), 68 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index a3ee32592..7d035daa3 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -1,32 +1,31 @@ { - "files": [ - "certora/helpers/Utils.sol", - "src/Midnight.sol" - ], - "verify": "Midnight:certora/specs/LiquidateLiveness.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, - "smt_timeout": 7200, - "prover_args": [ - " -destructiveOptimizations twostage -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}]" - ], - "build_cache": false, - "exclude_rule": [ - "nonZeroCollateralsAreActivated" - ], - "split_rules": [ - "liquidateZeroZeroNoRevert", - "liquidatableCanBeLiquidatedRepayAll", - "liquidatableCanBeLiquidatedSeizeAllPostMaturityDual", - "liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual", - "liquidatableCanBeLiquidatedSeizeAllPreMaturityRcfEngagedDual", - "liquidatableCanBeLiquidatedOneUnitPreMaturityDual", - "liquidatableCanBeLiquidatedOneUnitPostMaturityDual" - ], - "msg": "Liquidate Liveness" -} + "files": [ + "certora/helpers/Utils.sol", + "src/Midnight.sol" + ], + "verify": "Midnight:certora/specs/LiquidateLiveness.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, + "smt_timeout": 7200, + "prover_args": [ + " -destructiveOptimizations twostage -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}]" + ], + "build_cache": false, + "exclude_rule": [ + "nonZeroCollateralsAreActivated" + ], + "split_rules": [ + "liquidateZeroZeroNoRevert", + "liquidatableCanBeLiquidatedRepayAll", + "liquidatableCanBeLiquidatedSeizeAllPostMaturityDual", + "liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual", + "liquidatableCanBeLiquidatedOneUnitPreMaturityLltvFullDual", + "liquidatableCanBeLiquidatedOneUnitPostMaturityDual" + ], + "msg": "Liquidate Liveness" +} \ No newline at end of file diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index a9b276abe..305371eb2 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -111,9 +111,7 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(), "..."; } -/// Two-activated-collateral market with bitmap == 3 (bits 0 and 1 set); matches `loop_iter: 2`. -/// Proofs in this spec only formally cover 2-collateral markets. A length-1 generalization -/// was tested and regressed `RepayAll` and both `RcfMaxNoBadDebt` rules; see commit history. +/// 2-collateral market, bitmap == 3 (bits 0+1 set); matches `loop_iter: 2`. Length-1 regressed `RepayAll` and `RcfMaxNoBadDebt` (see git history). function dualCollateralSetup(Midnight.Market market, bytes32 id, address borrower) { require market.collateralParams.length == 2, "two-collateral market"; require collateralBitmap(id, borrower) == 3, "bitmap is exactly 3 (bits 0 and 1 set)"; @@ -134,9 +132,7 @@ function strategyARepaidUnitsAtMaxLif(Midnight.Market market, uint128 collat) re return summaryMulDivUp(step1, WAD(), maxLif); } -/// Common preamble used by every dual-setup rule below: 2-collateral market with -/// bitmap == 3, well-behaved env, both collaterals activated, feasible loss -/// accounting, not locked, and positive debt. +/// Shared preamble: dual-collateral setup, well-behaved env, activated collaterals, feasible loss accounting, unlocked, positive debt. function commonDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) { dualCollateralSetup(market, id, borrower); @@ -201,9 +197,7 @@ rule liquidateZeroZeroNoRevert(env e, Midnight.Market market, address borrower, require totalUnits(id) >= debtOf(id, borrower), "totalUnits >= borrower debt (Midnight.spec totalUnitsEqualsSumNegativeDebtPlusWithdrawable)"; require withdrawable(id) + debtOf(id, borrower) <= MAX_UINT128(), "withdrawable += repaidUnits won't overflow"; - /// Route via the maturity path when available; otherwise via the unhealthy path. The contract's NotLiquidatable - /// check (Midnight.sol:616-620) gates each path: healthyPath=true ⇒ requires timestamp > maturity; - /// healthyPath=false ⇒ requires originalDebt > maxDebt (i.e., !isHealthy). + /// Route via maturity path if post-maturity, else unhealthy path (NotLiquidatable gates each accordingly). bool healthyPath = e.block.timestamp > market.maturity; bytes data; liquidate@withrevert(e, market, 0, 0, 0, borrower, healthyPath, receiver, 0, data); @@ -218,15 +212,10 @@ rule liquidatableCanBeLiquidatedSeizeAllPostMaturityDual(env e, Midnight.Market bytes32 id = summaryToId(market); uint128 collat = seizeAllDualPreamble(e, market, id, borrower); - /// healthyPath=true ramps lif (Midnight.sol:641-643): lif = min(maxLif, WAD + (maxLif-WAD)·Δt/TIME_TO_MAX_LIF). - /// Require Δt ≥ TIME_TO_MAX_LIF unconditionally so that lif resolves to maxLif regardless of `healthy`; this is - /// what makes the helper `strategyARepaidUnitsAtMaxLif` match the contract's `repaidUnits` computation. - /// (The unhealthy + within-ramp-window post-maturity case is intentionally out of scope here.) + /// Δt ≥ TIME_TO_MAX_LIF pins lif = maxLif, matching `strategyARepaidUnitsAtMaxLif`. The unhealthy + within-ramp-window case is out of scope. require to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: post-maturity by at least TIME_TO_MAX_LIF"; - /// healthyPath=true: the contract gates NotLiquidatable on `timestamp > maturity` only (Midnight.sol:618), so both - /// the healthy and unhealthy branches go through here without the unhealthy-only `debt > maxDebt` gate; this also - /// skips the RCF check inside `if (!healthyPath)` (Midnight.sol:651). + /// healthyPath=true: gated only on `timestamp > maturity` (no debt > maxDebt, no RCF check). bytes data; liquidate@withrevert(e, market, 0, collat, 0, borrower, true, receiver, 0, data); assert !lastReverted; @@ -267,30 +256,14 @@ rule liquidatableCanBeLiquidatedOneUnitPreMaturityLltvFullDual(env e, Midnight.M assert !lastReverted; } -/// Post-maturity, healthy, `timestamp in [maturity, maturity + TIME_TO_MAX_LIF)`. -/// `lif` ramps symbolically in `[WAD, maxLif)`. No RCF check (post-maturity). -rule liquidatableCanBeLiquidatedOneUnitRampedLifDual(env e, Midnight.Market market, address borrower, address receiver) { +/// Post-maturity (merged Ramped + Pinned). Union of regimes covers all `timestamp > maturity`: +/// `lif` is either ramped in `[WAD, maxLif)` (healthy + within-window) or pinned at `maxLif` +/// (unhealthy or `timestamp >= maturity + TIME_TO_MAX_LIF`). No RCF check (post-maturity). +rule liquidatableCanBeLiquidatedOneUnitPostMaturityDual(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); oneUnitDualPreamble(e, market, id, borrower); require e.block.timestamp > market.maturity, "post-maturity"; - require to_mathint(e.block.timestamp) < to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "in ramped window"; - require isHealthy(market, id, borrower), "healthy (otherwise lif is pinned)"; - - /// healthyPath=true: post-maturity gating and no RCF check (see SeizeAllPostMaturityDual). - bytes data; - liquidate@withrevert(e, market, 0, 0, 1, borrower, true, receiver, 0, data); - assert !lastReverted; -} - -/// Post-maturity, `lif = maxLif` (constant). Either unhealthy or `timestamp >= maturity + TIME_TO_MAX_LIF`. -rule liquidatableCanBeLiquidatedOneUnitPinnedLifDual(env e, Midnight.Market market, address borrower, address receiver) { - bytes32 id = summaryToId(market); - oneUnitDualPreamble(e, market, id, borrower); - - require e.block.timestamp > market.maturity, "post-maturity"; - bool healthy = isHealthy(market, id, borrower); - require !healthy || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: unhealthy, or post-maturity by at least TIME_TO_MAX_LIF"; /// healthyPath=true: post-maturity gating and no RCF check (see SeizeAllPostMaturityDual). bytes data; @@ -304,7 +277,6 @@ rule liquidatableCanBeLiquidatedOneUnitPinnedLifDual(env e, Midnight.Market mark rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); - //uint128 collat = seizeAllDualPreamble(e, market, id, borrower); commonDualPreamble(e, market, id, borrower); uint256 debt = debtOf(id, borrower); From 28bf76453b357b762eb56cf37a51b7888ec6a14f Mon Sep 17 00:00:00 2001 From: lilCertora Date: Mon, 25 May 2026 18:02:29 +0200 Subject: [PATCH 29/53] conf linter --- certora/confs/LiquidateLiveness.conf | 60 ++++++++++++++-------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 7d035daa3..63f20f5bc 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -1,31 +1,31 @@ { - "files": [ - "certora/helpers/Utils.sol", - "src/Midnight.sol" - ], - "verify": "Midnight:certora/specs/LiquidateLiveness.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, - "smt_timeout": 7200, - "prover_args": [ - " -destructiveOptimizations twostage -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}]" - ], - "build_cache": false, - "exclude_rule": [ - "nonZeroCollateralsAreActivated" - ], - "split_rules": [ - "liquidateZeroZeroNoRevert", - "liquidatableCanBeLiquidatedRepayAll", - "liquidatableCanBeLiquidatedSeizeAllPostMaturityDual", - "liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual", - "liquidatableCanBeLiquidatedOneUnitPreMaturityLltvFullDual", - "liquidatableCanBeLiquidatedOneUnitPostMaturityDual" - ], - "msg": "Liquidate Liveness" -} \ No newline at end of file + "files": [ + "certora/helpers/Utils.sol", + "src/Midnight.sol" + ], + "verify": "Midnight:certora/specs/LiquidateLiveness.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, + "smt_timeout": 7200, + "prover_args": [ + " -destructiveOptimizations twostage -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}]" + ], + "build_cache": false, + "exclude_rule": [ + "nonZeroCollateralsAreActivated" + ], + "split_rules": [ + "liquidateZeroZeroNoRevert", + "liquidatableCanBeLiquidatedRepayAll", + "liquidatableCanBeLiquidatedSeizeAllPostMaturityDual", + "liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual", + "liquidatableCanBeLiquidatedOneUnitPreMaturityLltvFullDual", + "liquidatableCanBeLiquidatedOneUnitPostMaturityDual" + ], + "msg": "Liquidate Liveness" +} From a3f604a54c3fc41988db0e59172a9d0f2bcbef6d Mon Sep 17 00:00:00 2001 From: lilCertora Date: Wed, 27 May 2026 14:51:52 +0200 Subject: [PATCH 30/53] cleaning and simplifying --- certora/confs/LiquidateLiveness.conf | 16 ++-- certora/specs/LiquidateLiveness.spec | 127 ++++++++------------------- 2 files changed, 42 insertions(+), 101 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 63f20f5bc..d8d466646 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -13,19 +13,17 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - " -destructiveOptimizations twostage -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}]" - ], + " -destructiveOptimizations twostage -mediumTimeout 20 -lowTimeout 20 -tinyTimeout 20 -depth 20" + ], "build_cache": false, "exclude_rule": [ "nonZeroCollateralsAreActivated" ], - "split_rules": [ + "split_rules": [ "liquidateZeroZeroNoRevert", - "liquidatableCanBeLiquidatedRepayAll", - "liquidatableCanBeLiquidatedSeizeAllPostMaturityDual", - "liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual", - "liquidatableCanBeLiquidatedOneUnitPreMaturityLltvFullDual", - "liquidatableCanBeLiquidatedOneUnitPostMaturityDual" + "liquidatableCanBeLiquidatedPreMaturityLltvFullDual", + "liquidatableCanBeLiquidatedPreMaturityRcfEngagedDual", + "liquidatableCanBeLiquidatedPostMaturityDual", ], - "msg": "Liquidate Liveness" + "msg": "Liquidate Liveness TRY WORKING CONF" } diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 305371eb2..9955dd7ab 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -4,6 +4,17 @@ import "BitmapSummaries.spec"; using Utils as Utils; +/** +Property: "Debts can always be liquidated if unhealthy or expired." + +This is an existential liveness claim: for every (unhealthy ∨ expired) borrower with debt > 0, +there exists a `liquidate(...)` call that succeeds. One witness per state-space partition is enough, +and the minimal witness is `repaidUnits = 1` (the weakest possible non-trivial liquidation). + +Stronger statements ("the entire collateral can be seized in one call" / "the entire debt can be +repaid in one call") live in LiquidateLivenessStronger.spec. +*/ + methods { function multicall(bytes[]) external => HAVOC_ALL DELETE; @@ -100,14 +111,13 @@ strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint25 function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 i) { uint256 lltv = market.collateralParams[i].lltv; uint256 maxLif = market.collateralParams[i].maxLif; + require lltv > 0 && lltv <= WAD(), "valid lltv"; require maxLif >= WAD(), "valid maxLif"; - require lltv < WAD() => to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; require to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * to_mathint(WAD()), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; address oracle = market.collateralParams[i].oracle; - require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(), "..."; } @@ -123,15 +133,6 @@ function dualCollateralSetup(Midnight.Market market, bytes32 id, address borrowe validCollateralAt(market, id, borrower, 1); } -/// Replicates the contract's `repaidUnits = seizedAssets * P / SCALE * WAD / lif` -/// for Strategy A (seizedAssets = collat) when `lif = maxLif` -function strategyARepaidUnitsAtMaxLif(Midnight.Market market, uint128 collat) returns uint256 { - address oracle = market.collateralParams[0].oracle; - uint256 maxLif = market.collateralParams[0].maxLif; - uint256 step1 = summaryMulDivUp(collat, summaryPrice(oracle), ORACLE_PRICE_SCALE()); - return summaryMulDivUp(step1, WAD(), maxLif); -} - /// Shared preamble: dual-collateral setup, well-behaved env, activated collaterals, feasible loss accounting, unlocked, positive debt. function commonDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) { dualCollateralSetup(market, id, borrower); @@ -152,7 +153,7 @@ function commonDualPreamble(env e, Midnight.Market market, bytes32 id, address b requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); } -/// On top of `commonDualPreamble`, adds the LIVENESS bound on collateral 0 +/// On top of `commonDualPreamble`, adds the liveness bound on collateral 0 /// (worst-case `lif = maxLif` absorbs the 1-unit seizure). Used by OneUnit variants. function oneUnitDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) { commonDualPreamble(e, market, id, borrower); @@ -163,15 +164,13 @@ function oneUnitDualPreamble(env e, Midnight.Market market, bytes32 id, address require maxLif * ORACLE_PRICE_SCALE() <= collat * WAD() * summaryPrice(oracle), "LIVENESS: collat 0 absorbs the 1-unit seizure at maxLif"; } -/// On top of `commonDualPreamble`, restricts to Strategy A (seizing all of -/// collateral 0 at maxLif doesn't fully repay). Returns `collat[0]` so the rule -/// can pass it as `seizedAssets` to `liquidate`. Used by SeizeAll variants. -function seizeAllDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) returns uint128 { - commonDualPreamble(e, market, id, borrower); - - uint128 collat = collateral(id, borrower, 0); - require strategyARepaidUnitsAtMaxLif(market, collat) <= debtOf(id, borrower), "Strategy A applicable"; - return collat; +/// Compute `repaidUnits = seizedAssets * P / SCALE * WAD / lif` for Strategy A (seizedAssets = collat) when `lif = maxLif`. +/// Used here only by the RCF escape hatch precondition in the lltv < WAD case. +function strategyARepaidUnitsAtMaxLif(Midnight.Market market, uint128 collat) returns uint256 { + address oracle = market.collateralParams[0].oracle; + uint256 maxLif = market.collateralParams[0].maxLif; + uint256 step1 = summaryMulDivUp(collat, summaryPrice(oracle), ORACLE_PRICE_SCALE()); + return summaryMulDivUp(step1, WAD(), maxLif); } /// RULES /// @@ -181,21 +180,8 @@ function seizeAllDualPreamble(env e, Midnight.Market market, bytes32 id, address rule liquidateZeroZeroNoRevert(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); - dualCollateralSetup(market, id, borrower); - - require e.msg.value == 0, "no value sent"; - require market.liquidatorGate == 0, "no liquidator gate (see Reverts.spec)"; - require e.block.timestamp < MAX_TIMESTAMP(), "timestamp bounded"; - require market.maturity < MAX_TIMESTAMP(), "maturity bounded"; - - // idx 0 not needed as seizedAssets = repaidUnits = 0 - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); - - require debtOf(id, borrower) > 0, "borrower has debt"; - require !liquidationLocked(id, borrower), "not locked"; + commonDualPreamble(e, market, id, borrower); require e.block.timestamp > market.maturity || !isHealthy(market, id, borrower), "expired or unhealthy"; - require totalUnits(id) >= debtOf(id, borrower), "totalUnits >= borrower debt (Midnight.spec totalUnitsEqualsSumNegativeDebtPlusWithdrawable)"; - require withdrawable(id) + debtOf(id, borrower) <= MAX_UINT128(), "withdrawable += repaidUnits won't overflow"; /// Route via maturity path if post-maturity, else unhealthy path (NotLiquidatable gates each accordingly). bool healthyPath = e.block.timestamp > market.maturity; @@ -204,46 +190,10 @@ rule liquidateZeroZeroNoRevert(env e, Midnight.Market market, address borrower, assert !lastReverted; } -/// SEIZEALL DUAL VARIANTS /// - -/// Post-maturity. RCF is deactivated entirely (Midnight.sol:635), so the nonlinear `maxRepaid` -/// formula is never evaluated. -rule liquidatableCanBeLiquidatedSeizeAllPostMaturityDual(env e, Midnight.Market market, address borrower, address receiver) { - bytes32 id = summaryToId(market); - uint128 collat = seizeAllDualPreamble(e, market, id, borrower); - - /// Δt ≥ TIME_TO_MAX_LIF pins lif = maxLif, matching `strategyARepaidUnitsAtMaxLif`. The unhealthy + within-ramp-window case is out of scope. - require to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif: post-maturity by at least TIME_TO_MAX_LIF"; - - /// healthyPath=true: gated only on `timestamp > maturity` (no debt > maxDebt, no RCF check). - bytes data; - liquidate@withrevert(e, market, 0, collat, 0, borrower, true, receiver, 0, data); - assert !lastReverted; -} - -/// Pre-maturity unhealthy, `lltv[0] == WAD`. RCF `maxRepaid` collapses to `type(uint256).max` -/// (Midnight.sol:638-640), so the first RCF disjunct holds trivially. -rule liquidatableCanBeLiquidatedSeizeAllPreMaturityLltvFullDual(env e, Midnight.Market market, address borrower, address receiver) { - bytes32 id = summaryToId(market); - uint128 collat = seizeAllDualPreamble(e, market, id, borrower); - - require e.block.timestamp <= market.maturity, "pre-maturity"; - require !isHealthy(market, id, borrower), "unhealthy (required by healthyPath=false branch, Midnight.sol:618)"; - require market.collateralParams[0].lltv == WAD(), "lltv == WAD => RCF denominator vanishes"; - - /// healthyPath=false: pre-maturity liquidation goes through the unhealthy branch; lif = maxLif since - /// healthyPath=false (Midnight.sol:641-643). RCF check is trivialized by lltv == WAD ⇒ maxRepaid = max uint256. - bytes data; - liquidate@withrevert(e, market, 0, collat, 0, borrower, false, receiver, 0, data); - assert !lastReverted; -} - -/// ONEUNIT DUAL VARIANTS /// -/// Witness for "some debt can be repaid" with `repaidUnits = 1` on a 2-collateral market. -/// Split by lif/RCF regime so each sub-case is SMT-tractable. +/// Each rule witnesses "repaidUnits = 1 can be liquidated" in one partition of the (unhealthy ∨ expired) state space. /// Pre-maturity unhealthy, `lltv[0] == WAD`. RCF `maxRepaid` collapses to `type(uint256).max`. -rule liquidatableCanBeLiquidatedOneUnitPreMaturityLltvFullDual(env e, Midnight.Market market, address borrower, address receiver) { +rule liquidatableCanBeLiquidatedPreMaturityLltvFullDual(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); oneUnitDualPreamble(e, market, id, borrower); @@ -259,40 +209,33 @@ rule liquidatableCanBeLiquidatedOneUnitPreMaturityLltvFullDual(env e, Midnight.M /// Post-maturity (merged Ramped + Pinned). Union of regimes covers all `timestamp > maturity`: /// `lif` is either ramped in `[WAD, maxLif)` (healthy + within-window) or pinned at `maxLif` /// (unhealthy or `timestamp >= maturity + TIME_TO_MAX_LIF`). No RCF check (post-maturity). -rule liquidatableCanBeLiquidatedOneUnitPostMaturityDual(env e, Midnight.Market market, address borrower, address receiver) { +rule liquidatableCanBeLiquidatedPostMaturityDual(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); oneUnitDualPreamble(e, market, id, borrower); require e.block.timestamp > market.maturity, "post-maturity"; - /// healthyPath=true: post-maturity gating and no RCF check (see SeizeAllPostMaturityDual). + /// healthyPath=true: post-maturity gating and no RCF check. bytes data; liquidate@withrevert(e, market, 0, 0, 1, borrower, true, receiver, 0, data); assert !lastReverted; } -/////////////////// - -/// Post-maturity ⇒ use healthyPath=true, which skips the RCF check entirely. -rule liquidatableCanBeLiquidatedRepayAll(env e, Midnight.Market market, address borrower, address receiver) { +/// Pre-maturity unhealthy, `lltv[0] < WAD`, RCF actually engaged. +/// Uses the rcfThreshold escape hatch : requiring `rcfThreshold > strategyARepaidUnitsAtMaxLif` +/// makes the escape-hatch disjunct hold unconditionally, sidestepping `maxRepaid`. +rule liquidatableCanBeLiquidatedPreMaturityRcfEngagedDual(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); + oneUnitDualPreamble(e, market, id, borrower); - commonDualPreamble(e, market, id, borrower); - - uint256 debt = debtOf(id, borrower); - - bool healthy = isHealthy(market, id, borrower); - require e.block.timestamp > market.maturity, "post-maturity"; - require !healthy || to_mathint(e.block.timestamp) >= to_mathint(market.maturity) + to_mathint(TIME_TO_MAX_LIF()), "lif = maxLif"; + require e.block.timestamp <= market.maturity, "pre-maturity"; + require !isHealthy(market, id, borrower), "unhealthy (healthyPath=false branch, lif pinned at maxLif Midnight.sol:643)"; + require market.collateralParams[0].lltv < WAD(), "lltv < WAD => RCF maxRepaid is finite, formula actually evaluated"; uint128 collat = collateral(id, borrower, 0); - require strategyARepaidUnitsAtMaxLif(market, collat) > debt, "Strategy B applicable"; + require market.rcfThreshold > strategyARepaidUnitsAtMaxLif(market, collat), "rcfThreshold escape hatch trivializes RCF"; bytes data; - - // healthyPath=true so RCF check is skipped (it lives inside `if (!healthyPath)`) - liquidate@withrevert(e, market, 0, 0, debt, borrower, true, receiver, 0, data); + liquidate@withrevert(e, market, 0, 0, 1, borrower, false, receiver, 0, data); assert !lastReverted; } - -// @todo Pre-maturity, lltv < WAD, RCF actually engaged is not covered — only the lltv == WAD collapse case. Worth a TODO comment. From 05d53405542cac5802a3a5ab4add07e3bce45525 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Wed, 27 May 2026 14:59:44 +0200 Subject: [PATCH 31/53] conf linter --- certora/confs/LiquidateLiveness.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index d8d466646..cf8d6b815 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -23,7 +23,7 @@ "liquidateZeroZeroNoRevert", "liquidatableCanBeLiquidatedPreMaturityLltvFullDual", "liquidatableCanBeLiquidatedPreMaturityRcfEngagedDual", - "liquidatableCanBeLiquidatedPostMaturityDual", + "liquidatableCanBeLiquidatedPostMaturityDual" ], - "msg": "Liquidate Liveness TRY WORKING CONF" + "msg": "Liquidate Liveness" } From e80c7ab676c5fa51bbf8131030c28fb1be05b05e Mon Sep 17 00:00:00 2001 From: lilCertora Date: Wed, 27 May 2026 18:47:44 +0200 Subject: [PATCH 32/53] deep cleaning --- certora/confs/LiquidateLiveness.conf | 26 ++-- certora/specs/LiquidateLiveness.spec | 173 ++++++++++----------------- 2 files changed, 77 insertions(+), 122 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index cf8d6b815..852965cbb 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -3,7 +3,7 @@ "certora/helpers/Utils.sol", "src/Midnight.sol" ], - "verify": "Midnight:certora/specs/LiquidateLiveness.spec", + "verify": "Midnight:certora/specs/LiquidateLivenessMinimal.spec", "solc": "solc-0.8.34", "solc_via_ir": true, "solc_evm_version": "osaka", @@ -13,17 +13,25 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - " -destructiveOptimizations twostage -mediumTimeout 20 -lowTimeout 20 -tinyTimeout 20 -depth 20" - ], + "-destructiveOptimizations twostage", + "-mediumTimeout 20", + "-lowTimeout 20", + "-tinyTimeout 20", + "-depth 20", + "-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}]", + "-dontStopAtFirstSplitTimeout true", + "-backendStrategy singleRace", + "-smt_useLIA false", + "-smt_useNIA true" + ], "build_cache": false, "exclude_rule": [ "nonZeroCollateralsAreActivated" ], - "split_rules": [ - "liquidateZeroZeroNoRevert", - "liquidatableCanBeLiquidatedPreMaturityLltvFullDual", - "liquidatableCanBeLiquidatedPreMaturityRcfEngagedDual", - "liquidatableCanBeLiquidatedPostMaturityDual" + "split_rules": [ + "postMaturityCanBeLiquidated", + "unhealthyLltvFullCanBeLiquidated", + "unhealthyCanBeLiquidated" ], - "msg": "Liquidate Liveness" + "msg": "Liquidate Liveness (minimal)" } diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 9955dd7ab..db9642e5a 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -7,18 +7,27 @@ using Utils as Utils; /** Property: "Debts can always be liquidated if unhealthy or expired." -This is an existential liveness claim: for every (unhealthy ∨ expired) borrower with debt > 0, -there exists a `liquidate(...)` call that succeeds. One witness per state-space partition is enough, -and the minimal witness is `repaidUnits = 1` (the weakest possible non-trivial liquidation). +Existential liveness: for every liquidatable borrower, there exists a `liquidate` +call that succeeds. Witness in all rules: `repaidUnits = 1`. -Stronger statements ("the entire collateral can be seized in one call" / "the entire debt can be -repaid in one call") live in LiquidateLivenessStronger.spec. +- postMaturityCanBeLiquidated: `healthyPath = true` covers all `timestamp > maturity`. + RCF is skipped. + +- unhealthyLltvFullCanBeLiquidated: `healthyPath = false`, `lltv == WAD`. RCF maxRepaid + collapses to `type(uint256).max` (L656), so the RCF check passes unconditionally. + +- unhealthyCanBeLiquidated: `healthyPath = false`, any lltv. Uses the `rcfThreshold` + escape hatch (second disjunct of L659-660) to sidestep the nonlinear `maxRepaid`. + +Together they cover the property except for: unhealthy borrowers with `lltv < WAD` +in a market where the `rcfThreshold` escape hatch does not hold. That partition +requires reasoning about the nonlinear `maxRepaid` formula at L655 and is the +acknowledged limit of this proof. */ methods { function multicall(bytes[]) external => HAVOC_ALL DELETE; - /// ENVFREE VIEWS /// function debtOf(bytes32 id, address user) external returns (uint256) envfree; function collateral(bytes32 id, address user, uint256 index) external returns (uint128) envfree; function collateralBitmap(bytes32 id, address user) external returns (uint128) envfree; @@ -28,43 +37,29 @@ methods { function withdrawable(bytes32 id) external returns (uint256) envfree; function Utils.hashMarket(Midnight.Market) external returns (bytes32) envfree; - /// ORACLE: deterministic, non-reverting price per oracle address. function _.price() external => summaryPrice(calledContract) expect(uint256); - /// Skip touchMarket's first-time validation: we want a pre-existing market, and the validation cannot be triggered by liquidate alone in the liveness scenario. function touchMarket(Midnight.Market memory market) internal returns (bytes32) => summaryToId(market); function IdLib.toId(Midnight.Market memory market, uint256, address) internal returns (bytes32) => summaryToId(market); - /// TOKEN TRANSFERS: well-behaved (no revert, no return-false). The converse is in Reverts.spec. function SafeTransferLib.safeTransfer(address, address, uint256) internal => NONDET; function SafeTransferLib.safeTransferFrom(address, address, address, uint256) internal => NONDET; - /// MULDIV with tight rounding axioms (proved 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); } -/// CONSTANTS /// - definition WAD() returns uint256 = 10 ^ 18; - definition ORACLE_PRICE_SCALE() returns uint256 = 10 ^ 36; - definition MAX_UINT128() returns mathint = (1 << 128) - 1; - definition MAX_TIMESTAMP() returns mathint = 1 << 64; -definition TIME_TO_MAX_LIF() returns uint256 = 15 * 60; - -/// SUMMARIES /// - function summaryToId(Midnight.Market market) returns bytes32 { return Utils.hashMarket(market); } persistent ghost summaryPrice(address) returns uint256; -// Axioms bounds proven in MulDiv.spec (mulDivDownRoundsDown, mulDivDownTightBound). persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivDown(a, b, d) * d <= a * b; axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => (ghostMulDivDown(a, b, d) + 1) * d > a * b; @@ -73,19 +68,15 @@ persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivDown(0, a, d) == 0 && ghostMulDivDown(a, 0, d) == 0; } -// Axioms bounds proven in MulDiv.spec (mulDivUpRoundsUp, mulDivUpTightBound). persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivUp(a, b, d) * d >= a * b; axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && ghostMulDivUp(a, b, d) > 0 => (ghostMulDivUp(a, b, d) - 1) * d < a * b; axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivUp(a, b, d) <= a; axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(a, d, d) == a; axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(0, a, d) == 0 && ghostMulDivUp(a, 0, d) == 0; - - // Monotonicity in first arg — often needed to relate helper to contract: axiom forall uint256 a1. forall uint256 a2. forall uint256 b. forall uint256 d. d > 0 && a1 <= a2 => ghostMulDivUp(a1, b, d) <= ghostMulDivUp(a2, b, d); } -/// Case-analysis on the common deterministic patterns (y == d, x == d, zero inputs). function summaryMulDivDown(uint256 x, uint256 y, uint256 d) returns uint256 { if (d == 0) { revert(); @@ -100,140 +91,96 @@ function summaryMulDivUp(uint256 x, uint256 y, uint256 d) returns uint256 { return ghostMulDivUp(x, y, d); } -/// INVARIANT (proven in CollateralBitmap.spec; assumed here via requireInvariant) /// - strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint256 collateralIndex) collateralIndex < 128 => (collateral(id, user, collateralIndex) != 0 <=> summaryGetBit(currentContract.position[id][user].collateralBitmap, collateralIndex)); -/// HELPERS /// - -/// Per-collateral validity (lltv, maxLif, ExactMath bounds) and LIVENESS bounds `C_i * P_i <= ORACLE_PRICE_SCALE * WAD * MAX_UINT128`. +/// Per-collateral validity (lltv, maxLif, ExactMath bounds, liveness bound on collat * price). function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 i) { uint256 lltv = market.collateralParams[i].lltv; uint256 maxLif = market.collateralParams[i].maxLif; - require lltv > 0 && lltv <= WAD(), "valid lltv"; - require maxLif >= WAD(), "valid maxLif"; - require lltv < WAD() => to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; - require to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * to_mathint(WAD()), "ExactMath condition for RCF denominator WAD - lif*lltv/WAD is positive"; + require lltv > 0 && lltv <= WAD(); + require maxLif >= WAD(); + require lltv < WAD() => to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1); + require to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * to_mathint(WAD()); address oracle = market.collateralParams[i].oracle; - require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(), "..."; + require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) + <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(); } -/// 2-collateral market, bitmap == 3 (bits 0+1 set); matches `loop_iter: 2`. Length-1 regressed `RepayAll` and `RcfMaxNoBadDebt` (see git history). -function dualCollateralSetup(Midnight.Market market, bytes32 id, address borrower) { - require market.collateralParams.length == 2, "two-collateral market"; - require collateralBitmap(id, borrower) == 3, "bitmap is exactly 3 (bits 0 and 1 set)"; - - require summaryGetBit(3, 0) && summaryGetBit(3, 1), "ghost: bits 0 and 1 are set"; - require forall uint256 i. i >= 2 => !summaryGetBit(3, i), "ghost: no other bit is set"; +/// Shared preamble: 2-collateral market with both bits activated, well-behaved env, +/// unlocked, positive debt, and the liveness bound on collateral 0 that absorbs the +/// 1-unit seizure at lif = maxLif. +function preamble(env e, Midnight.Market market, bytes32 id, address borrower) { + require market.collateralParams.length == 2; + require collateralBitmap(id, borrower) == 3; + require summaryGetBit(3, 0) && summaryGetBit(3, 1); + require forall uint256 i. i >= 2 => !summaryGetBit(3, i); validCollateralAt(market, id, borrower, 0); validCollateralAt(market, id, borrower, 1); -} - -/// Shared preamble: dual-collateral setup, well-behaved env, activated collaterals, feasible loss accounting, unlocked, positive debt. -function commonDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) { - dualCollateralSetup(market, id, borrower); - require e.msg.value == 0, "no value sent"; - require market.liquidatorGate == 0, "no liquidator gate (see Reverts.spec)"; - require e.block.timestamp < MAX_TIMESTAMP(), "timestamp bounded"; - require market.maturity < MAX_TIMESTAMP(), "maturity bounded"; + require e.msg.value == 0; + require market.liquidatorGate == 0; + require e.block.timestamp < MAX_TIMESTAMP(); + require market.maturity < MAX_TIMESTAMP(); uint256 _debt = debtOf(id, borrower); - require totalUnits(id) >= _debt, "totalUnits >= borrower debt (Midnight.spec totalUnitsEqualsSumNegativeDebtPlusWithdrawable)"; - require to_mathint(withdrawable(id)) + to_mathint(debtOf(id, borrower)) <= MAX_UINT128(), "withdrawable += repaidUnits won't overflow"; - - require !liquidationLocked(id, borrower), "not locked"; - require _debt > 0, "borrower has debt"; + require totalUnits(id) >= _debt; + require to_mathint(withdrawable(id)) + to_mathint(_debt) <= MAX_UINT128(); + require !liquidationLocked(id, borrower); + require _debt > 0; requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); -} - -/// On top of `commonDualPreamble`, adds the liveness bound on collateral 0 -/// (worst-case `lif = maxLif` absorbs the 1-unit seizure). Used by OneUnit variants. -function oneUnitDualPreamble(env e, Midnight.Market market, bytes32 id, address borrower) { - commonDualPreamble(e, market, id, borrower); address oracle = market.collateralParams[0].oracle; uint128 collat = collateral(id, borrower, 0); uint256 maxLif = market.collateralParams[0].maxLif; - require maxLif * ORACLE_PRICE_SCALE() <= collat * WAD() * summaryPrice(oracle), "LIVENESS: collat 0 absorbs the 1-unit seizure at maxLif"; + require maxLif * ORACLE_PRICE_SCALE() <= collat * WAD() * summaryPrice(oracle); } -/// Compute `repaidUnits = seizedAssets * P / SCALE * WAD / lif` for Strategy A (seizedAssets = collat) when `lif = maxLif`. -/// Used here only by the RCF escape hatch precondition in the lltv < WAD case. -function strategyARepaidUnitsAtMaxLif(Midnight.Market market, uint128 collat) returns uint256 { - address oracle = market.collateralParams[0].oracle; - uint256 maxLif = market.collateralParams[0].maxLif; - uint256 step1 = summaryMulDivUp(collat, summaryPrice(oracle), ORACLE_PRICE_SCALE()); - return summaryMulDivUp(step1, WAD(), maxLif); -} - -/// RULES /// - -/// Sanity baseline: liquidate(0, 0, ...) does not revert on any liquidatable position. -/// Only realizes bad debt; useful as a baseline to confirm the well-behaved environment is correctly set up. -rule liquidateZeroZeroNoRevert(env e, Midnight.Market market, address borrower, address receiver) { +/// Post-maturity: healthyPath = true. RCF skipped, no lltv split needed. +rule postMaturityCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); + preamble(e, market, id, borrower); - commonDualPreamble(e, market, id, borrower); - require e.block.timestamp > market.maturity || !isHealthy(market, id, borrower), "expired or unhealthy"; + require e.block.timestamp > market.maturity; - /// Route via maturity path if post-maturity, else unhealthy path (NotLiquidatable gates each accordingly). - bool healthyPath = e.block.timestamp > market.maturity; bytes data; - liquidate@withrevert(e, market, 0, 0, 0, borrower, healthyPath, receiver, 0, data); + liquidate@withrevert(e, market, 0, 0, 1, borrower, true, receiver, 0, data); assert !lastReverted; } -/// Each rule witnesses "repaidUnits = 1 can be liquidated" in one partition of the (unhealthy ∨ expired) state space. - -/// Pre-maturity unhealthy, `lltv[0] == WAD`. RCF `maxRepaid` collapses to `type(uint256).max`. -rule liquidatableCanBeLiquidatedPreMaturityLltvFullDual(env e, Midnight.Market market, address borrower, address receiver) { +/// Unhealthy, lltv == WAD: healthyPath = false. RCF maxRepaid = type(uint256).max +/// (L656), so the check passes without any precondition on rcfThreshold. +rule unhealthyLltvFullCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); - oneUnitDualPreamble(e, market, id, borrower); + preamble(e, market, id, borrower); - require e.block.timestamp <= market.maturity, "pre-maturity"; - require !isHealthy(market, id, borrower), "unhealthy"; - require market.collateralParams[0].lltv == WAD(), "lltv == WAD => RCF denominator vanishes"; + require !isHealthy(market, id, borrower); + require market.collateralParams[0].lltv == WAD(); bytes data; liquidate@withrevert(e, market, 0, 0, 1, borrower, false, receiver, 0, data); assert !lastReverted; } -/// Post-maturity (merged Ramped + Pinned). Union of regimes covers all `timestamp > maturity`: -/// `lif` is either ramped in `[WAD, maxLif)` (healthy + within-window) or pinned at `maxLif` -/// (unhealthy or `timestamp >= maturity + TIME_TO_MAX_LIF`). No RCF check (post-maturity). -rule liquidatableCanBeLiquidatedPostMaturityDual(env e, Midnight.Market market, address borrower, address receiver) { - bytes32 id = summaryToId(market); - oneUnitDualPreamble(e, market, id, borrower); - - require e.block.timestamp > market.maturity, "post-maturity"; - - /// healthyPath=true: post-maturity gating and no RCF check. - bytes data; - liquidate@withrevert(e, market, 0, 0, 1, borrower, true, receiver, 0, data); - assert !lastReverted; -} - -/// Pre-maturity unhealthy, `lltv[0] < WAD`, RCF actually engaged. -/// Uses the rcfThreshold escape hatch : requiring `rcfThreshold > strategyARepaidUnitsAtMaxLif` -/// makes the escape-hatch disjunct hold unconditionally, sidestepping `maxRepaid`. -rule liquidatableCanBeLiquidatedPreMaturityRcfEngagedDual(env e, Midnight.Market market, address borrower, address receiver) { +/// Unhealthy, any lltv: healthyPath = false. The rcfThreshold escape hatch makes +/// the second disjunct of L659-660 hold, sidestepping the nonlinear maxRepaid at L655. +rule unhealthyCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); - oneUnitDualPreamble(e, market, id, borrower); + preamble(e, market, id, borrower); - require e.block.timestamp <= market.maturity, "pre-maturity"; - require !isHealthy(market, id, borrower), "unhealthy (healthyPath=false branch, lif pinned at maxLif Midnight.sol:643)"; - require market.collateralParams[0].lltv < WAD(), "lltv < WAD => RCF maxRepaid is finite, formula actually evaluated"; + require !isHealthy(market, id, borrower); + address oracle = market.collateralParams[0].oracle; uint128 collat = collateral(id, borrower, 0); - require market.rcfThreshold > strategyARepaidUnitsAtMaxLif(market, collat), "rcfThreshold escape hatch trivializes RCF"; + uint256 maxLif = market.collateralParams[0].maxLif; + uint256 step1 = summaryMulDivUp(collat, summaryPrice(oracle), ORACLE_PRICE_SCALE()); + uint256 collatValueOverLif = summaryMulDivUp(step1, WAD(), maxLif); + require market.rcfThreshold > collatValueOverLif; bytes data; liquidate@withrevert(e, market, 0, 0, 1, borrower, false, receiver, 0, data); From 845486e961accae23b8bda1198aa5f981bff4e4f Mon Sep 17 00:00:00 2001 From: lilCertora Date: Wed, 27 May 2026 18:48:57 +0200 Subject: [PATCH 33/53] linter --- certora/specs/LiquidateLiveness.spec | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index db9642e5a..ebc32aff9 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -50,8 +50,11 @@ methods { } definition WAD() returns uint256 = 10 ^ 18; + definition ORACLE_PRICE_SCALE() returns uint256 = 10 ^ 36; + definition MAX_UINT128() returns mathint = (1 << 128) - 1; + definition MAX_TIMESTAMP() returns mathint = 1 << 64; function summaryToId(Midnight.Market market) returns bytes32 { @@ -105,8 +108,7 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, require to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * to_mathint(WAD()); address oracle = market.collateralParams[i].oracle; - require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) - <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(); + require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(); } /// Shared preamble: 2-collateral market with both bits activated, well-behaved env, From be0c0611e2bfd198d00499234b365c7c6f600d70 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Wed, 27 May 2026 18:52:22 +0200 Subject: [PATCH 34/53] modif conf --- certora/confs/LiquidateLiveness.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 852965cbb..e65ad5d5a 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -3,7 +3,7 @@ "certora/helpers/Utils.sol", "src/Midnight.sol" ], - "verify": "Midnight:certora/specs/LiquidateLivenessMinimal.spec", + "verify": "Midnight:certora/specs/LiquidateLiveness.spec", "solc": "solc-0.8.34", "solc_via_ir": true, "solc_evm_version": "osaka", @@ -33,5 +33,5 @@ "unhealthyLltvFullCanBeLiquidated", "unhealthyCanBeLiquidated" ], - "msg": "Liquidate Liveness (minimal)" + "msg": "Liquidate Liveness" } From 3292d67d9041ef153595be71057f82c2a24ce080 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Wed, 27 May 2026 22:07:33 +0200 Subject: [PATCH 35/53] modif --- certora/specs/LiquidateLiveness.spec | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index ebc32aff9..7811d3bb5 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -155,8 +155,8 @@ rule postMaturityCanBeLiquidated(env e, Midnight.Market market, address borrower assert !lastReverted; } -/// Unhealthy, lltv == WAD: healthyPath = false. RCF maxRepaid = type(uint256).max -/// (L656), so the check passes without any precondition on rcfThreshold. +/// Unhealthy, lltv == WAD: healthyPath = false. RCF maxRepaid = type(uint256).max, +/// so the check passes without any precondition on rcfThreshold. rule unhealthyLltvFullCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); preamble(e, market, id, borrower); @@ -170,7 +170,7 @@ rule unhealthyLltvFullCanBeLiquidated(env e, Midnight.Market market, address bor } /// Unhealthy, any lltv: healthyPath = false. The rcfThreshold escape hatch makes -/// the second disjunct of L659-660 hold, sidestepping the nonlinear maxRepaid at L655. +/// the second disjunct hold, sidestepping the nonlinear maxRepaid. rule unhealthyCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); preamble(e, market, id, borrower); From 43be94606983f2ea4086cc3d751983e9285541b8 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Thu, 28 May 2026 16:19:38 +0200 Subject: [PATCH 36/53] cleaning + timeout fix --- certora/confs/LiquidateLiveness.conf | 16 +-- certora/specs/LiquidateLiveness.spec | 150 +++++++++++++++++++-------- 2 files changed, 110 insertions(+), 56 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index e65ad5d5a..8e4b1c5a0 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -13,24 +13,16 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - "-destructiveOptimizations twostage", - "-mediumTimeout 20", - "-lowTimeout 20", - "-tinyTimeout 20", - "-depth 20", - "-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}]", - "-dontStopAtFirstSplitTimeout true", - "-backendStrategy singleRace", - "-smt_useLIA false", - "-smt_useNIA true" - ], - "build_cache": false, + "-destructiveOptimizations twostage", + "-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}]" + ], "exclude_rule": [ "nonZeroCollateralsAreActivated" ], "split_rules": [ "postMaturityCanBeLiquidated", "unhealthyLltvFullCanBeLiquidated", + "unhealthyLowLltvCanBeLiquidated", "unhealthyCanBeLiquidated" ], "msg": "Liquidate Liveness" diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 7811d3bb5..8f4a1ee61 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -8,21 +8,31 @@ using Utils as Utils; Property: "Debts can always be liquidated if unhealthy or expired." Existential liveness: for every liquidatable borrower, there exists a `liquidate` -call that succeeds. Witness in all rules: `repaidUnits = 1`. - -- postMaturityCanBeLiquidated: `healthyPath = true` covers all `timestamp > maturity`. - RCF is skipped. - -- unhealthyLltvFullCanBeLiquidated: `healthyPath = false`, `lltv == WAD`. RCF maxRepaid - collapses to `type(uint256).max` (L656), so the RCF check passes unconditionally. - -- unhealthyCanBeLiquidated: `healthyPath = false`, any lltv. Uses the `rcfThreshold` - escape hatch (second disjunct of L659-660) to sidestep the nonlinear `maxRepaid`. - -Together they cover the property except for: unhealthy borrowers with `lltv < WAD` -in a market where the `rcfThreshold` escape hatch does not hold. That partition -requires reasoning about the nonlinear `maxRepaid` formula at L655 and is the -acknowledged limit of this proof. +call that succeeds. Witness in all rules: `repaidUnits = 1`, `seizedAssets = 0`, +`collateralIndex = 0`, `callback = 0`. + +Partitions (the liquidatability condition is `unhealthy || timestamp > maturity`): +- postMaturityCanBeLiquidated: `healthyPath = true`, `timestamp > maturity`. RCF skipped. +- unhealthyLltvFullCanBeLiquidated: `healthyPath = false`, `lltv[0] == WAD`. RCF maxRepaid + collapses to `type(uint256).max` (L657), so the RCF check passes unconditionally. +- unhealthyLowLltvCanBeLiquidated: `healthyPath = false`, `lltv[0] < WAD`. Uses the + extra `b >= d => mulDivUp(a,b,d) >= a` axiom (mirror of the existing mulDivDown axiom) + combined with `validCollateralAt`'s `lltv*maxLif <= WAD*(WAD-1)` to show maxRepaid >= 1. +- unhealthyCanBeLiquidated: `healthyPath = false`, any lltv, in markets where the + `rcfThreshold` escape hatch holds. Subsumed by unhealthyLowLltvCanBeLiquidated when + lltv < WAD; kept for the alternative reasoning path. + +Modeling assumptions (cf. LIVENESS section of Midnight.sol): +- 2-collateral market with both bits activated (loop_iter = 2 constraint). +- `market.liquidatorGate == 0` (no gate vetoes the liquidation). +- `e.msg.value == 0` (liquidate is non-payable). +- `SafeTransferLib.safeTransfer*` summarized as NONDET (tokens never revert on transfer). +- Oracle prices via a non-reverting persistent ghost (oracles do not revert nor return 0 + for collateral 0; price > 0 is forced by the preamble's seize bound). +- Borrower has `debt > 0` and is not transiently liquidation-locked (tx-start convention). +- Invariants from Midnight.spec assumed in preamble: `totalUnits == sumDebt + withdrawable` + (gives `totalUnits >= debt` and `withdrawable + debt <= MAX_UINT128`). +- `validCollateralAt` per-collateral bounds (protocol-enforced by touchMarket). */ methods { @@ -75,6 +85,10 @@ persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivUp(a, b, d) * d >= a * b; axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && ghostMulDivUp(a, b, d) > 0 => (ghostMulDivUp(a, b, d) - 1) * d < a * b; axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivUp(a, b, d) <= a; + + /// Mirror of ghostMulDivDown's `b <= d => <= a` axiom. Sound: result * d >= a*b >= a*d. + /// Needed by unhealthyLowLltvCanBeLiquidated to derive maxRepaid >= debt - maxDebt >= 1. + axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b >= d => ghostMulDivUp(a, b, d) >= a; axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(a, d, d) == a; axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(0, a, d) == 0 && ghostMulDivUp(a, 0, d) == 0; axiom forall uint256 a1. forall uint256 a2. forall uint256 b. forall uint256 d. d > 0 && a1 <= a2 => ghostMulDivUp(a1, b, d) <= ghostMulDivUp(a2, b, d); @@ -102,37 +116,37 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 lltv = market.collateralParams[i].lltv; uint256 maxLif = market.collateralParams[i].maxLif; - require lltv > 0 && lltv <= WAD(); - require maxLif >= WAD(); - require lltv < WAD() => to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1); - require to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * to_mathint(WAD()); + require lltv > 0 && lltv <= WAD(), "valid lltv (touchMarket)"; + require maxLif >= WAD(), "valid maxLif (touchMarket)"; + require lltv < WAD() => to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "ExactMath: lltv*maxLif <= WAD*(WAD-1) when lltv= 2 => !summaryGetBit(3, i); + require market.collateralParams.length == 2, "two-collateral market"; + require collateralBitmap(id, borrower) == 3, "both collateral bits activated"; + require summaryGetBit(3, 0) && summaryGetBit(3, 1), "bitmap=3: bits 0,1 set"; + require forall uint256 i. i >= 2 => !summaryGetBit(3, i), "bitmap=3: no other bits set"; validCollateralAt(market, id, borrower, 0); validCollateralAt(market, id, borrower, 1); - require e.msg.value == 0; - require market.liquidatorGate == 0; - require e.block.timestamp < MAX_TIMESTAMP(); - require market.maturity < MAX_TIMESTAMP(); + require e.msg.value == 0, "liquidate is non-payable"; + require market.liquidatorGate == 0, "no liquidator gate (LIVENESS)"; + require e.block.timestamp < MAX_TIMESTAMP(), "timestamp fits in uint64"; + require market.maturity < MAX_TIMESTAMP(), "maturity fits in uint64"; uint256 _debt = debtOf(id, borrower); - require totalUnits(id) >= _debt; - require to_mathint(withdrawable(id)) + to_mathint(_debt) <= MAX_UINT128(); - require !liquidationLocked(id, borrower); - require _debt > 0; + require totalUnits(id) >= _debt, "from totalUnitsEqualsSumNegativeDebtPlusWithdrawable (Midnight.spec)"; + require to_mathint(withdrawable(id)) + to_mathint(_debt) <= MAX_UINT128(), "from totalUnitsEqualsSumNegativeDebtPlusWithdrawable (Midnight.spec)"; + require !liquidationLocked(id, borrower), "transient lock is zero at tx start"; + require _debt > 0, "borrower has debt"; requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); @@ -140,7 +154,7 @@ function preamble(env e, Midnight.Market market, bytes32 id, address borrower) { address oracle = market.collateralParams[0].oracle; uint128 collat = collateral(id, borrower, 0); uint256 maxLif = market.collateralParams[0].maxLif; - require maxLif * ORACLE_PRICE_SCALE() <= collat * WAD() * summaryPrice(oracle); + require maxLif * ORACLE_PRICE_SCALE() <= collat * WAD() * summaryPrice(oracle), "1-unit seize fits in collat[0] (also forces price[0]>0)"; } /// Post-maturity: healthyPath = true. RCF skipped, no lltv split needed. @@ -148,41 +162,89 @@ rule postMaturityCanBeLiquidated(env e, Midnight.Market market, address borrower bytes32 id = summaryToId(market); preamble(e, market, id, borrower); - require e.block.timestamp > market.maturity; + require e.block.timestamp > market.maturity, "post-maturity partition"; bytes data; liquidate@withrevert(e, market, 0, 0, 1, borrower, true, receiver, 0, data); assert !lastReverted; } -/// Unhealthy, lltv == WAD: healthyPath = false. RCF maxRepaid = type(uint256).max, -/// so the check passes without any precondition on rcfThreshold. +/// Unhealthy, lltv == WAD: healthyPath = false. +/// RCF maxRepaid = type(uint256).max, so the check passes without any precondition on rcfThreshold. rule unhealthyLltvFullCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); preamble(e, market, id, borrower); - require !isHealthy(market, id, borrower); - require market.collateralParams[0].lltv == WAD(); + require !isHealthy(market, id, borrower), "unhealthy partition"; + require market.collateralParams[0].lltv == WAD(), "lltv == WAD partition (maxRepaid = uint256.max)"; bytes data; liquidate@withrevert(e, market, 0, 0, 1, borrower, false, receiver, 0, data); assert !lastReverted; } -/// Unhealthy, any lltv: healthyPath = false. The rcfThreshold escape hatch makes -/// the second disjunct hold, sidestepping the nonlinear maxRepaid. +/// Unhealthy, lltv < WAD: healthyPath = false. +/// Closes the partition left open by unhealthyCanBeLiquidated (rcfThreshold escape may fail). +/// +/// Proof sketch (relies on the new `b >= d => ghostMulDivUp(a,b,d) >= a` axiom): +/// inner := mulDivUp(maxLif, lltv, WAD). +/// From validCollateralAt: lltv*maxLif <= WAD*(WAD-1), and axiom 2 gives inner <= WAD-1. +/// Hence d := WAD - inner >= 1, so maxRepaid := mulDivUp(debt - maxDebt, WAD, d) is well-defined. +/// With WAD >= d, the new axiom gives maxRepaid >= debt - maxDebt. +/// From !isHealthy: debt - maxDebt >= 1, so maxRepaid >= 1 >= repaidUnits. RCF first disjunct holds. +/// +/// Without the scaffold below, the prover reaches ~95% proved at depth 20 but the +/// final ~5% of branches each contain an atomic nonlinear query that no solver +/// dispatches within smt_timeout (run hits the 2h global timeout). Pre-stating each +/// derivable lemma as a sound `require` makes the search trivial. +rule unhealthyLowLltvCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver) { + bytes32 id = summaryToId(market); + preamble(e, market, id, borrower); + + uint256 lltv = market.collateralParams[0].lltv; + require lltv < WAD(), "lltv < WAD partition"; + + // === Sound proof scaffolding (each `require` is derivable from preceding axioms) === + // + // (a) Replace `require !isHealthy(market, id, borrower)` with the equivalent direct condition, computed via the same ghost functions liquidate uses internally. + // Avoids the SMT having to relate isHealthy's while-loop maxDebt to liquidate's by commutativity under symbolic inputs. + uint256 maxLif = market.collateralParams[0].maxLif; + uint256 lltv1 = market.collateralParams[1].lltv; + uint128 collat0 = collateral(id, borrower, 0); + uint128 collat1 = collateral(id, borrower, 1); + uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint256 price1 = summaryPrice(market.collateralParams[1].oracle); + mathint maxDebt = to_mathint(ghostMulDivDown(ghostMulDivDown(collat0, price0, ORACLE_PRICE_SCALE()), lltv, WAD())) + to_mathint(ghostMulDivDown(ghostMulDivDown(collat1, price1, ORACLE_PRICE_SCALE()), lltv1, WAD())); + uint256 _debt = debtOf(id, borrower); + require to_mathint(_debt) > maxDebt, "unhealthy: debt > maxDebt (replaces isHealthy)"; + + // (b) Lemma: inner := mulDivUp(maxLif, lltv, WAD) <= WAD - 1. + // Derivable from validCollateralAt (lltv*maxLif <= WAD*(WAD-1) when lltv collatValueOverLif; + + /// Sufficient arithmetic bound for `rcfThreshold > mulDivUp(mulDivUp(collat, price, OPS), WAD, maxLif)`, + /// derived from the two mulDivUp ceiling axioms. + require to_mathint(market.rcfThreshold) * to_mathint(maxLif) * to_mathint(ORACLE_PRICE_SCALE()) > to_mathint(collat) * to_mathint(summaryPrice(oracle)) * to_mathint(WAD()) + (to_mathint(ORACLE_PRICE_SCALE()) - 1) * to_mathint(WAD()) + (to_mathint(maxLif) - 1) * to_mathint(ORACLE_PRICE_SCALE()), "rcfThreshold escape: ceiling-bound on mulDivUp(mulDivUp(collat,price,OPS),WAD,maxLif)"; bytes data; liquidate@withrevert(e, market, 0, 0, 1, borrower, false, receiver, 0, data); From 7ef0d0e69aa98a23ba3fd060e7108cfbc25dafb8 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Thu, 28 May 2026 16:31:58 +0200 Subject: [PATCH 37/53] linter conf --- certora/confs/LiquidateLiveness.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 8e4b1c5a0..17df2befa 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -13,9 +13,9 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - "-destructiveOptimizations twostage", + "-destructiveOptimizations twostage", "-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}]" - ], + ], "exclude_rule": [ "nonZeroCollateralsAreActivated" ], From cf20668d9d1db0b9544102ee41d24b573ad9f4a4 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Thu, 28 May 2026 21:51:29 +0200 Subject: [PATCH 38/53] add cache false working locally --- certora/confs/LiquidateLiveness.conf | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 17df2befa..8e750ef67 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -13,9 +13,9 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - "-destructiveOptimizations twostage", - "-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}]" - ], + " -destructiveOptimizations twostage -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}]" + ], + "build_cache": false, "exclude_rule": [ "nonZeroCollateralsAreActivated" ], @@ -25,5 +25,5 @@ "unhealthyLowLltvCanBeLiquidated", "unhealthyCanBeLiquidated" ], - "msg": "Liquidate Liveness" + "msg": "Liquidate Liveness WITHOUT BUILD CACHE" } From b508cae07becece4150e979e2a772ec8a85be44f Mon Sep 17 00:00:00 2001 From: lilCertora Date: Thu, 28 May 2026 21:56:31 +0200 Subject: [PATCH 39/53] linter conf --- certora/confs/LiquidateLiveness.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 8e750ef67..7c69d90be 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -13,8 +13,8 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - " -destructiveOptimizations twostage -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}]" - ], + " -destructiveOptimizations twostage -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}]" + ], "build_cache": false, "exclude_rule": [ "nonZeroCollateralsAreActivated" From 86016beca7b5464c09c832ffa24a1aa4fd222de8 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Fri, 29 May 2026 11:10:11 +0200 Subject: [PATCH 40/53] clean and fix timeout(hopefully --- certora/confs/LiquidateLiveness.conf | 5 +++-- certora/specs/LiquidateLiveness.spec | 32 +++++++++++----------------- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 7c69d90be..343c70324 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -13,7 +13,8 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - " -destructiveOptimizations twostage -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}]" + "-destructiveOptimizations twostage", + "-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}]" ], "build_cache": false, "exclude_rule": [ @@ -25,5 +26,5 @@ "unhealthyLowLltvCanBeLiquidated", "unhealthyCanBeLiquidated" ], - "msg": "Liquidate Liveness WITHOUT BUILD CACHE" + "msg": "Liquidate Liveness" } diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 8f4a1ee61..0f2ed504f 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -184,19 +184,7 @@ rule unhealthyLltvFullCanBeLiquidated(env e, Midnight.Market market, address bor } /// Unhealthy, lltv < WAD: healthyPath = false. -/// Closes the partition left open by unhealthyCanBeLiquidated (rcfThreshold escape may fail). -/// -/// Proof sketch (relies on the new `b >= d => ghostMulDivUp(a,b,d) >= a` axiom): -/// inner := mulDivUp(maxLif, lltv, WAD). -/// From validCollateralAt: lltv*maxLif <= WAD*(WAD-1), and axiom 2 gives inner <= WAD-1. -/// Hence d := WAD - inner >= 1, so maxRepaid := mulDivUp(debt - maxDebt, WAD, d) is well-defined. -/// With WAD >= d, the new axiom gives maxRepaid >= debt - maxDebt. -/// From !isHealthy: debt - maxDebt >= 1, so maxRepaid >= 1 >= repaidUnits. RCF first disjunct holds. -/// -/// Without the scaffold below, the prover reaches ~95% proved at depth 20 but the -/// final ~5% of branches each contain an atomic nonlinear query that no solver -/// dispatches within smt_timeout (run hits the 2h global timeout). Pre-stating each -/// derivable lemma as a sound `require` makes the search trivial. +/// Sound scaffolding lemmas help the solver with NIA chains (bad-debt path, RCF denominator). rule unhealthyLowLltvCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver) { bytes32 id = summaryToId(market); preamble(e, market, id, borrower); @@ -204,10 +192,7 @@ rule unhealthyLowLltvCanBeLiquidated(env e, Midnight.Market market, address borr uint256 lltv = market.collateralParams[0].lltv; require lltv < WAD(), "lltv < WAD partition"; - // === Sound proof scaffolding (each `require` is derivable from preceding axioms) === - // - // (a) Replace `require !isHealthy(market, id, borrower)` with the equivalent direct condition, computed via the same ghost functions liquidate uses internally. - // Avoids the SMT having to relate isHealthy's while-loop maxDebt to liquidate's by commutativity under symbolic inputs. + // (a) Direct maxDebt computation (avoids isHealthy's loop/liquidate commutativity). uint256 maxLif = market.collateralParams[0].maxLif; uint256 lltv1 = market.collateralParams[1].lltv; uint128 collat0 = collateral(id, borrower, 0); @@ -218,10 +203,17 @@ rule unhealthyLowLltvCanBeLiquidated(env e, Midnight.Market market, address borr uint256 _debt = debtOf(id, borrower); require to_mathint(_debt) > maxDebt, "unhealthy: debt > maxDebt (replaces isHealthy)"; - // (b) Lemma: inner := mulDivUp(maxLif, lltv, WAD) <= WAD - 1. - // Derivable from validCollateralAt (lltv*maxLif <= WAD*(WAD-1) when lltv= 1 (maxRepaid denominator is positive, from (b)). + require to_mathint(WAD()) - to_mathint(ghostMulDivUp(maxLif, lltv, WAD())) >= 1, "from (b): WAD - inner >= 1"; + + // (d) Per-collateral recovery > maxDebt contribution (from lltv*maxLif < WAD^2 + ghost axioms). + // Bridges the bad-debt path: ensures _position.debt > maxDebt after bad-debt realization. + uint256 maxLif1 = market.collateralParams[1].maxLif; + require to_mathint(ghostMulDivUp(ghostMulDivUp(collat0, price0, ORACLE_PRICE_SCALE()), WAD(), maxLif)) > to_mathint(ghostMulDivDown(ghostMulDivDown(collat0, price0, ORACLE_PRICE_SCALE()), lltv, WAD())), "lemma: recovery[0] > maxDebtContrib[0] (from lltv*maxLif < WAD^2, collat0*price0 > 0)"; + require to_mathint(ghostMulDivUp(ghostMulDivUp(collat1, price1, ORACLE_PRICE_SCALE()), WAD(), maxLif1)) >= to_mathint(ghostMulDivDown(ghostMulDivDown(collat1, price1, ORACLE_PRICE_SCALE()), lltv1, WAD())), "lemma: recovery[1] >= maxDebtContrib[1] (from lltv1*maxLif1 <= WAD^2)"; bytes data; liquidate@withrevert(e, market, 0, 0, 1, borrower, false, receiver, 0, data); From 958f3e3f0bac4a01f8bedb5d274e7dfb197d9110 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Fri, 29 May 2026 14:27:05 +0200 Subject: [PATCH 41/53] update midnight --- certora/specs/LiquidateLiveness.spec | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 0f2ed504f..1838867d4 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -38,13 +38,13 @@ Modeling assumptions (cf. LIVENESS section of Midnight.sol): methods { function multicall(bytes[]) external => HAVOC_ALL DELETE; - function debtOf(bytes32 id, address user) external returns (uint256) envfree; + function debtOf(bytes32 id, address user) external returns (uint128) envfree; function collateral(bytes32 id, address user, uint256 index) external returns (uint128) envfree; function collateralBitmap(bytes32 id, address user) external returns (uint128) envfree; function liquidationLocked(bytes32 id, address user) external returns (bool) envfree; function isHealthy(Midnight.Market, bytes32, address) external returns (bool) envfree; - function totalUnits(bytes32 id) external returns (uint256) envfree; - function withdrawable(bytes32 id) external returns (uint256) envfree; + function totalUnits(bytes32 id) external returns (uint128) envfree; + function withdrawable(bytes32 id) external returns (uint128) envfree; function Utils.hashMarket(Midnight.Market) external returns (bytes32) envfree; function _.price() external => summaryPrice(calledContract) expect(uint256); From 946d984a74f73a92cba2b862cbc3aa2620e17946 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Mon, 1 Jun 2026 14:18:57 +0200 Subject: [PATCH 42/53] modif cleaning and 3 collaterals --- certora/confs/LiquidateLiveness.conf | 15 +- certora/specs/LiquidateLiveness.spec | 364 ++++++++++++++++++++------- 2 files changed, 278 insertions(+), 101 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 343c70324..46c3314b8 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -8,7 +8,7 @@ "solc_via_ir": true, "solc_evm_version": "osaka", "optimistic_loop": true, - "loop_iter": 2, + "loop_iter": 3, "optimistic_hashing": true, "hashing_length_bound": 2048, "smt_timeout": 7200, @@ -21,10 +21,13 @@ "nonZeroCollateralsAreActivated" ], "split_rules": [ - "postMaturityCanBeLiquidated", - "unhealthyLltvFullCanBeLiquidated", - "unhealthyLowLltvCanBeLiquidated", - "unhealthyCanBeLiquidated" + "unhealthyLltvFullLiquidatableForAnySafeAmount", + "unhealthyLowLltvLiquidatableForAnySafeAmount", + "postMaturityLiquidatableForAnySafeAmount", + "seizeUnhealthyLltvFullLiquidatableForAnySafeAmount", + "seizeUnhealthyLowLltvLiquidatableForAnySafeAmount", + "seizePostMaturityLiquidatableForAnySafeAmount", + "badDebtCanBeLiquidated" ], - "msg": "Liquidate Liveness" + "msg": "Liquidate Liveness (3-collateral, parametric amount)" } diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 1838867d4..d18dfd9e5 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -5,34 +5,32 @@ import "BitmapSummaries.spec"; using Utils as Utils; /** -Property: "Debts can always be liquidated if unhealthy or expired." - -Existential liveness: for every liquidatable borrower, there exists a `liquidate` -call that succeeds. Witness in all rules: `repaidUnits = 1`, `seizedAssets = 0`, -`collateralIndex = 0`, `callback = 0`. - -Partitions (the liquidatability condition is `unhealthy || timestamp > maturity`): -- postMaturityCanBeLiquidated: `healthyPath = true`, `timestamp > maturity`. RCF skipped. -- unhealthyLltvFullCanBeLiquidated: `healthyPath = false`, `lltv[0] == WAD`. RCF maxRepaid - collapses to `type(uint256).max` (L657), so the RCF check passes unconditionally. -- unhealthyLowLltvCanBeLiquidated: `healthyPath = false`, `lltv[0] < WAD`. Uses the - extra `b >= d => mulDivUp(a,b,d) >= a` axiom (mirror of the existing mulDivDown axiom) - combined with `validCollateralAt`'s `lltv*maxLif <= WAD*(WAD-1)` to show maxRepaid >= 1. -- unhealthyCanBeLiquidated: `healthyPath = false`, any lltv, in markets where the - `rcfThreshold` escape hatch holds. Subsumed by unhealthyLowLltvCanBeLiquidated when - lltv < WAD; kept for the alternative reasoning path. - -Modeling assumptions (cf. LIVENESS section of Midnight.sol): -- 2-collateral market with both bits activated (loop_iter = 2 constraint). -- `market.liquidatorGate == 0` (no gate vetoes the liquidation). -- `e.msg.value == 0` (liquidate is non-payable). -- `SafeTransferLib.safeTransfer*` summarized as NONDET (tokens never revert on transfer). -- Oracle prices via a non-reverting persistent ghost (oracles do not revert nor return 0 - for collateral 0; price > 0 is forced by the preamble's seize bound). -- Borrower has `debt > 0` and is not transiently liquidation-locked (tx-start convention). -- Invariants from Midnight.spec assumed in preamble: `totalUnits == sumDebt + withdrawable` - (gives `totalUnits >= debt` and `withdrawable + debt <= MAX_UINT128`). -- `validCollateralAt` per-collateral bounds (protocol-enforced by touchMarket). +Property: "Debts can always be liquidated if unhealthy or expired" — full strength. + +For every liquidatable borrower and every amount in the safe interval an off-chain liquidator would compute, +`liquidate` (1) does not revert and (2) strictly decreases the borrower's debt. +Covers both modes (unhealthy lltv == WAD / lltv < WAD, post-maturity) and both entry paths (repay: parametric +repaidUnits; seize: parametric seizedAssets). Dust / inactive-collateral-0 borrowers (a seizing call would +divide by a zero price) are instead covered by the no-transfer bad-debt witness `badDebtCanBeLiquidated` (0/0). + +The safe interval is reconstructed in CVL from the contract's own intermediates; since mulDiv* are deterministic +ghosts the values match `liquidate` exactly, and each bound neutralises one revert site: + amount <= maxRepaid => RCF passes (L661); amount <= debtAfter => debt sub no underflow (L675); + seize(amount) <= collateral[0] => collateral sub no underflow (L669); debtAfter >= maxDebt => L659 ok. +The amount only touches the seized collateral 0; the other collaterals add only the amount-independent +maxDebt/badDebt sums (which vanish when they are inactive). + +Soundness: over-constraining the interval only weakens liveness (never unsound); under-constraining surfaces as +an !lastReverted counterexample, so the bounds need only be non-vacuous (basic sanity), not exactly tight. The +scaffolding `require`s (lowLltvScaffolding) are deterministic consequences of the mulDiv* ghost axioms and the +validCollateralAt bounds, justified inline at each require. + +Scope: a NUM_COLLATERALS-collateral market with up to NUM_COLLATERALS active collaterals, bounded by loop_iter. + +Assumptions (LIVENESS): no liquidator gate, well-behaved tokens (transfers summarized NONDET, i.e. non-reverting), +and oracle prices constant per address. The deterministic `mulDiv*` ghost summaries, the per-collateral validity +assumptions (`validCollateralAt`, as enforced by `touchMarket` at market creation), and the bitmap/collateral +coupling invariant are defined below. */ methods { @@ -67,6 +65,8 @@ definition MAX_UINT128() returns mathint = (1 << 128) - 1; definition MAX_TIMESTAMP() returns mathint = 1 << 64; +definition NUM_COLLATERALS() returns uint256 = 3; + function summaryToId(Midnight.Market market) returns bytes32 { return Utils.hashMarket(market); } @@ -79,6 +79,11 @@ persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivDown(a, b, d) <= a; axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivDown(a, d, d) == a; axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivDown(0, a, d) == 0 && ghostMulDivDown(a, 0, d) == 0; + + /// Monotonicity in each numerator factor. Sound (floor(a*b/d) is nondecreasing in a and b). Used by the + /// post-maturity range rule to upper-bound the decayed-lif seize by the maxLif seize (lif <= maxLif). + axiom forall uint256 a1. forall uint256 a2. forall uint256 b. forall uint256 d. d > 0 && a1 <= a2 => ghostMulDivDown(a1, b, d) <= ghostMulDivDown(a2, b, d); + axiom forall uint256 a. forall uint256 b1. forall uint256 b2. forall uint256 d. d > 0 && b1 <= b2 => ghostMulDivDown(a, b1, d) <= ghostMulDivDown(a, b2, d); } persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { @@ -87,11 +92,14 @@ persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivUp(a, b, d) <= a; /// Mirror of ghostMulDivDown's `b <= d => <= a` axiom. Sound: result * d >= a*b >= a*d. - /// Needed by unhealthyLowLltvCanBeLiquidated to derive maxRepaid >= debt - maxDebt >= 1. axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b >= d => ghostMulDivUp(a, b, d) >= a; axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(a, d, d) == a; axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(0, a, d) == 0 && ghostMulDivUp(a, 0, d) == 0; axiom forall uint256 a1. forall uint256 a2. forall uint256 b. forall uint256 d. d > 0 && a1 <= a2 => ghostMulDivUp(a1, b, d) <= ghostMulDivUp(a2, b, d); + + /// Antitone in the denominator. Sound (ceil(a*b/d) is nonincreasing in d). Used by the post-maturity seize + /// path to upper-bound the derived repaidUnits (lif in the denominator, lif >= WAD) by its value at lif = WAD. + axiom forall uint256 a. forall uint256 b. forall uint256 d1. forall uint256 d2. d1 > 0 && d1 <= d2 => ghostMulDivUp(a, b, d1) >= ghostMulDivUp(a, b, d2); } function summaryMulDivDown(uint256 x, uint256 y, uint256 d) returns uint256 { @@ -108,9 +116,6 @@ function summaryMulDivUp(uint256 x, uint256 y, uint256 d) returns uint256 { return ghostMulDivUp(x, y, d); } -strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint256 collateralIndex) - collateralIndex < 128 => (collateral(id, user, collateralIndex) != 0 <=> summaryGetBit(currentContract.position[id][user].collateralBitmap, collateralIndex)); - /// Per-collateral validity (lltv, maxLif, ExactMath bounds, liveness bound on collat * price). function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 i) { uint256 lltv = market.collateralParams[i].lltv; @@ -125,17 +130,17 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(), "oracle-quoted collat fits in uint128*WAD (LIVENESS)"; } -/// Shared preamble: 2-collateral market with both bits activated, well-behaved env, -/// unlocked, positive debt, and the liveness bound on collateral 0 that absorbs the -/// 1-unit seizure at lif = maxLif. -function preamble(env e, Midnight.Market market, bytes32 id, address borrower) { - require market.collateralParams.length == 2, "two-collateral market"; - require collateralBitmap(id, borrower) == 3, "both collateral bits activated"; - require summaryGetBit(3, 0) && summaryGetBit(3, 1), "bitmap=3: bits 0,1 set"; - require forall uint256 i. i >= 2 => !summaryGetBit(3, i), "bitmap=3: no other bits set"; +/// Shared setup for any liquidatable borrower in an N-collateral market: at most collaterals 0..N-1 active (so +/// the liquidate loop runs <= loop_iter times), well-behaved env, no liquidator gate, unlocked, positive debt, +/// and the totalUnits/withdrawable bounds imported from Midnight.spec. Does NOT assume which collateral is active. +function multiCollatSetup(env e, Midnight.Market market, bytes32 id, address borrower) { + require market.collateralParams.length == NUM_COLLATERALS(), "N-collateral market"; + uint128 bitmap = collateralBitmap(id, borrower); + require forall uint256 i. i >= NUM_COLLATERALS() => !summaryGetBit(bitmap, i), "at most collaterals 0..N-1 active (<= loop_iter)"; validCollateralAt(market, id, borrower, 0); validCollateralAt(market, id, borrower, 1); + validCollateralAt(market, id, borrower, 2); require e.msg.value == 0, "liquidate is non-payable"; require market.liquidatorGate == 0, "no liquidator gate (LIVENESS)"; @@ -143,102 +148,271 @@ function preamble(env e, Midnight.Market market, bytes32 id, address borrower) { require market.maturity < MAX_TIMESTAMP(), "maturity fits in uint64"; uint256 _debt = debtOf(id, borrower); - require totalUnits(id) >= _debt, "from totalUnitsEqualsSumNegativeDebtPlusWithdrawable (Midnight.spec)"; - require to_mathint(withdrawable(id)) + to_mathint(_debt) <= MAX_UINT128(), "from totalUnitsEqualsSumNegativeDebtPlusWithdrawable (Midnight.spec)"; + require totalUnits(id) >= _debt, "totalUnits = sumDebt + withdrawable >= this borrower's debt (Midnight.spec)"; + require to_mathint(withdrawable(id)) + to_mathint(_debt) <= MAX_UINT128(), "withdrawable + debt <= totalUnits <= uint128 max (Midnight.spec)"; require !liquidationLocked(id, borrower), "transient lock is zero at tx start"; require _debt > 0, "borrower has debt"; requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 2); +} + +/// Extends multiCollatSetup for the seizing rules: collateral 0 (the seized one) is active and large enough to +/// absorb the seizure. The magnitude bound (which also forces price0 > 0) is exactly the hypothesis behind the +/// strict recovery0 > contrib0 fact assumed in lowLltvScaffolding. Dust/inactive collateral 0 is covered instead +/// by badDebtCanBeLiquidated. +function seizablePreamble(env e, Midnight.Market market, bytes32 id, address borrower) { + multiCollatSetup(e, market, id, borrower); + require summaryGetBit(collateralBitmap(id, borrower), 0), "collateral 0 (the seized collateral) is active"; + require market.collateralParams[0].maxLif * ORACLE_PRICE_SCALE() <= collateral(id, borrower, 0) * WAD() * summaryPrice(market.collateralParams[0].oracle), "collateral 0 is non-dust & priced (forces price0 > 0; gives strict recovery0 > contrib0)"; +} + +/// Per-collateral maxDebt contribution (down-rounded) and recovery (the bad-debt offset). Both vanish when the +/// collateral is inactive (collat == 0 by nonZeroCollateralsAreActivated). +function contribAt(Midnight.Market market, bytes32 id, address borrower, uint256 i) returns mathint { + return ghostMulDivDown(ghostMulDivDown(collateral(id, borrower, i), summaryPrice(market.collateralParams[i].oracle), ORACLE_PRICE_SCALE()), market.collateralParams[i].lltv, WAD()); +} + +function recoveryAt(Midnight.Market market, bytes32 id, address borrower, uint256 i) returns mathint { + return ghostMulDivUp(ghostMulDivUp(collateral(id, borrower, i), summaryPrice(market.collateralParams[i].oracle), ORACLE_PRICE_SCALE()), WAD(), market.collateralParams[i].maxLif); +} + +/// maxDebt = sum over all collaterals of the down-rounded contribution. +function maxDebtSum(Midnight.Market market, bytes32 id, address borrower) returns mathint { + return contribAt(market, id, borrower, 0) + contribAt(market, id, borrower, 1) + contribAt(market, id, borrower, 2); +} + +/// debtAfter = debt - badDebt, where badDebt = zeroFloorSub chain = max(0, debt - sum of recoveries). +function debtAfterBadDebt(Midnight.Market market, bytes32 id, address borrower) returns mathint { + mathint rec = recoveryAt(market, id, borrower, 0) + recoveryAt(market, id, borrower, 1) + recoveryAt(market, id, borrower, 2); + mathint _debt = debtOf(id, borrower); + mathint badDebt = _debt > rec ? _debt - rec : 0; + return _debt - badDebt; +} + +/// Assumes the three arithmetic facts the lltv < WAD maxRepaid computation relies on. Each is a deterministic +/// consequence of the mulDiv* ghost axioms and the validCollateralAt bounds (themselves enforced by touchMarket +/// at market creation), so requiring them is sound: they hold on every valid Midnight market state. +function lowLltvScaffolding(Midnight.Market market, bytes32 id, address borrower) { + uint256 lltv0 = market.collateralParams[0].lltv; + uint256 maxLif0 = market.collateralParams[0].maxLif; + + // mulDivUp(maxLif, lltv, WAD) = ceil(maxLif*lltv / WAD) <= ceil(WAD*(WAD-1) / WAD) = WAD - 1, since + // validCollateralAt gives lltv*maxLif <= WAD*(WAD-1) when lltv < WAD (touchMarket only accepts + // maxLif = maxLif(lltv, cursor), which satisfies the ExactMath bound). So the maxRepaid denominator + // WAD - mulDivUp(lif, lltv, WAD) >= 1 (no zero/underflow at Midnight.sol's `WAD - lif.mulDivUp(lltv, WAD)`). + require to_mathint(ghostMulDivUp(maxLif0, lltv0, WAD())) <= to_mathint(WAD()) - 1, "WAD*(WAD-1) ExactMath bound (touchMarket) => inner <= WAD-1"; + + // recovery_i = ceil(ceil(collat*price/SCALE) * WAD / maxLif) and contrib_i = floor(floor(collat*price/SCALE) * + // lltv / WAD) quote the same collateral; recovery scales by WAD/maxLif and contribution by lltv/WAD. Because + // lltv*maxLif <= WAD*(WAD-1) < WAD^2 (validCollateralAt), WAD/maxLif > lltv/WAD, and the up/down rounding only + // widens the gap. For the seized collateral 0 the non-dust preamble bound forces the quote >= 1 (strictly + // positive), so the inequality is strict; this is what makes debtAfter > maxDebt and thus maxRepaid >= 1 + // (non-vacuous interval). For collaterals 1 and 2 the same comparison holds non-strictly, and trivially as + // 0 >= 0 when the collateral is inactive (collat == 0 by nonZeroCollateralsAreActivated). + require recoveryAt(market, id, borrower, 0) > contribAt(market, id, borrower, 0), "non-dust collat0, WAD/maxLif > lltv/WAD (lltv*maxLif < WAD^2) => recovery0 strictly > contrib0"; + require recoveryAt(market, id, borrower, 1) >= contribAt(market, id, borrower, 1), "WAD/maxLif >= lltv/WAD (lltv*maxLif <= WAD^2) => recovery1 >= contrib1 (0 >= 0 if inactive)"; + require recoveryAt(market, id, borrower, 2) >= contribAt(market, id, borrower, 2), "WAD/maxLif >= lltv/WAD (lltv*maxLif <= WAD^2) => recovery2 >= contrib2 (0 >= 0 if inactive)"; +} + +/// INVARIANT /// + +// Proven in CollateralBitmap.spec; assumed here via requireInvariant (not re-proven in this spec). +strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint256 collateralIndex) + collateralIndex < 128 => (collateral(id, user, collateralIndex) != 0 <=> summaryGetBit(currentContract.position[id][user].collateralBitmap, collateralIndex)); + +/// REPAY PATH (repaidUnits > 0, seizedAssets = 0) /// + +/// Unhealthy, lltv == WAD: maxRepaid = type(uint256).max, so the RCF check passes unconditionally and the +/// `_position.debt - maxDebt` subtraction is never executed. Only the debt and collateral underflow guards bind. +rule unhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 repaidUnits) { + bytes32 id = summaryToId(market); + seizablePreamble(e, market, id, borrower); - address oracle = market.collateralParams[0].oracle; - uint128 collat = collateral(id, borrower, 0); uint256 maxLif = market.collateralParams[0].maxLif; - require maxLif * ORACLE_PRICE_SCALE() <= collat * WAD() * summaryPrice(oracle), "1-unit seize fits in collat[0] (also forces price[0]>0)"; + require market.collateralParams[0].lltv == WAD(), "lltv == WAD partition (maxRepaid = uint256.max)"; + + uint128 collat0 = collateral(id, borrower, 0); + uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint256 _debt = debtOf(id, borrower); + mathint debtAfter = debtAfterBadDebt(market, id, borrower); + require to_mathint(_debt) > maxDebtSum(market, id, borrower), "unhealthy: debt > maxDebt"; + + require repaidUnits > 0; + require to_mathint(repaidUnits) <= debtAfter, "no debt underflow (L675)"; + require to_mathint(ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), price0)) <= to_mathint(collat0), "seize fits collateral[0] (L669)"; + + bytes data; + liquidate@withrevert(e, market, 0, 0, repaidUnits, borrower, false, receiver, 0, data); + assert !lastReverted, "the call is live (succeeds)"; + assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; } -/// Post-maturity: healthyPath = true. RCF skipped, no lltv split needed. -rule postMaturityCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver) { +/// Unhealthy, lltv < WAD: maxRepaid is finite, so the safe interval is additionally capped by repaidUnits <= maxRepaid. +rule unhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 repaidUnits) { bytes32 id = summaryToId(market); - preamble(e, market, id, borrower); + seizablePreamble(e, market, id, borrower); - require e.block.timestamp > market.maturity, "post-maturity partition"; + uint256 lltv = market.collateralParams[0].lltv; + uint256 maxLif = market.collateralParams[0].maxLif; + require lltv < WAD(), "lltv < WAD partition"; + + uint128 collat0 = collateral(id, borrower, 0); + uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint256 _debt = debtOf(id, borrower); + mathint maxDebt = maxDebtSum(market, id, borrower); + mathint debtAfter = debtAfterBadDebt(market, id, borrower); + require to_mathint(_debt) > maxDebt, "unhealthy: debt > maxDebt"; + + lowLltvScaffolding(market, id, borrower); + + // maxRepaid as computed by the contract (L658-660): (debtAfter - maxDebt) ceil-div (WAD - lif*lltv/WAD), + // using the seized collateral 0's lltv/lif. debtAfter >= maxDebt from the scaffolding facts. + mathint inner = ghostMulDivUp(maxLif, lltv, WAD()); + mathint maxRepaid = ghostMulDivUp(assert_uint256(debtAfter - maxDebt), WAD(), assert_uint256(to_mathint(WAD()) - inner)); + + require repaidUnits > 0; + require to_mathint(repaidUnits) <= maxRepaid, "RCF check passes (L661)"; + require to_mathint(repaidUnits) <= debtAfter, "no debt underflow (L675)"; + require to_mathint(ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), price0)) <= to_mathint(collat0), "seize fits collateral[0] (L669)"; + + bytes data; + liquidate@withrevert(e, market, 0, 0, repaidUnits, borrower, false, receiver, 0, data); + assert !lastReverted, "the call is live (succeeds)"; + assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; +} + +/// Post-maturity: liquidatable by expiry alone (no health check), RCF / `debt - maxDebt` block skipped. lif <= +/// maxLif post-maturity, so bounding the seize at maxLif (via ghostMulDivDown monotonicity) upper-bounds it. +rule postMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 repaidUnits) { + bytes32 id = summaryToId(market); + seizablePreamble(e, market, id, borrower); + + require e.block.timestamp > market.maturity, "post-maturity partition (liquidatable by expiry)"; + + uint256 maxLif = market.collateralParams[0].maxLif; + uint128 collat0 = collateral(id, borrower, 0); + uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint256 _debt = debtOf(id, borrower); + mathint debtAfter = debtAfterBadDebt(market, id, borrower); + + require repaidUnits > 0; + require to_mathint(repaidUnits) <= debtAfter, "no debt underflow (L675)"; + require to_mathint(ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), price0)) <= to_mathint(collat0), "seize (at lif <= maxLif) fits collateral[0] (L669)"; bytes data; - liquidate@withrevert(e, market, 0, 0, 1, borrower, true, receiver, 0, data); - assert !lastReverted; + liquidate@withrevert(e, market, 0, 0, repaidUnits, borrower, true, receiver, 0, data); + assert !lastReverted, "the call is live (succeeds)"; + assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; } -/// Unhealthy, lltv == WAD: healthyPath = false. -/// RCF maxRepaid = type(uint256).max, so the check passes without any precondition on rcfThreshold. -rule unhealthyLltvFullCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver) { +/// SEIZE PATH (seizedAssets > 0, repaidUnits = 0) /// +/// The liquidator specifies the collateral to seize; the contract derives repaidUnits (L650): +/// repaidUnits = mulDivUp(mulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE), WAD, lif). The collateral underflow +/// guard is a direct `seizedAssets <= collateral[0]`; debt-underflow and RCF apply to the derived repaidUnits. +/// seizedAssets > 0 and price0 > 0 force derived repaidUnits >= 1, so progress still holds. + +/// Seize path, unhealthy, lltv == WAD: maxRepaid = uint256.max (RCF auto-passes), lif = maxLif. +rule seizeUnhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 seizedAssets) { bytes32 id = summaryToId(market); - preamble(e, market, id, borrower); + seizablePreamble(e, market, id, borrower); - require !isHealthy(market, id, borrower), "unhealthy partition"; + uint256 maxLif = market.collateralParams[0].maxLif; require market.collateralParams[0].lltv == WAD(), "lltv == WAD partition (maxRepaid = uint256.max)"; + uint128 collat0 = collateral(id, borrower, 0); + uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint256 _debt = debtOf(id, borrower); + mathint debtAfter = debtAfterBadDebt(market, id, borrower); + require to_mathint(_debt) > maxDebtSum(market, id, borrower), "unhealthy: debt > maxDebt"; + + mathint repaidUnits = ghostMulDivUp(ghostMulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE()), WAD(), maxLif); + + require seizedAssets > 0; + require to_mathint(seizedAssets) <= to_mathint(collat0), "seize fits collateral[0] (L669)"; + require repaidUnits <= debtAfter, "derived repaidUnits: no debt underflow (L675)"; + bytes data; - liquidate@withrevert(e, market, 0, 0, 1, borrower, false, receiver, 0, data); - assert !lastReverted; + liquidate@withrevert(e, market, 0, seizedAssets, 0, borrower, false, receiver, 0, data); + assert !lastReverted, "the call is live (succeeds)"; + assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; } -/// Unhealthy, lltv < WAD: healthyPath = false. -/// Sound scaffolding lemmas help the solver with NIA chains (bad-debt path, RCF denominator). -rule unhealthyLowLltvCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver) { +/// Seize path, unhealthy, lltv < WAD: RCF caps the derived repaidUnits by maxRepaid; same scaffolding facts apply. +rule seizeUnhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 seizedAssets) { bytes32 id = summaryToId(market); - preamble(e, market, id, borrower); + seizablePreamble(e, market, id, borrower); uint256 lltv = market.collateralParams[0].lltv; + uint256 maxLif = market.collateralParams[0].maxLif; require lltv < WAD(), "lltv < WAD partition"; - // (a) Direct maxDebt computation (avoids isHealthy's loop/liquidate commutativity). - uint256 maxLif = market.collateralParams[0].maxLif; - uint256 lltv1 = market.collateralParams[1].lltv; uint128 collat0 = collateral(id, borrower, 0); - uint128 collat1 = collateral(id, borrower, 1); uint256 price0 = summaryPrice(market.collateralParams[0].oracle); - uint256 price1 = summaryPrice(market.collateralParams[1].oracle); - mathint maxDebt = to_mathint(ghostMulDivDown(ghostMulDivDown(collat0, price0, ORACLE_PRICE_SCALE()), lltv, WAD())) + to_mathint(ghostMulDivDown(ghostMulDivDown(collat1, price1, ORACLE_PRICE_SCALE()), lltv1, WAD())); uint256 _debt = debtOf(id, borrower); - require to_mathint(_debt) > maxDebt, "unhealthy: debt > maxDebt (replaces isHealthy)"; + mathint maxDebt = maxDebtSum(market, id, borrower); + mathint debtAfter = debtAfterBadDebt(market, id, borrower); + require to_mathint(_debt) > maxDebt, "unhealthy: debt > maxDebt"; + + lowLltvScaffolding(market, id, borrower); - // (b) inner := mulDivUp(maxLif, lltv, WAD) <= WAD - 1 (from validCollateralAt + axiom 2). - require to_mathint(ghostMulDivUp(maxLif, lltv, WAD())) <= to_mathint(WAD()) - 1, "lemma: mulDivUp(maxLif, lltv, WAD) <= WAD - 1 (from validCollateralAt + axiom 2)"; + mathint inner = ghostMulDivUp(maxLif, lltv, WAD()); + mathint maxRepaid = ghostMulDivUp(assert_uint256(debtAfter - maxDebt), WAD(), assert_uint256(to_mathint(WAD()) - inner)); - // (c) WAD - inner >= 1 (maxRepaid denominator is positive, from (b)). - require to_mathint(WAD()) - to_mathint(ghostMulDivUp(maxLif, lltv, WAD())) >= 1, "from (b): WAD - inner >= 1"; + mathint repaidUnits = ghostMulDivUp(ghostMulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE()), WAD(), maxLif); - // (d) Per-collateral recovery > maxDebt contribution (from lltv*maxLif < WAD^2 + ghost axioms). - // Bridges the bad-debt path: ensures _position.debt > maxDebt after bad-debt realization. - uint256 maxLif1 = market.collateralParams[1].maxLif; - require to_mathint(ghostMulDivUp(ghostMulDivUp(collat0, price0, ORACLE_PRICE_SCALE()), WAD(), maxLif)) > to_mathint(ghostMulDivDown(ghostMulDivDown(collat0, price0, ORACLE_PRICE_SCALE()), lltv, WAD())), "lemma: recovery[0] > maxDebtContrib[0] (from lltv*maxLif < WAD^2, collat0*price0 > 0)"; - require to_mathint(ghostMulDivUp(ghostMulDivUp(collat1, price1, ORACLE_PRICE_SCALE()), WAD(), maxLif1)) >= to_mathint(ghostMulDivDown(ghostMulDivDown(collat1, price1, ORACLE_PRICE_SCALE()), lltv1, WAD())), "lemma: recovery[1] >= maxDebtContrib[1] (from lltv1*maxLif1 <= WAD^2)"; + require seizedAssets > 0; + require to_mathint(seizedAssets) <= to_mathint(collat0), "seize fits collateral[0] (L669)"; + require repaidUnits <= maxRepaid, "derived repaidUnits: RCF check passes (L661)"; + require repaidUnits <= debtAfter, "derived repaidUnits: no debt underflow (L675)"; bytes data; - liquidate@withrevert(e, market, 0, 0, 1, borrower, false, receiver, 0, data); - assert !lastReverted; + liquidate@withrevert(e, market, 0, seizedAssets, 0, borrower, false, receiver, 0, data); + assert !lastReverted, "the call is live (succeeds)"; + assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; } -/// Unhealthy, any lltv: healthyPath = false. -/// The rcfThreshold escape hatch makes the second disjunct hold, sidestepping the nonlinear maxRepaid. -/// Subsumed by unhealthyLowLltvCanBeLiquidated for lltv < WAD and by unhealthyLltvFullCanBeLiquidated -/// for lltv == WAD; kept as an alternative proof using the second RCF disjunct. -rule unhealthyCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver) { +/// Seize path, post-maturity: no RCF. lif >= WAD, and the derived repaidUnits = mulDivUp(quote, WAD, lif) is +/// largest at lif = WAD (mulDivUp is antitone in its denominator), so it is upper-bounded by `quote` itself. +rule seizePostMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 seizedAssets) { bytes32 id = summaryToId(market); - preamble(e, market, id, borrower); + seizablePreamble(e, market, id, borrower); - require !isHealthy(market, id, borrower), "unhealthy partition"; + require e.block.timestamp > market.maturity, "post-maturity partition (liquidatable by expiry)"; - address oracle = market.collateralParams[0].oracle; - uint128 collat = collateral(id, borrower, 0); - uint256 maxLif = market.collateralParams[0].maxLif; + uint128 collat0 = collateral(id, borrower, 0); + uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint256 _debt = debtOf(id, borrower); + mathint debtAfter = debtAfterBadDebt(market, id, borrower); + + mathint quoteUp = ghostMulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE()); + + require seizedAssets > 0; + require to_mathint(seizedAssets) <= to_mathint(collat0), "seize fits collateral[0] (L669)"; + require quoteUp <= debtAfter, "derived repaidUnits (<= quote since lif >= WAD): no debt underflow (L675)"; + + bytes data; + liquidate@withrevert(e, market, 0, seizedAssets, 0, borrower, true, receiver, 0, data); + assert !lastReverted, "the call is live (succeeds)"; + assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; +} + +/// BAD-DEBT WITNESS (repaidUnits = 0, seizedAssets = 0) /// + +/// Any liquidatable borrower (unhealthy or expired) can be liquidated with the no-transfer call +/// repaidUnits = 0, seizedAssets = 0. This path skips the seize/RCF/underflow block entirely, so it never +/// reverts regardless of collateral magnitude or which collateral is active. It covers the borrowers the +/// seizing rules cannot: dust collateral 0, and borrowers whose collateral 0 is inactive (where a seizing call +/// would divide by a zero liquidatedCollatPrice). No progress assert: a 0/0 call only realizes bad debt, which +/// may be zero (so the debt need not strictly decrease). +rule badDebtCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver, bool postMaturityMode) { + bytes32 id = summaryToId(market); + multiCollatSetup(e, market, id, borrower); - /// Sufficient arithmetic bound for `rcfThreshold > mulDivUp(mulDivUp(collat, price, OPS), WAD, maxLif)`, - /// derived from the two mulDivUp ceiling axioms. - require to_mathint(market.rcfThreshold) * to_mathint(maxLif) * to_mathint(ORACLE_PRICE_SCALE()) > to_mathint(collat) * to_mathint(summaryPrice(oracle)) * to_mathint(WAD()) + (to_mathint(ORACLE_PRICE_SCALE()) - 1) * to_mathint(WAD()) + (to_mathint(maxLif) - 1) * to_mathint(ORACLE_PRICE_SCALE()), "rcfThreshold escape: ceiling-bound on mulDivUp(mulDivUp(collat,price,OPS),WAD,maxLif)"; + require postMaturityMode ? e.block.timestamp > market.maturity : !isHealthy(market, id, borrower), "borrower is liquidatable in the chosen mode"; bytes data; - liquidate@withrevert(e, market, 0, 0, 1, borrower, false, receiver, 0, data); - assert !lastReverted; + liquidate@withrevert(e, market, 0, 0, 0, borrower, postMaturityMode, receiver, 0, data); + assert !lastReverted, "the no-transfer bad-debt call is live (succeeds)"; } From b0d18c888dd1976560baf3d4d8f2e9b14b7b03fb Mon Sep 17 00:00:00 2001 From: lilCertora Date: Mon, 1 Jun 2026 14:43:55 +0200 Subject: [PATCH 43/53] cleaning --- certora/specs/LiquidateLiveness.spec | 84 ++++++++++------------------ 1 file changed, 30 insertions(+), 54 deletions(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index d18dfd9e5..a3a394925 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -5,32 +5,18 @@ import "BitmapSummaries.spec"; using Utils as Utils; /** -Property: "Debts can always be liquidated if unhealthy or expired" — full strength. - -For every liquidatable borrower and every amount in the safe interval an off-chain liquidator would compute, -`liquidate` (1) does not revert and (2) strictly decreases the borrower's debt. -Covers both modes (unhealthy lltv == WAD / lltv < WAD, post-maturity) and both entry paths (repay: parametric -repaidUnits; seize: parametric seizedAssets). Dust / inactive-collateral-0 borrowers (a seizing call would -divide by a zero price) are instead covered by the no-transfer bad-debt witness `badDebtCanBeLiquidated` (0/0). - -The safe interval is reconstructed in CVL from the contract's own intermediates; since mulDiv* are deterministic -ghosts the values match `liquidate` exactly, and each bound neutralises one revert site: - amount <= maxRepaid => RCF passes (L661); amount <= debtAfter => debt sub no underflow (L675); - seize(amount) <= collateral[0] => collateral sub no underflow (L669); debtAfter >= maxDebt => L659 ok. -The amount only touches the seized collateral 0; the other collaterals add only the amount-independent -maxDebt/badDebt sums (which vanish when they are inactive). - -Soundness: over-constraining the interval only weakens liveness (never unsound); under-constraining surfaces as -an !lastReverted counterexample, so the bounds need only be non-vacuous (basic sanity), not exactly tight. The -scaffolding `require`s (lowLltvScaffolding) are deterministic consequences of the mulDiv* ghost axioms and the -validCollateralAt bounds, justified inline at each require. - -Scope: a NUM_COLLATERALS-collateral market with up to NUM_COLLATERALS active collaterals, bounded by loop_iter. - -Assumptions (LIVENESS): no liquidator gate, well-behaved tokens (transfers summarized NONDET, i.e. non-reverting), -and oracle prices constant per address. The deterministic `mulDiv*` ghost summaries, the per-collateral validity -assumptions (`validCollateralAt`, as enforced by `touchMarket` at market creation), and the bitmap/collateral -coupling invariant are defined below. +Property: "Debts can always be liquidated if unhealthy or expired" — for every liquidatable borrower and every +amount in the safe interval, `liquidate` does not revert and strictly decreases debt. Covers both modes (unhealthy +lltv == / < WAD, post-maturity) and both paths (repay, seize). Dust / inactive-collateral-0 borrowers fall back to +the no-transfer witness `badDebtCanBeLiquidated` (0/0). + +The interval is rebuilt in CVL from the contract's intermediates (mulDiv* are deterministic ghosts, so values +match exactly), each bound neutralising one revert site: amount <= maxRepaid (RCF), <= debtAfter (debt sub), +seize(amount) <= collateral[0] (collat sub), debtAfter >= maxDebt. Soundness: over-constraining only weakens +liveness; under-constraining surfaces as an !lastReverted counterexample, so bounds need only be non-vacuous. + +Scope: NUM_COLLATERALS-collateral market, bounded by loop_iter. Assumptions (LIVENESS): no liquidator gate, +non-reverting tokens (NONDET), oracle prices constant per address, market valid (validCollateralAt / touchMarket). */ methods { @@ -130,9 +116,9 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(), "oracle-quoted collat fits in uint128*WAD (LIVENESS)"; } -/// Shared setup for any liquidatable borrower in an N-collateral market: at most collaterals 0..N-1 active (so -/// the liquidate loop runs <= loop_iter times), well-behaved env, no liquidator gate, unlocked, positive debt, -/// and the totalUnits/withdrawable bounds imported from Midnight.spec. Does NOT assume which collateral is active. +/// Shared setup for any liquidatable borrower: at most collaterals 0..N-1 active (loop runs <= loop_iter), no +/// liquidator gate, unlocked, positive debt, totalUnits/withdrawable bounds (Midnight.spec). Does NOT assume +/// which collateral is active. function multiCollatSetup(env e, Midnight.Market market, bytes32 id, address borrower) { require market.collateralParams.length == NUM_COLLATERALS(), "N-collateral market"; uint128 bitmap = collateralBitmap(id, borrower); @@ -155,13 +141,12 @@ function multiCollatSetup(env e, Midnight.Market market, bytes32 id, address bor requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 2); + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 2); // Proven in CollateralBitmap.spec; assumed here via requireInvariant (not re-proven in this spec). } -/// Extends multiCollatSetup for the seizing rules: collateral 0 (the seized one) is active and large enough to -/// absorb the seizure. The magnitude bound (which also forces price0 > 0) is exactly the hypothesis behind the -/// strict recovery0 > contrib0 fact assumed in lowLltvScaffolding. Dust/inactive collateral 0 is covered instead -/// by badDebtCanBeLiquidated. +/// Extends multiCollatSetup for the seizing rules: collateral 0 (the seized one) is active and non-dust. The +/// magnitude bound forces price0 > 0 and gives the strict recovery0 > contrib0 fact used in lowLltvScaffolding. +/// Dust/inactive collateral 0 is covered instead by badDebtCanBeLiquidated. function seizablePreamble(env e, Midnight.Market market, bytes32 id, address borrower) { multiCollatSetup(e, market, id, borrower); require summaryGetBit(collateralBitmap(id, borrower), 0), "collateral 0 (the seized collateral) is active"; @@ -198,19 +183,13 @@ function lowLltvScaffolding(Midnight.Market market, bytes32 id, address borrower uint256 lltv0 = market.collateralParams[0].lltv; uint256 maxLif0 = market.collateralParams[0].maxLif; - // mulDivUp(maxLif, lltv, WAD) = ceil(maxLif*lltv / WAD) <= ceil(WAD*(WAD-1) / WAD) = WAD - 1, since - // validCollateralAt gives lltv*maxLif <= WAD*(WAD-1) when lltv < WAD (touchMarket only accepts - // maxLif = maxLif(lltv, cursor), which satisfies the ExactMath bound). So the maxRepaid denominator - // WAD - mulDivUp(lif, lltv, WAD) >= 1 (no zero/underflow at Midnight.sol's `WAD - lif.mulDivUp(lltv, WAD)`). + // inner = ceil(maxLif*lltv/WAD) <= WAD-1 since validCollateralAt gives lltv*maxLif <= WAD*(WAD-1) for lltv= 1 (L659 ok). require to_mathint(ghostMulDivUp(maxLif0, lltv0, WAD())) <= to_mathint(WAD()) - 1, "WAD*(WAD-1) ExactMath bound (touchMarket) => inner <= WAD-1"; - // recovery_i = ceil(ceil(collat*price/SCALE) * WAD / maxLif) and contrib_i = floor(floor(collat*price/SCALE) * - // lltv / WAD) quote the same collateral; recovery scales by WAD/maxLif and contribution by lltv/WAD. Because - // lltv*maxLif <= WAD*(WAD-1) < WAD^2 (validCollateralAt), WAD/maxLif > lltv/WAD, and the up/down rounding only - // widens the gap. For the seized collateral 0 the non-dust preamble bound forces the quote >= 1 (strictly - // positive), so the inequality is strict; this is what makes debtAfter > maxDebt and thus maxRepaid >= 1 - // (non-vacuous interval). For collaterals 1 and 2 the same comparison holds non-strictly, and trivially as - // 0 >= 0 when the collateral is inactive (collat == 0 by nonZeroCollateralsAreActivated). + // recovery scales the quote by WAD/maxLif, contrib by lltv/WAD. lltv*maxLif <= WAD^2 (validCollateralAt) gives + // WAD/maxLif >= lltv/WAD, so recovery_i >= contrib_i, hence sum recoveries >= maxDebt => debtAfter >= maxDebt. + // Strict for collat 0 (non-dust quote >= 1, lltv*maxLif < WAD^2) => debtAfter > maxDebt => maxRepaid >= 1. require recoveryAt(market, id, borrower, 0) > contribAt(market, id, borrower, 0), "non-dust collat0, WAD/maxLif > lltv/WAD (lltv*maxLif < WAD^2) => recovery0 strictly > contrib0"; require recoveryAt(market, id, borrower, 1) >= contribAt(market, id, borrower, 1), "WAD/maxLif >= lltv/WAD (lltv*maxLif <= WAD^2) => recovery1 >= contrib1 (0 >= 0 if inactive)"; require recoveryAt(market, id, borrower, 2) >= contribAt(market, id, borrower, 2), "WAD/maxLif >= lltv/WAD (lltv*maxLif <= WAD^2) => recovery2 >= contrib2 (0 >= 0 if inactive)"; @@ -308,9 +287,8 @@ rule postMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market, add } /// SEIZE PATH (seizedAssets > 0, repaidUnits = 0) /// -/// The liquidator specifies the collateral to seize; the contract derives repaidUnits (L650): -/// repaidUnits = mulDivUp(mulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE), WAD, lif). The collateral underflow -/// guard is a direct `seizedAssets <= collateral[0]`; debt-underflow and RCF apply to the derived repaidUnits. +/// The contract derives repaidUnits = mulDivUp(mulDivUp(seizedAssets, price0, SCALE), WAD, lif) (L650). Collateral +/// underflow guard is a direct `seizedAssets <= collateral[0]`; debt-underflow and RCF apply to derived repaidUnits. /// seizedAssets > 0 and price0 > 0 force derived repaidUnits >= 1, so progress still holds. /// Seize path, unhealthy, lltv == WAD: maxRepaid = uint256.max (RCF auto-passes), lif = maxLif. @@ -400,12 +378,10 @@ rule seizePostMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market /// BAD-DEBT WITNESS (repaidUnits = 0, seizedAssets = 0) /// -/// Any liquidatable borrower (unhealthy or expired) can be liquidated with the no-transfer call -/// repaidUnits = 0, seizedAssets = 0. This path skips the seize/RCF/underflow block entirely, so it never -/// reverts regardless of collateral magnitude or which collateral is active. It covers the borrowers the -/// seizing rules cannot: dust collateral 0, and borrowers whose collateral 0 is inactive (where a seizing call -/// would divide by a zero liquidatedCollatPrice). No progress assert: a 0/0 call only realizes bad debt, which -/// may be zero (so the debt need not strictly decrease). +/// Any liquidatable borrower can be liquidated with the no-transfer 0/0 call: it skips the seize/RCF/underflow +/// block, so it never reverts regardless of collateral. Covers the borrowers the seizing rules cannot (dust or +/// inactive collateral 0, where a seizing call divides by a zero price). No progress assert: a 0/0 call only +/// realizes bad debt, which may be zero. rule badDebtCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver, bool postMaturityMode) { bytes32 id = summaryToId(market); multiCollatSetup(e, market, id, borrower); From c982cc060e7b3bb14b1fc9f2edd56224053d860b Mon Sep 17 00:00:00 2001 From: lilCertora Date: Tue, 2 Jun 2026 19:09:04 +0200 Subject: [PATCH 44/53] modif --- certora/specs/LiquidateLiveness.spec | 156 +++++++++++++-------------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index a3a394925..8fdccad86 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -5,18 +5,24 @@ import "BitmapSummaries.spec"; using Utils as Utils; /** -Property: "Debts can always be liquidated if unhealthy or expired" — for every liquidatable borrower and every -amount in the safe interval, `liquidate` does not revert and strictly decreases debt. Covers both modes (unhealthy -lltv == / < WAD, post-maturity) and both paths (repay, seize). Dust / inactive-collateral-0 borrowers fall back to -the no-transfer witness `badDebtCanBeLiquidated` (0/0). - -The interval is rebuilt in CVL from the contract's intermediates (mulDiv* are deterministic ghosts, so values -match exactly), each bound neutralising one revert site: amount <= maxRepaid (RCF), <= debtAfter (debt sub), -seize(amount) <= collateral[0] (collat sub), debtAfter >= maxDebt. Soundness: over-constraining only weakens -liveness; under-constraining surfaces as an !lastReverted counterexample, so bounds need only be non-vacuous. - -Scope: NUM_COLLATERALS-collateral market, bounded by loop_iter. Assumptions (LIVENESS): no liquidator gate, -non-reverting tokens (NONDET), oracle prices constant per address, market valid (validCollateralAt / touchMarket). +Property: "Debts can always be liquidated if unhealthy or expired" — full strength. + +Range liveness + progress (3-collateral, parametric amount): for every liquidatable borrower and every amount +in the safe interval an off-chain liquidator computes from the position, `liquidate` (1) does NOT revert and +(2) strictly decreases debt. Proven for both modes (unhealthy lltv == WAD / lltv < WAD, post-maturity) and both +entry paths (repay with parametric repaidUnits, seize with parametric seizedAssets). + +Borrowers the seizing rules cannot reach (dust / inactive collateral 0, where a seizing call would divide by a +zero liquidatedCollatPrice) are covered by the no-transfer bad-debt witness `badDebtCanBeLiquidated` +(repaidUnits = 0, seizedAssets = 0). Together they cover every liquidatable borrower in scope. + +The safe amount is reconstructed in CVL from the contract's own quantities. Because `mulDiv*` are summarized as +deterministic ghosts, those values are bit-for-bit identical to the ones inside `liquidate`, so bounding the +amount by them neutralises each revert site (annotated per-require with the Midnight.sol line). Over-constraining +only WEAKENS the result (never unsound); under-constraining surfaces as an `assert !lastReverted` counterexample. + +Assumptions (LIVENESS): no liquidator gate, well-behaved tokens (transfers summarized NONDET), constant oracle +prices per address. Scope: a 3-collateral market with up to 3 active collaterals, bounded by `loop_iter = 3`. */ methods { @@ -51,8 +57,6 @@ definition MAX_UINT128() returns mathint = (1 << 128) - 1; definition MAX_TIMESTAMP() returns mathint = 1 << 64; -definition NUM_COLLATERALS() returns uint256 = 3; - function summaryToId(Midnight.Market market) returns bytes32 { return Utils.hashMarket(market); } @@ -102,6 +106,9 @@ function summaryMulDivUp(uint256 x, uint256 y, uint256 d) returns uint256 { return ghostMulDivUp(x, y, d); } +strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint256 collateralIndex) + collateralIndex < 128 => (collateral(id, user, collateralIndex) != 0 <=> summaryGetBit(currentContract.position[id][user].collateralBitmap, collateralIndex)); + /// Per-collateral validity (lltv, maxLif, ExactMath bounds, liveness bound on collat * price). function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 i) { uint256 lltv = market.collateralParams[i].lltv; @@ -116,13 +123,13 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, require to_mathint(collateral(id, borrower, i)) * to_mathint(summaryPrice(oracle)) <= to_mathint(ORACLE_PRICE_SCALE()) * to_mathint(WAD()) * MAX_UINT128(), "oracle-quoted collat fits in uint128*WAD (LIVENESS)"; } -/// Shared setup for any liquidatable borrower: at most collaterals 0..N-1 active (loop runs <= loop_iter), no -/// liquidator gate, unlocked, positive debt, totalUnits/withdrawable bounds (Midnight.spec). Does NOT assume -/// which collateral is active. -function multiCollatSetup(env e, Midnight.Market market, bytes32 id, address borrower) { - require market.collateralParams.length == NUM_COLLATERALS(), "N-collateral market"; +/// Shared setup: 3-collateral market with at most collaterals 0,1,2 active (loop runs <= loop_iter), well-behaved +/// env, no liquidator gate, unlocked, positive debt, totalUnits/withdrawable bounds (Midnight.spec). Does NOT +/// assume which collateral is active. +function threeCollatSetup(env e, Midnight.Market market, bytes32 id, address borrower) { + require market.collateralParams.length == 3, "three-collateral market (borrower activates 0, 1, 2 or 3 of them)"; uint128 bitmap = collateralBitmap(id, borrower); - require forall uint256 i. i >= NUM_COLLATERALS() => !summaryGetBit(bitmap, i), "at most collaterals 0..N-1 active (<= loop_iter)"; + require forall uint256 i. i >= 3 => !summaryGetBit(bitmap, i), "at most collaterals 0,1,2 active (<= loop_iter)"; validCollateralAt(market, id, borrower, 0); validCollateralAt(market, id, borrower, 1); @@ -135,71 +142,65 @@ function multiCollatSetup(env e, Midnight.Market market, bytes32 id, address bor uint256 _debt = debtOf(id, borrower); require totalUnits(id) >= _debt, "totalUnits = sumDebt + withdrawable >= this borrower's debt (Midnight.spec)"; - require to_mathint(withdrawable(id)) + to_mathint(_debt) <= MAX_UINT128(), "withdrawable + debt <= totalUnits <= uint128 max (Midnight.spec)"; + require to_mathint(withdrawable(id)) + to_mathint(_debt) <= MAX_UINT128(), "withdrawable + debt <= sumDebt + withdrawable = totalUnits <= uint128 max (Midnight.spec)"; require !liquidationLocked(id, borrower), "transient lock is zero at tx start"; require _debt > 0, "borrower has debt"; requireInvariant nonZeroCollateralsAreActivated(id, borrower, 0); requireInvariant nonZeroCollateralsAreActivated(id, borrower, 1); - requireInvariant nonZeroCollateralsAreActivated(id, borrower, 2); // Proven in CollateralBitmap.spec; assumed here via requireInvariant (not re-proven in this spec). + requireInvariant nonZeroCollateralsAreActivated(id, borrower, 2); } -/// Extends multiCollatSetup for the seizing rules: collateral 0 (the seized one) is active and non-dust. The -/// magnitude bound forces price0 > 0 and gives the strict recovery0 > contrib0 fact used in lowLltvScaffolding. -/// Dust/inactive collateral 0 is covered instead by badDebtCanBeLiquidated. +/// Extends threeCollatSetup for the seizing rules: collateral 0 (the seized one) is active and priced, so the +/// liquidatedCollatPrice is non-zero (the seize/RCF block divides by it). function seizablePreamble(env e, Midnight.Market market, bytes32 id, address borrower) { - multiCollatSetup(e, market, id, borrower); + threeCollatSetup(e, market, id, borrower); require summaryGetBit(collateralBitmap(id, borrower), 0), "collateral 0 (the seized collateral) is active"; - require market.collateralParams[0].maxLif * ORACLE_PRICE_SCALE() <= collateral(id, borrower, 0) * WAD() * summaryPrice(market.collateralParams[0].oracle), "collateral 0 is non-dust & priced (forces price0 > 0; gives strict recovery0 > contrib0)"; -} - -/// Per-collateral maxDebt contribution (down-rounded) and recovery (the bad-debt offset). Both vanish when the -/// collateral is inactive (collat == 0 by nonZeroCollateralsAreActivated). -function contribAt(Midnight.Market market, bytes32 id, address borrower, uint256 i) returns mathint { - return ghostMulDivDown(ghostMulDivDown(collateral(id, borrower, i), summaryPrice(market.collateralParams[i].oracle), ORACLE_PRICE_SCALE()), market.collateralParams[i].lltv, WAD()); + require summaryPrice(market.collateralParams[0].oracle) > 0, "collateral 0 is priced (LIVENESS)"; } -function recoveryAt(Midnight.Market market, bytes32 id, address borrower, uint256 i) returns mathint { - return ghostMulDivUp(ghostMulDivUp(collateral(id, borrower, i), summaryPrice(market.collateralParams[i].oracle), ORACLE_PRICE_SCALE()), WAD(), market.collateralParams[i].maxLif); -} - -/// maxDebt = sum over all collaterals of the down-rounded contribution. +/// maxDebt = sum over all collaterals of collat * price * lltv (down-rounded). An inactive collateral's term +/// vanishes (its collat == 0 by nonZeroCollateralsAreActivated). function maxDebtSum(Midnight.Market market, bytes32 id, address borrower) returns mathint { - return contribAt(market, id, borrower, 0) + contribAt(market, id, borrower, 1) + contribAt(market, id, borrower, 2); + mathint contrib0 = ghostMulDivDown(ghostMulDivDown(collateral(id, borrower, 0), summaryPrice(market.collateralParams[0].oracle), ORACLE_PRICE_SCALE()), market.collateralParams[0].lltv, WAD()); + mathint contrib1 = ghostMulDivDown(ghostMulDivDown(collateral(id, borrower, 1), summaryPrice(market.collateralParams[1].oracle), ORACLE_PRICE_SCALE()), market.collateralParams[1].lltv, WAD()); + mathint contrib2 = ghostMulDivDown(ghostMulDivDown(collateral(id, borrower, 2), summaryPrice(market.collateralParams[2].oracle), ORACLE_PRICE_SCALE()), market.collateralParams[2].lltv, WAD()); + return contrib0 + contrib1 + contrib2; } -/// debtAfter = debt - badDebt, where badDebt = zeroFloorSub chain = max(0, debt - sum of recoveries). +/// debtAfter = debt - badDebt, where badDebt = zeroFloorSub chain = max(0, debt - recovery0 - recovery1 - recovery2). function debtAfterBadDebt(Midnight.Market market, bytes32 id, address borrower) returns mathint { - mathint rec = recoveryAt(market, id, borrower, 0) + recoveryAt(market, id, borrower, 1) + recoveryAt(market, id, borrower, 2); + mathint recovery0 = ghostMulDivUp(ghostMulDivUp(collateral(id, borrower, 0), summaryPrice(market.collateralParams[0].oracle), ORACLE_PRICE_SCALE()), WAD(), market.collateralParams[0].maxLif); + mathint recovery1 = ghostMulDivUp(ghostMulDivUp(collateral(id, borrower, 1), summaryPrice(market.collateralParams[1].oracle), ORACLE_PRICE_SCALE()), WAD(), market.collateralParams[1].maxLif); + mathint recovery2 = ghostMulDivUp(ghostMulDivUp(collateral(id, borrower, 2), summaryPrice(market.collateralParams[2].oracle), ORACLE_PRICE_SCALE()), WAD(), market.collateralParams[2].maxLif); mathint _debt = debtOf(id, borrower); - mathint badDebt = _debt > rec ? _debt - rec : 0; + mathint badDebt = _debt > recovery0 + recovery1 + recovery2 ? _debt - recovery0 - recovery1 - recovery2 : 0; return _debt - badDebt; } -/// Assumes the three arithmetic facts the lltv < WAD maxRepaid computation relies on. Each is a deterministic -/// consequence of the mulDiv* ghost axioms and the validCollateralAt bounds (themselves enforced by touchMarket -/// at market creation), so requiring them is sound: they hold on every valid Midnight market state. +/// Scaffolding facts for the lltv < WAD maxRepaid computation: denominator positive (WAD*WAD - maxLif*lltv >= WAD +/// from validCollateralAt) and per-collateral recovery >= maxDebt contribution (so debtAfter >= maxDebt). function lowLltvScaffolding(Midnight.Market market, bytes32 id, address borrower) { uint256 lltv0 = market.collateralParams[0].lltv; uint256 maxLif0 = market.collateralParams[0].maxLif; + uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint128 collat0 = collateral(id, borrower, 0); - // inner = ceil(maxLif*lltv/WAD) <= WAD-1 since validCollateralAt gives lltv*maxLif <= WAD*(WAD-1) for lltv= 1 (L659 ok). - require to_mathint(ghostMulDivUp(maxLif0, lltv0, WAD())) <= to_mathint(WAD()) - 1, "WAD*(WAD-1) ExactMath bound (touchMarket) => inner <= WAD-1"; - - // recovery scales the quote by WAD/maxLif, contrib by lltv/WAD. lltv*maxLif <= WAD^2 (validCollateralAt) gives - // WAD/maxLif >= lltv/WAD, so recovery_i >= contrib_i, hence sum recoveries >= maxDebt => debtAfter >= maxDebt. - // Strict for collat 0 (non-dust quote >= 1, lltv*maxLif < WAD^2) => debtAfter > maxDebt => maxRepaid >= 1. - require recoveryAt(market, id, borrower, 0) > contribAt(market, id, borrower, 0), "non-dust collat0, WAD/maxLif > lltv/WAD (lltv*maxLif < WAD^2) => recovery0 strictly > contrib0"; - require recoveryAt(market, id, borrower, 1) >= contribAt(market, id, borrower, 1), "WAD/maxLif >= lltv/WAD (lltv*maxLif <= WAD^2) => recovery1 >= contrib1 (0 >= 0 if inactive)"; - require recoveryAt(market, id, borrower, 2) >= contribAt(market, id, borrower, 2), "WAD/maxLif >= lltv/WAD (lltv*maxLif <= WAD^2) => recovery2 >= contrib2 (0 >= 0 if inactive)"; -} + uint256 lltv1 = market.collateralParams[1].lltv; + uint256 maxLif1 = market.collateralParams[1].maxLif; + uint256 price1 = summaryPrice(market.collateralParams[1].oracle); + uint128 collat1 = collateral(id, borrower, 1); -/// INVARIANT /// + uint256 lltv2 = market.collateralParams[2].lltv; + uint256 maxLif2 = market.collateralParams[2].maxLif; + uint256 price2 = summaryPrice(market.collateralParams[2].oracle); + uint128 collat2 = collateral(id, borrower, 2); -// Proven in CollateralBitmap.spec; assumed here via requireInvariant (not re-proven in this spec). -strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint256 collateralIndex) - collateralIndex < 128 => (collateral(id, user, collateralIndex) != 0 <=> summaryGetBit(currentContract.position[id][user].collateralBitmap, collateralIndex)); + require to_mathint(maxLif0) * to_mathint(lltv0) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "WAD*(WAD-1) ExactMath bound (touchMarket) => WAD*WAD - maxLif*lltv >= WAD >= 1"; + require to_mathint(ghostMulDivUp(ghostMulDivUp(collat0, price0, ORACLE_PRICE_SCALE()), WAD(), maxLif0)) > to_mathint(ghostMulDivDown(ghostMulDivDown(collat0, price0, ORACLE_PRICE_SCALE()), lltv0, WAD())), "recovery0 > maxDebtContrib0 (lltv0 < WAD, collat0*price0 >= ORACLE_PRICE_SCALE)"; + require to_mathint(ghostMulDivUp(ghostMulDivUp(collat1, price1, ORACLE_PRICE_SCALE()), WAD(), maxLif1)) >= to_mathint(ghostMulDivDown(ghostMulDivDown(collat1, price1, ORACLE_PRICE_SCALE()), lltv1, WAD())), "recovery1 >= maxDebtContrib1 (any valid collateral, incl. inactive)"; + require to_mathint(ghostMulDivUp(ghostMulDivUp(collat2, price2, ORACLE_PRICE_SCALE()), WAD(), maxLif2)) >= to_mathint(ghostMulDivDown(ghostMulDivDown(collat2, price2, ORACLE_PRICE_SCALE()), lltv2, WAD())), "recovery2 >= maxDebtContrib2 (any valid collateral, incl. inactive)"; +} /// REPAY PATH (repaidUnits > 0, seizedAssets = 0) /// @@ -246,10 +247,10 @@ rule unhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market market, lowLltvScaffolding(market, id, borrower); - // maxRepaid as computed by the contract (L658-660): (debtAfter - maxDebt) ceil-div (WAD - lif*lltv/WAD), - // using the seized collateral 0's lltv/lif. debtAfter >= maxDebt from the scaffolding facts. - mathint inner = ghostMulDivUp(maxLif, lltv, WAD()); - mathint maxRepaid = ghostMulDivUp(assert_uint256(debtAfter - maxDebt), WAD(), assert_uint256(to_mathint(WAD()) - inner)); + // maxRepaid per contract #944 (L658-660): mulDivUp(debtAfter - maxDebt, WAD*WAD, WAD*WAD - lif*lltv), lif = + // maxLif here. Reconstructed bit-for-bit so the bound matches the RCF check exactly; denominator > 0 and + // debtAfter >= maxDebt from lowLltvScaffolding. + mathint maxRepaid = ghostMulDivUp(assert_uint256(debtAfter - maxDebt), assert_uint256(to_mathint(WAD()) * to_mathint(WAD())), assert_uint256(to_mathint(WAD()) * to_mathint(WAD()) - to_mathint(maxLif) * to_mathint(lltv))); require repaidUnits > 0; require to_mathint(repaidUnits) <= maxRepaid, "RCF check passes (L661)"; @@ -286,10 +287,10 @@ rule postMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market, add assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; } -/// SEIZE PATH (seizedAssets > 0, repaidUnits = 0) /// -/// The contract derives repaidUnits = mulDivUp(mulDivUp(seizedAssets, price0, SCALE), WAD, lif) (L650). Collateral -/// underflow guard is a direct `seizedAssets <= collateral[0]`; debt-underflow and RCF apply to derived repaidUnits. -/// seizedAssets > 0 and price0 > 0 force derived repaidUnits >= 1, so progress still holds. +/// SEIZE PATH (seizedAssets > 0, repaidUnits = 0): contract derives repaidUnits (L650) = +/// mulDivUp(mulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE), WAD, lif). Collateral guard is a direct +/// `seizedAssets <= collateral[0]`; debt-underflow / RCF apply to the derived repaidUnits, which is >= 1 +/// (seizedAssets > 0, price0 > 0) so progress holds. /// /// Seize path, unhealthy, lltv == WAD: maxRepaid = uint256.max (RCF auto-passes), lif = maxLif. rule seizeUnhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 seizedAssets) { @@ -317,7 +318,7 @@ rule seizeUnhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market m assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; } -/// Seize path, unhealthy, lltv < WAD: RCF caps the derived repaidUnits by maxRepaid; same scaffolding facts apply. +/// Seize path, unhealthy, lltv < WAD: RCF caps the derived repaidUnits by maxRepaid; same scaffolding applies. rule seizeUnhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 seizedAssets) { bytes32 id = summaryToId(market); seizablePreamble(e, market, id, borrower); @@ -335,8 +336,9 @@ rule seizeUnhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market ma lowLltvScaffolding(market, id, borrower); - mathint inner = ghostMulDivUp(maxLif, lltv, WAD()); - mathint maxRepaid = ghostMulDivUp(assert_uint256(debtAfter - maxDebt), WAD(), assert_uint256(to_mathint(WAD()) - inner)); + // maxRepaid per contract #944 (L658-660): mulDivUp(debtAfter - maxDebt, WAD*WAD, WAD*WAD - lif*lltv), + // lif = maxLif here. Reconstructed bit-for-bit so the bound matches the contract's RCF check exactly. + mathint maxRepaid = ghostMulDivUp(assert_uint256(debtAfter - maxDebt), assert_uint256(to_mathint(WAD()) * to_mathint(WAD())), assert_uint256(to_mathint(WAD()) * to_mathint(WAD()) - to_mathint(maxLif) * to_mathint(lltv))); mathint repaidUnits = ghostMulDivUp(ghostMulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE()), WAD(), maxLif); @@ -376,15 +378,13 @@ rule seizePostMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; } -/// BAD-DEBT WITNESS (repaidUnits = 0, seizedAssets = 0) /// - -/// Any liquidatable borrower can be liquidated with the no-transfer 0/0 call: it skips the seize/RCF/underflow -/// block, so it never reverts regardless of collateral. Covers the borrowers the seizing rules cannot (dust or -/// inactive collateral 0, where a seizing call divides by a zero price). No progress assert: a 0/0 call only -/// realizes bad debt, which may be zero. +/// BAD-DEBT WITNESS (repaidUnits = 0, seizedAssets = 0): any liquidatable borrower can be liquidated with the +/// no-transfer call, which skips the seize/RCF/underflow block entirely (never reverts, any collateral state). +/// Covers the borrowers the seizing rules cannot (dust / inactive collateral 0). No progress assert: a 0/0 call +/// only realizes bad debt, which may be zero. /// rule badDebtCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver, bool postMaturityMode) { bytes32 id = summaryToId(market); - multiCollatSetup(e, market, id, borrower); + threeCollatSetup(e, market, id, borrower); require postMaturityMode ? e.block.timestamp > market.maturity : !isHealthy(market, id, borrower), "borrower is liquidatable in the chosen mode"; From 41b36ab1d8a4aebb7178841c87cae8d8bbf6bbb3 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Wed, 3 Jun 2026 14:02:26 +0200 Subject: [PATCH 45/53] cleaning and 3 collaterals --- certora/confs/LiquidateLiveness.conf | 12 +- certora/specs/LiquidateLiveness.spec | 159 ++++++++++++++++----------- 2 files changed, 100 insertions(+), 71 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 46c3314b8..42e72580e 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -13,10 +13,12 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - "-destructiveOptimizations twostage", - "-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}]" - ], - "build_cache": false, + "-destructiveOptimizations twostage", + "-mediumTimeout 20", + "-lowTimeout 20", + "-tinyTimeout 20", + "-depth 20" + ], "exclude_rule": [ "nonZeroCollateralsAreActivated" ], @@ -29,5 +31,5 @@ "seizePostMaturityLiquidatableForAnySafeAmount", "badDebtCanBeLiquidated" ], - "msg": "Liquidate Liveness (3-collateral, parametric amount)" + "msg": "Liquidate Liveness" } diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 8fdccad86..963ec702a 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -7,13 +7,14 @@ using Utils as Utils; /** Property: "Debts can always be liquidated if unhealthy or expired" — full strength. -Range liveness + progress (3-collateral, parametric amount): for every liquidatable borrower and every amount -in the safe interval an off-chain liquidator computes from the position, `liquidate` (1) does NOT revert and -(2) strictly decreases debt. Proven for both modes (unhealthy lltv == WAD / lltv < WAD, post-maturity) and both -entry paths (repay with parametric repaidUnits, seize with parametric seizedAssets). - -Borrowers the seizing rules cannot reach (dust / inactive collateral 0, where a seizing call would divide by a -zero liquidatedCollatPrice) are covered by the no-transfer bad-debt witness `badDebtCanBeLiquidated` +Range liveness + progress (3-collateral, parametric amount and parametric seized collateral): for every +liquidatable borrower, every seized collateral index in {0,1,2}, and every amount in the safe interval an +off-chain liquidator computes from the position, `liquidate` (1) does NOT revert and (2) strictly decreases +debt. Proven for both modes (unhealthy lltv == WAD / lltv < WAD, post-maturity) and both entry paths (repay +with parametric repaidUnits, seize with parametric seizedAssets). + +Borrowers the seizing rules cannot reach (dust / inactive seized collateral, where a seizing call would divide +by a zero liquidatedCollatPrice) are covered by the no-transfer bad-debt witness `badDebtCanBeLiquidated` (repaidUnits = 0, seizedAssets = 0). Together they cover every liquidatable borrower in scope. The safe amount is reconstructed in CVL from the contract's own quantities. Because `mulDiv*` are summarized as @@ -151,12 +152,27 @@ function threeCollatSetup(env e, Midnight.Market market, bytes32 id, address bor requireInvariant nonZeroCollateralsAreActivated(id, borrower, 2); } -/// Extends threeCollatSetup for the seizing rules: collateral 0 (the seized one) is active and priced, so the -/// liquidatedCollatPrice is non-zero (the seize/RCF block divides by it). -function seizablePreamble(env e, Midnight.Market market, bytes32 id, address borrower) { +/// Seized-collateral field selectors. A constant-index case split over the 3-collateral market keeps the prover +/// on concrete struct-array accesses (no symbolic indexing); collateralIndex is pinned to {0,1,2} by seizablePreamble. +function seizedPrice(Midnight.Market market, uint256 collateralIndex) returns uint256 { + return collateralIndex == 0 ? summaryPrice(market.collateralParams[0].oracle) : collateralIndex == 1 ? summaryPrice(market.collateralParams[1].oracle) : summaryPrice(market.collateralParams[2].oracle); +} + +function seizedMaxLif(Midnight.Market market, uint256 collateralIndex) returns uint256 { + return collateralIndex == 0 ? market.collateralParams[0].maxLif : collateralIndex == 1 ? market.collateralParams[1].maxLif : market.collateralParams[2].maxLif; +} + +function seizedLltv(Midnight.Market market, uint256 collateralIndex) returns uint256 { + return collateralIndex == 0 ? market.collateralParams[0].lltv : collateralIndex == 1 ? market.collateralParams[1].lltv : market.collateralParams[2].lltv; +} + +/// Extends threeCollatSetup for the seizing rules: the seized collateral (parametric collateralIndex in {0,1,2}) +/// is active and priced, so the liquidatedCollatPrice is non-zero (the seize/RCF block divides by it). +function seizablePreamble(env e, Midnight.Market market, bytes32 id, address borrower, uint256 collateralIndex) { threeCollatSetup(e, market, id, borrower); - require summaryGetBit(collateralBitmap(id, borrower), 0), "collateral 0 (the seized collateral) is active"; - require summaryPrice(market.collateralParams[0].oracle) > 0, "collateral 0 is priced (LIVENESS)"; + require collateralIndex == 0 || collateralIndex == 1 || collateralIndex == 2, "seized index in {0,1,2} (<= loop_iter)"; + require summaryGetBit(collateralBitmap(id, borrower), collateralIndex), "the seized collateral is active"; + require seizedPrice(market, collateralIndex) > 0, "the seized collateral is priced (LIVENESS)"; } /// maxDebt = sum over all collaterals of collat * price * lltv (down-rounded). An inactive collateral's term @@ -179,8 +195,10 @@ function debtAfterBadDebt(Midnight.Market market, bytes32 id, address borrower) } /// Scaffolding facts for the lltv < WAD maxRepaid computation: denominator positive (WAD*WAD - maxLif*lltv >= WAD -/// from validCollateralAt) and per-collateral recovery >= maxDebt contribution (so debtAfter >= maxDebt). -function lowLltvScaffolding(Midnight.Market market, bytes32 id, address borrower) { +/// from validCollateralAt) and per-collateral recovery >= maxDebt contribution (so debtAfter >= maxDebt). The +/// seized collateral additionally gets a strict recovery > contribution, forcing debtAfter > maxDebt, hence +/// maxRepaid >= 1 (keeps the safe interval non-vacuous for any active seized collateral with quote >= 1 unit). +function lowLltvScaffolding(Midnight.Market market, bytes32 id, address borrower, uint256 collateralIndex) { uint256 lltv0 = market.collateralParams[0].lltv; uint256 maxLif0 = market.collateralParams[0].maxLif; uint256 price0 = summaryPrice(market.collateralParams[0].oracle); @@ -196,56 +214,65 @@ function lowLltvScaffolding(Midnight.Market market, bytes32 id, address borrower uint256 price2 = summaryPrice(market.collateralParams[2].oracle); uint128 collat2 = collateral(id, borrower, 2); - require to_mathint(maxLif0) * to_mathint(lltv0) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "WAD*(WAD-1) ExactMath bound (touchMarket) => WAD*WAD - maxLif*lltv >= WAD >= 1"; - require to_mathint(ghostMulDivUp(ghostMulDivUp(collat0, price0, ORACLE_PRICE_SCALE()), WAD(), maxLif0)) > to_mathint(ghostMulDivDown(ghostMulDivDown(collat0, price0, ORACLE_PRICE_SCALE()), lltv0, WAD())), "recovery0 > maxDebtContrib0 (lltv0 < WAD, collat0*price0 >= ORACLE_PRICE_SCALE)"; + // recovery_i >= maxDebtContrib_i for every collateral (sound theorem from validCollateralAt; ensures debtAfter >= maxDebt). + require to_mathint(ghostMulDivUp(ghostMulDivUp(collat0, price0, ORACLE_PRICE_SCALE()), WAD(), maxLif0)) >= to_mathint(ghostMulDivDown(ghostMulDivDown(collat0, price0, ORACLE_PRICE_SCALE()), lltv0, WAD())), "recovery0 >= maxDebtContrib0 (any valid collateral, incl. inactive)"; require to_mathint(ghostMulDivUp(ghostMulDivUp(collat1, price1, ORACLE_PRICE_SCALE()), WAD(), maxLif1)) >= to_mathint(ghostMulDivDown(ghostMulDivDown(collat1, price1, ORACLE_PRICE_SCALE()), lltv1, WAD())), "recovery1 >= maxDebtContrib1 (any valid collateral, incl. inactive)"; require to_mathint(ghostMulDivUp(ghostMulDivUp(collat2, price2, ORACLE_PRICE_SCALE()), WAD(), maxLif2)) >= to_mathint(ghostMulDivDown(ghostMulDivDown(collat2, price2, ORACLE_PRICE_SCALE()), lltv2, WAD())), "recovery2 >= maxDebtContrib2 (any valid collateral, incl. inactive)"; + + // Seized collateral: ExactMath denominator bound (=> WAD*WAD - maxLif*lltv >= WAD >= 1) and strict + // recovery > maxDebtContrib (=> debtAfter > maxDebt, so maxRepaid >= 1; needs collatJ*priceJ >= ORACLE_PRICE_SCALE). + uint256 lltvJ = seizedLltv(market, collateralIndex); + uint256 maxLifJ = seizedMaxLif(market, collateralIndex); + uint256 priceJ = seizedPrice(market, collateralIndex); + uint128 collatJ = collateral(id, borrower, collateralIndex); + require to_mathint(maxLifJ) * to_mathint(lltvJ) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "WAD*(WAD-1) ExactMath bound (touchMarket) => WAD*WAD - maxLif*lltv >= WAD >= 1"; + require to_mathint(ghostMulDivUp(ghostMulDivUp(collatJ, priceJ, ORACLE_PRICE_SCALE()), WAD(), maxLifJ)) > to_mathint(ghostMulDivDown(ghostMulDivDown(collatJ, priceJ, ORACLE_PRICE_SCALE()), lltvJ, WAD())), "recoveryJ > maxDebtContribJ (seized lltv < WAD, collatJ*priceJ >= ORACLE_PRICE_SCALE)"; } /// REPAY PATH (repaidUnits > 0, seizedAssets = 0) /// /// Unhealthy, lltv == WAD: maxRepaid = type(uint256).max, so the RCF check passes unconditionally and the /// `_position.debt - maxDebt` subtraction is never executed. Only the debt and collateral underflow guards bind. -rule unhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 repaidUnits) { +rule unhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 collateralIndex, uint256 repaidUnits) { bytes32 id = summaryToId(market); - seizablePreamble(e, market, id, borrower); + seizablePreamble(e, market, id, borrower, collateralIndex); - uint256 maxLif = market.collateralParams[0].maxLif; - require market.collateralParams[0].lltv == WAD(), "lltv == WAD partition (maxRepaid = uint256.max)"; + uint256 maxLif = seizedMaxLif(market, collateralIndex); + require seizedLltv(market, collateralIndex) == WAD(), "lltv == WAD partition (maxRepaid = uint256.max)"; - uint128 collat0 = collateral(id, borrower, 0); - uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint128 collatJ = collateral(id, borrower, collateralIndex); + uint256 priceJ = seizedPrice(market, collateralIndex); uint256 _debt = debtOf(id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); require to_mathint(_debt) > maxDebtSum(market, id, borrower), "unhealthy: debt > maxDebt"; require repaidUnits > 0; require to_mathint(repaidUnits) <= debtAfter, "no debt underflow (L675)"; - require to_mathint(ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), price0)) <= to_mathint(collat0), "seize fits collateral[0] (L669)"; + require to_mathint(ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ)) <= to_mathint(collatJ), "seize fits the seized collateral (L669)"; bytes data; - liquidate@withrevert(e, market, 0, 0, repaidUnits, borrower, false, receiver, 0, data); + liquidate@withrevert(e, market, collateralIndex, 0, repaidUnits, borrower, false, receiver, 0, data); assert !lastReverted, "the call is live (succeeds)"; assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; } /// Unhealthy, lltv < WAD: maxRepaid is finite, so the safe interval is additionally capped by repaidUnits <= maxRepaid. -rule unhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 repaidUnits) { +rule unhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 collateralIndex, uint256 repaidUnits) { bytes32 id = summaryToId(market); - seizablePreamble(e, market, id, borrower); + seizablePreamble(e, market, id, borrower, collateralIndex); - uint256 lltv = market.collateralParams[0].lltv; - uint256 maxLif = market.collateralParams[0].maxLif; + uint256 lltv = seizedLltv(market, collateralIndex); + uint256 maxLif = seizedMaxLif(market, collateralIndex); require lltv < WAD(), "lltv < WAD partition"; - uint128 collat0 = collateral(id, borrower, 0); - uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint128 collatJ = collateral(id, borrower, collateralIndex); + uint256 priceJ = seizedPrice(market, collateralIndex); uint256 _debt = debtOf(id, borrower); mathint maxDebt = maxDebtSum(market, id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); require to_mathint(_debt) > maxDebt, "unhealthy: debt > maxDebt"; - lowLltvScaffolding(market, id, borrower); + lowLltvScaffolding(market, id, borrower, collateralIndex); // maxRepaid per contract #944 (L658-660): mulDivUp(debtAfter - maxDebt, WAD*WAD, WAD*WAD - lif*lltv), lif = // maxLif here. Reconstructed bit-for-bit so the bound matches the RCF check exactly; denominator > 0 and @@ -255,34 +282,34 @@ rule unhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market market, require repaidUnits > 0; require to_mathint(repaidUnits) <= maxRepaid, "RCF check passes (L661)"; require to_mathint(repaidUnits) <= debtAfter, "no debt underflow (L675)"; - require to_mathint(ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), price0)) <= to_mathint(collat0), "seize fits collateral[0] (L669)"; + require to_mathint(ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ)) <= to_mathint(collatJ), "seize fits the seized collateral (L669)"; bytes data; - liquidate@withrevert(e, market, 0, 0, repaidUnits, borrower, false, receiver, 0, data); + liquidate@withrevert(e, market, collateralIndex, 0, repaidUnits, borrower, false, receiver, 0, data); assert !lastReverted, "the call is live (succeeds)"; assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; } /// Post-maturity: liquidatable by expiry alone (no health check), RCF / `debt - maxDebt` block skipped. lif <= /// maxLif post-maturity, so bounding the seize at maxLif (via ghostMulDivDown monotonicity) upper-bounds it. -rule postMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 repaidUnits) { +rule postMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 collateralIndex, uint256 repaidUnits) { bytes32 id = summaryToId(market); - seizablePreamble(e, market, id, borrower); + seizablePreamble(e, market, id, borrower, collateralIndex); require e.block.timestamp > market.maturity, "post-maturity partition (liquidatable by expiry)"; - uint256 maxLif = market.collateralParams[0].maxLif; - uint128 collat0 = collateral(id, borrower, 0); - uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint256 maxLif = seizedMaxLif(market, collateralIndex); + uint128 collatJ = collateral(id, borrower, collateralIndex); + uint256 priceJ = seizedPrice(market, collateralIndex); uint256 _debt = debtOf(id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); require repaidUnits > 0; require to_mathint(repaidUnits) <= debtAfter, "no debt underflow (L675)"; - require to_mathint(ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), price0)) <= to_mathint(collat0), "seize (at lif <= maxLif) fits collateral[0] (L669)"; + require to_mathint(ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ)) <= to_mathint(collatJ), "seize (at lif <= maxLif) fits the seized collateral (L669)"; bytes data; - liquidate@withrevert(e, market, 0, 0, repaidUnits, borrower, true, receiver, 0, data); + liquidate@withrevert(e, market, collateralIndex, 0, repaidUnits, borrower, true, receiver, 0, data); assert !lastReverted, "the call is live (succeeds)"; assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; } @@ -293,87 +320,87 @@ rule postMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market, add /// (seizedAssets > 0, price0 > 0) so progress holds. /// /// Seize path, unhealthy, lltv == WAD: maxRepaid = uint256.max (RCF auto-passes), lif = maxLif. -rule seizeUnhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 seizedAssets) { +rule seizeUnhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 collateralIndex, uint256 seizedAssets) { bytes32 id = summaryToId(market); - seizablePreamble(e, market, id, borrower); + seizablePreamble(e, market, id, borrower, collateralIndex); - uint256 maxLif = market.collateralParams[0].maxLif; - require market.collateralParams[0].lltv == WAD(), "lltv == WAD partition (maxRepaid = uint256.max)"; + uint256 maxLif = seizedMaxLif(market, collateralIndex); + require seizedLltv(market, collateralIndex) == WAD(), "lltv == WAD partition (maxRepaid = uint256.max)"; - uint128 collat0 = collateral(id, borrower, 0); - uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint128 collatJ = collateral(id, borrower, collateralIndex); + uint256 priceJ = seizedPrice(market, collateralIndex); uint256 _debt = debtOf(id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); require to_mathint(_debt) > maxDebtSum(market, id, borrower), "unhealthy: debt > maxDebt"; - mathint repaidUnits = ghostMulDivUp(ghostMulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE()), WAD(), maxLif); + mathint repaidUnits = ghostMulDivUp(ghostMulDivUp(seizedAssets, priceJ, ORACLE_PRICE_SCALE()), WAD(), maxLif); require seizedAssets > 0; - require to_mathint(seizedAssets) <= to_mathint(collat0), "seize fits collateral[0] (L669)"; + require to_mathint(seizedAssets) <= to_mathint(collatJ), "seize fits the seized collateral (L669)"; require repaidUnits <= debtAfter, "derived repaidUnits: no debt underflow (L675)"; bytes data; - liquidate@withrevert(e, market, 0, seizedAssets, 0, borrower, false, receiver, 0, data); + liquidate@withrevert(e, market, collateralIndex, seizedAssets, 0, borrower, false, receiver, 0, data); assert !lastReverted, "the call is live (succeeds)"; assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; } /// Seize path, unhealthy, lltv < WAD: RCF caps the derived repaidUnits by maxRepaid; same scaffolding applies. -rule seizeUnhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 seizedAssets) { +rule seizeUnhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 collateralIndex, uint256 seizedAssets) { bytes32 id = summaryToId(market); - seizablePreamble(e, market, id, borrower); + seizablePreamble(e, market, id, borrower, collateralIndex); - uint256 lltv = market.collateralParams[0].lltv; - uint256 maxLif = market.collateralParams[0].maxLif; + uint256 lltv = seizedLltv(market, collateralIndex); + uint256 maxLif = seizedMaxLif(market, collateralIndex); require lltv < WAD(), "lltv < WAD partition"; - uint128 collat0 = collateral(id, borrower, 0); - uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint128 collatJ = collateral(id, borrower, collateralIndex); + uint256 priceJ = seizedPrice(market, collateralIndex); uint256 _debt = debtOf(id, borrower); mathint maxDebt = maxDebtSum(market, id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); require to_mathint(_debt) > maxDebt, "unhealthy: debt > maxDebt"; - lowLltvScaffolding(market, id, borrower); + lowLltvScaffolding(market, id, borrower, collateralIndex); // maxRepaid per contract #944 (L658-660): mulDivUp(debtAfter - maxDebt, WAD*WAD, WAD*WAD - lif*lltv), // lif = maxLif here. Reconstructed bit-for-bit so the bound matches the contract's RCF check exactly. mathint maxRepaid = ghostMulDivUp(assert_uint256(debtAfter - maxDebt), assert_uint256(to_mathint(WAD()) * to_mathint(WAD())), assert_uint256(to_mathint(WAD()) * to_mathint(WAD()) - to_mathint(maxLif) * to_mathint(lltv))); - mathint repaidUnits = ghostMulDivUp(ghostMulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE()), WAD(), maxLif); + mathint repaidUnits = ghostMulDivUp(ghostMulDivUp(seizedAssets, priceJ, ORACLE_PRICE_SCALE()), WAD(), maxLif); require seizedAssets > 0; - require to_mathint(seizedAssets) <= to_mathint(collat0), "seize fits collateral[0] (L669)"; + require to_mathint(seizedAssets) <= to_mathint(collatJ), "seize fits the seized collateral (L669)"; require repaidUnits <= maxRepaid, "derived repaidUnits: RCF check passes (L661)"; require repaidUnits <= debtAfter, "derived repaidUnits: no debt underflow (L675)"; bytes data; - liquidate@withrevert(e, market, 0, seizedAssets, 0, borrower, false, receiver, 0, data); + liquidate@withrevert(e, market, collateralIndex, seizedAssets, 0, borrower, false, receiver, 0, data); assert !lastReverted, "the call is live (succeeds)"; assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; } /// Seize path, post-maturity: no RCF. lif >= WAD, and the derived repaidUnits = mulDivUp(quote, WAD, lif) is /// largest at lif = WAD (mulDivUp is antitone in its denominator), so it is upper-bounded by `quote` itself. -rule seizePostMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 seizedAssets) { +rule seizePostMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 collateralIndex, uint256 seizedAssets) { bytes32 id = summaryToId(market); - seizablePreamble(e, market, id, borrower); + seizablePreamble(e, market, id, borrower, collateralIndex); require e.block.timestamp > market.maturity, "post-maturity partition (liquidatable by expiry)"; - uint128 collat0 = collateral(id, borrower, 0); - uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint128 collatJ = collateral(id, borrower, collateralIndex); + uint256 priceJ = seizedPrice(market, collateralIndex); uint256 _debt = debtOf(id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); - mathint quoteUp = ghostMulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE()); + mathint quoteUp = ghostMulDivUp(seizedAssets, priceJ, ORACLE_PRICE_SCALE()); require seizedAssets > 0; - require to_mathint(seizedAssets) <= to_mathint(collat0), "seize fits collateral[0] (L669)"; + require to_mathint(seizedAssets) <= to_mathint(collatJ), "seize fits the seized collateral (L669)"; require quoteUp <= debtAfter, "derived repaidUnits (<= quote since lif >= WAD): no debt underflow (L675)"; bytes data; - liquidate@withrevert(e, market, 0, seizedAssets, 0, borrower, true, receiver, 0, data); + liquidate@withrevert(e, market, collateralIndex, seizedAssets, 0, borrower, true, receiver, 0, data); assert !lastReverted, "the call is live (succeeds)"; assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; } From 747067bfff6179ae665bde593af0436f321f8b14 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Wed, 3 Jun 2026 14:05:42 +0200 Subject: [PATCH 46/53] linter conf --- certora/confs/LiquidateLiveness.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 42e72580e..84f835f3b 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -13,12 +13,12 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - "-destructiveOptimizations twostage", + "-destructiveOptimizations twostage", "-mediumTimeout 20", "-lowTimeout 20", "-tinyTimeout 20", "-depth 20" - ], + ], "exclude_rule": [ "nonZeroCollateralsAreActivated" ], From 11161a602f9548a0c30afba7b2bfa6c5fa8e29a4 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Wed, 3 Jun 2026 18:58:21 +0200 Subject: [PATCH 47/53] cleaning --- certora/specs/LiquidateLiveness.spec | 88 ++++++++++++---------------- 1 file changed, 39 insertions(+), 49 deletions(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 963ec702a..705df0b85 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -5,7 +5,7 @@ import "BitmapSummaries.spec"; using Utils as Utils; /** -Property: "Debts can always be liquidated if unhealthy or expired" — full strength. +Property: "Debts can always be liquidated if unhealthy or expired". Range liveness + progress (3-collateral, parametric amount and parametric seized collateral): for every liquidatable borrower, every seized collateral index in {0,1,2}, and every amount in the safe interval an @@ -54,10 +54,6 @@ definition WAD() returns uint256 = 10 ^ 18; definition ORACLE_PRICE_SCALE() returns uint256 = 10 ^ 36; -definition MAX_UINT128() returns mathint = (1 << 128) - 1; - -definition MAX_TIMESTAMP() returns mathint = 1 << 64; - function summaryToId(Midnight.Market market) returns bytes32 { return Utils.hashMarket(market); } @@ -88,8 +84,7 @@ persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(0, a, d) == 0 && ghostMulDivUp(a, 0, d) == 0; axiom forall uint256 a1. forall uint256 a2. forall uint256 b. forall uint256 d. d > 0 && a1 <= a2 => ghostMulDivUp(a1, b, d) <= ghostMulDivUp(a2, b, d); - /// Antitone in the denominator. Sound (ceil(a*b/d) is nonincreasing in d). Used by the post-maturity seize - /// path to upper-bound the derived repaidUnits (lif in the denominator, lif >= WAD) by its value at lif = WAD. + /// Sound (ceil(a*b/d) is nonincreasing in d). Used by the post-maturity seize path to upper-bound the derived repaidUnits (lif in the denominator, lif >= WAD) by its value at lif = WAD. axiom forall uint256 a. forall uint256 b. forall uint256 d1. forall uint256 d2. d1 > 0 && d1 <= d2 => ghostMulDivUp(a, b, d1) >= ghostMulDivUp(a, b, d2); } @@ -117,11 +112,11 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, require lltv > 0 && lltv <= WAD(), "valid lltv (touchMarket)"; require maxLif >= WAD(), "valid maxLif (touchMarket)"; - require lltv < WAD() => to_mathint(lltv) * to_mathint(maxLif) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "ExactMath: lltv*maxLif <= WAD*(WAD-1) when lltv lltv * maxLif <= WAD() * (WAD() - 1), "ExactMath: lltv*maxLif <= WAD*(WAD-1) when lltv= _debt, "totalUnits = sumDebt + withdrawable >= this borrower's debt (Midnight.spec)"; - require to_mathint(withdrawable(id)) + to_mathint(_debt) <= MAX_UINT128(), "withdrawable + debt <= sumDebt + withdrawable = totalUnits <= uint128 max (Midnight.spec)"; + require withdrawable(id) + _debt <= max_uint128, "withdrawable + debt <= sumDebt + withdrawable = totalUnits <= uint128 max (Midnight.spec)"; require !liquidationLocked(id, borrower), "transient lock is zero at tx start"; require _debt > 0, "borrower has debt"; @@ -215,9 +210,9 @@ function lowLltvScaffolding(Midnight.Market market, bytes32 id, address borrower uint128 collat2 = collateral(id, borrower, 2); // recovery_i >= maxDebtContrib_i for every collateral (sound theorem from validCollateralAt; ensures debtAfter >= maxDebt). - require to_mathint(ghostMulDivUp(ghostMulDivUp(collat0, price0, ORACLE_PRICE_SCALE()), WAD(), maxLif0)) >= to_mathint(ghostMulDivDown(ghostMulDivDown(collat0, price0, ORACLE_PRICE_SCALE()), lltv0, WAD())), "recovery0 >= maxDebtContrib0 (any valid collateral, incl. inactive)"; - require to_mathint(ghostMulDivUp(ghostMulDivUp(collat1, price1, ORACLE_PRICE_SCALE()), WAD(), maxLif1)) >= to_mathint(ghostMulDivDown(ghostMulDivDown(collat1, price1, ORACLE_PRICE_SCALE()), lltv1, WAD())), "recovery1 >= maxDebtContrib1 (any valid collateral, incl. inactive)"; - require to_mathint(ghostMulDivUp(ghostMulDivUp(collat2, price2, ORACLE_PRICE_SCALE()), WAD(), maxLif2)) >= to_mathint(ghostMulDivDown(ghostMulDivDown(collat2, price2, ORACLE_PRICE_SCALE()), lltv2, WAD())), "recovery2 >= maxDebtContrib2 (any valid collateral, incl. inactive)"; + require ghostMulDivUp(ghostMulDivUp(collat0, price0, ORACLE_PRICE_SCALE()), WAD(), maxLif0) >= ghostMulDivDown(ghostMulDivDown(collat0, price0, ORACLE_PRICE_SCALE()), lltv0, WAD()), "recovery0 >= maxDebtContrib0 (any valid collateral, incl. inactive)"; + require ghostMulDivUp(ghostMulDivUp(collat1, price1, ORACLE_PRICE_SCALE()), WAD(), maxLif1) >= ghostMulDivDown(ghostMulDivDown(collat1, price1, ORACLE_PRICE_SCALE()), lltv1, WAD()), "recovery1 >= maxDebtContrib1 (any valid collateral, incl. inactive)"; + require ghostMulDivUp(ghostMulDivUp(collat2, price2, ORACLE_PRICE_SCALE()), WAD(), maxLif2) >= ghostMulDivDown(ghostMulDivDown(collat2, price2, ORACLE_PRICE_SCALE()), lltv2, WAD()), "recovery2 >= maxDebtContrib2 (any valid collateral, incl. inactive)"; // Seized collateral: ExactMath denominator bound (=> WAD*WAD - maxLif*lltv >= WAD >= 1) and strict // recovery > maxDebtContrib (=> debtAfter > maxDebt, so maxRepaid >= 1; needs collatJ*priceJ >= ORACLE_PRICE_SCALE). @@ -225,8 +220,8 @@ function lowLltvScaffolding(Midnight.Market market, bytes32 id, address borrower uint256 maxLifJ = seizedMaxLif(market, collateralIndex); uint256 priceJ = seizedPrice(market, collateralIndex); uint128 collatJ = collateral(id, borrower, collateralIndex); - require to_mathint(maxLifJ) * to_mathint(lltvJ) <= to_mathint(WAD()) * (to_mathint(WAD()) - 1), "WAD*(WAD-1) ExactMath bound (touchMarket) => WAD*WAD - maxLif*lltv >= WAD >= 1"; - require to_mathint(ghostMulDivUp(ghostMulDivUp(collatJ, priceJ, ORACLE_PRICE_SCALE()), WAD(), maxLifJ)) > to_mathint(ghostMulDivDown(ghostMulDivDown(collatJ, priceJ, ORACLE_PRICE_SCALE()), lltvJ, WAD())), "recoveryJ > maxDebtContribJ (seized lltv < WAD, collatJ*priceJ >= ORACLE_PRICE_SCALE)"; + require maxLifJ * lltvJ <= WAD() * (WAD() - 1), "WAD*(WAD-1) ExactMath bound (touchMarket) => WAD*WAD - maxLif*lltv >= WAD >= 1"; + require ghostMulDivUp(ghostMulDivUp(collatJ, priceJ, ORACLE_PRICE_SCALE()), WAD(), maxLifJ) > ghostMulDivDown(ghostMulDivDown(collatJ, priceJ, ORACLE_PRICE_SCALE()), lltvJ, WAD()), "recoveryJ > maxDebtContribJ (seized lltv < WAD, collatJ*priceJ >= ORACLE_PRICE_SCALE)"; } /// REPAY PATH (repaidUnits > 0, seizedAssets = 0) /// @@ -244,16 +239,16 @@ rule unhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market market uint256 priceJ = seizedPrice(market, collateralIndex); uint256 _debt = debtOf(id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); - require to_mathint(_debt) > maxDebtSum(market, id, borrower), "unhealthy: debt > maxDebt"; + require _debt > maxDebtSum(market, id, borrower), "unhealthy: debt > maxDebt"; require repaidUnits > 0; - require to_mathint(repaidUnits) <= debtAfter, "no debt underflow (L675)"; - require to_mathint(ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ)) <= to_mathint(collatJ), "seize fits the seized collateral (L669)"; + require repaidUnits <= debtAfter, "no debt underflow (L675)"; + require ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ) <= collatJ, "seize fits the seized collateral (L669)"; bytes data; liquidate@withrevert(e, market, collateralIndex, 0, repaidUnits, borrower, false, receiver, 0, data); assert !lastReverted, "the call is live (succeeds)"; - assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; + assert debtOf(id, borrower) < _debt, "and it makes progress: debt strictly decreases"; } /// Unhealthy, lltv < WAD: maxRepaid is finite, so the safe interval is additionally capped by repaidUnits <= maxRepaid. @@ -270,24 +265,23 @@ rule unhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market market, uint256 _debt = debtOf(id, borrower); mathint maxDebt = maxDebtSum(market, id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); - require to_mathint(_debt) > maxDebt, "unhealthy: debt > maxDebt"; + require _debt > maxDebt, "unhealthy: debt > maxDebt"; lowLltvScaffolding(market, id, borrower, collateralIndex); - // maxRepaid per contract #944 (L658-660): mulDivUp(debtAfter - maxDebt, WAD*WAD, WAD*WAD - lif*lltv), lif = - // maxLif here. Reconstructed bit-for-bit so the bound matches the RCF check exactly; denominator > 0 and - // debtAfter >= maxDebt from lowLltvScaffolding. - mathint maxRepaid = ghostMulDivUp(assert_uint256(debtAfter - maxDebt), assert_uint256(to_mathint(WAD()) * to_mathint(WAD())), assert_uint256(to_mathint(WAD()) * to_mathint(WAD()) - to_mathint(maxLif) * to_mathint(lltv))); + // maxRepaid per contract #944 (L658-660): mulDivUp(debtAfter - maxDebt, WAD*WAD, WAD*WAD - lif*lltv), lif = maxLif here. + // Reconstructed bit-for-bit so the bound matches the RCF check exactly; denominator > 0 and debtAfter >= maxDebt from lowLltvScaffolding. + mathint maxRepaid = ghostMulDivUp(assert_uint256(debtAfter - maxDebt), assert_uint256(WAD() * WAD()), assert_uint256(WAD() * WAD() - maxLif * lltv)); require repaidUnits > 0; - require to_mathint(repaidUnits) <= maxRepaid, "RCF check passes (L661)"; - require to_mathint(repaidUnits) <= debtAfter, "no debt underflow (L675)"; - require to_mathint(ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ)) <= to_mathint(collatJ), "seize fits the seized collateral (L669)"; + require repaidUnits <= maxRepaid, "RCF check passes (L661)"; + require repaidUnits <= debtAfter, "no debt underflow (L675)"; + require ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ) <= collatJ, "seize fits the seized collateral (L669)"; bytes data; liquidate@withrevert(e, market, collateralIndex, 0, repaidUnits, borrower, false, receiver, 0, data); assert !lastReverted, "the call is live (succeeds)"; - assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; + assert debtOf(id, borrower) < _debt, "and it makes progress: debt strictly decreases"; } /// Post-maturity: liquidatable by expiry alone (no health check), RCF / `debt - maxDebt` block skipped. lif <= @@ -305,19 +299,17 @@ rule postMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market, add mathint debtAfter = debtAfterBadDebt(market, id, borrower); require repaidUnits > 0; - require to_mathint(repaidUnits) <= debtAfter, "no debt underflow (L675)"; - require to_mathint(ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ)) <= to_mathint(collatJ), "seize (at lif <= maxLif) fits the seized collateral (L669)"; + require repaidUnits <= debtAfter, "no debt underflow (L675)"; + require ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ) <= collatJ, "seize (at lif <= maxLif) fits the seized collateral (L669)"; bytes data; liquidate@withrevert(e, market, collateralIndex, 0, repaidUnits, borrower, true, receiver, 0, data); assert !lastReverted, "the call is live (succeeds)"; - assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; + assert debtOf(id, borrower) < _debt, "and it makes progress: debt strictly decreases"; } -/// SEIZE PATH (seizedAssets > 0, repaidUnits = 0): contract derives repaidUnits (L650) = -/// mulDivUp(mulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE), WAD, lif). Collateral guard is a direct -/// `seizedAssets <= collateral[0]`; debt-underflow / RCF apply to the derived repaidUnits, which is >= 1 -/// (seizedAssets > 0, price0 > 0) so progress holds. /// +/// SEIZE PATH (seizedAssets > 0, repaidUnits = 0): contract derives repaidUnits (L650) = mulDivUp(mulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE), WAD, lif). +/// Collateral guard is a direct `seizedAssets <= collateral[0]`; debt-underflow / RCF apply to the derived repaidUnits, which is >= 1 (seizedAssets > 0, price0 > 0) so progress holds. /// Seize path, unhealthy, lltv == WAD: maxRepaid = uint256.max (RCF auto-passes), lif = maxLif. rule seizeUnhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market market, address borrower, address receiver, uint256 collateralIndex, uint256 seizedAssets) { @@ -331,18 +323,18 @@ rule seizeUnhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market m uint256 priceJ = seizedPrice(market, collateralIndex); uint256 _debt = debtOf(id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); - require to_mathint(_debt) > maxDebtSum(market, id, borrower), "unhealthy: debt > maxDebt"; + require _debt > maxDebtSum(market, id, borrower), "unhealthy: debt > maxDebt"; mathint repaidUnits = ghostMulDivUp(ghostMulDivUp(seizedAssets, priceJ, ORACLE_PRICE_SCALE()), WAD(), maxLif); require seizedAssets > 0; - require to_mathint(seizedAssets) <= to_mathint(collatJ), "seize fits the seized collateral (L669)"; + require seizedAssets <= collatJ, "seize fits the seized collateral (L669)"; require repaidUnits <= debtAfter, "derived repaidUnits: no debt underflow (L675)"; bytes data; liquidate@withrevert(e, market, collateralIndex, seizedAssets, 0, borrower, false, receiver, 0, data); assert !lastReverted, "the call is live (succeeds)"; - assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; + assert debtOf(id, borrower) < _debt, "and it makes progress: debt strictly decreases"; } /// Seize path, unhealthy, lltv < WAD: RCF caps the derived repaidUnits by maxRepaid; same scaffolding applies. @@ -359,25 +351,25 @@ rule seizeUnhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market ma uint256 _debt = debtOf(id, borrower); mathint maxDebt = maxDebtSum(market, id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); - require to_mathint(_debt) > maxDebt, "unhealthy: debt > maxDebt"; + require _debt > maxDebt, "unhealthy: debt > maxDebt"; lowLltvScaffolding(market, id, borrower, collateralIndex); // maxRepaid per contract #944 (L658-660): mulDivUp(debtAfter - maxDebt, WAD*WAD, WAD*WAD - lif*lltv), // lif = maxLif here. Reconstructed bit-for-bit so the bound matches the contract's RCF check exactly. - mathint maxRepaid = ghostMulDivUp(assert_uint256(debtAfter - maxDebt), assert_uint256(to_mathint(WAD()) * to_mathint(WAD())), assert_uint256(to_mathint(WAD()) * to_mathint(WAD()) - to_mathint(maxLif) * to_mathint(lltv))); + mathint maxRepaid = ghostMulDivUp(assert_uint256(debtAfter - maxDebt), assert_uint256(WAD() * WAD()), assert_uint256(WAD() * WAD() - maxLif * lltv)); mathint repaidUnits = ghostMulDivUp(ghostMulDivUp(seizedAssets, priceJ, ORACLE_PRICE_SCALE()), WAD(), maxLif); require seizedAssets > 0; - require to_mathint(seizedAssets) <= to_mathint(collatJ), "seize fits the seized collateral (L669)"; + require seizedAssets <= collatJ, "seize fits the seized collateral (L669)"; require repaidUnits <= maxRepaid, "derived repaidUnits: RCF check passes (L661)"; require repaidUnits <= debtAfter, "derived repaidUnits: no debt underflow (L675)"; bytes data; liquidate@withrevert(e, market, collateralIndex, seizedAssets, 0, borrower, false, receiver, 0, data); assert !lastReverted, "the call is live (succeeds)"; - assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; + assert debtOf(id, borrower) < _debt, "and it makes progress: debt strictly decreases"; } /// Seize path, post-maturity: no RCF. lif >= WAD, and the derived repaidUnits = mulDivUp(quote, WAD, lif) is @@ -396,19 +388,17 @@ rule seizePostMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market mathint quoteUp = ghostMulDivUp(seizedAssets, priceJ, ORACLE_PRICE_SCALE()); require seizedAssets > 0; - require to_mathint(seizedAssets) <= to_mathint(collatJ), "seize fits the seized collateral (L669)"; + require seizedAssets <= collatJ, "seize fits the seized collateral (L669)"; require quoteUp <= debtAfter, "derived repaidUnits (<= quote since lif >= WAD): no debt underflow (L675)"; bytes data; liquidate@withrevert(e, market, collateralIndex, seizedAssets, 0, borrower, true, receiver, 0, data); assert !lastReverted, "the call is live (succeeds)"; - assert to_mathint(debtOf(id, borrower)) < to_mathint(_debt), "and it makes progress: debt strictly decreases"; + assert debtOf(id, borrower) < _debt, "and it makes progress: debt strictly decreases"; } /// BAD-DEBT WITNESS (repaidUnits = 0, seizedAssets = 0): any liquidatable borrower can be liquidated with the /// no-transfer call, which skips the seize/RCF/underflow block entirely (never reverts, any collateral state). -/// Covers the borrowers the seizing rules cannot (dust / inactive collateral 0). No progress assert: a 0/0 call -/// only realizes bad debt, which may be zero. /// rule badDebtCanBeLiquidated(env e, Midnight.Market market, address borrower, address receiver, bool postMaturityMode) { bytes32 id = summaryToId(market); threeCollatSetup(e, market, id, borrower); From 0149a7a48ba6b8d56ab0787b94a033b4be8bcb28 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Thu, 4 Jun 2026 18:14:15 +0200 Subject: [PATCH 48/53] clean and comments --- certora/confs/LiquidateLiveness.conf | 1 - certora/specs/LiquidateLiveness.spec | 149 ++++++++++++++------------- 2 files changed, 80 insertions(+), 70 deletions(-) diff --git a/certora/confs/LiquidateLiveness.conf b/certora/confs/LiquidateLiveness.conf index 84f835f3b..e572192a0 100644 --- a/certora/confs/LiquidateLiveness.conf +++ b/certora/confs/LiquidateLiveness.conf @@ -13,7 +13,6 @@ "hashing_length_bound": 2048, "smt_timeout": 7200, "prover_args": [ - "-destructiveOptimizations twostage", "-mediumTimeout 20", "-lowTimeout 20", "-tinyTimeout 20", diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 705df0b85..362ea78dc 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -7,20 +7,15 @@ using Utils as Utils; /** Property: "Debts can always be liquidated if unhealthy or expired". -Range liveness + progress (3-collateral, parametric amount and parametric seized collateral): for every -liquidatable borrower, every seized collateral index in {0,1,2}, and every amount in the safe interval an -off-chain liquidator computes from the position, `liquidate` (1) does NOT revert and (2) strictly decreases -debt. Proven for both modes (unhealthy lltv == WAD / lltv < WAD, post-maturity) and both entry paths (repay -with parametric repaidUnits, seize with parametric seizedAssets). +For every liquidatable borrower, every seized-collateral index in {0,1,2}, and every amount in the safe +interval an off-chain liquidator computes from the position, `liquidate` (1) does NOT revert and (2) strictly +decreases debt. Covered for both modes (unhealthy lltv == WAD / lltv < WAD, post-maturity) and both entry +paths (repay with parametric repaidUnits, seize with parametric seizedAssets). -Borrowers the seizing rules cannot reach (dust / inactive seized collateral, where a seizing call would divide -by a zero liquidatedCollatPrice) are covered by the no-transfer bad-debt witness `badDebtCanBeLiquidated` -(repaidUnits = 0, seizedAssets = 0). Together they cover every liquidatable borrower in scope. - -The safe amount is reconstructed in CVL from the contract's own quantities. Because `mulDiv*` are summarized as -deterministic ghosts, those values are bit-for-bit identical to the ones inside `liquidate`, so bounding the -amount by them neutralises each revert site (annotated per-require with the Midnight.sol line). Over-constraining -only WEAKENS the result (never unsound); under-constraining surfaces as an `assert !lastReverted` counterexample. +The seizing rules need the seized collateral active and priced (otherwise `liquidate` divides by a zero price). +Borrowers they cannot reach (dust / inactive seized collateral) are instead covered by the no-transfer witness +`badDebtCanBeLiquidated` (repaidUnits = seizedAssets = 0), which skips the seize block. Together they cover +every liquidatable borrower in scope. Assumptions (LIVENESS): no liquidator gate, well-behaved tokens (transfers summarized NONDET), constant oracle prices per address. Scope: a 3-collateral market with up to 3 active collaterals, bounded by `loop_iter = 3`. @@ -38,7 +33,7 @@ methods { function withdrawable(bytes32 id) external returns (uint128) envfree; function Utils.hashMarket(Midnight.Market) external returns (bytes32) envfree; - function _.price() external => summaryPrice(calledContract) expect(uint256); + function _.price() external => ghostPrice(calledContract) expect(uint256); function touchMarket(Midnight.Market memory market) internal returns (bytes32) => summaryToId(market); function IdLib.toId(Midnight.Market memory market, uint256, address) internal returns (bytes32) => summaryToId(market); @@ -54,37 +49,51 @@ definition WAD() returns uint256 = 10 ^ 18; definition ORACLE_PRICE_SCALE() returns uint256 = 10 ^ 36; +// Assume the market is already created. function summaryToId(Midnight.Market market) returns bytes32 { return Utils.hashMarket(market); } -persistent ghost summaryPrice(address) returns uint256; +persistent ghost ghostPrice(address) returns uint256; +/// Deterministic ghost for UtilsLib.mulDivDown. Each axiom is proven over the real mulDivDown in MulDiv.spec, so assuming them here is sound. persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { + // mulDivDownRoundsDown axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivDown(a, b, d) * d <= a * b; + // mulDivDownTightBound axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => (ghostMulDivDown(a, b, d) + 1) * d > a * b; + // mulDivArgumentLesserThanDenominator (b <= d => result <= a) axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivDown(a, b, d) <= a; + // exact division: from mulDivDownRoundsDown + mulDivDownTightBound at b = d axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivDown(a, d, d) == a; + // mulDivZero (left); right side from mulDivDownRoundsDown (a*0 = 0) axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivDown(0, a, d) == 0 && ghostMulDivDown(a, 0, d) == 0; - /// Monotonicity in each numerator factor. Sound (floor(a*b/d) is nondecreasing in a and b). Used by the - /// post-maturity range rule to upper-bound the decayed-lif seize by the maxLif seize (lif <= maxLif). + /// Monotonicity in each numerator factor (mulDivMonotoneA / mulDivMonotoneB). Used by the post-maturity + /// range rule to upper-bound the decayed-lif seize by the maxLif seize (lif <= maxLif). axiom forall uint256 a1. forall uint256 a2. forall uint256 b. forall uint256 d. d > 0 && a1 <= a2 => ghostMulDivDown(a1, b, d) <= ghostMulDivDown(a2, b, d); axiom forall uint256 a. forall uint256 b1. forall uint256 b2. forall uint256 d. d > 0 && b1 <= b2 => ghostMulDivDown(a, b1, d) <= ghostMulDivDown(a, b2, d); } +/// Deterministic ghost for UtilsLib.mulDivUp. Each axiom is proven over the real mulDivUp in MulDiv.spec, so assuming them here is sound. persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { + // mulDivUpRoundsUp axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivUp(a, b, d) * d >= a * b; + // mulDivUpTightBound axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && ghostMulDivUp(a, b, d) > 0 => (ghostMulDivUp(a, b, d) - 1) * d < a * b; + // mulDivArgumentLesserThanDenominator (b <= d => result <= a) axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivUp(a, b, d) <= a; - - /// Mirror of ghostMulDivDown's `b <= d => <= a` axiom. Sound: result * d >= a*b >= a*d. + // dual of the above: from mulDivUpRoundsUp, result * d >= a*b >= a*d axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b >= d => ghostMulDivUp(a, b, d) >= a; + // exact division: from mulDivUpRoundsUp + mulDivArgumentLesserThanDenominator at b = d axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(a, d, d) == a; + // mulDivZero (left); right side from mulDivUpUpperBound (a*0 = 0) axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(0, a, d) == 0 && ghostMulDivUp(a, 0, d) == 0; + // mulDivMonotoneA axiom forall uint256 a1. forall uint256 a2. forall uint256 b. forall uint256 d. d > 0 && a1 <= a2 => ghostMulDivUp(a1, b, d) <= ghostMulDivUp(a2, b, d); - /// Sound (ceil(a*b/d) is nonincreasing in d). Used by the post-maturity seize path to upper-bound the derived repaidUnits (lif in the denominator, lif >= WAD) by its value at lif = WAD. + /// Antitone in the denominator (mulDivMonotoneD). Used by the post-maturity seize path to upper-bound the + /// derived repaidUnits (lif in the denominator, lif >= WAD) by its value at lif = WAD. axiom forall uint256 a. forall uint256 b. forall uint256 d1. forall uint256 d2. d1 > 0 && d1 <= d2 => ghostMulDivUp(a, b, d1) >= ghostMulDivUp(a, b, d2); } @@ -102,26 +111,28 @@ function summaryMulDivUp(uint256 x, uint256 y, uint256 d) returns uint256 { return ghostMulDivUp(x, y, d); } + +/// Proven in CollateralBitmap.spec; excluded from this conf and only assumed via requireInvariant. strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint256 collateralIndex) collateralIndex < 128 => (collateral(id, user, collateralIndex) != 0 <=> summaryGetBit(currentContract.position[id][user].collateralBitmap, collateralIndex)); -/// Per-collateral validity (lltv, maxLif, ExactMath bounds, liveness bound on collat * price). +/// Per-collateral validity. The lltv/maxLif bounds are theorems about created markets, proven in +/// CreatedMarkets.spec and ExactMath.spec (cited per require); only the collat*price bound is a LIVENESS no-overflow assumption. function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 i) { uint256 lltv = market.collateralParams[i].lltv; uint256 maxLif = market.collateralParams[i].maxLif; - require lltv > 0 && lltv <= WAD(), "valid lltv (touchMarket)"; - require maxLif >= WAD(), "valid maxLif (touchMarket)"; - require lltv < WAD() => lltv * maxLif <= WAD() * (WAD() - 1), "ExactMath: lltv*maxLif <= WAD*(WAD-1) when lltv 0 && lltv <= WAD(), "lltv in (0, WAD] for a created market (createdMarketsHaveAllowedLltv, createdMarketsHaveLltvLessThanOrEqualToOne)"; + require maxLif >= WAD(), "maxLif >= WAD (maxLifIsAtLeastWad)"; + require lltv < WAD() => lltv * maxLif <= WAD() * (WAD() - 1), "lltv < WAD => lltv*maxLif <= WAD*(WAD-1) (lifTimesLltvStrictBound)"; + require lltv * maxLif <= WAD() * WAD(), "lltv*maxLif <= WAD*WAD (lifTimesLltvIsLessThanOrEqualToOne)"; address oracle = market.collateralParams[i].oracle; - require collateral(id, borrower, i) * summaryPrice(oracle) <= ORACLE_PRICE_SCALE() * WAD() * max_uint128, "oracle-quoted collat fits in uint128*WAD (LIVENESS)"; + require collateral(id, borrower, i) * ghostPrice(oracle) <= ORACLE_PRICE_SCALE() * WAD() * max_uint128, "oracle-quoted collat fits in uint128*WAD (LIVENESS)"; } /// Shared setup: 3-collateral market with at most collaterals 0,1,2 active (loop runs <= loop_iter), well-behaved -/// env, no liquidator gate, unlocked, positive debt, totalUnits/withdrawable bounds (Midnight.spec). Does NOT -/// assume which collateral is active. +/// env, no liquidator gate, unlocked, positive debt, totalUnits/withdrawable bounds (Midnight.spec). Does NOT assume which collateral is active. function threeCollatSetup(env e, Midnight.Market market, bytes32 id, address borrower) { require market.collateralParams.length == 3, "three-collateral market (borrower activates 0, 1, 2 or 3 of them)"; uint128 bitmap = collateralBitmap(id, borrower); @@ -150,7 +161,7 @@ function threeCollatSetup(env e, Midnight.Market market, bytes32 id, address bor /// Seized-collateral field selectors. A constant-index case split over the 3-collateral market keeps the prover /// on concrete struct-array accesses (no symbolic indexing); collateralIndex is pinned to {0,1,2} by seizablePreamble. function seizedPrice(Midnight.Market market, uint256 collateralIndex) returns uint256 { - return collateralIndex == 0 ? summaryPrice(market.collateralParams[0].oracle) : collateralIndex == 1 ? summaryPrice(market.collateralParams[1].oracle) : summaryPrice(market.collateralParams[2].oracle); + return collateralIndex == 0 ? ghostPrice(market.collateralParams[0].oracle) : collateralIndex == 1 ? ghostPrice(market.collateralParams[1].oracle) : ghostPrice(market.collateralParams[2].oracle); } function seizedMaxLif(Midnight.Market market, uint256 collateralIndex) returns uint256 { @@ -173,17 +184,17 @@ function seizablePreamble(env e, Midnight.Market market, bytes32 id, address bor /// maxDebt = sum over all collaterals of collat * price * lltv (down-rounded). An inactive collateral's term /// vanishes (its collat == 0 by nonZeroCollateralsAreActivated). function maxDebtSum(Midnight.Market market, bytes32 id, address borrower) returns mathint { - mathint contrib0 = ghostMulDivDown(ghostMulDivDown(collateral(id, borrower, 0), summaryPrice(market.collateralParams[0].oracle), ORACLE_PRICE_SCALE()), market.collateralParams[0].lltv, WAD()); - mathint contrib1 = ghostMulDivDown(ghostMulDivDown(collateral(id, borrower, 1), summaryPrice(market.collateralParams[1].oracle), ORACLE_PRICE_SCALE()), market.collateralParams[1].lltv, WAD()); - mathint contrib2 = ghostMulDivDown(ghostMulDivDown(collateral(id, borrower, 2), summaryPrice(market.collateralParams[2].oracle), ORACLE_PRICE_SCALE()), market.collateralParams[2].lltv, WAD()); + mathint contrib0 = ghostMulDivDown(ghostMulDivDown(collateral(id, borrower, 0), ghostPrice(market.collateralParams[0].oracle), ORACLE_PRICE_SCALE()), market.collateralParams[0].lltv, WAD()); + mathint contrib1 = ghostMulDivDown(ghostMulDivDown(collateral(id, borrower, 1), ghostPrice(market.collateralParams[1].oracle), ORACLE_PRICE_SCALE()), market.collateralParams[1].lltv, WAD()); + mathint contrib2 = ghostMulDivDown(ghostMulDivDown(collateral(id, borrower, 2), ghostPrice(market.collateralParams[2].oracle), ORACLE_PRICE_SCALE()), market.collateralParams[2].lltv, WAD()); return contrib0 + contrib1 + contrib2; } /// debtAfter = debt - badDebt, where badDebt = zeroFloorSub chain = max(0, debt - recovery0 - recovery1 - recovery2). function debtAfterBadDebt(Midnight.Market market, bytes32 id, address borrower) returns mathint { - mathint recovery0 = ghostMulDivUp(ghostMulDivUp(collateral(id, borrower, 0), summaryPrice(market.collateralParams[0].oracle), ORACLE_PRICE_SCALE()), WAD(), market.collateralParams[0].maxLif); - mathint recovery1 = ghostMulDivUp(ghostMulDivUp(collateral(id, borrower, 1), summaryPrice(market.collateralParams[1].oracle), ORACLE_PRICE_SCALE()), WAD(), market.collateralParams[1].maxLif); - mathint recovery2 = ghostMulDivUp(ghostMulDivUp(collateral(id, borrower, 2), summaryPrice(market.collateralParams[2].oracle), ORACLE_PRICE_SCALE()), WAD(), market.collateralParams[2].maxLif); + mathint recovery0 = ghostMulDivUp(ghostMulDivUp(collateral(id, borrower, 0), ghostPrice(market.collateralParams[0].oracle), ORACLE_PRICE_SCALE()), WAD(), market.collateralParams[0].maxLif); + mathint recovery1 = ghostMulDivUp(ghostMulDivUp(collateral(id, borrower, 1), ghostPrice(market.collateralParams[1].oracle), ORACLE_PRICE_SCALE()), WAD(), market.collateralParams[1].maxLif); + mathint recovery2 = ghostMulDivUp(ghostMulDivUp(collateral(id, borrower, 2), ghostPrice(market.collateralParams[2].oracle), ORACLE_PRICE_SCALE()), WAD(), market.collateralParams[2].maxLif); mathint _debt = debtOf(id, borrower); mathint badDebt = _debt > recovery0 + recovery1 + recovery2 ? _debt - recovery0 - recovery1 - recovery2 : 0; return _debt - badDebt; @@ -196,17 +207,17 @@ function debtAfterBadDebt(Midnight.Market market, bytes32 id, address borrower) function lowLltvScaffolding(Midnight.Market market, bytes32 id, address borrower, uint256 collateralIndex) { uint256 lltv0 = market.collateralParams[0].lltv; uint256 maxLif0 = market.collateralParams[0].maxLif; - uint256 price0 = summaryPrice(market.collateralParams[0].oracle); + uint256 price0 = ghostPrice(market.collateralParams[0].oracle); uint128 collat0 = collateral(id, borrower, 0); uint256 lltv1 = market.collateralParams[1].lltv; uint256 maxLif1 = market.collateralParams[1].maxLif; - uint256 price1 = summaryPrice(market.collateralParams[1].oracle); + uint256 price1 = ghostPrice(market.collateralParams[1].oracle); uint128 collat1 = collateral(id, borrower, 1); uint256 lltv2 = market.collateralParams[2].lltv; uint256 maxLif2 = market.collateralParams[2].maxLif; - uint256 price2 = summaryPrice(market.collateralParams[2].oracle); + uint256 price2 = ghostPrice(market.collateralParams[2].oracle); uint128 collat2 = collateral(id, borrower, 2); // recovery_i >= maxDebtContrib_i for every collateral (sound theorem from validCollateralAt; ensures debtAfter >= maxDebt). @@ -220,7 +231,7 @@ function lowLltvScaffolding(Midnight.Market market, bytes32 id, address borrower uint256 maxLifJ = seizedMaxLif(market, collateralIndex); uint256 priceJ = seizedPrice(market, collateralIndex); uint128 collatJ = collateral(id, borrower, collateralIndex); - require maxLifJ * lltvJ <= WAD() * (WAD() - 1), "WAD*(WAD-1) ExactMath bound (touchMarket) => WAD*WAD - maxLif*lltv >= WAD >= 1"; + require maxLifJ * lltvJ <= WAD() * (WAD() - 1), "maxLif*lltv <= WAD*(WAD-1) (lifTimesLltvStrictBound) => WAD*WAD - maxLif*lltv >= WAD >= 1"; require ghostMulDivUp(ghostMulDivUp(collatJ, priceJ, ORACLE_PRICE_SCALE()), WAD(), maxLifJ) > ghostMulDivDown(ghostMulDivDown(collatJ, priceJ, ORACLE_PRICE_SCALE()), lltvJ, WAD()), "recoveryJ > maxDebtContribJ (seized lltv < WAD, collatJ*priceJ >= ORACLE_PRICE_SCALE)"; } @@ -242,13 +253,13 @@ rule unhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market market require _debt > maxDebtSum(market, id, borrower), "unhealthy: debt > maxDebt"; require repaidUnits > 0; - require repaidUnits <= debtAfter, "no debt underflow (L675)"; - require ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ) <= collatJ, "seize fits the seized collateral (L669)"; + require repaidUnits <= debtAfter, "no debt underflow"; + require ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ) <= collatJ, "seize fits the seized collateral"; bytes data; liquidate@withrevert(e, market, collateralIndex, 0, repaidUnits, borrower, false, receiver, 0, data); - assert !lastReverted, "the call is live (succeeds)"; - assert debtOf(id, borrower) < _debt, "and it makes progress: debt strictly decreases"; + assert !lastReverted; + assert debtOf(id, borrower) < _debt; } /// Unhealthy, lltv < WAD: maxRepaid is finite, so the safe interval is additionally capped by repaidUnits <= maxRepaid. @@ -269,19 +280,19 @@ rule unhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market market, lowLltvScaffolding(market, id, borrower, collateralIndex); - // maxRepaid per contract #944 (L658-660): mulDivUp(debtAfter - maxDebt, WAD*WAD, WAD*WAD - lif*lltv), lif = maxLif here. + // maxRepaid (RCF check) = mulDivUp(debtAfter - maxDebt, WAD*WAD, WAD*WAD - lif*lltv), lif = maxLif here. // Reconstructed bit-for-bit so the bound matches the RCF check exactly; denominator > 0 and debtAfter >= maxDebt from lowLltvScaffolding. mathint maxRepaid = ghostMulDivUp(assert_uint256(debtAfter - maxDebt), assert_uint256(WAD() * WAD()), assert_uint256(WAD() * WAD() - maxLif * lltv)); require repaidUnits > 0; - require repaidUnits <= maxRepaid, "RCF check passes (L661)"; - require repaidUnits <= debtAfter, "no debt underflow (L675)"; - require ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ) <= collatJ, "seize fits the seized collateral (L669)"; + require repaidUnits <= maxRepaid, "RCF check passes"; + require repaidUnits <= debtAfter, "no debt underflow"; + require ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ) <= collatJ, "seize fits the seized collateral"; bytes data; liquidate@withrevert(e, market, collateralIndex, 0, repaidUnits, borrower, false, receiver, 0, data); - assert !lastReverted, "the call is live (succeeds)"; - assert debtOf(id, borrower) < _debt, "and it makes progress: debt strictly decreases"; + assert !lastReverted; + assert debtOf(id, borrower) < _debt; } /// Post-maturity: liquidatable by expiry alone (no health check), RCF / `debt - maxDebt` block skipped. lif <= @@ -299,16 +310,16 @@ rule postMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market, add mathint debtAfter = debtAfterBadDebt(market, id, borrower); require repaidUnits > 0; - require repaidUnits <= debtAfter, "no debt underflow (L675)"; - require ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ) <= collatJ, "seize (at lif <= maxLif) fits the seized collateral (L669)"; + require repaidUnits <= debtAfter, "no debt underflow"; + require ghostMulDivDown(ghostMulDivDown(repaidUnits, maxLif, WAD()), ORACLE_PRICE_SCALE(), priceJ) <= collatJ, "seize (at lif <= maxLif) fits the seized collateral"; bytes data; liquidate@withrevert(e, market, collateralIndex, 0, repaidUnits, borrower, true, receiver, 0, data); - assert !lastReverted, "the call is live (succeeds)"; - assert debtOf(id, borrower) < _debt, "and it makes progress: debt strictly decreases"; + assert !lastReverted; + assert debtOf(id, borrower) < _debt; } -/// SEIZE PATH (seizedAssets > 0, repaidUnits = 0): contract derives repaidUnits (L650) = mulDivUp(mulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE), WAD, lif). +/// SEIZE PATH (seizedAssets > 0, repaidUnits = 0): contract derives repaidUnits = mulDivUp(mulDivUp(seizedAssets, price0, ORACLE_PRICE_SCALE), WAD, lif). /// Collateral guard is a direct `seizedAssets <= collateral[0]`; debt-underflow / RCF apply to the derived repaidUnits, which is >= 1 (seizedAssets > 0, price0 > 0) so progress holds. /// Seize path, unhealthy, lltv == WAD: maxRepaid = uint256.max (RCF auto-passes), lif = maxLif. @@ -328,13 +339,13 @@ rule seizeUnhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market m mathint repaidUnits = ghostMulDivUp(ghostMulDivUp(seizedAssets, priceJ, ORACLE_PRICE_SCALE()), WAD(), maxLif); require seizedAssets > 0; - require seizedAssets <= collatJ, "seize fits the seized collateral (L669)"; - require repaidUnits <= debtAfter, "derived repaidUnits: no debt underflow (L675)"; + require seizedAssets <= collatJ, "seize fits the seized collateral"; + require repaidUnits <= debtAfter, "derived repaidUnits: no debt underflow"; bytes data; liquidate@withrevert(e, market, collateralIndex, seizedAssets, 0, borrower, false, receiver, 0, data); - assert !lastReverted, "the call is live (succeeds)"; - assert debtOf(id, borrower) < _debt, "and it makes progress: debt strictly decreases"; + assert !lastReverted; + assert debtOf(id, borrower) < _debt; } /// Seize path, unhealthy, lltv < WAD: RCF caps the derived repaidUnits by maxRepaid; same scaffolding applies. @@ -355,21 +366,21 @@ rule seizeUnhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market ma lowLltvScaffolding(market, id, borrower, collateralIndex); - // maxRepaid per contract #944 (L658-660): mulDivUp(debtAfter - maxDebt, WAD*WAD, WAD*WAD - lif*lltv), + // maxRepaid (RCF check) = mulDivUp(debtAfter - maxDebt, WAD*WAD, WAD*WAD - lif*lltv), // lif = maxLif here. Reconstructed bit-for-bit so the bound matches the contract's RCF check exactly. mathint maxRepaid = ghostMulDivUp(assert_uint256(debtAfter - maxDebt), assert_uint256(WAD() * WAD()), assert_uint256(WAD() * WAD() - maxLif * lltv)); mathint repaidUnits = ghostMulDivUp(ghostMulDivUp(seizedAssets, priceJ, ORACLE_PRICE_SCALE()), WAD(), maxLif); require seizedAssets > 0; - require seizedAssets <= collatJ, "seize fits the seized collateral (L669)"; - require repaidUnits <= maxRepaid, "derived repaidUnits: RCF check passes (L661)"; - require repaidUnits <= debtAfter, "derived repaidUnits: no debt underflow (L675)"; + require seizedAssets <= collatJ, "seize fits the seized collateral"; + require repaidUnits <= maxRepaid, "derived repaidUnits: RCF check passes"; + require repaidUnits <= debtAfter, "derived repaidUnits: no debt underflow"; bytes data; liquidate@withrevert(e, market, collateralIndex, seizedAssets, 0, borrower, false, receiver, 0, data); - assert !lastReverted, "the call is live (succeeds)"; - assert debtOf(id, borrower) < _debt, "and it makes progress: debt strictly decreases"; + assert !lastReverted; + assert debtOf(id, borrower) < _debt; } /// Seize path, post-maturity: no RCF. lif >= WAD, and the derived repaidUnits = mulDivUp(quote, WAD, lif) is @@ -388,13 +399,13 @@ rule seizePostMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market mathint quoteUp = ghostMulDivUp(seizedAssets, priceJ, ORACLE_PRICE_SCALE()); require seizedAssets > 0; - require seizedAssets <= collatJ, "seize fits the seized collateral (L669)"; - require quoteUp <= debtAfter, "derived repaidUnits (<= quote since lif >= WAD): no debt underflow (L675)"; + require seizedAssets <= collatJ, "seize fits the seized collateral"; + require quoteUp <= debtAfter, "derived repaidUnits (<= quote since lif >= WAD): no debt underflow"; bytes data; liquidate@withrevert(e, market, collateralIndex, seizedAssets, 0, borrower, true, receiver, 0, data); - assert !lastReverted, "the call is live (succeeds)"; - assert debtOf(id, borrower) < _debt, "and it makes progress: debt strictly decreases"; + assert !lastReverted; + assert debtOf(id, borrower) < _debt; } /// BAD-DEBT WITNESS (repaidUnits = 0, seizedAssets = 0): any liquidatable borrower can be liquidated with the @@ -407,5 +418,5 @@ rule badDebtCanBeLiquidated(env e, Midnight.Market market, address borrower, add bytes data; liquidate@withrevert(e, market, 0, 0, 0, borrower, postMaturityMode, receiver, 0, data); - assert !lastReverted, "the no-transfer bad-debt call is live (succeeds)"; + assert !lastReverted; } From 6175f9d7a76a4343933fecb8d3b53df3570477bd Mon Sep 17 00:00:00 2001 From: lilCertora Date: Fri, 5 Jun 2026 16:38:33 +0200 Subject: [PATCH 49/53] linter --- certora/specs/LiquidateLiveness.spec | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 362ea78dc..5a768a0de 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -60,12 +60,16 @@ persistent ghost ghostPrice(address) returns uint256; persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { // mulDivDownRoundsDown axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivDown(a, b, d) * d <= a * b; + // mulDivDownTightBound axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => (ghostMulDivDown(a, b, d) + 1) * d > a * b; + // mulDivArgumentLesserThanDenominator (b <= d => result <= a) axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivDown(a, b, d) <= a; + // exact division: from mulDivDownRoundsDown + mulDivDownTightBound at b = d axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivDown(a, d, d) == a; + // mulDivZero (left); right side from mulDivDownRoundsDown (a*0 = 0) axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivDown(0, a, d) == 0 && ghostMulDivDown(a, 0, d) == 0; @@ -79,16 +83,22 @@ persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { // mulDivUpRoundsUp axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivUp(a, b, d) * d >= a * b; + // mulDivUpTightBound axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && ghostMulDivUp(a, b, d) > 0 => (ghostMulDivUp(a, b, d) - 1) * d < a * b; + // mulDivArgumentLesserThanDenominator (b <= d => result <= a) axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivUp(a, b, d) <= a; + // dual of the above: from mulDivUpRoundsUp, result * d >= a*b >= a*d axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b >= d => ghostMulDivUp(a, b, d) >= a; + // exact division: from mulDivUpRoundsUp + mulDivArgumentLesserThanDenominator at b = d axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(a, d, d) == a; + // mulDivZero (left); right side from mulDivUpUpperBound (a*0 = 0) axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(0, a, d) == 0 && ghostMulDivUp(a, 0, d) == 0; + // mulDivMonotoneA axiom forall uint256 a1. forall uint256 a2. forall uint256 b. forall uint256 d. d > 0 && a1 <= a2 => ghostMulDivUp(a1, b, d) <= ghostMulDivUp(a2, b, d); @@ -111,7 +121,6 @@ function summaryMulDivUp(uint256 x, uint256 y, uint256 d) returns uint256 { return ghostMulDivUp(x, y, d); } - /// Proven in CollateralBitmap.spec; excluded from this conf and only assumed via requireInvariant. strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint256 collateralIndex) collateralIndex < 128 => (collateral(id, user, collateralIndex) != 0 <=> summaryGetBit(currentContract.position[id][user].collateralBitmap, collateralIndex)); From f7562b51867e1d878956a9596431a3220cf216f2 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Sun, 7 Jun 2026 15:55:52 +0200 Subject: [PATCH 50/53] cleaning, remove axioms and formatting --- certora/specs/LiquidateLiveness.spec | 119 ++++++++++----------------- 1 file changed, 45 insertions(+), 74 deletions(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 5a768a0de..6372cde56 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -14,8 +14,9 @@ paths (repay with parametric repaidUnits, seize with parametric seizedAssets). The seizing rules need the seized collateral active and priced (otherwise `liquidate` divides by a zero price). Borrowers they cannot reach (dust / inactive seized collateral) are instead covered by the no-transfer witness -`badDebtCanBeLiquidated` (repaidUnits = seizedAssets = 0), which skips the seize block. Together they cover -every liquidatable borrower in scope. +`badDebtCanBeLiquidated` (repaidUnits = seizedAssets = 0): it never reverts and, whenever there is bad debt to +realize, strictly decreases debt via the write-down (with no bad debt, no input can decrease debt). Together +they cover every liquidatable borrower in scope. Assumptions (LIVENESS): no liquidator gate, well-behaved tokens (transfers summarized NONDET), constant oracle prices per address. Scope: a 3-collateral market with up to 3 active collaterals, bounded by `loop_iter = 3`. @@ -33,14 +34,18 @@ methods { function withdrawable(bytes32 id) external returns (uint128) envfree; function Utils.hashMarket(Midnight.Market) external returns (bytes32) envfree; + // LIVENESS: constant oracle price per address. function _.price() external => ghostPrice(calledContract) expect(uint256); + // Market already created; skip touchMarket's creation branch (borrower has debt). function touchMarket(Midnight.Market memory market) internal returns (bytes32) => summaryToId(market); function IdLib.toId(Midnight.Market memory market, uint256, address) internal returns (bytes32) => summaryToId(market); + // LIVENESS: well-behaved tokens (transfers never revert). function SafeTransferLib.safeTransfer(address, address, uint256) internal => NONDET; function SafeTransferLib.safeTransferFrom(address, address, address, uint256) internal => NONDET; + // Deterministic mulDiv matching contract math; axioms proven 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); } @@ -56,7 +61,7 @@ function summaryToId(Midnight.Market market) returns bytes32 { persistent ghost ghostPrice(address) returns uint256; -/// Deterministic ghost for UtilsLib.mulDivDown. Each axiom is proven over the real mulDivDown in MulDiv.spec, so assuming them here is sound. +/// Deterministic ghost for UtilsLib.mulDivDown. Each axiom is proven in MulDiv.spec (rule named per axiom). persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { // mulDivDownRoundsDown axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivDown(a, b, d) * d <= a * b; @@ -64,47 +69,20 @@ persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { // mulDivDownTightBound axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => (ghostMulDivDown(a, b, d) + 1) * d > a * b; - // mulDivArgumentLesserThanDenominator (b <= d => result <= a) - axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivDown(a, b, d) <= a; - - // exact division: from mulDivDownRoundsDown + mulDivDownTightBound at b = d - axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivDown(a, d, d) == a; - - // mulDivZero (left); right side from mulDivDownRoundsDown (a*0 = 0) - axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivDown(0, a, d) == 0 && ghostMulDivDown(a, 0, d) == 0; - - /// Monotonicity in each numerator factor (mulDivMonotoneA / mulDivMonotoneB). Used by the post-maturity - /// range rule to upper-bound the decayed-lif seize by the maxLif seize (lif <= maxLif). + // mulDivMonotoneA axiom forall uint256 a1. forall uint256 a2. forall uint256 b. forall uint256 d. d > 0 && a1 <= a2 => ghostMulDivDown(a1, b, d) <= ghostMulDivDown(a2, b, d); - axiom forall uint256 a. forall uint256 b1. forall uint256 b2. forall uint256 d. d > 0 && b1 <= b2 => ghostMulDivDown(a, b1, d) <= ghostMulDivDown(a, b2, d); } -/// Deterministic ghost for UtilsLib.mulDivUp. Each axiom is proven over the real mulDivUp in MulDiv.spec, so assuming them here is sound. +/// Deterministic ghost for UtilsLib.mulDivUp. Each axiom is proven in MulDiv.spec (rule named per axiom). persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { // mulDivUpRoundsUp axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivUp(a, b, d) * d >= a * b; - // mulDivUpTightBound - axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && ghostMulDivUp(a, b, d) > 0 => (ghostMulDivUp(a, b, d) - 1) * d < a * b; - - // mulDivArgumentLesserThanDenominator (b <= d => result <= a) + // mulDivArgumentLesserThanDenominator axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivUp(a, b, d) <= a; - // dual of the above: from mulDivUpRoundsUp, result * d >= a*b >= a*d - axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b >= d => ghostMulDivUp(a, b, d) >= a; - - // exact division: from mulDivUpRoundsUp + mulDivArgumentLesserThanDenominator at b = d - axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(a, d, d) == a; - - // mulDivZero (left); right side from mulDivUpUpperBound (a*0 = 0) + // mulDivZero axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(0, a, d) == 0 && ghostMulDivUp(a, 0, d) == 0; - - // mulDivMonotoneA - axiom forall uint256 a1. forall uint256 a2. forall uint256 b. forall uint256 d. d > 0 && a1 <= a2 => ghostMulDivUp(a1, b, d) <= ghostMulDivUp(a2, b, d); - - /// Antitone in the denominator (mulDivMonotoneD). Used by the post-maturity seize path to upper-bound the - /// derived repaidUnits (lif in the denominator, lif >= WAD) by its value at lif = WAD. - axiom forall uint256 a. forall uint256 b. forall uint256 d1. forall uint256 d2. d1 > 0 && d1 <= d2 => ghostMulDivUp(a, b, d1) >= ghostMulDivUp(a, b, d2); } function summaryMulDivDown(uint256 x, uint256 y, uint256 d) returns uint256 { @@ -121,12 +99,12 @@ function summaryMulDivUp(uint256 x, uint256 y, uint256 d) returns uint256 { return ghostMulDivUp(x, y, d); } -/// Proven in CollateralBitmap.spec; excluded from this conf and only assumed via requireInvariant. +/// Proven in CollateralBitmap.spec; excluded from this conf and only assumed via requireInvariant (sound). strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint256 collateralIndex) collateralIndex < 128 => (collateral(id, user, collateralIndex) != 0 <=> summaryGetBit(currentContract.position[id][user].collateralBitmap, collateralIndex)); -/// Per-collateral validity. The lltv/maxLif bounds are theorems about created markets, proven in -/// CreatedMarkets.spec and ExactMath.spec (cited per require); only the collat*price bound is a LIVENESS no-overflow assumption. +/// Per-collateral validity. lltv/maxLif bounds are theorems about created markets (CreatedMarkets.spec, ExactMath.spec). +/// Only the collat*price bound is a LIVENESS no-overflow assumption. function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 i) { uint256 lltv = market.collateralParams[i].lltv; uint256 maxLif = market.collateralParams[i].maxLif; @@ -140,8 +118,8 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, require collateral(id, borrower, i) * ghostPrice(oracle) <= ORACLE_PRICE_SCALE() * WAD() * max_uint128, "oracle-quoted collat fits in uint128*WAD (LIVENESS)"; } -/// Shared setup: 3-collateral market with at most collaterals 0,1,2 active (loop runs <= loop_iter), well-behaved -/// env, no liquidator gate, unlocked, positive debt, totalUnits/withdrawable bounds (Midnight.spec). Does NOT assume which collateral is active. +/// Shared setup: 3-collateral market with at most collaterals 0,1,2 active (loop runs <= loop_iter), well-behaved env, +/// no liquidator gate, unlocked, positive debt, totalUnits/withdrawable bounds (Midnight.spec). Does not assume which collateral is active. function threeCollatSetup(env e, Midnight.Market market, bytes32 id, address borrower) { require market.collateralParams.length == 3, "three-collateral market (borrower activates 0, 1, 2 or 3 of them)"; uint128 bitmap = collateralBitmap(id, borrower); @@ -167,27 +145,13 @@ function threeCollatSetup(env e, Midnight.Market market, bytes32 id, address bor requireInvariant nonZeroCollateralsAreActivated(id, borrower, 2); } -/// Seized-collateral field selectors. A constant-index case split over the 3-collateral market keeps the prover -/// on concrete struct-array accesses (no symbolic indexing); collateralIndex is pinned to {0,1,2} by seizablePreamble. -function seizedPrice(Midnight.Market market, uint256 collateralIndex) returns uint256 { - return collateralIndex == 0 ? ghostPrice(market.collateralParams[0].oracle) : collateralIndex == 1 ? ghostPrice(market.collateralParams[1].oracle) : ghostPrice(market.collateralParams[2].oracle); -} - -function seizedMaxLif(Midnight.Market market, uint256 collateralIndex) returns uint256 { - return collateralIndex == 0 ? market.collateralParams[0].maxLif : collateralIndex == 1 ? market.collateralParams[1].maxLif : market.collateralParams[2].maxLif; -} - -function seizedLltv(Midnight.Market market, uint256 collateralIndex) returns uint256 { - return collateralIndex == 0 ? market.collateralParams[0].lltv : collateralIndex == 1 ? market.collateralParams[1].lltv : market.collateralParams[2].lltv; -} - -/// Extends threeCollatSetup for the seizing rules: the seized collateral (parametric collateralIndex in {0,1,2}) -/// is active and priced, so the liquidatedCollatPrice is non-zero (the seize/RCF block divides by it). +/// Extends threeCollatSetup for the seizing rules: collateralIndex in {0,1,2}, active and priced (constant-index +/// case split keeps concrete struct-array accesses; liquidatedCollatPrice must be non-zero). function seizablePreamble(env e, Midnight.Market market, bytes32 id, address borrower, uint256 collateralIndex) { threeCollatSetup(e, market, id, borrower); require collateralIndex == 0 || collateralIndex == 1 || collateralIndex == 2, "seized index in {0,1,2} (<= loop_iter)"; require summaryGetBit(collateralBitmap(id, borrower), collateralIndex), "the seized collateral is active"; - require seizedPrice(market, collateralIndex) > 0, "the seized collateral is priced (LIVENESS)"; + require (collateralIndex == 0 ? ghostPrice(market.collateralParams[0].oracle) : collateralIndex == 1 ? ghostPrice(market.collateralParams[1].oracle) : ghostPrice(market.collateralParams[2].oracle)) > 0, "the seized collateral is priced (LIVENESS)"; } /// maxDebt = sum over all collaterals of collat * price * lltv (down-rounded). An inactive collateral's term @@ -236,9 +200,9 @@ function lowLltvScaffolding(Midnight.Market market, bytes32 id, address borrower // Seized collateral: ExactMath denominator bound (=> WAD*WAD - maxLif*lltv >= WAD >= 1) and strict // recovery > maxDebtContrib (=> debtAfter > maxDebt, so maxRepaid >= 1; needs collatJ*priceJ >= ORACLE_PRICE_SCALE). - uint256 lltvJ = seizedLltv(market, collateralIndex); - uint256 maxLifJ = seizedMaxLif(market, collateralIndex); - uint256 priceJ = seizedPrice(market, collateralIndex); + uint256 lltvJ = collateralIndex == 0 ? market.collateralParams[0].lltv : collateralIndex == 1 ? market.collateralParams[1].lltv : market.collateralParams[2].lltv; + uint256 maxLifJ = collateralIndex == 0 ? market.collateralParams[0].maxLif : collateralIndex == 1 ? market.collateralParams[1].maxLif : market.collateralParams[2].maxLif; + uint256 priceJ = collateralIndex == 0 ? ghostPrice(market.collateralParams[0].oracle) : collateralIndex == 1 ? ghostPrice(market.collateralParams[1].oracle) : ghostPrice(market.collateralParams[2].oracle); uint128 collatJ = collateral(id, borrower, collateralIndex); require maxLifJ * lltvJ <= WAD() * (WAD() - 1), "maxLif*lltv <= WAD*(WAD-1) (lifTimesLltvStrictBound) => WAD*WAD - maxLif*lltv >= WAD >= 1"; require ghostMulDivUp(ghostMulDivUp(collatJ, priceJ, ORACLE_PRICE_SCALE()), WAD(), maxLifJ) > ghostMulDivDown(ghostMulDivDown(collatJ, priceJ, ORACLE_PRICE_SCALE()), lltvJ, WAD()), "recoveryJ > maxDebtContribJ (seized lltv < WAD, collatJ*priceJ >= ORACLE_PRICE_SCALE)"; @@ -252,11 +216,11 @@ rule unhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market market bytes32 id = summaryToId(market); seizablePreamble(e, market, id, borrower, collateralIndex); - uint256 maxLif = seizedMaxLif(market, collateralIndex); - require seizedLltv(market, collateralIndex) == WAD(), "lltv == WAD partition (maxRepaid = uint256.max)"; + uint256 maxLif = collateralIndex == 0 ? market.collateralParams[0].maxLif : collateralIndex == 1 ? market.collateralParams[1].maxLif : market.collateralParams[2].maxLif; + require (collateralIndex == 0 ? market.collateralParams[0].lltv : collateralIndex == 1 ? market.collateralParams[1].lltv : market.collateralParams[2].lltv) == WAD(), "lltv == WAD partition (maxRepaid = uint256.max)"; uint128 collatJ = collateral(id, borrower, collateralIndex); - uint256 priceJ = seizedPrice(market, collateralIndex); + uint256 priceJ = collateralIndex == 0 ? ghostPrice(market.collateralParams[0].oracle) : collateralIndex == 1 ? ghostPrice(market.collateralParams[1].oracle) : ghostPrice(market.collateralParams[2].oracle); uint256 _debt = debtOf(id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); require _debt > maxDebtSum(market, id, borrower), "unhealthy: debt > maxDebt"; @@ -276,12 +240,12 @@ rule unhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market market, bytes32 id = summaryToId(market); seizablePreamble(e, market, id, borrower, collateralIndex); - uint256 lltv = seizedLltv(market, collateralIndex); - uint256 maxLif = seizedMaxLif(market, collateralIndex); + uint256 lltv = collateralIndex == 0 ? market.collateralParams[0].lltv : collateralIndex == 1 ? market.collateralParams[1].lltv : market.collateralParams[2].lltv; + uint256 maxLif = collateralIndex == 0 ? market.collateralParams[0].maxLif : collateralIndex == 1 ? market.collateralParams[1].maxLif : market.collateralParams[2].maxLif; require lltv < WAD(), "lltv < WAD partition"; uint128 collatJ = collateral(id, borrower, collateralIndex); - uint256 priceJ = seizedPrice(market, collateralIndex); + uint256 priceJ = collateralIndex == 0 ? ghostPrice(market.collateralParams[0].oracle) : collateralIndex == 1 ? ghostPrice(market.collateralParams[1].oracle) : ghostPrice(market.collateralParams[2].oracle); uint256 _debt = debtOf(id, borrower); mathint maxDebt = maxDebtSum(market, id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); @@ -312,9 +276,9 @@ rule postMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market, add require e.block.timestamp > market.maturity, "post-maturity partition (liquidatable by expiry)"; - uint256 maxLif = seizedMaxLif(market, collateralIndex); + uint256 maxLif = collateralIndex == 0 ? market.collateralParams[0].maxLif : collateralIndex == 1 ? market.collateralParams[1].maxLif : market.collateralParams[2].maxLif; uint128 collatJ = collateral(id, borrower, collateralIndex); - uint256 priceJ = seizedPrice(market, collateralIndex); + uint256 priceJ = collateralIndex == 0 ? ghostPrice(market.collateralParams[0].oracle) : collateralIndex == 1 ? ghostPrice(market.collateralParams[1].oracle) : ghostPrice(market.collateralParams[2].oracle); uint256 _debt = debtOf(id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); @@ -336,11 +300,11 @@ rule seizeUnhealthyLltvFullLiquidatableForAnySafeAmount(env e, Midnight.Market m bytes32 id = summaryToId(market); seizablePreamble(e, market, id, borrower, collateralIndex); - uint256 maxLif = seizedMaxLif(market, collateralIndex); - require seizedLltv(market, collateralIndex) == WAD(), "lltv == WAD partition (maxRepaid = uint256.max)"; + uint256 maxLif = collateralIndex == 0 ? market.collateralParams[0].maxLif : collateralIndex == 1 ? market.collateralParams[1].maxLif : market.collateralParams[2].maxLif; + require (collateralIndex == 0 ? market.collateralParams[0].lltv : collateralIndex == 1 ? market.collateralParams[1].lltv : market.collateralParams[2].lltv) == WAD(), "lltv == WAD partition (maxRepaid = uint256.max)"; uint128 collatJ = collateral(id, borrower, collateralIndex); - uint256 priceJ = seizedPrice(market, collateralIndex); + uint256 priceJ = collateralIndex == 0 ? ghostPrice(market.collateralParams[0].oracle) : collateralIndex == 1 ? ghostPrice(market.collateralParams[1].oracle) : ghostPrice(market.collateralParams[2].oracle); uint256 _debt = debtOf(id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); require _debt > maxDebtSum(market, id, borrower), "unhealthy: debt > maxDebt"; @@ -362,12 +326,12 @@ rule seizeUnhealthyLowLltvLiquidatableForAnySafeAmount(env e, Midnight.Market ma bytes32 id = summaryToId(market); seizablePreamble(e, market, id, borrower, collateralIndex); - uint256 lltv = seizedLltv(market, collateralIndex); - uint256 maxLif = seizedMaxLif(market, collateralIndex); + uint256 lltv = collateralIndex == 0 ? market.collateralParams[0].lltv : collateralIndex == 1 ? market.collateralParams[1].lltv : market.collateralParams[2].lltv; + uint256 maxLif = collateralIndex == 0 ? market.collateralParams[0].maxLif : collateralIndex == 1 ? market.collateralParams[1].maxLif : market.collateralParams[2].maxLif; require lltv < WAD(), "lltv < WAD partition"; uint128 collatJ = collateral(id, borrower, collateralIndex); - uint256 priceJ = seizedPrice(market, collateralIndex); + uint256 priceJ = collateralIndex == 0 ? ghostPrice(market.collateralParams[0].oracle) : collateralIndex == 1 ? ghostPrice(market.collateralParams[1].oracle) : ghostPrice(market.collateralParams[2].oracle); uint256 _debt = debtOf(id, borrower); mathint maxDebt = maxDebtSum(market, id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); @@ -401,7 +365,7 @@ rule seizePostMaturityLiquidatableForAnySafeAmount(env e, Midnight.Market market require e.block.timestamp > market.maturity, "post-maturity partition (liquidatable by expiry)"; uint128 collatJ = collateral(id, borrower, collateralIndex); - uint256 priceJ = seizedPrice(market, collateralIndex); + uint256 priceJ = collateralIndex == 0 ? ghostPrice(market.collateralParams[0].oracle) : collateralIndex == 1 ? ghostPrice(market.collateralParams[1].oracle) : ghostPrice(market.collateralParams[2].oracle); uint256 _debt = debtOf(id, borrower); mathint debtAfter = debtAfterBadDebt(market, id, borrower); @@ -425,7 +389,14 @@ rule badDebtCanBeLiquidated(env e, Midnight.Market market, address borrower, add require postMaturityMode ? e.block.timestamp > market.maturity : !isHealthy(market, id, borrower), "borrower is liquidatable in the chosen mode"; + uint256 _debt = debtOf(id, borrower); + + // (0,0) skips the seize/RCF/underflow block, so the call's only debt effect is the bad-debt write-down: + // post-debt == debt - badDebt == debtAfterBadDebt. Hence bad debt to realize (debtAfter < debt) => debt strictly drops. + mathint debtAfter = debtAfterBadDebt(market, id, borrower); + bytes data; liquidate@withrevert(e, market, 0, 0, 0, borrower, postMaturityMode, receiver, 0, data); assert !lastReverted; + assert debtAfter < to_mathint(_debt) => debtOf(id, borrower) < _debt; } From cd6315e47721e7fd787e75871c71dde2dbcd33eb Mon Sep 17 00:00:00 2001 From: lilCertora Date: Sun, 7 Jun 2026 18:19:47 +0200 Subject: [PATCH 51/53] cleaning --- certora/specs/LiquidateLiveness.spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 6372cde56..96116a224 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -398,5 +398,5 @@ rule badDebtCanBeLiquidated(env e, Midnight.Market market, address borrower, add bytes data; liquidate@withrevert(e, market, 0, 0, 0, borrower, postMaturityMode, receiver, 0, data); assert !lastReverted; - assert debtAfter < to_mathint(_debt) => debtOf(id, borrower) < _debt; + assert debtOf(id, borrower) == debtAfter; } From d6917110d8107f14c1efe526a410d9e25358988f Mon Sep 17 00:00:00 2001 From: lilCertora Date: Sun, 7 Jun 2026 20:21:33 +0200 Subject: [PATCH 52/53] codex suggestion --- certora/specs/LiquidateLiveness.spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 96116a224..9b3a3c029 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -115,7 +115,7 @@ function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, require lltv * maxLif <= WAD() * WAD(), "lltv*maxLif <= WAD*WAD (lifTimesLltvIsLessThanOrEqualToOne)"; address oracle = market.collateralParams[i].oracle; - require collateral(id, borrower, i) * ghostPrice(oracle) <= ORACLE_PRICE_SCALE() * WAD() * max_uint128, "oracle-quoted collat fits in uint128*WAD (LIVENESS)"; + require collateral(id, borrower, i) * ghostPrice(oracle) + ORACLE_PRICE_SCALE() - 1 <= max_uint256, "collat*price fits in uint256 for mulDivUp(price, ORACLE_PRICE_SCALE) (LIVENESS)"; } /// Shared setup: 3-collateral market with at most collaterals 0,1,2 active (loop runs <= loop_iter), well-behaved env, From 3303269d93b89dc7a836926ec64058f47f8919f4 Mon Sep 17 00:00:00 2001 From: lilCertora Date: Tue, 9 Jun 2026 10:01:12 +0200 Subject: [PATCH 53/53] review --- certora/specs/LiquidateLiveness.spec | 42 ++++++++++++++-------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/certora/specs/LiquidateLiveness.spec b/certora/specs/LiquidateLiveness.spec index 9b3a3c029..5dc88baf9 100644 --- a/certora/specs/LiquidateLiveness.spec +++ b/certora/specs/LiquidateLiveness.spec @@ -34,14 +34,14 @@ methods { function withdrawable(bytes32 id) external returns (uint128) envfree; function Utils.hashMarket(Midnight.Market) external returns (bytes32) envfree; - // LIVENESS: constant oracle price per address. + // Constant oracle price per address. function _.price() external => ghostPrice(calledContract) expect(uint256); // Market already created; skip touchMarket's creation branch (borrower has debt). function touchMarket(Midnight.Market memory market) internal returns (bytes32) => summaryToId(market); function IdLib.toId(Midnight.Market memory market, uint256, address) internal returns (bytes32) => summaryToId(market); - // LIVENESS: well-behaved tokens (transfers never revert). + // Well-behaved tokens (transfers never revert). function SafeTransferLib.safeTransfer(address, address, uint256) internal => NONDET; function SafeTransferLib.safeTransferFrom(address, address, address, uint256) internal => NONDET; @@ -63,25 +63,25 @@ persistent ghost ghostPrice(address) returns uint256; /// Deterministic ghost for UtilsLib.mulDivDown. Each axiom is proven in MulDiv.spec (rule named per axiom). persistent ghost ghostMulDivDown(uint256, uint256, uint256) returns uint256 { - // mulDivDownRoundsDown + // Proved in Muldiv.spec : mulDivDownRoundsDown axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivDown(a, b, d) * d <= a * b; - // mulDivDownTightBound + // Proved in Muldiv.spec : mulDivDownTightBound axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => (ghostMulDivDown(a, b, d) + 1) * d > a * b; - // mulDivMonotoneA + // Proved in Muldiv.spec : mulDivMonotoneA axiom forall uint256 a1. forall uint256 a2. forall uint256 b. forall uint256 d. d > 0 && a1 <= a2 => ghostMulDivDown(a1, b, d) <= ghostMulDivDown(a2, b, d); } /// Deterministic ghost for UtilsLib.mulDivUp. Each axiom is proven in MulDiv.spec (rule named per axiom). persistent ghost ghostMulDivUp(uint256, uint256, uint256) returns uint256 { - // mulDivUpRoundsUp + // Proved in Muldiv.spec : mulDivUpRoundsUp axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 => ghostMulDivUp(a, b, d) * d >= a * b; - // mulDivArgumentLesserThanDenominator + // Proved in Muldiv.spec : mulDivArgumentLesserThanDenominator axiom forall uint256 a. forall uint256 b. forall uint256 d. d > 0 && b <= d => ghostMulDivUp(a, b, d) <= a; - // mulDivZero + // Proved in Muldiv.spec : mulDivZero axiom forall uint256 a. forall uint256 d. d > 0 => ghostMulDivUp(0, a, d) == 0 && ghostMulDivUp(a, 0, d) == 0; } @@ -99,27 +99,27 @@ function summaryMulDivUp(uint256 x, uint256 y, uint256 d) returns uint256 { return ghostMulDivUp(x, y, d); } -/// Proven in CollateralBitmap.spec; excluded from this conf and only assumed via requireInvariant (sound). +/// Proven in CollateralBitmap.spec; excluded from this conf and only assumed via requireInvariant. strong invariant nonZeroCollateralsAreActivated(bytes32 id, address user, uint256 collateralIndex) collateralIndex < 128 => (collateral(id, user, collateralIndex) != 0 <=> summaryGetBit(currentContract.position[id][user].collateralBitmap, collateralIndex)); -/// Per-collateral validity. lltv/maxLif bounds are theorems about created markets (CreatedMarkets.spec, ExactMath.spec). +/// Per-collateral validity. lltv/maxLif bounds are verified on created markets (CreatedMarkets.spec, ExactMath.spec). /// Only the collat*price bound is a LIVENESS no-overflow assumption. function validCollateralAt(Midnight.Market market, bytes32 id, address borrower, uint256 i) { uint256 lltv = market.collateralParams[i].lltv; uint256 maxLif = market.collateralParams[i].maxLif; - require lltv > 0 && lltv <= WAD(), "lltv in (0, WAD] for a created market (createdMarketsHaveAllowedLltv, createdMarketsHaveLltvLessThanOrEqualToOne)"; + require lltv > 0 && lltv <= WAD(), "lltv in (0, WAD] for a created market (in CreatedMarkets.spec)"; require maxLif >= WAD(), "maxLif >= WAD (maxLifIsAtLeastWad)"; require lltv < WAD() => lltv * maxLif <= WAD() * (WAD() - 1), "lltv < WAD => lltv*maxLif <= WAD*(WAD-1) (lifTimesLltvStrictBound)"; require lltv * maxLif <= WAD() * WAD(), "lltv*maxLif <= WAD*WAD (lifTimesLltvIsLessThanOrEqualToOne)"; address oracle = market.collateralParams[i].oracle; - require collateral(id, borrower, i) * ghostPrice(oracle) + ORACLE_PRICE_SCALE() - 1 <= max_uint256, "collat*price fits in uint256 for mulDivUp(price, ORACLE_PRICE_SCALE) (LIVENESS)"; + require collateral(id, borrower, i) * ghostPrice(oracle) + ORACLE_PRICE_SCALE() - 1 <= max_uint256, "collat*price fits in uint256 for mulDivUp(price, ORACLE_PRICE_SCALE)"; } -/// Shared setup: 3-collateral market with at most collaterals 0,1,2 active (loop runs <= loop_iter), well-behaved env, -/// no liquidator gate, unlocked, positive debt, totalUnits/withdrawable bounds (Midnight.spec). Does not assume which collateral is active. +/// Shared setup: 3-collateral market with at most collaterals 0,1,2 active, well-behaved env, no liquidator gate, +/// unlocked, positive debt, totalUnits/withdrawable bounds (Midnight.spec). function threeCollatSetup(env e, Midnight.Market market, bytes32 id, address borrower) { require market.collateralParams.length == 3, "three-collateral market (borrower activates 0, 1, 2 or 3 of them)"; uint128 bitmap = collateralBitmap(id, borrower); @@ -130,7 +130,7 @@ function threeCollatSetup(env e, Midnight.Market market, bytes32 id, address bor validCollateralAt(market, id, borrower, 2); require e.msg.value == 0, "liquidate is non-payable"; - require market.liquidatorGate == 0, "no liquidator gate (LIVENESS)"; + require market.liquidatorGate == 0, "no liquidator gate"; require e.block.timestamp <= max_uint64, "timestamp fits in uint64"; require market.maturity <= max_uint64, "maturity fits in uint64"; @@ -154,8 +154,8 @@ function seizablePreamble(env e, Midnight.Market market, bytes32 id, address bor require (collateralIndex == 0 ? ghostPrice(market.collateralParams[0].oracle) : collateralIndex == 1 ? ghostPrice(market.collateralParams[1].oracle) : ghostPrice(market.collateralParams[2].oracle)) > 0, "the seized collateral is priced (LIVENESS)"; } -/// maxDebt = sum over all collaterals of collat * price * lltv (down-rounded). An inactive collateral's term -/// vanishes (its collat == 0 by nonZeroCollateralsAreActivated). +/// maxDebt = sum over all collaterals of collat * price * lltv (down-rounded). +/// An inactive collateral's term vanishes (its collat == 0 by nonZeroCollateralsAreActivated). function maxDebtSum(Midnight.Market market, bytes32 id, address borrower) returns mathint { mathint contrib0 = ghostMulDivDown(ghostMulDivDown(collateral(id, borrower, 0), ghostPrice(market.collateralParams[0].oracle), ORACLE_PRICE_SCALE()), market.collateralParams[0].lltv, WAD()); mathint contrib1 = ghostMulDivDown(ghostMulDivDown(collateral(id, borrower, 1), ghostPrice(market.collateralParams[1].oracle), ORACLE_PRICE_SCALE()), market.collateralParams[1].lltv, WAD()); @@ -174,8 +174,8 @@ function debtAfterBadDebt(Midnight.Market market, bytes32 id, address borrower) } /// Scaffolding facts for the lltv < WAD maxRepaid computation: denominator positive (WAD*WAD - maxLif*lltv >= WAD -/// from validCollateralAt) and per-collateral recovery >= maxDebt contribution (so debtAfter >= maxDebt). The -/// seized collateral additionally gets a strict recovery > contribution, forcing debtAfter > maxDebt, hence +/// from validCollateralAt) and per-collateral recovery >= maxDebt contribution (so debtAfter >= maxDebt). +/// The seized collateral additionally gets a strict recovery > contribution, forcing debtAfter > maxDebt, hence /// maxRepaid >= 1 (keeps the safe interval non-vacuous for any active seized collateral with quote >= 1 unit). function lowLltvScaffolding(Midnight.Market market, bytes32 id, address borrower, uint256 collateralIndex) { uint256 lltv0 = market.collateralParams[0].lltv; @@ -193,7 +193,7 @@ function lowLltvScaffolding(Midnight.Market market, bytes32 id, address borrower uint256 price2 = ghostPrice(market.collateralParams[2].oracle); uint128 collat2 = collateral(id, borrower, 2); - // recovery_i >= maxDebtContrib_i for every collateral (sound theorem from validCollateralAt; ensures debtAfter >= maxDebt). + // recovery_i >= maxDebtContrib_i for every collateral require ghostMulDivUp(ghostMulDivUp(collat0, price0, ORACLE_PRICE_SCALE()), WAD(), maxLif0) >= ghostMulDivDown(ghostMulDivDown(collat0, price0, ORACLE_PRICE_SCALE()), lltv0, WAD()), "recovery0 >= maxDebtContrib0 (any valid collateral, incl. inactive)"; require ghostMulDivUp(ghostMulDivUp(collat1, price1, ORACLE_PRICE_SCALE()), WAD(), maxLif1) >= ghostMulDivDown(ghostMulDivDown(collat1, price1, ORACLE_PRICE_SCALE()), lltv1, WAD()), "recovery1 >= maxDebtContrib1 (any valid collateral, incl. inactive)"; require ghostMulDivUp(ghostMulDivUp(collat2, price2, ORACLE_PRICE_SCALE()), WAD(), maxLif2) >= ghostMulDivDown(ghostMulDivDown(collat2, price2, ORACLE_PRICE_SCALE()), lltv2, WAD()), "recovery2 >= maxDebtContrib2 (any valid collateral, incl. inactive)"; @@ -398,5 +398,5 @@ rule badDebtCanBeLiquidated(env e, Midnight.Market market, address borrower, add bytes data; liquidate@withrevert(e, market, 0, 0, 0, borrower, postMaturityMode, receiver, 0, data); assert !lastReverted; - assert debtOf(id, borrower) == debtAfter; + assert debtAfter < _debt => debtOf(id, borrower) < _debt; }