Summary
SeasonFacet contract holds the sunrise function and handles all logic for season changes. When Beanstalk advances to a new season, a reward is sent to the address that successfully called the sunrise function. The reward comprises the total gas spent on the transaction and uses other parameters such as the seconds elapsed after gm was ready to be called, stored in the secondsLate variable. However, the calculation of the secondsLate value is very likely to fail due to the season counting mechanism.
Vulnerability Details
When the sunrise function is called to start a new season, the function gm is executed. The gm function transitions Beanstalk to the next season. After some checks, the season number is updated, as well as the deltaB and caseId values. Calculations of the new gauge points for LP assets, soil issuance and germination process are also carried out. After the transition is performed, the incentivize function is finally called.
Calculation of the secondsLate value is given in the incentivize function:
function incentivize(address account, LibTransfer.To mode) private returns (uint256) {
uint256 secondsLate = block.timestamp.sub(
s.sys.season.start.add(s.sys.season.period.mul(s.sys.season.current))
);
address[] memory whitelistedWells = LibWhitelistedTokens.getWhitelistedWellLpTokens();
for (uint256 i; i < whitelistedWells.length; i++) {
LibWell.resetUsdTokenPriceForWell(whitelistedWells[i]);
LibWell.resetTwaReservesForWell(whitelistedWells[i]);
}
uint256 incentiveAmount = LibIncentive.determineReward(secondsLate);
LibTransfer.mintToken(C.bean(), incentiveAmount, account, mode);
emit LibIncentive.Incentivization(account, incentiveAmount);
return incentiveAmount;
}
Nevertheless, since the system update has already been performed at this point, the calculation of the seconsdLate value takes the new season number into account. This leads to an incorrect result since the product of s.sys.season.period.mul(s.sys.season.current) implies that all seasons, including the current one, have already ended.
Proof of concept
The following code is an ultra-minimalistic foundry test to illustrate the founded problem. Most of the elements and processes of the SeasonFacet contract have been ommited for simplicity. This code works as a standalone foundry contract test as it does not depend on external files or additional information. Executing it with a verbosity level of two (-vv) is enough to see the logged explanation of the outputs.
pragma solidity >=0.6.0 <0.9.0;
import {Test, console} from "forge-std/Test.sol";
struct Season {
uint32 current;
uint32 sunriseBlock;
uint256 start;
uint256 period;
}
contract incentivizeTest is Test {
Season public season;
SeasonFacet public seasonFacet;
function setUp() public {
season.current = 1;
season.start = block.timestamp;
season.period = 3600;
seasonFacet = new SeasonFacet();
}
function testIncentivize() public {
uint256 secondsLate;
uint256 expectedSecondsLate = 26;
console.log("Initial season state:");
console.log("Current season: %i", season.current);
console.log("Season start: %i", season.start);
console.log("block timestamp: %i", block.timestamp);
vm.warp(block.timestamp + season.period + expectedSecondsLate);
vm.roll(block.number + 1);
season.current += 1;
season.sunriseBlock = uint32(block.number);
console.log("\nFinal season state:");
console.log("Current season: %i", season.current);
console.log("Season start: %i", season.start);
console.log("block timestamp: %i", block.timestamp);
vm.expectRevert();
secondsLate = seasonFacet.incentivize(season);
secondsLate = seasonFacet.incentivize2(season);
assert(secondsLate == expectedSecondsLate);
}
}
contract SeasonFacet {
function incentivize(Season memory _season) public view returns (uint256) {
uint256 secondsLate = block.timestamp -
(_season.start + (_season.period * (_season.current)));
return secondsLate;
}
function incentivize2(Season memory _season) public view returns (uint256) {
uint256 secondsLate = block.timestamp -
(_season.start + (_season.period * (_season.current - 1)));
return secondsLate;
}
}
Impact
Impact : High
This issue causes that the incentivize function reverts due to the assignment of a negative value to a uint256 variable.
Likelihood: High
Tools Used
Manual Review
Recommended Mitigation
Considering only the past seasons as finished solves the issue:
function incentivize(address account, LibTransfer.To mode) private returns (uint256) {
uint256 secondsLate = block.timestamp.sub(
- s.sys.season.start.add(s.sys.season.period.mul(s.sys.season.current))
+ s.sys.season.start.add(s.sys.season.period.mul(s.sys.season.current - 1))
);
// reset USD Token prices and TWA reserves in storage for all whitelisted Well LP Tokens.
address[] memory whitelistedWells = LibWhitelistedTokens.getWhitelistedWellLpTokens();
for (uint256 i; i < whitelistedWells.length; i++) {
LibWell.resetUsdTokenPriceForWell(whitelistedWells[i]);
LibWell.resetTwaReservesForWell(whitelistedWells[i]);
}
uint256 incentiveAmount = LibIncentive.determineReward(secondsLate);
LibTransfer.mintToken(C.bean(), incentiveAmount, account, mode);
emit LibIncentive.Incentivization(account, incentiveAmount);
return incentiveAmount;
}