Platform: Code4rena
Start Date: 14/09/2022
Pot Size: $50,000 USDC
Total HM: 25
Participants: 110
Period: 5 days
Judge: hickuphh3
Total Solo HM: 9
Id: 162
League: ETH
Rank: 7/110
Findings: 4
Award: $1,849.16
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: carrotsmuggler
Also found by: 0x52, 0xDecorativePineapple, 0xPanas, Bahurum, Jeiwan, Lambda, PwnPatrol, R2, Respx, auditor0517, durianSausage, hyh, ladboy233, pauliax, scaraven, teawaterwire, zzzitron
36.6124 USDC - $36.61
The project implements a price oracle in order to get the relative price between the pegged asset and the price of the original asset (example: stETH to ETH). If the ratio (the pegged asset divided by the original asset) is 1 the Token is pegged, otherwise is depegged.
Bellow is a code snippet from the PegOracle.sol function.
if (price1 > price2) { nowPrice = (price2 * 10000) / price1; } else { nowPrice = (price1 * 10000) / price2; } int256 decimals10 = int256(10**(18 - priceFeed1.decimals())); nowPrice = nowPrice * decimals10; return ( roundID1, nowPrice / 1000000, startedAt1, timeStamp1, answeredInRound1 ); }
To fetch the ratio at any time, the PegOracle.sol
performs some calculations; first the relative price is multiplied by 1e4 and then it returns the above calculation divided by 1e6.
The Controller.sol file makes an external call to the PegOracle.sol oracle to get the relative price. After, the value returned, it is multiplied by 10**(18-(priceFeed.decimals())
and the result represents the relative price between the two assets.
The result is converted to 18 decimal points in order to be compared with the Strike Price passed by the admin on VaultFactory.sol
.
While the decimal calculation logic appear to works when decimals == 8
, it seems that it won't work when the decimals are different than 8. The impact is relative big, since the whole project depends on the ratio of the price oracles of the two assets.
Lets say that we have two assets, A and B, having 6 decimals. Assuming that the oracle of two assets report prices of 400 * 10 ** 6 = 400000000
and 399 * 10 ** 6 = 399000000
, their relative price transformed to 18 decimals is :
uint256(scalePriceTo18(priceA, 6) * 1e18 / scalePriceTo18(priceB, 6))
= 1002506265664160401
where scalePriceTo18
:
function scalePriceTo18(int256 _price, uint8 _priceDecimals) internal pure returns (int256) { if (_priceDecimals < 18) { return _price * int256(10 ** uint256(18 - _priceDecimals)); } else if (_priceDecimals > 18) { return _price * int256(10 ** uint256(_priceDecimals - 18)); } return _price; }
By using the formula provided in Controller.soland PegOracle.sol the result is 10025000000000000000000
which has 4 more decimals than the correct result.
The decimal calculation performed by the y2k codebase is :
x / y * (10 ^ 4 * 10 ^ 12 * 10 ^12) / (10 ^ 6)
= (x * 10 ^ 22 / y)
Manual Code Review
Since the 2 assets are required to having the same amount of decimals a formula that transforms the relative price to 1e18 could be:
x * 1e18 / y
.
An example that Chainlink implements, that includes a scalePrice
function, in order to find a different price denominator could be found here
#0 - HickupHH3
2022-10-17T10:13:02Z
dup of #195
🌟 Selected for report: 0xDecorativePineapple
1387.0274 USDC - $1,387.03
The project implements a price oracle in order to get the relative price between the pegged asset and the price of the original asset (example: stETH to ETH). If the ratio (the pegged asset divided by the original asset) is 1 the Token is pegged, otherwise is depegged.
Bellow is a code snippet from the PegOracle.sol function.
if (price1 > price2) { nowPrice = (price2 * 10000) / price1; } else { nowPrice = (price1 * 10000) / price2; } int256 decimals10 = int256(10**(18 - priceFeed1.decimals())); nowPrice = nowPrice * decimals10; return ( roundID1, nowPrice / 1000000, startedAt1, timeStamp1, answeredInRound1 ); }
To fetch the ratio at any time, the PegOracle.sol
performs some calculations; first the relative price is multiplied by 1e4 and then it returns the above calculation divided by 1e6.
The Controller.sol file makes an external call to the PegOracle.sol oracle to get the relative price. After, the value returned, it is multiplied by 10**(18-(priceFeed.decimals())
and the result represents the relative price between the two assets.
The result is converted to 18 decimal points in order to be compared with the Strike Price passed by the admin on VaultFactory.sol
.
Due to the fact that the first multiplication is first divided by 1e6
(PegOracle.sol#L78)( and then re-multiplied by uint256 decimals = 10**(18-(priceFeed.decimals()));
(Controller.sol#L299-L300) it leads to loss of precision. This behavior will make the relative price between the assets incorrect.
Bellow is a test that illustrates the above issue for various oracle pairs. The calculated ratio is compared against a modified version of an example of different price denominator, provided by Chainlink.
// SPDX-License-Identifier: MIT pragma solidity 0.8.15; import "forge-std/Test.sol"; import "../lib/AggregatorV3Interface.sol"; //run with: forge test --fork-url https://arb1.arbitrum.io/rpc -vv contract PegOracle { /*** @dev for example: oracle1 would be stETH / USD, while oracle2 would be ETH / USD oracle ***/ address public oracle1; address public oracle2; uint8 public decimals; AggregatorV3Interface internal priceFeed1; AggregatorV3Interface internal priceFeed2; /** @notice Contract constructor * @param _oracle1 First oracle address * @param _oracle2 Second oracle address */ constructor(address _oracle1, address _oracle2) { require(_oracle1 != address(0), "oracle1 cannot be the zero address"); require(_oracle2 != address(0), "oracle2 cannot be the zero address"); require(_oracle1 != _oracle2, "Cannot be same Oracle"); priceFeed1 = AggregatorV3Interface(_oracle1); priceFeed2 = AggregatorV3Interface(_oracle2); require( (priceFeed1.decimals() == priceFeed2.decimals()), "Decimals must be the same" ); oracle1 = _oracle1; oracle2 = _oracle2; decimals = priceFeed1.decimals(); } /** @notice Returns oracle-fed data from the latest round * @return roundID Current round id * @return nowPrice Current price * @return startedAt Starting timestamp * @return timeStamp Current timestamp * @return answeredInRound Round id for which answer was computed */ function latestRoundData() public view returns ( uint80 roundID, int256 nowPrice, uint256 startedAt, uint256 timeStamp, uint80 answeredInRound ) { ( uint80 roundID1, int256 price1, uint256 startedAt1, uint256 timeStamp1, uint80 answeredInRound1 ) = priceFeed1.latestRoundData(); int256 price2 = getOracle2_Price(); if (price1 > price2) { nowPrice = (price2 * 10000) / price1; } else { nowPrice = (price1 * 10000) / price2; } int256 decimals10 = int256(10**(18 - priceFeed1.decimals())); nowPrice = nowPrice * decimals10; return ( roundID1, nowPrice / 1000000, //1000000, startedAt1, timeStamp1, answeredInRound1 ); } /* solhint-disbable-next-line func-name-mixedcase */ /** @notice Lookup first oracle price * @return price Current first oracle price */ function getOracle1_Price() public view returns (int256 price) { ( uint80 roundID1, int256 price1, , uint256 timeStamp1, uint80 answeredInRound1 ) = priceFeed1.latestRoundData(); require(price1 > 0, "Chainlink price <= 0"); require( answeredInRound1 >= roundID1, "RoundID from Oracle is outdated!" ); require(timeStamp1 != 0, "Timestamp == 0 !"); return price1; } /* solhint-disbable-next-line func-name-mixedcase */ /** @notice Lookup second oracle price * @return price Current second oracle price */ function getOracle2_Price() public view returns (int256 price) { ( uint80 roundID2, int256 price2, , uint256 timeStamp2, uint80 answeredInRound2 ) = priceFeed2.latestRoundData(); require(price2 > 0, "Chainlink price <= 0"); require( answeredInRound2 >= roundID2, "RoundID from Oracle is outdated!" ); require(timeStamp2 != 0, "Timestamp == 0 !"); return price2; } function latestRoundData2() public view returns ( uint80 roundID, int256 nowPrice, uint256 startedAt, uint256 timeStamp, uint80 answeredInRound ) { ( uint80 roundID1, int256 price1, uint256 startedAt1, uint256 timeStamp1, uint80 answeredInRound1 ) = priceFeed1.latestRoundData(); price1 = scalePriceTo18(price1, priceFeed1.decimals()); int256 price2 = scalePriceTo18(getOracle2_Price(), priceFeed1.decimals()); return ( roundID1, price1 * 1e18 / price2, startedAt1, timeStamp1, answeredInRound1 ); } function scalePriceTo18(int256 _price, uint8 _priceDecimals) internal pure returns (int256) { if (_priceDecimals < 18) { return _price * int256(10 ** uint256(18 - _priceDecimals)); } else if (_priceDecimals > 18) { return _price * int256(10 ** uint256(_priceDecimals - 18)); } return _price; } } contract TestOracles is Test { address WETH = 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1; address tokenFRAX = 0x17FC002b466eEc40DaE837Fc4bE5c67993ddBd6F; address tokenMIM = 0xFEa7a6a0B346362BF88A9e4A88416B77a57D6c2A; address tokenFEI = 0x4A717522566C7A09FD2774cceDC5A8c43C5F9FD2; address tokenUSDC = 0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8; address tokenDAI = 0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1; address tokenSTETH = 0xEfa0dB536d2c8089685630fafe88CF7805966FC3; address oracleFRAX = 0x0809E3d38d1B4214958faf06D8b1B1a2b73f2ab8; address oracleMIM = 0x87121F6c9A9F6E90E59591E4Cf4804873f54A95b; address oracleFEI = 0x7c4720086E6feb755dab542c46DE4f728E88304d; address oracleUSDC = 0x50834F3163758fcC1Df9973b6e91f0F0F0434aD3; address oracleDAI = 0xc5C8E77B397E531B8EC06BFb0048328B30E9eCfB; address oracleSTETH = 0x07C5b924399cc23c24a95c8743DE4006a32b7f2a; address oracleETH = 0x639Fe6ab55C921f74e7fac1ee960C0B6293ba612; address btcEthOracle = 0xc5a90A6d7e4Af242dA238FFe279e9f2BA0c64B2e; PegOracle pegOracle = new PegOracle(oracleSTETH, oracleETH); PegOracle pegOracle2 = new PegOracle(oracleFRAX, oracleFEI); PegOracle pegOracle3 = new PegOracle(oracleDAI, oracleFEI); function setUp() public {} function convertBasedOnContractsLogic(int256 price, uint8 oracleDecimals) public returns(int256 newPrice){ uint256 decimals = 10**(18- oracleDecimals ); int256 newPrice = price * int256(decimals); return newPrice; } function testOraclePrices() public { (, int256 var1 ,,,) = pegOracle.latestRoundData(); emit log_int(convertBasedOnContractsLogic(var1, pegOracle.decimals())); (, int256 var2 ,,,) = pegOracle.latestRoundData2(); emit log_int(var2); (, int256 var3 ,,,) = pegOracle2.latestRoundData(); emit log_int(convertBasedOnContractsLogic(var3, pegOracle2.decimals())); (, int256 var4 ,,,) = pegOracle2.latestRoundData2(); emit log_int(var4); (, int256 var5 ,,,) = pegOracle3.latestRoundData(); emit log_int(convertBasedOnContractsLogic(var5, pegOracle3.decimals())); (, int256 var6 ,,,) = pegOracle3.latestRoundData2(); emit log_int(var6); } }
Here is the output after running the with: forge test --fork-url https://arb1.arbitrum.io/rpc -vv
:
990500000000000000
990544616614592905
996300000000000000 1003669952945847834
996000000000000000 1003940775578783463
Manual Review
Since the 2 assets are required to having the same amount of decimals a formula that transforms the relative price to 1e18 could be:
x * 1e18 / y
.
An example that Chainlink implements, that includes a scalePrice
function, in order to find a different price denominator could be found here
#0 - HickupHH3
2022-10-17T14:24:09Z
Agree with the issue; the precision loss may be the decisive factor between whether a depeg is ruled to have happened. Since the core functionality and user funds are at stake, the high severity is appropriate here.
388.9011 USDC - $388.90
The project implements a price oracle in order to get the relative price between the pegged asset and the price of the original asset (example: stETH to ETH). If the ratio (the pegged asset divided by the original asset) is 1 the Token is pegged, otherwise is depegged.
Bellow is a code snippet from the PegOracle.sol function.
if (price1 > price2) { nowPrice = (price2 * 10000) / price1; } else { nowPrice = (price1 * 10000) / price2; } int256 decimals10 = int256(10**(18 - priceFeed1.decimals())); nowPrice = nowPrice * decimals10; return ( roundID1, nowPrice / 1000000, startedAt1, timeStamp1, answeredInRound1 ); }
To fetch the ratio at any time, the PegOracle.sol
performs some calculations; first the relative price is multiplied by 1e4 and then it returns the above calculation divided by 1e6.
The Controller.sol file makes an external call to the PegOracle.sol oracle to get the relative price. After, the value returned, it is multiplied by 10**(18-(priceFeed.decimals())
and the result represents the relative price between the two assets.
The result is converted to 18 decimal points in order to be compared with the Strike Price passed by the admin on VaultFactory.sol
.
The PegOracle.sol
logic will return wrong results if the price of the first asset(price1
) is larger than the price of the second asset(price2
). This happens due to the fact that the code will return the ratio (price2 / price1
) , instead of the correct ratio (price1 / price2
) (https://github.com/code-423n4/2022-09-y2k-finance/blob/2175c044af98509261e4147edeb48e1036773771/src/oracles/PegOracle.sol#L67-L71) . An oracle has to return the ratio of the same prices, and not its inverse ratio.
This effectively means that the ratio returned by the oracle could not be greater than 1 * decimals == 1e18, and so breaking the application logic.
Assume that the price reported for assetA is 99978355
and the price reported for assetB is 99585473
, both in 8 decimals. The relative price transformed to 18 decimals is: 99978355 * 1e18 / 99585473 = 1003945173810642040
. The application instead will try to calculate the inverse ratio ,
1e18 * 99585473 / 99978355 ~ 996000000000000000
, which is less than 1 * 1e18.
Manual Code Review
Since the 2 assets are required to having the same amount of decimals a formula that transforms the relative price to 1e18 could be:
x * 1e18 / y
.
An example that Chainlink implements, that includes a scalePrice
function, in order to find a different price denominator could be found here.
#0 - HickupHH3
2022-10-17T10:36:41Z
I disagree with the issue. Looking at the way the price is intended to be utilised, it doesn't really matter if the inverse price is used instead of the normal ratio; what matters more is the delta.
Nevertheless, one may argue that external parties might want to use this oracle implementation and aren't aware of this caveat. Downgrading to low-risk QA.
#1 - HickupHH3
2022-10-17T13:44:20Z
after assessing #45, I realise that the functional vulnerability is the same: that the price ratio returned should be constant. However, only partial credit can be given as the justification in #45 is substantially stronger.
#2 - HickupHH3
2022-10-17T13:45:01Z
dup of #45
🌟 Selected for report: Respx
Also found by: 0x1f8b, 0xDecorativePineapple, 0xNazgul, 0xPanas, 0xSmartContract, 0xc0ffEE, 0xmuxyz, Aymen0909, Bahurum, Bnke0x0, CodingNameKiki, Deivitto, Jeiwan, Lambda, Picodes, PwnPatrol, R2, RaymondFam, Rolezn, Ruhum, Saintcode_, SooYa, Tointer, V_B, ajtra, ak1, async, auditor0517, brgltd, c3phas, carrotsmuggler, cccz, csanuragjain, datapunk, djxploit, durianSausage, eierina, erictee, gogo, imare, joestakey, jonatascm, kv, ladboy233, leosathya, lukris02, oyc_109, pashov, pauliax, rbserver, robee, rokinot, rvierdiiev, scaraven, simon135, unforgiven, wagmi, zzzitron
36.6223 USDC - $36.62
In the Y2K Finance docs, it is noted that: | Since Chainlink does not have it, we implemented an Oracle that divides the price of stETH by the price of ETH and get a ratio from it.
The Protocol implemented some price oracles that give the ratio between some pair, where each pairs consists from the pegged asset and the original. The oracles were implemented in order to decide if the Token is pegged or depegged.
But there are a few problems with the values returned from the Oracle:
decimals
getter returns the decimals of the underlying tokens, and not 18 decimals as noted in the docs. Also ,the true decimals of the value returned is not correctly calculated and depends of the underlying tokens.latestRoundData
returns the roundID
. the startedAt
and the answeredInRound
of the first asset.So, if other smart contracts depend on the values provided by those oracles, their calculations could be wrong.
function latestRoundData() public view returns ( uint80 roundID, int256 nowPrice, uint256 startedAt, uint256 timeStamp, uint80 answeredInRound ) { ( uint80 roundID1, int256 price1, uint256 startedAt1, uint256 timeStamp1, uint80 answeredInRound1 ) = priceFeed1.latestRoundData(); int256 price2 = getOracle2_Price(); if (price1 > price2) { nowPrice = (price2 * 10000) / price1; } else { nowPrice = (price1 * 10000) / price2; } int256 decimals10 = int256(10**(18 - priceFeed1.decimals())); nowPrice = nowPrice * decimals10; return ( roundID1, nowPrice / 1000000, startedAt1, timeStamp1, answeredInRound1 ); }
Manual Code Review
It is recommended to return the correct decimal number (in this case 18) and to make the latestRoundData
function return only the ration between the 2 tokens since the other values don't correctly represent the token pair.
#0 - MiguelBits
2022-09-29T23:29:39Z
added decimals bcuz of another issue.
however we need the other values from chainlink because Controller.sol needs to make some requirements around it for other oracles, so we should not create a different function to get price from pegOracle.sol
#1 - HickupHH3
2022-10-18T09:13:48Z
I side with the warden on the decimals issue. Will view it as mismatch against the spec.
As the sponsor mentioned, the data returned by latestRoundData
is needed, even though one may argue whether it should be taken from the average of the 2 price feeds instead (or the other one).
Downgrading to QA.
Part of #386