From cf8cf0b216715dafce48f663429338f655464598 Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Tue, 31 Dec 2024 05:24:22 -0600 Subject: [PATCH 1/9] standardize calculation for minimum transaction amount Hyperdrive returns the minimum transaction amount in base and rounds up when handling conversions. Previously, it was not being converted in some places and values were rounded down. This pr unifies the minimum transaction amount calculation and adds a buffer to overestimate it. --- contracts/EverlongStrategy.sol | 61 ++++-- contracts/interfaces/IEverlongStrategy.sol | 12 ++ contracts/libraries/HyperdriveExecution.sol | 21 ++ test/VaultTest.sol | 19 ++ test/everlong/EverlongForkSDAITest.sol | 72 +++++++ test/everlong/EverlongForkStETHTest.sol | 78 +++++++ test/everlong/EverlongTest.sol | 2 +- .../integration/SDAIVaultSharesToken.t.sol | 191 ------------------ test/everlong/units/Everlong.t.sol | 30 +++ .../everlong/units/EverlongForkSDAITest.t.sol | 75 +++++++ .../units/EverlongForkStETHTest.t.sol | 72 +++++++ 11 files changed, 420 insertions(+), 213 deletions(-) create mode 100644 test/everlong/EverlongForkSDAITest.sol create mode 100644 test/everlong/EverlongForkStETHTest.sol delete mode 100644 test/everlong/integration/SDAIVaultSharesToken.t.sol create mode 100644 test/everlong/units/EverlongForkSDAITest.t.sol create mode 100644 test/everlong/units/EverlongForkStETHTest.t.sol diff --git a/contracts/EverlongStrategy.sol b/contracts/EverlongStrategy.sol index 687f874..95e3a39 100644 --- a/contracts/EverlongStrategy.sol +++ b/contracts/EverlongStrategy.sol @@ -8,7 +8,7 @@ import { SafeERC20 } from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; import { BaseStrategy, ERC20 } from "tokenized-strategy/BaseStrategy.sol"; import { IEverlongStrategy } from "./interfaces/IEverlongStrategy.sol"; import { IERC20Wrappable } from "./interfaces/IERC20Wrappable.sol"; -import { EVERLONG_STRATEGY_KIND, EVERLONG_VERSION, ONE } from "./libraries/Constants.sol"; +import { EVERLONG_STRATEGY_KIND, EVERLONG_VERSION, MAX_BPS, ONE } from "./libraries/Constants.sol"; import { EverlongPortfolioLibrary } from "./libraries/EverlongPortfolio.sol"; import { HyperdriveExecutionLibrary } from "./libraries/HyperdriveExecution.sol"; @@ -103,6 +103,12 @@ contract EverlongStrategy is BaseStrategy { // │ Constants and Immutables │ // ╰───────────────────────────────────────────────────────────────────────╯ + /// @notice Amount to add to hyperdrive's minimum transaction amount to + /// account for hyperdrive's internal rounding. Represented as a + /// percentage of the value after conversions from base to shares + /// (if applicable) where 1e18 represents a 100% buffer. + uint256 public constant minimumTransactionAmountBuffer = 0.001e18; + /// @notice Amount of additional bonds to close during a partial position /// closure to avoid rounding errors. Represented as a percentage /// of the positions total amount of bonds where 1e18 represents @@ -330,7 +336,7 @@ contract EverlongStrategy is BaseStrategy { uint256 toSpend = _totalIdle.min(availableDepositLimit(address(this))); // If Everlong has sufficient idle, open a new position. - if (toSpend > _minimumTransactionAmount()) { + if (toSpend > minimumTransactionAmount()) { (uint256 maturityTime, uint256 bondAmount) = _openLong( toSpend, tendConfig.minOutput, @@ -473,7 +479,7 @@ contract EverlongStrategy is BaseStrategy { uint256 _targetOutput ) internal returns (uint256 output) { // Round `_targetOutput` up to Hyperdrive's minimum transaction amount. - _targetOutput = _targetOutput.max(_minimumTransactionAmount()); + _targetOutput = _targetOutput.max(minimumTransactionAmount()); // Since multiple position's worth of bonds may need to be closed, // iterate through each position starting with the most mature. @@ -502,7 +508,7 @@ contract EverlongStrategy is BaseStrategy { // Hyperdrive's minimum transaction amount. if ( totalPositionValue > - (_targetOutput - output + _minimumTransactionAmount()).mulUp( + (_targetOutput - output + minimumTransactionAmount()).mulUp( ONE + partialPositionClosureBuffer ) ) { @@ -682,22 +688,6 @@ contract EverlongStrategy is BaseStrategy { } } - /// @dev Retrieve hyperdrive's minimum transaction amount. - /// @return amount Hyperdrive's minimum transaction amount. - function _minimumTransactionAmount() - internal - view - returns (uint256 amount) - { - amount = _poolConfig.minimumTransactionAmount; - - // Since `amount` is denominated in hyperdrive's base currency. We must - // convert it. - if (!asBase || isWrapped) { - IHyperdrive(hyperdrive)._convertToShares(amount); - } - } - // ╭───────────────────────────────────────────────────────────────────────╮ // │ Views │ // ╰───────────────────────────────────────────────────────────────────────╯ @@ -752,7 +742,7 @@ contract EverlongStrategy is BaseStrategy { /// @return True if a new position can be opened, false otherwise. function canOpenPosition() public view returns (bool) { uint256 currentBalance = asset.balanceOf(address(this)); - return currentBalance > _minimumTransactionAmount(); + return currentBalance > minimumTransactionAmount(); } /// @notice Converts an amount denominated in wrapped tokens to an amount @@ -793,6 +783,35 @@ contract EverlongStrategy is BaseStrategy { IHyperdrive(hyperdrive).isMature(_portfolio.head()); } + /// @notice Gets the minimum amount of strategy assets needed to open a long + /// with hyperdrive. + /// @return amount Minimum amount of strategy assets needed to open a long + /// with hyperdrive. + function minimumTransactionAmount() public view returns (uint256 amount) { + // Retrieve the minimum transaction amount from the poolConfig. + // This value is already converted to shares if `asBase==false`. + amount = IHyperdrive(hyperdrive)._minimumTransactionAmount( + _poolConfig, + asBase + ); + + // If we're using a wrapped token, make sure to convert to the unwrapped + // value. + if (isWrapped) { + amount = convertToUnwrapped( + IHyperdrive(hyperdrive)._minimumTransactionAmount( + _poolConfig, + asBase + ) + ); + } + + // NOTE: Since some rounding occurs within hyperdrive when using its + // shares token, we choose to be safe and add a buffer to ensure + // that the amount will be above hyperdrive's minimum. + amount = amount.mulUp(ONE + minimumTransactionAmountBuffer); + } + /// @notice Retrieve the position at the specified location in the queue. /// @param _index Index in the queue to retrieve the position. /// @return The position at the specified location. diff --git a/contracts/interfaces/IEverlongStrategy.sol b/contracts/interfaces/IEverlongStrategy.sol index 88d154b..2e5ca60 100644 --- a/contracts/interfaces/IEverlongStrategy.sol +++ b/contracts/interfaces/IEverlongStrategy.sol @@ -118,6 +118,18 @@ interface IEverlongStrategy is IPermissionedStrategy, IEverlongEvents { /// @return The Everlong instance's kind. function kind() external pure returns (string memory); + /// @notice Gets the minimum amount of strategy assets needed to open a long + /// with hyperdrive. + /// @return Minimum amount of strategy assets needed to open a long with + /// hyperdrive. + function minimumTransactionAmount() external view returns (uint256); + + /// @notice Amount to add to hyperdrive's minimum transaction amount to + /// account for hyperdrive's internal rounding. Represented as a + /// percentage of the value after conversions from base to shares + /// (if applicable) where 1e18 represents a 100% buffer. + function minimumTransactionAmountBuffer() external view returns (uint256); + /// @notice Amount of additional bonds to close during a partial position /// closure to avoid rounding errors. Represented as a percentage /// of the positions total amount of bonds where 1e18 represents diff --git a/contracts/libraries/HyperdriveExecution.sol b/contracts/libraries/HyperdriveExecution.sol index ce4eb89..8137d8b 100644 --- a/contracts/libraries/HyperdriveExecution.sol +++ b/contracts/libraries/HyperdriveExecution.sol @@ -1432,4 +1432,25 @@ library HyperdriveExecutionLibrary { } return self.convertToShares(_baseAmount); } + + /// @dev Gets the minimum amount of strategy assets needed to open a long + /// with hyperdrive. + /// @param _poolConfig The hyperdrive PoolConfig. + /// @param _asBase Whether to transact in hyperdrive's base token or vault + /// shares token. + /// @return amount Minimum amount of strategy assets needed to open a long + /// with hyperdrive. + function _minimumTransactionAmount( + IHyperdrive self, + IHyperdrive.PoolConfig storage _poolConfig, + bool _asBase + ) public view returns (uint256 amount) { + amount = _poolConfig.minimumTransactionAmount; + + // Since `amount` is denominated in hyperdrive's base token. We must + // convert it to the shares token if `_asBase` is set to false. + if (!_asBase) { + amount = _convertToShares(self, amount); + } + } } diff --git a/test/VaultTest.sol b/test/VaultTest.sol index dbd0e35..3462f80 100644 --- a/test/VaultTest.sol +++ b/test/VaultTest.sol @@ -232,6 +232,25 @@ abstract contract VaultTest is HyperdriveTest { vm.stopPrank(); } + /// @dev Create all the role-based and arbitrary users used in testing. + /// The `setUp` function from `HyperdriveTest` creates these for us, + /// but if its overridden then this must be called. + function createTestUsers() internal { + // Create role-based users. + (deployer, ) = createUser("deployer"); + (governance, ) = createUser("governance"); + (keeper, ) = createUser("keeper"); + (management, ) = createUser("management"); + (emergencyAdmin, ) = createUser("emergencyAdmin"); + + // Create arbitrary test users. + (alice, alicePK) = createUser("alice"); + (bob, bobPK) = createUser("bob"); + (celine, celinePK) = createUser("celine"); + (dan, danPK) = createUser("dan"); + (eve, evePK) = createUser("eve"); + } + // ╭───────────────────────────────────────────────────────────────────────╮ // │ Deposit Helpers │ // ╰───────────────────────────────────────────────────────────────────────╯ diff --git a/test/everlong/EverlongForkSDAITest.sol b/test/everlong/EverlongForkSDAITest.sol new file mode 100644 index 0000000..94c206d --- /dev/null +++ b/test/everlong/EverlongForkSDAITest.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +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 { IEverlongStrategy } from "../../contracts/interfaces/IEverlongStrategy.sol"; +import { EVERLONG_STRATEGY_KIND, EVERLONG_VERSION } from "../../contracts/libraries/Constants.sol"; +import { EverlongTest } from "./EverlongTest.sol"; + +/// @dev Configures the testing Everlong instance to point to the existing +/// SDAIHyperdrive instance on mainnet. +contract EverlongForkSDAITest is EverlongTest { + address internal SDAI_HYPERDRIVE_ADDRESS = + 0x324395D5d835F84a02A75Aa26814f6fD22F25698; + + address internal SDAI_ADDRESS = 0x83F20F44975D03b1b09e64809B757c47f942BEeA; + + address internal SDAI_WHALE = 0x27d3745135693647155d87706FBFf3EB5B7345c2; + + function setUp() public virtual override { + vm.createSelectFork(vm.rpcUrl("mainnet"), FORK_BLOCK_NUMBER); + + createTestUsers(); + + // Set up the strategy to use the current StETH hyperdrive instance. + hyperdrive = IHyperdrive(SDAI_HYPERDRIVE_ADDRESS); + AS_BASE = false; + IS_WRAPPED = false; + + setUpRoleManager(); + setUpEverlongStrategy(); + setUpEverlongVault(); + } + + /// @dev "Mint" tokens to an account by transferring from the whale. + /// @param _amount Amount of tokens to "mint". + /// @param _to Destination for the tokens. + function mintSDAI(uint256 _amount, address _to) internal { + vm.startPrank(SDAI_WHALE); + asset.transfer(_to, _amount); + vm.stopPrank(); + } + + /// @dev Deposit into the SDAI everlong vault. + /// @param _assets Amount of assets to deposit. + /// @param _from Source of the tokens. + /// @return shares Amount of shares received from the deposit. + function depositSDAI( + uint256 _assets, + address _from + ) internal returns (uint256 shares) { + mintSDAI(_assets, _from); + vm.startPrank(_from); + asset.approve(address(vault), _assets); + shares = vault.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. + /// @return assets Amount of assets received from the redemption. + function redeemSDAI( + uint256 _shares, + address _from + ) internal returns (uint256 assets) { + vm.startPrank(_from); + assets = vault.redeem(_shares, _from, _from); + vm.stopPrank(); + } +} diff --git a/test/everlong/EverlongForkStETHTest.sol b/test/everlong/EverlongForkStETHTest.sol new file mode 100644 index 0000000..a27867a --- /dev/null +++ b/test/everlong/EverlongForkStETHTest.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +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 { IEverlongStrategy } from "../../contracts/interfaces/IEverlongStrategy.sol"; +import { EVERLONG_STRATEGY_KIND, EVERLONG_VERSION } from "../../contracts/libraries/Constants.sol"; +import { EverlongTest } from "./EverlongTest.sol"; + +/// @dev Configures the testing Everlong instance to point to the existing +/// StETHHyperdrive instance on mainnet. +contract EverlongForkStETHTest is EverlongTest { + address internal STETH_HYPERDRIVE_ADDRESS = + 0xd7e470043241C10970953Bd8374ee6238e77D735; + + address internal STETH_ADDRESS = 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + + address internal WSTETH_ADDRESS = + 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; + + address internal STETH_WHALE = 0x51C2cEF9efa48e08557A361B52DB34061c025a1B; + + address internal WSTETH_WHALE = 0x5313b39bf226ced2332C81eB97BB28c6fD50d1a3; + + function setUp() public virtual override { + vm.createSelectFork(vm.rpcUrl("mainnet"), FORK_BLOCK_NUMBER); + + createTestUsers(); + + // Set up the strategy to use the current StETH hyperdrive instance. + hyperdrive = IHyperdrive(STETH_HYPERDRIVE_ADDRESS); + AS_BASE = false; + IS_WRAPPED = true; + WRAPPED_ASSET = WSTETH_ADDRESS; + + setUpRoleManager(); + setUpEverlongStrategy(); + setUpEverlongVault(); + } + + /// @dev "Mint" tokens to an account by transferring from the whale. + /// @param _amount Amount of tokens to "mint". + /// @param _to Destination for the tokens. + function mintWSTETH(uint256 _amount, address _to) internal { + vm.startPrank(WSTETH_WHALE); + asset.transfer(_to, _amount); + vm.stopPrank(); + } + + /// @dev Deposit into the WSTETH everlong vault. + /// @param _assets Amount of assets to deposit. + /// @param _from Source of the tokens. + /// @return shares Amount of shares received from the deposit. + function depositWSTETH( + uint256 _assets, + address _from + ) internal returns (uint256 shares) { + mintWSTETH(_assets, _from); + vm.startPrank(_from); + asset.approve(address(vault), _assets); + shares = vault.deposit(_assets, _from); + vm.stopPrank(); + } + + /// @dev Redeem shares from the WSTETH everlong vault. + /// @param _shares Amount of shares to redeem. + /// @param _from Source of the shares. + /// @return assets Amount of assets received from the redemption. + function redeemWSTETH( + uint256 _shares, + address _from + ) internal returns (uint256 assets) { + vm.startPrank(_from); + assets = vault.redeem(_shares, _from, _from); + vm.stopPrank(); + } +} diff --git a/test/everlong/EverlongTest.sol b/test/everlong/EverlongTest.sol index 8b3c030..b09fda9 100644 --- a/test/everlong/EverlongTest.sol +++ b/test/everlong/EverlongTest.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.20; import { console2 as console } from "forge-std/console2.sol"; -import { IERC20 } from "hyperdrive/contracts/src/interfaces/IHyperdrive.sol"; +import { IERC20, IHyperdrive } from "hyperdrive/contracts/src/interfaces/IHyperdrive.sol"; import { FixedPointMath } from "hyperdrive/contracts/src/libraries/FixedPointMath.sol"; import { ERC20Mintable } from "hyperdrive/contracts/test/ERC20Mintable.sol"; import { HyperdriveTest } from "hyperdrive/test/utils/HyperdriveTest.sol"; diff --git a/test/everlong/integration/SDAIVaultSharesToken.t.sol b/test/everlong/integration/SDAIVaultSharesToken.t.sol deleted file mode 100644 index 999e49b..0000000 --- a/test/everlong/integration/SDAIVaultSharesToken.t.sol +++ /dev/null @@ -1,191 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -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 { FixedPointMath } from "hyperdrive/contracts/src/libraries/FixedPointMath.sol"; -import { Lib } from "hyperdrive/test/utils/Lib.sol"; -import { IVault } from "yearn-vaults-v3/interfaces/IVault.sol"; -import { IPermissionedStrategy } from "../../../contracts/interfaces/IPermissionedStrategy.sol"; -import { MAX_BPS } from "../../../contracts/libraries/Constants.sol"; -import { HyperdriveExecutionLibrary } from "../../../contracts/libraries/HyperdriveExecution.sol"; -import { EverlongStrategy } from "../../../contracts/EverlongStrategy.sol"; -import { EverlongTest } from "../EverlongTest.sol"; - -/// @dev Test ensuring that Everlong works with sDAI Hyperdrive and -/// AS_BASE=false. -contract TestSDAIVaultSharesToken is EverlongTest { - using FixedPointMath for *; - using Lib for *; - using HyperdriveExecutionLibrary for *; - - /// @dev SDAI whale account used for easy token minting. - address WHALE = 0x0740c011A4160139Bd2E4EA091581d35ee3454da; - - /// @dev "Mint" tokens to an account by transferring from the whale. - /// @param _amount Amount of tokens to "mint". - /// @param _to Destination for the tokens. - function mintAsset(uint256 _amount, address _to) internal { - vm.startPrank(WHALE); - asset.transfer(_to, _amount); - vm.stopPrank(); - } - - /// @dev Deposit into the SDAI everlong vault. - /// @param _assets Amount of assets to deposit. - /// @param _from Source of the tokens. - /// @return shares Amount of shares received from the deposit. - function depositSDAI( - uint256 _assets, - address _from - ) internal returns (uint256 shares) { - mintAsset(_assets, _from); - vm.startPrank(_from); - asset.approve(address(vault), _assets); - shares = vault.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. - /// @return assets Amount of assets received from the redemption. - function redeemSDAI( - uint256 _shares, - address _from - ) internal returns (uint256 assets) { - vm.startPrank(_from); - assets = vault.redeem(_shares, _from, _from); - vm.stopPrank(); - } - - /// @dev Deploy a strategy pointing to the sDAI hyperdrive instance and - /// create a vault around it. - function setUp() public virtual override { - super.setUp(); - - // sDai Hyperdrive mainnet address. - hyperdrive = IHyperdrive(0x324395D5d835F84a02A75Aa26814f6fD22F25698); - - // Set the correct asset. - asset = IERC20(hyperdrive.vaultSharesToken()); - - vm.startPrank(deployer); - - // Deploy and configure the strategy. - strategy = IPermissionedStrategy( - address( - new EverlongStrategy( - address(asset), - "sDAI Strategy", - address(hyperdrive), - false, - false - ) - ) - ); - strategy.setPerformanceFeeRecipient(governance); - strategy.setKeeper(address(keeperContract)); - strategy.setPendingManagement(management); - strategy.setEmergencyAdmin(emergencyAdmin); - - // Issue the deployer a bunch of stETH... this makes it easy to dish - // out to other users later. - // uint256 deployerETH = 1_000e18; - // deal(deployer, deployerETH); - // ILido(address(asset)).submit{ value: deployerETH }(deployer); - - vm.stopPrank(); - - // As the `management` address: - // 1. Accept the `management` role for the strategy. - // 2. Set the `profitMaxUnlockTime` to zero. - vm.startPrank(management); - strategy.acceptManagement(); - strategy.setProfitMaxUnlockTime(STRATEGY_PROFIT_MAX_UNLOCK_TIME); - strategy.setPerformanceFee(0); - vm.stopPrank(); - - // As the `governance` address: - // 1. Deploy the Vault using the RoleManager. - // 2. Add the EverlongStrategy to the vault. - // 3. Update the max debt for the strategy to be the maximum uint256. - // 4. Configure the vault to `auto_allocate` which will automatically - // update the strategy's debt on deposit. - vm.startPrank(governance); - vault = IVault( - roleManager.newVault( - address(asset), - 0, - EVERLONG_NAME, - EVERLONG_SYMBOL - ) - ); - vault.add_strategy(address(strategy)); - vault.update_max_debt_for_strategy( - address(strategy), - type(uint256).max - ); - roleManager.setPositionHolder( - roleManager.KEEPER(), - address(keeperContract) - ); - vm.stopPrank(); - - // As the `management` address, configure the DebtAllocator to not - // wait to update a strategy's debt and set the minimum change before - // updating to just above hyperdrive's minimum transaction amount. - vm.startPrank(management); - // Set the vault's duration for unlocking profit. - vault.setProfitMaxUnlockTime(VAULT_PROFIT_MAX_UNLOCK_TIME); - // Enable deposits to the strategy from the vault. - strategy.setDepositor(address(vault), true); - // Give the `EverlongStrategyKeeper` role to the keeper address. - debtAllocator.setKeeper(address(keeperContract), true); - // Set minimum wait time for updating strategy debt. - debtAllocator.setMinimumWait(0); - // Set minimum change in debt for triggering an update. - debtAllocator.setMinimumChange( - address(vault), - MINIMUM_TRANSACTION_AMOUNT + 1 - ); - debtAllocator.setStrategyDebtRatio( - address(vault), - address(strategy), - MAX_BPS - TARGET_IDLE_LIQUIDITY_BASIS_POINTS, - MAX_BPS - MIN_IDLE_LIQUIDITY_BASIS_POINTS - ); - vm.stopPrank(); - } - - /// @dev Ensure the deposit functions work as expected. - function test_deposit() external { - // Alice and Bob deposit into the vault. - uint256 depositAmount = 100e18; - uint256 aliceShares = depositSDAI(depositAmount, alice); - uint256 bobShares = depositSDAI(depositAmount, bob); - - // Alice and Bob should have non-zero share amounts. - assertGt(aliceShares, 0); - assertGt(bobShares, 0); - } - - /// @dev Ensure the rebalance and redeem functions work as expected. - function test_redeem() external { - // Alice and Bob deposit into the vault. - uint256 depositAmount = 100e18; - uint256 aliceShares = depositSDAI(depositAmount, alice); - uint256 bobShares = depositSDAI(depositAmount, bob); - - // The vault allocates funds to the strategy. - rebalance(); - - // Alice and Bob redeem their shares from the vault. - uint256 aliceRedeemAssets = redeemSDAI(aliceShares, alice); - uint256 bobRedeemAssets = redeemSDAI(bobShares, bob); - - // Neither Alice nor Bob should have more assets than they began with. - assertLe(aliceRedeemAssets, depositAmount); - assertLe(bobRedeemAssets, depositAmount); - } -} diff --git a/test/everlong/units/Everlong.t.sol b/test/everlong/units/Everlong.t.sol index 8749c88..a4accc1 100644 --- a/test/everlong/units/Everlong.t.sol +++ b/test/everlong/units/Everlong.t.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.20; +import { IERC20, IHyperdrive } from "hyperdrive/contracts/src/interfaces/IHyperdrive.sol"; +import { ILido } from "hyperdrive/contracts/src/interfaces/ILido.sol"; import { IEverlongStrategy } from "../../../contracts/interfaces/IEverlongStrategy.sol"; import { EVERLONG_STRATEGY_KIND, EVERLONG_VERSION } from "../../../contracts/libraries/Constants.sol"; import { EverlongTest } from "../EverlongTest.sol"; @@ -25,6 +27,34 @@ contract TestEverlong is EverlongTest { ); } + function test_minimumTransactionAmount_asBase_true() external { + // Obtain the minimum transaction amount from the strategy. + uint256 minTxAmount = IEverlongStrategy(address(strategy)) + .minimumTransactionAmount(); + + // Open a long with that amount and `asBase` set to true (the default + // for testing). + (uint256 maturityTime, uint256 bondAmount) = openLong( + alice, + minTxAmount, + AS_BASE + ); + + // Ensure the maturityTime and bondAmount are valid. + assertGt(maturityTime, 0); + assertGt(bondAmount, 0); + } + + /// @dev Ensure that the `minimumTransactionAmountBuffer` view function is + /// implemented. + function test_minimumTransactionAmountBuffer() external view { + assertGt( + IEverlongStrategy(address(strategy)) + .minimumTransactionAmountBuffer(), + 0 + ); + } + /// @dev Ensure that the `version()` view function is implemented. function test_version() external view { assertEq( diff --git a/test/everlong/units/EverlongForkSDAITest.t.sol b/test/everlong/units/EverlongForkSDAITest.t.sol new file mode 100644 index 0000000..63261e4 --- /dev/null +++ b/test/everlong/units/EverlongForkSDAITest.t.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: Apache-2.0 +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 { IEverlongStrategy } from "../../../contracts/interfaces/IEverlongStrategy.sol"; +import { EVERLONG_STRATEGY_KIND, EVERLONG_VERSION } from "../../../contracts/libraries/Constants.sol"; +import { EverlongForkSDAITest } from "../EverlongForkSDAITest.sol"; + +/// @dev Tests Everlong functionality when using the existing SDAIHyperdrive +/// instance on a fork. +contract TestEverlongForkSDAI is EverlongForkSDAITest { + /// @dev Ensure the deposit functions work as expected. + function test_deposit() external { + // Alice and Bob deposit into the vault. + uint256 depositAmount = 100e18; + uint256 aliceShares = depositSDAI(depositAmount, alice); + uint256 bobShares = depositSDAI(depositAmount, bob); + + // Alice and Bob should have non-zero share amounts. + assertGt(aliceShares, 0); + assertGt(bobShares, 0); + } + + /// @dev Ensure the rebalance and redeem functions work as expected. + function test_redeem() external { + // Alice and Bob deposit into the vault. + uint256 depositAmount = 100e18; + uint256 aliceShares = depositSDAI(depositAmount, alice); + uint256 bobShares = depositSDAI(depositAmount, bob); + + // The vault allocates funds to the strategy. + rebalance(); + + // Alice and Bob redeem their shares from the vault. + uint256 aliceRedeemAssets = redeemSDAI(aliceShares, alice); + uint256 bobRedeemAssets = redeemSDAI(bobShares, bob); + + // Neither Alice nor Bob should have more assets than they began with. + assertLe(aliceRedeemAssets, depositAmount); + assertLe(bobRedeemAssets, depositAmount); + } + + /// @dev Ensure that the `minimumTransactionAmount` calculated by the + /// strategy is greater than or equal to hyperdrive's representation. + function test_minimumTransactionAmount_asBase_false_not_wrapped() external { + // Obtain the minimum transaction amount from the strategy. + uint256 minTxAmount = IEverlongStrategy(address(strategy)) + .minimumTransactionAmount(); + + // Manually mint alice plenty of hyperdrive vault shares tokens. + mintSDAI(minTxAmount * 100, alice); + vm.prank(alice); + IERC20(SDAI_ADDRESS).approve(address(hyperdrive), type(uint256).max); + + vm.startPrank(alice); + // Open a long in hyperdrive with the vault shares tokens. + (uint256 maturityTime, uint256 bondAmount) = hyperdrive.openLong( + minTxAmount, + 0, // min bond proceeds + 0, // min vault share price + IHyperdrive.Options({ + destination: alice, + asBase: false, + extraData: "" + }) + ); + vm.stopPrank(); + + // Ensure the maturityTime and bondAmount are valid. + assertGt(maturityTime, 0); + assertGt(bondAmount, 0); + } +} diff --git a/test/everlong/units/EverlongForkStETHTest.t.sol b/test/everlong/units/EverlongForkStETHTest.t.sol new file mode 100644 index 0000000..339f26d --- /dev/null +++ b/test/everlong/units/EverlongForkStETHTest.t.sol @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { IERC20, IHyperdrive } from "hyperdrive/contracts/src/interfaces/IHyperdrive.sol"; +import { ILido } from "hyperdrive/contracts/src/interfaces/ILido.sol"; +import { IEverlongStrategy } from "../../../contracts/interfaces/IEverlongStrategy.sol"; +import { EVERLONG_STRATEGY_KIND, EVERLONG_VERSION } from "../../../contracts/libraries/Constants.sol"; +import { EverlongForkStETHTest } from "../EverlongForkStETHTest.sol"; + +/// @dev Tests Everlong functionality when using the existing StETHHyperdrive +/// instance on a fork. +contract TestEverlongForkStETH is EverlongForkStETHTest { + /// @dev Ensure the deposit functions work as expected. + function test_deposit() external { + // Alice and Bob deposit into the vault. + uint256 depositAmount = 100e18; + uint256 aliceShares = depositWSTETH(depositAmount, alice); + uint256 bobShares = depositWSTETH(depositAmount, bob); + + // Alice and Bob should have non-zero share amounts. + assertGt(aliceShares, 0); + assertGt(bobShares, 0); + } + + /// @dev Ensure the rebalance and redeem functions work as expected. + function test_redeem() external { + // Alice and Bob deposit into the vault. + uint256 depositAmount = 100e18; + uint256 aliceShares = depositWSTETH(depositAmount, alice); + uint256 bobShares = depositWSTETH(depositAmount, bob); + + // The vault allocates funds to the strategy. + rebalance(); + + // Alice and Bob redeem their shares from the vault. + uint256 aliceRedeemAssets = redeemWSTETH(aliceShares, alice); + uint256 bobRedeemAssets = redeemWSTETH(bobShares, bob); + + // Neither Alice nor Bob should have more assets than they began with. + assertLe(aliceRedeemAssets, depositAmount); + assertLe(bobRedeemAssets, depositAmount); + } + + /// @dev Ensure that the `minimumTransactionAmount` calculated by the + /// strategy is greater than or equal to hyperdrive's representation. + function test_minimumTransactionAmount_asBase_false_wrapped() external { + // Obtain the minimum transaction amount from the strategy. + uint256 minTxAmount = IEverlongStrategy(address(strategy)) + .minimumTransactionAmount(); + + // Manually mint alice plenty of hyperdrive vault shares tokens. + vm.startPrank(STETH_WHALE); + ILido(STETH_ADDRESS).transferShares(alice, minTxAmount * 100); + ILido(STETH_ADDRESS).approve(address(hyperdrive), type(uint256).max); + + // Open a long in hyperdrive with the vault shares tokens. + (uint256 maturityTime, uint256 bondAmount) = hyperdrive.openLong( + minTxAmount, + 0, // min bond proceeds + 0, // min vault share price + IHyperdrive.Options({ + destination: alice, + asBase: false, + extraData: "" + }) + ); + + // Ensure the maturityTime and bondAmount are valid. + assertGt(maturityTime, 0); + assertGt(bondAmount, 0); + } +} From fb15c585f6265f2449ce1043d1875774e8f09db5 Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Tue, 31 Dec 2024 05:31:32 -0600 Subject: [PATCH 2/9] use strategy's minimum transaction amount instead of constant --- test/everlong/units/Tend.t.sol | 44 +++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/test/everlong/units/Tend.t.sol b/test/everlong/units/Tend.t.sol index 175bf77..af01928 100644 --- a/test/everlong/units/Tend.t.sol +++ b/test/everlong/units/Tend.t.sol @@ -55,8 +55,16 @@ contract TestTend is EverlongTest { uint256 shares; uint256 spent; for (uint256 i; i < maxPositionCount; i++) { - spent += MINIMUM_TRANSACTION_AMOUNT * 2; - shares += depositVault(MINIMUM_TRANSACTION_AMOUNT * 2, alice, true); + spent += + IEverlongStrategy(address(strategy)) + .minimumTransactionAmount() * + 2; + shares += depositVault( + IEverlongStrategy(address(strategy)) + .minimumTransactionAmount() * 2, + alice, + true + ); advanceTimeWithCheckpointsAndReporting(CHECKPOINT_DURATION); } @@ -114,10 +122,13 @@ contract TestTend is EverlongTest { // Mint some tokens to Everlong for opening longs. // Ensure Everlong's balance is gte Hyperdrive's minTransactionAmount. // Ensure `tendTrigger()` returns true. - depositStrategy(MINIMUM_TRANSACTION_AMOUNT + 1, alice); + depositStrategy( + IEverlongStrategy(address(strategy)).minimumTransactionAmount() + 1, + alice + ); assertGt( IERC20(strategy.asset()).balanceOf(address(strategy)), - MINIMUM_TRANSACTION_AMOUNT + IEverlongStrategy(address(strategy)).minimumTransactionAmount() ); (bool canTend, ) = strategy.tendTrigger(); assertTrue( @@ -130,7 +141,10 @@ contract TestTend is EverlongTest { /// position. function test_tendTrigger_with_matured_position() external { // Mint some tokens to Everlong for opening longs and rebalance. - mintApproveAsset(address(strategy), MINIMUM_TRANSACTION_AMOUNT + 1); + mintApproveAsset( + address(strategy), + IEverlongStrategy(address(strategy)).minimumTransactionAmount() + 1 + ); rebalance(); // Increase block.timestamp until position is mature. @@ -202,7 +216,10 @@ contract TestTend is EverlongTest { /// @dev Tests that `TendConfig.minOutput` is obeyed when opening longs. function test_minOutput_open_long() external { // Deposit into the strategy as alice. - depositStrategy(MINIMUM_TRANSACTION_AMOUNT + 1, alice); + depositStrategy( + IEverlongStrategy(address(strategy)).minimumTransactionAmount() + 1, + alice + ); // Start a prank as a keeper. vm.startPrank(keeper); @@ -229,7 +246,10 @@ contract TestTend is EverlongTest { /// @dev Tests that `TendConfig.minVaultSharePrice` is obeyed when opening longs. function test_minVaultSharePrice_open_long() external { // Deposit into the strategy as alice. - depositStrategy(MINIMUM_TRANSACTION_AMOUNT + 1, alice); + depositStrategy( + IEverlongStrategy(address(strategy)).minimumTransactionAmount() + 1, + alice + ); // Start a prank as a keeper. vm.startPrank(keeper); @@ -257,7 +277,10 @@ contract TestTend is EverlongTest { /// tend(). function test_positionClosureLimit_tend() external { // Deposit into the strategy as alice. - depositStrategy(MINIMUM_TRANSACTION_AMOUNT + 1, alice); + depositStrategy( + IEverlongStrategy(address(strategy)).minimumTransactionAmount() + 1, + alice + ); // Rebalance to have the strategy hold a single position. rebalance(); @@ -265,7 +288,10 @@ contract TestTend is EverlongTest { // Fast forward a checkpoint duration, deposit, and rebalance. // This will result in two total positions held by the strategy. advanceTimeWithCheckpoints(CHECKPOINT_DURATION); - depositStrategy(MINIMUM_TRANSACTION_AMOUNT + 1, alice); + depositStrategy( + IEverlongStrategy(address(strategy)).minimumTransactionAmount() + 1, + alice + ); rebalance(); assertEq(IEverlongStrategy(address(strategy)).positionCount(), 2); From 7df32d8c18e335b6c71ed5bf1e0695478c141e25 Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Tue, 7 Jan 2025 06:03:28 -0600 Subject: [PATCH 3/9] responding to feedback from @mrtoph Remove additional conversion for wrapped tokens since the wrapped token denominations are the same as hyperdrive's vault shares token. --- contracts/EverlongStrategy.sol | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/contracts/EverlongStrategy.sol b/contracts/EverlongStrategy.sol index 95e3a39..178de0f 100644 --- a/contracts/EverlongStrategy.sol +++ b/contracts/EverlongStrategy.sol @@ -795,17 +795,6 @@ contract EverlongStrategy is BaseStrategy { asBase ); - // If we're using a wrapped token, make sure to convert to the unwrapped - // value. - if (isWrapped) { - amount = convertToUnwrapped( - IHyperdrive(hyperdrive)._minimumTransactionAmount( - _poolConfig, - asBase - ) - ); - } - // NOTE: Since some rounding occurs within hyperdrive when using its // shares token, we choose to be safe and add a buffer to ensure // that the amount will be above hyperdrive's minimum. From e94c2850cc3d4444e66225d3719bab5bcdfed621 Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Tue, 31 Dec 2024 06:10:53 -0600 Subject: [PATCH 4/9] 5 - closePositions respects minimumTransactionAmount Currently, `closePositions(..)` does not check whether a partial closure will result in a position value less than the minimum transaction amount. This PR adds the necessary logic for this check as well as a test to ensure it is behaving as expected. [issue](https://cantina.xyz/code/4f25dfd5-d3e6-4e7a-9481-d7306b795f2b/findings/5) --- contracts/EverlongStrategy.sol | 56 +++++++++++++------ .../integration/PartialClosures.t.sol | 28 +++++++++- 2 files changed, 65 insertions(+), 19 deletions(-) diff --git a/contracts/EverlongStrategy.sol b/contracts/EverlongStrategy.sol index 178de0f..3e8d06f 100644 --- a/contracts/EverlongStrategy.sol +++ b/contracts/EverlongStrategy.sol @@ -502,27 +502,49 @@ contract EverlongStrategy is BaseStrategy { "" ); + // Calculate the amount of bonds needed to reach the minimum + // transaction amount. + uint256 minimumBonds = minimumTransactionAmount().mulDivUp( + position.bondAmount, + totalPositionValue + ); + + // 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 +563,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/integration/PartialClosures.t.sol b/test/everlong/integration/PartialClosures.t.sol index 40d92b0..7e9359c 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,29 @@ contract TestPartialClosures is EverlongTest { // Ensure Everlong has one position left. assertEq(IEverlongStrategy(address(strategy)).positionCount(), 1); } + + function test_partial_closures_position_remainder_gt_minTransactionAmount() + 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 = ( + IEverlongStrategy(address(strategy)).minimumTransactionAmount() + ).mulDivDown(aliceShares, aliceDepositAmount); + + // Redeem shares such that the remaining share value should be less + // than the minimum transaction amount. + redeemStrategy(aliceShares - minTxShareAmount, alice, true); + redeemStrategy(minTxShareAmount, alice, true); + + // There should be no positions left. + assertEq(IEverlongStrategy(address(strategy)).positionCount(), 0); + } } From 756a06ccea6db38fe69be31560823e8763e68a88 Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Tue, 31 Dec 2024 06:13:10 -0600 Subject: [PATCH 5/9] commenting --- test/everlong/integration/PartialClosures.t.sol | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/everlong/integration/PartialClosures.t.sol b/test/everlong/integration/PartialClosures.t.sol index 7e9359c..e6bcf17 100644 --- a/test/everlong/integration/PartialClosures.t.sol +++ b/test/everlong/integration/PartialClosures.t.sol @@ -116,6 +116,9 @@ contract TestPartialClosures is EverlongTest { 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_remainder_gt_minTransactionAmount() external { From 8464f8c8a9e19e8c6402e22f80871024b172f3bd Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Tue, 31 Dec 2024 10:01:33 -0600 Subject: [PATCH 6/9] add test with asBase=false --- test/everlong/EverlongForkSDAITest.sol | 36 +++++++++++++++++++ .../integration/PartialClosures.t.sol | 5 +-- .../everlong/units/EverlongForkSDAITest.t.sol | 30 ++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) 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 e6bcf17..0a4590a 100644 --- a/test/everlong/integration/PartialClosures.t.sol +++ b/test/everlong/integration/PartialClosures.t.sol @@ -119,9 +119,7 @@ contract TestPartialClosures is EverlongTest { /// @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_remainder_gt_minTransactionAmount() - external - { + function test_partial_closures_position_min_transaction_amount() external { // Alice deposits into Everlong. uint256 aliceDepositAmount = 1000e18; uint256 aliceShares = depositStrategy(aliceDepositAmount, alice, true); @@ -138,7 +136,6 @@ contract TestPartialClosures is EverlongTest { // Redeem shares such that the remaining share value should be less // than the minimum transaction amount. redeemStrategy(aliceShares - minTxShareAmount, alice, true); - redeemStrategy(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..ac6c924 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,31 @@ 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 = IEverlongStrategy(address(strategy)) + .minimumTransactionAmount() + .mulDivDown(aliceShares, aliceDepositAmount); + + // 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); + } } From 36fa8ca7f7aa09f7d37fa8579d9e0f875efdd187 Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Tue, 7 Jan 2025 06:45:15 -0600 Subject: [PATCH 7/9] addressing feedback from @mrtoph No conversions are needed for min tx amount when closing longs since it's denominated in bonds not execution tokens --- contracts/EverlongStrategy.sol | 9 +++------ test/everlong/integration/PartialClosures.t.sol | 7 ++++--- test/everlong/units/EverlongForkSDAITest.t.sol | 7 ++++--- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/contracts/EverlongStrategy.sol b/contracts/EverlongStrategy.sol index 3e8d06f..472a4e5 100644 --- a/contracts/EverlongStrategy.sol +++ b/contracts/EverlongStrategy.sol @@ -502,12 +502,9 @@ contract EverlongStrategy is BaseStrategy { "" ); - // Calculate the amount of bonds needed to reach the minimum - // transaction amount. - uint256 minimumBonds = minimumTransactionAmount().mulDivUp( - position.bondAmount, - totalPositionValue - ); + // 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; diff --git a/test/everlong/integration/PartialClosures.t.sol b/test/everlong/integration/PartialClosures.t.sol index 0a4590a..f594634 100644 --- a/test/everlong/integration/PartialClosures.t.sol +++ b/test/everlong/integration/PartialClosures.t.sol @@ -129,9 +129,10 @@ contract TestPartialClosures is EverlongTest { // Calculate how many shares are neeed to reach the minimum transaction // amount. - uint256 minTxShareAmount = ( - IEverlongStrategy(address(strategy)).minimumTransactionAmount() - ).mulDivDown(aliceShares, aliceDepositAmount); + 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. diff --git a/test/everlong/units/EverlongForkSDAITest.t.sol b/test/everlong/units/EverlongForkSDAITest.t.sol index ac6c924..c3bba1d 100644 --- a/test/everlong/units/EverlongForkSDAITest.t.sol +++ b/test/everlong/units/EverlongForkSDAITest.t.sol @@ -90,9 +90,10 @@ contract TestEverlongForkSDAI is EverlongForkSDAITest { // Calculate how many shares are neeed to reach the minimum transaction // amount. - uint256 minTxShareAmount = IEverlongStrategy(address(strategy)) - .minimumTransactionAmount() - .mulDivDown(aliceShares, aliceDepositAmount); + 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. From c8cf3ca8e4a7fe8bfb861c7ee54c4f4fa681c0c9 Mon Sep 17 00:00:00 2001 From: Xiangyu Xu Date: Tue, 18 Mar 2025 12:57:29 -0700 Subject: [PATCH 8/9] pin foundry version to a stable version --- .github/workflows/coverage.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e7a55ad..f4f3ccf 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: 0.3.0 cache: false - name: forge version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d4d19f7..29579cd 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: 0.3.0 - name: Run Forge build run: | From e16fd88aed318f43297f2e08d77f4567916780ce Mon Sep 17 00:00:00 2001 From: Xiangyu Xu Date: Tue, 18 Mar 2025 13:10:11 -0700 Subject: [PATCH 9/9] pin foundry version to a stable version --- .github/workflows/coverage.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index f4f3ccf..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: 0.3.0 + version: v0.3.0 cache: false - name: forge version diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29579cd..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: 0.3.0 + version: v0.3.0 - name: Run Forge build run: |