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/3] 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/3] 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/3] 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.