From d6e6aeb557e788a4333507050a9b1a9622b8050a Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Thu, 12 Dec 2024 17:42:16 -0600 Subject: [PATCH 01/10] add support for wrapped hyperdrive tokens This enables support for hyperdrive instances with rebasing tokens since yearn is unable to support rebasing tokens --- contracts/EverlongStrategy.sol | 355 +++++++++++++++--- contracts/EverlongStrategyKeeper.sol | 6 + contracts/interfaces/IERC20Wrappable.sol | 28 ++ contracts/interfaces/IEverlongStrategy.sol | 28 ++ script/DeployEverlongStrategy.s.sol | 26 +- test/VaultTest.sol | 2 +- test/everlong/EverlongTest.sol | 32 +- .../integration/SDAIVaultSharesToken.t.sol | 1 + test/everlong/units/Wrapping.t.sol | 94 +++++ 9 files changed, 513 insertions(+), 59 deletions(-) create mode 100644 contracts/interfaces/IERC20Wrappable.sol create mode 100644 test/everlong/units/Wrapping.t.sol diff --git a/contracts/EverlongStrategy.sol b/contracts/EverlongStrategy.sol index db53268..3764abb 100644 --- a/contracts/EverlongStrategy.sol +++ b/contracts/EverlongStrategy.sol @@ -7,6 +7,7 @@ import { SafeCast } from "hyperdrive/contracts/src/libraries/SafeCast.sol"; 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 { EverlongPortfolioLibrary } from "./libraries/EverlongPortfolio.sol"; import { HyperdriveExecutionLibrary } from "./libraries/HyperdriveExecution.sol"; @@ -72,7 +73,7 @@ contract EverlongStrategy is BaseStrategy { using FixedPointMath for uint256; using HyperdriveExecutionLibrary for IHyperdrive; using EverlongPortfolioLibrary for EverlongPortfolioLibrary.State; - using SafeCast for *; + using SafeCast for uint256; using SafeERC20 for ERC20; // ╭───────────────────────────────────────────────────────────────────────╮ @@ -121,9 +122,19 @@ contract EverlongStrategy is BaseStrategy { /// If false, use the Hyperdrive's `vaultSharesToken`. bool public immutable asBase; + /// @notice Whether the strategy asset is a wrapped hyperdrive token. + /// @dev Wrapping is a workaround to allow using hyperdrive instances with + /// rebasing tokens when Yearn explicitly does not support them. + bool public immutable isWrapped; + /// @dev The Hyperdrive's PoolConfig. IHyperdrive.PoolConfig internal _poolConfig; + /// @notice Address of the hyperdrive token. + /// @dev Determined by `asBase`. If true, then hyperdrive's base token is + /// used. If false, then hyperdrive's vault shares token is used. + address public immutable hyperdriveToken; + // ╭───────────────────────────────────────────────────────────────────────╮ // │ State │ // ╰───────────────────────────────────────────────────────────────────────╯ @@ -147,16 +158,33 @@ contract EverlongStrategy is BaseStrategy { /// @param _asset Asset to use for the strategy. /// @param __name Name for the strategy. /// @param _hyperdrive Address of the Hyperdrive instance. - /// @param _asBase Whether `_asset` is Hyperdrive's base asset. + /// @param _asBase Whether to use the base token when interacting with + /// hyperdrive. If false, use the vault shares token. + /// @param _isWrapped True if `asset` is a wrapped hyperdrive token. constructor( address _asset, string memory __name, address _hyperdrive, - bool _asBase + bool _asBase, + bool _isWrapped ) BaseStrategy(_asset, __name) { + // Store the hyperdrive instance's address. hyperdrive = _hyperdrive; + + // Store whether to interact with hyperdrive using its base token. asBase = _asBase; + + // Store the hyperdrive's PoolConfig since it's static. _poolConfig = IHyperdrive(_hyperdrive).getPoolConfig(); + + // Store the hyperdrive token to use when opening/closing longs. + hyperdriveToken = address( + _asBase ? _poolConfig.baseToken : _poolConfig.vaultSharesToken + ); + + // Store whether `asset` should be treated as a wrapped hyperdrive + // token. + isWrapped = _isWrapped; } // ╭───────────────────────────────────────────────────────────────────────╮ @@ -289,23 +317,29 @@ contract EverlongStrategy is BaseStrategy { } // Close matured positions. - _totalIdle += _closeMaturedPositions(tendConfig.positionClosureLimit); + _totalIdle += _closeMaturedPositions( + tendConfig.positionClosureLimit, + tendConfig.extraData + ); // Limit the amount that can be spent by the deposit limit. uint256 toSpend = _totalIdle.min(availableDepositLimit(address(this))); + // Calculate the minimum transaction amount in strategy assets for the + // hyperdrive instance. + uint256 minTransactionAmount = _poolConfig.minimumTransactionAmount; + if (isWrapped) { + minTransactionAmount = convertToWrapped(minTransactionAmount); + } + // If Everlong has sufficient idle, open a new position. if (toSpend > _poolConfig.minimumTransactionAmount) { - // Approve leaving an extra wei so the slot stays warm. - ERC20(asset).forceApprove(address(hyperdrive), toSpend + 1); - (uint256 maturityTime, uint256 bondAmount) = IHyperdrive(hyperdrive) - .openLong( - asBase, - toSpend, - tendConfig.minOutput, - tendConfig.minVaultSharePrice, - tendConfig.extraData - ); + (uint256 maturityTime, uint256 bondAmount) = _openLong( + toSpend, + tendConfig.minOutput, + tendConfig.minVaultSharePrice, + tendConfig.extraData + ); // Account for the new position in the portfolio. _portfolio.handleOpenPosition(maturityTime, bondAmount); @@ -399,7 +433,8 @@ contract EverlongStrategy is BaseStrategy { /// A value of zero indicates no limit. /// @return output Proceeds of closing the matured positions. function _closeMaturedPositions( - uint256 _limit + uint256 _limit, + bytes memory _extraData ) internal returns (uint256 output) { // A value of zero for `_limit` indicates no limit. if (_limit == 0) { @@ -424,12 +459,10 @@ contract EverlongStrategy is BaseStrategy { // Close the position add the amount of assets received to the // cumulative output. - output += IHyperdrive(hyperdrive).closeLong( - asBase, - position, - 0, - "" - ); + // + // There's no need to set the slippage guard when closing matured + // positions. + output += _closeLong(position, 0, _extraData); // Update portfolio accounting to reflect the closed position. _portfolio.handleClosePosition(); @@ -459,12 +492,7 @@ contract EverlongStrategy is BaseStrategy { // Calculate the value of the entire position, and use it to derive // the expected output for partial closures. - totalPositionValue = IHyperdrive(hyperdrive).previewCloseLong( - asBase, - _poolConfig, - position, - "" - ); + totalPositionValue = _previewCloseLong(position, ""); // Close only part of the position if there are sufficient bonds // to reach the target output without leaving a small amount left. @@ -483,10 +511,14 @@ contract EverlongStrategy is BaseStrategy { totalPositionValue ); - // Close part of the position and enforce the slippage guard. + // Close part of the position. + // + // Since this functino would never be called as part of a + // `tend()`, there's no need to retrieve the `TendConfig` and + // set the slippage guard. + // // Add the amount of assets received to the total output. - output += IHyperdrive(hyperdrive).closeLong( - asBase, + output += _closeLong( IEverlongStrategy.EverlongPosition({ maturityTime: position.maturityTime, bondAmount: bondsNeeded.toUint128() @@ -503,15 +535,16 @@ contract EverlongStrategy is BaseStrategy { } // Close the entire position. else { - // Close the entire position and increase the cumulative output. - output += IHyperdrive(hyperdrive).closeLong( - asBase, - position, - 0, - "" - ); + // Close the entire position. + // + // Since this functino would never be called as part of a + // `tend()`, there's no need to retrieve the `TendConfig` and + // set the slippage guard. + // + // Add the amount of assets received to the total output. + output += _closeLong(position, 0, ""); - // Update portfolio accounting to include the partial closure. + // Update portfolio accounting to include the closed position. _portfolio.handleClosePosition(); } } @@ -520,6 +553,213 @@ contract EverlongStrategy is BaseStrategy { return output; } + // ╭───────────────────────────────────────────────────────────────────────╮ + // │ Wrapped Token Helpers │ + // ╰───────────────────────────────────────────────────────────────────────╯ + + /// @dev Wrap the hyperdrive token so that it can be used in the strategy. + /// @param _unwrappedAmount Amount of unwrapped hyperdrive tokens to wrap. + /// @return _wrappedAmount Amount of wrapped tokens received. + function _wrap( + uint256 _unwrappedAmount + ) internal returns (uint256 _wrappedAmount) { + // If the strategy doesn't use a wrapped asset, revert. + if (!isWrapped) { + revert IEverlongStrategy.AssetNotWrapped(); + } + + // Approve the wrapped asset contract for the hyperdrive tokens. + // Add one to the approval amount to leave the slot dirty and save + // gas on future approvals. + ERC20(hyperdriveToken).approve(address(asset), _unwrappedAmount); + + // Wrap the hyperdrive tokens. + _wrappedAmount = IERC20Wrappable(address(asset)).wrap(_unwrappedAmount); + } + + /// @dev Unwrap the strategy asset so that it can be used with hyperdrive. + /// @param _wrappedAmount Amount of wrapped strategy assets to unwrap. + /// @return _unwrappedAmount Amount of unwrapped tokens received. + function _unwrap( + uint256 _wrappedAmount + ) internal returns (uint256 _unwrappedAmount) { + // If the strategy doesn't use a wrapped asset, revert. + if (!isWrapped) { + revert IEverlongStrategy.AssetNotWrapped(); + } + + // Unwrap the strategy assets. + _unwrappedAmount = IERC20Wrappable(address(asset)).unwrap( + _wrappedAmount + ); + } + + /// @dev Calculate the maximum amount of strategy assets that can be spent + /// on longs before hyperdrive runs out of liquidity. + /// @return assets Max amount of strategy assets that can be spent on longs. + function _calculateMaxLong() internal view returns (uint256 assets) { + // Retrieve the max long amount denominated in hyperdrive base tokens. + assets = IHyperdrive(hyperdrive).calculateMaxLong(); + + // If `asBase` is false, convert the amount to be denominated in vault + // share tokens. + if (!asBase) { + assets = IHyperdrive(hyperdrive)._convertToShares(assets); + } + + // If using a wrapped token, convert the amount to be denominated in + // the wrapped token. + if (isWrapped) { + assets = convertToWrapped(assets); + } + } + + /// @dev Open a long with the specified amount of assets. Return the amount + /// of bonds received and their maturityTime. + /// @param _toSpend Amount of strategy assets to spend. + /// @param _minOutput Minimum amount of bonds to accept. + /// @param _minVaultSharePrice Minimum hyperdrive vault share price to + /// purchase at. + /// @param _extraData Extra data to pass to hyperdrive. + /// @return maturityTime Maturity time for bonds received. + /// @return bondAmount Amount of bonds received. + function _openLong( + uint256 _toSpend, + uint256 _minOutput, + uint256 _minVaultSharePrice, + bytes memory _extraData + ) internal returns (uint256 maturityTime, uint256 bondAmount) { + // Prepare for opening the long differently if the strategy asset is + // a wrapped hyperdrive token. + if (isWrapped) { + // The strategy asset is a wrapped hyperdrive token so it must be + // unwrapped. + _toSpend = _unwrap(_toSpend); + + // Convert amounts so that they are denominated in the unwrapped + // token. + _minOutput = convertToUnwrapped(_minOutput); + _minVaultSharePrice = convertToUnwrapped(_minVaultSharePrice); + + // NOTE: Converting between wrappi + // Approve hyperdrive for the unwrapped asset, which is also the + // `hyperdriveToken`. + // + // Leave the approval slot dirty to save gas on future approvals. + ERC20(hyperdriveToken).forceApprove( + address(hyperdrive), + _toSpend + 1 + ); + } + // The strategy asset is not wrapped, no conversions are necessary. + // Approve the hyperdrive contract for strategy asset. + // + // Leave the approval slot dirty to save gas on future approvals. + else { + ERC20(asset).forceApprove(address(hyperdrive), _toSpend + 1); + } + + // Open the long. Return the maturity time and amount of bonds received. + (maturityTime, bondAmount) = IHyperdrive(hyperdrive).openLong( + asBase, + _toSpend, + _minOutput, + _minVaultSharePrice, + _extraData + ); + } + + /// @dev Preview the amount of bonds received from opening a position with + /// the specified amount of strategy assets. + /// @param _toSpend Amount of strategy assets to spend. + /// @param _extraData Extra data to pass to hyperdrive. + /// @return bondAmount Amount of bonds that would be received. + function _previewOpenLong( + uint256 _toSpend, + bytes memory _extraData + ) internal view returns (uint256 bondAmount) { + // Prepare for opening the long differently if the strategy asset is + // a wrapped hyperdrive token. + if (isWrapped) { + // Convert amounts so that they are denominated in the unwrapped + // token. + _toSpend = convertToUnwrapped(_toSpend); + } + + // Call the preview function and return the expected amount of bonds + // to be received. + bondAmount = IHyperdrive(hyperdrive).previewOpenLong( + asBase, + _poolConfig, + _toSpend, + _extraData + ); + } + + /// @dev Preview the amount of assets received from closing the specified + /// position. + /// @param _position Position to close. + /// @param _minOutput Minimum amount of proceeds to accept. + /// @param _extraData Extra data to pass to hyperdrive. + /// @return proceeds Amount of strategy assets that would be received. + function _closeLong( + IEverlongStrategy.EverlongPosition memory _position, + uint256 _minOutput, + bytes memory _extraData + ) internal returns (uint256 proceeds) { + // Prepare for closing the long differently if the strategy asset is + // a wrapped hyperdrive token. + if (isWrapped) { + // Convert amounts so that they are denominated in the unwrapped + // token. + _minOutput = convertToUnwrapped(_minOutput); + } + + // Close the long. + proceeds = IHyperdrive(hyperdrive).closeLong( + asBase, + _position, + _minOutput, + _extraData + ); + + // The proceeds must be wrapped if the strategy asset is a wrapped + // hyperdrive token. + if (isWrapped) { + // Approve the wrapped contract for the proceeds. Add one to the + // approval amount to save gas on future approvals by leaving the + // slot dirty. + ERC20(hyperdriveToken).forceApprove(address(asset), proceeds + 1); + + // Wrap the proceeds. + proceeds = _wrap(proceeds); + } + } + + /// @dev Preview the amount of assets received from closing the specified + /// position. + /// @param _position Position to close. + /// @param _extraData Extra data to pass to hyperdrive. + /// @return proceeds Amount of strategy assets that would be received. + function _previewCloseLong( + IEverlongStrategy.EverlongPosition memory _position, + bytes memory _extraData + ) internal view returns (uint256 proceeds) { + // Call the preview function. + proceeds = IHyperdrive(hyperdrive).previewCloseLong( + asBase, + _poolConfig, + _position, + _extraData + ); + + // If the strategy asset is a wrapped hyperdrive token, convert + // amounts so that they are denominated in the unwrapped token. + if (isWrapped) { + proceeds = convertToWrapped(proceeds); + } + } + // ╭───────────────────────────────────────────────────────────────────────╮ // │ Views │ // ╰───────────────────────────────────────────────────────────────────────╯ @@ -535,7 +775,7 @@ contract EverlongStrategy is BaseStrategy { // Only pre-approved addresses are able to deposit. if (_depositors[_depositor] || _depositor == address(this)) { // Limit deposits to the maximum long that can be opened in hyperdrive. - return IHyperdrive(hyperdrive).calculateMaxLong(); + return _calculateMaxLong(); } // Address has not been pre-approved, return 0. return 0; @@ -551,19 +791,18 @@ contract EverlongStrategy is BaseStrategy { /// bonds and the weighted average maturity of all positions. /// @return value The present portfolio value. function calculatePortfolioValue() public view returns (uint256 value) { + (, IEverlongStrategy.TendConfig memory tendConfig) = getTendConfig(); if (_portfolio.totalBonds != 0) { // NOTE: The maturity time is rounded to the next checkpoint to // underestimate the portfolio value. - value += IHyperdrive(hyperdrive).previewCloseLong( - asBase, - _poolConfig, + value += _previewCloseLong( IEverlongStrategy.EverlongPosition({ maturityTime: IHyperdrive(hyperdrive) .getCheckpointIdUp(_portfolio.avgMaturityTime) .toUint128(), bondAmount: _portfolio.totalBonds }), - "" + tendConfig.extraData ); } } @@ -572,9 +811,33 @@ contract EverlongStrategy is BaseStrategy { /// a new position. /// @return True if a new position can be opened, false otherwise. function canOpenPosition() public view returns (bool) { - return - asset.balanceOf(address(this)) > - _poolConfig.minimumTransactionAmount; + uint256 currentBalance = asset.balanceOf(address(this)); + if (isWrapped) { + currentBalance = convertToUnwrapped(currentBalance); + } + return currentBalance > _poolConfig.minimumTransactionAmount; + } + + function convertToUnwrapped( + uint256 _wrappedAmount + ) public view returns (uint256 _unwrappedAmount) { + if (!isWrapped) { + revert IEverlongStrategy.AssetNotWrapped(); + } + _unwrappedAmount = IHyperdrive(hyperdrive)._convertToBase( + _wrappedAmount + ); + } + + function convertToWrapped( + uint256 _unwrappedAmount + ) public view returns (uint256 _wrappedAmount) { + if (!isWrapped) { + revert IEverlongStrategy.AssetNotWrapped(); + } + _wrappedAmount = IHyperdrive(hyperdrive)._convertToShares( + _unwrappedAmount + ); } /// @notice Returns whether the portfolio has matured positions. diff --git a/contracts/EverlongStrategyKeeper.sol b/contracts/EverlongStrategyKeeper.sol index ebe7a59..85d0dfa 100644 --- a/contracts/EverlongStrategyKeeper.sol +++ b/contracts/EverlongStrategyKeeper.sol @@ -338,6 +338,12 @@ contract EverlongStrategyKeeper is Ownable { return 0; } + // If the strategy is a wrapped token, convert the spend amount to + // be denominated in hyperdrive's token. + if (IEverlongStrategy(_strategy).isWrapped()) { + canSpend = IEverlongStrategy(_strategy).convertToWrapped(canSpend); + } + // Calculate the amount of bonds that would be received if a long was // opened with `canSpend` assets. uint256 expectedOutput = hyperdrive.previewOpenLong( diff --git a/contracts/interfaces/IERC20Wrappable.sol b/contracts/interfaces/IERC20Wrappable.sol new file mode 100644 index 0000000..a225509 --- /dev/null +++ b/contracts/interfaces/IERC20Wrappable.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { IERC20 } from "openzeppelin/interfaces/IERC20.sol"; + +/// @author DELV +/// @title IERC20Wrappable +/// @notice Interface for an ERC20 token that can be wrapped/unwrapped. +/// @dev Since Yearn explicitly does not support rebasing tokens as +/// vault/strategy assets, wrapping is mandatory. +/// @custom:disclaimer The language used in this code is for coding convenience +/// only, and is not intended to, and does not, have any +/// particular legal or regulatory significance. +interface IERC20Wrappable is IERC20 { + /// @notice Wrap the input amount of assets. + /// @param _unwrappedAmount Amount of assets to wrap. + /// @return _wrappedAmount Amount of wrapped assets that are returned. + function wrap( + uint256 _unwrappedAmount + ) external returns (uint256 _wrappedAmount); + + /// @notice Unwrap the input amount of assets. + /// @param _wrappedAmount Amount of assets to unwrap. + /// @return _unwrappedAmount Amount of unwrapped assets that are returned. + function unwrap( + uint256 _wrappedAmount + ) external returns (uint256 _unwrappedAmount); +} diff --git a/contracts/interfaces/IEverlongStrategy.sol b/contracts/interfaces/IEverlongStrategy.sol index 285751a..9898cef 100644 --- a/contracts/interfaces/IEverlongStrategy.sol +++ b/contracts/interfaces/IEverlongStrategy.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.20; +import { IERC20 } from "openzeppelin/interfaces/IERC20.sol"; import { IEverlongEvents } from "./IEverlongEvents.sol"; import { IPermissionedStrategy } from "./IPermissionedStrategy.sol"; @@ -31,6 +32,14 @@ interface IEverlongStrategy is IPermissionedStrategy, IEverlongEvents { bytes extraData; } + // ╭───────────────────────────────────────────────────────────────────────╮ + // │ Errors │ + // ╰───────────────────────────────────────────────────────────────────────╯ + + /// @notice Thrown when calling wrap conversion functions on a strategy with + /// a non-wrapped asset. + error AssetNotWrapped(); + // ╭───────────────────────────────────────────────────────────────────────╮ // │ SETTERS │ // ╰───────────────────────────────────────────────────────────────────────╯ @@ -63,6 +72,22 @@ interface IEverlongStrategy is IPermissionedStrategy, IEverlongEvents { /// @return True if a new position can be opened, false otherwise. function canOpenPosition() external view returns (bool); + /// @notice Convert the amount of unwrapped tokens to the amount received + /// after wrapping. + /// @param _unwrappedAmount Amount of unwrapped tokens. + /// @return _wrappedAmount Amount of wrapped tokens. + function convertToWrapped( + uint256 _unwrappedAmount + ) external view returns (uint256 _wrappedAmount); + + /// @notice Convert the amount of wrapped tokens to the amount received + /// after unwrapping. + /// @param _wrappedAmount Amount of wrapped tokens. + /// @return _unwrappedAmount Amount of unwrapped tokens. + function convertToUnwrapped( + uint256 _wrappedAmount + ) external view returns (uint256 _unwrappedAmount); + /// @notice Reads and returns the current tend configuration from transient /// storage. /// @return tendEnabled Whether TendConfig has been set. @@ -78,6 +103,9 @@ interface IEverlongStrategy is IPermissionedStrategy, IEverlongEvents { /// @notice Gets the address of the underlying Hyperdrive Instance function hyperdrive() external view returns (address); + /// @notice Returns whether the strategy's asset is a wrapped hyperdrive token. + function isWrapped() external view returns (bool); + /// @notice Gets the Everlong instance's kind. /// @return The Everlong instance's kind. function kind() external pure returns (string memory); diff --git a/script/DeployEverlongStrategy.s.sol b/script/DeployEverlongStrategy.s.sol index 5938d4e..528efc6 100644 --- a/script/DeployEverlongStrategy.s.sol +++ b/script/DeployEverlongStrategy.s.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity 0.8.24; +import { IERC20 } from "openzeppelin/interfaces/IERC20.sol"; import { IHyperdrive } from "hyperdrive/contracts/src/interfaces/IHyperdrive.sol"; import { IEverlongStrategy } from "../contracts/interfaces/IEverlongStrategy.sol"; import { EVERLONG_STRATEGY_KIND, EVERLONG_STRATEGY_KEEPER_KIND } from "../contracts/libraries/Constants.sol"; @@ -47,6 +48,12 @@ contract DeployEverlongStrategy is BaseDeployScript { string internal KEEPER_CONTRACT_NAME; string internal KEEPER_CONTRACT_NAME_DEFAULT; + bool internal IS_WRAPPED; + bool internal constant IS_WRAPPED_DEFAULT = false; + + address internal ASSET; + address internal ASSET_DEFAULT; + // ╭───────────────────────────────────────────────────────────────────────╮ // │ Artifact Struct. │ // ╰───────────────────────────────────────────────────────────────────────╯ @@ -95,6 +102,13 @@ contract DeployEverlongStrategy is BaseDeployScript { KEEPER_CONTRACT_NAME_DEFAULT ); output.keeperContractName = KEEPER_CONTRACT_NAME; + IS_WRAPPED = vm.envOr("IS_WRAPPED", IS_WRAPPED_DEFAULT); + ASSET_DEFAULT = address( + AS_BASE + ? IHyperdrive(output.hyperdrive).baseToken() + : IHyperdrive(output.hyperdrive).vaultSharesToken() + ); + ASSET = vm.envOr("ASSET", ASSET_DEFAULT); // Validate optional arguments. require( @@ -106,9 +120,7 @@ contract DeployEverlongStrategy is BaseDeployScript { ).keeperContract; // Resolve the asset address. - address asset = AS_BASE - ? IHyperdrive(output.hyperdrive).baseToken() - : IHyperdrive(output.hyperdrive).vaultSharesToken(); + address asset = ASSET; // Save the strategy's kind to output. output.kind = EVERLONG_STRATEGY_KIND; @@ -121,7 +133,13 @@ contract DeployEverlongStrategy is BaseDeployScript { // 5. Set the strategy's emergencyAdmin to `emergencyAdmin`. vm.startBroadcast(DEPLOYER_PRIVATE_KEY); output.strategy = address( - new EverlongStrategy(asset, output.name, output.hyperdrive, AS_BASE) + new EverlongStrategy( + asset, + output.name, + output.hyperdrive, + AS_BASE, + IS_WRAPPED + ) ); IEverlongStrategy(output.strategy).setPerformanceFeeRecipient( output.governance diff --git a/test/VaultTest.sol b/test/VaultTest.sol index dbd0e35..10571eb 100644 --- a/test/VaultTest.sol +++ b/test/VaultTest.sol @@ -166,8 +166,8 @@ abstract contract VaultTest is HyperdriveTest { /// @dev Set up the testing environment on a fork of mainnet. function setUp() public virtual override { - vm.createSelectFork(vm.rpcUrl("mainnet"), FORK_BLOCK_NUMBER); super.setUp(); + vm.createSelectFork(vm.rpcUrl("mainnet"), FORK_BLOCK_NUMBER); setUpHyperdrive(); setUpRoleManager(); } diff --git a/test/everlong/EverlongTest.sol b/test/everlong/EverlongTest.sol index bf24cde..fc418f3 100644 --- a/test/everlong/EverlongTest.sol +++ b/test/everlong/EverlongTest.sol @@ -43,6 +43,12 @@ contract EverlongTest is VaultTest, IEverlongEvents { /// @dev Maximum slippage for vault share price. uint256 internal MIN_VAULT_SHARE_PRICE_SLIPPAGE = 500; + /// @dev Whether to use a wrapped asset for the strategy. + bool IS_WRAPPED; + + /// @dev Asset to use for the strategy when IS_WRAPPED=true. + address WRAPPED_ASSET; + /// @dev Periphery contract to simplify maintenance operations for vaults /// and strategies. EverlongStrategyKeeper internal keeperContract; @@ -76,12 +82,17 @@ contract EverlongTest is VaultTest, IEverlongEvents { strategy = IPermissionedStrategy( address( new EverlongStrategy( - AS_BASE - ? hyperdrive.baseToken() - : hyperdrive.vaultSharesToken(), + IS_WRAPPED + ? WRAPPED_ASSET + : ( + AS_BASE + ? hyperdrive.baseToken() + : hyperdrive.vaultSharesToken() + ), EVERLONG_NAME, address(hyperdrive), - AS_BASE + AS_BASE, + IS_WRAPPED ) ) ); @@ -93,7 +104,15 @@ contract EverlongTest is VaultTest, IEverlongEvents { vm.stopPrank(); // Set the appropriate asset. - asset = IERC20(hyperdrive.baseToken()); + asset = ( + IS_WRAPPED + ? IERC20(WRAPPED_ASSET) + : IERC20( + AS_BASE + ? hyperdrive.baseToken() + : hyperdrive.vaultSharesToken() + ) + ); // As the `management` address: // 1. Accept the `management` role for the strategy. @@ -184,9 +203,6 @@ contract EverlongTest is VaultTest, IEverlongEvents { }) ); vm.stopPrank(); - // Skip forward one second so that `update_debt` doesn't get called on - // the same timestamp. - skip(1); } /// @dev Call `report` on the strategy then call `process_report` on the diff --git a/test/everlong/integration/SDAIVaultSharesToken.t.sol b/test/everlong/integration/SDAIVaultSharesToken.t.sol index d64e3d0..999e49b 100644 --- a/test/everlong/integration/SDAIVaultSharesToken.t.sol +++ b/test/everlong/integration/SDAIVaultSharesToken.t.sol @@ -79,6 +79,7 @@ contract TestSDAIVaultSharesToken is EverlongTest { address(asset), "sDAI Strategy", address(hyperdrive), + false, false ) ) diff --git a/test/everlong/units/Wrapping.t.sol b/test/everlong/units/Wrapping.t.sol new file mode 100644 index 0000000..11ee939 --- /dev/null +++ b/test/everlong/units/Wrapping.t.sol @@ -0,0 +1,94 @@ +// 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 { IERC20Wrappable } from "../../../contracts/interfaces/IERC20Wrappable.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 Tests wrapping functionality for rebasing tokens. +contract TestWrapping is EverlongTest { + using FixedPointMath for *; + + /// @dev The StETH token address. + address internal constant STETH = + 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + + /// @dev The WStETH address. + address constant WSTETH = 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; + + // @dev StETH hyperdrive address. + address constant STETH_HYPERDRIVE = + 0xd7e470043241C10970953Bd8374ee6238e77D735; + + /// @dev The StETH whale address. + address internal constant STETH_WHALE = + 0x1982b2F5814301d4e9a8b0201555376e62F82428; + + /// @dev Mint some WStETH to the specified account. + function mint(address _to, uint256 _amount) internal { + vm.startPrank(STETH_WHALE); + IERC20(STETH).approve(WSTETH, type(uint256).max); + IERC20Wrappable(WSTETH).wrap( + IEverlongStrategy(address(strategy)) + .convertToUnwrapped(_amount) + .mulUp(1.001e18) + ); + IERC20Wrappable(WSTETH).transfer(_to, _amount); + vm.stopPrank(); + } + + /// @dev Deposit the specified amount of wrapped assets into the vault. + function depositWrapped( + address _from, + uint256 _amount + ) internal returns (uint256 shares) { + mint(_from, _amount); + vm.startPrank(_from); + IERC20Wrappable(WSTETH).approve(address(vault), _amount + 1); + shares = vault.deposit(_amount, _from); + vm.stopPrank(); + } + + /// @dev Redeem the specified amount of shares from the vault. + function redeemWrapped( + address _from, + uint256 _shares + ) internal returns (uint256 assets) { + vm.startPrank(_from); + + assets = vault.redeem(_shares, _from, _from); + vm.stopPrank(); + } + + function setUp() public virtual override { + super.setUp(); + + AS_BASE = false; + IS_WRAPPED = true; + WRAPPED_ASSET = WSTETH; + hyperdrive = IHyperdrive(STETH_HYPERDRIVE); + + setUpEverlongStrategy(); + setUpEverlongVault(); + } + + /// @dev Ensure the deposit functions work as expected. + function test_deposit() external { + // Alice deposits into the vault. + uint256 depositAmount = 100e18; + uint256 aliceShares = depositWrapped(alice, depositAmount); + + // Alice should have non-zero share amounts. + assertGt(aliceShares, 0); + + // FIXME: The call below fails with `ALLOWANCE_EXCEEDED` despite the + // proper approval being in place... + // + // Call update_debt and tend. + rebalance(); + } +} From 9464669df92fecd068737f4a54848a3f8987cc70 Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Sat, 14 Dec 2024 01:37:55 -0600 Subject: [PATCH 02/10] fixes and redeem part of test --- contracts/EverlongStrategy.sol | 12 +++++------- test/everlong/units/Wrapping.t.sol | 19 +++++++++++++------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/contracts/EverlongStrategy.sol b/contracts/EverlongStrategy.sol index 3764abb..c345300 100644 --- a/contracts/EverlongStrategy.sol +++ b/contracts/EverlongStrategy.sol @@ -636,12 +636,6 @@ contract EverlongStrategy is BaseStrategy { // unwrapped. _toSpend = _unwrap(_toSpend); - // Convert amounts so that they are denominated in the unwrapped - // token. - _minOutput = convertToUnwrapped(_minOutput); - _minVaultSharePrice = convertToUnwrapped(_minVaultSharePrice); - - // NOTE: Converting between wrappi // Approve hyperdrive for the unwrapped asset, which is also the // `hyperdriveToken`. // @@ -650,6 +644,10 @@ contract EverlongStrategy is BaseStrategy { address(hyperdrive), _toSpend + 1 ); + + // Convert back to hyperdrive's base denomination, same as the + // wrapped token's. + _toSpend = convertToWrapped(_toSpend); } // The strategy asset is not wrapped, no conversions are necessary. // Approve the hyperdrive contract for strategy asset. @@ -732,7 +730,7 @@ contract EverlongStrategy is BaseStrategy { ERC20(hyperdriveToken).forceApprove(address(asset), proceeds + 1); // Wrap the proceeds. - proceeds = _wrap(proceeds); + proceeds = _wrap(convertToUnwrapped(proceeds)); } } diff --git a/test/everlong/units/Wrapping.t.sol b/test/everlong/units/Wrapping.t.sol index 11ee939..7371803 100644 --- a/test/everlong/units/Wrapping.t.sol +++ b/test/everlong/units/Wrapping.t.sol @@ -76,19 +76,26 @@ contract TestWrapping is EverlongTest { setUpEverlongVault(); } - /// @dev Ensure the deposit functions work as expected. - function test_deposit() external { + /// @dev Ensure the deposit and redeem functions work as expected. + function test_deposit_redeem() external { // Alice deposits into the vault. uint256 depositAmount = 100e18; uint256 aliceShares = depositWrapped(alice, depositAmount); + uint256 bobShares = depositWrapped(bob, depositAmount); - // Alice should have non-zero share amounts. + // Alice and Bob should have non-zero share amounts. assertGt(aliceShares, 0); + assertGt(bobShares, 0); - // FIXME: The call below fails with `ALLOWANCE_EXCEEDED` despite the - // proper approval being in place... - // // Call update_debt and tend. rebalance(); + + // Alice and Bob redeem from the vault. + uint256 aliceProceeds = redeemWrapped(alice, aliceShares); + uint256 bobProceeds = redeemWrapped(bob, bobShares); + + // Alice and Bob should have within 1% of their starting balance. + assertApproxEqRel(depositAmount, aliceProceeds, 0.01e18); + assertApproxEqRel(depositAmount, bobProceeds, 0.01e18); } } From 7d32a3867d2c9bddde3b735d86c2cde4c42655b4 Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Sat, 14 Dec 2024 02:00:10 -0600 Subject: [PATCH 03/10] test fix --- contracts/EverlongStrategy.sol | 6 +++--- test/VaultTest.sol | 2 +- test/everlong/EverlongTest.sol | 3 +++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/contracts/EverlongStrategy.sol b/contracts/EverlongStrategy.sol index c345300..20b83e0 100644 --- a/contracts/EverlongStrategy.sol +++ b/contracts/EverlongStrategy.sol @@ -753,9 +753,9 @@ contract EverlongStrategy is BaseStrategy { // If the strategy asset is a wrapped hyperdrive token, convert // amounts so that they are denominated in the unwrapped token. - if (isWrapped) { - proceeds = convertToWrapped(proceeds); - } + // if (isWrapped) { + // proceeds = convertToWrapped(proceeds); + // } } // ╭───────────────────────────────────────────────────────────────────────╮ diff --git a/test/VaultTest.sol b/test/VaultTest.sol index 10571eb..dbd0e35 100644 --- a/test/VaultTest.sol +++ b/test/VaultTest.sol @@ -166,8 +166,8 @@ abstract contract VaultTest is HyperdriveTest { /// @dev Set up the testing environment on a fork of mainnet. function setUp() public virtual override { - super.setUp(); vm.createSelectFork(vm.rpcUrl("mainnet"), FORK_BLOCK_NUMBER); + super.setUp(); setUpHyperdrive(); setUpRoleManager(); } diff --git a/test/everlong/EverlongTest.sol b/test/everlong/EverlongTest.sol index fc418f3..8b3c030 100644 --- a/test/everlong/EverlongTest.sol +++ b/test/everlong/EverlongTest.sol @@ -203,6 +203,9 @@ contract EverlongTest is VaultTest, IEverlongEvents { }) ); vm.stopPrank(); + // Skip forward one second so that `update_debt` doesn't get called on + // the same timestamp. + skip(1); } /// @dev Call `report` on the strategy then call `process_report` on the From 41ff42fd195d54a97d224922cd1fdb4d7bf5f280 Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Sat, 14 Dec 2024 16:09:18 -0600 Subject: [PATCH 04/10] fix issues with LPMath library revert on getPoolInfo --- test/everlong/units/Wrapping.t.sol | 34 +++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/test/everlong/units/Wrapping.t.sol b/test/everlong/units/Wrapping.t.sol index 7371803..df2c1d8 100644 --- a/test/everlong/units/Wrapping.t.sol +++ b/test/everlong/units/Wrapping.t.sol @@ -28,6 +28,28 @@ contract TestWrapping is EverlongTest { address internal constant STETH_WHALE = 0x1982b2F5814301d4e9a8b0201555376e62F82428; + /// @dev Using the standard set up process with the mainnet + /// `StETHHyperdrive` instance leads to issues with the `LPMath` + /// library. To avoid this, we have to use a custom `setUp` that + /// does not attempt to deploy a test instance of hyperdrive. + function setUp() public virtual override { + vm.createSelectFork(vm.rpcUrl("mainnet"), FORK_BLOCK_NUMBER); + (alice, ) = createUser("alice"); + (bob, ) = createUser("bob"); + (governance, ) = createUser("governance"); + (management, ) = createUser("management"); + (deployer, ) = createUser("deployer"); + + AS_BASE = false; + IS_WRAPPED = true; + WRAPPED_ASSET = WSTETH; + hyperdrive = IHyperdrive(STETH_HYPERDRIVE); + + setUpRoleManager(); + setUpEverlongStrategy(); + setUpEverlongVault(); + } + /// @dev Mint some WStETH to the specified account. function mint(address _to, uint256 _amount) internal { vm.startPrank(STETH_WHALE); @@ -64,18 +86,6 @@ contract TestWrapping is EverlongTest { vm.stopPrank(); } - function setUp() public virtual override { - super.setUp(); - - AS_BASE = false; - IS_WRAPPED = true; - WRAPPED_ASSET = WSTETH; - hyperdrive = IHyperdrive(STETH_HYPERDRIVE); - - setUpEverlongStrategy(); - setUpEverlongVault(); - } - /// @dev Ensure the deposit and redeem functions work as expected. function test_deposit_redeem() external { // Alice deposits into the vault. From abcc57531bfe9e9b8138f2094c79dfc41878ee36 Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Sat, 14 Dec 2024 16:13:00 -0600 Subject: [PATCH 05/10] cleanup --- contracts/EverlongStrategy.sol | 40 +--------------------------------- 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/contracts/EverlongStrategy.sol b/contracts/EverlongStrategy.sol index 20b83e0..20d6799 100644 --- a/contracts/EverlongStrategy.sol +++ b/contracts/EverlongStrategy.sol @@ -73,7 +73,7 @@ contract EverlongStrategy is BaseStrategy { using FixedPointMath for uint256; using HyperdriveExecutionLibrary for IHyperdrive; using EverlongPortfolioLibrary for EverlongPortfolioLibrary.State; - using SafeCast for uint256; + using SafeCast for *; using SafeERC20 for ERC20; // ╭───────────────────────────────────────────────────────────────────────╮ @@ -325,13 +325,6 @@ contract EverlongStrategy is BaseStrategy { // Limit the amount that can be spent by the deposit limit. uint256 toSpend = _totalIdle.min(availableDepositLimit(address(this))); - // Calculate the minimum transaction amount in strategy assets for the - // hyperdrive instance. - uint256 minTransactionAmount = _poolConfig.minimumTransactionAmount; - if (isWrapped) { - minTransactionAmount = convertToWrapped(minTransactionAmount); - } - // If Everlong has sufficient idle, open a new position. if (toSpend > _poolConfig.minimumTransactionAmount) { (uint256 maturityTime, uint256 bondAmount) = _openLong( @@ -606,12 +599,6 @@ contract EverlongStrategy is BaseStrategy { if (!asBase) { assets = IHyperdrive(hyperdrive)._convertToShares(assets); } - - // If using a wrapped token, convert the amount to be denominated in - // the wrapped token. - if (isWrapped) { - assets = convertToWrapped(assets); - } } /// @dev Open a long with the specified amount of assets. Return the amount @@ -676,14 +663,6 @@ contract EverlongStrategy is BaseStrategy { uint256 _toSpend, bytes memory _extraData ) internal view returns (uint256 bondAmount) { - // Prepare for opening the long differently if the strategy asset is - // a wrapped hyperdrive token. - if (isWrapped) { - // Convert amounts so that they are denominated in the unwrapped - // token. - _toSpend = convertToUnwrapped(_toSpend); - } - // Call the preview function and return the expected amount of bonds // to be received. bondAmount = IHyperdrive(hyperdrive).previewOpenLong( @@ -705,14 +684,6 @@ contract EverlongStrategy is BaseStrategy { uint256 _minOutput, bytes memory _extraData ) internal returns (uint256 proceeds) { - // Prepare for closing the long differently if the strategy asset is - // a wrapped hyperdrive token. - if (isWrapped) { - // Convert amounts so that they are denominated in the unwrapped - // token. - _minOutput = convertToUnwrapped(_minOutput); - } - // Close the long. proceeds = IHyperdrive(hyperdrive).closeLong( asBase, @@ -750,12 +721,6 @@ contract EverlongStrategy is BaseStrategy { _position, _extraData ); - - // If the strategy asset is a wrapped hyperdrive token, convert - // amounts so that they are denominated in the unwrapped token. - // if (isWrapped) { - // proceeds = convertToWrapped(proceeds); - // } } // ╭───────────────────────────────────────────────────────────────────────╮ @@ -810,9 +775,6 @@ 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)); - if (isWrapped) { - currentBalance = convertToUnwrapped(currentBalance); - } return currentBalance > _poolConfig.minimumTransactionAmount; } From 858033a50bb47ecb1cd6cace5db0f74a2a1c631f Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Sat, 14 Dec 2024 18:02:24 -0600 Subject: [PATCH 06/10] responding to feedback from @jalextowle --- contracts/EverlongStrategyKeeper.sol | 6 ------ 1 file changed, 6 deletions(-) diff --git a/contracts/EverlongStrategyKeeper.sol b/contracts/EverlongStrategyKeeper.sol index 85d0dfa..ebe7a59 100644 --- a/contracts/EverlongStrategyKeeper.sol +++ b/contracts/EverlongStrategyKeeper.sol @@ -338,12 +338,6 @@ contract EverlongStrategyKeeper is Ownable { return 0; } - // If the strategy is a wrapped token, convert the spend amount to - // be denominated in hyperdrive's token. - if (IEverlongStrategy(_strategy).isWrapped()) { - canSpend = IEverlongStrategy(_strategy).convertToWrapped(canSpend); - } - // Calculate the amount of bonds that would be received if a long was // opened with `canSpend` assets. uint256 expectedOutput = hyperdrive.previewOpenLong( From 807fabd260f5ee7226ff96bd399591401c4b442b Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Sat, 14 Dec 2024 18:50:03 -0600 Subject: [PATCH 07/10] responding to feedback from @jalextowle --- contracts/EverlongStrategy.sol | 74 +++++++++------------------------- 1 file changed, 19 insertions(+), 55 deletions(-) diff --git a/contracts/EverlongStrategy.sol b/contracts/EverlongStrategy.sol index 20d6799..590c7c2 100644 --- a/contracts/EverlongStrategy.sol +++ b/contracts/EverlongStrategy.sol @@ -485,7 +485,12 @@ contract EverlongStrategy is BaseStrategy { // Calculate the value of the entire position, and use it to derive // the expected output for partial closures. - totalPositionValue = _previewCloseLong(position, ""); + totalPositionValue = IHyperdrive(hyperdrive).previewCloseLong( + asBase, + _poolConfig, + position, + "" + ); // Close only part of the position if there are sufficient bonds // to reach the target output without leaving a small amount left. @@ -587,20 +592,6 @@ contract EverlongStrategy is BaseStrategy { ); } - /// @dev Calculate the maximum amount of strategy assets that can be spent - /// on longs before hyperdrive runs out of liquidity. - /// @return assets Max amount of strategy assets that can be spent on longs. - function _calculateMaxLong() internal view returns (uint256 assets) { - // Retrieve the max long amount denominated in hyperdrive base tokens. - assets = IHyperdrive(hyperdrive).calculateMaxLong(); - - // If `asBase` is false, convert the amount to be denominated in vault - // share tokens. - if (!asBase) { - assets = IHyperdrive(hyperdrive)._convertToShares(assets); - } - } - /// @dev Open a long with the specified amount of assets. Return the amount /// of bonds received and their maturityTime. /// @param _toSpend Amount of strategy assets to spend. @@ -632,7 +623,7 @@ contract EverlongStrategy is BaseStrategy { _toSpend + 1 ); - // Convert back to hyperdrive's base denomination, same as the + // Convert back to hyperdrive token's denomination, same as the // wrapped token's. _toSpend = convertToWrapped(_toSpend); } @@ -654,25 +645,6 @@ contract EverlongStrategy is BaseStrategy { ); } - /// @dev Preview the amount of bonds received from opening a position with - /// the specified amount of strategy assets. - /// @param _toSpend Amount of strategy assets to spend. - /// @param _extraData Extra data to pass to hyperdrive. - /// @return bondAmount Amount of bonds that would be received. - function _previewOpenLong( - uint256 _toSpend, - bytes memory _extraData - ) internal view returns (uint256 bondAmount) { - // Call the preview function and return the expected amount of bonds - // to be received. - bondAmount = IHyperdrive(hyperdrive).previewOpenLong( - asBase, - _poolConfig, - _toSpend, - _extraData - ); - } - /// @dev Preview the amount of assets received from closing the specified /// position. /// @param _position Position to close. @@ -705,24 +677,6 @@ contract EverlongStrategy is BaseStrategy { } } - /// @dev Preview the amount of assets received from closing the specified - /// position. - /// @param _position Position to close. - /// @param _extraData Extra data to pass to hyperdrive. - /// @return proceeds Amount of strategy assets that would be received. - function _previewCloseLong( - IEverlongStrategy.EverlongPosition memory _position, - bytes memory _extraData - ) internal view returns (uint256 proceeds) { - // Call the preview function. - proceeds = IHyperdrive(hyperdrive).previewCloseLong( - asBase, - _poolConfig, - _position, - _extraData - ); - } - // ╭───────────────────────────────────────────────────────────────────────╮ // │ Views │ // ╰───────────────────────────────────────────────────────────────────────╯ @@ -738,7 +692,7 @@ contract EverlongStrategy is BaseStrategy { // Only pre-approved addresses are able to deposit. if (_depositors[_depositor] || _depositor == address(this)) { // Limit deposits to the maximum long that can be opened in hyperdrive. - return _calculateMaxLong(); + return IHyperdrive(hyperdrive).calculateMaxLong(); } // Address has not been pre-approved, return 0. return 0; @@ -758,7 +712,9 @@ contract EverlongStrategy is BaseStrategy { if (_portfolio.totalBonds != 0) { // NOTE: The maturity time is rounded to the next checkpoint to // underestimate the portfolio value. - value += _previewCloseLong( + value += IHyperdrive(hyperdrive).previewCloseLong( + asBase, + _poolConfig, IEverlongStrategy.EverlongPosition({ maturityTime: IHyperdrive(hyperdrive) .getCheckpointIdUp(_portfolio.avgMaturityTime) @@ -778,6 +734,10 @@ contract EverlongStrategy is BaseStrategy { return currentBalance > _poolConfig.minimumTransactionAmount; } + /// @notice Converts an amount denominated in wrapped tokens to an amount + /// denominated in unwrapped tokens. + /// @param _wrappedAmount Amount in wrapped tokens. + /// @return _unwrappedAmount Amount in unwrapped tokens. function convertToUnwrapped( uint256 _wrappedAmount ) public view returns (uint256 _unwrappedAmount) { @@ -789,6 +749,10 @@ contract EverlongStrategy is BaseStrategy { ); } + /// @notice Converts an amount denominated in unwrapped tokens to an amount + /// denominated in wrapped tokens. + /// @param _unwrappedAmount Amount in unwrapped tokens. + /// @return _wrappedAmount Amount in wrapped tokens. function convertToWrapped( uint256 _unwrappedAmount ) public view returns (uint256 _wrappedAmount) { From 1a4ecb2c5a305c9ff7c6bc8605cfc79f8792f3a5 Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Sat, 14 Dec 2024 19:40:27 -0600 Subject: [PATCH 08/10] convert "hyperdrive token" language to `execution token` language --- contracts/EverlongStrategy.sol | 44 ++++++++++++---------- contracts/interfaces/IEverlongStrategy.sol | 8 ++++ 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/contracts/EverlongStrategy.sol b/contracts/EverlongStrategy.sol index 590c7c2..543d56e 100644 --- a/contracts/EverlongStrategy.sol +++ b/contracts/EverlongStrategy.sol @@ -122,7 +122,8 @@ contract EverlongStrategy is BaseStrategy { /// If false, use the Hyperdrive's `vaultSharesToken`. bool public immutable asBase; - /// @notice Whether the strategy asset is a wrapped hyperdrive token. + /// @notice Whether the strategy asset is a wrapped version of hyperdrive's + /// base/vaultShares token. /// @dev Wrapping is a workaround to allow using hyperdrive instances with /// rebasing tokens when Yearn explicitly does not support them. bool public immutable isWrapped; @@ -130,10 +131,12 @@ contract EverlongStrategy is BaseStrategy { /// @dev The Hyperdrive's PoolConfig. IHyperdrive.PoolConfig internal _poolConfig; - /// @notice Address of the hyperdrive token. - /// @dev Determined by `asBase`. If true, then hyperdrive's base token is - /// used. If false, then hyperdrive's vault shares token is used. - address public immutable hyperdriveToken; + /// @notice Token used to execute trades with hyperdrive. + /// @dev Determined by `asBase`. + /// If `asBase=true`, then hyperdrive's base token is used. + /// If `asBase=false`, then hyperdrive's vault shares token is used. + /// Same as the strategy asset `asset` unless `isWrapped=true` + address public immutable executionToken; // ╭───────────────────────────────────────────────────────────────────────╮ // │ State │ @@ -160,7 +163,8 @@ contract EverlongStrategy is BaseStrategy { /// @param _hyperdrive Address of the Hyperdrive instance. /// @param _asBase Whether to use the base token when interacting with /// hyperdrive. If false, use the vault shares token. - /// @param _isWrapped True if `asset` is a wrapped hyperdrive token. + /// @param _isWrapped True if `asset` is a wrapped version of hyperdrive's + /// base/vaultShares token. constructor( address _asset, string memory __name, @@ -177,8 +181,8 @@ contract EverlongStrategy is BaseStrategy { // Store the hyperdrive's PoolConfig since it's static. _poolConfig = IHyperdrive(_hyperdrive).getPoolConfig(); - // Store the hyperdrive token to use when opening/closing longs. - hyperdriveToken = address( + // Store the execution token to use when opening/closing longs. + executionToken = address( _asBase ? _poolConfig.baseToken : _poolConfig.vaultSharesToken ); @@ -555,8 +559,8 @@ contract EverlongStrategy is BaseStrategy { // │ Wrapped Token Helpers │ // ╰───────────────────────────────────────────────────────────────────────╯ - /// @dev Wrap the hyperdrive token so that it can be used in the strategy. - /// @param _unwrappedAmount Amount of unwrapped hyperdrive tokens to wrap. + /// @dev Wrap the `executionToken` so that it can be used in the strategy. + /// @param _unwrappedAmount Amount of unwrapped execution tokens to wrap. /// @return _wrappedAmount Amount of wrapped tokens received. function _wrap( uint256 _unwrappedAmount @@ -566,12 +570,12 @@ contract EverlongStrategy is BaseStrategy { revert IEverlongStrategy.AssetNotWrapped(); } - // Approve the wrapped asset contract for the hyperdrive tokens. + // Approve the wrapped asset contract for the execution token. // Add one to the approval amount to leave the slot dirty and save // gas on future approvals. - ERC20(hyperdriveToken).approve(address(asset), _unwrappedAmount); + ERC20(executionToken).approve(address(asset), _unwrappedAmount); - // Wrap the hyperdrive tokens. + // Wrap the execution tokens. _wrappedAmount = IERC20Wrappable(address(asset)).wrap(_unwrappedAmount); } @@ -608,22 +612,22 @@ contract EverlongStrategy is BaseStrategy { bytes memory _extraData ) internal returns (uint256 maturityTime, uint256 bondAmount) { // Prepare for opening the long differently if the strategy asset is - // a wrapped hyperdrive token. + // a wrapped `executionToken`. if (isWrapped) { - // The strategy asset is a wrapped hyperdrive token so it must be + // The strategy asset is a wrapped `executionToken` so it must be // unwrapped. _toSpend = _unwrap(_toSpend); // Approve hyperdrive for the unwrapped asset, which is also the - // `hyperdriveToken`. + // `executionToken`. // // Leave the approval slot dirty to save gas on future approvals. - ERC20(hyperdriveToken).forceApprove( + ERC20(executionToken).forceApprove( address(hyperdrive), _toSpend + 1 ); - // Convert back to hyperdrive token's denomination, same as the + // Convert back to `executionToken`'s denomination, same as the // wrapped token's. _toSpend = convertToWrapped(_toSpend); } @@ -665,12 +669,12 @@ contract EverlongStrategy is BaseStrategy { ); // The proceeds must be wrapped if the strategy asset is a wrapped - // hyperdrive token. + // `executionToken`. if (isWrapped) { // Approve the wrapped contract for the proceeds. Add one to the // approval amount to save gas on future approvals by leaving the // slot dirty. - ERC20(hyperdriveToken).forceApprove(address(asset), proceeds + 1); + ERC20(executionToken).forceApprove(address(asset), proceeds + 1); // Wrap the proceeds. proceeds = _wrap(convertToUnwrapped(proceeds)); diff --git a/contracts/interfaces/IEverlongStrategy.sol b/contracts/interfaces/IEverlongStrategy.sol index 9898cef..88d154b 100644 --- a/contracts/interfaces/IEverlongStrategy.sol +++ b/contracts/interfaces/IEverlongStrategy.sol @@ -88,6 +88,14 @@ interface IEverlongStrategy is IPermissionedStrategy, IEverlongEvents { uint256 _wrappedAmount ) external view returns (uint256 _unwrappedAmount); + /// @notice Token used to execute trades with hyperdrive. + /// @dev Determined by `asBase`. + /// If `asBase=true`, then hyperdrive's base token is used. + /// If `asBase=false`, then hyperdrive's vault shares token is used. + /// Same as the strategy asset `asset` unless `isWrapped=true` + /// @return The token used to execute trades with hyperdrive. + function executionToken() external view returns (address); + /// @notice Reads and returns the current tend configuration from transient /// storage. /// @return tendEnabled Whether TendConfig has been set. From a1591dc3c0bf2d9f051f222e28dbb71ba2e1fb80 Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Sat, 14 Dec 2024 19:41:29 -0600 Subject: [PATCH 09/10] Update contracts/EverlongStrategy.sol Co-authored-by: Alex Towle --- contracts/EverlongStrategy.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/EverlongStrategy.sol b/contracts/EverlongStrategy.sol index 543d56e..7e1f677 100644 --- a/contracts/EverlongStrategy.sol +++ b/contracts/EverlongStrategy.sol @@ -539,7 +539,7 @@ contract EverlongStrategy is BaseStrategy { else { // Close the entire 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. // From e82d8b3de79830e4d771e00ddd58a9c6fc031c9d Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Sat, 14 Dec 2024 21:52:32 -0600 Subject: [PATCH 10/10] correctly convert base token values from hyperdrive (#38) Some values received from hyperdrive are denominated in base tokens. These must be converted to vault shares token denominated values when `asBase==true || isWrapped==true` in the strategy --- contracts/EverlongStrategy.sol | 29 +++++++++++++++---- contracts/libraries/HyperdriveExecution.sol | 26 +++++++++++++---- .../integration/PartialClosures.t.sol | 2 +- test/everlong/integration/Sandwich.t.sol | 16 +++++----- test/everlong/units/HyperdriveExecution.t.sol | 4 +-- 5 files changed, 54 insertions(+), 23 deletions(-) diff --git a/contracts/EverlongStrategy.sol b/contracts/EverlongStrategy.sol index 7e1f677..687f874 100644 --- a/contracts/EverlongStrategy.sol +++ b/contracts/EverlongStrategy.sol @@ -330,7 +330,7 @@ contract EverlongStrategy is BaseStrategy { uint256 toSpend = _totalIdle.min(availableDepositLimit(address(this))); // If Everlong has sufficient idle, open a new position. - if (toSpend > _poolConfig.minimumTransactionAmount) { + if (toSpend > _minimumTransactionAmount()) { (uint256 maturityTime, uint256 bondAmount) = _openLong( toSpend, tendConfig.minOutput, @@ -473,7 +473,7 @@ contract EverlongStrategy is BaseStrategy { uint256 _targetOutput ) internal returns (uint256 output) { // Round `_targetOutput` up to Hyperdrive's minimum transaction amount. - _targetOutput = _targetOutput.max(_poolConfig.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,8 +502,9 @@ contract EverlongStrategy is BaseStrategy { // Hyperdrive's minimum transaction amount. if ( totalPositionValue > - (_targetOutput - output + _poolConfig.minimumTransactionAmount) - .mulUp(ONE + partialPositionClosureBuffer) + (_targetOutput - output + _minimumTransactionAmount()).mulUp( + ONE + partialPositionClosureBuffer + ) ) { // Calculate the amount of bonds to close from the position. uint256 bondsNeeded = uint256(position.bondAmount).mulDivUp( @@ -681,6 +682,22 @@ 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 │ // ╰───────────────────────────────────────────────────────────────────────╯ @@ -696,7 +713,7 @@ contract EverlongStrategy is BaseStrategy { // Only pre-approved addresses are able to deposit. if (_depositors[_depositor] || _depositor == address(this)) { // Limit deposits to the maximum long that can be opened in hyperdrive. - return IHyperdrive(hyperdrive).calculateMaxLong(); + return IHyperdrive(hyperdrive).calculateMaxLong(asBase); } // Address has not been pre-approved, return 0. return 0; @@ -735,7 +752,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 > _poolConfig.minimumTransactionAmount; + return currentBalance > _minimumTransactionAmount(); } /// @notice Converts an amount denominated in wrapped tokens to an amount diff --git a/contracts/libraries/HyperdriveExecution.sol b/contracts/libraries/HyperdriveExecution.sol index 7b28ebb..ce4eb89 100644 --- a/contracts/libraries/HyperdriveExecution.sol +++ b/contracts/libraries/HyperdriveExecution.sol @@ -492,15 +492,18 @@ library HyperdriveExecutionLibrary { // HACK: Copied from `delvtech/hyperdrive` repo. // /// @dev Calculates the maximum amount of longs that can be opened. + /// @param _asBase Whether to transact using hyperdrive's base or vault + /// shares token. /// @param _maxIterations The maximum number of iterations to use. - /// @return baseAmount The cost of buying the maximum amount of longs. + /// @return amount The cost of buying the maximum amount of longs. function calculateMaxLong( IHyperdrive self, + bool _asBase, uint256 _maxIterations - ) internal view returns (uint256 baseAmount) { + ) internal view returns (uint256 amount) { IHyperdrive.PoolConfig memory poolConfig = self.getPoolConfig(); IHyperdrive.PoolInfo memory poolInfo = self.getPoolInfo(); - (baseAmount, ) = calculateMaxLong( + (amount, ) = calculateMaxLong( MaxTradeParams({ shareReserves: poolInfo.shareReserves, shareAdjustment: poolInfo.shareAdjustment, @@ -518,17 +521,28 @@ library HyperdriveExecutionLibrary { self.getCheckpointExposure(latestCheckpoint(self)), _maxIterations ); - return baseAmount; + + // The above `amount` is denominated in hyperdrive's base token. + // If `_asBase == false` then hyperdrive's vault shares token is being + // used and we must convert the value. + if (!_asBase) { + amount = _convertToShares(self, amount); + } + + return amount; } // HACK: Copied from `delvtech/hyperdrive` repo. // /// @dev Calculates the maximum amount of longs that can be opened. + /// @param _asBase Whether to transact using hyperdrive's base or vault + /// shares token. /// @return baseAmount The cost of buying the maximum amount of longs. function calculateMaxLong( - IHyperdrive self + IHyperdrive self, + bool _asBase ) internal view returns (uint256 baseAmount) { - return calculateMaxLong(self, 7); + return calculateMaxLong(self, _asBase, 7); } // HACK: Copied from `delvtech/hyperdrive` repo. diff --git a/test/everlong/integration/PartialClosures.t.sol b/test/everlong/integration/PartialClosures.t.sol index 19ed870..40d92b0 100644 --- a/test/everlong/integration/PartialClosures.t.sol +++ b/test/everlong/integration/PartialClosures.t.sol @@ -25,7 +25,7 @@ contract TestPartialClosures is EverlongTest { uint256 aliceDepositAmount = bound( _deposit, MINIMUM_TRANSACTION_AMOUNT * 100, // Increase minimum bound otherwise partial redemption won't occur - hyperdrive.calculateMaxLong() + hyperdrive.calculateMaxLong(AS_BASE) ); uint256 aliceShares = depositStrategy(aliceDepositAmount, alice, true); uint256 positionBondsAfterDeposit = IEverlongStrategy(address(strategy)) diff --git a/test/everlong/integration/Sandwich.t.sol b/test/everlong/integration/Sandwich.t.sol index 7fe7bdd..f582b6d 100644 --- a/test/everlong/integration/Sandwich.t.sol +++ b/test/everlong/integration/Sandwich.t.sol @@ -42,7 +42,7 @@ contract TestSandwichHelper is EverlongTest { _bystanderDeposit = bound( _bystanderDeposit, MINIMUM_TRANSACTION_AMOUNT * 5, - hyperdrive.calculateMaxLong() / 3 + hyperdrive.calculateMaxLong(AS_BASE) / 3 ); uint256 bystanderShares = depositVault( _bystanderDeposit, @@ -67,7 +67,7 @@ contract TestSandwichHelper is EverlongTest { _attackerDeposit = bound( _attackerDeposit, MINIMUM_TRANSACTION_AMOUNT * 5, - hyperdrive.calculateMaxLong() / 3 + hyperdrive.calculateMaxLong(AS_BASE) / 3 ); uint256 attackerShares = depositVault( _attackerDeposit, @@ -125,7 +125,7 @@ contract TestSandwichHelper is EverlongTest { _bystanderDeposit = bound( _bystanderDeposit, MINIMUM_TRANSACTION_AMOUNT * 5, - hyperdrive.calculateMaxLong() / 3 + hyperdrive.calculateMaxLong(AS_BASE) / 3 ); uint256 bystanderShares = depositVault( _bystanderDeposit, @@ -139,7 +139,7 @@ contract TestSandwichHelper is EverlongTest { _attackerDeposit = bound( _attackerDeposit, MINIMUM_TRANSACTION_AMOUNT * 5, - hyperdrive.calculateMaxLong() / 3 + hyperdrive.calculateMaxLong(AS_BASE) / 3 ); uint256 attackerShares = depositVault( _attackerDeposit, @@ -212,7 +212,7 @@ contract TestSandwichHelper is EverlongTest { _bystanderDeposit = bound( _bystanderDeposit, MINIMUM_TRANSACTION_AMOUNT * 5, - hyperdrive.calculateMaxLong() / 3 + hyperdrive.calculateMaxLong(AS_BASE) / 3 ); uint256 bystanderEverlongShares = depositVault( _bystanderDeposit, @@ -226,7 +226,7 @@ contract TestSandwichHelper is EverlongTest { _attackerDeposit = bound( _attackerDeposit, MINIMUM_TRANSACTION_AMOUNT * 5, - hyperdrive.calculateMaxLong() / 3 + hyperdrive.calculateMaxLong(AS_BASE) / 3 ); uint256 attackerEverlongShares = depositVault( _attackerDeposit, @@ -279,7 +279,7 @@ contract TestSandwichHelper is EverlongTest { _bystanderDeposit = bound( _bystanderDeposit, MINIMUM_TRANSACTION_AMOUNT * 5, - hyperdrive.calculateMaxLong() / 3 + hyperdrive.calculateMaxLong(AS_BASE) / 3 ); uint256 bystanderShares = depositVault( _bystanderDeposit, @@ -293,7 +293,7 @@ contract TestSandwichHelper is EverlongTest { _attackerDeposit = bound( _attackerDeposit, MINIMUM_TRANSACTION_AMOUNT * 5, - hyperdrive.calculateMaxLong() / 3 + hyperdrive.calculateMaxLong(AS_BASE) / 3 ); uint256 attackerShares = depositVault( _attackerDeposit, diff --git a/test/everlong/units/HyperdriveExecution.t.sol b/test/everlong/units/HyperdriveExecution.t.sol index 75b744d..283be85 100644 --- a/test/everlong/units/HyperdriveExecution.t.sol +++ b/test/everlong/units/HyperdriveExecution.t.sol @@ -206,7 +206,7 @@ contract TestHyperdriveExecution is EverlongTest { // decrease the value of the share adjustment to a non-trivial value. matureLongAmount = matureLongAmount.normalizeToRange( MINIMUM_TRANSACTION_AMOUNT + 1, - hyperdrive.calculateMaxLong() / 2 + hyperdrive.calculateMaxLong(AS_BASE) / 2 ); openLong(alice, matureLongAmount); advanceTime(hyperdrive.getPoolConfig().positionDuration, 0); @@ -343,7 +343,7 @@ contract TestHyperdriveExecution is EverlongTest { // value which stress tests the max long function. initialLongAmount = initialLongAmount.normalizeToRange( MINIMUM_TRANSACTION_AMOUNT + 1, - hyperdrive.calculateMaxLong() / 2 + hyperdrive.calculateMaxLong(AS_BASE) / 2 ); openLong(bob, initialLongAmount); initialShortAmount = initialShortAmount.normalizeToRange(