Contract fails to deliver promised returns, but doesn't lose value
Description
Brief/Intro
The _forceRepay function in the AlchemistV3.sol contract fails to update the global cumulativeEarmarked variable when repaying earmarked debt during liquidations, causing an inflated value that disrupts debt accounting. This leads to incorrect liveUnearmarked calculations, skewing debt earmarking and collateral allocation. In normal operations with high totalDebt, the issue persists without triggering the clamping mechanism in _subDebt.
Vulnerability Details
The protocol tracks debt in two ways: totalDebt (total outstanding debt across all positions) and cumulativeEarmarked (total debt earmarked for redemption). Each position has a local account.earmarked value, representing the portion of its debt earmarked for repayment. The liveUnearmarked value (totalDebt - cumulativeEarmarked) is used to calculate how much new debt can be earmarked for redemption in the _earmark function.
The _forceRepay function is designed to repay a position’s debt, prioritizing earmarked debt, using the position’s collateral.
function_forceRepay(uint256accountId,uint256amount)internalreturns(uint256){if(amount ==0){return0;}_checkForValidAccountId(accountId); Account storage account = _accounts[accountId];_earmark();_sync(accountId);uint256 debt;_checkState((debt = account.debt)>0);uint256 credit = amount > debt ? debt : amount;uint256 creditToYield =convertDebtTokensToYield(credit);_subDebt(accountId, credit);// Repay debt from earmarked amount of debt firstuint256 earmarkToRemove = credit > account.earmarked ? account.earmarked : credit; account.earmarked -= earmarkToRemove;// ... (rest of the function handles collateral and fees)}
The function calculates earmarkToRemove, the amount of earmarked debt to repay (capped at account.earmarked). It reduces account.earmarked by earmarkToRemove, correctly updating the position’s local state. Then, it calls _subDebt to reduce account.debt and totalDebt by credit.
However, it does not reduce cumulativeEarmarked, leaving the global earmarked debt inflated.
For comparison, the repay function (used for voluntary repayments) correctly updates both local and global earmark values:
The absence of cumulativeEarmarked -= earmarkPaidGlobal in _forceRepay means that when earmarked debt is repaid during liquidation, the global cumulativeEarmarked remains unchanged, overstating the total earmarked debt.
The _subDebt function, called by _forceRepay, includes a clamping mechanism to prevent cumulativeEarmarked from exceeding totalDebt:
After reducing totalDebt, the clamp ensures cumulativeEarmarked <= totalDebt. This prevents an invalid state but only triggers if cumulativeEarmarked exceeds totalDebt after a liquidation.
However, in many cases, especially in normal operations with high totalDebt (e.g., many active positions), the inflated cumulativeEarmarked remains below totalDebt. Until then, the incorrect state affects _earmark and _sync calculations. For example:
Since 360e18 < 385e18, the clamp doesn’t trigger, and the incorrect cumulativeEarmarked persists.
Impact Details
cumulativeEarmarked, which tracks the total debt earmarked for redemption across all positions, becomes overstated. An inflated cumulativeEarmarked reduces liveUnearmarked, which represents the pool of debt available for new earmarking. This leads to an artificially low liveUnearmarked, causing the protocol to miscalculate how much debt can be earmarked for redemption.
function repay(uint256 amount, uint256 recipientTokenId) public returns (uint256) {
// ... (earlier checks and calculations)
uint256 earmarkToRemove = credit > account.earmarked ? account.earmarked : credit;
account.earmarked -= earmarkToRemove;
uint256 earmarkPaidGlobal = cumulativeEarmarked > earmarkToRemove ? earmarkToRemove : cumulativeEarmarked;
cumulativeEarmarked -= earmarkPaidGlobal;
// ... (rest of the function)
}
function _subDebt(uint256 tokenId, uint256 amount) internal {
// ... (debt and collateral updates)
account.debt -= amount;
totalDebt -= amount;
_totalLocked -= toFree;
account.rawLocked = lockedCollateral - toFree;
// Clamp to avoid underflow due to rounding later at a later time
if (cumulativeEarmarked > totalDebt) {
cumulativeEarmarked = totalDebt;
}
}