Platform: Code4rena
Start Date: 24/03/2023
Pot Size: $49,200 USDC
Total HM: 20
Participants: 246
Period: 6 days
Judge: Picodes
Total Solo HM: 1
Id: 226
League: ETH
Rank: 90/246
Findings: 1
Award: $48.63
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: ladboy233
Also found by: 0xkazim, 0xnev, Bauer, J4de, Matin, UniversalCrypto, cryptothemex, jasonxiale, juancito, koxuan, latt1ce, neumo
48.6252 USDC - $48.63
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L73-L75 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/SfrxEth.sol#L112 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L215
Precision loss due to wrong mathematical operation orders.
The function stake()
is intended to get the ETH of a user and convert it to the derivative tokens which are currently three staked ERC20 tokens. This function gets the ETH value of the derivatives mentioned above and calculates the amount needed for minting the SafETH
upgradeable token.
The function ethPerDerivative()
is responsible for getting the price of a derivative in terms of ETH, which is implemented in each derivative contract. Considering the price calculation of the derivatives inside the ethPerDerivative()
is correct (which is excluded from the contest scope), the WstEth
works correctly. The Reth
also seems not to have a problem at this stage. However, the staked Frax Ether (SfrxEth
) performs a convertToAssets()
in its core which has a mulDivDown()
from the fixed-point math library, and then performs a division over the oracle-based price. Thus it suffers from a precision loss.
mulDivDown(x,y,z)
is simply equal to $\dfrac{xy}{z}$ at which the denominator is totalSupply
in convertToAssets()
function:
function convertToAssets(uint256 shares) public view virtual returns (uint256) { uint256 supply = totalSupply; // Saves an extra SLOAD if totalSupply is non-zero. return supply == 0 ? shares : shares.mulDivDown(totalAssets(), supply); }
Thus, the return value of this function is multiplied by $10^{18}$ and then divided by the price.
In the next step, this value is multiplied by the balance of each derivative in the contract SafETH
:
(derivatives[i].ethPerDerivative(derivatives[i].balance()) * derivatives[i].balance()) / 10 ** 18;
In simple words, the code wants to perform these tasks:
$ a = \dfrac{10^{18} \times totalAssets}{totalSupply} $
$ b = \dfrac{10^{18} \times a}{priceOracle} $
$ c = \dfrac{b \times balance}{10^{18}} $
To implement these steps through solidity, we need to perform all the multiplications and then divide the results to avoid precision loss. To test this precision loss, a simple foundry test code may be implemented:
function testDivision(uint _totalSupply, uint totalAssets, uint balance) external { uint256 supply = _totalSupply; uint256 shares = 1e18; uint256 oracle = 1.03e18; // SFrxETH approximate price uint256 convertToAssets = shares.mulDivDown(totalAssets, supply); uint256 classicDivision = ((1e18 * division / oracle) * balance) / 1e18; uint256 numerator = 1e18 * shares * totalAssets * balance; uint256 denominator = oracle * supply * 1e18; uint256 correctDivision = numerator/denominator; assertFalse(correctDivison == classicDivision); }
This test fails for arbitrary totalSupply
, totalAssets
, and balance
.
Thus, the underlyingValue
is calculated wrongly.
Manual analysis, Foundry
It's recommended the protocol avoid division before multiplication and perform division operations at last.
#0 - c4-pre-sort
2023-04-04T16:40:38Z
0xSorryNotSorry marked the issue as duplicate of #1044
#1 - c4-judge
2023-04-22T10:32:59Z
Picodes marked the issue as satisfactory