📄

Report #29992

Report Date
April 11, 2024
Status
Confirmed
Payout
10,000

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

Report Info

Report ID

#29992

Report type

Smart Contract

Has PoC?

Yes

Target

https://etherscan.io/address/0xC1E088fC1323b20BCBee9bd1B9fC9546db5624C5

Impacts

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

Description

  • 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 less 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

A user calls UnripeFacet:chop() for UNRIPE_LP to chop his UNRIPE_LP in exchange for part of the underlying LP tokens:

     function chop(
        address unripeToken,
        uint256 amount,
        LibTransfer.From fromMode,
        LibTransfer.To toMode
    ) external payable nonReentrant returns (uint256 underlyingAmount) {
        uint256 unripeSupply = IERC20(unripeToken).totalSupply();

        amount = LibTransfer.burnToken(IBean(unripeToken), amount, msg.sender, fromMode);

        underlyingAmount = _getPenalizedUnderlying(unripeToken, amount, unripeSupply);

        require(underlyingAmount > 0, "Chop: no underlying");

        LibUnripe.decrementUnderlying(unripeToken, underlyingAmount);

        address underlyingToken = s.u[unripeToken].underlyingToken;

        IERC20(underlyingToken).sendToken(underlyingAmount, msg.sender, toMode);

        emit Chop(msg.sender, unripeToken, amount, underlyingAmount);
    }

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

`underlyingAmount = _getPenalizedUnderlying(unripeToken, amount, unripeSupply);’

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%, users that still hold UNRIPE_LP tokens will get less value for their tokens since the recapitalisation ends to early.

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

CURRENT IMPLEMETATION:

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

The POC is written with the currently deployed implementation of the UnripeFacet and the test is done on the forked Etherium main net. 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 invited 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);
        expect(valueAtFullRecapitalisationBefore).to.be.gt(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.

BIR-14: Decrease Recapitalization Upon Chop

BIC Response

Given that the issue is not exploitable in the sense of theft (there's no liquid market that an attacker can use to buy up all the Unripe assets to then Chop, etc.), we believe that the most accurate categorization is “Contract fails to deliver promised returns, but doesn't lose value”, and thus Medium severity.

Based on our bounty page, this submission's ( Smart Contract - Medium) reward is based on a set of internal criteria established by the BIC (the exploitability of the bug, the impact it causes and the likelihood of the vulnerability presenting itself), with a minimum reward of USD 1000 and maximum reward of USD 10,000.

Although the issue is not exploitable in the traditional sense and would have very low impact as a result of Chopping over the course of a single hour (see Funds at Risk definition), given the high likelihood the vulnerability presenting itself during any particular Chop, the BIC has determined that this bug report be rewarded 10,000 Beans.

It's worth noting that even if the Severity categorized as High, this report would qualify for the minimum High reward of 10,000 Beans given the practicable economic damage.