DeFiHardhat
21,000 USDC
View results
Submission Details
Severity: medium
Invalid

Peg mechanism is compromised due to logic to fetch min deltaB

Context

The purpose of this new soil issue mechanism is to minimize the potential of over-issuing Soil (debt) when Beanstalk is below peg. By comparing both the time-weighted average (TWA) deltaB and the instantaneous deltaB (from instantaneous reserves), the system can choose the more conservative value, thus reducing potential manipulation and stabilizing the protocol.

Vulnerability Details

The solution implemented(Math.min(uint256(-twaDeltaB), uint256(-instDeltaB)) now introduces another problem: the protocol under-issuing Soil when
-deltaB increases because the instantaneous reserves could reflect a more negative deltaB compared to the TWA deltaB.

Scenario:

  • If the deltaB becomes increasingly negative toward the end of several seasons due to trades on the last blocks/minutes, the soil will be under-issued due to instantaneous reserves reflecting the latest (most bearish) price.

As we can see below, the protocol will always fetch the min price, given the scenario above it will always fetch the twaDeltaB.

function setSoilBelowPeg(int256 twaDeltaB) internal {
// Calculate deltaB from instantaneous reserves of all whitelisted Wells.
int256 instDeltaB;
address[] memory tokens = LibWhitelistedTokens.getWhitelistedWellLpTokens();
for (uint256 i = 0; i < tokens.length; i++) {
int256 wellInstDeltaB = LibWellMinting.instantaneousDeltaB(tokens[i]);
instDeltaB = instDeltaB.add(wellInstDeltaB);
}
// Set new soil.
@> setSoil(Math.min(uint256(-twaDeltaB), uint256(-instDeltaB)));
}

Impact

  • When deltaB is increasing negatively, Beanstalk will consistently issue less soil than it should.

  • Price will be pushed further down, potentially breaking the peg mechanism.

PoC

The PoC below proves that Beanstalk will always fetch the twaDeltaB even when the correct price should be from the instantaneous reserve.

  • Create a file called SunSoilBroken.test.js and add the following code:

const { expect } = require('chai')
const { deploy } = require('../scripts/deploy.js')
const { takeSnapshot, revertToSnapshot } = require("./utils/snapshot")
const { to6, toStalk, toBean, to18 } = require('./utils/helpers.js');
const { USDC, UNRIPE_BEAN, UNRIPE_LP, BEAN,ETH_USDC_UNISWAP_V3, BASE_FEE_CONTRACT, THREE_CURVE, THREE_POOL, BEAN_3_CURVE, BEAN_ETH_WELL, WSTETH, WETH, BEAN_WSTETH_WELL } = require('./utils/constants.js');
const { ethers } = require('hardhat');
const { setEthUsdChainlinkPrice, setWstethUsdPrice } = require('../utils/oracle.js');
const { deployBasin } = require('../scripts/basin.js');
const ZERO_BYTES = ethers.utils.formatBytes32String('0x0')
const { deployBasinV1_1Upgrade } = require('../scripts/basinV1_1.js');
const { advanceTime } = require('../utils/helpers.js');
const { setReserves } = require('../utils/well.js');
let user, user2, owner;
let userAddress, ownerAddress, user2Address;
describe('SunSoilBroken', function () {
before(async function () {
[owner, user, user2] = await ethers.getSigners()
userAddress = user.address;
user2Address = user2.address;
const contracts = await deploy("Test", false, true)
ownerAddress = contracts.account;
this.diamond = contracts.beanstalkDiamond;
this.season = await ethers.getContractAt('MockSeasonFacet', this.diamond.address)
this.fertilizer = await ethers.getContractAt('MockFertilizerFacet', this.diamond.address)
this.silo = await ethers.getContractAt('MockSiloFacet', this.diamond.address)
this.field = await ethers.getContractAt('MockFieldFacet', this.diamond.address)
this.usdc = await ethers.getContractAt('MockToken', USDC);
this.wsteth = await ethers.getContractAt('MockToken', WSTETH);
this.unripe = await ethers.getContractAt('MockUnripeFacet', this.diamond.address)
// These are needed for sunrise incentive test
this.basefee = await ethers.getContractAt('MockBlockBasefee', BASE_FEE_CONTRACT);
this.tokenFacet = await ethers.getContractAt('TokenFacet', contracts.beanstalkDiamond.address)
this.bean = await ethers.getContractAt('MockToken', BEAN);
this.threeCurve = await ethers.getContractAt('MockToken', THREE_CURVE);
this.threePool = await ethers.getContractAt('Mock3Curve', THREE_POOL);
await this.threePool.set_virtual_price(to18('1'));
this.beanThreeCurve = await ethers.getContractAt('MockMeta3Curve', BEAN_3_CURVE);
this.uniswapV3EthUsdc = await ethers.getContractAt('MockUniswapV3Pool', ETH_USDC_UNISWAP_V3);
this.siloGetters = await ethers.getContractAt('SiloGettersFacet', this.diamond.address)
await this.beanThreeCurve.set_supply(toBean('100000'));
await this.beanThreeCurve.set_A_precise('1000');
await this.beanThreeCurve.set_virtual_price(to18('1'));
await this.beanThreeCurve.set_balances([toBean('10000'), to18('10000')]);
await this.beanThreeCurve.reset_cumulative();
this.whitelist = await ethers.getContractAt('WhitelistFacet', this.diamond.address);
this.result = await this.whitelist.connect(owner).dewhitelistToken(BEAN_3_CURVE);
await this.usdc.mint(owner.address, to6('10000'))
await this.bean.mint(owner.address, to6('10000'))
await this.wsteth.mint(owner.address, to18('10000'))
await this.usdc.connect(owner).approve(this.diamond.address, to6('10000'))
await this.wsteth.connect(owner).approve(this.diamond.address, to18('10000'))
this.unripeBean = await ethers.getContractAt('MockToken', UNRIPE_BEAN)
// add unripe
this.unripeLP = await ethers.getContractAt('MockToken', UNRIPE_LP)
await this.unripeLP.mint(userAddress, to6('1000'))
await this.unripeLP.connect(user).approve(this.diamond.address, to6('100000000'))
await this.unripeBean.mint(userAddress, to6('1000'))
await this.unripeBean.connect(user).approve(this.diamond.address, to6('100000000'))
await this.unripe.addUnripeToken(UNRIPE_BEAN, BEAN, ZERO_BYTES)
await this.unripe.addUnripeToken(UNRIPE_LP, BEAN_WSTETH_WELL, ZERO_BYTES);
await setEthUsdChainlinkPrice('1000');
await setWstethUsdPrice('1000');
let c = await deployBasin(true, undefined, false, true)
await c.multiFlowPump.update([toBean('10000'), to18('10')], 0x00);
await c.multiFlowPump.update([toBean('10000'), to18('10')], 0x00);
c = await deployBasinV1_1Upgrade(c, true, undefined, true, justDeploy=true, mockPump=false)
this.pump = c.multiFlowPump;
this.well = c.well;
this.weth = await ethers.getContractAt('MockToken', WSTETH);
await this.bean.mint(userAddress, toBean('10000000000'));
await this.bean.mint(user2Address, toBean('10000000000'));
await this.weth.mint(userAddress, to18('1000000000'));
await this.weth.mint(user2Address, to18('1000000000'));
await this.bean.connect(user).approve(this.well.address, ethers.constants.MaxUint256);
await this.bean.connect(user2).approve(this.well.address, ethers.constants.MaxUint256);
await this.bean.connect(owner).approve(this.well.address, ethers.constants.MaxUint256);
await this.weth.connect(user).approve(this.well.address, ethers.constants.MaxUint256);
await this.weth.connect(user2).approve(this.well.address, ethers.constants.MaxUint256);
await this.weth.connect(owner).approve(this.well.address, ethers.constants.MaxUint256);
await this.season.siloSunrise(0)
})
beforeEach(async function () {
snapshotId = await takeSnapshot()
})
afterEach(async function () {
await revertToSnapshot(snapshotId)
})
it("When deltaB < 0 logic for -twaDeltaB and -instantaneous deltaB (-twaDeltaB < -instDeltaB) is broken", async function () {
// PRE CONDITIONS:
// whitelist well to be included in the instantaneous deltaB calculation
await this.silo.mockWhitelistToken(BEAN_WSTETH_WELL, this.silo.interface.getSighash("mockBDV(uint256 amount)"), "10000", "1");
// set initial reserves
await setReserves(
user,
this.well,
[to6('10000'), to18('10')]
);
await setReserves(
user,
this.well,
[to6('10000'), to18('10')]
);
// go forward 1800 blocks
await advanceTime(1800)
// set reserves to 2M Beans and 1000 Eth
await setReserves(
user,
this.well,
[to6('2000000'), to18('1000')]
);
await setReserves(
user,
this.well,
[to6('2000000'), to18('1000')]
);
// go forward 1800 blocks
await advanceTime(1800)
// send 0 eth to beanstalk
await user.sendTransaction({
to: this.diamond.address,
value: 0
})
// increase reserves to 3M Beans and 1000 Eth
await setReserves(
user,
this.well,
[to6('3000000'), to18('1000')]
);
// twaDeltaB = -100000000
// instantaneousDeltaB = -639582614561
// twaDeltaB, case ID
this.result = await this.season.sunSunrise('-100000000', 8);
await expect(this.result).to.emit(this.season, 'Soil').withArgs(3, '100000000');
await expect(await this.field.totalSoil()).to.be.equal('100000000');
await advanceTime(1800)
// deltaB drops
await setReserves(
user,
this.well,
[to6('3100000'), to18('1000')]
);
// twaDeltaB = -100000000
let previousInstantaneousDeltaB = -639582614561
// next instantaneousDeltaB = -1274960273462
// beanstalk will underissue soil due to always relying on the min(deltaB). Even though the current deltaB is in fact greater than the -twaDeltaB
this.result = await this.season.sunSunrise(previousInstantaneousDeltaB, 8);
await expect(this.result).to.emit(this.season, 'Soil').withArgs(4, '585786437627');
await expect(await this.field.totalSoil()).to.be.equal('585786437627');
})
})

Output:

✔ When deltaB < 0 logic for -twaDeltaB and -instantaneous deltaB (-twaDeltaB < -instDeltaB) is broken (1435ms)
1 passing (12s)

Tools Used

Manual Review & Hardhat

Recommendations

  • (Suggested) Rethink the solution for under/over issue soil.

  • (Optional) Add a Tolerance Threshold(i.e 10%):

  1. Introduce a configurable percentage tolerance threshold that allows the instantaneous deltaB to be different from the TWA deltaB within an acceptable range.

  2. If the instantaneous deltaB is within the tolerance threshold of the TWA deltaB, use the TWA deltaB. Otherwise, use the more conservative value.

Updates

Lead Judging Commences

giovannidisiena Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Design choice
holydevoti0n Submitter
about 1 year ago
holydevoti0n Submitter
about 1 year ago
giovannidisiena Lead Judge
about 1 year ago
giovannidisiena Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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