During liquidations, AlchemistV3::_forceRepay() correctly reduces the account earmarked value account.earmarked but does not reduce the global earmarked cumulativeEarmarked.
Vulnerability Details
The buggy snippet is the following:
function _forceRepay(uint256 accountId, uint256 amount) internal returns (uint256) {
....
// Repay debt from earmarked amount of debt first
uint256 earmarkToRemove = credit > account.earmarked ? account.earmarked : credit;
account.earmarked -= earmarkToRemove;
creditToYield = creditToYield > account.collateralBalance ? account.collateralBalance : creditToYield;
account.collateralBalance -= creditToYield;
uint256 protocolFeeTotal = creditToYield * protocolFee / BPS;
emit ForceRepay(accountId, amount, creditToYield, protocolFeeTotal);
if (account.collateralBalance > protocolFeeTotal) {
account.collateralBalance -= protocolFeeTotal;
// Transfer the protocol fee to the protocol fee receiver
TokenUtils.safeTransfer(myt, protocolFeeReceiver, protocolFeeTotal);
}
if (creditToYield > 0) {
// Transfer the repaid tokens from the account to the transmuter.
TokenUtils.safeTransfer(myt, address(transmuter), creditToYield);
}
return creditToYield;
}
While in repay() call global earmarked is reduced:
This breaks the core invariant that the global earmark tracks the aggregate of user earmarks.
Because cumulativeEarmarked is used both as the denominator for redemption weighting and to compute liveUnearmarked = totalDebt - cumulativeEarmarked for future earmarks, the error propagates into worngly priced redemptions and persistent accounting drift.
These are the relevant snippet where cumulativeEarmarked is used:
Impact Details
These are the main impacts:
Users debts are reduced less than they should during redemptions
Protocol global accounting corruption: divergence between burned synthetics and aggregate debt reduction
Protocol risk over time: repeated liquidations via _forceRepay() can degrade solvency
Given the above impacts the severity is CRITICAL.
Proof of Concept
Proof of Concept
The PoC forces calling of _forceRepay() during a liquidation and demonstrates that global cumulativeEarmarked doesn't decrease after a liquidation.