Summary
The LibShipping
is rounding down the amounts of beans to distribute.
function getBeansFromPoints(
uint256[] memory shipmentAmounts,
ShipmentPlan[] memory shipmentPlans,
uint256 totalPoints,
uint256 beansToShip
) public pure {
for (uint256 i; i < shipmentPlans.length; i++) {
if (shipmentPlans[i].points == 0) continue;
@> shipmentAmounts[i] = (beansToShip * shipmentPlans[i].points) / totalPoints;
}
}
This is causing two issues:
Beanstalk consistently does not distribute all the amount of Beans among the shipments.
Beanstalk distributes 0 Beans when the number of shipments exceeds the amount of Beans available for distribution.
Vulnerability Details
The current configuration for the Shipment planner is: 33.33% of Beans equally distribute among Silo, Field, and Barn.
Currently, only one field is registered, so the "underdistribution" of Beans is minimal. However, as more shippments are added to the shipment planner, the problem will worsen.
The second issue caused by rounding down is that when the value to be distributed is less than the number of shipments, no Beans will be distributed. For example, if 2 Beans are to be distributed among 3 shipments, the result will be 0 Beans distributed.
PoC - Setup
First, change the LibWstethEthOracle
to return a price != 0. A known issue reported in a previous contest hasn't been fixed and due to this, the current value returned is 0.
At the end of the getWstethEthPrice
function add the following else condition:
function getWstethEthPrice(uint256 lookback) internal view returns (uint256 wstethEthPrice) {
if (LibOracleHelpers.getPercentDifference(chainlinkPrice, uniswapPrice) < MAX_DIFFERENCE) {
wstethEthPrice = chainlinkPrice.add(uniswapPrice).div(AVERAGE_DENOMINATOR);
if (wstethEthPrice > stethPerWsteth) wstethEthPrice = stethPerWsteth;
wstethEthPrice = wstethEthPrice.div(PRECISION_DENOMINATOR);
}
}
+ else {
+ wstethEthPrice = chainlinkPrice;
+ }
Second, add the following logging in LibShipping
so we can see how much it is been distributed among the shipments:
+ import "forge-std/console.sol";
function ship(uint256 beansToShip) public {
...
// Ship it.
for (uint256 i; i < shipmentAmounts.length; i++) {
+ if (shipmentRoutes[i].recipient == ShipmentRecipient.SILO){
+ console.log("shipment recipient: SILO");
+ } else if (shipmentRoutes[i].recipient == ShipmentRecipient.FIELD) {
+ console.log("shipment recipient: FIELD");
+ } else if (shipmentRoutes[i].recipient == ShipmentRecipient.BARN) {
+ console.log("shipment recipient: BARN");
+ }
+ console.log("shipment amount: %s", shipmentAmounts[i]);
if (shipmentAmounts[i] == 0) continue;
LibReceiving.receiveShipment(
shipmentRoutes[i].recipient,
shipmentAmounts[i],
shipmentRoutes[i].data
);
}
emit Shipped(s.sys.season.current, beansToShip);
}
PoC - Tests
Now let's run the tests for both scenarios. Add the following test on Sun.t.sol
:
function test_sunFertilizerFieldAndSilo_causesIncorrectDistribution() public {
uint256 podsInField = 200;
uint256 sproutsInBarn = 1e18;
int256 deltaB = 200;
uint256 caseId = 3;
setRoutes_siloAndBarnAndFields();
uint32 currentSeason = bs.season();
uint256 initialBeanBalance = C.bean().balanceOf(BEANSTALK);
bs.incrementTotalPodsE(0, podsInField);
initializeUnripeTokens(users[0], 100e6, 100e18);
uint256 fertilizerMinted;
(sproutsInBarn, fertilizerMinted) = addFertilizerBasedOnSprouts(0, sproutsInBarn);
assertGt(sproutsInBarn, 0, "!sprouts 0");
assertEq(sproutsInBarn, bs.totalUnfertilizedBeans(), "invalid sprouts in barn");
uint256 beansInBeanstalk = C.bean().balanceOf(BEANSTALK);
season.sunSunrise(deltaB, caseId);
if (deltaB >= 0) {
assertEq(
C.bean().balanceOf(BEANSTALK) - beansInBeanstalk,
uint256(deltaB),
"invalid bean minted +deltaB"
);
}
}
Run: forge test --match-test test_sunFertilizerFieldAndSilo_causesIncorrectDistribution -vv
Output:
shipment recipient: SILO
shipment amount: 66
shipment recipient: BARN
shipment amount: 66
shipment recipient: FIELD
shipment amount: 66
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 245.52ms
In this case, due to the rounding-down, 2 Beans haven't been distributed.
Now still using the same test, change the variable deltaB
to 2
and run the test again.
Output:
shipment recipient: SILO
shipment amount: 0
shipment recipient: BARN
shipment amount: 0
shipment recipient: FIELD
shipment amount: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 243.21ms
Even though there were Beans to be distributed for SILO/FIELD, this didn't occur. 0 Bens distributed.
Another observation is that when the Barn
is not eligible for beans distribution, Beanstalk distribution should occur 50/50 for field and silo, but as we can see above this doesn't happen.
Impact
Beanstalk will distribute less Beans than it should to its shipments.
User's APR/APY will be strongly affected as this issue happen in a sunrise call. (every time DeltaB > 0)
For cases where Beanstalk should distribute Beans to Silo/Field, it will distribute 0.
Tools Used
Manual Review & Foundry.
Recommendations
Distribute 100% of the Beans, according to the priority(points) of the shipments.
function getBeansFromPoints(
uint256[] memory shipmentAmounts,
ShipmentPlan[] memory shipmentPlans,
uint256 totalPoints,
uint256 beansToShip
) public pure {
+ uint256 distributedBeans = 0;
for (uint256 i; i < shipmentPlans.length; i++) {
// Do not modify amount for streams with 0 points. They either are zero or have already been set.
if (shipmentPlans[i].points == 0) continue;
shipmentAmounts[i] = (beansToShip * shipmentPlans[i].points) / totalPoints; // round down
+ distributedBeans += shipmentAmounts[i];
}
uint256 remainingBeans = beansToShip - distributedBeans;
+ // Distribute the remaining beans proportionally according to the points
+ for (uint256 i = 0; i < shipmentPlans.length && remainingBeans > 0; i++) {
+ if (shipmentPlans[i].points == 0) continue; // Skip shipments with 0 points
+
+ // Calculate the fractional bean that should have been allocated
+ uint256 fractionalBean = (beansToShip * shipmentPlans[i].points) % totalPoints;
+
+ // Distribute an additional bean if the fractional part is non-zero
+ if (fractionalBean > 0) {
+ shipmentAmounts[i] += 1;
+ remainingBeans -= 1;
+ }
+
+ // Exit loop if no remaining beans are left
+ if (remainingBeans == 0) break;
+ }
}
✅ Run the test again and see that distribution occurs properly for both cases.
shipment recipient: SILO
shipment amount: 67
shipment recipient: BARN
shipment amount: 67
shipment recipient: FIELD
shipment amount: 66
shipment recipient: SILO
shipment amount: 1
shipment recipient: BARN
shipment amount: 1
shipment recipient: FIELD
shipment amount: 0