more contest details

Results Summary

Number of findings:

  • High: 0
  • Medium: 3
  • Low: 5

High Risk Findings

Medium Risk Findings

M-01. Calling init() will fail because the well that should be whitelisted has no liquidity

Submitted by BARW.

Relevant GitHub Links

https://github.com/Cyfrin/2024-04-beanstalk-2/blob/27ff8c87c9164c1fbff054be5f22e56f86cdf127/protocol/contracts/libraries/Well/LibWellBdv.sol#L37-L39

Impact

According to the team “the initial liquidity for the new Bean-wstEth well will be added to the well once the Eth from the old well is converted to wstEth”. The problem is that it will not be possible to initiate the migration to the new well because the init() function will revert if the new well does not have any liquidity in it.

Proof of Concept

When calling InitMigrateUnripeBeanEthToBeanSteth:init() to initiate the migration from the Bean-ETH well to the Bean-wstETH well, the new well is whitelisted by calling LibWhitelist:whitelistToken(). During this function call the function LibWellBdv:bdv is entered:

    function bdv(
        address well,
        uint amount
    ) internal view returns (uint _bdv) {
        uint beanIndex = LibWell.getBeanIndexFromWell(well);

        // For now, assume Beanstalk should always use the first pump and given that the Well has been whitelisted, it should be assumed
        // that the first Pump has been verified when the Well was whitelisted.
        Call[] memory pumps = IWell(well).pumps();
        uint[] memory reserves = IInstantaneousPump(pumps[0].target).readInstantaneousReserves(well, pumps[0].data);
        // If the Bean reserve is beneath the minimum balance, the oracle should be considered as off.
        require(reserves[beanIndex] >= C.WELL_MINIMUM_BEAN_BALANCE, "Silo: Well Bean balance below min"); 
        Call memory wellFunction = IWell(well).wellFunction();
        uint lpTokenSupplyBefore = IWellFunction(wellFunction.target).calcLpTokenSupply(reserves, wellFunction.data);
        reserves[beanIndex] = reserves[beanIndex].sub(BEAN_UNIT); // remove one Bean
        uint deltaLPTokenSupply = lpTokenSupplyBefore.sub(
            IWellFunction(wellFunction.target).calcLpTokenSupply(reserves, wellFunction.data)
        );
        _bdv = amount.mul(BEAN_UNIT).div(deltaLPTokenSupply);
    }

This function gets the reserves of the new Bean-wstETH well that should be whitelisted and checks if the bean reserves of the well are >= WELL_MINIMUM_BEAN_BALANCE which is set to 1000 beans:

uint[] memory reserves = IInstantaneousPump(pumps[0].target).readInstantaneousReserves(well, pumps[0].data);
        // If the Bean reserve is beneath the minimum balance, the oracle should be considered as off.
        require(reserves[beanIndex] >= C.WELL_MINIMUM_BEAN_BALANCE, "Silo: Well Bean balance
uint256 internal constant WELL_MINIMUM_BEAN_BALANCE = 1000_000_000; // 1,000 Beans

The issue arises from the fact that at the time the well is whitelisted it does not have any liquidity in it because according to the development team “the initial liquidity will be added to the well once the Eth is converted to wstEth”. This means the function will revert and therefore it will not be possible to initiate the migration.

Recommended Mitigation Steps

Do not whitelist the well when calling InitMigrateUnripeBeanEthToBeanSteth:init() but add a second function InitMigrateUnripeBeanEthToBeanSteth:finish() where the LP tokens for the new well are added and the new well which now has enough liquidity is whitelisted.

M-02. LibWstethEthOracle::getWstethEthPrice returns wrong wstETH/ETH price in some conditions impacting system operations

Submitted by KiteWeb3, holydevoti0n, Bauchibred, zhuying, 0xBeastBoy, bladesec, 0xTheBlackPanther. Selected submission by: KiteWeb3.

Relevant GitHub Links

https://github.com/Cyfrin/2024-04-beanstalk-2/blob/a3d702c2e108cac6ebdf2416906cbca73c83ec99/protocol/contracts/libraries/Oracle/LibWstethEthOracle.sol#L35-L37

https://github.com/Cyfrin/2024-04-beanstalk-2/blob/a3d702c2e108cac6ebdf2416906cbca73c83ec99/protocol/contracts/libraries/Oracle/LibWstethEthOracle.sol#L93-L98

Summary

The LibWstethEthOracle::getWstethEthPrice function is designed in the system to compute the wstETH/ETH price . On the top of the LibWstethEthOracle contract a detailed NatSpec describes the price computation logic. Reported here for clarity: "It then computes a wstETH:ETH price by taking the minimum of (3) and either the average of (1) and (2) if (1) and (2) are within MAX_DIFFERENCE from each other or (1)."

According to the NatSpec, the contract should compute the wstETH:ETH price by taking the minimum of the the redemption value or the average of the Chainlink and Uniswap oracle prices if their percent difference is within a specified threshold (MAX_DIFFERENCE) or the Chainlink oracle price if the percent difference exceeds this threshold. However, the actual implementation does not handle the scenario where the percent difference exceeds MAX_DIFFERENCE. Consequently, users interacts with the system, such as minting fertilizer tokens, using inaccurate price data.

Vulnerability Details

The LibWstethEthOracle::getWstethEthPrice lacks explicit handling for scenarios where the percent difference between the Chainlink and Uniswap oracle prices is greater then MAX_DIFFERENCE. This omission leads to situations where the contract does not default to the Chainlink price as intended, affecting the accuracy and reliability of the wstETH:ETH price computation.

/**
 * @title Wsteth Eth Oracle Library
 * @author brendan
 * @notice Computes the wstETH:ETH price.
 * @dev
 * The oracle reads from 4 data sources:
 * a. wstETH:stETH Redemption Rate: (0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0)
 * b. stETH:ETH Chainlink Oracle: (0x86392dC19c0b719886221c78AB11eb8Cf5c52812)
 * c. wstETH:ETH Uniswap Pool: (0x109830a1AAaD605BbF02a9dFA7B0B92EC2FB7dAa)
 * d. stETH:ETH Redemption: (1:1)
 *
 * It then computes the wstETH:ETH price in 3 ways:
 * 1. wstETH -> ETH via Chainlink: a * b
 * 2. wstETH -> ETH via wstETH:ETH Uniswap Pool: c * 1
 * 3. wstETH -> ETH via stETH redemption: a * d
 *
@> * It then computes a wstETH:ETH price by taking the minimum of (3) and either the average of (1) and (2)
@> * if (1) and (2) are within `MAX_DIFFERENCE` from each other or (1).
**/


    function getWstethEthPrice(uint256 lookback) internal view returns (uint256 wstethEthPrice) {

        uint256 chainlinkPrice = lookback == 0 ? 
            LibChainlinkOracle.getPrice(WSTETH_ETH_CHAINLINK_PRICE_AGGREGATOR, LibChainlinkOracle.FOUR_DAY_TIMEOUT) :
            LibChainlinkOracle.getTwap(WSTETH_ETH_CHAINLINK_PRICE_AGGREGATOR, LibChainlinkOracle.FOUR_DAY_TIMEOUT, lookback);

        // Check if the chainlink price is broken or frozen.
        if (chainlinkPrice == 0) return 0;

        uint256 stethPerWsteth = IWsteth(C.WSTETH).stEthPerToken();
        
        chainlinkPrice = chainlinkPrice.mul(stethPerWsteth).div(CHAINLINK_DENOMINATOR);


        // Uniswap V3 only supports a uint32 lookback.
        if (lookback > type(uint32).max) return 0;
        uint256 uniswapPrice = LibUniswapOracle.getTwap(
            lookback == 0 ? LibUniswapOracle.FIFTEEN_MINUTES :
            uint32(lookback),
            WSTETH_ETH_UNIV3_01_POOL, C.WSTETH, C.WETH, ONE
        );

        // Check if the uniswapPrice oracle fails.
        if (uniswapPrice == 0) return 0;

@>        if (LibOracleHelpers.getPercentDifference(chainlinkPrice, uniswapPrice) < MAX_DIFFERENCE) {
@>           wstethEthPrice = chainlinkPrice.add(uniswapPrice).div(AVERAGE_DENOMINATOR);
@>            if (wstethEthPrice > stethPerWsteth) wstethEthPrice = stethPerWsteth;
@>            wstethEthPrice = wstethEthPrice.div(PRECISION_DENOMINATOR);
        }
    }
}

Impact

The absence of the missing return of the Chainlink oracle price in scenarios of significant price discrepancy between the Chainlink and Uniswap oracles (LibOracleHelpers.getPercentDifference(chainlinkPrice, uniswapPrice) > MAX_DIFFERENCE) can lead to a scenario where the contract uses an average price that does not accurately reflect market conditions. The smart contract will operate with an inaccurate wstETH:ETH price, impacting operations dependent on this price. This could result in financial losses for users and undermine the integrity of the system.

For example, in the beanstalk system, the FertilizerFacet::mintFertilizer function relies on the LibWstethEthOracle::getWstethEthPrice to fetch the wstETH:ETH price from. This price is crucial for calculating the amount of Fertilizer tokens that can be acquired with the provided tokenAmountIn. However, if this function returns an inaccurate price, it would not reflect the actual price of the asset. Consequently, users could continue to mint fertilizer tokens using this inaccurate price data, leading to transactions occurring at incorrect prices.

Tools Used

Manual review

Recommendations

Modify the LibWstethEthOracle::getWstethEthPrice function to include explicit logic for handling the case where the percent difference between the Chainlink and Uniswap prices is greater then MAX_DIFFERENCE.

if (LibOracleHelpers.getPercentDifference(chainlinkPrice, uniswapPrice) < MAX_DIFFERENCE) {
            wstethEthPrice = chainlinkPrice.add(uniswapPrice).div(AVERAGE_DENOMINATOR);
+      } else {
+        wstethEthPrice = chainlinkPrice;
+    }
            if (wstethEthPrice > stethPerWsteth) wstethEthPrice = stethPerWsteth;
            wstethEthPrice = wstethEthPrice.div(PRECISION_DENOMINATOR);
-        }

M-03. Protocol unintentionally implements an asymmetric method of calculating the price difference

Submitted by Bauchibred, bladesec. Selected submission by: Bauchibred.

Relevant GitHub Links

https://github.com/Cyfrin/2024-04-beanstalk-2/blob/27ff8c87c9164c1fbff054be5f22e56f86cdf127/protocol/contracts/libraries/Oracle/LibOracleHelpers.sol#L25-L32

https://github.com/Cyfrin/2024-04-beanstalk-2/blob/27ff8c87c9164c1fbff054be5f22e56f86cdf127/protocol/contracts/libraries/Oracle/LibWstethEthOracle.sol#L68-L98

Summary

The protocol exhibits a disparity in the calculation of price differences due to the current implementation in LibOracleHelpers::getPercentDifference(). This affects LibWstethEthOracle::getWstethEthPrice(), since the present implementation causes unintended returns of 0 for price queries. The discrepancy arises from the flawed calculation logic as explained in the report, causing the protocol to incorrectly assume price manipulation in certain valid scenarios. This flaw undermines the reliability of price queries and cwould result in denial-of-service unintended implementation

Vulnerability Details

First take a look at https://github.com/Cyfrin/2024-04-beanstalk-2/blob/27ff8c87c9164c1fbff054be5f22e56f86cdf127/protocol/contracts/libraries/Oracle/LibOracleHelpers.sol#L25-L32

    function getPercentDifference(
        uint x,
        uint y
    ) internal pure returns (uint256 percentDifference) {
        percentDifference = x.mul(ONE).div(y);
        percentDifference = x > y ? percentDifference - ONE : ONE - percentDifference; // SafeMath unnecessary due to conditional check
    }
}

This function is used to get the percent difference between two values, keep in mind that "values" in this context are "prices" provided by different oracle sources, protocol hardcodes the numerator and denominator of getting the percentage as x and y respectively x.mul(ONE).div(y), but evidently we can see that the percentage of the price difference is finalized depending on if x > y or not, causing the equation to be flawed, cause percentDifference - ONE & ONE - percentDifference would yield different results in the context for two same x and y price.

To deduce the differences in the formular, we can see that

  • If x > y then the percentDifference is gotten as ( ( (1e18 * X ) / Y ) - 1e18), however
  • If x < y then the percentDifference is gotten as (1e18 - ( ( 1e18 * X ) / Y ) )

Using generic values of 5 and 6... and assuming 1e18 as 1000 just for POC sake, i.e instances of 1e18 * X is now 1000X.

For the first case (X = 5, Y = 6), we get a percentDifference of 166,67 ~ 16,7% For the second case (X = 6, Y = 5), we get a percentDifference of 200 = 20%

Now, take a look at the only instance where this check is implemented in scope, https://github.com/Cyfrin/2024-04-beanstalk-2/blob/27ff8c87c9164c1fbff054be5f22e56f86cdf127/protocol/contracts/libraries/Oracle/LibWstethEthOracle.sol#L68-L98

    function getWstethEthPrice(uint256 lookback) internal view returns (uint256 wstethEthPrice) {

        uint256 chainlinkPrice = lookback == 0 ?
            LibChainlinkOracle.getPrice(WSTETH_ETH_CHAINLINK_PRICE_AGGREGATOR, LibChainlinkOracle.FOUR_DAY_TIMEOUT) :
            LibChainlinkOracle.getTwap(WSTETH_ETH_CHAINLINK_PRICE_AGGREGATOR, LibChainlinkOracle.FOUR_DAY_TIMEOUT, lookback);

        // Check if the chainlink price is broken or frozen.
        if (chainlinkPrice == 0) return 0;

        uint256 stethPerWsteth = IWsteth(C.WSTETH).stEthPerToken();

        chainlinkPrice = chainlinkPrice.mul(stethPerWsteth).div(CHAINLINK_DENOMINATOR);


        // Uniswap V3 only supports a uint32 lookback.
        if (lookback > type(uint32).max) return 0;
        uint256 uniswapPrice = LibUniswapOracle.getTwap(
            lookback == 0 ? LibUniswapOracle.FIFTEEN_MINUTES :
            uint32(lookback),
            WSTETH_ETH_UNIV3_01_POOL, C.WSTETH, C.WETH, ONE
        );

        // Check if the uniswapPrice oracle fails.
        if (uniswapPrice == 0) return 0;
        //@audit `getPercentDifference` is queried with `MAX_DIFFERENCE`
        if (LibOracleHelpers.getPercentDifference(chainlinkPrice, uniswapPrice) < MAX_DIFFERENCE) {
            wstethEthPrice = chainlinkPrice.add(uniswapPrice).div(AVERAGE_DENOMINATOR);
            if (wstethEthPrice > stethPerWsteth) wstethEthPrice = stethPerWsteth;
            wstethEthPrice = wstethEthPrice.div(PRECISION_DENOMINATOR);
        }
    }

This function is used to get the wstEth price, but the MAX_DIFFERENCE check is applied here and in a case where the price discrepancy is more than MAX_DIFFERENCE this function returns 0.

The above explained case means that the current implementation of getPercentDifference would cause protocol assuming the price has been manipulated for the same difference just dependent on if the chainlink price is higher/lower than the uniswap returned price, causing 0 to be returned in some valid cases and considering the fact that the MAX_DIFFERENCE has been set very low, this would frequently occur, to use a real life case a sa POC, as at the time of writing this report, the value of wstEth in comparison to eth is 1,16323, so assuming the queried price X from chainlink returns 1,17490, and uniswap price Y returns 1,16323, the getPercentDifference() check fails, since the difference in percentage is = 1,0032409755594 * 1e18 i.e > MAX_DIFFERENCE, however if we swap which provider provides which, i.e now chainlink X returns 1,16323 and uniswap price Y returns 1,17490, the getPercentDifference() check passes since the difference in percentage would be = 0.99327602349136 * 1e18 i.e < MAX_DIFFERENCE proving the disparity and this calculation wstethEthPrice = chainlinkPrice.add(uniswapPrice).div(AVERAGE_DENOMINATOR); would be the same for the two cases irrespective of (x > y)? i.e in our highlighted case above for both instances the calculation would equal ((1,17490, + 1,16323 ) / 2) = 1,169065 and should be considered valid in both cases.

Impact

Desynchronization of price queries and this directly affects all instances in protocol where LibWstethEthOracle.sol's getWstethEthPrice() is called, i.e in two same cases where the provided prices are the same, the attempt to query the price correctly works with one where as it doesn't with the other, leading to unintended return of 0 for price essentially a DOS to LibWstethEthOracle.sol's getWstethEthPrice() since protocol would now assume that the price has been manipulated, keep in mind that in real sense this calculation wstethEthPrice = chainlinkPrice.add(uniswapPrice).div(AVERAGE_DENOMINATOR); would always return the same value for both cases and as such the price should be valid and accepted in both cases.

Tool Used

Manual review

Recommended Mitigation Steps.

Make the check to always be in sync, this can be by ensuring that the divisor is always the lower/greater price and then maybe reimplementing the value of MAX_DIFFERENCE to take this into consideration, the former alone is sufficient, i.e reimplement https://github.com/Cyfrin/2024-04-beanstalk-2/blob/27ff8c87c9164c1fbff054be5f22e56f86cdf127/protocol/contracts/libraries/Oracle/LibOracleHelpers.sol#L25-L32 to:

        function getPercentDifference(
            uint x,
            uint y
        ) internal pure returns (uint256 percentDifference) {
            if (x == y) {
                percentDifference = 0;
            } else if (x < y) {
                percentDifference = x.mul(ONE).div(y);
                percentDifference = ONE - percentDifference;
            } else {
                percentDifference = y.mul(ONE).div(x);
                percentDifference = ONE - percentDifference;
            }
            return percentDifference;
        }

Here we are always using the bigger price as the denominator, thereby making sure that in whichever of the two cases explained in report, i.e if x > y or not a fixed percentDifference is provided and this can then be accurately checked against protocol's set MAX_DIFFERENCE value.

Low Risk Findings

L-01. Becasue of devision before multiplication in LibFertilizer:remainingRecapitalization() less fertilizers is mintable than is needed to fully recapitalize the lost LP token value

Submitted by BARW.

Relevant GitHub Links

https://github.com/Cyfrin/2024-04-beanstalk-2/blob/27ff8c87c9164c1fbff054be5f22e56f86cdf127/protocol/contracts/libraries/LibFertilizer.sol#L182-L195

https://github.com/Cyfrin/2024-04-beanstalk-2/blob/27ff8c87c9164c1fbff054be5f22e56f86cdf127/protocol/contracts/C.sol#L188-L190

https://github.com/Cyfrin/2024-04-beanstalk-2/blob/27ff8c87c9164c1fbff054be5f22e56f86cdf127/protocol/contracts/beanstalk/barn/FertilizerFacet.sol#L64-L84

Summary

There is a division before multiplication at LibFertilizer:remainingRecapitalisation(), which will cause the protocol to enable the mint of less fertilizer than intended. This will result in less value recapitalized than lost and therefor loss of value for the holders of UNRIPE_LP tokens.

Vulnerability Details

When calling LibFertilizer::remainingRecapitalization() the amount of totalDollars this calculated that still need to be recapitalized:

     uint256 totalDollars = C.dollarPerUnripeLP().mul(C.unripeLP().totalSupply()).div(DECIMALS);

For this the function C.dollarPerUnripeLP is used invoking a division operation before the following multiplication, leading to precision loss:

     function dollarPerUnripeLP() internal pure returns (uint256) {
        return 1e12/UNRIPE_LP_PER_DOLLAR;
    }

POC

Add this file to test folder and run forge test -vv --mt test_precision_loss

// SPDX-License-Identifier: MIT
pragma solidity =0.7.6;
import {SafeMath} from "openzeppelin/utils/math/SafeMath.sol";
import "forge-std/Test.sol";
import "forge-std/console.sol";

contract TestPrecisionLoss is Test {
    using SafeMath for uint256;

    function setUp() public {}

    function test_precision_loss() external {
        uint256 UNRIPE_LP_TOTAL_SUPPLY = 96033202232306;
        uint128 DECIMALS = 1e6;
        uint256 UNRIPE_LP_PER_DOLLAR = 1884592; // 145_113_507_403_282 / 77_000_000
        uint256 dollarPerUnripeLP = 1e12 / UNRIPE_LP_TOTAL_SUPPLY;

        uint256 totalDollars1 = (1e12 / UNRIPE_LP_PER_DOLLAR)
            .mul(UNRIPE_LP_TOTAL_SUPPLY)
            .div(DECIMALS);
        console.log("totalDollars1: ", totalDollars1);
        uint256 totalDollars2 = (1e12 * UNRIPE_LP_TOTAL_SUPPLY)
            .div(UNRIPE_LP_PER_DOLLAR)
            .div(DECIMALS);
        console.log("totalDollars2: ", totalDollars2);
        console.log("the diff: ", totalDollars2 - totalDollars1);
    }
}

The output of the test is the following:

Running 1 test for test/testPrecisionLoss.sol:TestPrecisionLoss
[PASS] test_precision_loss() (gas: 5691)
Logs:
  totalDollars1:  50956945702101
  totalDollars2:  50957025304313
  the diff:  79602212

Impact

remainingRecapitalization() will always return lesser value than it should be. This will lead to less fertilizer available for mint and therefore less USD value that will get recapitalized for the UNRIPE_LP holders. Here is the related code in FertilizerFacet::mintFertilizer():

        uint128 remaining = uint128(LibFertilizer.remainingRecapitalization().div(1e6));// remaining <= 77_000_000 so downcasting is safe.
        require(fertilizerAmountOut <= remaining, "Fertilizer: Not enough remaining.");

Tools Used

Foundry

Recommendations

--uint256 totalDollars = C.dollarPerUnripeLP().mul(C.unripeLP().totalSupply()).div(DECIMALS);
++uint256 totalDollars = (1e12).mul(C.unripeLP().totalSupply()).div(C.unripeLPPerDollara()).div(DECIMALS);

L-02. Deprecated pool BEAN:WETH on LibBarnRaise used as fallback

Submitted by holydevoti0n.

Relevant GitHub Links

https://github.com/Cyfrin/2024-04-beanstalk-2/blob/a3d702c2e108cac6ebdf2416906cbca73c83ec99/protocol/contracts/libraries/LibBarnRaise.sol#L26-L29

https://github.com/Cyfrin/2024-04-beanstalk-2/blob/a3d702c2e108cac6ebdf2416906cbca73c83ec99/protocol/contracts/beanstalk/init/InitMigrateUnripeBeanEthToBeanSteth.sol#L67C23-L67C46

https://github.com/Cyfrin/2024-04-beanstalk-2/blob/a3d702c2e108cac6ebdf2416906cbca73c83ec99/protocol/contracts/libraries/LibFertilizer.sol#L250

Summary

The protocol will migrate the Bean:WETH Well LP to Bean:wsETH Well LP after initializing the bip migration:

InitMigrateUnripeBeanEthToBeanSteth -> LibFertilizer -> beginBarnRaiseMigration -> switchUnderlyingToken

LibUnripe.switchUnderlyingToken(C.UNRIPE_LP, well);

This will change the underlying token of C.UNRIPE_LP to the new Bean:wsETH pool.

Vulnerability Details

Whenever getBarnRaiseWell uses a fallback underlyingToken, the correct pool to be returned should be the new one added Bean:wsETH not Bean:WETH. But currently, the Bean:WETH pool is used.

Impact

As the getBarnRaiseWell is used in several areas of the protocol like:

  • Token conversions(LibConvert)
  • Calculate BDV
  • Calculate the caseId

Whenever the fallback underlyingToken is used it will completely break the protocol logic as Bean:WETH is not the current underlying token after the migration.

PoC

Add the following test inside BeanEthToBeanWstethMigration.test.js -> 'Initializes migration'

describe('When the fallback unlderyingToken is used', async function () {
      it('should return valid fallback token', async function () {
        await this.beanstalk.connect(owner).switchUnderlyingToken(UNRIPE_LP, ethers.constants.AddressZero)
        expect(await this.beanstalk.getBarnRaiseToken()).to.be.equal(WSTETH)
      })
    })

Output:

21 passing (19s)
  1 failing

  1) Bean:Eth to Bean:Wsteth Migration
       Initializes migration
         When the fallback unlderyingToken is used
           should return valid fallback token:

      AssertionError: expected '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C7…' to equal '0x7f39C581F595B53c5cb19bD0b3f8dA6c935…'
      + expected - actual

      -0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2
      +0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0

Tools Used

Hardhard & Manual Review

Recommendations

Add the C.BEAN_WSTETH_WELL as a fallback for the underlying token on LibBarnRaise. Also, ensure C.BEAN_WSTETH_WELL will have the correct address.

return
            s.u[C.UNRIPE_LP].underlyingToken == address(0)
-                ? C.BEAN_ETH_WELL
+                ? C.BEAN_WSTETH_WELL
                : s.u[C.UNRIPE_LP].underlyingToken;

L-03. There is a more efficient and secure way to compute wstETH:ETH price using Chainlink

Submitted by holydevoti0n, InAllHonesty, 0xBeastBoy. Selected submission by: InAllHonesty.

Relevant GitHub Links

https://github.com/Cyfrin/2024-04-beanstalk-2/blob/27ff8c87c9164c1fbff054be5f22e56f86cdf127/protocol/contracts/libraries/Oracle/LibWstethEthOracle.sol#L24-L37

Summary

The chosen stETH:ETH Chainlink Oracle has a huge heartbeat, which exposes the protocol to unnecessary risk that could be easily mitigated by chosing another path of computing the same price with two different Chainlink Oracles that have a better heartbeat.

Vulnerability Details

According to the handy comments in LibWstethEthOracle.sol the price ofwstETH:ETH is computed as follows:

The oracle reads from 4 data sources:
a. wstETH:stETH Redemption Rate
b. stETH:ETH Chainlink Oracle
c. wstETH:ETH Uniswap Pool
d. stETH:ETH Redemption (1:1)
 
It then computes the wstETH:ETH price in 3 ways:
1. wstETH -> ETH via Chainlink: a * b 
2. wstETH -> ETH via wstETH:ETH Uniswap Pool: c * 1
3. wstETH -> ETH via stETH redemption: a * d

Looking at the feed details on Chainlink's Price Feed page we can see the following details:

Pair: STETH / ETH
Deviation 0.5%	
Heartbeat 86400s
Decimals 18

On the same page we find the following:

Pair: STETH / USD
Deviation 1%	
Heartbeat 3600s
Decimals 8
	
Pair: ETH / USD
Deviation 0.5%	
Heartbeat 3600s
Decimals 8

Changing the way the Chainlink price is computed from a * b to a * (STETH / USD) * 1/(ETH / USD)(adjusted for decimals) would yield an overall heartbeat of 3600s (1 hour) vs the existing one of 86400s (1 day).

A similar finding is available here.

Moreover there's a strong chance this was the intention of the developer given that the RFC doesn't specify the stETH:ETH Chainlink Oracle but specifies the stETH:USD Chainlink Oracle and a different method of computing the wstETH:ETH price.

Impact

Protocol could use inaccurate prices, or at least could benefit from a more accurate price feed in case the proposed changed is implemented.

Likelihood: Low to Extremely Low

Impact: The consumption of stale prices is usually Medium-High depending on how bad the consumed price is.

Overall I consider the severity Low.

Tools Used

Manual review

Recommendations

Implement the a * (STETH / USD) * 1/(ETH / USD)(adjusted for decimals) instead of wstETH -> ETH via Chainlink: a * b used currently.

L-04. LibUnripeConvert.sol :: getBeanAmountOut() incorrectly calculates the amount of BEAN.

Submitted by IvanFitro, bladesec. Selected submission by: IvanFitro.

Relevant GitHub Links

https://github.com/Cyfrin/2024-04-beanstalk-2/blob/27ff8c87c9164c1fbff054be5f22e56f86cdf127/protocol/contracts/libraries/Convert/LibUnripeConvert.sol#L130-L145

Summary

getBeanAmountOut() incorrectly uses C.UNRIPE_BEAN.totalSupply() instead of C.UNRIPE_LP.totalSupply() to calculate the BEAN amount, resulting in an incorrect calculation.

Vulnerability Details

getBeanAmountOut() calculates the amount of BEAN tokens a user would receive per BEAN:3CRV LP provided.

function getBeanAmountOut(uint256 amountIn)
        internal
        view
        returns (uint256 bean)
    {   
        uint256 lp = LibUnripe.unripeToUnderlying(
            C.UNRIPE_LP,
            amountIn,
@>          IBean(C.UNRIPE_BEAN).totalSupply()  
        );
        bean = LibWellConvert.getBeanAmountOut(LibBarnRaise.getBarnRaiseWell(), lp);
        bean = LibUnripe
            .underlyingToUnripe(C.UNRIPE_BEAN, bean)
            .mul(LibUnripe.percentBeansRecapped())
            .div(LibUnripe.percentLPRecapped());
    }

As observed, the calculation in getBeanAmountOut() mistakenly utilizes IBean(C.UNRIPE_BEAN).totalSupply() to determine the LP. However, this line calculates the LP not the BEAN token amount. The correct implementation is using IBean(C.UNRIPE_LP).totalSupply() to next calcualte correctly the desired BEAN token quantity.

Impact

The BEAN token amount obtained is incorrect.

Tools Used

Manual review.

Recommendations

Change IBean(C.UNRIPE_BEAN).totalSupply() for IBean(C.UNRIPE_LP).totalSupply() .

function getBeanAmountOut(uint256 amountIn)
        internal
        view
        returns (uint256 bean)
    {   
        uint256 lp = LibUnripe.unripeToUnderlying(
            C.UNRIPE_LP,
            amountIn,
-          IBean(C.UNRIPE_BEAN).totalSupply()
+          IBean(C.UNRIPE_LP).totalSupply() 
        );
        bean = LibWellConvert.getBeanAmountOut(LibBarnRaise.getBarnRaiseWell(), lp);
        bean = LibUnripe
            .underlyingToUnripe(C.UNRIPE_BEAN, bean)
            .mul(LibUnripe.percentBeansRecapped())
            .div(LibUnripe.percentLPRecapped());
    }

L-05. Missing the lookback parameter when invoking the getWstethUsdPrice() in the getTokenPrice function

Submitted by mrMorningstar, 0xbug, bladesec, KupiaSec, ge6a. Selected submission by: 0xbug.

Relevant GitHub Links

https://github.com/Cyfrin/2024-04-Beanstalk-2/blob/main/protocol/contracts/libraries/Oracle/LibUsdOracle.sol#L64

Summary

The getWstethUsdPrice() function is being called without using the lookback parameter if it's the WSTETH token. The function uses a constant value of 0 for the lookback parameter when calling LibWstethUsdOracle.getWstethUsdPrice(). So it always returns the current spot price for wstETH.

Vulnerability Details

    function getTokenPrice(address token, uint256 lookback) internal view returns (uint256) {
         if (token == C.WETH) {
            uint256 ethUsdPrice = LibEthUsdOracle.getEthUsdPrice(lookback);
            if (ethUsdPrice == 0) return 0;
            return ethUsdPrice;
        }
        if (token == C.WSTETH) {
            uint256 wstethUsdPrice = LibWstethUsdOracle.getWstethUsdPrice(0); // @audit missing lookback?
            if (wstethUsdPrice == 0) return 0;
            return wstethUsdPrice;
        }
        revert("Oracle: Token not supported.");
    }

Impact

It's always returning the current price instead of TWAP for wstETH. This could lead to inaccurate calculations in calling this getTokenPrice for wstETH.

Tools Used

Manual review

Recommendations

It's recommended to use the lookback parameter instead of 0. uint256 wstethUsdPrice = LibWstethUsdOracle.getWstethUsdPrice(lookback);