Contract fails to deliver promised returns, but doesn't lose value
Description
Brief/Intro
l would say this bug breaks an important invariant as the stake of every cdp is supposed to be updated on every cdp operation, duo to the fact that the stake ratio always changes with every split fee. This issue will allow the user to pay less or simply wrong collateral fee onwards as the split fee owned by the cdp is calculated based on its stake.
Vulnerability Details
The total stake variable is used for the determination of the correct amount of collateral fee that needs to be payed per unit staked and every cdp has an amount of stake which correspondents to the amount of collateral the position has. As the system takes its split fee from the total collateral shares, this stake ratio changes as a result when doing any cdp operation the system correctly syncs and adjusts the collateral shares of the cdp and updates its stake based on the new stake ratio.
By going over the protocol logic, we can notice that on every cdp operation the system updates the cdp's stake:
When adjusting a cdp the system calculates and updates the cdp's stake based on the latest stake ratio and the amount of coll shares the cdp is left with after the adjusting.
When partial liquidating a cdp the system calculates and updates the cdp's stake based on the latest stake ratio and the amount of coll shares the cdp is left with after the partial liquidation.
When successfully partially redeeming from a cdp the system calculates and updates the cdp's stake based on the latest stake ratio and the amount of coll shares the cdp is left with after the partial redemption.
The problem we are facing occurs when partial redeeming, before every redemption happens the system successfully syncs the accounting of the particular cdp to accordingly adjust its collateral shares for the redemption. Currently there could be two outcomes when partial redeeming:
The partial redemption is successful as a result the system updates the cdp's accounting to correspondent the new values the position has.
The partial redemption is canceled, this can happen when the user provides a wrong NICR hint or if the partial redeeming either drops the cdp collateral below the minimum balance of 2 stETH or if its debt drops below the minimum change of 1000 wei.
In a case when partial redeeming is canceled the system doesn't revert but returns, in this case the particular cdp will keep its synced stats but the system misses to update its stake to correspondent the synced collateral shares and the latest stake ratio. This can be problematic considering that the split fee the cdp owns will be calculated based on the wrong stake when the next positive rebase happens.
} else {// Debt remains, reinsert Cdpuint256 newNICR = EbtcMath._computeNominalCR(newColl, newDebt);/* * If the provided hint is out of date, we bail since trying to reinsert without a good hint will almost * certainly result in running out of gas. * * If the resultant net coll of the partial is less than the minimum, we bail. */if ( newNICR != _redeemColFromCdp.partialRedemptionHintNICR || collateral.getPooledEthByShares(newColl) < MIN_NET_STETH_BALANCE || newDebt < MIN_CHANGE ) { singleRedemption.cancelledPartial =true;return singleRedemption; } singleRedemption.newPartialNICR = newNICR; Cdps[_redeemColFromCdp.cdpId].debt = newDebt; Cdps[_redeemColFromCdp.cdpId].coll = newColl;_updateStakeAndTotalStakes(_redeemColFromCdp.cdpId);emitCdpUpdated( _redeemColFromCdp.cdpId,ISortedCdps(sortedCdps).getOwnerAddress(_redeemColFromCdp.cdpId), msg.sender, _oldDebtAndColl.debt, _oldDebtAndColl.collShares, newDebt, newColl, Cdps[_redeemColFromCdp.cdpId].stake, CdpOperation.redeemCollateral ); }
Impact Details
The stake functionality is crucial for the protocol as both the split fee and bad debt is calculated based on it. So it is mandatory to keep this value of stake as accurate as possible. Not updating a cdp stake after syncing the accounting may lead to the cdp paying a wrong amount of split fee or bad debt next time. As mentioned in my brief/info section this is more like an invariant that needs to hold as on every cdp operation the system updates the cdp's stake.
// SPDX-License-Identifier: UNLICENSEDpragmasolidity 0.8.17;import"forge-std/Test.sol";import {eBTCBaseInvariants} from"./BaseInvariants.sol";contractstormyiseBTCBaseInvariants {address wallet =address(0xbad455);uint256 yearlyIncrease =36000000000000000;uint256 dailyIncrease = yearlyIncrease /365;uint256 ethPerShare =1e18;bytes32 newCdp;bytes32 partialCdp;uint256 price;uint256 grossColl;uint256 debtMCR;uint256 debtColl;bytes32 last;functionsetUp() publicoverride { super.setUp();connectCoreContracts();connectLQTYContractsToCore();}functiontestSortingForPartialRedemption() public {// fetch the price price = priceFeedMock.fetchPrice();// calculate the net coll + liquidator reward grossColl =10e18+ cdpManager.LIQUIDATOR_REWARD();// calculate the debt allowed with collateral ratio of 110% debtMCR = _utils.calculateBorrowAmount(10e18, price, MINIMAL_COLLATERAL_RATIO);// calculate the debt allowed with collateral ratio of 125% debtColl = _utils.calculateBorrowAmount(10e18, price, COLLATERAL_RATIO);// open 5 cdps in the system_openTestCDP(wallet, grossColl, debtColl -20000);_openTestCDP(wallet, grossColl, debtColl -10000);_openTestCDP(wallet, grossColl, debtColl -7500); partialCdp =_openTestCDP(wallet, grossColl, debtColl);_openTestCDP(wallet, grossColl, debtMCR); vm.startPrank(wallet);// approve ebtc token to cdp manager eBTCToken.approve(address(cdpManager), eBTCToken.balanceOf(wallet));// increase the eth per share with one day split fee collateral.setEthPerShare(ethPerShare + dailyIncrease);// get the last Cdp in the system last = sortedCdps.getLast();// record the old coll and stake of the partial redeemed cdpuint256 oldCollBefore = cdpManager.getCdpCollShares(partialCdp);uint256 oldStakeBefore = cdpManager.getCdpStake(partialCdp);// sync the debt twap spot value_syncSystemDebtTwapToSpotValue();// fully redeem the last cdp and try to partial redeem but cancel with wrong NICR cdpManager.redeemCollateral( debtMCR + (debtColl /2), last,bytes32(0),bytes32(0),0,0,1e18 ); vm.stopPrank();// record the new coll and stake after the canceled partial redeeminguint256 newCollAfter = cdpManager.getCdpCollShares(partialCdp);uint256 newStakeAfter = cdpManager.getCdpStake(partialCdp);// ensure that the last cdp was fully redeemedassert(!sortedCdps.contains(last));// console log the old and new stats of the canceled cdp console.log("================Before"); console.log("CollBefore :", oldCollBefore); console.log("StakeBefore :", oldStakeBefore); console.log("================After"); console.log("CollAfter :", newCollAfter); console.log("StakeAfter :", newStakeAfter);// fetch the stake and coll snapshotsuint256 stakeSnapshot = cdpManager.totalStakesSnapshot();uint256 collSnapshot = cdpManager.totalCollateralSnapshot();// calculate the correct stake based on the cdp new coll and latest stake ratiouint256 correctStake = (newCollAfter * stakeSnapshot) / collSnapshot;// console log the correct stake console.log("====================="); console.log("CorrectStake :", correctStake);// The diference is based on 5 cdps in the system and only one daily increase.}}