Summary
Assume Bob has nonzero roots, indicating he has already deposited and mowed. Then, in the following season, it rains. Because Bob had nonzero roots before the rainy season, he now has nonzero rain roots.
If Bob transfers all his deposits to Alice during this rainy season and there is a flood in the next season, neither Bob nor Alice will receive any SoP rewards related to the flood in the next season.
Vulnerability Details
When transferring a deposit, first the function _mow
is called for both sender and receiver.
function transferDeposit(
address sender,
address recipient,
address token,
int96 stem,
uint256 amount
) public payable fundsSafu noNetFlow noSupplyChange nonReentrant returns (uint256 _bdv) {
LibSilo._mow(sender, token);
LibSilo._mow(recipient, token);
_bdv = _transferDeposit(sender, recipient, token, stem, amount);
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/silo/SiloFacet/SiloFacet.sol#L144-L146
Since, it is raining, in the function _mow
, the function LibFlood.handleRainAndSops
is invoked.
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 the function LibFlood.handleRainAndSops
, since sender has nonzero roots, and the flood is not happened, the body of the third if-clause will be executed, where the lastRain
and rainRoots
of the sender will be updated.
function handleRainAndSops(address account, uint32 lastUpdate) internal {
if (s.sys.season.raining) {
if (s.sys.season.rainStart > lastUpdate) {
s.accts[account].lastRain = s.sys.season.rainStart;
s.accts[account].sop.rainRoots = s.accts[account].roots;
}
}
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibFlood.sol#L124-L127
In the function LibFlood.handleRainAndSops
, since receiver has zero roots, the body of first if-clause will be executed, where the lastSop
of the receiver is updated to the rain start season.
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-L109
At the end of the transfer, only the deposited amount and stalks (germinating or active ones) are transferred from the sender to the receiver. Note that it is assumed that the sender has transferred all his amount to the receiver, so after the transfer, the sender does not hold any roots.
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/silo/SiloFacet/TokenSilo.sol#L356
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibSilo.sol#L302
Thus, after the transfer, the lastRain
and rainRoots
of both the sender and the receiver will remain unchanged and will not be updated.
If the next season is still rainy, leading to a flood, SoP rewards would be allocated to stakeholders at the beginning of the flood. Therefore, it is expected that the transferred roots should be entitled to SoP rewards, as those roots were created before the rainy season began.
After the flood, both the sender and the receiver call the mow
function to update their state and determine whether any SoP rewards are allocated to them. Note that the sop reward is the oposite token of the bean in a well.
Since the sender does not hold any roots, the body of the first if-clause will be executed. Consequently, no SoP rewards are allocated to the sender, even though he has nonzero rainRoots
.
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-L109
Regarding the receiver, since he has nonzero roots and lastSopSeason
is higher than his lastUpdateSeason
, the body of the second and third if-clauses will be executed.
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;
}
if (s.sys.season.raining) {
if (s.sys.season.lastSop == s.sys.season.rainStart) {
address[] memory tokens = LibWhitelistedTokens.getWhitelistedWellLpTokens();
for (uint i; i < tokens.length; i++) {
s.accts[account].sop.perWellPlenty[tokens[i]].plentyPerRoot = s.sys.sop.sops[
s.sys.season.lastSop
][tokens[i]];
}
}
}
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibFlood.sol#L103
Inside the function balanceOfPlenty
, where it calculates how many SoP rewards are allocated to the receiver, since the receiver's lastRain
is zero, the body of the else-clause will be executed. Additionally, because the lastSop
is equal to the receiver's last update, the body of the second if-clause will not be executed. As a result, the variable plenty
, which indicates the amount of reward allocated to the receiver, will be zero.
function balanceOfPlenty(address account, address well) internal view returns (uint256 plenty) {
} else {
previousPPR = s.sys.sop.sops[s.accts[account].lastSop][well];
}
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibFlood.sol#L172-L175
Thus, neither the sender nor the receiver of the deposit are allocated the SoP rewards.
The root cause of this issue is that when a deposit is transferred, the rainRoots
and lastRain
data are not transferred from the sender to the receiver, or are not updated accordingly.
Test Case
In the following test, user1 (sender) deposited and mowed in seasons 5 and 6, respectively. Then, there is rain in season 7. In this season, user1 transfers all his deposited amount to user2 (receiver). It shows that the sender's lastRain
and rainRoots
are equal to 7
and 2000000000000000000000
, respectively. While these two storage variables for the receiver are both equal to zero. Then, there is a flood in season 8, where both sender and receiver mow to update their states. It shows that balanceOfPlenty
for both are zero, and when they call the function claimPlenty
, they receive nothing.
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("transferring before flood scenario", async function () {
it("transferring deposit loses the SoP rewards", 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 mockBeanstalk.siloSunrise(0);
await beanstalk.mow(user.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).transferDeposit(user.address, user2.address, BEAN, depositedStem1, to6("1000"));
let user1Rain = await beanstalk.balanceOfSop(user.address);
console.log("user1.lastRain after transfer: ", user1Rain.lastRain);
console.log("user1.rainRoots after transfer: ", user1Rain.roots);
let user2Rain = await beanstalk.balanceOfSop(user2.address);
console.log("user2.lastRain after transfer: ", user2Rain.lastRain);
console.log("user2.rainRoots after transfer: ", user2Rain.roots);
await mockBeanstalk.rainSunrise();
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));
});
})
The output is:
current season: 5
season.rainStart: 7
season.raining: true
rain.roots: BigNumber { value: "2000000000000000000000" }
user1.lastRain after transfer: 7
user1.rainRoots after transfer: BigNumber { value: "2000000000000000000000" }
user2.lastRain after transfer: 0
user2.rainRoots after transfer: BigNumber { value: "0" }
user1's balanceOfPlenty: BigNumber { value: "0" }
user2's balanceOfPlenty: BigNumber { value: "0" }
balance user1: BigNumber { value: "0" }
balance user2: BigNumber { value: "0" }
✔ transferring deposit loses the SoP rewards (128ms)
Impact
Loss of SoP rewards.
When distributing SoP rewards, the allocation is based on the number of roots in the protocol, ensuring proportional distribution among stalk holders. However, if a transfer occurs during a rainy season, the rewards associated with those transferred roots become unclaimable by both the sender and receiver, effectively locking them within the protocol. Note that the reward is the oposite token of the bean in a well.
Tools Used
Recommendations
One possible solution could be to update the lastRain
and rainRoots
of the receiver based on the sender. However, this solution should be thoroughly tested in other scenarios to ensure it does not break any existing logic.
function transferStalk(address sender, address recipient, uint256 stalk) internal {
s.accts[recipient].lastRain = s.accts[sender].lastRain;
uint256 rainRoots;
rainRoots = roots == s.accts[sender].roots
? s.accts[sender].sop.rainRoots
: s.sys.rain.roots.sub(1).mul(roots).div(s.sys.silo.roots).add(1);
s.accts[recipient].sop.rainRoots += rainRoots;
s.accts[sender].sop.rainRoots -= rainRoots;
}
https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibSilo.sol#L293