Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] evaluate whether a constant vault share price is an appropriate invariant #13

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
83 changes: 37 additions & 46 deletions contracts/Everlong.sol
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ contract Everlong is IEverlong {

/// @notice Maximum slippage allowed when closing longs with Hyperdrive.
/// @dev Represented as a percentage with 1e18 signifying 100%.
uint256 public constant maxCloseLongSlippage = 0.001e18;
uint256 public constant maxCloseLongSlippage = 0.0001e18;

/// @notice Amount of additional bonds to close during a partial position
/// closure to avoid rounding errors. Represented as a percentage
Expand Down Expand Up @@ -236,6 +236,8 @@ contract Everlong is IEverlong {

/// @notice Returns an approximate lower bound on the amount of assets
/// received from redeeming the specified amount of shares.
/// @dev Losses and gains to the portfolio since the last `_totalAssets`
/// update are applied proportionally.
/// @param _shares Amount of shares to redeem.
/// @return assets Amount of assets that will be received.
function previewRedeem(
Expand All @@ -244,36 +246,28 @@ contract Everlong is IEverlong {
// Convert the share amount to assets.
assets = convertToAssets(_shares);

// TODO: Hold the vault share price constant.
//
// Apply losses incurred by the portfolio.
uint256 losses = _calculatePortfolioLosses().mulDivUp(
assets,
_totalAssets
);

// If the losses from closing immature positions exceeds the assets
// owed to the redeemer, set the assets owed to zero.
if (losses > assets) {
// NOTE: We return zero since `previewRedeem` must not revert.
assets = 0;
}
// Decrement the assets owed to the redeemer by the amount of losses
// incurred from closing immature positions.
else {
// Calculate and apply unrealized portfolio losses.
// Losses are rounded up.
uint256 lastTotalAssets = _totalAssets;
uint256 currentTotalAssets = _calculateTotalAssets();
if (_totalAssets > currentTotalAssets) {
unchecked {
assets -= losses;
assets -= (lastTotalAssets - currentTotalAssets).mulDivUp(
assets,
lastTotalAssets
);
}
}
}

/// @dev Attempt rebalancing after a deposit if idle is above max.
/// @dev Increase Everlong's total assets by the amount deposited.
function _afterDeposit(uint256 _assets, uint256) internal virtual override {
// Add the deposit to Everlong's assets.
_totalAssets += _assets;
}

/// @dev Frees sufficient assets for a withdrawal by closing positions.
/// @dev Frees sufficient assets for a withdrawal by closing positions and
/// update Everlong's total assets accounting.
/// @param _assets Amount of assets owed to the withdrawer.
function _beforeWithdraw(
uint256 _assets,
Expand All @@ -297,9 +291,8 @@ contract Everlong is IEverlong {
_closePositions(_assets - balance);
}

// Recalculate the assets under Everlong control less the amount being
// withdrawn.
_totalAssets = _calculateTotalAssets() - _assets;
// Decrement the assets under Everlong control by withdrawal amount.
_totalAssets -= _assets;
}

// ╭─────────────────────────────────────────────────────────╮
Expand Down Expand Up @@ -467,10 +460,17 @@ contract Everlong is IEverlong {

// Update portfolio accounting to reflect the closed position.
_portfolio.handleClosePosition();

// Increment the counter tracking the number of positions closed.
++count;
}
}

/// @dev Close positions until the targeted amount of output is received.
/// @dev It is possible for this function to successfully return without
/// having received the target amount of assets. In practice, this
/// does not occur due to slippage guards and underestimation
/// of Everlong's portfolio value.
/// @param _targetOutput Target amount of proceeds to receive.
/// @return output Total output received from closed positions.
function _closePositions(
Expand Down Expand Up @@ -502,6 +502,7 @@ contract Everlong is IEverlong {

// Close only part of the position if there are sufficient bonds
// to reach the target output without leaving a small amount left.
//
// For this case, the remaining bonds must be worth at least
// Hyperdrive's minimum transaction amount.
if (
Expand Down Expand Up @@ -567,29 +568,19 @@ contract Everlong is IEverlong {
if (_portfolio.totalBonds != 0) {
// NOTE: The maturity time is rounded to the next checkpoint to
// underestimate the portfolio value.
value += IHyperdrive(hyperdrive).previewCloseLong(
asBase,
IEverlong.Position({
maturityTime: IHyperdrive(hyperdrive)
.getCheckpointIdUp(_portfolio.avgMaturityTime)
.toUint128(),
bondAmount: _portfolio.totalBonds
}),
""
);
}
}

/// @dev Calculates the amount of losses the portfolio has incurred since
/// `_totalAssets` was last calculated. If no losses have been incurred
/// return 0.
/// @return Amount of losses incurred by the portfolio (if any).
function _calculatePortfolioLosses() internal view returns (uint256) {
uint256 newTotalAssets = _calculateTotalAssets();
if (_totalAssets > newTotalAssets) {
return _totalAssets - newTotalAssets;
value += IHyperdrive(hyperdrive)
.previewCloseLong(
asBase,
IEverlong.Position({
maturityTime: IHyperdrive(hyperdrive)
.getCheckpointIdUp(_portfolio.avgMaturityTime)
.toUint128(),
bondAmount: _portfolio.totalBonds
}),
""
)
.mulDown(1e18 - maxCloseLongSlippage);
}
return 0;
}

// ╭─────────────────────────────────────────────────────────╮
Expand Down
10 changes: 10 additions & 0 deletions contracts/interfaces/IEverlong.sol
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,14 @@ abstract contract IEverlong is

/// @notice Thrown when a redemption results in zero output assets.
error RedemptionZeroOutput();

// ── Rebalancing ────────────────────────────────────────────

/// @notice Thrown when the spending override is below hyperdrive's
/// minimum transaction amount.
error SpendingOverrideTooLow();

/// @notice Thrown when the spending override is above the difference
/// between Everlong's balance and its max idle liquidity.
error SpendingOverrideTooHigh();
}
7 changes: 7 additions & 0 deletions contracts/interfaces/IEverlongPortfolio.sol
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ interface IEverlongPortfolio {
uint256 _limit
) external returns (uint256 output);

/// @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 Down
2 changes: 1 addition & 1 deletion test/integration/CloseImmatureLongs.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ 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 CloseImmatureLongs is EverlongTest {
contract TestCloseImmatureLongs is EverlongTest {
using Packing for bytes32;
using FixedPointMath for uint128;
using FixedPointMath for uint256;
Expand Down
6 changes: 3 additions & 3 deletions test/integration/PartialClosures.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { EverlongTest } from "../harnesses/EverlongTest.sol";
import { IEverlong } from "../../contracts/interfaces/IEverlong.sol";
import { HyperdriveExecutionLibrary } from "../../contracts/libraries/HyperdriveExecution.sol";

contract PartialClosures is EverlongTest {
contract TestPartialClosures is EverlongTest {
using FixedPointMath for uint256;
using Lib for *;
using HyperdriveUtils for *;
Expand Down Expand Up @@ -50,7 +50,7 @@ contract PartialClosures is EverlongTest {
0.8e18
);
uint256 aliceRedeemAmount = aliceShares.mulDown(_redemptionPercentage);
redeemEverlong(aliceRedeemAmount, alice);
redeemEverlong(aliceRedeemAmount, alice, false);
uint256 positionBondsAfterRedeem = everlong.totalBonds();

// Ensure Everlong still has a position open.
Expand All @@ -64,7 +64,7 @@ contract PartialClosures is EverlongTest {
1e18 - TARGET_IDLE_LIQUIDITY_PERCENTAGE
),
positionBondsAfterRedeem,
0.01e18
0.001e18
);
}

Expand Down
13 changes: 10 additions & 3 deletions test/integration/Sandwich.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Lib } from "hyperdrive/test/utils/Lib.sol";
import { HyperdriveUtils } from "hyperdrive/test/utils/HyperdriveUtils.sol";
import { EverlongTest } from "../harnesses/EverlongTest.sol";

contract Sandwich is EverlongTest {
contract TestSandwich is EverlongTest {
using Lib for *;
using HyperdriveUtils for *;

Expand Down Expand Up @@ -75,6 +75,8 @@ contract Sandwich is EverlongTest {
}

// TODO: Decrease min range to Hyperdrive `MINIMUM_TRANSACTION_AMOUNT`.
// NOTE: Rebalancing is not performed after some interactions with Everlong
// since the attack being evaluated is atomic.
//
/// @dev Tests the following scenario:
/// 1. First an innocent bystander deposits into Everlong. At that time, we
Expand All @@ -95,6 +97,9 @@ contract Sandwich is EverlongTest {
address attacker = alice;
address bystander = bob;

// Initialize Everlong with a bond portfolio.
depositEverlong(10_000e18, celine);

// The bystander deposits into Everlong.
_bystanderDepositAmount = bound(
_bystanderDepositAmount,
Expand Down Expand Up @@ -153,10 +158,12 @@ contract Sandwich is EverlongTest {
attackerShortProceeds;

// Ensure the attacker does not profit.
assertLt(attackerProceeds, attackerPaid);
assertLe(attackerProceeds, attackerPaid);
}

// TODO: Decrease min range to Hyperdrive `MINIMUM_TRANSACTION_AMOUNT`.
// NOTE: Rebalancing is not performed after some interactions with Everlong
// since the attack being evaluated is atomic.
//
/// @dev Tests the following scenario:
/// 1. Attacker adds liquidity.
Expand Down Expand Up @@ -224,6 +231,6 @@ contract Sandwich is EverlongTest {
redeemEverlong(bystanderEverlongShares, bystander, true);

// Ensure that the attacker does not profit from their actions.
assertLt(attackerEverlongProceeds, _attackerDeposit);
assertLe(attackerEverlongProceeds, _attackerDeposit);
}
}
69 changes: 69 additions & 0 deletions test/playgrounds/VaultSharePrice.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: Apache-2.0
pragma solidity 0.8.22;

import { console2 as console } from "forge-std/console2.sol";
import { FixedPointMath } from "hyperdrive/contracts/src/libraries/FixedPointMath.sol";
import { Lib } from "hyperdrive/test/utils/Lib.sol";
import { HyperdriveUtils } from "hyperdrive/test/utils/HyperdriveUtils.sol";
import { IERC20 } from "openzeppelin/interfaces/IERC20.sol";
import { EverlongTest } from "../harnesses/EverlongTest.sol";
import { IEverlong } from "../../contracts/interfaces/IEverlong.sol";
import { HyperdriveExecutionLibrary } from "../../contracts/libraries/HyperdriveExecution.sol";

contract TestVaultSharePrice is EverlongTest {
using FixedPointMath for uint256;
using Lib for *;
using HyperdriveUtils for *;
using HyperdriveExecutionLibrary for *;

function test_vault_share_price_deposit_redeem() external {
// Skip this test unless disabled manually.
vm.skip(true);

deployEverlong();

// Alice makes a deposit.
uint256 aliceDeposit = 10_000e18;
uint256 aliceShares = depositEverlong(aliceDeposit, alice);

console.log(
"Vault Share Price 1: %e",
everlong.totalAssets().divDown(everlong.totalSupply())
);

// Bob makes a deposit.
uint256 bobDeposit = 10_000e18;
uint256 bobShares = depositEverlong(bobDeposit, bob);
console.log(
"Vault Share Price 2: %e",
everlong.totalAssets().divDown(everlong.totalSupply())
);

// Celine makes a deposit.
uint256 celineDeposit = 10_000e18;
uint256 celineShares = depositEverlong(celineDeposit, celine);
console.log(
"Vault Share Price 3: %e",
everlong.totalAssets().divDown(everlong.totalSupply())
);

// Bob redeems.
redeemEverlong(bobShares, bob);
console.log(
"Vault Share Price 4: %e",
everlong.totalAssets().divDown(everlong.totalSupply())
);

// Celine redeems.
redeemEverlong(celineShares, celine);
console.log(
"Vault Share Price 5: %e",
everlong.totalAssets().divDown(everlong.totalSupply())
);

console.log(
"Everlong Balance: %e",
IERC20(everlong.asset()).balanceOf(address(everlong))
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ uint256 constant HYPERDRIVE_LONG_EXPOSURE_LONGS_OUTSTANDING_SLOT = 3;
uint256 constant HYPERDRIVE_SHARE_ADJUSTMENT_SHORTS_OUTSTANDING_SLOT = 4;

/// @dev Tests vault share price manipulation with the underlying hyperdrive instance.
contract VaultSharePriceManipulation is EverlongTest {
contract TestVaultSharePriceManipulation is EverlongTest {
using Packing for bytes32;
using FixedPointMath for uint128;
using FixedPointMath for uint256;
Expand Down
14 changes: 0 additions & 14 deletions test/units/EverlongERC4626.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -92,18 +92,4 @@ contract TestEverlongERC4626 is EverlongTest {
// and within margins.
assertRedemption(shares / 3, alice);
}

/// @dev Tests that the `_beforeWithdraw` hook doesn't underflow when
/// Everlong's balance is greater than the assets being redeemed.
function test_beforeWithdraw_balance_gt_assets() external {
// Deploy Everlong.
deployEverlong();

// Mint some assets to everlong
uint256 assets = 100e18;
mintApproveEverlongBaseAsset(address(everlong), assets);

// Call the `_beforeWithdraw` hook.
everlong.exposed_beforeWithdraw(assets - 10e18, 0);
}
}
2 changes: 1 addition & 1 deletion test/units/EverlongPortfolio.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ contract TestEverlongPortfolio is EverlongTest {
spendingLimit: 1,
minOutput: 0,
minVaultSharePrice: 0,
positionClosureLimit: 0,
positionClosureLimit: 1,
extraData: ""
})
);
Expand Down
Loading