diff --git a/markets/perps-market/cannonfile.test.toml b/markets/perps-market/cannonfile.test.toml index ef38c2a0fb..4541e55ff5 100644 --- a/markets/perps-market/cannonfile.test.toml +++ b/markets/perps-market/cannonfile.test.toml @@ -48,6 +48,9 @@ artifact = "LiquidationModule" [contract.CollateralConfigurationModule] artifact = "CollateralConfigurationModule" +[contract.LimitOrderModule] +artifact = "LimitOrderModule" + [contract.MarketConfigurationModule] artifact = "MarketConfigurationModule" @@ -83,6 +86,7 @@ contracts = [ "MarketConfigurationModule", "CollateralConfigurationModule", "GlobalPerpsMarketModule", + "LimitOrderModule", ] [invoke.upgrade_proxy] @@ -132,6 +136,12 @@ func = "setFeatureFlagAllowAll" from = "<%= settings.owner %>" args = ["<%= formatBytes32String('perpsSystem') %>", true] +[invoke.addLimitOrderToFeatureFlag] +target = ["PerpsMarketProxy"] +func = "setFeatureFlagAllowAll" +from = "<%= settings.owner %>" +args = ["<%= formatBytes32String('limitOrder') %>", true] + [contract.MockPythERC7412Wrapper] artifact = "contracts/mocks/MockPythERC7412Wrapper.sol:MockPythERC7412Wrapper" diff --git a/markets/perps-market/cannonfile.toml b/markets/perps-market/cannonfile.toml index 2e63ef4747..8f731a8b51 100644 --- a/markets/perps-market/cannonfile.toml +++ b/markets/perps-market/cannonfile.toml @@ -60,6 +60,9 @@ artifact = "CollateralConfigurationModule" [contract.MarketConfigurationModule] artifact = "MarketConfigurationModule" +[contract.LimitOrderModule] +artifact = "LimitOrderModule" + [contract.FeatureFlagModule] artifact = "contracts/modules/FeatureFlagModule.sol:FeatureFlagModule" @@ -92,6 +95,7 @@ contracts = [ "MarketConfigurationModule", "CollateralConfigurationModule", "GlobalPerpsMarketModule", + "LimitOrderModule", ] [invoke.upgrade_proxy] diff --git a/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol b/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol index b9ef04a9a0..cdab330e69 100644 --- a/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol +++ b/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol @@ -39,6 +39,13 @@ interface IGlobalPerpsMarketModule { */ event ReferrerShareUpdated(address referrer, uint256 shareRatioD18); + /** + * @notice Emitted when the share percentage for a relayer address has been updated. + * @param relayer The address of the relayer + * @param shareRatioD18 The new share ratio for the relayer + */ + event RelayerShareUpdated(address relayer, uint256 shareRatioD18); + /** * @notice Emitted when interest rate parameters are set * @param lowUtilizationInterestRateGradient interest rate gradient applied to utilization prior to hitting the gradient breakpoint @@ -74,6 +81,11 @@ interface IGlobalPerpsMarketModule { */ error InvalidReferrerShareRatio(uint256 shareRatioD18); + /** + * @notice Thrown when a relayer share gets set to larger than 100% + */ + error InvalidRelayerShareRatio(uint256 shareRatioD18); + /** * @notice Thrown when gradient breakpoint is lower than low gradient or higher than high gradient */ @@ -236,4 +248,18 @@ interface IGlobalPerpsMarketModule { * @dev InterestRateUpdated event is emitted */ function updateInterestRate() external; + + /** + * @notice Update the referral share percentage for a relayer + * @param relayer The address of the relayer + * @param shareRatioD18 The new share percentage for the relayer + */ + function updateRelayerShare(address relayer, uint256 shareRatioD18) external; + + /** + * @notice get the referral share percentage for the specified relayer + * @param relayer The address of the relayer + * @return shareRatioD18 The configured share percentage for the relayer + */ + function getRelayerShare(address relayer) external view returns (uint256 shareRatioD18); } diff --git a/markets/perps-market/contracts/interfaces/ILimitOrderModule.sol b/markets/perps-market/contracts/interfaces/ILimitOrderModule.sol new file mode 100644 index 0000000000..7f72f404b8 --- /dev/null +++ b/markets/perps-market/contracts/interfaces/ILimitOrderModule.sol @@ -0,0 +1,145 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {LimitOrder} from "../storage/LimitOrder.sol"; + +/** + * @title limit order module + */ +interface ILimitOrderModule { + /** + * @notice cancels a limit order nonce for an account and prevents it from being called + * @param accountId id of the account used for the limit order + * @param limitOrderNonce limit order nonce to cancel + * @param price limit order nonce to cancel + * @param amount limit order nonce to cancel + */ + event LimitOrderCancelled( + uint128 indexed accountId, + uint256 limitOrderNonce, + uint256 price, + int256 amount + ); + + /** + * @notice Gets fired when a new limit order is settled. + * @param marketId Id of the market used for the trade. + * @param accountId Id of the account used for the trade. + * @param price Price at which the limit order was settled. + * @param pnl Pnl of the previous closed position. + * @param accruedFunding Accrued funding of the previous closed position. + * @param amount directional size of the limit order. + * @param newSize New size of the position after settlement. + * @param limitOrderFees Amount of fees collected by the protocol and relayer combined. + * @param relayerFees Amount of fees collected by the relayer. + * @param collectedFees Amount of fees collected by fee collector. + * @param trackingCode Optional code for integrator tracking purposes. + * @param interest interest charges + */ + event LimitOrderSettled( + uint128 indexed marketId, + uint128 indexed accountId, + uint256 price, + int256 pnl, + int256 accruedFunding, + int128 amount, + int128 newSize, + uint256 limitOrderFees, + uint256 relayerFees, + uint256 collectedFees, + bytes32 indexed trackingCode, + uint256 interest + ); + + /** + * @notice thrown when a limit order that is attempted to be cancelled has already been used + * @param accountId id of the account used for the limit order + * @param limitOrderNonce limit order nonce to cancel + * @param price limit order nonce to cancel + * @param amount limit order nonce to cancel + */ + error LimitOrderAlreadyUsed( + uint128 accountId, + uint256 limitOrderNonce, + uint256 price, + int256 amount + ); + + /** + * @notice Thrown when attempting to use two makers or two takers + * @param shortIsMaker is the short a maker? + * @param longIsMaker is the long a maker? + */ + error MismatchingMakerTakerLimitOrder(bool shortIsMaker, bool longIsMaker); + + /** + * @notice Thrown when attempting to use an invalid relayer + * @param relayer address of the relayer submitted with a limit order + */ + error LimitOrderRelayerInvalid(address relayer); + + /** + * @notice Thrown when attempting to use two different relayers + */ + error LimitOrderDifferentRelayer(address shortRelayer, address longRelayer); + + /** + * @notice Thrown when attempting to use two different markets + */ + error LimitOrderMarketMismatch(uint256 shortMarketId, uint256 longMarketId); + + /** + * @notice Thrown when attempting to use an expired limit order on either side + */ + error LimitOrderExpired( + uint128 shortAccountId, + uint256 shortExpiration, + uint128 longAccountId, + uint256 longExpiration, + uint256 blockTimetamp + ); + + /** + * @notice Thrown when attempting to use two different amounts + */ + error LimitOrderAmountError(int256 shortAmount, int256 longAmount); + + /** + * @notice cancels a limit order with a nonce from being called for an account + * @param order the order to cancel + * @param sig the order signature + */ + function cancelLimitOrder( + LimitOrder.SignedOrderRequest calldata order, + LimitOrder.Signature calldata sig + ) external; + + /** + * @notice gets the fees for a limit order + * @param marketId the id for the market + * @param amount the amount for the order + * @param price the price of the order + * @param isMaker a boolean to get the fee for a taker vs maker + * @return limitOrderFees the fees for the limit order + */ + function getLimitOrderFees( + uint128 marketId, + int128 amount, + uint256 price, + bool isMaker + ) external view returns (uint256); + + /** + * @notice Settles long and short limit orders of matching amounts submitted by a valid relayer + * @param longOrder a limit order going long on a given market + * @param longSignature a signature used to validate the long market ordert + * @param shortOrder a limit order going short on a given market + * @param shortSignature a signature used to validate the short market ordert + */ + function settleLimitOrder( + LimitOrder.SignedOrderRequest calldata shortOrder, + LimitOrder.Signature calldata shortSignature, + LimitOrder.SignedOrderRequest calldata longOrder, + LimitOrder.Signature calldata longSignature + ) external; +} diff --git a/markets/perps-market/contracts/interfaces/IMarketConfigurationModule.sol b/markets/perps-market/contracts/interfaces/IMarketConfigurationModule.sol index 084503b80a..29662827f9 100644 --- a/markets/perps-market/contracts/interfaces/IMarketConfigurationModule.sol +++ b/markets/perps-market/contracts/interfaces/IMarketConfigurationModule.sol @@ -51,6 +51,18 @@ interface IMarketConfigurationModule { */ event OrderFeesSet(uint128 indexed marketId, uint256 makerFeeRatio, uint256 takerFeeRatio); + /** + * @notice Gets fired when limit order fees are updated. + * @param marketId udpates fees to this specific market. + * @param limitOrderMakerFeeRatio the limit order maker fee ratio. + * @param limitOrderTakerFeeRatio the limit order taker fee ratio. + */ + event LimitOrderFeesSet( + uint128 indexed marketId, + uint256 limitOrderMakerFeeRatio, + uint256 limitOrderTakerFeeRatio + ); + /** * @notice Gets fired when funding parameters are updated. * @param marketId udpates funding parameters to this specific market. @@ -151,6 +163,18 @@ interface IMarketConfigurationModule { */ function setOrderFees(uint128 marketId, uint256 makerFeeRatio, uint256 takerFeeRatio) external; + /** + * @notice Set limit order fees for a market with this function. + * @param marketId id of the market to set limit order fees. + * @param limitOrderMakerFeeRatio the limit order maker fee ratio. + * @param limitOrderTakerFeeRatio the limit order taker fee ratio. + */ + function setLimitOrderFees( + uint128 marketId, + uint256 limitOrderMakerFeeRatio, + uint256 limitOrderTakerFeeRatio + ) external; + /** * @notice Set node id for perps market * @param perpsMarketId id of the market to set price feed. @@ -330,6 +354,16 @@ interface IMarketConfigurationModule { uint128 marketId ) external view returns (uint256 makerFeeRatio, uint256 takerFeeRatio); + /** + * @notice Gets the limit order fees of a market. + * @param marketId id of the market. + * @return limitOrderMakerFeeRatio the limit order maker fee ratio. + * @return limitOrderTakerFeeRatio the limit order taker fee ratio. + */ + function getLimitOrderFees( + uint128 marketId + ) external view returns (uint256 limitOrderMakerFeeRatio, uint256 limitOrderTakerFeeRatio); + /** * @notice Gets the locked OI ratio of a market. * @param marketId id of the market. diff --git a/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol b/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol index 68d3f30e81..6172fe42f0 100644 --- a/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol +++ b/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol @@ -271,4 +271,32 @@ contract GlobalPerpsMarketModule is IGlobalPerpsMarketModule { emit InterestRateUpdated(PerpsMarketFactory.load().perpsMarketId, interestRate); } + + /** + * @inheritdoc IGlobalPerpsMarketModule + */ + function updateRelayerShare(address relayer, uint256 shareRatioD18) external override { + OwnableStorage.onlyOwner(); + + if (shareRatioD18 > DecimalMath.UNIT) { + revert InvalidRelayerShareRatio(shareRatioD18); + } + + if (relayer == address(0)) { + revert AddressError.ZeroAddress(); + } + + GlobalPerpsMarketConfiguration.load().relayerShare[relayer] = shareRatioD18; + + emit RelayerShareUpdated(relayer, shareRatioD18); + } + + /** + * @inheritdoc IGlobalPerpsMarketModule + */ + function getRelayerShare( + address relayer + ) external view override returns (uint256 shareRatioD18) { + return GlobalPerpsMarketConfiguration.load().relayerShare[relayer]; + } } diff --git a/markets/perps-market/contracts/modules/LimitOrderModule.sol b/markets/perps-market/contracts/modules/LimitOrderModule.sol new file mode 100644 index 0000000000..6db46d6af8 --- /dev/null +++ b/markets/perps-market/contracts/modules/LimitOrderModule.sol @@ -0,0 +1,408 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {Account} from "@synthetixio/main/contracts/storage/Account.sol"; +import {AccountRBAC} from "@synthetixio/main/contracts/storage/AccountRBAC.sol"; +import {DecimalMath} from "@synthetixio/core-contracts/contracts/utils/DecimalMath.sol"; +import {SafeCastI256} from "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; +import {SafeCastU256} from "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; +import {FeatureFlag} from "@synthetixio/core-modules/contracts/storage/FeatureFlag.sol"; +import {ILimitOrderModule} from "../interfaces/ILimitOrderModule.sol"; +import {IMarketEvents} from "../interfaces/IMarketEvents.sol"; +import {IAccountEvents} from "../interfaces/IAccountEvents.sol"; +import {AsyncOrder} from "../storage/AsyncOrder.sol"; +import {LimitOrder} from "../storage/LimitOrder.sol"; +import {GlobalPerpsMarketConfiguration} from "../storage/GlobalPerpsMarketConfiguration.sol"; +import {GlobalPerpsMarket} from "../storage/GlobalPerpsMarket.sol"; +import {PerpsMarketFactory} from "../storage/PerpsMarketFactory.sol"; +import {PerpsMarket} from "../storage/PerpsMarket.sol"; +import {Position} from "../storage/Position.sol"; +import {PerpsPrice} from "../storage/PerpsPrice.sol"; +import {PerpsAccount, SNX_USD_MARKET_ID} from "../storage/PerpsAccount.sol"; +import {PerpsMarketConfiguration} from "../storage/PerpsMarketConfiguration.sol"; +import {MathUtil} from "../utils/MathUtil.sol"; +import {Flags} from "../utils/Flags.sol"; +// import "hardhat/console.sol"; + +/** + * @title Module for settling signed P2P limit orders + * @dev See ILimitOrderModule. + */ +contract LimitOrderModule is ILimitOrderModule, IMarketEvents, IAccountEvents { + using DecimalMath for int128; + using DecimalMath for uint256; + using SafeCastI256 for int256; + using SafeCastU256 for uint256; + using LimitOrder for LimitOrder.Data; + using Account for Account.Data; + using GlobalPerpsMarketConfiguration for GlobalPerpsMarketConfiguration.Data; + using GlobalPerpsMarket for GlobalPerpsMarket.Data; + using PerpsMarket for PerpsMarket.Data; + using Position for Position.Data; + using PerpsAccount for PerpsAccount.Data; + using PerpsMarketConfiguration for PerpsMarketConfiguration.Data; + + // keccak256("SignedOrderRequest(uint128 accountId,uint128 marketId,address relayer,int128 amount,uint256 price,limitOrderMaker bool,expiration uint256,nonce uint256,trackingCode bytes32)"); + bytes32 private constant _ORDER_TYPEHASH = + 0x4641f2e4f75597d1e96e7bdefb2097481b29cbfc2505e980f185449f02f5f52b; + + /** + * @notice Thrown when there's not enough margin to cover the order and settlement costs associated. + */ + error InsufficientMargin(int256 availableMargin, uint256 minMargin); + + // TODO add max limit order view function here and to the ILimitOrderModule + /** + * @inheritdoc ILimitOrderModule + */ + // function getMaxOrderSize() external view {} + + /** + * @inheritdoc ILimitOrderModule + */ + function getLimitOrderFees( + uint128 marketId, + int128 amount, + uint256 price, + bool isMaker + ) external view returns (uint256) { + PerpsMarketConfiguration.Data storage marketConfig = PerpsMarketConfiguration.load( + marketId + ); + return getLimitOrderFeesHelper(amount, price, isMaker, marketConfig); + } + + /** + * @inheritdoc ILimitOrderModule + */ + function cancelLimitOrder( + LimitOrder.SignedOrderRequest calldata order, + LimitOrder.Signature calldata sig + ) external { + FeatureFlag.ensureAccessToFeature(Flags.PERPS_SYSTEM); + FeatureFlag.ensureAccessToFeature(Flags.LIMIT_ORDER); + checkSigPermission(order, sig); + LimitOrder.Data storage limitOrderData = LimitOrder.load(); + + if (limitOrderData.isLimitOrderNonceUsed(order.accountId, order.nonce)) { + revert LimitOrderAlreadyUsed(order.accountId, order.nonce, order.price, order.amount); + } else { + limitOrderData.markLimitOrderNonceUsed(order.accountId, order.nonce); + emit LimitOrderCancelled(order.accountId, order.nonce, order.price, order.amount); + } + } + + /** + * @inheritdoc ILimitOrderModule + */ + function settleLimitOrder( + LimitOrder.SignedOrderRequest calldata shortOrder, + LimitOrder.Signature calldata shortSignature, + LimitOrder.SignedOrderRequest calldata longOrder, + LimitOrder.Signature calldata longSignature + ) external { + FeatureFlag.ensureAccessToFeature(Flags.PERPS_SYSTEM); + FeatureFlag.ensureAccessToFeature(Flags.LIMIT_ORDER); + PerpsMarket.loadValid(shortOrder.marketId); + + checkSigPermission(shortOrder, shortSignature); + checkSigPermission(longOrder, longSignature); + + uint256 lastPriceCheck = PerpsPrice.getCurrentPrice( + shortOrder.marketId, + PerpsPrice.Tolerance.DEFAULT + ); + + PerpsMarket.Data storage perpsMarketData = PerpsMarket.load(shortOrder.marketId); + perpsMarketData.recomputeFunding(lastPriceCheck); + + PerpsMarketConfiguration.Data storage marketConfig = PerpsMarketConfiguration.load( + shortOrder.marketId + ); + // console.log("maxMarketSize", marketConfig.maxMarketSize); + // console.log("maxMarketValue", marketConfig.maxMarketValue); + perpsMarketData.validateLimitOrderSize( + marketConfig.maxMarketSize, + marketConfig.maxMarketValue, + longOrder.price, + longOrder.amount + ); + + validateLimitOrder(shortOrder); + validateLimitOrder(longOrder); + validateLimitOrderPair(shortOrder, longOrder); + + uint256 shareRatioD18 = GlobalPerpsMarketConfiguration.load().relayerShare[ + longOrder.relayer + ]; + if (shareRatioD18 == 0) { + revert LimitOrderRelayerInvalid(longOrder.relayer); + } + + ( + uint256 shortLimitOrderFees, + Position.Data storage shortOldPosition, + Position.Data memory shortNewPosition + ) = validateRequest(shortOrder, lastPriceCheck, marketConfig, perpsMarketData); + ( + uint256 longLimitOrderFees, + Position.Data storage longOldPosition, + Position.Data memory longNewPosition + ) = validateRequest(longOrder, lastPriceCheck, marketConfig, perpsMarketData); + + settleRequest(shortOrder, shortLimitOrderFees, shortOldPosition, shortNewPosition); + settleRequest(longOrder, longLimitOrderFees, longOldPosition, longNewPosition); + } + + function checkSigPermission( + LimitOrder.SignedOrderRequest calldata order, + LimitOrder.Signature calldata sig + ) internal { + Account.exists(order.accountId); + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + domainSeparator(), + keccak256( + abi.encode( + _ORDER_TYPEHASH, + order.accountId, + order.marketId, + order.relayer, + order.amount, + order.price, + order.limitOrderMaker, + order.expiration, + order.nonce, + order.trackingCode + ) + ) + ) + ); + address signingAddress = ecrecover(digest, sig.v, sig.r, sig.s); + + Account.loadAccountAndValidateSignerPermission( + order.accountId, + AccountRBAC._PERPS_COMMIT_LIMIT_ORDER_PERMISSION, + signingAddress + ); + } + + function validateLimitOrder(LimitOrder.SignedOrderRequest calldata order) internal view { + // TODO still need this? + AsyncOrder.checkPendingOrder(order.accountId); + PerpsAccount.validateMaxPositions(order.accountId, order.marketId); + LimitOrder.load().isLimitOrderNonceUsed(order.accountId, order.nonce); + GlobalPerpsMarket.load().checkLiquidation(order.accountId); + } + + function validateLimitOrderPair( + LimitOrder.SignedOrderRequest calldata shortOrder, + LimitOrder.SignedOrderRequest calldata longOrder + ) internal view { + if (shortOrder.limitOrderMaker == longOrder.limitOrderMaker) { + revert MismatchingMakerTakerLimitOrder( + shortOrder.limitOrderMaker, + longOrder.limitOrderMaker + ); + } + if (shortOrder.relayer != longOrder.relayer) { + revert LimitOrderDifferentRelayer(shortOrder.relayer, longOrder.relayer); + } + if (shortOrder.marketId != longOrder.marketId) { + revert LimitOrderMarketMismatch(shortOrder.marketId, longOrder.marketId); + } + if (shortOrder.expiration <= block.timestamp || longOrder.expiration <= block.timestamp) { + revert LimitOrderExpired( + shortOrder.accountId, + shortOrder.expiration, + longOrder.accountId, + longOrder.expiration, + block.timestamp + ); + } + if (shortOrder.amount >= 0 || (shortOrder.amount != -longOrder.amount)) { + revert LimitOrderAmountError(shortOrder.amount, longOrder.amount); + } + } + + /** + * @notice Checks if the limit order request is valid + * it recomputes market funding rate, calculates fill price and fees for the order + * and with that data it checks that: + * - the account is eligible for liquidation + * - the fill price is within the acceptable price range + * - the position size doesn't exceed market configured limits + * - the account has enough margin to cover for the fees + * - the account has enough margin to not be liquidable immediately after the order is settled + * if the order can be executed, it returns (runtime., oldPosition, newPosition) + */ + function validateRequest( + LimitOrder.SignedOrderRequest calldata order, + uint256 lastPriceCheck, + PerpsMarketConfiguration.Data storage marketConfig, + PerpsMarket.Data storage perpsMarketData + ) internal view returns (uint256, Position.Data storage oldPosition, Position.Data memory) { + LimitOrder.ValidateRequestRuntime memory runtime; + runtime.amount = order.amount; + runtime.accountId = order.accountId; + runtime.marketId = order.marketId; + runtime.price = order.price; + + PerpsAccount.Data storage account = PerpsAccount.load(runtime.accountId); + ( + runtime.isEligible, + runtime.currentAvailableMargin, + runtime.requiredInitialMargin, + , + + ) = account.isEligibleForLiquidation(PerpsPrice.Tolerance.DEFAULT); + + if (runtime.isEligible) { + revert PerpsAccount.AccountLiquidatable(runtime.accountId); + } + + runtime.limitOrderFees = getLimitOrderFeesHelper( + order.amount, + order.price, + order.limitOrderMaker, + marketConfig + ); + + oldPosition = PerpsMarket.accountPosition(runtime.marketId, runtime.accountId); + runtime.newPositionSize = oldPosition.size + runtime.amount; + + // only account for negative pnl + runtime.currentAvailableMargin += MathUtil.min( + AsyncOrder.calculateFillPricePnl(runtime.price, lastPriceCheck, runtime.amount), + 0 + ); + if (runtime.currentAvailableMargin < runtime.limitOrderFees.toInt()) { + revert InsufficientMargin(runtime.currentAvailableMargin, runtime.limitOrderFees); + } + + runtime.totalRequiredMargin = + AsyncOrder.getRequiredMarginWithNewPosition( + account, + marketConfig, + runtime.marketId, + oldPosition.size, + runtime.newPositionSize, + runtime.price, + runtime.requiredInitialMargin + ) + + runtime.limitOrderFees; + + if (runtime.currentAvailableMargin < runtime.totalRequiredMargin.toInt()) { + revert InsufficientMargin(runtime.currentAvailableMargin, runtime.totalRequiredMargin); + } + // TODO add check if this logic below is needed or should be changed + // int256 lockedCreditDelta = perpsMarketData.requiredCreditForSize( + // MathUtil.abs(runtime.newPositionSize).toInt() - MathUtil.abs(oldPosition.size).toInt(), + // PerpsPrice.Tolerance.DEFAULT + // ); + // GlobalPerpsMarket.load().validateMarketCapacity(lockedCreditDelta); + + runtime.newPosition = Position.Data({ + marketId: runtime.marketId, + latestInteractionPrice: order.price.to128(), + latestInteractionFunding: perpsMarketData.lastFundingValue.to128(), + latestInterestAccrued: 0, + size: runtime.newPositionSize + }); + + return (runtime.limitOrderFees, oldPosition, runtime.newPosition); + } + + function settleRequest( + LimitOrder.SignedOrderRequest calldata order, + uint256 limitOrderFees, + Position.Data storage oldPosition, + Position.Data memory newPosition + ) internal { + LimitOrder.SettleRequestRuntime memory runtime; + runtime.accountId = order.accountId; + runtime.marketId = order.marketId; + runtime.limitOrderFees = limitOrderFees; + runtime.amount = order.amount; + runtime.price = order.price; + + PerpsAccount.Data storage perpsAccount = PerpsAccount.load(runtime.accountId); + (runtime.pnl, , runtime.chargedInterest, runtime.accruedFunding, , ) = oldPosition.getPnl( + order.price + ); + + runtime.chargedAmount = runtime.pnl - runtime.limitOrderFees.toInt(); + perpsAccount.charge(runtime.chargedAmount); + emit AccountCharged(runtime.accountId, runtime.chargedAmount, perpsAccount.debt); + + // after pnl is realized, update position + runtime.updateData = PerpsMarket.loadValid(runtime.marketId).updatePositionData( + runtime.accountId, + newPosition + ); + perpsAccount.updateOpenPositions(runtime.marketId, newPosition.size); + + emit MarketUpdated( + runtime.updateData.marketId, + runtime.price, + runtime.updateData.skew, + runtime.updateData.size, + runtime.amount, + runtime.updateData.currentFundingRate, + runtime.updateData.currentFundingVelocity, + runtime.updateData.interestRate + ); + + PerpsMarketFactory.Data storage factory = PerpsMarketFactory.load(); + (runtime.relayerFees, runtime.feeCollectorFees) = GlobalPerpsMarketConfiguration + .load() + .collectFees(limitOrderFees, order.relayer, factory); + + LimitOrder.load().markLimitOrderNonceUsed(runtime.accountId, order.nonce); + // emit event + emit LimitOrderSettled( + runtime.marketId, + runtime.accountId, + runtime.price, + runtime.pnl, + runtime.accruedFunding, + runtime.amount, + runtime.newPosition.size, + runtime.limitOrderFees, + runtime.relayerFees, + runtime.feeCollectorFees, + order.trackingCode, + runtime.chargedInterest + ); + } + + // TODO double check math here + function getLimitOrderFeesHelper( + int128 amount, + uint256 price, + bool isMaker, + PerpsMarketConfiguration.Data storage marketConfig + ) internal view returns (uint256) { + uint256 fees = isMaker + ? marketConfig.orderFees.limitOrderMakerFee + : marketConfig.orderFees.limitOrderTakerFee; + + return MathUtil.abs(amount).mulDecimal(price).mulDecimal(fees); + } + + function domainSeparator() internal view returns (bytes32) { + return + keccak256( + abi.encode( + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ), + keccak256(bytes("SyntheticPerpetualFutures")), + keccak256(bytes("1")), + block.chainid, + address(this) + ) + ); + } +} diff --git a/markets/perps-market/contracts/modules/MarketConfigurationModule.sol b/markets/perps-market/contracts/modules/MarketConfigurationModule.sol index fd220f8426..7306cd5db7 100644 --- a/markets/perps-market/contracts/modules/MarketConfigurationModule.sol +++ b/markets/perps-market/contracts/modules/MarketConfigurationModule.sol @@ -94,6 +94,21 @@ contract MarketConfigurationModule is IMarketConfigurationModule { emit OrderFeesSet(marketId, makerFeeRatio, takerFeeRatio); } + /** + * @inheritdoc IMarketConfigurationModule + */ + function setLimitOrderFees( + uint128 marketId, + uint256 limitOrderMakerFeeRatio, + uint256 limitOrderTakerFeeRatio + ) external override { + OwnableStorage.onlyOwner(); + PerpsMarketConfiguration.Data storage config = PerpsMarketConfiguration.load(marketId); + config.orderFees.limitOrderMakerFee = limitOrderMakerFeeRatio; + config.orderFees.limitOrderTakerFee = limitOrderTakerFeeRatio; + emit LimitOrderFeesSet(marketId, limitOrderMakerFeeRatio, limitOrderTakerFeeRatio); + } + /** * @inheritdoc IMarketConfigurationModule */ @@ -331,6 +346,18 @@ contract MarketConfigurationModule is IMarketConfigurationModule { takerFee = config.orderFees.takerFee; } + /** + * @inheritdoc IMarketConfigurationModule + */ + function getLimitOrderFees( + uint128 marketId + ) external view override returns (uint256 limitOrderMakerFee, uint256 limitOrderTakerFee) { + PerpsMarketConfiguration.Data storage config = PerpsMarketConfiguration.load(marketId); + + limitOrderMakerFee = config.orderFees.limitOrderMakerFee; + limitOrderTakerFee = config.orderFees.limitOrderTakerFee; + } + /** * @inheritdoc IMarketConfigurationModule */ diff --git a/markets/perps-market/contracts/storage/GlobalPerpsMarketConfiguration.sol b/markets/perps-market/contracts/storage/GlobalPerpsMarketConfiguration.sol index 7e14ddb4aa..d484288e95 100644 --- a/markets/perps-market/contracts/storage/GlobalPerpsMarketConfiguration.sol +++ b/markets/perps-market/contracts/storage/GlobalPerpsMarketConfiguration.sol @@ -95,6 +95,10 @@ library GlobalPerpsMarketConfiguration { * @dev reward distributor implementation. This is used as a base to be cloned to distribute rewards to the liquidator. */ address rewardDistributorImplementation; + /** + * @dev Percentage share of fees for each limit order relayer address + */ + mapping(address => uint256) relayerShare; } function load() internal pure returns (Data storage globalMarketConfig) { @@ -167,23 +171,23 @@ library GlobalPerpsMarketConfiguration { return (referralFees, 0); } - uint256 feeCollectorQuote = self.feeCollector.quoteFees( + feeCollectorFees = self.feeCollector.quoteFees( factory.perpsMarketId, remainingFees, ERC2771Context._msgSender() ); - if (feeCollectorQuote == 0) { + if (feeCollectorFees == 0) { return (referralFees, 0); } - if (feeCollectorQuote > remainingFees) { - feeCollectorQuote = remainingFees; + if (feeCollectorFees > remainingFees) { + feeCollectorFees = remainingFees; } - factory.withdrawMarketUsd(address(self.feeCollector), feeCollectorQuote); + factory.withdrawMarketUsd(address(self.feeCollector), feeCollectorFees); - return (referralFees, feeCollectorQuote); + return (referralFees, feeCollectorFees); } function calculateCollateralLiquidateReward( @@ -222,4 +226,21 @@ library GlobalPerpsMarketConfiguration { factory.withdrawMarketUsd(referrer, referralFeesSent); } } + + function _collectRelayerFees( + Data storage self, + uint256 fees, + address relayer, + PerpsMarketFactory.Data storage factory + ) private returns (uint256 relayerFeesSent) { + if (fees == 0 || relayer == address(0)) { + return 0; + } + + uint256 relayerShareRatio = self.relayerShare[relayer]; + if (relayerShareRatio > 0) { + relayerFeesSent = fees.mulDecimal(relayerShareRatio); + factory.withdrawMarketUsd(relayer, relayerFeesSent); + } + } } diff --git a/markets/perps-market/contracts/storage/LimitOrder.sol b/markets/perps-market/contracts/storage/LimitOrder.sol new file mode 100644 index 0000000000..6911641378 --- /dev/null +++ b/markets/perps-market/contracts/storage/LimitOrder.sol @@ -0,0 +1,146 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {Position} from "./Position.sol"; +import {MarketUpdate} from "./MarketUpdate.sol"; + +library LimitOrder { + /** + * @notice Gets thrown when a limit order is not on the right nonce + */ + error LimitOrderInvalidNonce(uint128 accountId, uint256 invalidNonce, uint256 validNonce); + + bytes32 private constant _SLOT_LIMIT_ORDER = + keccak256(abi.encode("io.synthetix.perps-market.LimitOrder")); + + struct Data { + /** + * @dev a mapping of account ids to their current order nonces which increment one at a time. + */ + mapping(uint128 => mapping(uint256 => uint256)) limitOrderNonceBitmaps; + } + + /** + * @notice Limit Order structured data. + */ + struct SignedOrderRequest { + /** + * @dev Limit order account id. + */ + uint128 accountId; + /** + * @dev Limit order market id. + */ + uint128 marketId; + /** + * @dev Limit order relayer address. + */ + address relayer; + /** + * @dev Limit order amount. + */ + int128 amount; + /** + * @dev Limit order price. + */ + uint256 price; + /** + * @dev Is the account a maker? + */ + bool limitOrderMaker; + /** + * @dev Limit order expiration. + */ + uint256 expiration; + /** + * @dev Limit order nonce. + */ + uint256 nonce; + /** + * @dev An optional code provided by frontends to assist with tracking the source of volume and fees. + */ + bytes32 trackingCode; + } + + /** + * @notice Limit Order signature struct. + */ + struct Signature { + uint8 v; + bytes32 r; + bytes32 s; + } + + /** + * @dev Struct used internally in validateRequest() to prevent stack too deep error. + */ + struct ValidateRequestRuntime { + bool isEligible; + int128 amount; + uint128 accountId; + uint128 marketId; + uint256 price; + uint256 limitOrderFees; + int128 newPositionSize; + int256 currentAvailableMargin; + uint256 requiredInitialMargin; + uint256 totalRequiredMargin; + Position.Data newPosition; + } + + /** + * @dev Struct used internally in settleRequest() to prevent stack too deep error. + */ + struct SettleRequestRuntime { + uint128 marketId; + uint128 accountId; + int128 amount; + int256 pnl; + MarketUpdate.Data updateData; + uint256 chargedInterest; + Position.Data newPosition; + Position.Data oldPosition; + uint256 relayerFees; + uint256 feeCollectorFees; + int256 accruedFunding; + uint256 limitOrderFees; + uint256 price; + int256 chargedAmount; + } + + function load() internal pure returns (Data storage limitOrderNonces) { + bytes32 s = _SLOT_LIMIT_ORDER; + assembly { + limitOrderNonces.slot := s + } + } + + /** + * @dev Checks if a limit order nonce has been used by a given account. + * @param self The Data storage struct. + * @param accountId The account ID to check. + * @param nonce The limit order nonce to check. + * @return bool true if the nonce has been used, false otherwise. + */ + function isLimitOrderNonceUsed( + Data storage self, + uint128 accountId, + uint256 nonce + ) internal view returns (bool) { + uint256 slot = nonce / 256; // Determine the bitmap slot + uint256 bit = nonce % 256; // Determine the bit position within the slot + return (self.limitOrderNonceBitmaps[accountId][slot] & (1 << bit)) != 0; + } + + /** + * @dev Marks a limit order nonce as used for a given account. + * @param self The Data storage struct. + * @param accountId The account ID to mark the nonce for. + * @param nonce The nonce to mark as used. + */ + function markLimitOrderNonceUsed(Data storage self, uint128 accountId, uint256 nonce) internal { + uint256 slot = nonce / 256; + uint256 bit = nonce % 256; + self.limitOrderNonceBitmaps[accountId][slot] |= 1 << bit; + } +} diff --git a/markets/perps-market/contracts/storage/OrderFee.sol b/markets/perps-market/contracts/storage/OrderFee.sol index ba26076ec3..324ec14f26 100644 --- a/markets/perps-market/contracts/storage/OrderFee.sol +++ b/markets/perps-market/contracts/storage/OrderFee.sol @@ -14,5 +14,13 @@ library OrderFee { * @dev Taker fee. Applied when order (or partial order) is increasing skew. */ uint256 takerFee; + /** + * @dev Limit order maker fee. Applied when limit order is fully matched. + */ + uint256 limitOrderMakerFee; + /** + * @dev Limit order taker fee. Applied when limit order is fully matched. + */ + uint256 limitOrderTakerFee; } } diff --git a/markets/perps-market/contracts/storage/PerpsMarket.sol b/markets/perps-market/contracts/storage/PerpsMarket.sol index 111f53c277..a9886ec496 100644 --- a/markets/perps-market/contracts/storage/PerpsMarket.sol +++ b/markets/perps-market/contracts/storage/PerpsMarket.sol @@ -455,4 +455,49 @@ library PerpsMarket { ) internal view returns (Position.Data storage position) { position = load(marketId).positions[accountId]; } + + function validateLimitOrderSize( + Data storage self, + uint256 maxSize, + uint256 maxValue, + uint256 price, + int128 amount + ) internal view { + int256 newMarketSize = self.size.toInt() + (MathUtil.abs(amount).toInt() * 2); + int256 newLongSize = newMarketSize + self.skew; + int256 newShortSize = newMarketSize - self.skew; + + // newSideSize still includes an extra factor of 2 here, so we will divide by 2 in the actual condition + if (maxSize < MathUtil.abs(newLongSize / 2)) { + revert PerpsMarketConfiguration.MaxOpenInterestReached( + self.id, + maxSize, + newLongSize / 2 + ); + } else if (maxSize < MathUtil.abs(newShortSize / 2)) { + revert PerpsMarketConfiguration.MaxOpenInterestReached( + self.id, + maxSize, + newShortSize / 2 + ); + } + + // same check but with value (size * price) + // note that if maxValue param is set to 0, this validation is skipped + if (maxValue > 0 && maxValue < MathUtil.abs(newLongSize / 2).mulDecimal(price)) { + revert PerpsMarketConfiguration.MaxUSDOpenInterestReached( + self.id, + maxValue, + newLongSize / 2, + price + ); + } else if (maxValue > 0 && maxValue < MathUtil.abs(newShortSize / 2).mulDecimal(price)) { + revert PerpsMarketConfiguration.MaxUSDOpenInterestReached( + self.id, + maxValue, + newShortSize / 2, + price + ); + } + } } diff --git a/markets/perps-market/contracts/utils/Flags.sol b/markets/perps-market/contracts/utils/Flags.sol index c4df5e65e8..6bb171ff2b 100644 --- a/markets/perps-market/contracts/utils/Flags.sol +++ b/markets/perps-market/contracts/utils/Flags.sol @@ -4,4 +4,5 @@ pragma solidity >=0.8.11 <0.9.0; library Flags { bytes32 public constant PERPS_SYSTEM = "perpsSystem"; bytes32 public constant CREATE_MARKET = "createMarket"; + bytes32 public constant LIMIT_ORDER = "limitOrder"; } diff --git a/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts b/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts new file mode 100644 index 0000000000..14fb465161 --- /dev/null +++ b/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts @@ -0,0 +1,496 @@ +import { ethers } from 'ethers'; +import { bn, bootstrapMarkets } from '../bootstrap'; +// import { fastForwardTo } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import { SynthMarkets } from '@synthetixio/spot-market/test/common'; +import { + DepositCollateralData, + depositCollateral, + createMatchingLimitOrders, + signOrder, + Order, +} from '../helpers'; +import { wei } from '@synthetixio/wei'; +// import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +// import assert from 'assert'; +// import { getTxTime } from '@synthetixio/core-utils/src/utils/hardhat/rpc'; + +describe('Settle Offchain Limit Order tests', () => { + const { systems, perpsMarkets, synthMarkets, provider, trader1, trader2, signers, owner } = + bootstrapMarkets({ + synthMarkets: [ + { + name: 'Bitcoin', + token: 'snxBTC', + buyPrice: bn(10_000), + sellPrice: bn(10_000), + }, + ], + perpsMarkets: [ + { + requestedMarketId: 25, + name: 'Ether', + token: 'snxETH', + price: bn(1000), + fundingParams: { skewScale: bn(100_000), maxFundingVelocity: bn(0) }, + }, + ], + traderAccountIds: [2, 3], + }); + let ethMarketId: ethers.BigNumber; + let btcSynth: SynthMarkets[number]; + let shortOrder: Order; + let longOrder: Order; + const price = bn(999.9995); + const amount = bn(1); + const nonZeroLimitOrderMakerFee = bn(0.0002); // 2bps + const nonZeroLimitOrderTakerFee = bn(0.0006); // 6bps + let relayer: ethers.Signer; + const relayerRatio = wei(0.3); // 30% + + before('identify relayer', async () => { + relayer = signers()[8]; + }); + + before('identify actors, set fee collector, set relayer fees, set market fees', async () => { + ethMarketId = perpsMarkets()[0].marketId(); + btcSynth = synthMarkets()[0]; + await systems() + .PerpsMarket.connect(owner()) + .setFeeCollector(systems().FeeCollectorMock.address); + await systems() + .PerpsMarket.connect(owner()) + .updateRelayerShare(await relayer.getAddress(), relayerRatio.toBN()); // 30% + await systems() + .PerpsMarket.connect(owner()) + .setLimitOrderFees(ethMarketId, nonZeroLimitOrderMakerFee, nonZeroLimitOrderTakerFee); + }); + + // let btcSynth: SynthMarkets[number]; + + const PERPS_COMMIT_LIMIT_ORDER_PERMISSION_NAME = ethers.utils.formatBytes32String( + 'PERPS_COMMIT_LIMIT_ORDER' + ); + + const restoreToCommit = snapshotCheckpoint(provider); + + const testCase: Array<{ name: string; collateralData: DepositCollateralData[] }> = [ + { + name: 'snxUSD and snxBTC', + collateralData: [ + { + systems, + trader: trader1, + accountId: () => 2, + collaterals: [ + { + snxUSDAmount: () => bn(10_000_000), + }, + { + synthMarket: () => btcSynth, + snxUSDAmount: () => bn(10_000_000), + }, + ], + }, + { + systems, + trader: trader2, + accountId: () => 3, + collaterals: [ + { + snxUSDAmount: () => bn(10_000_000), + }, + { + synthMarket: () => btcSynth, + snxUSDAmount: () => bn(10_000_000), + }, + ], + }, + ], + }, + ]; + + // TODO set the maker and taker fees. Require those to be set in the code maybe? + let tx: ethers.ContractTransaction; + // let startTime: number; + + before(restoreToCommit); + + before('add collateral', async () => { + await depositCollateral(testCase[0].collateralData[0]); + await depositCollateral(testCase[0].collateralData[1]); + }); + + before('creates the orders', async () => { + const orders = createMatchingLimitOrders({ + accountId: testCase[0].collateralData[1].accountId(), + marketId: ethMarketId, + relayer: ethers.utils.getAddress(await relayer.getAddress()), + amount, + isShort: false, + trackingCode: ethers.constants.HashZero, + price, + expiration: Math.floor(Date.now() / 1000) + 1000, + nonce: 9732849, + isMaker: false, + }); + shortOrder = orders.shortOrder; + longOrder = orders.longOrder; + }); + + const restoreToSnapshot = snapshotCheckpoint(provider); + + it('settles the orders and emits the proper events', async () => { + const signedShortOrder = await signOrder( + shortOrder, + trader1() as ethers.Wallet, + systems().PerpsMarket.address + ); + const signedLongOrder = await signOrder( + longOrder, + trader2() as ethers.Wallet, + systems().PerpsMarket.address + ); + tx = await systems() + .PerpsMarket.connect(owner()) + .settleLimitOrder(shortOrder, signedShortOrder, longOrder, signedLongOrder); + + const pnlShort = 0, + pnlLong = 0; + const accruedFundingShort = 0, + accruedFundingLong = 0; + const newPositionSizeShort = 0, + newPositionSizeLong = 0; + const limitOrderFeesShort = amount + .mul(price) + .div(bn(1)) + .mul(nonZeroLimitOrderMakerFee) + .div(bn(1)) + .toString(), + limitOrderFeesLong = amount + .mul(price) + .div(bn(1)) + .mul(nonZeroLimitOrderTakerFee) + .div(bn(1)) + .toString(); + const relayerFees = 0; + const feeCollectorFees = 0; + const chargedInterestShort = 0, + chargedInterestLong = 0; + + const orderSettledEventsArgs = { + trader1: [ + `${ethMarketId}`, + `${shortOrder.accountId}`, + `${price}`, + `${pnlShort}`, + `${accruedFundingShort}`, + `${shortOrder.amount}`, + `${newPositionSizeShort}`, + `${limitOrderFeesShort}`, + `${relayerFees}`, + `${feeCollectorFees}`, + `"${shortOrder.trackingCode}"`, + `${chargedInterestShort}`, + ].join(', '), + trader2: [ + `${ethMarketId}`, + `${longOrder.accountId}`, + `${price}`, + `${pnlLong}`, + `${accruedFundingLong}`, + `${longOrder.amount}`, + `${newPositionSizeLong}`, + `${limitOrderFeesLong}`, + `${relayerFees}`, + `${feeCollectorFees}`, + `"${longOrder.trackingCode}"`, + `${chargedInterestLong}`, + ].join(', '), + }; + // TODO fix this test + const marketUpdateEventsArgs = { + trader1: [ + `${ethMarketId}`, + `${price}`, + -1000000000000000000, + 1000000000000000000, + `${shortOrder.amount}`, + 0, + 0, + 0, + ].join(', '), + trader2: [ + `${ethMarketId}`, + `${price}`, + 0, + 2000000000000000000, + `${longOrder.amount}`, + 0, + 0, + 0, + ].join(', '), + }; + // const collateralDeductedEventsArgs = { + // trader1: [`${shortOrder.accountId}`, `${0}`, `${limitOrderFeesShort}`].join(', '), + // trader2: [`${longOrder.accountId}`, `${0}`, `${limitOrderFeesLong}`].join(', '), + // }; + await assertEvent( + tx, + `LimitOrderSettled(${orderSettledEventsArgs.trader1})`, + systems().PerpsMarket + ); + await assertEvent( + tx, + `LimitOrderSettled(${orderSettledEventsArgs.trader2})`, + systems().PerpsMarket + ); + await assertEvent( + tx, + `MarketUpdated(${marketUpdateEventsArgs.trader1})`, + systems().PerpsMarket + ); + await assertEvent( + tx, + `MarketUpdated(${marketUpdateEventsArgs.trader2})`, + systems().PerpsMarket + ); + // await assertEvent( + // tx, + // `CollateralDeducted(${collateralDeductedEventsArgs.trader1})`, + // systems().PerpsMarket + // ); + // await assertEvent( + // tx, + // `CollateralDeducted(${collateralDeductedEventsArgs.trader2})`, + // systems().PerpsMarket + // ); + }); + + it('fails to cancel an already completed limit order', async () => { + const signedShortOrder = await signOrder( + shortOrder, + trader1() as ethers.Wallet, + systems().PerpsMarket.address + ); + await assertRevert( + systems().PerpsMarket.connect(owner()).cancelLimitOrder(shortOrder, signedShortOrder), + `LimitOrderAlreadyUsed(${shortOrder.accountId}, ${shortOrder.nonce}, ${shortOrder.price}, ${shortOrder.amount})` + ); + }); + + it('successfully cancels a new limit order', async () => { + const newNonceShortOrder = { ...shortOrder, nonce: 197889234 }; + const signedNewNonceShortOrder = await signOrder( + newNonceShortOrder, + trader1() as ethers.Wallet, + systems().PerpsMarket.address + ); + const successTx = await systems() + .PerpsMarket.connect(owner()) + .cancelLimitOrder(newNonceShortOrder, signedNewNonceShortOrder); + + await assertEvent( + successTx, + `LimitOrderCancelled(${newNonceShortOrder.accountId}, ${newNonceShortOrder.nonce}, ${newNonceShortOrder.price}, ${newNonceShortOrder.amount})`, + systems().PerpsMarket + ); + }); + + it('fails to cancel a new limit order that is already settled', async () => { + const newNonceShortOrder = { ...shortOrder, nonce: 197889234 }; + const signedNewNonceShortOrder = await signOrder( + newNonceShortOrder, + trader1() as ethers.Wallet, + systems().PerpsMarket.address + ); + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .cancelLimitOrder(newNonceShortOrder, signedNewNonceShortOrder), + `LimitOrderAlreadyUsed(${newNonceShortOrder.accountId}, ${newNonceShortOrder.nonce}, ${newNonceShortOrder.price}, ${newNonceShortOrder.amount})` + ); + }); + + // TODO add the other transaction here and call rest + it('fails when the relayers are different for each order', async () => { + const badLongOrder = { ...longOrder, relayer: await trader1().getAddress() }; + const signedShortOrder = await signOrder( + shortOrder, + trader1() as ethers.Wallet, + systems().PerpsMarket.address + ); + const badSignedLongOrder = await signOrder( + badLongOrder, + trader2() as ethers.Wallet, + systems().PerpsMarket.address + ); + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .settleLimitOrder(shortOrder, signedShortOrder, badLongOrder, badSignedLongOrder), + `LimitOrderDifferentRelayer(${shortOrder.relayer}, ${badLongOrder.relayer})` + ); + }); + + it('fails when the markets are different for each order', async () => { + const badLongOrder = { ...longOrder, marketId: ethers.BigNumber.from(133) }; + const signedShortOrder = await signOrder( + shortOrder, + trader1() as ethers.Wallet, + systems().PerpsMarket.address + ); + const badSignedLongOrder = await signOrder( + badLongOrder, + trader2() as ethers.Wallet, + systems().PerpsMarket.address + ); + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .settleLimitOrder(shortOrder, signedShortOrder, badLongOrder, badSignedLongOrder), + `LimitOrderMarketMismatch(${shortOrder.marketId}, ${badLongOrder.marketId})` + ); + }); + + it('fails when the amounts are different for each order', async () => { + const badLongOrder = { ...longOrder, amount: bn(10) }; + const signedShortOrder = await signOrder( + shortOrder, + trader1() as ethers.Wallet, + systems().PerpsMarket.address + ); + const badSignedLongOrder = await signOrder( + badLongOrder, + trader2() as ethers.Wallet, + systems().PerpsMarket.address + ); + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .settleLimitOrder(shortOrder, signedShortOrder, badLongOrder, badSignedLongOrder), + `LimitOrderAmountError(${shortOrder.amount}, ${badLongOrder.amount})` + ); + }); + + it('fails when the orders are both makers', async () => { + const badLongOrder = { ...longOrder, limitOrderMaker: true }; + const signedShortOrder = await signOrder( + shortOrder, + trader1() as ethers.Wallet, + systems().PerpsMarket.address + ); + const badSignedLongOrder = await signOrder( + badLongOrder, + trader2() as ethers.Wallet, + systems().PerpsMarket.address + ); + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .settleLimitOrder(shortOrder, signedShortOrder, badLongOrder, badSignedLongOrder), + `MismatchingMakerTakerLimitOrder(${shortOrder.limitOrderMaker}, ${badLongOrder.limitOrderMaker})` + ); + }); + + it('fails with an invalid relayer', async () => { + const badLongOrder = { ...longOrder, relayer: await trader1().getAddress() }; + const badShortOrder = { ...shortOrder, relayer: await trader1().getAddress() }; + const badSignedShortOrder = await signOrder( + badShortOrder, + trader1() as ethers.Wallet, + systems().PerpsMarket.address + ); + const badSignedLongOrder = await signOrder( + badLongOrder, + trader2() as ethers.Wallet, + systems().PerpsMarket.address + ); + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .settleLimitOrder(badShortOrder, badSignedShortOrder, badLongOrder, badSignedLongOrder), + `LimitOrderRelayerInvalid(${badLongOrder.relayer})` + ); + }); + + it('fails when the signing account is not authorized for a permission', async () => { + const badSignerAddress = await trader1().getAddress(); + const signedShortOrder = await signOrder( + shortOrder, + trader1() as ethers.Wallet, + systems().PerpsMarket.address + ); + const badSignedLongOrder = await signOrder( + longOrder, + // NOTE fails because this should be trader2 + trader1() as ethers.Wallet, + systems().PerpsMarket.address + ); + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .settleLimitOrder(shortOrder, signedShortOrder, longOrder, badSignedLongOrder), + `PermissionDenied(${longOrder.accountId}, "${PERPS_COMMIT_LIMIT_ORDER_PERMISSION_NAME}", "${badSignerAddress}")` + ); + }); + + it('fails when either limit order has expired', async () => { + const blockNumber = await provider().getBlockNumber(); + const block = await provider().getBlock(blockNumber); + const expirationTimestamp = block.timestamp - 1000; + + const badLongOrder = { ...longOrder, expiration: expirationTimestamp }; + + const signedShortOrder = await signOrder( + shortOrder, + trader1() as ethers.Wallet, + systems().PerpsMarket.address + ); + + const badSignedLongOrder = await signOrder( + badLongOrder, + trader2() as ethers.Wallet, + systems().PerpsMarket.address + ); + + const nextBlock = await provider().getBlock('latest'); + const nextBlockTimestamp = nextBlock.timestamp; + + // TODO fix this test - it only works if I add +1 for some reason + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .settleLimitOrder(shortOrder, signedShortOrder, badLongOrder, badSignedLongOrder), + `LimitOrderExpired(${shortOrder.accountId}, ${shortOrder.expiration}, ${longOrder.accountId}, ${badLongOrder.expiration}, ${nextBlockTimestamp + 1})` + ); + + const badShortOrder = { ...shortOrder, expiration: expirationTimestamp }; + + const badSignedShortOrder = await signOrder( + badShortOrder, + trader1() as ethers.Wallet, + systems().PerpsMarket.address + ); + + const signedLongOrder = await signOrder( + longOrder, + trader2() as ethers.Wallet, + systems().PerpsMarket.address + ); + + const nextBlockTwo = await provider().getBlock('latest'); + const nextBlockTimestampTwo = nextBlockTwo.timestamp; + + // TODO fix this test - it only works if I add +1 for some reason + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .settleLimitOrder(badShortOrder, badSignedShortOrder, longOrder, signedLongOrder), + `LimitOrderExpired(${badShortOrder.accountId}, ${badShortOrder.expiration}, ${longOrder.accountId}, ${longOrder.expiration}, ${nextBlockTimestampTwo + 1})` + ); + }); + after(restoreToSnapshot); +}); diff --git a/markets/perps-market/test/integration/Market/MarketConfiguration.test.ts b/markets/perps-market/test/integration/Market/MarketConfiguration.test.ts index 469f5a5309..8942dbe494 100644 --- a/markets/perps-market/test/integration/Market/MarketConfiguration.test.ts +++ b/markets/perps-market/test/integration/Market/MarketConfiguration.test.ts @@ -18,7 +18,7 @@ describe('MarketConfiguration', () => { const fixture = { token: 'snxETH', marketName: 'TestPerpsMarket', - orderFees: { makerFee: 0, takerFee: 1 }, + orderFees: { makerFee: 0, takerFee: 1, limitOrderMakerFee: 1, limitOrderTakerFee: 2 }, settlementStrategy: { strategyType: 0, commitmentPriceDelay: 0, @@ -254,6 +254,26 @@ describe('MarketConfiguration', () => { systems().PerpsMarket ); }); + + it('owner can set limit order fees and events are emitted', async () => { + await assertEvent( + await systems() + .PerpsMarket.connect(owner()) + .setLimitOrderFees( + marketId, + fixture.orderFees.limitOrderMakerFee, + fixture.orderFees.limitOrderTakerFee + ), + 'LimitOrderFeesSet(' + + marketId.toString() + + ', ' + + fixture.orderFees.limitOrderMakerFee.toString() + + ', ' + + fixture.orderFees.limitOrderTakerFee.toString() + + ')', + systems().PerpsMarket + ); + }); it('owner can set max market size and events are emitted', async () => { await assertEvent( await systems() @@ -368,6 +388,17 @@ describe('MarketConfiguration', () => { 'Unauthorized', systems().PerpsMarket ); + await assertRevert( + systems() + .PerpsMarket.connect(randomUser) + .setLimitOrderFees( + marketId, + fixture.orderFees.limitOrderMakerFee, + fixture.orderFees.limitOrderTakerFee + ), + 'Unauthorized', + systems().PerpsMarket + ); await assertRevert( systems().PerpsMarket.connect(randomUser).setMaxMarketSize(marketId, fixture.maxMarketSize), 'Unauthorized', diff --git a/markets/perps-market/test/integration/helpers/index.ts b/markets/perps-market/test/integration/helpers/index.ts index 71426459b5..e5f468f852 100644 --- a/markets/perps-market/test/integration/helpers/index.ts +++ b/markets/perps-market/test/integration/helpers/index.ts @@ -6,3 +6,4 @@ export * from './computeFees'; export * from './requiredMargins'; export * from './createAccountAndPosition'; export * from './interestRate'; +export * from './limitOrderHelper'; diff --git a/markets/perps-market/test/integration/helpers/limitOrderHelper.ts b/markets/perps-market/test/integration/helpers/limitOrderHelper.ts new file mode 100644 index 0000000000..cf8471166c --- /dev/null +++ b/markets/perps-market/test/integration/helpers/limitOrderHelper.ts @@ -0,0 +1,169 @@ +import { ethers, BigNumber } from 'ethers'; +import { ecsign } from 'ethereumjs-util'; + +export interface Order { + accountId: number; + marketId: BigNumber; + relayer: string; + amount: BigNumber; + price: BigNumber; + limitOrderMaker: boolean; + expiration: number; + nonce: number; + trackingCode: string; +} + +interface OrderCreationArgs { + accountId: number; + isShort: boolean; + isMaker: boolean; + marketId: BigNumber; + relayer: string; + amount: BigNumber; + price: BigNumber; + expiration: number; + nonce: number; + trackingCode: string; +} + +async function getDomain(signer: ethers.Wallet, contractAddress: string): Promise { + const chainId = await signer.getChainId(); + + return ethers.utils.keccak256( + ethers.utils.defaultAbiCoder.encode( + ['bytes32', 'bytes32', 'bytes32', 'uint256', 'address'], + [ + ethers.utils.keccak256( + ethers.utils.toUtf8Bytes( + 'EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)' + ) + ), + ethers.utils.keccak256(ethers.utils.toUtf8Bytes('SyntheticPerpetualFutures')), + ethers.utils.keccak256(ethers.utils.toUtf8Bytes('1')), + chainId, + contractAddress, + ] + ) + ); +} + +function createLimitOrder(orderArgs: OrderCreationArgs): Order { + const { + accountId, + marketId, + relayer, + isShort, + amount, + price, + isMaker, + expiration, + nonce, + trackingCode, + } = orderArgs; + return { + accountId, + marketId, + relayer, + amount: isShort + ? amount.lt(0) + ? amount + : amount.mul(-1) + : amount.gt(0) + ? amount + : amount.mul(-1), + price, + limitOrderMaker: isMaker, + expiration, + nonce, + trackingCode, + }; +} + +export function createMatchingLimitOrders(orderArgs: OrderCreationArgs): { + shortOrder: Order; + longOrder: Order; +} { + if (orderArgs.amount.lt(0) || orderArgs.isShort) { + throw new Error('arguments must be for the long position for this method to work'); + } + const order = createLimitOrder(orderArgs); + const oppositeOrder = createLimitOrder({ + ...orderArgs, + isShort: true, + accountId: orderArgs.accountId - 1, + isMaker: !orderArgs.isMaker, + }); + return { + shortOrder: oppositeOrder, + longOrder: order, + }; +} + +const ORDER_TYPEHASH = ethers.utils.keccak256( + ethers.utils.toUtf8Bytes( + 'SignedOrderRequest(uint128 accountId,uint128 marketId,address relayer,int128 amount,uint256 price,limitOrderMaker bool,expiration uint256,nonce uint256,trackingCode bytes32)' + ) +); + +export async function signOrder( + order: Order, + signer: ethers.Wallet, + contractAddress: string +): Promise<{ v: number; r: Buffer; s: Buffer }> { + const { + accountId, + marketId, + relayer, + amount, + price, + limitOrderMaker, + expiration, + nonce, + trackingCode, + } = order; + const domainSeparator = await getDomain(signer, contractAddress); + + const digest = ethers.utils.keccak256( + ethers.utils.solidityPack( + ['bytes1', 'bytes1', 'bytes32', 'bytes32'], + [ + '0x19', + '0x01', + domainSeparator, + ethers.utils.keccak256( + ethers.utils.defaultAbiCoder.encode( + [ + 'bytes32', + 'uint128', + 'uint128', + 'address', + 'int128', + 'uint256', + 'bool', + 'uint256', + 'uint256', + 'bytes32', + ], + [ + ORDER_TYPEHASH, + accountId, + marketId, + relayer, + amount, + price, + limitOrderMaker, + expiration, + nonce, + trackingCode, + ] + ) + ), + ] + ) + ); + + return ecsign( + Buffer.from(digest.slice(2), 'hex'), + Buffer.from(signer.privateKey.slice(2), 'hex') + ); +} diff --git a/protocol/synthetix/contracts/storage/Account.sol b/protocol/synthetix/contracts/storage/Account.sol index 9eb3e811ff..3ff74607a7 100644 --- a/protocol/synthetix/contracts/storage/Account.sol +++ b/protocol/synthetix/contracts/storage/Account.sol @@ -167,6 +167,30 @@ library Account { recordInteraction(account); } + // NOTE go over this method with Sunny in more detail + /** + * @dev Loads the Account object for the specified accountId, + * and validates that sender has the specified permission. It also resets + * the interaction timeout. These + * are different actions but they are merged in a single function + * because loading an account and checking for a permission is a very + * common use case in other parts of the code. + */ + function loadAccountAndValidateSignerPermission( + uint128 accountId, + bytes32 permission, + address signingAddress + ) internal { + Data storage account = Account.load(accountId); + + if (!account.rbac.authorized(permission, signingAddress)) { + revert PermissionDenied(accountId, permission, signingAddress); + } + + // NOTE do we want limit order matching to count on the withdrawal timeout? + recordInteraction(account); + } + /** * @dev Loads the Account object for the specified accountId, * and validates that sender has the specified permission. It also resets diff --git a/protocol/synthetix/contracts/storage/AccountRBAC.sol b/protocol/synthetix/contracts/storage/AccountRBAC.sol index 32348262b6..6871c1b1ab 100644 --- a/protocol/synthetix/contracts/storage/AccountRBAC.sol +++ b/protocol/synthetix/contracts/storage/AccountRBAC.sol @@ -22,6 +22,7 @@ library AccountRBAC { bytes32 internal constant _REWARDS_PERMISSION = "REWARDS"; bytes32 internal constant _PERPS_MODIFY_COLLATERAL_PERMISSION = "PERPS_MODIFY_COLLATERAL"; bytes32 internal constant _PERPS_COMMIT_ASYNC_ORDER_PERMISSION = "PERPS_COMMIT_ASYNC_ORDER"; + bytes32 internal constant _PERPS_COMMIT_LIMIT_ORDER_PERMISSION = "PERPS_COMMIT_LIMIT_ORDER"; bytes32 internal constant _BURN_PERMISSION = "BURN"; /** @@ -56,6 +57,7 @@ library AccountRBAC { permission != AccountRBAC._REWARDS_PERMISSION && permission != AccountRBAC._PERPS_MODIFY_COLLATERAL_PERMISSION && permission != AccountRBAC._PERPS_COMMIT_ASYNC_ORDER_PERMISSION && + permission != AccountRBAC._PERPS_COMMIT_LIMIT_ORDER_PERMISSION && permission != AccountRBAC._BURN_PERMISSION ) { revert InvalidPermission(permission);