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 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():
This function determines how much underlying the user should get by calling
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:
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:
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
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
Calculate how much damage was already done and adjust the current s.recapitalized value accordingly through a BIP
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));
}
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
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.