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

De-whitelisting can lead to loss/lock of rewards that are not claimed yet

Summary

If some SOP rewards during a flood are allocated to a stalkholder, and the stalkholder does not claim their rewards, and later the well is de-whitelisted by the authorized caller for any reason, the stalkholder cannot claim their reward anymore, and the reward will be locked in the protocol.

Vulnerability Details

The root cause of this issue is that when a token is de-whitelisted by the authorized caller, there is no check for any nonzero claimable rewards. Additionally, it does not require stalkholders (who are entitled to some rewards) to claim their rewards.

function dewhitelistToken(address token) external payable fundsSafu noNetFlow noSupplyChange {
LibDiamond.enforceIsOwnerOrContract();
LibWhitelist.dewhitelistToken(token);
}

https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/silo/WhitelistFacet/WhitelistFacet.sol#L32

function dewhitelistToken(address token) internal {
//....
// delete the selector and encodeType.
delete s.sys.silo.assetSettings[token].selector;
//.....
}

https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Silo/LibWhitelist.sol#L413

When the user claims their reward, the system checks whether the well is whitelisted by calling the function isWell(address).

function _claimPlenty(address account, address well, LibTransfer.To toMode) internal {
//....
if (plenty > 0 && LibWell.isWell(well)) {
//....
}
}

https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/beanstalk/silo/SiloFacet/ClaimFacet.sol#L97

function isWell(address well) internal view returns (bool _isWell) {
AppStorage storage s = LibAppStorage.diamondStorage();
return s.sys.silo.assetSettings[well].selector == WELL_BDV_SELECTOR;
}

https://github.com/Cyfrin/2024-05-beanstalk-the-finale/blob/main/protocol/contracts/libraries/Well/LibWell.sol#L117

Please note that the possibility of such a scenario is not low, because users often do not claim their rewards immediately. Therefore, it is highly likely that there will always be nonzero claimable rewards for a well that is going to be de-whitelisted.

Test Case

In the following test, the user deposits and mows in seasons 5 and 6, respectively. Then, there is rain and a flood in seasons 7 and 8, respectively. The user mows to see the amount of rewards (balanceOfPlenty) allocated to him. It shows that the allocated reward is 51191151829696906017 SOP tokens. However, before the user claims his reward, the owner de-whitelists the well. So, when the user calls claimPlenty, he receives no rewards.

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("consecutive floods scenarios", async function () {
it("dewhitelisted well disallows claiming rewards", async function () {
console.log("current season: ", await beanstalk.season()); // 5
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
// there is a flood in season 8
await mockBeanstalk.rainSunrise(); // 7 => 8
await beanstalk.mow(user.address, bean.address);
const balanceOfPlentyUser1 = await beanstalk.balanceOfPlenty(user.address, this.well.address);
console.log("user1's balanceOfPlenty: ", balanceOfPlentyUser1);
await beanstalk.connect(owner).dewhitelistToken(this.well.address);
await beanstalk.connect(user).claimPlenty(this.well.address, EXTERNAL);
console.log("balance user1: ", await this.weth.balanceOf(user.address));
});
})

The output is:

current season: 5
user1's balanceOfPlenty: BigNumber { value: "51191151829696906017" }
balance user1: BigNumber { value: "0" }

Impact

Lock of rewards if the well is de-whitelisted.

Tools Used

Recommendations

It is recommended that during the de-whitelisting of a well, it should be ensured that there are no claimable rewards on that well. However, this solution can introduce another attack vector where an attacker intentionally does not claim their reward to prevent the well from being de-whitelisted.

Another solution is that when claiming rewards, it checks the well is whitelisted now or it was whitelisted before.

function _claimPlenty(address account, address well, LibTransfer.To toMode) internal {
//....
if (plenty > 0 && (LibWell.isWell(well) || LibWell.wasWell(well)) {
//....
}
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 11 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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