DeFiHardhatOracleProxyUpdates
100,000 USDC
View results
Submission Details
Severity: low
Invalid

When Oracle fails sow reverts and compromise the peg mechanism

Summary

When user calls gm and the call for the oracle fails, it will return 0 for the deltaB value and this will impact the following state variables:

s.season.abovePeg - will be set to false on stepSun because deltaB == 0

s.f.soil - will be set to zero also on stepSun because deltaB == 0

Vulnerability Details

sowWithMin utilizes the s.f.soil as a slippage mechanism, but previously when oracle failed on gm call, the following happened on stepSun:

setSoil(uint256(-deltaB)); // @audit deltaB is zero
s.season.abovePeg = false;

As this value is zero, the sow function will prevent any call to sow beans as the value to be minted cannot surpass zero:

(uint256 soil, uint256 _morningTemperature, bool abovePeg) = _totalSoilAndTemperature();
require(
soil >= minSoil && beans >= minSoil,
"Field: Soil Slippage"
);

Transaction will revert due to Field: Soil Slippage

Impact

The peg mechanism will suffer DoS until the next successful call of sunrise + oracle returns the correct data.

Users will not be able to sow their beans when in fact they should because the system will think that there is no soil available.

Storage variables will impact the next season peg as their value was incorrectly updated(soil being set to zero, protocol being set to below peg when it could be above the peg, the temperature changed to an incorrect value due to invalid calcId impacted by deltaB == 0).

PoC

  1. Prepare the environment to work with Foundry + Updated Mocks
    https://gist.github.com/h0lydev/fcdb00c797adfdf8e4816031e095fd6c

  2. Make sure to have the mainnet forked through Anvil: anvil --fork-url https://rpc.ankr.com/eth

  3. Create the Season.t.sol file under the folder foundry and paste the code below. Then run forge test --match-contract SeasonTest -vv.

// SPDX-License-Identifier: MIT
pragma solidity =0.7.6;
pragma abicoder v2;
import { Sun } from "contracts/beanstalk/sun/SeasonFacet/Sun.sol";
import { MockSeasonFacet } from "contracts/mocks/mockFacets/MockSeasonFacet.sol";
import { MockSiloFacet } from "contracts/mocks/mockFacets/MockSiloFacet.sol";
import { MockFieldFacet } from "contracts/mocks/mockFacets/MockFieldFacet.sol";
import { MockWhitelistFacet } from "contracts/mocks/mockFacets/MockWhitelistFacet.sol";
import {LibWhitelist} from "contracts/libraries/Silo/LibWhitelist.sol";
import { Utils } from "./utils/Utils.sol";
import "./utils/TestHelper.sol";
import "contracts/libraries/LibSafeMath32.sol";
import "contracts/C.sol";
contract SeasonTest is MockSeasonFacet, TestHelper {
using SafeMath for uint256;
using LibSafeMath32 for uint32;
bool oracleFailed;
function setUp() public {
console.log("diamondSetup");
vm.createSelectFork('local');
oracleFailed = false;
setupDiamond();
dewhitelistCurvePool();
mintUnripeLPToUser1();
mintUnripeBeanToUser1();
setOraclePrices(false, 1000e6, 1000e6, 1000e6);
_setReservesForWell(1000000e6, 1000e18);
// user / tokens
mintTokenForUsers();
setTokenApprovalForUsers();
enableFertilizerAndMintActiveFertilizers();
callSunriseForUser1();
}
//////////// Setup functions ////////////
function setTokenApprovalForUsers() internal {
approveTokensForUser(deployer);
approveTokensForUser(user1);
approveTokensForUser(user2);
approveTokensForUser(user3);
approveTokensForUser(user4);
approveTokensForUser(user5);
}
function mintTokenForUsers() internal {
mintWETHtoUser(deployer);
mintWETHtoUser(user1);
mintWETHtoUser(user2);
mintWETHtoUser(user3);
mintWETHtoUser(user4);
mintWETHtoUser(user5);
// mint C.bean() to users
C.bean().mint(deployer, 10e6);
C.bean().mint(user1, 10e6);
C.bean().mint(user2, 10e6);
C.bean().mint(user3, 10e6);
C.bean().mint(user4, 10e6);
C.bean().mint(user5, 10e6);
}
function approveTokensForUser(address user) prank(user) internal {
mockWETH.approve(address(diamond), type(uint256).max);
unripeLP.approve(address(diamond), type(uint256).max);
unripeBean.approve(address(diamond), type(uint256).max);
well.approve(address(diamond), type(uint256).max);
C.bean().approve(address(field), type(uint256).max);
C.bean().approve(address(field), type(uint256).max);
}
function dewhitelistCurvePool() public {
vm.prank(deployer);
whitelist.dewhitelistToken(C.CURVE_BEAN_METAPOOL);
}
function mintWETHtoUser(address user) prank(user) internal {
mockWETH.mint(user, 1000e18);
}
function mintUnripeLPToUser1() internal {
unripeLP.mint(user1, 1000e6);
}
function mintUnripeBeanToUser1() internal {
unripeBean.mint(user1, 1000e6);
}
function enableFertilizerAndMintActiveFertilizers() internal {
// second parameter is the unfertilizedIndex
fertilizer.setFertilizerE(true, 10000e6);
vm.prank(deployer);
fertilizer.addFertilizerOwner(7500, 1e11, 99);
vm.prank(deployer);
fertilizer.addFertilizerOwner(6200, 1e11, 99);
addUnripeTokensToFacet();
}
function addUnripeTokensToFacet() prank(deployer) internal {
unripe.addUnripeToken(C.UNRIPE_BEAN, C.BEAN, bytes32(0));
unripe.addUnripeToken(C.UNRIPE_LP, C.BEAN_ETH_WELL, bytes32(0));
}
function callSunriseForUser1() prank(user1) internal {
_ensurePreConditions();
_advanceInTime(2 hours);
season.sunrise();
}
function setOraclePrices(bool makeOracleFail, int256 chainlinkPrice, uint256 ethUsdtPrice, uint256 ethUsdcPrice) internal {
if (makeOracleFail) {
_addEthUsdPriceChainlink(0);
oracleFailed = true;
} else {
oracleFailed = false;
_addEthUsdPriceChainlink(chainlinkPrice);
_setEthUsdtPrice(ethUsdtPrice);
_setEthUsdcPrice(ethUsdcPrice);
}
}
////////////////////////////////////////// TESTS //////////////////////////////////////////
function testSeason_whenOracleFails() public {
// Given the oracle fails
setOraclePrices(true, 0, 0, 0);
_advanceInTime(1 hours);
// When sunrise
vm.prank(user4);
season.sunrise();
_ensurePosConditions();
_ensureInvariants_whenOracacleFailedAreBroken();
// DoS after sunrise, no one can sow anymore, but the last season had soil available to be sown
vm.expectRevert("Field: Soil Slippage");
_sowWithUser(user4, 4 minutes, 10, 1e3, 5);
// Other users also try in different times within the next season but all fail
vm.expectRevert("Field: Soil Slippage");
_sowWithUser(user1, 7 minutes, 20, 1e3, 15);
vm.expectRevert("Field: Soil Slippage");
_sowWithUser(user2, 10 minutes, 30, 1e6, 10);
vm.expectRevert("Field: Soil Slippage");
_sowWithUser(user3, 13 minutes, 40, 5e6, 5);
vm.expectRevert("Field: Soil Slippage");
_sowWithUser(user3, 15 minutes, 45, 2e6, 2);
}
function testSow_whenOracleSucceed() public {
_advanceInTime(1 hours);
// When sunrise
vm.prank(user4);
season.sunrise();
_ensurePosConditions();
// sow succeeds
_sowWithUser(user4, 4 minutes, 20, 1e3, 5);
}
////////////////////////////////////////// HELPERS //////////////////////////////////////////
function _ensurePreConditions() internal {
assertTrue(season.thisSowTime() == type(uint32).max, "thisSowTime should be max");
assertTrue(season.lastSowTime() == type(uint32).max, "thisLastSowTime should be max");
assertEq(season.getIsFarm(), 1, "isFarm should be 1");
assertEq(season.getUsdTokenPrice(), 1, "usdTokenPrice should be 1");
assertEq(season.getReserve0(), 1, "reserve0 should be 1");
assertEq(season.getReserve1(), 1, "reserve1 should be 1");
assertFalse(season.getAbovePeg(), "pre - abovePeg should be false");
assertEq(season.getSoil(), 0, "soil should be == 0");
}
function _ensurePosConditions() internal {
// set in calcDeltaPodDemand during sunrise.
assertTrue(season.thisSowTime() == type(uint32).max, "thisSowTime should be max");
assertTrue(season.lastSowTime() == type(uint32).max, "thisLastSowTime should be max");
assertEq(season.getUsdTokenPrice(), 1);
assertEq(season.getReserve0(), 1);
assertEq(season.getReserve1(), 1);
assertTrue(season.wellOracleSnapshot().length > 0, "wellOracleSnapshot should be empty");
}
function _ensureInvariants_whenOracacleFailedAreBroken() internal {
// Even though soils were not sown in the last season, the soil is 0
assertTrue(season.getSoil() == 0, "brokenInvariants: soil should be 0");
assertTrue(field.totalSoil() == 0, "brokenInvariants: totalSoil should be 0");
// Above peg is always set to false because the deltaB == 0 when oracle fails.
assertFalse(season.getAbovePeg(), "brokenInvariants: abovePeg should be false");
}
function _sowWithUser(address user, uint256 timeToAdvance, uint256 blocksToAdvance, uint256 amount, uint256 minTemperature) prank(user) internal {
_advanceInTime(timeToAdvance);
vm.roll(block.number + blocksToAdvance);
field.sow(amount, minTemperature, LibTransfer.From.EXTERNAL);
}
}

Output:
Suite result: ok. 2 passed; 0 failed; 0 skipped; finished
Users are blocked from sowing their beans.

Here you can have a more detailed output with several logs across the contracts showing the system is affected by it:
https://gist.github.com/h0lydev/4ca0f742839f588d213da399b7ced17f

Tools Used

Manual Review & Foundry

Recommendations

It is noticed that the developers have the intention of never reverting the sunrise function to decrease the risk of depeg and breaking the incentive for users calling it. But at the same time, those state variables shouldn't be updated as if the system is working correctly because they will impact the next season as stated in this finding.

It is tricky to propose a simple fix to the problem without impacting the system as a whole. Here are a few ideas that could be used:

  1. (Recommended) An effective solution could be to store the latest response from Chainlink and in case it fails and the timeout(a limit that you can be added to accept a previous response from the oracle) is not reached yet, the protocol could use the previous response. Liquity Protocol uses this approach, an example here: https://github.com/liquity/dev/blob/main/packages/contracts/contracts/PriceFeed.sol

This solution will be effective for the protocol because the oracle is also called in different places like when minting fertilizers(getMintFertilizerOut), getting the well price(getRatiosAndBeanIndex), and getConstantProductWell. As the oracle is used along the protocol in many places, the latest successful price would be often up-to-date and within the limit time defined to use the previous price when the chainlink oracle fails.

Alternatives:

  1. Considering revert the function as chainlink failing doesn't happen quite often. And if it does, it is because chainlink is broken, so that could be taken into consideration.(A keeper could also be taken in place to ensure gm is always called)

  2. Rethink how to handle/recover from chainlink oracle failing.(like preventing storage variables from being updated/adding checks in place that only updated them when necessary)

Updates

Lead Judging Commences

giovannidisiena Lead Judge about 1 year ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement
Assigned finding tags:

Oracle failure

Support

FAQs

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