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

LibShippiment fails to distribute rewards properly

Summary

The LibShippingis 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++) {
// 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
}
}

This is causing two issues:

  1. Beanstalk consistently does not distribute all the amount of Beans among the shipments.

  2. 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.

  1. 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 LibWstethEthOracleto 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 getWstethEthPricefunction 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 LibShippingso 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:

// import "forge-std/console.sol";
function test_sunFertilizerFieldAndSilo_causesIncorrectDistribution() public {
// pre conditions
uint256 podsInField = 200;
uint256 sproutsInBarn = 1e18;
int256 deltaB = 200; // @audit should distribute 33/33/33 - Silo, Field, Barn. Will skip bar when is not fertilizing.
uint256 caseId = 3;
setRoutes_siloAndBarnAndFields();
uint32 currentSeason = bs.season();
uint256 initialBeanBalance = C.bean().balanceOf(BEANSTALK);
// enable field and barn to receive beans
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);
// action
season.sunSunrise(deltaB, caseId);
// post conditions
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 2and 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.

// unfair Bean distribution issue - fixed. 200 Beans distributed
shipment recipient: SILO
shipment amount: 67
shipment recipient: BARN
shipment amount: 67
shipment recipient: FIELD
shipment amount: 66
// 0 bean distribution issue - fixed. 2 Beans distributed
shipment recipient: SILO
shipment amount: 1
shipment recipient: BARN
shipment amount: 1
shipment recipient: FIELD
shipment amount: 0
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.