DeFiHardhatFoundry
250,000 USDC
View results
Submission Details
Severity: medium
Invalid

Loss of grown stalks during converting due to rounding issue

Summary

During the converting due to the rounding issue, all the grown stalks associated with the input token will not be kept, and some will be orphaned. So, the grown stalks associated with the output token would be smaller.

Vulnerability Details

When converting the input token to the output token, the grown stalks will not be forfeited. This is the important feature of converting. To do this, when converting, the grown stalks associated with the input token amount will be removed, and then they will be added associated with the output token.

function pipelineConvert(
address inputToken,
int96[] calldata stems,
uint256[] calldata amounts,
address outputToken,
AdvancedFarmCall[] calldata advancedFarmCalls
)
external
payable
fundsSafu
nonReentrant
returns (int96 toStem, uint256 fromAmount, uint256 toAmount, uint256 fromBdv, uint256 toBdv)
{
//....
uint256 grownStalk;
(grownStalk, fromBdv) = LibConvert._withdrawTokens(inputToken, stems, amounts, fromAmount);
//....
toStem = LibConvert._depositTokensForConvert(outputToken, toAmount, toBdv, grownStalk);
//...
}

https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/silo/PipelineConvertFacet.sol#L62

Based on the bdv of the output token and the amount of grown stalks, the new stem toStem will be calculated.

function calculateStemForTokenFromGrownStalk(
address token,
uint256 grownStalk,
uint256 bdv
) internal view returns (int96 stem, GerminationSide side) {
LibGerminate.GermStem memory germStem = LibGerminate.getGerminatingStem(token);
stem = germStem.stemTip.sub(
SafeCast.toInt96(SafeCast.toInt256(grownStalk.mul(PRECISION).div(bdv)))
);
side = LibGerminate._getGerminationState(stem, germStem);
}

https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibTokenSilo.sol#L545

This shows that the toStem is equal to:

toStem = stemTip - (grownstalks associated with input token) / (bdv of output token)

The issue is that the part (grownstalks associated with input token) / (bdv of output token) will be rounded down, which is equivalent to the following piece of code. Thus, due to rounding, the value of toStem would be bigger.

SafeCast.toInt96(SafeCast.toInt256(grownStalk.mul(PRECISION).div(bdv)))

Since, toStem is bigger than expected due to the rounding issue, the grown stalks associated with the output token would be smaller than the grown stalks associated with the input token.

As a result, due to this rounding issue, part of the grown stalks associated with the input token will be orphaned. In other words, if the user withdraws fully the output token amount in toStem, this orphaned grown stalks will not be removed. Thus, they behave like a zombie, because any rewards distributed to the protocol will be partly allocated to stalk holders. Now, these orphaned grown stalks can be allocated rewards, but those rewards can not be withdrawn as they do not have any underlying deposited amount.

Test Case

In the following foundry test, the user has deposited 1000e6 LP. After two seasons (passing germination periods), the user converts these LP to bean. It shows that the user's grown stalks before converting is equal to 7825826816, while after the convert, it is equal to 7825826000. The difference is 816 which is originating from the rounding issue. Then the user fully withdraws bean tokens. After the withdrawal, it shows that the stalk balance of the user is 816. It means that this amount is orphaned and can not be removed.

function testConvertLPToBeanRounding() public {
uint amount = 1000e6;
// manipulate well so we won't have a penalty applied
setDeltaBforWell(-int256(amount), beanEthWell, C.WETH);
(int96 stem, uint256 lpAmountOut) = depositLPAndPassGermination(amount, beanEthWell);
// Create arrays for stem and amount.
int96[] memory stems = new int96[](1);
stems[0] = stem;
AdvancedFarmCall[] memory beanToLPFarmCalls = createLPToBeanFarmCalls(lpAmountOut);
uint256[] memory amounts = new uint256[](1);
amounts[0] = lpAmountOut;
console.log(
"user's grownstalk of LP before convert: ",
bs.grownStalkForDeposit(users[1], beanEthWell, stem)
);
vm.prank(users[1]); // do this as user 1
(int96 toStem, uint256 fromAmount, uint256 toAmount, uint256 fromBdv, uint256 toBdv) = convert.pipelineConvert(
beanEthWell, // input token
stems, // stems
amounts, // amount
C.BEAN, // token out
beanToLPFarmCalls // farmData
);
console.log(
"user's grownstalk of bean after convert: ",
bs.grownStalkForDeposit(users[1], C.BEAN, toStem)
);
vm.prank(users[1]);
bs.withdrawDeposit(C.BEAN, toStem, toAmount, 0);
console.log("user's balance Of Stalk after full withdrawal: ", bs.balanceOfStalk(users[1]));
}
function depositLPAndPassGermination(
uint256 amount,
address well
) public returns (int96 stem, uint256 lpAmountOut) {
// mint beans to user 1
bean.mint(users[1], amount);
// user 1 deposits bean into bean:eth well, first approve
vm.prank(users[1]);
bean.approve(well, type(uint256).max);
uint256[] memory tokenAmountsIn = new uint256[](2);
tokenAmountsIn[0] = amount;
tokenAmountsIn[1] = 0;
vm.prank(users[1]);
lpAmountOut = IWell(well).addLiquidity(tokenAmountsIn, 0, users[1], type(uint256).max);
// approve spending well token to beanstalk
vm.prank(users[1]);
MockToken(well).approve(BEANSTALK, type(uint256).max);
vm.prank(users[1]);
(uint256 depositedAmount, uint256 _bdv, int96 theStem) = silo.deposit(
well,
lpAmountOut,
LibTransfer.From.EXTERNAL
);
stem = theStem;
passGermination();
}

The output is:

user's grownstalk of LP before convert: 7825826816
user's grownstalk of bean after convert: 7825826000
user's balance Of Stalk after full withdrawal: 816

Impact

  • Loss of grown stalks during converting.

  • These orphaned grown stalks will behave like a zombie and they will be allocated rewards while thay can not be withdrawn.

Tools Used

Recommendations

The following modification is recommended:

function calculateStemForTokenFromGrownStalk(
address token,
uint256 grownStalk,
uint256 bdv
) internal view returns (int96 stem, GerminationSide side, uint256 newGrownStalk) {
LibGerminate.GermStem memory germStem = LibGerminate.getGerminatingStem(token);
stem = germStem.stemTip.sub(
SafeCast.toInt96(SafeCast.toInt256(grownStalk.mul(PRECISION).div(bdv)))
);
side = LibGerminate._getGerminationState(stem, germStem);
//modification
uint256 deltaStemTip = uint256(int256(germStem.stemTip.sub(stem)));
newGrownStalk = deltaStemTip.mul(bdv).div(PRECISION);
}

Then the returned newGrownStalk should be used for the rest of the code.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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