From 5af5661f339ccaf62129b8a883a298bcda86facc Mon Sep 17 00:00:00 2001 From: Noah Litvin <335975+noahlitvin@users.noreply.github.com> Date: Thu, 27 Jun 2024 11:48:28 -0400 Subject: [PATCH 1/6] initial commit to implement orderbook settlement --- markets/perps-market/cannonfile.test.toml | 10 + markets/perps-market/cannonfile.toml | 4 + .../interfaces/IGlobalPerpsMarketModule.sol | 26 ++ .../interfaces/ILimitOrderModule.sol | 138 ++++++ .../modules/GlobalPerpsMarketModule.sol | 28 ++ .../contracts/modules/LimitOrderModule.sol | 415 ++++++++++++++++++ .../GlobalPerpsMarketConfiguration.sol | 33 +- .../contracts/storage/LimitOrder.sol | 149 +++++++ .../contracts/storage/OrderFee.sol | 8 + .../contracts/storage/PerpsMarket.sol | 45 ++ .../perps-market/contracts/utils/Flags.sol | 1 + ...ffchainLimitOrder.settleLimitOrder.test.ts | 394 +++++++++++++++++ .../test/integration/helpers/index.ts | 1 + .../integration/helpers/limitOrderHelper.ts | 168 +++++++ .../synthetix/contracts/storage/Account.sol | 24 + .../contracts/storage/AccountRBAC.sol | 5 + 16 files changed, 1443 insertions(+), 6 deletions(-) create mode 100644 markets/perps-market/contracts/interfaces/ILimitOrderModule.sol create mode 100644 markets/perps-market/contracts/modules/LimitOrderModule.sol create mode 100644 markets/perps-market/contracts/storage/LimitOrder.sol create mode 100644 markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts create mode 100644 markets/perps-market/test/integration/helpers/limitOrderHelper.ts 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..44a4be4f01 --- /dev/null +++ b/markets/perps-market/contracts/interfaces/ILimitOrderModule.sol @@ -0,0 +1,138 @@ +//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 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/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..236d703b2c --- /dev/null +++ b/markets/perps-market/contracts/modules/LimitOrderModule.sol @@ -0,0 +1,415 @@ +//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 {ERC2771Context} from "@synthetixio/core-contracts/contracts/utils/ERC2771Context.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"; + +/** + * @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 { + // TODO consider adding feature flag here + 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 + ); + 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 { + 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.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.calculateStartingPnl(runtime.price, lastPriceCheck, runtime.newPositionSize), + 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); + } + 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.pnlUint = MathUtil.abs(runtime.pnl); + + if (runtime.pnl > 0) { + perpsAccount.updateCollateralAmount(SNX_USD_MARKET_ID, runtime.pnl); + } else if (runtime.pnl < 0) { + runtime.limitOrderFees += runtime.pnlUint; + } + + // 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 + ); + + // since margin is deposited when trader deposits, as long as the owed collateral is deducted + // from internal accounting, fees are automatically realized by the stakers + if (runtime.limitOrderFees > 0) { + (runtime.deductedSynthIds, runtime.deductedAmount) = perpsAccount.deductFromAccount( + runtime.limitOrderFees + ); + for ( + runtime.synthDeductionIterator = 0; + runtime.synthDeductionIterator < runtime.deductedSynthIds.length; + runtime.synthDeductionIterator++ + ) { + if (runtime.deductedAmount[runtime.synthDeductionIterator] > 0) { + emit CollateralDeducted( + runtime.accountId, + runtime.deductedSynthIds[runtime.synthDeductionIterator], + runtime.deductedAmount[runtime.synthDeductionIterator] + ); + } + } + } + + 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/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..2f74374d73 --- /dev/null +++ b/markets/perps-market/contracts/storage/LimitOrder.sol @@ -0,0 +1,149 @@ +//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; + uint256 pnlUint; + MarketUpdate.Data updateData; + uint256 chargedInterest; + Position.Data newPosition; + Position.Data oldPosition; + uint256 synthDeductionIterator; + uint128[] deductedSynthIds; + uint256[] deductedAmount; + uint256 relayerFees; + uint256 feeCollectorFees; + int256 accruedFunding; + uint256 limitOrderFees; + uint256 price; + } + + 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..1b74fa52d9 --- /dev/null +++ b/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts @@ -0,0 +1,394 @@ +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.only('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); + let relayer: ethers.Signer; + const relayerRatio = wei(0.3); // 30% + + before('identify relayer', async () => { + relayer = signers()[8]; + }); + + // TODO set on a per market level + before('set fee collector and referral', async () => { + await systems() + .PerpsMarket.connect(owner()) + .setFeeCollector(systems().FeeCollectorMock.address); + await systems() + .PerpsMarket.connect(owner()) + .updateRelayerShare(await relayer.getAddress(), relayerRatio.toBN()); // 30% + }); + + // let btcSynth: SynthMarkets[number]; + + const PERPS_COMMIT_LIMIT_ORDER_PERMISSION_NAME = ethers.utils.formatBytes32String( + 'PERPS_COMMIT_LIMIT_ORDER' + ); + + before('identify actors', async () => { + ethMarketId = perpsMarkets()[0].marketId(); + btcSynth = synthMarkets()[0]; + }); + + 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 + ); + console.log('signer should be the account owner'); + console.log('short order signer', await trader1().getAddress()); + const signedLongOrder = await signOrder( + longOrder, + trader2() as ethers.Wallet, + systems().PerpsMarket.address + ); + console.log('long order signer', await trader2().getAddress()); + tx = await systems() + .PerpsMarket.connect(owner()) + .settleLimitOrder(shortOrder, signedShortOrder, longOrder, signedLongOrder); + const orderSettledEventsArgs = { + trader1: [ + `${ethMarketId}`, + `${shortOrder.accountId}`, + `${price}`, + `${0}`, + `${0}`, + `${shortOrder.amount}`, + `${0}`, + `${0}`, + `${0}`, + `${0}`, + `"${shortOrder.trackingCode}"`, + `${0}`, + ].join(', '), + trader2: [ + `${ethMarketId}`, + `${longOrder.accountId}`, + `${price}`, + `${0}`, + `${0}`, + `${longOrder.amount}`, + `${0}`, + `${0}`, + `${0}`, + `${0}`, + `"${longOrder.trackingCode.toString()}"`, + `${0}`, + ].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(', '), + }; + 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 + ); + }); + + 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 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/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..af43fc3adc --- /dev/null +++ b/markets/perps-market/test/integration/helpers/limitOrderHelper.ts @@ -0,0 +1,168 @@ +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, + }); + 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..4d9f3595cb 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); + } + + // TODO record interaction??? look into it + 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..ed90ad79da 100644 --- a/protocol/synthetix/contracts/storage/AccountRBAC.sol +++ b/protocol/synthetix/contracts/storage/AccountRBAC.sol @@ -3,6 +3,7 @@ pragma solidity >=0.8.11 <0.9.0; import "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; import "@synthetixio/core-contracts/contracts/errors/AddressError.sol"; +import "hardhat/console.sol"; /** * @title Object for tracking an accounts permissions (role based access control). @@ -22,6 +23,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 +58,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); @@ -136,6 +139,8 @@ library AccountRBAC { bytes32 permission, address target ) internal view returns (bool) { + console.log("signing address aka target", target); + console.log("account owner self.owner", self.owner); return ((target == self.owner) || hasPermission(self, _ADMIN_PERMISSION, target) || hasPermission(self, permission, target)); From a6a7f0ac505bbb2e69834bb503e4997fdad3c6ca Mon Sep 17 00:00:00 2001 From: Fred Snax Date: Thu, 25 Jul 2024 15:17:47 -0700 Subject: [PATCH 2/6] remove .only --- .../LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts b/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts index 1b74fa52d9..f7c26d98d2 100644 --- a/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts +++ b/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts @@ -17,7 +17,7 @@ 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.only('Settle Offchain Limit Order tests', () => { +describe('Settle Offchain Limit Order tests', () => { const { systems, perpsMarkets, synthMarkets, provider, trader1, trader2, signers, owner } = bootstrapMarkets({ synthMarkets: [ From e9d66c923e817d947a6b2be31c764f9a3c3cba4e Mon Sep 17 00:00:00 2001 From: Fred Snax Date: Fri, 26 Jul 2024 09:01:23 -0700 Subject: [PATCH 3/6] wip --- .../interfaces/IMarketConfigurationModule.sol | 34 +++++++++ .../modules/MarketConfigurationModule.sol | 27 +++++++ ...ffchainLimitOrder.settleLimitOrder.test.ts | 73 +++++++++++++------ .../Market/MarketConfiguration.test.ts | 33 ++++++++- .../synthetix/contracts/storage/Account.sol | 2 +- 5 files changed, 143 insertions(+), 26 deletions(-) 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/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/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts b/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts index f7c26d98d2..6dcf8b7416 100644 --- a/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts +++ b/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts @@ -52,14 +52,20 @@ describe('Settle Offchain Limit Order tests', () => { relayer = signers()[8]; }); - // TODO set on a per market level - before('set fee collector and referral', async () => { + 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% + const nonZeroLimitOrderMakerFee = bn(0.0002); // 2bps + const nonZeroLimitOrderTakerFee = bn(0.0006); // 6bps + await systems() + .PerpsMarket.connect(owner()) + .setLimitOrderFees(ethMarketId, nonZeroLimitOrderMakerFee, nonZeroLimitOrderTakerFee); }); // let btcSynth: SynthMarkets[number]; @@ -68,11 +74,6 @@ describe('Settle Offchain Limit Order tests', () => { 'PERPS_COMMIT_LIMIT_ORDER' ); - before('identify actors', async () => { - ethMarketId = perpsMarkets()[0].marketId(); - btcSynth = synthMarkets()[0]; - }); - const restoreToCommit = snapshotCheckpoint(provider); const testCase: Array<{ name: string; collateralData: DepositCollateralData[] }> = [ @@ -147,45 +148,55 @@ describe('Settle Offchain Limit Order tests', () => { trader1() as ethers.Wallet, systems().PerpsMarket.address ); - console.log('signer should be the account owner'); - console.log('short order signer', await trader1().getAddress()); const signedLongOrder = await signOrder( longOrder, trader2() as ethers.Wallet, systems().PerpsMarket.address ); - console.log('long order signer', await trader2().getAddress()); 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 = 599999700000000000, + limitOrderFeesLong = 599999700000000000; + const relayerFees = 0; + const feeCollectorFees = 0; + const chargedInterestShort = 0, + chargedInterestLong = 0; const orderSettledEventsArgs = { trader1: [ `${ethMarketId}`, `${shortOrder.accountId}`, `${price}`, - `${0}`, - `${0}`, + `${pnlShort}`, + `${accruedFundingShort}`, `${shortOrder.amount}`, - `${0}`, - `${0}`, - `${0}`, - `${0}`, + `${newPositionSizeShort}`, + `${limitOrderFeesShort}`, + `${relayerFees}`, + `${feeCollectorFees}`, `"${shortOrder.trackingCode}"`, - `${0}`, + `${chargedInterestShort}`, ].join(', '), trader2: [ `${ethMarketId}`, `${longOrder.accountId}`, `${price}`, - `${0}`, - `${0}`, + `${pnlLong}`, + `${accruedFundingLong}`, `${longOrder.amount}`, - `${0}`, - `${0}`, - `${0}`, - `${0}`, + `${newPositionSizeLong}`, + `${limitOrderFeesLong}`, + `${relayerFees}`, + `${feeCollectorFees}`, `"${longOrder.trackingCode.toString()}"`, - `${0}`, + `${chargedInterestLong}`, ].join(', '), }; // TODO fix this test @@ -211,6 +222,10 @@ describe('Settle Offchain Limit Order tests', () => { 0, ].join(', '), }; + const collateralDeductedEventsArgs = { + trader1: [`${shortOrder.accountId}`, `${0}`, `${limitOrderFeesShort}`].join(', '), + trader2: [`${longOrder.accountId}`, `${0}`, `${limitOrderFeesLong}`].join(', '), + }; await assertEvent( tx, `LimitOrderSettled(${orderSettledEventsArgs.trader1})`, @@ -231,6 +246,16 @@ describe('Settle Offchain Limit Order tests', () => { `MarketUpdated(${marketUpdateEventsArgs.trader2})`, systems().PerpsMarket ); + await assertEvent( + tx, + `CollateralDeducted(${collateralDeductedEventsArgs.trader1})`, + systems().PerpsMarket + ); + await assertEvent( + tx, + `CollateralDeducted(${collateralDeductedEventsArgs.trader2})`, + systems().PerpsMarket + ); }); it('fails when the relayers are different for each order', async () => { 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/protocol/synthetix/contracts/storage/Account.sol b/protocol/synthetix/contracts/storage/Account.sol index 4d9f3595cb..3ff74607a7 100644 --- a/protocol/synthetix/contracts/storage/Account.sol +++ b/protocol/synthetix/contracts/storage/Account.sol @@ -187,7 +187,7 @@ library Account { revert PermissionDenied(accountId, permission, signingAddress); } - // TODO record interaction??? look into it + // NOTE do we want limit order matching to count on the withdrawal timeout? recordInteraction(account); } From c64c0f82824f209148a8d389b192cef638897cec Mon Sep 17 00:00:00 2001 From: Fred Snax Date: Fri, 26 Jul 2024 14:41:01 -0700 Subject: [PATCH 4/6] wip --- .../interfaces/ILimitOrderModule.sol | 7 ++++ .../contracts/modules/LimitOrderModule.sol | 6 +++ ...ffchainLimitOrder.settleLimitOrder.test.ts | 41 ++++++++++++++++--- .../integration/helpers/limitOrderHelper.ts | 1 + 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/markets/perps-market/contracts/interfaces/ILimitOrderModule.sol b/markets/perps-market/contracts/interfaces/ILimitOrderModule.sol index 44a4be4f01..7f72f404b8 100644 --- a/markets/perps-market/contracts/interfaces/ILimitOrderModule.sol +++ b/markets/perps-market/contracts/interfaces/ILimitOrderModule.sol @@ -65,6 +65,13 @@ interface ILimitOrderModule { 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 diff --git a/markets/perps-market/contracts/modules/LimitOrderModule.sol b/markets/perps-market/contracts/modules/LimitOrderModule.sol index 236d703b2c..781b4ad0c1 100644 --- a/markets/perps-market/contracts/modules/LimitOrderModule.sol +++ b/markets/perps-market/contracts/modules/LimitOrderModule.sol @@ -196,6 +196,12 @@ contract LimitOrderModule is ILimitOrderModule, IMarketEvents, IAccountEvents { 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); } diff --git a/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts b/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts index 6dcf8b7416..afdbc6b18c 100644 --- a/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts +++ b/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts @@ -45,6 +45,8 @@ describe('Settle Offchain Limit Order tests', () => { 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% @@ -61,8 +63,6 @@ describe('Settle Offchain Limit Order tests', () => { await systems() .PerpsMarket.connect(owner()) .updateRelayerShare(await relayer.getAddress(), relayerRatio.toBN()); // 30% - const nonZeroLimitOrderMakerFee = bn(0.0002); // 2bps - const nonZeroLimitOrderTakerFee = bn(0.0006); // 6bps await systems() .PerpsMarket.connect(owner()) .setLimitOrderFees(ethMarketId, nonZeroLimitOrderMakerFee, nonZeroLimitOrderTakerFee); @@ -163,12 +163,23 @@ describe('Settle Offchain Limit Order tests', () => { accruedFundingLong = 0; const newPositionSizeShort = 0, newPositionSizeLong = 0; - const limitOrderFeesShort = 599999700000000000, - limitOrderFeesLong = 599999700000000000; + 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}`, @@ -195,7 +206,7 @@ describe('Settle Offchain Limit Order tests', () => { `${limitOrderFeesLong}`, `${relayerFees}`, `${feeCollectorFees}`, - `"${longOrder.trackingCode.toString()}"`, + `"${longOrder.trackingCode}"`, `${chargedInterestLong}`, ].join(', '), }; @@ -318,6 +329,26 @@ describe('Settle Offchain Limit Order tests', () => { ); }); + 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() }; diff --git a/markets/perps-market/test/integration/helpers/limitOrderHelper.ts b/markets/perps-market/test/integration/helpers/limitOrderHelper.ts index af43fc3adc..cf8471166c 100644 --- a/markets/perps-market/test/integration/helpers/limitOrderHelper.ts +++ b/markets/perps-market/test/integration/helpers/limitOrderHelper.ts @@ -91,6 +91,7 @@ export function createMatchingLimitOrders(orderArgs: OrderCreationArgs): { ...orderArgs, isShort: true, accountId: orderArgs.accountId - 1, + isMaker: !orderArgs.isMaker, }); return { shortOrder: oppositeOrder, From bb5b5d612a402262aa5d3248c386cb503b5732df Mon Sep 17 00:00:00 2001 From: Fred Snax Date: Tue, 6 Aug 2024 13:49:18 -0700 Subject: [PATCH 5/6] wip --- .../contracts/modules/LimitOrderModule.sol | 7 ++- ...ffchainLimitOrder.settleLimitOrder.test.ts | 50 ++++++++++++++++++- .../contracts/storage/AccountRBAC.sol | 3 -- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/markets/perps-market/contracts/modules/LimitOrderModule.sol b/markets/perps-market/contracts/modules/LimitOrderModule.sol index 781b4ad0c1..33557edcb0 100644 --- a/markets/perps-market/contracts/modules/LimitOrderModule.sol +++ b/markets/perps-market/contracts/modules/LimitOrderModule.sol @@ -7,7 +7,6 @@ import {DecimalMath} from "@synthetixio/core-contracts/contracts/utils/DecimalMa 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 {ERC2771Context} from "@synthetixio/core-contracts/contracts/utils/ERC2771Context.sol"; import {ILimitOrderModule} from "../interfaces/ILimitOrderModule.sol"; import {IMarketEvents} from "../interfaces/IMarketEvents.sol"; import {IAccountEvents} from "../interfaces/IAccountEvents.sol"; @@ -23,6 +22,7 @@ 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 @@ -79,7 +79,8 @@ contract LimitOrderModule is ILimitOrderModule, IMarketEvents, IAccountEvents { LimitOrder.SignedOrderRequest calldata order, LimitOrder.Signature calldata sig ) external { - // TODO consider adding feature flag here + FeatureFlag.ensureAccessToFeature(Flags.PERPS_SYSTEM); + FeatureFlag.ensureAccessToFeature(Flags.LIMIT_ORDER); checkSigPermission(order, sig); LimitOrder.Data storage limitOrderData = LimitOrder.load(); @@ -118,6 +119,8 @@ contract LimitOrderModule is ILimitOrderModule, IMarketEvents, IAccountEvents { PerpsMarketConfiguration.Data storage marketConfig = PerpsMarketConfiguration.load( shortOrder.marketId ); + console.log("maxMarketSize", marketConfig.maxMarketSize); + console.log("maxMarketValue", marketConfig.maxMarketValue); perpsMarketData.validateLimitOrderSize( marketConfig.maxMarketSize, marketConfig.maxMarketValue, diff --git a/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts b/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts index afdbc6b18c..f350bed584 100644 --- a/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts +++ b/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts @@ -17,7 +17,7 @@ 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', () => { +describe.only('Settle Offchain Limit Order tests', () => { const { systems, perpsMarkets, synthMarkets, provider, trader1, trader2, signers, owner } = bootstrapMarkets({ synthMarkets: [ @@ -142,7 +142,7 @@ describe('Settle Offchain Limit Order tests', () => { const restoreToSnapshot = snapshotCheckpoint(provider); - it('settles the orders and emits the proper events', async () => { + it.only('settles the orders and emits the proper events', async () => { const signedShortOrder = await signOrder( shortOrder, trader1() as ethers.Wallet, @@ -269,6 +269,52 @@ describe('Settle Offchain Limit Order tests', () => { ); }); + it.only('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.only('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.only('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( diff --git a/protocol/synthetix/contracts/storage/AccountRBAC.sol b/protocol/synthetix/contracts/storage/AccountRBAC.sol index ed90ad79da..6871c1b1ab 100644 --- a/protocol/synthetix/contracts/storage/AccountRBAC.sol +++ b/protocol/synthetix/contracts/storage/AccountRBAC.sol @@ -3,7 +3,6 @@ pragma solidity >=0.8.11 <0.9.0; import "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; import "@synthetixio/core-contracts/contracts/errors/AddressError.sol"; -import "hardhat/console.sol"; /** * @title Object for tracking an accounts permissions (role based access control). @@ -139,8 +138,6 @@ library AccountRBAC { bytes32 permission, address target ) internal view returns (bool) { - console.log("signing address aka target", target); - console.log("account owner self.owner", self.owner); return ((target == self.owner) || hasPermission(self, _ADMIN_PERMISSION, target) || hasPermission(self, permission, target)); From 0e1ba0c42b70d1ba6665c2d32cca51ee558529d0 Mon Sep 17 00:00:00 2001 From: Fred Snax Date: Fri, 16 Aug 2024 11:48:49 -0700 Subject: [PATCH 6/6] rebase off main and start working on multi collateral and other recent changes --- .../contracts/modules/LimitOrderModule.sol | 48 +++++++------------ .../contracts/storage/LimitOrder.sol | 5 +- ...ffchainLimitOrder.settleLimitOrder.test.ts | 38 +++++++-------- 3 files changed, 36 insertions(+), 55 deletions(-) diff --git a/markets/perps-market/contracts/modules/LimitOrderModule.sol b/markets/perps-market/contracts/modules/LimitOrderModule.sol index 33557edcb0..6db46d6af8 100644 --- a/markets/perps-market/contracts/modules/LimitOrderModule.sol +++ b/markets/perps-market/contracts/modules/LimitOrderModule.sol @@ -22,7 +22,7 @@ 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"; +// import "hardhat/console.sol"; /** * @title Module for settling signed P2P limit orders @@ -119,8 +119,8 @@ contract LimitOrderModule is ILimitOrderModule, IMarketEvents, IAccountEvents { PerpsMarketConfiguration.Data storage marketConfig = PerpsMarketConfiguration.load( shortOrder.marketId ); - console.log("maxMarketSize", marketConfig.maxMarketSize); - console.log("maxMarketValue", marketConfig.maxMarketValue); + // console.log("maxMarketSize", marketConfig.maxMarketSize); + // console.log("maxMarketValue", marketConfig.maxMarketValue); perpsMarketData.validateLimitOrderSize( marketConfig.maxMarketSize, marketConfig.maxMarketValue, @@ -158,7 +158,7 @@ contract LimitOrderModule is ILimitOrderModule, IMarketEvents, IAccountEvents { LimitOrder.SignedOrderRequest calldata order, LimitOrder.Signature calldata sig ) internal { - // Account.exists(order.accountId); + Account.exists(order.accountId); bytes32 digest = keccak256( abi.encodePacked( "\x19\x01", @@ -189,6 +189,7 @@ contract LimitOrderModule is ILimitOrderModule, IMarketEvents, IAccountEvents { } 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); @@ -273,7 +274,7 @@ contract LimitOrderModule is ILimitOrderModule, IMarketEvents, IAccountEvents { // only account for negative pnl runtime.currentAvailableMargin += MathUtil.min( - AsyncOrder.calculateStartingPnl(runtime.price, lastPriceCheck, runtime.newPositionSize), + AsyncOrder.calculateFillPricePnl(runtime.price, lastPriceCheck, runtime.amount), 0 ); if (runtime.currentAvailableMargin < runtime.limitOrderFees.toInt()) { @@ -295,6 +296,13 @@ contract LimitOrderModule is ILimitOrderModule, IMarketEvents, IAccountEvents { 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(), @@ -323,13 +331,10 @@ contract LimitOrderModule is ILimitOrderModule, IMarketEvents, IAccountEvents { (runtime.pnl, , runtime.chargedInterest, runtime.accruedFunding, , ) = oldPosition.getPnl( order.price ); - runtime.pnlUint = MathUtil.abs(runtime.pnl); - if (runtime.pnl > 0) { - perpsAccount.updateCollateralAmount(SNX_USD_MARKET_ID, runtime.pnl); - } else if (runtime.pnl < 0) { - runtime.limitOrderFees += runtime.pnlUint; - } + 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( @@ -349,27 +354,6 @@ contract LimitOrderModule is ILimitOrderModule, IMarketEvents, IAccountEvents { runtime.updateData.interestRate ); - // since margin is deposited when trader deposits, as long as the owed collateral is deducted - // from internal accounting, fees are automatically realized by the stakers - if (runtime.limitOrderFees > 0) { - (runtime.deductedSynthIds, runtime.deductedAmount) = perpsAccount.deductFromAccount( - runtime.limitOrderFees - ); - for ( - runtime.synthDeductionIterator = 0; - runtime.synthDeductionIterator < runtime.deductedSynthIds.length; - runtime.synthDeductionIterator++ - ) { - if (runtime.deductedAmount[runtime.synthDeductionIterator] > 0) { - emit CollateralDeducted( - runtime.accountId, - runtime.deductedSynthIds[runtime.synthDeductionIterator], - runtime.deductedAmount[runtime.synthDeductionIterator] - ); - } - } - } - PerpsMarketFactory.Data storage factory = PerpsMarketFactory.load(); (runtime.relayerFees, runtime.feeCollectorFees) = GlobalPerpsMarketConfiguration .load() diff --git a/markets/perps-market/contracts/storage/LimitOrder.sol b/markets/perps-market/contracts/storage/LimitOrder.sol index 2f74374d73..6911641378 100644 --- a/markets/perps-market/contracts/storage/LimitOrder.sol +++ b/markets/perps-market/contracts/storage/LimitOrder.sol @@ -96,19 +96,16 @@ library LimitOrder { uint128 accountId; int128 amount; int256 pnl; - uint256 pnlUint; MarketUpdate.Data updateData; uint256 chargedInterest; Position.Data newPosition; Position.Data oldPosition; - uint256 synthDeductionIterator; - uint128[] deductedSynthIds; - uint256[] deductedAmount; uint256 relayerFees; uint256 feeCollectorFees; int256 accruedFunding; uint256 limitOrderFees; uint256 price; + int256 chargedAmount; } function load() internal pure returns (Data storage limitOrderNonces) { diff --git a/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts b/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts index f350bed584..14fb465161 100644 --- a/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts +++ b/markets/perps-market/test/integration/LimitOrders/OffchainLimitOrder.settleLimitOrder.test.ts @@ -17,7 +17,7 @@ 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.only('Settle Offchain Limit Order tests', () => { +describe('Settle Offchain Limit Order tests', () => { const { systems, perpsMarkets, synthMarkets, provider, trader1, trader2, signers, owner } = bootstrapMarkets({ synthMarkets: [ @@ -142,7 +142,7 @@ describe.only('Settle Offchain Limit Order tests', () => { const restoreToSnapshot = snapshotCheckpoint(provider); - it.only('settles the orders and emits the proper events', async () => { + it('settles the orders and emits the proper events', async () => { const signedShortOrder = await signOrder( shortOrder, trader1() as ethers.Wallet, @@ -233,10 +233,10 @@ describe.only('Settle Offchain Limit Order tests', () => { 0, ].join(', '), }; - const collateralDeductedEventsArgs = { - trader1: [`${shortOrder.accountId}`, `${0}`, `${limitOrderFeesShort}`].join(', '), - trader2: [`${longOrder.accountId}`, `${0}`, `${limitOrderFeesLong}`].join(', '), - }; + // const collateralDeductedEventsArgs = { + // trader1: [`${shortOrder.accountId}`, `${0}`, `${limitOrderFeesShort}`].join(', '), + // trader2: [`${longOrder.accountId}`, `${0}`, `${limitOrderFeesLong}`].join(', '), + // }; await assertEvent( tx, `LimitOrderSettled(${orderSettledEventsArgs.trader1})`, @@ -257,19 +257,19 @@ describe.only('Settle Offchain Limit Order tests', () => { `MarketUpdated(${marketUpdateEventsArgs.trader2})`, systems().PerpsMarket ); - await assertEvent( - tx, - `CollateralDeducted(${collateralDeductedEventsArgs.trader1})`, - systems().PerpsMarket - ); - await assertEvent( - tx, - `CollateralDeducted(${collateralDeductedEventsArgs.trader2})`, - systems().PerpsMarket - ); + // await assertEvent( + // tx, + // `CollateralDeducted(${collateralDeductedEventsArgs.trader1})`, + // systems().PerpsMarket + // ); + // await assertEvent( + // tx, + // `CollateralDeducted(${collateralDeductedEventsArgs.trader2})`, + // systems().PerpsMarket + // ); }); - it.only('fails to cancel an already completed limit order', async () => { + it('fails to cancel an already completed limit order', async () => { const signedShortOrder = await signOrder( shortOrder, trader1() as ethers.Wallet, @@ -281,7 +281,7 @@ describe.only('Settle Offchain Limit Order tests', () => { ); }); - it.only('successfully cancels a new limit order', async () => { + it('successfully cancels a new limit order', async () => { const newNonceShortOrder = { ...shortOrder, nonce: 197889234 }; const signedNewNonceShortOrder = await signOrder( newNonceShortOrder, @@ -299,7 +299,7 @@ describe.only('Settle Offchain Limit Order tests', () => { ); }); - it.only('fails to cancel a new limit order that is already settled', async () => { + it('fails to cancel a new limit order that is already settled', async () => { const newNonceShortOrder = { ...shortOrder, nonce: 197889234 }; const signedNewNonceShortOrder = await signOrder( newNonceShortOrder,