From 60ec1f15f07a08f858774a1d8b4d188649824c11 Mon Sep 17 00:00:00 2001 From: "John McClure (pickleback)" Date: Mon, 9 Sep 2024 13:14:33 -0500 Subject: [PATCH] vault share pricing and unmatured position valuation (#4) * refactor position accounting into library * positive interest works * cleanup, rename playground to pricing test, add remappings.txt to massively speed up LSP * add tests for hyperdrive w/ fees * moving things around and adding tests for portfolio value estimation * massively simplify calculateCloseLong and remove unused internal contracts * hyperdrive execution math is exact * wei-exact accounting * comment all the things * Update Everlong.sol Co-authored-by: Alex Towle * Update IEverlong.sol Co-authored-by: Alex Towle * Update contracts/interfaces/IEverlong.sol Co-authored-by: Alex Towle * addressing feedback from @jalextowle * addressing feedback from @jalextowle * testing fixes (#5) * tmp * portfolio and 4626 fixes/tests * fix rebase issues * update existing tests, remove position tests (will re-add similar scope with idle liquidity feature) * Update test/units/EverlongPortfolio.t.sol Co-authored-by: Alex Towle * Update contracts/Everlong.sol Co-authored-by: Alex Towle --------- Co-authored-by: Alex Towle --------- Co-authored-by: Alex Towle --- .gitignore | 4 + contracts/Everlong.sol | 339 ++++++++++-- contracts/interfaces/IEverlong.sol | 42 +- contracts/interfaces/IEverlongEvents.sol | 17 +- ...ngPositions.sol => IEverlongPortfolio.sol} | 15 +- contracts/internal/EverlongAdmin.sol | 45 -- contracts/internal/EverlongBase.sol | 33 -- contracts/internal/EverlongERC4626.sol | 158 ------ contracts/internal/EverlongPositions.sol | 354 ------------ contracts/internal/EverlongStorage.sol | 64 --- contracts/libraries/Constants.sol | 2 + contracts/libraries/HyperdriveExecution.sol | 512 ++++++++++++++++++ contracts/libraries/Portfolio.sol | 206 +++++++ contracts/libraries/Position.sol | 33 ++ contracts/types/Position.sol | 11 - foundry.toml | 8 +- remappings.txt | 16 + test/exposed/EverlongAdminExposed.sol | 10 - test/exposed/EverlongERC4626Exposed.sol | 6 +- test/exposed/EverlongExposed.sol | 55 +- test/exposed/EverlongPortfolioExposed.sol | 32 ++ test/exposed/EverlongPositionsExposed.sol | 89 --- test/harnesses/EverlongTest.sol | 105 +++- test/integration/CloseImmatureLongs.t.sol | 181 +++++++ test/units/Everlong.t.sol | 5 + test/units/EverlongAdmin.t.sol | 7 +- test/units/EverlongERC4626.t.sol | 280 +++------- test/units/EverlongPortfolio.t.sol | 122 +++++ test/units/EverlongPositions.t.sol | 338 ------------ test/units/HyperdriveExecution.t.sol | 204 +++++++ 30 files changed, 1844 insertions(+), 1449 deletions(-) rename contracts/interfaces/{IEverlongPositions.sol => IEverlongPortfolio.sol} (79%) delete mode 100644 contracts/internal/EverlongAdmin.sol delete mode 100644 contracts/internal/EverlongBase.sol delete mode 100644 contracts/internal/EverlongERC4626.sol delete mode 100644 contracts/internal/EverlongPositions.sol delete mode 100644 contracts/internal/EverlongStorage.sol create mode 100644 contracts/libraries/HyperdriveExecution.sol create mode 100644 contracts/libraries/Portfolio.sol create mode 100644 contracts/libraries/Position.sol delete mode 100644 contracts/types/Position.sol create mode 100644 remappings.txt delete mode 100644 test/exposed/EverlongAdminExposed.sol create mode 100644 test/exposed/EverlongPortfolioExposed.sol delete mode 100644 test/exposed/EverlongPositionsExposed.sol create mode 100644 test/integration/CloseImmatureLongs.t.sol create mode 100644 test/units/EverlongPortfolio.t.sol delete mode 100644 test/units/EverlongPositions.t.sol create mode 100644 test/units/HyperdriveExecution.t.sol diff --git a/.gitignore b/.gitignore index 258b967..a0e3cf6 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,7 @@ lcov.info # vscode workspace settings .vscode + +# notes +NOTES.md +TODO.md diff --git a/contracts/Everlong.sol b/contracts/Everlong.sol index e5d82d6..a2f41fb 100644 --- a/contracts/Everlong.sol +++ b/contracts/Everlong.sol @@ -2,21 +2,23 @@ pragma solidity 0.8.22; import { IHyperdrive } from "hyperdrive/contracts/src/interfaces/IHyperdrive.sol"; +import { FixedPointMath } from "hyperdrive/contracts/src/libraries/FixedPointMath.sol"; +import { SafeCast } from "hyperdrive/contracts/src/libraries/SafeCast.sol"; +import { ERC20 } from "openzeppelin/token/ERC20/ERC20.sol"; +import { SafeERC20 } from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; import { IEverlong } from "./interfaces/IEverlong.sol"; -import { EverlongAdmin } from "./internal/EverlongAdmin.sol"; -import { EverlongBase } from "./internal/EverlongBase.sol"; -import { EverlongERC4626 } from "./internal/EverlongERC4626.sol"; -import { EverlongPositions } from "./internal/EverlongPositions.sol"; import { EVERLONG_KIND, EVERLONG_VERSION } from "./libraries/Constants.sol"; +import { HyperdriveExecutionLibrary } from "./libraries/HyperdriveExecution.sol"; +import { Portfolio } from "./libraries/Portfolio.sol"; -/// ,---..-. .-.,---. ,---. ,-. .---. .-. .-. ,--, -/// | .-' \ \ / / | .-' | .-.\ | | / .-. ) | \| |.' .' -/// | `-. \ V / | `-. | `-'/ | | | | |(_)| | || | __ -/// | .-' ) / | .-' | ( | | | | | | | |\ |\ \ ( _) -/// | `--.(_) | `--.| |\ \ | `--.\ `-' / | | |)| \ `-) ) -/// /( __.' /( __.'|_| \)\ |( __.')---' /( (_) )\____/ -/// (__) (__) (__)(_) (_) (__) (__) -/// +// ,---..-. .-.,---. ,---. ,-. .---. .-. .-. ,--, +// | .-' \ \ / / | .-' | .-.\ | | / .-. ) | \| |.' .' +// | `-. \ V / | `-. | `-'/ | | | | |(_)| | || | __ +// | .-' ) / | .-' | ( | | | | | | | |\ |\ \ ( _) +// | `--.(_) | `--.| |\ \ | `--.\ `-' / | | |)| \ `-) ) +// /( __.' /( __.'|_| \)\ |( __.')---' /( (_) )\____/ +// (__) (__) (__)(_) (_) (__) (__) +// // ########## #++################################### ### ###### // ########## ##################################### ########### // ######################################################### @@ -66,7 +68,76 @@ import { EVERLONG_KIND, EVERLONG_VERSION } from "./libraries/Constants.sol"; /// @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. -contract Everlong is EverlongAdmin, EverlongPositions, EverlongERC4626 { +contract Everlong is IEverlong { + using FixedPointMath for uint256; + using HyperdriveExecutionLibrary for IHyperdrive; + using Portfolio for Portfolio.State; + using SafeCast for *; + using SafeERC20 for ERC20; + + // ╭─────────────────────────────────────────────────────────╮ + // │ Storage │ + // ╰─────────────────────────────────────────────────────────╯ + + // ───────────────────────── Immutables ────────────────────── + + // NOTE: Immutables accessed during transactions are left as internal + // to avoid the gas overhead of auto-generated getter functions. + // https://zokyo-auditing-tutorials.gitbook.io/zokyo-gas-savings/tutorials/gas-saving-technique-23-public-to-private-constants + + /// @dev Name of the Everlong token. + string public _name; + + /// @dev Symbol of the Everlong token. + string internal _symbol; + + /// @notice Address of the Hyperdrive instance wrapped by Everlong. + address public immutable override hyperdrive; + + /// @dev Whether to use Hyperdrive's base token to purchase bonds. + /// If false, use the Hyperdrive's `vaultSharesToken`. + bool public immutable asBase; + + /// @dev Address of the underlying asset to use with hyperdrive. + address public immutable _asset; + + /// @dev Decimals to use with asset. + uint8 internal immutable _decimals; + + /// @dev Kind of everlong. + string public constant override kind = EVERLONG_KIND; + + /// @dev Version of everlong. + string public constant override version = EVERLONG_VERSION; + + /// @notice Virtual shares are used to mitigate inflation attacks. + bool public constant useVirtualShares = true; + + /// @notice Used to reduce the feasibility of an inflation attack. + /// TODO: Determine the appropriate value for our case. Current value + /// was picked arbitrarily. + uint8 public constant decimalsOffset = 3; + + // ─────────────────────────── State ──────────────────────── + + /// @dev Address of the contract admin. + address public admin; + + /// @dev Structure to store and account for everlong-controlled positions. + Portfolio.State internal _portfolio; + + // ╭─────────────────────────────────────────────────────────╮ + // │ Modifiers │ + // ╰─────────────────────────────────────────────────────────╯ + + /// @dev Ensures that the contract is being called by admin. + modifier onlyAdmin() { + if (msg.sender != admin) { + revert IEverlong.Unauthorized(); + } + _; + } + // ╭─────────────────────────────────────────────────────────╮ // │ Constructor │ // ╰─────────────────────────────────────────────────────────╯ @@ -74,56 +145,240 @@ contract Everlong is EverlongAdmin, EverlongPositions, EverlongERC4626 { /// @notice Initial configuration paramters for EverlongERC4626. /// @param __name Name of the ERC20 token managed by Everlong. /// @param __symbol Symbol of the ERC20 token managed by Everlong. - /// @param __hyperdrive Address of the Hyperdrive instance. - /// @param __asBase Whether to use the base or shares token from Hyperdrive. + /// @param __decimals Decimals of the Everlong token and Hyperdrive token. + /// @param _hyperdrive Address of the Hyperdrive instance. + /// @param _asBase Whether to use the base or shares token from Hyperdrive. constructor( string memory __name, string memory __symbol, - address __hyperdrive, - bool __asBase + uint8 __decimals, + address _hyperdrive, + bool _asBase ) { // Store constructor parameters. _name = __name; _symbol = __symbol; - _hyperdrive = __hyperdrive; - _asBase = __asBase; - _asset = __asBase - ? IHyperdrive(__hyperdrive).baseToken() - : IHyperdrive(__hyperdrive).vaultSharesToken(); + _decimals = __decimals; + hyperdrive = _hyperdrive; + asBase = _asBase; + _asset = _asBase + ? IHyperdrive(_hyperdrive).baseToken() + : IHyperdrive(_hyperdrive).vaultSharesToken(); // Set the admin to the contract deployer. - _admin = msg.sender; + admin = msg.sender; + } - // Attempt to retrieve the decimals from the {_asset} contract. - // If it does not implement `decimals() (uint256)`, use the default. - (bool success, uint8 result) = _tryGetAssetDecimals(_asset); - _decimals = success ? result : _DEFAULT_UNDERLYING_DECIMALS; + // ╭─────────────────────────────────────────────────────────╮ + // │ Admin │ + // ╰─────────────────────────────────────────────────────────╯ + + /// @notice Allows admin to transfer the admin role. + /// @param _admin The new admin address. + function setAdmin(address _admin) external onlyAdmin { + admin = _admin; + emit AdminUpdated(_admin); + } + + // ╭─────────────────────────────────────────────────────────╮ + // │ ERC4626 │ + // ╰─────────────────────────────────────────────────────────╯ + + /// @notice Calculate the total amount of assets controlled by everlong. + /// @notice To do this efficiently, the weighted average maturity is used. + /// @dev Underestimates the actual value by overestimating the average + /// maturity of the portfolio. + /// @return Total amount of assets controlled by Everlong. + function totalAssets() public view override returns (uint256) { + // If everlong holds no bonds, return the balance. + uint256 balance = ERC20(_asset).balanceOf(address(this)); + if (_portfolio.totalBonds == 0) { + return balance; + } + + // Estimate the value of everlong-controlled positions by calculating + // the proceeds one would receive from closing a position with the portfolio's + // total amount of bonds and weighted average maturity. + // The weighted average maturity is rounded to the next checkpoint + // timestamp to underestimate the value. + return + balance + + IHyperdrive(hyperdrive).previewCloseLong( + asBase, + IEverlong.Position({ + maturityTime: IHyperdrive(hyperdrive) + .getCheckpointIdUp(_portfolio.avgMaturityTime) + .toUint128(), + bondAmount: _portfolio.totalBonds + }), + "" + ); + } + + /// @dev Rebalance after a deposit if needed. + function _afterDeposit(uint256, uint256) internal virtual override { + if (canRebalance()) { + rebalance(); + } + } + + /// @dev Frees sufficient assets for a withdrawal by closing positions. + /// @param assets Amount of assets owed to the withdrawer. + function _beforeWithdraw( + uint256 assets, + uint256 + ) internal virtual override { + // Close more positions until sufficient idle to process withdrawal. + _closePositions(assets - ERC20(_asset).balanceOf(address(this))); + } + + // ╭─────────────────────────────────────────────────────────╮ + // │ Rebalancing │ + // ╰─────────────────────────────────────────────────────────╯ + + /// @notice Rebalance the everlong portfolio by closing mature positions + /// and using the proceeds to open new positions. + function rebalance() public override { + // Close matured positions. + _closeMaturedPositions(); + + // Spend idle on opening a new position. Leave an extra wei for the + // approval to keep the slot warm. + uint256 toSpend = ERC20(_asset).balanceOf(address(this)); + ERC20(_asset).forceApprove(address(hyperdrive), toSpend + 1); + (uint256 maturityTime, uint256 bondAmount) = IHyperdrive(hyperdrive) + .openLong(asBase, toSpend, ""); + + // Account for the new position in the portfolio. + _portfolio.handleOpenPosition(maturityTime, bondAmount); + } + + // FIXME: Consider idle liquidity + maybe maxLong? + // + /// @notice Returns whether the portfolio needs rebalancing. + /// @return True if the portfolio needs rebalancing, false otherwise. + function canRebalance() public view returns (bool) { + return true; + } + + // ╭─────────────────────────────────────────────────────────╮ + // │ Hyperdrive │ + // ╰─────────────────────────────────────────────────────────╯ + + /// @dev Close only matured positions in the portfolio. + /// @return output Proceeds of closing the matured positions. + function _closeMaturedPositions() internal returns (uint256 output) { + IEverlong.Position memory position; + while (!_portfolio.isEmpty()) { + position = _portfolio.head(); + if (!IHyperdrive(hyperdrive).isMature(position)) { + return output; + } + output += IHyperdrive(hyperdrive).closeLong(asBase, position, ""); + _portfolio.handleClosePosition(); + } + return output; + } + + /// @dev Close positions until the targeted amount of output is received. + /// @param _targetOutput Minimum amount of proceeds to receive. + /// @return output Total output received from closed positions. + function _closePositions( + uint256 _targetOutput + ) internal returns (uint256 output) { + while (!_portfolio.isEmpty() && output < _targetOutput) { + output += IHyperdrive(hyperdrive).closeLong( + asBase, + _portfolio.head(), + "" + ); + _portfolio.handleClosePosition(); + } + return output; } // ╭─────────────────────────────────────────────────────────╮ // │ Getters │ // ╰─────────────────────────────────────────────────────────╯ - /// @notice Gets the Everlong instance's kind. - /// @return The Everlong instance's kind. - function kind() external view returns (string memory) { - return EVERLONG_KIND; + /// @notice Name of the Everlong token. + /// @return Name of the Everlong token. + function name() public view override returns (string memory) { + return _name; + } + + /// @notice Symbol of the Everlong token. + /// @return Symbol of the Everlong token. + function symbol() public view override returns (string memory) { + return _symbol; + } + + /// @dev The underlying asset decimals. + /// @return The underlying asset decimals. + function _underlyingDecimals() + internal + view + virtual + override + returns (uint8) + { + return _decimals; + } + + /// @dev The decimal offset used for virtual shares. + /// @return The decimal offset used for virtual shares. + function _decimalsOffset() internal view virtual override returns (uint8) { + return decimalsOffset; + } + + /// @notice Address of the token used to interact with the Hyperdrive instance. + /// @return Address of the token used to interact with the Hyperdrive instance. + function asset() public view override returns (address) { + return address(_asset); + } + + /// @notice Returns whether the portfolio has matured positions. + /// @return True if the portfolio has matured positions, false otherwise. + function hasMaturedPositions() external view returns (bool) { + return IHyperdrive(hyperdrive).isMature(_portfolio.head()); + } + + /// @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. + function positionAt( + uint256 _index + ) external view returns (IEverlong.Position memory) { + return _portfolio.at(_index); + } + + /// @notice Returns how many positions are currently in the queue. + /// @return The queue's position count. + function positionCount() external view returns (uint256) { + return _portfolio.positionCount(); } - /// @notice Gets the Everlong instance's version. - /// @return The Everlong instance's version. - function version() external view returns (string memory) { - return EVERLONG_VERSION; + /// @notice Calculates the estimated value of the position at _index. + /// @param _index Location of the position to value. + /// @return Estimated proceeds of closing the position. + function positionValue(uint256 _index) external view returns (uint256) { + return + IHyperdrive(hyperdrive).previewCloseLong( + asBase, + _portfolio.at(_index), + "" + ); } - /// @notice Gets the address of the underlying Hyperdrive Instance. - function hyperdrive() external view returns (address) { - return _hyperdrive; + /// @notice Weighted average maturity timestamp of the portfolio. + /// @return Weighted average maturity timestamp of the portfolio. + function avgMaturityTime() external view returns (uint128) { + return _portfolio.avgMaturityTime; } - /// @notice Gets whether Everlong uses Hyperdrive's base token to - /// transact. - function asBase() external view returns (bool) { - return _asBase; + /// @notice Total quantity of bonds held in the portfolio. + /// @return Total quantity of bonds held in the portfolio. + function totalBonds() external view returns (uint128) { + return _portfolio.totalBonds; } } diff --git a/contracts/interfaces/IEverlong.sol b/contracts/interfaces/IEverlong.sol index f37f3a9..99db4f0 100644 --- a/contracts/interfaces/IEverlong.sol +++ b/contracts/interfaces/IEverlong.sol @@ -1,31 +1,43 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.20; -import { IERC4626 } from "openzeppelin/interfaces/IERC4626.sol"; +import { ERC4626 } from "solady/tokens/ERC4626.sol"; import { IEverlongAdmin } from "./IEverlongAdmin.sol"; import { IEverlongEvents } from "./IEverlongEvents.sol"; -import { IEverlongPositions } from "./IEverlongPositions.sol"; +import { IEverlongPortfolio } from "./IEverlongPortfolio.sol"; -interface IEverlong is +abstract contract IEverlong is + ERC4626, IEverlongAdmin, - IERC4626, IEverlongEvents, - IEverlongPositions + IEverlongPortfolio { + // ╭─────────────────────────────────────────────────────────╮ + // │ Structs │ + // ╰─────────────────────────────────────────────────────────╯ + + /// @notice Contains the information needed to identify an open Hyperdrive position. + struct Position { + /// @notice Time when the position matures. + uint128 maturityTime; + /// @notice Amount of bonds in the position. + uint128 bondAmount; + } + // ╭─────────────────────────────────────────────────────────╮ // │ Getters │ // ╰─────────────────────────────────────────────────────────╯ /// @notice Gets the address of the underlying Hyperdrive Instance - function hyperdrive() external view returns (address); + function hyperdrive() external view virtual returns (address); /// @notice Gets the Everlong instance's kind. /// @return The Everlong instance's kind. - function kind() external pure returns (string memory); + function kind() external pure virtual returns (string memory); /// @notice Gets the Everlong instance's version. /// @return The Everlong instance's version. - function version() external pure returns (string memory); + function version() external pure virtual returns (string memory); // ╭─────────────────────────────────────────────────────────╮ // │ Errors │ @@ -35,18 +47,4 @@ interface IEverlong is /// @notice Thrown when caller is not the admin. error Unauthorized(); - - // ── Positions ────────────────────────────────────────────── - - /// @notice Thrown when attempting to insert a position with - /// a `maturityTime` sooner than the most recent position's. - error InconsistentPositionMaturity(); - - /// @notice Thrown when attempting to close a position with - /// a `bondAmount` greater than that contained by the position. - error InconsistentPositionBondAmount(); - - /// @notice Thrown when a target idle amount is too high to be reached - /// even after closing all positions. - error TargetIdleTooHigh(); } diff --git a/contracts/interfaces/IEverlongEvents.sol b/contracts/interfaces/IEverlongEvents.sol index 983638d..ed8bcb4 100644 --- a/contracts/interfaces/IEverlongEvents.sol +++ b/contracts/interfaces/IEverlongEvents.sol @@ -10,25 +10,12 @@ interface IEverlongEvents { // ── Positions ────────────────────────────────────────────── /// @notice Emitted when a new position is added to the bond portfolio. - /// @dev This event will only be emitted with new `maturityTime`s in the portfolio. /// TODO: Reconsider naming https://github.com/delvtech/hyperdrive/pull/1096#discussion_r1681337414 - event PositionOpened( - uint128 indexed maturityTime, - uint128 bondAmount, - uint256 index - ); - - /// @notice Emitted when an existing position's `bondAmount` is modified. - /// TODO: Reconsider naming https://github.com/delvtech/hyperdrive/pull/1096#discussion_r1681337414 - event PositionUpdated( - uint128 indexed maturityTime, - uint128 newBondAmount, - uint256 index - ); + event PositionOpened(uint128 indexed maturityTime, uint128 bondAmount); /// @notice Emitted when an existing position is closed. /// TODO: Reconsider naming https://github.com/delvtech/hyperdrive/pull/1096#discussion_r1681337414 - event PositionClosed(uint128 indexed maturityTime); + event PositionClosed(uint128 indexed maturityTime, uint128 bondAmount); /// @notice Emitted when Everlong's underlying portfolio is rebalanced. event Rebalanced(); diff --git a/contracts/interfaces/IEverlongPositions.sol b/contracts/interfaces/IEverlongPortfolio.sol similarity index 79% rename from contracts/interfaces/IEverlongPositions.sol rename to contracts/interfaces/IEverlongPortfolio.sol index 6759fbf..74cf25b 100644 --- a/contracts/interfaces/IEverlongPositions.sol +++ b/contracts/interfaces/IEverlongPortfolio.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.20; -import { Position } from "../types/Position.sol"; +import { IEverlong } from "./IEverlong.sol"; -interface IEverlongPositions { +interface IEverlongPortfolio { // ╭─────────────────────────────────────────────────────────╮ // │ Stateful │ // ╰─────────────────────────────────────────────────────────╯ @@ -17,25 +17,20 @@ interface IEverlongPositions { /// @notice Gets the number of positions managed by the Everlong instance. /// @return The number of positions. - function getPositionCount() external view returns (uint256); + function positionCount() external view returns (uint256); /// @notice Gets the position at an index. /// Position `maturityTime` increases with each index. /// @param _index The index of the position. /// @return The position. - function getPosition( + function positionAt( uint256 _index - ) external view returns (Position memory); + ) external view returns (IEverlong.Position memory); /// @notice Determines whether any positions are matured. /// @return True if any positions are matured, false otherwise. function hasMaturedPositions() external view returns (bool); - /// @notice Determines whether Everlong has sufficient excess liquidity - /// for opening a long. - /// @return True if sufficient excess liquidity, false otherwise. - function hasSufficientExcessLiquidity() external view returns (bool); - /// @notice Determines whether Everlong's portfolio can currently be rebalanced. /// @return True if the portfolio can be rebalanced, false otherwise. function canRebalance() external view returns (bool); diff --git a/contracts/internal/EverlongAdmin.sol b/contracts/internal/EverlongAdmin.sol deleted file mode 100644 index 95bda9a..0000000 --- a/contracts/internal/EverlongAdmin.sol +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.22; - -import { IEverlong } from "../interfaces/IEverlong.sol"; -import { IEverlongAdmin } from "../interfaces/IEverlongAdmin.sol"; -import { EverlongBase } from "../internal/EverlongBase.sol"; - -/// @author DELV -/// @title EverlongAdmin -/// @notice Permissioning for Everlong. -/// @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. -abstract contract EverlongAdmin is EverlongBase, IEverlongAdmin { - // ╭─────────────────────────────────────────────────────────╮ - // │ Modifiers │ - // ╰─────────────────────────────────────────────────────────╯ - - /// @dev Ensures that the contract is being called by admin. - modifier onlyAdmin() { - if (msg.sender != _admin) { - revert IEverlong.Unauthorized(); - } - _; - } - - // ╭─────────────────────────────────────────────────────────╮ - // │ Stateful │ - // ╰─────────────────────────────────────────────────────────╯ - - /// @inheritdoc IEverlongAdmin - function setAdmin(address admin_) external onlyAdmin { - _admin = admin_; - emit AdminUpdated(admin_); - } - - // ╭─────────────────────────────────────────────────────────╮ - // │ Getters │ - // ╰─────────────────────────────────────────────────────────╯ - - /// @inheritdoc IEverlongAdmin - function admin() external view returns (address) { - return _admin; - } -} diff --git a/contracts/internal/EverlongBase.sol b/contracts/internal/EverlongBase.sol deleted file mode 100644 index 4e93098..0000000 --- a/contracts/internal/EverlongBase.sol +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.22; - -import { DoubleEndedQueue } from "openzeppelin/utils/structs/DoubleEndedQueue.sol"; -import { IEverlongEvents } from "../interfaces/IEverlongEvents.sol"; -import { EverlongStorage } from "./EverlongStorage.sol"; - -// TODO: Reassess whether centralized configuration management makes sense. -// https://github.com/delvtech/everlong/pull/2#discussion_r1703799747 -/// @author DELV -/// @title EverlongBase -/// @notice Base contract for Everlong. -/// @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. -abstract contract EverlongBase is EverlongStorage, IEverlongEvents { - using DoubleEndedQueue for DoubleEndedQueue.Bytes32Deque; - - // ╭─────────────────────────────────────────────────────────╮ - // │ Stateful │ - // ╰─────────────────────────────────────────────────────────╯ - - /// @dev Rebalances the Everlong bond portfolio if needed. - function _rebalance() internal virtual; - - /// @dev Close positions until sufficient idle liquidity is held. - /// @dev Reverts if the target is unreachable. - /// @param _target Target amount of idle liquidity to reach. - /// @return idle Amount of idle after the increase. - function _increaseIdle( - uint256 _target - ) internal virtual returns (uint256 idle); -} diff --git a/contracts/internal/EverlongERC4626.sol b/contracts/internal/EverlongERC4626.sol deleted file mode 100644 index 211aedb..0000000 --- a/contracts/internal/EverlongERC4626.sol +++ /dev/null @@ -1,158 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.22; - -import { IHyperdrive } from "hyperdrive/contracts/src/interfaces/IHyperdrive.sol"; -import { HyperdriveUtils } from "hyperdrive/test/utils/HyperdriveUtils.sol"; -import { ERC4626 } from "solady/tokens/ERC4626.sol"; -import { EverlongBase } from "./EverlongBase.sol"; - -/// @author DELV -/// @title EverlongERC4626 -/// @notice Everlong ERC4626 implementation. -/// @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. -abstract contract EverlongERC4626 is ERC4626, EverlongBase { - // ╭─────────────────────────────────────────────────────────╮ - // │ Stateful │ - // ╰─────────────────────────────────────────────────────────╯ - - /// @inheritdoc ERC4626 - function redeem( - uint256 shares, - address to, - address owner - ) public override returns (uint256 assets) { - // Execute the original ERC4626 `redeem(...)` logic which includes - // calling the `_beforeWithdraw(...)` hook to ensure there is - // sufficient idle liquidity to service the redemption. - assets = super.redeem(shares, to, owner); - - // Rebalance Everlong's positions by closing any matured positions - // and reinvesting the proceeds in new bonds. - _rebalance(); - } - - // TODO: Implement. - /// @inheritdoc ERC4626 - function mint(uint256, address) public override returns (uint256 assets) { - revert("mint not implemented, please use deposit"); - } - - // TODO: Implement. - /// @inheritdoc ERC4626 - function withdraw( - uint256, - address, - address - ) public override returns (uint256 shares) { - revert("withdraw not implemented, please use redeem"); - } - - // ╭─────────────────────────────────────────────────────────╮ - // │ Overrides │ - // ╰─────────────────────────────────────────────────────────╯ - - // TODO: Actually estimate this based on current position contents. - // Currently, this is obtained from tracking deposits - withdrawals - // which results in Everlong taking all profit and users receiving - // exactly what they put in. - /// @notice Returns the total value of assets managed by Everlong. - /// @return Total managed asset value. - function totalAssets() public view override returns (uint256) { - return _virtualAssets; - } - - /// @inheritdoc ERC4626 - function maxDeposit( - address - ) public view override returns (uint256 maxAssets) { - maxAssets = HyperdriveUtils.calculateMaxLong(IHyperdrive(_hyperdrive)); - } - - /// @dev Decrement the virtual assets and close sufficient positions to - /// service the withdrawal. - /// @param _assets Amount of assets owed to the withdrawer. - function _beforeWithdraw( - uint256 _assets, - uint256 - ) internal virtual override { - // TODO: Re-evaluate this accounting logic after discussing - // withdrawal shares and whether to close immature positions. - // - // Increase the virtual value of Everlong controlled assets by the - // amount deposited. - _virtualAssets -= _assets; - - // Close remaining positions until Everlong's balance is enough to - // meet the withdrawal. - _increaseIdle(_assets); - } - - /// @dev Increment the virtual assets and rebalance positions to use - /// the newly-deposited liquidity. - /// @param _assets Amount of assets deposited. - function _afterDeposit(uint256 _assets, uint256) internal virtual override { - // TODO: Re-evaluate this accounting logic after discussing - // withdrawal shares and whether to close immature positions. - // - // Increase the virtual value of Everlong controlled assets by the - // amount deposited. - _virtualAssets += _assets; - - // Rebalance the Everlong portfolio. - _rebalance(); - } - - // ╭─────────────────────────────────────────────────────────╮ - // │ Internal │ - // ╰─────────────────────────────────────────────────────────╯ - - /// @dev Returns whether virtual shares will be used to mitigate the inflation attack. - /// @dev See: https://github.com/OpenZeppelin/openzeppelin-contracts/issues/3706 - /// @dev MUST NOT revert. - function _useVirtualShares() internal view virtual override returns (bool) { - return useVirtualShares; - } - - /// @dev Returns the number of decimals of the underlying asset. - /// @dev MUST NOT revert. - function _underlyingDecimals() - internal - view - virtual - override - returns (uint8) - { - return _decimals; - } - - /// @dev A non-zero value used to make the inflation attack even more unfeasible. - /// @dev MUST NOT revert. - function _decimalsOffset() internal view virtual override returns (uint8) { - return decimalsOffset; - } - - // ╭─────────────────────────────────────────────────────────╮ - // │ Getters │ - // ╰─────────────────────────────────────────────────────────╯ - - /// @notice Address of the token to use for deposits/withdrawals. - /// @dev MUST be an ERC20 token contract. - /// @return Asset address. - function asset() public view virtual override returns (address) { - return _asset; - } - - /// @notice Returns the name of the Everlong token. - /// @return Everlong token name. - function name() public view virtual override returns (string memory) { - return _name; - } - - /// @notice Returns the symbol of the Everlong token. - /// @return Everlong token symbol. - function symbol() public view virtual override returns (string memory) { - return _symbol; - } -} diff --git a/contracts/internal/EverlongPositions.sol b/contracts/internal/EverlongPositions.sol deleted file mode 100644 index eab954c..0000000 --- a/contracts/internal/EverlongPositions.sol +++ /dev/null @@ -1,354 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.22; - -import { IHyperdrive } from "hyperdrive/contracts/src/interfaces/IHyperdrive.sol"; -import { DoubleEndedQueue } from "openzeppelin/utils/structs/DoubleEndedQueue.sol"; -import { IERC20 } from "openzeppelin/interfaces/IERC20.sol"; -import { IEverlong } from "../interfaces/IEverlong.sol"; -import { IEverlongPositions } from "../interfaces/IEverlongPositions.sol"; -import { Position } from "../types/Position.sol"; -import { EverlongBase } from "./EverlongBase.sol"; - -/// @author DELV -/// @title EverlongPositions -/// @notice Everlong bond position management. -/// @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. -abstract contract EverlongPositions is EverlongBase, IEverlongPositions { - using DoubleEndedQueue for DoubleEndedQueue.Bytes32Deque; - - // ╭─────────────────────────────────────────────────────────╮ - // │ Stateful │ - // ╰─────────────────────────────────────────────────────────╯ - - /// @inheritdoc IEverlongPositions - function rebalance() external { - _rebalance(); - } - - /// @dev Rebalances the Everlong bond portfolio if needed. - function _rebalance() internal override { - // Close all mature positions (if present) so that the proceeds can be - // used to purchase longs. - if (hasMaturedPositions()) { - _closeMaturedPositions(); - } - - // Spend Everlong's excess idle liquidity (if sufficient) on opening a long. - if (hasSufficientExcessLiquidity()) { - _spendExcessLiquidity(); - } - - // Emit the `Rebalanced()` event. - emit Rebalanced(); - } - - // ╭─────────────────────────────────────────────────────────╮ - // │ Virtual │ - // ╰─────────────────────────────────────────────────────────╯ - - // TODO: Implement idle liquidity and possibly remove. - /// @dev Calculates the amount of excess liquidity that can be spent opening longs. - /// @dev Can be overridden by child contracts. - /// @return Amount of excess liquidity that can be spent opening longs. - function _excessLiquidity() internal view virtual returns (uint256) { - // Return the current balance of the contract. - return IERC20(_asset).balanceOf(address(this)); - } - - // TODO: Come up with a safer value or remove. - /// @dev Calculates the minimum `openLong` output from Hyperdrive - /// given the amount of capital being spend. - /// @dev Can be overridden by child contracts. - /// @param _amount Amount of capital provided for `openLong`. - /// @return Minimum number of bonds to receive from `openLong`. - function _minOpenLongOutput( - uint256 _amount - ) internal view virtual returns (uint256) { - return 0; - } - - // TODO: Come up with a safer value or remove. - /// @dev Calculates the minimum vault share price at which to - /// open the long. - /// @dev Can be overridden by child contracts. - /// @param _amount Amount of capital provided for `openLong`. - /// @return minimum vault share price for `openLong`. - function _minVaultSharePrice( - uint256 _amount - ) internal view virtual returns (uint256) { - return 0; - } - - // TODO: Come up with a safer value or remove. - /// @dev Calculates the minimum proceeds Everlong will accept for - /// closing the long. - /// @dev Can be overridden by child contracts. - /// @param _maturityTime Maturity time of the long to close. - /// @param _bondAmount Amount of bonds to close. - function _minCloseLongOutput( - uint256 _maturityTime, - uint256 _bondAmount - ) internal view returns (uint256) { - return 0; - } - - // ╭─────────────────────────────────────────────────────────╮ - // │ Position Opening (Internal) │ - // ╰─────────────────────────────────────────────────────────╯ - - /// @dev Spend the excess idle liquidity for the Everlong contract. - /// @dev Can be overridden by implementing contracts to configure - /// how much idle to spend and how it is spent. - function _spendExcessLiquidity() internal { - // Open the long position with the available excess liquidity. - // TODO: Worry about slippage. - // TODO: Ensure amount < maxLongAmount - // TODO: Idle liquidity implementation - uint256 _amount = _excessLiquidity(); - IERC20(_asset).approve(_hyperdrive, _amount); - (uint256 _maturityTime, uint256 _bondAmount) = IHyperdrive(_hyperdrive) - .openLong( - _amount, - _minOpenLongOutput(_amount), - _minVaultSharePrice(_amount), - IHyperdrive.Options(address(this), _asBase, "") - ); - - // Update positions to reflect the newly opened long. - _handleOpenLong(uint128(_maturityTime), uint128(_bondAmount)); - } - - /// @dev Account for newly purchased bonds within the `PositionManager`. - /// @param _maturityTime Maturity time for the newly purchased bonds. - /// @param _bondAmountPurchased Amount of bonds purchased. - function _handleOpenLong( - uint128 _maturityTime, - uint128 _bondAmountPurchased - ) internal { - // Revert if the incoming position's `maturityTime` - // is sooner than the most recently added position's maturity. - if ( - _positions.length() != 0 && - _decodePosition(_positions.back()).maturityTime > _maturityTime - ) { - revert IEverlong.InconsistentPositionMaturity(); - } - // A position already exists with the incoming `maturityTime`. - // The existing position's `bondAmount` is updated. - else if ( - _positions.length() != 0 && - _decodePosition(_positions.back()).maturityTime == _maturityTime - ) { - Position memory _oldPosition = _decodePosition( - _positions.popBack() - ); - _positions.pushBack( - _encodePosition( - _maturityTime, - _oldPosition.bondAmount + _bondAmountPurchased - ) - ); - emit PositionUpdated( - _maturityTime, - _oldPosition.bondAmount + _bondAmountPurchased, - _positions.length() - 1 - ); - } - // No position exists with the incoming `maturityTime`. - // Push a new position to the end of the queue. - else { - _positions.pushBack( - _encodePosition(_maturityTime, _bondAmountPurchased) - ); - emit PositionOpened( - _maturityTime, - _bondAmountPurchased, - _positions.length() - 1 - ); - } - } - - // ╭─────────────────────────────────────────────────────────╮ - // │ Position Closing (Internal) │ - // ╰─────────────────────────────────────────────────────────╯ - - /// @inheritdoc EverlongBase - function _increaseIdle( - uint256 _target - ) internal override returns (uint256 idle) { - // Obtain the current amount of idle held by Everlong and return if - // it is above the target. - idle = IERC20(_asset).balanceOf(address(this)); - if (idle >= _target) { - return idle; - } - - // Close all matured positions and return if updated idle is above - // the target. - idle += _closeMaturedPositions(); - if (idle >= _target) { - return idle; - } - - // Close immature positions from oldest to newest until idle is - // above the target. - uint256 positionCount = _positions.length(); - Position memory position; - while (positionCount > 0) { - position = getPosition(0); - - // Close the position and add output to the current idle. - idle += IHyperdrive(_hyperdrive).closeLong( - position.maturityTime, - position.bondAmount, - _minCloseLongOutput(position.maturityTime, position.bondAmount), - IHyperdrive.Options(address(this), _asBase, "") - ); - - // Update accounting for the closed position. - _handleCloseLong(uint128(position.bondAmount)); - - // Return if the updated idle is above the target. - if (idle >= _target) { - return idle; - } - - positionCount--; - } - - // Revert since all positions are closed and the target idle is - // has not been met; - revert IEverlong.TargetIdleTooHigh(); - } - - /// @dev Close all matured positions. - /// @return output Output received from closing the positions. - function _closeMaturedPositions() internal returns (uint256 output) { - // Loop through mature positions and close them all. - // TODO: Enable closing of mature positions incrementally to avoid - // the case where the # of mature positions exceeds the max - // gas per block. - Position memory _position; - while (hasMaturedPositions()) { - // Retrieve the oldest matured position and close it. - _position = getPosition(0); - output += IHyperdrive(_hyperdrive).closeLong( - _position.maturityTime, - _position.bondAmount, - _minCloseLongOutput( - _position.maturityTime, - _position.bondAmount - ), - IHyperdrive.Options(address(this), _asBase, "") - ); - - // Update positions to reflect the newly closed long. - _handleCloseLong(uint128(_position.bondAmount)); - } - } - - // PERF: Popping then pushing the same position is inefficient. - /// @dev Account for closed bonds at the oldest `maturityTime` - /// within the `PositionManager`. - /// @param _bondAmountClosed Amount of bonds closed. - function _handleCloseLong(uint128 _bondAmountClosed) internal { - // Remove the oldest position from the front queue. - Position memory _position = _decodePosition(_positions.popFront()); - - // Compare the input bond amount to the most mature position's - // `bondAmount`. - if (_bondAmountClosed > _position.bondAmount) { - revert IEverlong.InconsistentPositionBondAmount(); - } - // The amount to close equals the position size. - // Nothing further needs to be done. - else if (_bondAmountClosed == _position.bondAmount) { - emit PositionClosed(_position.maturityTime); - } else { - // The amount to close is not equal to the position size. - // Push the position less the amount of longs closed to the front. - _positions.pushFront( - _encodePosition( - _position.maturityTime, - _position.bondAmount - _bondAmountClosed - ) - ); - emit PositionUpdated( - _position.maturityTime, - _position.bondAmount - _bondAmountClosed, - 0 - ); - } - } - - // ╭─────────────────────────────────────────────────────────╮ - // │ Position Encoding/Decoding │ - // ╰─────────────────────────────────────────────────────────╯ - - /// @dev Encodes the `_maturityTime` and `_bondAmount` into bytes32 - /// to store in the queue. - /// @param _maturityTime Timestamp the position matures. - /// @param _bondAmount Amount of bonds in the position. - /// @return The bytes32 encoded `Position`. - function _encodePosition( - uint128 _maturityTime, - uint128 _bondAmount - ) public pure returns (bytes32) { - return bytes32((uint256(_maturityTime) << 128) | uint256(_bondAmount)); - } - - /// @dev Decodes the bytes32 data into a `Position` struct. - /// @param _position The bytes32 encoded data. - /// @return The decoded `Position`. - function _decodePosition( - bytes32 _position - ) public pure returns (Position memory) { - uint128 _maturityTime = uint128(uint256(_position) >> 128); - uint128 _bondAmount = uint128(uint256(_position)); - return Position(_maturityTime, _bondAmount); - } - - // ╭─────────────────────────────────────────────────────────╮ - // │ Getters │ - // ╰─────────────────────────────────────────────────────────╯ - - /// @inheritdoc IEverlongPositions - function getPositionCount() public view returns (uint256) { - return _positions.length(); - } - - /// @inheritdoc IEverlongPositions - function getPosition( - uint256 _index - ) public view returns (Position memory position) { - position = _decodePosition(_positions.at(_index)); - } - - /// @inheritdoc IEverlongPositions - function hasMaturedPositions() public view returns (bool) { - // Return false if there are no positions. - if (_positions.length() == 0) return false; - - // Return true if the current block timestamp is after - // the oldest position's `maturityTime`. - return (_decodePosition(_positions.at(0)).maturityTime <= - block.timestamp); - } - - /// @inheritdoc IEverlongPositions - function hasSufficientExcessLiquidity() public view returns (bool) { - // Return whether the current excess liquidity is greater than - // Hyperdrive's minimum transaction amount. - return - _excessLiquidity() >= - IHyperdrive(_hyperdrive).getPoolConfig().minimumTransactionAmount; - } - - // TODO: Consider storing hyperdrive's minimumTransactionAmount. - /// @inheritdoc IEverlongPositions - function canRebalance() public view returns (bool) { - return hasMaturedPositions() || hasSufficientExcessLiquidity(); - } -} diff --git a/contracts/internal/EverlongStorage.sol b/contracts/internal/EverlongStorage.sol deleted file mode 100644 index 1ab84dd..0000000 --- a/contracts/internal/EverlongStorage.sol +++ /dev/null @@ -1,64 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity 0.8.22; - -import { DoubleEndedQueue } from "openzeppelin/utils/structs/DoubleEndedQueue.sol"; - -// TODO: Reassess whether centralized configuration management makes sense. -// https://github.com/delvtech/everlong/pull/2#discussion_r1703799747 -/// @author DELV -/// @title EverlongStorage -/// @notice Base contract for Everlong. -/// @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. -abstract contract EverlongStorage { - using DoubleEndedQueue for DoubleEndedQueue.Bytes32Deque; - // ── Admin ────────────────────────────────────────────────── - - /// @dev Address of the contract admin. - address internal _admin; - - // ── Hyperdrive ───────────────────────────────────────────── - - /// @notice Address of the Hyperdrive instance wrapped by Everlong. - address internal immutable _hyperdrive; - - /// @dev Whether to use Hyperdrive's base token to purchase bonds. - // If false, use the Hyperdrive's `vaultSharesToken`. - bool internal immutable _asBase; - - // ── Positions ────────────────────────────────────────────── - - // TODO: Reassess using a more tailored data structure. - // - /// @dev Utility data structure to manage the position queue. - /// Supports pushing and popping from both the front and back. - DoubleEndedQueue.Bytes32Deque internal _positions; - - // ── ERC4626 ──────────────────────────────────────────────── - - /// @notice Virtual shares are used to mitigate inflation attacks. - bool public constant useVirtualShares = true; - - /// @notice Used to reduce the feasibility of an inflation attack. - /// TODO: Determine the appropriate value for our case. Current value - /// was picked arbitrarily. - uint8 public constant decimalsOffset = 3; - - /// @dev Address of the token to use for Hyperdrive bond purchase/close. - address internal immutable _asset; - - /// @dev Decimals used by the `_asset`. - uint8 internal immutable _decimals; - - // TODO: Remove in favor of more sophisticated position valuation. - // TODO: Use some SafeMath library. - /// @dev Virtual asset count to track amount deposited into Hyperdrive. - uint256 internal _virtualAssets; - - /// @dev Name of the Everlong token. - string internal _name; - - /// @dev Symbol of the Everlong token. - string internal _symbol; -} diff --git a/contracts/libraries/Constants.sol b/contracts/libraries/Constants.sol index 23b41e9..8b97b6e 100644 --- a/contracts/libraries/Constants.sol +++ b/contracts/libraries/Constants.sol @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.20; +uint256 constant ONE = 1e18; + string constant EVERLONG_VERSION = "v0.0.1"; string constant EVERLONG_KIND = "Everlong"; diff --git a/contracts/libraries/HyperdriveExecution.sol b/contracts/libraries/HyperdriveExecution.sol new file mode 100644 index 0000000..c8d6da4 --- /dev/null +++ b/contracts/libraries/HyperdriveExecution.sol @@ -0,0 +1,512 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { IHyperdrive } from "hyperdrive/contracts/src/interfaces/IHyperdrive.sol"; +import { FixedPointMath } from "hyperdrive/contracts/src/libraries/FixedPointMath.sol"; +import { HyperdriveMath } from "hyperdrive/contracts/src/libraries/HyperdriveMath.sol"; +import { SafeCast } from "hyperdrive/contracts/src/libraries/SafeCast.sol"; +import { YieldSpaceMath } from "hyperdrive/contracts/src/libraries/YieldSpaceMath.sol"; +import { ERC20 } from "openzeppelin/token/ERC20/ERC20.sol"; +import { SafeERC20 } from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; +import { Packing } from "openzeppelin/utils/Packing.sol"; +import { IEverlong } from "../interfaces/IEverlong.sol"; +import { IEverlongEvents } from "../interfaces/IEverlongEvents.sol"; +import { ONE } from "./Constants.sol"; + +// TODO: Extract into its own library. +uint256 constant HYPERDRIVE_SHARE_RESERVES_BOND_RESERVES_SLOT = 2; +uint256 constant HYPERDRIVE_LONG_EXPOSURE_LONGS_OUTSTANDING_SLOT = 3; +uint256 constant HYPERDRIVE_SHARE_ADJUSTMENT_SHORTS_OUTSTANDING_SLOT = 4; + +/// @author DELV +/// @title HyperdriveExecutionLibrary +/// @notice Library to handle the execution of trades with hyperdrive. +/// @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. +library HyperdriveExecutionLibrary { + using FixedPointMath for uint256; + using SafeCast for *; + using SafeERC20 for ERC20; + using Packing for bytes32; + + // ╭─────────────────────────────────────────────────────────╮ + // │ Open Long │ + // ╰─────────────────────────────────────────────────────────╯ + + /// @dev Opens a long with hyperdrive using amount. + /// @param _asBase Whether to use hyperdrive's base asset. + /// @param _amount Amount of assets to spend. + /// @return maturityTime Maturity timestamp of the opened position. + /// @return bondAmount Amount of bonds received. + function openLong( + IHyperdrive self, + bool _asBase, + uint256 _amount, + bytes memory // unused extra data + ) internal returns (uint256 maturityTime, uint256 bondAmount) { + // TODO: Slippage + (maturityTime, bondAmount) = self.openLong( + _amount, + 0, + 0, + IHyperdrive.Options(address(this), _asBase, "") + ); + emit IEverlongEvents.PositionOpened( + maturityTime.toUint128(), + bondAmount.toUint128() + ); + } + + /// @dev Calculates the result of opening a long with hyperdrive. + /// @param _asBase Whether to use hyperdrive's base asset. + /// @param _amount Amount of assets to spend. + /// @return Amount of bonds received. + function previewOpenLong( + IHyperdrive self, + bool _asBase, + uint256 _amount, + bytes memory // unused extra data + ) internal view returns (uint256) { + return + _calculateOpenLong( + self, + _asBase ? self.convertToShares(_amount) : _amount + ); + } + + /// @dev Calculates the amount of output bonds received from opening a + /// long. The process is as follows: + /// 1. Calculates the raw amount using yield space. + /// 2. Subtracts fees. + /// @param _shareAmount Amount of shares being exchanged for bonds. + /// @return Amount of bonds received. + function _calculateOpenLong( + IHyperdrive self, + uint256 _shareAmount + ) internal view returns (uint256) { + // We must load the entire PoolConfig since it contains values from + // immutables without public accessors. + IHyperdrive.PoolConfig memory poolConfig = self.getPoolConfig(); + + // Prepare the necessary information to perform the yield space + // calculation. We save gas by reading storage directly instead of + // retrieving entire PoolInfo struct. + uint256[] memory slots = new uint256[](2); + slots[0] = HYPERDRIVE_SHARE_RESERVES_BOND_RESERVES_SLOT; + slots[1] = HYPERDRIVE_SHARE_ADJUSTMENT_SHORTS_OUTSTANDING_SLOT; + bytes32[] memory values = self.load(slots); + uint256 effectiveShareReserves = HyperdriveMath + .calculateEffectiveShareReserves( + uint128(values[0].extract_32_16(16)), // shareReserves + uint256(uint128(values[1].extract_32_16(16))).toInt256() // shareAdjustment + ); + uint256 bondReserves = uint128(values[0].extract_32_16(0)); + uint256 _vaultSharePrice = vaultSharePrice(self); + + // Calculate the change in hyperdrive's bond reserves given a purchase + // of _shareAmount. This amount is equivalent to the amount of bonds + // the purchaser will receive (not accounting for fees). + uint256 bondReservesDelta = YieldSpaceMath + .calculateBondsOutGivenSharesInDown( + effectiveShareReserves, + bondReserves, + _shareAmount, + // NOTE: Since the bonds traded on the curve are newly minted, + // we use a time remaining of 1. This means that we can use + // `_timeStretch = t * _timeStretch`. + ONE - poolConfig.timeStretch, + _vaultSharePrice, + poolConfig.initialVaultSharePrice + ); + + // Apply fees to the output bond amount and return it. + uint256 spotPrice = HyperdriveMath.calculateSpotPrice( + effectiveShareReserves, + bondReserves, + poolConfig.initialVaultSharePrice, + poolConfig.timeStretch + ); + bondReservesDelta = _calculateOpenLongFees( + _shareAmount, + bondReservesDelta, + _vaultSharePrice, + spotPrice, + poolConfig.fees.curve + ); + return bondReservesDelta; + } + + /// @dev Calculate the fees involved with opening the long and apply them. + /// @param _shareReservesDelta The change in the share reserves without fees. + /// @param _bondReservesDelta The change in the bond reserves without fees. + /// @param _vaultSharePrice The current vault share price. + /// @param _spotPrice The current spot price. + /// @return The change in the bond reserves with fees. + function _calculateOpenLongFees( + uint256 _shareReservesDelta, + uint256 _bondReservesDelta, + uint256 _vaultSharePrice, + uint256 _spotPrice, + uint256 _curveFee + ) internal pure returns (uint256) { + // Calculate the fees charged to the user (curveFee). + uint256 curveFee = _calculateFeesGivenShares( + _shareReservesDelta, + _spotPrice, + _vaultSharePrice, + _curveFee + ); + + // Calculate the impact of the curve fee on the bond reserves. The curve + // fee benefits the LPs by causing less bonds to be deducted from the + // bond reserves. + _bondReservesDelta -= curveFee; + + return (_bondReservesDelta); + } + + // ╭─────────────────────────────────────────────────────────╮ + // │ Close Long │ + // ╰─────────────────────────────────────────────────────────╯ + + /// @dev Close a long with the input position's bond amount and maturity. + /// @param _asBase Whether to receive hyperdrive's base token as output. + /// @param _position Position information used to specify the long to close. + /// @return proceeds The amount of output assets received from closing the long. + function closeLong( + IHyperdrive self, + bool _asBase, + IEverlong.Position memory _position, + bytes memory // unused extradata + ) internal returns (uint256 proceeds) { + // TODO: Slippage + proceeds = self.closeLong( + _position.maturityTime, + _position.bondAmount, + 0, + IHyperdrive.Options(address(this), _asBase, "") + ); + emit IEverlongEvents.PositionClosed( + _position.maturityTime, + _position.bondAmount + ); + } + + /// @dev Calculate the amount of output assets received from closing a + /// long. + /// @dev Always less than or equal to the actual amount of assets received. + /// @param _asBase Whether to receive hyperdrive's base token as output. + /// @param _position Position information used to specify the long to close. + /// @return The amount of output assets received from closing the long. + function previewCloseLong( + IHyperdrive self, + bool _asBase, + IEverlong.Position memory _position, + bytes memory // unused extradata + ) internal view returns (uint256) { + uint256 shareProceeds = _calculateCloseLong(self, _position); + if (_asBase) { + return self.convertToBase(shareProceeds); + } + return shareProceeds; + } + + /// @dev Calculates the amount of output assets received from closing a + /// long. The process is as follows: + /// 1. Calculates the raw amount using yield space. + /// 2. Subtracts fees. + /// 3. Accounts for negative interest. + /// 4. Converts to shares and back to account for any rounding issues. + /// @param _position Position containing information on the long to close. + /// @return The amount of output assets received from closing the long. + function _calculateCloseLong( + IHyperdrive self, + IEverlong.Position memory _position + ) internal view returns (uint256) { + // We must load the entire PoolConfig since it contains values from + // immutables without public accessors. + IHyperdrive.PoolConfig memory poolConfig = self.getPoolConfig(); + + // Prepare the necessary information to perform the yield space + // calculation. We save gas by reading storage directly instead of + // retrieving entire PoolInfo struct. + uint256[] memory slots = new uint256[](2); + slots[0] = HYPERDRIVE_SHARE_RESERVES_BOND_RESERVES_SLOT; + slots[1] = HYPERDRIVE_SHARE_ADJUSTMENT_SHORTS_OUTSTANDING_SLOT; + bytes32[] memory values = self.load(slots); + uint256 effectiveShareReserves = HyperdriveMath + .calculateEffectiveShareReserves( + uint128(values[0].extract_32_16(16)), // shareReserves + uint256(uint128(values[1].extract_32_16(16))).toInt256() // shareAdjustment + ); + uint256 _normalizedTimeRemaining = normalizedTimeRemaining( + self, + _position.maturityTime + ); + + // Hyperdrive uses the vaultSharePrice at the beginning of the + // checkpoint as the open price, and the current vaultSharePrice as + // the close price. + uint256 closeVaultSharePrice = vaultSharePrice(self); + uint256 openVaultSharePrice = getCheckpointDown( + self, + _position.maturityTime - poolConfig.positionDuration + ).vaultSharePrice; + + // Calculate the raw proceeds of the close without fees. + (, , uint256 shareProceeds) = HyperdriveMath.calculateCloseLong( + effectiveShareReserves, + uint128(values[0].extract_32_16(0)), // bondReserves + _position.bondAmount, + _normalizedTimeRemaining, + poolConfig.timeStretch, + closeVaultSharePrice, + poolConfig.initialVaultSharePrice + ); + + // Calculate the fees that should be paid by the trader. The trader + // pays a fee on the curve and flat parts of the trade. Most of the + // fees go the LPs, but a portion goes to governance. + uint256 spotPrice = HyperdriveMath.calculateSpotPrice( + effectiveShareReserves, + uint128(values[0].extract_32_16(0)), // bondReserves + poolConfig.initialVaultSharePrice, + poolConfig.timeStretch + ); + IHyperdrive.Fees memory fees = poolConfig.fees; + ( + uint256 curveFee, // shares + uint256 flatFee // shares + ) = _calculateFeesGivenBonds( + _position.bondAmount, + _normalizedTimeRemaining, + spotPrice, + closeVaultSharePrice, + fees.curve, + fees.flat + ); + + // Subtract fees from the proceeds. + shareProceeds -= curveFee + flatFee; + + // Adjust the proceeds to account for negative interest. + if (closeVaultSharePrice < openVaultSharePrice) { + shareProceeds = shareProceeds.mulDivDown( + closeVaultSharePrice, + openVaultSharePrice + ); + } + + // Correct for any error that crept into the calculation of the share + // amount by converting the shares to base and then back to shares + // using the vault's share conversion logic. + uint256 baseAmount = shareProceeds.mulDown(closeVaultSharePrice); + shareProceeds = self.convertToShares(baseAmount); + + return shareProceeds; + } + + /// @dev Calculates the fees that go to the LPs and governance. + /// @param _bondAmount The amount of bonds being exchanged for shares. + /// @param _normalizedTimeRemaining The normalized amount of time until + /// maturity. + /// @param _spotPrice The price without slippage of bonds in terms of base + /// (base/bonds). + /// @param _vaultSharePrice The current vault share price (base/shares). + /// @return curveFee The curve fee. The fee is in terms of shares. + /// @return flatFee The flat fee. The fee is in terms of shares. + function _calculateFeesGivenBonds( + uint256 _bondAmount, + uint256 _normalizedTimeRemaining, + uint256 _spotPrice, + uint256 _vaultSharePrice, + uint256 _curveFee, + uint256 _flatFee + ) internal pure returns (uint256 curveFee, uint256 flatFee) { + // NOTE: Round up to overestimate the curve fee. + // + // p (spot price) tells us how many base a bond is worth -> p = base/bonds + // 1 - p tells us how many additional base a bond is worth at + // maturity -> (1 - p) = additional base/bonds + // + // The curve fee is taken from the additional base the user gets for + // each bond at maturity: + // + // curve fee = ((1 - p) * phi_curve * d_y * t)/c + // = (base/bonds * phi_curve * bonds * t) / (base/shares) + // = (base/bonds * phi_curve * bonds * t) * (shares/base) + // = (base * phi_curve * t) * (shares/base) + // = phi_curve * t * shares + curveFee = _curveFee + .mulUp(ONE - _spotPrice) + .mulUp(_bondAmount) + .mulDivUp(_normalizedTimeRemaining, _vaultSharePrice); + + // NOTE: Round up to overestimate the flat fee. + // + // The flat portion of the fee is taken from the matured bonds. + // Since a matured bond is worth 1 base, it is appropriate to consider + // d_y in units of base: + // + // flat fee = (d_y * (1 - t) * phi_flat) / c + // = (base * (1 - t) * phi_flat) / (base/shares) + // = (base * (1 - t) * phi_flat) * (shares/base) + // = shares * (1 - t) * phi_flat + uint256 flat = _bondAmount.mulDivUp( + ONE - _normalizedTimeRemaining, + _vaultSharePrice + ); + flatFee = flat.mulUp(_flatFee); + } + + /// @dev Calculates the fees that go to the LPs and governance. + /// @dev See `lib/hyperdrive/contracts/src/internal/HyperdriveBase.sol` + /// for more information. + /// @param _shareAmount The amount of shares exchanged for bonds. + /// @param _spotPrice The price without slippage of bonds in terms of base + /// (base/bonds). + /// @param _vaultSharePrice The current vault share price (base/shares). + /// @return curveFee The curve fee. The fee is in terms of bonds. + function _calculateFeesGivenShares( + uint256 _shareAmount, + uint256 _spotPrice, + uint256 _vaultSharePrice, + uint256 _curveFee + ) internal pure returns (uint256 curveFee) { + // NOTE: Round up to overestimate the curve fee. + // + // Fixed Rate (r) = (value at maturity - purchase price)/(purchase price) + // = (1-p)/p + // = ((1 / p) - 1) + // = the ROI at maturity of a bond purchased at price p + // + // Another way to think about it: + // + // p (spot price) tells us how many base a bond is worth -> p = base/bonds + // 1/p tells us how many bonds a base is worth -> 1/p = bonds/base + // 1/p - 1 tells us how many additional bonds we get for each + // base -> (1/p - 1) = additional bonds/base + // + // The curve fee is taken from the additional bonds the user gets for + // each base: + // + // curve fee = ((1 / p) - 1) * phi_curve * c * dz + // = r * phi_curve * base/shares * shares + // = bonds/base * phi_curve * base + // = bonds * phi_curve + curveFee = (uint256(ONE).divUp(_spotPrice) - ONE) + .mulUp(_curveFee) + .mulUp(_vaultSharePrice) + .mulUp(_shareAmount); + } + + /// @dev Obtains the vaultSharePrice from the hyperdrive instance. + /// @return The current vaultSharePrice. + function vaultSharePrice(IHyperdrive self) internal view returns (uint256) { + return self.convertToBase(ONE); + } + + /// @dev Returns whether a position is mature. + /// @param _position Position to evaluate. + /// @return True if the position is mature false otherwise. + function isMature( + IHyperdrive self, + IEverlong.Position memory _position + ) internal view returns (bool) { + return isMature(self, _position.maturityTime); + } + + /// @dev Returns whether a position is mature. + /// @param _maturity Maturity to evaluate. + /// @return True if the position is mature false otherwise. + function isMature( + IHyperdrive self, + uint256 _maturity + ) internal view returns (bool) { + return normalizedTimeRemaining(self, _maturity) == 0; + } + + /// @dev Converts the duration of the bond to a value between 0 and 1. + /// @param _maturity Maturity time to evaluate. + /// @return timeRemaining The normalized duration of the bond. + function normalizedTimeRemaining( + IHyperdrive self, + uint256 _maturity + ) internal view returns (uint256 timeRemaining) { + // Use the latest checkpoint to calculate the time remaining if the + // bond is not mature. + timeRemaining = _maturity > latestCheckpoint(self) + ? _maturity - latestCheckpoint(self) + : 0; + + // Represent the time remaining as a fraction of the term. + timeRemaining = timeRemaining.divDown( + self.getPoolConfig().positionDuration + ); + + // Since we overestimate the time remaining to underestimate the + // proceeds, there is an edge case where _normalizedTimeRemaining > 1 + // if the position was opened in the same checkpoint. For this case, + // we can just set it to ONE. + if (timeRemaining > ONE) { + timeRemaining = ONE; + } + } + + /// @dev Retrieve the latest checkpoint time from the hyperdrive instance. + /// @return The latest checkpoint time. + function latestCheckpoint( + IHyperdrive self + ) internal view returns (uint256) { + return + HyperdriveMath.calculateCheckpointTime( + uint256(block.timestamp), + self.getPoolConfig().checkpointDuration + ); + } + + /// @dev Returns the closest checkpoint timestamp before _timestamp. + /// @param _timestamp The timestamp to search for checkpoints. + /// @return The closest checkpoint timestamp. + function getCheckpointIdDown( + IHyperdrive self, + uint256 _timestamp + ) internal view returns (uint256) { + return + _timestamp - (_timestamp % self.getPoolConfig().checkpointDuration); + } + + /// @dev Returns the closest checkpoint before _timestamp. + /// @param _timestamp The timestamp to search for checkpoints. + /// @return The closest checkpoint. + function getCheckpointDown( + IHyperdrive self, + uint256 _timestamp + ) internal view returns (IHyperdrive.Checkpoint memory) { + return self.getCheckpoint(getCheckpointIdDown(self, _timestamp)); + } + + /// @dev Returns the closest checkpoint timestamp after _timestamp. + /// @param _timestamp The timestamp to search for checkpoints. + /// @return The closest checkpoint timestamp. + function getCheckpointIdUp( + IHyperdrive self, + uint256 _timestamp + ) internal view returns (uint256) { + uint256 _checkpointDuration = self.getPoolConfig().checkpointDuration; + return + _timestamp + + (_checkpointDuration - (_timestamp % _checkpointDuration)); + } + + /// @dev Returns the closest checkpoint after _timestamp. + /// @param _timestamp The timestamp to search for checkpoints. + /// @return The closest checkpoint. + function getCheckpointUp( + IHyperdrive self, + uint256 _timestamp + ) internal view returns (IHyperdrive.Checkpoint memory) { + return self.getCheckpoint(getCheckpointIdUp(self, _timestamp)); + } +} diff --git a/contracts/libraries/Portfolio.sol b/contracts/libraries/Portfolio.sol new file mode 100644 index 0000000..0ab26ff --- /dev/null +++ b/contracts/libraries/Portfolio.sol @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { FixedPointMath } from "hyperdrive/contracts/src/libraries/FixedPointMath.sol"; +import { SafeCast } from "hyperdrive/contracts/src/libraries/SafeCast.sol"; +import { IEverlong } from "../interfaces/IEverlong.sol"; +import { PositionLibrary } from "./Position.sol"; + +/// @author DELV +/// @title Portfolio +/// @notice Library to handle storage and accounting for a bond portfolio. +/// @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. +library Portfolio { + using FixedPointMath for uint256; + using SafeCast for *; + using PositionLibrary for IEverlong.Position; + + // TODO: Rename me. + // + /// @notice Thrown on attempting to access either end of an empty queue. + error IndexOutOfBounds(); + + // TODO: Rename me. + // + /// @notice Thrown on attempting to remove a position from an empty queue. + error QueueEmpty(); + + // TODO: Rename me. + // + /// @notice Thrown on attempting to add a position to a full queue. + error QueueFull(); + + /// @dev The state of the portfolio which contains a double-ended queue + /// of {IEverlong.Position} along with the portfolio's average + /// maturity, vault share price, and total bond count. + struct State { + /// @dev Starting index for the double-ended queue structure. + uint128 _begin; + /// @dev Ending index for the double-ended queue structure. + uint128 _end; + /// @dev Weighted average maturity time for the portfolio. + uint128 avgMaturityTime; + /// @dev Total bond count of the portfolio. + uint128 totalBonds; + /// @dev Mapping of indices to {IEverlong.Position} for the + /// double-ended queue structure. + mapping(uint256 index => IEverlong.Position) _q; + } + + /// @notice Update portfolio accounting a newly-opened position. + /// @param _maturityTime Maturity of the opened position. + /// @param _bondAmount Amount of bonds in the opened position. + function handleOpenPosition( + State storage self, + uint256 _maturityTime, + uint256 _bondAmount + ) internal { + // Check whether the incoming maturity is already in the portfolio. + // Since the portfolio's positions are stored as a queue (old -> new), + // we need only check the 'tail' position. + if (!isEmpty(self) && tail(self).maturityTime == _maturityTime) { + // The maturity is already present in the portfolio, so update it + // with the additional bonds and the price of those bonds. + tail(self).increase(_bondAmount); + } else { + // The maturity is not in the portfolio, so add a new position. + _addPosition( + self, + IEverlong.Position(uint128(_maturityTime), uint128(_bondAmount)) + ); + } + + // Update the portfolio's weighted averages. + self.avgMaturityTime = uint256(self.avgMaturityTime) + .updateWeightedAverage( + self.totalBonds, + _maturityTime, + _bondAmount, + true + ) + .toUint128(); + + // Update the portfolio's total bond count. + self.totalBonds += uint128(_bondAmount); + } + + /// @notice Update portfolio accounting for a newly-closed position. + /// Since the portfolio handles positions via a queue, the + /// position being closed always the oldest at the head. + function handleClosePosition(State storage self) internal { + IEverlong.Position memory position = _removePosition(self); + self.avgMaturityTime = uint256(self.avgMaturityTime) + .updateWeightedAverage( + self.totalBonds, + position.maturityTime, + position.bondAmount, + false + ) + .toUint128(); + self.totalBonds -= position.bondAmount; + } + + /// @notice Obtain the position at the head of the queue. + /// This is the oldest position in the portfolio. + /// @return Position at the head of the queue. + function head( + State storage self + ) internal view returns (IEverlong.Position memory) { + // Revert if the queue is empty. + if (isEmpty(self)) revert IndexOutOfBounds(); + + // Return the item at the start index. + return self._q[self._begin]; + } + + /// @notice Obtain the position at the tail of the queue. + /// This is the most recent position in the portfolio. + /// @return Position at the tail of the queue. + function tail( + State storage self + ) internal view returns (IEverlong.Position storage) { + // Revert if the queue is empty. + if (isEmpty(self)) revert IndexOutOfBounds(); + + // Return the item at the end index. + unchecked { + return self._q[self._end - 1]; + } + } + + /// @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. + function at( + State storage self, + uint256 _index + ) internal view returns (IEverlong.Position memory) { + // Ensure the requested index is within range. + if (_index >= positionCount(self)) revert IndexOutOfBounds(); + + // Return the position at the specified index. + unchecked { + return self._q[self._begin + uint256(_index)]; + } + } + + /// @notice Returns whether the position queue is empty. + /// @return True if the position queue is empty, false otherwise. + function isEmpty(State storage self) internal view returns (bool) { + return self._end == self._begin; + } + + /// @notice Returns how many positions are currently in the queue. + /// @return The queue's position count. + function positionCount(State storage self) internal view returns (uint256) { + unchecked { + return uint256(self._end - self._begin); + } + } + + /// @dev Push a new {IEverlong.Position} to the position queue. + /// @param value Position to be pushed. + function _addPosition( + State storage self, + IEverlong.Position memory value + ) internal { + unchecked { + uint128 backIndex = self._end; + + // Ensure we haven't run out of indices. + if (backIndex + 1 == self._begin) revert QueueFull(); + + // Update indices to extend the queue. + self._q[backIndex] = value; + self._end = backIndex + 1; + } + } + + /// @dev Pop the oldest {IEverlong.Position} from the position queue. + /// @return value A copy of the position that was just popped. + function _removePosition( + State storage self + ) internal returns (IEverlong.Position memory value) { + unchecked { + uint128 frontIndex = self._begin; + + // Ensure there are items in the queue. + if (frontIndex == self._end) revert QueueEmpty(); + + // TODO: Ensure that we're safe to not fully clear storage here. + // + // Update indices to shrink the queue. + value = self._q[frontIndex]; + delete self._q[frontIndex]; + self._begin = frontIndex + 1; + } + } + + /// @dev Reset the queue, removing all positions. + function _clear(State storage self) internal { + self._begin = 0; + self._end = 0; + } +} diff --git a/contracts/libraries/Position.sol b/contracts/libraries/Position.sol new file mode 100644 index 0000000..0897252 --- /dev/null +++ b/contracts/libraries/Position.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { FixedPointMath } from "hyperdrive/contracts/src/libraries/FixedPointMath.sol"; +import { SafeCast } from "hyperdrive/contracts/src/libraries/SafeCast.sol"; +import { IEverlong } from "../interfaces/IEverlong.sol"; + +/// @author DELV +/// @title PositionLibrary +/// @notice Library for interacting with {IEverlong.Position}s. +/// @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. +library PositionLibrary { + using FixedPointMath for uint256; + using SafeCast for *; + + /// @notice Increase the position's bond count and update the vaultSharePrice + /// to be a weighted average of previous and current prices. + /// @param _bondAmount Amount to increase the position's bond count by. + function increase( + IEverlong.Position storage self, + uint256 _bondAmount + ) internal { + self.bondAmount += _bondAmount.toUint128(); + } + + /// @notice Reset the contents of a position. + function clear(IEverlong.Position storage self) internal { + self.maturityTime = 0; + self.bondAmount = 0; + } +} diff --git a/contracts/types/Position.sol b/contracts/types/Position.sol deleted file mode 100644 index f048202..0000000 --- a/contracts/types/Position.sol +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.20; - -/// @notice Tracks the total amount of bonds managed by Everlong -/// with the same maturityTime. -struct Position { - /// @notice Checkpoint time when the position matures. - uint128 maturityTime; - /// @notice Quantity of bonds in the position. - uint128 bondAmount; -} diff --git a/foundry.toml b/foundry.toml index c8f1e0d..7c68e8f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -14,10 +14,10 @@ cache = true cache_path = 'forge-cache' # Import statement remappings remappings = [ - 'forge-std=lib/forge-std/src', - 'hyperdrive=lib/hyperdrive', - 'openzeppelin=lib/openzeppelin-contracts/contracts', - 'solady=lib/solady/src', + 'forge-std/=lib/forge-std/src', + 'hyperdrive/=lib/hyperdrive', + 'openzeppelin/=lib/openzeppelin-contracts/contracts', + 'solady/=lib/solady/src', ] # gas limit - max u64 gas_limit = "18446744073709551615" diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..2969a5e --- /dev/null +++ b/remappings.txt @@ -0,0 +1,16 @@ +forge-std/=lib/forge-std/src/ +hyperdrive/=lib/hyperdrive/ +openzeppelin/=lib/openzeppelin-contracts/contracts/ +solady/=lib/solady/src/ +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ +ExcessivelySafeCall/=lib/hyperdrive/lib/ExcessivelySafeCall/src/ +aave-v3-core/=lib/hyperdrive/lib/aave-v3-core/ +aave/=lib/hyperdrive/lib/aave-v3-core/contracts/ +ds-test/=lib/hyperdrive/lib/solmate/lib/ds-test/src/ +erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/ +etherfi/=lib/hyperdrive/lib/smart-contracts/ +halmos-cheatcodes/=lib/openzeppelin-contracts/lib/halmos-cheatcodes/src/ +morpho-blue/=lib/hyperdrive/lib/morpho-blue/ +nomad/=lib/hyperdrive/lib/ExcessivelySafeCall/src/ +openzeppelin-contracts/=lib/openzeppelin-contracts/ +solmate/=lib/hyperdrive/lib/solmate/src/ diff --git a/test/exposed/EverlongAdminExposed.sol b/test/exposed/EverlongAdminExposed.sol deleted file mode 100644 index 0be9790..0000000 --- a/test/exposed/EverlongAdminExposed.sol +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.20; - -import { EverlongAdmin } from "../../contracts/internal/EverlongAdmin.sol"; - -/// @title EverlongAdminExposed -/// @dev Exposes all internal functions for the `EverlongAdmin` contract. -abstract contract EverlongAdminExposed is EverlongAdmin { - // TODO: Add exposed internal functions as needed. -} diff --git a/test/exposed/EverlongERC4626Exposed.sol b/test/exposed/EverlongERC4626Exposed.sol index b1945cd..e93c5fd 100644 --- a/test/exposed/EverlongERC4626Exposed.sol +++ b/test/exposed/EverlongERC4626Exposed.sol @@ -1,15 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.20; -import { EverlongERC4626 } from "../../contracts/internal/EverlongERC4626.sol"; +import { Everlong } from "../../contracts/Everlong.sol"; /// @title EverlongERC4626Exposed /// @dev Exposes all internal functions for the `EverlongERC4626` contract. -abstract contract EverlongERC4626Exposed is EverlongERC4626 { +abstract contract EverlongERC4626Exposed is Everlong { function exposed_beforeWithdraw(uint256 _assets, uint256 _shares) public { return _beforeWithdraw(_assets, _shares); } - + function exposed_afterDeposit(uint256 _assets, uint256 _shares) public { return _afterDeposit(_assets, _shares); } diff --git a/test/exposed/EverlongExposed.sol b/test/exposed/EverlongExposed.sol index 3561059..3a778b7 100644 --- a/test/exposed/EverlongExposed.sol +++ b/test/exposed/EverlongExposed.sol @@ -2,29 +2,62 @@ pragma solidity ^0.8.20; import { Test } from "forge-std/Test.sol"; +import { IEverlong } from "../../contracts/interfaces/IEverlong.sol"; +import { Portfolio } from "../../contracts/libraries/Portfolio.sol"; import { Everlong } from "../../contracts/Everlong.sol"; -import { EverlongAdminExposed } from "./EverlongAdminExposed.sol"; -import { EverlongERC4626Exposed } from "./EverlongERC4626Exposed.sol"; -import { EverlongPositionsExposed } from "./EverlongPositionsExposed.sol"; /// @title EverlongExposed /// @dev Exposes all internal functions for the `Everlong` contract. -contract EverlongExposed is - EverlongAdminExposed, - EverlongERC4626Exposed, - EverlongPositionsExposed, - Everlong, - Test -{ +contract EverlongExposed is Everlong, Test { + using Portfolio for Portfolio.State; + /// @notice Initial configuration paramters for Everlong. /// @param hyperdrive_ Address of the Hyperdrive instance wrapped by Everlong. /// @param name_ Name of the ERC20 token managed by Everlong. /// @param symbol_ Symbol of the ERC20 token managed by Everlong. + /// @param decimals_ Decimals of the Everlong token and Hyperdrive token. /// @param asBase_ Whether to use Hyperdrive's base token for bond purchases. constructor( string memory name_, string memory symbol_, + uint8 decimals_, address hyperdrive_, bool asBase_ - ) Everlong(name_, symbol_, hyperdrive_, asBase_) {} + ) Everlong(name_, symbol_, decimals_, hyperdrive_, asBase_) {} + + // ╭─────────────────────────────────────────────────────────╮ + // │ ERC4626 │ + // ╰─────────────────────────────────────────────────────────╯ + + function exposed_beforeWithdraw(uint256 _assets, uint256 _shares) public { + return _beforeWithdraw(_assets, _shares); + } + + function exposed_afterDeposit(uint256 _assets, uint256 _shares) public { + return _afterDeposit(_assets, _shares); + } + + // ╭─────────────────────────────────────────────────────────╮ + // │ Portfolio │ + // ╰─────────────────────────────────────────────────────────╯ + + function exposed_handleOpenPosition( + IEverlong.Position memory _position + ) public { + _portfolio.handleOpenPosition( + _position.maturityTime, + _position.bondAmount + ); + } + + function exposed_handleOpenPosition( + uint256 _maturityTime, + uint256 _bondAmount + ) public { + _portfolio.handleOpenPosition(_maturityTime, _bondAmount); + } + + function exposed_handleClosePosition() public { + _portfolio.handleClosePosition(); + } } diff --git a/test/exposed/EverlongPortfolioExposed.sol b/test/exposed/EverlongPortfolioExposed.sol new file mode 100644 index 0000000..231d963 --- /dev/null +++ b/test/exposed/EverlongPortfolioExposed.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { IEverlong } from "../../contracts/interfaces/IEverlong.sol"; +import { Everlong } from "../../contracts/Everlong.sol"; +import { Portfolio } from "../../contracts/libraries/Portfolio.sol"; + +/// @title EverlongPortfolioExposed +/// @dev Exposes all internal functions for the `EverlongPositions` contract. +abstract contract EverlongPortfolioExposed is Everlong { + using Portfolio for Portfolio.State; + + function exposed_handleOpenPosition( + IEverlong.Position memory _position + ) public { + _portfolio.handleOpenPosition( + _position.maturityTime, + _position.bondAmount + ); + } + + function exposed_handleOpenPosition( + uint256 _maturityTime, + uint256 _bondAmount + ) public { + _portfolio.handleOpenPosition(_maturityTime, _bondAmount); + } + + function exposed_handleClosePosition() public { + _portfolio.handleClosePosition(); + } +} diff --git a/test/exposed/EverlongPositionsExposed.sol b/test/exposed/EverlongPositionsExposed.sol deleted file mode 100644 index 6b9d98c..0000000 --- a/test/exposed/EverlongPositionsExposed.sol +++ /dev/null @@ -1,89 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.20; - -import { EverlongPositions } from "../../contracts/internal/EverlongPositions.sol"; - -/// @title EverlongPositionsExposed -/// @dev Exposes all internal functions for the `EverlongPositions` contract. -abstract contract EverlongPositionsExposed is EverlongPositions { - /// @notice Calculates the amount of excess liquidity that can be spent opening longs. - /// @notice Can be overridden by child contracts. - /// @return Amount of excess liquidity that can be spent opening longs. - function exposed_excessLiquidity() public view virtual returns (uint256) { - return _excessLiquidity(); - } - - /// @notice Calculates the minimum `openLong` output from Hyperdrive - /// given the amount of capital being spend. - /// @notice Can be overridden by child contracts. - /// @param _amount Amount of capital provided for `openLong`. - /// @return Minimum number of bonds to receive from `openLong`. - function exposed_minOpenLongOutput( - uint256 _amount - ) public view virtual returns (uint256) { - return _minOpenLongOutput(_amount); - } - - /// @notice Calculates the minimum vault share price at which to - /// open the long. - /// @notice Can be overridden by child contracts. - /// @param _amount Amount of capital provided for `openLong`. - /// @return minimum vault share price for `openLong`. - function exposed_minVaultSharePrice( - uint256 _amount - ) public view virtual returns (uint256) { - return _minVaultSharePrice(_amount); - } - - /// @notice Calculates the minimum proceeds Everlong will accept for - /// closing the long. - /// @notice Can be overridden by child contracts. - /// @param _maturityTime Maturity time of the long to close. - /// @param _bondAmount Amount of bonds to close. - function exposed_minCloseLongOutput( - uint256 _maturityTime, - uint256 _bondAmount - ) public view returns (uint256) { - return _minCloseLongOutput(_maturityTime, _bondAmount); - } - - /// @notice Spend the excess idle liquidity for the Everlong contract. - /// @notice Can be overridden by implementing contracts to configure - /// how much idle to spend and how it is spent. - function exposed_spendExcessLiquidity() public { - return _spendExcessLiquidity(); - } - - /// @notice Account for newly purchased bonds within the `PositionManager`. - /// @param _maturityTime Maturity time for the newly purchased bonds. - /// @param _bondAmountPurchased Amount of bonds purchased. - function exposed_handleOpenLong( - uint128 _maturityTime, - uint128 _bondAmountPurchased - ) public { - return _handleOpenLong(_maturityTime, _bondAmountPurchased); - } - - /// @dev Close positions until sufficient idle liquidity is held. - /// @dev Reverts if the target is unreachable. - /// @param _target Target amount of idle liquidity to reach. - /// @return idle Amount of idle after the increase. - function exposed_increaseIdle( - uint256 _target - ) internal returns (uint256 idle) { - return _increaseIdle(_target); - } - - /// @notice Close all matured positions. - /// @return output Output received from closing the positions. - function exposed_closeMaturedPositions() public returns (uint256 output) { - output = _closeMaturedPositions(); - } - - /// @notice Account for closed bonds at the oldest `maturityTime` - /// within the `PositionManager`. - /// @param _bondAmountClosed Amount of bonds closed. - function exposed_handleCloseLong(uint128 _bondAmountClosed) public { - return _handleCloseLong(_bondAmountClosed); - } -} diff --git a/test/harnesses/EverlongTest.sol b/test/harnesses/EverlongTest.sol index 7ef79e2..1422f02 100644 --- a/test/harnesses/EverlongTest.sol +++ b/test/harnesses/EverlongTest.sol @@ -6,7 +6,7 @@ import { console2 as console } from "forge-std/console2.sol"; import { HyperdriveTest } from "hyperdrive/test/utils/HyperdriveTest.sol"; import { ERC20Mintable } from "hyperdrive/contracts/test/ERC20Mintable.sol"; import { IEverlongEvents } from "../../contracts/interfaces/IEverlongEvents.sol"; -import { Position } from "../../contracts/types/Position.sol"; +import { IEverlong } from "../../contracts/interfaces/IEverlong.sol"; import { EverlongExposed } from "../exposed/EverlongExposed.sol"; // TODO: Refactor this to include an instance of `Everlong` with exposed internal functions. @@ -44,36 +44,93 @@ contract EverlongTest is HyperdriveTest, IEverlongEvents { /// @dev Everlong token symbol. string internal EVERLONG_SYMBOL = "evTest"; + // ╭─────────────────────────────────────────────────────────╮ + // │ Hyperdrive Configuration │ + // ╰─────────────────────────────────────────────────────────╯ + + address internal HYPERDRIVE_INITIALIZER = address(0); + + uint256 internal FIXED_RATE = 0.05e18; + int256 internal VARIABLE_RATE = 0.10e18; + + uint256 internal INITIAL_VAULT_SHARE_PRICE = 1e18; + uint256 internal INITIAL_CONTRIBUTION = 500_000_000e18; + + uint256 internal CURVE_FEE = 0.01e18; + uint256 internal FLAT_FEE = 0.0005e18; + uint256 internal GOVERNANCE_LP_FEE = 0.15e18; + uint256 internal GOVERNANCE_ZOMBIE_FEE = 0.03e18; + function setUp() public virtual override { super.setUp(); - vm.startPrank(deployer); - deploy(); - vm.stopPrank(); } - /// @dev Deploy the Everlong instance with default underlying, name, and symbol. - function deploy() internal { + // ╭─────────────────────────────────────────────────────────╮ + // │ Deploy Helpers │ + // ╰─────────────────────────────────────────────────────────╯ + + /// @dev Deploy the Everlong instance with default underlying, name, + /// and symbol. + function deployEverlong() internal { + // Deploy the hyperdrive instance. + deploy( + deployer, + FIXED_RATE, + INITIAL_VAULT_SHARE_PRICE, + CURVE_FEE, + FLAT_FEE, + GOVERNANCE_LP_FEE, + GOVERNANCE_ZOMBIE_FEE + ); + + // Seed liquidity for the hyperdrive instance. + if (HYPERDRIVE_INITIALIZER == address(0)) { + HYPERDRIVE_INITIALIZER = deployer; + } + initialize(HYPERDRIVE_INITIALIZER, FIXED_RATE, INITIAL_CONTRIBUTION); + + vm.startPrank(deployer); everlong = new EverlongExposed( EVERLONG_NAME, EVERLONG_SYMBOL, + hyperdrive.decimals(), address(hyperdrive), true ); + vm.stopPrank(); + + // Fast forward and accrue some interest. + advanceTimeWithCheckpoints(POSITION_DURATION * 2, VARIABLE_RATE); } - /// @dev Deploy the Everlong instance with custom underlying, name, and symbol. - function deploy( - string memory _name, - string memory _symbol, - address _underlying, - bool _asBase - ) internal { - everlong = new EverlongExposed( - _name, - _symbol, - address(_underlying), - _asBase - ); + // ╭─────────────────────────────────────────────────────────╮ + // │ Deposit Helpers │ + // ╰─────────────────────────────────────────────────────────╯ + + function depositEverlong( + uint256 _amount, + address _depositor + ) internal returns (uint256 shares) { + // Resolve the appropriate token. + ERC20Mintable token = ERC20Mintable(everlong.asset()); + + // Mint sufficient tokens to _depositor. + vm.startPrank(_depositor); + token.mint(_amount); + vm.stopPrank(); + + // Approve everlong as _depositor. + vm.startPrank(_depositor); + token.approve(address(everlong), _amount); + vm.stopPrank(); + + // Make the deposit. + vm.startPrank(_depositor); + shares = everlong.deposit(_amount, _depositor); + vm.stopPrank(); + + // Return the amount of shares issued to _depositor for the deposit. + return shares; } // TODO: This is gross, will refactor @@ -100,8 +157,8 @@ contract EverlongTest is HyperdriveTest, IEverlongEvents { function logPositions() public view { /* solhint-disable no-console */ console.log("-- POSITIONS -------------------------------"); - for (uint128 i = 0; i < everlong.getPositionCount(); ++i) { - Position memory p = everlong.getPosition(i); + for (uint128 i = 0; i < everlong.positionCount(); ++i) { + IEverlong.Position memory p = everlong.positionAt(i); console.log( "index: %s - maturityTime: %s - bondAmount: %s", i, @@ -120,10 +177,10 @@ contract EverlongTest is HyperdriveTest, IEverlongEvents { /// @param _error Message to display for failing assertions. function assertPosition( uint256 _index, - Position memory _position, + IEverlong.Position memory _position, string memory _error - ) public view { - Position memory p = everlong.getPosition(_index); + ) public view virtual { + IEverlong.Position memory p = everlong.positionAt(_index); assertEq(_position.maturityTime, p.maturityTime, _error); assertEq(_position.bondAmount, p.bondAmount, _error); } diff --git a/test/integration/CloseImmatureLongs.t.sol b/test/integration/CloseImmatureLongs.t.sol new file mode 100644 index 0000000..ecf28a9 --- /dev/null +++ b/test/integration/CloseImmatureLongs.t.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { console2 as console } from "forge-std/console2.sol"; +import { IHyperdrive } from "hyperdrive/contracts/src/interfaces/IHyperdrive.sol"; +import { FixedPointMath } from "hyperdrive/contracts/src/libraries/FixedPointMath.sol"; +import { HyperdriveUtils } from "hyperdrive/test/utils/HyperdriveUtils.sol"; +import { Lib } from "hyperdrive/test/utils/Lib.sol"; +import { ERC20Mintable } from "hyperdrive/contracts/test/ERC20Mintable.sol"; +import { EVERLONG_KIND, EVERLONG_VERSION } from "../../contracts/libraries/Constants.sol"; +import { EverlongTest } from "../harnesses/EverlongTest.sol"; +import { Packing } from "openzeppelin/utils/Packing.sol"; + +uint256 constant HYPERDRIVE_SHARE_RESERVES_BOND_RESERVES_SLOT = 2; +uint256 constant HYPERDRIVE_LONG_EXPOSURE_LONGS_OUTSTANDING_SLOT = 3; +uint256 constant HYPERDRIVE_SHARE_ADJUSTMENT_SHORTS_OUTSTANDING_SLOT = 4; + +/// @dev Tests pricing functionality for the portfolio and unmatured positions. +contract PricingTest is EverlongTest { + using Packing for bytes32; + using FixedPointMath for uint128; + using FixedPointMath for uint256; + using Lib for *; + + function test_positive_interest_long_half_term_fees() external { + // This tests the following scenario: + // - initial_vault_share_price > 1 + // - positive interest causes the share price to go up + // - a long is opened + // - positive interest accrues over half term + // - long is closed + { + uint256 initialVaultSharePrice = 1.5e18; + int256 preTradeVariableInterest = 0.10e18; + int256 variableInterest = 0.05e18; + half_term_everlong_fees( + initialVaultSharePrice, + preTradeVariableInterest, + variableInterest + ); + } + + // This tests the following scenario: + // - initial_vault_share_price = 1 + // - positive interest causes the share price to go up + // - a long is opened + // - positive interest accrues over half term + // - long is closed + { + uint256 initialVaultSharePrice = 1e18; + int256 preTradeVariableInterest = 0.10e18; + int256 variableInterest = 0.05e18; + half_term_everlong_fees( + initialVaultSharePrice, + preTradeVariableInterest, + variableInterest + ); + } + + // This tests the following scenario: + // - initial_vault_share_price < 1 + // - positive interest causes the share price to go up + // - a long is opened + // - positive interest accrues over half term + // - long is closed + { + uint256 initialVaultSharePrice = 0.95e18; + int256 preTradeVariableInterest = 0.10e18; + int256 variableInterest = 0.05e18; + half_term_everlong_fees( + initialVaultSharePrice, + preTradeVariableInterest, + variableInterest + ); + } + } + + function test_negative_interest_long_half_term_fees() external { + // This tests the following scenario: + // - initial_vault_share_price > 1 + // - negative interest causes the share price to go down + // - a long is opened + // - negative interest accrues over half term + // - long is closed + { + uint256 initialVaultSharePrice = 1.5e18; + int256 preTradeVariableInterest = -0.10e18; + int256 variableInterest = -0.05e18; + half_term_everlong_fees( + initialVaultSharePrice, + preTradeVariableInterest, + variableInterest + ); + } + + // This tests the following scenario: + // - initial_vault_share_price = 1 + // - negative interest causes the share price to go down + // - a long is opened + // - negative interest accrues over half term + // - long is closed + { + uint256 initialVaultSharePrice = 1e18; + int256 preTradeVariableInterest = -0.10e18; + int256 variableInterest = -0.05e18; + half_term_everlong_fees( + initialVaultSharePrice, + preTradeVariableInterest, + variableInterest + ); + } + + // This tests the following scenario: + // - initial_vault_share_price < 1 + // - negative interest causes the share price to go further down + // - a long is opened + // - negative interest accrues over half term + // - long is closed + { + uint256 initialVaultSharePrice = 0.90e18; + int256 preTradeVariableInterest = -0.10e18; + int256 variableInterest = -0.05e18; + half_term_everlong_fees( + initialVaultSharePrice, + preTradeVariableInterest, + variableInterest + ); + } + } + + function half_term_everlong_fees( + uint256 initialVaultSharePrice, + int256 preTradeVariableInterest, + int256 variableInterest + ) internal { + INITIAL_VAULT_SHARE_PRICE = initialVaultSharePrice; + VARIABLE_RATE = preTradeVariableInterest; + deployEverlong(); + VARIABLE_RATE = variableInterest; + + vm.startPrank(bob); + + // Deposit. + uint256 basePaid = 10_000e18; + ERC20Mintable(everlong.asset()).mint(basePaid); + ERC20Mintable(everlong.asset()).approve(address(everlong), basePaid); + uint256 shares = everlong.deposit(basePaid, bob); + + // half term passes + advanceTimeWithCheckpoints(POSITION_DURATION / 2, variableInterest); + + // Estimate the proceeds. + uint256 estimatedProceeds = everlong.previewRedeem(shares); + console.log("previewRedeem: %s", estimatedProceeds); + console.log("totalAssets: %s", everlong.totalAssets()); + + // Close the long. + uint256 baseProceeds = everlong.redeem(shares, bob, bob); + console.log("actual: %s", baseProceeds); + console.log( + "assets: %s", + ERC20Mintable(everlong.asset()).balanceOf(address(everlong)) + ); + console.log("avg maturity time: %s", everlong.avgMaturityTime()); + console.log("total bonds : %s", everlong.totalBonds()); + if (estimatedProceeds > baseProceeds) { + console.log("DIFFERENCE: %s", estimatedProceeds - baseProceeds); + } + + // logPortfolioMetrics(); + + assertGe(baseProceeds, estimatedProceeds); + assertApproxEqAbs( + baseProceeds, + estimatedProceeds, + 20, + "failed equality" + ); + vm.stopPrank(); + } +} diff --git a/test/units/Everlong.t.sol b/test/units/Everlong.t.sol index e5c40e8..b3b51b4 100644 --- a/test/units/Everlong.t.sol +++ b/test/units/Everlong.t.sol @@ -6,6 +6,11 @@ import { EverlongTest } from "../harnesses/EverlongTest.sol"; /// @dev Tests Everlong functionality. contract TestEverlong is EverlongTest { + function setUp() public override { + super.setUp(); + deployEverlong(); + } + /// @dev Ensure that the `hyperdrive()` view function is implemented. function test_hyperdrive() external view { assertEq( diff --git a/test/units/EverlongAdmin.t.sol b/test/units/EverlongAdmin.t.sol index 1dd4714..ab39bb5 100644 --- a/test/units/EverlongAdmin.t.sol +++ b/test/units/EverlongAdmin.t.sol @@ -6,8 +6,13 @@ import { console2 as console } from "forge-std/console2.sol"; import { EverlongTest } from "../harnesses/EverlongTest.sol"; import { IEverlong } from "../../contracts/interfaces/IEverlong.sol"; -/// @dev Tests EverlongAdmin functionality. +/// @dev Tests Everlong Admin functionality. contract TestEverlongAdmin is EverlongTest { + function setUp() public override { + super.setUp(); + deployEverlong(); + } + /// @dev Validates revert when `setAdmin` called by non-admin. function test_setAdmin_failure_unauthorized() external { // Ensure that an unauthorized user cannot set the admin address. diff --git a/test/units/EverlongERC4626.t.sol b/test/units/EverlongERC4626.t.sol index 3b56d02..de80eb4 100644 --- a/test/units/EverlongERC4626.t.sol +++ b/test/units/EverlongERC4626.t.sol @@ -3,243 +3,93 @@ pragma solidity ^0.8.20; // solhint-disable-next-line no-console, no-unused-import import { console2 as console } from "forge-std/console2.sol"; -import { ERC20Mintable } from "hyperdrive/contracts/test/ERC20Mintable.sol"; import { EverlongTest } from "../harnesses/EverlongTest.sol"; -import { IEverlongEvents } from "../../contracts/interfaces/IEverlongEvents.sol"; /// @dev Tests EverlongERC4626 functionality. +/// @dev Functions not overridden by Everlong are assumed to be functional. contract TestEverlongERC4626 is EverlongTest { - /// @dev Validates behavior of the `deposit()` function for a single - /// depositor. - /// @dev When calling the `deposit()` function... - /// 1. The `Rebalanced` event should be emitted - /// 2. The balance of the Everlong contract should be minimal. - /// 3. The share amount issued to the depositor should be equal to the - /// total supply of Everlong shares. - /// 4. The amount of assets deposited should relate to the share - /// count as follows: - /// shares_issued = assets_deposited * (10 ^ decimalsOffset) - function test_deposit_single_depositor() external { - // Initialize the Hyperdrive instance by adding liquidity from Alice. - uint256 fixedRate = 0.5e18; - uint256 contribution = 10_000e18; - initialize(alice, fixedRate, contribution); - - // Mint bob some assets and approve the Everlong contract. - uint256 depositAmount = 1e18; - mintApproveEverlongBaseAsset(bob, depositAmount); - - // 1. Deposit assets into Everlong as Bob and confirm `Rebalanced` event - // is emitted. - vm.startPrank(bob); - vm.expectEmit(true, true, true, true); - emit IEverlongEvents.Rebalanced(); - everlong.deposit(depositAmount, bob); + /// @dev Performs a redemption while ensuring the preview amount at most + /// equals the actual output and is within tolerance. + /// @param _shares Amount of shares to redeem. + /// @param _redeemer Address of the share holder. + /// @return assets Assets sent to _redeemer from the redemption. + function assertRedemption( + uint256 _shares, + address _redeemer + ) public returns (uint256 assets) { + uint256 preview = everlong.previewRedeem(_shares); + vm.startPrank(_redeemer); + assets = everlong.redeem(_shares, _redeemer, _redeemer); vm.stopPrank(); - - // 2. Confirm that Everlong's balance is less than Hyperdrive's - // minimum transaction amount. - assertLt( - ERC20Mintable(everlong.asset()).balanceOf(address(everlong)), - hyperdrive.getPoolConfig().minimumTransactionAmount, - "Everlong balance should be below min tx amount after single depositor deposit+rebalance" - ); - - // 3. Confirm that Bob's share balance equals the total supply of shares. - assertEq( - everlong.balanceOf(bob), - everlong.totalSupply(), - "single depositor share balance should equal total supply of shares" - ); - - // 4. Confirm `shares_issued = assets_deposited * (10 ^ decimalsOffset)` - assertEq( - everlong.balanceOf(bob), - depositAmount * (10 ** everlong.decimalsOffset()) - ); - } - - /// @dev Validates that `redeem()` will close the necessary positions with a - /// mature position sufficient to cover withdrawal amount. - function test_redeem_close_positions_mature() external { - // Initialize the Hyperdrive instance by adding liquidity from Alice. - uint256 fixedRate = 0.5e18; - uint256 contribution = 10_000e18; - initialize(alice, fixedRate, contribution); - - // Deposit assets into Everlong as Bob. - mintApproveEverlongBaseAsset(bob, 10e18); - vm.startPrank(bob); - everlong.deposit(1e18, bob); - vm.stopPrank(); - - // Advance time by a checkpoint so that future deposits result in - // new positions. - advanceTime(hyperdrive.getPoolConfig().checkpointDuration, 0); - - // Confirm that Everlong currently does not have a matured position. - assertFalse(everlong.hasMaturedPositions()); - - // Deposit assets into Everlong as Celine to create another position. - mintApproveEverlongBaseAsset(celine, 2e18); - vm.startPrank(celine); - everlong.deposit(2e18, celine); - vm.stopPrank(); - - // Confirm that Everlong now has two positions. - assertEq(everlong.getPositionCount(), 2); - - // Advance time to mature Bob's position but not Celine's. - advanceTime(everlong.getPosition(0).maturityTime - block.timestamp, 0); - - // Confirm that Everlong now has a matured position. - assertTrue(everlong.hasMaturedPositions()); - - // Redeem all of Bob's shares. - vm.startPrank(bob); - everlong.redeem(everlong.balanceOf(bob), bob, bob); - vm.stopPrank(); - - // Confirm that Everlong has two immature positions: - // 1. Created from Celine's deposit. - // 2. Created from rebalance on Bob's redemption. - assertEq(everlong.getPositionCount(), 2); - assertGt(everlong.getPosition(0).maturityTime, block.timestamp); + assertLe(preview, assets); + assertApproxEqAbs(preview, assets, 1e9); } - /// @dev Validates that `redeem()` will close all positions when closing - /// an immature position is required to service the withdrawal. - function test_redeem_close_positions_immature() external { - // Initialize the Hyperdrive instance by adding liquidity from Alice. - uint256 fixedRate = 0.5e18; - uint256 contribution = 10_000e18; - initialize(alice, fixedRate, contribution); - - // Mint Bob and Celine some assets and approve the Everlong contract. - mintApproveEverlongBaseAsset(bob, 10e18); - - // Deposit assets into Everlong as Bob. - vm.startPrank(bob); - everlong.deposit(1e18, bob); - vm.stopPrank(); - - // Advance time by a checkpoint so that future deposits result in - // new positions. - advanceTime(hyperdrive.getPoolConfig().checkpointDuration, 0); - - // Confirm that Everlong currently does not have a matured position. - assertFalse(everlong.hasMaturedPositions()); - - // Deposit more assets into Everlong as Bob to create another position. - vm.startPrank(bob); - everlong.deposit(2e18, bob); - vm.stopPrank(); - - // Confirm that Everlong now has two positions. - assertEq(everlong.getPositionCount(), 2); - - // Advance time to mature only the first position. - advanceTime(everlong.getPosition(0).maturityTime - block.timestamp, 0); - - // Confirm that Everlong now has a matured position. - assertTrue(everlong.hasMaturedPositions()); + /// @dev Tests that previewRedeem does not overestimate proceeds for a + /// single shareholder immediately redeeming all their shares. + function test_previewRedeem_single_instant_full() external { + // Deploy the everlong instance. + deployEverlong(); - // Redeem all of Bob's shares. - vm.startPrank(bob); - everlong.redeem(everlong.balanceOf(bob), bob, bob); - vm.stopPrank(); - - // Confirm that Everlong now has only the position created from - // rebalancing on Bob's redemption. - assertEq(everlong.getPositionCount(), 1); - } + // Deposit into everlong. + uint256 amount = 250e18; + uint256 shares = depositEverlong(amount, alice); - /// @dev Ensure that the `asset()` view function is implemented. - function test_asset() external view { - assertEq( - everlong.asset(), - address(hyperdrive.baseToken()), - "asset() should return hyperdrive base token address" - ); + // Ensure that previewRedeem output is at most equal to actual output + // and within margins. + assertRedemption(shares, alice); } - /// @dev Ensure that the `name()` view function is implemented. - function test_name() external view { - assertNotEq(everlong.name(), "", "name() not return an empty string"); - } + /// @dev Tests that previewRedeem does not overestimate proceeds for a + /// single shareholder immediately redeeming part of their shares. + function test_previewRedeem_single_instant_partial() external { + // Deploy the everlong instance. + deployEverlong(); - /// @dev Ensure that the `symbol()` view function is implemented. - function test_symbol() external view { - assertNotEq( - everlong.symbol(), - "", - "symbol() not return an empty string" - ); - } + // Deposit into everlong. + uint256 amount = 250e18; + uint256 shares = depositEverlong(amount, alice); - /// @dev Validates that `maxDeposit(..)` returns a value that is - /// reasonable (less than uint256.max) and actually depositable. - function test_maxDeposit_is_depositable() external { - // Initialize the Hyperdrive instance by adding liquidity from Alice. - uint256 fixedRate = 0.5e18; - uint256 contribution = 10_000e18; - initialize(alice, fixedRate, contribution); - - // Ensure that `maxDeposit(Bob)` is less than uint256.max. - uint256 maxDeposit = everlong.maxDeposit(bob); - assertLt(maxDeposit, type(uint256).max); - - // Attempt to deposit `maxDeposit` as Bob. - mintApproveEverlongBaseAsset(bob, maxDeposit); - vm.startPrank(bob); - everlong.deposit(maxDeposit, bob); - vm.stopPrank(); + // Ensure that previewRedeem output is at most equal to actual output + // and within margins. + assertRedemption(shares / 2, alice); } - /// @dev Validates that `maxDeposit(..)` decreases when a deposit is made. - function test_maxDeposit_decreases_after_deposit() external { - // Initialize the Hyperdrive instance by adding liquidity from Alice. - uint256 fixedRate = 0.5e18; - uint256 contribution = 10_000e18; - initialize(alice, fixedRate, contribution); - - // Make the maximum deposit as Bob. - uint256 maxDeposit = everlong.maxDeposit(bob); - mintApproveEverlongBaseAsset(bob, maxDeposit); - vm.startPrank(bob); - everlong.deposit(maxDeposit, bob); - vm.stopPrank(); + /// @dev Tests that previewRedeem does not overestimate proceeds for a + /// single shareholder waiting half the position duration and + /// redeeming all their shares. + function test_previewRedeem_single_unmatured_full() external { + // Deploy the everlong instance. + deployEverlong(); - // Ensure that the new maximum deposit is less than before. - assertLt( - everlong.maxDeposit(bob), - maxDeposit, - "max deposit should decrease after a deposit is made" - ); - } + // Deposit into everlong. + uint256 amount = 250e18; + uint256 shares = depositEverlong(amount, alice); - /// @dev Validates that `_afterDeposit` increases total assets. - function test_afterDeposit_virtual_asset_increase() external { - // Call `_afterDeposit` with some assets. - everlong.exposed_afterDeposit(5, 1); + // Fast forward to halfway through maturity. + advanceTime(POSITION_DURATION / 2, VARIABLE_RATE); - // Ensure `totalAssets()` is increased by the correct amount. - assertEq(everlong.totalAssets(), 5); + // Ensure that previewRedeem output is at most equal to actual output + // and within margins. + assertRedemption(shares, alice); } - /// @dev Validates that `_beforeWithdraw` decreases total assets. - function test_beforeWithdraw_virtual_asset_decrease() external { - // Call `_afterDeposit` to increase total asset count. - everlong.exposed_afterDeposit(5, 1); + /// @dev Tests that previewRedeem does not overestimate proceeds for a + /// single shareholder waiting half the position duration and + /// redeeming some of their shares. + function test_previewRedeem_single_unmatured_partial() external { + // Deploy the everlong instance. + deployEverlong(); - // Mint Everlong some assets so it can pass the withdrawal - // balance check. - ERC20Mintable(everlong.asset()).mint(address(everlong), 5); + // Deposit into everlong. + uint256 amount = 250e18; + uint256 shares = depositEverlong(amount, alice); - // Call `_beforeWithdraw` to decrease total asset count. - everlong.exposed_beforeWithdraw(5, 1); + // Fast forward to halfway through maturity. + advanceTime(POSITION_DURATION / 2, VARIABLE_RATE); - // Ensure `totalAssets()` is decreased by the correct amount. - assertEq(everlong.totalAssets(), 0); + // Ensure that previewRedeem output is at most equal to actual output + // and within margins. + assertRedemption(shares / 3, alice); } } diff --git a/test/units/EverlongPortfolio.t.sol b/test/units/EverlongPortfolio.t.sol new file mode 100644 index 0000000..7220149 --- /dev/null +++ b/test/units/EverlongPortfolio.t.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +// solhint-disable-next-line no-console, no-unused-import +import { console2 as console } from "forge-std/console2.sol"; +import { ERC20Mintable } from "hyperdrive/contracts/test/ERC20Mintable.sol"; +import { IERC20 } from "openzeppelin/interfaces/IERC20.sol"; +import { IEverlong } from "../../contracts/interfaces/IEverlong.sol"; +import { Portfolio } from "../../contracts/libraries/Portfolio.sol"; +import { EverlongTest } from "../harnesses/EverlongTest.sol"; + +/// @dev Tests for Everlong position management functionality. +contract TestEverlongPositions is EverlongTest { + using Portfolio for Portfolio.State; + + Portfolio.State public portfolio; + + /// @dev Asserts that the position at the specified index is equal + /// to the input `position`. + /// @param _index Index of the position to compare. + /// @param _position Input position to validate against + /// @param _error Message to display for failing assertions. + function assertPosition( + uint256 _index, + IEverlong.Position memory _position, + string memory _error + ) public view override { + IEverlong.Position memory p = portfolio.at(_index); + assertEq(_position.maturityTime, p.maturityTime, _error); + assertEq(_position.bondAmount, p.bondAmount, _error); + } + + function setUp() public virtual override { + super.setUp(); + } + + /// @dev Validates `recordOpenedLongs(..)` behavior when called + /// with no preexisting positions. + function test_handleOpenLong_no_positions() external { + // Initial count should be zero. + assertEq( + portfolio.positionCount(), + 0, + "initial position count should be 0" + ); + + // Record an opened position. + // Check that position count is increased + portfolio.handleOpenPosition(1, 1); + assertEq( + portfolio.positionCount(), + 1, + "position count should be 1 after opening 1 long" + ); + } + + /// @dev Validates `recordOpenedLongs(..)` behavior when called + /// with multiple positions having distinct maturity times. + function test_handleOpenLong_distinct_maturity() external { + // Record two opened positions with distinct maturity times. + portfolio.handleOpenPosition(1, 1); + portfolio.handleOpenPosition(2, 2); + + // Check position count is 2. + assertEq( + portfolio.positionCount(), + 2, + "position count should be 2 after opening 2 longs with distinct maturities" + ); + + // Check position order is [(1,1),(2,2)]. + assertPosition( + 0, + IEverlong.Position({ maturityTime: 1, bondAmount: 1 }), + "position at index 0 should be (1,1) after opening 2 longs with distinct maturities" + ); + assertPosition( + 1, + IEverlong.Position({ maturityTime: 2, bondAmount: 2 }), + "position at index 1 should be (2,2) after opening 2 longs with distinct maturities" + ); + } + + /// @dev Validates `recordOpenedLongs(..)` behavior when called + /// with multiple positions having the same maturity time. + function test_handleOpenLong_same_maturity() external { + // Record two opened positions with same maturity times. + // Check that `PositionUpdated` event is emitted. + portfolio.handleOpenPosition(1, 1); + portfolio.handleOpenPosition(1, 1); + + // Check position count is 1. + assertEq( + portfolio.positionCount(), + 1, + "position count should be 1 after opening 2 longs with same maturity" + ); + + // Check position is now (1,2). + assertPosition( + 0, + IEverlong.Position(uint128(1), uint128(2)), + "position at index 0 should be (1,2) after opening two longs with same maturity" + ); + } + + /// @dev Validates `recordLongsClosed(..)` behavior when + /// called with the full bondAmount of the position. + function test_handleCloseLong_full_amount() external { + // Record opening and fully closing a long. + // Check that `PositionClosed` event is emitted. + portfolio.handleOpenPosition(1, 1); + portfolio.handleClosePosition(); + + // Check position count is 0. + assertEq( + portfolio.positionCount(), + 0, + "position count should be 0 after opening and closing a long for the full bond amount" + ); + } +} diff --git a/test/units/EverlongPositions.t.sol b/test/units/EverlongPositions.t.sol deleted file mode 100644 index d313a79..0000000 --- a/test/units/EverlongPositions.t.sol +++ /dev/null @@ -1,338 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -pragma solidity ^0.8.20; - -// solhint-disable-next-line no-console, no-unused-import -import { console2 as console } from "forge-std/console2.sol"; -import { ERC20Mintable } from "hyperdrive/contracts/test/ERC20Mintable.sol"; -import { IERC20 } from "openzeppelin/interfaces/IERC20.sol"; -import { IEverlong } from "../../contracts/interfaces/IEverlong.sol"; -import { Position } from "../../contracts/types/Position.sol"; -import { EverlongTest } from "../harnesses/EverlongTest.sol"; - -/// @dev Tests for Everlong position management functionality. -contract TestEverlongPositions is EverlongTest { - function setUp() public virtual override { - super.setUp(); - } - - /// @dev Validate that `hasMaturedPositions()` returns false - /// with no positions. - function test_hasMaturedPositions_false_when_no_positions() external view { - // Check that `hasMaturedPositions()` returns false - // when no positions are held. - assertFalse( - everlong.hasMaturedPositions(), - "should return false when no positions" - ); - } - - /// @dev Validate that `hasMaturedPositions()` returns false - /// with no mature positions. - function test_hasMaturedPositions_false_when_no_mature_positions() - external - { - // Open an unmature position. - everlong.exposed_handleOpenLong(uint128(block.timestamp) + 1, 5); - - // Check that `hasMaturedPositions()` returns false. - assertFalse( - everlong.hasMaturedPositions(), - "should return false when position is newly created" - ); - } - - /// @dev Validate that `hasMaturedPositions()` returns true - /// with a mature position. - function test_hasMaturedPositions_true_when_single_matured_position() - external - { - // Open unmatured positions with different maturity times. - everlong.exposed_handleOpenLong(2, 5); - everlong.exposed_handleOpenLong(3, 5); - - // Mature the first position (second will be unmature). - advanceTime(1, 0); - - // Check that `hasMaturedPositions()` returns true. - assertTrue( - everlong.hasMaturedPositions(), - "should return true with single matured position" - ); - } - - /// @dev Validate that `hasSufficientExcessLiquidity` returns false - /// when Everlong has no balance. - function test_hasSufficientExcessLiquidity_false_no_balance() - external - view - { - // Check that the contract has no balance. - assertEq(IERC20(everlong.asset()).balanceOf(address(everlong)), 0); - // Check that `hasSufficientExcessLiquidity` returns false. - assertFalse( - everlong.hasSufficientExcessLiquidity(), - "hasSufficientExcessLiquidity should return false with no balance" - ); - } - - /// @dev Validate that `hasSufficientExcessLiquidity` returns true - /// when Everlong has a large balance. - function test_hasSufficientExcessLiquidity_true_large_balance() external { - // Mint the contract some tokens. - uint256 _mintAmount = 5_000_000e18; - ERC20Mintable(everlong.asset()).mint(address(everlong), _mintAmount); - - // Check that the contract has a large balance. - assertEq( - IERC20(everlong.asset()).balanceOf(address(everlong)), - _mintAmount - ); - - // Check that `hasSufficientExcessLiquidity` returns true. - assertTrue( - everlong.hasSufficientExcessLiquidity(), - "hasSufficientExcessLiquidity should return false with no balance" - ); - } - - /// @dev Validates `recordOpenedLongs(..)` behavior when called - /// with no preexisting positions. - function test_exposed_handleOpenLong_no_positions() external { - // Initial count should be zero. - assertEq( - everlong.getPositionCount(), - 0, - "initial position count should be 0" - ); - - // Record an opened position. - // Check that: - // - `PositionOpened` event is emitted - // - Position count is increased - vm.expectEmit(true, true, true, true); - emit PositionOpened(1, 1, 0); - everlong.exposed_handleOpenLong(1, 1); - assertEq( - everlong.getPositionCount(), - 1, - "position count should be 1 after opening 1 long" - ); - } - - /// @dev Validates `recordOpenedLongs(..)` behavior when called - /// with multiple positions having distinct maturity times. - function test_exposed_handleOpenLong_distinct_maturity() external { - // Record two opened positions with distinct maturity times. - everlong.exposed_handleOpenLong(1, 1); - everlong.exposed_handleOpenLong(2, 2); - - // Check position count is 2. - assertEq( - everlong.getPositionCount(), - 2, - "position count should be 2 after opening 2 longs with distinct maturities" - ); - - // Check position order is [(1,1),(2,2)]. - assertPosition( - 0, - Position({ maturityTime: 1, bondAmount: 1 }), - "position at index 0 should be (1,1) after opening 2 longs with distinct maturities" - ); - assertPosition( - 1, - Position(uint128(2), uint128(2)), - "position at index 1 should be (2,2) after opening 2 longs with distinct maturities" - ); - } - - /// @dev Validates `recordOpenedLongs(..)` behavior when called - /// with multiple positions having the same maturity time. - function test_exposed_handleOpenLong_same_maturity() external { - // Record two opened positions with same maturity times. - // Check that `PositionUpdated` event is emitted. - everlong.exposed_handleOpenLong(1, 1); - vm.expectEmit(true, true, true, true); - emit PositionUpdated(1, 2, 0); - everlong.exposed_handleOpenLong(1, 1); - - // Check position count is 1. - assertEq( - everlong.getPositionCount(), - 1, - "position count should be 1 after opening 2 longs with same maturity" - ); - - // Check position is now (1,2). - assertPosition( - 0, - Position(uint128(1), uint128(2)), - "position at index 0 should be (1,2) after opening two longs with same maturity" - ); - } - - /// @dev Validates `recordOpenedLongs(..)` behavior when called - /// with a position with a maturity time sooner than the most - /// recently added position's maturity time. - function test_exposed_handleOpenLong_failure_shorter_maturity() external { - // Record an opened position. - everlong.exposed_handleOpenLong(5, 1); - - // Ensure than recording another position with a lower maturity time - // results in a revert. - vm.expectRevert(IEverlong.InconsistentPositionMaturity.selector); - everlong.exposed_handleOpenLong(1, 1); - } - - /// @dev Validates `recordLongsClosed(..)` behavior when - /// called with more than the bondAmount of the position. - function test_exposed_handleCloseLong_failure_greater_amount() external { - // Record opening and partially closing a long. - // Check that `PositionUpdated` event is emitted. - everlong.exposed_handleOpenLong(1, 2); - vm.expectRevert(IEverlong.InconsistentPositionBondAmount.selector); - everlong.exposed_handleCloseLong(3); - } - - /// @dev Validates `recordLongsClosed(..)` behavior when - /// called with the full bondAmount of the position. - function test_exposed_handleCloseLong_full_amount() external { - // Record opening and fully closing a long. - // Check that `PositionClosed` event is emitted. - everlong.exposed_handleOpenLong(1, 1); - vm.expectEmit(true, true, true, true); - emit PositionClosed(1); - everlong.exposed_handleCloseLong(1); - - // Check position count is 0. - assertEq( - everlong.getPositionCount(), - 0, - "position count should be 0 after opening and closing a long for the full bond amount" - ); - } - - /// @dev Validates `recordLongsClosed(..)` behavior when - /// called with less than the bondAmount of the position. - function test_exposed_handleCloseLong_partial_amount() external { - // Record opening and partially closing a long. - // Check that `PositionUpdated` event is emitted. - everlong.exposed_handleOpenLong(1, 2); - vm.expectEmit(true, true, true, true); - emit PositionUpdated(1, 1, 0); - everlong.exposed_handleCloseLong(1); - - // Check position count is 1. - assertEq( - everlong.getPositionCount(), - 1, - "position count should be 1 after opening and closing a long for the partial bond amount" - ); - } - - /// @dev Ensure that `canRebalance()` returns false when everlong has - /// no positions nor balance. - function test_canRebalance_false_no_positions_no_balance() external view { - // Check that Everlong: - // - has no positions - // - has no balance - // - `canRebalance()` returns false - assertEq( - everlong.getPositionCount(), - 0, - "everlong should not intialize with positions" - ); - assertEq( - IERC20(everlong.asset()).balanceOf(address(everlong)), - 0, - "everlong should not initialize with a balance" - ); - assertFalse( - everlong.canRebalance(), - "cannot rebalance without matured positions or balance" - ); - } - - /// @dev Ensures that `canRebalance()` returns true with a balance - /// greater than Hyperdrive's minTransactionAmount. - function test_canRebalance_with_balance_over_min_tx_amount() external { - // Initialize the Hyperdrive instance by adding liquidity from Alice. - uint256 fixedRate = 0.5e18; - uint256 contribution = 10_000e18; - initialize(alice, fixedRate, contribution); - - // Mint some tokens to Everlong for opening longs. - // Ensure Everlong's balance is gte Hyperdrive's minTransactionAmount. - // Ensure `canRebalance()` returns true. - mintApproveEverlongBaseAsset(address(everlong), 100e18); - assertGe( - IERC20(everlong.asset()).balanceOf(address(everlong)), - hyperdrive.getPoolConfig().minimumTransactionAmount - ); - assertTrue( - everlong.canRebalance(), - "everlong should be able to rebalance when it has a balance > hyperdrive's minTransactionAmount" - ); - } - - /// @dev Ensures that `canRebalance()` returns false immediately after - /// a rebalance is performed. - function test_canRebalance_false_after_rebalance() external { - // Initialize the Hyperdrive instance by adding liquidity from Alice. - uint256 fixedRate = 0.5e18; - uint256 contribution = 10_000e18; - initialize(alice, fixedRate, contribution); - - // Mint some tokens to Everlong for opening Longs. - // Call `rebalance()` to cause Everlong to open a position. - // Ensure the `Rebalanced()` event is emitted. - mintApproveEverlongBaseAsset(address(everlong), 100e18); - vm.expectEmit(true, true, true, true); - emit Rebalanced(); - everlong.rebalance(); - - // Ensure the position count is now 1. - // Ensure Everlong's balance is lt Hyperdrive's minTransactionAmount. - // Ensure `canRebalance()` returns false. - assertEq( - everlong.getPositionCount(), - 1, - "position count after first rebalance with balance should be 1" - ); - assertLt( - IERC20(everlong.asset()).balanceOf(address(everlong)), - hyperdrive.getPoolConfig().minimumTransactionAmount, - "everlong balance after first rebalance should be less than hyperdrive's minTransactionAmount" - ); - assertFalse( - everlong.canRebalance(), - "cannot rebalance without matured positions nor sufficient balance after first rebalance" - ); - } - - /// @dev Ensures that `canRebalance()` returns true with a matured - /// position. - function test_canRebalance_with_matured_position() external { - // Initialize the Hyperdrive instance by adding liquidity from Alice. - uint256 fixedRate = 0.5e18; - uint256 contribution = 10_000e18; - initialize(alice, fixedRate, contribution); - - // Mint some tokens to Everlong for opening longs and rebalance. - mintApproveEverlongBaseAsset(address(everlong), 100e18); - everlong.rebalance(); - - // Increase block.timestamp until position is mature. - // Ensure Everlong has a matured position. - // Ensure `canRebalance()` returns true. - advanceTime(everlong.getPosition(0).maturityTime, 0); - assertTrue( - everlong.hasMaturedPositions(), - "everlong should have matured position after advancing time" - ); - assertTrue( - everlong.canRebalance(), - "everlong should allow rebalance with matured position" - ); - } -} diff --git a/test/units/HyperdriveExecution.t.sol b/test/units/HyperdriveExecution.t.sol new file mode 100644 index 0000000..8db1ca8 --- /dev/null +++ b/test/units/HyperdriveExecution.t.sol @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +// solhint-disable-next-line no-console, no-unused-import +import { console2 as console } from "forge-std/console2.sol"; +import { IHyperdrive } from "hyperdrive/contracts/src/interfaces/IHyperdrive.sol"; +import { SafeCast } from "hyperdrive/contracts/src/libraries/SafeCast.sol"; +import { HyperdriveUtils } from "hyperdrive/test/utils/HyperdriveUtils.sol"; +import { ERC20Mintable } from "hyperdrive/contracts/test/ERC20Mintable.sol"; +import { IERC20 } from "openzeppelin/interfaces/IERC20.sol"; +import { IEverlong } from "../../contracts/interfaces/IEverlong.sol"; +import { Portfolio } from "../../contracts/libraries/Portfolio.sol"; +import { HyperdriveExecutionLibrary } from "../../contracts/libraries/HyperdriveExecution.sol"; +import { EverlongTest } from "../harnesses/EverlongTest.sol"; + +/// @dev Tests the functionality around opening, closing, and valuing hyperdrive positions. +contract TestHyperdriveExecution is EverlongTest { + using HyperdriveExecutionLibrary for IHyperdrive; + using SafeCast for *; + + Portfolio.State public portfolio; + + function setUp() public virtual override { + super.setUp(); + } + + function test_previewOpenLong() external { + // Deploy the everlong instance. + deployEverlong(); + + // With no longs, ensure the estimated and actual bond amounts are + // the same. + uint256 longAmount = 10e18; + uint256 previewBonds = hyperdrive.previewOpenLong( + everlong.asBase(), + longAmount, + "" + ); + (, uint256 actualBonds) = openLong(alice, longAmount); + assertEq(previewBonds, actualBonds); + + // Ensure the estimated and actual bond amounts still match when there + // is an existing position in hyperdrive. + longAmount = 500e18; + previewBonds = hyperdrive.previewOpenLong( + everlong.asBase(), + longAmount, + "" + ); + (, actualBonds) = openLong(bob, longAmount); + assertEq(previewBonds, actualBonds); + } + + function test_previewCloseLong_immediate_close() external { + // Deploy the everlong instance. + deployEverlong(); + + // Open a long. + uint256 amount = 100e18; + (uint256 maturityTime, uint256 bondAmount) = openLong(alice, amount); + + // Ensure the preview amount underestimates the actual and is + // within the tolerance. + uint256 previewAssets = hyperdrive.previewCloseLong( + everlong.asBase(), + IEverlong.Position({ + maturityTime: maturityTime.toUint128(), + bondAmount: bondAmount.toUint128() + }), + "" + ); + uint256 actualAssets = closeLong(alice, maturityTime, bondAmount); + assertEq(actualAssets, previewAssets); + } + + function test_previewCloseLong_immediate_close_negative_interest() + external + { + // Deploy the everlong instance with a negative interest rate. + VARIABLE_RATE = -0.05e18; + deployEverlong(); + + // Open a long. + uint256 amount = 100e18; + (uint256 maturityTime, uint256 bondAmount) = openLong(alice, amount); + + // Ensure the preview amount underestimates the actual and is + // within the tolerance. + uint256 previewAssets = hyperdrive.previewCloseLong( + everlong.asBase(), + IEverlong.Position({ + maturityTime: maturityTime.toUint128(), + bondAmount: bondAmount.toUint128() + }), + "" + ); + uint256 actualAssets = closeLong(alice, maturityTime, bondAmount); + assertEq(actualAssets, previewAssets); + } + + function test_previewCloseLong_partial_maturity() external { + // Deploy the everlong instance. + deployEverlong(); + + // Open a long. + uint256 amount = 100e18; + (uint256 maturityTime, uint256 bondAmount) = openLong(alice, amount); + + // Advance halfway through the term. + advanceTimeWithCheckpoints(POSITION_DURATION / 2, VARIABLE_RATE); + + // Ensure the preview amount underestimates the actual and is + // within the tolerance. + uint256 previewAssets = hyperdrive.previewCloseLong( + everlong.asBase(), + IEverlong.Position({ + maturityTime: maturityTime.toUint128(), + bondAmount: bondAmount.toUint128() + }), + "" + ); + uint256 actualAssets = closeLong(alice, maturityTime, bondAmount); + assertEq(actualAssets, previewAssets); + } + + function test_previewCloseLong_partial_maturity_negative_interest() + external + { + // Deploy the everlong instance with a negative interest rate. + VARIABLE_RATE = -0.05e18; + deployEverlong(); + + // Open a long. + uint256 amount = 100e18; + (uint256 maturityTime, uint256 bondAmount) = openLong(alice, amount); + + // Advance halfway through the term. + advanceTimeWithCheckpoints(POSITION_DURATION / 2, VARIABLE_RATE); + + // Ensure the preview amount underestimates the actual and is + // within the tolerance. + uint256 previewAssets = hyperdrive.previewCloseLong( + everlong.asBase(), + IEverlong.Position({ + maturityTime: maturityTime.toUint128(), + bondAmount: bondAmount.toUint128() + }), + "" + ); + uint256 actualAssets = closeLong(alice, maturityTime, bondAmount); + assertEq(actualAssets, previewAssets); + } + + function test_previewCloseLong_full_maturity() external { + // Deploy the everlong instance. + deployEverlong(); + + // Open a long. + uint256 amount = 100e18; + (uint256 maturityTime, uint256 bondAmount) = openLong(alice, amount); + + // Advance halfway through the term. + advanceTimeWithCheckpoints(POSITION_DURATION, VARIABLE_RATE); + + // Ensure the preview amount underestimates the actual and is + // within the tolerance. + uint256 previewAssets = hyperdrive.previewCloseLong( + everlong.asBase(), + IEverlong.Position({ + maturityTime: maturityTime.toUint128(), + bondAmount: bondAmount.toUint128() + }), + "" + ); + uint256 actualAssets = closeLong(alice, maturityTime, bondAmount); + assertEq(actualAssets, previewAssets); + } + + function test_previewCloseLong_full_maturity_negative_interest() external { + // Deploy the everlong instance. + VARIABLE_RATE = -0.05e18; + deployEverlong(); + + // Open a long. + uint256 amount = 100e18; + (uint256 maturityTime, uint256 bondAmount) = openLong(alice, amount); + + // Advance halfway through the term. + advanceTimeWithCheckpoints(POSITION_DURATION, VARIABLE_RATE); + + // Ensure the preview amount underestimates the actual and is + // within the tolerance. + uint256 previewAssets = hyperdrive.previewCloseLong( + everlong.asBase(), + IEverlong.Position({ + maturityTime: maturityTime.toUint128(), + bondAmount: bondAmount.toUint128() + }), + "" + ); + uint256 actualAssets = closeLong(alice, maturityTime, bondAmount); + assertEq(actualAssets, previewAssets); + } +}