From 694be8d4fe9480bd7eb93a2e4f6776ec6e5f0a2f Mon Sep 17 00:00:00 2001 From: fr1j0 Date: Wed, 11 Feb 2026 12:39:37 -0500 Subject: [PATCH 1/4] Fix convert capacity double-counting bug When multiple converts occurred in the same block, the convert capacity tracking was double-counting usage because calculateConvertCapacityPenalty() returned cumulative values that applyStalkPenalty() added to storage again. Changes: - calculateConvertCapacityPenalty() now returns delta values instead of cumulative - calculatePerWellCapacity() now returns delta values and removes unused parameter - Added NatSpec documentation for pdCapacity return value - Added tests verifying sequential converts consume linear capacity Before fix: storage = old + (old + delta) = 2*old + delta After fix: storage = old + delta Co-Authored-By: Claude Opus 4.5 --- contracts/libraries/Convert/LibConvert.sol | 31 ++- .../convert/ConvertCapacityDoubleCount.t.sol | 217 ++++++++++++++++++ .../convert/ConvertCapacityForkTest.t.sol | 159 +++++++++++++ 3 files changed, 391 insertions(+), 16 deletions(-) create mode 100644 test/foundry/convert/ConvertCapacityDoubleCount.t.sol create mode 100644 test/foundry/convert/ConvertCapacityForkTest.t.sol diff --git a/contracts/libraries/Convert/LibConvert.sol b/contracts/libraries/Convert/LibConvert.sol index 16969a02..977c202c 100644 --- a/contracts/libraries/Convert/LibConvert.sol +++ b/contracts/libraries/Convert/LibConvert.sol @@ -287,6 +287,7 @@ library LibConvert { * @param outputToken Address of the output well * @param outputTokenAmountInDirectionOfPeg The amount deltaB was converted towards peg for the output well * @return cumulativePenalty The total Convert Capacity penalty, note it can return greater than the BDV converted + * @return pdCapacity The capacity deltas for overall, inputToken, and outputToken to add to storage */ function calculateConvertCapacityPenalty( uint256 overallCappedDeltaB, @@ -312,20 +313,17 @@ library LibConvert { overallCappedDeltaB.sub(convertCap.overallConvertCapacityUsed); } - // update overall remaining convert capacity - pdCapacity.overall = convertCap.overallConvertCapacityUsed.add( - overallAmountInDirectionOfPeg - ); + // Return capacity delta for caller to add to storage + pdCapacity.overall = overallAmountInDirectionOfPeg; - // update per-well convert capacity + // Calculate per-well capacity delta for caller to add to storage if (inputToken != s.sys.bean && inputTokenAmountInDirectionOfPeg > 0) { (cumulativePenalty, pdCapacity.inputToken) = calculatePerWellCapacity( inputToken, inputTokenAmountInDirectionOfPeg, cumulativePenalty, - convertCap, - pdCapacity.inputToken + convertCap ); } @@ -334,8 +332,7 @@ library LibConvert { outputToken, outputTokenAmountInDirectionOfPeg, cumulativePenalty, - convertCap, - pdCapacity.outputToken + convertCap ); } } @@ -344,16 +341,18 @@ library LibConvert { address wellToken, uint256 amountInDirectionOfPeg, uint256 cumulativePenalty, - ConvertCapacity storage convertCap, - uint256 pdCapacityToken + ConvertCapacity storage convertCap ) internal view returns (uint256, uint256) { uint256 tokenWellCapacity = abs(LibDeltaB.cappedReservesDeltaB(wellToken)); - pdCapacityToken = convertCap.wellConvertCapacityUsed[wellToken].add(amountInDirectionOfPeg); - if (pdCapacityToken > tokenWellCapacity) { - cumulativePenalty = cumulativePenalty.add(pdCapacityToken.sub(tokenWellCapacity)); + // Use cumulative for penalty check + uint256 cumulativeUsed = convertCap.wellConvertCapacityUsed[wellToken].add( + amountInDirectionOfPeg + ); + if (cumulativeUsed > tokenWellCapacity) { + cumulativePenalty = cumulativePenalty.add(cumulativeUsed.sub(tokenWellCapacity)); } - - return (cumulativePenalty, pdCapacityToken); + // Return delta (not cumulative) for storage update + return (cumulativePenalty, amountInDirectionOfPeg); } /** diff --git a/test/foundry/convert/ConvertCapacityDoubleCount.t.sol b/test/foundry/convert/ConvertCapacityDoubleCount.t.sol new file mode 100644 index 00000000..3dcae977 --- /dev/null +++ b/test/foundry/convert/ConvertCapacityDoubleCount.t.sol @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.0 <0.9.0; +pragma abicoder v2; + +import {TestHelper} from "test/foundry/utils/TestHelper.sol"; +import {IMockFBeanstalk} from "contracts/interfaces/IMockFBeanstalk.sol"; +import {MockPump} from "contracts/mocks/well/MockPump.sol"; +import {IWell, Call} from "contracts/interfaces/basin/IWell.sol"; +import {MockToken} from "contracts/mocks/MockToken.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {LibConvert} from "contracts/libraries/Convert/LibConvert.sol"; +import {LibRedundantMath256} from "contracts/libraries/Math/LibRedundantMath256.sol"; +import {MockPipelineConvertFacet, AdvancedPipeCall} from "contracts/mocks/mockFacets/MockPipelineConvertFacet.sol"; +import "forge-std/Test.sol"; + +/** + * @title ConvertCapacityDoubleCountTest + * @notice Test that convert capacity is not double-counted when multiple converts occur in the same block. + * @dev This test verifies the fix for the bug where calculateConvertCapacityPenalty() returned + * cumulative values that applyStalkPenalty() added to storage again, causing double-counting. + */ +contract ConvertCapacityDoubleCountTest is TestHelper { + using LibRedundantMath256 for uint256; + + MockPipelineConvertFacet pipelineConvert = MockPipelineConvertFacet(BEANSTALK); + address beanEthWell = BEAN_ETH_WELL; + + address[] farmers; + + function setUp() public { + initializeBeanstalkTestState(true, false); + + // Initialize farmers + farmers.push(users[1]); + farmers.push(users[2]); + farmers.push(users[3]); + + // Add initial liquidity to bean eth well + vm.prank(users[0]); + addLiquidityToWell( + beanEthWell, + 10_000e6, // 10,000 bean + 10 ether // 10 WETH + ); + + // Mint beans to farmers + mintTokensToUsers(farmers, BEAN, MAX_DEPOSIT_BOUND); + } + + /** + * @notice Test that sequential converts in the same block consume capacity linearly. + * @dev Before the fix, the second convert would consume more capacity than the first + * due to double-counting (storage = old + (old + delta) instead of storage = old + delta). + * + * Example with bug: + * - 1st convert of 50: storage = 0 + (0 + 50) = 50 ✓ + * - 2nd convert of 50: storage = 50 + (50 + 50) = 150 ✗ (should be 100) + * + * After fix, both should consume equal capacity for equal amounts. + */ + function test_sameBlockMultipleConverts_capacityNotDoubleCount() public { + vm.pauseGasMetering(); + + uint256 convertAmount = 500e6; // 500 beans per convert + + // Set deltaB high enough to allow multiple converts without hitting capacity + setDeltaBforWell(5000e6, beanEthWell, WETH); + + // Deposit beans for all farmers and pass germination + int96 stem1 = depositBeanAndPassGermination(convertAmount, farmers[0]); + int96 stem2 = depositBeanAndPassGermination(convertAmount, farmers[1]); + int96 stem3 = depositBeanAndPassGermination(convertAmount, farmers[2]); + + // Get initial capacity + uint256 initialCapacity = bs.getOverallConvertCapacity(); + assertGt(initialCapacity, 0, "Initial capacity should be > 0"); + + // Perform first convert + beanToLPDoConvert(convertAmount, stem1, farmers[0]); + uint256 capacityAfter1 = bs.getOverallConvertCapacity(); + uint256 usedByConvert1 = initialCapacity - capacityAfter1; + + // Perform second convert in same block + beanToLPDoConvert(convertAmount, stem2, farmers[1]); + uint256 capacityAfter2 = bs.getOverallConvertCapacity(); + uint256 usedByConvert2 = capacityAfter1 - capacityAfter2; + + // Perform third convert in same block + beanToLPDoConvert(convertAmount, stem3, farmers[2]); + uint256 capacityAfter3 = bs.getOverallConvertCapacity(); + uint256 usedByConvert3 = capacityAfter2 - capacityAfter3; + + vm.resumeGasMetering(); + + // Log capacity usage for comparison with fork test + console.log("Capacity used by convert 1:", usedByConvert1); + console.log("Capacity used by convert 2:", usedByConvert2); + console.log("Capacity used by convert 3:", usedByConvert3); + if (usedByConvert1 > 0) { + console.log("Ratio (convert2/convert1):", (usedByConvert2 * 100) / usedByConvert1, "%"); + console.log("Ratio (convert3/convert1):", (usedByConvert3 * 100) / usedByConvert1, "%"); + } + + // Assert: All three converts should use approximately the same capacity + // Allow 15% tolerance for slippage from BDV calculations as pool reserves change + // Before the fix, the ratio would be 2x or more due to double-counting + assertApproxEqRel( + usedByConvert1, usedByConvert2, 0.15e18, "Convert 1 and 2 should use approximately equal capacity" + ); + assertApproxEqRel( + usedByConvert2, usedByConvert3, 0.15e18, "Convert 2 and 3 should use approximately equal capacity" + ); + + // Also verify total capacity used is approximately 3x the first convert + // Before the fix: total would be 50 + 150 + 350 = 550 instead of 150 + uint256 totalUsed = initialCapacity - capacityAfter3; + assertApproxEqRel( + totalUsed, + usedByConvert1 * 3, + 0.2e18, + "Total capacity used should be ~3x single convert (not exponentially increasing)" + ); + } + + /** + * @notice Test per-well capacity is not double-counted for sequential converts. + */ + function test_sameBlockMultipleConverts_perWellCapacityNotDoubleCount() public { + vm.pauseGasMetering(); + + uint256 convertAmount = 500e6; + + // Set deltaB high enough + setDeltaBforWell(5000e6, beanEthWell, WETH); + + // Deposit beans for farmers + int96 stem1 = depositBeanAndPassGermination(convertAmount, farmers[0]); + int96 stem2 = depositBeanAndPassGermination(convertAmount, farmers[1]); + + // Get initial per-well capacity + uint256 initialWellCapacity = bs.getWellConvertCapacity(beanEthWell); + assertGt(initialWellCapacity, 0, "Initial well capacity should be > 0"); + + // Perform first convert + beanToLPDoConvert(convertAmount, stem1, farmers[0]); + uint256 wellCapacityAfter1 = bs.getWellConvertCapacity(beanEthWell); + uint256 wellUsedByConvert1 = initialWellCapacity - wellCapacityAfter1; + + // Perform second convert in same block + beanToLPDoConvert(convertAmount, stem2, farmers[1]); + uint256 wellCapacityAfter2 = bs.getWellConvertCapacity(beanEthWell); + uint256 wellUsedByConvert2 = wellCapacityAfter1 - wellCapacityAfter2; + + vm.resumeGasMetering(); + + // Assert: Both converts should use approximately the same per-well capacity + assertApproxEqRel( + wellUsedByConvert1, wellUsedByConvert2, 0.05e18, "Per-well capacity should be consumed linearly" + ); + } + + // Helper functions + + function depositBeanAndPassGermination(uint256 amount, address user) internal returns (int96 stem) { + vm.pauseGasMetering(); + bean.mint(user, amount); + + address[] memory userArr = new address[](1); + userArr[0] = user; + + (amount, stem) = setUpSiloDepositTest(amount, userArr); + + passGermination(); + } + + function beanToLPDoConvert(uint256 amount, int96 stem, address user) + internal + returns (int96 outputStem, uint256 outputAmount) + { + int96[] memory stems = new int96[](1); + stems[0] = stem; + + AdvancedPipeCall[] memory beanToLPPipeCalls = createBeanToLPPipeCalls(amount, new AdvancedPipeCall[](0)); + + uint256[] memory amounts = new uint256[](1); + amounts[0] = amount; + + vm.resumeGasMetering(); + vm.prank(user); + (outputStem, outputAmount,,,) = + pipelineConvert.pipelineConvert(BEAN, stems, amounts, beanEthWell, beanToLPPipeCalls); + } + + function createBeanToLPPipeCalls(uint256 beanAmount, AdvancedPipeCall[] memory extraPipeCalls) + internal + view + returns (AdvancedPipeCall[] memory pipeCalls) + { + pipeCalls = new AdvancedPipeCall[](2 + extraPipeCalls.length); + + bytes memory approveWell = abi.encodeWithSelector(IERC20.approve.selector, beanEthWell, beanAmount); + pipeCalls[0] = AdvancedPipeCall(BEAN, approveWell, abi.encode(0)); + + uint256[] memory tokenAmountsIn = new uint256[](2); + tokenAmountsIn[0] = beanAmount; + tokenAmountsIn[1] = 0; + + bytes memory addBeans = abi.encodeWithSelector( + IWell(beanEthWell).addLiquidity.selector, tokenAmountsIn, 0, PIPELINE, type(uint256).max + ); + pipeCalls[1] = AdvancedPipeCall(beanEthWell, addBeans, abi.encode(0)); + + for (uint256 i = 0; i < extraPipeCalls.length; i++) { + pipeCalls[2 + i] = extraPipeCalls[i]; + } + } +} diff --git a/test/foundry/convert/ConvertCapacityForkTest.t.sol b/test/foundry/convert/ConvertCapacityForkTest.t.sol new file mode 100644 index 00000000..0a3e61a5 --- /dev/null +++ b/test/foundry/convert/ConvertCapacityForkTest.t.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.6.0 <0.9.0; +pragma abicoder v2; + +import "forge-std/Test.sol"; +import {LibConvertData} from "contracts/libraries/Convert/LibConvertData.sol"; + +interface IBeanstalk { + function getDeposit( + address account, + address token, + int96 stem + ) external view returns (uint256 amount, uint256 bdv); + function balanceOfStalk(address account) external view returns (uint256); + function convert( + bytes calldata convertData, + int96[] calldata stems, + uint256[] calldata amounts + ) external payable returns (int96, uint256, uint256, uint256, uint256); + function poolCurrentDeltaB(address pool) external view returns (int256); + function getOverallConvertCapacity() external view returns (uint256); +} + +interface IWell { + function tokens() external view returns (address[] memory); + function getSwapIn( + address tokenIn, + address tokenOut, + uint256 amountOut + ) external view returns (uint256); + function shift( + address tokenOut, + uint256 minAmountOut, + address recipient + ) external returns (uint256); +} + +interface IERC20 { + function transfer(address to, uint256 amount) external returns (bool); +} + +/** + * @title ConvertCapacityForkTest + * @notice Fork test from whitehat report to verify convert capacity double-counting bug is fixed. + * @dev Before fix: Convert 1 uses 250945301, Convert 2 uses 501823323 (199% ratio) + * After fix: Both converts should use approximately the same capacity. + * + * Run: BASE_RPC= forge test --match-contract ConvertCapacityForkTest -vv + */ +contract ConvertCapacityForkTest is Test { + address constant PINTO_DIAMOND = 0xD1A0D188E861ed9d15773a2F3574a2e94134bA8f; + address constant PINTO_TOKEN = 0xb170000aeeFa790fa61D6e837d1035906839a3c8; + address constant PINTO_USDC_WELL = 0x3e1133aC082716DDC3114bbEFEeD8B1731eA9cb1; + address constant REAL_FARMER = 0xFb94D3404c1d3D9D6F08f79e58041d5EA95AccfA; + int96 constant FARMER_STEM = 590486100; + uint256 constant FORK_BLOCK = 27236526; + IBeanstalk bs; + + function setUp() public { + vm.createSelectFork(vm.envString("BASE_RPC"), FORK_BLOCK); + bs = IBeanstalk(PINTO_DIAMOND); + } + + /** + * @notice This test SHOULD FAIL after the fix is applied. + * @dev The assertion expects convert 2 to use >150% of convert 1's capacity (the bug behavior). + * With the fix, both converts use approximately the same capacity, so this assertion fails. + */ + function test_forkBase_convertCapacityDoubleCount_EXPECT_FAIL() public { + (uint256 depositAmount, ) = bs.getDeposit(REAL_FARMER, PINTO_TOKEN, FARMER_STEM); + console.log("Farmer deposit:", depositAmount); + require(depositAmount > 0, "No deposit found"); + + uint256 capacityBefore = bs.getOverallConvertCapacity(); + console.log("Overall convert capacity:", capacityBefore); + + uint256 convertAmount = 500e6; + bytes memory convertData = abi.encode( + LibConvertData.ConvertKind.BEANS_TO_WELL_LP, + convertAmount, + uint256(0), + PINTO_USDC_WELL + ); + int96[] memory stems = new int96[](1); + stems[0] = FARMER_STEM; + uint256[] memory amounts = new uint256[](1); + amounts[0] = convertAmount; + + vm.prank(REAL_FARMER); + bs.convert(convertData, stems, amounts); + uint256 capacityAfter1 = bs.getOverallConvertCapacity(); + uint256 usedByConvert1 = capacityBefore - capacityAfter1; + + vm.prank(REAL_FARMER); + bs.convert(convertData, stems, amounts); + uint256 capacityAfter2 = bs.getOverallConvertCapacity(); + uint256 usedByConvert2 = capacityAfter1 - capacityAfter2; + + console.log("Capacity used by convert 1:", usedByConvert1); + console.log("Capacity used by convert 2:", usedByConvert2); + console.log("Ratio (convert2/convert1):", (usedByConvert2 * 100) / usedByConvert1, "%"); + + // This assertion expects the BUG behavior (2nd convert uses >150% of 1st) + // After fix, this should FAIL because both converts use ~equal capacity + assertGt( + usedByConvert2, + (usedByConvert1 * 15) / 10, + "Bug: 2nd convert uses disproportionately more capacity than 1st" + ); + } + + /** + * @notice This test verifies the fix is working correctly. + * @dev After fix, both converts should use approximately the same capacity (within 20% tolerance for slippage). + */ + function test_forkBase_convertCapacityFixed() public { + (uint256 depositAmount, ) = bs.getDeposit(REAL_FARMER, PINTO_TOKEN, FARMER_STEM); + console.log("Farmer deposit:", depositAmount); + require(depositAmount > 0, "No deposit found"); + + uint256 capacityBefore = bs.getOverallConvertCapacity(); + console.log("Overall convert capacity:", capacityBefore); + + uint256 convertAmount = 500e6; + bytes memory convertData = abi.encode( + LibConvertData.ConvertKind.BEANS_TO_WELL_LP, + convertAmount, + uint256(0), + PINTO_USDC_WELL + ); + int96[] memory stems = new int96[](1); + stems[0] = FARMER_STEM; + uint256[] memory amounts = new uint256[](1); + amounts[0] = convertAmount; + + vm.prank(REAL_FARMER); + bs.convert(convertData, stems, amounts); + uint256 capacityAfter1 = bs.getOverallConvertCapacity(); + uint256 usedByConvert1 = capacityBefore - capacityAfter1; + + vm.prank(REAL_FARMER); + bs.convert(convertData, stems, amounts); + uint256 capacityAfter2 = bs.getOverallConvertCapacity(); + uint256 usedByConvert2 = capacityAfter1 - capacityAfter2; + + console.log("Capacity used by convert 1:", usedByConvert1); + console.log("Capacity used by convert 2:", usedByConvert2); + console.log("Ratio (convert2/convert1):", (usedByConvert2 * 100) / usedByConvert1, "%"); + + // After fix: both converts should use approximately equal capacity + // Allow 20% tolerance for slippage from pool state changes + assertApproxEqRel( + usedByConvert1, + usedByConvert2, + 0.20e18, + "Fix verified: converts use approximately equal capacity" + ); + } +} From 1dcd75c79d1c0b1399b576adce2336860b1dbe07 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 11 Feb 2026 18:02:30 +0000 Subject: [PATCH 2/4] auto-format: prettier formatting for Solidity files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../convert/ConvertCapacityDoubleCount.t.sol | 64 +++++++++++++------ 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/test/foundry/convert/ConvertCapacityDoubleCount.t.sol b/test/foundry/convert/ConvertCapacityDoubleCount.t.sol index 3dcae977..8c235f77 100644 --- a/test/foundry/convert/ConvertCapacityDoubleCount.t.sol +++ b/test/foundry/convert/ConvertCapacityDoubleCount.t.sol @@ -105,10 +105,16 @@ contract ConvertCapacityDoubleCountTest is TestHelper { // Allow 15% tolerance for slippage from BDV calculations as pool reserves change // Before the fix, the ratio would be 2x or more due to double-counting assertApproxEqRel( - usedByConvert1, usedByConvert2, 0.15e18, "Convert 1 and 2 should use approximately equal capacity" + usedByConvert1, + usedByConvert2, + 0.15e18, + "Convert 1 and 2 should use approximately equal capacity" ); assertApproxEqRel( - usedByConvert2, usedByConvert3, 0.15e18, "Convert 2 and 3 should use approximately equal capacity" + usedByConvert2, + usedByConvert3, + 0.15e18, + "Convert 2 and 3 should use approximately equal capacity" ); // Also verify total capacity used is approximately 3x the first convert @@ -155,13 +161,19 @@ contract ConvertCapacityDoubleCountTest is TestHelper { // Assert: Both converts should use approximately the same per-well capacity assertApproxEqRel( - wellUsedByConvert1, wellUsedByConvert2, 0.05e18, "Per-well capacity should be consumed linearly" + wellUsedByConvert1, + wellUsedByConvert2, + 0.05e18, + "Per-well capacity should be consumed linearly" ); } // Helper functions - function depositBeanAndPassGermination(uint256 amount, address user) internal returns (int96 stem) { + function depositBeanAndPassGermination( + uint256 amount, + address user + ) internal returns (int96 stem) { vm.pauseGasMetering(); bean.mint(user, amount); @@ -173,32 +185,44 @@ contract ConvertCapacityDoubleCountTest is TestHelper { passGermination(); } - function beanToLPDoConvert(uint256 amount, int96 stem, address user) - internal - returns (int96 outputStem, uint256 outputAmount) - { + function beanToLPDoConvert( + uint256 amount, + int96 stem, + address user + ) internal returns (int96 outputStem, uint256 outputAmount) { int96[] memory stems = new int96[](1); stems[0] = stem; - AdvancedPipeCall[] memory beanToLPPipeCalls = createBeanToLPPipeCalls(amount, new AdvancedPipeCall[](0)); + AdvancedPipeCall[] memory beanToLPPipeCalls = createBeanToLPPipeCalls( + amount, + new AdvancedPipeCall[](0) + ); uint256[] memory amounts = new uint256[](1); amounts[0] = amount; vm.resumeGasMetering(); vm.prank(user); - (outputStem, outputAmount,,,) = - pipelineConvert.pipelineConvert(BEAN, stems, amounts, beanEthWell, beanToLPPipeCalls); + (outputStem, outputAmount, , , ) = pipelineConvert.pipelineConvert( + BEAN, + stems, + amounts, + beanEthWell, + beanToLPPipeCalls + ); } - function createBeanToLPPipeCalls(uint256 beanAmount, AdvancedPipeCall[] memory extraPipeCalls) - internal - view - returns (AdvancedPipeCall[] memory pipeCalls) - { + function createBeanToLPPipeCalls( + uint256 beanAmount, + AdvancedPipeCall[] memory extraPipeCalls + ) internal view returns (AdvancedPipeCall[] memory pipeCalls) { pipeCalls = new AdvancedPipeCall[](2 + extraPipeCalls.length); - bytes memory approveWell = abi.encodeWithSelector(IERC20.approve.selector, beanEthWell, beanAmount); + bytes memory approveWell = abi.encodeWithSelector( + IERC20.approve.selector, + beanEthWell, + beanAmount + ); pipeCalls[0] = AdvancedPipeCall(BEAN, approveWell, abi.encode(0)); uint256[] memory tokenAmountsIn = new uint256[](2); @@ -206,7 +230,11 @@ contract ConvertCapacityDoubleCountTest is TestHelper { tokenAmountsIn[1] = 0; bytes memory addBeans = abi.encodeWithSelector( - IWell(beanEthWell).addLiquidity.selector, tokenAmountsIn, 0, PIPELINE, type(uint256).max + IWell(beanEthWell).addLiquidity.selector, + tokenAmountsIn, + 0, + PIPELINE, + type(uint256).max ); pipeCalls[1] = AdvancedPipeCall(beanEthWell, addBeans, abi.encode(0)); From ff25c6861612456d8e22b9ac9ba585731c64efd2 Mon Sep 17 00:00:00 2001 From: fr1j0 Date: Wed, 11 Feb 2026 13:31:22 -0500 Subject: [PATCH 3/4] Fix convert capacity double-counting in overall peg maintenance --- contracts/libraries/Convert/LibConvert.sol | 8 +- .../convert/ConvertCapacityForkTest.t.sol | 85 +------------------ 2 files changed, 8 insertions(+), 85 deletions(-) diff --git a/contracts/libraries/Convert/LibConvert.sol b/contracts/libraries/Convert/LibConvert.sol index 977c202c..a537e0eb 100644 --- a/contracts/libraries/Convert/LibConvert.sol +++ b/contracts/libraries/Convert/LibConvert.sol @@ -313,10 +313,10 @@ library LibConvert { overallCappedDeltaB.sub(convertCap.overallConvertCapacityUsed); } - // Return capacity delta for caller to add to storage + // Return this convert's capacity usage for caller to add to storage pdCapacity.overall = overallAmountInDirectionOfPeg; - // Calculate per-well capacity delta for caller to add to storage + // Calculate per-well capacity usage for caller to add to storage if (inputToken != s.sys.bean && inputTokenAmountInDirectionOfPeg > 0) { (cumulativePenalty, pdCapacity.inputToken) = calculatePerWellCapacity( @@ -344,14 +344,14 @@ library LibConvert { ConvertCapacity storage convertCap ) internal view returns (uint256, uint256) { uint256 tokenWellCapacity = abs(LibDeltaB.cappedReservesDeltaB(wellToken)); - // Use cumulative for penalty check + uint256 cumulativeUsed = convertCap.wellConvertCapacityUsed[wellToken].add( amountInDirectionOfPeg ); if (cumulativeUsed > tokenWellCapacity) { cumulativePenalty = cumulativePenalty.add(cumulativeUsed.sub(tokenWellCapacity)); } - // Return delta (not cumulative) for storage update + // Return this convert's capacity usage for caller to add to storage return (cumulativePenalty, amountInDirectionOfPeg); } diff --git a/test/foundry/convert/ConvertCapacityForkTest.t.sol b/test/foundry/convert/ConvertCapacityForkTest.t.sol index 0a3e61a5..b36ec0c3 100644 --- a/test/foundry/convert/ConvertCapacityForkTest.t.sol +++ b/test/foundry/convert/ConvertCapacityForkTest.t.sol @@ -4,36 +4,8 @@ pragma abicoder v2; import "forge-std/Test.sol"; import {LibConvertData} from "contracts/libraries/Convert/LibConvertData.sol"; - -interface IBeanstalk { - function getDeposit( - address account, - address token, - int96 stem - ) external view returns (uint256 amount, uint256 bdv); - function balanceOfStalk(address account) external view returns (uint256); - function convert( - bytes calldata convertData, - int96[] calldata stems, - uint256[] calldata amounts - ) external payable returns (int96, uint256, uint256, uint256, uint256); - function poolCurrentDeltaB(address pool) external view returns (int256); - function getOverallConvertCapacity() external view returns (uint256); -} - -interface IWell { - function tokens() external view returns (address[] memory); - function getSwapIn( - address tokenIn, - address tokenOut, - uint256 amountOut - ) external view returns (uint256); - function shift( - address tokenOut, - uint256 minAmountOut, - address recipient - ) external returns (uint256); -} +import {TestHelper} from "test/foundry/utils/TestHelper.sol"; +import {IMockFBeanstalk} from "contracts/interfaces/IMockFBeanstalk.sol"; interface IERC20 { function transfer(address to, uint256 amount) external returns (bool); @@ -47,18 +19,17 @@ interface IERC20 { * * Run: BASE_RPC= forge test --match-contract ConvertCapacityForkTest -vv */ -contract ConvertCapacityForkTest is Test { +contract ConvertCapacityForkTest is TestHelper { address constant PINTO_DIAMOND = 0xD1A0D188E861ed9d15773a2F3574a2e94134bA8f; address constant PINTO_TOKEN = 0xb170000aeeFa790fa61D6e837d1035906839a3c8; address constant PINTO_USDC_WELL = 0x3e1133aC082716DDC3114bbEFEeD8B1731eA9cb1; address constant REAL_FARMER = 0xFb94D3404c1d3D9D6F08f79e58041d5EA95AccfA; int96 constant FARMER_STEM = 590486100; uint256 constant FORK_BLOCK = 27236526; - IBeanstalk bs; function setUp() public { vm.createSelectFork(vm.envString("BASE_RPC"), FORK_BLOCK); - bs = IBeanstalk(PINTO_DIAMOND); + bs = IMockFBeanstalk(PINTO_DIAMOND); } /** @@ -108,52 +79,4 @@ contract ConvertCapacityForkTest is Test { "Bug: 2nd convert uses disproportionately more capacity than 1st" ); } - - /** - * @notice This test verifies the fix is working correctly. - * @dev After fix, both converts should use approximately the same capacity (within 20% tolerance for slippage). - */ - function test_forkBase_convertCapacityFixed() public { - (uint256 depositAmount, ) = bs.getDeposit(REAL_FARMER, PINTO_TOKEN, FARMER_STEM); - console.log("Farmer deposit:", depositAmount); - require(depositAmount > 0, "No deposit found"); - - uint256 capacityBefore = bs.getOverallConvertCapacity(); - console.log("Overall convert capacity:", capacityBefore); - - uint256 convertAmount = 500e6; - bytes memory convertData = abi.encode( - LibConvertData.ConvertKind.BEANS_TO_WELL_LP, - convertAmount, - uint256(0), - PINTO_USDC_WELL - ); - int96[] memory stems = new int96[](1); - stems[0] = FARMER_STEM; - uint256[] memory amounts = new uint256[](1); - amounts[0] = convertAmount; - - vm.prank(REAL_FARMER); - bs.convert(convertData, stems, amounts); - uint256 capacityAfter1 = bs.getOverallConvertCapacity(); - uint256 usedByConvert1 = capacityBefore - capacityAfter1; - - vm.prank(REAL_FARMER); - bs.convert(convertData, stems, amounts); - uint256 capacityAfter2 = bs.getOverallConvertCapacity(); - uint256 usedByConvert2 = capacityAfter1 - capacityAfter2; - - console.log("Capacity used by convert 1:", usedByConvert1); - console.log("Capacity used by convert 2:", usedByConvert2); - console.log("Ratio (convert2/convert1):", (usedByConvert2 * 100) / usedByConvert1, "%"); - - // After fix: both converts should use approximately equal capacity - // Allow 20% tolerance for slippage from pool state changes - assertApproxEqRel( - usedByConvert1, - usedByConvert2, - 0.20e18, - "Fix verified: converts use approximately equal capacity" - ); - } } From 8416c98397885b7bbd0f3296097d853f25250562 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:12:53 +0000 Subject: [PATCH 4/4] Rename convert capacity variables to reflect delta semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed variable names in the convert capacity call chain to better reflect that they now represent delta values (incremental amounts) rather than cumulative totals after the double-counting fix: - overallConvertCapacityUsed → overallCapacityDelta - inputTokenAmountUsed → inputTokenCapacityDelta - outputTokenAmountUsed → outputTokenCapacityDelta This addresses the naming confusion identified in code review where variables suggested cumulative semantics but actually contained deltas. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: frijo --- contracts/libraries/Convert/LibConvert.sol | 24 +++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/contracts/libraries/Convert/LibConvert.sol b/contracts/libraries/Convert/LibConvert.sol index a537e0eb..e8af0dda 100644 --- a/contracts/libraries/Convert/LibConvert.sol +++ b/contracts/libraries/Convert/LibConvert.sol @@ -195,15 +195,15 @@ library LibConvert { address outputToken ) internal returns (uint256 stalkPenaltyBdv) { AppStorage storage s = LibAppStorage.diamondStorage(); - uint256 overallConvertCapacityUsed; - uint256 inputTokenAmountUsed; - uint256 outputTokenAmountUsed; + uint256 overallCapacityDelta; + uint256 inputTokenCapacityDelta; + uint256 outputTokenCapacityDelta; ( stalkPenaltyBdv, - overallConvertCapacityUsed, - inputTokenAmountUsed, - outputTokenAmountUsed + overallCapacityDelta, + inputTokenCapacityDelta, + outputTokenCapacityDelta ) = calculateStalkPenalty( dbs, bdvConverted, @@ -215,14 +215,14 @@ library LibConvert { // Update penalties in storage. ConvertCapacity storage convertCap = s.sys.convertCapacity[block.number]; convertCap.overallConvertCapacityUsed = convertCap.overallConvertCapacityUsed.add( - overallConvertCapacityUsed + overallCapacityDelta ); convertCap.wellConvertCapacityUsed[inputToken] = convertCap .wellConvertCapacityUsed[inputToken] - .add(inputTokenAmountUsed); + .add(inputTokenCapacityDelta); convertCap.wellConvertCapacityUsed[outputToken] = convertCap .wellConvertCapacityUsed[outputToken] - .add(outputTokenAmountUsed); + .add(outputTokenCapacityDelta); } ////// Stalk Penalty Calculations ////// @@ -241,9 +241,9 @@ library LibConvert { view returns ( uint256 stalkPenaltyBdv, - uint256 overallConvertCapacityUsed, - uint256 inputTokenAmountUsed, - uint256 outputTokenAmountUsed + uint256 overallCapacityDelta, + uint256 inputTokenCapacityDelta, + uint256 outputTokenCapacityDelta ) { StalkPenaltyData memory spd;