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;
}