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);
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;
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) {
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);
stepOracle();
mockStartSop();
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
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, 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"));
[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"));
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);
await mockBeanstalk.siloSunrise(0);
});
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());
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);
await beanstalk.mow(user.address, bean.address);
await beanstalk.mow(user2.address, bean.address);
await mockBeanstalk.rainSunrise();
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);
await mockBeanstalk.rainSunrise();
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));
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