diff --git a/contracts/Hyperdrive.sol b/contracts/Hyperdrive.sol index f2cf8a01c..34e93265a 100644 --- a/contracts/Hyperdrive.sol +++ b/contracts/Hyperdrive.sol @@ -16,7 +16,7 @@ import { IHyperdrive } from "contracts/interfaces/IHyperdrive.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 Hyperdrive is MultiToken, IHyperdrive { +abstract contract Hyperdrive is MultiToken, IHyperdrive { using FixedPointMath for uint256; /// Tokens /// @@ -107,7 +107,6 @@ contract Hyperdrive is MultiToken, IHyperdrive { } /// Yield Source /// - // In order to deploy a yield source implement must be written which implements the following methods /// @notice Transfers base from the user and commits it to the yield source. /// @param amount The amount of base to deposit. @@ -115,7 +114,7 @@ contract Hyperdrive is MultiToken, IHyperdrive { /// @return sharePrice The share price at time of deposit. function deposit( uint256 amount - ) internal virtual returns (uint256 sharesMinted, uint256 sharePrice) {} + ) internal virtual returns (uint256 sharesMinted, uint256 sharePrice); /// @notice Withdraws shares from the yield source and sends the base /// released to the destination. @@ -126,11 +125,11 @@ contract Hyperdrive is MultiToken, IHyperdrive { function withdraw( uint256 shares, address destination - ) internal virtual returns (uint256 amountWithdrawn, uint256 sharePrice) {} + ) internal virtual returns (uint256 amountWithdrawn, uint256 sharePrice); ///@notice Loads the share price from the yield source ///@return sharePrice The current share price. - function pricePerShare() internal virtual returns (uint256 sharePrice) {} + function pricePerShare() internal view virtual returns (uint256 sharePrice); /// LP /// @@ -152,10 +151,10 @@ contract Hyperdrive is MultiToken, IHyperdrive { // Update the reserves. The bond reserves are calculated so that the // pool is initialized with the target APR. shareReserves = shares; - bondReserves = HyperdriveMath.calculateBondReserves( - shares, + bondReserves = HyperdriveMath.calculateInitialBondReserves( shares, sharePrice, + initialSharePrice, _apr, positionDuration, timeStretch @@ -164,7 +163,11 @@ contract Hyperdrive is MultiToken, IHyperdrive { // Mint LP shares to the initializer. // TODO - Should we index the lp share and virtual reserve to shares or to underlying? // I think in the case where price per share < 1 there may be a problem. - _mint(AssetId._LP_ASSET_ID, msg.sender, shares); + _mint( + AssetId._LP_ASSET_ID, + msg.sender, + sharePrice.mulDown(shares).add(bondReserves) + ); } // TODO: Add slippage protection. @@ -360,6 +363,44 @@ contract Hyperdrive is MultiToken, IHyperdrive { if (_minOutput > _proceeds) revert Errors.OutputLimit(); } + /// @notice Redeems long and short withdrawal shares. + /// @param _longWithdrawalShares The long withdrawal shares to redeem. + /// @param _shortWithdrawalShares The short withdrawal shares to redeem. + function redeemWithdrawalShares( + uint256 _longWithdrawalShares, + uint256 _shortWithdrawalShares + ) external { + uint256 baseProceeds = 0; + + // Perform a checkpoint. + uint256 sharePrice = pricePerShare(); + _applyCheckpoint(_latestCheckpoint(), sharePrice); + + // Redeem the long withdrawal shares. + uint256 proceeds = _applyWithdrawalShareRedemption( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.LongWithdrawalShare, 0), + _longWithdrawalShares, + longWithdrawalSharesOutstanding, + longWithdrawalShareProceeds + ); + + // Redeem the short withdrawal shares. + proceeds += _applyWithdrawalShareRedemption( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.ShortWithdrawalShare, + 0 + ), + _shortWithdrawalShares, + shortWithdrawalSharesOutstanding, + shortWithdrawalShareProceeds + ); + + // Withdraw the funds released by redeeming the withdrawal shares. + // TODO: Better destination support. + uint256 shareProceeds = baseProceeds.divDown(sharePrice); + withdraw(shareProceeds, msg.sender); + } + /// Long /// /// @notice Opens a long position. @@ -651,7 +692,6 @@ contract Hyperdrive is MultiToken, IHyperdrive { if (_maturityTime <= block.timestamp) { closeSharePrice = checkpoints[_maturityTime]; } - // Recycle a local variable _bondAmount = _bondAmount.divDown(openSharePrice).sub(sharePayment); uint256 shortProceeds = closeSharePrice.mulDown(_bondAmount).divDown( sharePrice @@ -703,6 +743,38 @@ contract Hyperdrive is MultiToken, IHyperdrive { } } + /// Getters /// + + /// @notice Gets info about the pool's reserves and other state that is + /// important to evaluate potential trades. + /// @return shareReserves_ The share reserves. + /// @return bondReserves_ The bond reserves. + /// @return lpTotalSupply The total supply of LP shares. + /// @return sharePrice The share price. + /// @return longsOutstanding_ The longs that haven't been closed. + /// @return shortsOutstanding_ The shorts that haven't been closed. + function getPoolInfo() + external + view + returns ( + uint256 shareReserves_, + uint256 bondReserves_, + uint256 lpTotalSupply, + uint256 sharePrice, + uint256 longsOutstanding_, + uint256 shortsOutstanding_ + ) + { + return ( + shareReserves, + bondReserves, + totalSupply[AssetId._LP_ASSET_ID], + pricePerShare(), + longsOutstanding, + shortsOutstanding + ); + } + /// Helpers /// /// @dev Applies the trading deltas from a closed long to the reserves and diff --git a/contracts/libraries/AssetId.sol b/contracts/libraries/AssetId.sol index d0586c1a7..ba8fc9af0 100644 --- a/contracts/libraries/AssetId.sol +++ b/contracts/libraries/AssetId.sol @@ -36,6 +36,12 @@ library AssetId { uint256 _timestamp ) internal pure returns (uint256 id) { // [identifier: 8 bits][timestamp: 248 bits] + if ( + _timestamp > + 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff + ) { + revert Errors.InvalidTimestamp(); + } assembly { id := or(shl(0xf8, _prefix), _timestamp) } diff --git a/contracts/libraries/Errors.sol b/contracts/libraries/Errors.sol index c40ca7e5f..6defba758 100644 --- a/contracts/libraries/Errors.sol +++ b/contracts/libraries/Errors.sol @@ -42,14 +42,14 @@ library Errors { /// ############### /// ### AssetId ### /// ############### - error AssetIDCorruption(); + error InvalidTimestamp(); /// ##################### /// ### BondWrapper ### /// ##################### - error BondMatured(); - error InsufficientPrice(); error AlreadyClosed(); + error BondMatured(); error BondNotMatured(); + error InsufficientPrice(); } diff --git a/contracts/libraries/HyperdriveMath.sol b/contracts/libraries/HyperdriveMath.sol index 62e9250e7..695c340cb 100644 --- a/contracts/libraries/HyperdriveMath.sol +++ b/contracts/libraries/HyperdriveMath.sol @@ -5,10 +5,6 @@ import { Errors } from "contracts/libraries/Errors.sol"; import { FixedPointMath } from "contracts/libraries/FixedPointMath.sol"; import { YieldSpaceMath } from "contracts/libraries/YieldSpaceMath.sol"; -// FIXME: The matrix of uses of flat+curve includes cases that should never -// occur. In particular, if isBondOut && t < 1 or isBondIn && t < 1, then the -// flat part refers to base tokens and the model doesn't make sense. -// /// @author Delve /// @title Hyperdrive /// @notice Math for the Hyperdrive pricing model. @@ -34,9 +30,9 @@ library HyperdriveMath { uint256 _positionDuration, uint256 _timeStretch ) internal pure returns (uint256 apr) { - // NOTE: This calculation is automatically scaled in the divDown operation + // NOTE: Using divDown to convert to fixed point format. uint256 t = _positionDuration.divDown(365 days); - uint256 tau = t.divDown(_timeStretch); + uint256 tau = t.mulDown(_timeStretch); // ((y + s) / (mu * z)) ** -tau uint256 spotPrice = _initialSharePrice .mulDown(_shareReserves) @@ -47,9 +43,39 @@ library HyperdriveMath { FixedPointMath.ONE_18.sub(spotPrice).divDown(spotPrice.mulDown(t)); } - // TODO: There is likely a more efficient formulation for when the rate is - // based on the existing share and bond reserves. - // + /// @dev Calculates the initial bond reserves assuming that the initial LP + /// receives LP shares amounting to c * z + y. + /// @param _shareReserves The pool's share reserves. + /// @param _sharePrice The pool's share price. + /// @param _initialSharePrice The pool's initial share price. + /// @param _apr The pool's APR. + /// @param _positionDuration The amount of time until maturity in seconds. + /// @param _timeStretch The time stretch parameter. + /// @return bondReserves The bond reserves that make the pool have a + /// specified APR. + function calculateInitialBondReserves( + uint256 _shareReserves, + uint256 _sharePrice, + uint256 _initialSharePrice, + uint256 _apr, + uint256 _positionDuration, + uint256 _timeStretch + ) internal pure returns (uint256 bondReserves) { + // NOTE: Using divDown to convert to fixed point format. + uint256 t = _positionDuration.divDown(365 days); + uint256 tau = t.mulDown(_timeStretch); + // mu * (1 + apr * t) ** (1 / tau) - c + uint256 rhs = _initialSharePrice + .mulDown( + FixedPointMath.ONE_18.add(_apr.mulDown(t)).pow( + FixedPointMath.ONE_18.divDown(tau) + ) + ) + .sub(_sharePrice); + // (z / 2) * (mu * (1 + apr * t) ** (1 / tau) - c) + return _shareReserves.divDown(2 * FixedPointMath.ONE_18).mulDown(rhs); + } + /// @dev Calculates the bond reserves that will make the pool have a /// specified APR. /// @param _shareReserves The pool's share reserves. @@ -68,9 +94,9 @@ library HyperdriveMath { uint256 _positionDuration, uint256 _timeStretch ) internal pure returns (uint256 bondReserves) { - // NOTE: This calculation is automatically scaled in the divDown operation + // NOTE: Using divDown to convert to fixed point format. uint256 t = _positionDuration.divDown(365 days); - uint256 tau = t.divDown(_timeStretch); + uint256 tau = t.mulDown(_timeStretch); // (1 + apr * t) ** (1 / tau) uint256 interestFactor = FixedPointMath.ONE_18.add(_apr.mulDown(t)).pow( FixedPointMath.ONE_18.divDown(tau) @@ -248,16 +274,19 @@ library HyperdriveMath { // the trade was applied to the share and bond reserves. _shareReserves = _shareReserves.add(flat); _bondReserves = _bondReserves.sub(flat.mulDown(_sharePrice)); - uint256 curveIn = YieldSpaceMath.calculateInGivenOut( - _shareReserves, - _bondReserves, - _bondReserveAdjustment, - curveOut, - FixedPointMath.ONE_18.sub(_timeStretch), - _sharePrice, - _initialSharePrice, - false - ); + uint256 curveIn = 0; + if (curveOut > 0) { + curveIn = YieldSpaceMath.calculateInGivenOut( + _shareReserves, + _bondReserves, + _bondReserveAdjustment, + curveOut, + FixedPointMath.ONE_18.sub(_timeStretch), + _sharePrice, + _initialSharePrice, + false + ); + } return (flat.add(curveIn), curveOut, flat.add(curveIn)); } diff --git a/contracts/libraries/YieldSpaceMath.sol b/contracts/libraries/YieldSpaceMath.sol index 04153fe62..f40333796 100644 --- a/contracts/libraries/YieldSpaceMath.sol +++ b/contracts/libraries/YieldSpaceMath.sol @@ -1,7 +1,7 @@ /// SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.18; -import "contracts/libraries/FixedPointMath.sol"; +import { FixedPointMath } from "contracts/libraries/FixedPointMath.sol"; // FIXME: This doesn't compute the fee but maybe it should. // diff --git a/package.json b/package.json index 02866743f..2abf10cf5 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "clean": "forge clean", "lint": "yarn solhint && yarn style-check && yarn spell-check && echo \"done\"", "prettier": "npx prettier --write .", - "test": "forge test", + "test": "forge test -vvv", "solhint": "npx solhint -f table contracts/*.sol contracts/**/*.sol", "spell-check": "npx cspell ./**/**/**.sol --gitignore", "style-check": "npx prettier --check .", diff --git a/test/Hyperdrive.t.sol b/test/Hyperdrive.t.sol index bd203c626..5efb5a487 100644 --- a/test/Hyperdrive.t.sol +++ b/test/Hyperdrive.t.sol @@ -1,40 +1,793 @@ // SPDX-License-Identifier: Apache-2.0 pragma solidity ^0.8.18; -import { ERC20PresetFixedSupply } from "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetFixedSupply.sol"; +import { stdError } from "forge-std/StdError.sol"; import { Test } from "forge-std/Test.sol"; import { ForwarderFactory } from "contracts/ForwarderFactory.sol"; -import { Hyperdrive } from "contracts/Hyperdrive.sol"; -import "contracts/libraries/FixedPointMath.sol"; +import { ForwarderFactory } from "contracts/ForwarderFactory.sol"; +import { AssetId } from "contracts/libraries/AssetId.sol"; +import { Errors } from "contracts/libraries/Errors.sol"; +import { FixedPointMath } from "contracts/libraries/FixedPointMath.sol"; +import { HyperdriveMath } from "contracts/libraries/HyperdriveMath.sol"; +import { ERC20Mintable } from "test/mocks/ERC20Mintable.sol"; +import { MockHyperdrive } from "test/mocks/MockHyperdrive.sol"; contract HyperdriveTest is Test { + using FixedPointMath for uint256; + address alice = address(uint160(uint256(keccak256("alice")))); + address bob = address(uint160(uint256(keccak256("bob")))); - ERC20PresetFixedSupply baseToken; - Hyperdrive hyperdrive; + ERC20Mintable baseToken; + MockHyperdrive hyperdrive; function setUp() public { - vm.prank(alice); + vm.startPrank(alice); - // Instantiate the tokens. - bytes32 linkerCodeHash = bytes32(0); - ForwarderFactory forwarderFactory = new ForwarderFactory(); - baseToken = new ERC20PresetFixedSupply( - "DAI Stablecoin", - "DAI", - 10.0e18, - alice - ); + // Instantiate the base token. + baseToken = new ERC20Mintable(); // Instantiate Hyperdrive. - hyperdrive = new Hyperdrive({ - _linkerCodeHash: linkerCodeHash, - _linkerFactory: address(forwarderFactory), - _baseToken: baseToken, - _initialSharePrice: FixedPointMath.ONE_18, - _checkpointsPerTerm: 365, - _checkpointDuration: 1 days, - _timeStretch: 22.186877016851916266e18 - }); + uint256 timeStretch = FixedPointMath.ONE_18.divDown( + 22.186877016851916266e18 + ); + hyperdrive = new MockHyperdrive( + baseToken, + FixedPointMath.ONE_18, + 365, + 1 days, + timeStretch + ); + + // Advance time so that Hyperdrive can look back more than a position + // duration. + vm.warp(365 days * 3); + } + + /// initialize /// + + function initialize( + address lp, + uint256 apr, + uint256 contribution + ) internal { + vm.stopPrank(); + vm.startPrank(lp); + + // Initialize the pool. + baseToken.mint(contribution); + baseToken.approve(address(hyperdrive), contribution); + hyperdrive.initialize(contribution, apr); + } + + function test_initialize_failure() external { + uint256 apr = 0.5e18; + uint256 contribution = 1000.0e18; + + // Initialize the pool with Alice. + initialize(alice, apr, contribution); + + // Attempt to initialize the pool a second time. This should fail. + vm.stopPrank(); + vm.startPrank(bob); + baseToken.mint(contribution); + baseToken.approve(address(hyperdrive), contribution); + vm.expectRevert(Errors.PoolAlreadyInitialized.selector); + hyperdrive.initialize(contribution, apr); + } + + // TODO: We need a test that verifies that the quoted APR is the same as the + // realized APR of making a small trade on the pool. This should be part of + // the open long testing. + // + // TODO: This should ultimately be a fuzz test that fuzzes over the initial + // share price, the APR, the contribution, the position duration, and other + // parameters that can have an impact on the pool's APR. + function test_initialize_success() external { + uint256 apr = 0.05e18; + uint256 contribution = 1000e18; + + // Initialize the pool with Alice. + initialize(alice, apr, contribution); + + // Ensure that the pool's APR is approximately equal to the target APR. + uint256 poolApr = HyperdriveMath.calculateAPRFromReserves( + hyperdrive.shareReserves(), + hyperdrive.bondReserves(), + hyperdrive.totalSupply(AssetId._LP_ASSET_ID), + hyperdrive.initialSharePrice(), + hyperdrive.positionDuration(), + hyperdrive.timeStretch() + ); + assertApproxEqAbs(poolApr, apr, 1e1); // 17 decimals of precision + + // Ensure that Alice's base balance has been depleted and that Alice + // received some LP tokens. + assertEq(baseToken.balanceOf(alice), 0); + assertEq(baseToken.balanceOf(address(hyperdrive)), contribution); + assertEq( + hyperdrive.totalSupply(AssetId._LP_ASSET_ID), + contribution + hyperdrive.bondReserves() + ); + } + + /// openLong /// + + function test_open_long_zero_amount() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Attempt to purchase bonds with zero base. This should fail. + vm.stopPrank(); + vm.startPrank(bob); + vm.expectRevert(Errors.ZeroAmount.selector); + hyperdrive.openLong(0, 0); + } + + function test_open_long_extreme_amount() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Attempt to purchase more bonds than exist. This should fail. + vm.stopPrank(); + vm.startPrank(bob); + uint256 baseAmount = hyperdrive.bondReserves(); + baseToken.mint(baseAmount); + baseToken.approve(address(hyperdrive), baseAmount); + vm.expectRevert(stdError.arithmeticError); + hyperdrive.openLong(baseAmount, 0); + } + + function test_open_long() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Get the reserves before opening the long. + PoolInfo memory poolInfoBefore = getPoolInfo(); + + // TODO: Small base amounts result in higher than quoted APRs. We should + // first investigate the math to see if there are obvious simplifications + // to be made, and then consider increasing the amount of precision in + // used in our fixed rate format. + // + // Purchase a small amount of bonds. + vm.stopPrank(); + vm.startPrank(bob); + uint256 baseAmount = 10e18; + baseToken.mint(baseAmount); + baseToken.approve(address(hyperdrive), baseAmount); + hyperdrive.openLong(baseAmount, 0); + + // Verify the base transfers. + assertEq(baseToken.balanceOf(bob), 0); + assertEq( + baseToken.balanceOf(address(hyperdrive)), + contribution + baseAmount + ); + + // Verify that Bob received an acceptable amount of bonds. Since the + // base amount is very low relative to the pool's liquidity, the implied + // APR should be approximately equal to the pool's APR. + uint256 maturityTime = (block.timestamp - (block.timestamp % 1 days)) + + 365 days; + uint256 bondAmount = hyperdrive.balanceOf( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Long, maturityTime), + bob + ); + uint256 realizedApr = calculateAPRFromRealizedPrice( + baseAmount, + bondAmount, + maturityTime - block.timestamp, + 365 days + ); + // TODO: This tolerance seems too high. + assertApproxEqAbs(realizedApr, apr, 1e10); + + // Verify that the reserves were updated correctly. + PoolInfo memory poolInfoAfter = getPoolInfo(); + assertEq( + poolInfoAfter.shareReserves, + poolInfoBefore.shareReserves + + baseAmount.divDown(poolInfoBefore.sharePrice) + ); + assertEq( + poolInfoAfter.bondReserves, + poolInfoBefore.bondReserves - bondAmount + ); + assertEq(poolInfoAfter.lpTotalSupply, poolInfoBefore.lpTotalSupply); + assertEq(poolInfoAfter.sharePrice, poolInfoBefore.sharePrice); + assertEq( + poolInfoAfter.longsOutstanding, + poolInfoBefore.longsOutstanding + bondAmount + ); + assertEq( + poolInfoAfter.shortsOutstanding, + poolInfoBefore.shortsOutstanding + ); + } + + /// Close Long /// + + function test_close_long_zero_amount() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Purchase some bonds. + vm.stopPrank(); + vm.startPrank(bob); + uint256 baseAmount = 10e18; + baseToken.mint(baseAmount); + baseToken.approve(address(hyperdrive), baseAmount); + hyperdrive.openLong(baseAmount, 0); + + // Attempt to close zero longs. This should fail. + vm.stopPrank(); + vm.startPrank(bob); + vm.expectRevert(Errors.ZeroAmount.selector); + uint256 maturityTime = (block.timestamp - (block.timestamp % 1 days)) + + 365 days; + hyperdrive.closeLong(maturityTime, 0, 0); + } + + function test_close_long_invalid_amount() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Purchase some bonds. + vm.stopPrank(); + vm.startPrank(bob); + uint256 baseAmount = 10e18; + baseToken.mint(baseAmount); + baseToken.approve(address(hyperdrive), baseAmount); + hyperdrive.openLong(baseAmount, 0); + + // Attempt to close too many longs. This should fail. + vm.stopPrank(); + vm.startPrank(bob); + uint256 maturityTime = (block.timestamp - (block.timestamp % 1 days)) + + 365 days; + uint256 bondAmount = hyperdrive.balanceOf( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Long, maturityTime), + bob + ); + vm.expectRevert(stdError.arithmeticError); + hyperdrive.closeLong(maturityTime, bondAmount + 1, 0); + } + + function test_close_long_invalid_timestamp() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Purchase some bonds. + vm.stopPrank(); + vm.startPrank(bob); + uint256 baseAmount = 10e18; + baseToken.mint(baseAmount); + baseToken.approve(address(hyperdrive), baseAmount); + hyperdrive.openLong(baseAmount, 0); + + // Attempt to use a timestamp greater than the maximum range. + vm.stopPrank(); + vm.startPrank(bob); + vm.expectRevert(Errors.InvalidTimestamp.selector); + hyperdrive.closeLong(uint256(type(uint248).max) + 1, 1, 0); + } + + function test_close_long_immediately() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Purchase some bonds. + vm.stopPrank(); + vm.startPrank(bob); + uint256 baseAmount = 10e18; + baseToken.mint(baseAmount); + baseToken.approve(address(hyperdrive), baseAmount); + hyperdrive.openLong(baseAmount, 0); + + // Get the reserves before closing the long. + PoolInfo memory poolInfoBefore = getPoolInfo(); + + // Immediately close the bonds. + vm.stopPrank(); + vm.startPrank(bob); + uint256 maturityTime = (block.timestamp - (block.timestamp % 1 days)) + + 365 days; + uint256 bondAmount = hyperdrive.balanceOf( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Long, maturityTime), + bob + ); + hyperdrive.closeLong(maturityTime, bondAmount, 0); + + // TODO: Bob receives more base than he started with. Fees should take + // care of this, but this should be investigating nonetheless. + // + // Verify that all of Bob's bonds were burned and that he has + // approximately as much base as he started with. + uint256 baseProceeds = baseToken.balanceOf(bob); + assertEq( + hyperdrive.balanceOf( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Long, maturityTime), + bob + ), + 0 + ); + assertApproxEqAbs(baseProceeds, baseAmount, 1e10); + + // Verify that the reserves were updated correctly. Since this trade + // happens at the beginning of the term, the bond reserves should be + // increased by the full amount. + PoolInfo memory poolInfoAfter = getPoolInfo(); + assertEq( + poolInfoAfter.shareReserves, + poolInfoBefore.shareReserves - + baseProceeds.divDown(poolInfoBefore.sharePrice) + ); + assertEq( + poolInfoAfter.bondReserves, + poolInfoBefore.bondReserves + bondAmount + ); + assertEq(poolInfoAfter.lpTotalSupply, poolInfoBefore.lpTotalSupply); + assertEq(poolInfoAfter.sharePrice, poolInfoBefore.sharePrice); + assertEq( + poolInfoAfter.longsOutstanding, + poolInfoBefore.longsOutstanding - bondAmount + ); + assertEq( + poolInfoAfter.shortsOutstanding, + poolInfoBefore.shortsOutstanding + ); + } + + // TODO: Clean up these tests. + function test_close_long_redeem() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Purchase some bonds. + vm.stopPrank(); + vm.startPrank(bob); + uint256 baseAmount = 10e18; + baseToken.mint(baseAmount); + baseToken.approve(address(hyperdrive), baseAmount); + hyperdrive.openLong(baseAmount, 0); + uint256 maturityTime = (block.timestamp - (block.timestamp % 1 days)) + + 365 days; + + // Get the reserves before closing the long. + PoolInfo memory poolInfoBefore = getPoolInfo(); + + // The term passes. + vm.warp(block.timestamp + 365 days); + + // Redeem the bonds + vm.stopPrank(); + vm.startPrank(bob); + uint256 bondAmount = hyperdrive.balanceOf( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Long, maturityTime), + bob + ); + hyperdrive.closeLong(maturityTime, bondAmount, 0); + + // TODO: Bob receives more base than the bond amount. It appears that + // the yield space implementation returns a positive value even when + // the input is zero. + // + // Verify that all of Bob's bonds were burned and that he has + // approximately as much base as he started with. + uint256 baseProceeds = baseToken.balanceOf(bob); + assertEq( + hyperdrive.balanceOf( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Long, maturityTime), + bob + ), + 0 + ); + assertApproxEqAbs(baseProceeds, bondAmount, 1e10); + + // Verify that the reserves were updated correctly. Since this trade + // is a redemption, there should be no changes to the bond reserves. + PoolInfo memory poolInfoAfter = getPoolInfo(); + assertEq( + poolInfoAfter.shareReserves, + poolInfoBefore.shareReserves - + bondAmount.divDown(poolInfoBefore.sharePrice) + ); + assertEq(poolInfoAfter.bondReserves, poolInfoBefore.bondReserves); + assertEq(poolInfoAfter.lpTotalSupply, poolInfoBefore.lpTotalSupply); + assertEq(poolInfoAfter.sharePrice, poolInfoBefore.sharePrice); + assertEq( + poolInfoAfter.longsOutstanding, + poolInfoBefore.longsOutstanding - bondAmount + ); + assertEq( + poolInfoAfter.shortsOutstanding, + poolInfoBefore.shortsOutstanding + ); + } + + /// Open Short /// + + function test_open_short_zero_amount() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Attempt to short zero bonds. This should fail. + vm.stopPrank(); + vm.startPrank(bob); + vm.expectRevert(Errors.ZeroAmount.selector); + hyperdrive.openShort(0, type(uint256).max); + } + + function test_open_short_extreme_amount() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Attempt to short an extreme amount of bonds. This should fail. + vm.stopPrank(); + vm.startPrank(bob); + uint256 baseAmount = hyperdrive.shareReserves(); + baseToken.mint(baseAmount); + baseToken.approve(address(hyperdrive), baseAmount); + vm.expectRevert(Errors.FixedPointMath_SubOverflow.selector); + hyperdrive.openShort(baseAmount * 2, type(uint256).max); + } + + function test_open_short() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Get the reserves before opening the short. + PoolInfo memory poolInfoBefore = getPoolInfo(); + + // Short a small amount of bonds. + vm.stopPrank(); + vm.startPrank(bob); + uint256 bondAmount = 10e18; + baseToken.mint(bondAmount); + baseToken.approve(address(hyperdrive), bondAmount); + hyperdrive.openShort(bondAmount, type(uint256).max); + + // Verify that Hyperdrive received the max loss and that Bob received + // the short tokens. + uint256 maxLoss = bondAmount - baseToken.balanceOf(bob); + uint256 maturityTime = (block.timestamp - (block.timestamp % 1 days)) + + 365 days; + assertEq( + baseToken.balanceOf(address(hyperdrive)), + contribution + maxLoss + ); + assertEq( + hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + maturityTime + ), + bob + ), + bondAmount + ); + + // Verify that Bob's short has an acceptable fixed rate. Since the bond + // amount is very low relative to the pool's liquidity, the implied APR + // should be approximately equal to the pool's APR. + uint256 baseAmount = bondAmount - maxLoss; + uint256 realizedApr = calculateAPRFromRealizedPrice( + baseAmount, + bondAmount, + maturityTime - block.timestamp, + 365 days + ); + // TODO: This tolerance seems too high. + assertApproxEqAbs(realizedApr, apr, 1e10); + + // Verify that the reserves were updated correctly. + PoolInfo memory poolInfoAfter = getPoolInfo(); + assertEq( + poolInfoAfter.shareReserves, + poolInfoBefore.shareReserves - + baseAmount.divDown(poolInfoBefore.sharePrice) + ); + assertEq( + poolInfoAfter.bondReserves, + poolInfoBefore.bondReserves + bondAmount + ); + assertEq(poolInfoAfter.lpTotalSupply, poolInfoBefore.lpTotalSupply); + assertEq(poolInfoAfter.sharePrice, poolInfoBefore.sharePrice); + assertEq( + poolInfoAfter.longsOutstanding, + poolInfoBefore.longsOutstanding + ); + assertEq( + poolInfoAfter.shortsOutstanding, + poolInfoBefore.shortsOutstanding + bondAmount + ); + } + + /// Close Short /// + + function test_close_short_zero_amount() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Open a short.. + vm.stopPrank(); + vm.startPrank(bob); + uint256 bondAmount = 10e18; + baseToken.mint(bondAmount); + baseToken.approve(address(hyperdrive), bondAmount); + hyperdrive.openShort(bondAmount, type(uint256).max); + + // Attempt to close zero shorts. This should fail. + vm.stopPrank(); + vm.startPrank(bob); + vm.expectRevert(Errors.ZeroAmount.selector); + uint256 maturityTime = (block.timestamp - (block.timestamp % 1 days)) + + 365 days; + hyperdrive.closeShort(maturityTime, 0, 0); + } + + function test_close_short_invalid_amount() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Purchase some bonds. + vm.stopPrank(); + vm.startPrank(bob); + uint256 bondAmount = 10e18; + baseToken.mint(bondAmount); + baseToken.approve(address(hyperdrive), bondAmount); + hyperdrive.openShort(bondAmount, type(uint256).max); + + // Attempt to close too many shorts. This should fail. + vm.stopPrank(); + vm.startPrank(bob); + uint256 maturityTime = (block.timestamp - (block.timestamp % 1 days)) + + 365 days; + vm.expectRevert(stdError.arithmeticError); + hyperdrive.closeShort(maturityTime, bondAmount + 1, 0); + } + + function test_close_short_invalid_timestamp() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Open a short. + vm.stopPrank(); + vm.startPrank(bob); + uint256 bondAmount = 10e18; + baseToken.mint(bondAmount); + baseToken.approve(address(hyperdrive), bondAmount); + hyperdrive.openShort(bondAmount, type(uint256).max); + + // Attempt to use a timestamp greater than the maximum range. + vm.stopPrank(); + vm.startPrank(bob); + vm.expectRevert(Errors.InvalidTimestamp.selector); + hyperdrive.closeShort(uint256(type(uint248).max) + 1, 1, 0); + } + + function test_close_short_immediately() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Purchase some bonds. + vm.stopPrank(); + vm.startPrank(bob); + uint256 bondAmount = 10e18; + baseToken.mint(bondAmount); + baseToken.approve(address(hyperdrive), bondAmount); + hyperdrive.openShort(bondAmount, type(uint256).max); + + // Get the reserves before closing the long. + PoolInfo memory poolInfoBefore = getPoolInfo(); + + // Immediately close the bonds. + vm.stopPrank(); + vm.startPrank(bob); + uint256 maturityTime = (block.timestamp - (block.timestamp % 1 days)) + + 365 days; + hyperdrive.closeShort(maturityTime, bondAmount, 0); + + // TODO: Bob receives more base than he started with. Fees should take + // care of this, but this should be investigating nonetheless. + // + // Verify that all of Bob's bonds were burned and that he has + // approximately as much base as he started with. + uint256 baseAmount = baseToken.balanceOf(bob); + assertEq( + hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + maturityTime + ), + bob + ), + 0 + ); + assertApproxEqAbs(baseAmount, bondAmount, 1e10); + + // Verify that the reserves were updated correctly. Since this trade + // happens at the beginning of the term, the bond reserves should be + // increased by the full amount. + PoolInfo memory poolInfoAfter = getPoolInfo(); + assertApproxEqAbs( + poolInfoAfter.shareReserves, + poolInfoBefore.shareReserves + + baseAmount.divDown(poolInfoBefore.sharePrice), + 1e18 + ); + assertEq( + poolInfoAfter.bondReserves, + poolInfoBefore.bondReserves - bondAmount + ); + assertEq(poolInfoAfter.lpTotalSupply, poolInfoBefore.lpTotalSupply); + assertEq(poolInfoAfter.sharePrice, poolInfoBefore.sharePrice); + assertEq( + poolInfoAfter.longsOutstanding, + poolInfoBefore.longsOutstanding + ); + assertEq( + poolInfoAfter.shortsOutstanding, + poolInfoBefore.shortsOutstanding - bondAmount + ); + } + + // TODO: Clean up these tests. + function test_close_short_redeem() external { + uint256 apr = 0.05e18; + + // Initialize the pool with a large amount of capital. + uint256 contribution = 500_000_000e18; + initialize(alice, apr, contribution); + + // Purchase some bonds. + vm.stopPrank(); + vm.startPrank(bob); + uint256 bondAmount = 10e18; + baseToken.mint(bondAmount); + baseToken.approve(address(hyperdrive), bondAmount); + hyperdrive.openShort(bondAmount, type(uint256).max); + uint256 maturityTime = (block.timestamp - (block.timestamp % 1 days)) + + 365 days; + + // Get the reserves before closing the long. + PoolInfo memory poolInfoBefore = getPoolInfo(); + + // The term passes. + vm.warp(block.timestamp + 365 days); + + // Get the base balance before closing the short. + uint256 baseBalanceBefore = baseToken.balanceOf(bob); + + // Redeem the bonds + vm.stopPrank(); + vm.startPrank(bob); + hyperdrive.closeShort(maturityTime, bondAmount, 0); + + // TODO: Investigate this more to see if there are any irregularities + // like there are with the long redemption test. + // + // Verify that all of Bob's bonds were burned and that he has + // approximately as much base as he started with. + uint256 baseBalanceAfter = baseToken.balanceOf(bob); + assertEq( + hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + maturityTime + ), + bob + ), + 0 + ); + assertApproxEqAbs(baseBalanceAfter, baseBalanceBefore, 1e10); + + // Verify that the reserves were updated correctly. Since this trade + // is a redemption, there should be no changes to the bond reserves. + PoolInfo memory poolInfoAfter = getPoolInfo(); + assertEq( + poolInfoAfter.shareReserves, + poolInfoBefore.shareReserves + + bondAmount.divDown(poolInfoBefore.sharePrice) + ); + assertEq(poolInfoAfter.bondReserves, poolInfoBefore.bondReserves); + assertEq(poolInfoAfter.lpTotalSupply, poolInfoBefore.lpTotalSupply); + assertEq(poolInfoAfter.sharePrice, poolInfoBefore.sharePrice); + assertEq( + poolInfoAfter.longsOutstanding, + poolInfoBefore.longsOutstanding + ); + assertEq( + poolInfoAfter.shortsOutstanding, + poolInfoBefore.shortsOutstanding - bondAmount + ); + } + + /// Utils /// + + function calculateAPRFromRealizedPrice( + uint256 baseAmount, + uint256 bondAmount, + uint256 timeRemaining, + uint256 positionDuration + ) internal pure returns (uint256) { + // apr = (dy - dx) / (dx * t) + uint256 t = timeRemaining.divDown(positionDuration); + return (bondAmount.sub(baseAmount)).divDown(baseAmount.mulDown(t)); + } + + struct PoolInfo { + uint256 shareReserves; + uint256 bondReserves; + uint256 lpTotalSupply; + uint256 sharePrice; + uint256 longsOutstanding; + uint256 shortsOutstanding; + } + + function getPoolInfo() internal view returns (PoolInfo memory) { + ( + uint256 shareReserves, + uint256 bondReserves, + uint256 lpTotalSupply, + uint256 sharePrice, + uint256 longsOutstanding, + uint256 shortsOutstanding + ) = hyperdrive.getPoolInfo(); + return + PoolInfo({ + shareReserves: shareReserves, + bondReserves: bondReserves, + lpTotalSupply: lpTotalSupply, + sharePrice: sharePrice, + longsOutstanding: longsOutstanding, + shortsOutstanding: shortsOutstanding + }); } } diff --git a/test/mocks/ERC20Mintable.sol b/test/mocks/ERC20Mintable.sol new file mode 100644 index 000000000..636287957 --- /dev/null +++ b/test/mocks/ERC20Mintable.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.15; + +import { ERC20Burnable } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract ERC20Mintable is ERC20 { + constructor() ERC20("Base", "BASE") {} + + function mint(uint256 amount) external { + _mint(msg.sender, amount); + } + + function burn(uint256 amount) external { + _burn(msg.sender, amount); + } +} diff --git a/test/mocks/MockHyperdrive.sol b/test/mocks/MockHyperdrive.sol new file mode 100644 index 000000000..d5acefb68 --- /dev/null +++ b/test/mocks/MockHyperdrive.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.15; + +import { ERC20PresetMinterPauser } from "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol"; +import { ForwarderFactory } from "contracts/ForwarderFactory.sol"; +import { Hyperdrive } from "contracts/Hyperdrive.sol"; +import { FixedPointMath } from "contracts/libraries/FixedPointMath.sol"; +import { Errors } from "contracts/libraries/Errors.sol"; +import { ERC20Mintable } from "test/mocks/ERC20Mintable.sol"; + +contract MockHyperdrive is Hyperdrive { + using FixedPointMath for uint256; + + uint256 internal _sharePrice; + + constructor( + ERC20Mintable baseToken, + uint256 _initialSharePrice, + uint256 _checkpointsPerTerm, + uint256 _checkpointDuration, + uint256 _timeStretch + ) + Hyperdrive( + bytes32(0), + address(new ForwarderFactory()), + baseToken, + _initialSharePrice, + _checkpointsPerTerm, + _checkpointDuration, + _timeStretch + ) + { + _sharePrice = _initialSharePrice; + } + + /// Mocks /// + + error InvalidSharePrice(); + + function getSharePrice() external view returns (uint256) { + return _sharePrice; + } + + function setSharePrice(uint256 sharePrice) external { + if (sharePrice <= _sharePrice) { + revert InvalidSharePrice(); + } + + // Update the share price and accrue interest. + ERC20Mintable(address(baseToken)).mint( + (sharePrice.sub(_sharePrice)).mulDown( + baseToken.balanceOf(address(this)) + ) + ); + _sharePrice = sharePrice; + } + + /// Overrides /// + + function deposit( + uint256 amount + ) internal override returns (uint256, uint256) { + bool success = baseToken.transferFrom( + msg.sender, + address(this), + amount + ); + if (!success) { + revert Errors.TransferFailed(); + } + return (amount.divDown(_sharePrice), _sharePrice); + } + + function withdraw( + uint256 shares, + address destination + ) internal override returns (uint256, uint256) { + uint256 amountWithdrawn = shares.mulDown(_sharePrice); + bool success = baseToken.transfer(destination, amountWithdrawn); + if (!success) { + revert Errors.TransferFailed(); + } + return (amountWithdrawn, _sharePrice); + } + + function pricePerShare() internal view override returns (uint256) { + return _sharePrice; + } +}