Summary
At multiple places in the code, user's collateral ratio has been calculated in a manner which causes loss of precision (rounding-up) due to division before multiplication. This causes potential loss for the DittoETH protocol, among other problems.
Root Cause
Use of the following piece of code causes rounding-up:
uint256 cRatio = short.getCollateralRatioSpotPrice(LibOracle.getSavedOrSpotOraclePrice(asset));
uint256 oraclePrice = LibOracle.getOraclePrice(asset);
...
...
...
uint256 cRatio = short.getCollateralRatioSpotPrice(oraclePrice);
Vulnerability Details
Let's break the issue down into 4 smaller parts:
PART 1:
Let us first look inside getOraclePrice():
File: contracts/libraries/LibOracle.sol
20 function getOraclePrice(address asset) internal view returns (uint256) {
21 AppStorage storage s = appStorage();
22 AggregatorV3Interface baseOracle = AggregatorV3Interface(s.baseOracle);
23 uint256 protocolPrice = getPrice(asset);
24
25 (
26 uint80 baseRoundID,
27 int256 basePrice,
28
29 ,
30 uint256 baseTimeStamp,
31
32 ) = baseOracle.latestRoundData();
33
34 AggregatorV3Interface oracle = AggregatorV3Interface(s.asset[asset].oracle);
35 if (address(oracle) == address(0)) revert Errors.InvalidAsset();
36
37 @> if (oracle == baseOracle) {
38
39 uint256 basePriceInEth = basePrice > 0
40 @> ? uint256(basePrice * Constants.BASE_ORACLE_DECIMALS).inv()
41 : 0;
42 basePriceInEth = baseOracleCircuitBreaker(
43 protocolPrice, baseRoundID, basePrice, baseTimeStamp, basePriceInEth
44 );
45 @> return basePriceInEth;
46 } else {
47
48 (
49 uint80 roundID,
50 int256 price,
51
52 ,
53 uint256 timeStamp,
54
55 ) = oracle.latestRoundData();
56 @> uint256 priceInEth = uint256(price).div(uint256(basePrice));
57 oracleCircuitBreaker(
58 roundID, baseRoundID, price, basePrice, timeStamp, baseTimeStamp
59 );
60 @> return priceInEth;
61 }
62 }
Based on whether the oracle
is baseOracle
or not, the function returns either basePriceEth
or priceInEth
.
basePriceEth
can be uint256(basePrice * Constants.BASE_ORACLE_DECIMALS).inv()
which is basically 1e36 / (basePrice * Constants.BASE_ORACLE_DECIMALS)
or simply written, of the form oracleN / oracleD
where oracleN
is the numerator with value 1e36 (as defined here) and oracleD
is the denominator.
priceInEth
is given as uint256 priceInEth = uint256(price).div(uint256(basePrice))
which again is of the form oracleN / oracleD
.
PART 2:
getSavedOrSpotOraclePrice() too internally calls the above getOraclePrice()
function, if it has been equal to or more than 15 minutes since the last time LibOrders.getOffsetTime()
was set:
File: contracts/libraries/LibOracle.sol
153 function getSavedOrSpotOraclePrice(address asset) internal view returns (uint256) {
154 if (LibOrders.getOffsetTime() - getTime(asset) < 15 minutes) {
155 return getPrice(asset);
156 } else {
157 @> return getOraclePrice(asset);
158 }
159 }
PART 3:
getCollateralRatioSpotPrice() calculates cRatio
as:
File: contracts/libraries/LibShortRecord.sol
30 function getCollateralRatioSpotPrice(
31 STypes.ShortRecord memory short,
32 uint256 oraclePrice
33 ) internal pure returns (uint256 cRatio) {
34 @> return short.collateral.div(short.ercDebt.mul(oraclePrice));
35 }
PART 4 (FINAL PART):
There are multiple places in the code (mentioned below under Impacts section) which compare the user's cRatio
to initialCR
or LibAsset.primaryLiquidationCR(_asset)
in the following manner:
if (short.getCollateralRatioSpotPrice(LibOracle.getSavedOrSpotOraclePrice(asset)) < LibAsset.primaryLiquidationCR(asset))
Calling short.getCollateralRatioSpotPrice(LibOracle.getSavedOrSpotOraclePrice(asset))
means the value returned from it would be:
// @audit-issue : Potential precision loss. Division before multiplication should not be done.
shortCollateral / (shortErcDebt * (oracleN / oracleD)) // return short.collateral.div(short.ercDebt.mul(oraclePrice));
which has the potential for precision loss (rounding-up) due to division before multiplication. The correct style ought to be:
+ @> (shortCollateral * oracleD) / (shortErcDebt * oracleN)
Impacts
File: contracts/facets/YieldFacet.sol
76 function _distributeYield(address asset)
77 private
78 onlyValidAsset(asset)
79 returns (uint88 yield, uint256 dittoYieldShares)
80 {
81 uint256 vault = s.asset[asset].vault;
82
83 uint80 zethYieldRate = s.vault[vault].zethYieldRate;
84
85 uint256 timestamp = LibOrders.getOffsetTimeHours();
86
87 @> uint256 oraclePrice = LibOracle.getPrice(asset);
88
89 uint256 initialCR = LibAsset.initialMargin(asset) + 1 ether;
90
91 uint8 id = s.shortRecords[asset][msg.sender][Constants.HEAD].nextId;
92
93 while (true) {
94
95 STypes.ShortRecord storage short = s.shortRecords[asset][msg.sender][id];
96
97 bool isNotRecentlyModified =
98 timestamp - short.updatedAt > Constants.YIELD_DELAY_HOURS;
99
100 if (short.status != SR.Cancelled && isNotRecentlyModified) {
101 uint88 shortYield =
102 short.collateral.mulU88(zethYieldRate - short.zethYieldRate);
103
104 yield += shortYield;
105
106 short.zethYieldRate = zethYieldRate;
107
108 @> uint256 cRatio = short.getCollateralRatioSpotPrice(oraclePrice);
109 @> if (cRatio <= initialCR) {
110 @> dittoYieldShares += shortYield;
111 } else {
112
113 dittoYieldShares += shortYield.mul(initialCR).div(cRatio);
114 }
115 }
116
117 if (short.nextId > Constants.HEAD) {
118 id = short.nextId;
119 } else {
120 break;
121 }
122 }
123 }
This rounding-up can lead to user's cRatio
to be considered as >initialCR
even when it's slightly lower. This results in greater dittoYieldShares
being calculated.
File: contracts/facets/MarginCallPrimaryFacet.sol
43 function flagShort(address asset, address shorter, uint8 id, uint16 flaggerHint)
44 external
45 isNotFrozen(asset)
46 nonReentrant
47 onlyValidShortRecord(asset, shorter, id)
48 {
49 if (msg.sender == shorter) revert Errors.CannotFlagSelf();
50 STypes.ShortRecord storage short = s.shortRecords[asset][shorter][id];
51 short.updateErcDebt(asset);
52
53 if (
54 @> short.getCollateralRatioSpotPrice(LibOracle.getSavedOrSpotOraclePrice(asset))
55 @> >= LibAsset.primaryLiquidationCR(asset)
56 ) {
57 revert Errors.SufficientCollateral();
58 }
59
60 uint256 adjustedTimestamp = LibOrders.getOffsetTimeHours();
61
62
63 if (short.flaggerId != 0) {
64 uint256 timeDiff = adjustedTimestamp - short.updatedAt;
65 uint256 resetLiquidationTime = LibAsset.resetLiquidationTime(asset);
66
67 if (timeDiff <= resetLiquidationTime) {
68 revert Errors.MarginCallAlreadyFlagged();
69 }
70 }
71
72 short.setFlagger(cusd, flaggerHint);
73 emit Events.FlagShort(asset, shorter, id, msg.sender, adjustedTimestamp);
74 }
File: contracts/facets/MarginCallSecondaryFacet.sol
38 function liquidateSecondary(
39 address asset,
40 MTypes.BatchMC[] memory batches,
41 uint88 liquidateAmount,
42 bool isWallet
43 ) external onlyValidAsset(asset) isNotFrozen(asset) nonReentrant {
44 STypes.AssetUser storage AssetUser = s.assetUser[asset][msg.sender];
45 MTypes.MarginCallSecondary memory m;
46 uint256 minimumCR = LibAsset.minimumCR(asset);
47 uint256 oraclePrice = LibOracle.getSavedOrSpotOraclePrice(asset);
48 uint256 secondaryLiquidationCR = LibAsset.secondaryLiquidationCR(asset);
49
50 uint88 liquidatorCollateral;
51 uint88 liquidateAmountLeft = liquidateAmount;
52 for (uint256 i; i < batches.length;) {
53 @> m = _setMarginCallStruct(
54 asset, batches[i].shorter, batches[i].shortId, minimumCR, oraclePrice
55 );
56
......
......
......
129 function _setMarginCallStruct(
130 address asset,
131 address shorter,
132 uint8 id,
133 uint256 minimumCR,
134 uint256 oraclePrice
135 ) private returns (MTypes.MarginCallSecondary memory) {
136 LibShortRecord.updateErcDebt(asset, shorter, id);
137
138 MTypes.MarginCallSecondary memory m;
139 m.asset = asset;
140 m.short = s.shortRecords[asset][shorter][id];
141 m.vault = s.asset[asset].vault;
142 m.shorter = shorter;
143 m.minimumCR = minimumCR;
144 @> m.cRatio = m.short.getCollateralRatioSpotPrice(oraclePrice);
145 return m;
146 }
File: contracts/facets/ShortRecordFacet.sol
117 function combineShorts(address asset, uint8[] memory ids)
118 external
119 isNotFrozen(asset)
120 nonReentrant
121 onlyValidShortRecord(asset, msg.sender, ids[0])
122 {
123 if (ids.length < 2) revert Errors.InsufficientNumberOfShorts();
124
125 STypes.ShortRecord storage firstShort = s.shortRecords[asset][msg.sender][ids[0]];
......
......
......
174
175
176 firstShort.merge(ercDebt, ercDebtSocialized, collateral, yield, c.shortUpdatedAt);
177
178
179 if (c.shortFlagExists) {
180 if (
181 @> firstShort.getCollateralRatioSpotPrice(
182 @> LibOracle.getSavedOrSpotOraclePrice(_asset)
183 @> ) < LibAsset.primaryLiquidationCR(_asset)
184 ) revert Errors.InsufficientCollateral();
185
186 firstShort.resetFlag();
187 }
188 emit Events.CombineShorts(asset, msg.sender, ids);
189 }
NOTE:
While the operation done in this piece of code is a bit different from the above analysis, I am clubbing it with this bug report as the underlying issue is the same (and the resolution would be similar): Multiplication and division operations should not be done directly on top of fetched oracle price, without paying attention to new order of evaluation:
File: contracts/libraries/LibOrders.sol
812 function _updateOracleAndStartingShort(address asset, uint16[] memory shortHintArray)
813 private
814 {
815 AppStorage storage s = appStorage();
815 uint256 oraclePrice = LibOracle.getOraclePrice(asset);
......
......
......
845
846 bool startingShortWithinOracleRange = shortPrice
847 @> <= oraclePrice.mul(1.01 ether)
848 && s.shorts[asset][prevId].price >= oraclePrice;
......
......
......
866 }
The effective calculation being done above is:
(oracleN / oracleD) * (1.01 ether)
Which should have been:
(oracleN * 1.01 ether) / oracle
Similar multiplication or division operations have been done on price
at various places throughout the code, which can be clubbed under this root cause itself.
PoC
Have attempted to keep all values in close proximity to the ones present in forked mainnet tests.
Let's assume some values for numerator & denominator and other variables:
uint256 private short_collateral = 100361729669569000000;
uint256 private short_ercDebt = 100000000000000000000000;
uint256 private price = 99995505;
uint256 private basePrice = 199270190598;
uint256 private primaryLiquidationCR = 2000000000000000000;
So calculated priceInEth = price.div(basePrice) = 501808648347845
Let's calculate for the scenario of flagShort()
where the code logic says:
53 if (
54 @> short.getCollateralRatioSpotPrice(LibOracle.getSavedOrSpotOraclePrice(asset))
55 @> >= LibAsset.primaryLiquidationCR(asset)
56 ) {
57 revert Errors.SufficientCollateral();
58 }
Create a file named test/IncorrectCRatioCheck.t.sol
and paste the following code in it. Some mock functions are included here which mirror protocol's calculation style:
pragma solidity 0.8.21;
import {U256} from "contracts/libraries/PRBMathHelper.sol";
import {OBFixture} from "test/utils/OBFixture.sol";
import {console} from "contracts/libraries/console.sol";
contract IncorrectCRatioCheck is OBFixture {
using U256 for uint256;
uint256 private short_collateral = 85307470219133700000;
uint256 private short_ercDebt = 100000000000000000000000;
uint256 private price = 99995505;
uint256 private basePrice = 199270190598;
uint256 private primaryLiquidationCR = 1700000000000000000;
function _getSavedOrSpotOraclePrice() internal view returns (uint256) {
uint256 priceInEth = price.div(basePrice);
return priceInEth;
}
function getCollateralRatioSpotPrice_IncorrectStyle_As_In_Existing_DittoProtocol(
uint256 oraclePrice
) internal view returns (uint256) {
return short_collateral.div(short_ercDebt.mul(oraclePrice));
}
function getCollateralRatioSpotPrice_CorrectStyle(uint256 oracleN, uint256 oracleD)
internal
view
returns (uint256)
{
return (short_collateral.mul(oracleD)).div(short_ercDebt.mul(oracleN));
}
function test_GetCollateralRatioSpotPrice_IncorrectStyle_As_In_Existing_DittoProtocol(
) public view {
uint256 cRatio =
getCollateralRatioSpotPrice_IncorrectStyle_As_In_Existing_DittoProtocol(
_getSavedOrSpotOraclePrice()
);
console.log("cRatio calculated (existing style) =", cRatio);
if (cRatio >= primaryLiquidationCR) {
console.log("Errors.SufficientCollateral; can not be flagged");
} else {
console.log("InsufficientCollateral; can be flagged");
}
}
function test_GetCollateralRatioSpotPrice_CorrectStyle() public view {
uint256 cRatio = getCollateralRatioSpotPrice_CorrectStyle(price, basePrice);
console.log("cRatio calculated (correct style) =", cRatio);
if (cRatio >= primaryLiquidationCR) {
console.log("Errors.SufficientCollateral; can not be flagged");
} else {
console.log("InsufficientCollateral; can be flagged");
}
}
}
Logs:
cRatio calculated (existing style) = 1700000000000000996
Errors.SufficientCollateral; can not be flagged
So the short can not be flagged as cRatio > primaryLiquidationCR
of 1700000000000000000.
Logs:
cRatio calculated (correct style) = 1699999999999899995
InsufficientCollateral; can be flagged
Short's cRatio is actually below primaryLiquidationCR. Should have been flagged ideally.
Tools Used
Manual review, forge test.
Recommendations
These steps need to be taken to fix the issue. Developer may have to make some additional changes since .mul
, .div
, etc are being used from the PRBMathHelper.sol
library. Following is the general workflow required:
Create additional functions to fetch oracle parameters instead of price: Create copies of getOraclePrice()
and getSavedOrSpotOraclePrice()
, but these ones return oracleN
& oracleD
instead of the calculated price. Let's assume the new names to be getOraclePriceParams()
and getSavedOrSpotOraclePriceParams()
.
Create a new function to calculate cRatio which will be used in place of the above occurences of getCollateralRatioSpotPrice()
:
function getCollateralRatioSpotPriceFromOracleParams(
STypes.ShortRecord memory short,
uint256 oracleN,
uint256 oracleD
) internal pure returns (uint256 cRatio) {
return (short.collateral.mul(oracleD)).div(short.ercDebt.mul(oracleN));
}
845 //@dev: force hint to be within 1% of oracleprice
846 bool startingShortWithinOracleRange = shortPrice
- 847 <= oraclePrice.mul(1.01 ether)
+ 847 <= (oracleN.mul(1.01 ether)).div(oracleD)
848 && s.shorts[asset][prevId].price >= oraclePrice;