DeFiHardhat
21,000 USDC
View results
Submission Details
Severity: high
Invalid

Hardcoded slippage when converting unripe to ripe could cost users their tokens

Summary

The Beanstalk protocol's current mechanism for converting unripe tokens into their ripe counterparts, lacks any slippage protection whatsoever. This oversight means users may receive significantly less than expected when converting unripe tokens to ripe tokens, with the system accepting any value > = 1 wei as mathematically valid, regardless of the intended transaction amount provided by the users.

This situation arises because the calculation for determining the amount of ripe tokens to return does not consider market conditions or allow for adjustments, albeit it leading to potential losses for users who rely on the accuracy of the conversion rate.

Vulnerability Details

First see https://github.com/Cyfrin/2024-05-Beanstalk-3/blob/662d26f12ee219ee92dc485c06e01a4cb5ee8dfb/protocol/contracts/beanstalk/barn/UnripeFacet.sol#L79-L100

function chop(
address unripeToken,
uint256 amount,
LibTransfer.From fromMode,
LibTransfer.To toMode
) external payable nonReentrant returns (uint256) {
// burn the token from the msg.sender address
uint256 supply = IBean(unripeToken).totalSupply();
amount = LibTransfer.burnToken(IBean(unripeToken), amount, msg.sender, fromMode);
// get ripe address and ripe amount
//@audit
(address underlyingToken, uint256 underlyingAmount) = LibChop.chop(
unripeToken,
amount,
supply
);
// send the corresponding amount of ripe token to the user address
require(underlyingAmount > 0, "Chop: no underlying");
IERC20(underlyingToken).sendToken(underlyingAmount, msg.sender, toMode);
// emit the event
emit Chop(msg.sender, unripeToken, amount, underlyingAmount);
return underlyingAmount;
}

Now see https://github.com/Cyfrin/2024-05-Beanstalk-3/blob/662d26f12ee219ee92dc485c06e01a4cb5ee8dfb/protocol/contracts/libraries/Convert/LibChopConvert.sol#L27-L46

function convertUnripeToRipe(bytes memory convertData)
internal
returns (
address tokenOut,
address tokenIn,
uint256 amountOut,
uint256 amountIn
)
{
// Decode convertdata
(amountIn, tokenIn) = convertData.lambdaConvert();
//@audit
(tokenOut, amountOut) = LibChop.chop(
tokenIn,
amountIn,
IBean(tokenIn).totalSupply()
);
IBean(tokenIn).burn(amountIn);
}

These functions are used to convert an unripe asset to it's ripe counterpart where the latter takes care of converting deposited Unripe tokens into their deposited Ripe Tokens and the former chops the asset according to the recapitalization % after burning the amountIn, note that both functions delegate the heavy conversion to LibChop.chop https://github.com/Cyfrin/2024-05-Beanstalk-3/blob/662d26f12ee219ee92dc485c06e01a4cb5ee8dfb/protocol/contracts/libraries/LibChop.sol#L27-L37

function chop(
address unripeToken,
uint256 amount,
uint256 supply
) internal returns (address underlyingToken, uint256 underlyingAmount) {
AppStorage storage s = LibAppStorage.diamondStorage();
//@audit
underlyingAmount = LibUnripe.getPenalizedUnderlying(unripeToken, amount, supply);
// remove the underlying amount and decrease s.recapitalized if token is unripe LP
LibUnripe.removeUnderlying(unripeToken, underlyingAmount);
underlyingToken = s.u[unripeToken].underlyingToken;
}

This function does the "chopping" or converting of the Unripe Token into its Ripe Token, the underlying amount being removed is gotten from LibUnripe.getPenalizedUnderlying() and the amount from LibChopConvertconvertUnripeToRipe() is being passed to this function to get the underlyingAmount for the provided amount.

Now here is the implementation of LibUnripe.getPenalizedUnderlying() https://github.com/Cyfrin/2024-05-Beanstalk-3/blob/662d26f12ee219ee92dc485c06e01a4cb5ee8dfb/protocol/contracts/libraries/LibUnripe.sol#L149-L171

function getPenalizedUnderlying(
address unripeToken,
uint256 amount,
uint256 supply
) internal view returns (uint256 redeem) {
require(isUnripe(unripeToken), "not vesting");
AppStorage storage s = LibAppStorage.diamondStorage();
// getTotalRecapDollarsNeeded() queries for the total urLP supply which is burned upon a chop
// If the token being chopped is unripeLP, getting the current supply here is inaccurate due to the burn
// Instead, we use the supply passed in as an argument to getTotalRecapDollarsNeeded since the supply variable
// here is the total urToken supply queried before burnning the unripe token
uint256 totalUsdNeeded = unripeToken == C.UNRIPE_LP ? LibFertilizer.getTotalRecapDollarsNeeded(supply)
: LibFertilizer.getTotalRecapDollarsNeeded();
// chop rate = total redeemable * (%DollarRecapitalized)^2 * share of unripe tokens
// redeem = totalRipeUnderlying * (usdValueRaised/totalUsdNeeded)^2 * UnripeAmountIn/UnripeSupply;
// But totalRipeUnderlying = CurrentUnderlying * totalUsdNeeded/usdValueRaised to get the total underlying
// redeem = currentRipeUnderlying * (usdValueRaised/totalUsdNeeded) * UnripeAmountIn/UnripeSupply
uint256 underlyingAmount = s.u[unripeToken].balanceOfUnderlying;
redeem = underlyingAmount.mul(s.recapitalized).div(totalUsdNeeded).mul(amount).div(supply);
// cap `redeem to `balanceOfUnderlying in the case that `s.recapitalized` exceeds `totalUsdNeeded`.
// this can occur due to unripe LP chops.
//@audit
if(redeem > underlyingAmount) redeem = underlyingAmount;
}

Evidently the if(redeem > underlyingAmount) redeem = underlyingAmount; translates to the fact that the reduction has been "hardcoded" and essentially this process lacks any slippage which is necessary for any swap.

That's to say this implementation would cause for the wrong amount of underlying to be removed via LibUnripe, essentially showcasing that for the amount of Unripe Token passed into LibChop#chop() the wrong rates is used to change it into its Ripe Token.

Now since this attempt at converting lacks any slippage whatsoever the attempt at sending users the tokens could return as low as 1 wei and there is no way for a user to decline this https://github.com/Cyfrin/2024-05-Beanstalk-3/blob/662d26f12ee219ee92dc485c06e01a4cb5ee8dfb/protocol/contracts/beanstalk/barn/UnripeFacet.sol#L95-L96

require(underlyingAmount > 0, "Chop: no underlying");
IERC20(underlyingToken).sendToken(underlyingAmount, msg.sender, toMode);

Impact

Users would lose out on their unripe asset when chopping it into its ripe counterpart since no slippage whatsoever is applied, and any value above 1 is acceptable for whatever amount of unripe tokens that gets burnt for the users, which indicates that even a slippage of 99% is unfairly acceptable for users.

This also falls on the "Contract fails to return promised returns" since the documentation clearly state that this calculation is according to the new chop rate which is %Recapitalized^2, albeit this is of a higher severity as it causes user's loss of funds, cause users who do the calculation before hand with the rate in mind would receive lesser tokens and they can't reject the reduction, keep in mind that if they knew this reduction would be done before hand, they can just reduce the amount that's been chopped to ensure their redemption is not heavily reduced, since the amount that's passed to be chopped has a direct relation to the amount that ends up being redeemed: https://github.com/Cyfrin/2024-05-Beanstalk-3/blob/662d26f12ee219ee92dc485c06e01a4cb5ee8dfb/protocol/contracts/libraries/LibUnripe.sol#L167

redeem = underlyingAmount.mul(s.recapitalized).div(totalUsdNeeded).mul(amount).div(supply);

Tools Used

Manual review

Recommendations

Consider making the attempt at chopping the unripe tokens being exactly changed for the amount of ripe tokens, in this case if (redeem > underlyingAmount) redeem = underlyingAmount; then this should also reflect in the amount of unripe tokens chopped, i.e the amount passed into LibUnripe.getPenalizedUnderlying() should be reduced to cover the available underlying amount and essentially the amount of unripe tokens chopped via LibChop.chop() should also be reduced.

TLDR: Apply a slippage parameter to UnripeFacet#chop() and check to see if the underlying amount returned is >= to this slippaged value provided by the user and if yes, allow their transactions to succeed.

Updates

Lead Judging Commences

giovannidisiena Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
bauchibred Submitter
about 1 year ago
giovannidisiena Lead Judge
about 1 year ago
bauchibred Submitter
about 1 year ago
giovannidisiena Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.