DittoETH

Ditto
DeFiFoundryOracle
55,000 USDC
View results
Submission Details
Severity: high
Valid

Collateral can be lost during '_performForcedBid()' in the liquidation

Summary

In the Primary Liquidation, _performForcedBid() is used to create a bid to cover the Erc Debt of the short record being liquidated. If the short order of the record being liquidated still exists and matches with the forced bid, what gets matched is filled into the short record that is currently being liquidated. The problem is that at the end of the liquidation, this short record is canceled in the case of a full liquidation:

//File: contracts/facets/MarginCallPrimaryFacet.sol
function _fullorPartialLiquidation(MTypes.MarginCallPrimary memory m) private {
uint88 decreaseCol = min88(m.totalFee + m.ethFilled, m.short.collateral);
if (m.short.ercDebt == m.ercDebtMatched) {
// Full liquidation
LibShortRecord.disburseCollateral(
m.asset,
m.shorter,
m.short.collateral,
m.short.zethYieldRate,
m.short.updatedAt
);
LibShortRecord.deleteShortRecord(m.asset, m.shorter, m.short.id); //@audit The short record will be canceled even if it was filled during the forced bid. So the collateral filled up is lost
if (!m.loseCollateral) {
m.short.collateral -= decreaseCol;
s.vaultUser[m.vault][m.shorter].ethEscrowed += m.short.collateral;
s.vaultUser[m.vault][address(this)].ethEscrowed -= m.short.collateral;
}

or overwritten in the case of a partial liquidation:

} else {
// Partial liquidation
m.short.ercDebt -= m.ercDebtMatched;
m.short.collateral -= decreaseCol;
s.shortRecords[m.asset][m.shorter][m.short.id] = m.short; //@audit The collateral is also lost here; the short record is overwritten here rather than canceled
s.vaultUser[m.vault][address(this)].ethEscrowed -= m.short.collateral;
LibShortRecord.disburseCollateral(
m.asset, m.shorter, decreaseCol, m.short.zethYieldRate, m.short.updatedAt
);

Vulnerability Details

Here is a PoC that demonstrates the scenario when a forced bid matches with the short order of the record to be liquidated and a full liquidation occurs:

// SPDX-License-Identifier: GPL-3.0-only
pragma solidity 0.8.21;
import {Errors} from "contracts/libraries/Errors.sol";
import {Events} from "contracts/libraries/Events.sol";
import {STypes, MTypes, O} from "contracts/libraries/DataTypes.sol";
import {Constants} from "contracts/libraries/Constants.sol";
import {OBFixture} from "test/utils/OBFixture.sol";
import {console} from "forge-std/console.sol";
import "forge-std/Vm.sol";
import {U256, U88, U80} from "contracts/libraries/PRBMathHelper.sol";
import {IAsset} from "interfaces/IAsset.sol";
contract Poc is OBFixture {
using U256 for uint256;
using U88 for uint88;
using U80 for uint80;
//Creates a bid and a short that exactly match so that a short record is created, as at least one more short record is needed to liquidate a short record
function makeShortRecord() public {
MTypes.OrderHint[] memory orderHintArray;
uint16[] memory shortHintArray;
//Bid
uint80 bidPrice = 0.0030 ether;
uint88 bidErcAmount = 3000 ether;
uint256 bidEth = bidPrice.mul(bidErcAmount);
shortHintArray = setShortHintArray();
orderHintArray = diamond.getHintArray(asset, bidPrice, O.LimitBid);
deal(receiver, bidEth);
vm.startPrank(receiver);
diamond.depositEth{value: bidEth}(address(bridgeReth));
diamond.createBid(asset, bidPrice, bidErcAmount, false, orderHintArray, shortHintArray);
vm.stopPrank();
//Short
uint80 price = 0.0021 ether;
uint88 ercAmount = 3000 ether;
uint256 eth = uint256(ercAmount.mul(price) * 5);
orderHintArray = diamond.getHintArray(asset, price, O.LimitShort);
shortHintArray = setShortHintArray();
vm.deal(receiver, eth);
vm.startPrank(receiver);
diamond.depositEth{value: eth}(_bridgeReth);
diamond.createLimitShort(asset, price, ercAmount, orderHintArray, shortHintArray, 500);
vm.stopPrank();
}
function testPOC() public {
//Initial Price: 250000000000000
makeShortRecord(); //Creates the first short record as at least one more is required for liquidation
MTypes.OrderHint[] memory orderHintArray;
uint16[] memory shortHintArray;
//Bid is created so that it can be matched with the short order
uint80 bidPrice = 0.0021 ether;
uint88 bidErcAmount = 2000 ether;
uint256 bidEth = bidPrice.mul(bidErcAmount);
shortHintArray = setShortHintArray();
orderHintArray = diamond.getHintArray(asset, bidPrice, O.LimitBid);
deal(extra, bidEth);
vm.startPrank(extra);
diamond.depositEth{value: bidEth}(address(bridgeReth));
diamond.createBid(asset, bidPrice, bidErcAmount, false, orderHintArray, shortHintArray);
vm.stopPrank();
uint80 price = 0.0021 ether;
uint88 ercAmount = 4000 ether; //This is matched with the above bid, so that 2000 erc are in the short record, and 2000 remain in this short order.
uint256 eth = uint256(ercAmount.mul(price) * 5); //Eth for depositing. *5 because it is overcollateralized
orderHintArray = diamond.getHintArray(asset, price, O.LimitShort);
shortHintArray = setShortHintArray();
vm.deal(sender, eth);
vm.startPrank(sender);
diamond.depositEth{value: eth}(_bridgeReth);
diamond.createLimitShort(asset, price, ercAmount, orderHintArray, shortHintArray, 500); //The short is created so that it can match with the just created bid, and a short record is created that will be liquidated later
vm.stopPrank();
//After a short record for the 'sender' has been created, the price changes so that the short record is below the primaryLiquidationCR
int256 newEthPrice = 90000000000000000000; //new price
skipTimeAndSetEth({skipTime: 1 hours, ethPrice: newEthPrice}); //New price is set. The fact that time is fast-forwarded is not relevant.
//Short record is flagged because it is under primaryLiquidationCR
vm.startPrank(extra);
diamond.flagShort(asset, sender, Constants.SHORT_STARTING_ID, Constants.HEAD);
vm.stopPrank();
skipTimeAndSetEth({skipTime: 11 hours, ethPrice: newEthPrice}); //This is only to correctly skip time, the price remains the same
uint256 ercDebtBeforeLiquidation = diamond.getShortRecord(asset, sender, Constants.SHORT_STARTING_ID).ercDebt;
assert(ercDebtBeforeLiquidation == 2000 ether); //This shows that prior to the liquidation, the ERC debt on the short records is 2000
vm.startPrank(extra);
diamond.liquidate(asset, sender, Constants.SHORT_STARTING_ID, shortHintArray); //The short record is being liquidated
vm.stopPrank();
uint256 ercDebtAfterLiquidation = diamond.getShortRecord(asset, sender, Constants.SHORT_STARTING_ID).ercDebt;
STypes.ShortRecord[] memory shortRecordsSender = diamond.getShortRecords(asset, sender);
assert(ercDebtAfterLiquidation == 4000 ether); //This shows that during the liquidation, the forced bid matched with the short order of the liquidated short record and filled the short record
assert(shortRecordsSender.length == 0); //This shows that the short record has been deleted, and the collateral from the match with the forced bid is lost
}
}

This file can be added to the test folder and tested with this command: forge test --match-test testPOC -vv

There is another instance of this issue in ExitShortFacet.sol in the function exitShort(). Here, a forced bid is also created to settle ERC debt. If it's a full exit, the same issue can occur here as in the liquidation, as the short record is deleted here as well, and it's not taken into consideration that the forced bid may match the short order of the short record.

Impact

The ERC debt that arose from the matching with the forced bid and the short order can no longer be repaid as they are no longer present in any short record. Additionally, the collateral from the match with the forced bid cannot be reclaimed by the shorter

Tools Used

VSCode, Foundry

Recommendations

The short record being processed should be able to be placed in an editing status. Furthermore, filling out a short record should only be allowed if the short record is not in the editing status.

Updates

Lead Judging Commences

0xnevi Lead Judge
over 1 year ago
0xnevi Lead Judge
over 1 year ago
0xnevi Lead Judge over 1 year ago
Submission Judgement Published
Invalidated
Reason: Other
schnilch Submitter
over 1 year ago
0xnevi Lead Judge
over 1 year ago
0xnevi Lead Judge over 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-521

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.