diff --git a/contracts/beanstalk/facets/silo/ConvertGettersFacet.sol b/contracts/beanstalk/facets/silo/ConvertGettersFacet.sol index 0f46360a..50649cf8 100644 --- a/contracts/beanstalk/facets/silo/ConvertGettersFacet.sol +++ b/contracts/beanstalk/facets/silo/ConvertGettersFacet.sol @@ -68,7 +68,7 @@ contract ConvertGettersFacet { * @return deltaB The capped reserves deltaB for the well */ function cappedReservesDeltaB(address well) external view returns (int256 deltaB) { - return LibDeltaB.cappedReservesDeltaB(well); + (deltaB, ) = LibDeltaB.cappedReservesDeltaB(well); } /** @@ -110,8 +110,9 @@ contract ConvertGettersFacet { */ function getWellConvertCapacity(address well) external view returns (uint256) { AppStorage storage s = LibAppStorage.diamondStorage(); + (int256 deltaB, ) = LibDeltaB.cappedReservesDeltaB(well); return - LibConvert.abs(LibDeltaB.cappedReservesDeltaB(well)).sub( + LibConvert.abs(deltaB).sub( s.sys.convertCapacity[block.number].wellConvertCapacityUsed[well] ); } @@ -125,7 +126,8 @@ contract ConvertGettersFacet { uint256 bdvConverted, uint256 overallConvertCapacity, address inputToken, - address outputToken + address outputToken, + uint256 fromAmount ) external view @@ -142,7 +144,8 @@ contract ConvertGettersFacet { bdvConverted, overallConvertCapacity, inputToken, - outputToken + outputToken, + fromAmount ); } diff --git a/contracts/beanstalk/facets/silo/abstract/ConvertBase.sol b/contracts/beanstalk/facets/silo/abstract/ConvertBase.sol index 9aa0e442..012807a2 100644 --- a/contracts/beanstalk/facets/silo/abstract/ConvertBase.sol +++ b/contracts/beanstalk/facets/silo/abstract/ConvertBase.sol @@ -91,7 +91,8 @@ abstract contract ConvertBase is Invariable, ReentrancyGuard { convertData, cp.fromToken, cp.toToken, - toBdv + toBdv, + cp.fromAmount ); // if the Farmer is converting between beans and well LP, check for diff --git a/contracts/interfaces/IMockFBeanstalk.sol b/contracts/interfaces/IMockFBeanstalk.sol index e1d4bc0e..37d2ba67 100644 --- a/contracts/interfaces/IMockFBeanstalk.sol +++ b/contracts/interfaces/IMockFBeanstalk.sol @@ -106,12 +106,12 @@ interface IMockFBeanstalk { } struct DeltaBStorage { - int256 beforeInputTokenDeltaB; - int256 afterInputTokenDeltaB; - int256 beforeOutputTokenDeltaB; - int256 afterOutputTokenDeltaB; - int256 beforeOverallDeltaB; - int256 afterOverallDeltaB; + int256 beforeInputTokenSpotDeltaB; + int256 afterInputTokenSpotDeltaB; + int256 beforeOutputTokenSpotDeltaB; + int256 afterOutputTokenSpotDeltaB; + int256 cappedOverallDeltaB; + int256 shadowOverallDeltaB; } struct Deposit { @@ -744,7 +744,15 @@ interface IMockFBeanstalk { uint256 inputTokenAmountInDirectionOfPeg, address outputToken, uint256 outputTokenAmountInDirectionOfPeg - ) external view returns (uint256 cumulativePenalty, PenaltyData memory pdCapacity); + ) + external + view + returns ( + uint256 cumulativePenalty, + PenaltyData memory pdCapacity, + address targetWell, + uint256[] memory targetWellReserves + ); function calculateDeltaBFromReserves( address well, @@ -757,7 +765,8 @@ interface IMockFBeanstalk { uint256 bdvConverted, uint256 overallConvertCapacity, address inputToken, - address outputToken + address outputToken, + uint256 fromAmount ) external view diff --git a/contracts/libraries/Convert/LibConvert.sol b/contracts/libraries/Convert/LibConvert.sol index e8af0dda..56ebf4d7 100644 --- a/contracts/libraries/Convert/LibConvert.sol +++ b/contracts/libraries/Convert/LibConvert.sol @@ -52,13 +52,18 @@ library LibConvert { uint256[] depositIds; } + /** + * @param shadowOverallDeltaB Post-convert overall deltaB anchored to the capped baseline + * rather than raw spot values. Captures only the spot change caused by the convert, + * so pre-existing spot manipulation is neutralized. + */ struct DeltaBStorage { - int256 beforeInputTokenDeltaB; - int256 afterInputTokenDeltaB; - int256 beforeOutputTokenDeltaB; - int256 afterOutputTokenDeltaB; - int256 beforeOverallDeltaB; - int256 afterOverallDeltaB; + int256 beforeInputTokenSpotDeltaB; + int256 afterInputTokenSpotDeltaB; + int256 beforeOutputTokenSpotDeltaB; + int256 afterOutputTokenSpotDeltaB; + int256 cappedOverallDeltaB; + int256 shadowOverallDeltaB; } struct PenaltyData { @@ -192,7 +197,8 @@ library LibConvert { uint256 bdvConverted, uint256 overallConvertCapacity, address inputToken, - address outputToken + address outputToken, + uint256 fromAmount ) internal returns (uint256 stalkPenaltyBdv) { AppStorage storage s = LibAppStorage.diamondStorage(); uint256 overallCapacityDelta; @@ -209,7 +215,8 @@ library LibConvert { bdvConverted, overallConvertCapacity, inputToken, - outputToken + outputToken, + fromAmount ); // Update penalties in storage. @@ -235,7 +242,8 @@ library LibConvert { uint256 bdvConverted, uint256 overallConvertCapacity, address inputToken, - address outputToken + address outputToken, + uint256 fromAmount ) internal view @@ -256,7 +264,15 @@ library LibConvert { spd.againstPeg.inputToken.add(spd.againstPeg.outputToken) ); - (spd.convertCapacityPenalty, spd.capacity) = calculateConvertCapacityPenalty( + // Get capacity penalty, target well, and reserves in one call + address targetWell; + uint256[] memory targetWellReserves; + ( + spd.convertCapacityPenalty, + spd.capacity, + targetWell, + targetWellReserves + ) = calculateConvertCapacityPenalty( overallConvertCapacity, spd.directionOfPeg.overall, inputToken, @@ -265,12 +281,26 @@ library LibConvert { spd.directionOfPeg.outputToken ); - // Cap amount of bdv penalized at amount of bdv converted (no penalty should be over 100%) - stalkPenaltyBdv = min( - max(spd.higherAmountAgainstPeg, spd.convertCapacityPenalty), - bdvConverted + uint256 penaltyAmount = max(spd.higherAmountAgainstPeg, spd.convertCapacityPenalty); + + uint256 pipelineConvertDeltaBImpact = LibDeltaB.calculateMaxDeltaBImpact( + inputToken, + fromAmount, + targetWell, + targetWellReserves ); + if (pipelineConvertDeltaBImpact > 0) { + // This scales the penalty proportionally to how much of the theoretical max was penalized + stalkPenaltyBdv = min( + (penaltyAmount * bdvConverted) / pipelineConvertDeltaBImpact, + bdvConverted + ); + } else { + // L2L/AL2L converts have zero deltaB impact, resulting in zero penalty. + stalkPenaltyBdv = 0; + } + return ( stalkPenaltyBdv, spd.capacity.overall, @@ -280,14 +310,17 @@ library LibConvert { } /** + * @notice Calculates the convert capacity penalty and determines the target well for the conversion. * @param overallCappedDeltaB The capped overall deltaB for all wells * @param overallAmountInDirectionOfPeg The amount deltaB was converted towards peg - * @param inputToken Address of the input well + * @param inputToken Address of the input token * @param inputTokenAmountInDirectionOfPeg The amount deltaB was converted towards peg for the input well - * @param outputToken Address of the output well + * @param outputToken Address of the output token * @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 + * @return targetWell The well involved in the convert (address(0) for L2L/AL2L converts) + * @return targetWellReserves The capped reserves for targetWell (empty for L2L/AL2L converts) */ function calculateConvertCapacityPenalty( uint256 overallCappedDeltaB, @@ -296,7 +329,16 @@ library LibConvert { uint256 inputTokenAmountInDirectionOfPeg, address outputToken, uint256 outputTokenAmountInDirectionOfPeg - ) internal view returns (uint256 cumulativePenalty, PenaltyData memory pdCapacity) { + ) + internal + view + returns ( + uint256 cumulativePenalty, + PenaltyData memory pdCapacity, + address targetWell, + uint256[] memory targetWellReserves + ) + { AppStorage storage s = LibAppStorage.diamondStorage(); ConvertCapacity storage convertCap = s.sys.convertCapacity[block.number]; @@ -316,8 +358,19 @@ library LibConvert { // Return this convert's capacity usage for caller to add to storage pdCapacity.overall = overallAmountInDirectionOfPeg; - // Calculate per-well capacity usage for caller to add to storage + // Determine target well. For L2L/AL2L (inputToken == outputToken), skip penalty calculation. + if (inputToken != outputToken) { + if (inputToken == s.sys.bean) { + targetWell = outputToken; + } else { + targetWell = inputToken; + } + + // `targetWell` must be a well at this point. + (, targetWellReserves) = LibDeltaB.cappedReservesDeltaB(targetWell); + } + // Calculate per-well capacity usage for caller to add to storage if (inputToken != s.sys.bean && inputTokenAmountInDirectionOfPeg > 0) { (cumulativePenalty, pdCapacity.inputToken) = calculatePerWellCapacity( inputToken, @@ -343,7 +396,8 @@ library LibConvert { uint256 cumulativePenalty, ConvertCapacity storage convertCap ) internal view returns (uint256, uint256) { - uint256 tokenWellCapacity = abs(LibDeltaB.cappedReservesDeltaB(wellToken)); + (int256 deltaB, ) = LibDeltaB.cappedReservesDeltaB(wellToken); + uint256 tokenWellCapacity = abs(deltaB); uint256 cumulativeUsed = convertCap.wellConvertCapacityUsed[wellToken].add( amountInDirectionOfPeg @@ -361,11 +415,14 @@ library LibConvert { function calculateAmountAgainstPeg( DeltaBStorage memory dbs ) internal pure returns (PenaltyData memory pd) { - pd.overall = calculateAgainstPeg(dbs.beforeOverallDeltaB, dbs.afterOverallDeltaB); - pd.inputToken = calculateAgainstPeg(dbs.beforeInputTokenDeltaB, dbs.afterInputTokenDeltaB); + pd.overall = calculateAgainstPeg(dbs.cappedOverallDeltaB, dbs.shadowOverallDeltaB); + pd.inputToken = calculateAgainstPeg( + dbs.beforeInputTokenSpotDeltaB, + dbs.afterInputTokenSpotDeltaB + ); pd.outputToken = calculateAgainstPeg( - dbs.beforeOutputTokenDeltaB, - dbs.afterOutputTokenDeltaB + dbs.beforeOutputTokenSpotDeltaB, + dbs.afterOutputTokenSpotDeltaB ); } @@ -397,11 +454,14 @@ library LibConvert { function calculateConvertedTowardsPeg( DeltaBStorage memory dbs ) internal pure returns (PenaltyData memory pd) { - pd.overall = calculateTowardsPeg(dbs.beforeOverallDeltaB, dbs.afterOverallDeltaB); - pd.inputToken = calculateTowardsPeg(dbs.beforeInputTokenDeltaB, dbs.afterInputTokenDeltaB); + pd.overall = calculateTowardsPeg(dbs.cappedOverallDeltaB, dbs.shadowOverallDeltaB); + pd.inputToken = calculateTowardsPeg( + dbs.beforeInputTokenSpotDeltaB, + dbs.afterInputTokenSpotDeltaB + ); pd.outputToken = calculateTowardsPeg( - dbs.beforeOutputTokenDeltaB, - dbs.afterOutputTokenDeltaB + dbs.beforeOutputTokenSpotDeltaB, + dbs.afterOutputTokenSpotDeltaB ); } @@ -412,11 +472,11 @@ library LibConvert { int256 beforeTokenDeltaB, int256 afterTokenDeltaB ) internal pure returns (uint256) { - // Calculate absolute values of beforeInputTokenDeltaB and afterInputTokenDeltaB using the abs() function + // Calculate absolute values of beforeTokenDeltaB and afterTokenDeltaB using the abs() function uint256 beforeDeltaAbs = abs(beforeTokenDeltaB); uint256 afterDeltaAbs = abs(afterTokenDeltaB); - // Check if afterInputTokenDeltaB and beforeInputTokenDeltaB have the same sign + // Check if afterTokenDeltaB and beforeTokenDeltaB have the same sign if ( (beforeTokenDeltaB >= 0 && afterTokenDeltaB >= 0) || (beforeTokenDeltaB < 0 && afterTokenDeltaB < 0) @@ -426,7 +486,7 @@ library LibConvert { // Return the difference between beforeDeltaAbs and afterDeltaAbs return beforeDeltaAbs.sub(afterDeltaAbs); } else { - // If afterInputTokenDeltaB is further from or equal to zero, return zero + // If afterTokenDeltaB is further from or equal to zero, return zero return 0; } } else { diff --git a/contracts/libraries/Convert/LibPipelineConvert.sol b/contracts/libraries/Convert/LibPipelineConvert.sol index 2c19f6c4..5685c401 100644 --- a/contracts/libraries/Convert/LibPipelineConvert.sol +++ b/contracts/libraries/Convert/LibPipelineConvert.sol @@ -32,6 +32,7 @@ library LibPipelineConvert { uint256 newBdv; uint256[] initialLpSupply; uint256 initialGrownStalk; + int256 beforeSpotOverallDeltaB; } function executePipelineConvert( @@ -48,7 +49,7 @@ library LibPipelineConvert { ); // Store the capped overall deltaB, this limits the overall convert power for the block - pipeData.overallConvertCapacity = LibConvert.abs(LibDeltaB.overallCappedDeltaB()); + pipeData.overallConvertCapacity = LibConvert.abs(pipeData.deltaB.cappedOverallDeltaB); IERC20(inputToken).transfer(C.PIPELINE, fromAmount); IPipeline(C.PIPELINE).advancedPipe(advancedPipeCalls); @@ -67,7 +68,9 @@ library LibPipelineConvert { pipeData.deltaB, pipeData.overallConvertCapacity, newBdv, - pipeData.initialLpSupply + pipeData.initialLpSupply, + pipeData.beforeSpotOverallDeltaB, + fromAmount ); // scale initial grown stalk proportionally to the bdv lost (if any) @@ -81,6 +84,8 @@ library LibPipelineConvert { /** * @notice Calculates the stalk penalty for a convert. Updates convert capacity used. + * @dev Uses TWAP as a manipulation-resistant baseline and measures actual spot price changes + * to determine the convert's impact on deltaB. */ function prepareStalkPenaltyCalculation( address inputToken, @@ -88,16 +93,23 @@ library LibPipelineConvert { LibConvert.DeltaBStorage memory dbs, uint256 overallConvertCapacity, uint256 toBdv, - uint256[] memory initialLpSupply + uint256[] memory initialLpSupply, + int256 beforeSpotOverallDeltaB, + uint256 inputAmount ) public returns (uint256) { - dbs.afterOverallDeltaB = LibDeltaB.scaledOverallCurrentDeltaB(initialLpSupply); + { + int256 afterSpotOverallDeltaB = LibDeltaB.scaledOverallCurrentDeltaB(initialLpSupply); + dbs.shadowOverallDeltaB = + dbs.cappedOverallDeltaB + + (afterSpotOverallDeltaB - beforeSpotOverallDeltaB); + } - // modify afterInputTokenDeltaB and afterOutputTokenDeltaB to scale using before/after LP amounts + // modify afterInputTokenSpotDeltaB and afterOutputTokenSpotDeltaB to scale using before/after LP amounts if (LibWell.isWell(inputToken)) { uint256 i = LibWhitelistedTokens.getIndexFromWhitelistedWellLpTokens(inputToken); // input token supply was burned, check to avoid division by zero uint256 currentInputTokenSupply = IERC20(inputToken).totalSupply(); - dbs.afterInputTokenDeltaB = currentInputTokenSupply == 0 + dbs.afterInputTokenSpotDeltaB = currentInputTokenSupply == 0 ? int256(0) : LibDeltaB.scaledDeltaB( initialLpSupply[i], @@ -108,7 +120,7 @@ library LibPipelineConvert { if (LibWell.isWell(outputToken)) { uint256 i = LibWhitelistedTokens.getIndexFromWhitelistedWellLpTokens(outputToken); - dbs.afterOutputTokenDeltaB = LibDeltaB.scaledDeltaB( + dbs.afterOutputTokenSpotDeltaB = LibDeltaB.scaledDeltaB( initialLpSupply[i], IERC20(outputToken).totalSupply(), LibDeltaB.getCurrentDeltaB(outputToken) @@ -121,7 +133,8 @@ library LibPipelineConvert { toBdv, overallConvertCapacity, inputToken, - outputToken + outputToken, + inputAmount ); } @@ -143,9 +156,12 @@ library LibPipelineConvert { address fromToken, address toToken ) internal view returns (PipelineConvertData memory pipeData) { - pipeData.deltaB.beforeOverallDeltaB = LibDeltaB.overallCurrentDeltaB(); - pipeData.deltaB.beforeInputTokenDeltaB = LibDeltaB.getCurrentDeltaB(fromToken); - pipeData.deltaB.beforeOutputTokenDeltaB = LibDeltaB.getCurrentDeltaB(toToken); + // Use capped deltaB as baseline (resistant to flash loan manipulation). + pipeData.deltaB.cappedOverallDeltaB = LibDeltaB.overallCappedDeltaB(); + // Store current spot deltaB to measure actual change after convert. + pipeData.beforeSpotOverallDeltaB = LibDeltaB.overallCurrentDeltaB(); + pipeData.deltaB.beforeInputTokenSpotDeltaB = LibDeltaB.getCurrentDeltaB(fromToken); + pipeData.deltaB.beforeOutputTokenSpotDeltaB = LibDeltaB.getCurrentDeltaB(toToken); pipeData.initialLpSupply = LibDeltaB.getLpSupply(); } @@ -189,7 +205,8 @@ library LibPipelineConvert { bytes calldata convertData, address fromToken, address toToken, - uint256 toBdv + uint256 toBdv, + uint256 fromAmount ) public returns (uint256 grownStalk) { LibConvertData.ConvertKind kind = convertData.convertKind(); if ( @@ -204,7 +221,9 @@ library LibPipelineConvert { pipeData.deltaB, pipeData.overallConvertCapacity, toBdv, - pipeData.initialLpSupply + pipeData.initialLpSupply, + pipeData.beforeSpotOverallDeltaB, + fromAmount ); // apply penalty to grown stalk as a % of bdv converted. See {LibConvert.executePipelineConvert} diff --git a/contracts/libraries/Oracle/LibDeltaB.sol b/contracts/libraries/Oracle/LibDeltaB.sol index 704cf8f3..e5a2ef33 100644 --- a/contracts/libraries/Oracle/LibDeltaB.sol +++ b/contracts/libraries/Oracle/LibDeltaB.sol @@ -15,7 +15,7 @@ import {IBeanstalkWellFunction} from "contracts/interfaces/basin/IBeanstalkWellF import {LibAppStorage, AppStorage} from "contracts/libraries/LibAppStorage.sol"; /** - * @title LibPipelineConvert + * @title LibDeltaB */ library LibDeltaB { @@ -107,33 +107,38 @@ library LibDeltaB { } /** - * @notice returns the overall cappedReserves deltaB for all whitelisted well tokens. + * @notice returns the cappedReserves deltaB and reserves for a well. + * @param well The well to get the deltaB and reserves for. + * @return deltaB The deltaB for the well. + * @return reserves The capped reserves for the well. */ - function cappedReservesDeltaB(address well) internal view returns (int256) { + function cappedReservesDeltaB( + address well + ) internal view returns (int256 deltaB, uint256[] memory reserves) { AppStorage storage s = LibAppStorage.diamondStorage(); if (well == s.sys.bean) { - return 0; + return (0, new uint256[](0)); } - uint256[] memory instReserves = cappedReserves(well); - if (instReserves.length == 0) { - return 0; + reserves = cappedReserves(well); + if (reserves.length == 0) { + return (0, reserves); } // if less than minimum bean balance, return 0, otherwise // calculateDeltaBFromReserves will revert - if (instReserves[LibWell.getBeanIndexFromWell(well)] < C.WELL_MINIMUM_BEAN_BALANCE) { - return 0; + if (reserves[LibWell.getBeanIndexFromWell(well)] < C.WELL_MINIMUM_BEAN_BALANCE) { + return (0, reserves); } // calculate deltaB. - return calculateDeltaBFromReserves(well, instReserves, ZERO_LOOKBACK); + deltaB = calculateDeltaBFromReserves(well, reserves, ZERO_LOOKBACK); } // Calculates overall deltaB, used by convert for stalk penalty purposes function overallCappedDeltaB() internal view returns (int256 deltaB) { address[] memory tokens = LibWhitelistedTokens.getWhitelistedWellLpTokens(); for (uint256 i = 0; i < tokens.length; i++) { - int256 cappedDeltaB = cappedReservesDeltaB(tokens[i]); - deltaB = deltaB.add(cappedDeltaB); + (int256 wellDeltaB, ) = cappedReservesDeltaB(tokens[i]); + deltaB = deltaB.add(wellDeltaB); } } @@ -227,4 +232,125 @@ library LibDeltaB { return 0; } } + + /** + * @notice Calculates the maximum deltaB impact for a given input amount. + * @dev Uses capped reserves (TWAP-based) to simulate the conversion. + * Returns |deltaB_before - deltaB_after| for the affected well. + * @param inputToken The token being converted from (Bean or LP token) + * @param fromAmount The amount of input token being converted + * @param targetWell The Well involved in the conversion + * @param reserves Capped reserves for targetWell. + * @return maxDeltaBImpact Maximum possible deltaB change from this conversion + */ + function calculateMaxDeltaBImpact( + address inputToken, + uint256 fromAmount, + address targetWell, + uint256[] memory reserves + ) internal view returns (uint256 maxDeltaBImpact) { + // L2L/AL2L converts pass targetWell as address(0) to skip penalty calculation. + if (targetWell == address(0)) return 0; + AppStorage storage s = LibAppStorage.diamondStorage(); + + require(reserves.length > 0, "Convert: Failed to read capped reserves"); + + address bean = s.sys.bean; + address well = inputToken == bean ? targetWell : inputToken; + (int256 beforeDeltaB, uint256 beanIndex) = calculateDeltaBFromReservesLiquidity( + well, + reserves, + ZERO_LOOKBACK + ); + + if (inputToken == bean) { + // Bean input: calculate the deltaB impact of adding single bean-sided liquidity to targetWell + reserves[beanIndex] += fromAmount; + } else { + // LP input: calculate the deltaB impact of removing single bean-sided liquidity from inputToken well + reserves[beanIndex] = calcSingleSidedRemovalDeltaB( + well, + reserves, + fromAmount, + beanIndex + ); + } + + (int256 afterDeltaB, ) = calculateDeltaBFromReservesLiquidity( + well, + reserves, + ZERO_LOOKBACK + ); + int256 deltaBDiff = afterDeltaB - beforeDeltaB; + maxDeltaBImpact = deltaBDiff >= 0 ? uint256(deltaBDiff) : uint256(-deltaBDiff); + } + + /** + * @notice Calculates deltaB for single-sided liquidity operations (converts). + * @dev Reverts if bean reserve < minimum or oracle fails. + * @param well The address of the Well + * @param reserves The reserves to calculate deltaB from + * @param lookback The lookback period for price ratios + * @return deltaB (target bean reserve - actual bean reserve) + */ + function calculateDeltaBFromReservesLiquidity( + address well, + uint256[] memory reserves, + uint256 lookback + ) internal view returns (int256 deltaB, uint256 beanIndex) { + IERC20[] memory tokens = IWell(well).tokens(); + Call memory wellFunction = IWell(well).wellFunction(); + + bool success; + uint256[] memory ratios; + (ratios, beanIndex, success) = LibWell.getRatiosAndBeanIndex(tokens, lookback); + + require( + reserves[beanIndex] >= C.WELL_MINIMUM_BEAN_BALANCE, + "Well: Bean reserve is less than the minimum" + ); + + // If the USD Oracle call fails, a deltaB cannot be determined + if (!success) { + revert("Well: USD Oracle call failed"); + } + + uint256 reserve = IBeanstalkWellFunction(wellFunction.target).calcReserveAtRatioLiquidity( + reserves, + beanIndex, + ratios, + wellFunction.data + ); + + return (int256(reserve).sub(int256(reserves[beanIndex])), beanIndex); + } + + /** + * @notice Calculates the amount of Beans in a Well after a single sided removal of liquidity from a well. + * @param well The address of the Well + * @param reserves The reserves to calculate the new Bean reserve from + * @param fromAmount The amount of liquidity to remove + * @return newBeanReserve The new Bean reserve + */ + function calcSingleSidedRemovalDeltaB( + address well, + uint256[] memory reserves, + uint256 fromAmount, + uint256 beanIndex + ) internal view returns (uint256 newBeanReserve) { + Call memory wellFunction = IWell(well).wellFunction(); + uint256 theoreticalLpSupply = IBeanstalkWellFunction(wellFunction.target).calcLpTokenSupply( + reserves, + wellFunction.data + ); + require(theoreticalLpSupply > 0, "Convert: Theoretical LP supply is zero"); + require(fromAmount < theoreticalLpSupply, "Convert: fromAmount exceeds LP supply"); + uint256 newLpSupply = theoreticalLpSupply - fromAmount; + newBeanReserve = IBeanstalkWellFunction(wellFunction.target).calcReserve( + reserves, + beanIndex, + newLpSupply, + wellFunction.data + ); + } } diff --git a/contracts/mocks/mockFacets/MockPipelineConvertFacet.sol b/contracts/mocks/mockFacets/MockPipelineConvertFacet.sol index 5513a596..3d909cd4 100644 --- a/contracts/mocks/mockFacets/MockPipelineConvertFacet.sol +++ b/contracts/mocks/mockFacets/MockPipelineConvertFacet.sol @@ -20,14 +20,24 @@ contract MockPipelineConvertFacet is PipelineConvertFacet { uint256 inputTokenAmountInDirectionOfPeg, address outputToken, uint256 outputTokenAmountInDirectionOfPeg - ) external view returns (uint256 cumulativePenalty, LibConvert.PenaltyData memory pdCapacity) { - (cumulativePenalty, pdCapacity) = LibConvert.calculateConvertCapacityPenalty( - overallCappedDeltaB, - overallAmountInDirectionOfPeg, - inputToken, - inputTokenAmountInDirectionOfPeg, - outputToken, - outputTokenAmountInDirectionOfPeg - ); + ) + external + view + returns ( + uint256 cumulativePenalty, + LibConvert.PenaltyData memory pdCapacity, + address targetWell, + uint256[] memory targetWellReserves + ) + { + (cumulativePenalty, pdCapacity, targetWell, targetWellReserves) = LibConvert + .calculateConvertCapacityPenalty( + overallCappedDeltaB, + overallAmountInDirectionOfPeg, + inputToken, + inputTokenAmountInDirectionOfPeg, + outputToken, + outputTokenAmountInDirectionOfPeg + ); } } diff --git a/contracts/mocks/well/MockPump.sol b/contracts/mocks/well/MockPump.sol index 5eee871d..758bb29f 100644 --- a/contracts/mocks/well/MockPump.sol +++ b/contracts/mocks/well/MockPump.sol @@ -58,6 +58,10 @@ contract MockPump is IInstantaneousPump, ICumulativePump { _update(msg.sender, _reserves, data); } + function setCappedReserves(address well, uint[] memory _cappedReserves) external { + reservesData[well].cappedReserves = _cappedReserves; + } + function setCumulativeReserves(address well, uint[] memory _cumulativeReserves) external { reservesData[well].cumulativeReserves = _cumulativeReserves; } diff --git a/test/foundry/farm/PipelineConvert.t.sol b/test/foundry/farm/PipelineConvert.t.sol index 8063397b..1528845f 100644 --- a/test/foundry/farm/PipelineConvert.t.sol +++ b/test/foundry/farm/PipelineConvert.t.sol @@ -338,22 +338,22 @@ contract PipelineConvertTest is TestHelper { IMockFBeanstalk.DeltaBStorage memory dbs; - dbs.beforeInputTokenDeltaB = bs.poolCurrentDeltaB(pd.inputWell); + dbs.beforeInputTokenSpotDeltaB = bs.poolCurrentDeltaB(pd.inputWell); - dbs.afterInputTokenDeltaB = LibDeltaB.scaledDeltaB( + dbs.afterInputTokenSpotDeltaB = LibDeltaB.scaledDeltaB( pd.beforeInputTokenLPSupply, pd.afterInputTokenLPSupply, pd.inputWellNewDeltaB ); - dbs.beforeOutputTokenDeltaB = bs.poolCurrentDeltaB(pd.outputWell); + dbs.beforeOutputTokenSpotDeltaB = bs.poolCurrentDeltaB(pd.outputWell); - dbs.afterOutputTokenDeltaB = LibDeltaB.scaledDeltaB( + dbs.afterOutputTokenSpotDeltaB = LibDeltaB.scaledDeltaB( pd.beforeOutputTokenLPSupply, pd.afterOutputTokenLPSupply, pd.outputWellNewDeltaB ); - dbs.beforeOverallDeltaB = bs.overallCurrentDeltaB(); - dbs.afterOverallDeltaB = dbs.afterInputTokenDeltaB + dbs.afterOutputTokenDeltaB; // update and for scaled deltaB + dbs.cappedOverallDeltaB = bs.overallCurrentDeltaB(); + dbs.shadowOverallDeltaB = dbs.afterInputTokenSpotDeltaB + dbs.afterOutputTokenSpotDeltaB; // update and for scaled deltaB pd.newBdv = bs.bdv(pd.outputWell, pd.wellAmountOut); @@ -362,7 +362,8 @@ contract PipelineConvertTest is TestHelper { pd.newBdv, LibConvert.abs(bs.overallCappedDeltaB()), // overall convert capacity pd.inputWell, - pd.outputWell + pd.outputWell, + pd.amountOfDepositedLP ); (pd.outputStem, ) = bs.calculateStemForTokenFromGrownStalk( @@ -828,9 +829,23 @@ contract PipelineConvertTest is TestHelper { uint256 convertCapacityStage2 = bs.getOverallConvertCapacity(); assertLe(convertCapacityStage2, convertCapacityStage1); - // add more eth to well again + // MockPump._update() overwrites instantaneous, cumulative, AND capped reserves + // in a single call. In production, capped reserves are not affected by + // spot manipulation within the same block. Since addEthToWell triggers a Well.addLiquidity + // which calls pump.update(), we must snapshot and restore capped reserves to prevent + // the manipulation below from inflating the capped deltaB used by shadow deltaB calculation. + Call[] memory pumps = IWell(beanEthWell).pumps(); + uint256[] memory savedCappedReserves = MockPump(pumps[0].target).readCappedReserves( + beanEthWell, + new bytes(0) + ); + + // simulate flash-loan manipulation: add ETH to push well further above peg addEthToWell(users[1], ethAmount); + // restore capped reserves so shadow deltaB reflects TWAP, not manipulated spot + MockPump(pumps[0].target).setCappedReserves(beanEthWell, savedCappedReserves); + beanToLPDoConvert(amount, stem2, users[2]); uint256 convertCapacityStage3 = bs.getOverallConvertCapacity(); @@ -844,6 +859,78 @@ contract PipelineConvertTest is TestHelper { assertGe(grownStalkBefore, 0); } + /** + * @notice Verifies that SPOT oracle manipulation does not allow preserving more grownStalk. + * @dev This test simulates a flash loan attack where an attacker manipulates SPOT deltaB + * without affecting TWAP, then converts. The penalty calculation should ensure that + * manipulation does NOT result in more preserved grownStalk than a normal convert. + * + * Attack scenario: + * 1. Start with Bean BELOW peg (excess Bean, negative deltaB) + * 2. Attacker swaps ETH → Bean to push SPOT above peg + * 3. TWAP remains negative (below peg) because pump isn't updated + * 4. Attacker converts Bean → LP hoping penalty calculation uses manipulated SPOT + * 5. Penalty uses TWAP baseline, manipulation fails + */ + function testManipulationDoesNotPreserveMoreGrownStalk(uint256 amount) public { + amount = bound(amount, 500e6, 2000e6); + + // Create BELOW PEG state by adding excess Beans to the well + // This makes deltaB negative (Bean excess = below peg) + uint256 excessBeans = 5000e6; + mintTokensToUser(users[0], BEAN, excessBeans); + vm.startPrank(users[0]); + MockToken(BEAN).approve(beanEthWell, excessBeans); + uint256[] memory tokenAmountsIn = new uint256[](2); + tokenAmountsIn[0] = excessBeans; + tokenAmountsIn[1] = 0; + IWell(beanEthWell).addLiquidity(tokenAmountsIn, 0, users[0], type(uint256).max); + vm.stopPrank(); + + // Update pump to reflect below peg state in TWAP + updateMockPumpUsingWellReserves(beanEthWell); + vm.roll(block.number + 1); + + int256 initialDeltaB = bs.overallCurrentDeltaB(); + require(initialDeltaB < 0, "Should be below peg"); + + // Setup deposit with grown stalk + int96 stem = depositBeanAndPassGermination(amount, users[1]); + season.siloSunrise(10); + uint256 grownBefore = bs.grownStalkForDeposit(users[1], BEAN, stem); + require(grownBefore > 0, "Should have grown stalk"); + + uint256 manipulationAmount = 20 ether; + + uint256 snapshotId = vm.snapshot(); + + // --- Scenario A: Normal Convert (no manipulation) --- + (int96 stemA, ) = beanToLPDoConvert(amount, stem, users[1]); + uint256 grownNormal = bs.grownStalkForDeposit(users[1], beanEthWell, stemA); + + vm.revertTo(snapshotId); + + // --- Scenario B: Manipulated Convert (swap to push SPOT above peg first) --- + // Attacker adds ETH to push SPOT above peg, but TWAP stays at old below-peg value + MockToken(WETH).mint(users[1], manipulationAmount); + vm.startPrank(users[1]); + MockToken(WETH).approve(beanEthWell, manipulationAmount); + uint256[] memory ethAmounts = new uint256[](2); + ethAmounts[0] = 0; + ethAmounts[1] = manipulationAmount; + IWell(beanEthWell).addLiquidity(ethAmounts, 0, users[1], type(uint256).max); + vm.stopPrank(); + + (int96 stemB, ) = beanToLPDoConvert(amount, stem, users[1]); + uint256 grownManipulated = bs.grownStalkForDeposit(users[1], beanEthWell, stemB); + + assertLe( + grownManipulated, + grownNormal, + "Manipulation preserved more grownStalk than normal convert" + ); + } + function testConvertingOutputTokenNotWell() public { int96[] memory stems = new int96[](1); stems[0] = 0; @@ -1077,9 +1164,9 @@ contract PipelineConvertTest is TestHelper { beanEthWell ); td.lpAmountAfter = td.lpAmountBefore.add(td.lpAmountOut); - dbs.beforeOverallDeltaB = bs.overallCurrentDeltaB(); + dbs.cappedOverallDeltaB = bs.overallCurrentDeltaB(); // calculate scaled overall deltaB, based on just the well affected - dbs.afterOverallDeltaB = LibDeltaB.scaledDeltaB( + dbs.shadowOverallDeltaB = LibDeltaB.scaledDeltaB( td.lpAmountBefore, td.lpAmountAfter, td.calculatedDeltaBAfter @@ -1091,7 +1178,8 @@ contract PipelineConvertTest is TestHelper { td.bdvOfDepositedLp, LibConvert.abs(bs.overallCappedDeltaB()), // overall convert capacity BEAN, - BEAN + BEAN, + amount ); // using stalk penalty, calculate what the new stem should be @@ -1206,12 +1294,12 @@ contract PipelineConvertTest is TestHelper { updateMockPumpUsingWellReserves(beanEthWell); IMockFBeanstalk.DeltaBStorage memory dbs; - dbs.beforeOverallDeltaB = -int256(amount); - dbs.afterOverallDeltaB = 0; - dbs.beforeInputTokenDeltaB = -int256(amount); - dbs.afterInputTokenDeltaB = 0; - dbs.beforeOutputTokenDeltaB = 0; - dbs.afterOutputTokenDeltaB = 0; + dbs.cappedOverallDeltaB = -int256(amount); + dbs.shadowOverallDeltaB = 0; + dbs.beforeInputTokenSpotDeltaB = -int256(amount); + dbs.afterInputTokenSpotDeltaB = 0; + dbs.beforeOutputTokenSpotDeltaB = 0; + dbs.afterOutputTokenSpotDeltaB = 0; uint256 bdvConverted = amount; uint256 overallConvertCapacity = amount; address inputToken = beanEthWell; @@ -1222,7 +1310,8 @@ contract PipelineConvertTest is TestHelper { bdvConverted, overallConvertCapacity, inputToken, - outputToken + outputToken, + bdvConverted ); assertEq(penalty, 0); } @@ -1239,7 +1328,7 @@ contract PipelineConvertTest is TestHelper { uint256 inputTokenAmountInDirectionOfPeg = amount; address outputToken = BEAN; uint256 outputTokenAmountInDirectionOfPeg = amount; - (uint256 penalty, ) = pipelineConvert.calculateConvertCapacityPenaltyE( + (uint256 penalty, , , ) = pipelineConvert.calculateConvertCapacityPenaltyE( overallCappedDeltaB, overallAmountInDirectionOfPeg, inputToken, @@ -1256,7 +1345,7 @@ contract PipelineConvertTest is TestHelper { inputTokenAmountInDirectionOfPeg = amount; outputToken = BEAN; outputTokenAmountInDirectionOfPeg = amount; - (penalty, ) = pipelineConvert.calculateConvertCapacityPenaltyE( + (penalty, , , ) = pipelineConvert.calculateConvertCapacityPenaltyE( overallCappedDeltaB, overallAmountInDirectionOfPeg, inputToken, @@ -1278,7 +1367,7 @@ contract PipelineConvertTest is TestHelper { uint256 inputTokenAmountInDirectionOfPeg = 0; address outputToken = BEAN; uint256 outputTokenAmountInDirectionOfPeg = 0; - (uint256 penalty, ) = pipelineConvert.calculateConvertCapacityPenaltyE( + (uint256 penalty, , , ) = pipelineConvert.calculateConvertCapacityPenaltyE( overallCappedDeltaB, overallAmountInDirectionOfPeg, inputToken, @@ -1297,7 +1386,7 @@ contract PipelineConvertTest is TestHelper { uint256 inputTokenAmountInDirectionOfPeg = 0; address outputToken = BEAN; uint256 outputTokenAmountInDirectionOfPeg = 0; - (uint256 penalty, ) = pipelineConvert.calculateConvertCapacityPenaltyE( + (uint256 penalty, , , ) = pipelineConvert.calculateConvertCapacityPenaltyE( overallCappedDeltaB, overallAmountInDirectionOfPeg, inputToken, @@ -1313,7 +1402,7 @@ contract PipelineConvertTest is TestHelper { inputTokenAmountInDirectionOfPeg = amount; outputToken = BEAN; outputTokenAmountInDirectionOfPeg = 0; - (penalty, ) = pipelineConvert.calculateConvertCapacityPenaltyE( + (penalty, , , ) = pipelineConvert.calculateConvertCapacityPenaltyE( overallCappedDeltaB, overallAmountInDirectionOfPeg, inputToken, @@ -1333,7 +1422,7 @@ contract PipelineConvertTest is TestHelper { uint256 inputTokenAmountInDirectionOfPeg = amount; address outputToken = BEAN; uint256 outputTokenAmountInDirectionOfPeg = 0; - (uint256 penalty, ) = pipelineConvert.calculateConvertCapacityPenaltyE( + (uint256 penalty, , , ) = pipelineConvert.calculateConvertCapacityPenaltyE( overallCappedDeltaB, overallAmountInDirectionOfPeg, inputToken, @@ -1353,7 +1442,7 @@ contract PipelineConvertTest is TestHelper { uint256 inputTokenAmountInDirectionOfPeg = 0; address outputToken = beanEthWell; uint256 outputTokenAmountInDirectionOfPeg = amount; - (uint256 penalty, ) = pipelineConvert.calculateConvertCapacityPenaltyE( + (uint256 penalty, , , ) = pipelineConvert.calculateConvertCapacityPenaltyE( overallCappedDeltaB, overallAmountInDirectionOfPeg, inputToken, @@ -1369,12 +1458,12 @@ contract PipelineConvertTest is TestHelper { updateMockPumpUsingWellReserves(beanEthWell); IMockFBeanstalk.DeltaBStorage memory dbs; - dbs.beforeOverallDeltaB = -200; - dbs.afterOverallDeltaB = -100; - dbs.beforeInputTokenDeltaB = -100; - dbs.afterInputTokenDeltaB = 0; - dbs.beforeOutputTokenDeltaB = 0; - dbs.afterOutputTokenDeltaB = 0; + dbs.cappedOverallDeltaB = -200; + dbs.shadowOverallDeltaB = -100; + dbs.beforeInputTokenSpotDeltaB = -100; + dbs.afterInputTokenSpotDeltaB = 0; + dbs.beforeOutputTokenSpotDeltaB = 0; + dbs.afterOutputTokenSpotDeltaB = 0; uint256 bdvConverted = 100; uint256 overallCappedDeltaB = 100; @@ -1386,7 +1475,8 @@ contract PipelineConvertTest is TestHelper { bdvConverted, overallCappedDeltaB, inputToken, - outputToken + outputToken, + bdvConverted ); assertEq(penalty, 0); } @@ -1396,12 +1486,12 @@ contract PipelineConvertTest is TestHelper { updateMockPumpUsingWellReserves(beanEthWell); IMockFBeanstalk.DeltaBStorage memory dbs; - dbs.beforeOverallDeltaB = 100; - dbs.afterOverallDeltaB = 0; - dbs.beforeInputTokenDeltaB = -100; - dbs.afterInputTokenDeltaB = 0; - dbs.beforeOutputTokenDeltaB = 0; - dbs.afterOutputTokenDeltaB = 0; + dbs.cappedOverallDeltaB = 100; + dbs.shadowOverallDeltaB = 0; + dbs.beforeInputTokenSpotDeltaB = -100; + dbs.afterInputTokenSpotDeltaB = 0; + dbs.beforeOutputTokenSpotDeltaB = 0; + dbs.afterOutputTokenSpotDeltaB = 0; uint256 bdvConverted = 100; uint256 overallCappedDeltaB = 100; @@ -1413,7 +1503,8 @@ contract PipelineConvertTest is TestHelper { bdvConverted, overallCappedDeltaB, inputToken, - outputToken + outputToken, + bdvConverted ); assertEq(penalty, 0); } @@ -1434,7 +1525,8 @@ contract PipelineConvertTest is TestHelper { bdvConverted, overallConvertCapacity, inputToken, - outputToken + outputToken, + bdvConverted ); assertEq(stalkPenaltyBdv, 0); } @@ -1451,60 +1543,82 @@ contract PipelineConvertTest is TestHelper { uint256 overallConvertCapacity ) = setupTowardsPegDeltaBStorageNegative(); - dbs.beforeInputTokenDeltaB = 100; - dbs.beforeOutputTokenDeltaB = 100; + dbs.beforeInputTokenSpotDeltaB = 100; + dbs.beforeOutputTokenSpotDeltaB = 100; (uint256 stalkPenaltyBdv, , , ) = bs.calculateStalkPenalty( dbs, bdvConverted, overallConvertCapacity, inputToken, - outputToken + outputToken, + bdvConverted ); assertEq(stalkPenaltyBdv, 0); } - function testCalcStalkPenaltyNoOverallCap() public view { - ( - IMockFBeanstalk.DeltaBStorage memory dbs, - address inputToken, - address outputToken, - uint256 bdvConverted, - uint256 overallConvertCapacity - ) = setupTowardsPegDeltaBStorageNegative(); + function testCalcStalkPenaltyNoOverallCap() public { + // Set up well off peg + setDeltaBforWell(-1000e6, beanEthWell, WETH); + updateMockPumpUsingWellReserves(beanEthWell); + + IMockFBeanstalk.DeltaBStorage memory dbs; + address inputToken = beanEthWell; + address outputToken = BEAN; + uint256 bdvConverted = 100e6; + uint256 overallConvertCapacity = 0; - overallConvertCapacity = 0; - dbs.beforeOverallDeltaB = -100; + dbs.beforeInputTokenSpotDeltaB = 0; + dbs.afterInputTokenSpotDeltaB = -100e6; + dbs.beforeOutputTokenSpotDeltaB = 0; + dbs.afterOutputTokenSpotDeltaB = 0; + dbs.cappedOverallDeltaB = 0; + dbs.shadowOverallDeltaB = -100e6; + + uint256 fromAmount = 1e18; // 1 LP token (uint256 stalkPenaltyBdv, , , ) = bs.calculateStalkPenalty( dbs, bdvConverted, overallConvertCapacity, inputToken, - outputToken + outputToken, + fromAmount ); - assertEq(stalkPenaltyBdv, 100); + + assertGt(stalkPenaltyBdv, 0, "Penalty should be non-zero when converting against peg"); } - function testCalcStalkPenaltyNoInputTokenCap() public view { - ( - IMockFBeanstalk.DeltaBStorage memory dbs, - address inputToken, - address outputToken, - uint256 bdvConverted, - uint256 overallConvertCapacity - ) = setupTowardsPegDeltaBStorageNegative(); + function testCalcStalkPenaltyNoInputTokenCap() public { + // Set up well off peg + setDeltaBforWell(-1000e6, beanEthWell, WETH); + updateMockPumpUsingWellReserves(beanEthWell); - dbs.beforeOverallDeltaB = -100; + IMockFBeanstalk.DeltaBStorage memory dbs; + address inputToken = beanEthWell; + address outputToken = BEAN; + uint256 bdvConverted = 100e6; + uint256 overallConvertCapacity = 100e6; + + dbs.beforeInputTokenSpotDeltaB = 0; + dbs.afterInputTokenSpotDeltaB = -100e6; + dbs.beforeOutputTokenSpotDeltaB = 0; + dbs.afterOutputTokenSpotDeltaB = 0; + dbs.cappedOverallDeltaB = 0; + dbs.shadowOverallDeltaB = -100e6; + + uint256 fromAmount = 1e18; // 1 LP token (uint256 stalkPenaltyBdv, , , ) = bs.calculateStalkPenalty( dbs, bdvConverted, overallConvertCapacity, inputToken, - outputToken + outputToken, + fromAmount ); - assertEq(stalkPenaltyBdv, 100); + + assertGt(stalkPenaltyBdv, 0, "Penalty should be non-zero when converting against peg"); } function testCalcStalkPenaltyNoOutputTokenCap() public view { @@ -1518,14 +1632,15 @@ contract PipelineConvertTest is TestHelper { inputToken = BEAN; outputToken = beanEthWell; - dbs.beforeOverallDeltaB = -100; + dbs.cappedOverallDeltaB = -100; (uint256 stalkPenaltyBdv, , , ) = bs.calculateStalkPenalty( dbs, bdvConverted, overallConvertCapacity, inputToken, - outputToken + outputToken, + bdvConverted ); assertEq(stalkPenaltyBdv, 100); } @@ -1540,12 +1655,12 @@ contract PipelineConvertTest is TestHelper { ) = setupTowardsPegDeltaBStorageNegative(); // Ensure `higherAmountAgainstPeg` is greater than `convertCapacityPenalty` - dbs.beforeInputTokenDeltaB = -200; - dbs.afterInputTokenDeltaB = -50; - dbs.beforeOutputTokenDeltaB = -100; - dbs.afterOutputTokenDeltaB = -500; - dbs.beforeOverallDeltaB = -300; - dbs.afterOverallDeltaB = -250; + dbs.beforeInputTokenSpotDeltaB = -200; + dbs.afterInputTokenSpotDeltaB = -50; + dbs.beforeOutputTokenSpotDeltaB = -100; + dbs.afterOutputTokenSpotDeltaB = -500; + dbs.cappedOverallDeltaB = -300; + dbs.shadowOverallDeltaB = -250; overallConvertCapacity = 100; // Set low to force penalty @@ -1565,7 +1680,8 @@ contract PipelineConvertTest is TestHelper { bdvConverted, overallConvertCapacity, inputToken, - outputToken + outputToken, + bdvConverted ); // final calculation @@ -1647,12 +1763,12 @@ contract PipelineConvertTest is TestHelper { uint256 overallConvertCapacity ) { - dbs.beforeInputTokenDeltaB = -100; - dbs.afterInputTokenDeltaB = 0; - dbs.beforeOutputTokenDeltaB = -100; - dbs.afterOutputTokenDeltaB = 0; - dbs.beforeOverallDeltaB = 0; - dbs.afterOverallDeltaB = 0; + dbs.beforeInputTokenSpotDeltaB = -100; + dbs.afterInputTokenSpotDeltaB = 0; + dbs.beforeOutputTokenSpotDeltaB = -100; + dbs.afterOutputTokenSpotDeltaB = 0; + dbs.cappedOverallDeltaB = 0; + dbs.shadowOverallDeltaB = 0; inputToken = beanEthWell; outputToken = BEAN; diff --git a/test/foundry/forks/ConvertManipulationFork.t.sol b/test/foundry/forks/ConvertManipulationFork.t.sol new file mode 100644 index 00000000..19d06a0a --- /dev/null +++ b/test/foundry/forks/ConvertManipulationFork.t.sol @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import {IMockFBeanstalk} from "contracts/interfaces/IMockFBeanstalk.sol"; +import {TestHelper, C} from "test/foundry/utils/TestHelper.sol"; +import "forge-std/console.sol"; +import {LibTransfer} from "contracts/libraries/Token/LibTransfer.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IWell} from "contracts/interfaces/basin/IWell.sol"; + +/** + * @title ConvertManipulationFork + * @notice Tests that convert penalty calculations resist spot oracle manipulation attacks + * + * @dev Attack Vector: + * An attacker could attempt to preserve more grown stalk during converts by: + * 1. Flash loan assets to manipulate spot price (push toward peg) + * 2. Execute pipelineConvert while spot oracle shows favorable deltaB + * 3. Reverse the manipulation, paying only swap fees + * + * The Shadow DeltaB mechanism should prevent this by using time-weighted values + * instead of instantaneous spot prices for penalty calculations. + * + * This test verifies that flash loan manipulation does not provide + * advantage in preserving grown stalk during Bean -> LP converts. + */ +contract ConvertManipulationFork is TestHelper { + // Base Mainnet Well addresses + address constant PINTO_USDC_WELL = 0x3e1133aC082716DDC3114bbEFEeD8B1731eA9cb1; + address constant PINTO_CBETH_WELL = 0x3e111115A82dF6190e36ADf0d552880663A4dBF1; + + // Base Mainnet token addresses + address constant BASE_USDC = 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; + address constant BASE_CBETH = 0x2Ae3F1Ec7F1F5012CFEab0185bfc7aa3cf0DEc22; + + // Pipeline address for convert operations + address constant BASE_PIPELINE = 0xb1bE0001f5a373b69b1E132b420e6D9687155e80; + + address user; + + function setUp() public { + uint256 forkBlock = 40729500; + forkMainnetAndUpgradeAllFacets( + forkBlock, + vm.envString("BASE_RPC"), + PINTO, + "", + new bytes(0) + ); + bs = IMockFBeanstalk(PINTO); + updateOracleTimeouts(L2_PINTO, false); + + user = makeAddr("user"); + } + + /** + * @notice Verifies that spot oracle manipulation does not provide advantage + * + * Test methodology: + * 1. Create a Pinto deposit with accumulated grown stalk + * 2. Measure grown stalk preserved after normal convert (no manipulation) + * 3. Measure grown stalk preserved after manipulated convert (spot pushed above peg) + * 4. Assert manipulation does not preserve more grown stalk + * + * The manipulation simulates a flash loan attack where an attacker swaps + * large amounts into Pinto across multiple wells to push spot price above peg, + * executes a convert, then reverses the swaps. + */ + function test_forkBase_spotManipulationResistance() public { + uint256 depositAmount = 1000e6; + + int96 stem = _depositAndAccumulateGrownStalk(depositAmount); + uint256 initialGrownStalk = bs.grownStalkForDeposit(user, L2_PINTO, stem); + require(initialGrownStalk > 0, "Should have grown stalk"); + + console.log("=== SPOT ORACLE MANIPULATION RESISTANCE TEST ==="); + console.log("Initial deltaB:", _formatSigned(bs.overallCurrentDeltaB())); + console.log("Grown stalk before convert:", initialGrownStalk); + + uint256 snapshotId = vm.snapshot(); + + // Execute convert without manipulation (baseline) + (, uint256 grownStalkNormal) = _executeConvert( + user, + L2_PINTO, + stem, + depositAmount, + PINTO_USDC_WELL + ); + console.log("Grown stalk after normal convert:", grownStalkNormal); + + vm.revertTo(snapshotId); + + // Simulate flash loan manipulation: swap into Pinto to push spot above peg + _manipulateSpotPrice(); + + console.log("DeltaB after manipulation:", _formatSigned(bs.overallCurrentDeltaB())); + + // Execute convert with manipulated spot price + (, uint256 grownStalkManipulated) = _executeConvert( + user, + L2_PINTO, + stem, + depositAmount, + PINTO_USDC_WELL + ); + console.log("Grown stalk after manipulated convert:", grownStalkManipulated); + + // Verify manipulation does not provide any advantage + console.log(""); + console.log("=== RESULTS ==="); + console.log("Grown stalk (normal): ", grownStalkNormal); + console.log("Grown stalk (manipulated):", grownStalkManipulated); + + assertLe( + grownStalkManipulated, + grownStalkNormal, + "Manipulation should not preserve more grown stalk" + ); + console.log("=== TEST PASSED ==="); + } + + /** + * @notice Deposits Pinto and advances seasons to accumulate grown stalk + * @param amount Amount of Pinto to deposit + * @return stem The stem of the created deposit + */ + function _depositAndAccumulateGrownStalk(uint256 amount) internal returns (int96 stem) { + deal(L2_PINTO, user, amount); + + vm.startPrank(user); + IERC20(L2_PINTO).approve(address(bs), amount); + (, , stem) = bs.deposit(L2_PINTO, amount, uint8(LibTransfer.From.EXTERNAL)); + vm.stopPrank(); + + bs.farmSunrises(100); + } + + /** + * @notice Simulates flash loan manipulation by swapping into Pinto on multiple wells + * @dev Pushes spot deltaB from negative (below peg) to positive (above peg) + */ + function _manipulateSpotPrice() internal { + uint256 usdcAmount = 1_000_000e6; + uint256 cbethAmount = 300 ether; + + deal(BASE_USDC, user, usdcAmount); + deal(BASE_CBETH, user, cbethAmount); + + vm.startPrank(user); + + IERC20(BASE_USDC).approve(PINTO_USDC_WELL, usdcAmount); + IWell(PINTO_USDC_WELL).swapFrom( + IERC20(BASE_USDC), + IERC20(L2_PINTO), + usdcAmount, + 0, + user, + block.timestamp + ); + + IERC20(BASE_CBETH).approve(PINTO_CBETH_WELL, cbethAmount); + IWell(PINTO_CBETH_WELL).swapFrom( + IERC20(BASE_CBETH), + IERC20(L2_PINTO), + cbethAmount, + 0, + user, + block.timestamp + ); + + vm.stopPrank(); + } + + /** + * @notice Executes a pipelineConvert and returns the resulting grown stalk + */ + function _executeConvert( + address account, + address inputToken, + int96 stem, + uint256 amount, + address outputToken + ) internal returns (int96 newStem, uint256 grownStalk) { + int96[] memory stems = new int96[](1); + stems[0] = stem; + uint256[] memory amounts = new uint256[](1); + amounts[0] = amount; + + vm.prank(account); + (newStem, , , , ) = bs.pipelineConvert( + inputToken, + stems, + amounts, + outputToken, + _buildPipelineCalls(inputToken, outputToken, amount) + ); + + grownStalk = bs.grownStalkForDeposit(account, outputToken, newStem); + } + + /** + * @notice Builds pipeline calls for Bean -> LP conversion via addLiquidity + */ + function _buildPipelineCalls( + address inputToken, + address outputToken, + uint256 amount + ) internal pure returns (IMockFBeanstalk.AdvancedPipeCall[] memory) { + IMockFBeanstalk.AdvancedPipeCall[] memory calls = new IMockFBeanstalk.AdvancedPipeCall[](2); + + address targetWell = inputToken == L2_PINTO ? outputToken : inputToken; + + bytes memory approveData = abi.encodeWithSelector( + IERC20.approve.selector, + targetWell, + type(uint256).max + ); + calls[0] = IMockFBeanstalk.AdvancedPipeCall(inputToken, approveData, abi.encode(0)); + + uint256[] memory tokenAmounts = new uint256[](2); + tokenAmounts[0] = amount; + tokenAmounts[1] = 0; + bytes memory addLiquidityData = abi.encodeWithSelector( + IWell.addLiquidity.selector, + tokenAmounts, + 0, + BASE_PIPELINE, + type(uint256).max + ); + calls[1] = IMockFBeanstalk.AdvancedPipeCall(targetWell, addLiquidityData, abi.encode(0)); + + return calls; + } + + function _formatSigned(int256 value) internal pure returns (string memory) { + if (value >= 0) { + return string(abi.encodePacked("+", vm.toString(uint256(value)))); + } else { + return string(abi.encodePacked("-", vm.toString(uint256(-value)))); + } + } +} diff --git a/test/hardhat/Tractor.test.js b/test/hardhat/Tractor.test.js index 7f5bbfeb..65cf1ac6 100644 --- a/test/hardhat/Tractor.test.js +++ b/test/hardhat/Tractor.test.js @@ -112,6 +112,8 @@ describe("Tractor", function () { await this.well .connect(owner) .addLiquidity([to6("1000000"), to18("2000")], 0, owner.address, ethers.constants.MaxUint256); + // Sync pump with post-addLiquidity reserves. + await this.well.connect(owner).sync(owner.address, 0); this.blueprint = { publisher: publisher.address, data: ethers.utils.hexlify("0x"), @@ -518,6 +520,8 @@ describe("Tractor", function () { await this.well .connect(owner) .addLiquidity([to6("3000000"), to18("0")], 0, owner.address, ethers.constants.MaxUint256); + // Sync pump with post-addLiquidity reserves. + await this.well.connect(owner).sync(owner.address, 0); // Convert in other direction (LP->Bean). operatorData = ethers.utils.defaultAbiCoder.encode( diff --git a/test/hardhat/WellConvert.test.js b/test/hardhat/WellConvert.test.js index 0ebd3a64..48aacd06 100644 --- a/test/hardhat/WellConvert.test.js +++ b/test/hardhat/WellConvert.test.js @@ -287,9 +287,13 @@ describe("Well Convert", function () { // call sunrise twice to finish germination (germinating deposits cannot convert). await mockBeanstalk.siloSunrise("0"); await mockBeanstalk.siloSunrise("0"); + + const [toStem, fromAmount, toAmount, fromBdv, toBdv] = await beanstalk + .connect(owner) + .callStatic.convert(convertData, ["0"], [to18("2000")]); await beanstalk.connect(owner).convert(convertData, ["0"], [to18("2000")]); - deposit = await beanstalk.getDeposit(owner.address, BEAN, "-3520050"); + deposit = await beanstalk.getDeposit(owner.address, BEAN, toStem); expect(deposit[0]).to.be.equal("134564064605"); }); diff --git a/utils/well.js b/utils/well.js index b3abdceb..d94ae047 100644 --- a/utils/well.js +++ b/utils/well.js @@ -237,6 +237,12 @@ async function setReserves(account, well, amounts) { ethers.constants.MaxUint256 ); } + + // Second Well call to sync pump — Well pushes pre-op reserves to pump, + // so after a single add/remove the pump still has stale reserves. + if (add || remove) { + await well.connect(account).sync(account.address, ethers.constants.Zero); + } } async function impersonateBeanWstethWell() {