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

Transferring a deposit fully in a rainy season loses the rewards during the flood

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);
// Need to update the recipient's Silo as well.
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) {
// 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 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 rain started after update, set account variables to track rain.
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 there has been a Sop since rain started,
// save plentyPerRoot in case another SOP happens during rain.
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 {
// If it was not raining, just use the PPR at previous SOP.
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` 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("transferring before flood scenario", async function () {
it("transferring deposit loses the SoP rewards", 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 mockBeanstalk.siloSunrise(0); // 5 => 6
await beanstalk.mow(user.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).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);
// there is a flood in season 8
await mockBeanstalk.rainSunrise(); // 7 => 8
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

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Transferring a deposit fully in a rainy season loses the rewards during the flood

Appeal created

fyamf Submitter
about 1 year ago
fyamf Submitter
about 1 year ago
inallhonesty Lead Judge
about 1 year ago
inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Transferring a deposit fully in a rainy season loses the rewards during the flood

Support

FAQs

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