Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a43821d
[Certora] Guard liquidateAtCapRestoresHealth against bad-debt vacuity
claude Aug 4, 2026
9be13e8
[Certora] Prove liquidateAtCapRestoresHealth is revert-free
claude Aug 4, 2026
0255092
[Certora] Add liquidate's non-revert preconditions for revert-freedom
claude Aug 4, 2026
4eefdb3
[Certora] Model mulDiv overflow deterministically for revert-freedom
claude Aug 4, 2026
cb871fb
[Certora] Require msg.value == 0 for liquidate revert-freedom
claude Aug 4, 2026
ea34470
[Certora] Clamp maxRepaidFor to debt; drop the repaid < debt require
claude Aug 5, 2026
8a863da
[Certora] Generalize liquidateAtCapRestoresHealth to 1 or 2 collaterals
claude Aug 5, 2026
fbb3714
[Certora] Fix bitmap pin for 1/2-collat generalization (ground facts)
claude Aug 5, 2026
9e3e2ae
[Certora] Split liquidateAtCap rule into health + liveness
claude Aug 5, 2026
de0d3b7
[Certora] Restack on the review cleanups of the base PR
claude Aug 27, 2026
b77d2a8
Merge the base branch (main merge + review cleanups) into the bad-deb…
claude Aug 27, 2026
2433e56
[Certora] Align MaxRepaidHealthy with the shared mulDiv axioms
claude Aug 27, 2026
0461411
Merge the base branch, and correct the transfer-summary justification
claude Aug 27, 2026
d9c8093
Merge the base branch, de-duplicating the shared mulDiv-axiom refactor
claude Aug 31, 2026
962c216
[Certora] Keep the base branch's one-line rule comment
claude Aug 31, 2026
82188ca
[Certora] Merge jhoenicke's axiom comments from #1083 base
claude Aug 31, 2026
6473734
[Certora] Merge #1083 af632555 (cur -> old rename) into bad-debt guard
claude Aug 31, 2026
bf3a01c
Merge branch 'claude/certora-max-repaid-healthy' into claude/max-repa…
claude Sep 1, 2026
09a753b
Merge remote-tracking branch 'origin/claude/certora-max-repaid-health…
claude Sep 1, 2026
ead78c4
docs(certora): cover liveness rule and single-collateral scope
claude Sep 1, 2026
22306e1
Merge branch 'main' into claude/max-repaid-healthy-baddebt-guard
claude Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions certora/helpers/MidnightWrapper.sol
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ contract MidnightWrapper is Midnight {

/* maxRepaidFor recomputes the repay-cap-factor cap of Midnight.liquidate through a bitmap-free,
* array-based code path. maxDebt is summed exactly as in isHealthyNoBitmap and the liquidate bad-debt
* loop, then the cap's mulDivUp is applied with lif = maxLif (normal mode).
* loop, then the cap's mulDivUp is applied with lif = maxLif (normal mode). The result is clamped to the
* debt: a liquidator can never repay more than the outstanding debt, so the returned cap is always
* <= debt by construction (which is why the rule needs no separate repaid <= debt bound).
* Expects the position to be unhealthy (debt > maxDebt) so that debt - maxDebt does not underflow. */
function maxRepaidFor(Market memory market, bytes32 id, uint256 collateralIndex, address borrower)
public
Expand All @@ -56,7 +58,27 @@ contract MidnightWrapper is Midnight {
CollateralParams memory liquidatedParam = market.collateralParams[collateralIndex];
uint256 lltv = liquidatedParam.lltv;
uint256 lif = maxLif(lltv, liquidatedParam.liquidationCursor);
return (debt - maxDebt).mulDivUp(WAD * WAD, WAD * WAD - lif * lltv);
uint256 maxRepaid = (debt - maxDebt).mulDivUp(WAD * WAD, WAD * WAD - lif * lltv);
return UtilsLib.min(maxRepaid, debt);
}

/* badDebtFor recomputes the badDebt of Midnight.liquidate through a bitmap-free, array-based code path.
* Used to pin the no-bad-debt case (badDebtFor == 0), under which liquidate does not reduce the position
* debt before computing the RCF cap. */
function badDebtFor(Market memory market, bytes32 id, address borrower) public view returns (uint256) {
Position storage _position = position[id][borrower];
uint256 badDebt = _position.debt;
uint256 len = market.collateralParams.length;
for (uint256 i = len; i > 0;) {
i--;
CollateralParams memory collateralParam = market.collateralParams[i];
uint256 price = IOracle(collateralParam.oracle).price();
badDebt = badDebt.zeroFloorSub(
_position.collateral[i].mulDivUp(price, ORACLE_PRICE_SCALE)
.mulDivUp(WAD, maxLif(collateralParam.lltv, collateralParam.liquidationCursor))
);
}
return badDebt;
}

// This realizableBadDebt function recomputes, verbatim, the badDebt local that
Expand Down
171 changes: 150 additions & 21 deletions certora/specs/MaxRepaidHealthy.spec
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ methods {
function multicall(bytes[]) external => HAVOC_ALL DELETE;

function collateral(bytes32 id, address user, uint256) external returns (uint128) envfree;
function collateralBitmap(bytes32 id, address user) external returns (uint128) envfree;
function debt(bytes32 id, address user) external returns (uint128) envfree;
function isHealthyNoBitmap(Midnight.Market, bytes32, address) external returns (bool) envfree;
function maxRepaidFor(Midnight.Market, bytes32, uint256, address) external returns (uint256) envfree;
function badDebtFor(Midnight.Market, bytes32, address) external returns (uint256) envfree;
function liquidationLocked(bytes32, address) external returns (bool) envfree;

// Assumption: price does not change during the rule (same value in maxRepaidFor, in liquidate and in the
// post-state isHealthyNoBitmap). Deterministic per oracle address, as in Healthiness.spec.
Expand All @@ -30,17 +33,27 @@ methods {
function IdLib.toId(Midnight.Market memory market) internal returns (bytes32) => summaryToId(market);
function IdLib.storeInCode(Midnight.Market memory) internal returns (address) => NONDET;

// Summarizing mulDivDown and mulDivUp by unconstrained deterministic ghosts adds no assumption about
// mulDiv: the ghosts are arbitrary, and the summaries revert on a nondeterministic overflow flag, so
// every real mulDiv behaviour is still allowed. All the arithmetic the rule actually needs is required
// explicitly below, one ground instance per rule proved over the concrete mulDiv in MulDiv.spec.
// Summarizing mulDivDown and mulDivUp by the shared ghosts of MulDivAxioms.spec adds no assumption
// about mulDiv: the ghosts are arbitrary, and the summaries reproduce exactly the revert condition of the
// real mulDiv, so every real mulDiv behaviour is still allowed. All the arithmetic the rules need is
// required explicitly below as ground instances of the axioms of MulDivAxioms.spec, each proved over the
// real math functions in MulDiv.spec. Those axioms are stated over mathint, so requiring them never
// prunes a case where an intermediate sum or product exceeds 256 bits.
function UtilsLib.mulDivDown(uint256 x, uint256 y, uint256 d) internal returns (uint256) => summaryMulDivDown(x, y, d);
function UtilsLib.mulDivUp(uint256 x, uint256 y, uint256 d) internal returns (uint256) => summaryMulDivUp(x, y, d);

// maxLif is deterministic for each (lltv, liquidationCursor) pair.
function maxLif(uint256 lltv, uint256 liquidationCursor) internal returns (uint256) => maxLifGhost(lltv, liquidationCursor);

// Unresolved callbacks and token calls use AUTO/HAVOC_ECF, which models non-reentrant callees.
// Token transfers move external ERC20 balances only, never the borrower's position storage, so summarizing
// them as non-reverting no-ops is sound for this direction. liquidateAtCapDoesNotRevert additionally
// constrains liquidatorGate == 0 and callback == 0, so canLiquidate / onLiquidate are never reached there.
// liquidateAtCapRestoresHealth constrains neither, so a reentrant callee is not modelled: reentrancy is out
// of scope for these rules and is covered by Reentrancy.spec and Healthiness.spec.
function SafeTransferLib.safeTransfer(address, address, uint256) internal => NONDET;
function SafeTransferLib.safeTransferFrom(address, address, address, uint256) internal => NONDET;
Comment on lines +53 to +54

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve transfer failures in the liveness rule

When the loan or collateral token rejects a transfer—for example because the liquidator lacks balance or allowance—the real liquidate call reverts in safeTransfer or safeTransferFrom, but these non-reverting summaries prevent liquidate@withrevert from exploring that branch. This is acceptable for the restoration rule's successful-path assertion, but it makes the new universal no-revert assertion unsound unless token-transfer success is explicitly part of the rule's stated and modeled preconditions.

AGENTS.md reference: AGENTS.md:L45-L50

Useful? React with 👍 / 👎.

function _.transferFrom(address from, address to, uint256 amount) external => NONDET;
function _.transfer(address to, uint256 amount) external => NONDET;
}

/// SUMMARY ///
Expand Down Expand Up @@ -73,13 +86,13 @@ persistent ghost address globalMarketLoanToken;

persistent ghost uint256 globalMarketChainId;

// Exactly two collaterals is not a restriction on the result. Liquidating touches a single collateral, so the
// At most two collaterals is not a restriction on the result. Liquidating touches a single collateral, so the
// whole contribution of every other collateral to maxDebt enters the reasoning as one arbitrary non-negative
// value, and one extra collateral with an arbitrary amount, price and LLTV already realizes every such value.
// The second collateral therefore plays the role of the arbitrary otherCollatContribution of the Rocq proof,
// and a market with more collaterals is covered by the same argument.
persistent ghost uint256 globalMarketCollateralLength {
axiom globalMarketCollateralLength == 2;
axiom globalMarketCollateralLength <= 2;
}

persistent ghost mapping(uint256 => address) globalMarketCollateralOracle;
Expand Down Expand Up @@ -126,23 +139,62 @@ function summaryToId(Midnight.Market market) returns (bytes32) {

/// RULE ///

// In a two-collateral market, liquidating at the amount computed by maxRepaidFor leaves the position healthy.
// The call uses normal mode and covers the strictly unhealthy and health-boundary cases. See the
// globalMarketCollateralLength axiom for why two collaterals is general enough.
// RESTORATION. In a single- or two-collateral, RCF-active, no-bad-debt market, liquidating at the amount
// computed by maxRepaidFor leaves the borrower healthy. The liquidate call is PLAIN (normal mode), so only
// non-reverting executions are asserted healthy; the companion rule liquidateAtCapDoesNotRevert proves that the
// liquidation actually succeeds. Covers the strictly-unhealthy case (the boundary is healthy already).
// See the globalMarketCollateralLength axiom for why at most two collaterals is general enough.
rule liquidateAtCapRestoresHealth(env e, uint256 collateralIndex, address borrower, address receiver, address callback, bytes data) {
Comment thread
claude[bot] marked this conversation as resolved.
Midnight.Market globalMarket = getGlobalMarket();

require globalMarketCollateralLength == 1 || globalMarketCollateralLength == 2, "single- or two-collateral market";

// No bad debt is realized, so liquidate does not reduce the position debt before computing the RCF cap, and
// maxRepaidFor reproduces that cap from the same debt (Rocq assumes no bad debt).
require badDebtFor(globalMarket, globalId, borrower) == 0, "no bad debt realized";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the no-bad-debt verification scope

This assumption materially narrows the property, but certora/README.md:40-41 still claims that the spec covers an unhealthy borrower generally and that repaying maxRepaid restores health. The description should disclose that positions with realizable bad debt are excluded, along with the rule's other RCF and market-size restrictions, so readers do not overestimate the formal coverage.

AGENTS.md reference: AGENTS.md:L72-L75

Useful? React with 👍 / 👎.


uint256 collatBefore = collateral(globalId, borrower, collateralIndex);
uint256 debtBefore = debt(globalId, borrower);

// This rule checks that using `repaidUnits == maxRepaid` is enough to put the account healthy. This means that the RCF doesn't prevent to put the position back to health.
uint256 repaidUnits = maxRepaidFor(globalMarket, globalId, collateralIndex, borrower);

// maxRepaidFor's non-reverting collateral lookup establishes collateralIndex < 2.
uint256 otherIndex = assert_uint256(1 - collateralIndex);
uint256 otherCollatBefore = collateral(globalId, borrower, otherIndex);
uint256 otherLltv = globalMarketCollateralLLTV[otherIndex];
uint256 otherPrice = summaryPrice(globalMarket.collateralParams[otherIndex].oracle);
// maxRepaidFor's non-reverting collateral lookup establishes collateralIndex < globalMarketCollateralLength.
uint256 lltv = globalMarketCollateralLLTV[collateralIndex];
uint256 lif = maxLifGhost(lltv, globalMarketCollateralLiquidationCursor[collateralIndex]);
uint256 price = summaryPrice(globalMarket.collateralParams[collateralIndex].oracle);

// Restoration hypotheses (Rocq): strictly unhealthy, RCF active (lltv < WAD) with a positive RCF cap
// denominator, and a positive price. No revert-clearing guards are needed here: the plain call keeps only
// the non-reverting executions.
require !isHealthyNoBitmap(globalMarket, globalId, borrower), "strictly unhealthy: debt > maxDebt";
require lltv < WAD(), "RCF is active only for lltv < WAD";
require lif * lltv <= 999 * 10 ^ 15 * WAD(), "maxLif * lltv <= 0.999 * WAD^2: RCF denominator positive";
require price > 0, "positive liquidated-collateral price";

// The other collateral's contribution to maxDebt exists only in a two-collateral market (the Rocq
// otherCollatContribution); in a single-collateral market it is 0.
mathint otherContrib = 0;
if (globalMarketCollateralLength == 2) {
uint256 otherIndex = assert_uint256(1 - collateralIndex);
uint256 otherCollatBefore = collateral(globalId, borrower, otherIndex);
uint256 otherLltv = globalMarketCollateralLLTV[otherIndex];
uint256 otherPrice = summaryPrice(globalMarket.collateralParams[otherIndex].oracle);
otherContrib = ghostMulDivDown(ghostMulDivDown(otherCollatBefore, otherPrice, ORACLE_PRICE_SCALE()), otherLltv, WAD());
}

// The activated collaterals are exactly [0, globalMarketCollateralLength), so liquidate's bitmap maxDebt /
// badDebt loop ranges over the same indices as the array-based maxRepaidFor / isHealthyNoBitmap. Pinned
// with ground facts per index (no ghost-bounded quantifier, which the solver would not instantiate at the
// loop's concrete bits) for each supported market size.
uint128 bitmap = collateralBitmap(globalId, borrower);
require summaryGetBit(bitmap, 0), "collateral 0 is activated";
if (globalMarketCollateralLength == 2) {
require summaryGetBit(bitmap, 1), "collateral 1 is activated (two-collateral market)";
require forall uint256 otherBit. otherBit != 0 && otherBit != 1 => !summaryGetBit(bitmap, otherBit), "only the two collaterals are activated";
} else {
require forall uint256 otherBit. otherBit != 0 => !summaryGetBit(bitmap, otherBit), "single-collateral: only collateral 0 is activated";
}

uint256 seizedOut;
uint256 repaidOut;
Expand All @@ -154,12 +206,9 @@ rule liquidateAtCapRestoresHealth(env e, uint256 collateralIndex, address borrow
/// MAX-DEBT DROP BOUND ///
// Establish oldContrib - newContrib <= maxDebtDropBound. When it seizes, liquidate computes
// seizedOut = floor(floor(repaidUnits * lif / WAD) * ORACLE_PRICE_SCALE / price), matching the ghost terms
// below. Each require is one ground instance of a rule proved in MulDiv.spec.
// below. Each require is one ground instance of an axiom of MulDivAxioms.spec, proved in MulDiv.spec.

uint256 lltv = globalMarketCollateralLLTV[collateralIndex];
uint256 lif = maxLifGhost(lltv, globalMarketCollateralLiquidationCursor[collateralIndex]);
mathint maxSeizedValue = ghostMulDivDown(repaidUnits, lif, WAD());
uint256 price = summaryPrice(globalMarket.collateralParams[collateralIndex].oracle);

// By that same computation, seizedOut == ghostMulDivDown(maxSeizedValue, ORACLE_PRICE_SCALE(), price).
mathint oldCollatValue = ghostMulDivDown(collatBefore, price, ORACLE_PRICE_SCALE());
Expand Down Expand Up @@ -191,8 +240,6 @@ rule liquidateAtCapRestoresHealth(env e, uint256 collateralIndex, address borrow
// repaidUnits is ceil(gap * WAD^2 / (WAD^2 - lif * lltv)). The two rounding facts below imply
// maxDebtDropBound <= repaidUnits - gap. Therefore the new max debt falls by no more than the amount
// repaid in excess of the old health gap.
mathint otherCollatValue = ghostMulDivDown(otherCollatBefore, otherPrice, ORACLE_PRICE_SCALE());
mathint otherContrib = ghostMulDivDown(otherCollatValue, otherLltv, WAD());
mathint maxDebtBefore = oldContrib + otherContrib;

mathint gap = debtBefore - maxDebtBefore;
Expand All @@ -203,3 +250,85 @@ rule liquidateAtCapRestoresHealth(env e, uint256 collateralIndex, address borrow

assert isHealthyAfter;
}

// LIVENESS. In the same single- or two-collateral, RCF-active, no-bad-debt regime, liquidating at maxRepaidFor
// does not revert. Mirrors LossFactor.spec's liquidateLossFactorDoesNotRevert. This rule proves only no-revert;
// the restoration of health is proved by liquidateAtCapRestoresHealth.
rule liquidateAtCapDoesNotRevert(env e, uint256 collateralIndex, address borrower, address receiver, address callback, bytes data) {
Midnight.Market globalMarket = getGlobalMarket();

require globalMarketCollateralLength == 1 || globalMarketCollateralLength == 2, "single- or two-collateral market";

// No bad debt is realized, so the bad-debt socialization block of liquidate is skipped.
require badDebtFor(globalMarket, globalId, borrower) == 0, "no bad debt realized";

uint256 collatBefore = collateral(globalId, borrower, collateralIndex);
uint256 debtBefore = debt(globalId, borrower);
uint256 repaidUnits = maxRepaidFor(globalMarket, globalId, collateralIndex, borrower);

uint256 lltv = globalMarketCollateralLLTV[collateralIndex];
uint256 lif = maxLifGhost(lltv, globalMarketCollateralLiquidationCursor[collateralIndex]);
uint256 price = summaryPrice(globalMarket.collateralParams[collateralIndex].oracle);

// liquidate's own require guards + RCF-active regime.
require e.msg.value == 0, "Midnight is not payable (liquidate is non-payable)";
require currentContract.marketState[globalId].tickSpacing != 0, "market already created (touchMarket is a no-op)";
require globalMarketLiquidatorGate == 0, "no liquidator gate";
require callback == 0, "no liquidate callback";
require !liquidationLocked(globalId, borrower), "borrower not liquidation-locked";
require !isHealthyNoBitmap(globalMarket, globalId, borrower), "strictly unhealthy: debt > maxDebt";
require lltv < WAD(), "RCF is active only for lltv < WAD";
require lif * lltv <= 999 * 10 ^ 15 * WAD(), "maxLif * lltv <= 0.999 * WAD^2: RCF denominator positive";
require price > 0, "positive liquidated-collateral price";

// No-overflow regime (Midnight LIVENESS: the checked mulDiv reverts on 256-bit overflow). Every checked
// product in liquidate's maxDebt / badDebt / seize / RCF computations stays within 256 bits, and every
// maxLif denominator is positive, so no summarized mulDiv takes its overflow / d == 0 revert branch.
require lif > 0, "positive maxLif for the liquidated collateral (created-market invariant)";
require lif <= 2 * WAD(), "maxLif <= 2 * WAD";
require collatBefore * price + ORACLE_PRICE_SCALE() <= max_uint256, "collateral value (index) fits 256 bits";
require ghostMulDivDown(collatBefore, price, ORACLE_PRICE_SCALE()) * lltv <= max_uint256, "maxDebt term (index) fits 256 bits";
require ghostMulDivUp(collatBefore, price, ORACLE_PRICE_SCALE()) * WAD() + lif <= max_uint256, "badDebt term (index) fits 256 bits";
require repaidUnits * lif <= max_uint256, "maxSeizedValue product fits 256 bits";
require ghostMulDivDown(repaidUnits, lif, WAD()) * ORACLE_PRICE_SCALE() <= max_uint256, "seized value fits 256 bits";

// The other collateral's no-overflow bounds are needed only when liquidate's loop visits that collateral.
if (globalMarketCollateralLength == 2) {
uint256 otherIndex = assert_uint256(1 - collateralIndex);
uint256 otherCollatBefore = collateral(globalId, borrower, otherIndex);
uint256 otherLltv = globalMarketCollateralLLTV[otherIndex];
uint256 otherPrice = summaryPrice(globalMarket.collateralParams[otherIndex].oracle);
uint256 maxLifOther = maxLifGhost(otherLltv, globalMarketCollateralLiquidationCursor[otherIndex]);
require maxLifOther > 0, "positive maxLif for the other collateral (created-market invariant)";
require otherCollatBefore * otherPrice + ORACLE_PRICE_SCALE() <= max_uint256, "collateral value (other) fits 256 bits";
require ghostMulDivDown(otherCollatBefore, otherPrice, ORACLE_PRICE_SCALE()) * otherLltv <= max_uint256, "maxDebt term (other) fits 256 bits";
require ghostMulDivUp(otherCollatBefore, otherPrice, ORACLE_PRICE_SCALE()) * WAD() + maxLifOther <= max_uint256, "badDebt term (other) fits 256 bits";
}

// The activated collaterals are exactly [0, globalMarketCollateralLength): liquidate's bitmap loop ranges
// over the same indices as maxRepaidFor / badDebtFor, and the liquidatable check reads the same maxDebt.
// Ground-fact pin per supported market size.
uint128 bitmap = collateralBitmap(globalId, borrower);
require summaryGetBit(bitmap, 0), "collateral 0 is activated";
if (globalMarketCollateralLength == 2) {
require summaryGetBit(bitmap, 1), "collateral 1 is activated (two-collateral market)";
require forall uint256 otherBit. otherBit != 0 && otherBit != 1 => !summaryGetBit(bitmap, otherBit), "only the two collaterals are activated";
} else {
require forall uint256 otherBit. otherBit != 0 => !summaryGetBit(bitmap, otherBit), "single-collateral: only collateral 0 is activated";
}

// The seized collateral does not exceed the position collateral, so liquidate's collateral subtraction
// does not underflow. Left as an explicit hypothesis pending review; implied by no bad debt + the
// maxRepaidFor debt clamp.
require collatBefore >= ghostMulDivDown(ghostMulDivDown(repaidUnits, lif, WAD()), ORACLE_PRICE_SCALE(), price), "seized <= collateral";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prove rather than assume sufficient selected collateral

In a two-collateral position, zero bad debt only says the aggregate collateral can cover the debt; it does not ensure the selected collateral can cover the seizure. For example, the borrower can have a tiny amount at collateralIndex and enough value in the other collateral to satisfy badDebtFor == 0, while the cap-derived seizure exceeds the selected amount and liquidate reverts at its collateral subtraction. This require removes exactly those counterexamples, so the new no-revert rule does not establish its stated regime; either prove the bound from stronger valid hypotheses or explicitly scope the property to sufficiently funded selected collateral.

AGENTS.md reference: AGENTS.md:L10-L11

Useful? React with 👍 / 👎.


// No overflow of the market's withdrawable when the repaid units are credited.
require currentContract.marketState[globalId].withdrawable + repaidUnits <= max_uint128, "withdrawable credit does not overflow";

uint256 seizedOut;
uint256 repaidOut;
seizedOut, repaidOut = liquidate@withrevert(e, globalMarket, collateralIndex, 0, repaidUnits, borrower, false, receiver, callback, data);

// Liquidating at the RCF cap succeeds: repaidUnits <= maxRepaid satisfies liquidate's RCF require.
assert !lastReverted, "liquidating at the RCF cap does not revert";
}