diff --git a/contracts/src/interfaces/ISwapRouter.sol b/contracts/src/interfaces/ISwapRouter.sol new file mode 100644 index 000000000..a9b552bb2 --- /dev/null +++ b/contracts/src/interfaces/ISwapRouter.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.22; + +interface ISwapRouter { + struct ExactInputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + uint160 sqrtPriceLimitX96; + } + + function exactInputSingle( + ExactInputSingleParams calldata params + ) external payable returns (uint256 amountOut); + + struct ExactInputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountIn; + uint256 amountOutMinimum; + } + + function exactInput( + ExactInputParams calldata params + ) external payable returns (uint256 amountOut); + + struct ExactOutputSingleParams { + address tokenIn; + address tokenOut; + uint24 fee; + address recipient; + uint256 deadline; + uint256 amountOut; + uint256 amountInMaximum; + uint160 sqrtPriceLimitX96; + } + + function exactOutputSingle( + ExactOutputSingleParams calldata params + ) external payable returns (uint256 amountIn); + + struct ExactOutputParams { + bytes path; + address recipient; + uint256 deadline; + uint256 amountOut; + uint256 amountInMaximum; + } + + function exactOutput( + ExactOutputParams calldata params + ) external payable returns (uint256 amountIn); +} diff --git a/contracts/src/interfaces/IUniV3Zap.sol b/contracts/src/interfaces/IUniV3Zap.sol new file mode 100644 index 000000000..b157c5f8f --- /dev/null +++ b/contracts/src/interfaces/IUniV3Zap.sol @@ -0,0 +1,289 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { IHyperdrive } from "./IHyperdrive.sol"; +import { ISwapRouter } from "./ISwapRouter.sol"; + +/// @title IUniV3Zap +/// @author DELV +/// @notice The interface for the UniV3Zap contract. +/// @custom:disclaimer The language used in this code is for coding convenience +/// only, and is not intended to, and does not, have any +/// particular legal or regulatory significance. +interface IUniV3Zap { + /// Errors /// + + /// @notice Thrown when attempting to zap to an invalid input token. + error InvalidInputToken(); + + /// @notice Thrown when attempting to zap to an invalid output token. + error InvalidOutputToken(); + + /// @notice Thrown when attempting to zap to an invalid recipient. + error InvalidRecipient(); + + /// @notice Thrown when attempting to zap with an invalid source amount. If + /// the source asset doesn't needs to be wrapped, this needs to be + /// the same as the swap's input amount. + error InvalidSourceAmount(); + + /// @notice Thrown when attempting to zap with an invalid source asset. If + /// the source asset needs to be wrapped, this shouldn't be the same + /// address as the input token to the swap. Otherwise, they should + /// be the same. + error InvalidSourceAsset(); + + /// @notice Thrown when attempting to zap from an asset to itself. This + /// protects users from swaps that could only lead them to incur + /// losses through fees and slippage. + error InvalidSwap(); + + /// @notice Thrown when receiving ether outside of an zap. + error InvalidTransfer(); + + /// @notice Thrown when ether is sent to an instance that doesn't accept + /// ether as a deposit asset. + error NotPayable(); + + /// @notice Thrown when we should be wrapping assets, but the zap isn't + /// configured to wrap. + error ShouldWrapAssets(); + + /// @notice Thrown when an ether transfer fails. + error TransferFailed(); + + /// Structs /// + + /// @dev The options parameter provided to all of the functions that zap + /// funds into Hyperdrive. + struct ZapInOptions { + /// @dev The Uniswap swap parameters to use when swapping assets to the + /// deposit asset. + ISwapRouter.ExactInputParams swapParams; + /// @dev In most cases, this should be equal to the input token of the + /// swap. In some cases, this will be a rebasing token like stETH + /// that needs to be wrapped to make it suitable for swapping on + /// Uniswap. + address sourceAsset; + /// @dev The amount of source tokens that should be swapped. In most + /// cases, this should be equal to the `swapParams.amountIn`, but + /// in the case of wrapped tokens, this amount will supersede that + /// quantity. + uint256 sourceAmount; + /// @dev A flag that indicates whether or not the source token should + /// be wrapped into the input token. Uniswap v3 demands complete + /// precision on the input token amounts, which makes it hard to + /// work with rebasing tokens that have imprecise transfer + /// functions. Wrapping tokens provides a workaround for these + /// issues. + bool shouldWrap; + /// @dev A flag that indicates whether or not the Hyperdrive vault + /// shares token is a vault shares token. This is used to ensure + /// that the input into Hyperdrive properly handles rebasing tokens. + bool isRebasing; + } + + /// Metadata /// + + /// @notice Returns the name of this zap. + /// @return The name of this zap. + function name() external view returns (string memory); + + /// @notice Returns the kind of this zap. + /// @return The kind of this zap. + function kind() external view returns (string memory); + + /// @notice Returns the version of this zap. + /// @return The version of this zap. + function version() external view returns (string memory); + + /// LPs /// + + /// @notice Executes a swap on Uniswap and uses the proceeds to add + /// liquidity on Hyperdrive. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _minLpSharePrice The minimum LP share price the LP is willing + /// to accept for their shares. LPs incur negative slippage when + /// adding liquidity if there is a net curve position in the market, + /// so this allows LPs to protect themselves from high levels of + /// slippage. The units of this quantity are either base or vault + /// shares, depending on the value of `_options.asBase`. + /// @param _minApr The minimum APR at which the LP is willing to supply. + /// @param _maxApr The maximum APR at which the LP is willing to supply. + /// @param _hyperdriveOptions The options that configure how the Hyperdrive + /// operation is settled. + /// @param _zapInOptions The options that configure how the zap will be + /// settled. + /// @return lpShares The LP shares received by the LP. + function addLiquidityZap( + IHyperdrive _hyperdrive, + uint256 _minLpSharePrice, + uint256 _minApr, + uint256 _maxApr, + IHyperdrive.Options calldata _hyperdriveOptions, + IUniV3Zap.ZapInOptions calldata _zapInOptions + ) external payable returns (uint256 lpShares); + + /// @notice Removes liquidity on Hyperdrive and converts the proceeds to the + /// traders preferred asset by executing a swap on Uniswap v3. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _lpShares The LP shares to burn. + /// @param _minOutputPerShare The minimum amount the LP expects to receive + /// for each withdrawal share that is burned. The units of this + /// quantity are either base or vault shares, depending on the value + /// of `_options.asBase`. + /// @param _options The options that configure how the operation is settled. + /// @param _swapParams The Uniswap swap parameters for a multi-hop fill. + /// @param _shouldWrap A flag indicating whether or not the proceeds need to + /// be wrapped. + /// @return proceeds The proceeds of removing liquidity. These proceeds will + /// be in units determined by the Uniswap swap parameters. + /// @return withdrawalShares The base that the LP receives buys out some of + /// their LP shares, but it may not be sufficient to fully buy the + /// LP out. In this case, the LP receives withdrawal shares equal in + /// value to the present value they are owed. As idle capital + /// becomes available, the pool will buy back these shares. + function removeLiquidityZap( + IHyperdrive _hyperdrive, + uint256 _lpShares, + uint256 _minOutputPerShare, + IHyperdrive.Options calldata _options, + ISwapRouter.ExactInputParams calldata _swapParams, + bool _shouldWrap + ) external returns (uint256 proceeds, uint256 withdrawalShares); + + /// @notice Redeem withdrawal shares on Hyperdrive and converts the proceeds + /// to the traders preferred asset by executing a swap on Uniswap + /// v3. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _withdrawalShares The withdrawal shares to redeem. + /// @param _minOutputPerShare The minimum amount the LP expects to + /// receive for each withdrawal share that is burned. The units of + /// this quantity are either base or vault shares, depending on the + /// value of `_options.asBase`. + /// @param _options The options that configure how the operation is settled. + /// @param _swapParams The Uniswap swap parameters for a multi-hop fill. + /// @param _shouldWrap A flag indicating whether or not the proceeds need to + /// be wrapped. + /// @return proceeds The proceeds of redeeming withdrawal shares. These + /// proceeds will be in units determined by the Uniswap swap + /// parameters. + /// @return withdrawalSharesRedeemed The amount of withdrawal shares that + /// were redeemed. + function redeemWithdrawalSharesZap( + IHyperdrive _hyperdrive, + uint256 _withdrawalShares, + uint256 _minOutputPerShare, + IHyperdrive.Options calldata _options, + ISwapRouter.ExactInputParams calldata _swapParams, + bool _shouldWrap + ) external returns (uint256 proceeds, uint256 withdrawalSharesRedeemed); + + /// Longs /// + + /// @notice Executes a swap on Uniswap and uses the proceeds to open a long + /// on Hyperdrive. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _minOutput The minimum number of bonds to receive. + /// @param _minVaultSharePrice The minimum vault share price at which to + /// open the long. This allows traders to protect themselves from + /// opening a long in a checkpoint where negative interest has + /// accrued. + /// @param _hyperdriveOptions The options that configure how the Hyperdrive + /// operation is settled. + /// @param _zapInOptions The options that configure how the zap will be + /// settled. + /// @return maturityTime The maturity time of the bonds. + /// @return longAmount The amount of bonds the trader received. + function openLongZap( + IHyperdrive _hyperdrive, + uint256 _minOutput, + uint256 _minVaultSharePrice, + IHyperdrive.Options calldata _hyperdriveOptions, + IUniV3Zap.ZapInOptions calldata _zapInOptions + ) external payable returns (uint256 maturityTime, uint256 longAmount); + + /// @notice Closes a long on Hyperdrive and converts the proceeds to the + /// traders preferred asset by executing a swap on Uniswap v3. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _maturityTime The maturity time of the long. + /// @param _bondAmount The amount of longs to close. + /// @param _minOutput The minimum proceeds the trader will accept. The units + /// of this quantity are either base or vault shares, depending on + /// the value of `_options.asBase`. + /// @param _options The options that configure how the Hyperdrive trade is + /// settled. + /// @param _swapParams The Uniswap swap parameters for a multi-hop fill. + /// @param _shouldWrap A flag indicating whether or not the proceeds need to + /// be wrapped. + /// @return proceeds The proceeds of closing the long. These proceeds will + /// be in units determined by the Uniswap swap parameters. + function closeLongZap( + IHyperdrive _hyperdrive, + uint256 _maturityTime, + uint256 _bondAmount, + uint256 _minOutput, + IHyperdrive.Options calldata _options, + ISwapRouter.ExactInputParams calldata _swapParams, + bool _shouldWrap + ) external returns (uint256 proceeds); + + /// Shorts /// + + /// @notice Executes a swap on Uniswap and uses the proceeds to open a short + /// on Hyperdrive. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _maxDeposit The most the user expects to deposit for this trade. + /// The units of this quantity are either base or vault shares, + /// depending on the value of `_options.asBase`. + /// @param _minVaultSharePrice The minimum vault share price at which to open + /// the short. This allows traders to protect themselves from opening + /// a short in a checkpoint where negative interest has accrued. + /// @param _hyperdriveOptions The options that configure how the Hyperdrive + /// operation is settled. + /// @param _zapInOptions The options that configure how the zap will be + /// settled. + /// @return maturityTime The maturity time of the bonds. + /// @return deposit The amount the user deposited for this trade. The units + /// of this quantity are either base or vault shares, depending on + /// the value of `_options.asBase`. + function openShortZap( + IHyperdrive _hyperdrive, + uint256 _bondAmount, + uint256 _maxDeposit, + uint256 _minVaultSharePrice, + IHyperdrive.Options calldata _hyperdriveOptions, + IUniV3Zap.ZapInOptions calldata _zapInOptions + ) external payable returns (uint256 maturityTime, uint256 deposit); + + /// @notice Closes a short on Hyperdrive and converts the proceeds to the + /// traders preferred asset by executing a swap on Uniswap v3. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _maturityTime The maturity time of the short. + /// @param _bondAmount The amount of shorts to close. + /// @param _minOutput The minimum output of this trade. The units of this + /// quantity are either base or vault shares, depending on the value + /// of `_options.asBase`. + /// @param _options The options that configure how the Hyperdrive trade is + /// settled. + /// @param _swapParams The Uniswap swap parameters for a multi-hop fill. + /// @param _shouldWrap A flag indicating whether or not the proceeds need to + /// be wrapped. + /// @return proceeds The proceeds of closing the short. These proceeds will + /// be in units determined by the Uniswap swap parameters. + function closeShortZap( + IHyperdrive _hyperdrive, + uint256 _maturityTime, + uint256 _bondAmount, + uint256 _minOutput, + IHyperdrive.Options calldata _options, + ISwapRouter.ExactInputParams calldata _swapParams, + bool _shouldWrap + ) external returns (uint256 proceeds); + + /// Getters /// + + /// @notice Returns the Uniswap swap router. + /// @return The Uniswap swap router. + function swapRouter() external view returns (ISwapRouter); +} diff --git a/contracts/src/interfaces/IWETH.sol b/contracts/src/interfaces/IWETH.sol new file mode 100644 index 000000000..f6cb8fcc7 --- /dev/null +++ b/contracts/src/interfaces/IWETH.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { IERC20 } from "./IERC20.sol"; + +interface IWETH is IERC20 { + function deposit() external payable; + + function withdraw(uint256 wad) external; +} diff --git a/contracts/src/interfaces/IWrappedERC20.sol b/contracts/src/interfaces/IWrappedERC20.sol new file mode 100644 index 000000000..ac3dc87c2 --- /dev/null +++ b/contracts/src/interfaces/IWrappedERC20.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { IERC20 } from "./IERC20.sol"; + +interface IWrappedERC20 is IERC20 { + function wrap(uint256 _amount) external returns (uint256); +} diff --git a/contracts/src/libraries/Constants.sol b/contracts/src/libraries/Constants.sol index 8df1e21d7..a4365be5a 100644 --- a/contracts/src/libraries/Constants.sol +++ b/contracts/src/libraries/Constants.sol @@ -5,7 +5,7 @@ pragma solidity ^0.8.20; address constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; /// @dev The version of the contracts. -string constant VERSION = "v1.0.19"; +string constant VERSION = "v1.0.20"; /// @dev The number of targets that must be deployed for a full deployment. uint256 constant NUM_TARGETS = 5; @@ -111,3 +111,6 @@ string constant STAKING_USDS_HYPERDRIVE_KIND = "StakingUSDSHyperdrive"; /// @dev The kind of StkWellSHyperdrive. string constant STK_WELL_HYPERDRIVE_KIND = "StkWellHyperdrive"; + +/// @dev The kind of UniV3Zap. +string constant UNI_V3_ZAP_KIND = "UniV3Zap"; diff --git a/contracts/src/libraries/UniV3Path.sol b/contracts/src/libraries/UniV3Path.sol new file mode 100644 index 000000000..f820be6ab --- /dev/null +++ b/contracts/src/libraries/UniV3Path.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +/// @title Path +/// @author DELV +/// @notice This is a library for interacting with Uniswap's multi-hop paths. +library UniV3Path { + /// @dev Returns the input token of a Uniswap swap. + /// @param _path The Uniswap path for a multi-hop fill. + /// @return tokenIn_ The input token of a Uniswap swap. + function tokenIn( + bytes memory _path + ) internal pure returns (address tokenIn_) { + // Look up the `tokenIn` as the first part of the path. + assembly { + tokenIn_ := div( + mload(add(add(_path, 0x20), 0x0)), + 0x1000000000000000000000000 + ) + } + + return tokenIn_; + } + + /// @dev Returns the output token of a Uniswap swap. + /// @param _path The Uniswap path for a multi-hop fill. + /// @return tokenOut_ The output token of a Uniswap swap. + function tokenOut( + bytes memory _path + ) internal pure returns (address tokenOut_) { + // Look up the `tokenOut` as the last address in the path. + assembly { + tokenOut_ := div( + // NOTE: We add the path pointer to the path length plus 12 + // because this will point us 20 bytes from the end of the path. + // This gives us the last address in the path. + mload(add(add(_path, mload(_path)), 12)), + 0x1000000000000000000000000 + ) + } + + return tokenOut_; + } +} diff --git a/contracts/src/zaps/UniV3Zap.sol b/contracts/src/zaps/UniV3Zap.sol new file mode 100644 index 000000000..bb7701efe --- /dev/null +++ b/contracts/src/zaps/UniV3Zap.sol @@ -0,0 +1,952 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { ERC20 } from "openzeppelin/token/ERC20/ERC20.sol"; +import { ReentrancyGuard } from "openzeppelin/utils/ReentrancyGuard.sol"; +import { SafeERC20 } from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; +import { IERC20 } from "../interfaces/IERC20.sol"; +import { IERC4626 } from "../interfaces/IERC4626.sol"; +import { ILido } from "../interfaces/ILido.sol"; +import { IHyperdrive } from "../interfaces/IHyperdrive.sol"; +import { ISwapRouter } from "../interfaces/ISwapRouter.sol"; +import { IUniV3Zap } from "../interfaces/IUniV3Zap.sol"; +import { IWETH } from "../interfaces/IWETH.sol"; +import { IWrappedERC20 } from "../interfaces/IWrappedERC20.sol"; +import { AssetId } from "../libraries/AssetId.sol"; +import { ETH, UNI_V3_ZAP_KIND, VERSION } from "../libraries/Constants.sol"; +import { FixedPointMath } from "../libraries/FixedPointMath.sol"; +import { UniV3Path } from "../libraries/UniV3Path.sol"; + +/// @title UniV3Zap +/// @author DELV +/// @notice A zap contract that uses Uniswap v3 to execute swaps before or after +/// interacting 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. +contract UniV3Zap is IUniV3Zap, ReentrancyGuard { + using FixedPointMath for uint256; + using SafeERC20 for ERC20; + using UniV3Path for bytes; + + /// @dev We can assume that almost all Hyperdrive deployments have the + /// `convertToBase` and `convertToShares` functions, but there is + /// one legacy sDAI pool that was deployed before these functions + /// were written. We explicitly special case conversions for this + /// pool. + address internal constant LEGACY_SDAI_HYPERDRIVE = + address(0x324395D5d835F84a02A75Aa26814f6fD22F25698); + + /// @notice We can assume that almost all Hyperdrive deployments have the + /// `convertToBase` and `convertToShares` functions, but there is + /// one legacy stETH pool that was deployed before these functions + /// were written. We explicitly special case conversions for this + /// pool. + address internal constant LEGACY_STETH_HYPERDRIVE = + address(0xd7e470043241C10970953Bd8374ee6238e77D735); + + /// @notice The Uniswap swap router. + ISwapRouter public immutable swapRouter; + + /// @notice The wrapped ether address. + IWETH public immutable weth; + + /// @notice The name of this zap. + string public name; + + /// @notice The kind of this zap. + string public constant kind = UNI_V3_ZAP_KIND; + + /// @notice The version of this zap. + string public constant version = VERSION; + + /// @notice Instantiates the zap contract. + /// @param _name The name of this zap contract. + /// @param _swapRouter The uniswap swap router. + /// @param _weth The wrapped ether address. + constructor(string memory _name, ISwapRouter _swapRouter, IWETH _weth) { + name = _name; + swapRouter = _swapRouter; + weth = _weth; + } + + /// Receive /// + + /// @notice Allows ETH to be received within the context of a zap. + /// @dev This fails if it isn't called within the context of a zap to reduce + /// the likelihood of users sending ether to this contract accidentally. + receive() external payable { + // Ensures that the zap is receiving ether inside the context of another + // call. This means that the ether transfer should be the result of a + // Hyperdrive trade or unwrapping WETH. + if (!_reentrancyGuardEntered()) { + revert InvalidTransfer(); + } + } + + /// LPs /// + + /// @notice Executes a swap on Uniswap and uses the proceeds to add + /// liquidity on Hyperdrive. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _minLpSharePrice The minimum LP share price the LP is willing + /// to accept for their shares. LPs incur negative slippage when + /// adding liquidity if there is a net curve position in the market, + /// so this allows LPs to protect themselves from high levels of + /// slippage. The units of this quantity are either base or vault + /// shares, depending on the value of `_options.asBase`. + /// @param _minApr The minimum APR at which the LP is willing to supply. + /// @param _maxApr The maximum APR at which the LP is willing to supply. + /// @param _hyperdriveOptions The options that configure how the Hyperdrive + /// operation is settled. + /// @param _zapInOptions The options that configure how the zap will be + /// settled. + /// @return lpShares The LP shares received by the LP. + function addLiquidityZap( + IHyperdrive _hyperdrive, + uint256 _minLpSharePrice, + uint256 _minApr, + uint256 _maxApr, + IHyperdrive.Options calldata _hyperdriveOptions, + IUniV3Zap.ZapInOptions calldata _zapInOptions + ) external payable nonReentrant returns (uint256 lpShares) { + // Validate the zap parameters. + bool shouldConvertToETH = _validateZapIn( + _hyperdrive, + _hyperdriveOptions, + _zapInOptions + ); + + // Zap the funds that will be used to add liquidity and approve the pool + // to spend these funds. + uint256 proceeds = _zapIn(_zapInOptions, shouldConvertToETH); + + // If the deposit isn't in ETH, we need to set an approval on Hyperdrive. + if (!shouldConvertToETH) { + // NOTE: We increase the required approval amount by 1 wei so that the + // pool ends with an approval of 1 wei. This makes future approvals + // cheaper by keeping the storage slot warm. + ERC20(_zapInOptions.swapParams.path.tokenOut()).forceApprove( + address(_hyperdrive), + proceeds + 1 + ); + } + + // Add liquidity using the proceeds of the trade. If the vault shares + // token is a rebasing token, the proceeds amount needs to be converted + // to vault shares. + if (!_hyperdriveOptions.asBase && _zapInOptions.isRebasing) { + proceeds = _convertToShares(_hyperdrive, proceeds); + } + uint256 value = shouldConvertToETH ? proceeds : 0; + lpShares = _hyperdrive.addLiquidity{ value: value }( + proceeds, + _minLpSharePrice, + _minApr, + _maxApr, + _hyperdriveOptions + ); + + return lpShares; + } + + /// @notice Removes liquidity on Hyperdrive and converts the proceeds to the + /// traders preferred asset by executing a swap on Uniswap v3. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _lpShares The LP shares to burn. + /// @param _minOutputPerShare The minimum amount the LP expects to receive + /// for each withdrawal share that is burned. The units of this + /// quantity are either base or vault shares, depending on the value + /// of `_options.asBase`. + /// @param _options The options that configure how the operation is settled. + /// @param _swapParams The Uniswap swap parameters for a multi-hop fill. + /// @param _shouldWrap A flag indicating whether or not the proceeds need to + /// be wrapped. + /// @return proceeds The proceeds of removing liquidity. These proceeds will + /// be in units determined by the Uniswap swap parameters. + /// @return withdrawalShares The base that the LP receives buys out some of + /// their LP shares, but it may not be sufficient to fully buy the + /// LP out. In this case, the LP receives withdrawal shares equal in + /// value to the present value they are owed. As idle capital + /// becomes available, the pool will buy back these shares. + function removeLiquidityZap( + IHyperdrive _hyperdrive, + uint256 _lpShares, + uint256 _minOutputPerShare, + IHyperdrive.Options calldata _options, + ISwapRouter.ExactInputParams calldata _swapParams, + bool _shouldWrap + ) + external + nonReentrant + returns (uint256 proceeds, uint256 withdrawalShares) + { + // Validate the zap parameters. + address proceedsAsset = _validateZapOut( + _hyperdrive, + _options, + _swapParams, + _shouldWrap + ); + + // Take custody of the LP shares. + _hyperdrive.transferFrom( + AssetId._LP_ASSET_ID, + msg.sender, + address(this), + _lpShares + ); + + // Remove the liquidity, zap the proceeds into the target asset, and + // send them to the LP. + // + // NOTE: We zap out the contract's entire balance of the swap's input + // token. This ensures that we properly handle vault shares tokens that + // rebase and also ensures that this contract doesn't end up with stuck + // tokens. As a consequence, we don't need the output value of closing + // the long position. + (, withdrawalShares) = _hyperdrive.removeLiquidity( + _lpShares, + _minOutputPerShare, + _options + ); + proceeds = _zapOut(_swapParams, proceedsAsset, _shouldWrap); + + return (proceeds, withdrawalShares); + } + + /// @notice Redeem withdrawal shares on Hyperdrive and converts the proceeds + /// to the traders preferred asset by executing a swap on Uniswap + /// v3. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _withdrawalShares The withdrawal shares to redeem. + /// @param _minOutputPerShare The minimum amount the LP expects to + /// receive for each withdrawal share that is burned. The units of + /// this quantity are either base or vault shares, depending on the + /// value of `_options.asBase`. + /// @param _options The options that configure how the operation is settled. + /// @param _swapParams The Uniswap swap parameters for a multi-hop fill. + /// @param _shouldWrap A flag indicating whether or not the proceeds need to + /// be wrapped. + /// @return proceeds The proceeds of redeeming withdrawal shares. These + /// proceeds will be in units determined by the Uniswap swap + /// parameters. + /// @return withdrawalSharesRedeemed The amount of withdrawal shares that + /// were redeemed. + function redeemWithdrawalSharesZap( + IHyperdrive _hyperdrive, + uint256 _withdrawalShares, + uint256 _minOutputPerShare, + IHyperdrive.Options calldata _options, + ISwapRouter.ExactInputParams calldata _swapParams, + bool _shouldWrap + ) + external + nonReentrant + returns (uint256 proceeds, uint256 withdrawalSharesRedeemed) + { + // Validate the zap parameters. + address proceedsAsset = _validateZapOut( + _hyperdrive, + _options, + _swapParams, + _shouldWrap + ); + + // Take custody of the LP shares. + _hyperdrive.transferFrom( + AssetId._WITHDRAWAL_SHARE_ASSET_ID, + msg.sender, + address(this), + _withdrawalShares + ); + + // Redeem the withdrawal shares, zap the proceeds into the target asset, + // and send them to the LP. + // + // NOTE: We zap out the contract's entire balance of the swap's input + // token. This ensures that we properly handle vault shares tokens that + // rebase and also ensures that this contract doesn't end up with stuck + // tokens. As a consequence, we don't need the output value of closing + // the long position. + (, withdrawalSharesRedeemed) = _hyperdrive.redeemWithdrawalShares( + _withdrawalShares, + _minOutputPerShare, + _options + ); + proceeds = _zapOut(_swapParams, proceedsAsset, _shouldWrap); + + return (proceeds, withdrawalSharesRedeemed); + } + + /// Longs /// + + /// @notice Executes a swap on Uniswap and uses the proceeds to open a long + /// on Hyperdrive. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _minOutput The minimum number of bonds to receive. + /// @param _minVaultSharePrice The minimum vault share price at which to + /// open the long. This allows traders to protect themselves from + /// opening a long in a checkpoint where negative interest has + /// accrued. + /// @param _hyperdriveOptions The options that configure how the Hyperdrive + /// operation is settled. + /// @param _zapInOptions The options that configure how the zap will be + /// settled. + /// @return maturityTime The maturity time of the bonds. + /// @return longAmount The amount of bonds the trader received. + function openLongZap( + IHyperdrive _hyperdrive, + uint256 _minOutput, + uint256 _minVaultSharePrice, + IHyperdrive.Options calldata _hyperdriveOptions, + IUniV3Zap.ZapInOptions calldata _zapInOptions + ) + external + payable + nonReentrant + returns (uint256 maturityTime, uint256 longAmount) + { + // Validate the zap parameters. + bool shouldConvertToETH = _validateZapIn( + _hyperdrive, + _hyperdriveOptions, + _zapInOptions + ); + + // Zap the funds that will be used to open the long and approve the pool + // to spend these funds. + uint256 proceeds = _zapIn(_zapInOptions, shouldConvertToETH); + + // If the deposit isn't in ETH, we need to set an approval on Hyperdrive. + if (!shouldConvertToETH) { + // NOTE: We increase the required approval amount by 1 wei so that the + // pool ends with an approval of 1 wei. This makes future approvals + // cheaper by keeping the storage slot warm. + ERC20(_zapInOptions.swapParams.path.tokenOut()).forceApprove( + address(_hyperdrive), + proceeds + 1 + ); + } + + // Open a long using the proceeds of the trade. If the vault shares + // token is a rebasing token, the proceeds amount needs to be converted + // to vault shares. + if (!_hyperdriveOptions.asBase && _zapInOptions.isRebasing) { + proceeds = _convertToShares(_hyperdrive, proceeds); + } + uint256 value = shouldConvertToETH ? proceeds : 0; + (maturityTime, longAmount) = _hyperdrive.openLong{ value: value }( + proceeds, + _minOutput, + _minVaultSharePrice, + _hyperdriveOptions + ); + + return (maturityTime, longAmount); + } + + /// @notice Closes a long on Hyperdrive and converts the proceeds to the + /// traders preferred asset by executing a swap on Uniswap v3. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _maturityTime The maturity time of the long. + /// @param _bondAmount The amount of longs to close. + /// @param _minOutput The minimum proceeds the trader will accept. The units + /// of this quantity are either base or vault shares, depending on + /// the value of `_options.asBase`. + /// @param _options The options that configure how the Hyperdrive trade is + /// settled. + /// @param _swapParams The Uniswap swap parameters for a multi-hop fill. + /// @param _shouldWrap A flag indicating whether or not the proceeds need to + /// be wrapped. + /// @return proceeds The proceeds of closing the long. These proceeds will + /// be in units determined by the Uniswap swap parameters. + function closeLongZap( + IHyperdrive _hyperdrive, + uint256 _maturityTime, + uint256 _bondAmount, + uint256 _minOutput, + IHyperdrive.Options calldata _options, + ISwapRouter.ExactInputParams calldata _swapParams, + bool _shouldWrap + ) external nonReentrant returns (uint256 proceeds) { + // Validate the zap parameters. + address proceedsAsset = _validateZapOut( + _hyperdrive, + _options, + _swapParams, + _shouldWrap + ); + + // Take custody of the long position. + _hyperdrive.transferFrom( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Long, _maturityTime), + msg.sender, + address(this), + _bondAmount + ); + + // Close the long, zap the proceeds into the target asset, and send + // them to the trader. + // + // NOTE: We zap out the contract's entire balance of the swap's input + // token. This ensures that we properly handle vault shares tokens that + // rebase and also ensures that this contract doesn't end up with stuck + // tokens. As a consequence, we don't need the output value of closing + // the long position. + _hyperdrive.closeLong(_maturityTime, _bondAmount, _minOutput, _options); + proceeds = _zapOut(_swapParams, proceedsAsset, _shouldWrap); + + return proceeds; + } + + /// Shorts /// + + /// @notice Executes a swap on Uniswap and uses the proceeds to open a short + /// on Hyperdrive. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _maxDeposit The most the user expects to deposit for this trade. + /// The units of this quantity are either base or vault shares, + /// depending on the value of `_options.asBase`. + /// @param _minVaultSharePrice The minimum vault share price at which to open + /// the short. This allows traders to protect themselves from opening + /// a short in a checkpoint where negative interest has accrued. + /// @param _hyperdriveOptions The options that configure how the Hyperdrive + /// operation is settled. + /// @param _zapInOptions The options that configure how the zap will be + /// settled. + /// @return maturityTime The maturity time of the bonds. + /// @return deposit The amount the user deposited for this trade. The units + /// of this quantity are either base or vault shares, depending on + /// the value of `_options.asBase`. + function openShortZap( + IHyperdrive _hyperdrive, + uint256 _bondAmount, + uint256 _maxDeposit, + uint256 _minVaultSharePrice, + IHyperdrive.Options calldata _hyperdriveOptions, + IUniV3Zap.ZapInOptions calldata _zapInOptions + ) + external + payable + nonReentrant + returns (uint256 maturityTime, uint256 deposit) + { + // Validate the zap parameters. + bool shouldConvertToETH = _validateZapIn( + _hyperdrive, + _hyperdriveOptions, + _zapInOptions + ); + + // Zap the funds that will be used to open the long and approve the pool + // to spend these funds. + uint256 proceeds = _zapIn(_zapInOptions, shouldConvertToETH); + + // If the deposit isn't in ETH, we need to set an approval on Hyperdrive. + address tokenOut = _zapInOptions.swapParams.path.tokenOut(); + if (!shouldConvertToETH) { + // NOTE: We increase the required approval amount by 1 wei so that the + // pool ends with an approval of 1 wei. This makes future approvals + // cheaper by keeping the storage slot warm. + ERC20(tokenOut).forceApprove(address(_hyperdrive), proceeds + 1); + } + + // Open a short using the proceeds of the trade. + uint256 value = shouldConvertToETH ? proceeds : 0; + (maturityTime, deposit) = _hyperdrive.openShort{ value: value }( + _bondAmount, + _maxDeposit, + _minVaultSharePrice, + _hyperdriveOptions + ); + + // If the deposit was in ETH and capital is left after the trade, send + // it back to the trader. + if (shouldConvertToETH) { + uint256 balance = address(this).balance; + if (balance > 0) { + (bool success, ) = msg.sender.call{ value: balance }(""); + if (!success) { + revert TransferFailed(); + } + } + } + // Otherwise, if the deposit asset was an ERC20 token and capital is + // left after the trade, send it back to the trader. + else { + uint256 balance = IERC20(tokenOut).balanceOf(address(this)); + if (balance > 0) { + ERC20(tokenOut).safeTransfer(msg.sender, balance); + } + } + + return (maturityTime, deposit); + } + + /// @notice Closes a short on Hyperdrive and converts the proceeds to the + /// traders preferred asset by executing a swap on Uniswap v3. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _maturityTime The maturity time of the short. + /// @param _bondAmount The amount of shorts to close. + /// @param _minOutput The minimum output of this trade. The units of this + /// quantity are either base or vault shares, depending on the value + /// of `_options.asBase`. + /// @param _options The options that configure how the Hyperdrive trade is + /// settled. + /// @param _swapParams The Uniswap swap parameters for a multi-hop fill. + /// @param _shouldWrap A flag indicating whether or not the proceeds need to + /// be wrapped. + /// @return proceeds The proceeds of closing the short. These proceeds will + /// be in units determined by the Uniswap swap parameters. + function closeShortZap( + IHyperdrive _hyperdrive, + uint256 _maturityTime, + uint256 _bondAmount, + uint256 _minOutput, + IHyperdrive.Options calldata _options, + ISwapRouter.ExactInputParams calldata _swapParams, + bool _shouldWrap + ) external nonReentrant returns (uint256 proceeds) { + // Validate the zap parameters. + address proceedsAsset = _validateZapOut( + _hyperdrive, + _options, + _swapParams, + _shouldWrap + ); + + // Take custody of the short position. + _hyperdrive.transferFrom( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Short, _maturityTime), + msg.sender, + address(this), + _bondAmount + ); + + // Close the short, zap the proceeds into the target asset, and send + // them to the trader. + // + // NOTE: We zap out the contract's entire balance of the swap's input + // token. This ensures that we properly handle vault shares tokens that + // rebase and also ensures that this contract doesn't end up with stuck + // tokens. As a consequence, we don't need the output value of closing + // the long position. + _hyperdrive.closeShort( + _maturityTime, + _bondAmount, + _minOutput, + _options + ); + proceeds = _zapOut(_swapParams, proceedsAsset, _shouldWrap); + + return proceeds; + } + + /// Helpers /// + + /// @dev Validate the swap parameters for zapping tokens into Hyperdrive. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _hyperdriveOptions The options that configure how the Hyperdrive + /// operation is settled. + /// @param _zapInOptions The options for the zap. + /// @return A flag indicating whether or not the zap's output should be + /// converted to ETH. + function _validateZapIn( + IHyperdrive _hyperdrive, + IHyperdrive.Options calldata _hyperdriveOptions, + IUniV3Zap.ZapInOptions calldata _zapInOptions + ) internal view returns (bool) { + // Ensure that the swap recipient is this contract. + if (_zapInOptions.swapParams.recipient != address(this)) { + revert InvalidRecipient(); + } + + // Ensure that the input and output tokens of the swap are different. + // If they are the same, this will fail at best or lead to an + // unnecessary swap at worst. + address tokenIn = _zapInOptions.swapParams.path.tokenIn(); + address tokenOut = _zapInOptions.swapParams.path.tokenOut(); + if (tokenIn == tokenOut) { + revert InvalidSwap(); + } + + // Ensure that the options are properly configured if we are wrapping + // tokens. + if (_zapInOptions.shouldWrap) { + // Ensure that the source asset isn't the same as the input asset. + if (_zapInOptions.sourceAsset == tokenIn) { + revert InvalidSourceAsset(); + } + + // If ETH is the source asset, make sure that WETH is specified as + // the input asset. + if (_zapInOptions.sourceAsset == ETH && tokenIn != address(weth)) { + revert InvalidSourceAsset(); + } + + // If ETH is the source asset, make sure that sufficient ETH was + // sent and that the source amount equals the input amount. + if ( + _zapInOptions.sourceAsset == ETH && + (msg.value < _zapInOptions.sourceAmount || + _zapInOptions.sourceAmount != + _zapInOptions.swapParams.amountIn) + ) { + revert InvalidSourceAsset(); + } + } + // Ensure that the options are properly configured if we're not wrapping + // tokens. + else { + // Ensure that the source asset is the same as the input asset to the + // swap. + if (_zapInOptions.sourceAsset != tokenIn) { + revert InvalidSourceAsset(); + } + + // Ensure that the source amount is the same as the input amount. + if ( + _zapInOptions.sourceAmount != _zapInOptions.swapParams.amountIn + ) { + revert InvalidSourceAmount(); + } + } + + // If we're depositing with base, the output token is WETH, and the base + // token is ETH, we need to convert the WETH proceeds of the zap to ETH + // before executing the Hyperdrive trade. + address baseToken = _hyperdrive.baseToken(); + if ( + _hyperdriveOptions.asBase && + tokenOut == address(weth) && + baseToken == ETH + ) { + return true; + } + // Ensure that if we're depositing with base that the output token + // of the zap is the Hyperdrive pool's base token. + else if ( + _hyperdriveOptions.asBase && tokenOut != _hyperdrive.baseToken() + ) { + revert InvalidOutputToken(); + } + // Ensure that if we're depositing with vault shares that the output + // token of the zap is the Hyperdrive pool's vault shares token. + else if ( + !_hyperdriveOptions.asBase && + tokenOut != _hyperdrive.vaultSharesToken() + ) { + revert InvalidOutputToken(); + } + + return false; + } + + /// @dev Validate the swap parameters for zapping tokens out of Hyperdrive. + /// @param _hyperdrive The Hyperdrive pool to open the long on. + /// @param _hyperdriveOptions The options that configure how the Hyperdrive + /// operation is settled. + /// @param _swapParams The Uniswap swap parameters for a multi-hop fill. + /// @param _shouldWrap A flag indicating that the Hyperdrive proceeds should + /// be wrapped before the swap is made. + /// @return The asset that will be output by the Hyperdrive operation as + /// proceeds. + function _validateZapOut( + IHyperdrive _hyperdrive, + IHyperdrive.Options calldata _hyperdriveOptions, + ISwapRouter.ExactInputParams memory _swapParams, + bool _shouldWrap + ) internal view returns (address) { + // Ensure that the swap recipient is the sender. + if (_hyperdriveOptions.destination != address(this)) { + revert InvalidRecipient(); + } + + // Ensure that the input and output tokens of the swap are different. + // If they are the same, this will fail at best or lead to an + // unnecessary swap at worst. + address tokenIn = _swapParams.path.tokenIn(); + address tokenOut = _swapParams.path.tokenOut(); + if (tokenIn == tokenOut) { + revert InvalidSwap(); + } + + // If the withdrawal is in base, the proceeds are in the base token. + // Otherwise, they are in the vault shares token. + address proceedsAsset; + if (_hyperdriveOptions.asBase) { + proceedsAsset = _hyperdrive.baseToken(); + } else { + proceedsAsset = _hyperdrive.vaultSharesToken(); + } + + // Validate the zap if the proceeds are in ETH. + if (proceedsAsset == ETH) { + // Ensure that we are wrapping the proceeds. + if (!_shouldWrap) { + revert ShouldWrapAssets(); + } + + // Ensure that the input token is WETH. + if (tokenIn != address(weth)) { + revert InvalidInputToken(); + } + } + // Validate the other cases. + else { + // Ensure that if we aren't wrapping the proceeds that the proceeds + // asset matches the input token. + if (!_shouldWrap && proceedsAsset != tokenIn) { + revert InvalidInputToken(); + } + + // Ensure that if we are wrapping the proceeds that the proceeds + // asset doesn't match the input token. + if (_shouldWrap && proceedsAsset == tokenIn) { + revert InvalidInputToken(); + } + } + + return proceedsAsset; + } + + /// @dev Zaps funds into this contract to open positions on Hyperdrive. + /// @param _zapInOptions The options for the zap. + /// @param _shouldConvertToETH A flag indicating whether or not the proceeds + /// should be converted to ETH. + /// @return proceeds The amount of assets that were zapped into this + /// contract. + function _zapIn( + IUniV3Zap.ZapInOptions memory _zapInOptions, + bool _shouldConvertToETH + ) internal returns (uint256 proceeds) { + // If the source token is ETH, we'll pay for the swap directly with ETH. + uint256 refund; + uint256 value; + address tokenIn = _zapInOptions.swapParams.path.tokenIn(); + if (_zapInOptions.sourceAsset == ETH) { + // Refund the difference between the message value and the input + // amount to the sender. + refund = msg.value - _zapInOptions.swapParams.amountIn; + + // Send the input amount of ETH to the swap router. + value = _zapInOptions.swapParams.amountIn; + } + // If the we need to wrap assets for the swap, we'll need to + // take custody of the source assets, wrap them, and then approve the + // swap router to spend the wrapped assets. + else if (_zapInOptions.shouldWrap) { + // Take custody of the source assets to wrap. The input token is + // assumed to be the wrapped version of the source token, so we set + // up an approval on that. + ERC20(_zapInOptions.sourceAsset).safeTransferFrom( + msg.sender, + address(this), + _zapInOptions.sourceAmount + ); + // NOTE: We increase the required approval amount by 1 wei so that + // the input token ends with an approval of 1 wei. This makes future + // approvals cheaper by keeping the storage slot warm. + ERC20(_zapInOptions.sourceAsset).forceApprove( + address(tokenIn), + _zapInOptions.sourceAmount + 1 + ); + + // Wrap the source assets. Then we update the swap parameters so + // that the swap's input amount is equal to this contract's total + // balance of the input token. Finally, we set up an approval for + // the swap router. + IWrappedERC20(tokenIn).wrap(_zapInOptions.sourceAmount); + _zapInOptions.swapParams.amountIn = IERC20(tokenIn).balanceOf( + address(this) + ); + // NOTE: We increase the required approval amount by 1 wei so that + // the router ends with an approval of 1 wei. This makes future + // approvals cheaper by keeping the storage slot warm. + ERC20(tokenIn).forceApprove( + address(swapRouter), + _zapInOptions.swapParams.amountIn + 1 + ); + + // Refund all of the ETH sent to the contract. + refund = msg.value; + } + // Otherwise, we just need to take custody of the input assets to the + // swap. + else { + // Take custody of the assets to swap. Then we update the swap + // parameters so that the swap's input amount is equal to this + // contract's total balance of the input token. This ensures that + // stuck tokens from this contract are used. + ERC20(tokenIn).safeTransferFrom( + msg.sender, + address(this), + _zapInOptions.sourceAmount + ); + _zapInOptions.swapParams.amountIn = IERC20(tokenIn).balanceOf( + address(this) + ); + + // Approve the swap router to spend the input tokens. + // + // NOTE: We increase the required approval amount by 1 wei so that + // the router ends with an approval of 1 wei. This makes future + // approvals cheaper by keeping the storage slot warm. + ERC20(tokenIn).forceApprove( + address(swapRouter), + _zapInOptions.swapParams.amountIn + 1 + ); + + // Refund all of the ETH sent to the contract. + refund = msg.value; + } + + // Execute the Uniswap trade. + proceeds = swapRouter.exactInput{ value: value }( + _zapInOptions.swapParams + ); + + // If the proceeds should be converted to ETH, withdraw the ETH from the + // WETH proceeds. + if (_shouldConvertToETH) { + weth.withdraw(proceeds); + } + + // If necessary, refund ETH to the sender. + if (refund > 0) { + (bool success, ) = msg.sender.call{ value: refund }(""); + if (!success) { + revert TransferFailed(); + } + } + + return proceeds; + } + + /// @dev Zaps the proceeds of closing a Hyperdrive position into a trader's + /// preferred tokens. + /// @param _swapParams The Uniswap swap parameters for a multi-hop fill. + /// @param _proceedsAsset The token received from Hyperdrive as proceeds. + /// @param _shouldWrap A flag indicating whether or not the proceeds need to + /// be wrapped before the swap. + /// @return proceeds The proceeds of the zap that were transferred to the + /// trader. + function _zapOut( + ISwapRouter.ExactInputParams memory _swapParams, + address _proceedsAsset, + bool _shouldWrap + ) internal returns (uint256 proceeds) { + // If necessary, wrap the Hyperdrive proceeds to prepare for the swap. + address tokenIn = _swapParams.path.tokenIn(); + if (_shouldWrap) { + // If the proceeds token is ETH, the proceeds should be converted to + // WETH. We use the entire balance to avoid stuck ether. + if (_proceedsAsset == ETH) { + weth.deposit{ value: address(this).balance }(); + } + // Otherwise, we wrap the entire token balance of the proceeds token + // into the input token with the standard wrapping interface. + else { + // Approve and wrap the ERC20. + // + // NOTE: We increase the required approval amount by 1 wei so that the + // router ends with an approval of 1 wei. This makes future approvals + // cheaper by keeping the storage slot warm. + uint256 balance_ = IERC20(_proceedsAsset).balanceOf( + address(this) + ); + ERC20(_proceedsAsset).forceApprove( + address(tokenIn), + balance_ + 1 + ); + IWrappedERC20(tokenIn).wrap(balance_); + } + } + + // Update the swap parameters so that the input amount is equal to the + // proceeds of closing the position and the minimum amount out is scaled + // to the size of the proceeds. This will ensure that the swap is + // properly sized for the proceeds that need to be converted. + // + // NOTE: Use the zap contract's balance rather than the proceeds + // reported by Hyperdrive to avoid having to handle the difference + // between rebasing and non-rebasing tokens. + uint256 balance = IERC20(tokenIn).balanceOf(address(this)); + _swapParams.amountOutMinimum = balance.mulDivDown( + _swapParams.amountOutMinimum, + _swapParams.amountIn + ); + _swapParams.amountIn = balance; + + // If the output token is WETH, we always unwrap to ETH. In this case, + // we make this contract the recipient of the Uniswap swap. + address tokenOut = _swapParams.path.tokenOut(); + address recipient = _swapParams.recipient; + if (tokenOut == address(weth)) { + _swapParams.recipient = address(this); + } + + // Execute the Uniswap trade. + // + // NOTE: We increase the required approval amount by 1 wei so that the + // router ends with an approval of 1 wei. This makes future approvals + // cheaper by keeping the storage slot warm. + ERC20(tokenIn).forceApprove( + address(swapRouter), + _swapParams.amountIn + 1 + ); + proceeds = swapRouter.exactInput(_swapParams); + + // If the output token is WETH, unwrap the WETH and send the ETH. + if (tokenOut == address(weth)) { + weth.withdraw(weth.balanceOf(address(this))); + (bool success, ) = recipient.call{ value: address(this).balance }( + "" + ); + if (!success) { + revert TransferFailed(); + } + } + + return proceeds; + } + + /// @dev Converts a quantity in base to vault shares. This works for all + /// Hyperdrive pools. + /// @param _hyperdrive The Hyperdrive instance. + /// @param _baseAmount The base amount. + /// @return The converted vault shares amount. + function _convertToShares( + IHyperdrive _hyperdrive, + uint256 _baseAmount + ) internal view returns (uint256) { + // If this is a mainnet deployment and the address is the legacy stETH + // pool, we have to convert the proceeds to shares manually using Lido's + // `getSharesByPooledEth` function. + if ( + block.chainid == 1 && + address(_hyperdrive) == LEGACY_STETH_HYPERDRIVE + ) { + return + ILido(_hyperdrive.vaultSharesToken()).getSharesByPooledEth( + _baseAmount + ); + } + // If this is a mainnet deployment and the address is the legacy stETH + // pool, we have to convert the proceeds to shares manually using Lido's + // `getSharesByPooledEth` function. + else if ( + block.chainid == 1 && address(_hyperdrive) == LEGACY_SDAI_HYPERDRIVE + ) { + return + IERC4626(_hyperdrive.vaultSharesToken()).convertToShares( + _baseAmount + ); + } + // Otherwise, we can use the built-in `convertToShares` function. + else { + return _hyperdrive.convertToShares(_baseAmount); + } + } +} diff --git a/test/utils/BaseTest.sol b/test/utils/BaseTest.sol index 5c23c623d..58a6f1853 100644 --- a/test/utils/BaseTest.sol +++ b/test/utils/BaseTest.sol @@ -130,7 +130,7 @@ contract BaseTest is Test { } function fundAccounts( - address hyperdrive, + address approvalTarget, IERC20 token, address source, address[] memory accounts @@ -145,9 +145,9 @@ contract BaseTest is Test { accounts[i] ); - // Approve Hyperdrive on behalf of the account. + // Approve the approval target on behalf of the account. vm.startPrank(accounts[i]); - token.approve(hyperdrive, type(uint256).max); + token.approve(approvalTarget, type(uint256).max); } } } diff --git a/test/zaps/uni-v3/AddLiquidityZap.t.sol b/test/zaps/uni-v3/AddLiquidityZap.t.sol new file mode 100644 index 000000000..03344c88b --- /dev/null +++ b/test/zaps/uni-v3/AddLiquidityZap.t.sol @@ -0,0 +1,642 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { IERC20 } from "../../../contracts/src/interfaces/IERC20.sol"; +import { IHyperdrive } from "../../../contracts/src/interfaces/IHyperdrive.sol"; +import { ISwapRouter } from "../../../contracts/src/interfaces/ISwapRouter.sol"; +import { IUniV3Zap } from "../../../contracts/src/interfaces/IUniV3Zap.sol"; +import { AssetId } from "../../../contracts/src/libraries/AssetId.sol"; +import { ETH } from "../../../contracts/src/libraries/Constants.sol"; +import { FixedPointMath } from "../../../contracts/src/libraries/FixedPointMath.sol"; +import { UniV3Path } from "../../../contracts/src/libraries/UniV3Path.sol"; +import { HyperdriveUtils } from "../../utils/HyperdriveUtils.sol"; +import { UniV3ZapTest } from "./UniV3Zap.t.sol"; + +contract AddLiquidityZapTest is UniV3ZapTest { + using FixedPointMath for uint256; + using HyperdriveUtils for IHyperdrive; + using UniV3Path for bytes; + + /// @notice Ensure that zapping into `addLiquidity` will fail when the + /// recipient isn't the zap contract. + function test_addLiquidityZap_failure_invalidRecipient() external { + vm.expectRevert(IUniV3Zap.InvalidRecipient.selector); + zap.addLiquidityZap( + SDAI_HYPERDRIVE, + 0, // minimum LP share price + 0, // minimum APR + type(uint256).max, // maximum APR + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: bob, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + } + + /// @notice Ensure that zapping into `addLiquidity` will fail when the + /// input and output tokens are the same. + function test_addLiquidityZap_failure_invalidSwap() external { + vm.expectRevert(IUniV3Zap.InvalidSwap.selector); + zap.addLiquidityZap( + SDAI_HYPERDRIVE, + 0, // minimum LP share price + 0, // minimum APR + type(uint256).max, // maximum APR + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e18 + }), + sourceAsset: DAI, + sourceAmount: 1_000e18, + shouldWrap: false, + isRebasing: false + }) + ); + } + + /// @notice Ensure that zapping into `addLiquidity` with base will fail when + /// the output isn't the base token. + function test_addLiquidityZap_failure_invalidOutputToken_asBase() external { + vm.expectRevert(IUniV3Zap.InvalidOutputToken.selector); + zap.addLiquidityZap( + SDAI_HYPERDRIVE, + 0, // minimum LP share price + 0, // minimum APR + type(uint256).max, // maximum APR + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + USDC, + LOW_FEE_TIER, + WETH, + LOW_FEE_TIER, + SDAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 850e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + } + + /// @notice Ensure that zapping into `addLiquidity` with vault shares will + /// fail when the output isn't the vault shares token. + function test_addLiquidityZap_failure_invalidOutputToken_asShares() + external + { + vm.expectRevert(IUniV3Zap.InvalidOutputToken.selector); + zap.addLiquidityZap( + SDAI_HYPERDRIVE, + 0, // minimum LP share price + 0, // minimum APR + type(uint256).max, // maximum APR + IHyperdrive.Options({ + destination: alice, + asBase: false, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + } + + /// @notice Ensure that zapping into `addLiquidity` with base refunds the + /// sender when they send ETH that can't be used for the zap. + function test_addLiquidityZap_success_asBase_refund() external { + // Get Alice's ether balance before the zap. + uint256 aliceBalanceBefore = alice.balance; + + // Zaps into `addLiquidity` with `asBase` as `true` from USDC to DAI. + zap.addLiquidityZap{ value: 100e18 }( + SDAI_HYPERDRIVE, + 0, // minimum LP share price + 0, // minimum APR + type(uint256).max, // maximum APR + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + + // Ensure that Alice's balance didn't change. This indicates that her + // ETH transfer was fully refunded. + assertEq(alice.balance, aliceBalanceBefore); + } + + /// @notice Ensure that zapping into `addLiquidity` with vault shares + /// refunds the sender when they send ETH that can't be used for the + /// zap. + function test_addLiquidityZap_success_asShares_refund() external { + // Get Alice's ether balance before the zap. + uint256 aliceBalanceBefore = alice.balance; + + // Zaps into `addLiquidity` with `asBase` as `false` from USDC to sDAI. + zap.addLiquidityZap{ value: 100e18 }( + SDAI_HYPERDRIVE, + 0, // minimum LP share price + 0, // minimum APR + type(uint256).max, // maximum APR + IHyperdrive.Options({ + destination: alice, + asBase: false, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + USDC, + LOW_FEE_TIER, + WETH, + LOW_FEE_TIER, + SDAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 850e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + + // Ensure that Alice's balance didn't change. This indicates that her + // ETH transfer was fully refunded. + assertEq(alice.balance, aliceBalanceBefore); + } + + /// @notice Ensure that Alice can pay for a zap from WETH to DAI with WETH. + /// We send extra ETH in the zap to ensure that Alice gets refunded + /// for the excess. + function test_addLiquidityZap_success_asBase_withWETH() external { + // Ensure that adding liquidity with vault shares using a zap from + // ETH (via WETH) to DAI is successful. + _verifyAddLiquidityZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + sourceAsset: WETH, + sourceAmount: 0.3882e18, + shouldWrap: false, + isRebasing: false + }), + 0.1e18, // this should be refunded + true // as base + ); + } + + /// @notice Ensure that Alice can pay for a zap from WETH to sDAI with WETH. + /// We send extra ETH in the zap to ensure that Alice gets refunded + /// for the excess. + function test_addLiquidityZap_success_asShares_withWETH() external { + // Ensure that adding liquidity with vault shares using a zap from + // ETH (via WETH) to sDAI is successful. + _verifyAddLiquidityZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, SDAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.3882e18, + amountOutMinimum: 886e18 + }), + sourceAsset: WETH, + sourceAmount: 0.3882e18, + shouldWrap: false, + isRebasing: false + }), + 0.1e18, // this should be refunded + false // as base + ); + } + + /// @notice Ensure that Alice can pay for a zap from WETH to DAI with ETH. + /// We send extra ETH in the zap to ensure that Alice gets refunded + /// for the excess. + function test_addLiquidityZap_success_asBase_withETH() external { + // Ensure that adding liquidity with vault shares using a zap from + // ETH (via WETH) to DAI is successful. + _verifyAddLiquidityZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + sourceAsset: ETH, + sourceAmount: 0.3882e18, + shouldWrap: true, + isRebasing: false + }), + 10e18, // most of this should be refunded + true // as base + ); + } + + /// @notice Ensure that Alice can pay for a zap from WETH to sDAI with ETH. + /// We send extra ETH in the zap to ensure that Alice gets refunded + /// for the excess. + function test_addLiquidityZap_success_asShares_withETH() external { + // Ensure that adding liquidity with vault shares using a zap from + // ETH (via WETH) to sDAI is successful. + _verifyAddLiquidityZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, SDAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.3882e18, + amountOutMinimum: 886e18 + }), + sourceAsset: ETH, + sourceAmount: 0.3882e18, + shouldWrap: true, + isRebasing: false + }), + 10e18, // most of this should be refunded + false // as base + ); + } + + /// @notice Ensure that zapping into `addLiquidity` with base succeeds with + /// a rebasing yield source. + function test_addLiquidityZap_success_rebasing_asBase() external { + // Ensure that adding liquidity with base using a zap from USDC to WETH + // (and ultimately into ETH) is successful. + _verifyAddLiquidityZap( + STETH_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, MEDIUM_FEE_TIER, WETH), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 0.38e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: true + }), + 10e18, // this should be completely refunded + true // as base + ); + } + + /// @notice Ensure that zapping into `addLiquidity` with vault shares + /// succeeds with a rebasing yield source. + function test_addLiquidityZap_success_rebasing_asShares() external { + // Ensure that adding liquidity with vault shares using a zap from + // USDC to stETH is successful. + _verifyAddLiquidityZap( + STETH_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + USDC, + MEDIUM_FEE_TIER, + WETH, + HIGH_FEE_TIER, + STETH + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 0.38e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: true + }), + 0, + false // as base + ); + } + + /// @notice Ensure that zapping into `addLiquidity` with base succeeds with + /// a non-rebasing yield source. + function test_addLiquidityZap_success_nonRebasing_asBase() external { + // Ensure that adding liquidity with base using a zap from USDC to DAI + // is successful. + _verifyAddLiquidityZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }), + 0, + true // as base + ); + } + + /// @notice Ensure that zapping into `addLiquidity` with vault shares + /// succeeds with a non-rebasing yield source. + function test_addLiquidityZap_success_nonRebasing_asShares() external { + // Ensure that adding liquidity with vault shares using a zap from + // USDC to sDAI is successful. + _verifyAddLiquidityZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + USDC, + LOW_FEE_TIER, + WETH, + LOW_FEE_TIER, + SDAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 850e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }), + 10e18, // this should be completely refunded + false // as base + ); + } + + /// @notice Ensure that zapping into `addLiquidity` with base succeeds when + /// the input needs to be wrapped. + function test_addLiquidityZap_success_shouldWrap_asBase() external { + _verifyAddLiquidityZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + WSTETH, + LOWEST_FEE_TIER, + WETH, + LOW_FEE_TIER, + DAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.2534e18, + amountOutMinimum: 771e18 + }), + sourceAsset: STETH, + sourceAmount: 0.3e18, + shouldWrap: true, + isRebasing: false + }), + 10e18, // this should be completely refunded + true // as base + ); + } + + /// @notice Ensure that zapping into `addLiquidity` with vault shares + /// succeeds when the input needs to be wrapped. + function test_addLiquidityZap_success_shouldWrap_asShares() external { + _verifyAddLiquidityZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + WSTETH, + LOWEST_FEE_TIER, + WETH, + LOW_FEE_TIER, + SDAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.2534e18, + amountOutMinimum: 691e18 + }), + sourceAsset: STETH, + sourceAmount: 0.3e18, + shouldWrap: true, + isRebasing: false + }), + 10e18, // this should be completely refunded + false // as shares + ); + } + + /// @dev Verify that `addLiquidityZap` performs correctly under the + /// specified conditions. + /// @param _hyperdrive The Hyperdrive instance. + /// @param _zapInOptions The options for the zap. + /// @param _value The ETH value to send in the transaction. + /// @param _asBase A flag indicating whether or not the deposit should be in + /// base. + function _verifyAddLiquidityZap( + IHyperdrive _hyperdrive, + IUniV3Zap.ZapInOptions memory _zapInOptions, + uint256 _value, + bool _asBase + ) internal { + // Gets some data about the trader and the pool before the zap. + bool isETHInput = _zapInOptions.swapParams.path.tokenIn() == WETH && + _value > _zapInOptions.swapParams.amountIn; + uint256 aliceBalanceBefore; + if (isETHInput) { + aliceBalanceBefore = alice.balance; + } else { + aliceBalanceBefore = IERC20(_zapInOptions.sourceAsset).balanceOf( + alice + ); + } + uint256 hyperdriveVaultSharesBalanceBefore = IERC20( + _hyperdrive.vaultSharesToken() + ).balanceOf(address(_hyperdrive)); + uint256 lpTotalSupplyBefore = _hyperdrive.getPoolInfo().lpTotalSupply; + uint256 lpSharesBefore = _hyperdrive.balanceOf( + AssetId._LP_ASSET_ID, + alice + ); + + // Zap into `addLiquidity`. + IHyperdrive hyperdrive_ = _hyperdrive; // avoid stack-too-deep + IUniV3Zap.ZapInOptions memory zapInOptions = _zapInOptions; // avoid stack-too-deep + bool asBase = _asBase; // avoid stack-too-deep + uint256 lpShares = zap.addLiquidityZap{ value: _value }( + _hyperdrive, + 0, // minimum LP share price + 0, // minimum APR + type(uint256).max, // maximum APR + IHyperdrive.Options({ + destination: alice, + asBase: asBase, + extraData: "" + }), + zapInOptions + ); + + // Ensure that Alice was charged the correct amount of the input token. + if (isETHInput) { + assertEq( + alice.balance, + aliceBalanceBefore - zapInOptions.sourceAmount + ); + } else { + assertEq( + IERC20(_zapInOptions.sourceAsset).balanceOf(alice), + aliceBalanceBefore - zapInOptions.sourceAmount + ); + } + + // Ensure that Hyperdrive received more than the minimum output of the + // swap. + uint256 hyperdriveVaultSharesBalanceAfter = IERC20( + hyperdrive_.vaultSharesToken() + ).balanceOf(address(hyperdrive_)); + if (zapInOptions.isRebasing) { + // NOTE: Since the vault shares rebase, the units are in base. + assertGt( + hyperdriveVaultSharesBalanceAfter, + hyperdriveVaultSharesBalanceBefore + + zapInOptions.swapParams.amountOutMinimum + ); + } else if (_asBase) { + // NOTE: Since the vault shares don't rebase, the units are in shares. + assertGt( + hyperdriveVaultSharesBalanceAfter, + hyperdriveVaultSharesBalanceBefore + + _convertToShares( + hyperdrive_, + zapInOptions.swapParams.amountOutMinimum + ) + ); + } else { + // NOTE: Since the vault shares don't rebase, the units are in shares. + assertGt( + hyperdriveVaultSharesBalanceAfter, + hyperdriveVaultSharesBalanceBefore + + zapInOptions.swapParams.amountOutMinimum + ); + } + + // Ensure that Alice received an appropriate amount of LP shares and + // that the LP total supply increased. + if (zapInOptions.isRebasing) { + if (asBase) { + assertGt( + lpShares, + zapInOptions.swapParams.amountOutMinimum.divDown( + hyperdrive_.getPoolInfo().lpSharePrice + ) + ); + } else { + assertGt( + lpShares, + zapInOptions.swapParams.amountOutMinimum.divDown( + hyperdrive_.getPoolInfo().lpSharePrice + ) + ); + } + } else { + if (asBase) { + assertGt( + lpShares, + zapInOptions.swapParams.amountOutMinimum.divDown( + hyperdrive_.getPoolInfo().lpSharePrice + ) + ); + } else { + assertGt( + lpShares, + _convertToBase( + hyperdrive_, + zapInOptions.swapParams.amountOutMinimum + ).divDown(hyperdrive_.getPoolInfo().lpSharePrice) + ); + } + } + assertEq( + hyperdrive_.balanceOf(AssetId._LP_ASSET_ID, alice), + lpSharesBefore + lpShares + ); + assertEq( + hyperdrive_.getPoolInfo().lpTotalSupply, + lpTotalSupplyBefore + lpShares + ); + } +} diff --git a/test/zaps/uni-v3/CloseLongZap.t.sol b/test/zaps/uni-v3/CloseLongZap.t.sol new file mode 100644 index 000000000..afec3328d --- /dev/null +++ b/test/zaps/uni-v3/CloseLongZap.t.sol @@ -0,0 +1,473 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { IERC20 } from "../../../contracts/src/interfaces/IERC20.sol"; +import { IHyperdrive } from "../../../contracts/src/interfaces/IHyperdrive.sol"; +import { ISwapRouter } from "../../../contracts/src/interfaces/ISwapRouter.sol"; +import { IUniV3Zap } from "../../../contracts/src/interfaces/IUniV3Zap.sol"; +import { AssetId } from "../../../contracts/src/libraries/AssetId.sol"; +import { FixedPointMath } from "../../../contracts/src/libraries/FixedPointMath.sol"; +import { UniV3Path } from "../../../contracts/src/libraries/UniV3Path.sol"; +import { HyperdriveUtils } from "../../utils/HyperdriveUtils.sol"; +import { UniV3ZapTest } from "./UniV3Zap.t.sol"; + +contract CloseLongZapTest is UniV3ZapTest { + using FixedPointMath for uint256; + using HyperdriveUtils for IHyperdrive; + using UniV3Path for bytes; + + /// @dev The maturity time for the long position in the sDAI pool. + uint256 internal maturityTimeSDAI; + + /// @dev The maturity time for the long position in the rETH pool. + uint256 internal maturityTimeRETH; + + /// @dev The maturity time for the long position in the stETH pool. + uint256 internal maturityTimeStETH; + + /// @dev The maturity time for the long position in the WETH pool. + uint256 internal maturityTimeWETHVault; + + /// @dev The long amount in the sDAI pool. + uint256 internal longAmountSDAI; + + /// @dev The long amount in the rETH pool. + uint256 internal longAmountRETH; + + /// @dev The long amount in the stETH pool. + uint256 internal longAmountStETH; + + /// @dev The long amount in the WETH pool. + uint256 internal longAmountWETHVault; + + /// Set Up /// + + /// @notice Prepare long positions in each of the Hyperdrive markets. + function setUp() public override { + // Sets up the underlying test infrastructure. + super.setUp(); + + // Prepare the sDAI, rETH, stETH, and WETH vault Hyperdrive markets. + (maturityTimeSDAI, longAmountSDAI) = _prepareHyperdrive( + SDAI_HYPERDRIVE, + 500e18, + true + ); + (maturityTimeRETH, longAmountRETH) = _prepareHyperdrive( + RETH_HYPERDRIVE, + 0.01e18, + false + ); + (maturityTimeStETH, longAmountStETH) = _prepareHyperdrive( + STETH_HYPERDRIVE, + 0.01e18, + false + ); + (maturityTimeWETHVault, longAmountWETHVault) = _prepareHyperdrive( + WETH_VAULT_HYPERDRIVE, + 0.01e18, + true + ); + } + + /// @dev Prepares the Hyperdrive instance for the close long tests. + /// @param _hyperdrive The Hyperdrive instance. + /// @param _amount The amount of base or vault shares to invest in long + /// positions. + /// @param _asBase A flag indicating whether or not the contribution is in + /// base or vault shares. + /// @return The maturity time of the long positions. + /// @return The amount of long positions that were opened. + function _prepareHyperdrive( + IHyperdrive _hyperdrive, + uint256 _amount, + bool _asBase + ) internal returns (uint256, uint256) { + // If we're opening longs with the base token, approve Hyperdrive to + // spend base tokens. + if (_asBase) { + IERC20(_hyperdrive.baseToken()).approve( + address(_hyperdrive), + type(uint256).max + ); + } + // Otherwise, approve Hyperdrive to spend vault shares. + else { + IERC20(_hyperdrive.vaultSharesToken()).approve( + address(_hyperdrive), + type(uint256).max + ); + } + + // Add a large amount of liquidity to ensure that there is adequate + // liquidity to open the long. + _hyperdrive.addLiquidity( + _amount.mulDown(1_000e18), + 0, + 0, + type(uint256).max, + IHyperdrive.Options({ + destination: alice, + asBase: _asBase, + extraData: new bytes(0) + }) + ); + + // Open longs in the Hyperdrive pool. + (uint256 maturityTime, uint256 longAmount) = _hyperdrive.openLong( + _amount, + 0, + 0, + IHyperdrive.Options({ + destination: alice, + asBase: _asBase, + extraData: new bytes(0) + }) + ); + + // Set an approval on the zap to spend the long positions. + _hyperdrive.setApproval( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Long, maturityTime), + address(zap), + longAmount + ); + + return (maturityTime, longAmount); + } + + /// Tests /// + + /// @notice Ensure that zapping out of `closeLong` will fail when the + /// recipient of `closeLong` isn't the zap contract. + function test_closeLongZap_failure_invalidRecipient() external { + vm.expectRevert(IUniV3Zap.InvalidRecipient.selector); + zap.closeLongZap( + SDAI_HYPERDRIVE, + maturityTimeSDAI, // maturity time + longAmountSDAI, // long amount + 0, // min output + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, USDC), + recipient: bob, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e6 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `closeLong` will fail when the + /// recipient of `closeLong` isn't the zap contract. + function test_closeLongZap_failure_invalidSwap() external { + vm.expectRevert(IUniV3Zap.InvalidSwap.selector); + zap.closeLongZap( + SDAI_HYPERDRIVE, + maturityTimeSDAI, // maturity time + longAmountSDAI, // long amount + 0, // min output + IHyperdrive.Options({ + destination: address(zap), + asBase: true, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, DAI), + recipient: bob, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e18 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `closeLong` with base will fail + /// when the input isn't the base token. + function test_closeLongZap_failure_invalidInputToken_asBase() external { + vm.expectRevert(IUniV3Zap.InvalidInputToken.selector); + zap.closeLongZap( + SDAI_HYPERDRIVE, + maturityTimeSDAI, // maturity time + longAmountSDAI, // long amount + 0, // min output + IHyperdrive.Options({ + destination: address(zap), + asBase: true, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: alice, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `closeLong` with vault shares + /// will fail when the input isn't the vault shares token. + function test_closeLongZap_failure_invalidInputToken_asShares() external { + vm.expectRevert(IUniV3Zap.InvalidInputToken.selector); + zap.closeLongZap( + SDAI_HYPERDRIVE, + maturityTimeSDAI, // maturity time + longAmountSDAI, // long amount + 0, // min output + IHyperdrive.Options({ + destination: address(zap), + asBase: false, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, USDC), + recipient: alice, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e6 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `closeLong` with base will + /// succeed when the base token is WETH. This ensures that WETH is + /// handled properly despite the zap unwrapping WETH into ETH in + /// some cases. + function test_closeLongZap_success_asBase_withWETH() external { + _verifyCloseLongZap( + WETH_VAULT_HYPERDRIVE, + maturityTimeWETHVault, // maturity time + longAmountWETHVault, // long amount + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is smaller than the proceeds will be. + // This will automatically be adjusted up. + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + false, // should wrap + true // as base + ); + } + + /// @notice Ensure that zapping out of `closeLong` with base will + /// succeed when the yield source is rebasing and when the input is + /// the base token. + /// @dev This also tests using ETH as the output assets and verifies that + /// we can properly convert to WETH. + function test_closeLongZap_success_rebasing_asBase() external { + _verifyCloseLongZap( + RETH_HYPERDRIVE, + maturityTimeRETH, // maturity time + longAmountRETH, // long amount + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is smaller than the proceeds will be. + // This will automatically be adjusted up. + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + true, // should wrap + true // as base + ); + } + + /// @notice Ensure that zapping out of `closeLong` with vault shares + /// will succeed when the yield source is rebasing and when the + /// input is the vault shares token. + function test_closeLongZap_success_rebasing_asShares() external { + _verifyCloseLongZap( + STETH_HYPERDRIVE, + maturityTimeStETH, // maturity time + longAmountStETH, // long amount + ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + WSTETH, + LOWEST_FEE_TIER, + WETH, + LOW_FEE_TIER, + USDC + ), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is smaller than the proceeds will be. + // This will automatically be adjusted up. + amountIn: 0.32796e18, + amountOutMinimum: 950e6 + }), + true, // should wrap + false // as base + ); + } + + /// @notice Ensure that zapping out of `closeLong` with base will + /// succeed when the yield source is non-rebasing and when the input is + /// the base token. + function test_closeLongZap_success_nonRebasing_asBase() external { + _verifyCloseLongZap( + SDAI_HYPERDRIVE, + maturityTimeSDAI, // maturity time + longAmountSDAI, // long amount + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOW_FEE_TIER, WETH), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is larger than the proceeds will be. + // This will automatically be adjusted down. + amountIn: 1_000e18, + amountOutMinimum: 0.38e18 + }), + false, // should wrap + true // as base + ); + } + + /// @notice Ensure that zapping out of `closeLong` with vault shares + /// will succeed when the yield source is non-rebasing and when the + /// input is the vault shares token. + function test_closeLongZap_success_nonRebasing_asShares() external { + _verifyCloseLongZap( + SDAI_HYPERDRIVE, + maturityTimeSDAI, // maturity time + longAmountSDAI, // long amount + ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + SDAI, + LOW_FEE_TIER, + WETH, + LOW_FEE_TIER, + USDC + ), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is larger than the proceeds will be. + // This will automatically be adjusted down. + amountIn: 885e18, + amountOutMinimum: 900e6 + }), + false, // should wrap + false // as base + ); + } + + /// @dev Verify that `closeLongZap` performs correctly under the + /// specified conditions. + /// @param _hyperdrive The Hyperdrive instance. + /// @param _maturityTime The maturity time of the long position. + /// @param _bondAmount The amount of longs to close. + /// @param _swapParams The Uniswap multi-hop swap parameters. + /// @param _shouldWrap A flag indicating whether or not the proceeds should + /// be wrapped before the swap. + /// @param _asBase A flag indicating whether or not the deposit should be in + /// base. + function _verifyCloseLongZap( + IHyperdrive _hyperdrive, + uint256 _maturityTime, + uint256 _bondAmount, + ISwapRouter.ExactInputParams memory _swapParams, + bool _shouldWrap, + bool _asBase + ) internal { + // Simulate closing the position without using the zap. + uint256 expectedHyperdriveVaultSharesBalanceAfter; + { + uint256 snapshotId = vm.snapshot(); + _hyperdrive.closeLong( + _maturityTime, + _bondAmount, + 0, // min output + IHyperdrive.Options({ + destination: alice, + asBase: _asBase, + extraData: new bytes(0) + }) + ); + expectedHyperdriveVaultSharesBalanceAfter = IERC20( + _hyperdrive.vaultSharesToken() + ).balanceOf(address(_hyperdrive)); + vm.revertTo(snapshotId); + } + + // Get some data before executing the zap. + uint256 longsOutstandingBefore = _hyperdrive + .getPoolInfo() + .longsOutstanding; + uint256 aliceOutputBalanceBefore; + address tokenOut = _swapParams.path.tokenOut(); + if (tokenOut == WETH) { + aliceOutputBalanceBefore = alice.balance; + } else { + aliceOutputBalanceBefore = IERC20(tokenOut).balanceOf(alice); + } + uint256 aliceLongBalanceBefore = _hyperdrive.balanceOf( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Long, _maturityTime), + alice + ); + + // Execute the zap. + IHyperdrive hyperdrive = _hyperdrive; // avoid stack-too-deep + uint256 maturityTime = _maturityTime; // avoid stack-too-deep + uint256 bondAmount = _bondAmount; // avoid stack-too-deep + ISwapRouter.ExactInputParams memory swapParams = _swapParams; // avoid stack-too-deep + bool shouldWrap = _shouldWrap; // avoid stack-too-deep + bool asBase = _asBase; // avoid stack-too-deep + uint256 proceeds = zap.closeLongZap( + hyperdrive, + maturityTime, // maturity time + bondAmount, // bond amount + 0, // minimum output per share + IHyperdrive.Options({ + destination: address(zap), + asBase: asBase, + extraData: "" + }), + swapParams, + shouldWrap + ); + + // Ensure that Alice received the expected proceeds. + if (tokenOut == WETH) { + assertEq(alice.balance, aliceOutputBalanceBefore + proceeds); + } else { + assertEq( + IERC20(tokenOut).balanceOf(alice), + aliceOutputBalanceBefore + proceeds + ); + } + + // Ensure that the vault shares balance is what we would predict from + // the simulation. + assertEq( + IERC20(hyperdrive.vaultSharesToken()).balanceOf( + address(hyperdrive) + ), + expectedHyperdriveVaultSharesBalanceAfter + ); + + // Ensure that the longs outstanding and Alice's balance of longs + // decreased by the bond amount + assertEq( + hyperdrive.getPoolInfo().longsOutstanding, + longsOutstandingBefore - bondAmount + ); + assertEq( + hyperdrive.balanceOf( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Long, maturityTime), + alice + ), + aliceLongBalanceBefore - bondAmount + ); + } +} diff --git a/test/zaps/uni-v3/CloseShortZap.t.sol b/test/zaps/uni-v3/CloseShortZap.t.sol new file mode 100644 index 000000000..18fde21df --- /dev/null +++ b/test/zaps/uni-v3/CloseShortZap.t.sol @@ -0,0 +1,481 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { IERC20 } from "../../../contracts/src/interfaces/IERC20.sol"; +import { IHyperdrive } from "../../../contracts/src/interfaces/IHyperdrive.sol"; +import { ISwapRouter } from "../../../contracts/src/interfaces/ISwapRouter.sol"; +import { IUniV3Zap } from "../../../contracts/src/interfaces/IUniV3Zap.sol"; +import { AssetId } from "../../../contracts/src/libraries/AssetId.sol"; +import { FixedPointMath } from "../../../contracts/src/libraries/FixedPointMath.sol"; +import { UniV3Path } from "../../../contracts/src/libraries/UniV3Path.sol"; +import { HyperdriveUtils } from "../../utils/HyperdriveUtils.sol"; +import { UniV3ZapTest } from "./UniV3Zap.t.sol"; + +contract CloseShortZapTest is UniV3ZapTest { + using FixedPointMath for uint256; + using HyperdriveUtils for IHyperdrive; + using UniV3Path for bytes; + + /// @dev The maturity time for the short position in the sDAI pool. + uint256 internal maturityTimeSDAI; + + /// @dev The maturity time for the short position in the rETH pool. + uint256 internal maturityTimeRETH; + + /// @dev The maturity time for the short position in the stETH pool. + uint256 internal maturityTimeStETH; + + /// @dev The maturity time for the short position in the WETH pool. + uint256 internal maturityTimeWETHVault; + + /// @dev The short amount in the sDAI pool. + uint256 internal shortAmountSDAI; + + /// @dev The short amount in the rETH pool. + uint256 internal shortAmountRETH; + + /// @dev The short amount in the stETH pool. + uint256 internal shortAmountStETH; + + /// @dev The short amount in the WETH pool. + uint256 internal shortAmountWETHVault; + + /// Set Up /// + + /// @notice Prepare long positions in each of the Hyperdrive markets. + function setUp() public override { + // Sets up the underlying test infrastructure. + super.setUp(); + + // Prepare the sDAI, rETH, stETH, and WETH vault Hyperdrive markets. + shortAmountSDAI = 500e18; + maturityTimeSDAI = _prepareHyperdrive( + SDAI_HYPERDRIVE, + shortAmountSDAI, + true + ); + shortAmountRETH = 1e18; + maturityTimeRETH = _prepareHyperdrive( + RETH_HYPERDRIVE, + shortAmountRETH, + false + ); + shortAmountStETH = 1e18; + maturityTimeStETH = _prepareHyperdrive( + STETH_HYPERDRIVE, + shortAmountStETH, + false + ); + shortAmountWETHVault = 1e18; + maturityTimeWETHVault = _prepareHyperdrive( + WETH_VAULT_HYPERDRIVE, + shortAmountWETHVault, + true + ); + } + + /// @dev Prepares the Hyperdrive instance for the close long tests. + /// @param _hyperdrive The Hyperdrive instance. + /// @param _bondAmount The amount of bonds to short. + /// @param _asBase A flag indicating whether or not the contribution is in + /// base or vault shares. + /// @return The maturity time of the long positions. + function _prepareHyperdrive( + IHyperdrive _hyperdrive, + uint256 _bondAmount, + bool _asBase + ) internal returns (uint256) { + // If we're opening shorts with the base token, approve Hyperdrive to + // spend base tokens. + if (_asBase) { + IERC20(_hyperdrive.baseToken()).approve( + address(_hyperdrive), + type(uint256).max + ); + } + // Otherwise, approve Hyperdrive to spend vault shares. + else { + IERC20(_hyperdrive.vaultSharesToken()).approve( + address(_hyperdrive), + type(uint256).max + ); + } + + // Add a large amount of liquidity to ensure that there is adequate + // liquidity to open the long. + uint256 contribution = ( + _asBase ? _bondAmount : _convertToShares(_hyperdrive, _bondAmount) + ).mulDown(1_000e18); + _hyperdrive.addLiquidity( + contribution, + 0, + 0, + type(uint256).max, + IHyperdrive.Options({ + destination: alice, + asBase: _asBase, + extraData: new bytes(0) + }) + ); + + // Open shorts in the Hyperdrive pool. + (uint256 maturityTime, ) = _hyperdrive.openShort( + _bondAmount, + type(uint256).max, + 0, + IHyperdrive.Options({ + destination: alice, + asBase: _asBase, + extraData: new bytes(0) + }) + ); + + // Set an approval on the zap to spend the long positions. + _hyperdrive.setApproval( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Short, maturityTime), + address(zap), + _bondAmount + ); + + return maturityTime; + } + + /// Tests /// + + /// @notice Ensure that zapping out of `closeShort` will fail when the + /// recipient of `closeShort` isn't the zap contract. + function test_closeShortZap_failure_invalidRecipient() external { + vm.expectRevert(IUniV3Zap.InvalidRecipient.selector); + zap.closeShortZap( + SDAI_HYPERDRIVE, + maturityTimeSDAI, // maturity time + shortAmountSDAI, // short amount + 0, // min output + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, USDC), + recipient: bob, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e6 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `closeShort` will fail when the + /// recipient of `closeShort` isn't the zap contract. + function test_closeShortZap_failure_invalidSwap() external { + vm.expectRevert(IUniV3Zap.InvalidSwap.selector); + zap.closeShortZap( + SDAI_HYPERDRIVE, + maturityTimeSDAI, // maturity time + shortAmountSDAI, // long amount + 0, // min output + IHyperdrive.Options({ + destination: address(zap), + asBase: true, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, DAI), + recipient: bob, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e18 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `closeShort` with base will fail + /// when the input isn't the base token. + function test_closeShortZap_failure_invalidInputToken_asBase() external { + vm.expectRevert(IUniV3Zap.InvalidInputToken.selector); + zap.closeShortZap( + SDAI_HYPERDRIVE, + maturityTimeSDAI, // maturity time + shortAmountSDAI, // short amount + 0, // min output + IHyperdrive.Options({ + destination: address(zap), + asBase: true, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: alice, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `closeShort` with vault shares + /// will fail when the input isn't the vault shares token. + function test_closeShortZap_failure_invalidInputToken_asShares() external { + vm.expectRevert(IUniV3Zap.InvalidInputToken.selector); + zap.closeShortZap( + SDAI_HYPERDRIVE, + maturityTimeSDAI, // maturity time + shortAmountSDAI, // short amount + 0, // min output + IHyperdrive.Options({ + destination: address(zap), + asBase: false, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, USDC), + recipient: alice, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e6 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `closeShort` with base will + /// succeed when the base token is WETH. This ensures that WETH is + /// handled properly despite the zap unwrapping WETH into ETH in + /// some cases. + function test_closeShortZap_success_asBase_withWETH() external { + _verifyCloseLongZap( + WETH_VAULT_HYPERDRIVE, + maturityTimeWETHVault, // maturity time + shortAmountWETHVault, // short amount + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is smaller than the proceeds will be. + // This will automatically be adjusted up. + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + false, // should wrap + true // as base + ); + } + + /// @notice Ensure that zapping out of `closeShort` with base will + /// succeed when the yield source is rebasing and when the input is + /// the base token. + /// @dev This also tests using ETH as the output assets and verifies that + /// we can properly convert to WETH. + function test_closeShortZap_success_rebasing_asBase() external { + _verifyCloseLongZap( + RETH_HYPERDRIVE, + maturityTimeRETH, // maturity time + shortAmountRETH, // short amount + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is smaller than the proceeds will be. + // This will automatically be adjusted up. + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + true, // should wrap + true // as base + ); + } + + /// @notice Ensure that zapping out of `closeShort` with vault shares + /// will succeed when the yield source is rebasing and when the + /// input is the vault shares token. + function test_closeShortZap_success_rebasing_asShares() external { + _verifyCloseLongZap( + STETH_HYPERDRIVE, + maturityTimeStETH, // maturity time + shortAmountStETH, // short amount + ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + WSTETH, + LOWEST_FEE_TIER, + WETH, + LOW_FEE_TIER, + USDC + ), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is smaller than the proceeds will be. + // This will automatically be adjusted up. + amountIn: 0.32796e18, + amountOutMinimum: 950e6 + }), + true, // should wrap + false // as base + ); + } + + /// @notice Ensure that zapping out of `closeShort` with base will + /// succeed when the yield source is non-rebasing and when the input is + /// the base token. + function test_closeShortZap_success_nonRebasing_asBase() external { + _verifyCloseLongZap( + SDAI_HYPERDRIVE, + maturityTimeSDAI, // maturity time + shortAmountSDAI, // short amount + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOW_FEE_TIER, WETH), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is larger than the proceeds will be. + // This will automatically be adjusted down. + amountIn: 1_000e18, + amountOutMinimum: 0.38e18 + }), + false, // should wrap + true // as base + ); + } + + /// @notice Ensure that zapping out of `closeShort` with vault shares + /// will succeed when the yield source is non-rebasing and when the + /// input is the vault shares token. + function test_closeShortZap_success_nonRebasing_asShares() external { + _verifyCloseLongZap( + SDAI_HYPERDRIVE, + maturityTimeSDAI, // maturity time + shortAmountSDAI, // short amount + ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + SDAI, + LOW_FEE_TIER, + WETH, + LOW_FEE_TIER, + USDC + ), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is larger than the proceeds will be. + // This will automatically be adjusted down. + amountIn: 885e18, + amountOutMinimum: 900e6 + }), + false, // should wrap + false // as base + ); + } + + /// @dev Verify that `closeShortZap` performs correctly under the + /// specified conditions. + /// @param _hyperdrive The Hyperdrive instance. + /// @param _maturityTime The maturity time of the long position. + /// @param _bondAmount The amount of longs to close. + /// @param _swapParams The Uniswap multi-hop swap parameters. + /// @param _shouldWrap A flag indicating whether or not the proceeds should + /// be wrapped before the swap. + /// @param _asBase A flag indicating whether or not the deposit should be in + /// base. + function _verifyCloseLongZap( + IHyperdrive _hyperdrive, + uint256 _maturityTime, + uint256 _bondAmount, + ISwapRouter.ExactInputParams memory _swapParams, + bool _shouldWrap, + bool _asBase + ) internal { + // Simulate closing the position without using the zap. + uint256 expectedHyperdriveVaultSharesBalanceAfter; + { + uint256 snapshotId = vm.snapshot(); + _hyperdrive.closeShort( + _maturityTime, + _bondAmount, + 0, // min output + IHyperdrive.Options({ + destination: alice, + asBase: _asBase, + extraData: new bytes(0) + }) + ); + expectedHyperdriveVaultSharesBalanceAfter = IERC20( + _hyperdrive.vaultSharesToken() + ).balanceOf(address(_hyperdrive)); + vm.revertTo(snapshotId); + } + + // Get some data before executing the zap. + uint256 shortsOutstandingBefore = _hyperdrive + .getPoolInfo() + .shortsOutstanding; + uint256 aliceOutputBalanceBefore; + address tokenOut = _swapParams.path.tokenOut(); + if (tokenOut == WETH) { + aliceOutputBalanceBefore = alice.balance; + } else { + aliceOutputBalanceBefore = IERC20(tokenOut).balanceOf(alice); + } + uint256 aliceShortBalanceBefore = _hyperdrive.balanceOf( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Short, _maturityTime), + alice + ); + + // Execute the zap. + IHyperdrive hyperdrive = _hyperdrive; // avoid stack-too-deep + uint256 maturityTime = _maturityTime; // avoid stack-too-deep + uint256 bondAmount = _bondAmount; // avoid stack-too-deep + ISwapRouter.ExactInputParams memory swapParams = _swapParams; // avoid stack-too-deep + bool shouldWrap = _shouldWrap; // avoid stack-too-deep + bool asBase = _asBase; // avoid stack-too-deep + uint256 proceeds = zap.closeShortZap( + hyperdrive, + maturityTime, // maturity time + bondAmount, // bond amount + 0, // minimum output per share + IHyperdrive.Options({ + destination: address(zap), + asBase: asBase, + extraData: "" + }), + swapParams, + shouldWrap + ); + + // Ensure that Alice received the expected proceeds. + if (tokenOut == WETH) { + assertEq(alice.balance, aliceOutputBalanceBefore + proceeds); + } else { + assertEq( + IERC20(tokenOut).balanceOf(alice), + aliceOutputBalanceBefore + proceeds + ); + } + + // Ensure that the vault shares balance is what we would predict from + // the simulation. + assertEq( + IERC20(hyperdrive.vaultSharesToken()).balanceOf( + address(hyperdrive) + ), + expectedHyperdriveVaultSharesBalanceAfter + ); + + // Ensure that the shorts outstanding and Alice's balance of shorts + // decreased by the bond amount + assertEq( + hyperdrive.getPoolInfo().shortsOutstanding, + shortsOutstandingBefore - bondAmount + ); + assertEq( + hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + maturityTime + ), + alice + ), + aliceShortBalanceBefore - bondAmount + ); + } +} diff --git a/test/zaps/uni-v3/Conversions.t.sol b/test/zaps/uni-v3/Conversions.t.sol new file mode 100644 index 000000000..daf09f6a7 --- /dev/null +++ b/test/zaps/uni-v3/Conversions.t.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { IERC4626 } from "../../../contracts/src/interfaces/IERC4626.sol"; +import { IHyperdrive } from "../../../contracts/src/interfaces/IHyperdrive.sol"; +import { ILido } from "../../../contracts/src/interfaces/ILido.sol"; +import { ISwapRouter } from "../../../contracts/src/interfaces/ISwapRouter.sol"; +import { IWETH } from "../../../contracts/src/interfaces/IWETH.sol"; +import { ONE } from "../../../contracts/src/libraries/FixedPointMath.sol"; +import { UniV3Zap } from "../../../contracts/src/zaps/UniV3Zap.sol"; +import { UniV3ZapTest } from "./UniV3Zap.t.sol"; + +contract MockUniV3Zap is UniV3Zap { + constructor( + string memory _name, + ISwapRouter _swapRouter, + IWETH _weth + ) UniV3Zap(_name, _swapRouter, _weth) {} + + function convertToShares( + IHyperdrive _hyperdrive, + uint256 _baseAmount + ) external view returns (uint256) { + return _convertToShares(_hyperdrive, _baseAmount); + } +} + +contract ConversionsTest is UniV3ZapTest { + /// @dev The mock zap contract. + MockUniV3Zap internal mock; + + /// @dev Set up the mock zap contract. + function setUp() public override { + // Run the higher-level setup logic. + super.setUp(); + + // Instantiate the zap contract. + mock = new MockUniV3Zap(NAME, SWAP_ROUTER, IWETH(WETH)); + } + + /// @notice Ensure that share conversions work properly for the legacy sDAI + /// contract. + function test_convertToShares_sDaiLegacy() external view { + uint256 shareAmount = mock.convertToShares( + IHyperdrive(LEGACY_SDAI_HYPERDRIVE), + ONE + ); + assertEq(IERC4626(SDAI).convertToShares(ONE), shareAmount); + } + + /// @notice Ensure that share conversions work properly for the legacy stETH + /// contract. + function test_convertToShares_stETHLegacy() external view { + uint256 shareAmount = mock.convertToShares( + IHyperdrive(LEGACY_STETH_HYPERDRIVE), + ONE + ); + assertEq(ILido(STETH).getSharesByPooledEth(ONE), shareAmount); + } + + /// @notice Ensure that share conversions work properly for the Hyperdrive + /// instances that aren't legacy instances. + function test_convertToShares_nonLegacy() external view { + uint256 shareAmount = mock.convertToShares( + IHyperdrive(RETH_HYPERDRIVE), + ONE + ); + assertEq(RETH_HYPERDRIVE.convertToShares(ONE), shareAmount); + } +} diff --git a/test/zaps/uni-v3/Metadata.t.sol b/test/zaps/uni-v3/Metadata.t.sol new file mode 100644 index 000000000..569cab6dd --- /dev/null +++ b/test/zaps/uni-v3/Metadata.t.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { UNI_V3_ZAP_KIND, VERSION } from "../../../contracts/src/libraries/Constants.sol"; +import { UniV3ZapTest } from "./UniV3Zap.t.sol"; + +contract MetadataTest is UniV3ZapTest { + /// @notice Ensure that the name is set up correctly. + function test_name() external view { + assertEq(zap.name(), NAME); + } + + /// @notice Ensure that the kind is set up correctly. + function test_kind() external view { + assertEq(zap.kind(), UNI_V3_ZAP_KIND); + } + + /// @notice Ensure that the version is set up correctly. + function test_version() external view { + assertEq(zap.version(), VERSION); + } +} diff --git a/test/zaps/uni-v3/OpenLongZap.sol b/test/zaps/uni-v3/OpenLongZap.sol new file mode 100644 index 000000000..51d02b0b2 --- /dev/null +++ b/test/zaps/uni-v3/OpenLongZap.sol @@ -0,0 +1,657 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { IERC20 } from "../../../contracts/src/interfaces/IERC20.sol"; +import { IHyperdrive } from "../../../contracts/src/interfaces/IHyperdrive.sol"; +import { ISwapRouter } from "../../../contracts/src/interfaces/ISwapRouter.sol"; +import { IUniV3Zap } from "../../../contracts/src/interfaces/IUniV3Zap.sol"; +import { AssetId } from "../../../contracts/src/libraries/AssetId.sol"; +import { ETH } from "../../../contracts/src/libraries/Constants.sol"; +import { FixedPointMath } from "../../../contracts/src/libraries/FixedPointMath.sol"; +import { UniV3Path } from "../../../contracts/src/libraries/UniV3Path.sol"; +import { HyperdriveUtils } from "../../utils/HyperdriveUtils.sol"; +import { UniV3ZapTest } from "./UniV3Zap.t.sol"; + +contract OpenLongZapTest is UniV3ZapTest { + using FixedPointMath for uint256; + using HyperdriveUtils for IHyperdrive; + using UniV3Path for bytes; + + /// @notice Ensure that zapping into `openLong` will fail when the + /// recipient isn't the zap contract. + function test_openLongZap_failure_invalidRecipient() external { + // Ensure that the zap fails when the recipient isn't Hyperdrive. + vm.expectRevert(IUniV3Zap.InvalidRecipient.selector); + zap.openLongZap( + SDAI_HYPERDRIVE, + 0, // minimum output + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: bob, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + } + + /// @notice Ensure that zapping into `openLong` will fail when the + /// input and output tokens are the same. + function test_openLongZap_failure_invalidSwap() external { + vm.expectRevert(IUniV3Zap.InvalidSwap.selector); + zap.openLongZap( + SDAI_HYPERDRIVE, + 0, // minimum output + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e18 + }), + sourceAsset: DAI, + sourceAmount: 1_000e18, + shouldWrap: false, + isRebasing: false + }) + ); + } + + /// @notice Ensure that zapping into `openLong` with base will fail when + /// the output isn't the base token. + function test_openLongZap_failure_invalidOutputToken_asBase() external { + // Ensure that the zap fails when `asBase` is true and the output token + // isn't the base token. + vm.expectRevert(IUniV3Zap.InvalidOutputToken.selector); + zap.openLongZap( + SDAI_HYPERDRIVE, + 0, // minimum output + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + USDC, + LOW_FEE_TIER, + WETH, + LOW_FEE_TIER, + SDAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 850e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + } + + /// @notice Ensure that zapping into `openLong` with vault shares will + /// fail when the output isn't the vault shares token. + function test_openLongZap_failure_invalidOutputToken_asShares() external { + // Ensure that the zap fails when `asBase` is false and the output token + // isn't the vault shares token. + vm.expectRevert(IUniV3Zap.InvalidOutputToken.selector); + zap.openLongZap( + SDAI_HYPERDRIVE, + 0, // minimum output + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: false, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + } + + /// @notice Ensure that zapping into `openLong` with base refunds the + /// sender when they send ETH that can't be used for the zap. + function test_openLongZap_success_asBase_refund() external { + // Get Alice's ether balance before the zap. + uint256 aliceBalanceBefore = alice.balance; + + // Zaps into `addLiquidity` with `asBase` as `true` from USDC to DAI. + zap.openLongZap{ value: 100e18 }( + SDAI_HYPERDRIVE, + 0, // minimum output + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + + // Ensure that Alice's balance didn't change. This indicates that her + // ETH transfer was fully refunded. + assertEq(alice.balance, aliceBalanceBefore); + } + + /// @notice Ensure that zapping into `openLong` with vault shares + /// refunds the sender when they send ETH that can't be used for the + /// zap. + function test_openLongZap_success_asShares_refund() external { + // Get Alice's ether balance before the zap. + uint256 aliceBalanceBefore = alice.balance; + + // Zaps into `addLiquidity` with `asBase` as `false` from USDC to sDAI. + zap.openLongZap{ value: 100e18 }( + SDAI_HYPERDRIVE, + 0, // minimum output + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: false, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + USDC, + LOW_FEE_TIER, + WETH, + LOW_FEE_TIER, + SDAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 850e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + + // Ensure that Alice's balance didn't change. This indicates that her + // ETH transfer was fully refunded. + assertEq(alice.balance, aliceBalanceBefore); + } + + /// @notice Ensure that Alice can pay for a zap from WETH to DAI with WETH. + /// We send extra ETH in the zap to ensure that Alice gets refunded + /// for the excess. + function test_openLongZap_success_asBase_withWETH() external { + // Ensure that adding liquidity with vault shares using a zap from + // ETH (via WETH) to DAI is successful. + _verifyOpenLongZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + sourceAsset: WETH, + sourceAmount: 0.3882e18, + shouldWrap: false, + isRebasing: false + }), + 0.1e18, // this should be refunded + true // as base + ); + } + + /// @notice Ensure that Alice can pay for a zap from WETH to sDAI with WETH. + /// We send extra ETH in the zap to ensure that Alice gets refunded + /// for the excess. + function test_openLongZap_success_asShares_withWETH() external { + // Ensure that adding liquidity with vault shares using a zap from + // ETH (via WETH) to sDAI is successful. + _verifyOpenLongZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, SDAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.3882e18, + amountOutMinimum: 886e18 + }), + sourceAsset: WETH, + sourceAmount: 0.3882e18, + shouldWrap: false, + isRebasing: false + }), + 0.1e18, // this should be refunded + false // as base + ); + } + + /// @notice Ensure that Alice can pay for a zap from WETH to DAI with ETH. + /// We send extra ETH in the zap to ensure that Alice gets refunded + /// for the excess. + function test_openLongZap_success_asBase_withETH() external { + // Ensure that adding liquidity with vault shares using a zap from + // ETH (via WETH) to DAI is successful. + _verifyOpenLongZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + sourceAsset: ETH, + sourceAmount: 0.3882e18, + shouldWrap: true, + isRebasing: false + }), + 10e18, // most of this should be refunded + true // as base + ); + } + + /// @notice Ensure that Alice can pay for a zap from WETH to sDAI with ETH. + /// We send extra ETH in the zap to ensure that Alice gets refunded + /// for the excess. + function test_openLongZap_success_asShares_withETH() external { + // Ensure that adding liquidity with vault shares using a zap from + // ETH (via WETH) to sDAI is successful. + _verifyOpenLongZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, SDAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.3882e18, + amountOutMinimum: 886e18 + }), + sourceAsset: ETH, + sourceAmount: 0.3882e18, + shouldWrap: true, + isRebasing: false + }), + 10e18, // most of this should be refunded + false // as base + ); + } + + /// @notice Ensure that zapping into `openLong` with base succeeds with + /// a rebasing yield source. + function test_openLongZap_success_rebasing_asBase() external { + // Ensure that adding liquidity with base using a zap from USDC to WETH + // (and ultimately into ETH) is successful. + _verifyOpenLongZap( + STETH_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, MEDIUM_FEE_TIER, WETH), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 0.3869e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: true + }), + 10e18, // this should be completely refunded + true // as base + ); + } + + /// @notice Ensure that zapping into `openLong` with vault shares + /// succeeds with a rebasing yield source. + function test_openLongZap_success_rebasing_asShares() external { + // Ensure that adding liquidity with vault shares using a zap from + // USDC to stETH is successful. + _verifyOpenLongZap( + STETH_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + USDC, + MEDIUM_FEE_TIER, + WETH, + HIGH_FEE_TIER, + STETH + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 0.383997e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: true + }), + 0, + false // as base + ); + } + + /// @notice Ensure that zapping into `openLong` with base succeeds with + /// a non-rebasing yield source. + function test_openLongZap_success_nonRebasing_asBase() external { + // Ensure that adding liquidity with base using a zap from USDC to DAI + // is successful. + _verifyOpenLongZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }), + 0, + true // as base + ); + } + + /// @notice Ensure that zapping into `openLong` with vault shares + /// succeeds with a non-rebasing yield source. + function test_openLongZap_success_nonRebasing_asShares() external { + // Ensure that adding liquidity with vault shares using a zap from + // USDC to sDAI is successful. + _verifyOpenLongZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + USDC, + LOW_FEE_TIER, + WETH, + LOW_FEE_TIER, + SDAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 885e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }), + 10e18, // this should be completely refunded + false // as base + ); + } + + /// @notice Ensure that zapping into `openLong` with base succeeds when + /// the input needs to be wrapped. + function test_openLongZap_success_shouldWrap_asBase() external { + _verifyOpenLongZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + WSTETH, + LOWEST_FEE_TIER, + WETH, + LOW_FEE_TIER, + DAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.2534e18, + amountOutMinimum: 771e18 + }), + sourceAsset: STETH, + sourceAmount: 0.3e18, + shouldWrap: true, + isRebasing: false + }), + 10e18, // this should be completely refunded + true // as base + ); + } + + /// @notice Ensure that zapping into `openLong` with vault shares + /// succeeds when the input needs to be wrapped. + function test_openLongZap_success_shouldWrap_asShares() external { + _verifyOpenLongZap( + SDAI_HYPERDRIVE, + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + WSTETH, + LOWEST_FEE_TIER, + WETH, + LOW_FEE_TIER, + SDAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.2534e18, + amountOutMinimum: 691e18 + }), + sourceAsset: STETH, + sourceAmount: 0.3e18, + shouldWrap: true, + isRebasing: false + }), + 10e18, // this should be completely refunded + false // as shares + ); + } + + /// @dev Verify that `openLongZap` performs correctly under the + /// specified conditions. + /// @param _hyperdrive The Hyperdrive instance. + /// @param _zapInOptions The options for the zap. + /// @param _value The ETH value to send in the transaction. + /// @param _asBase A flag indicating whether or not the deposit should be in + /// base. + function _verifyOpenLongZap( + IHyperdrive _hyperdrive, + IUniV3Zap.ZapInOptions memory _zapInOptions, + uint256 _value, + bool _asBase + ) internal { + // Gets some data about the trader and the pool before the zap. + bool isETHInput = _zapInOptions.swapParams.path.tokenIn() == WETH && + _value > _zapInOptions.swapParams.amountIn; + uint256 aliceBalanceBefore; + if (isETHInput) { + aliceBalanceBefore = alice.balance; + } else { + aliceBalanceBefore = IERC20(_zapInOptions.sourceAsset).balanceOf( + alice + ); + } + uint256 hyperdriveVaultSharesBalanceBefore = IERC20( + _hyperdrive.vaultSharesToken() + ).balanceOf(address(_hyperdrive)); + uint256 longsOutstandingBefore = _hyperdrive + .getPoolInfo() + .longsOutstanding; + uint256 longBalanceBefore = _hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Long, + hyperdrive.latestCheckpoint() + POSITION_DURATION + ), + alice + ); + uint256 spotPriceBefore = _hyperdrive.calculateSpotPrice(); + + // Zap into `openLong`. + uint256 value = _value; // avoid stack-too-deep + IUniV3Zap.ZapInOptions memory zapInOptions = _zapInOptions; // avoid stack-too-deep + IHyperdrive hyperdrive = _hyperdrive; // avoid stack-too-deep + bool asBase = _asBase; // avoid stack-too-deep + (uint256 maturityTime, uint256 longAmount) = zap.openLongZap{ + value: value + }( + hyperdrive, + 0, // minimum output + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: asBase, + extraData: "" + }), + zapInOptions + ); + + // Ensure that the maturity time is the latest checkpoint. + assertEq( + maturityTime, + hyperdrive.latestCheckpoint() + + hyperdrive.getPoolConfig().positionDuration + ); + + // Ensure that Alice was charged the correct amount of the input token. + if (isETHInput) { + assertEq( + alice.balance, + aliceBalanceBefore - zapInOptions.sourceAmount + ); + } else { + assertEq( + IERC20(zapInOptions.sourceAsset).balanceOf(alice), + aliceBalanceBefore - zapInOptions.sourceAmount + ); + } + + // Ensure that Hyperdrive received more than the minimum output of the + // swap. + uint256 hyperdriveVaultSharesBalanceAfter = IERC20( + hyperdrive.vaultSharesToken() + ).balanceOf(address(hyperdrive)); + if (zapInOptions.isRebasing) { + // NOTE: Since the vault shares rebase, the units are in base. + assertGt( + hyperdriveVaultSharesBalanceAfter, + hyperdriveVaultSharesBalanceBefore + + zapInOptions.swapParams.amountOutMinimum + ); + } else if (asBase) { + // NOTE: Since the vault shares don't rebase, the units are in shares. + assertGt( + hyperdriveVaultSharesBalanceAfter, + hyperdriveVaultSharesBalanceBefore + + _convertToShares( + hyperdrive, + zapInOptions.swapParams.amountOutMinimum + ) + ); + } else { + // NOTE: Since the vault shares don't rebase, the units are in shares. + assertGt( + hyperdriveVaultSharesBalanceAfter, + hyperdriveVaultSharesBalanceBefore + + zapInOptions.swapParams.amountOutMinimum + ); + } + + // Ensure that Alice received an appropriate amount of LP shares and + // that the LP total supply increased. + if (_zapInOptions.isRebasing) { + if (_asBase) { + assertGt( + zapInOptions.swapParams.amountOutMinimum.divDown( + longAmount + ), + spotPriceBefore + ); + } else { + assertGt( + zapInOptions.swapParams.amountOutMinimum.divDown( + longAmount + ), + spotPriceBefore + ); + } + } else { + if (_asBase) { + assertGt( + zapInOptions.swapParams.amountOutMinimum.divDown( + longAmount + ), + spotPriceBefore + ); + } else { + assertGt( + _convertToBase( + hyperdrive, + zapInOptions.swapParams.amountOutMinimum + ).divDown(longAmount), + spotPriceBefore + ); + } + } + assertEq( + hyperdrive.balanceOf( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Long, maturityTime), + alice + ), + longBalanceBefore + longAmount + ); + assertEq( + hyperdrive.getPoolInfo().longsOutstanding, + longsOutstandingBefore + longAmount + ); + } +} diff --git a/test/zaps/uni-v3/OpenShortZap.t.sol b/test/zaps/uni-v3/OpenShortZap.t.sol new file mode 100644 index 000000000..5cef2ee3f --- /dev/null +++ b/test/zaps/uni-v3/OpenShortZap.t.sol @@ -0,0 +1,703 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { IERC20 } from "../../../contracts/src/interfaces/IERC20.sol"; +import { IHyperdrive } from "../../../contracts/src/interfaces/IHyperdrive.sol"; +import { ISwapRouter } from "../../../contracts/src/interfaces/ISwapRouter.sol"; +import { IUniV3Zap } from "../../../contracts/src/interfaces/IUniV3Zap.sol"; +import { AssetId } from "../../../contracts/src/libraries/AssetId.sol"; +import { ETH } from "../../../contracts/src/libraries/Constants.sol"; +import { FixedPointMath } from "../../../contracts/src/libraries/FixedPointMath.sol"; +import { UniV3Path } from "../../../contracts/src/libraries/UniV3Path.sol"; +import { HyperdriveUtils } from "../../utils/HyperdriveUtils.sol"; +import { UniV3ZapTest } from "./UniV3Zap.t.sol"; + +contract OpenShortZapTest is UniV3ZapTest { + using FixedPointMath for uint256; + using HyperdriveUtils for IHyperdrive; + using UniV3Path for bytes; + + /// @notice Ensure that zapping into `openShort` will fail when the + /// recipient isn't the zap contract. + function test_openShortZap_failure_invalidRecipient() external { + vm.expectRevert(IUniV3Zap.InvalidRecipient.selector); + zap.openShortZap( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + type(uint256).max, // maximum deposit + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: bob, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + } + + /// @notice Ensure that zapping into `openShort` will fail when the + /// input and output tokens are the same. + function test_openShortZap_failure_invalidSwap() external { + vm.expectRevert(IUniV3Zap.InvalidSwap.selector); + zap.openShortZap( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + type(uint256).max, // maximum deposit + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e18 + }), + sourceAsset: DAI, + sourceAmount: 1_000e18, + shouldWrap: false, + isRebasing: false + }) + ); + } + + /// @notice Ensure that zapping into `openShort` with base will fail when + /// the output isn't the base token. + function test_openShortZap_failure_invalidOutputToken_asBase() external { + vm.expectRevert(IUniV3Zap.InvalidOutputToken.selector); + zap.openShortZap( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + type(uint256).max, // maximum deposit + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + USDC, + LOW_FEE_TIER, + WETH, + LOW_FEE_TIER, + SDAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 850e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + } + + /// @notice Ensure that zapping into `openShort` with vault shares will + /// fail when the output isn't the vault shares token. + function test_openShortZap_failure_invalidOutputToken_asShares() external { + vm.expectRevert(IUniV3Zap.InvalidOutputToken.selector); + zap.openShortZap( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + type(uint256).max, // maximum deposit + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: false, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + } + + /// @notice Ensure that zapping into `openShort` with base refunds the + /// sender when they send ETH that can't be used for the zap. + function test_openShortZap_success_asBase_refund() external { + // Get Alice's ether balance before the zap. + uint256 aliceBalanceBefore = alice.balance; + + // Zaps into `addLiquidity` with `asBase` as `true` from USDC to DAI. + zap.openShortZap{ value: 100e18 }( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + type(uint256).max, // maximum deposit + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + + // Ensure that Alice's balance didn't change. This indicates that her + // ETH transfer was fully refunded. + assertEq(alice.balance, aliceBalanceBefore); + } + + /// @notice Ensure that zapping into `openShort` with vault shares + /// refunds the sender when they send ETH that can't be used for the + /// zap. + function test_openShortZap_success_asShares_refund() external { + // Get Alice's ether balance before the zap. + uint256 aliceBalanceBefore = alice.balance; + + // Zaps into `addLiquidity` with `asBase` as `false` from USDC to sDAI. + zap.openShortZap{ value: 100e18 }( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + type(uint256).max, // maximum deposit + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: false, + extraData: "" + }), + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + USDC, + LOW_FEE_TIER, + WETH, + LOW_FEE_TIER, + SDAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 850e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }) + ); + + // Ensure that Alice's balance didn't change. This indicates that her + // ETH transfer was fully refunded. + assertEq(alice.balance, aliceBalanceBefore); + } + + /// @notice Ensure that Alice can pay for a zap from WETH to DAI with WETH. + /// We send extra ETH in the zap to ensure that Alice gets refunded + /// for the excess. + function test_openShortZap_success_asBase_withWETH() external { + // Ensure that adding liquidity with vault shares using a zap from + // ETH (via WETH) to DAI is successful. + _verifyOpenShortZap( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + sourceAsset: WETH, + sourceAmount: 0.3882e18, + shouldWrap: false, + isRebasing: false + }), + 0.1e18, // this should be refunded + true // as base + ); + } + + /// @notice Ensure that Alice can pay for a zap from WETH to sDAI with WETH. + /// We send extra ETH in the zap to ensure that Alice gets refunded + /// for the excess. + function test_openShortZap_success_asShares_withWETH() external { + // Ensure that adding liquidity with vault shares using a zap from + // ETH (via WETH) to sDAI is successful. + _verifyOpenShortZap( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, SDAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.3882e18, + amountOutMinimum: 886e18 + }), + sourceAsset: WETH, + sourceAmount: 0.3882e18, + shouldWrap: false, + isRebasing: false + }), + 0.1e18, // this should be refunded + false // as base + ); + } + + /// @notice Ensure that Alice can pay for a zap from WETH to DAI with ETH. + /// We send extra ETH in the zap to ensure that Alice gets refunded + /// for the excess. + function test_openShortZap_success_asBase_withETH() external { + // Ensure that adding liquidity with vault shares using a zap from + // ETH (via WETH) to DAI is successful. + _verifyOpenShortZap( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + sourceAsset: ETH, + sourceAmount: 0.3882e18, + shouldWrap: true, + isRebasing: false + }), + 10e18, // most of this should be refunded + true // as base + ); + } + + /// @notice Ensure that Alice can pay for a zap from WETH to sDAI with ETH. + /// We send extra ETH in the zap to ensure that Alice gets refunded + /// for the excess. + function test_openShortZap_success_asShares_withETH() external { + // Ensure that adding liquidity with vault shares using a zap from + // ETH (via WETH) to sDAI is successful. + _verifyOpenShortZap( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, SDAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.3882e18, + amountOutMinimum: 886e18 + }), + sourceAsset: ETH, + sourceAmount: 0.3882e18, + shouldWrap: true, + isRebasing: false + }), + 10e18, // most of this should be refunded + false // as base + ); + } + + /// @notice Ensure that zapping into `openShort` with base succeeds with + /// a rebasing yield source. + function test_openShortZap_success_rebasing_asBase() external { + // Ensure that adding liquidity with base using a zap from USDC to WETH + // (and ultimately into ETH) is successful. + _verifyOpenShortZap( + STETH_HYPERDRIVE, + 1e18, // bond amount + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, MEDIUM_FEE_TIER, WETH), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 0.38e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: true + }), + 10e18, // this should be completely refunded + true // as base + ); + } + + /// @notice Ensure that zapping into `openShort` with vault shares + /// succeeds with a rebasing yield source. + function test_openShortZap_success_rebasing_asShares() external { + // Ensure that adding liquidity with vault shares using a zap from + // USDC to stETH is successful. + _verifyOpenShortZap( + STETH_HYPERDRIVE, + 1e18, // bond amount + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + USDC, + MEDIUM_FEE_TIER, + WETH, + HIGH_FEE_TIER, + STETH + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 0.38e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: true + }), + 0, + false // as base + ); + } + + /// @notice Ensure that zapping into `openShort` with base succeeds with + /// a non-rebasing yield source. + function test_openShortZap_success_nonRebasing_asBase() external { + // Ensure that adding liquidity with base using a zap from USDC to DAI + // is successful. + _verifyOpenShortZap( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }), + 0, + true // as base + ); + } + + /// @notice Ensure that zapping into `openShort` with vault shares + /// succeeds with a non-rebasing yield source. + function test_openShortZap_success_nonRebasing_asShares() external { + // Ensure that adding liquidity with vault shares using a zap from + // USDC to sDAI is successful. + _verifyOpenShortZap( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + USDC, + LOW_FEE_TIER, + WETH, + LOW_FEE_TIER, + SDAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 850e18 + }), + sourceAsset: USDC, + sourceAmount: 1_000e6, + shouldWrap: false, + isRebasing: false + }), + 10e18, // this should be completely refunded + false // as base + ); + } + + /// @notice Ensure that zapping into `openShort` with base succeeds when + /// the input needs to be wrapped. + function test_openShortZap_success_shouldWrap_asBase() external { + _verifyOpenShortZap( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + WSTETH, + LOWEST_FEE_TIER, + WETH, + LOW_FEE_TIER, + DAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.2534e18, + amountOutMinimum: 771e18 + }), + sourceAsset: STETH, + sourceAmount: 0.3e18, + shouldWrap: true, + isRebasing: false + }), + 10e18, // this should be completely refunded + true // as base + ); + } + + /// @notice Ensure that zapping into `openShort` with vault shares + /// succeeds when the input needs to be wrapped. + function test_openShortZap_success_shouldWrap_asShares() external { + _verifyOpenShortZap( + SDAI_HYPERDRIVE, + 3_000e18, // bond amount + IUniV3Zap.ZapInOptions({ + swapParams: ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + WSTETH, + LOWEST_FEE_TIER, + WETH, + LOW_FEE_TIER, + SDAI + ), + recipient: address(zap), + deadline: block.timestamp + 1 minutes, + amountIn: 0.2534e18, + amountOutMinimum: 691e18 + }), + sourceAsset: STETH, + sourceAmount: 0.3e18, + shouldWrap: true, + isRebasing: false + }), + 10e18, // this should be completely refunded + false // as shares + ); + } + + struct VerifyOpenShortParams { + /// @dev A flag indicating whether or not the swap input is in ETH. + bool isETHInput; + /// @dev A flag indicating whether or not the swap output is in ETH. + bool isETHOutput; + /// @dev Alice's balance of the swap input before the zap. + uint256 aliceInputBalanceBefore; + /// @dev Alice's balance of the swap output before the zap. + uint256 aliceOutputBalanceBefore; + /// @dev Alice's balance of shorts in this checkpoint before the zap. + uint256 aliceShortBalanceBefore; + /// @dev Hyperdrive's vault shares balance before the zap. + uint256 hyperdriveVaultSharesBalanceBefore; + /// @dev The amount of shorts outstanding before the zap. + uint256 shortsOutstandingBefore; + /// @dev The spot price before the zap. + uint256 spotPriceBefore; + } + + /// @dev Verify that `openShortZap` performs correctly under the + /// specified conditions. + /// @param _hyperdrive The Hyperdrive instance. + /// @param _bondAmount The amount of bonds to short. + /// @param _zapInOptions The options for the zap. + /// @param _value The ETH value to send in the transaction. + /// @param _asBase A flag indicating whether or not the deposit should be in + /// base. + function _verifyOpenShortZap( + IHyperdrive _hyperdrive, + uint256 _bondAmount, + IUniV3Zap.ZapInOptions memory _zapInOptions, + uint256 _value, + bool _asBase + ) internal { + // Gets some data about the trader and the pool before the zap. + VerifyOpenShortParams memory params; + params.isETHInput = + _zapInOptions.swapParams.path.tokenIn() == WETH && + _value > _zapInOptions.swapParams.amountIn; + if (params.isETHInput) { + params.aliceInputBalanceBefore = alice.balance; + } else { + params.aliceInputBalanceBefore = IERC20(_zapInOptions.sourceAsset) + .balanceOf(alice); + } + params.isETHOutput = _asBase && _hyperdrive.baseToken() == ETH; + if (params.isETHOutput) { + params.aliceOutputBalanceBefore = alice.balance; + } else { + params.aliceOutputBalanceBefore = IERC20( + _zapInOptions.swapParams.path.tokenOut() + ).balanceOf(alice); + } + params.aliceShortBalanceBefore = _hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + hyperdrive.latestCheckpoint() + POSITION_DURATION + ), + alice + ); + params.hyperdriveVaultSharesBalanceBefore = IERC20( + _hyperdrive.vaultSharesToken() + ).balanceOf(address(_hyperdrive)); + params.shortsOutstandingBefore = _hyperdrive + .getPoolInfo() + .shortsOutstanding; + params.spotPriceBefore = _hyperdrive.calculateSpotPrice(); + + // Zap into `openShort`. + (uint256 maturityTime, uint256 deposit) = zap.openShortZap{ + value: _value + }( + _hyperdrive, + _bondAmount, + type(uint256).max, // maximum deposit + 0, // minimum vault share price + IHyperdrive.Options({ + destination: alice, + asBase: _asBase, + extraData: "" + }), + _zapInOptions + ); + + // Ensure that the maturity time is the latest checkpoint. + assertEq( + maturityTime, + _hyperdrive.latestCheckpoint() + + _hyperdrive.getPoolConfig().positionDuration + ); + + // Ensure that Alice was charged the correct amount of the input token. + if (params.isETHInput) { + assertEq( + alice.balance, + params.aliceInputBalanceBefore - _zapInOptions.sourceAmount + ); + } else { + assertEq( + IERC20(_zapInOptions.sourceAsset).balanceOf(alice), + params.aliceInputBalanceBefore - _zapInOptions.sourceAmount + ); + } + + // Ensure that Alice was refunded any of the output token that wasn't + // used. + if (params.isETHOutput) { + assertGt( + alice.balance, + params.aliceOutputBalanceBefore + + (_zapInOptions.swapParams.amountOutMinimum - deposit) + ); + } else { + assertGt( + IERC20(_zapInOptions.swapParams.path.tokenOut()).balanceOf( + alice + ), + params.aliceOutputBalanceBefore + + (_zapInOptions.swapParams.amountOutMinimum - deposit) + ); + } + + // Ensure that Hyperdrive received the deposit. + uint256 hyperdriveVaultSharesBalanceAfter = IERC20( + _hyperdrive.vaultSharesToken() + ).balanceOf(address(_hyperdrive)); + if (_zapInOptions.isRebasing) { + if (_asBase) { + // NOTE: Since the vault shares rebase, the units are in base. + assertEq( + hyperdriveVaultSharesBalanceAfter, + params.hyperdriveVaultSharesBalanceBefore + deposit + ); + } else { + assertApproxEqAbs( + hyperdriveVaultSharesBalanceAfter, + params.hyperdriveVaultSharesBalanceBefore + + _convertToBase(_hyperdrive, deposit), + 1 + ); + } + } else { + if (_asBase) { + // NOTE: Since the vault shares don't rebase, the units are in shares. + assertApproxEqAbs( + hyperdriveVaultSharesBalanceAfter, + params.hyperdriveVaultSharesBalanceBefore + + _convertToShares(_hyperdrive, deposit), + 1 + ); + } else { + // NOTE: Since the vault shares don't rebase, the units are in shares. + assertEq( + hyperdriveVaultSharesBalanceAfter, + params.hyperdriveVaultSharesBalanceBefore + deposit + ); + } + } + + // Ensure that Alice received an appropriate amount of LP shares and + // that the LP total supply increased. + if (_zapInOptions.isRebasing) { + if (_asBase) { + assertLt(deposit.divDown(_bondAmount), params.spotPriceBefore); + } else { + assertLt(deposit.divDown(_bondAmount), params.spotPriceBefore); + } + } else { + if (_asBase) { + assertLt(deposit.divDown(_bondAmount), params.spotPriceBefore); + } else { + assertLt( + _convertToBase(_hyperdrive, deposit).divDown(_bondAmount), + params.spotPriceBefore + ); + } + } + assertEq( + _hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + maturityTime + ), + alice + ), + params.aliceShortBalanceBefore + _bondAmount + ); + assertEq( + _hyperdrive.getPoolInfo().shortsOutstanding, + params.shortsOutstandingBefore + _bondAmount + ); + } +} diff --git a/test/zaps/uni-v3/Receive.t.sol b/test/zaps/uni-v3/Receive.t.sol new file mode 100644 index 000000000..7f300b721 --- /dev/null +++ b/test/zaps/uni-v3/Receive.t.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { IUniV3Zap } from "../../../contracts/src/interfaces/IUniV3Zap.sol"; +import { UniV3ZapTest } from "./UniV3Zap.t.sol"; + +contract ReceiveTest is UniV3ZapTest { + /// @dev This test ensures that ether can't be sent to the UniV3Zap outside + /// of the context of a zap. + function test_receive_failure() external { + (bool success, bytes memory data) = address(zap).call{ value: 1 ether }( + "" + ); + assertFalse(success); + assertEq( + data, + abi.encodeWithSelector(IUniV3Zap.InvalidTransfer.selector) + ); + } +} diff --git a/test/zaps/uni-v3/RedeemWithdrawalShares.sol b/test/zaps/uni-v3/RedeemWithdrawalShares.sol new file mode 100644 index 000000000..d1d9efca4 --- /dev/null +++ b/test/zaps/uni-v3/RedeemWithdrawalShares.sol @@ -0,0 +1,483 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { IERC20 } from "../../../contracts/src/interfaces/IERC20.sol"; +import { IHyperdrive } from "../../../contracts/src/interfaces/IHyperdrive.sol"; +import { ISwapRouter } from "../../../contracts/src/interfaces/ISwapRouter.sol"; +import { IUniV3Zap } from "../../../contracts/src/interfaces/IUniV3Zap.sol"; +import { AssetId } from "../../../contracts/src/libraries/AssetId.sol"; +import { FixedPointMath } from "../../../contracts/src/libraries/FixedPointMath.sol"; +import { UniV3Path } from "../../../contracts/src/libraries/UniV3Path.sol"; +import { HyperdriveUtils } from "../../utils/HyperdriveUtils.sol"; +import { UniV3ZapTest } from "./UniV3Zap.t.sol"; + +contract RedeemWithdrawalSharesZapTest is UniV3ZapTest { + using FixedPointMath for uint256; + using HyperdriveUtils for IHyperdrive; + using UniV3Path for bytes; + + /// @dev The withdrawal shares in the sDAI pool. + uint256 internal withdrawalSharesSDAI; + + /// @dev The withdrawal shares in the rETH pool. + uint256 internal withdrawalSharesRETH; + + /// @dev The withdrawal shares in the stETH pool. + uint256 internal withdrawalSharesStETH; + + /// @dev The withdrawal shares in the WETH pool. + uint256 internal withdrawalSharesWETHVault; + + /// Set Up /// + + /// @notice Prepare each of the Hyperdrive markets with withdrawal shares. + function setUp() public override { + // Sets up the underlying test infrastructure. + super.setUp(); + + // Prepare the sDAI, rETH, stETH, and WETH vault Hyperdrive markets. + withdrawalSharesSDAI = _prepareHyperdrive( + SDAI_HYPERDRIVE, + 500e18, + true + ); + withdrawalSharesRETH = _prepareHyperdrive(RETH_HYPERDRIVE, 1e18, false); + withdrawalSharesStETH = _prepareHyperdrive( + STETH_HYPERDRIVE, + 1e18, + false + ); + withdrawalSharesWETHVault = _prepareHyperdrive( + WETH_VAULT_HYPERDRIVE, + 1e18, + true + ); + } + + /// @dev Prepares the Hyperdrive instance for the redeem withdrawal shares + /// tests. + /// @param _hyperdrive The Hyperdrive instance. + /// @param _contribution The contribution amount. + /// @param _asBase A flag indicating whether or not the contribution is in + /// base or vault shares. + /// @return The amount of LP shares procured. + function _prepareHyperdrive( + IHyperdrive _hyperdrive, + uint256 _contribution, + bool _asBase + ) internal returns (uint256) { + // If we're adding liquidity with the base token, approve Hyperdrive to + // spend base tokens. + if (_asBase) { + IERC20(_hyperdrive.baseToken()).approve( + address(_hyperdrive), + type(uint256).max + ); + } + // Otherwise, approve Hyperdrive to spend vault shares. + else { + IERC20(_hyperdrive.vaultSharesToken()).approve( + address(_hyperdrive), + type(uint256).max + ); + } + + // Add liquidity to the Hyperdrive pool. + uint256 lpShares = _hyperdrive.addLiquidity( + _contribution, + 0, + 0, + type(uint256).max, + IHyperdrive.Options({ + destination: alice, + asBase: _asBase, + extraData: new bytes(0) + }) + ); + + // Alice opens a large short. + uint256 shortAmount = _hyperdrive.calculateMaxShort() - + _hyperdrive.getPoolConfig().minimumTransactionAmount; + (uint256 maturityTime, ) = _hyperdrive.openShort( + shortAmount, + type(uint256).max, + 0, + IHyperdrive.Options({ + destination: alice, + asBase: _asBase, + extraData: new bytes(0) + }) + ); + + // Alice removes her liquidity. We ensure that she received some + // withdrawal shares. + (, uint256 withdrawalShares) = _hyperdrive.removeLiquidity( + lpShares, + 0, + IHyperdrive.Options({ + destination: alice, + asBase: _asBase, + extraData: new bytes(0) + }) + ); + assertGt(withdrawalShares, 0); + + // Advance to maturity and mint a chcekpoint. Ensure that the withdrawal + // shares are paid out now that the short is closed. + vm.warp(maturityTime); + _hyperdrive.checkpoint(hyperdrive.latestCheckpoint(), 0); + assertEq( + _hyperdrive.getPoolInfo().withdrawalSharesReadyToWithdraw, + withdrawalShares + ); + + // Set an approval on the zap to spend the LP shares. + _hyperdrive.setApproval( + AssetId._WITHDRAWAL_SHARE_ASSET_ID, + address(zap), + withdrawalShares + ); + + return withdrawalShares; + } + + /// Tests /// + + /// @notice Ensure that zapping out of `redeemWithdrawalShares` will fail when the + /// recipient of `redeemWithdrawalShares` isn't the zap contract. + function test_redeemWithdrawalSharesZap_failure_invalidRecipient() + external + { + vm.expectRevert(IUniV3Zap.InvalidRecipient.selector); + zap.redeemWithdrawalSharesZap( + SDAI_HYPERDRIVE, + withdrawalSharesSDAI, // lp shares + 0, // minimum output per share + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, USDC), + recipient: bob, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e6 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `redeemWithdrawalShares` will fail + /// when the recipient of `redeemWithdrawalShares` isn't the zap + /// contract. + function test_redeemWithdrawalSharesZap_failure_invalidSwap() external { + vm.expectRevert(IUniV3Zap.InvalidSwap.selector); + zap.redeemWithdrawalSharesZap( + SDAI_HYPERDRIVE, + withdrawalSharesSDAI, // lp shares + 0, // minimum output per share + IHyperdrive.Options({ + destination: address(zap), + asBase: true, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, DAI), + recipient: bob, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e18 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `redeemWithdrawalShares` with base will fail + /// when the input isn't the base token. + function test_redeemWithdrawalSharesZap_failure_invalidInputToken_asBase() + external + { + vm.expectRevert(IUniV3Zap.InvalidInputToken.selector); + zap.redeemWithdrawalSharesZap( + SDAI_HYPERDRIVE, + withdrawalSharesSDAI, // lp shares + 0, // minimum output per share + IHyperdrive.Options({ + destination: address(zap), + asBase: true, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: alice, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `redeemWithdrawalShares` with vault shares + /// will fail when the input isn't the vault shares token. + function test_redeemWithdrawalSharesZap_failure_invalidInputToken_asShares() + external + { + vm.expectRevert(IUniV3Zap.InvalidInputToken.selector); + zap.redeemWithdrawalSharesZap( + SDAI_HYPERDRIVE, + withdrawalSharesSDAI, // lp shares + 0, // minimum output per share + IHyperdrive.Options({ + destination: address(zap), + asBase: false, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, USDC), + recipient: alice, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e6 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `redeemWithdrawalShares` with base will + /// succeed when the base token is WETH. This ensures that WETH is + /// handled properly despite the zap unwrapping WETH into ETH in + /// some cases. + function test_redeemWithdrawalSharesZap_success_asBase_withWETH() external { + _verifyRedeemWithdrawalSharesZap( + WETH_VAULT_HYPERDRIVE, + withdrawalSharesWETHVault, // lp shares + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is smaller than the proceeds will be. + // This will automatically be adjusted up. + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + false, // should wrap + true // as base + ); + } + + /// @notice Ensure that zapping out of `redeemWithdrawalShares` with base will + /// succeed when the yield source is rebasing and when the input is + /// the base token. + /// @dev This also tests using ETH as the output assets and verifies that + /// we can properly convert to WETH. + function test_redeemWithdrawalSharesZap_success_rebasing_asBase() external { + _verifyRedeemWithdrawalSharesZap( + RETH_HYPERDRIVE, + withdrawalSharesRETH, // lp shares + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is smaller than the proceeds will be. + // This will automatically be adjusted up. + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + true, // should wrap + true // as base + ); + } + + /// @notice Ensure that zapping out of `redeemWithdrawalShares` with vault shares + /// will succeed when the yield source is rebasing and when the + /// input is the vault shares token. + function test_redeemWithdrawalSharesZap_success_rebasing_asShares() + external + { + _verifyRedeemWithdrawalSharesZap( + STETH_HYPERDRIVE, + withdrawalSharesStETH, // lp shares + ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + WSTETH, + LOWEST_FEE_TIER, + WETH, + LOW_FEE_TIER, + USDC + ), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is smaller than the proceeds will be. + // This will automatically be adjusted up. + amountIn: 0.32796e18, + amountOutMinimum: 950e6 + }), + true, // should wrap + false // as base + ); + } + + /// @notice Ensure that zapping out of `redeemWithdrawalShares` with base will + /// succeed when the yield source is non-rebasing and when the input is + /// the base token. + function test_redeemWithdrawalSharesZap_success_nonRebasing_asBase() + external + { + _verifyRedeemWithdrawalSharesZap( + SDAI_HYPERDRIVE, + withdrawalSharesSDAI, // lp shares + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOW_FEE_TIER, WETH), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is larger than the proceeds will be. + // This will automatically be adjusted down. + amountIn: 1_000e18, + amountOutMinimum: 0.38e18 + }), + false, // should wrap + true // as base + ); + } + + /// @notice Ensure that zapping out of `redeemWithdrawalShares` with vault shares + /// will succeed when the yield source is non-rebasing and when the + /// input is the vault shares token. + function test_redeemWithdrawalSharesZap_success_nonRebasing_asShares() + external + { + _verifyRedeemWithdrawalSharesZap( + SDAI_HYPERDRIVE, + withdrawalSharesSDAI, // lp shares + ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + SDAI, + LOW_FEE_TIER, + WETH, + LOW_FEE_TIER, + USDC + ), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is larger than the proceeds will be. + // This will automatically be adjusted down. + amountIn: 885e18, + amountOutMinimum: 900e6 + }), + false, // should wrap + false // as base + ); + } + + /// @dev Verify that `redeemWithdrawalSharesZap` performs correctly under the + /// specified conditions. + /// @param _hyperdrive The Hyperdrive instance. + /// @param _withdrawalShares The amount of withdrawal shares to remove. + /// @param _swapParams The Uniswap multi-hop swap parameters. + /// @param _shouldWrap A flag indicating whether or not the proceeds should + /// be wrapped before the swap. + /// @param _asBase A flag indicating whether or not the deposit should be in + /// base. + function _verifyRedeemWithdrawalSharesZap( + IHyperdrive _hyperdrive, + uint256 _withdrawalShares, + ISwapRouter.ExactInputParams memory _swapParams, + bool _shouldWrap, + bool _asBase + ) internal { + // Simulate closing the position without using the zap. + uint256 expectedHyperdriveVaultSharesBalanceAfter; + uint256 expectedWithdrawalSharesRedeemed; + { + uint256 snapshotId = vm.snapshot(); + (, expectedWithdrawalSharesRedeemed) = _hyperdrive + .redeemWithdrawalShares( + _withdrawalShares, + 0, // min output per shares + IHyperdrive.Options({ + destination: alice, + asBase: _asBase, + extraData: new bytes(0) + }) + ); + expectedHyperdriveVaultSharesBalanceAfter = IERC20( + _hyperdrive.vaultSharesToken() + ).balanceOf(address(_hyperdrive)); + vm.revertTo(snapshotId); + } + + // Get some data before executing the zap. + uint256 withdrawalSharesReadyToWithdrawBefore = _hyperdrive + .getPoolInfo() + .withdrawalSharesReadyToWithdraw; + uint256 aliceOutputBalanceBefore; + address tokenOut = _swapParams.path.tokenOut(); + if (tokenOut == WETH) { + aliceOutputBalanceBefore = alice.balance; + } else { + aliceOutputBalanceBefore = IERC20(tokenOut).balanceOf(alice); + } + uint256 aliceWithdrawalSharesBefore = _hyperdrive.balanceOf( + AssetId._WITHDRAWAL_SHARE_ASSET_ID, + alice + ); + + // Execute the zap. + IHyperdrive hyperdrive = _hyperdrive; // avoid stack-too-deep + uint256 withdrawalShares = _withdrawalShares; // avoid stack-too-deep + ISwapRouter.ExactInputParams memory swapParams = _swapParams; // avoid stack-too-deep + bool shouldWrap = _shouldWrap; // avoid stack-too-deep + bool asBase = _asBase; // avoid stack-too-deep + (uint256 proceeds, uint256 withdrawalSharesRedeemed) = zap + .redeemWithdrawalSharesZap( + hyperdrive, + withdrawalShares, // withdrawal shares + 0, // minimum output per share + IHyperdrive.Options({ + destination: address(zap), + asBase: asBase, + extraData: "" + }), + swapParams, + shouldWrap + ); + + // Ensure that Alice received the expected proceeds. + if (tokenOut == WETH) { + assertEq(alice.balance, aliceOutputBalanceBefore + proceeds); + } else { + assertEq( + IERC20(tokenOut).balanceOf(alice), + aliceOutputBalanceBefore + proceeds + ); + } + + // Ensure that the withdrawal shares redeemed were equal to the expected + // withdrawal shares redeemed. + assertEq(withdrawalSharesRedeemed, expectedWithdrawalSharesRedeemed); + + // Ensure that the vault shares balance is what we would predict from + // the simulation. + assertEq( + IERC20(hyperdrive.vaultSharesToken()).balanceOf( + address(hyperdrive) + ), + expectedHyperdriveVaultSharesBalanceAfter + ); + + // Ensure that the withdrawal shares ready to withdraw and Alice's + // balance of withdrawal shares decreased by the withdrawal shares + // redeemed. + assertEq( + hyperdrive.getPoolInfo().withdrawalSharesReadyToWithdraw, + withdrawalSharesReadyToWithdrawBefore - withdrawalSharesRedeemed + ); + assertEq( + hyperdrive.balanceOf(AssetId._WITHDRAWAL_SHARE_ASSET_ID, alice), + aliceWithdrawalSharesBefore - withdrawalSharesRedeemed + ); + } +} diff --git a/test/zaps/uni-v3/RemoveLiquidityZap.sol b/test/zaps/uni-v3/RemoveLiquidityZap.sol new file mode 100644 index 000000000..e1dcd1899 --- /dev/null +++ b/test/zaps/uni-v3/RemoveLiquidityZap.sol @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { IERC20 } from "../../../contracts/src/interfaces/IERC20.sol"; +import { IHyperdrive } from "../../../contracts/src/interfaces/IHyperdrive.sol"; +import { ISwapRouter } from "../../../contracts/src/interfaces/ISwapRouter.sol"; +import { IUniV3Zap } from "../../../contracts/src/interfaces/IUniV3Zap.sol"; +import { AssetId } from "../../../contracts/src/libraries/AssetId.sol"; +import { FixedPointMath } from "../../../contracts/src/libraries/FixedPointMath.sol"; +import { UniV3Path } from "../../../contracts/src/libraries/UniV3Path.sol"; +import { HyperdriveUtils } from "../../utils/HyperdriveUtils.sol"; +import { UniV3ZapTest } from "./UniV3Zap.t.sol"; + +contract RemoveLiquidityZapTest is UniV3ZapTest { + using FixedPointMath for uint256; + using HyperdriveUtils for IHyperdrive; + using UniV3Path for bytes; + + /// @dev The LP shares in the sDAI pool. + uint256 internal lpSharesSDAI; + + /// @dev The LP shares in the rETH pool. + uint256 internal lpSharesRETH; + + /// @dev The LP shares in the stETH pool. + uint256 internal lpSharesStETH; + + /// @dev The LP shares in the WETH pool. + uint256 internal lpSharesWETHVault; + + /// Set Up /// + + /// @notice Add liquidity in each of the markets so there are LP shares to + /// remove. + function setUp() public override { + // Sets up the underlying test infrastructure. + super.setUp(); + + // Prepare the sDAI, rETH, stETH, and WETH vault Hyperdrive markets. + lpSharesSDAI = _prepareHyperdrive(SDAI_HYPERDRIVE, 500e18, true); + lpSharesRETH = _prepareHyperdrive(RETH_HYPERDRIVE, 1e18, false); + lpSharesStETH = _prepareHyperdrive(STETH_HYPERDRIVE, 1e18, false); + lpSharesWETHVault = _prepareHyperdrive( + WETH_VAULT_HYPERDRIVE, + 1e18, + true + ); + } + + /// @dev Prepares the Hyperdrive instance for the remove liquidity tests. + /// @param _hyperdrive The Hyperdrive instance. + /// @param _contribution The contribution amount. + /// @param _asBase A flag indicating whether or not the contribution is in + /// base or vault shares. + /// @return The amount of LP shares procured. + function _prepareHyperdrive( + IHyperdrive _hyperdrive, + uint256 _contribution, + bool _asBase + ) internal returns (uint256) { + // If we're adding liquidity with the base token, approve Hyperdrive to + // spend base tokens. + if (_asBase) { + IERC20(_hyperdrive.baseToken()).approve( + address(_hyperdrive), + type(uint256).max + ); + } + // Otherwise, approve Hyperdrive to spend vault shares. + else { + IERC20(_hyperdrive.vaultSharesToken()).approve( + address(_hyperdrive), + type(uint256).max + ); + } + + // Add liquidity to the Hyperdrive pool. + uint256 lpShares = _hyperdrive.addLiquidity( + _contribution, + 0, + 0, + type(uint256).max, + IHyperdrive.Options({ + destination: alice, + asBase: _asBase, + extraData: new bytes(0) + }) + ); + + // Set an approval on the zap to spend the LP shares. + _hyperdrive.setApproval(AssetId._LP_ASSET_ID, address(zap), lpShares); + + return lpShares; + } + + /// Tests /// + + /// @notice Ensure that zapping out of `removeLiquidity` will fail when the + /// recipient of `removeLiquidity` isn't the zap contract. + function test_removeLiquidityZap_failure_invalidRecipient() external { + vm.expectRevert(IUniV3Zap.InvalidRecipient.selector); + zap.removeLiquidityZap( + SDAI_HYPERDRIVE, + lpSharesSDAI, // lp shares + 0, // minimum output per share + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, USDC), + recipient: bob, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e6 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `removeLiquidity` will fail when the + /// recipient of `removeLiquidity` isn't the zap contract. + function test_removeLiquidityZap_failure_invalidSwap() external { + vm.expectRevert(IUniV3Zap.InvalidSwap.selector); + zap.removeLiquidityZap( + SDAI_HYPERDRIVE, + lpSharesSDAI, // lp shares + 0, // minimum output per share + IHyperdrive.Options({ + destination: address(zap), + asBase: true, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, DAI), + recipient: bob, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e18 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `removeLiquidity` with base will fail + /// when the input isn't the base token. + function test_removeLiquidityZap_failure_invalidInputToken_asBase() + external + { + vm.expectRevert(IUniV3Zap.InvalidInputToken.selector); + zap.removeLiquidityZap( + SDAI_HYPERDRIVE, + lpSharesSDAI, // lp shares + 0, // minimum output per share + IHyperdrive.Options({ + destination: address(zap), + asBase: true, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(USDC, LOWEST_FEE_TIER, DAI), + recipient: alice, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e6, + amountOutMinimum: 999e18 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `removeLiquidity` with vault shares + /// will fail when the input isn't the vault shares token. + function test_removeLiquidityZap_failure_invalidInputToken_asShares() + external + { + vm.expectRevert(IUniV3Zap.InvalidInputToken.selector); + zap.removeLiquidityZap( + SDAI_HYPERDRIVE, + lpSharesSDAI, // lp shares + 0, // minimum output per share + IHyperdrive.Options({ + destination: address(zap), + asBase: false, + extraData: "" + }), + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOWEST_FEE_TIER, USDC), + recipient: alice, + deadline: block.timestamp + 1 minutes, + amountIn: 1_000e18, + amountOutMinimum: 999e6 + }), + false // should wrap + ); + } + + /// @notice Ensure that zapping out of `removeLiquidity` with base will + /// succeed when the base token is WETH. This ensures that WETH is + /// handled properly despite the zap unwrapping WETH into ETH in + /// some cases. + function test_removeLiquidityZap_success_asBase_withWETH() external { + _verifyRemoveLiquidityZap( + WETH_VAULT_HYPERDRIVE, + lpSharesWETHVault, // lp shares + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is smaller than the proceeds will be. + // This will automatically be adjusted up. + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + false, // should wrap + true // as base + ); + } + + /// @notice Ensure that zapping out of `removeLiquidity` with base will + /// succeed when the yield source is rebasing and when the input is + /// the base token. + /// @dev This also tests using ETH as the output assets and verifies that + /// we can properly convert to WETH. + function test_removeLiquidityZap_success_rebasing_asBase() external { + _verifyRemoveLiquidityZap( + RETH_HYPERDRIVE, + lpSharesRETH, // lp shares + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(WETH, LOW_FEE_TIER, DAI), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is smaller than the proceeds will be. + // This will automatically be adjusted up. + amountIn: 0.3882e18, + amountOutMinimum: 999e18 + }), + true, // should wrap + true // as base + ); + } + + /// @notice Ensure that zapping out of `removeLiquidity` with vault shares + /// will succeed when the yield source is rebasing and when the + /// input is the vault shares token. + function test_removeLiquidityZap_success_rebasing_asShares() external { + _verifyRemoveLiquidityZap( + STETH_HYPERDRIVE, + lpSharesStETH, // lp shares + ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + WSTETH, + LOWEST_FEE_TIER, + WETH, + LOW_FEE_TIER, + USDC + ), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is smaller than the proceeds will be. + // This will automatically be adjusted up. + amountIn: 0.32796e18, + amountOutMinimum: 950e6 + }), + true, // should wrap + false // as base + ); + } + + /// @notice Ensure that zapping out of `removeLiquidity` with base will + /// succeed when the yield source is non-rebasing and when the input is + /// the base token. + function test_removeLiquidityZap_success_nonRebasing_asBase() external { + _verifyRemoveLiquidityZap( + SDAI_HYPERDRIVE, + lpSharesSDAI, // lp shares + ISwapRouter.ExactInputParams({ + path: abi.encodePacked(DAI, LOW_FEE_TIER, WETH), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is larger than the proceeds will be. + // This will automatically be adjusted down. + amountIn: 1_000e18, + amountOutMinimum: 0.38e18 + }), + false, // should wrap + true // as base + ); + } + + /// @notice Ensure that zapping out of `removeLiquidity` with vault shares + /// will succeed when the yield source is non-rebasing and when the + /// input is the vault shares token. + function test_removeLiquidityZap_success_nonRebasing_asShares() external { + _verifyRemoveLiquidityZap( + SDAI_HYPERDRIVE, + lpSharesSDAI, // lp shares + ISwapRouter.ExactInputParams({ + path: abi.encodePacked( + SDAI, + LOW_FEE_TIER, + WETH, + LOW_FEE_TIER, + USDC + ), + recipient: alice, + deadline: block.timestamp + 1 minutes, + // NOTE: The amount in is larger than the proceeds will be. + // This will automatically be adjusted down. + amountIn: 885e18, + amountOutMinimum: 900e6 + }), + false, // should wrap + false // as base + ); + } + + /// @dev Verify that `removeLiquidityZap` performs correctly under the + /// specified conditions. + /// @param _hyperdrive The Hyperdrive instance. + /// @param _lpShares The amount of LP shares to remove. + /// @param _swapParams The Uniswap multi-hop swap parameters. + /// @param _shouldWrap A flag indicating whether or not the proceeds should + /// be wrapped before the swap. + /// @param _asBase A flag indicating whether or not the deposit should be in + /// base. + function _verifyRemoveLiquidityZap( + IHyperdrive _hyperdrive, + uint256 _lpShares, + ISwapRouter.ExactInputParams memory _swapParams, + bool _shouldWrap, + bool _asBase + ) internal { + // Simulate closing the position without using the zap. + uint256 expectedHyperdriveVaultSharesBalanceAfter; + uint256 expectedWithdrawalShares; + { + uint256 snapshotId = vm.snapshot(); + (, expectedWithdrawalShares) = _hyperdrive.removeLiquidity( + _lpShares, + 0, // min output per shares + IHyperdrive.Options({ + destination: alice, + asBase: _asBase, + extraData: new bytes(0) + }) + ); + expectedHyperdriveVaultSharesBalanceAfter = IERC20( + _hyperdrive.vaultSharesToken() + ).balanceOf(address(_hyperdrive)); + vm.revertTo(snapshotId); + } + + // Get some data before executing the zap. + uint256 lpTotalSupplyBefore = _hyperdrive.getPoolInfo().lpTotalSupply; + uint256 aliceOutputBalanceBefore; + address tokenOut = _swapParams.path.tokenOut(); + if (tokenOut == WETH) { + aliceOutputBalanceBefore = alice.balance; + } else { + aliceOutputBalanceBefore = IERC20(tokenOut).balanceOf(alice); + } + uint256 aliceLPSharesBefore = _hyperdrive.balanceOf( + AssetId._LP_ASSET_ID, + alice + ); + uint256 aliceWithdrawalSharesBefore = _hyperdrive.balanceOf( + AssetId._WITHDRAWAL_SHARE_ASSET_ID, + alice + ); + + // Execute the zap. + IHyperdrive hyperdrive = _hyperdrive; // avoid stack-too-deep + uint256 lpShares = _lpShares; // avoid stack-too-deep + ISwapRouter.ExactInputParams memory swapParams = _swapParams; // avoid stack-too-deep + bool shouldWrap = _shouldWrap; // avoid stack-too-deep + bool asBase = _asBase; // avoid stack-too-deep + (uint256 proceeds, uint256 withdrawalShares) = zap.removeLiquidityZap( + hyperdrive, + lpShares, // lp shares + 0, // minimum output per share + IHyperdrive.Options({ + destination: address(zap), + asBase: asBase, + extraData: "" + }), + swapParams, + shouldWrap + ); + + // Ensure that Alice received the expected proceeds. + if (tokenOut == WETH) { + assertEq(alice.balance, aliceOutputBalanceBefore + proceeds); + } else { + assertEq( + IERC20(tokenOut).balanceOf(alice), + aliceOutputBalanceBefore + proceeds + ); + } + + // Ensure that the withdrawal shares were equal to the expected + // withdrawal shares. + assertEq(withdrawalShares, expectedWithdrawalShares); + + // Ensure that the vault shares balance is what we would predict from + // the simulation. + assertEq( + IERC20(hyperdrive.vaultSharesToken()).balanceOf( + address(hyperdrive) + ), + expectedHyperdriveVaultSharesBalanceAfter + ); + + // Ensure that the LP total supply decreased by the LP shares minus the + // number of withdrawal shares and that Alice's balance of LP shares + // decreased by the LP shares. + assertEq( + hyperdrive.getPoolInfo().lpTotalSupply, + lpTotalSupplyBefore - (lpShares - withdrawalShares) + ); + assertEq( + hyperdrive.balanceOf(AssetId._LP_ASSET_ID, alice), + aliceLPSharesBefore - lpShares + ); + assertEq( + hyperdrive.balanceOf(AssetId._WITHDRAWAL_SHARE_ASSET_ID, alice), + aliceWithdrawalSharesBefore + withdrawalShares + ); + } +} diff --git a/test/zaps/uni-v3/UniV3Zap.t.sol b/test/zaps/uni-v3/UniV3Zap.t.sol new file mode 100644 index 000000000..76d699c75 --- /dev/null +++ b/test/zaps/uni-v3/UniV3Zap.t.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { ERC4626Hyperdrive } from "../../../contracts/src/instances/erc4626/ERC4626Hyperdrive.sol"; +import { ERC4626Target0 } from "../../../contracts/src/instances/erc4626/ERC4626Target0.sol"; +import { ERC4626Target1 } from "../../../contracts/src/instances/erc4626/ERC4626Target1.sol"; +import { ERC4626Target2 } from "../../../contracts/src/instances/erc4626/ERC4626Target2.sol"; +import { ERC4626Target3 } from "../../../contracts/src/instances/erc4626/ERC4626Target3.sol"; +import { ERC4626Target4 } from "../../../contracts/src/instances/erc4626/ERC4626Target4.sol"; +import { IERC20 } from "../../../contracts/src/interfaces/IERC20.sol"; +import { IERC4626 } from "../../../contracts/src/interfaces/IERC4626.sol"; +import { ILido } from "../../../contracts/src/interfaces/ILido.sol"; +import { IHyperdrive } from "../../../contracts/src/interfaces/IHyperdrive.sol"; +import { ISwapRouter } from "../../../contracts/src/interfaces/ISwapRouter.sol"; +import { IUniV3Zap } from "../../../contracts/src/interfaces/IUniV3Zap.sol"; +import { IWETH } from "../../../contracts/src/interfaces/IWETH.sol"; +import { UniV3Path } from "../../../contracts/src/libraries/UniV3Path.sol"; +import { UniV3Zap } from "../../../contracts/src/zaps/UniV3Zap.sol"; +import { ERC20Mintable } from "../../../contracts/test/ERC20Mintable.sol"; +import { MockERC4626 } from "../../../contracts/test/MockERC4626.sol"; +import { HyperdriveTest } from "../../utils/HyperdriveTest.sol"; + +contract UniV3ZapTest is HyperdriveTest { + /// @dev The name of the zap contract. + string internal constant NAME = "DELV Uniswap v3 Zap"; + + /// @dev We can assume that almost all Hyperdrive deployments have the + /// `convertToBase` and `convertToShares` functions, but there is + /// one legacy sDAI pool that was deployed before these functions + /// were written. We explicitly special case conversions for this + /// pool. + address internal constant LEGACY_SDAI_HYPERDRIVE = + address(0x324395D5d835F84a02A75Aa26814f6fD22F25698); + + /// @dev We can assume that almost all Hyperdrive deployments have the + /// `convertToBase` and `convertToShares` functions, but there is + /// a legacy stETH pool that was deployed before these functions were + /// written. We explicitly special case conversions for this pool. + address internal constant LEGACY_STETH_HYPERDRIVE = + address(0xd7e470043241C10970953Bd8374ee6238e77D735); + + /// @dev Uniswap's lowest fee tier. + uint24 internal constant LOWEST_FEE_TIER = 100; + + /// @dev Uniswap's low fee tier. + uint24 internal constant LOW_FEE_TIER = 500; + + /// @dev Uniswap's medium fee tier. + uint24 internal constant MEDIUM_FEE_TIER = 3_000; + + /// @dev Uniswap's high fee tier. + uint24 internal constant HIGH_FEE_TIER = 10_000; + + /// @dev The USDC token address. + address internal constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + + /// @dev The DAI token address. + address internal constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + + /// @dev The sDAI token address. + address internal constant SDAI = 0x83F20F44975D03b1b09e64809B757c47f942BEeA; + + /// @dev The Wrapped Ether token address. + address internal constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + + /// @dev The rETH token address. + address internal constant RETH = 0xae78736Cd615f374D3085123A210448E74Fc6393; + + /// @dev The stETH token address. + address internal constant STETH = + 0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84; + + /// @dev The wstETH token address. + address internal constant WSTETH = + 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; + + /// @dev The USDC whale address + address internal constant USDC_WHALE = + 0x37305B1cD40574E4C5Ce33f8e8306Be057fD7341; + + /// @dev The DAI whale address + address internal constant DAI_WHALE = + 0x40ec5B33f54e0E8A33A975908C5BA1c14e5BbbDf; + + /// @dev The sDAI whale address + address internal constant SDAI_WHALE = + 0x4C612E3B15b96Ff9A6faED838F8d07d479a8dD4c; + + /// @dev The WETH whale address + address internal constant WETH_WHALE = + 0xF04a5cC80B1E94C69B48f5ee68a08CD2F09A7c3E; + + /// @dev The rETH whale address + address internal constant RETH_WHALE = + 0xCc9EE9483f662091a1de4795249E24aC0aC2630f; + + /// @dev The stETH whale address + address internal constant STETH_WHALE = + 0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0; + + /// @dev The Uniswap swap router. + ISwapRouter internal constant SWAP_ROUTER = + ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564); + + /// @dev The Hyperdrive mainnet sDAI pool. + IHyperdrive internal constant SDAI_HYPERDRIVE = + IHyperdrive(0x324395D5d835F84a02A75Aa26814f6fD22F25698); + + /// @dev The Hyperdrive mainnet stETH pool. + IHyperdrive internal constant STETH_HYPERDRIVE = + IHyperdrive(0xd7e470043241C10970953Bd8374ee6238e77D735); + + /// @dev The Hyperdrive mainnet rETH pool. + IHyperdrive internal constant RETH_HYPERDRIVE = + IHyperdrive(0xca5dB9Bb25D09A9bF3b22360Be3763b5f2d13589); + + /// @dev A Hyperdrive instance that integrates with a MockERC4626 vault that + /// uses WETH as the base asset. This is useful for testing situations + /// where Hyperdrive's base token is WETH. + IHyperdrive internal WETH_VAULT_HYPERDRIVE; + + /// @dev The Uniswap v3 zap contract. + IUniV3Zap internal zap; + + /// @dev Set up balances and Hyperdrive instances to test the zap. This is + /// a mainnet fork test that uses real Hyperdrive instances. + function setUp() public virtual override __mainnet_fork(20_830_093) { + // Run the higher-level setup logic. + super.setUp(); + + // Instantiate the zap contract. + zap = IUniV3Zap(new UniV3Zap(NAME, SWAP_ROUTER, IWETH(WETH))); + + // Set up Alice as the sender. + vm.stopPrank(); + vm.startPrank(alice); + + // Fund Alice from some whale accounts. + address[] memory accounts = new address[](1); + accounts[0] = alice; + fundAccounts(address(zap), IERC20(USDC), USDC_WHALE, accounts); + fundAccounts(address(zap), IERC20(DAI), DAI_WHALE, accounts); + fundAccounts(address(zap), IERC20(SDAI), SDAI_WHALE, accounts); + fundAccounts(address(zap), IERC20(WETH), WETH_WHALE, accounts); + fundAccounts(address(zap), IERC20(RETH), RETH_WHALE, accounts); + fundAccounts(address(zap), IERC20(STETH), STETH_WHALE, accounts); + + // Deploy and initialize a Hyperdrive instance that integrates with a + // WETH yield souce. + MockERC4626 wethVault = new MockERC4626( + ERC20Mintable(address(WETH)), + "WETH Vault", + "WETH_VAULT", + 0, + address(0), + false, + type(uint256).max + ); + IHyperdrive.PoolConfig memory config = testConfig( + 0.05e18, + POSITION_DURATION + ); + config.baseToken = IERC20(WETH); + config.vaultSharesToken = IERC20(address(wethVault)); + config.minimumShareReserves = 1e15; + WETH_VAULT_HYPERDRIVE = IHyperdrive( + address( + new ERC4626Hyperdrive( + "WETH Vault Hyperdrive", + config, + adminController, + address(new ERC4626Target0(config, adminController)), + address(new ERC4626Target1(config, adminController)), + address(new ERC4626Target2(config, adminController)), + address(new ERC4626Target3(config, adminController)), + address(new ERC4626Target4(config, adminController)) + ) + ) + ); + IERC20(WETH).approve(address(WETH_VAULT_HYPERDRIVE), 1e18); + WETH_VAULT_HYPERDRIVE.initialize( + 1e18, + 0.05e18, + IHyperdrive.Options({ + destination: alice, + asBase: true, + extraData: new bytes(0) + }) + ); + } + + /// Helpers /// + + /// @dev Converts a quantity to base. This works for all Hyperdrive pools. + function _convertToBase( + IHyperdrive _hyperdrive, + uint256 _sharesAmount + ) internal view returns (uint256) { + // If this is a mainnet deployment and the address is the legacy stETH + // pool, we have to convert the proceeds to shares manually using Lido's + // `getPooledEthByShares` function. + if ( + block.chainid == 1 && + address(_hyperdrive) == LEGACY_STETH_HYPERDRIVE + ) { + return + ILido(_hyperdrive.vaultSharesToken()).getPooledEthByShares( + _sharesAmount + ); + } + // If this is a mainnet deployment and the address is the legacy stETH + // pool, we have to convert the proceeds to shares manually using Lido's + // `getSharesByPooledEth` function. + else if ( + block.chainid == 1 && address(_hyperdrive) == LEGACY_SDAI_HYPERDRIVE + ) { + return + IERC4626(_hyperdrive.vaultSharesToken()).convertToAssets( + _sharesAmount + ); + } + // Otherwise, we can use the built-in `convertToBase` function. + else { + return _hyperdrive.convertToBase(_sharesAmount); + } + } + + /// @dev Converts a quantity to shares. This works for all Hyperdrive pools. + function _convertToShares( + IHyperdrive _hyperdrive, + uint256 _baseAmount + ) internal view returns (uint256) { + // If this is a mainnet deployment and the address is the legacy stETH + // pool, we have to convert the proceeds to shares manually using Lido's + // `getSharesByPooledEth` function. + if ( + block.chainid == 1 && + address(_hyperdrive) == LEGACY_STETH_HYPERDRIVE + ) { + return + ILido(_hyperdrive.vaultSharesToken()).getSharesByPooledEth( + _baseAmount + ); + } + // If this is a mainnet deployment and the address is the legacy stETH + // pool, we have to convert the proceeds to shares manually using Lido's + // `getSharesByPooledEth` function. + else if ( + block.chainid == 1 && address(_hyperdrive) == LEGACY_SDAI_HYPERDRIVE + ) { + return + IERC4626(_hyperdrive.vaultSharesToken()).convertToShares( + _baseAmount + ); + } + // Otherwise, we can use the built-in `convertToShares` function. + else { + return _hyperdrive.convertToShares(_baseAmount); + } + } +}