Skip to content

Commit

Permalink
(2/3) keeper-based rebalance + rebalance options (#12)
Browse files Browse the repository at this point in the history
* first stab at partial closures

* introduce minimum remaining bonds threshold to avoid positions with very few bonds

* fix

* better partial estimation, fuzz tests, pricing edge case test

* better comments, tighter tolerances

* fixes to avoid underflow when trying to withdraw assets greater than everlong's _totalAssets

* cleanup

* remove console

* no need for mature position slippage guards

* slippage guard docs and partial closure test adjustments

* convert rebalance to only be called by an external keeper

Keepers are expected to use the configuration object to circumvent
hyperdrive errors

* notes for future self

* better commenting and devex

* testing cleanup + make rebalance opt-in for depositEverlong and redeemEverlong

* rework spendingOverride into spendingLimit

* wording
  • Loading branch information
mcclurejt authored Oct 31, 2024
1 parent de2c61c commit cadf9be
Show file tree
Hide file tree
Showing 14 changed files with 609 additions and 174 deletions.
132 changes: 90 additions & 42 deletions contracts/Everlong.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 { IERC20 } from "openzeppelin/interfaces/IERC20.sol";
import { SafeERC20 } from "openzeppelin/token/ERC20/utils/SafeERC20.sol";
import { FixedPointMathLib } from "solady/utils/FixedPointMathLib.sol";
import { IEverlong } from "./interfaces/IEverlong.sol";
Expand Down Expand Up @@ -74,7 +74,7 @@ contract Everlong is IEverlong {
using HyperdriveExecutionLibrary for IHyperdrive;
using Portfolio for Portfolio.State;
using SafeCast for *;
using SafeERC20 for ERC20;
using SafeERC20 for IERC20;

// ╭─────────────────────────────────────────────────────────╮
// │ Storage │
Expand Down Expand Up @@ -267,17 +267,10 @@ contract Everlong is IEverlong {
}
}

// TODO: Do not rebalance on deposit. This change will require updating
// the test suite as well to perform rebalances when time is advanced.
//
/// @dev Attempt rebalancing after a deposit if idle is above max.
function _afterDeposit(uint256 _assets, uint256) internal virtual override {
// If there is excess liquidity beyond the max, rebalance.
if (ERC20(_asset).balanceOf(address(this)) > maxIdleLiquidity()) {
rebalance();
} else {
_totalAssets += _assets;
}
// Add the deposit to Everlong's assets.
_totalAssets += _assets;
}

/// @dev Frees sufficient assets for a withdrawal by closing positions.
Expand All @@ -298,8 +291,8 @@ contract Everlong is IEverlong {
//
// If we do not have enough balance to service the withdrawal after
// closing any matured positions, close more positions.
uint256 balance = ERC20(_asset).balanceOf(address(this)) +
_closeMaturedPositions();
uint256 balance = IERC20(_asset).balanceOf(address(this)) +
closeMaturedPositions(type(uint256).max);
if (_assets > balance) {
_closePositions(_assets - balance);
}
Expand All @@ -313,35 +306,60 @@ contract Everlong is IEverlong {
// │ Rebalancing │
// ╰─────────────────────────────────────────────────────────╯

// TODO: Handle case where rebalancing would exceed gas limit
// TODO: Handle when Hyperdrive has insufficient liquidity.
//
/// @notice Rebalance the everlong portfolio by closing mature positions
/// and using the proceeds over target idle to open new positions.
function rebalance() public override {
/// @dev Errors from hyperdrive are not handled. The keeper must configure
/// the correct parameters to avoid issues with insufficient liquidity
/// and running out of gas from mature position closures.
/// @param _options Options to control the rebalance behavior.
function rebalance(
IEverlong.RebalanceOptions memory _options
) external onlyAdmin {
// Early return if no rebalancing is needed.
if (!canRebalance()) {
return;
}

// Calculate the new portfolio value and save it.
_totalAssets = _calculateTotalAssets();

// Close matured positions.
_closeMaturedPositions();

// Amount to spend is the current balance less the target idle.
uint256 toSpend = ERC20(_asset).balanceOf(address(this)) -
targetIdleLiquidity();
closeMaturedPositions(_options.positionClosureLimit);

// If Everlong has sufficient idle, open a new position.
if (canOpenPosition()) {
// Calculate how much idle to spend on the position.
// A value of 0 for spendingLimit indicates no limit.
uint256 balance = IERC20(_asset).balanceOf(address(this));
uint256 target = targetIdleLiquidity();
uint256 toSpend = (
_options.spendingLimit == 0
? balance - target
: _options.spendingLimit.min(balance - target)
);

// Open a new position. Leave an extra wei for the approval to keep
// the slot warm.
ERC20(_asset).forceApprove(address(hyperdrive), toSpend + 1);
(uint256 maturityTime, uint256 bondAmount) = IHyperdrive(hyperdrive)
.openLong(asBase, toSpend, "");
// If toSpend is above hyperdrive's minimum, open a new
// position.
// Leave an extra wei for the approval to keep the slot warm.
if (
toSpend >=
IHyperdrive(hyperdrive).getPoolConfig().minimumTransactionAmount
) {
IERC20(_asset).forceApprove(address(hyperdrive), toSpend + 1);
(uint256 maturityTime, uint256 bondAmount) = IHyperdrive(
hyperdrive
).openLong(
asBase,
toSpend,
_options.minOutput,
_options.minVaultSharePrice,
_options.extraData
);

// Account for the new position in the portfolio.
_portfolio.handleOpenPosition(maturityTime, bondAmount);
}
}

// Account for the new position in the portfolio.
_portfolio.handleOpenPosition(maturityTime, bondAmount);
// Calculate an updated portfolio value and save it.
_totalAssets = _calculateTotalAssets();

emit Rebalanced();
}
Expand All @@ -354,14 +372,21 @@ contract Everlong is IEverlong {
/// - The current idle liquidity is above the target.
/// @return True if the portfolio can be rebalanced, false otherwise.
function canRebalance() public view returns (bool) {
uint256 balance = ERC20(_asset).balanceOf(address(this));
uint256 target = targetIdleLiquidity();
return (hasMaturedPositions() ||
(balance > target &&
balance - target >
return hasMaturedPositions() || canOpenPosition();
}

/// @notice Returns whether Everlong has sufficient idle liquidity to open
/// a new position.
/// @return True if a new position can be opened, false otherwise.
function canOpenPosition() public view returns (bool) {
uint256 balance = IERC20(_asset).balanceOf(address(this));
uint256 max = maxIdleLiquidity();
return
balance > max &&
(balance - max >
IHyperdrive(hyperdrive)
.getPoolConfig()
.minimumTransactionAmount));
.minimumTransactionAmount);
}

/// @notice Returns the target amount of funds to keep idle in Everlong.
Expand Down Expand Up @@ -403,21 +428,44 @@ contract Everlong is IEverlong {
// │ Hyperdrive │
// ╰─────────────────────────────────────────────────────────╯

/// @dev Close only matured positions in the portfolio.
/// @notice Close only matured positions in the portfolio.
/// @param _limit The maximum number of positions to close.
/// A value of zero indicates no limit.
/// @return output Proceeds of closing the matured positions.
function _closeMaturedPositions() internal returns (uint256 output) {
function closeMaturedPositions(
uint256 _limit
) public returns (uint256 output) {
// A value of zero for `_limit` indicates no limit.
if (_limit == 0) {
_limit = type(uint256).max;
}

// Iterate through positions from most to least mature.
// Exit if:
// - There are no more positions.
// - The current position is not mature.
// - The limit on closed positions has been reached.
IEverlong.Position memory position;
while (!_portfolio.isEmpty()) {
for (uint256 count; !_portfolio.isEmpty() && count < _limit; ++count) {
// Retrieve the most mature position.
position = _portfolio.head();

// If the position is not mature, return the output received thus
// far.
if (!IHyperdrive(hyperdrive).isMature(position)) {
return output;
}

// Close the position add the amount of assets received to the
// cumulative output.
output += IHyperdrive(hyperdrive).closeLong(
asBase,
position,
0,
""
);

// Update portfolio accounting to reflect the closed position.
_portfolio.handleClosePosition();
}
}
Expand Down Expand Up @@ -515,7 +563,7 @@ contract Everlong is IEverlong {
/// bonds and the weighted average maturity of all positions.
/// @return value The present portfolio value.
function _calculateTotalAssets() internal view returns (uint256 value) {
value = ERC20(_asset).balanceOf(address(this));
value = IERC20(_asset).balanceOf(address(this));
if (_portfolio.totalBonds != 0) {
// NOTE: The maturity time is rounded to the next checkpoint to
// underestimate the portfolio value.
Expand Down
19 changes: 19 additions & 0 deletions contracts/interfaces/IEverlong.sol
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ abstract contract IEverlong is
uint128 bondAmount;
}

// TODO: Revisit position closure limit to see what POSITION_DURATION would
// be needed to run out of gas.
//
/// @notice Parameters to specify how a rebalance will be performed.
struct RebalanceOptions {
/// @notice Limit on the amount of idle to spend on a new position.
/// @dev A value of zero indicates no limit.
uint256 spendingLimit;
/// @notice Minimum amount of bonds to receive when opening a position.
uint256 minOutput;
/// @notice Minimum vault share price when opening a position.
uint256 minVaultSharePrice;
/// @notice Maximum amount of mature positions that can be closed.
/// @dev A value of zero indicates no limit.
uint256 positionClosureLimit;
/// @notice Passed to hyperdrive `openLong()` and `closeLong()`.
bytes extraData;
}

// ╭─────────────────────────────────────────────────────────╮
// │ Getters │
// ╰─────────────────────────────────────────────────────────╯
Expand Down
15 changes: 14 additions & 1 deletion contracts/interfaces/IEverlongPortfolio.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,15 @@ interface IEverlongPortfolio {
// ╰─────────────────────────────────────────────────────────╯

/// @notice Rebalances the Everlong bond portfolio if needed.
function rebalance() external;
/// @param _options Options to control the rebalance behavior.
function rebalance(IEverlong.RebalanceOptions memory _options) external;

/// @notice Closes mature positions in the Everlong portfolio.
/// @param _limit The maximum number of positions to close.
/// @return output Amount of assets received from the closed positions.
function closeMaturedPositions(
uint256 _limit
) external returns (uint256 output);

// ╭─────────────────────────────────────────────────────────╮
// │ Getters │
Expand All @@ -35,6 +43,11 @@ interface IEverlongPortfolio {
/// @return True if the portfolio can be rebalanced, false otherwise.
function canRebalance() external view returns (bool);

/// @notice Returns whether Everlong has sufficient idle liquidity to open
/// a new position.
/// @return True if a new position can be opened, false otherwise.
function canOpenPosition() external view returns (bool);

/// @notice Returns the target percentage of idle liquidity to maintain.
/// @dev Expressed as a fraction of ONE.
/// @return The target percentage of idle liquidity to maintain.
Expand Down
34 changes: 31 additions & 3 deletions contracts/libraries/HyperdriveExecution.sol
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,48 @@ library HyperdriveExecutionLibrary {
/// @dev Opens a long with hyperdrive using amount.
/// @param _asBase Whether to use hyperdrive's base asset.
/// @param _amount Amount of assets to spend.
/// @param _extraData Extra data to pass to hyperdrive.
/// @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
bytes memory _extraData
) internal returns (uint256 maturityTime, uint256 bondAmount) {
// TODO: Slippage
(maturityTime, bondAmount) = self.openLong(
_amount,
0,
0,
IHyperdrive.Options(address(this), _asBase, "")
IHyperdrive.Options(address(this), _asBase, _extraData)
);
emit IEverlongEvents.PositionOpened(
maturityTime.toUint128(),
bondAmount.toUint128()
);
}

/// @dev Opens a long with hyperdrive using amount.
/// @param _asBase Whether to use hyperdrive's base asset.
/// @param _amount Amount of assets to spend.
/// @param _minOutput Minimum amount of bonds to receive.
/// @param _minVaultSharePrice Minimum hyperdrive vault share price.
/// @param _extraData Extra data to pass to hyperdrive.
/// @return maturityTime Maturity timestamp of the opened position.
/// @return bondAmount Amount of bonds received.
function openLong(
IHyperdrive self,
bool _asBase,
uint256 _amount,
uint256 _minOutput,
uint256 _minVaultSharePrice,
bytes memory _extraData
) internal returns (uint256 maturityTime, uint256 bondAmount) {
(maturityTime, bondAmount) = self.openLong(
_amount,
_minOutput,
_minVaultSharePrice,
IHyperdrive.Options(address(this), _asBase, _extraData)
);
emit IEverlongEvents.PositionOpened(
maturityTime.toUint128(),
Expand Down
16 changes: 0 additions & 16 deletions test/exposed/EverlongERC4626Exposed.sol

This file was deleted.

32 changes: 0 additions & 32 deletions test/exposed/EverlongPortfolioExposed.sol

This file was deleted.

Loading

0 comments on commit cadf9be

Please sign in to comment.