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

Loss/lock of rewards in case of withdrawing fully before a flood

Summary

Withdrawing a deposit entirely before a flood results in a situation where the SOP rewards held in the protocol become unclaimable and inaccessible. In other words, the user cannot claim their rewards, and the rewards will be permanently locked within the protocol.

Vulnerability Details

When a new season is going to start, the function handleRain is called. The flow of function calls is as follows:

SeasonFacet::gm ==> Weather::calcCaseIdandUpdate ==> LibFlood::handleRain

In this function, if the condition for raining is met, the amount of roots in Silo will be set as rain roots. This means that at the time raining is going to start, how much roots are available in the Silo.

function handleRain(uint256 caseId) external {
//....
s.sys.rain.roots = s.sys.silo.roots;
//....
}

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

If the condition for raining is still met, a flood will occur in the next season. In this case, when the handleRain function is called, the private function sopWell is triggered to mint beans and swap them for SOP tokens in the well.

function sopWell(WellDeltaB memory wellDeltaB) private {
AppStorage storage s = LibAppStorage.diamondStorage();
if (wellDeltaB.deltaB > 0) {
IERC20 sopToken = LibWell.getNonBeanTokenFromWell(wellDeltaB.well);
uint256 sopBeans = uint256(wellDeltaB.deltaB);
C.bean().mint(address(this), sopBeans);
// Approve and Swap Beans for the non-bean token of the SOP well.
C.bean().approve(wellDeltaB.well, sopBeans);
uint256 amountOut = IWell(wellDeltaB.well).swapFrom(
C.bean(),
sopToken,
sopBeans,
0,
address(this),
type(uint256).max
);
rewardSop(wellDeltaB.well, amountOut, address(sopToken));
emit SeasonOfPlentyWell(
s.sys.season.current,
wellDeltaB.well,
address(sopToken),
amountOut
);
}
}

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

Then, the private function rewardSop is called to assign PPR (plenty per root) to the rain start season. In other words, the amount of sop tokens (that are gained by swapping beans in the well) are divided by the amount of roots at the beginning of raining season to calculate PPR for the season when rain started.

function rewardSop(address well, uint256 amount, address sopToken) private {
AppStorage storage s = LibAppStorage.diamondStorage();
s.sys.sop.sops[s.sys.season.rainStart][well] = s
.sys
.sop
.sops[s.sys.season.lastSop][well].add(amount.mul(C.SOP_PRECISION).div(s.sys.rain.roots));
s.sys.season.lastSop = s.sys.season.rainStart;
s.sys.season.lastSopSeason = s.sys.season.current;
// update Beanstalk's stored overall plenty for this well
s.sys.sop.plentyPerSopToken[sopToken] += amount;
}

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

Note that, these sop tokens are now held by Beanstalk, and users can claim them later by calling claimPlenty. To do so, users must first mow to update their state. Since, there was rain, the function handleRainAndSops would be called.

function _mow(address account, address token) external {
//...
uint32 currentSeason = s.sys.season.current;
if (s.sys.season.rainStart > s.sys.season.stemStartSeason) {
if (lastUpdate <= s.sys.season.rainStart && lastUpdate <= currentSeason) {
// Increments `plenty` for `account` if a Flood has occured.
// Saves Rain Roots for `account` if it is Raining.
LibFlood.handleRainAndSops(account, lastUpdate);
}
}
//...
}

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

In this function, the internal function balanceOfPlenty would be invoked to calculate the amount rewards the users are allocated.

function handleRainAndSops(address account, uint32 lastUpdate) internal {
//....
if (s.sys.season.lastSopSeason > lastUpdate) {
address[] memory tokens = LibWhitelistedTokens.getWhitelistedWellLpTokens();
for (uint i; i < tokens.length; i++) {
s.accts[account].sop.perWellPlenty[tokens[i]].plenty = balanceOfPlenty(
account,
tokens[i]
);
}
s.accts[account].lastSop = s.sys.season.lastSop;
}
//.....
}

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

In this function, based on the PPR calculated in the previous steps and the user's roots, the amount of plenty allocated to the user is calculated.

function balanceOfPlenty(address account, address well) internal view returns (uint256 plenty) {
//.....
if (s.sys.season.lastSop > s.accts[account].lastUpdate) {
uint256 plentyPerRoot = s.sys.sop.sops[s.sys.season.lastSop][well].sub(previousPPR);
plenty = plenty.add(plentyPerRoot.mul(s.accts[account].roots).div(C.SOP_PRECISION));
}
}

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

The user can later call claimPlenty to receive the reward.
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/silo/SiloFacet/ClaimFacet.sol#L65

The issue is that if the deposited amount is fully withdrawn before the flood, the SOP rewards allocated to stalkholders at the season when the rain started will be unreachable. In other words, those rewards will be locked in the protocol and cannot be withdrawn.

For better understanding, please consider the following scenario:

Bob and Alice both deposits the equal amounts in season 5, and then they mowed in season 6. Due to a situation, it starts raining in season 7. When season 7 starts, the s.sys.rain.roots is set equal to Bob's roots + Alice's roots (assuming that only these two users have deposited for simplicity).

Then, Bob decides to withdraw fully in season 7, so the total roots in the Silo would be updated to Alice's roots. Suppose due to the situation, there will be again rain in the next season. So, there will be a flood in season 8. When the sop tokens are going to be rewarded, the amount of sop will be divided by the amount of roots when the rain started. So, we will have:`

s.sys.sop.sops[7][well] = amount of sop tokens / (Bob's roots + Alice's roots)

This shows that half of the SOP tokens are allocated to Bob and the other half to Alice. After mowing, both users should be able to claim their rewards by calling the function claimPlenty. The issue is that only half of the SOP tokens held in Beanstalk can be claimed by Alice. The other half is unreachable because Bob's roots are zero (as he fully withdrew). Therefore, when Bob mows, the function handleRainAndSops returns without assigning the reward to him.

function handleRainAndSops(address account, uint32 lastUpdate) internal {
//....
if (s.accts[account].roots == 0) {
s.accts[account].lastSop = s.sys.season.rainStart;
s.accts[account].lastRain = 0;
return;
}
//....
}

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

Note that Bob cannot deposit an amount and mow to gain nonzero roots in order to claim his reward. This is because each time deposit or mow is called, the user's last update season is refreshed, preventing them from fulfilling the following required condition.
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibSilo.sol#L435

Test Case

In the following test, two users deposit and mow in season 5 and 6, respectively. Then rain starts in season 7. User1 withdraws his deposit fully in season 7. Then, there is a flood in season 8. It shows that due to the flood, 51191151829696906017 weth are transferred to Beanstalk. This amount is the reward, and they could be claimable by the users. So, both users mow, which shows that balanceOfPlenty for user1 and user2 are 0 and 25595575914848453008, resepctively. It means that user1 is not allocated any reward. Note that, total reward is 51191151829696906017, while only 25595575914848453008 is claimable by user2. So, the other half is locked in the protocol and unreachable.

Note that to run the test properly, the helper function rainSunrise in MockSeasonFacet should be corrected as follows:

function rainSunrise() public {
require(!s.sys.paused, "Season: Paused.");
s.sys.season.current += 1;
s.sys.season.sunriseBlock = uint32(block.number);
// update last snapshot in beanstalk.
stepOracle();
mockStartSop();
// this code is added
LibGerminate.endTotalGermination(
s.sys.season.current,
LibWhitelistedTokens.getWhitelistedTokens()
);
}

https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/mocks/mockFacets/MockSeasonFacet.sol#L95

// yarn hardhat test --grep "Sop Test Cases"
const { expect } = require("chai");
const { deploy } = require("../scripts/deploy.js");
const { EXTERNAL, INTERNAL, INTERNAL_EXTERNAL, INTERNAL_TOLERANT } = require("./utils/balances.js");
const {
BEAN,
BEAN_ETH_WELL,
WETH,
MAX_UINT256,
ZERO_ADDRESS,
BEAN_WSTETH_WELL,
WSTETH
} = require("./utils/constants.js");
const { to18, to6, advanceTime } = require("./utils/helpers.js");
const { deployMockWell, whitelistWell, deployMockWellWithMockPump } = require("../utils/well.js");
const { takeSnapshot, revertToSnapshot } = require("./utils/snapshot.js");
const {
setStethEthChainlinkPrice,
setWstethEthUniswapPrice,
setEthUsdChainlinkPrice
} = require("../utils/oracle.js");
const { getAllBeanstalkContracts } = require("../utils/contracts.js");
let user, user2, owner;
describe("Sop Test Cases", function () {
before(async function () {
[owner, user, user2] = await ethers.getSigners();
const contracts = await deploy((verbose = false), (mock = true), (reset = true));
ownerAddress = contracts.account;
this.diamond = contracts.beanstalkDiamond;
// `beanstalk` contains all functions that the regualar beanstalk has.
// `mockBeanstalk` has functions that are only available in the mockFacets.
[beanstalk, mockBeanstalk] = await getAllBeanstalkContracts(this.diamond.address);
bean = await ethers.getContractAt("Bean", BEAN);
this.weth = await ethers.getContractAt("MockToken", WETH);
mockBeanstalk.deployStemsUpgrade();
await mockBeanstalk.siloSunrise(0);
await bean.connect(user).approve(beanstalk.address, "100000000000");
await bean.connect(user2).approve(beanstalk.address, "100000000000");
await bean.mint(user.address, to6("10000"));
await bean.mint(user2.address, to6("10000"));
// init wells
[this.well, this.wellFunction, this.pump] = await deployMockWellWithMockPump();
await deployMockWellWithMockPump(BEAN_WSTETH_WELL, WSTETH);
await this.well.connect(owner).approve(this.diamond.address, to18("100000000"));
await this.well.connect(user).approve(this.diamond.address, to18("100000000"));
// set reserves at a 1000:1 ratio.
await this.pump.setCumulativeReserves(this.well.address, [to6("1000000"), to18("1000")]);
await this.well.mint(ownerAddress, to18("500"));
await this.well.mint(user.address, to18("500"));
await mockBeanstalk.siloSunrise(0);
await mockBeanstalk.captureWellE(this.well.address);
await setEthUsdChainlinkPrice("1000");
await setStethEthChainlinkPrice("1000");
await setStethEthChainlinkPrice("1");
await setWstethEthUniswapPrice("1");
await this.well.setReserves([to6("1000000"), to18("1100")]);
await this.pump.setInstantaneousReserves(this.well.address, [to6("1000000"), to18("1100")]);
await mockBeanstalk.siloSunrise(0); // 3 -> 4
await mockBeanstalk.siloSunrise(0); // 4 -> 5
});
beforeEach(async function () {
snapshotId = await takeSnapshot();
});
afterEach(async function () {
await revertToSnapshot(snapshotId);
});
describe("flood scenarios", async function () {
it("withdrawing before flood, locks the reward forever", async function () {
console.log("current season: ", await beanstalk.season()); // 5
const ret1 = await beanstalk.connect(user).callStatic.deposit(bean.address, to6("1000"), EXTERNAL);
const depositedStem1 = ret1[2];
await beanstalk.connect(user).deposit(bean.address, to6("1000"), EXTERNAL);
await beanstalk.connect(user2).deposit(bean.address, to6("1000"), EXTERNAL);
await mockBeanstalk.siloSunrise(0); // 5 => 6
await beanstalk.mow(user.address, bean.address);
await beanstalk.mow(user2.address, bean.address);
// rain starts in season 7
await mockBeanstalk.rainSunrise(); // 6 => 7
const rain = await beanstalk.rain();
let season = await beanstalk.time();
console.log("season.rainStart: ", season.rainStart);
console.log("season.raining: ", season.raining);
console.log("rain.roots: ", rain.roots);
await beanstalk.connect(user).withdrawDeposit(BEAN, depositedStem1, to6("1000"), EXTERNAL);
// there is a flood in season 8
await mockBeanstalk.rainSunrise(); // 7 => 8
const protocolBalanceOfRewards = await this.weth.balanceOf(beanstalk.address);
console.log("balance of protocol: ", protocolBalanceOfRewards);
await beanstalk.mow(user.address, bean.address);
await beanstalk.mow(user2.address, bean.address);
const balanceOfPlentyUser1 = await beanstalk.balanceOfPlenty(user.address, this.well.address);
const balanceOfPlentyUser2 = await beanstalk.balanceOfPlenty(user2.address, this.well.address);
console.log("user1's balanceOfPlenty: ", balanceOfPlentyUser1);
console.log("user2's balanceOfPlenty: ", balanceOfPlentyUser2);
await beanstalk.connect(user).claimPlenty(this.well.address, EXTERNAL);
console.log("balance user1: ", await this.weth.balanceOf(user.address));
await beanstalk.connect(user2).claimPlenty(this.well.address, EXTERNAL);
console.log("balance user2: ", await this.weth.balanceOf(user2.address));
// protocolBalanceOfRewards is subtracted by one due to rounding.
expect(BigInt(balanceOfPlentyUser1) + BigInt(balanceOfPlentyUser2)).to.be.equal(BigInt(protocolBalanceOfRewards) - BigInt(1));
});
})

The output is:

current season: 5
season.rainStart: 7
season.raining: true
rain.roots: BigNumber { value: "4000000000000000000000" }
balance of protocol: BigNumber { value: "51191151829696906017" }
user1's balanceOfPlenty: BigNumber { value: "0" }
user2's balanceOfPlenty: BigNumber { value: "25595575914848453008" }
balance user1: BigNumber { value: "0" }
balance user2: BigNumber { value: "25595575914848453008" }
1) withdrawing before flood, locks the reward forever
0 passing (9s)
1 failing
1) Sop Test Cases
withdrawing before flood, locks the reward forever:
AssertionError: expected 25595575914848453008n to equal 51191151829696906016n
+ expected - actual
-25595575914848453008n
+51191151829696906016n

Impact

The rewards are locked in the protocol and will be unreachable. Note that the SOP reward is the opposite token of the bean in the well.

Tools Used

Recommendations

One possible solution is to modify the function handleRainAndSops as follows, where it checks s.accts[account].sop.rainRoots == 0 as well as s.accts[account].roots == 0:

function handleRainAndSops(address account, uint32 lastUpdate) internal {
//...
if (s.accts[account].roots == 0 && s.accts[account].sop.rainRoots == 0) {
s.accts[account].lastSop = s.sys.season.rainStart;
s.accts[account].lastRain = 0;
return;
}
//...
}

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

Updates

Lead Judging Commences

inallhonesty Lead Judge 11 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Loss/lock of rewards in case of withdrawing fully before a flood

Appeal created

fyamf Submitter
11 months ago
inallhonesty Lead Judge
11 months ago
inallhonesty Lead Judge 10 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Loss/lock of rewards in case of withdrawing fully before a flood

Support

FAQs

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