Beanstalk Notion
Beanstalk Notion
/
🪲
Bug Reports
/
BIC Notes
/
📄
Report #32998
📄

Report #32998

Report Date
July 9, 2024
Status
Closed
Payout

Potential for users to claim more beans than intended in specific scenarios

‣
Report Info

Report ID

#32998

Report type

Smart Contract

Has PoC?

Yes

Target

https://etherscan.io/address/0x39cdAf9Dc6057Fd7Ae81Aaed64D7A062aAf452fD

Impacts

Theft of unclaimed yield

Description

The contract has a potential edge case where users could claim more beans than intended if the beansPerFertilizer (bpf) value decreases between transactions.

The issue is in __update function, which calculates beans based on the difference between the current bpf and the last recorded bpf for each fertilizer ID. If the global bpf decreases, users could potentially claim beans multiple times for the same bpf range.

uint256 stopBpf = bpf < ids[i] ? bpf : ids[i];
uint256 deltaBpf = stopBpf - _balances[ids[i]][account].lastBpf;
if (deltaBpf > 0) {
    beans = beans.add(deltaBpf.mul(_balances[ids[i]][account].amount));
    _balances[ids[i]][account].lastBpf = uint128(stopBpf);
}

Example Calculation:

Consider the following scenario:

  1. User has 100 units of fertilizer with ID 1000
  2. Initial bpf is 800
  3. User claims beans (800 - 0) * 100 = 80,000 beans
  4. bpf increases to 900, then decreases back to 800
  5. User claims again, but lastBpf is still 800, so no beans are claimed
  6. bpf increases to 850
  7. User claims (850 - 800) * 100 = 5,000 beans

In this scenario, the user has claimed for the 800-850 range twice.

Mitigation steps

Consider implementing a mechanism to track the highest bpf claimed for each fertilizer ID, rather than just the last bpf. This could be done by modifying the _balances struct:

struct Balance {
    uint128 amount;
    uint128 lastBpf;
    uint128 highestClaimedBpf;
}

Then, update the __update function:

Proof of concept

BIC Response

While the situation you describe is possible in theory, in practice the value of bpf never decreases. The Fertilizer contract assumes the security and correctness of implementation in the Beanstalk contract, which is the only address that is allowed to call the __update function.

Thus, we are closing this report and no reward will be issued.

function __update(
    address account,
    uint256[] memory ids,
    uint256 bpf
) internal returns (uint256 beans) {
    for (uint256 i; i < ids.length; ++i) {
        uint256 stopBpf = bpf < ids[i] ? bpf : ids[i];
        uint256 startBpf = _balances[ids[i]][account].highestClaimedBpf > _balances[ids[i]][account].lastBpf ?
            _balances[ids[i]][account].highestClaimedBpf : _balances[ids[i]][account].lastBpf;
        if (stopBpf > startBpf) {
            uint256 deltaBpf = stopBpf - startBpf;
            beans = beans.add(deltaBpf.mul(_balances[ids[i]][account].amount));
            _balances[ids[i]][account].lastBpf = uint128(stopBpf);
            _balances[ids[i]][account].highestClaimedBpf = uint128(stopBpf);
        }
    }
    emit ClaimFertilizer(ids, beans);
}
function testBpfDecreaseScenario() public {
    uint256 fertilizerId = 1000;
    uint128 amount = 100;
    uint128 initialBpf = 800;
    
    // Mint fertilizer
    fertilizer.beanstalkMint(address(this), fertilizerId, amount, initialBpf);
    
    uint256[] memory ids = new uint256[](1);
    ids[0] = fertilizerId;
    
    // First claim
    uint256 claimedBeans1 = fertilizer.beanstalkUpdate(address(this), ids, initialBpf);
    assert(claimedBeans1 == 80000, "First claim incorrect");
    
    // bpf increases to 900, then decreases to 800 (simulated by owner)
    IBS(fertilizer.owner()).setBeansPerFertilizer(900);
    IBS(fertilizer.owner()).setBeansPerFertilizer(800);
    
    // Second claim (should be 0)
    uint256 claimedBeans2 = fertilizer.beanstalkUpdate(address(this), ids, 800);
    assert(claimedBeans2 == 0, "Second claim should be 0");
    
    // bpf increases to 850
    IBS(fertilizer.owner()).setBeansPerFertilizer(850);
    
    // Third claim (should be 5000 in current implementation, 0 in fixed version)
    uint256 claimedBeans3 = fertilizer.beanstalkUpdate(address(this), ids, 850);
    assert(claimedBeans3 == 5000, "Third claim incorrect in current implementation");
    // assert(claimedBeans3 == 0, "Third claim should be 0 in fixed version");
}