From d9e0a074fed28e6b63bb7d0424f163cdf44b78ef Mon Sep 17 00:00:00 2001 From: Alex Towle Date: Mon, 25 Nov 2024 16:17:17 -1000 Subject: [PATCH] Hyperdrive Matching Engine (#1201) * Progress checkpoint * Checkpoint #2 * Wrote a working example of the OTC trade on cbBTC/USDC using flash loans * Committed temporary progress * Added the gap calculation * Started building the real matching engine * Implemented a MCP matching engine * Updated the test to utilize the new matching engine * Wired up the new matching engine with the integration test * Reverted typo in `contracts/src/internal/HyperdriveShort.sol` * Added a bunch of unit tests for the matching engine * Wrote a full set of unit tests for the matching engine * Fixed linter errors * Addressed some review feedback from @mcclurejt * Removed outdated comment * Addressed remaining feedback from @mcclurejt --- .../interfaces/IHyperdriveMatchingEngine.sol | 172 ++ contracts/src/libraries/Constants.sol | 3 + .../src/matching/HyperdriveMatchingEngine.sol | 471 +++++ deployments.json | 2 +- .../instances/erc4626/ERC4626Hyperdrive.t.sol | 5 +- .../factory/HyperdriveFactory.t.sol | 14 +- .../matching/DirectMatchTest.t.sol | 500 ++++++ .../HyperdriveMatchingEngineTest.t.sol | 1537 +++++++++++++++++ test/utils/BaseTest.sol | 50 +- 9 files changed, 2725 insertions(+), 29 deletions(-) create mode 100644 contracts/src/interfaces/IHyperdriveMatchingEngine.sol create mode 100644 contracts/src/matching/HyperdriveMatchingEngine.sol create mode 100644 test/integrations/matching/DirectMatchTest.t.sol create mode 100644 test/units/matching/HyperdriveMatchingEngineTest.t.sol diff --git a/contracts/src/interfaces/IHyperdriveMatchingEngine.sol b/contracts/src/interfaces/IHyperdriveMatchingEngine.sol new file mode 100644 index 000000000..21dc2f55a --- /dev/null +++ b/contracts/src/interfaces/IHyperdriveMatchingEngine.sol @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { IMorpho } from "morpho-blue/src/interfaces/IMorpho.sol"; +import { IMorphoFlashLoanCallback } from "morpho-blue/src/interfaces/IMorphoCallbacks.sol"; +import { IHyperdrive } from "../interfaces/IHyperdrive.sol"; + +/// @title IHyperdriveMatchingEngine +/// @notice Interface for the Hyperdrive matching engine. +interface IHyperdriveMatchingEngine is IMorphoFlashLoanCallback { + /// @notice Thrown when an order is already cancelled. + error AlreadyCancelled(); + + /// @notice Thrown when an order is already expired. + error AlreadyExpired(); + + /// @notice Thrown when the destination for the add or remove liquidity + /// options isn't configured to this contract. + error InvalidDestination(); + + /// @notice Thrown when orders that don't cross are matched. + error InvalidMatch(); + + /// @notice Thrown when the order type doesn't match the expected type. + error InvalidOrderType(); + + /// @notice Thrown when an address that didn't create an order tries to + /// cancel it. + error InvalidSender(); + + /// @notice Thrown when `asBase = false` is used. This implementation is + /// opinionated to keep the implementation simple. + error InvalidSettlementAsset(); + + /// @notice Thrown when the signature for an order intent doesn't recover to + /// the expected signer address. + error InvalidSignature(); + + /// @notice Thrown when the long and short orders don't refer to the same + /// Hyperdrive instance. + error MismatchedHyperdrive(); + + /// @notice Emitted when orders are cancelled. + event OrdersCancelled(address indexed trader, bytes32[] orderHashes); + + /// @notice Emitted when orders are matched. + event OrdersMatched( + IHyperdrive indexed hyperdrive, + bytes32 indexed longOrderHash, + bytes32 indexed shortOrderHash, + address long, + address short + ); + + /// @notice The type of an order intent. + enum OrderType { + OpenLong, + OpenShort + } + + /// @notice The order intent struct that encodes a trader's desire to trade. + struct OrderIntent { + /// @dev The trader address that will be charged when orders are matched. + address trader; + /// @dev The Hyperdrive address where the trade will be executed. + IHyperdrive hyperdrive; + /// @dev The amount to be used in the trade. In the case of `OpenLong`, + /// this is the amount of base to deposit, and in the case of + /// `OpenShort`, this is the amount of bonds to short. + uint256 amount; + /// @dev The slippage guard to be used in the trade. In the case of + /// `OpenLong`, this is the minimum output in bonds, and in the + /// case of `OpenShort`, this is the maximum deposit in base. + uint256 slippageGuard; + /// @dev The minimum vault share price. This protects traders against + /// the sudden accrual of negative interest in a yield source. + uint256 minVaultSharePrice; + /// @dev The options that configure how the trade will be settled. + /// `asBase` is required to be true, the `destination` is the + /// address that receives the long or short position that is + /// purchased, and the extra data is configured for the yield + /// source that is being used. Since the extra data isn't included + /// in the order's hash, it can be updated between the order being + /// signed and executed. This is helpful for applications like DFB + /// that rely on the extra data field to record metadata in events. + IHyperdrive.Options options; + /// @dev The type of the order. This is either `OpenLong` or `OpenShort`. + OrderType orderType; + /// @dev The signature that demonstrates the source's intent to complete + /// the trade. + bytes signature; + /// @dev The order's expiry timestamp. At or after this timestamp, the + /// order can't be filled. + uint256 expiry; + /// @dev The order's salt. This introduces some randomness which ensures + /// that duplicate orders don't collide. + bytes32 salt; + } + + /// @notice Get the name of this matching engine. + /// @return The name string. + function name() external view returns (string memory); + + /// @notice Get the kind of this matching engine. + /// @return The kind string. + function kind() external view returns (string memory); + + /// @notice Get the version of this matching engine. + /// @return The version string. + function version() external view returns (string memory); + + /// @notice Get the Morpho flash loan provider. + /// @return The Morpho contract address. + function morpho() external view returns (IMorpho); + + /// @notice Returns whether or not an order has been cancelled. + /// @param orderHash The hash of the order. + /// @return True if the order was cancelled and false otherwise. + function isCancelled(bytes32 orderHash) external view returns (bool); + + /// @notice Get the EIP712 typehash for the + /// `IHyperdriveMatchingEngine.OrderIntent` struct. + /// @return The typehash. + function ORDER_INTENT_TYPEHASH() external view returns (bytes32); + + /// @notice Get the EIP712 typehash for the `IHyperdrive.Options` struct. + /// @return The typehash. + function OPTIONS_TYPEHASH() external view returns (bytes32); + + /// @notice Allows a trader to cancel a list of their orders. + /// @param _orders The orders to cancel. + function cancelOrders(OrderIntent[] calldata _orders) external; + + /// @notice Directly matches a long and a short order using a flash loan for + /// liquidity. + /// @param _longOrder The order intent to open a long. + /// @param _shortOrder The order intent to open a short. + /// @param _lpAmount The amount to flash borrow and LP. + /// @param _addLiquidityOptions The options used when adding liquidity. + /// @param _removeLiquidityOptions The options used when removing liquidity. + /// @param _feeRecipient The address that receives the LP fees from matching + /// the trades. + /// @param _isLongFirst A flag indicating whether the long or short should be + /// opened first. + function matchOrders( + OrderIntent calldata _longOrder, + OrderIntent calldata _shortOrder, + uint256 _lpAmount, + IHyperdrive.Options calldata _addLiquidityOptions, + IHyperdrive.Options calldata _removeLiquidityOptions, + address _feeRecipient, + bool _isLongFirst + ) external; + + /// @notice Hashes an order intent according to EIP-712. + /// @param _order The order intent to hash. + /// @return The hash of the order intent. + function hashOrderIntent( + OrderIntent calldata _order + ) external view returns (bytes32); + + /// @notice Verifies a signature for a known signer. + /// @param _hash The EIP-712 hash of the order. + /// @param _signature The signature bytes. + /// @param _signer The expected signer. + /// @return True if signature is valid, false otherwise. + function verifySignature( + bytes32 _hash, + bytes calldata _signature, + address _signer + ) external view returns (bool); +} diff --git a/contracts/src/libraries/Constants.sol b/contracts/src/libraries/Constants.sol index b8ddb697c..d57b45d1e 100644 --- a/contracts/src/libraries/Constants.sol +++ b/contracts/src/libraries/Constants.sol @@ -25,6 +25,9 @@ string constant HYPERDRIVE_CHECKPOINT_SUBREWARDER_KIND = "HyperdriveCheckpointSu /// @dev The kind of the Hyperdrive factory. string constant HYPERDRIVE_FACTORY_KIND = "HyperdriveFactory"; +/// @dev The kind of the Hyperdrive matching engine. +string constant HYPERDRIVE_MATCHING_ENGINE_KIND = "HyperdriveMatchingEngine"; + /// @dev The kind of the Hyperdrive registry. string constant HYPERDRIVE_REGISTRY_KIND = "HyperdriveRegistry"; diff --git a/contracts/src/matching/HyperdriveMatchingEngine.sol b/contracts/src/matching/HyperdriveMatchingEngine.sol new file mode 100644 index 000000000..4aaf0e502 --- /dev/null +++ b/contracts/src/matching/HyperdriveMatchingEngine.sol @@ -0,0 +1,471 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { IMorpho } from "morpho-blue/src/interfaces/IMorpho.sol"; +import { IERC1271 } from "openzeppelin/interfaces/IERC1271.sol"; +import { ERC20 } from "openzeppelin/token/ERC20/ERC20.sol"; +import { SafeERC20 } from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; +import { ECDSA } from "openzeppelin/utils/cryptography/ECDSA.sol"; +import { EIP712 } from "openzeppelin/utils/cryptography/EIP712.sol"; +import { ReentrancyGuard } from "openzeppelin/utils/ReentrancyGuard.sol"; +import { IHyperdrive } from "../interfaces/IHyperdrive.sol"; +import { IHyperdriveMatchingEngine } from "../interfaces/IHyperdriveMatchingEngine.sol"; +import { AssetId } from "../libraries/AssetId.sol"; +import { HYPERDRIVE_MATCHING_ENGINE_KIND, VERSION } from "../libraries/Constants.sol"; +import { FixedPointMath } from "../libraries/FixedPointMath.sol"; + +/// @author DELV +/// @title HyperdriveMatchingEngine +/// @notice A matching engine that processes order intents and settles trades on +/// the Hyperdrive AMM. +/// @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 HyperdriveMatchingEngine is + IHyperdriveMatchingEngine, + ReentrancyGuard, + EIP712 +{ + using FixedPointMath for uint256; + using SafeERC20 for ERC20; + + /// @notice The EIP712 typehash of the `IHyperdriveMatchingEngine.OrderIntent` + /// struct. + bytes32 public constant ORDER_INTENT_TYPEHASH = + keccak256( + "OrderIntent(address trader,address hyperdrive,uint256 amount,uint256 slippageGuard,uint256 minVaultSharePrice,Options options,uint8 orderType,uint256 expiry,bytes32 salt)" + ); + + /// @notice The EIP712 typehash of the `IHyperdrive.Options` struct. + /// @dev We exclude extra data from the options hashing since it has no + /// effect on execution. + bytes32 public constant OPTIONS_TYPEHASH = + keccak256("Options(address destination,bool asBase)"); + + /// @notice The name of this matching engine. + string public name; + + /// @notice The kind of this matching engine. + string public constant kind = HYPERDRIVE_MATCHING_ENGINE_KIND; + + /// @notice The version of this matching engine. + string public constant version = VERSION; + + /// @notice The morpho market that this matching engine connects to as a + /// flash loan provider. + IMorpho public immutable morpho; + + /// @notice A mapping from order hashes to their cancellation status. + mapping(bytes32 => bool) public isCancelled; + + /// @notice Initializes the matching engine. + /// @param _name The name of this matching engine. + /// @param _morpho The Morpho pool. + constructor(string memory _name, IMorpho _morpho) EIP712(_name, VERSION) { + name = _name; + morpho = _morpho; + } + + /// @notice Allows a trader to cancel a list of their orders. + /// @param _orders The orders to cancel. + function cancelOrders(OrderIntent[] calldata _orders) external { + // Cancel all of the orders in the batch. + bytes32[] memory orderHashes = new bytes32[](_orders.length); + for (uint256 i = 0; i < _orders.length; i++) { + // Ensure that the sender is the trader in the order. + if (msg.sender != _orders[i].trader) { + revert InvalidSender(); + } + + // Ensure that the sender signed each order. + bytes32 orderHash = hashOrderIntent(_orders[i]); + if (!verifySignature(orderHash, _orders[i].signature, msg.sender)) { + revert InvalidSignature(); + } + + // Cancel the order. + isCancelled[orderHash] = true; + orderHashes[i] = orderHash; + } + + emit OrdersCancelled(msg.sender, orderHashes); + } + + /// @notice Directly matches a long and a short order. To avoid the need for + /// liquidity, this function will open a flash loan on Morpho to + /// ensure that the pool is appropriately capitalized. + /// @dev This function isn't marked as nonReentrant because this contract + /// will be reentered when the Morpho flash-loan callback is processed. + /// `onMorphoFlashLoan` has been marked as non reentrant to ensure that + /// the trading logic can't be reentered. + /// @param _longOrder The order intent to open a long. + /// @param _shortOrder The order intent to open a short. + /// @param _lpAmount The amount to flash borrow and LP. + /// @param _addLiquidityOptions The options used when adding liquidity. + /// @param _removeLiquidityOptions The options used when removing liquidity. + /// @param _feeRecipient The address that receives the LP fees from matching + /// the trades. + /// @param _isLongFirst A flag indicating whether the long or short should + /// be opened first. + function matchOrders( + OrderIntent calldata _longOrder, + OrderIntent calldata _shortOrder, + uint256 _lpAmount, + IHyperdrive.Options calldata _addLiquidityOptions, + IHyperdrive.Options calldata _removeLiquidityOptions, + address _feeRecipient, + bool _isLongFirst + ) external { + // Validate the order intents and the add and remove liquidity options + // in preparation of matching the orders. + (bytes32 longOrderHash, bytes32 shortOrderHash) = _validateOrders( + _longOrder, + _shortOrder, + _addLiquidityOptions, + _removeLiquidityOptions + ); + + // Cancel the orders so that they can't be used again. + isCancelled[longOrderHash] = true; + isCancelled[shortOrderHash] = true; + + // Send off the flash loan call to Morpho. The remaining execution logic + // will be executed in the `onMorphoFlashLoan` callback. + morpho.flashLoan( + // NOTE: The loan token is always the base token since we require + // `asBase` to be true. + _longOrder.hyperdrive.baseToken(), + _lpAmount, + abi.encode( + _longOrder, + _shortOrder, + _addLiquidityOptions, + _removeLiquidityOptions, + _feeRecipient, + _isLongFirst + ) + ); + + // Emit an `OrdersMatched` event. + emit OrdersMatched( + _longOrder.hyperdrive, + longOrderHash, + shortOrderHash, + _longOrder.trader, + _shortOrder.trader + ); + } + + /// @notice Callback called when a flash loan occurs. + /// @dev The callback is called only if data is not empty. + /// @param _lpAmount The amount of assets that were flash loaned. + /// @param _data Arbitrary data passed to the `flashLoan` function. + function onMorphoFlashLoan( + uint256 _lpAmount, + bytes calldata _data + ) external nonReentrant { + // Decode the execution parameters. This encodes the information + // required to execute the LP, long, and short operations. + ( + OrderIntent memory longOrder, + OrderIntent memory shortOrder, + IHyperdrive.Options memory addLiquidityOptions, + IHyperdrive.Options memory removeLiquidityOptions, + address feeRecipient, + bool isLongFirst + ) = abi.decode( + _data, + ( + OrderIntent, + OrderIntent, + IHyperdrive.Options, + IHyperdrive.Options, + address, + bool + ) + ); + + // Add liquidity to the pool. + IHyperdrive hyperdrive = longOrder.hyperdrive; + ERC20 baseToken = ERC20(hyperdrive.baseToken()); + uint256 lpAmount = _lpAmount; // avoid stack-too-deep + uint256 lpShares = _addLiquidity( + hyperdrive, + baseToken, + lpAmount, + addLiquidityOptions + ); + + // If the long should be executed first, execute the long and then the + // short. + if (isLongFirst) { + _openLong(hyperdrive, baseToken, longOrder); + _openShort(hyperdrive, baseToken, shortOrder); + } + // Otherwise, execute the short and then the long. + else { + _openShort(hyperdrive, baseToken, shortOrder); + _openLong(hyperdrive, baseToken, longOrder); + } + + // Remove liquidity. This will repay the flash loan. + (uint256 proceeds, uint256 withdrawalShares) = hyperdrive + .removeLiquidity(lpShares, 0, removeLiquidityOptions); + + // If the withdrawal shares are greater than zero, send them to the fee + // recipient. + if (withdrawalShares > 0) { + hyperdrive.transferFrom( + AssetId._WITHDRAWAL_SHARE_ASSET_ID, + address(this), + feeRecipient, + withdrawalShares + ); + } + + // If the proceeds are greater than the LP amount, we send the difference + // to the fee recipient. + if (proceeds > lpAmount) { + baseToken.safeTransfer(feeRecipient, proceeds - lpAmount); + } + + // Approve Morpho Blue to take back the assets that were provided. + baseToken.forceApprove(address(morpho), lpAmount); + } + + /// @notice Hashes an order intent according to EIP-712. + /// @param _order The order intent to hash. + /// @return The hash of the order intent. + function hashOrderIntent( + OrderIntent calldata _order + ) public view returns (bytes32) { + return + _hashTypedDataV4( + keccak256( + abi.encode( + ORDER_INTENT_TYPEHASH, + _order.trader, + _order.hyperdrive, + _order.amount, + _order.slippageGuard, + _order.minVaultSharePrice, + keccak256( + abi.encode( + OPTIONS_TYPEHASH, + _order.options.destination, + _order.options.asBase + ) + ), + uint8(_order.orderType), + _order.expiry, + _order.salt + ) + ) + ); + } + + /// @notice Verifies a signature for a known signer. Returns a flag + /// indicating whether signature verification was successful. + /// @param _hash The EIP-712 hash of the order. + /// @param _signature The signature bytes. + /// @param _signer The expected signer. + /// @return A flag inidicating whether signature verification was successful. + function verifySignature( + bytes32 _hash, + bytes calldata _signature, + address _signer + ) public view returns (bool) { + // For contracts, we use EIP-1271 signatures. + if (_signer.code.length > 0) { + try IERC1271(_signer).isValidSignature(_hash, _signature) returns ( + bytes4 magicValue + ) { + if (magicValue != IERC1271.isValidSignature.selector) { + return false; + } + return true; + } catch { + return false; + } + } + + // For EOAs, verify the ECDSA signature. + if (ECDSA.recover(_hash, _signature) != _signer) { + return false; + } + + return true; + } + + /// @dev Adds liquidity to the Hyperdrive pool. + /// @param _hyperdrive The Hyperdrive pool. + /// @param _baseToken The base token of the pool. + /// @param _lpAmount The amount of base to LP. + /// @param _options The options that configures how the deposit will be + /// settled. + /// @return The amount of LP shares received. + function _addLiquidity( + IHyperdrive _hyperdrive, + ERC20 _baseToken, + uint256 _lpAmount, + IHyperdrive.Options memory _options + ) internal returns (uint256) { + _baseToken.forceApprove(address(_hyperdrive), _lpAmount + 1); + return + _hyperdrive.addLiquidity( + _lpAmount, + 0, + 0, + type(uint256).max, + _options + ); + } + + /// @dev Opens a long position in the Hyperdrive pool. + /// @param _hyperdrive The Hyperdrive pool. + /// @param _baseToken The base token of the pool. + /// @param _order The order containing the trade parameters. + function _openLong( + IHyperdrive _hyperdrive, + ERC20 _baseToken, + OrderIntent memory _order + ) internal { + _baseToken.safeTransferFrom( + _order.trader, + address(this), + _order.amount + ); + _baseToken.forceApprove(address(_hyperdrive), _order.amount + 1); + _hyperdrive.openLong( + _order.amount, + _order.slippageGuard, + _order.minVaultSharePrice, + _order.options + ); + } + + /// @dev Opens a short position in the Hyperdrive pool. + /// @param _hyperdrive The Hyperdrive pool. + /// @param _baseToken The base token of the pool. + /// @param _order The order containing the trade parameters. + function _openShort( + IHyperdrive _hyperdrive, + ERC20 _baseToken, + OrderIntent memory _order + ) internal { + _baseToken.safeTransferFrom( + _order.trader, + address(this), + _order.slippageGuard + ); + _baseToken.forceApprove(address(_hyperdrive), _order.slippageGuard + 1); + (, uint256 shortPaid) = _hyperdrive.openShort( + _order.amount, + _order.slippageGuard, + _order.minVaultSharePrice, + _order.options + ); + if (_order.slippageGuard > shortPaid) { + _baseToken.safeTransfer( + _order.trader, + _order.slippageGuard - shortPaid + ); + } + } + + /// @dev Validates orders and returns their hashes. + /// @param _longOrder The order intent to open a long. + /// @param _shortOrder The order intent to open a short. + /// @param _addLiquidityOptions The options used when adding liquidity. + /// @param _removeLiquidityOptions The options used when removing liquidity. + /// @return longOrderHash The hash of the long order. + /// @return shortOrderHash The hash of the short order. + function _validateOrders( + OrderIntent calldata _longOrder, + OrderIntent calldata _shortOrder, + IHyperdrive.Options calldata _addLiquidityOptions, + IHyperdrive.Options calldata _removeLiquidityOptions + ) internal view returns (bytes32 longOrderHash, bytes32 shortOrderHash) { + // Ensure that the long and short orders are the correct type. + if ( + _longOrder.orderType != OrderType.OpenLong || + _shortOrder.orderType != OrderType.OpenShort + ) { + revert InvalidOrderType(); + } + + // Ensure that neither order has expired. + if ( + _longOrder.expiry <= block.timestamp || + _shortOrder.expiry <= block.timestamp + ) { + revert AlreadyExpired(); + } + + // Ensure that both orders refer to the same Hyperdrive pool. + if (_longOrder.hyperdrive != _shortOrder.hyperdrive) { + revert MismatchedHyperdrive(); + } + + // Ensure that all of the transactions should be settled with base. + if ( + !_longOrder.options.asBase || + !_shortOrder.options.asBase || + !_addLiquidityOptions.asBase || + !_removeLiquidityOptions.asBase + ) { + revert InvalidSettlementAsset(); + } + + // Ensure that the orders cross. We can calculate a worst-case price + // for the long and short using the `amount` and `slippageGuard` fields. + // In order for the orders to cross, the price of the long should be + // equal to or higher than the price of the short. This implies that the + // long is willing to buy bonds at a price equal or higher than the + // short is selling bonds, which ensures that the trade is valid. + if ( + _longOrder.slippageGuard != 0 && + _shortOrder.slippageGuard < _shortOrder.amount && + _longOrder.amount.divDown(_longOrder.slippageGuard) < + (_shortOrder.amount - _shortOrder.slippageGuard).divDown( + _shortOrder.amount + ) + ) { + revert InvalidMatch(); + } + + // Hash both orders. + longOrderHash = hashOrderIntent(_longOrder); + shortOrderHash = hashOrderIntent(_shortOrder); + + // Ensure that neither order has been cancelled. + if (isCancelled[longOrderHash] || isCancelled[shortOrderHash]) { + revert AlreadyCancelled(); + } + + // Ensure that the order intents were signed correctly. + if ( + !verifySignature( + longOrderHash, + _longOrder.signature, + _longOrder.trader + ) || + !verifySignature( + shortOrderHash, + _shortOrder.signature, + _shortOrder.trader + ) + ) { + revert InvalidSignature(); + } + + // Ensure that the destination of the add/remove liquidity options is + // this contract. + if ( + _addLiquidityOptions.destination != address(this) || + _removeLiquidityOptions.destination != address(this) + ) { + revert InvalidDestination(); + } + } +} diff --git a/deployments.json b/deployments.json index 0e174c4ee..a9a63bfed 100644 --- a/deployments.json +++ b/deployments.json @@ -1744,4 +1744,4 @@ "timestamp": "2024-10-30T18:12:24.607Z" } } -} \ No newline at end of file +} diff --git a/test/instances/erc4626/ERC4626Hyperdrive.t.sol b/test/instances/erc4626/ERC4626Hyperdrive.t.sol index 845585c72..4ab2e2cf7 100644 --- a/test/instances/erc4626/ERC4626Hyperdrive.t.sol +++ b/test/instances/erc4626/ERC4626Hyperdrive.t.sol @@ -53,9 +53,8 @@ contract ERC4626HyperdriveTest is HyperdriveTest { IHyperdriveFactory internal factory; function setUp() public override __mainnet_fork(16_685_972) { - alice = createUser("alice"); - bob = createUser("bob"); - + // Run the higher-level setup. + super.setUp(); vm.startPrank(deployer); // Deploy the ERC4626Hyperdrive factory and deployer. diff --git a/test/integrations/factory/HyperdriveFactory.t.sol b/test/integrations/factory/HyperdriveFactory.t.sol index 4976f1fca..c3264d8a7 100644 --- a/test/integrations/factory/HyperdriveFactory.t.sol +++ b/test/integrations/factory/HyperdriveFactory.t.sol @@ -2855,8 +2855,8 @@ contract HyperdriveFactoryBaseTest is HyperdriveTest { IHyperdriveFactory internal factory; function setUp() public virtual override __mainnet_fork(16_685_972) { - alice = createUser("alice"); - bob = createUser("bob"); + (alice, ) = createUser("alice"); + (bob, ) = createUser("bob"); vm.startPrank(deployer); @@ -3087,8 +3087,8 @@ contract ERC4626FactoryMultiDeployTest is HyperdriveFactoryBaseTest { } function test_hyperdriveFactoryDeploy_multiDeploy_multiPool() external { - address charlie = createUser("charlie"); // External user 1 - address dan = createUser("dan"); // External user 2 + (address charlie, ) = createUser("charlie"); // External user 1 + (address dan, ) = createUser("dan"); // External user 2 vm.startPrank(charlie); @@ -3328,7 +3328,7 @@ contract ERC4626InstanceGetterTest is HyperdriveFactoryBaseTest { function testFuzz_hyperdriveFactory_getNumberOfInstances( uint256 numberOfInstances ) external { - address charlie = createUser("charlie"); + (address charlie, ) = createUser("charlie"); numberOfInstances = _bound(numberOfInstances, 1, 10); @@ -3342,7 +3342,7 @@ contract ERC4626InstanceGetterTest is HyperdriveFactoryBaseTest { function testFuzz_hyperdriveFactory_getInstanceAtIndex( uint256 numberOfInstances ) external { - address charlie = createUser("charlie"); + (address charlie, ) = createUser("charlie"); numberOfInstances = _bound(numberOfInstances, 1, 10); @@ -3377,7 +3377,7 @@ contract ERC4626InstanceGetterTest is HyperdriveFactoryBaseTest { uint256 startingIndex, uint256 endingIndex ) external { - address charlie = createUser("charlie"); + (address charlie, ) = createUser("charlie"); numberOfInstances = _bound(numberOfInstances, 1, 10); startingIndex = _bound(startingIndex, 0, numberOfInstances - 1); diff --git a/test/integrations/matching/DirectMatchTest.t.sol b/test/integrations/matching/DirectMatchTest.t.sol new file mode 100644 index 000000000..6e1e2f75b --- /dev/null +++ b/test/integrations/matching/DirectMatchTest.t.sol @@ -0,0 +1,500 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { IMorpho, Market, MarketParams, Id } from "morpho-blue/src/interfaces/IMorpho.sol"; +import { MarketParamsLib } from "morpho-blue/src/libraries/MarketParamsLib.sol"; +import { MorphoBlueConversions } from "../../../contracts/src/instances/morpho-blue/MorphoBlueConversions.sol"; +import { MorphoBlueHyperdrive } from "../../../contracts/src/instances/morpho-blue/MorphoBlueHyperdrive.sol"; +import { MorphoBlueTarget0 } from "../../../contracts/src/instances/morpho-blue/MorphoBlueTarget0.sol"; +import { MorphoBlueTarget1 } from "../../../contracts/src/instances/morpho-blue/MorphoBlueTarget1.sol"; +import { MorphoBlueTarget2 } from "../../../contracts/src/instances/morpho-blue/MorphoBlueTarget2.sol"; +import { MorphoBlueTarget3 } from "../../../contracts/src/instances/morpho-blue/MorphoBlueTarget3.sol"; +import { MorphoBlueTarget4 } from "../../../contracts/src/instances/morpho-blue/MorphoBlueTarget4.sol"; +import { IERC20 } from "../../../contracts/src/interfaces/IERC20.sol"; +import { IHyperdrive } from "../../../contracts/src/interfaces/IHyperdrive.sol"; +import { IHyperdriveMatchingEngine } from "../../../contracts/src/interfaces/IHyperdriveMatchingEngine.sol"; +import { IMorphoBlueHyperdrive } from "../../../contracts/src/interfaces/IMorphoBlueHyperdrive.sol"; +import { AssetId } from "../../../contracts/src/libraries/AssetId.sol"; +import { FixedPointMath, ONE } from "../../../contracts/src/libraries/FixedPointMath.sol"; +import { HyperdriveMatchingEngine } from "../../../contracts/src/matching/HyperdriveMatchingEngine.sol"; +import { HyperdriveTest } from "../../utils/HyperdriveTest.sol"; +import { HyperdriveUtils } from "../../utils/HyperdriveUtils.sol"; +import { Lib } from "../../utils/Lib.sol"; + +/// @dev This test suite demonstrates how two traders can be matched directly +/// using the Hyperdrive Matching Engine. +contract DirectMatchTest is HyperdriveTest { + using FixedPointMath for *; + using HyperdriveUtils for *; + using MarketParamsLib for MarketParams; + using Lib for *; + + /// @dev The cbBTC address. + address internal constant CB_BTC = + 0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf; + + /// @dev The USDC address. + address internal constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + + /// @dev The address of the Morpho Blue pool. + IMorpho internal constant MORPHO = + IMorpho(0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb); + + /// @dev The address of the oracle for Morpho's cbBTC/USDC pool. + address internal constant ORACLE = + 0xA6D6950c9F177F1De7f7757FB33539e3Ec60182a; + + /// @dev The address of the interest rate model for Morpho's cbBTC/USDC pool. + address internal constant IRM = 0x870aC11D48B15DB9a138Cf899d20F13F79Ba00BC; + + /// @dev The liquidation loan to value for Morpho's cbBTC/USDC pool. + uint256 internal constant LLTV = 860000000000000000; + + /// @dev The whale addresses that have large balances of USDC. + address[] internal WHALES = [ + 0x54edC2D90BBfE50526E333c7FfEaD3B0F22D39F0, + 0x37305B1cD40574E4C5Ce33f8e8306Be057fD7341, + 0x4B16c5dE96EB2117bBE5fd171E4d203624B014aa + ]; + + /// @dev This is the initializer of the JIT pool. + address internal constant INITIALIZER = + 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; + + /// @dev This is the LP we'll use to provide JIT liquidity.. + address internal constant LP = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + + /// @dev The private key of the LP. + uint256 internal constant LP_PK = + 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + + /// @dev This is the address that will buy the short to hedge their borrow + /// position. + address internal constant HEDGER = + 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC; + + /// @dev The private key of the Hedger. + uint256 internal constant HEDGER_PK = + 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a; + + /// @dev The Hyperdrive matching engine that is deployed. + IHyperdriveMatchingEngine internal matchingEngine; + + /// @dev Sets up the test harness on a mainnet fork and complete the + /// following actions: + /// + /// 1. Deploy the Morpho Blue cbBTC/USDC pool. + /// 2. Deploy the Hyperdrive matching engine. + /// 3. Set up whale accounts. + /// 4. Initialize the Hyperdrive pool. + function setUp() public override __mainnet_fork(21_239_626) { + // Run the higher-level setup logic. + super.setUp(); + + // Deploy a Morpho Blue cbBTC/USDC pool. + IMorphoBlueHyperdrive.MorphoBlueParams + memory params = IMorphoBlueHyperdrive.MorphoBlueParams({ + morpho: MORPHO, + collateralToken: address(CB_BTC), + oracle: address(ORACLE), + irm: address(IRM), + lltv: LLTV + }); + IHyperdrive.PoolConfig memory config = testConfig(0.04e18, 182 days); + config.baseToken = IERC20(USDC); + config.vaultSharesToken = IERC20(address(0)); + config.fees.curve = 0.01e18; + config.fees.flat = 0.0005e18; + config.fees.governanceLP = 0.15e18; + config.minimumShareReserves = 1e6; + config.minimumTransactionAmount = 1e6; + config.initialVaultSharePrice = MorphoBlueConversions.convertToBase( + MORPHO, + config.baseToken, + params.collateralToken, + params.oracle, + params.irm, + params.lltv, + ONE + ); + hyperdrive = IHyperdrive( + address( + new MorphoBlueHyperdrive( + "MorphoBlueHyperdrive", + config, + adminController, + address( + new MorphoBlueTarget0(config, adminController, params) + ), + address( + new MorphoBlueTarget1(config, adminController, params) + ), + address( + new MorphoBlueTarget2(config, adminController, params) + ), + address( + new MorphoBlueTarget3(config, adminController, params) + ), + address( + new MorphoBlueTarget4(config, adminController, params) + ), + params + ) + ) + ); + + // Deploy the Hyperdrive matching engine. + matchingEngine = IHyperdriveMatchingEngine( + new HyperdriveMatchingEngine("Hyperdrive Matching Engine", MORPHO) + ); + + // Fund each of the accounts from the whale accounts. + IERC20 baseToken = IERC20(hyperdrive.baseToken()); + address[] memory accounts = new address[](3); + accounts[0] = INITIALIZER; + accounts[1] = LP; + accounts[2] = HEDGER; + for (uint256 i = 0; i < WHALES.length; i++) { + uint256 balance = baseToken.balanceOf(WHALES[i]); + for (uint256 j = 0; j < accounts.length; j++) { + vm.stopPrank(); + vm.startPrank(WHALES[i]); + baseToken.transfer(accounts[j], balance / accounts.length); + } + } + + // Approve Hyperdrive and the matching engine with each of the whales. + for (uint256 i = 0; i < accounts.length; i++) { + vm.stopPrank(); + vm.startPrank(accounts[i]); + baseToken.approve(address(hyperdrive), type(uint256).max); + baseToken.approve(address(matchingEngine), type(uint256).max); + } + + // Initialize the Hyperdrive pool. + vm.stopPrank(); + vm.startPrank(INITIALIZER); + hyperdrive.initialize( + 100e6, + 0.0361e18, + IHyperdrive.Options({ + asBase: true, + destination: INITIALIZER, + extraData: "" + }) + ); + } + + /// @dev This test demonstrates matching two traders using the Hyperdrive + /// matching engine. The short receives a rate better than 5.11% and + /// the long receives a rate better than 5.02%. We add a random jitter + /// at the beginning of the test by advancing some time and accruing + /// interest. This demonstrates that orders aren't brittle and can be + /// signed and then executed several days later while interest is + /// accruing. Having this jitter does increase the spread between the + /// borrow and supply rates (the fixed lender would get a rate higher + /// than 5.09% without the jitter). + /// @param _timeElapsed The random jitter to advance before executing the + /// trade. + /// @param _variableRate The random amount of interest to accrue during the + /// jitter. + function test_direct_match( + uint256 _timeElapsed, + uint256 _variableRate + ) external { + // Get some information before the trade. + uint256 lpBaseBalanceBefore = IERC20(hyperdrive.baseToken()).balanceOf( + LP + ); + uint256 lpLongBalanceBefore = hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Long, + hyperdrive.latestCheckpoint() + ), + LP + ); + uint256 hedgerBaseBalanceBefore = IERC20(hyperdrive.baseToken()) + .balanceOf(HEDGER); + uint256 hedgerShortBalanceBefore = hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + hyperdrive.latestCheckpoint() + ), + HEDGER + ); + + // Advance the time and accrue some interest. By fuzzing over the time + // to advance and the variable rate, we demonstrate that the orders that + // we create aren't "brittle." Once we find a set of orders that works + // to directly match two parties, the orders should still work several + // days later. + _timeElapsed = _timeElapsed.normalizeToRange(0, 3 days); + _variableRate = _variableRate.normalizeToRange(0, 0.1e18); + advanceTime(_timeElapsed, int256(_variableRate)); + + // Create the two orders and sign them. + IHyperdriveMatchingEngine.OrderIntent + memory longOrder = IHyperdriveMatchingEngine.OrderIntent({ + trader: LP, + hyperdrive: hyperdrive, + amount: 2_440_000e6, + slippageGuard: 2_499_999e6, + minVaultSharePrice: hyperdrive + .getCheckpoint(hyperdrive.latestCheckpoint()) + .vaultSharePrice, + options: IHyperdrive.Options({ + asBase: true, + destination: LP, + extraData: "" + }), + orderType: IHyperdriveMatchingEngine.OrderType.OpenLong, + signature: new bytes(0), + expiry: block.timestamp + 1 hours, + salt: bytes32(uint256(0xdeadbeef)) + }); + IHyperdriveMatchingEngine.OrderIntent + memory shortOrder = IHyperdriveMatchingEngine.OrderIntent({ + trader: HEDGER, + hyperdrive: hyperdrive, + amount: 2_500_000e6, + slippageGuard: 65_000e6, + minVaultSharePrice: hyperdrive + .getCheckpoint(hyperdrive.latestCheckpoint()) + .vaultSharePrice, + options: IHyperdrive.Options({ + asBase: true, + destination: HEDGER, + extraData: "" + }), + orderType: IHyperdriveMatchingEngine.OrderType.OpenShort, + signature: new bytes(0), + expiry: block.timestamp + 1 hours, + salt: bytes32(uint256(0xbeefbabe)) + }); + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + LP_PK, + matchingEngine.hashOrderIntent(longOrder) + ); + longOrder.signature = abi.encodePacked(r, s, v); + (v, r, s) = vm.sign( + HEDGER_PK, + matchingEngine.hashOrderIntent(shortOrder) + ); + shortOrder.signature = abi.encodePacked(r, s, v); + + // Update the short order's extra data after signing. This simulates + // what the DFB UI will do to ensure an up-to-date quote. Since the + // extra data isn't baked into the hash, this will not cause issues with + // signature verification. + shortOrder.options.extraData = hex"deadbeefbabefade6660"; + + // Match the two counterparties using flash loans. + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // flash loan amount + 18_500_000e6, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + LP, + false + ); + + // Create two more orders and sign them. + longOrder = IHyperdriveMatchingEngine.OrderIntent({ + trader: LP, + hyperdrive: hyperdrive, + amount: 2_440_000e6, + slippageGuard: 2_499_999e6, + minVaultSharePrice: hyperdrive + .getCheckpoint(hyperdrive.latestCheckpoint()) + .vaultSharePrice, + options: IHyperdrive.Options({ + asBase: true, + destination: LP, + extraData: "" + }), + orderType: IHyperdriveMatchingEngine.OrderType.OpenLong, + signature: new bytes(0), + expiry: block.timestamp + 1 hours, + salt: bytes32(uint256(0xbeefdead)) + }); + shortOrder = IHyperdriveMatchingEngine.OrderIntent({ + trader: HEDGER, + hyperdrive: hyperdrive, + amount: 2_500_000e6, + slippageGuard: 65_000e6, + minVaultSharePrice: hyperdrive + .getCheckpoint(hyperdrive.latestCheckpoint()) + .vaultSharePrice, + options: IHyperdrive.Options({ + asBase: true, + destination: HEDGER, + extraData: "" + }), + orderType: IHyperdriveMatchingEngine.OrderType.OpenShort, + signature: new bytes(0), + expiry: block.timestamp + 1 hours, + salt: bytes32(uint256(0xbabebeef)) + }); + (v, r, s) = vm.sign(LP_PK, matchingEngine.hashOrderIntent(longOrder)); + longOrder.signature = abi.encodePacked(r, s, v); + (v, r, s) = vm.sign( + HEDGER_PK, + matchingEngine.hashOrderIntent(shortOrder) + ); + shortOrder.signature = abi.encodePacked(r, s, v); + + // Update the short order's extra data after signing. This simulates + // what the DFB UI will do to ensure an up-to-date quote. Since the + // extra data isn't baked into the hash, this will not cause issues with + // signature verification. + shortOrder.options.extraData = hex"deadbeefbabefade6660"; + + // Match the two counterparties using flash loans. + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // flash loan amount + 18_500_000e6, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + LP, + false + ); + + // Ensure that the short received 5,000,000 bonds and that their rate + // was lower than 5.11%. + { + uint256 shortPaid = hedgerBaseBalanceBefore - + IERC20(hyperdrive.baseToken()).balanceOf(HEDGER); + uint256 shortAmount = hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + hyperdrive.latestCheckpoint() + + hyperdrive.getPoolConfig().positionDuration + ), + HEDGER + ) - hedgerShortBalanceBefore; + uint256 prepaidInterest = shortAmount.mulDown( + hyperdrive.getPoolInfo().vaultSharePrice - + hyperdrive + .getCheckpoint(hyperdrive.latestCheckpoint()) + .vaultSharePrice + ); + shortPaid -= prepaidInterest; + uint256 shortFixedRate = HyperdriveUtils + .calculateAPRFromRealizedPrice( + shortAmount - + (shortPaid - + shortAmount.mulDown( + hyperdrive.getPoolConfig().fees.flat + )), + shortAmount, + hyperdrive.getPoolConfig().positionDuration.divDown( + 365 days + ) + ); + assertEq(shortAmount, 5_000_000e6); + assertLt(shortFixedRate, 0.0511e18); + } + + // Ensure that the long received 5,000,000 or more bonds and that their + // rate was higher than 5.02%. + { + uint256 longPaid = lpBaseBalanceBefore - + IERC20(hyperdrive.baseToken()).balanceOf(LP); + uint256 longAmount = hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Long, + hyperdrive.latestCheckpoint() + + hyperdrive.getPoolConfig().positionDuration + ), + LP + ) - lpLongBalanceBefore; + uint256 longFixedRate = HyperdriveUtils + .calculateAPRFromRealizedPrice( + longPaid, + longAmount, + hyperdrive.getPoolConfig().positionDuration.divDown( + 365 days + ) + ); + assertGt(longAmount, 5_000_000e6); + assertGt(longFixedRate, 0.0502e18); + } + } + + /// @dev Advance time and accrue interest. + /// @param timeDelta The time to advance. + /// @param variableRate The variable rate. + function advanceTime( + uint256 timeDelta, + int256 variableRate + ) internal override { + // Advance the time. + vm.warp(block.timestamp + timeDelta); + + // Accrue interest in the Morpho market. This amounts to manually + // updating the total supply assets and the last update time. + Id marketId = MarketParams({ + loanToken: USDC, + collateralToken: CB_BTC, + oracle: ORACLE, + irm: IRM, + lltv: LLTV + }).id(); + Market memory market = MORPHO.market(marketId); + (uint256 totalSupplyAssets, ) = uint256(market.totalSupplyAssets) + .calculateInterest(variableRate, timeDelta); + bytes32 marketLocation = keccak256(abi.encode(marketId, 3)); + vm.store( + address(MORPHO), + marketLocation, + bytes32( + (uint256(market.totalSupplyShares) << 128) | totalSupplyAssets + ) + ); + vm.store( + address(MORPHO), + bytes32(uint256(marketLocation) + 2), + bytes32((uint256(market.fee) << 128) | uint256(block.timestamp)) + ); + + // In order to prevent transfers from failing, we also need to increase + // the DAI balance of the Morpho vault to match the total assets. + mintBaseTokens(address(MORPHO), totalSupplyAssets); + } + + /// @dev Mints base tokens to a specified account. + /// @param _recipient The recipient of the minted tokens. + /// @param _amount The amount of tokens to mint. + function mintBaseTokens(address _recipient, uint256 _amount) internal { + bytes32 balanceLocation = keccak256(abi.encode(address(_recipient), 9)); + vm.store(USDC, balanceLocation, bytes32(_amount)); + } +} diff --git a/test/units/matching/HyperdriveMatchingEngineTest.t.sol b/test/units/matching/HyperdriveMatchingEngineTest.t.sol new file mode 100644 index 000000000..8139adb06 --- /dev/null +++ b/test/units/matching/HyperdriveMatchingEngineTest.t.sol @@ -0,0 +1,1537 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.22; + +import { VmSafe } from "forge-std/Vm.sol"; +import { IMorpho } from "morpho-blue/src/interfaces/IMorpho.sol"; +import { IMorphoFlashLoanCallback } from "morpho-blue/src/interfaces/IMorphoCallbacks.sol"; +import { ERC20 } from "openzeppelin/token/ERC20/ERC20.sol"; +import { SafeERC20 } from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; +import { IHyperdrive } from "../../../contracts/src/interfaces/IHyperdrive.sol"; +import { IHyperdriveMatchingEngine } from "../../../contracts/src/interfaces/IHyperdriveMatchingEngine.sol"; +import { AssetId } from "../../../contracts/src/libraries/AssetId.sol"; +import { FixedPointMath } from "../../../contracts/src/libraries/FixedPointMath.sol"; +import { HyperdriveMatchingEngine } from "../../../contracts/src/matching/HyperdriveMatchingEngine.sol"; +import { HyperdriveTest } from "../../utils/HyperdriveTest.sol"; +import { HyperdriveUtils } from "../../utils/HyperdriveUtils.sol"; +import { Lib } from "../../utils/Lib.sol"; + +contract FlashLoaner { + using SafeERC20 for ERC20; + + /// @notice Allows anyone to take out a flash loan. This must be paid back + /// by the end of the transaction. + /// @param _token The token that is flash borrowed. + /// @param _assets The amount of assets flash borrowed. + /// @param _data The data for the flash loan callback. + function flashLoan( + ERC20 _token, + uint256 _assets, + bytes calldata _data + ) external { + // Send the flash loan to the account. + _token.safeTransfer(msg.sender, _assets); + + // Call the callback. + IMorphoFlashLoanCallback(msg.sender).onMorphoFlashLoan(_assets, _data); + + // Transfer the tokens back to this contract. + _token.safeTransferFrom(msg.sender, address(this), _assets); + } +} + +contract EIP1271Signer { + using SafeERC20 for ERC20; + + /// @notice A flag indicating whether signature validation should succeed or + /// fail. + bool public shouldVerifySignature = true; + + /// @notice Calls `matchingEngine.cancelOrders` with the provided orders. + /// @param _matchingEngine The matching engine where the orders should be + /// cancelled. + /// @param _orders The orders to cancel. + function cancelOrders( + IHyperdriveMatchingEngine _matchingEngine, + IHyperdriveMatchingEngine.OrderIntent[] memory _orders + ) external { + _matchingEngine.cancelOrders(_orders); + } + + /// @notice Approves a specified token with a specified amount. + /// @param _token The token to approve. + /// @param _target The target of the approval. + /// @param _amount The amount to approve. + function approve(ERC20 _token, address _target, uint256 _amount) external { + _token.forceApprove(_target, _amount); + } + + /// @notice Sets `shouldVerifySignature` to the provided value. + /// @param _value The updated value. + function setShouldVerifySignature(bool _value) external { + shouldVerifySignature = _value; + } + + /// @notice Returns whether the signature provided is valid. + /// @return The magic value if the signature verified and zero otherwise. + function isValidSignature( + bytes32, + bytes memory + ) external view returns (bytes4) { + if (shouldVerifySignature) { + return this.isValidSignature.selector; + } else { + return bytes4(0); + } + } +} + +/// @dev This test suite provides coverage for the different paths through the +/// matching engine's code. It uses the normal test harness with a mocked +/// out EIP1271 signer and flash loaner to test all of the different cases. +contract HyperdriveMatchingEngineTest is HyperdriveTest { + using FixedPointMath for uint256; + using HyperdriveUtils for IHyperdrive; + using Lib for *; + + /// @dev Emitted when orders are cancelled + event OrdersCancelled(address indexed trader, bytes32[] orderHashes); + + /// @dev Emitted when orders are matched + event OrdersMatched( + IHyperdrive indexed hyperdrive, + bytes32 indexed longOrderHash, + bytes32 indexed shortOrderHash, + address long, + address short + ); + + /// @dev A salt used to help create orders. + bytes32 internal salt = bytes32(uint256(0xdeadbeef)); + + /// @dev The deployer EIP1271 signer. + EIP1271Signer internal signer; + + /// @dev The deployed Hyperdrive matching engine. + IHyperdriveMatchingEngine internal matchingEngine; + + /// @notice Sets up the matching engine test with the following actions: + /// + /// 1. Deploy and initialize Hyperdrive pool with fees. + /// 2. Deploy and fund a flash loaner contract. + /// 3. Deploy a Hyperdrive matching engine. + /// 4. Fund some EOA accounts with base tokens. + /// 5. Deploy and fund an EIP1271 signer. + function setUp() public override { + // Run the higher level setup. + super.setUp(); + + // Deploy and initialize a Hyperdrive pool with fees. + IHyperdrive.PoolConfig memory config = testConfig( + 0.05e18, + POSITION_DURATION + ); + config.fees.curve = 0.01e18; + config.fees.flat = 0.0005e18; + config.fees.governanceLP = 0.15e18; + config.fees.governanceZombie = 0.03e18; + deploy(alice, config); + initialize(alice, 0.05e18, 100_000e18); + + // Deploy a flash loan contract and seed it with lots of base tokens. + IMorpho flashLoaner = IMorpho(address(new FlashLoaner())); + baseToken.mint(address(flashLoaner), 100_000_000e18); + + // Deploy the Hyperdrive matching engine. + matchingEngine = IHyperdriveMatchingEngine( + new HyperdriveMatchingEngine( + "Hyperdrive Matching Engine", + flashLoaner + ) + ); + + // Fund Alice, Bob, and Celine with base tokens and approve the matching + // engine. + address[3] memory accounts = [alice, bob, celine]; + for (uint256 i = 0; i < accounts.length; i++) { + vm.stopPrank(); + vm.startPrank(accounts[i]); + baseToken.mint(100_000_000e18); + baseToken.approve(address(matchingEngine), type(uint256).max); + } + + // Deploy and fund a EIP1271 signer and approve the matching engine on + // their behalf. + signer = new EIP1271Signer(); + baseToken.mint(address(signer), 100_000_000e18); + signer.approve( + ERC20(address(baseToken)), + address(matchingEngine), + type(uint256).max + ); + + // Start recording event logs. + vm.recordLogs(); + } + + /// @dev Ensures that orders can't be cancelled by anyone other than the + /// sender. + function test_cancelOrders_failure_invalidSender() external { + // Create an order that can be used for testing several cases. + IHyperdriveMatchingEngine.OrderIntent memory order = _createOrderIntent( + alice, + 100_000e18, + 105_000e18, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + IHyperdriveMatchingEngine.OrderIntent[] + memory orders = new IHyperdriveMatchingEngine.OrderIntent[](1); + orders[0] = order; + + // Ensure that Bob can't cancel Alice's order. + order.signature = _signOrderIntent(order, bobPK); + vm.stopPrank(); + vm.startPrank(bob); + vm.expectRevert(IHyperdriveMatchingEngine.InvalidSender.selector); + matchingEngine.cancelOrders(orders); + + // Ensure cancelling fails when EIP1271 signer isn't the order's trader. + signer.setShouldVerifySignature(false); + order.signature = hex"deadbeef"; + vm.expectRevert(IHyperdriveMatchingEngine.InvalidSender.selector); + signer.cancelOrders(matchingEngine, orders); + } + + /// @dev Ensures that orders can't be cancelled without providing a valid + /// signature. + function test_cancelOrders_failure_invalidSignature() external { + // Create an order that can be used for testing several cases. + IHyperdriveMatchingEngine.OrderIntent memory order = _createOrderIntent( + alice, + 100_000e18, + 105_000e18, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + IHyperdriveMatchingEngine.OrderIntent[] + memory orders = new IHyperdriveMatchingEngine.OrderIntent[](1); + orders[0] = order; + + // Ensure cancelling fails with an empty signature for an EOA. + vm.stopPrank(); + vm.startPrank(alice); + vm.expectRevert(); + matchingEngine.cancelOrders(orders); + + // Ensure cancelling fails with a malformed signature for an EOA. + order.signature = hex"deadbeef"; + vm.expectRevert(); + matchingEngine.cancelOrders(orders); + + // Ensure cancelling fails when the wrong account signs the order. + order.signature = _signOrderIntent(order, bobPK); + vm.expectRevert(IHyperdriveMatchingEngine.InvalidSignature.selector); + matchingEngine.cancelOrders(orders); + + // Ensure cancelling fails when EIP1271 signer doesn't approve. + signer.setShouldVerifySignature(false); + order.trader = address(signer); + order.signature = hex"deadbeef"; + vm.expectRevert(IHyperdriveMatchingEngine.InvalidSignature.selector); + signer.cancelOrders(matchingEngine, orders); + } + + /// @dev Ensures that an EOA can cancel an order they create. + function test_cancelOrders_success_eoaOrder() external { + // Create an EOA order. + IHyperdriveMatchingEngine.OrderIntent memory order = _createOrderIntent( + alice, + 100_000e18, + 105_000e18, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + order.signature = _signOrderIntent(order, alicePK); + IHyperdriveMatchingEngine.OrderIntent[] + memory orders = new IHyperdriveMatchingEngine.OrderIntent[](1); + orders[0] = order; + + // Ensure that Alice can cancel her order. + vm.stopPrank(); + vm.startPrank(alice); + matchingEngine.cancelOrders(orders); + + // Ensure the correct event was emitted. + _verifyCancelOrdersEvent(alice, orders); + + // Ensure that the order was cancelled. + assertTrue( + matchingEngine.isCancelled(matchingEngine.hashOrderIntent(order)) + ); + } + + /// @dev Ensures that an EIP1271 signer can cancel an order they create. + function test_cancelOrders_success_eip1271Order() external { + // Create an EOA order. + IHyperdriveMatchingEngine.OrderIntent memory order = _createOrderIntent( + address(signer), + 100_000e18, + 105_000e18, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + order.signature = hex"deadbeef"; + IHyperdriveMatchingEngine.OrderIntent[] + memory orders = new IHyperdriveMatchingEngine.OrderIntent[](1); + orders[0] = order; + + // Ensure that the EIP1271 signer can cancel their order. + vm.stopPrank(); + vm.startPrank(alice); + signer.cancelOrders(matchingEngine, orders); + + // Ensure the correct event was emitted. + _verifyCancelOrdersEvent(address(signer), orders); + + // Ensure that the order was cancelled. + assertTrue( + matchingEngine.isCancelled(matchingEngine.hashOrderIntent(order)) + ); + } + + /// @dev Ensures that multiple orders can be cancelled simultanesouly by + /// EOAs and EIP1271 signers. + function test_cancelOrders_success_multipleOrders() external { + // An EOA successfully cancels several orders. + { + // The EOA creates and cancels several orders. + IHyperdriveMatchingEngine.OrderIntent[] + memory orders = new IHyperdriveMatchingEngine.OrderIntent[](3); + orders[0] = _createOrderIntent( + address(alice), + 100_000e18, + 105_000e18, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + orders[0].signature = _signOrderIntent(orders[0], alicePK); + orders[1] = _createOrderIntent( + address(alice), + 100_000e18, + 200_000e18, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + orders[1].signature = _signOrderIntent(orders[1], alicePK); + orders[2] = _createOrderIntent( + address(alice), + 100_000e18, + 105_000e18, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + orders[2].signature = _signOrderIntent(orders[2], alicePK); + vm.stopPrank(); + vm.startPrank(alice); + matchingEngine.cancelOrders(orders); + + // Ensure the correct event was emitted. + _verifyCancelOrdersEvent(address(alice), orders); + + // Ensure that the orders were cancelled. + for (uint256 i = 0; i < orders.length; i++) { + assertTrue( + matchingEngine.isCancelled( + matchingEngine.hashOrderIntent(orders[i]) + ) + ); + } + } + + // An EIP1271 signer cancels several orders. + { + // The EIP1271 signer cancels several orders. + IHyperdriveMatchingEngine.OrderIntent[] + memory orders = new IHyperdriveMatchingEngine.OrderIntent[](3); + orders[0] = _createOrderIntent( + address(signer), + 100_000e18, + 105_000e18, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + orders[0].signature = hex"deadbeef"; + orders[1] = _createOrderIntent( + address(signer), + 100_000e18, + 200_000e18, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + orders[1].signature = hex"deadbeef"; + orders[2] = _createOrderIntent( + address(signer), + 100_000e18, + 105_000e18, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + orders[2].signature = hex"deadbeef"; + vm.stopPrank(); + vm.startPrank(alice); + signer.cancelOrders(matchingEngine, orders); + + // Ensure the correct event was emitted. + _verifyCancelOrdersEvent(address(signer), orders); + + // Ensure that the orders were cancelled. + for (uint256 i = 0; i < orders.length; i++) { + assertTrue( + matchingEngine.isCancelled( + matchingEngine.hashOrderIntent(orders[i]) + ) + ); + } + } + } + + /// @dev Ensures that order matching fails when one or both of the orders + /// have the wrong order type. + function test_matchOrders_failure_invalidOrderType() external { + // Create two orders that can be used for this test. + IHyperdriveMatchingEngine.OrderIntent + memory longOrder = _createOrderIntent( + alice, + 100_000e18, + 0, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + IHyperdriveMatchingEngine.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + 105_000e18, + type(uint256).max, + IHyperdriveMatchingEngine.OrderType.OpenShort + ); + + // Ensure that order matching fails when the long order type is wrong. + longOrder.orderType = IHyperdriveMatchingEngine.OrderType.OpenShort; + vm.expectRevert(IHyperdriveMatchingEngine.InvalidOrderType.selector); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + + // Ensure that order matching fails when the short order type is wrong. + longOrder.orderType = IHyperdriveMatchingEngine.OrderType.OpenLong; + shortOrder.orderType = IHyperdriveMatchingEngine.OrderType.OpenLong; + vm.expectRevert(IHyperdriveMatchingEngine.InvalidOrderType.selector); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + + // Ensure that order matching fails when both order types are wrong. + longOrder.orderType = IHyperdriveMatchingEngine.OrderType.OpenShort; + shortOrder.orderType = IHyperdriveMatchingEngine.OrderType.OpenLong; + vm.expectRevert(IHyperdriveMatchingEngine.InvalidOrderType.selector); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + } + + /// @dev Ensures that order matching fails when one or both of the orders + /// are expired. + function test_matchOrders_failure_alreadyExpired() external { + // Create two orders that can be used for this test. + IHyperdriveMatchingEngine.OrderIntent + memory longOrder = _createOrderIntent( + alice, + 100_000e18, + 0, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + IHyperdriveMatchingEngine.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + 105_000e18, + type(uint256).max, + IHyperdriveMatchingEngine.OrderType.OpenShort + ); + + // Ensure that order matching fails when the long order is expired. + longOrder.expiry = block.timestamp - 1 days; + vm.expectRevert(IHyperdriveMatchingEngine.AlreadyExpired.selector); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + + // Ensure that order matching fails when the short order is expired. + longOrder.expiry = block.timestamp + 1 days; + shortOrder.expiry = block.timestamp; + vm.expectRevert(IHyperdriveMatchingEngine.AlreadyExpired.selector); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + + // Ensure that order matching fails when both orders are expired. + longOrder.expiry = block.timestamp - 365 days; + shortOrder.expiry = block.timestamp; + vm.expectRevert(IHyperdriveMatchingEngine.AlreadyExpired.selector); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + } + + /// @dev Ensures that order matching fails when the orders refer to + /// different Hyperdrive instances. + function test_matchOrders_failure_mismatchedHyperdrive() external { + // Create two orders that can be used for this test. These orders have + // different Hyperdrive addresses. + IHyperdriveMatchingEngine.OrderIntent + memory longOrder = _createOrderIntent( + alice, + 100_000e18, + 0, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + IHyperdriveMatchingEngine.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + 105_000e18, + type(uint256).max, + IHyperdriveMatchingEngine.OrderType.OpenShort + ); + shortOrder.hyperdrive = IHyperdrive(address(0xdeadbeef)); + + // Ensure that order matching fails when the orders have different + // Hyperdrive pools. + vm.expectRevert( + IHyperdriveMatchingEngine.MismatchedHyperdrive.selector + ); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + } + + /// @dev Ensures that order matching fails when either order and/or either + /// option have specified `asBase` as false. + function test_matchOrders_failure_invalidSettlementAsset() external { + // Create two orders that can be used for this test. + IHyperdriveMatchingEngine.OrderIntent + memory longOrder = _createOrderIntent( + alice, + 100_000e18, + 0, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + IHyperdriveMatchingEngine.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + 105_000e18, + type(uint256).max, + IHyperdriveMatchingEngine.OrderType.OpenShort + ); + + // Ensure that order matching fails when the long order specifies + // `asBase` as false. + longOrder.options.asBase = false; + vm.expectRevert( + IHyperdriveMatchingEngine.InvalidSettlementAsset.selector + ); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + + // Ensure that order matching fails when the short order specifies + // `asBase` as false. + longOrder.options.asBase = true; + shortOrder.options.asBase = false; + vm.expectRevert( + IHyperdriveMatchingEngine.InvalidSettlementAsset.selector + ); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + + // Ensure that order matching fails when the add liquidity options + // specify `asBase` as false. + shortOrder.options.asBase = true; + vm.expectRevert( + IHyperdriveMatchingEngine.InvalidSettlementAsset.selector + ); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: false, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + + // Ensure that order matching fails when the remove liquidity options + // specify `asBase` as false. + shortOrder.options.asBase = true; + vm.expectRevert( + IHyperdriveMatchingEngine.InvalidSettlementAsset.selector + ); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: false, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + } + + /// @dev Ensures that order matching fails when the price of the short is + /// higher than the price of the long. + function test_matchOrders_failure_invalidMatch() external { + // Create two orders that can be used for this test. The short has a + // higher price than the long, so these orders don't cross. + IHyperdriveMatchingEngine.OrderIntent + memory longOrder = _createOrderIntent( + alice, + 100_000e18, + 101_000e18, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + IHyperdriveMatchingEngine.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + 101_000e18, + 100e18, + IHyperdriveMatchingEngine.OrderType.OpenShort + ); + + // Ensure that order matching fails when the orders don't cross. + vm.expectRevert(IHyperdriveMatchingEngine.InvalidMatch.selector); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + } + + /// @dev Ensures that order matching fails when either or both of the orders + /// have already been cancelled. + function test_matchOrders_failure_alreadyCancelled() external { + // Create two orders that can be used for this test. + IHyperdriveMatchingEngine.OrderIntent + memory longOrder = _createOrderIntent( + alice, + 100_000e18, + 0, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + longOrder.signature = _signOrderIntent(longOrder, alicePK); + IHyperdriveMatchingEngine.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + 101_000e18, + type(uint256).max, + IHyperdriveMatchingEngine.OrderType.OpenShort + ); + shortOrder.signature = _signOrderIntent(shortOrder, bobPK); + + // Ensure that order matching fails when the long order was cancelled. + { + uint256 snapshotId = vm.snapshot(); + vm.stopPrank(); + vm.startPrank(alice); + IHyperdriveMatchingEngine.OrderIntent[] + memory orders = new IHyperdriveMatchingEngine.OrderIntent[](1); + orders[0] = longOrder; + matchingEngine.cancelOrders(orders); + vm.expectRevert( + IHyperdriveMatchingEngine.AlreadyCancelled.selector + ); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + vm.revertTo(snapshotId); + } + + // Ensure that order matching fails when the long order was cancelled. + { + uint256 snapshotId = vm.snapshot(); + vm.stopPrank(); + vm.startPrank(bob); + IHyperdriveMatchingEngine.OrderIntent[] + memory orders = new IHyperdriveMatchingEngine.OrderIntent[](1); + orders[0] = shortOrder; + matchingEngine.cancelOrders(orders); + vm.expectRevert( + IHyperdriveMatchingEngine.AlreadyCancelled.selector + ); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + vm.revertTo(snapshotId); + } + + // Ensure that order matching fails when both orders were cancelled. + { + uint256 snapshotId = vm.snapshot(); + vm.stopPrank(); + vm.startPrank(alice); + IHyperdriveMatchingEngine.OrderIntent[] + memory orders = new IHyperdriveMatchingEngine.OrderIntent[](1); + orders[0] = longOrder; + matchingEngine.cancelOrders(orders); + vm.startPrank(bob); + orders = new IHyperdriveMatchingEngine.OrderIntent[](1); + orders[0] = shortOrder; + matchingEngine.cancelOrders(orders); + vm.expectRevert( + IHyperdriveMatchingEngine.AlreadyCancelled.selector + ); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + vm.revertTo(snapshotId); + } + } + + /// @dev Ensures that order matching fails when either or both of the orders + /// have invalid signatures. + function test_matchOrders_failure_invalidSignature() external { + // Create two orders that can be used for this test. + IHyperdriveMatchingEngine.OrderIntent + memory longOrder = _createOrderIntent( + alice, + 100_000e18, + 0, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + longOrder.signature = _signOrderIntent(longOrder, alicePK); + IHyperdriveMatchingEngine.OrderIntent + memory shortOrder = _createOrderIntent( + address(signer), + 101_000e18, + type(uint256).max, + IHyperdriveMatchingEngine.OrderType.OpenShort + ); + shortOrder.signature = hex"deadbeef"; + + // Ensure that order matching fails when the long order has an invalid + // signature. + longOrder.signature = _signOrderIntent(longOrder, celinePK); + vm.expectRevert(IHyperdriveMatchingEngine.InvalidSignature.selector); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + + // Ensure that order matching fails when the short order has an invalid + // signature. + longOrder.signature = _signOrderIntent(longOrder, alicePK); + signer.setShouldVerifySignature(false); + vm.expectRevert(IHyperdriveMatchingEngine.InvalidSignature.selector); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + signer.setShouldVerifySignature(true); + + // Ensure that order matching fails when both orders have invalid + // signatures. + longOrder.signature = _signOrderIntent(longOrder, celinePK); + signer.setShouldVerifySignature(false); + vm.expectRevert(IHyperdriveMatchingEngine.InvalidSignature.selector); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + } + + /// @dev Ensures that order matching fails when either or both of the add or + /// remove liquidity options specify a destination other than the + /// matching engine. + function test_matchOrders_failure_invalidDestination() external { + // Create two orders that can be used for this test. + IHyperdriveMatchingEngine.OrderIntent + memory longOrder = _createOrderIntent( + alice, + 100_000e18, + 0, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + longOrder.signature = _signOrderIntent(longOrder, alicePK); + IHyperdriveMatchingEngine.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + 101_000e18, + type(uint256).max, + IHyperdriveMatchingEngine.OrderType.OpenShort + ); + shortOrder.signature = _signOrderIntent(shortOrder, bobPK); + + // Ensure that order matching fails when the add liquidity options + // specify a destination other than the matching engine. + vm.expectRevert(IHyperdriveMatchingEngine.InvalidDestination.selector); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(celine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + + // Ensure that order matching fails when the remove liquidity options + // specify a destination other than the matching engine. + vm.expectRevert(IHyperdriveMatchingEngine.InvalidDestination.selector); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(celine), + extraData: "" + }), + // fee recipient + celine, + true + ); + + // Ensure that order matching fails when both the add liquidity options + // and the remove liquidity options specify a destination other than the + // matching engine. + vm.expectRevert(IHyperdriveMatchingEngine.InvalidDestination.selector); + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(celine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(celine), + extraData: "" + }), + // fee recipient + celine, + true + ); + } + + /// @dev Ensures that orders can be matched and executed with the long first. + /// Both traders should receive a realized rate lower than the starting + /// rate. + function test_matchOrders_success_longFirst() external { + // Create two orders that can be used for this test. + IHyperdriveMatchingEngine.OrderIntent + memory longOrder = _createOrderIntent( + alice, + 100_000e18, + 0, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + longOrder.signature = _signOrderIntent(longOrder, alicePK); + IHyperdriveMatchingEngine.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + 101_000e18, + 101_000e18, + IHyperdriveMatchingEngine.OrderType.OpenShort + ); + shortOrder.signature = _signOrderIntent(shortOrder, bobPK); + + // Get some data before the trades are matched. + uint256 spotRate = hyperdrive.calculateSpotAPR(); + uint256 aliceBaseBalanceBefore = ERC20(hyperdrive.baseToken()) + .balanceOf(alice); + uint256 aliceLongBalanceBefore = hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Long, + hyperdrive.latestCheckpoint() + ), + alice + ); + uint256 bobBaseBalanceBefore = ERC20(hyperdrive.baseToken()).balanceOf( + bob + ); + uint256 bobShortBalanceBefore = hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + hyperdrive.latestCheckpoint() + ), + bob + ); + uint256 celineBaseBalanceBefore = ERC20(hyperdrive.baseToken()) + .balanceOf(celine); + + // Match the orders. + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + true + ); + + // Ensure that the long received a rate lower than the spot rate. + { + uint256 longPaid = aliceBaseBalanceBefore - + ERC20(hyperdrive.baseToken()).balanceOf(alice); + uint256 longAmount = hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Long, + hyperdrive.latestCheckpoint() + + hyperdrive.getPoolConfig().positionDuration + ), + alice + ) - aliceLongBalanceBefore; + uint256 longFixedRate = HyperdriveUtils + .calculateAPRFromRealizedPrice( + longPaid, + longAmount, + hyperdrive.getPoolConfig().positionDuration.divDown( + 365 days + ) + ); + assertEq(longPaid, longOrder.amount); + assertLt(longFixedRate, spotRate); + } + + // Ensure that the short received a rate lower than the spot rate. + { + uint256 shortPaid = bobBaseBalanceBefore - + ERC20(hyperdrive.baseToken()).balanceOf(bob); + uint256 shortAmount = hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + hyperdrive.latestCheckpoint() + + hyperdrive.getPoolConfig().positionDuration + ), + bob + ) - bobShortBalanceBefore; + uint256 prepaidInterest = shortAmount.mulDown( + hyperdrive.getPoolInfo().vaultSharePrice - + hyperdrive + .getCheckpoint(hyperdrive.latestCheckpoint()) + .vaultSharePrice + ); + shortPaid -= prepaidInterest; + uint256 shortFixedRate = HyperdriveUtils + .calculateAPRFromRealizedPrice( + shortAmount - + (shortPaid - + shortAmount.mulDown( + hyperdrive.getPoolConfig().fees.flat + )), + shortAmount, + hyperdrive.getPoolConfig().positionDuration.divDown( + 365 days + ) + ); + assertEq(shortAmount, shortOrder.amount); + assertLt(shortFixedRate, spotRate); + } + + // Ensure that the fee recipient receives some fees. + assertGt( + ERC20(hyperdrive.baseToken()).balanceOf(celine), + celineBaseBalanceBefore + ); + } + + /// @dev Ensures that orders can be matched and executed with the short first. + /// Both traders should receive a realized rate higher than the starting + /// rate. + function test_matchOrders_success_shortFirst() external { + // Create two orders that can be used for this test. + IHyperdriveMatchingEngine.OrderIntent + memory longOrder = _createOrderIntent( + alice, + 100_000e18, + 0, + IHyperdriveMatchingEngine.OrderType.OpenLong + ); + longOrder.signature = _signOrderIntent(longOrder, alicePK); + IHyperdriveMatchingEngine.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + 101_000e18, + 101_000e18, + IHyperdriveMatchingEngine.OrderType.OpenShort + ); + shortOrder.signature = _signOrderIntent(shortOrder, bobPK); + + // Get some data before the trades are matched. + uint256 spotRate = hyperdrive.calculateSpotAPR(); + uint256 aliceBaseBalanceBefore = ERC20(hyperdrive.baseToken()) + .balanceOf(alice); + uint256 aliceLongBalanceBefore = hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Long, + hyperdrive.latestCheckpoint() + ), + alice + ); + uint256 bobBaseBalanceBefore = ERC20(hyperdrive.baseToken()).balanceOf( + bob + ); + uint256 bobShortBalanceBefore = hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + hyperdrive.latestCheckpoint() + ), + bob + ); + uint256 celineBaseBalanceBefore = ERC20(hyperdrive.baseToken()) + .balanceOf(celine); + + // Match the orders. + matchingEngine.matchOrders( + // long order + longOrder, + // short order + shortOrder, + // LP amount + 2_000_000e18, + // add liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // remove liquidity options + IHyperdrive.Options({ + asBase: true, + destination: address(matchingEngine), + extraData: "" + }), + // fee recipient + celine, + false + ); + + // Ensure that the long received a rate lower than the spot rate. + { + uint256 longPaid = aliceBaseBalanceBefore - + ERC20(hyperdrive.baseToken()).balanceOf(alice); + uint256 longAmount = hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Long, + hyperdrive.latestCheckpoint() + + hyperdrive.getPoolConfig().positionDuration + ), + alice + ) - aliceLongBalanceBefore; + uint256 longFixedRate = HyperdriveUtils + .calculateAPRFromRealizedPrice( + longPaid, + longAmount, + hyperdrive.getPoolConfig().positionDuration.divDown( + 365 days + ) + ); + assertEq(longPaid, longOrder.amount); + assertGt(longFixedRate, spotRate); + } + + // Ensure that the short received a rate lower than the spot rate. + { + uint256 shortPaid = bobBaseBalanceBefore - + ERC20(hyperdrive.baseToken()).balanceOf(bob); + uint256 shortAmount = hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + hyperdrive.latestCheckpoint() + + hyperdrive.getPoolConfig().positionDuration + ), + bob + ) - bobShortBalanceBefore; + uint256 prepaidInterest = shortAmount.mulDown( + hyperdrive.getPoolInfo().vaultSharePrice - + hyperdrive + .getCheckpoint(hyperdrive.latestCheckpoint()) + .vaultSharePrice + ); + shortPaid -= prepaidInterest; + uint256 shortFixedRate = HyperdriveUtils + .calculateAPRFromRealizedPrice( + shortAmount - + (shortPaid - + shortAmount.mulDown( + hyperdrive.getPoolConfig().fees.flat + )), + shortAmount, + hyperdrive.getPoolConfig().positionDuration.divDown( + 365 days + ) + ); + assertEq(shortAmount, shortOrder.amount); + assertGt(shortFixedRate, spotRate); + } + + // Ensure that the fee recipient receives some fees. + assertGt( + ERC20(hyperdrive.baseToken()).balanceOf(celine), + celineBaseBalanceBefore + ); + } + + /// @dev Creates an unsigned order intent with default parameters. + /// @param _trader The trader creating the order. + /// @param _amount The amount that should be traded. This is the base + /// deposit when opening a long and the bond deposit when opening a + /// short. + /// @param _slippageGuard The slippage guard for the order. This is + /// `minOutput` when opening a long and `maxDeposit` when opening a + /// short. + /// @param _orderType The type of the order. This is either `OpenLong` or + /// `OpenShort`. + function _createOrderIntent( + address _trader, + uint256 _amount, + uint256 _slippageGuard, + IHyperdriveMatchingEngine.OrderType _orderType + ) internal returns (IHyperdriveMatchingEngine.OrderIntent memory) { + salt = keccak256(abi.encode(salt)); + return + IHyperdriveMatchingEngine.OrderIntent({ + trader: _trader, + hyperdrive: hyperdrive, + amount: _amount, + slippageGuard: _slippageGuard, + minVaultSharePrice: hyperdrive + .getCheckpoint(hyperdrive.latestCheckpoint()) + .vaultSharePrice, + options: IHyperdrive.Options({ + destination: _trader, + asBase: true, + extraData: "" + }), + orderType: _orderType, + signature: "", + expiry: block.timestamp + 1 days, + salt: salt + }); + } + + /// @dev Signs an order intent with an EOA's private key. + /// @param _order The order intent to sign. + /// @param _privateKey The private key to use when signing. + /// @return The signature. + function _signOrderIntent( + IHyperdriveMatchingEngine.OrderIntent memory _order, + uint256 _privateKey + ) internal view returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + _privateKey, + matchingEngine.hashOrderIntent(_order) + ); + return abi.encodePacked(r, s, v); + } + + /// @dev Ensure that the correct event was emitted when orders were + /// cancelled. + /// @param _trader The trader that cancelled the order. + /// @param _orders The orders that were cancelled. + function _verifyCancelOrdersEvent( + address _trader, + IHyperdriveMatchingEngine.OrderIntent[] memory _orders + ) internal { + VmSafe.Log[] memory logs = vm.getRecordedLogs().filterLogs( + OrdersCancelled.selector + ); + assertEq(logs.length, 1); + VmSafe.Log memory log = logs[0]; + assertEq(address(uint160(uint256(log.topics[1]))), _trader); + bytes32[] memory orderHashes = abi.decode(log.data, (bytes32[])); + assertEq(orderHashes.length, _orders.length); + for (uint256 i = 0; i < orderHashes.length; i++) { + assertEq( + orderHashes[i], + matchingEngine.hashOrderIntent(_orders[i]) + ); + } + } +} diff --git a/test/utils/BaseTest.sol b/test/utils/BaseTest.sol index d65edfedf..4f64f03e0 100644 --- a/test/utils/BaseTest.sol +++ b/test/utils/BaseTest.sol @@ -6,19 +6,32 @@ import { IERC20 } from "../../contracts/src/interfaces/IERC20.sol"; contract BaseTest is Test { address internal alice; + uint256 internal alicePK; address internal bob; + uint256 internal bobPK; address internal celine; + uint256 internal celinePK; address internal dan; + uint256 internal danPK; address internal eve; + uint256 internal evePK; address internal minter; + uint256 internal minterPK; address internal deployer; + uint256 internal deployerPK; address internal feeCollector; + uint256 internal feeCollectorPK; address internal sweepCollector; + uint256 internal sweepCollectorPK; address internal governance; + uint256 internal governancePK; address internal pauser; + uint256 internal pauserPK; address internal registrar; + uint256 internal registrarPK; address internal rewardSource; + uint256 internal rewardSourcePK; error WhaleBalanceExceeded(); error WhaleIsContract(); @@ -35,20 +48,20 @@ contract BaseTest is Test { bool isForked; function setUp() public virtual { - alice = createUser("alice"); - bob = createUser("bob"); - celine = createUser("celine"); - dan = createUser("dan"); - eve = createUser("eve"); - - deployer = createUser("deployer"); - minter = createUser("minter"); - feeCollector = createUser("feeCollector"); - sweepCollector = createUser("sweepCollector"); - governance = createUser("governance"); - pauser = createUser("pauser"); - registrar = createUser("registrar"); - rewardSource = createUser("rewardSource"); + (alice, alicePK) = createUser("alice"); + (bob, bobPK) = createUser("bob"); + (celine, celinePK) = createUser("celine"); + (dan, danPK) = createUser("dan"); + (eve, evePK) = createUser("eve"); + + (deployer, deployerPK) = createUser("deployer"); + (minter, minterPK) = createUser("minter"); + (feeCollector, feeCollectorPK) = createUser("feeCollector"); + (sweepCollector, sweepCollectorPK) = createUser("sweepCollector"); + (governance, governancePK) = createUser("governance"); + (pauser, pauserPK) = createUser("pauser"); + (registrar, registrarPK) = createUser("registrar"); + (rewardSource, rewardSourcePK) = createUser("rewardSource"); __init__ = block.timestamp; } @@ -108,10 +121,11 @@ contract BaseTest is Test { } // creates a user - function createUser(string memory name) public returns (address _user) { - _user = address(uint160(uint256(keccak256(abi.encode(name))))); - vm.label(_user, name); - vm.deal(_user, 10000 ether); + function createUser( + string memory _name + ) public returns (address user, uint256 privateKey) { + (user, privateKey) = makeAddrAndKey(_name); + vm.deal(user, 10000 ether); } function whaleTransfer(