diff --git a/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol b/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol index 5c4d4c645e..1dcf17df69 100644 --- a/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol +++ b/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol @@ -19,13 +19,17 @@ interface IGlobalPerpsMarketModule { event SynthDeductionPrioritySet(uint128[] newSynthDeductionPriority); /** - * @notice Gets fired when liquidation reward guard is set or updated. - * @param minLiquidationRewardUsd Minimum liquidation reward expressed as USD value. - * @param maxLiquidationRewardUsd Maximum liquidation reward expressed as USD value. - */ - event LiquidationRewardGuardsSet( - uint256 indexed minLiquidationRewardUsd, - uint256 indexed maxLiquidationRewardUsd + * @notice Gets fired when keeper reward guard is set or updated. + * @param minKeeperRewardUsd Minimum keeper reward expressed as USD value. + * @param minKeeperProfitRatioD18 Minimum keeper profit ratio used together with minKeeperRewardUsd to calculate the minimum. + * @param maxKeeperRewardUsd Maximum keeper reward expressed as USD value. + * @param maxKeeperScalingRatioD18 Scaling used to calculate the Maximum keeper reward together with maxKeeperRewardUsd. + */ + event KeeperRewardGuardsSet( + uint256 minKeeperRewardUsd, + uint256 minKeeperProfitRatioD18, + uint256 maxKeeperRewardUsd, + uint256 maxKeeperScalingRatioD18 ); /** @@ -48,6 +52,12 @@ interface IGlobalPerpsMarketModule { */ event PerAccountCapsSet(uint128 maxPositionsPerAccount, uint128 maxCollateralsPerAccount); + /** + * @notice Gets fired when feed id for keeper cost node id is updated. + * @param keeperCostNodeId oracle node id + */ + event KeeperCostNodeIdUpdated(bytes32 keeperCostNodeId); + /** * @notice Thrown when the fee collector does not implement the IFeeCollector interface */ @@ -87,24 +97,35 @@ interface IGlobalPerpsMarketModule { function getSynthDeductionPriority() external view returns (uint128[] memory); /** - * @notice Sets the liquidation reward guard (min and max). - * @param minLiquidationRewardUsd Minimum liquidation reward expressed as USD value. - * @param maxLiquidationRewardUsd Maximum liquidation reward expressed as USD value. + * @notice Sets the keeper reward guard (min and max). + * @param minKeeperRewardUsd Minimum keeper reward expressed as USD value. + * @param minKeeperProfitRatioD18 Minimum keeper profit ratio used together with minKeeperRewardUsd to calculate the minimum. + * @param maxKeeperRewardUsd Maximum keeper reward expressed as USD value. + * @param maxKeeperScalingRatioD18 Scaling used to calculate the Maximum keeper reward together with maxKeeperRewardUsd. */ - function setLiquidationRewardGuards( - uint256 minLiquidationRewardUsd, - uint256 maxLiquidationRewardUsd + function setKeeperRewardGuards( + uint256 minKeeperRewardUsd, + uint256 minKeeperProfitRatioD18, + uint256 maxKeeperRewardUsd, + uint256 maxKeeperScalingRatioD18 ) external; /** - * @notice Gets the liquidation reward guard (min and max). - * @return minLiquidationRewardUsd Minimum liquidation reward expressed as USD value. - * @return maxLiquidationRewardUsd Maximum liquidation reward expressed as USD value. + * @notice Gets the keeper reward guard (min and max). + * @return minKeeperRewardUsd Minimum keeper reward expressed as USD value. + * @return minKeeperProfitRatioD18 Minimum keeper profit ratio used together with minKeeperRewardUsd to calculate the minimum. + * @return maxKeeperRewardUsd Maximum keeper reward expressed as USD value. + * @return maxKeeperScalingRatioD18 Scaling used to calculate the Maximum keeper reward together with maxKeeperRewardUsd. */ - function getLiquidationRewardGuards() + function getKeeperRewardGuards() external view - returns (uint256 minLiquidationRewardUsd, uint256 maxLiquidationRewardUsd); + returns ( + uint256 minKeeperRewardUsd, + uint256 minKeeperProfitRatioD18, + uint256 maxKeeperRewardUsd, + uint maxKeeperScalingRatioD18 + ); /** * @notice Gets the total collateral value of all deposited collateral from all traders. @@ -158,6 +179,18 @@ interface IGlobalPerpsMarketModule { */ function getReferrerShare(address referrer) external view returns (uint256 shareRatioD18); + /** + * @notice Set node id for keeper cost + * @param keeperCostNodeId the node id + */ + function updateKeeperCostNodeId(bytes32 keeperCostNodeId) external; + + /** + * @notice Get the node id for keeper cost + * @return keeperCostNodeId the node id + */ + function getKeeperCostNodeId() external view returns (bytes32 keeperCostNodeId); + /** * @notice get all existing market ids * @return marketIds an array of existing market ids diff --git a/markets/perps-market/contracts/interfaces/ILiquidationModule.sol b/markets/perps-market/contracts/interfaces/ILiquidationModule.sol index ee44e49907..4b387357cf 100644 --- a/markets/perps-market/contracts/interfaces/ILiquidationModule.sol +++ b/markets/perps-market/contracts/interfaces/ILiquidationModule.sol @@ -31,7 +31,11 @@ interface ILiquidationModule { * @param reward total reward sent to liquidator. * @param fullLiquidation flag indicating if it was a partial or full liquidation. */ - event AccountLiquidated(uint128 indexed accountId, uint256 reward, bool fullLiquidation); + event AccountLiquidationAttempt( + uint128 indexed accountId, + uint256 reward, + bool fullLiquidation + ); /** * @notice Liquidates an account. diff --git a/markets/perps-market/contracts/interfaces/IPerpsAccountModule.sol b/markets/perps-market/contracts/interfaces/IPerpsAccountModule.sol index 5ce763f2af..4d9d9d255f 100644 --- a/markets/perps-market/contracts/interfaces/IPerpsAccountModule.sol +++ b/markets/perps-market/contracts/interfaces/IPerpsAccountModule.sol @@ -92,7 +92,6 @@ interface IPerpsAccountModule { * @param accountId Id of the account. * @return requiredInitialMargin initial margin req (used when withdrawing collateral). * @return requiredMaintenanceMargin maintenance margin req (used to determine liquidation threshold). - * @return totalAccumulatedLiquidationRewards sum of all liquidation rewards of if all account open positions were to be liquidated fully. * @return maxLiquidationReward max liquidation reward the keeper would receive if account was fully liquidated. Note here that the accumulated rewards are checked against the global max/min configured liquidation rewards. */ function getRequiredMargins( @@ -103,7 +102,6 @@ interface IPerpsAccountModule { returns ( uint256 requiredInitialMargin, uint256 requiredMaintenanceMargin, - uint256 totalAccumulatedLiquidationRewards, uint256 maxLiquidationReward ); } diff --git a/markets/perps-market/contracts/mocks/MockGasPriceNode.sol b/markets/perps-market/contracts/mocks/MockGasPriceNode.sol new file mode 100644 index 0000000000..80cd51e1ec --- /dev/null +++ b/markets/perps-market/contracts/mocks/MockGasPriceNode.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import "@synthetixio/oracle-manager/contracts/interfaces/external/IExternalNode.sol"; + +contract MockGasPriceNode is IExternalNode { + NodeOutput.Data private output; + + uint256 public constant KIND_SETTLEMENT = 0; + uint256 public constant KIND_FLAG = 1; + uint256 public constant KIND_LIQUIDATE = 2; + + uint public settlementCost; + uint public flagCost; + uint public liquidateCost; + + constructor() {} + + function setCosts(uint _settlementCost, uint _flagCost, uint _liquidateCost) external { + settlementCost = _settlementCost; + flagCost = _flagCost; + liquidateCost = _liquidateCost; + } + + // solhint-disable numcast/safe-cast + function process( + NodeOutput.Data[] memory, + bytes memory, + bytes32[] memory runtimeKeys, + bytes32[] memory runtimeValues + ) external view override returns (NodeOutput.Data memory) { + NodeOutput.Data memory theOutput = output; + uint256 executionKind; + uint256 numberOfUpdatedFeeds; + for (uint256 i = 0; i < runtimeKeys.length; i++) { + if (runtimeKeys[i] == "executionKind") { + executionKind = uint256(runtimeValues[i]); + continue; + } + if (runtimeKeys[i] == "numberOfUpdatedFeeds") { + numberOfUpdatedFeeds = uint256(runtimeValues[i]); + continue; + } + } + + if (executionKind == KIND_SETTLEMENT) { + theOutput.price = int(settlementCost); + } else if (executionKind == KIND_FLAG) { + theOutput.price = int(flagCost * numberOfUpdatedFeeds); + } else if (executionKind == KIND_LIQUIDATE) { + theOutput.price = int(liquidateCost); + } else { + revert("Invalid execution kind"); + } + + return theOutput; + } + + function isValid( + NodeDefinition.Data memory nodeDefinition + ) external pure override returns (bool) { + return nodeDefinition.nodeType == NodeDefinition.NodeType.EXTERNAL; + } + + function supportsInterface(bytes4) public view virtual override(IERC165) returns (bool) { + return true; + } +} diff --git a/markets/perps-market/contracts/modules/AsyncOrderModule.sol b/markets/perps-market/contracts/modules/AsyncOrderModule.sol index cbd59ca088..d37d24a97f 100644 --- a/markets/perps-market/contracts/modules/AsyncOrderModule.sol +++ b/markets/perps-market/contracts/modules/AsyncOrderModule.sol @@ -123,23 +123,19 @@ contract AsyncOrderModule is IAsyncOrderModule { ); Position.Data storage oldPosition = PerpsMarket.accountPosition(marketId, accountId); - ( - , - uint256 currentMaintenanceMargin, - uint256 currentTotalLiquidationRewards, - - ) = PerpsAccount.load(accountId).getAccountRequiredMargins(); + PerpsAccount.Data storage account = PerpsAccount.load(accountId); + (, uint256 currentMaintenanceMargin, ) = account.getAccountRequiredMargins(); (uint256 orderFees, uint256 fillPrice) = _computeOrderFees(marketId, sizeDelta); return AsyncOrder.getRequiredMarginWithNewPosition( + account, marketConfig, marketId, oldPosition.size, oldPosition.size + sizeDelta, fillPrice, - currentMaintenanceMargin, - currentTotalLiquidationRewards + currentMaintenanceMargin ) + orderFees; } diff --git a/markets/perps-market/contracts/modules/AsyncOrderSettlementPythModule.sol b/markets/perps-market/contracts/modules/AsyncOrderSettlementPythModule.sol index 74dce7586c..73ee4d05d0 100644 --- a/markets/perps-market/contracts/modules/AsyncOrderSettlementPythModule.sol +++ b/markets/perps-market/contracts/modules/AsyncOrderSettlementPythModule.sol @@ -15,6 +15,7 @@ import {PerpsMarketFactory} from "../storage/PerpsMarketFactory.sol"; import {GlobalPerpsMarketConfiguration} from "../storage/GlobalPerpsMarketConfiguration.sol"; import {IMarketEvents} from "../interfaces/IMarketEvents.sol"; import {IAccountEvents} from "../interfaces/IAccountEvents.sol"; +import {KeeperCosts} from "../storage/KeeperCosts.sol"; /** * @title Module for settling async orders using pyth as price feed. @@ -32,6 +33,7 @@ contract AsyncOrderSettlementPythModule is using GlobalPerpsMarket for GlobalPerpsMarket.Data; using GlobalPerpsMarketConfiguration for GlobalPerpsMarketConfiguration.Data; using Position for Position.Data; + using KeeperCosts for KeeperCosts.Data; /** * @inheritdoc IAsyncOrderSettlementPythModule @@ -64,8 +66,7 @@ contract AsyncOrderSettlementPythModule is (runtime.newPosition, runtime.totalFees, runtime.fillPrice, oldPosition) = asyncOrder .validateRequest(settlementStrategy, price); - runtime.amountToDeduct += runtime.totalFees; - + runtime.amountToDeduct = runtime.totalFees; runtime.newPositionSize = runtime.newPosition.size; runtime.sizeDelta = asyncOrder.request.sizeDelta; @@ -114,7 +115,9 @@ contract AsyncOrderSettlementPythModule is } } } - runtime.settlementReward = settlementStrategy.settlementReward; + runtime.settlementReward = + settlementStrategy.settlementReward + + KeeperCosts.load().getSettlementKeeperCosts(runtime.accountId); if (runtime.settlementReward > 0) { // pay keeper diff --git a/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol b/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol index 69e3495233..974c3754e1 100644 --- a/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol +++ b/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol @@ -11,6 +11,7 @@ import {IGlobalPerpsMarketModule} from "../interfaces/IGlobalPerpsMarketModule.s import {OwnableStorage} from "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol"; import {AddressError} from "@synthetixio/core-contracts/contracts/errors/AddressError.sol"; import {ParameterError} from "@synthetixio/core-contracts/contracts/errors/ParameterError.sol"; +import {KeeperCosts} from "../storage/KeeperCosts.sol"; /** * @title Module for global Perps Market settings. @@ -20,6 +21,7 @@ contract GlobalPerpsMarketModule is IGlobalPerpsMarketModule { using SetUtil for SetUtil.UintSet; using GlobalPerpsMarketConfiguration for GlobalPerpsMarketConfiguration.Data; using GlobalPerpsMarket for GlobalPerpsMarket.Data; + using KeeperCosts for KeeperCosts.Data; /** * @inheritdoc IGlobalPerpsMarketModule @@ -68,34 +70,50 @@ contract GlobalPerpsMarketModule is IGlobalPerpsMarketModule { /** * @inheritdoc IGlobalPerpsMarketModule */ - function setLiquidationRewardGuards( - uint256 minLiquidationRewardUsd, - uint256 maxLiquidationRewardUsd + function setKeeperRewardGuards( + uint256 minKeeperRewardUsd, + uint256 minKeeperProfitRatioD18, + uint256 maxKeeperRewardUsd, + uint256 maxKeeperScalingRatioD18 ) external override { OwnableStorage.onlyOwner(); - if (minLiquidationRewardUsd > maxLiquidationRewardUsd) { - revert ParameterError.InvalidParameter("min/maxLiquidationRewardUSD", "min > max"); + if (minKeeperRewardUsd > maxKeeperRewardUsd) { + revert ParameterError.InvalidParameter("min/maxKeeperRewardUSD", "min > max"); } GlobalPerpsMarketConfiguration.Data storage store = GlobalPerpsMarketConfiguration.load(); - store.minLiquidationRewardUsd = minLiquidationRewardUsd; - store.maxLiquidationRewardUsd = maxLiquidationRewardUsd; - - emit LiquidationRewardGuardsSet(minLiquidationRewardUsd, maxLiquidationRewardUsd); + store.minKeeperRewardUsd = minKeeperRewardUsd; + store.minKeeperProfitRatioD18 = minKeeperProfitRatioD18; + store.maxKeeperRewardUsd = maxKeeperRewardUsd; + store.maxKeeperScalingRatioD18 = maxKeeperScalingRatioD18; + + emit KeeperRewardGuardsSet( + minKeeperRewardUsd, + minKeeperProfitRatioD18, + maxKeeperRewardUsd, + maxKeeperScalingRatioD18 + ); } /** * @inheritdoc IGlobalPerpsMarketModule */ - function getLiquidationRewardGuards() + function getKeeperRewardGuards() external view override - returns (uint256 minLiquidationRewardUsd, uint256 maxLiquidationRewardUsd) + returns ( + uint256 minKeeperRewardUsd, + uint256 minKeeperProfitRatioD18, + uint256 maxKeeperRewardUsd, + uint256 maxKeeperScalingRatioD18 + ) { GlobalPerpsMarketConfiguration.Data storage store = GlobalPerpsMarketConfiguration.load(); - minLiquidationRewardUsd = store.minLiquidationRewardUsd; - maxLiquidationRewardUsd = store.maxLiquidationRewardUsd; + minKeeperRewardUsd = store.minKeeperRewardUsd; + minKeeperProfitRatioD18 = store.minKeeperProfitRatioD18; + maxKeeperRewardUsd = store.maxKeeperRewardUsd; + maxKeeperScalingRatioD18 = store.maxKeeperScalingRatioD18; } /** @@ -134,6 +152,24 @@ contract GlobalPerpsMarketModule is IGlobalPerpsMarketModule { return address(GlobalPerpsMarketConfiguration.load().feeCollector); } + /** + * @inheritdoc IGlobalPerpsMarketModule + */ + function updateKeeperCostNodeId(bytes32 keeperCostNodeId) external override { + OwnableStorage.onlyOwner(); + + KeeperCosts.load().update(keeperCostNodeId); + + emit KeeperCostNodeIdUpdated(keeperCostNodeId); + } + + /** + * @inheritdoc IGlobalPerpsMarketModule + */ + function getKeeperCostNodeId() external view override returns (bytes32 keeperCostNodeId) { + return KeeperCosts.load().keeperCostNodeId; + } + /** * @inheritdoc IGlobalPerpsMarketModule */ diff --git a/markets/perps-market/contracts/modules/LiquidationModule.sol b/markets/perps-market/contracts/modules/LiquidationModule.sol index 55fec60420..f6818e8ed6 100644 --- a/markets/perps-market/contracts/modules/LiquidationModule.sol +++ b/markets/perps-market/contracts/modules/LiquidationModule.sol @@ -16,6 +16,7 @@ import {PerpsMarketConfiguration} from "../storage/PerpsMarketConfiguration.sol" import {GlobalPerpsMarket} from "../storage/GlobalPerpsMarket.sol"; import {MarketUpdate} from "../storage/MarketUpdate.sol"; import {IMarketEvents} from "../interfaces/IMarketEvents.sol"; +import {KeeperCosts} from "../storage/KeeperCosts.sol"; /** * @title Module for liquidating accounts. @@ -31,6 +32,7 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { using PerpsMarketFactory for PerpsMarketFactory.Data; using PerpsMarket for PerpsMarket.Data; using GlobalPerpsMarketConfiguration for GlobalPerpsMarketConfiguration.Data; + using KeeperCosts for KeeperCosts.Data; /** * @inheritdoc ILiquidationModule @@ -41,16 +43,16 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { .liquidatableAccounts; PerpsAccount.Data storage account = PerpsAccount.load(accountId); if (!liquidatableAccounts.contains(accountId)) { - (bool isEligible, , , , , ) = account.isEligibleForLiquidation(); + (bool isEligible, , , , ) = account.isEligibleForLiquidation(); if (isEligible) { - account.flagForLiquidation(); - liquidationReward = _liquidateAccount(account); + (uint flagCost, uint marginCollected) = account.flagForLiquidation(); + liquidationReward = _liquidateAccount(account, flagCost, marginCollected, true); } else { revert NotEligibleForLiquidation(accountId); } } else { - liquidationReward = _liquidateAccount(account); + liquidationReward = _liquidateAccount(account, 0, 0, false); } } @@ -72,7 +74,7 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { for (uint i = 0; i < numberOfAccountsToLiquidate; i++) { uint128 accountId = liquidatableAccounts[i].to128(); - liquidationReward += _liquidateAccount(PerpsAccount.load(accountId)); + liquidationReward += _liquidateAccount(PerpsAccount.load(accountId), 0, 0, false); } } @@ -92,7 +94,7 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { continue; } - liquidationReward += _liquidateAccount(PerpsAccount.load(accountId)); + liquidationReward += _liquidateAccount(PerpsAccount.load(accountId), 0, 0, false); } } @@ -107,7 +109,7 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { * @inheritdoc ILiquidationModule */ function canLiquidate(uint128 accountId) external view override returns (bool isEligible) { - (isEligible, , , , , ) = PerpsAccount.load(accountId).isEligibleForLiquidation(); + (isEligible, , , , ) = PerpsAccount.load(accountId).isEligibleForLiquidation(); } /** @@ -127,19 +129,34 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { ); } + struct LiquidateAccountRuntime { + uint128 accountId; + uint256 totalLiquidationRewards; + uint256 totalLiquidated; + bool accountFullyLiquidated; + uint256 totalLiquidationCost; + uint256 loopIterator; // stack too deep to the extreme + } + /** * @dev liquidates an account */ function _liquidateAccount( - PerpsAccount.Data storage account + PerpsAccount.Data storage account, + uint costOfFlagExecution, + uint totalCollateralValue, + bool includeFlaggingReward ) internal returns (uint256 keeperLiquidationReward) { - uint128 accountId = account.id; + LiquidateAccountRuntime memory runtime; + runtime.accountId = account.id; uint256[] memory openPositionMarketIds = account.openPositionMarketIds.values(); - uint totalLiquidationRewards; - - for (uint i = 0; i < openPositionMarketIds.length; i++) { - uint128 positionMarketId = openPositionMarketIds[i].to128(); + for ( + runtime.loopIterator = 0; + runtime.loopIterator < openPositionMarketIds.length; + runtime.loopIterator++ + ) { + uint128 positionMarketId = openPositionMarketIds[runtime.loopIterator].to128(); uint256 price = PerpsPrice.getCurrentPrice(positionMarketId); ( @@ -152,6 +169,7 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { if (amountLiquidated == 0) { continue; } + runtime.totalLiquidated += amountLiquidated; emit MarketUpdated( positionMarketId, @@ -163,41 +181,68 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { marketUpdateData.currentFundingVelocity ); - emit PositionLiquidated(accountId, positionMarketId, amountLiquidated, newPositionSize); + emit PositionLiquidated( + runtime.accountId, + positionMarketId, + amountLiquidated, + newPositionSize + ); // using amountToLiquidate to calculate liquidation reward - uint256 liquidationReward = PerpsMarketConfiguration - .load(positionMarketId) - .calculateLiquidationReward(amountLiquidated.mulDecimal(price)); + uint256 liquidationReward = includeFlaggingReward + ? PerpsMarketConfiguration.load(positionMarketId).calculateLiquidationReward( + amountLiquidated.mulDecimal(price) + ) + : 0; // endorsed liquidators do not get liquidation rewards if ( ERC2771Context._msgSender() != PerpsMarketConfiguration.load(positionMarketId).endorsedLiquidator ) { - totalLiquidationRewards += liquidationReward; + runtime.totalLiquidationRewards += liquidationReward; } } - keeperLiquidationReward = _processLiquidationRewards(totalLiquidationRewards); - - bool accountFullyLiquidated = account.openPositionMarketIds.length() == 0; - if (accountFullyLiquidated) { - GlobalPerpsMarket.load().liquidatableAccounts.remove(accountId); + runtime.totalLiquidationCost = + KeeperCosts.load().getLiquidateKeeperCosts() + + costOfFlagExecution; + if (runtime.totalLiquidated > 0) { + keeperLiquidationReward = _processLiquidationRewards( + runtime.totalLiquidationRewards, + runtime.totalLiquidationCost, + totalCollateralValue + ); + runtime.accountFullyLiquidated = account.openPositionMarketIds.length() == 0; + if (runtime.accountFullyLiquidated) { + GlobalPerpsMarket.load().liquidatableAccounts.remove(runtime.accountId); + } } - emit AccountLiquidated(accountId, keeperLiquidationReward, accountFullyLiquidated); + emit AccountLiquidationAttempt( + runtime.accountId, + keeperLiquidationReward, + runtime.accountFullyLiquidated + ); } /** * @dev process the accumulated liquidation rewards */ - function _processLiquidationRewards(uint256 totalRewards) private returns (uint256 reward) { - if (totalRewards == 0) { + function _processLiquidationRewards( + uint256 keeperRewards, + uint256 costOfExecutionInUsd, + uint256 availableMarginInUsd + ) private returns (uint256 reward) { + if ((keeperRewards + costOfExecutionInUsd) == 0) { return 0; } // pay out liquidation rewards - reward = GlobalPerpsMarketConfiguration.load().liquidationReward(totalRewards); + reward = GlobalPerpsMarketConfiguration.load().keeperReward( + keeperRewards, + costOfExecutionInUsd, + availableMarginInUsd + ); if (reward > 0) { PerpsMarketFactory.load().withdrawMarketUsd(ERC2771Context._msgSender(), reward); } diff --git a/markets/perps-market/contracts/modules/PerpsAccountModule.sol b/markets/perps-market/contracts/modules/PerpsAccountModule.sol index 50f3f3861c..b888f0ce71 100644 --- a/markets/perps-market/contracts/modules/PerpsAccountModule.sol +++ b/markets/perps-market/contracts/modules/PerpsAccountModule.sol @@ -127,7 +127,7 @@ contract PerpsAccountModule is IPerpsAccountModule { ) external view override returns (int256 withdrawableMargin) { PerpsAccount.Data storage account = PerpsAccount.load(accountId); int256 availableMargin = account.getAvailableMargin(); - (uint256 initialRequiredMargin, , , uint256 liquidationReward) = account + (uint256 initialRequiredMargin, , uint256 liquidationReward) = account .getAccountRequiredMargins(); uint256 requiredMargin = initialRequiredMargin + liquidationReward; @@ -147,21 +147,16 @@ contract PerpsAccountModule is IPerpsAccountModule { returns ( uint256 requiredInitialMargin, uint256 requiredMaintenanceMargin, - uint256 totalAccumulatedLiquidationRewards, uint256 maxLiquidationReward ) { PerpsAccount.Data storage account = PerpsAccount.load(accountId); if (account.openPositionMarketIds.length() == 0) { - return (0, 0, 0, 0); + return (0, 0, 0); } - ( - requiredInitialMargin, - requiredMaintenanceMargin, - totalAccumulatedLiquidationRewards, - maxLiquidationReward - ) = account.getAccountRequiredMargins(); + (requiredInitialMargin, requiredMaintenanceMargin, maxLiquidationReward) = account + .getAccountRequiredMargins(); // Include liquidation rewards to required initial margin and required maintenance margin requiredInitialMargin += maxLiquidationReward; diff --git a/markets/perps-market/contracts/storage/AsyncOrder.sol b/markets/perps-market/contracts/storage/AsyncOrder.sol index 5f52dc9ca0..8a4cb76936 100644 --- a/markets/perps-market/contracts/storage/AsyncOrder.sol +++ b/markets/perps-market/contracts/storage/AsyncOrder.sol @@ -3,6 +3,7 @@ pragma solidity >=0.8.11 <0.9.0; import {DecimalMath} from "@synthetixio/core-contracts/contracts/utils/DecimalMath.sol"; import {SafeCastI256, SafeCastU256} from "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; +import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; import {SettlementStrategy} from "./SettlementStrategy.sol"; import {Position} from "./Position.sol"; import {PerpsMarketConfiguration} from "./PerpsMarketConfiguration.sol"; @@ -12,11 +13,13 @@ import {PerpsPrice} from "./PerpsPrice.sol"; import {PerpsAccount} from "./PerpsAccount.sol"; import {MathUtil} from "../utils/MathUtil.sol"; import {OrderFee} from "./OrderFee.sol"; +import {KeeperCosts} from "./KeeperCosts.sol"; /** * @title Async order top level data storage */ library AsyncOrder { + using SetUtil for SetUtil.UintSet; using DecimalMath for int256; using DecimalMath for int128; using DecimalMath for uint256; @@ -26,6 +29,7 @@ library AsyncOrder { using GlobalPerpsMarketConfiguration for GlobalPerpsMarketConfiguration.Data; using PerpsMarket for PerpsMarket.Data; using PerpsAccount for PerpsAccount.Data; + using KeeperCosts for KeeperCosts.Data; /** * @notice Thrown when settlement window is not open yet. @@ -294,8 +298,7 @@ library AsyncOrder { runtime.currentAvailableMargin, , runtime.requiredMaintenanceMargin, - runtime.accumulatedLiquidationRewards, - + runtime.currentLiquidationReward ) = account.isEligibleForLiquidation(); if (isEligible) { @@ -327,6 +330,7 @@ library AsyncOrder { perpsMarketData.skew, marketConfig.orderFees ) + + KeeperCosts.load().getSettlementKeeperCosts(runtime.accountId) + strategy.settlementReward; if (runtime.currentAvailableMargin < runtime.orderFees.toInt()) { @@ -345,13 +349,13 @@ library AsyncOrder { runtime.totalRequiredMargin = getRequiredMarginWithNewPosition( + account, marketConfig, runtime.marketId, oldPosition.size, runtime.newPositionSize, runtime.fillPrice, - runtime.requiredMaintenanceMargin, - runtime.accumulatedLiquidationRewards + runtime.requiredMaintenanceMargin ) + runtime.orderFees; @@ -510,42 +514,81 @@ library AsyncOrder { return (priceBefore + priceAfter).toUint().divDecimal(DecimalMath.UNIT * 2); } + struct RequiredMarginWithNewPositionRuntime { + uint256 newRequiredMargin; + uint256 oldRequiredMargin; + uint256 requiredMarginForNewPosition; + uint accumulatedLiquidationRewards; + uint maxNumberOfWindows; + uint numberOfWindows; + uint256 requiredRewardMargin; + } + /** * @notice After the required margins are calculated with the old position, this function replaces the * old position data with the new position margin requirements and returns them. */ function getRequiredMarginWithNewPosition( + PerpsAccount.Data storage account, PerpsMarketConfiguration.Data storage marketConfig, uint128 marketId, int128 oldPositionSize, int128 newPositionSize, uint256 fillPrice, - uint256 currentTotalMaintenanceMargin, - uint256 currentTotalLiquidationRewards + uint256 currentTotalMaintenanceMargin ) internal view returns (uint256) { + RequiredMarginWithNewPositionRuntime memory runtime; // get initial margin requirement for the new position - (, , uint256 newRequiredMargin, , uint256 newLiquidationReward) = marketConfig - .calculateRequiredMargins(newPositionSize, fillPrice); + (, , runtime.newRequiredMargin, , ) = marketConfig.calculateRequiredMargins( + newPositionSize, + fillPrice + ); // get maintenance margin of old position - (, , , uint256 oldRequiredMargin, uint256 oldLiquidationReward) = marketConfig - .calculateRequiredMargins(oldPositionSize, PerpsPrice.getCurrentPrice(marketId)); + (, , , runtime.oldRequiredMargin, ) = marketConfig.calculateRequiredMargins( + oldPositionSize, + PerpsPrice.getCurrentPrice(marketId) + ); // remove the maintenance margin and add the initial margin requirement // this gets us our total required margin for new position - uint256 requiredMarginForNewPosition = currentTotalMaintenanceMargin + - newRequiredMargin - - oldRequiredMargin; - - // do same thing for liquidation rewards and account for minimum liquidation margin - uint256 requiredLiquidationRewardMargin = GlobalPerpsMarketConfiguration - .load() - .minimumLiquidationReward( - currentTotalLiquidationRewards + newLiquidationReward - oldLiquidationReward - ); + runtime.requiredMarginForNewPosition = + currentTotalMaintenanceMargin + + runtime.newRequiredMargin - + runtime.oldRequiredMargin; + + (runtime.accumulatedLiquidationRewards, runtime.maxNumberOfWindows) = account + .getKeeperRewardsAndCosts(marketId); + runtime.accumulatedLiquidationRewards += marketConfig.calculateLiquidationReward( + MathUtil.abs(newPositionSize).mulDecimal(fillPrice) + ); + runtime.numberOfWindows = marketConfig.numberOfLiquidationWindows( + MathUtil.abs(newPositionSize) + ); + runtime.maxNumberOfWindows = MathUtil.max( + runtime.numberOfWindows, + runtime.maxNumberOfWindows + ); + + runtime.requiredRewardMargin = account.getPossibleLiquidationReward( + runtime.accumulatedLiquidationRewards, + runtime.maxNumberOfWindows + ); // this is the required margin for the new position (minus any order fees) - return requiredMarginForNewPosition + requiredLiquidationRewardMargin; + return runtime.requiredMarginForNewPosition + runtime.requiredRewardMargin; + } + + function getNewPositionsCount( + int128 oldPositionSize, + int128 newPositionSize + ) internal pure returns (bool openingNewPosition, bool closingPosition) { + // newPosition>0 and oldPosition >0 => nothing changes + // newPosition>0 and oldPosition ==0 => currentPositionsLenght+1 + // newPosition==0 and oldPosition >0 => currentPositionsLenght-1 + openingNewPosition = (newPositionSize > 0 && oldPositionSize == 0); + + closingPosition = newPositionSize == 0 && oldPositionSize > 0; } /** diff --git a/markets/perps-market/contracts/storage/GlobalPerpsMarketConfiguration.sol b/markets/perps-market/contracts/storage/GlobalPerpsMarketConfiguration.sol index 562dd44904..ebcb8d4777 100644 --- a/markets/perps-market/contracts/storage/GlobalPerpsMarketConfiguration.sol +++ b/markets/perps-market/contracts/storage/GlobalPerpsMarketConfiguration.sol @@ -37,13 +37,13 @@ library GlobalPerpsMarketConfiguration { */ uint128[] synthDeductionPriority; /** - * @dev minimum configured liquidation reward for the sender who liquidates the account + * @dev minimum configured keeper reward for the sender who liquidates the account */ - uint minLiquidationRewardUsd; + uint minKeeperRewardUsd; /** - * @dev maximum configured liquidation reward for the sender who liquidates the account + * @dev maximum configured keeper reward for the sender who liquidates the account */ - uint maxLiquidationRewardUsd; + uint maxKeeperRewardUsd; /** * @dev maximum configured number of concurrent positions per account. * @notice If set to zero it means no new positions can be opened, but existing positions can be increased or decreased. @@ -56,6 +56,14 @@ library GlobalPerpsMarketConfiguration { * @notice If set to a larger number (larger than number of collaterals enabled) it means is unlimited. */ uint128 maxCollateralsPerAccount; + /** + * @dev used together with minKeeperRewardUsd to get the minumum keeper reward for the sender who settles, or liquidates the account + */ + uint minKeeperProfitRatioD18; + /** + * @dev used together with maxKeeperRewardUsd to get the maximum keeper reward for the sender who settles, or liquidates the account + */ + uint maxKeeperScalingRatioD18; } function load() internal pure returns (Data storage globalMarketConfig) { @@ -65,29 +73,45 @@ library GlobalPerpsMarketConfiguration { } } - /** - * @dev returns the liquidation reward based on total liquidation rewards from all markets compared against min/max - */ - function liquidationReward( + function minimumKeeperRewardCap( + Data storage self, + uint256 costOfExecutionInUsd + ) internal view returns (uint256) { + return + MathUtil.max( + costOfExecutionInUsd + self.minKeeperRewardUsd, + costOfExecutionInUsd.mulDecimal(self.minKeeperProfitRatioD18 + DecimalMath.UNIT) + ); + } + + function maximumKeeperRewardCap( Data storage self, - uint256 totalLiquidationRewards + uint256 availableMarginInUsd ) internal view returns (uint256) { + // Note: if availableMarginInUsd is zero, it means the account was flagged, so the maximumKeeperRewardCap will just be maxKeeperRewardUsd + if (availableMarginInUsd == 0) { + return self.maxKeeperRewardUsd; + } + return MathUtil.min( - MathUtil.max(totalLiquidationRewards, self.minLiquidationRewardUsd), - self.maxLiquidationRewardUsd + availableMarginInUsd.mulDecimal(self.maxKeeperScalingRatioD18), + self.maxKeeperRewardUsd ); } /** - * @dev returns the liquidation reward based on total liquidation rewards from all markets compared against only min - * @notice this is used when calculating the required margin for an account as there's no upper cap since the total liquidation rewards are dependent on available amount in liquidation window + * @dev returns the keeper reward based on total keeper rewards from all markets compared against min/max */ - function minimumLiquidationReward( + function keeperReward( Data storage self, - uint256 totalLiquidationRewards + uint256 keeperRewards, + uint256 costOfExecutionInUsd, + uint256 availableMarginInUsd ) internal view returns (uint256) { - return MathUtil.max(self.minLiquidationRewardUsd, totalLiquidationRewards); + uint minCap = minimumKeeperRewardCap(self, costOfExecutionInUsd); + uint maxCap = maximumKeeperRewardCap(self, availableMarginInUsd); + return MathUtil.min(MathUtil.max(minCap, keeperRewards + costOfExecutionInUsd), maxCap); } function updateSynthDeductionPriority( diff --git a/markets/perps-market/contracts/storage/KeeperCosts.sol b/markets/perps-market/contracts/storage/KeeperCosts.sol new file mode 100644 index 0000000000..14eb0af9c3 --- /dev/null +++ b/markets/perps-market/contracts/storage/KeeperCosts.sol @@ -0,0 +1,98 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {SafeCastI256} from "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; +import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; +import {INodeModule} from "@synthetixio/oracle-manager/contracts/interfaces/INodeModule.sol"; +import {NodeOutput} from "@synthetixio/oracle-manager/contracts/storage/NodeOutput.sol"; +import {PerpsMarketFactory} from "./PerpsMarketFactory.sol"; +import {PerpsAccount} from "./PerpsAccount.sol"; +import {PerpsMarketConfiguration} from "./PerpsMarketConfiguration.sol"; + +uint128 constant SNX_USD_MARKET_ID = 0; + +/** + * @title Keeper txn execution costs for rewards calculation based on gas price + */ +library KeeperCosts { + using SafeCastI256 for int256; + using SetUtil for SetUtil.UintSet; + using PerpsAccount for PerpsAccount.Data; + using PerpsMarketConfiguration for PerpsMarketConfiguration.Data; + + uint256 private constant KIND_SETTLEMENT = 0; + uint256 private constant KIND_FLAG = 1; + uint256 private constant KIND_LIQUIDATE = 2; + + struct Data { + bytes32 keeperCostNodeId; + } + + function load() internal pure returns (Data storage price) { + bytes32 s = keccak256(abi.encode("io.synthetix.perps-market.KeeperCosts")); + assembly { + price.slot := s + } + } + + function update(Data storage self, bytes32 keeperCostNodeId) internal { + self.keeperCostNodeId = keeperCostNodeId; + } + + function getSettlementKeeperCosts( + Data storage self, + uint128 accountId + ) internal view returns (uint sUSDCost) { + PerpsMarketFactory.Data storage factory = PerpsMarketFactory.load(); + + accountId; // unused for now, but will be used to calculate rewards based on account collaterals in the future + + sUSDCost = _processWithRuntime(self.keeperCostNodeId, factory, 0, KIND_SETTLEMENT); + } + + function getFlagKeeperCosts( + Data storage self, + uint128 accountId + ) internal view returns (uint sUSDCost) { + PerpsMarketFactory.Data storage factory = PerpsMarketFactory.load(); + + PerpsAccount.Data storage account = PerpsAccount.load(accountId); + uint numberOfCollateralFeeds = account.activeCollateralTypes.contains(SNX_USD_MARKET_ID) + ? account.activeCollateralTypes.length() - 1 + : account.activeCollateralTypes.length(); + uint numberOfUpdatedFeeds = numberOfCollateralFeeds + + account.openPositionMarketIds.length(); + + sUSDCost = _processWithRuntime( + self.keeperCostNodeId, + factory, + numberOfUpdatedFeeds, + KIND_FLAG + ); + } + + function getLiquidateKeeperCosts(Data storage self) internal view returns (uint sUSDCost) { + PerpsMarketFactory.Data storage factory = PerpsMarketFactory.load(); + + sUSDCost = _processWithRuntime(self.keeperCostNodeId, factory, 0, KIND_LIQUIDATE); + } + + function _processWithRuntime( + bytes32 keeperCostNodeId, + PerpsMarketFactory.Data storage factory, + uint256 numberOfUpdatedFeeds, + uint256 executionKind + ) private view returns (uint sUSDCost) { + bytes32[] memory runtimeKeys = new bytes32[](4); + bytes32[] memory runtimeValues = new bytes32[](4); + runtimeKeys[0] = bytes32("numberOfUpdatedFeeds"); + runtimeKeys[1] = bytes32("executionKind"); + runtimeValues[0] = bytes32(numberOfUpdatedFeeds); + runtimeValues[1] = bytes32(executionKind); + + sUSDCost = INodeModule(factory.oracle) + .processWithRuntime(keeperCostNodeId, runtimeKeys, runtimeValues) + .price + .toUint(); + } +} diff --git a/markets/perps-market/contracts/storage/PerpsAccount.sol b/markets/perps-market/contracts/storage/PerpsAccount.sol index ef8c5110ff..5d3806ef39 100644 --- a/markets/perps-market/contracts/storage/PerpsAccount.sol +++ b/markets/perps-market/contracts/storage/PerpsAccount.sol @@ -14,6 +14,7 @@ import {PerpsMarketFactory} from "./PerpsMarketFactory.sol"; import {GlobalPerpsMarket} from "./GlobalPerpsMarket.sol"; import {GlobalPerpsMarketConfiguration} from "./GlobalPerpsMarketConfiguration.sol"; import {PerpsMarketConfiguration} from "./PerpsMarketConfiguration.sol"; +import {KeeperCosts} from "../storage/KeeperCosts.sol"; uint128 constant SNX_USD_MARKET_ID = 0; @@ -34,6 +35,7 @@ library PerpsAccount { using GlobalPerpsMarketConfiguration for GlobalPerpsMarketConfiguration.Data; using DecimalMath for int256; using DecimalMath for uint256; + using KeeperCosts for KeeperCosts.Data; struct Data { // @dev synth marketId => amount @@ -117,31 +119,32 @@ library PerpsAccount { int256 availableMargin, uint256 requiredInitialMargin, uint256 requiredMaintenanceMargin, - uint256 accumulatedLiquidationRewards, uint256 liquidationReward ) { availableMargin = getAvailableMargin(self); if (self.openPositionMarketIds.length() == 0) { - return (false, availableMargin, 0, 0, 0, 0); + return (false, availableMargin, 0, 0, 0); } ( requiredInitialMargin, requiredMaintenanceMargin, - accumulatedLiquidationRewards, liquidationReward ) = getAccountRequiredMargins(self); isEligible = (requiredMaintenanceMargin + liquidationReward).toInt() > availableMargin; } - function flagForLiquidation(Data storage self) internal { + function flagForLiquidation( + Data storage self + ) internal returns (uint256 flagKeeperCost, uint256 marginCollected) { SetUtil.UintSet storage liquidatableAccounts = GlobalPerpsMarket .load() .liquidatableAccounts; if (!liquidatableAccounts.contains(self.id)) { + flagKeeperCost = KeeperCosts.load().getFlagKeeperCosts(self.id); liquidatableAccounts.add(self.id); - convertAllCollateralToUsd(self); + marginCollected = convertAllCollateralToUsd(self); } } @@ -193,7 +196,6 @@ library PerpsAccount { int256 availableMargin, uint256 initialRequiredMargin, , - , uint256 liquidationReward ) = isEligibleForLiquidation(self); @@ -278,12 +280,7 @@ library PerpsAccount { ) internal view - returns ( - uint initialMargin, - uint maintenanceMargin, - uint accumulatedLiquidationRewards, - uint liquidationReward - ) + returns (uint initialMargin, uint maintenanceMargin, uint possibleLiquidationReward) { // use separate accounting for liquidation rewards so we can compare against global min/max liquidation reward values for (uint i = 1; i <= self.openPositionMarketIds.length(); i++) { @@ -292,36 +289,77 @@ library PerpsAccount { PerpsMarketConfiguration.Data storage marketConfig = PerpsMarketConfiguration.load( marketId ); - ( - , - , - uint256 positionInitialMargin, - uint256 positionMaintenanceMargin, - uint256 liquidationMargin - ) = marketConfig.calculateRequiredMargins( - position.size, - PerpsPrice.getCurrentPrice(marketId) - ); + (, , uint256 positionInitialMargin, uint256 positionMaintenanceMargin, ) = marketConfig + .calculateRequiredMargins(position.size, PerpsPrice.getCurrentPrice(marketId)); - accumulatedLiquidationRewards += liquidationMargin; maintenanceMargin += positionMaintenanceMargin; initialMargin += positionInitialMargin; } - // if account was liquidated, we account for liquidation reward that would be paid out to the liquidation keeper in required margin - uint256 possibleLiquidationReward = GlobalPerpsMarketConfiguration - .load() - .minimumLiquidationReward(accumulatedLiquidationRewards); + (uint accumulatedLiquidationRewards, uint maxNumberOfWindows) = getKeeperRewardsAndCosts( + self, + 0 + ); + possibleLiquidationReward = getPossibleLiquidationReward( + self, + accumulatedLiquidationRewards, + maxNumberOfWindows + ); + + return (initialMargin, maintenanceMargin, possibleLiquidationReward); + } - return ( - initialMargin, - maintenanceMargin, + function getKeeperRewardsAndCosts( + Data storage self, + uint128 skipMarketId + ) internal view returns (uint accumulatedLiquidationRewards, uint maxNumberOfWindows) { + // use separate accounting for liquidation rewards so we can compare against global min/max liquidation reward values + for (uint i = 1; i <= self.openPositionMarketIds.length(); i++) { + uint128 marketId = self.openPositionMarketIds.valueAt(i).to128(); + if (marketId == skipMarketId) continue; + Position.Data storage position = PerpsMarket.load(marketId).positions[self.id]; + PerpsMarketConfiguration.Data storage marketConfig = PerpsMarketConfiguration.load( + marketId + ); + + uint numberOfWindows = marketConfig.numberOfLiquidationWindows( + MathUtil.abs(position.size) + ); + + uint256 liquidationMargin = marketConfig.calculateLiquidationReward( + MathUtil.abs(position.size).mulDecimal(PerpsPrice.getCurrentPrice(marketId)) + ); + accumulatedLiquidationRewards += liquidationMargin; + + maxNumberOfWindows = MathUtil.max(numberOfWindows, maxNumberOfWindows); + } + } + + function getPossibleLiquidationReward( + Data storage self, + uint accumulatedLiquidationRewards, + uint numOfWindows + ) internal view returns (uint possibleLiquidationReward) { + GlobalPerpsMarketConfiguration.Data storage globalConfig = GlobalPerpsMarketConfiguration + .load(); + KeeperCosts.Data storage keeperCosts = KeeperCosts.load(); + uint costOfFlagging = keeperCosts.getFlagKeeperCosts(self.id); + uint costOfLiquidation = keeperCosts.getLiquidateKeeperCosts(); + uint256 liquidateAndFlagCost = globalConfig.keeperReward( accumulatedLiquidationRewards, - possibleLiquidationReward + costOfFlagging, + getTotalCollateralValue(self) ); + uint256 liquidateWindowsCosts = numOfWindows == 0 + ? 0 + : globalConfig.keeperReward(0, costOfLiquidation, 0) * (numOfWindows - 1); + + possibleLiquidationReward = liquidateAndFlagCost + liquidateWindowsCosts; } - function convertAllCollateralToUsd(Data storage self) internal { + function convertAllCollateralToUsd( + Data storage self + ) internal returns (uint256 totalConvertedCollateral) { PerpsMarketFactory.Data storage factory = PerpsMarketFactory.load(); uint[] memory activeCollateralTypes = self.activeCollateralTypes.values(); @@ -331,13 +369,14 @@ library PerpsAccount { for (uint256 i = 0; i < activeCollateralTypes.length; i++) { uint128 synthMarketId = activeCollateralTypes[i].to128(); if (synthMarketId == SNX_USD_MARKET_ID) { + totalConvertedCollateral += self.collateralAmounts[synthMarketId]; updateCollateralAmount( self, synthMarketId, -(self.collateralAmounts[synthMarketId].toInt()) ); } else { - _deductAllSynth(self, factory, synthMarketId); + totalConvertedCollateral += _deductAllSynth(self, factory, synthMarketId); } } } @@ -494,7 +533,7 @@ library PerpsAccount { Data storage self, PerpsMarketFactory.Data storage factory, uint128 synthMarketId - ) private { + ) private returns (uint256 amountUsd) { uint amount = self.collateralAmounts[synthMarketId]; address synth = factory.spotMarket.getSynth(synthMarketId); @@ -502,7 +541,7 @@ library PerpsAccount { factory.synthetix.withdrawMarketCollateral(factory.perpsMarketId, synth, amount); // 2. sell collateral for snxUSD - (uint amountUsd, ) = PerpsMarketFactory.load().spotMarket.sellExactIn( + (amountUsd, ) = PerpsMarketFactory.load().spotMarket.sellExactIn( synthMarketId, amount, 0, diff --git a/markets/perps-market/contracts/storage/PerpsMarket.sol b/markets/perps-market/contracts/storage/PerpsMarket.sol index 994a9e7bb3..461828d4da 100644 --- a/markets/perps-market/contracts/storage/PerpsMarket.sol +++ b/markets/perps-market/contracts/storage/PerpsMarket.sol @@ -11,6 +11,7 @@ import {MarketUpdate} from "./MarketUpdate.sol"; import {MathUtil} from "../utils/MathUtil.sol"; import {PerpsPrice} from "./PerpsPrice.sol"; import {Liquidation} from "./Liquidation.sol"; +import {KeeperCosts} from "./KeeperCosts.sol"; /** * @title Data for a single perps market @@ -34,6 +35,11 @@ library PerpsMarket { */ error PriceFeedNotSet(uint128 marketId); + /** + * @notice Thrown when attempting to load a market without a configured keeper costs + */ + error KeeperCostsNotSet(); + struct Data { string name; string symbol; @@ -94,6 +100,10 @@ library PerpsMarket { if (PerpsPrice.load(marketId).feedId == "") { revert PriceFeedNotSet(marketId); } + + if (KeeperCosts.load().keeperCostNodeId == "") { + revert KeeperCostsNotSet(); + } } /** diff --git a/markets/perps-market/contracts/storage/PerpsMarketConfiguration.sol b/markets/perps-market/contracts/storage/PerpsMarketConfiguration.sol index 6a4b69bc66..6ffd8233da 100644 --- a/markets/perps-market/contracts/storage/PerpsMarketConfiguration.sol +++ b/markets/perps-market/contracts/storage/PerpsMarketConfiguration.sol @@ -86,6 +86,13 @@ library PerpsMarketConfiguration { ) * self.maxSecondsInLiquidationWindow; } + function numberOfLiquidationWindows( + Data storage self, + uint positionSize + ) internal view returns (uint256) { + return MathUtil.ceilDivide(positionSize, maxLiquidationAmountInWindow(self)); + } + function calculateLiquidationReward( Data storage self, uint256 notionalValue diff --git a/markets/perps-market/contracts/utils/MathUtil.sol b/markets/perps-market/contracts/utils/MathUtil.sol index 13661b2912..f19281cadb 100644 --- a/markets/perps-market/contracts/utils/MathUtil.sol +++ b/markets/perps-market/contracts/utils/MathUtil.sol @@ -43,4 +43,9 @@ library MathUtil { function sameSide(int a, int b) internal pure returns (bool) { return (a == 0) || (b == 0) || (a > 0) == (b > 0); } + + function ceilDivide(uint a, uint b) internal pure returns (uint) { + if (b == 0) return 0; + return a / b + (a % b == 0 ? 0 : 1); + } } diff --git a/markets/perps-market/test/integration/Account/Margins.test.ts b/markets/perps-market/test/integration/Account/Margins.test.ts index 2a936d3a56..5997a8cf3e 100644 --- a/markets/perps-market/test/integration/Account/Margins.test.ts +++ b/markets/perps-market/test/integration/Account/Margins.test.ts @@ -51,6 +51,12 @@ describe('Account margins test', () => { synthMarkets: [], perpsMarkets: perpsMarketConfig, traderAccountIds: [accountId, 5], + liquidationGuards: { + minLiquidationReward: bn(0), + minKeeperProfitRatioD18: bn(0), + maxLiquidationReward: bn(10_000), + maxKeeperScalingRatioD18: bn(1), + }, }); // add $100k @@ -160,7 +166,7 @@ describe('Account margins test', () => { }); it('has correct initial and maintenance margin', async () => { - const [initialMargin, maintenanceMargin, , maxLiquidationReward] = + const [initialMargin, maintenanceMargin, maxLiquidationReward] = await systems().PerpsMarket.getRequiredMargins(accountId); assertBn.equal( initialMargin.sub(maxLiquidationReward), diff --git a/markets/perps-market/test/integration/Account/ModifyCollateral.deposit.test.ts b/markets/perps-market/test/integration/Account/ModifyCollateral.deposit.test.ts index 1c248bdda4..a2691f4048 100644 --- a/markets/perps-market/test/integration/Account/ModifyCollateral.deposit.test.ts +++ b/markets/perps-market/test/integration/Account/ModifyCollateral.deposit.test.ts @@ -32,7 +32,7 @@ describe('ModifyCollateral Deposit', () => { synthBTCMarketId = synthMarkets()[0].marketId(); // 3 }); - describe('deposit by modifyCollateral()', async () => { + describe('deposit by modifyCollateral()', () => { let spotBalanceBefore: ethers.BigNumber; let modifyCollateralTxn: ethers.providers.TransactionResponse; diff --git a/markets/perps-market/test/integration/Account/ModifyCollateral.failures.test.ts b/markets/perps-market/test/integration/Account/ModifyCollateral.failures.test.ts index ad92c46067..f044e103ca 100644 --- a/markets/perps-market/test/integration/Account/ModifyCollateral.failures.test.ts +++ b/markets/perps-market/test/integration/Account/ModifyCollateral.failures.test.ts @@ -61,7 +61,7 @@ describe('ModifyCollateral', () => { .buy(synthLINKMarketId, usdAmount, minAmountReceived, referrer); }); - describe('failure cases', async () => { + describe('failure cases', () => { it('reverts when the account does not exist', async () => { await assertRevert( systems() diff --git a/markets/perps-market/test/integration/Account/ModifyCollateral.withdraw.test.ts b/markets/perps-market/test/integration/Account/ModifyCollateral.withdraw.test.ts index 3195e82735..0128e65691 100644 --- a/markets/perps-market/test/integration/Account/ModifyCollateral.withdraw.test.ts +++ b/markets/perps-market/test/integration/Account/ModifyCollateral.withdraw.test.ts @@ -38,7 +38,7 @@ describe('ModifyCollateral Withdraw', () => { const restoreToSetup = snapshotCheckpoint(provider); - describe('withdraw without open position modifyCollateral() from another account', async () => { + describe('withdraw without open position modifyCollateral() from another account', () => { before(restoreToSetup); before('owner sets limits to max', async () => { @@ -88,7 +88,7 @@ describe('ModifyCollateral Withdraw', () => { }); }); - describe('withdraw without open position modifyCollateral()', async () => { + describe('withdraw without open position modifyCollateral()', () => { let spotBalanceBefore: ethers.BigNumber; let modifyCollateralWithdrawTxn: ethers.providers.TransactionResponse; @@ -222,6 +222,13 @@ describe('ModifyCollateral Withdraw', () => { const { systems, provider, trader1, perpsMarkets, synthMarkets } = bootstrapMarkets({ synthMarkets: spotMarketConfig, perpsMarkets: perpsMarketConfigs, + liquidationGuards: { + minLiquidationReward: bn(0), + minKeeperProfitRatioD18: bn(0), + maxLiquidationReward: bn(10_000), + maxKeeperScalingRatioD18: bn(1), + }, + traderAccountIds, }); before('add some snx collateral to margin', async () => { @@ -279,7 +286,7 @@ describe('ModifyCollateral Withdraw', () => { }); } }); - describe('account check after initial positions open', async () => { + describe('account check after initial positions open', () => { it('should have correct open interest', async () => { const expectedOi = 100_000; // abs((-2 * 30000) + (20 * 2000)) assertBn.equal( diff --git a/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.Caps.test.ts b/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.Caps.test.ts new file mode 100644 index 0000000000..b7f391196c --- /dev/null +++ b/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.Caps.test.ts @@ -0,0 +1,216 @@ +import { ethers } from 'ethers'; +import { bn, bootstrapMarkets } from '../bootstrap'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import { depositCollateral, openPosition } from '../helpers'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; + +describe('Keeper Rewards - Caps', () => { + const KeeperCosts = { + settlementCost: 1111, + flagCost: 3333, + liquidateCost: 5555, + }; + const { systems, perpsMarkets, provider, trader1, keeperCostOracleNode, keeper, 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(10) }, + }, + { + requestedMarketId: 30, + name: 'Link', + token: 'snxLINK', + price: bn(5000), + fundingParams: { skewScale: bn(200_000), maxFundingVelocity: bn(20) }, + }, + ], + traderAccountIds: [2, 3], + }); + let ethMarketId: ethers.BigNumber; + let ethSettlementStrategyId: ethers.BigNumber; + + before('identify actors', () => { + ethMarketId = perpsMarkets()[0].marketId(); + ethSettlementStrategyId = perpsMarkets()[0].strategyId(); + }); + + const collateralsTestCase = [ + { + name: 'only snxUSD', + collateralData: { + systems, + trader: trader1, + accountId: () => 2, + collaterals: [ + { + snxUSDAmount: () => bn(10_000), + }, + ], + }, + }, + ]; + + before('set keeper costs', async () => { + await keeperCostOracleNode() + .connect(owner()) + .setCosts(KeeperCosts.settlementCost, KeeperCosts.flagCost, KeeperCosts.liquidateCost); + }); + + const restoreToConfiguration = snapshotCheckpoint(provider); + + it('keeper costs are configured correctly', async () => { + // Note: `it` required to ensure the `describes` below work as expected + assertBn.equal(await keeperCostOracleNode().settlementCost(), KeeperCosts.settlementCost); + assertBn.equal(await keeperCostOracleNode().flagCost(), KeeperCosts.flagCost); + assertBn.equal(await keeperCostOracleNode().liquidateCost(), KeeperCosts.liquidateCost); + }); + + const capTestCases = [ + { + name: 'uncapped just cost', + withRatio: false, + lowerCap: 1, + lowerProfitRatio: bn(0.005), + higherCap: bn(10), + higherProfitRatio: bn(0.005), + expected: Math.trunc((KeeperCosts.flagCost + KeeperCosts.liquidateCost) * (1 + 0.005)), + }, + { + name: 'lower capped just cost', + withRatio: false, + lowerCap: 10_000, + lowerProfitRatio: bn(0.005), + higherCap: 10_0000, + higherProfitRatio: bn(0.005), + expected: 10_000 + (KeeperCosts.flagCost + KeeperCosts.liquidateCost), + }, + { + name: 'higher capped just cost', + withRatio: false, + lowerCap: 1, + lowerProfitRatio: bn(0.005), + higherCap: 100, + higherProfitRatio: bn(0.005), + expected: 100, + }, + { + name: 'uncapped plus reward ratio', + withRatio: true, + lowerCap: 1, + lowerProfitRatio: bn(0.005), + higherCap: bn(10), + higherProfitRatio: bn(0.005), + expected: bn(5).add(Math.trunc(KeeperCosts.flagCost + KeeperCosts.liquidateCost)), + }, + { + name: 'lower capped plus reward ratio', + withRatio: true, + lowerCap: bn(8), + lowerProfitRatio: bn(0.005), + higherCap: bn(10), + higherProfitRatio: bn(0.005), + expected: bn(8).add(KeeperCosts.flagCost + KeeperCosts.liquidateCost), + }, + { + name: 'higher capped plus reward ratio', + withRatio: true, + lowerCap: bn(1), + lowerProfitRatio: bn(0.005), + higherCap: bn(3), + higherProfitRatio: bn(0.005), + expected: bn(3), + }, + ]; + + for (let i = 0; i < capTestCases.length; i++) { + const test = capTestCases[i]; + describe(`${test.name}`, () => { + let liquidateTxn: ethers.providers.TransactionResponse; + before(restoreToConfiguration); + + before('set minLiquidationRewardUsd, maxLiquidationRewardUsd', async () => { + await systems().PerpsMarket.setKeeperRewardGuards( + test.lowerCap, + test.lowerProfitRatio, + test.higherCap, + test.higherProfitRatio + ); + }); + + before('set liquidation reward ratio', async () => { + if (test.withRatio) { + const initialMarginFraction = bn(0); + const maintenanceMarginScalar = bn(0); + const minimumInitialMarginRatio = bn(0); + const liquidationRewardRatio = bn(0.05); // 100 * 0.05 = 5 + const minimumPositionMargin = bn(0); + await systems() + .PerpsMarket.connect(owner()) + .setLiquidationParameters( + ethMarketId, + initialMarginFraction, + maintenanceMarginScalar, + minimumInitialMarginRatio, + liquidationRewardRatio, + minimumPositionMargin + ); + } + }); + + before('add collateral', async () => { + await depositCollateral(collateralsTestCase[0].collateralData); + }); + + before('open position', async () => { + await openPosition({ + systems, + provider, + trader: trader1(), + accountId: 2, + keeper: keeper(), + marketId: ethMarketId, + sizeDelta: bn(100), + settlementStrategyId: ethSettlementStrategyId, + price: bn(1000), + }); + }); + + before('lower price to liquidation', async () => { + await perpsMarkets()[0].aggregator().mockSetCurrentPrice(bn(1)); + }); + + before('liquidate account', async () => { + liquidateTxn = await systems().PerpsMarket.connect(keeper()).liquidate(2); + }); + + it('emits position liquidated event', async () => { + await assertEvent( + liquidateTxn, + `PositionLiquidated(2, 25, ${bn(100)}, 0)`, + systems().PerpsMarket + ); + }); + + it('emits account liquidated event', async () => { + await assertEvent( + liquidateTxn, + `AccountLiquidationAttempt(2, ${test.expected}, true)`, // not capped + systems().PerpsMarket + ); + }); + }); + } +}); diff --git a/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.Large-Position.test.ts b/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.Large-Position.test.ts new file mode 100644 index 0000000000..6f6baf11ba --- /dev/null +++ b/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.Large-Position.test.ts @@ -0,0 +1,239 @@ +import { ethers } from 'ethers'; +import { bn, bootstrapMarkets } from '../bootstrap'; +import { depositCollateral, openPosition } from '../helpers'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import { fastForwardTo, getTxTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; + +describe('Keeper Rewards - Multiple Liquidation steps', () => { + const KeeperCosts = { + settlementCost: 1111, + flagCost: 3333, + liquidateCost: 5555, + }; + const { systems, perpsMarkets, provider, trader1, keeperCostOracleNode, keeper, 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), + orderFees: { + makerFee: bn(0.007), + takerFee: bn(0.003), + }, + fundingParams: { skewScale: bn(1_000), maxFundingVelocity: bn(10) }, + }, + ], + traderAccountIds: [2, 3], + }); + let ethMarketId: ethers.BigNumber; + let ethSettlementStrategyId: ethers.BigNumber; + let liquidateTxn: ethers.providers.TransactionResponse; + + before('identify actors', async () => { + ethMarketId = perpsMarkets()[0].marketId(); + ethSettlementStrategyId = perpsMarkets()[0].strategyId(); + }); + + const collateralsTestCase = [ + { + name: 'only snxUSD', + collateralData: { + systems, + trader: trader1, + accountId: () => 2, + collaterals: [ + { + snxUSDAmount: () => bn(100_000), + }, + ], + }, + }, + ]; + + before('set keeper costs', async () => { + await keeperCostOracleNode() + .connect(owner()) + .setCosts(KeeperCosts.settlementCost, KeeperCosts.flagCost, KeeperCosts.liquidateCost); + }); + + const rewardGuards = { + minKeeperRewardUsd: 1, + minKeeperProfitRatioD18: bn(0), + maxKeeperRewardUsd: bn(10), + maxKeeperScalingRatioD18: bn(0.005), + }; + before('set minLiquidationRewardUsd, maxLiquidationRewardUsd - uncapped', async () => { + await systems().PerpsMarket.setKeeperRewardGuards( + rewardGuards.minKeeperRewardUsd, + rewardGuards.minKeeperProfitRatioD18, + rewardGuards.maxKeeperRewardUsd, + rewardGuards.maxKeeperScalingRatioD18 + ); + }); + + before('set liquidation reward ratio', async () => { + const initialMarginFraction = bn(0); + const maintenanceMarginScalar = bn(0); + const minimumInitialMarginRatio = bn(0); + const liquidationRewardRatio = bn(0.05); // 100 * 0.05 = 5 + const minimumPositionMargin = bn(0); + // max liquidation + const maxLiquidationLimitAccumulationMultiplier = bn(1); + const maxSecondsInLiquidationWindow = ethers.BigNumber.from(10); + await systems() + .PerpsMarket.connect(owner()) + .setLiquidationParameters( + ethMarketId, + initialMarginFraction, + maintenanceMarginScalar, + minimumInitialMarginRatio, + liquidationRewardRatio, + minimumPositionMargin + ); + await systems() + .PerpsMarket.connect(owner()) + .setMaxLiquidationParameters( + ethMarketId, + maxLiquidationLimitAccumulationMultiplier, + maxSecondsInLiquidationWindow, + 0, + ethers.constants.AddressZero + ); + }); + /** + * Based on the above configuration, the max liquidation amount for window == 100 + * * (maker + taker) * skewScale * secondsInWindow * multiplier + * * 0.01 * 1_000 * 10 * 1 = 100_000 + */ + + let latestLiquidationTime: number; + + before('add collateral', async () => { + await depositCollateral(collateralsTestCase[0].collateralData); + }); + + before('open position', async () => { + await openPosition({ + systems, + provider, + trader: trader1(), + accountId: 2, + keeper: keeper(), + marketId: ethMarketId, + sizeDelta: bn(201), + settlementStrategyId: ethSettlementStrategyId, + price: bn(1000), + }); + }); + + before('lower price to liquidation', async () => { + await perpsMarkets()[0].aggregator().mockSetCurrentPrice(bn(1)); + }); + + it('liquidate account - 1st step (100 of 201)', async () => { + const initialKeeperBalance = await systems().USD.balanceOf(await keeper().getAddress()); + + // calls liquidate on the perps market, 1st step => will flag and will liquidate 100 of original 201 + liquidateTxn = await systems().PerpsMarket.connect(keeper()).liquidate(2); + latestLiquidationTime = await getTxTime(provider(), liquidateTxn); + + // emits position liquidated event + await assertEvent( + liquidateTxn, + `PositionLiquidated(2, 25, ${bn(100)}, ${bn(101)})`, + systems().PerpsMarket + ); + + // emits account liquidated event. + // includes the flag reward + flag cost + 1 liquidation cost + const expected = bn(5).add(KeeperCosts.flagCost + KeeperCosts.liquidateCost); + + await assertEvent( + liquidateTxn, + `AccountLiquidationAttempt(2, ${expected}, false)`, // not capped + systems().PerpsMarket + ); + + // keeper gets paid + assertBn.equal( + await systems().USD.balanceOf(await keeper().getAddress()), + initialKeeperBalance.add(expected) + ); + }); + + it('liquidate account - 2nd step (100 of 101)', async () => { + await fastForwardTo(latestLiquidationTime + 35, provider()); + + const initialKeeperBalance = await systems().USD.balanceOf(await keeper().getAddress()); + + // calls liquidate on the perps market, 2nd step => won't flag and will liquidate 100 of original 201 + liquidateTxn = await systems().PerpsMarket.connect(keeper()).liquidate(2); + latestLiquidationTime = await getTxTime(provider(), liquidateTxn); + + // emits position liquidated event + await assertEvent( + liquidateTxn, + `PositionLiquidated(2, 25, ${bn(100)}, ${bn(1)})`, + systems().PerpsMarket + ); + + // emits account liquidated event + // since it was flagged it only gets 1 liquidation txn cost + const expected = KeeperCosts.liquidateCost + rewardGuards.minKeeperRewardUsd; + + await assertEvent( + liquidateTxn, + `AccountLiquidationAttempt(2, ${expected}, false)`, // not capped + systems().PerpsMarket + ); + + // keeper gets paid + assertBn.equal( + await systems().USD.balanceOf(await keeper().getAddress()), + initialKeeperBalance.add(expected) + ); + }); + + it('liquidate account - last step (1 of 1)', async () => { + await fastForwardTo(latestLiquidationTime + 35, provider()); + + const initialKeeperBalance = await systems().USD.balanceOf(await keeper().getAddress()); + + // calls liquidate on the perps market, 3rd step => won't flag and will liquidate 1 of original 201 + liquidateTxn = await systems().PerpsMarket.connect(keeper()).liquidate(2); + + // emits position liquidated event + await assertEvent( + liquidateTxn, + `PositionLiquidated(2, 25, ${bn(1)}, ${bn(0)})`, + systems().PerpsMarket + ); + + // emits account liquidated event + // since it was flagged it only gets 1 liquidation txn cost + const expected = KeeperCosts.liquidateCost + rewardGuards.minKeeperRewardUsd; + + await assertEvent( + liquidateTxn, + `AccountLiquidationAttempt(2, ${expected}, true)`, // not capped + systems().PerpsMarket + ); + + // keeper gets paid + assertBn.equal( + await systems().USD.balanceOf(await keeper().getAddress()), + initialKeeperBalance.add(expected) + ); + }); +}); diff --git a/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.N-Collaterals.test.ts b/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.N-Collaterals.test.ts new file mode 100644 index 0000000000..fd36f6cbe6 --- /dev/null +++ b/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.N-Collaterals.test.ts @@ -0,0 +1,176 @@ +import { ethers } from 'ethers'; +import { bn, bootstrapMarkets } from '../bootstrap'; +import { depositCollateral, openPosition } from '../helpers'; +import { SynthMarkets } from '@synthetixio/spot-market/test/common'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; + +describe('Keeper Rewards - Multiple Collaterals', () => { + const KeeperCosts = { + settlementCost: 1111, + flagCost: 3333, + liquidateCost: 5555, + }; + const { + systems, + perpsMarkets, + synthMarkets, + provider, + trader1, + keeperCostOracleNode, + keeper, + owner, + } = bootstrapMarkets({ + synthMarkets: [ + { + name: 'Bitcoin', + token: 'snxBTC', + buyPrice: bn(10_000), + sellPrice: bn(10_000), + }, + { + name: 'Ethereum', + token: 'snxETH', + buyPrice: bn(1_000), + sellPrice: bn(1_000), + }, + ], + perpsMarkets: [ + { + requestedMarketId: 25, + name: 'Ether', + token: 'snxETH', + price: bn(1000), + fundingParams: { skewScale: bn(100_000), maxFundingVelocity: bn(10) }, + }, + { + requestedMarketId: 30, + name: 'Link', + token: 'snxLINK', + price: bn(5000), + fundingParams: { skewScale: bn(200_000), maxFundingVelocity: bn(20) }, + }, + ], + traderAccountIds: [2, 3], + }); + let ethMarketId: ethers.BigNumber; + let ethSettlementStrategyId: ethers.BigNumber; + let btcSynth: SynthMarkets[number]; + let ethSynth: SynthMarkets[number]; + let liquidateTxn: ethers.providers.TransactionResponse; + + before('identify actors', async () => { + ethMarketId = perpsMarkets()[0].marketId(); + ethSettlementStrategyId = perpsMarkets()[0].strategyId(); + btcSynth = synthMarkets()[0]; + ethSynth = synthMarkets()[1]; + }); + + const collateralsTestCase = [ + { + name: 'multiple collaterals', + collateralData: { + systems, + trader: trader1, + accountId: () => 2, + collaterals: [ + { + snxUSDAmount: () => bn(10_000), + }, + { + synthMarket: () => btcSynth, + snxUSDAmount: () => bn(1_000), + }, + { + synthMarket: () => ethSynth, + snxUSDAmount: () => bn(1_000), + }, + ], + }, + }, + ]; + + before('set keeper costs', async () => { + await keeperCostOracleNode() + .connect(owner()) + .setCosts(KeeperCosts.settlementCost, KeeperCosts.flagCost, KeeperCosts.liquidateCost); + }); + + before('set minLiquidationRewardUsd, maxLiquidationRewardUsd - uncapped', async () => { + await systems().PerpsMarket.setKeeperRewardGuards(1, 0, bn(10), bn(0.005)); + }); + + before('set liquidation reward ratio', async () => { + const initialMarginFraction = bn(0); + const maintenanceMarginScalar = bn(0); + const minimumInitialMarginRatio = bn(0); + const liquidationRewardRatio = bn(0.05); // 100 * 0.05 = 5 + const minimumPositionMargin = bn(0); + await systems() + .PerpsMarket.connect(owner()) + .setLiquidationParameters( + ethMarketId, + initialMarginFraction, + maintenanceMarginScalar, + minimumInitialMarginRatio, + liquidationRewardRatio, + minimumPositionMargin + ); + }); + + before('add collateral', async () => { + await depositCollateral(collateralsTestCase[0].collateralData); + }); + + before('open position', async () => { + await openPosition({ + systems, + provider, + trader: trader1(), + accountId: 2, + keeper: keeper(), + marketId: ethMarketId, + sizeDelta: bn(100), + settlementStrategyId: ethSettlementStrategyId, + price: bn(1000), + }); + }); + + before('lower price to liquidation', async () => { + await perpsMarkets()[0].aggregator().mockSetCurrentPrice(bn(1)); + }); + + let initialKeeperBalance: ethers.BigNumber; + before('call liquidate', async () => { + initialKeeperBalance = await systems().USD.balanceOf(await keeper().getAddress()); + }); + + before('liquidate account', async () => { + liquidateTxn = await systems().PerpsMarket.connect(keeper()).liquidate(2); + }); + + it('emits position liquidated event', async () => { + await assertEvent( + liquidateTxn, + `PositionLiquidated(2, 25, ${bn(100)}, 0)`, + systems().PerpsMarket + ); + }); + + it('emits account liquidated event', async () => { + // 1 position, snxUSD collateral + 2 synth collateral + const expected = bn(5).add(KeeperCosts.flagCost * 3 + KeeperCosts.liquidateCost); + + await assertEvent( + liquidateTxn, + `AccountLiquidationAttempt(2, ${expected}, true)`, // not capped + systems().PerpsMarket + ); + }); + + it('keeper gets paid', async () => { + const keeperBalance = await systems().USD.balanceOf(await keeper().getAddress()); + const expected = bn(5).add(KeeperCosts.flagCost * 3 + KeeperCosts.liquidateCost); + assertBn.equal(keeperBalance, initialKeeperBalance.add(expected)); + }); +}); diff --git a/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.N-Positions.test.ts b/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.N-Positions.test.ts new file mode 100644 index 0000000000..3dc96cc105 --- /dev/null +++ b/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.N-Positions.test.ts @@ -0,0 +1,230 @@ +import { ethers } from 'ethers'; +import { bn, bootstrapMarkets } from '../bootstrap'; +import { depositCollateral, openPosition } from '../helpers'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; + +describe('Keeper Rewards - Multiple Positions', () => { + const KeeperCosts = { + settlementCost: 1111, + flagCost: 3333, + liquidateCost: 5555, + }; + const { systems, perpsMarkets, provider, trader1, keeperCostOracleNode, keeper, 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(10) }, + }, + { + requestedMarketId: 30, + name: 'Optimism', + token: 'snxOP', + price: bn(10), + fundingParams: { skewScale: bn(100_000), maxFundingVelocity: bn(10) }, + }, + { + requestedMarketId: 35, + name: 'Link', + token: 'snxLINK', + price: bn(100), + fundingParams: { skewScale: bn(100_000), maxFundingVelocity: bn(10) }, + }, + ], + traderAccountIds: [2, 3], + }); + let ethMarketId: ethers.BigNumber; + let opMarketId: ethers.BigNumber; + let linkMarketId: ethers.BigNumber; + let ethSettlementStrategyId: ethers.BigNumber; + let liquidateTxn: ethers.providers.TransactionResponse; + + before('identify actors', async () => { + ethMarketId = perpsMarkets()[0].marketId(); + opMarketId = perpsMarkets()[1].marketId(); + linkMarketId = perpsMarkets()[2].marketId(); + ethSettlementStrategyId = perpsMarkets()[0].strategyId(); + }); + + const collateralsTestCase = [ + { + name: 'only snxUSD', + collateralData: { + systems, + trader: trader1, + accountId: () => 2, + collaterals: [ + { + snxUSDAmount: () => bn(10_000), + }, + ], + }, + }, + ]; + + before('set keeper costs', async () => { + await keeperCostOracleNode() + .connect(owner()) + .setCosts(KeeperCosts.settlementCost, KeeperCosts.flagCost, KeeperCosts.liquidateCost); + }); + + before('set minLiquidationRewardUsd, maxLiquidationRewardUsd - uncapped', async () => { + await systems().PerpsMarket.setKeeperRewardGuards(1, 0, bn(100), bn(0.005)); + }); + + before('set liquidation reward ratio', async () => { + const initialMarginFraction = bn(0); + const maintenanceMarginScalar = bn(0); + const minimumInitialMarginRatio = bn(0); + const liquidationRewardRatio = bn(0.05); // 100 * 0.05 = 5 + const minimumPositionMargin = bn(0); + + await systems() + .PerpsMarket.connect(owner()) + .setLiquidationParameters( + ethMarketId, + initialMarginFraction, + maintenanceMarginScalar, + minimumInitialMarginRatio, + liquidationRewardRatio, + minimumPositionMargin + ); + + await systems() + .PerpsMarket.connect(owner()) + .setLiquidationParameters( + opMarketId, + initialMarginFraction, + maintenanceMarginScalar, + minimumInitialMarginRatio, + liquidationRewardRatio, + minimumPositionMargin + ); + + await systems() + .PerpsMarket.connect(owner()) + .setLiquidationParameters( + linkMarketId, + initialMarginFraction, + maintenanceMarginScalar, + minimumInitialMarginRatio, + liquidationRewardRatio, + minimumPositionMargin + ); + }); + + before('add collateral', async () => { + await depositCollateral(collateralsTestCase[0].collateralData); + }); + + before('open positions', async () => { + await openPosition({ + systems, + provider, + trader: trader1(), + accountId: 2, + keeper: keeper(), + marketId: ethMarketId, + sizeDelta: bn(100), + settlementStrategyId: ethSettlementStrategyId, + price: bn(1000), + }); + await openPosition({ + systems, + provider, + trader: trader1(), + accountId: 2, + keeper: keeper(), + marketId: opMarketId, + sizeDelta: bn(100), + settlementStrategyId: ethSettlementStrategyId, + price: bn(10), + }); + await openPosition({ + systems, + provider, + trader: trader1(), + accountId: 2, + keeper: keeper(), + marketId: linkMarketId, + sizeDelta: bn(100), + settlementStrategyId: ethSettlementStrategyId, + price: bn(100), + }); + }); + + before('lower price to liquidation', async () => { + await perpsMarkets()[0].aggregator().mockSetCurrentPrice(bn(1)); + await perpsMarkets()[1].aggregator().mockSetCurrentPrice(bn(2)); + await perpsMarkets()[2].aggregator().mockSetCurrentPrice(bn(3)); + }); + + let initialKeeperBalance: ethers.BigNumber; + before('call liquidate', async () => { + initialKeeperBalance = await systems().USD.balanceOf(await keeper().getAddress()); + }); + + before('liquidate account', async () => { + liquidateTxn = await systems().PerpsMarket.connect(keeper()).liquidate(2); + }); + + it('emits position liquidated event', async () => { + await assertEvent( + liquidateTxn, + `PositionLiquidated(2, 25, ${bn(100)}, 0)`, + systems().PerpsMarket + ); + await assertEvent( + liquidateTxn, + `PositionLiquidated(2, 30, ${bn(100)}, 0)`, + systems().PerpsMarket + ); + await assertEvent( + liquidateTxn, + `PositionLiquidated(2, 35, ${bn(100)}, 0)`, + systems().PerpsMarket + ); + }); + + it('emits account liquidated event', async () => { + // for each position: size * price * rewardRatio + // eth : 100 * 1 * 0.05 + // op : 200 * 1 * 0.05 + // link: 300 * 1 * 0.05 + const keeperRewardRatio = bn(100 * 0.05) + .add(bn(200 * 0.05)) + .add(bn(300 * 0.05)); + + // 3 positions, snxUSD collateral + const expected = keeperRewardRatio.add(KeeperCosts.flagCost * 3 + KeeperCosts.liquidateCost); + + await assertEvent( + liquidateTxn, + `AccountLiquidationAttempt(2, ${expected}, true)`, // not capped + systems().PerpsMarket + ); + }); + + it('keeper gets paid', async () => { + const keeperBalance = await systems().USD.balanceOf(await keeper().getAddress()); + const keeperRewardRatio = bn(100 * 0.05) + .add(bn(200 * 0.05)) + .add(bn(300 * 0.05)); + + // 3 positions, snxUSD collateral + const expected = keeperRewardRatio.add(KeeperCosts.flagCost * 3 + KeeperCosts.liquidateCost); + assertBn.equal(keeperBalance, initialKeeperBalance.add(expected)); + }); +}); diff --git a/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.Settlement.test.ts b/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.Settlement.test.ts new file mode 100644 index 0000000000..d49d75e5a2 --- /dev/null +++ b/markets/perps-market/test/integration/KeeperRewards/KeeperRewards.Settlement.test.ts @@ -0,0 +1,227 @@ +import { ethers } from 'ethers'; +import { DEFAULT_SETTLEMENT_STRATEGY, bn, bootstrapMarkets } from '../bootstrap'; +import { fastForwardTo } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { depositCollateral } from '../helpers'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import { getTxTime } from '@synthetixio/core-utils/src/utils/hardhat/rpc'; +import { calculateFillPrice } from '../helpers/fillPrice'; +import { wei } from '@synthetixio/wei'; +import { calcCurrentFundingVelocity } from '../helpers/funding-calcs'; + +describe('Keeper Rewards - Settlement', () => { + const KeeperCosts = { + settlementCost: 1111, + flagCost: 3333, + liquidateCost: 5555, + }; + const { systems, perpsMarkets, provider, trader1, keeperCostOracleNode, keeper, 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(10) }, + }, + { + requestedMarketId: 30, + name: 'Link', + token: 'snxLINK', + price: bn(5000), + fundingParams: { skewScale: bn(200_000), maxFundingVelocity: bn(20) }, + }, + ], + traderAccountIds: [2, 3], + }); + let ethMarketId: ethers.BigNumber; + + before('identify actors', async () => { + ethMarketId = perpsMarkets()[0].marketId(); + }); + + const collateralsTestCase = [ + { + name: 'only snxUSD', + collateralData: { + systems, + trader: trader1, + accountId: () => 2, + collaterals: [ + { + snxUSDAmount: () => bn(10_000), + }, + ], + }, + }, + ]; + + let extraData: string, updateFee: ethers.BigNumber; + + let tx: ethers.ContractTransaction; + let startTime: number; + let pythPriceData: string; + let settleTx: ethers.ContractTransaction; + + before('set keeper costs', async () => { + await keeperCostOracleNode() + .connect(owner()) + .setCosts(KeeperCosts.settlementCost, KeeperCosts.flagCost, KeeperCosts.liquidateCost); + }); + + before('add collateral', async () => { + await depositCollateral(collateralsTestCase[0].collateralData); + }); + + before('commit the order', async () => { + tx = await systems() + .PerpsMarket.connect(trader1()) + .commitOrder({ + marketId: ethMarketId, + accountId: 2, + sizeDelta: bn(1), + settlementStrategyId: 0, + acceptablePrice: bn(1050), // 5% slippage + referrer: ethers.constants.AddressZero, + trackingCode: ethers.constants.HashZero, + }); + startTime = await getTxTime(provider(), tx); + }); + + before('setup bytes data', () => { + extraData = ethers.utils.defaultAbiCoder.encode(['uint128'], [2]); + // pythCallData = ethers.utils.solidityPack( + // ['bytes32', 'uint64'], + // [DEFAULT_SETTLEMENT_STRATEGY.feedId, startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay] + // ); + }); + + before('fast forward to settlement time', async () => { + // fast forward to settlement + await fastForwardTo(startTime + DEFAULT_SETTLEMENT_STRATEGY.settlementDelay + 1, provider()); + }); + + before('prepare data', async () => { + // Get the latest price + pythPriceData = await systems().MockPyth.createPriceFeedUpdateData( + DEFAULT_SETTLEMENT_STRATEGY.feedId, + 1000_0000, + 1, + -4, + 1000_0000, + 1, + startTime + 6 + ); + updateFee = await systems().MockPyth['getUpdateFee(uint256)'](1); + }); + + let initialKeeperBalance: ethers.BigNumber; + before('call liquidate', async () => { + initialKeeperBalance = await systems().USD.balanceOf(await keeper().getAddress()); + }); + + before('settle', async () => { + settleTx = await systems() + .PerpsMarket.connect(keeper()) + .settlePythOrder(pythPriceData, extraData, { value: updateFee }); + }); + + it('keeper gets paid', async () => { + const keeperBalance = await systems().USD.balanceOf(await keeper().getAddress()); + assertBn.equal( + keeperBalance, + initialKeeperBalance + .add(DEFAULT_SETTLEMENT_STRATEGY.settlementReward) + .add(KeeperCosts.settlementCost) + ); + }); + + it('emits settle event', async () => { + const accountId = 2; + const fillPrice = calculateFillPrice(wei(0), wei(100_000), wei(1), wei(1000)).toBN(); + const sizeDelta = bn(1); + const newPositionSize = bn(1); + const totalFees = DEFAULT_SETTLEMENT_STRATEGY.settlementReward.add(KeeperCosts.settlementCost); + const settlementReward = DEFAULT_SETTLEMENT_STRATEGY.settlementReward.add( + KeeperCosts.settlementCost + ); + const trackingCode = `"${ethers.constants.HashZero}"`; + const msgSender = `"${await keeper().getAddress()}"`; + const params = [ + ethMarketId, + accountId, + fillPrice, + 0, + 0, + sizeDelta, + newPositionSize, + totalFees, + 0, // referral fees + 0, // collected fees + settlementReward, + trackingCode, + msgSender, + ]; + await assertEvent(settleTx, `OrderSettled(${params.join(', ')})`, systems().PerpsMarket); + }); + + it('emits market updated event', async () => { + const price = bn(1000); + const marketSize = bn(1); + const marketSkew = bn(1); + const sizeDelta = bn(1); + const currentFundingRate = bn(0); + const currentFundingVelocity = calcCurrentFundingVelocity({ + skew: wei(1), + skewScale: wei(100_000), + maxFundingVelocity: wei(10), + }); + const params = [ + ethMarketId, + price, + marketSkew, + marketSize, + sizeDelta, + currentFundingRate, + currentFundingVelocity.toBN(), // Funding rates should be tested more thoroughly elsewhre + ]; + await assertEvent(settleTx, `MarketUpdated(${params.join(', ')})`, systems().PerpsMarket); + }); + + it('emits collateral deducted events', async () => { + let pendingTotalFees = DEFAULT_SETTLEMENT_STRATEGY.settlementReward.add( + KeeperCosts.settlementCost + ); + const accountId = 2; + + const collateral = collateralsTestCase[0].collateralData.collaterals[0]; + const synthMarket = 0; + let deductedCollateralAmount: ethers.BigNumber = bn(0); + deductedCollateralAmount = collateral.snxUSDAmount().lt(pendingTotalFees) + ? collateral.snxUSDAmount() + : pendingTotalFees; + pendingTotalFees = pendingTotalFees.sub(deductedCollateralAmount); + + await assertEvent( + settleTx, + `CollateralDeducted(${accountId}, ${synthMarket}, ${deductedCollateralAmount})`, + systems().PerpsMarket + ); + }); + + it('check position is live', async () => { + const [pnl, funding, size] = await systems().PerpsMarket.getOpenPosition(2, ethMarketId); + assertBn.equal(pnl, bn(-0.005)); + assertBn.equal(funding, bn(0)); + assertBn.equal(size, bn(1)); + }); +}); diff --git a/markets/perps-market/test/integration/Liquidation/Liquidation.flaggedLiquidation.test.ts b/markets/perps-market/test/integration/Liquidation/Liquidation.flaggedLiquidation.test.ts index d9d6d4752c..9c00c41892 100644 --- a/markets/perps-market/test/integration/Liquidation/Liquidation.flaggedLiquidation.test.ts +++ b/markets/perps-market/test/integration/Liquidation/Liquidation.flaggedLiquidation.test.ts @@ -6,12 +6,14 @@ import { fastForwardTo, getTxTime } from '@synthetixio/core-utils/utils/hardhat/ import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; import { ethers } from 'ethers'; -describe('Liquidation - flaggedLiquidation', async () => { +describe('Liquidation - flaggedLiquidation', () => { const { systems, provider, trader1, trader2, trader3, keeper, owner, perpsMarkets } = bootstrapMarkets({ liquidationGuards: { minLiquidationReward: bn(5), + minKeeperProfitRatioD18: bn(0), maxLiquidationReward: bn(1000), + maxKeeperScalingRatioD18: bn(0), }, synthMarkets: [], perpsMarkets: [ diff --git a/markets/perps-market/test/integration/Liquidation/Liquidation.margin.test.ts b/markets/perps-market/test/integration/Liquidation/Liquidation.margin.test.ts index 208e7a25ac..34deef9031 100644 --- a/markets/perps-market/test/integration/Liquidation/Liquidation.margin.test.ts +++ b/markets/perps-market/test/integration/Liquidation/Liquidation.margin.test.ts @@ -6,7 +6,7 @@ import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot import { ethers } from 'ethers'; import assert from 'assert/strict'; -describe('Liquidation - margin', async () => { +describe('Liquidation - margin', () => { const perpsMarketConfigs = [ { requestedMarketId: 50, @@ -149,7 +149,7 @@ describe('Liquidation - margin', async () => { } }); - describe('account check after initial positions open', async () => { + describe('account check after initial positions open', () => { it('should have correct open interest', async () => { assertBn.equal(await systems().PerpsMarket.totalAccountOpenInterest(2), bn(100_000)); }); diff --git a/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.endorsedLiquidator.ts b/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.endorsedLiquidator.test.ts similarity index 95% rename from markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.endorsedLiquidator.ts rename to markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.endorsedLiquidator.test.ts index bb368605a8..ef5719046e 100644 --- a/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.endorsedLiquidator.ts +++ b/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.endorsedLiquidator.test.ts @@ -3,7 +3,7 @@ import { PerpsMarket, bn, bootstrapMarkets } from '../bootstrap'; import { openPosition } from '../helpers'; import assertBn from '@synthetixio/core-utils/src/utils/assertions/assert-bignumber'; -describe('Liquidation - endorsed liquidator', async () => { +describe('Liquidation - endorsed liquidator', () => { const { systems, provider, owner, trader1, keeper, perpsMarkets } = bootstrapMarkets({ synthMarkets: [], perpsMarkets: [ @@ -92,7 +92,7 @@ describe('Liquidation - endorsed liquidator', async () => { }); it('did not send any liquidation reward', async () => { - assertBn.equal(await systems().USD.balanceOf(keeper().getAddress()), 0); + assertBn.equal(await systems().USD.balanceOf(await keeper().getAddress()), 0); }); }); }); diff --git a/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.macro.test.ts b/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.macro.test.ts index a40b355f3f..ba373250bb 100644 --- a/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.macro.test.ts +++ b/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.macro.test.ts @@ -4,7 +4,7 @@ import { openPosition } from '../helpers'; import assertBn from '@synthetixio/core-utils/src/utils/assertions/assert-bignumber'; import { ethers } from 'ethers'; -describe('Liquidation - max liquidatable amount with multiple continuing liquidations', async () => { +describe('Liquidation - max liquidatable amount with multiple continuing liquidations', () => { const { systems, provider, trader1, trader2, keeper, perpsMarkets } = bootstrapMarkets({ synthMarkets: [], perpsMarkets: [ diff --git a/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.maxPd.test.ts b/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.maxPd.test.ts index 969067b33b..71f6736e34 100644 --- a/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.maxPd.test.ts +++ b/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.maxPd.test.ts @@ -3,7 +3,7 @@ import { PerpsMarket, bn, bootstrapMarkets } from '../bootstrap'; import { openPosition } from '../helpers'; import assertBn from '@synthetixio/core-utils/src/utils/assertions/assert-bignumber'; -describe('Liquidation - max pd', async () => { +describe('Liquidation - max pd', () => { const { systems, provider, owner, trader1, trader2, keeper, perpsMarkets } = bootstrapMarkets({ synthMarkets: [], perpsMarkets: [ diff --git a/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.test.ts b/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.test.ts index 9bd070ff31..624789dc21 100644 --- a/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.test.ts +++ b/markets/perps-market/test/integration/Liquidation/Liquidation.maxLiquidationAmount.test.ts @@ -4,11 +4,13 @@ import { openPosition } from '../helpers'; import { fastForwardTo, getTxTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; import { ethers } from 'ethers'; -describe('Liquidation - max liquidatable amount', async () => { +describe('Liquidation - max liquidatable amount', () => { const { systems, provider, trader1, trader2, keeper, perpsMarkets } = bootstrapMarkets({ liquidationGuards: { minLiquidationReward: bn(5), + minKeeperProfitRatioD18: bn(0), maxLiquidationReward: bn(1000), + maxKeeperScalingRatioD18: bn(0), }, synthMarkets: [], perpsMarkets: [ diff --git a/markets/perps-market/test/integration/Liquidation/Liquidation.multi-collateral.test.ts b/markets/perps-market/test/integration/Liquidation/Liquidation.multi-collateral.test.ts index b8cda4b634..8dca41e93d 100644 --- a/markets/perps-market/test/integration/Liquidation/Liquidation.multi-collateral.test.ts +++ b/markets/perps-market/test/integration/Liquidation/Liquidation.multi-collateral.test.ts @@ -7,7 +7,7 @@ import { ethers } from 'ethers'; import { calculatePricePnl } from '../helpers/fillPrice'; import { wei } from '@synthetixio/wei'; -describe('Liquidation - multi collateral', async () => { +describe('Liquidation - multi collateral', () => { const perpsMarketConfigs = [ { requestedMarketId: 50, @@ -72,7 +72,9 @@ describe('Liquidation - multi collateral', async () => { bootstrapMarkets({ liquidationGuards: { minLiquidationReward: bn(10), + minKeeperProfitRatioD18: bn(0), maxLiquidationReward: bn(1000), + maxKeeperScalingRatioD18: bn(0.5), }, synthMarkets: [ { @@ -133,7 +135,7 @@ describe('Liquidation - multi collateral', async () => { OpenPositionData, 'systems' | 'provider' | 'trader' | 'accountId' | 'keeper' >; - before('identify common props', async () => { + before('identify common props', () => { commonOpenPositionProps = { systems, provider, @@ -161,7 +163,7 @@ describe('Liquidation - multi collateral', async () => { } }); - describe('account check after initial positions open', async () => { + describe('account check after initial positions open', () => { it('should have correct open interest', async () => { assertBn.equal(await systems().PerpsMarket.totalAccountOpenInterest(2), bn(80_000)); }); @@ -211,7 +213,7 @@ describe('Liquidation - multi collateral', async () => { it('emits account liquidated event', async () => { await assertEvent( liquidateTxn, - `AccountLiquidated(2, ${bn(1000)}, true)`, // max liquidation reward $1000 + `AccountLiquidationAttempt(2, ${bn(1000)}, true)`, // max liquidation reward $1000 systems().PerpsMarket ); }); diff --git a/markets/perps-market/test/integration/Market/CreateMarket.test.ts b/markets/perps-market/test/integration/Market/CreateMarket.test.ts index 72dfeab062..0ead296e54 100644 --- a/markets/perps-market/test/integration/Market/CreateMarket.test.ts +++ b/markets/perps-market/test/integration/Market/CreateMarket.test.ts @@ -5,6 +5,7 @@ import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; import { createOracleNode } from '@synthetixio/oracle-manager/test/common'; +import { createKeeperCostNode } from '../bootstrap/createKeeperCostNode'; describe('Create Market test', () => { const name = 'Ether', @@ -15,6 +16,7 @@ describe('Create Market test', () => { synthMarkets: [], perpsMarkets: [], // don't create a market in bootstrap traderAccountIds: [2, 3], + skipKeeperCostOracleNode: true, }); let randomAccount: ethers.Signer; @@ -23,7 +25,7 @@ describe('Create Market test', () => { [, , , , randomAccount] = signers(); }); - describe('market initialization', async () => { + describe('market initialization', () => { before(restore); const marketId = BigNumber.from(25); @@ -71,7 +73,7 @@ describe('Create Market test', () => { }); }); - describe('market initialization with invalid parameters', async () => { + describe('market initialization with invalid parameters', () => { before(restore); const marketId = BigNumber.from(25); @@ -101,11 +103,12 @@ describe('Create Market test', () => { }); }); - describe('market operation and configuration', async () => { + describe('market operation and configuration', () => { before(restore); const marketId = BigNumber.from(25); let oracleNodeId: string; + let keeperCostNodeId: string; before('create perps market', async () => { await systems().PerpsMarket.connect(owner()).createMarket(marketId, name, token); @@ -115,11 +118,16 @@ describe('Create Market test', () => { await systems().PerpsMarket.connect(owner()).setMaxMarketSize(marketId, bn(99999999)); }); - before('create price nodes', async () => { + before('create price node', async () => { const results = await createOracleNode(owner(), price, systems().OracleManager); oracleNodeId = results.oracleNodeId; }); + before('create keeper reward node', async () => { + const results = await createKeeperCostNode(owner(), systems().OracleManager); + keeperCostNodeId = results.keeperCostNodeId; + }); + describe('attempt to update price data with non-owner', () => { it('reverts', async () => { await assertRevert( @@ -129,6 +137,15 @@ describe('Create Market test', () => { }); }); + describe('attempt to update keeper cost with non-owner', () => { + it('reverts', async () => { + await assertRevert( + systems().PerpsMarket.connect(randomAccount).updateKeeperCostNodeId(keeperCostNodeId), + 'Unauthorized' + ); + }); + }); + describe('before setting up price data', () => { it('reverts when trying to use the market', async () => { await assertRevert( @@ -153,52 +170,11 @@ describe('Create Market test', () => { await systems().PerpsMarket.connect(owner()).updatePriceData(marketId, oracleNodeId); }); - before('create settlement strategy', async () => { - await systems() - .PerpsMarket.connect(owner()) - .addSettlementStrategy(marketId, { - strategyType: 0, - settlementDelay: 5, - settlementWindowDuration: 120, - priceWindowDuration: 120, - priceVerificationContract: ethers.constants.AddressZero, - feedId: ethers.constants.HashZero, - url: '', - disabled: false, - settlementReward: bn(5), - }); - }); - - before('set skew scale', async () => { - await systems() - .PerpsMarket.connect(owner()) - .setFundingParameters(marketId, bn(100_000), bn(0)); - }); - - before('ensure per account max is set to zero', async () => { - await systems().PerpsMarket.connect(owner()).setPerAccountCaps(0, 0); - }); - - it('reverts when trying add collateral if max collaterals per account is zero', async () => { - await assertRevert( - systems().PerpsMarket.connect(trader1()).modifyCollateral(2, 0, bn(10_000)), - 'MaxCollateralsPerAccountReached("0")' - ); - }); - - describe('when max collaterals per account is set to non-zero', () => { - before('set max collaterals per account', async () => { - await systems().PerpsMarket.connect(owner()).setPerAccountCaps(0, 1000); - }); - - before('add collateral', async () => { - await systems().PerpsMarket.connect(trader1()).modifyCollateral(2, 0, bn(10_000)); - }); - - it('reverts when trying to add position if max positions per account is zero', async () => { + describe('before setting up price data', () => { + it('reverts when trying to use the market', async () => { await assertRevert( systems() - .PerpsMarket.connect(trader1()) + .PerpsMarket.connect(owner()) .commitOrder({ marketId: marketId, accountId: 2, @@ -206,36 +182,102 @@ describe('Create Market test', () => { settlementStrategyId: 0, acceptablePrice: bn(1050), // 5% slippage referrer: ethers.constants.AddressZero, - trackingCode: ethers.constants.HashZero, }), - 'MaxPositionsPerAccountReached("0")' + 'KeeperCostsNotSet' ); }); + }); + + describe('when keeper cost data is updated', () => { + before('update keeper reward data', async () => { + await systems().PerpsMarket.connect(owner()).updateKeeperCostNodeId(keeperCostNodeId); + }); - describe('when max positions per account is set to non-zero', () => { - before('set max positions per account', async () => { - await systems().PerpsMarket.connect(owner()).setPerAccountCaps(1000, 1000); + before('create settlement strategy', async () => { + await systems() + .PerpsMarket.connect(owner()) + .addSettlementStrategy(marketId, { + strategyType: 0, + settlementDelay: 5, + settlementWindowDuration: 120, + priceWindowDuration: 120, + priceVerificationContract: ethers.constants.AddressZero, + feedId: ethers.constants.HashZero, + url: '', + disabled: false, + settlementReward: bn(5), + }); + }); + + before('set skew scale', async () => { + await systems() + .PerpsMarket.connect(owner()) + .setFundingParameters(marketId, bn(100_000), bn(0)); + }); + + before('ensure per account max is set to zero', async () => { + await systems().PerpsMarket.connect(owner()).setPerAccountCaps(0, 0); + }); + + it('reverts when trying add collateral if max collaterals per account is zero', async () => { + await assertRevert( + systems().PerpsMarket.connect(trader1()).modifyCollateral(2, 0, bn(10_000)), + 'MaxCollateralsPerAccountReached("0")' + ); + }); + + describe('when max collaterals per account is set to non-zero', () => { + before('set max collaterals per account', async () => { + await systems().PerpsMarket.connect(owner()).setPerAccountCaps(0, 1000); }); - it('should be able to use the market', async () => { - await systems() - .PerpsMarket.connect(trader1()) - .commitOrder({ - marketId: marketId, - accountId: 2, - sizeDelta: bn(1), - settlementStrategyId: 0, - acceptablePrice: bn(1050), // 5% slippage - referrer: ethers.constants.AddressZero, - trackingCode: ethers.constants.HashZero, - }); + + before('add collateral', async () => { + await systems().PerpsMarket.connect(trader1()).modifyCollateral(2, 0, bn(10_000)); + }); + + it('reverts when trying to add position if max positions per account is zero', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(trader1()) + .commitOrder({ + marketId: marketId, + accountId: 2, + sizeDelta: bn(1), + settlementStrategyId: 0, + acceptablePrice: bn(1050), // 5% slippage + referrer: ethers.constants.AddressZero, + + trackingCode: ethers.constants.HashZero, + }), + 'MaxPositionsPerAccountReached("0")' + ); + }); + + describe('when max positions per account is set to non-zero', () => { + before('set max positions per account', async () => { + await systems().PerpsMarket.connect(owner()).setPerAccountCaps(1000, 1000); + }); + it('should be able to use the market', async () => { + await systems() + .PerpsMarket.connect(trader1()) + .commitOrder({ + marketId: marketId, + accountId: 2, + sizeDelta: bn(1), + settlementStrategyId: 0, + acceptablePrice: bn(1050), // 5% slippage + referrer: ethers.constants.AddressZero, + trackingCode: ethers.constants.HashZero, + }); + }); }); }); }); }); }); - describe('market interface views', async () => { + describe('market interface views', () => { before(restore); const marketId = BigNumber.from(25); @@ -259,7 +301,7 @@ describe('Create Market test', () => { }); }); - describe('factory setup', async () => { + describe('factory setup', () => { before(restore); describe('attempt to do it with non-owner', () => { diff --git a/markets/perps-market/test/integration/Market/MarketConfiguration.test.ts b/markets/perps-market/test/integration/Market/MarketConfiguration.test.ts index c07b74dba9..3227827b3c 100644 --- a/markets/perps-market/test/integration/Market/MarketConfiguration.test.ts +++ b/markets/perps-market/test/integration/Market/MarketConfiguration.test.ts @@ -6,7 +6,7 @@ import assertBn from '@synthetixio/core-utils/src/utils/assertions/assert-bignum import assertEvent from '@synthetixio/core-utils/src/utils/assertions/assert-event'; import assert from 'assert'; -describe('MarketConfiguration', async () => { +describe('MarketConfiguration', () => { const { systems, signers, owner } = bootstrapMarkets({ synthMarkets: [], perpsMarkets: [], @@ -191,7 +191,7 @@ describe('MarketConfiguration', async () => { assertBn.equal(lockedOiRatio, fixture.lockedOiPercentRatioD18); }); - it('should revert transaction when not market owner sets parameters', async () => { + it('should revert when a non-owner owner attempts to set parameters', async () => { await assertRevert( systems() .PerpsMarket.connect(randomUser) diff --git a/markets/perps-market/test/integration/Market/MarketDebt.withFunding.test.ts b/markets/perps-market/test/integration/Market/MarketDebt.withFunding.test.ts index bf641d22bb..d8dac5bb13 100644 --- a/markets/perps-market/test/integration/Market/MarketDebt.withFunding.test.ts +++ b/markets/perps-market/test/integration/Market/MarketDebt.withFunding.test.ts @@ -47,6 +47,12 @@ describe('Market Debt - with funding', () => { }, ], traderAccountIds, + liquidationGuards: { + minLiquidationReward: bn(0), + minKeeperProfitRatioD18: bn(0), + maxLiquidationReward: bn(10_000), + maxKeeperScalingRatioD18: bn(1), + }, }); let perpsMarket: PerpsMarket; diff --git a/markets/perps-market/test/integration/Markets/GlobalPerpsMarket.test.ts b/markets/perps-market/test/integration/Markets/GlobalPerpsMarket.test.ts index 2593c513a9..1a498e3ab5 100644 --- a/markets/perps-market/test/integration/Markets/GlobalPerpsMarket.test.ts +++ b/markets/perps-market/test/integration/Markets/GlobalPerpsMarket.test.ts @@ -21,7 +21,7 @@ describe('GlobalPerpsMarket', () => { async () => { await systems().PerpsMarket.setMaxCollateralAmount(perpsMarkets()[0].marketId(), bn(10000)); await systems().PerpsMarket.setSynthDeductionPriority([1, 2]); - await systems().PerpsMarket.setLiquidationRewardGuards(100, 500); + await systems().PerpsMarket.setKeeperRewardGuards(100, bn(0.001), 500, bn(0.005)); } ); @@ -60,15 +60,19 @@ describe('GlobalPerpsMarket', () => { }); }); - it('returns the correct minLiquidationRewardUsd ', async () => { - const liquidationGuards = await systems().PerpsMarket.getLiquidationRewardGuards(); - assertBn.equal(liquidationGuards.minLiquidationRewardUsd, 100); - assertBn.equal(liquidationGuards.maxLiquidationRewardUsd, 500); + it('returns the correct minKeeperRewardUsd ', async () => { + const liquidationGuards = await systems().PerpsMarket.getKeeperRewardGuards(); + assertBn.equal(liquidationGuards.minKeeperRewardUsd, 100); + assertBn.equal(liquidationGuards.minKeeperProfitRatioD18, bn(0.001)); + assertBn.equal(liquidationGuards.maxKeeperRewardUsd, 500); + assertBn.equal(liquidationGuards.maxKeeperScalingRatioD18, bn(0.005)); }); it('transaction should fail if setter function are called by external user', async () => { await assertRevert( - systems().PerpsMarket.connect(trader1()).setLiquidationRewardGuards(100, 500), + systems() + .PerpsMarket.connect(trader1()) + .setKeeperRewardGuards(100, bn(0.001), 500, bn(0.005)), `Unauthorized("${await trader1().getAddress()}")` ); await assertRevert( @@ -85,8 +89,8 @@ describe('GlobalPerpsMarket', () => { it('transaction should fail if min and max are inverted', async () => { await assertRevert( - systems().PerpsMarket.connect(owner()).setLiquidationRewardGuards(500, 100), - 'InvalidParameter("min/maxLiquidationRewardUSD", "min > max")' + systems().PerpsMarket.connect(owner()).setKeeperRewardGuards(500, bn(0.001), 100, bn(0.005)), + 'InvalidParameter("min/maxKeeperRewardUSD", "min > max")' ); }); diff --git a/markets/perps-market/test/integration/Orders/Order.marginValidation.capped.test.ts b/markets/perps-market/test/integration/Orders/Order.marginValidation.capped.test.ts new file mode 100644 index 0000000000..10733d36df --- /dev/null +++ b/markets/perps-market/test/integration/Orders/Order.marginValidation.capped.test.ts @@ -0,0 +1,334 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { bn, bootstrapMarkets } from '../bootstrap'; +import { + calculateFillPrice, + openPosition, + requiredMargins, + getRequiredLiquidationRewardMargin, +} from '../helpers'; +import { wei } from '@synthetixio/wei'; +import { ethers } from 'ethers'; + +describe('Orders - capped margin validation', () => { + const liqParams = { + btc: { + imRatio: wei(0.02), + minIm: wei(0.01), + mmScalar: wei(0.5), + liqRatio: wei(0.0075), + }, + eth: { + imRatio: wei(0.02), + minIm: wei(0.01), + mmScalar: wei(0.5), + liqRatio: wei(0.01), + }, + }; + const liqGuards = { + minLiquidationReward: wei(1), + minKeeperProfitRatioD18: wei(0.01), + maxLiquidationReward: wei(110), + maxKeeperScalingRatioD18: wei(10), + }; + + const { systems, provider, trader1, perpsMarkets, keeper } = bootstrapMarkets({ + synthMarkets: [], + liquidationGuards: { + minLiquidationReward: liqGuards.minLiquidationReward.bn, + minKeeperProfitRatioD18: liqGuards.minKeeperProfitRatioD18.bn, + maxLiquidationReward: liqGuards.maxLiquidationReward.bn, + maxKeeperScalingRatioD18: liqGuards.maxKeeperScalingRatioD18.bn, + }, + perpsMarkets: [ + { + requestedMarketId: 50, + name: 'Bitcoin', + token: 'BTC', + price: bn(10_000), + fundingParams: { skewScale: bn(1000), maxFundingVelocity: bn(0) }, + liquidationParams: { + initialMarginFraction: liqParams.btc.imRatio.toBN(), + minimumInitialMarginRatio: liqParams.btc.minIm.toBN(), + maintenanceMarginScalar: liqParams.btc.mmScalar.toBN(), + maxLiquidationLimitAccumulationMultiplier: bn(1), + liquidationRewardRatio: liqParams.btc.liqRatio.toBN(), + maxSecondsInLiquidationWindow: ethers.BigNumber.from(10), + minimumPositionMargin: bn(0), + }, + settlementStrategy: { + settlementReward: bn(0), + }, + }, + { + requestedMarketId: 51, + name: 'Ether', + token: 'ETH', + price: bn(2000), + fundingParams: { skewScale: bn(10_000), maxFundingVelocity: bn(0) }, + liquidationParams: { + initialMarginFraction: liqParams.eth.imRatio.toBN(), + minimumInitialMarginRatio: liqParams.eth.minIm.toBN(), + maintenanceMarginScalar: liqParams.eth.mmScalar.toBN(), + maxLiquidationLimitAccumulationMultiplier: bn(1), + liquidationRewardRatio: liqParams.eth.liqRatio.toBN(), + maxSecondsInLiquidationWindow: ethers.BigNumber.from(10), + minimumPositionMargin: bn(0), + }, + settlementStrategy: { + settlementReward: bn(0), + }, + }, + ], + traderAccountIds: [2], + }); + + before('add margin to account', async () => { + await systems().PerpsMarket.connect(trader1()).modifyCollateral(2, 0, bn(100)); + }); + + describe('openPosition 1 failure', () => { + let orderFees: ethers.BigNumber; + before('get order fees', async () => { + [orderFees] = await systems().PerpsMarket.computeOrderFees(51, 3); + }); + + it('reverts if not enough margin', async () => { + const { initialMargin, liquidationMargin } = requiredMargins( + { + initialMarginRatio: liqParams.eth.imRatio, + minimumInitialMarginRatio: liqParams.eth.minIm, + maintenanceMarginScalar: liqParams.eth.mmScalar, + liquidationRewardRatio: liqParams.eth.liqRatio, + }, + wei(3), + calculateFillPrice(wei(0), wei(10_000), wei(3), wei(2000)), + wei(10_000) + ); + + const totalRequiredMargin = initialMargin + .add( + getRequiredLiquidationRewardMargin(liquidationMargin, liqGuards, { + costOfTx: wei(0), + margin: wei(100), + }) + ) + .add(orderFees); + + assertBn.equal( + await systems().PerpsMarket.requiredMarginForOrder(2, 51, bn(3)), + totalRequiredMargin.toBN() + ); + + await assertRevert( + systems() + .PerpsMarket.connect(trader1()) + .commitOrder({ + marketId: 51, + accountId: 2, + sizeDelta: bn(3), + settlementStrategyId: perpsMarkets()[1].strategyId(), + acceptablePrice: bn(3000), + referrer: ethers.constants.AddressZero, + trackingCode: ethers.constants.HashZero, + }), + `InsufficientMargin("${bn(100)}", "${totalRequiredMargin.toString(18, true)}")` + ); + }); + }); + + describe('openPosition 1 success', () => { + before('add more margin', async () => { + await systems().PerpsMarket.connect(trader1()).modifyCollateral(2, 0, bn(100)); + }); + + before('open position', async () => { + await openPosition({ + systems, + provider, + trader: trader1(), + accountId: 2, + keeper: keeper(), + marketId: perpsMarkets()[1].marketId(), + sizeDelta: bn(3), + settlementStrategyId: perpsMarkets()[1].strategyId(), + price: bn(2000), + }); + }); + + it('opens position', async () => { + const [, , positionSize] = await systems().PerpsMarket.getOpenPosition( + 2, + perpsMarkets()[1].marketId() + ); + assertBn.equal(positionSize, bn(3)); + }); + }); + + describe('openPosition 2 failure', () => { + let orderFees: ethers.BigNumber; + before('get order fees', async () => { + [orderFees] = await systems().PerpsMarket.computeOrderFees(50, 5); + }); + + it('reverts if not enough margin', async () => { + // previous order margins + const { maintenanceMargin: ethMaintMargin, liquidationMargin: ethLiqMargin } = + requiredMargins( + { + initialMarginRatio: liqParams.eth.imRatio, + minimumInitialMarginRatio: liqParams.eth.minIm, + maintenanceMarginScalar: liqParams.eth.mmScalar, + liquidationRewardRatio: liqParams.eth.liqRatio, + }, + wei(3), + wei(2000), + wei(10_000) + ); + + const { initialMargin: btcInitialMargin, liquidationMargin: btcLiqMargin } = requiredMargins( + { + initialMarginRatio: liqParams.btc.imRatio, + minimumInitialMarginRatio: liqParams.btc.minIm, + maintenanceMarginScalar: liqParams.btc.mmScalar, + liquidationRewardRatio: liqParams.btc.liqRatio, + }, + wei(5), + calculateFillPrice(wei(0), wei(1000), wei(5), wei(10_000)), + wei(1000) + ); + + const liqReward = getRequiredLiquidationRewardMargin( + ethLiqMargin.add(btcLiqMargin), + liqGuards, + { + costOfTx: wei(0), + margin: wei(200), + } + ); + + const totalRequiredMargin = ethMaintMargin + .add(btcInitialMargin) + .add(liqReward) + .add(orderFees); + + assertBn.equal( + await systems().PerpsMarket.requiredMarginForOrder(2, 50, bn(5)), + totalRequiredMargin.toBN() + ); + + await assertRevert( + systems() + .PerpsMarket.connect(trader1()) + .commitOrder({ + marketId: 50, + accountId: 2, + sizeDelta: bn(5), + settlementStrategyId: perpsMarkets()[0].strategyId(), + acceptablePrice: bn(11000), + referrer: ethers.constants.AddressZero, + trackingCode: ethers.constants.HashZero, + }), + `InsufficientMargin("${await systems().PerpsMarket.getAvailableMargin( + 2 + )}", "${totalRequiredMargin.toString(18, true)}")` + ); + }); + }); + + describe('openPosition 2 success', () => { + before('add more margin', async () => { + await systems().PerpsMarket.connect(trader1()).modifyCollateral(2, 0, bn(900)); + }); + + before('open position', async () => { + await openPosition({ + systems, + provider, + trader: trader1(), + accountId: 2, + keeper: keeper(), + marketId: perpsMarkets()[0].marketId(), + sizeDelta: bn(5), + settlementStrategyId: perpsMarkets()[0].strategyId(), + price: bn(10_000), + }); + }); + + it('opens position', async () => { + const [, , positionSize] = await systems().PerpsMarket.getOpenPosition(2, 50); + assertBn.equal(positionSize, bn(5)); + }); + }); + + describe('modify position', () => { + let orderFees: ethers.BigNumber; + before('get order fees', async () => { + [orderFees] = await systems().PerpsMarket.computeOrderFees(50, 5); + }); + + it('reverts if not enough margin', async () => { + // previous order margins + const { maintenanceMargin: ethMaintMargin, liquidationMargin: ethLiqMargin } = + requiredMargins( + { + initialMarginRatio: liqParams.eth.imRatio, + minimumInitialMarginRatio: liqParams.eth.minIm, + maintenanceMarginScalar: liqParams.eth.mmScalar, + liquidationRewardRatio: liqParams.eth.liqRatio, + }, + wei(3), + wei(2000), + wei(10_000) + ); + + const { initialMargin: btcInitialMargin, liquidationMargin: btcLiqMargin } = requiredMargins( + { + initialMarginRatio: liqParams.btc.imRatio, + minimumInitialMarginRatio: liqParams.btc.minIm, + maintenanceMarginScalar: liqParams.btc.mmScalar, + liquidationRewardRatio: liqParams.btc.liqRatio, + }, + wei(10), + calculateFillPrice(wei(5), wei(1000), wei(5), wei(10_000)), + wei(1000) + ); + + const liqReward = getRequiredLiquidationRewardMargin( + ethLiqMargin.add(btcLiqMargin), + liqGuards, + { + costOfTx: wei(0), + margin: wei(200 + 900), + } + ); + + const totalRequiredMargin = ethMaintMargin + .add(btcInitialMargin) + .add(liqReward) + .add(orderFees); + + assertBn.equal( + await systems().PerpsMarket.requiredMarginForOrder(2, 50, bn(5)), + totalRequiredMargin.toBN() + ); + + await assertRevert( + systems() + .PerpsMarket.connect(trader1()) + .commitOrder({ + marketId: 50, + accountId: 2, + sizeDelta: bn(5), + settlementStrategyId: perpsMarkets()[0].strategyId(), + acceptablePrice: bn(11000), + referrer: ethers.constants.AddressZero, + trackingCode: ethers.constants.HashZero, + }), + `InsufficientMargin("${await systems().PerpsMarket.getAvailableMargin( + 2 + )}", "${totalRequiredMargin.toString(18, true)}")` + ); + }); + }); +}); diff --git a/markets/perps-market/test/integration/Orders/Order.marginValidation.test.ts b/markets/perps-market/test/integration/Orders/Order.marginValidation.test.ts index 831aae34fb..3497b37892 100644 --- a/markets/perps-market/test/integration/Orders/Order.marginValidation.test.ts +++ b/markets/perps-market/test/integration/Orders/Order.marginValidation.test.ts @@ -11,7 +11,6 @@ import { wei } from '@synthetixio/wei'; import { ethers } from 'ethers'; const MIN_LIQUIDATION_REWARD = wei(100); - describe('Orders - margin validation', () => { const liqParams = { btc: { @@ -27,12 +26,20 @@ describe('Orders - margin validation', () => { liqRatio: wei(0.01), }, }; + const liqGuards = { + minLiquidationReward: wei(MIN_LIQUIDATION_REWARD), + minKeeperProfitRatioD18: wei(0), + maxLiquidationReward: wei(10_000), + maxKeeperScalingRatioD18: wei(1000), + }; const { systems, provider, trader1, perpsMarkets, keeper } = bootstrapMarkets({ synthMarkets: [], liquidationGuards: { - minLiquidationReward: MIN_LIQUIDATION_REWARD.toBN(), - maxLiquidationReward: bn(500), + minLiquidationReward: liqGuards.minLiquidationReward.bn, + minKeeperProfitRatioD18: liqGuards.minKeeperProfitRatioD18.bn, + maxLiquidationReward: liqGuards.maxLiquidationReward.bn, + maxKeeperScalingRatioD18: liqGuards.maxKeeperScalingRatioD18.bn, }, perpsMarkets: [ { @@ -101,7 +108,12 @@ describe('Orders - margin validation', () => { ); const totalRequiredMargin = initialMargin - .add(getRequiredLiquidationRewardMargin(liquidationMargin, MIN_LIQUIDATION_REWARD)) + .add( + getRequiredLiquidationRewardMargin(liquidationMargin, liqGuards, { + costOfTx: wei(0), + margin: wei(100), + }) + ) .add(orderFees); assertBn.equal( @@ -189,7 +201,11 @@ describe('Orders - margin validation', () => { const liqReward = getRequiredLiquidationRewardMargin( ethLiqMargin.add(btcLiqMargin), - MIN_LIQUIDATION_REWARD + liqGuards, + { + costOfTx: wei(0), + margin: wei(100), + } ); const totalRequiredMargin = ethMaintMargin @@ -281,7 +297,11 @@ describe('Orders - margin validation', () => { const liqReward = getRequiredLiquidationRewardMargin( ethLiqMargin.add(btcLiqMargin), - MIN_LIQUIDATION_REWARD + liqGuards, + { + costOfTx: wei(0), + margin: wei(100), + } ); const totalRequiredMargin = ethMaintMargin diff --git a/markets/perps-market/test/integration/bootstrap/bootstrap.ts b/markets/perps-market/test/integration/bootstrap/bootstrap.ts index 366645c5e8..955c094997 100644 --- a/markets/perps-market/test/integration/bootstrap/bootstrap.ts +++ b/markets/perps-market/test/integration/bootstrap/bootstrap.ts @@ -10,6 +10,8 @@ import { wei } from '@synthetixio/wei'; import { ethers } from 'ethers'; import { AccountProxy, FeeCollectorMock, PerpsMarketProxy } from '../../generated/typechain'; import { bootstrapPerpsMarkets, bootstrapTraders, PerpsMarketData } from './'; +import { createKeeperCostNode } from './createKeeperCostNode'; +import { MockGasPriceNode } from '../../../typechain-types/contracts/mocks/MockGasPriceNode'; type Proxies = { ['synthetix.CoreProxy']: CoreProxy; @@ -84,10 +86,13 @@ type BootstrapArgs = { traderAccountIds: Array; liquidationGuards?: { minLiquidationReward: ethers.BigNumber; + minKeeperProfitRatioD18: ethers.BigNumber; maxLiquidationReward: ethers.BigNumber; + maxKeeperScalingRatioD18: ethers.BigNumber; }; maxPositionsPerAccount?: ethers.BigNumber; maxCollateralsPerAccount?: ethers.BigNumber; + skipKeeperCostOracleNode?: boolean; }; export function bootstrapMarkets(data: BootstrapArgs) { @@ -105,6 +110,20 @@ export function bootstrapMarkets(data: BootstrapArgs) { accountIds: data.traderAccountIds, }); + let keeperCostOracleNode: MockGasPriceNode; + + before('create perps gas usage nodes', async () => { + if (data.skipKeeperCostOracleNode) { + return; + } + + const results = await createKeeperCostNode(owner(), systems().OracleManager); + const keeperCostNodeId = results.keeperCostNodeId; + keeperCostOracleNode = results.keeperCostNode; + + await systems().PerpsMarket.connect(owner()).updateKeeperCostNodeId(keeperCostNodeId); + }); + // auto set all synth markets collaterals to max before('set collateral max', async () => { for (const { marketId } of synthMarkets()) { @@ -147,9 +166,11 @@ export function bootstrapMarkets(data: BootstrapArgs) { before('set liquidation guards', async () => { await systems() .PerpsMarket.connect(owner()) - .setLiquidationRewardGuards( + .setKeeperRewardGuards( liquidationGuards.minLiquidationReward, - liquidationGuards.maxLiquidationReward + liquidationGuards.minKeeperProfitRatioD18, + liquidationGuards.maxLiquidationReward, + liquidationGuards.maxKeeperScalingRatioD18 ); }); } @@ -167,6 +188,7 @@ export function bootstrapMarkets(data: BootstrapArgs) { keeper, owner, perpsMarkets, + keeperCostOracleNode: () => keeperCostOracleNode, synthMarkets, superMarketId, poolId, diff --git a/markets/perps-market/test/integration/bootstrap/createKeeperCostNode.ts b/markets/perps-market/test/integration/bootstrap/createKeeperCostNode.ts new file mode 100644 index 0000000000..ec72952eb5 --- /dev/null +++ b/markets/perps-market/test/integration/bootstrap/createKeeperCostNode.ts @@ -0,0 +1,25 @@ +import { ethers } from 'ethers'; +import hre from 'hardhat'; +import { Proxy } from '@synthetixio/oracle-manager/test/generated/typechain'; +import NodeTypes from '@synthetixio/oracle-manager/test/integration/mixins/Node.types'; + +export const createKeeperCostNode = async (owner: ethers.Signer, OracleManager: Proxy) => { + const abi = ethers.utils.defaultAbiCoder; + const factory = await hre.ethers.getContractFactory('MockGasPriceNode'); + const keeperCostNode = await factory.connect(owner).deploy(); + + await keeperCostNode.setCosts(0, 0, 0); + + const params1 = abi.encode(['address'], [keeperCostNode.address]); + await OracleManager.connect(owner).registerNode(NodeTypes.EXTERNAL, params1, []); + const keeperCostNodeId = await OracleManager.connect(owner).getNodeId( + NodeTypes.EXTERNAL, + params1, + [] + ); + + return { + keeperCostNodeId, + keeperCostNode, + }; +}; diff --git a/markets/perps-market/test/integration/helpers/requiredMargins.ts b/markets/perps-market/test/integration/helpers/requiredMargins.ts index 59d7bb76d6..b8be942a97 100644 --- a/markets/perps-market/test/integration/helpers/requiredMargins.ts +++ b/markets/perps-market/test/integration/helpers/requiredMargins.ts @@ -1,4 +1,4 @@ -import Wei from '@synthetixio/wei'; +import { Wei } from '@synthetixio/wei'; type Config = { initialMarginRatio: Wei; @@ -7,14 +7,14 @@ type Config = { liquidationRewardRatio: Wei; }; -export const requiredMargins = (config: Config, size: Wei, price: Wei, skewScale: Wei) => { +export const requiredMargins = (config: Config, size: Wei, fillPrice: Wei, skewScale: Wei) => { const impactOnSkew = size.div(skewScale); const initialMarginRatio = impactOnSkew .mul(config.initialMarginRatio) .add(config.minimumInitialMarginRatio); const maintenanceMarginRatio = initialMarginRatio.mul(config.maintenanceMarginScalar); - const notional = size.mul(price); + const notional = size.mul(fillPrice); return { initialMargin: notional.mul(initialMarginRatio), @@ -23,6 +23,26 @@ export const requiredMargins = (config: Config, size: Wei, price: Wei, skewScale }; }; -export const getRequiredLiquidationRewardMargin = (reward: Wei, min: Wei) => { - return Wei.max(reward, min); +export const getRequiredLiquidationRewardMargin = ( + reward: Wei, + liqGuards: { + minLiquidationReward: Wei; + minKeeperProfitRatioD18: Wei; + maxLiquidationReward: Wei; + maxKeeperScalingRatioD18: Wei; + }, + liqParams: { + costOfTx: Wei; + margin: Wei; + } +) => { + const minCap = Wei.max( + liqGuards.minLiquidationReward, + liqParams.costOfTx.mul(liqGuards.minKeeperProfitRatioD18) + ); + const maxCap = Wei.min( + liqGuards.maxLiquidationReward, + liqParams.margin.mul(liqGuards.maxKeeperScalingRatioD18) + ); + return Wei.min(Wei.max(reward, minCap), maxCap); }; diff --git a/protocol/oracle-manager/contracts/nodes/StalenessCircuitBreakerNode.sol b/protocol/oracle-manager/contracts/nodes/StalenessCircuitBreakerNode.sol index a1229b30db..5ae5387ad4 100644 --- a/protocol/oracle-manager/contracts/nodes/StalenessCircuitBreakerNode.sol +++ b/protocol/oracle-manager/contracts/nodes/StalenessCircuitBreakerNode.sol @@ -5,6 +5,7 @@ import "../storage/NodeDefinition.sol"; import "../storage/NodeOutput.sol"; library StalenessCircuitBreakerNode { + // 0xff3259d0 == StalenessToleranceExceeded() error StalenessToleranceExceeded(); function process(