diff --git a/contracts/src/interfaces/IHyperdriveMatchingEngineV2.sol b/contracts/src/interfaces/IHyperdriveMatchingEngineV2.sol new file mode 100644 index 000000000..f423fc23d --- /dev/null +++ b/contracts/src/interfaces/IHyperdriveMatchingEngineV2.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.20; + +import { IHyperdrive } from "../interfaces/IHyperdrive.sol"; + +/// @title IHyperdriveMatchingEngine +/// @notice Interface for the Hyperdrive matching engine. +interface IHyperdriveMatchingEngineV2 { + /// @notice Thrown when an order is already cancelled. + error AlreadyCancelled(); + + /// @notice Thrown when an order is already expired. + error AlreadyExpired(); + + /// @notice Thrown when the counterparty doesn't match the counterparty + /// signed into the order. + error InvalidCounterparty(); + + /// @notice Thrown when the destination for the add or remove liquidity + /// options isn't configured to this contract. + error InvalidDestination(); + + /// @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 Thrown when the amount overflows. + error AmountOverflow(); + + /// @notice Thrown when the used fund amount is greater than the order specified. + error InvalidFundAmount(); + + /// @notice Thrown when the maturity time is not within the asked range + /// in minting or transferring situations; or the maturity time + /// is not the same for both orders in burning situations. + error InvalidMaturityTime(); + + /// @notice Thrown when the funding amount is insufficient to cover the cost. + error InsufficientFunding(); + + /// @notice Thrown when the order combination is invalid. + error InvalidOrderCombination(); + + /// @notice Thrown when the order is already fully executed. + error AlreadyFullyExecuted(); + + /// @notice Emitted when orders are cancelled. + /// @param trader The address of the trader who cancelled the orders. + /// @param orderHashes The hashes of the cancelled orders. + event OrdersCancelled(address indexed trader, bytes32[] orderHashes); + + /// @notice Emitted when orders are matched. + /// @param hyperdrive The Hyperdrive contract where the trade occurred. + /// @param order1Hash The hash of the first order. + /// @param order2Hash The hash of the second order. + /// @param order1Trader The trader of the first order. + /// @param order2Trader The trader of the second order. + /// @param order1BondAmountUsed The amount of bonds used for the first order. + /// @param order2BondAmountUsed The amount of bonds used for the second order. + /// @param order1FundAmountUsed The amount of funds used for the first order. + /// @param order2FundAmountUsed The amount of funds used for the second order. + event OrdersMatched( + IHyperdrive indexed hyperdrive, + bytes32 indexed order1Hash, + bytes32 indexed order2Hash, + address order1Trader, + address order2Trader, + uint256 order1BondAmountUsed, + uint256 order2BondAmountUsed, + uint256 order1FundAmountUsed, + uint256 order2FundAmountUsed + ); + + /// @notice Emitted when an order is filled by a taker. + /// @param hyperdrive The Hyperdrive contract where the trade occurred. + /// @param orderHash The hash of the order. + /// @param maker The maker of the order. + /// @param taker The taker of the order. + /// @param bondAmount The amount of bonds used for the order. + /// @param fundAmount The amount of funds used for the order. + event OrderFilled( + IHyperdrive indexed hyperdrive, + bytes32 indexed orderHash, + address indexed maker, + address taker, + uint256 bondAmount, + uint256 fundAmount + ); + + /// @notice The type of an order intent. + enum OrderType { + OpenLong, + OpenShort, + CloseLong, + CloseShort + } + + /// @notice The order intent struct that encodes a trader's desire to trade. + /// @dev All monetary values use the same decimals as the base token. + struct OrderIntent { + /// @dev The trader address that will be charged when orders are matched. + address trader; + /// @dev The counterparty of the trade. If left as zero, the validation + /// is skipped. + address counterparty; + /// @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` or + /// `OpenShort`, this is the amount of funds to deposit; and in the + /// case of `CloseLong` or `CloseShort`, this is the min amount of + /// funds to receive. + uint256 fundAmount; + /// @dev The minimum output amount expected from the trade. In the case of + /// `OpenLong` or `OpenShort`, this is the min amount of bonds to + /// receive; and in the case of `CloseLong` or `CloseShort`, this is + /// the amount of bonds to close. + uint256 bondAmount; + /// @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. Legal values are `OpenLong`, `OpenShort`, + /// `CloseLong`, or `CloseShort`. + OrderType orderType; + /// @dev The minimum and maximum maturity time for the order. + /// For `OpenLong` or `OpenShort` orders where the `onlyNewPositions` + /// is false, these values are checked for match validation. + /// For `CloseLong` or `CloseShort` orders, these values must be equal + /// and specify the maturity time of the position to close. + uint256 minMaturityTime; + uint256 maxMaturityTime; + /// @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 Struct to track the amounts used for each order. + /// @param bondAmount The amount of bonds used. + /// @param fundAmount The amount of fund tokens used. + struct OrderAmounts { + uint128 bondAmount; + uint128 fundAmount; + } + + /// @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 buffer amount used for cost calculations. + /// @return The buffer amount. + function TOKEN_AMOUNT_BUFFER() external view returns (uint256); + + /// @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 Returns the amounts used for a specific order. + /// @param orderHash The hash of the order. + /// @return bondAmount The bond amount used for the order. + /// @return fundAmount The fund amount used for the order. + function orderAmountsUsed( + bytes32 orderHash + ) external view returns (uint128 bondAmount, uint128 fundAmount); + + /// @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 _order1 The order intent to open a long. + /// @param _order2 The order intent to open a short. + /// @param _surplusRecipient The address that receives the surplus funds from + /// matching the trades. + function matchOrders( + OrderIntent calldata _order1, + OrderIntent calldata _order2, + address _surplusRecipient + ) external; + + /// @notice Fills a maker order by the taker. + /// @param _makerOrder The maker order to fill. + /// @param _takerOrder The taker order created on the fly by the frontend. + function fillOrder( + OrderIntent calldata _makerOrder, + OrderIntent calldata _takerOrder + ) 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); + + /// @notice Handles the receipt of a single ERC1155 token type. This + /// function is called at the end of a `safeTransferFrom` after the + /// balance has been updated. + /// @return The magic function selector if the transfer is allowed, and the + /// the 0 bytes4 otherwise. + function onERC1155Received( + address, + address, + uint256, + uint256, + bytes calldata + ) external pure returns (bytes4); +} diff --git a/contracts/src/matching/HyperdriveMatchingEngineV2.sol b/contracts/src/matching/HyperdriveMatchingEngineV2.sol new file mode 100644 index 000000000..5dea3486b --- /dev/null +++ b/contracts/src/matching/HyperdriveMatchingEngineV2.sol @@ -0,0 +1,1484 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.24; + +import { ECDSA } from "openzeppelin/utils/cryptography/ECDSA.sol"; +import { EIP712 } from "openzeppelin/utils/cryptography/EIP712.sol"; +import { ERC20 } from "openzeppelin/token/ERC20/ERC20.sol"; +import { IERC1271 } from "openzeppelin/interfaces/IERC1271.sol"; +import { ReentrancyGuard } from "openzeppelin/utils/ReentrancyGuard.sol"; +import { SafeERC20 } from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; +import { AssetId } from "../libraries/AssetId.sol"; +import { FixedPointMath } from "../libraries/FixedPointMath.sol"; +import { HYPERDRIVE_MATCHING_ENGINE_KIND, VERSION } from "../libraries/Constants.sol"; +import { HyperdriveMath } from "../libraries/HyperdriveMath.sol"; +import { IHyperdrive } from "../interfaces/IHyperdrive.sol"; +import { IHyperdriveMatchingEngineV2 } from "../interfaces/IHyperdriveMatchingEngineV2.sol"; + +/// @author DELV +/// @title HyperdriveMatchingEngine +/// @notice A matching engine that processes order intents and settles trades on +/// the Hyperdrive AMM. +/// @dev This version uses direct Hyperdrive mint/burn functions instead of flash +/// loans. +/// @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 HyperdriveMatchingEngineV2 is + IHyperdriveMatchingEngineV2, + ReentrancyGuard, + EIP712 +{ + using FixedPointMath for uint256; + using SafeERC20 for ERC20; + + /// @notice The EIP712 typehash of the OrderIntent struct. + bytes32 public constant ORDER_INTENT_TYPEHASH = + keccak256( + "OrderIntent(address trader,address counterparty,address hyperdrive,uint256 fundAmount,uint256 bondAmount,uint256 minVaultSharePrice,Options options,uint8 orderType,uint256 minMaturityTime,uint256 maxMaturityTime,uint256 expiry,bytes32 salt)" + ); + + /// @notice The EIP712 typehash of the Options struct. + 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 buffer amount used for cost related calculations. + /// @dev TODO: The buffer amount needs more testing. + uint256 public constant TOKEN_AMOUNT_BUFFER = 10; + + /// @notice Mapping to track cancelled orders. + mapping(bytes32 => bool) public isCancelled; + + /// @notice Mapping to track the amounts used for each order. + mapping(bytes32 => OrderAmounts) public orderAmountsUsed; + + /// @notice Initializes the matching engine. + /// @param _name The name of this matching engine. + constructor(string memory _name) EIP712(_name, VERSION) { + name = _name; + } + + /// @notice Matches two orders. The ordering of the inputs matters, and + /// the general rule is to put the open order before the close + /// order and the long order before the short order. For example, + /// OpenLong + CloseLong is valid, but CloseLong + OpenLong is + /// invalid; OpenLong + OpenShort is valid, but OpenShort + + /// OpenLong is invalid. + /// @param _order1 The first order to match. + /// @param _order2 The second order to match. + /// @param _surplusRecipient The address that receives the surplus funds + /// from matching the trades. + function matchOrders( + OrderIntent calldata _order1, + OrderIntent calldata _order2, + address _surplusRecipient + ) external nonReentrant { + // Set the surplus recipient to the caller if not specified. + if (_surplusRecipient == address(0)) { + _surplusRecipient = msg.sender; + } + + // Validate orders. + (bytes32 order1Hash, bytes32 order2Hash) = _validateOrdersNoTaker( + _order1, + _order2 + ); + + IHyperdrive hyperdrive = _order1.hyperdrive; + ERC20 fundToken; + if (_order1.options.asBase) { + fundToken = ERC20(hyperdrive.baseToken()); + } else { + fundToken = ERC20(hyperdrive.vaultSharesToken()); + } + + // Handle different order type combinations. + // Case 1: Long + Short creation using mint(). + if ( + _order1.orderType == OrderType.OpenLong && + _order2.orderType == OrderType.OpenShort + ) { + // Calculate matching amount. + // @dev This could have been placed before the control flow for + // shorter code, but it's put here to avoid stack-too-deep. + uint256 bondMatchAmount = _calculateBondMatchAmount( + _order1, + _order2, + order1Hash, + order2Hash + ); + + // Calculate the amount of fund tokens to transfer based on the + // bondMatchAmount using dynamic pricing. During a series of partial + // matching, the pricing requirements can go easier as needed for each + // new match, hence increasing the match likelihood. + // NOTE: Round the required fund amount down to prevent overspending + // and possible reverting at a later step. + uint256 fundTokenAmountOrder1 = (_order1.fundAmount - + orderAmountsUsed[order1Hash].fundAmount).mulDivDown( + bondMatchAmount, + (_order1.bondAmount - + orderAmountsUsed[order1Hash].bondAmount) + ); + uint256 fundTokenAmountOrder2 = (_order2.fundAmount - + orderAmountsUsed[order2Hash].fundAmount).mulDivDown( + bondMatchAmount, + (_order2.bondAmount - + orderAmountsUsed[order2Hash].bondAmount) + ); + + // Update order fund amount used. + _updateOrderAmount(order1Hash, fundTokenAmountOrder1, false); + _updateOrderAmount(order2Hash, fundTokenAmountOrder2, false); + + // Check if the fund amount used is greater than the order amount. + if ( + orderAmountsUsed[order1Hash].fundAmount > _order1.fundAmount || + orderAmountsUsed[order2Hash].fundAmount > _order2.fundAmount + ) { + revert InvalidFundAmount(); + } + + // Calculate costs and parameters. + (uint256 maturityTime, uint256 cost) = _calculateMintCost( + hyperdrive, + bondMatchAmount + ); + + // Check if the maturity time is within the range. + if ( + maturityTime < _order1.minMaturityTime || + maturityTime > _order1.maxMaturityTime || + maturityTime < _order2.minMaturityTime || + maturityTime > _order2.maxMaturityTime + ) { + revert InvalidMaturityTime(); + } + + // Mint the bonds. + uint256 bondAmount = _handleMint( + _order1, + _order2, + fundTokenAmountOrder1, + fundTokenAmountOrder2, + cost, + bondMatchAmount, + fundToken, + hyperdrive + ); + + // Update order bond amount used. + _updateOrderAmount(order1Hash, bondAmount, true); + _updateOrderAmount(order2Hash, bondAmount, true); + } + // Case 2: Long + Short closing using burn(). + else if ( + _order1.orderType == OrderType.CloseLong && + _order2.orderType == OrderType.CloseShort + ) { + // Verify both orders have the same maturity time. + if (_order1.maxMaturityTime != _order2.maxMaturityTime) { + revert InvalidMaturityTime(); + } + + // Calculate matching amount. + uint256 bondMatchAmount = _calculateBondMatchAmount( + _order1, + _order2, + order1Hash, + order2Hash + ); + + // Get the min fund output according to the bondMatchAmount. + // NOTE: Round the required fund amount up to respect the order specified + // min fund output. + uint256 minFundAmountOrder1 = (_order1.fundAmount - + orderAmountsUsed[order1Hash].fundAmount).mulDivUp( + bondMatchAmount, + (_order1.bondAmount - + orderAmountsUsed[order1Hash].bondAmount) + ); + uint256 minFundAmountOrder2 = (_order2.fundAmount - + orderAmountsUsed[order2Hash].fundAmount).mulDivUp( + bondMatchAmount, + (_order2.bondAmount - + orderAmountsUsed[order2Hash].bondAmount) + ); + + // Update order bond amount used. + // @dev After the update, there is no need to check if the bond + // amount used is greater than the order amount, as the order + // amount is already used to calculate the bondMatchAmount. + _updateOrderAmount(order1Hash, bondMatchAmount, true); + _updateOrderAmount(order2Hash, bondMatchAmount, true); + + // Handle burn operation through helper function. + _handleBurn( + _order1, + _order2, + minFundAmountOrder1, + minFundAmountOrder2, + bondMatchAmount, + fundToken, + hyperdrive + ); + + // Update order fund amount used. + _updateOrderAmount(order1Hash, minFundAmountOrder1, false); + _updateOrderAmount(order2Hash, minFundAmountOrder2, false); + } + // Case 3: Transfer positions between traders. + else if ( + (_order1.orderType == OrderType.OpenLong && + _order2.orderType == OrderType.CloseLong) || + (_order1.orderType == OrderType.OpenShort && + _order2.orderType == OrderType.CloseShort) + ) { + // Verify that the maturity time of the close order matches the + // open order's requirements. + if ( + _order2.maxMaturityTime > _order1.maxMaturityTime || + _order2.maxMaturityTime < _order1.minMaturityTime + ) { + revert InvalidMaturityTime(); + } + + // Calculate matching amount. + uint256 bondMatchAmount = _calculateBondMatchAmount( + _order1, + _order2, + order1Hash, + order2Hash + ); + + // Calculate the amount of fund tokens to transfer based on the + // bondMatchAmount using dynamic pricing. During a series of partial + // matching, the pricing requirements can go easier as needed for each + // new match, hence increasing the match likelihood. + // NOTE: Round the required fund amount down to prevent overspending + // and possible reverting at a later step. + uint256 fundTokenAmountOrder1 = (_order1.fundAmount - + orderAmountsUsed[order1Hash].fundAmount).mulDivDown( + bondMatchAmount, + (_order1.bondAmount - + orderAmountsUsed[order1Hash].bondAmount) + ); + + // Get the min fund output according to the bondMatchAmount. + // NOTE: Round the required fund amount up to respect the order specified + // min fund output. + uint256 minFundAmountOrder2 = (_order2.fundAmount - + orderAmountsUsed[order2Hash].fundAmount).mulDivUp( + bondMatchAmount, + (_order2.bondAmount - + orderAmountsUsed[order2Hash].bondAmount) + ); + + // Check if trader 1 has enough fund to transfer to trader 2. + // @dev Also considering any donations to help match the orders. + if ( + fundTokenAmountOrder1 + fundToken.balanceOf(address(this)) < + minFundAmountOrder2 + ) { + revert InsufficientFunding(); + } + + // Update order bond amount used. + // @dev After the update, there is no need to check if the bond + // amount used is greater than the order amount, as the order + // amount is already used to calculate the bondMatchAmount. + _updateOrderAmount(order1Hash, bondMatchAmount, true); + _updateOrderAmount(order2Hash, bondMatchAmount, true); + + _handleTransfer( + _order1, + _order2, + fundTokenAmountOrder1, + minFundAmountOrder2, + bondMatchAmount, + fundToken, + hyperdrive + ); + + if (fundTokenAmountOrder1 < minFundAmountOrder2) { + fundToken.safeTransfer( + _order2.options.destination, + minFundAmountOrder2 - fundTokenAmountOrder1 + ); + } else if (fundTokenAmountOrder1 > minFundAmountOrder2) { + fundToken.safeTransferFrom( + _order1.trader, + _surplusRecipient, + fundTokenAmountOrder1 - minFundAmountOrder2 + ); + } + + // Update order fund amount used. + _updateOrderAmount(order1Hash, fundTokenAmountOrder1, false); + _updateOrderAmount(order2Hash, minFundAmountOrder2, false); + } + // All other cases are invalid. + else { + revert InvalidOrderCombination(); + } + + // Transfer the remaining fund tokens back to the surplus recipient. + uint256 remainingBalance = fundToken.balanceOf(address(this)); + if (remainingBalance > 0) { + fundToken.safeTransfer(_surplusRecipient, remainingBalance); + } + + emit OrdersMatched( + hyperdrive, + order1Hash, + order2Hash, + _order1.trader, + _order2.trader, + orderAmountsUsed[order1Hash].bondAmount, + orderAmountsUsed[order2Hash].bondAmount, + orderAmountsUsed[order1Hash].fundAmount, + orderAmountsUsed[order2Hash].fundAmount + ); + } + + /// @notice Fills a maker order by the taker. + /// @param _makerOrder The maker order to fill. + /// @param _takerOrder The taker order created on the fly by the frontend. + /// @dev The frontend will have to take some necessary values from the user + /// like the destination address, the orderType, and the maturity time + /// for the close position...etc., and create the minimal sufficient + /// struct as the _takerOrder argument. For example: + /// + /// OrderIntent({ + /// trader: msg.sender, // Take the user's address. + /// counterparty: address(0), // Not needed for immediate fill. + /// hyperdrive: IHyperdrive(address(0)), // Not needed for immediate fill. + /// fundAmount: 0, // Not needed for immediate fill. + /// bondAmount: _bondAmount, // Take from the user's input. + /// minVaultSharePrice: _makerOrder.minVaultSharePrice, // From maker order. + /// options: IHyperdrive.Options({ + /// // Take destination from the user's input; if null, set to msg.sender. + /// destination: _destination or msg.sender, + /// asBase: _makerOrder.options.asBase, // From maker order. + /// extraData: "" + /// }), + /// orderType: _takerOrderType, // Take from the user's input. + /// // For closing positions, take maturity time from the user's input; + /// // otherwise, use values from the maker order. + /// minMaturityTime: _closeOrderMaturityTime or _makerOrder.minMaturityTime, + /// maxMaturityTime: _closeOrderMaturityTime or _makerOrder.maxMaturityTime, + /// expiry: 0, // Not needed for immediate fill. + /// salt: 0, // Not needed for immediate fill. + /// signature: "" // Not needed for immediate fill. + /// }) + function fillOrder( + OrderIntent calldata _makerOrder, + OrderIntent calldata _takerOrder + ) external nonReentrant { + // Validate maker order and taker order. + bytes32 makerOrderHash = _validateOrdersWithTaker( + _makerOrder, + _takerOrder + ); + + // Calculates the amount of bonds that can be matched between two orders. + OrderAmounts memory amountsMaker = orderAmountsUsed[makerOrderHash]; + uint256 makerBondAmount = _makerOrder.bondAmount - + amountsMaker.bondAmount; + uint256 bondMatchAmount = makerBondAmount.min(_takerOrder.bondAmount); + + IHyperdrive hyperdrive = _makerOrder.hyperdrive; + ERC20 fundToken; + if (_makerOrder.options.asBase) { + fundToken = ERC20(hyperdrive.baseToken()); + } else { + fundToken = ERC20(hyperdrive.vaultSharesToken()); + } + + // Handle different maker order types. + if (_makerOrder.orderType == OrderType.OpenLong) { + if (_takerOrder.orderType == OrderType.OpenShort) { + // OpenLong + OpenShort: _handleMint(). + // Calculate the amount of fund tokens to transfer based on the + // bondMatchAmount using dynamic pricing. During a series of partial + // matching, the pricing requirements can go easier as needed for + // each new match, hence increasing the match likelihood. + // NOTE: Round the required fund amount down to prevent overspending + // and possible reverting at a later step. + uint256 fundTokenAmountMaker = (_makerOrder.fundAmount - + orderAmountsUsed[makerOrderHash].fundAmount).mulDivDown( + bondMatchAmount, + (_makerOrder.bondAmount - + orderAmountsUsed[makerOrderHash].bondAmount) + ); + + // Update order fund amount used. + _updateOrderAmount(makerOrderHash, fundTokenAmountMaker, false); + + // Check if the fund amount used is greater than the order amount. + if ( + orderAmountsUsed[makerOrderHash].fundAmount > + _makerOrder.fundAmount + ) { + revert InvalidFundAmount(); + } + + // Calculate costs and parameters. + (uint256 maturityTime, uint256 cost) = _calculateMintCost( + hyperdrive, + bondMatchAmount + ); + + // Check if the maturity time is within the range. + if ( + maturityTime < _makerOrder.minMaturityTime || + maturityTime > _makerOrder.maxMaturityTime + ) { + revert InvalidMaturityTime(); + } + + // Calculate the amount of fund tokens the taker needs to pay. + uint256 fundTokenAmountTaker = fundTokenAmountMaker > + cost + TOKEN_AMOUNT_BUFFER + ? 0 + : cost + TOKEN_AMOUNT_BUFFER - fundTokenAmountMaker; + + // Mint the bonds. + uint256 bondAmount = _handleMint( + _makerOrder, + _takerOrder, + fundTokenAmountMaker, + fundTokenAmountTaker, + cost, + bondMatchAmount, + fundToken, + hyperdrive + ); + + // Update order bond amount used. + _updateOrderAmount(makerOrderHash, bondAmount, true); + } else if (_takerOrder.orderType == OrderType.CloseLong) { + // OpenLong + CloseLong: _handleTransfer(). + // Verify that the maturity time of the close order matches the + // open order's requirements. + if ( + _takerOrder.maxMaturityTime > _makerOrder.maxMaturityTime || + _takerOrder.maxMaturityTime < _makerOrder.minMaturityTime + ) { + revert InvalidMaturityTime(); + } + + // Calculate the amount of fund tokens to transfer based on the + // bondMatchAmount using dynamic pricing. During a series of partial + // matching, the pricing requirements can go easier as needed for each + // new match, hence increasing the match likelihood. + // NOTE: Round the required fund amount down to prevent overspending + // and possible reverting at a later step. + uint256 fundTokenAmountMaker = (_makerOrder.fundAmount - + orderAmountsUsed[makerOrderHash].fundAmount).mulDivDown( + bondMatchAmount, + (_makerOrder.bondAmount - + orderAmountsUsed[makerOrderHash].bondAmount) + ); + + // The taker simply agrees with the maker's fund amount, and no + // additional donation nor validations need to be considered + uint256 minFundAmountTaker = fundTokenAmountMaker; + + // Update order bond amount used. + // @dev After the update, there is no need to check if the bond + // amount used is greater than the order amount, as the order + // amount is already used to calculate the bondMatchAmount. + _updateOrderAmount(makerOrderHash, bondMatchAmount, true); + + _handleTransfer( + _makerOrder, + _takerOrder, + fundTokenAmountMaker, + minFundAmountTaker, + bondMatchAmount, + fundToken, + hyperdrive + ); + + // Update order fund amount used. + _updateOrderAmount(makerOrderHash, fundTokenAmountMaker, false); + } else { + revert InvalidOrderCombination(); + } + } else if (_makerOrder.orderType == OrderType.OpenShort) { + if (_takerOrder.orderType == OrderType.OpenLong) { + // OpenShort + OpenLong: _handleMint() but reverse the order. + // Calculate the amount of fund tokens to transfer based on the + // bondMatchAmount using dynamic pricing. During a series of partial + // matching, the pricing requirements can go easier as needed for + // each new match, hence increasing the match likelihood. + // NOTE: Round the required fund amount down to prevent overspending + // and possible reverting at a later step. + uint256 fundTokenAmountMaker = (_makerOrder.fundAmount - + orderAmountsUsed[makerOrderHash].fundAmount).mulDivDown( + bondMatchAmount, + (_makerOrder.bondAmount - + orderAmountsUsed[makerOrderHash].bondAmount) + ); + + // Update order fund amount used. + _updateOrderAmount(makerOrderHash, fundTokenAmountMaker, false); + + // Check if the fund amount used is greater than the order amount. + if ( + orderAmountsUsed[makerOrderHash].fundAmount > + _makerOrder.fundAmount + ) { + revert InvalidFundAmount(); + } + + // Calculate costs and parameters. + (uint256 maturityTime, uint256 cost) = _calculateMintCost( + hyperdrive, + bondMatchAmount + ); + + // Check if the maturity time is within the range. + if ( + maturityTime < _makerOrder.minMaturityTime || + maturityTime > _makerOrder.maxMaturityTime + ) { + revert InvalidMaturityTime(); + } + + // Calculate the amount of fund tokens the taker needs to pay. + uint256 fundTokenAmountTaker = fundTokenAmountMaker > + cost + TOKEN_AMOUNT_BUFFER + ? 0 + : cost + TOKEN_AMOUNT_BUFFER - fundTokenAmountMaker; + + // Mint the bonds. + uint256 bondAmount = _handleMint( + _takerOrder, + _makerOrder, + fundTokenAmountTaker, + fundTokenAmountMaker, + cost, + bondMatchAmount, + fundToken, + hyperdrive + ); + + // Update order bond amount used. + _updateOrderAmount(makerOrderHash, bondAmount, true); + } else if (_takerOrder.orderType == OrderType.CloseShort) { + // OpenShort + CloseShort: _handleTransfer(). + // Verify that the maturity time of the close order matches the + // open order's requirements. + if ( + _takerOrder.maxMaturityTime > _makerOrder.maxMaturityTime || + _takerOrder.maxMaturityTime < _makerOrder.minMaturityTime + ) { + revert InvalidMaturityTime(); + } + + // Calculate the amount of fund tokens to transfer based on the + // bondMatchAmount using dynamic pricing. During a series of partial + // matching, the pricing requirements can go easier as needed for each + // new match, hence increasing the match likelihood. + // NOTE: Round the required fund amount down to prevent overspending + // and possible reverting at a later step. + uint256 fundTokenAmountMaker = (_makerOrder.fundAmount - + orderAmountsUsed[makerOrderHash].fundAmount).mulDivDown( + bondMatchAmount, + (_makerOrder.bondAmount - + orderAmountsUsed[makerOrderHash].bondAmount) + ); + + // The taker simply agrees with the maker's fund amount, and no + // additional donation nor validations need to be considered + uint256 minFundAmountTaker = fundTokenAmountMaker; + + // Update order bond amount used. + // @dev After the update, there is no need to check if the bond + // amount used is greater than the order amount, as the order + // amount is already used to calculate the bondMatchAmount. + _updateOrderAmount(makerOrderHash, bondMatchAmount, true); + + _handleTransfer( + _makerOrder, + _takerOrder, + fundTokenAmountMaker, + minFundAmountTaker, + bondMatchAmount, + fundToken, + hyperdrive + ); + + // Update order fund amount used. + _updateOrderAmount(makerOrderHash, fundTokenAmountMaker, false); + } else { + revert InvalidOrderCombination(); + } + } else if (_makerOrder.orderType == OrderType.CloseLong) { + if (_takerOrder.orderType == OrderType.OpenLong) { + // CloseLong + OpenLong: _handleTransfer() but reverse the order. + // Verify that the maturity time of the close order matches the + // open order's requirements. + if ( + _makerOrder.maxMaturityTime > _takerOrder.maxMaturityTime || + _makerOrder.maxMaturityTime < _takerOrder.minMaturityTime + ) { + revert InvalidMaturityTime(); + } + + // Calculate the amount of fund tokens to transfer based on the + // bondMatchAmount using dynamic pricing. During a series of partial + // matching, the pricing requirements can go easier as needed for each + // new match, hence increasing the match likelihood. + // NOTE: Round the required fund amount down to prevent overspending + // and possible reverting at a later step. + uint256 minFundAmountMaker = (_makerOrder.fundAmount - + orderAmountsUsed[makerOrderHash].fundAmount).mulDivDown( + bondMatchAmount, + (_makerOrder.bondAmount - + orderAmountsUsed[makerOrderHash].bondAmount) + ); + + // The taker simply agrees with the maker's fund amount, and no + // additional donation nor validations need to be considered + uint256 fundTokenAmountTaker = minFundAmountMaker; + + // Update order bond amount used. + // @dev After the update, there is no need to check if the bond + // amount used is greater than the order amount, as the order + // amount is already used to calculate the bondMatchAmount. + _updateOrderAmount(makerOrderHash, bondMatchAmount, true); + + _handleTransfer( + _takerOrder, + _makerOrder, + fundTokenAmountTaker, + minFundAmountMaker, + bondMatchAmount, + fundToken, + hyperdrive + ); + + // Update order fund amount used. + _updateOrderAmount(makerOrderHash, minFundAmountMaker, false); + } else if (_takerOrder.orderType == OrderType.CloseShort) { + // CloseLong + CloseShort: _handleBurn(). + // Verify both orders have the same maturity time. + if ( + _makerOrder.maxMaturityTime != _takerOrder.maxMaturityTime + ) { + revert InvalidMaturityTime(); + } + + // Get the min fund output according to the bondMatchAmount. + // NOTE: Round the required fund amount up to respect the order + // specified min fund output. + uint256 minFundAmountMaker = (_makerOrder.fundAmount - + orderAmountsUsed[makerOrderHash].fundAmount).mulDivUp( + bondMatchAmount, + (_makerOrder.bondAmount - + orderAmountsUsed[makerOrderHash].bondAmount) + ); + + // The taker takes whatever the leftover fund amount is. + // @dev The taker will not receive proceeds inside the _handleBurn(), + // but will receive the leftover fund at the surplus distribution. + uint256 minFundAmountTaker = 0; + + // Update order bond amount used. + // @dev After the update, there is no need to check if the bond + // amount used is greater than the order amount, as the order + // amount is already used to calculate the bondMatchAmount. + _updateOrderAmount(makerOrderHash, bondMatchAmount, true); + + // Handle burn operation through helper function. + _handleBurn( + _makerOrder, + _takerOrder, + minFundAmountMaker, + minFundAmountTaker, + bondMatchAmount, + fundToken, + hyperdrive + ); + + // Update order fund amount used. + _updateOrderAmount(makerOrderHash, minFundAmountMaker, false); + } else { + revert InvalidOrderCombination(); + } + } else if (_makerOrder.orderType == OrderType.CloseShort) { + if (_takerOrder.orderType == OrderType.OpenShort) { + // CloseShort + OpenShort: _handleTransfer() but reverse the order. + // Verify that the maturity time of the close order matches the + // open order's requirements. + if ( + _makerOrder.maxMaturityTime > _takerOrder.maxMaturityTime || + _makerOrder.maxMaturityTime < _takerOrder.minMaturityTime + ) { + revert InvalidMaturityTime(); + } + + // Calculate the amount of fund tokens to transfer based on the + // bondMatchAmount using dynamic pricing. During a series of partial + // matching, the pricing requirements can go easier as needed for each + // new match, hence increasing the match likelihood. + // NOTE: Round the required fund amount down to prevent overspending + // and possible reverting at a later step. + uint256 minFundAmountMaker = (_makerOrder.fundAmount - + orderAmountsUsed[makerOrderHash].fundAmount).mulDivDown( + bondMatchAmount, + (_makerOrder.bondAmount - + orderAmountsUsed[makerOrderHash].bondAmount) + ); + + // The taker simply agrees with the maker's fund amount, and no + // additional donation nor validations need to be considered + uint256 fundTokenAmountTaker = minFundAmountMaker; + + // Update order bond amount used. + // @dev After the update, there is no need to check if the bond + // amount used is greater than the order amount, as the order + // amount is already used to calculate the bondMatchAmount. + _updateOrderAmount(makerOrderHash, bondMatchAmount, true); + + _handleTransfer( + _takerOrder, + _makerOrder, + fundTokenAmountTaker, + minFundAmountMaker, + bondMatchAmount, + fundToken, + hyperdrive + ); + + // Update order fund amount used. + _updateOrderAmount(makerOrderHash, minFundAmountMaker, false); + } else if (_takerOrder.orderType == OrderType.CloseLong) { + // CloseShort + CloseLong: _handleBurn() but reverse the order. + // Verify both orders have the same maturity time. + if ( + _makerOrder.maxMaturityTime != _takerOrder.maxMaturityTime + ) { + revert InvalidMaturityTime(); + } + + // Get the min fund output according to the bondMatchAmount. + // NOTE: Round the required fund amount up to respect the order + // specified min fund output. + uint256 minFundAmountMaker = (_makerOrder.fundAmount - + orderAmountsUsed[makerOrderHash].fundAmount).mulDivUp( + bondMatchAmount, + (_makerOrder.bondAmount - + orderAmountsUsed[makerOrderHash].bondAmount) + ); + + // The taker takes whatever the leftover fund amount is. + // @dev The taker will not receive proceeds inside the _handleBurn(), + // but will receive the leftover fund at the surplus distribution. + uint256 minFundAmountTaker = 0; + + // Update order bond amount used. + // @dev After the update, there is no need to check if the bond + // amount used is greater than the order amount, as the order + // amount is already used to calculate the bondMatchAmount. + _updateOrderAmount(makerOrderHash, bondMatchAmount, true); + + // Handle burn operation through helper function. + _handleBurn( + _takerOrder, + _makerOrder, + minFundAmountTaker, + minFundAmountMaker, + bondMatchAmount, + fundToken, + hyperdrive + ); + + // Update order fund amount used. + _updateOrderAmount(makerOrderHash, minFundAmountMaker, false); + } else { + revert InvalidOrderCombination(); + } + } else { + revert InvalidOrderCombination(); + } + + // Transfer any remaining fund tokens back to the taker's destination. + uint256 remainingBalance = fundToken.balanceOf(address(this)); + if (remainingBalance > 0) { + fundToken.safeTransfer( + _takerOrder.options.destination, + remainingBalance + ); + } + + emit OrderFilled( + _makerOrder.hyperdrive, + makerOrderHash, + _makerOrder.trader, + _takerOrder.trader, + orderAmountsUsed[makerOrderHash].bondAmount, + orderAmountsUsed[makerOrderHash].fundAmount + ); + } + + /// @notice Allows traders to cancel their orders. + /// @param _orders Array of orders to cancel. + function cancelOrders( + OrderIntent[] calldata _orders + ) external nonReentrant { + bytes32[] memory orderHashes = new bytes32[](_orders.length); + for (uint256 i = 0; i < _orders.length; i++) { + // Ensure sender is the trader. + if (msg.sender != _orders[i].trader) { + revert InvalidSender(); + } + + // Verify signature. + 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 Hashes an order intent according to EIP-712. + /// @param _order The order intent to hash. + /// @return The hash of the order intent. + /// @dev Use two helper functions to encode to avoid stack too deep. + function hashOrderIntent( + OrderIntent calldata _order + ) public view returns (bytes32) { + // Get the encoded parts. + bytes memory encodedPart1 = _encodeOrderPart1(_order); + bytes memory encodedPart2 = _encodeOrderPart2(_order); + + // Concatenate and calculate the final hash + return + _hashTypedDataV4( + keccak256(bytes.concat(encodedPart1, encodedPart2)) + ); + } + + /// @notice Verifies a signature for a given 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 + ) public view returns (bool) { + // For contracts, use EIP-1271. + if (_signer.code.length > 0) { + try IERC1271(_signer).isValidSignature(_hash, _signature) returns ( + bytes4 magicValue + ) { + return magicValue == IERC1271.isValidSignature.selector; + } catch { + return false; + } + } + + // For EOAs, verify ECDSA signature. + return ECDSA.recover(_hash, _signature) == _signer; + } + + /// @dev Encodes the first part of the order intent. + /// @param _order The order intent to encode. + /// @return The encoded part of the order intent. + function _encodeOrderPart1( + OrderIntent calldata _order + ) internal pure returns (bytes memory) { + return + abi.encode( + ORDER_INTENT_TYPEHASH, + _order.trader, + _order.counterparty, + address(_order.hyperdrive), + _order.fundAmount, + _order.bondAmount + ); + } + + /// @dev Encodes the second part of the order intent. + /// @param _order The order intent to encode. + /// @return The encoded part of the order intent. + function _encodeOrderPart2( + OrderIntent calldata _order + ) internal pure returns (bytes memory) { + bytes32 optionsHash = keccak256( + abi.encode( + OPTIONS_TYPEHASH, + _order.options.destination, + _order.options.asBase + ) + ); + + return + abi.encode( + _order.minVaultSharePrice, + optionsHash, + uint8(_order.orderType), + _order.minMaturityTime, + _order.maxMaturityTime, + _order.expiry, + _order.salt + ); + } + + /// @dev Validates orders before matching them. + /// @param _order1 The first order to validate. + /// @param _order2 The second order to validate. + /// @return order1Hash The hash of the first order. + /// @return order2Hash The hash of the second order. + function _validateOrdersNoTaker( + OrderIntent calldata _order1, + OrderIntent calldata _order2 + ) internal view returns (bytes32 order1Hash, bytes32 order2Hash) { + // Verify counterparties. + if ( + (_order1.counterparty != address(0) && + _order1.counterparty != _order2.trader) || + (_order2.counterparty != address(0) && + _order2.counterparty != _order1.trader) + ) { + revert InvalidCounterparty(); + } + + // Check expiry. + if ( + _order1.expiry <= block.timestamp || + _order2.expiry <= block.timestamp + ) { + revert AlreadyExpired(); + } + + // Verify Hyperdrive instance. + if (_order1.hyperdrive != _order2.hyperdrive) { + revert MismatchedHyperdrive(); + } + + // Verify settlement asset. + // @dev TODO: only supporting both true or both false for now. + // Supporting mixed asBase values needs code changes on the Hyperdrive + // instances. + if (_order1.options.asBase != _order2.options.asBase) { + revert InvalidSettlementAsset(); + } + + // Verify valid maturity time. + if ( + _order1.minMaturityTime > _order1.maxMaturityTime || + _order2.minMaturityTime > _order2.maxMaturityTime + ) { + revert InvalidMaturityTime(); + } + + // For close orders, minMaturityTime must equal maxMaturityTime. + if ( + _order1.orderType == OrderType.CloseLong || + _order1.orderType == OrderType.CloseShort + ) { + if (_order1.minMaturityTime != _order1.maxMaturityTime) { + revert InvalidMaturityTime(); + } + } + if ( + _order2.orderType == OrderType.CloseLong || + _order2.orderType == OrderType.CloseShort + ) { + if (_order2.minMaturityTime != _order2.maxMaturityTime) { + revert InvalidMaturityTime(); + } + } + + // Check that the destination is not the zero address. + if ( + _order1.options.destination == address(0) || + _order2.options.destination == address(0) + ) { + revert InvalidDestination(); + } + + // Hash orders. + order1Hash = hashOrderIntent(_order1); + order2Hash = hashOrderIntent(_order2); + + // Check if orders are fully executed. + if ( + orderAmountsUsed[order1Hash].bondAmount >= _order1.bondAmount || + orderAmountsUsed[order1Hash].fundAmount >= _order1.fundAmount + ) { + revert AlreadyFullyExecuted(); + } + if ( + orderAmountsUsed[order2Hash].bondAmount >= _order2.bondAmount || + orderAmountsUsed[order2Hash].fundAmount >= _order2.fundAmount + ) { + revert AlreadyFullyExecuted(); + } + + // Check if orders are cancelled. + if (isCancelled[order1Hash] || isCancelled[order2Hash]) { + revert AlreadyCancelled(); + } + + // Verify signatures. + if ( + !verifySignature(order1Hash, _order1.signature, _order1.trader) || + !verifySignature(order2Hash, _order2.signature, _order2.trader) + ) { + revert InvalidSignature(); + } + } + + /// @dev Validates the maker and taker orders. This function has shrinked + /// logic from the _validateOrdersNoTaker function, as the taker order + /// is only a minimal sufficient struct created by the frontend, with + /// some fields being faulty values. + /// @param _makerOrder The maker order to validate. + /// @param _takerOrder The taker order to validate. + /// @return makerOrderHash The hash of the maker order. + function _validateOrdersWithTaker( + OrderIntent calldata _makerOrder, + OrderIntent calldata _takerOrder + ) internal view returns (bytes32 makerOrderHash) { + // Verify the maker's counterparty is the taker. + if ( + (_makerOrder.counterparty != address(0) && + _makerOrder.counterparty != _takerOrder.trader) + ) { + revert InvalidCounterparty(); + } + + // Check expiry. + if (_makerOrder.expiry <= block.timestamp) { + revert AlreadyExpired(); + } + + // Verify settlement asset. + // @dev TODO: only supporting both true or both false for now. + // Supporting mixed asBase values needs code changes on the Hyperdrive + // instances. + if (_makerOrder.options.asBase != _takerOrder.options.asBase) { + revert InvalidSettlementAsset(); + } + + // Verify valid maturity time. + if ( + _makerOrder.minMaturityTime > _makerOrder.maxMaturityTime || + _takerOrder.minMaturityTime > _takerOrder.maxMaturityTime + ) { + revert InvalidMaturityTime(); + } + + // For the close order, minMaturityTime must equal maxMaturityTime. + if ( + _makerOrder.orderType == OrderType.CloseLong || + _makerOrder.orderType == OrderType.CloseShort + ) { + if (_makerOrder.minMaturityTime != _makerOrder.maxMaturityTime) { + revert InvalidMaturityTime(); + } + } + if ( + _takerOrder.orderType == OrderType.CloseLong || + _takerOrder.orderType == OrderType.CloseShort + ) { + if (_takerOrder.minMaturityTime != _takerOrder.maxMaturityTime) { + revert InvalidMaturityTime(); + } + } + + // Check that the destination is not the zero address. + if ( + _makerOrder.options.destination == address(0) || + _takerOrder.options.destination == address(0) + ) { + revert InvalidDestination(); + } + + // Hash the order. + makerOrderHash = hashOrderIntent(_makerOrder); + + // Check if the maker order is fully executed. + if ( + orderAmountsUsed[makerOrderHash].bondAmount >= + _makerOrder.bondAmount || + orderAmountsUsed[makerOrderHash].fundAmount >= + _makerOrder.fundAmount + ) { + revert AlreadyFullyExecuted(); + } + + // Check if the maker order is cancelled. + if (isCancelled[makerOrderHash]) { + revert AlreadyCancelled(); + } + + // Verify the maker's signature. + if ( + !verifySignature( + makerOrderHash, + _makerOrder.signature, + _makerOrder.trader + ) + ) { + revert InvalidSignature(); + } + } + + /// @dev Calculates the amount of bonds that can be matched between two orders. + /// @param _order1 The first order to match. + /// @param _order2 The second order to match. + /// @param _order1Hash The hash of the first order. + /// @param _order2Hash The hash of the second order. + /// @return bondMatchAmount The amount of bonds that can be matched. + function _calculateBondMatchAmount( + OrderIntent calldata _order1, + OrderIntent calldata _order2, + bytes32 _order1Hash, + bytes32 _order2Hash + ) internal view returns (uint256 bondMatchAmount) { + OrderAmounts memory amounts1 = orderAmountsUsed[_order1Hash]; + OrderAmounts memory amounts2 = orderAmountsUsed[_order2Hash]; + + uint256 order1BondAmount = _order1.bondAmount - amounts1.bondAmount; + uint256 order2BondAmount = _order2.bondAmount - amounts2.bondAmount; + + bondMatchAmount = order1BondAmount.min(order2BondAmount); + } + + /// @dev Handles the minting of matching positions. + /// @param _longOrder The order for opening a long position. + /// @param _shortOrder The order for opening a short position. + /// @param _fundTokenAmountLongOrder The amount of fund tokens from the long + /// order. + /// @param _fundTokenAmountShortOrder The amount of fund tokens from the short + /// order. + /// @param _cost The total cost of the operation. + /// @param _bondMatchAmount The amount of bonds to mint. + /// @param _fundToken The fund token being used. + /// @param _hyperdrive The Hyperdrive contract instance. + /// @return The amount of bonds minted. + function _handleMint( + OrderIntent calldata _longOrder, + OrderIntent calldata _shortOrder, + uint256 _fundTokenAmountLongOrder, + uint256 _fundTokenAmountShortOrder, + uint256 _cost, + uint256 _bondMatchAmount, + ERC20 _fundToken, + IHyperdrive _hyperdrive + ) internal returns (uint256) { + // Transfer fund tokens from long trader. + _fundToken.safeTransferFrom( + _longOrder.trader, + address(this), + _fundTokenAmountLongOrder + ); + + // Transfer fund tokens from short trader. + _fundToken.safeTransferFrom( + _shortOrder.trader, + address(this), + _fundTokenAmountShortOrder + ); + + // Approve Hyperdrive. + // @dev Use balanceOf to get the total amount of fund tokens instead of + // summing up the two amounts, in order to open the door for any + // potential donation to help match orders. + uint256 totalFundTokenAmount = _fundToken.balanceOf(address(this)); + uint256 fundTokenAmountToUse = _cost + TOKEN_AMOUNT_BUFFER; + if (totalFundTokenAmount < fundTokenAmountToUse) { + revert InsufficientFunding(); + } + + // @dev Add 1 wei of approval so that the storage slot stays hot. + _fundToken.forceApprove(address(_hyperdrive), fundTokenAmountToUse + 1); + + // Create PairOptions. + IHyperdrive.PairOptions memory pairOptions = IHyperdrive.PairOptions({ + longDestination: _longOrder.options.destination, + shortDestination: _shortOrder.options.destination, + asBase: _longOrder.options.asBase, + extraData: "" + }); + + // Calculate minVaultSharePrice. + // @dev Take the larger of the two minVaultSharePrice as the min guard + // price to prevent slippage, so that it satisfies both orders. + uint256 minVaultSharePrice = _longOrder.minVaultSharePrice.max( + _shortOrder.minVaultSharePrice + ); + + // Mint matching positions. + (, uint256 bondAmount) = _hyperdrive.mint( + fundTokenAmountToUse, + _bondMatchAmount, + minVaultSharePrice, + pairOptions + ); + + // Return the bondAmount. + return bondAmount; + } + + /// @dev Handles the burning of matching positions. + /// @param _longOrder The first order (CloseLong). + /// @param _shortOrder The second order (CloseShort). + /// @param _minFundAmountLongOrder The minimum fund amount for the long order. + /// @param _minFundAmountShortOrder The minimum fund amount for the short order. + /// @param _bondMatchAmount The amount of bonds to burn. + /// @param _fundToken The fund token being used. + /// @param _hyperdrive The Hyperdrive contract instance. + function _handleBurn( + OrderIntent calldata _longOrder, + OrderIntent calldata _shortOrder, + uint256 _minFundAmountLongOrder, + uint256 _minFundAmountShortOrder, + uint256 _bondMatchAmount, + ERC20 _fundToken, + IHyperdrive _hyperdrive + ) internal { + // Get asset IDs for the long and short positions. + uint256 longAssetId = AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Long, + _longOrder.maxMaturityTime + ); + uint256 shortAssetId = AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + _shortOrder.maxMaturityTime + ); + + // This contract needs to take custody of the bonds before burning. + _hyperdrive.safeTransferFrom( + _longOrder.trader, + address(this), + longAssetId, + _bondMatchAmount, + "" + ); + _hyperdrive.safeTransferFrom( + _shortOrder.trader, + address(this), + shortAssetId, + _bondMatchAmount, + "" + ); + + // Calculate minOutput and consider the potential donation to help match + // orders. + uint256 minOutput = (_minFundAmountLongOrder + + _minFundAmountShortOrder) > _fundToken.balanceOf(address(this)) + ? _minFundAmountLongOrder + + _minFundAmountShortOrder - + _fundToken.balanceOf(address(this)) + : 0; + + // Stack cycling to avoid stack-too-deep. + OrderIntent calldata longOrder = _longOrder; + OrderIntent calldata shortOrder = _shortOrder; + + // Burn the matching positions. + _hyperdrive.burn( + longOrder.maxMaturityTime, + _bondMatchAmount, + minOutput, + IHyperdrive.Options({ + destination: address(this), + asBase: longOrder.options.asBase, + extraData: "" + }) + ); + + // Transfer proceeds to traders. + _fundToken.safeTransfer( + longOrder.options.destination, + _minFundAmountLongOrder + ); + _fundToken.safeTransfer( + shortOrder.options.destination, + _minFundAmountShortOrder + ); + } + + /// @dev Handles the transfer of positions between traders. + /// @param _openOrder The order for opening a position. + /// @param _closeOrder The order for closing a position. + /// @param _fundTokenAmountOpenOrder The amount of fund tokens from the + /// open order. + /// @param _minFundAmountCloseOrder The minimum fund amount for the close + /// order. + /// @param _bondMatchAmount The amount of bonds to transfer. + /// @param _fundToken The fund token being used. + /// @param _hyperdrive The Hyperdrive contract instance. + function _handleTransfer( + OrderIntent calldata _openOrder, + OrderIntent calldata _closeOrder, + uint256 _fundTokenAmountOpenOrder, + uint256 _minFundAmountCloseOrder, + uint256 _bondMatchAmount, + ERC20 _fundToken, + IHyperdrive _hyperdrive + ) internal { + // Get asset ID for the position. + uint256 assetId; + if (_openOrder.orderType == OrderType.OpenLong) { + assetId = AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Long, + _closeOrder.maxMaturityTime + ); + } else { + assetId = AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + _closeOrder.maxMaturityTime + ); + } + + // Transfer the position from the close trader to the open trader. + _hyperdrive.safeTransferFrom( + _closeOrder.trader, + _openOrder.options.destination, + assetId, + _bondMatchAmount, + "" + ); + + // Transfer fund tokens from open trader to the close trader. + _fundToken.safeTransferFrom( + _openOrder.trader, + _closeOrder.options.destination, + _fundTokenAmountOpenOrder.min(_minFundAmountCloseOrder) + ); + } + + /// @dev Gets the most recent checkpoint time. + /// @param _checkpointDuration The duration of the checkpoint. + /// @return latestCheckpoint The latest checkpoint. + function _latestCheckpoint( + uint256 _checkpointDuration + ) internal view returns (uint256 latestCheckpoint) { + latestCheckpoint = HyperdriveMath.calculateCheckpointTime( + block.timestamp, + _checkpointDuration + ); + } + + /// @dev Calculates the cost and parameters for minting positions. + /// @param _hyperdrive The Hyperdrive contract instance. + /// @param _bondMatchAmount The amount of bonds to mint. + /// @return maturityTime The maturity time for new positions. + /// @return cost The total cost including fees. + function _calculateMintCost( + IHyperdrive _hyperdrive, + uint256 _bondMatchAmount + ) internal view returns (uint256 maturityTime, uint256 cost) { + // Get pool configuration. + IHyperdrive.PoolConfig memory config = _hyperdrive.getPoolConfig(); + + // Calculate checkpoint and maturity time. + uint256 latestCheckpoint = _latestCheckpoint(config.checkpointDuration); + maturityTime = latestCheckpoint + config.positionDuration; + + // Get vault share prices. + uint256 vaultSharePrice = _hyperdrive.convertToBase(1e18); + uint256 openVaultSharePrice = _hyperdrive + .getCheckpoint(latestCheckpoint) + .vaultSharePrice; + if (openVaultSharePrice == 0) { + openVaultSharePrice = vaultSharePrice; + } + + // Calculate the required fund amount. + // NOTE: Round the required fund amount up to overestimate the cost. + cost = _bondMatchAmount.mulDivUp( + vaultSharePrice.max(openVaultSharePrice), + openVaultSharePrice + ); + + // Add flat fee. + // NOTE: Round the flat fee calculation up to match other flows. + uint256 flatFee = _bondMatchAmount.mulUp(config.fees.flat); + cost += flatFee; + + // Add governance fee. + // NOTE: Round the governance fee calculation down to match other flows. + uint256 governanceFee = 2 * flatFee.mulDown(config.fees.governanceLP); + cost += governanceFee; + } + + /// @dev Updates either the bond amount or fund amount used for a given order. + /// @param orderHash The hash of the order. + /// @param amount The amount to add. + /// @param updateBond If true, updates bond amount; if false, updates fund + /// amount. + function _updateOrderAmount( + bytes32 orderHash, + uint256 amount, + bool updateBond + ) internal { + OrderAmounts memory amounts = orderAmountsUsed[orderHash]; + + if (updateBond) { + // Check for overflow before casting to uint128 + if (amounts.bondAmount + amount > type(uint128).max) { + revert AmountOverflow(); + } + orderAmountsUsed[orderHash] = OrderAmounts({ + bondAmount: uint128(amounts.bondAmount + amount), + fundAmount: amounts.fundAmount + }); + } else { + // Check for overflow before casting to uint128. + if (amounts.fundAmount + amount > type(uint128).max) { + revert AmountOverflow(); + } + orderAmountsUsed[orderHash] = OrderAmounts({ + bondAmount: amounts.bondAmount, + fundAmount: uint128(amounts.fundAmount + amount) + }); + } + } + + /// @notice Handles the receipt of a single ERC1155 token type. This + /// function is called at the end of a `safeTransferFrom` after the + /// balance has been updated. + /// @return The magic function selector if the transfer is allowed, and the + /// the 0 bytes4 otherwise. + function onERC1155Received( + address, + address, + uint256, + uint256, + bytes calldata + ) external pure returns (bytes4) { + // This contract always accepts the transfer. + return + bytes4( + keccak256( + "onERC1155Received(address,address,uint256,uint256,bytes)" + ) + ); + } +} diff --git a/test/units/matching/HyperdriveMatchingEngineV2Test.t.sol b/test/units/matching/HyperdriveMatchingEngineV2Test.t.sol new file mode 100644 index 000000000..d3cfe95c5 --- /dev/null +++ b/test/units/matching/HyperdriveMatchingEngineV2Test.t.sol @@ -0,0 +1,849 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.24; + +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 { IHyperdriveMatchingEngineV2 } from "../../../contracts/src/interfaces/IHyperdriveMatchingEngineV2.sol"; +import { AssetId } from "../../../contracts/src/libraries/AssetId.sol"; +import { FixedPointMath } from "../../../contracts/src/libraries/FixedPointMath.sol"; +import { HyperdriveMatchingEngineV2 } from "../../../contracts/src/matching/HyperdriveMatchingEngineV2.sol"; +import { HyperdriveTest } from "../../utils/HyperdriveTest.sol"; +import { HyperdriveUtils } from "../../utils/HyperdriveUtils.sol"; +import { Lib } from "../../utils/Lib.sol"; + +contract HyperdriveMatchingEngineV2Test is HyperdriveTest { + using FixedPointMath for uint256; + using HyperdriveUtils for IHyperdrive; + using SafeERC20 for ERC20; + using Lib for *; + + /// @dev A salt used to help create orders. + bytes32 internal constant salt = bytes32(uint256(0xdeadbeef)); + + /// @dev The deployed Hyperdrive matching engine. + HyperdriveMatchingEngineV2 internal matchingEngine; + + /// @notice Sets up the matching engine test with the following actions: + /// + /// 1. Deploy and initialize Hyperdrive pool with fees. + /// 2. Deploy matching engine. + /// 3. Fund accounts and approve matching engine. + function setUp() public override { + 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; + deploy(alice, config); + initialize(alice, 0.05e18, 100_000e18); + + // Deploy matching engine. + matchingEngine = new HyperdriveMatchingEngineV2( + "Hyperdrive Matching Engine V2" + ); + + // Fund accounts and approve 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); + baseToken.approve(address(hyperdrive), type(uint256).max); + } + + vm.recordLogs(); + } + + /// @dev Tests matching orders with open long and open short orders. + function test_matchOrders_openLongAndOpenShort() public { + // Create orders. + IHyperdriveMatchingEngineV2.OrderIntent + memory longOrder = _createOrderIntent( + alice, + address(0), + 100_000e18, // fundAmount. + 95_000e18, // bondAmount. + IHyperdriveMatchingEngineV2.OrderType.OpenLong + ); + + IHyperdriveMatchingEngineV2.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + address(0), + 101_000e18, // fundAmount. + 95_000e18, // bondAmount. + IHyperdriveMatchingEngineV2.OrderType.OpenShort + ); + + // Sign orders. + longOrder.signature = _signOrderIntent(longOrder, alicePK); + shortOrder.signature = _signOrderIntent(shortOrder, bobPK); + + // Record balances before. + uint256 aliceBaseBalanceBefore = baseToken.balanceOf(alice); + uint256 bobBaseBalanceBefore = baseToken.balanceOf(bob); + uint256 aliceLongBalanceBefore = _getLongBalance(alice); + uint256 bobShortBalanceBefore = _getShortBalance(bob); + + // Match orders. + matchingEngine.matchOrders(longOrder, shortOrder, celine); + + // Verify balances after. + assertLt(baseToken.balanceOf(alice), aliceBaseBalanceBefore); + assertLt(baseToken.balanceOf(bob), bobBaseBalanceBefore); + assertGt(_getLongBalance(alice), aliceLongBalanceBefore); + assertGt(_getShortBalance(bob), bobShortBalanceBefore); + } + + /// @dev Tests matching orders with close long and close short orders. + function test_matchOrders_closeLongAndCloseShort() public { + // First create and match open orders to create positions. + test_matchOrders_openLongAndOpenShort(); + + uint256 maturityTime = hyperdrive.latestCheckpoint() + + hyperdrive.getPoolConfig().positionDuration; + + // Approve Hyperdrive bonds positions to the matching engine. + uint256 longAssetId = AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Long, + maturityTime + ); + uint256 shortAssetId = AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + maturityTime + ); + + vm.startPrank(alice); + hyperdrive.setApproval( + longAssetId, + address(matchingEngine), + type(uint256).max + ); + vm.stopPrank(); + + vm.startPrank(bob); + hyperdrive.setApproval( + shortAssetId, + address(matchingEngine), + type(uint256).max + ); + vm.stopPrank(); + + // Create close orders. + IHyperdriveMatchingEngineV2.OrderIntent + memory closeLongOrder = _createOrderIntent( + alice, + address(0), + 90_000e18, // min fund amount to receive. + 95_000e18, // bond amount to close. + IHyperdriveMatchingEngineV2.OrderType.CloseLong + ); + closeLongOrder.minMaturityTime = maturityTime; + closeLongOrder.maxMaturityTime = maturityTime; + + IHyperdriveMatchingEngineV2.OrderIntent + memory closeShortOrder = _createOrderIntent( + bob, + address(0), + 5_001e18, // min fund amount to receive. + 95_000e18, // bond amount to close. + IHyperdriveMatchingEngineV2.OrderType.CloseShort + ); + closeShortOrder.minMaturityTime = maturityTime; + closeShortOrder.maxMaturityTime = maturityTime; + + // Sign orders. + closeLongOrder.signature = _signOrderIntent(closeLongOrder, alicePK); + closeShortOrder.signature = _signOrderIntent(closeShortOrder, bobPK); + + // Record balances before. + uint256 aliceBaseBalanceBefore = baseToken.balanceOf(alice); + uint256 bobBaseBalanceBefore = baseToken.balanceOf(bob); + uint256 aliceLongBalanceBefore = _getLongBalance(alice); + uint256 bobShortBalanceBefore = _getShortBalance(bob); + + // Match orders. + matchingEngine.matchOrders(closeLongOrder, closeShortOrder, celine); + + // Verify balances after. + assertGt(baseToken.balanceOf(alice), aliceBaseBalanceBefore); + assertGt(baseToken.balanceOf(bob), bobBaseBalanceBefore); + assertLt(_getLongBalance(alice), aliceLongBalanceBefore); + assertLt(_getShortBalance(bob), bobShortBalanceBefore); + } + + /// @dev Tests matching orders with close long and close short orders with + /// different maturity times. + function test_matchOrders_revertInvalidMaturityTime() public { + // Create close orders with different maturity times. + uint256 maturityTime = hyperdrive.latestCheckpoint() + + hyperdrive.getPoolConfig().positionDuration; + + IHyperdriveMatchingEngineV2.OrderIntent + memory closeLongOrder = _createOrderIntent( + alice, + address(0), + 90_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.CloseLong + ); + closeLongOrder.minMaturityTime = maturityTime; + closeLongOrder.maxMaturityTime = maturityTime; + + IHyperdriveMatchingEngineV2.OrderIntent + memory closeShortOrder = _createOrderIntent( + bob, + address(0), + 90_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.CloseShort + ); + closeShortOrder.minMaturityTime = maturityTime + 1 days; + closeShortOrder.maxMaturityTime = maturityTime + 1 days; + + closeLongOrder.signature = _signOrderIntent(closeLongOrder, alicePK); + closeShortOrder.signature = _signOrderIntent(closeShortOrder, bobPK); + + vm.expectRevert( + IHyperdriveMatchingEngineV2.InvalidMaturityTime.selector + ); + matchingEngine.matchOrders(closeLongOrder, closeShortOrder, celine); + } + + /// @dev Tests matching orders with insufficient funding. + function test_matchOrders_failure_insufficientFunding() public { + // Create orders with insufficient funding. + IHyperdriveMatchingEngineV2.OrderIntent + memory longOrder = _createOrderIntent( + alice, + address(0), + 1e18, // Very small fundAmount. + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenLong + ); + + IHyperdriveMatchingEngineV2.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + address(0), + 1e18, // Very small fundAmount. + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenShort + ); + + longOrder.signature = _signOrderIntent(longOrder, alicePK); + shortOrder.signature = _signOrderIntent(shortOrder, bobPK); + + vm.expectRevert( + IHyperdriveMatchingEngineV2.InsufficientFunding.selector + ); + matchingEngine.matchOrders(longOrder, shortOrder, celine); + } + + /// @dev Tests matching orders with valid but different bond amounts + /// (partial match). + function test_matchOrders_differentBondAmounts() public { + // Create orders with different bond amounts - this should succeed with + // partial matching. + IHyperdriveMatchingEngineV2.OrderIntent + memory longOrder = _createOrderIntent( + alice, + address(0), + 100_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenLong + ); + + IHyperdriveMatchingEngineV2.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + address(0), + 100_000e18, + 90_000e18, // Different but valid bond amount. + IHyperdriveMatchingEngineV2.OrderType.OpenShort + ); + + longOrder.signature = _signOrderIntent(longOrder, alicePK); + shortOrder.signature = _signOrderIntent(shortOrder, bobPK); + + // Record balances before. + uint256 aliceLongBalanceBefore = _getLongBalance(alice); + uint256 bobShortBalanceBefore = _getShortBalance(bob); + + // Match orders - should succeed with partial match. + matchingEngine.matchOrders(longOrder, shortOrder, celine); + + // Verify partial fill - should match the smaller of the two amounts. + assertGe(_getLongBalance(alice) - aliceLongBalanceBefore, 90_000e18); + assertGe(_getShortBalance(bob) - bobShortBalanceBefore, 90_000e18); + } + + /// @dev Tests matching orders with invalid bond amounts (exceeds available + /// balance). + function test_matchOrders_failure_invalidBondAmount() public { + // First create some positions. + test_matchOrders_openLongAndOpenShort(); + + uint256 maturityTime = hyperdrive.latestCheckpoint() + + hyperdrive.getPoolConfig().positionDuration; + + // Approve Hyperdrive bonds positions to the matching engine. + uint256 longAssetId = AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Long, + maturityTime + ); + uint256 shortAssetId = AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + maturityTime + ); + + vm.startPrank(alice); + hyperdrive.setApproval( + longAssetId, + address(matchingEngine), + type(uint256).max + ); + vm.stopPrank(); + + vm.startPrank(bob); + hyperdrive.setApproval( + shortAssetId, + address(matchingEngine), + type(uint256).max + ); + vm.stopPrank(); + + // Try to close more bonds than available. + IHyperdriveMatchingEngineV2.OrderIntent + memory closeLongOrder = _createOrderIntent( + alice, + address(0), + 100_000e18, + 200_000e18, // More than what alice has. + IHyperdriveMatchingEngineV2.OrderType.CloseLong + ); + closeLongOrder.minMaturityTime = maturityTime; + closeLongOrder.maxMaturityTime = maturityTime; + + IHyperdriveMatchingEngineV2.OrderIntent + memory closeShortOrder = _createOrderIntent( + bob, + address(0), + 100_000e18, + 200_000e18, + IHyperdriveMatchingEngineV2.OrderType.CloseShort + ); + closeShortOrder.minMaturityTime = maturityTime; + closeShortOrder.maxMaturityTime = maturityTime; + + closeLongOrder.signature = _signOrderIntent(closeLongOrder, alicePK); + closeShortOrder.signature = _signOrderIntent(closeShortOrder, bobPK); + + // Should revert because traders don't have enough bonds. + // @dev TODO: Looks like there is no good error code to use for this + // expected revert, as the error is just an arithmetic underflow? + vm.expectRevert(); + matchingEngine.matchOrders(closeLongOrder, closeShortOrder, celine); + } + + /// @dev Tests matching orders with expired orders. + function test_matchOrders_failure_alreadyExpired() public { + IHyperdriveMatchingEngineV2.OrderIntent + memory longOrder = _createOrderIntent( + alice, + address(0), + 100_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenLong + ); + longOrder.expiry = block.timestamp - 1; // Already expired. + + IHyperdriveMatchingEngineV2.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + address(0), + 100_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenShort + ); + + longOrder.signature = _signOrderIntent(longOrder, alicePK); + shortOrder.signature = _signOrderIntent(shortOrder, bobPK); + + vm.expectRevert(IHyperdriveMatchingEngineV2.AlreadyExpired.selector); + matchingEngine.matchOrders(longOrder, shortOrder, celine); + } + + /// @dev Tests matching orders with mismatched Hyperdrive instances. + function test_matchOrders_failure_mismatchedHyperdrive() public { + IHyperdriveMatchingEngineV2.OrderIntent + memory longOrder = _createOrderIntent( + alice, + address(0), + 100_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenLong + ); + + IHyperdriveMatchingEngineV2.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + address(0), + 100_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenShort + ); + shortOrder.hyperdrive = IHyperdrive(address(0xdead)); // Different Hyperdrive instance. + + longOrder.signature = _signOrderIntent(longOrder, alicePK); + shortOrder.signature = _signOrderIntent(shortOrder, bobPK); + + vm.expectRevert( + IHyperdriveMatchingEngineV2.MismatchedHyperdrive.selector + ); + matchingEngine.matchOrders(longOrder, shortOrder, celine); + } + + /// @dev Tests successful partial matching of orders. + function test_matchOrders_partialMatch() public { + // Create orders where one has larger amount than the other. + IHyperdriveMatchingEngineV2.OrderIntent + memory longOrder = _createOrderIntent( + alice, + address(0), + 100_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenLong + ); + + IHyperdriveMatchingEngineV2.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + address(0), + 50_000e18, // Half the amount. + 47_500e18, // Half the bonds. + IHyperdriveMatchingEngineV2.OrderType.OpenShort + ); + + longOrder.signature = _signOrderIntent(longOrder, alicePK); + shortOrder.signature = _signOrderIntent(shortOrder, bobPK); + + // Record balances before. + uint256 aliceLongBalanceBefore = _getLongBalance(alice); + uint256 bobShortBalanceBefore = _getShortBalance(bob); + + // Match orders. + matchingEngine.matchOrders(longOrder, shortOrder, celine); + + // Verify partial fill. + assertGe(_getLongBalance(alice) - aliceLongBalanceBefore, 47_500e18); + assertGe(_getShortBalance(bob) - bobShortBalanceBefore, 47_500e18); + + // Verify order is not fully cancelled for alice. + bytes32 orderHash = matchingEngine.hashOrderIntent(longOrder); + assertFalse(matchingEngine.isCancelled(orderHash)); + } + + /// @dev Tests matching orders with invalid vault share price. + function test_matchOrders_failure_invalidVaultSharePrice() public { + IHyperdriveMatchingEngineV2.OrderIntent + memory longOrder = _createOrderIntent( + alice, + address(0), + 100_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenLong + ); + longOrder.minVaultSharePrice = type(uint256).max; // Unreasonably high min vault share price. + + IHyperdriveMatchingEngineV2.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + address(0), + 100_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenShort + ); + shortOrder.minVaultSharePrice = type(uint256).max; // Unreasonably high min vault share price. + + longOrder.signature = _signOrderIntent(longOrder, alicePK); + shortOrder.signature = _signOrderIntent(shortOrder, bobPK); + + vm.expectRevert(IHyperdrive.MinimumSharePrice.selector); + matchingEngine.matchOrders(longOrder, shortOrder, celine); + } + + /// @dev Tests matching orders with invalid signatures. + function test_matchOrders_failure_invalidSignature() public { + IHyperdriveMatchingEngineV2.OrderIntent + memory longOrder = _createOrderIntent( + alice, + address(0), + 100_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenLong + ); + + IHyperdriveMatchingEngineV2.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + address(0), + 100_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenShort + ); + + // Sign with wrong private keys. + longOrder.signature = _signOrderIntent(longOrder, bobPK); // Wrong signer. + shortOrder.signature = _signOrderIntent(shortOrder, bobPK); + + vm.expectRevert(IHyperdriveMatchingEngineV2.InvalidSignature.selector); + matchingEngine.matchOrders(longOrder, shortOrder, celine); + } + + /// @dev Tests matching orders with OpenLong + CloseLong (transfer case) + /// @dev Tests matching orders with OpenLong + CloseLong (transfer case) + function test_matchOrders_openLongAndCloseLong() public { + // First create a long position for alice + test_matchOrders_openLongAndOpenShort(); + + uint256 maturityTime = hyperdrive.latestCheckpoint() + + hyperdrive.getPoolConfig().positionDuration; + + // Approve matching engine for alice's long position + uint256 longAssetId = AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Long, + maturityTime + ); + + vm.startPrank(alice); + hyperdrive.setApproval( + longAssetId, + address(matchingEngine), + type(uint256).max + ); + vm.stopPrank(); + + // Create orders + IHyperdriveMatchingEngineV2.OrderIntent + memory openLongOrder = _createOrderIntent( + bob, // bob wants to open long + address(0), + 100_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenLong + ); + + IHyperdriveMatchingEngineV2.OrderIntent + memory closeLongOrder = _createOrderIntent( + alice, // alice wants to close her long + address(0), + 90_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.CloseLong + ); + closeLongOrder.minMaturityTime = maturityTime; + closeLongOrder.maxMaturityTime = maturityTime; + + // Sign orders + openLongOrder.signature = _signOrderIntent(openLongOrder, bobPK); + closeLongOrder.signature = _signOrderIntent(closeLongOrder, alicePK); + + // Record balances before + uint256 aliceBaseBalanceBefore = baseToken.balanceOf(alice); + uint256 bobBaseBalanceBefore = baseToken.balanceOf(bob); + uint256 aliceLongBalanceBefore = _getLongBalance(alice); + uint256 bobLongBalanceBefore = _getLongBalance(bob); + + // Match orders + matchingEngine.matchOrders(openLongOrder, closeLongOrder, celine); + + // Verify balances + assertGt(baseToken.balanceOf(alice), aliceBaseBalanceBefore); // alice receives payment + assertLt(baseToken.balanceOf(bob), bobBaseBalanceBefore); // bob pays + assertLt(_getLongBalance(alice), aliceLongBalanceBefore); // alice's long position decreases + assertGt(_getLongBalance(bob), bobLongBalanceBefore); // bob receives long position + } + + /// @dev Fuzzing test to verify TOKEN_AMOUNT_BUFFER is sufficient + function testFuzz_tokenAmountBuffer(uint256 bondAmount) public { + bondAmount = bound(bondAmount, 100e18, 1_000_000e18); + uint256 fundAmount1 = bondAmount / 2; + (, uint256 cost) = _calculateMintCost(bondAmount); + uint256 fundAmount2 = cost + 10 - fundAmount1; + + // Create orders + IHyperdriveMatchingEngineV2.OrderIntent + memory longOrder = _createOrderIntent( + alice, + address(0), + fundAmount1, + bondAmount, + IHyperdriveMatchingEngineV2.OrderType.OpenLong + ); + + IHyperdriveMatchingEngineV2.OrderIntent + memory shortOrder = _createOrderIntent( + bob, + address(0), + fundAmount2, + bondAmount, + IHyperdriveMatchingEngineV2.OrderType.OpenShort + ); + + // Sign orders + longOrder.signature = _signOrderIntent(longOrder, alicePK); + shortOrder.signature = _signOrderIntent(shortOrder, bobPK); + + // Match orders should not revert due to insufficient buffer + matchingEngine.matchOrders(longOrder, shortOrder, celine); + } + + /// @dev Tests fillOrder with OpenLong maker and OpenShort taker + function test_fillOrder_openLongMakerOpenShortTaker() public { + // Create maker order + IHyperdriveMatchingEngineV2.OrderIntent + memory makerOrder = _createOrderIntent( + alice, + address(0), + 93_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenLong + ); + makerOrder.signature = _signOrderIntent(makerOrder, alicePK); + + // Create minimal taker order + IHyperdriveMatchingEngineV2.OrderIntent + memory takerOrder = _createOrderIntent( + bob, + address(0), + 0, // Not needed for immediate fill + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenShort + ); + + // Record balances before + uint256 aliceBaseBalanceBefore = baseToken.balanceOf(alice); + uint256 bobBaseBalanceBefore = baseToken.balanceOf(bob); + uint256 aliceLongBalanceBefore = _getLongBalance(alice); + uint256 bobShortBalanceBefore = _getShortBalance(bob); + + // Fill order + matchingEngine.fillOrder(makerOrder, takerOrder); + + // Verify balances + assertLt(baseToken.balanceOf(alice), aliceBaseBalanceBefore); + assertLt(baseToken.balanceOf(bob), bobBaseBalanceBefore); + assertGt(_getLongBalance(alice), aliceLongBalanceBefore); + assertGt(_getShortBalance(bob), bobShortBalanceBefore); + } + + /// @dev Tests fillOrder with OpenShort maker and OpenLong taker + function test_fillOrder_openShortMakerOpenLongTaker() public { + // Create maker order + IHyperdriveMatchingEngineV2.OrderIntent + memory makerOrder = _createOrderIntent( + alice, + address(0), + 2_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenShort + ); + makerOrder.signature = _signOrderIntent(makerOrder, alicePK); + + // Create minimal taker order + IHyperdriveMatchingEngineV2.OrderIntent + memory takerOrder = _createOrderIntent( + bob, + address(0), + 0, // Not needed for immediate fill + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenLong + ); + + // Record balances before + uint256 aliceBaseBalanceBefore = baseToken.balanceOf(alice); + uint256 bobBaseBalanceBefore = baseToken.balanceOf(bob); + uint256 aliceShortBalanceBefore = _getShortBalance(alice); + uint256 bobLongBalanceBefore = _getLongBalance(bob); + + // Fill order + matchingEngine.fillOrder(makerOrder, takerOrder); + + // Verify balances + assertLt(baseToken.balanceOf(alice), aliceBaseBalanceBefore); + assertLt(baseToken.balanceOf(bob), bobBaseBalanceBefore); + assertGt(_getLongBalance(bob), bobLongBalanceBefore); + assertGt(_getShortBalance(alice), aliceShortBalanceBefore); + } + + /// @dev Tests fillOrder failure cases + function test_fillOrder_failures() public { + IHyperdriveMatchingEngineV2.OrderIntent + memory makerOrder = _createOrderIntent( + alice, + address(0), + 100_000e18, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenLong + ); + makerOrder.signature = _signOrderIntent(makerOrder, alicePK); + + // Test invalid order combination + IHyperdriveMatchingEngineV2.OrderIntent + memory invalidTakerOrder = _createOrderIntent( + bob, + address(0), + 0, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenLong // Same as maker + ); + + vm.expectRevert( + IHyperdriveMatchingEngineV2.InvalidOrderCombination.selector + ); + matchingEngine.fillOrder(makerOrder, invalidTakerOrder); + + // Test expired order + makerOrder.expiry = block.timestamp - 1; + IHyperdriveMatchingEngineV2.OrderIntent + memory validTakerOrder = _createOrderIntent( + bob, + address(0), + 0, + 95_000e18, + IHyperdriveMatchingEngineV2.OrderType.OpenShort + ); + + vm.expectRevert(IHyperdriveMatchingEngineV2.AlreadyExpired.selector); + matchingEngine.fillOrder(makerOrder, validTakerOrder); + } + + // Helper functions. + + /// @dev Creates an order intent. + /// @param trader The address of the trader. + /// @param counterparty The address of the counterparty. + /// @param fundAmount The amount of base tokens to fund the order. + /// @param bondAmount The amount of bonds to fund the order. + /// @param orderType The type of the order. + /// @return The order intent. + function _createOrderIntent( + address trader, + address counterparty, + uint256 fundAmount, + uint256 bondAmount, + IHyperdriveMatchingEngineV2.OrderType orderType + ) internal view returns (IHyperdriveMatchingEngineV2.OrderIntent memory) { + return + IHyperdriveMatchingEngineV2.OrderIntent({ + trader: trader, + counterparty: counterparty, + hyperdrive: hyperdrive, + fundAmount: fundAmount, + bondAmount: bondAmount, + minVaultSharePrice: 0, + options: IHyperdrive.Options({ + destination: trader, + asBase: true, + extraData: "" + }), + orderType: orderType, + minMaturityTime: 0, + maxMaturityTime: type(uint256).max, + signature: "", + expiry: block.timestamp + 1 days, + salt: salt + }); + } + + /// @dev Gets the balance of long bonds for an account. + /// @param account The address of the account. + /// @return The balance of long bonds for the account. + function _getLongBalance(address account) internal view returns (uint256) { + uint256 maturityTime = hyperdrive.latestCheckpoint() + + hyperdrive.getPoolConfig().positionDuration; + return + hyperdrive.balanceOf( + AssetId.encodeAssetId(AssetId.AssetIdPrefix.Long, maturityTime), + account + ); + } + + /// @dev Gets the balance of short bonds for an account. + /// @param account The address of the account. + /// @return The balance of short bonds for the account. + function _getShortBalance(address account) internal view returns (uint256) { + uint256 maturityTime = hyperdrive.latestCheckpoint() + + hyperdrive.getPoolConfig().positionDuration; + return + hyperdrive.balanceOf( + AssetId.encodeAssetId( + AssetId.AssetIdPrefix.Short, + maturityTime + ), + account + ); + } + + /// @dev Signs an order intent. + /// @param order The order intent to sign. + /// @param privateKey The private key of the signer. + /// @return The signature of the order intent. + function _signOrderIntent( + IHyperdriveMatchingEngineV2.OrderIntent memory order, + uint256 privateKey + ) internal view returns (bytes memory) { + bytes32 digest = matchingEngine.hashOrderIntent(order); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); + return abi.encodePacked(r, s, v); + } + + /// @dev Calculates the cost and parameters for minting positions. + /// @param _bondMatchAmount The amount of bonds to mint. + /// @return maturityTime The maturity time for new positions. + /// @return cost The total cost including fees. + function _calculateMintCost( + uint256 _bondMatchAmount + ) internal view returns (uint256 maturityTime, uint256 cost) { + // Get pool configuration. + IHyperdrive.PoolConfig memory config = hyperdrive.getPoolConfig(); + + // Calculate checkpoint and maturity time. + uint256 latestCheckpoint = hyperdrive.latestCheckpoint(); + maturityTime = latestCheckpoint + config.positionDuration; + + // Get vault share prices. + uint256 vaultSharePrice = hyperdrive.convertToBase(1e18); + uint256 openVaultSharePrice = hyperdrive + .getCheckpoint(latestCheckpoint) + .vaultSharePrice; + if (openVaultSharePrice == 0) { + openVaultSharePrice = vaultSharePrice; + } + + // Calculate the required fund amount. + // NOTE: Round the required fund amount up to overestimate the cost. + cost = _bondMatchAmount.mulDivUp( + vaultSharePrice.max(openVaultSharePrice), + openVaultSharePrice + ); + + // Add flat fee. + // NOTE: Round the flat fee calculation up to match other flows. + uint256 flatFee = _bondMatchAmount.mulUp(config.fees.flat); + cost += flatFee; + + // Add governance fee. + // NOTE: Round the governance fee calculation down to match other flows. + uint256 governanceFee = 2 * flatFee.mulDown(config.fees.governanceLP); + cost += governanceFee; + } +}