Report #29983

Report Date
April 10, 2024

Not reducing `s.recapitalisation` when users call `UnripeFacet:chop()` for UNRIPE_LP reduces the value of uncopped UNRIPE_LP

Report Info

Report ID#29983

Report type

Smart Contract

Has PoC?





  • Contract fails to deliver promised returns, but doesn't lose value


  • Every time a user calls chop() for UNRIPE_LP the value of the UNRIPE_LP held by other users reduces • Holders of UNRIPE_LP tokens will only be able to claim a portion of the value they should be able to when the recapitalisation rate reaches 100%

Because the variable s.recapitalisation is not decreased when users call UnripeFacet:chop() for UNRIPE_LP tokens, the value of UNRIPE_LP that have not been chopped yet reduces in value. This will result in significantly lesser payout for holders of UNRIPE_LP tokens who wait to chop their UNRIPE_LP tokens until the recapitalisation reaches 100% and all sprouts are fertilized.

Root Cause

When a user calls UnripeFacet:chop() for UNRIPE_LP to chop his UNRIPE_LP in exchange for part of the underlying LP tokens, the called functions calls the internal function chop():

    function chop(
        address unripeToken,
        uint256 amount,
        uint256 supply
    ) internal returns (address underlyingToken, uint256 underlyingAmount) {
        AppStorage storage s = LibAppStorage.diamondStorage();
        underlyingAmount = LibUnripe._getPenalizedUnderlying(unripeToken, amount, supply);
        LibUnripe.decrementUnderlying(unripeToken, underlyingAmount);
        underlyingToken = s.u[unripeToken].underlyingToken;

This function determines how much underlying the user should get by calling

underlyingAmount = LibUnripe._getPenalizedUnderlying(unripeToken, amount, supply);

and decrements the underlying by calling

LibUnripe.decrementUnderlying(unripeToken, underlyingAmount);

Then the calculated amount is sent from the protocol to the user.

The issue arises from the fact that during the call the variable s.recapitalisation is not decreased by the amount of underlying that is send to the user. This results in a recapitalisation value that is higher than the actual value of the underlying tokens held by the protocol. When the recapitalisation rate reaches 100% the users that still hold UNRIPE_LP tokens will get less value for their tokens since the recapitalisation end to eraly.

Example calculation:

The relevant values in this scenario are s. recapitalized, the totalAmountToRecapitalize, the total supply of UNRIPE_LP (supplyUNRIPE_LP) and the usdPerUNRIPE_LP. Together they calculate the % of the totalAmountToRecapitalize that has already been recapitalized (Recapitalisation%).

For simplicity this example assumes a usdPerUNRIPE_LP of 1 and a value of 1USD per underlying token.

The example below assumes for the initial state a s. recapitalized of 500 USD. Since the value of one underlying token is 1 USD this means that the protocol holds 500 underlying tokens.

Furthermore, a total supply of UNRIPE_LP of 1000 is assumed. This means that the total amount that needs to be recapitalized is:

totalAmountToRecapitalize = supplyUNRIPE_LP * usdPerUNRIPE = 1000 UNRIPE_LP * 1 USD/UNRIPE_LP = 1000 USD

The Recapitalisation% is therefore:

Recapitalisation% = 500 USD / 1000 USD = 50%

The value a UNRIPE_LP token should have once recapitalisation reaches 100% is:

finalValue = (500USD / 1000) / 50% = 1 USD


In the current implementation, if a use burns/chops 500 UNRIPE_LP tokens and gets 100 underlying tokens for it (20% of the final value) the numbers change like this:

supplyUNRIPE_LP = 1000 – 500 = 500

totalAmountToRecapitalize = supplyUNRIPE_LP * usdPerUNRIPE = 500 UNRIPE_LP * 1 USD/UNRIPE_LP = 500 USD

s. recapitalized = 500 USD (stays the same since it is not adjusted)

The Recapitalisation% is therefore:

Recapitalisation% = 500 USD / 500 USD = 100%

The value a UNRIPE_LP token now that the recapitalisation is 100% is:

finalValue = 400USD / 500 = 0,8 USD

                        |   Inital State | Current implementation |
supplyUNRIPE_LP	        |      1000    	 |             500         |
Value underlying        |      $500      |            $400         |
s. recapitalized    	|      $500      |            $500         |
100% recapitalisation   |     $1.000 	 |            $500         |
% recapitalized	        |       50%	     |             100%        |
supply UNRIP_LP	        |      1000	     |              500    	   |
usdPerUNRIPE_LP	        |        1       |            	1          |
still to recapitalize   |      $500      |              $0         |
value per UNRIPE_LP 	|       $1     	 |              $0,8       |
at full recapitalisation|                |                         |
Value lost              |	             |            $ 100,00     |

This example shows that all value that is extracted from the protocol by chopping UNRIPE_LP tokens reduces the final value of the tokens not chopped yet.

Calculation of economic damage:

The amount that still need to be recovered until the recapitalisation reaches 100% is approximately 39,5 mill USD.

Assuming an average copping yield of 20% for 50% of this still outstanding amount is on the conservative side considering that copping get more and more attractive the higher the recapitalisation rate gets.

Based on this assumptions the economic damage amounts to:

39,5 mil * 50% * 10% = 3,95 mil USD

Suggested Fix

  1. Calculate how much damage was already done and adjust the current s.recapitalized value accordingly through a BIP
  2. When chopping make sure to check if the chopped assed is the UNRIPE_LP token and if so reduce the s.recapitalized accordingly. For this change the function  LibUnripe.decrementUnderlying from this:
    function decrementUnderlying(address token, uint256 amount) internal {
        AppStorage storage s = LibAppStorage.diamondStorage();
        s.u[token].balanceOfUnderlying = s.u[token].balanceOfUnderlying.sub(amount);
        emit ChangeUnderlying(token, -int256(amount));

To this:

    function decrementUnderlying(address token, uint256 amount) internal {
        AppStorage storage s = LibAppStorage.diamondStorage();
        s.u[token].balanceOfUnderlying = s.u[token].balanceOfUnderlying.sub(amount);
        s.recapitalized = s.recapitalized.sub(amount);
        emit ChangeUnderlying(token, -int256(amount));

Proof of concept

I've added the poc in the test file  test/ChopIssue.test.js within the GitHub repository (https://github.com/BenRai1/Beanstalk_Chop_Issue) where I invided publius and Brean0. I've also added a screenshot of the test results.

Alternatively, run this hardhat test on a fork of the mainnet by first running

npx hardhat node --network hardhat

And then in an other terminal

npx hardhat test test/ChopIssue.test.js

const { expect } = require('chai');
const { UNRIPE_LP} = require('./utils/constants.js');
const { ethers } = require("hardhat");
const { EXTERNAL } = require("./utils/balances.js");

//contract addresses
const beanstalkDiamondAddress = '0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5'
const underlingLPTokenAddress = '0xBEA0e11282e2bB5893bEcE110cF199501e872bAd' //BEAN-ETH-Well

//user addresses
const ethWhaleAddress = '0xbDA5747bFD65F08deb54cb465eB87D40e51B197E' //10_000 eth
const unripeLPWhale1 = '0x1085057e6d9AD66e73D3cC788079155660264152' //203k
const unripeLPWhale2 = '0x8fE7261B58A691e40F7A21D38D27965E2d3AFd6E' //58k
const unripeLPWhale3 = '0xaF616dABa40f81b75aF5373294d4dBE29DD0E0f6' //49k

describe(`Chopping issue`, function () {
    before(async function () {
        unripeFacet = await ethers.getContractAt("MockUnripeFacet",beanstalkDiamondAddress);
        fertilizerFacet = await ethers.getContractAt("MockFertilizerFacet",beanstalkDiamondAddress);
        unripeLp = await ethers.getContractAt("MockToken", UNRIPE_LP);
        underlyingLP = await ethers.getContractAt("MockToken", underlingLPTokenAddress);
        whale1 = await ethers.getImpersonatedSigner(unripeLPWhale1);
        whale2 = await ethers.getImpersonatedSigner(unripeLPWhale2);
        whale3 = await ethers.getImpersonatedSigner(unripeLPWhale3);
        ethWhale = await ethers.getImpersonatedSigner(ethWhaleAddress);
        await ethWhale.sendTransaction({to: whale1.address, value: ethers.utils.parseEther("100")});
        await ethWhale.sendTransaction({to: whale2.address, value: ethers.utils.parseEther("100")});
        await ethWhale.sendTransaction({to: whale3.address, value: ethers.utils.parseEther("100")});


    it(`UNRIPE_LP tokens are worth less after others chop`, async function () {
        totalSupplyUNRIPE_LPBefore = await unripeLp.totalSupply();
        recapitalisationBefore = await unripeFacet.getRecapFundedPercent(UNRIPE_LP);
        //check the value of unripeLP tokens of whale3
        balanceOfUnderlyingWhale3 = await unripeFacet.balanceOfUnderlying(UNRIPE_LP, whale3.address);
        valueAtFullRecapitalisationBefore = balanceOfUnderlyingWhale3 *1_000_000 / recapitalisationBefore;

        console.log("------------------Values before Chop ------------------");
        console.log("Total Supply UNRIPE_LP Before", Number(totalSupplyUNRIPE_LPBefore));
        console.log("Recapitalisation Before", Number(recapitalisationBefore));
        console.log("Value of Whale3 before", valueAtFullRecapitalisationBefore);

        //inpersonnate whale1
        balanceUnripeLPWhale1Before = await unripeLp.balanceOf(whale1.address);
        //cop all of his unripeLP tokens
        await unripeLp.connect(whale1).approve(beanstalkDiamondAddress, balanceUnripeLPWhale1Before);
        await unripeFacet.connect(whale1).chop(UNRIPE_LP,balanceUnripeLPWhale1Before, EXTERNAL, EXTERNAL);

        //inpersonnate whale2
        balanceUnripeLPWhale2Before = await unripeLp.balanceOf(whale2.address);
        //cop all of his unripeLP tokens
        await unripeLp.connect(whale2).approve(beanstalkDiamondAddress, balanceUnripeLPWhale2Before);
        await unripeFacet.connect(whale2).chop(UNRIPE_LP,balanceUnripeLPWhale2Before, EXTERNAL, EXTERNAL);

        //check supply of unreipeLP tokens
        totalSupplyUNRIPE_LPAfter = await unripeLp.totalSupply();
        //check recapitalisation
        recapitalisationAfter = await unripeFacet.getRecapFundedPercent(UNRIPE_LP);

        //check the value of whale3's unripeLP tokens
        balanceOfUnderlyingWhale3After = await unripeFacet.balanceOfUnderlying(UNRIPE_LP, whale3.address);
        valueAtFullRecapitalisationAfter = balanceOfUnderlyingWhale3After *1_000_000 / recapitalisationAfter;

        console.log("------------------Values after Chop ------------------");
        console.log("Total Supply UNRIPE_LP After", Number(totalSupplyUNRIPE_LPAfter));
        console.log("Recapitalisation After", Number(recapitalisationAfter));
        console.log("Value of Whale3 after", valueAtFullRecapitalisationAfter);


The test check the balanceOfUnderlying(amount of underlying tokens corresponding to the UNRIPE_LP tokens held by the user (whale3)) and by using the current recapitalisation ration the value of the user’s tokens are calculated once the recapitalisation reaches 100% (valueAtFullRecapitalisationBefore).

Then 203k and 58k UNRIPE_LP tokens from other users are chopped (whale1 and whale2) and the valueAtFullRecapitalisationAfter is calculated. The result shows that the value was reduced by chopping other users UNRIPE_LP.

Immunefi Response

Thank you for submitting a vulnerability report to the Beanstalk bug bounty program. We have reviewed your report and regret to inform you that we will have to close it due to inadequate proof of concept (PoC).

Immunefi review:

  • assessed impact by the triage team is in scope for the bug bounty program
  • assessed asset by the triage team is not in scope for the bug bounty program
  • The submitted PoC does not correspond to the described issue.
  • Technical Review:
    • The reported issue pertains to a different version of the contract that is Out of scope of the BBP. The PoC also relies on this version, hindering a thorough analysis of the vulnerability. We request WH to file a new submission if he believes the issue is present in the current in scope version of the protocol.

To ensure the proper escalation and evaluation of your report, Immunefi has checked the PoC to see if it matches the assessed impact and bug description, as well as verified the accuracy of your claims.

Please note that the project's team will receive a report of the closed submission and may choose to re-open it at their discretion. However, they are under no obligation to do so.