diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e7a55ad..6f0a554 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -23,7 +23,7 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 with: - version: nightly + version: v0.3.0 cache: false - name: forge version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d4d19f7..8a50492 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 with: - version: nightly + version: v0.3.0 - name: Run Forge build run: | diff --git a/contracts/EverlongStrategy.sol b/contracts/EverlongStrategy.sol index 178de0f..472a4e5 100644 --- a/contracts/EverlongStrategy.sol +++ b/contracts/EverlongStrategy.sol @@ -502,27 +502,46 @@ contract EverlongStrategy is BaseStrategy { "" ); + // No conversions are needed for hyperdrive's minimum transaction + // amount when closing longs since it's denominated in bonds. + uint256 minimumBonds = _poolConfig.minimumTransactionAmount; + + // Calculate how many bonds out of the position need to be closed. + uint256 bondsNeeded; + if (totalPositionValue <= _targetOutput - output) { + // We need the entire position's bonds. + // We can always assume it's at least the minimum transaction + // amount. + bondsNeeded = uint256(position.bondAmount); + } else { + // `bondsNeeded <= position.bondAmount` since + // `_targetOutput - output < totalPositionValue`. + bondsNeeded = uint256(position.bondAmount).mulDivUp( + (_targetOutput - output), + totalPositionValue + ); + + // We need to close at least the `minimumTransactionAmount` + // value of bonds. It's safe to assume that the position + // has at least that much. + bondsNeeded = bondsNeeded.max(minimumBonds); + + // If closing bondsNeeded would leave a position with less than + // `minimumTransactionAmount`, close the entire position + // instead. + if (position.bondAmount - bondsNeeded < minimumBonds) { + bondsNeeded = position.bondAmount; + } + } + // Close only part of the position if there are sufficient bonds // to reach the target output without leaving a small amount left. // For this case, the remaining bonds must be worth at least // Hyperdrive's minimum transaction amount. - if ( - totalPositionValue > - (_targetOutput - output + minimumTransactionAmount()).mulUp( - ONE + partialPositionClosureBuffer - ) - ) { - // Calculate the amount of bonds to close from the position. - uint256 bondsNeeded = uint256(position.bondAmount).mulDivUp( - (_targetOutput - output).mulUp( - ONE + partialPositionClosureBuffer - ), - totalPositionValue - ); - + if (bondsNeeded < position.bondAmount) { // Close part of the position. // - // Since this functino would never be called as part of a + // Since this function would never be called as part of a // `tend()`, there's no need to retrieve the `TendConfig` and // set the slippage guard. // @@ -541,9 +560,7 @@ contract EverlongStrategy is BaseStrategy { // No more closures are needed. return output; - } - // Close the entire position. - else { + } else { // Close the entire position. // // Since this function would never be called as part of a diff --git a/test/everlong/EverlongForkSDAITest.sol b/test/everlong/EverlongForkSDAITest.sol index 94c206d..d310846 100644 --- a/test/everlong/EverlongForkSDAITest.sol +++ b/test/everlong/EverlongForkSDAITest.sol @@ -57,6 +57,29 @@ contract EverlongForkSDAITest is EverlongTest { vm.stopPrank(); } + /// @dev Deposit into the SDAI everlong strategy. + /// @param _assets Amount of assets to deposit. + /// @param _from Source of the tokens. + /// @return shares Amount of shares received from the deposit. + function depositSDAIStrategy( + uint256 _assets, + address _from + ) internal returns (uint256 shares) { + // Mint _from some SDAI. + mintSDAI(_assets, _from); + + // Enable deposits from _from. + vm.startPrank(management); + strategy.setDepositor(_from, true); + vm.stopPrank(); + + // Make the approval and deposit. + vm.startPrank(_from); + asset.approve(address(strategy), _assets); + shares = strategy.deposit(_assets, _from); + vm.stopPrank(); + } + /// @dev Redeem shares from the SDAI everlong vault. /// @param _shares Amount of shares to redeem. /// @param _from Source of the shares. @@ -69,4 +92,17 @@ contract EverlongForkSDAITest is EverlongTest { assets = vault.redeem(_shares, _from, _from); vm.stopPrank(); } + + /// @dev Redeem shares from the SDAI everlong strategy. + /// @param _shares Amount of shares to redeem. + /// @param _from Source of the shares. + /// @return assets Amount of assets received from the redemption. + function redeemSDAIStrategy( + uint256 _shares, + address _from + ) internal returns (uint256 assets) { + vm.startPrank(_from); + assets = strategy.redeem(_shares, _from, _from); + vm.stopPrank(); + } } diff --git a/test/everlong/integration/PartialClosures.t.sol b/test/everlong/integration/PartialClosures.t.sol index 40d92b0..f594634 100644 --- a/test/everlong/integration/PartialClosures.t.sol +++ b/test/everlong/integration/PartialClosures.t.sol @@ -24,7 +24,8 @@ contract TestPartialClosures is EverlongTest { // Alice deposits into Everlong. uint256 aliceDepositAmount = bound( _deposit, - MINIMUM_TRANSACTION_AMOUNT * 100, // Increase minimum bound otherwise partial redemption won't occur + IEverlongStrategy(address(strategy)).minimumTransactionAmount() * + 100, // Increase minimum bound otherwise partial redemption won't occur hyperdrive.calculateMaxLong(AS_BASE) ); uint256 aliceShares = depositStrategy(aliceDepositAmount, alice, true); @@ -114,4 +115,30 @@ contract TestPartialClosures is EverlongTest { // Ensure Everlong has one position left. assertEq(IEverlongStrategy(address(strategy)).positionCount(), 1); } + + /// @dev Tests that when a partial closure would result in a remaining + /// position value less than the minimum transaction amount, the entire + /// position is closed. + function test_partial_closures_position_min_transaction_amount() external { + // Alice deposits into Everlong. + uint256 aliceDepositAmount = 1000e18; + uint256 aliceShares = depositStrategy(aliceDepositAmount, alice, true); + + // Ensure there is now one position. + assertEq(IEverlongStrategy(address(strategy)).positionCount(), 1); + + // Calculate how many shares are neeed to reach the minimum transaction + // amount. + uint256 minTxShareAmount = (aliceShares).mulDivDown( + hyperdrive.getPoolConfig().minimumTransactionAmount, + IEverlongStrategy(address(strategy)).positionAt(0).bondAmount + ); + + // Redeem shares such that the remaining share value should be less + // than the minimum transaction amount. + redeemStrategy(aliceShares - minTxShareAmount, alice, true); + + // There should be no positions left. + assertEq(IEverlongStrategy(address(strategy)).positionCount(), 0); + } } diff --git a/test/everlong/units/EverlongForkSDAITest.t.sol b/test/everlong/units/EverlongForkSDAITest.t.sol index 63261e4..c3bba1d 100644 --- a/test/everlong/units/EverlongForkSDAITest.t.sol +++ b/test/everlong/units/EverlongForkSDAITest.t.sol @@ -4,6 +4,7 @@ pragma solidity ^0.8.20; import { console2 as console } from "forge-std/console2.sol"; import { IERC20, IHyperdrive } from "hyperdrive/contracts/src/interfaces/IHyperdrive.sol"; import { ILido } from "hyperdrive/contracts/src/interfaces/ILido.sol"; +import { FixedPointMath } from "hyperdrive/contracts/src/libraries/FixedPointMath.sol"; import { IEverlongStrategy } from "../../../contracts/interfaces/IEverlongStrategy.sol"; import { EVERLONG_STRATEGY_KIND, EVERLONG_VERSION } from "../../../contracts/libraries/Constants.sol"; import { EverlongForkSDAITest } from "../EverlongForkSDAITest.sol"; @@ -11,6 +12,8 @@ import { EverlongForkSDAITest } from "../EverlongForkSDAITest.sol"; /// @dev Tests Everlong functionality when using the existing SDAIHyperdrive /// instance on a fork. contract TestEverlongForkSDAI is EverlongForkSDAITest { + using FixedPointMath for uint256; + /// @dev Ensure the deposit functions work as expected. function test_deposit() external { // Alice and Bob deposit into the vault. @@ -72,4 +75,32 @@ contract TestEverlongForkSDAI is EverlongForkSDAITest { assertGt(maturityTime, 0); assertGt(bondAmount, 0); } + + /// @dev Tests that when a partial closure would result in a remaining + /// position value less than the minimum transaction amount, the entire + /// position is closed. + function test_partial_closures_min_transaction_amount() external { + // Alice deposits into Everlong. + uint256 aliceDepositAmount = 10e18; + uint256 aliceShares = depositSDAIStrategy(aliceDepositAmount, alice); + rebalance(); + + // Ensure there is now one position. + assertEq(IEverlongStrategy(address(strategy)).positionCount(), 1); + + // Calculate how many shares are neeed to reach the minimum transaction + // amount. + uint256 minTxShareAmount = (aliceShares).mulDivDown( + hyperdrive.getPoolConfig().minimumTransactionAmount, + IEverlongStrategy(address(strategy)).positionAt(0).bondAmount + ); + + // Redeem shares such that the remaining share value should be less + // than the minimum transaction amount. + redeemSDAIStrategy(aliceShares - minTxShareAmount, alice); + rebalance(); + + // There should be no positions left. + assertEq(IEverlongStrategy(address(strategy)).positionCount(), 0); + } }