diff --git a/auxiliary/PerpsRewardDistributor/.gitignore b/auxiliary/PerpsRewardDistributor/.gitignore new file mode 100644 index 0000000000..fb680d769d --- /dev/null +++ b/auxiliary/PerpsRewardDistributor/.gitignore @@ -0,0 +1,7 @@ +cannon +contracts/generated +contracts/modules/test +contracts/Router.sol +deployments/hardhat +deployments/local +typechain-types diff --git a/auxiliary/PerpsRewardDistributor/LICENSE b/auxiliary/PerpsRewardDistributor/LICENSE new file mode 100644 index 0000000000..d6c840a550 --- /dev/null +++ b/auxiliary/PerpsRewardDistributor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Synthetix + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/auxiliary/PerpsRewardDistributor/cannonfile.test.toml b/auxiliary/PerpsRewardDistributor/cannonfile.test.toml new file mode 100644 index 0000000000..7a0acdfaad --- /dev/null +++ b/auxiliary/PerpsRewardDistributor/cannonfile.test.toml @@ -0,0 +1,2 @@ +version = "<%= package.version %>-testable" +include = ["cannonfile.toml"] diff --git a/auxiliary/PerpsRewardDistributor/cannonfile.toml b/auxiliary/PerpsRewardDistributor/cannonfile.toml new file mode 100644 index 0000000000..ffe62fb763 --- /dev/null +++ b/auxiliary/PerpsRewardDistributor/cannonfile.toml @@ -0,0 +1,7 @@ +name = "perps-reward-distributor" +version = "<%= package.version %>" +description = "Perps Reward Distributor implementation" + +[contract.PerpsRewardDistributor] +artifact = "contracts/PerpsRewardDistributor.sol:PerpsRewardDistributor" +create2 = true diff --git a/auxiliary/PerpsRewardDistributor/contracts/PerpsRewardDistributor.sol b/auxiliary/PerpsRewardDistributor/contracts/PerpsRewardDistributor.sol new file mode 100644 index 0000000000..88959c74d6 --- /dev/null +++ b/auxiliary/PerpsRewardDistributor/contracts/PerpsRewardDistributor.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {IPerpRewardDistributor} from "./interfaces/IPerpsRewardDistributor.sol"; +import {IRewardDistributor} from "@synthetixio/main/contracts/interfaces/external/IRewardDistributor.sol"; +import {IRewardsManagerModule} from "@synthetixio/main/contracts/interfaces/IRewardsManagerModule.sol"; +import {IERC165} from "@synthetixio/core-contracts/contracts/interfaces/IERC165.sol"; +import {IERC20} from "@synthetixio/core-contracts/contracts/interfaces/IERC20.sol"; + +contract PerpsRewardDistributor is IPerpRewardDistributor { + string private constant _version = "1.0.0"; + + bool private _initialized; + + address private _rewardManager; // synthetix + address private _token; + string private _name; + + uint128 private _poolId; + address private _perpMarket; + bool public shouldFailPayout; + + error AlreadyInitialized(); + error OnlyPerpMarket(); + error OnlyRewardManager(); + + constructor() { + // Should be initialized by the factory. This prevents usage of the instance without proper initialization + _initialized = true; + } + + /** + * Initialize the contract + * @notice This function should be called only once at clone node creation. + * @notice it can be called only once by anyone (not checking sender) + * @notice but will be created by the factory and initialized immediatly after creation, and consumed only if initialization succeeds + * @param rewardManager address of the reward manager (Synthetix core proxy) + * @param perpMarket address of the perp market + * @param poolId poolId of the pool + * @param token_ token address of the collateral + * @param name_ rewards distribution name + */ + function initialize( + address rewardManager, + address perpMarket, + uint128 poolId, + address token_, + string memory name_ + ) external { + if (_initialized) { + revert AlreadyInitialized(); + } + _rewardManager = rewardManager; + _perpMarket = perpMarket; + _poolId = poolId; + _token = token_; + _name = name_; + } + + function distributeRewards(address collateralType, uint256 amount) external { + onlyPerpMarket(); + IRewardsManagerModule(_rewardManager).distributeRewards( + _poolId, + collateralType, + amount, + uint64(block.timestamp), // solhint-disable-line numcast/safe-cast + 0 + ); + } + + function setShouldFailPayout(bool _shouldFailedPayout) external { + onlyPerpMarket(); // perp market is in fact the owner + shouldFailPayout = _shouldFailedPayout; + } + + function payout( + uint128 /* accountId */, + uint128 /* poolId */, + address /* collateralType */, + address sender, + uint256 amount + ) external override returns (bool) { + onlyRewardManager(); + IERC20(_token).transfer(sender, amount); + return !shouldFailPayout; + } + + function onPositionUpdated( + uint128 /* accountId */, + uint128 /* poolId */, + address /* collateralType */, + uint256 /* newShares */ + ) external pure override {} + + function token() external view override returns (address) { + return _token; + } + + function name() external view override returns (string memory) { + return _name; + } + + function getPoolId() external view override returns (uint128) { + return _poolId; + } + + function version() external pure virtual override returns (string memory) { + return _version; + } + + /** + * @inheritdoc IERC165 + */ + function supportsInterface( + bytes4 interfaceId + ) public view virtual override(IERC165) returns (bool) { + return + interfaceId == type(IRewardDistributor).interfaceId || + interfaceId == type(IPerpRewardDistributor).interfaceId || + interfaceId == this.supportsInterface.selector; + } + + function onlyPerpMarket() internal view { + // solhint-disable-next-line meta-transactions/no-msg-sender + if (msg.sender != _perpMarket) { + revert OnlyPerpMarket(); + } + } + + function onlyRewardManager() internal view { + // solhint-disable-next-line meta-transactions/no-msg-sender + if (msg.sender != _rewardManager) { + revert OnlyRewardManager(); + } + } +} diff --git a/auxiliary/PerpsRewardDistributor/contracts/interfaces/IPerpsRewardDistributor.sol b/auxiliary/PerpsRewardDistributor/contracts/interfaces/IPerpsRewardDistributor.sol new file mode 100644 index 0000000000..4825ab8581 --- /dev/null +++ b/auxiliary/PerpsRewardDistributor/contracts/interfaces/IPerpsRewardDistributor.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {IRewardDistributor} from "@synthetixio/main/contracts/interfaces/external/IRewardDistributor.sol"; + +interface IPerpRewardDistributor is IRewardDistributor { + /** + * @notice Returns the version of the PerpRewardDistributor. + * @return The Semver contract version as a string. + */ + function version() external view returns (string memory); + + /** + * @notice Returns the id of the pool this was registered with. + */ + function getPoolId() external view returns (uint128); + + /** + * @notice Initializes the PerpRewardDistributor with references, name, token to distribute etc. + */ + function initialize( + address rewardManager, + address perpMarket, + uint128 poolId_, + address token_, + string memory name_ + ) external; + + /** + * @notice Set true to disable `payout` to revert on claim or false to allow. + */ + function setShouldFailPayout(bool _shouldFailedPayout) external; + + /** + * @notice Creates a new distribution entry for LPs of `collateralType` to `amount` of tokens. + */ + function distributeRewards(address collateralType, uint256 amount) external; +} diff --git a/auxiliary/PerpsRewardDistributor/hardhat.config.ts b/auxiliary/PerpsRewardDistributor/hardhat.config.ts new file mode 100644 index 0000000000..5794c123f9 --- /dev/null +++ b/auxiliary/PerpsRewardDistributor/hardhat.config.ts @@ -0,0 +1,23 @@ +import commonConfig from '@synthetixio/common-config/hardhat.config'; + +import 'solidity-docgen'; +import { templates } from '@synthetixio/docgen'; + +const config = { + ...commonConfig, + docgen: { + exclude: [ + './interfaces/external', + './modules', + './mixins', + './mocks', + './utils', + './storage', + './Proxy.sol', + './Router.sol', + ], + templates, + }, +}; + +export default config; diff --git a/auxiliary/PerpsRewardDistributor/package.json b/auxiliary/PerpsRewardDistributor/package.json new file mode 100644 index 0000000000..c181f9b5d0 --- /dev/null +++ b/auxiliary/PerpsRewardDistributor/package.json @@ -0,0 +1,34 @@ +{ + "name": "@synthetixio/perps-reward-distributor", + "version": "3.3.17", + "description": "Perps Reward Distributor implementation", + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "CANNON_REGISTRY_PRIORITY=local hardhat test", + "coverage": "hardhat coverage --network hardhat", + "clean": "hardhat clean", + "build": "yarn build:contracts", + "build:contracts": "hardhat cannon:build", + "build-testable": "CANNON_REGISTRY_PRIORITY=local hardhat cannon:build cannonfile.test.toml", + "compile-contracts": "hardhat compile", + "size-contracts": "hardhat compile && hardhat size-contracts", + "publish-contracts": "cannon publish perps-reward-distributor:$(node -p 'require(`./package.json`).version') --chain-id 13370 --quiet --tags $(node -p '/^\\d+\\.\\d+\\.\\d+$/.test(require(`./package.json`).version) ? `latest` : `dev`')", + "postpack": "yarn build && yarn publish-contracts", + "docgen": "hardhat docgen" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@synthetixio/common-config": "workspace:*", + "@synthetixio/core-contracts": "workspace:*", + "@synthetixio/core-modules": "workspace:*", + "@synthetixio/docgen": "workspace:*", + "hardhat": "^2.19.5", + "solidity-docgen": "^0.6.0-beta.36", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/auxiliary/PerpsRewardDistributor/tsconfig.json b/auxiliary/PerpsRewardDistributor/tsconfig.json new file mode 100644 index 0000000000..35eac7af25 --- /dev/null +++ b/auxiliary/PerpsRewardDistributor/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../utils/common-config/tsconfig.json", + "compilerOptions": { + "noEmit": true + } +} diff --git a/markets/perps-market/cannonfile.test.toml b/markets/perps-market/cannonfile.test.toml index 367b401282..eced8d54c8 100644 --- a/markets/perps-market/cannonfile.test.toml +++ b/markets/perps-market/cannonfile.test.toml @@ -43,6 +43,9 @@ artifact = "PerpsMarketModule" [contract.LiquidationModule] artifact = "LiquidationModule" +[contract.CollateralConfigurationModule] +artifact = "CollateralConfigurationModule" + [contract.MarketConfigurationModule] artifact = "MarketConfigurationModule" @@ -76,6 +79,7 @@ contracts = [ "FeatureFlagModule", "LiquidationModule", "MarketConfigurationModule", + "CollateralConfigurationModule", "GlobalPerpsMarketModule", ] @@ -138,3 +142,6 @@ artifact = "contracts/mocks/MockPythERC7412Wrapper.sol:MockPythERC7412Wrapper" [contract.FeeCollectorMock] artifact = "contracts/mocks/FeeCollectorMock.sol:FeeCollectorMock" + +[contract.MockPerpsRewardDistributor] +artifact = "contracts/mocks/MockPerpsRewardDistributor.sol:MockPerpsRewardDistributor" diff --git a/markets/perps-market/cannonfile.toml b/markets/perps-market/cannonfile.toml index afc4a83f84..2e63ef4747 100644 --- a/markets/perps-market/cannonfile.toml +++ b/markets/perps-market/cannonfile.toml @@ -54,6 +54,9 @@ artifact = "PerpsMarketModule" [contract.LiquidationModule] artifact = "LiquidationModule" +[contract.CollateralConfigurationModule] +artifact = "CollateralConfigurationModule" + [contract.MarketConfigurationModule] artifact = "MarketConfigurationModule" @@ -87,6 +90,7 @@ contracts = [ "FeatureFlagModule", "LiquidationModule", "MarketConfigurationModule", + "CollateralConfigurationModule", "GlobalPerpsMarketModule", ] diff --git a/markets/perps-market/contracts/interfaces/ICollateralConfigurationModule.sol b/markets/perps-market/contracts/interfaces/ICollateralConfigurationModule.sol new file mode 100644 index 0000000000..2663c2bc58 --- /dev/null +++ b/markets/perps-market/contracts/interfaces/ICollateralConfigurationModule.sol @@ -0,0 +1,138 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +/** + * @title Collateral configuration module. + */ +interface ICollateralConfigurationModule { + /** + * @notice Gets fired when max collateral amount for synth for all the markets is set by owner. + * @param synthMarketId Synth market id, 0 for snxUSD. + * @param maxCollateralAmount max amount that was set for the synth + * @param upperLimitDiscount upper limit discount that was set for the synth + * @param lowerLimitDiscount lower limit discount that was set for the synth + * @param discountScalar discount scalar that was set for the synth + */ + event CollateralConfigurationSet( + uint128 indexed synthMarketId, + uint256 maxCollateralAmount, + uint256 upperLimitDiscount, + uint256 lowerLimitDiscount, + uint256 discountScalar + ); + + /** + * @notice Gets fired when the collateral liquidation reward ratio is updated. + * @param collateralLiquidateRewardRatioD18 new collateral liquidation reward ratio. + */ + event CollateralLiquidateRewardRatioSet(uint128 collateralLiquidateRewardRatioD18); + + /** + * @notice Gets fired when the reward distribitor implementation is set. This is used as base to be cloned to distribute rewards to the liquidator. + * @param rewardDistributorImplementation new reward distributor implementation. + */ + event RewardDistributorImplementationSet(address rewardDistributorImplementation); + + /** + * @notice Gets fired when a new reward distributor is registered. + * @param distributor the new distributor address. + */ + event RewardDistributorRegistered(address distributor); + + /** + * @notice Sets the max collateral amount for a specific synth market. + * @param synthMarketId Synth market id, 0 for snxUSD. + * @param maxCollateralAmount Max collateral amount to set for the synth market id. + * @param upperLimitDiscount Collateral value is discounted and capped at this value. In % units. + * @param lowerLimitDiscount Collateral value is discounted and at minimum, this value. In % units. + * @param discountScalar This value is used to scale the impactOnSkew of the collateral. + */ + function setCollateralConfiguration( + uint128 synthMarketId, + uint256 maxCollateralAmount, + uint256 upperLimitDiscount, + uint256 lowerLimitDiscount, + uint256 discountScalar + ) external; + + /** + * @notice Gets the max collateral amount for a specific synth market. + * @param synthMarketId Synth market id, 0 for snxUSD. + * @return maxCollateralAmount max collateral amount of the specified synth market id + */ + function getCollateralConfiguration( + uint128 synthMarketId + ) + external + view + returns ( + uint256 maxCollateralAmount, + uint256 upperLimitDiscount, + uint256 lowerLimitDiscount, + uint256 discountScalar + ); + + /** + * @notice Sets the collateral liquidation reward ratio. + * @param collateralLiquidateRewardRatioD18 the new collateral liquidation reward ratio. + */ + function setCollateralLiquidateRewardRatio(uint128 collateralLiquidateRewardRatioD18) external; + + /** + * @notice Gets the collateral liquidation reward ratio. + */ + function getCollateralLiquidateRewardRatio() + external + view + returns (uint128 collateralLiquidateRewardRatioD18); + + /** + * @notice Sets the reward distributor implementation. This is used as base to be cloned to distribute rewards to the liquidator. + * @param rewardDistributorImplementation the new reward distributor implementation. + */ + function setRewardDistributorImplementation(address rewardDistributorImplementation) external; + + /** + * @notice Gets the reward distributor implementation. + */ + function getRewardDistributorImplementation() + external + view + returns (address rewardDistributorImplementation); + + /** + * @notice Registers a new reward distributor. + * @param poolId the pool id. + * @param token the collateral token address. + * @param previousDistributor the previous distributor address if there was one. Set it to address(0) if first distributor, or need to create a new clone. + * @param name the name of the distributor. + * @param collateralId the collateral id. + * @param poolDelegatedCollateralTypes the pool delegated collateral types. + * @return distributor the new distributor address. + */ + function registerDistributor( + uint128 poolId, + address token, + address previousDistributor, + string calldata name, + uint128 collateralId, + address[] calldata poolDelegatedCollateralTypes + ) external returns (address distributor); + + /** + * @notice Checks if a distributor is registered. + * @param distributor the distributor address. + * @return isRegistered true if the distributor is registered. + */ + function isRegistered(address distributor) external view returns (bool); + + /** + * @notice Gets the registered distributor for a collateral id. + * @param collateralId the collateral id. + * @return distributor the distributor address. + * @return poolDelegatedCollateralTypes the pool delegated collateral types. + */ + function getRegisteredDistributor( + uint128 collateralId + ) external view returns (address distributor, address[] memory poolDelegatedCollateralTypes); +} diff --git a/markets/perps-market/contracts/interfaces/IDistributorErrors.sol b/markets/perps-market/contracts/interfaces/IDistributorErrors.sol new file mode 100644 index 0000000000..df7f2dd9c8 --- /dev/null +++ b/markets/perps-market/contracts/interfaces/IDistributorErrors.sol @@ -0,0 +1,17 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +/** + * @title Distributor errors used on several places in the system. + */ +interface IDistributorErrors { + /** + * @notice Thrown when attempting to use a wrong distributor + */ + error InvalidDistributor(uint128 id, address distributor); + + /** + * @notice Thrown when attempting to use a wrong contract as distributor + */ + error InvalidDistributorContract(address distributor); +} diff --git a/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol b/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol index 4f534edf30..f65d37b28f 100644 --- a/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol +++ b/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol @@ -12,13 +12,6 @@ interface IGlobalPerpsMarketModule { */ event InterestRateUpdated(uint128 indexed superMarketId, uint128 interestRate); - /** - * @notice Gets fired when max collateral amount for synth for all the markets is set by owner. - * @param synthMarketId Synth market id, 0 for snxUSD. - * @param maxCollateralAmount max amount that was set for the synth - */ - event CollateralConfigurationSet(uint128 indexed synthMarketId, uint256 maxCollateralAmount); - /** * @notice Gets fired when the synth deduction priority is updated by owner. * @param newSynthDeductionPriority new synth id priority order for deductions. @@ -95,25 +88,6 @@ interface IGlobalPerpsMarketModule { uint128 highUtilizationInterestRateGradient ); - /** - * @notice Sets the max collateral amount for a specific synth market. - * @param synthMarketId Synth market id, 0 for snxUSD. - * @param maxCollateralAmount Max collateral amount to set for the synth market id. - */ - function setCollateralConfiguration( - uint128 synthMarketId, - uint256 maxCollateralAmount - ) external; - - /** - * @notice Gets the max collateral amount for a specific synth market. - * @param synthMarketId Synth market id, 0 for snxUSD. - * @return maxCollateralAmount max collateral amount of the specified synth market id - */ - function getCollateralConfiguration( - uint128 synthMarketId - ) external view returns (uint256 maxCollateralAmount); - /** * @notice Gets the list of supported collaterals. * @return supportedCollaterals list of supported collateral ids. By supported collateral we mean a collateral which max is greater than zero diff --git a/markets/perps-market/contracts/interfaces/ILiquidationModule.sol b/markets/perps-market/contracts/interfaces/ILiquidationModule.sol index be66973ae5..4a77b263fd 100644 --- a/markets/perps-market/contracts/interfaces/ILiquidationModule.sol +++ b/markets/perps-market/contracts/interfaces/ILiquidationModule.sol @@ -10,6 +10,16 @@ interface ILiquidationModule { */ error NotEligibleForLiquidation(uint128 accountId); + /** + * @notice Thrown when attempting to liquidate an account's margin when not elegible for liquidation + */ + error NotEligibleForMarginLiquidation(uint128 accountId); + + /** + * @notice Thrown when attempting to liquidate an account's margin when it has open positions (should use normal liquidate) + */ + error AccountHasOpenPositions(uint128 accountId); + /** * @notice Gets fired when an account position is liquidated . * @param marketId Id of the position's market. @@ -61,6 +71,14 @@ interface ILiquidationModule { */ function liquidate(uint128 accountId) external returns (uint256 liquidationReward); + /** + * @notice Liquidates an account's margin when no other positions exist. + * @dev if available margin is negative and no positions exist, then account margin can be liquidated by calling this function + * @param accountId Id of the account to liquidate. + * @return liquidationReward total reward sent to liquidator. + */ + function liquidateMarginOnly(uint128 accountId) external returns (uint256 liquidationReward); + /** * @notice Liquidates up to maxNumberOfAccounts flagged accounts. * @param maxNumberOfAccounts max number of accounts to liquidate. @@ -92,6 +110,12 @@ interface ILiquidationModule { */ function canLiquidate(uint128 accountId) external view returns (bool isEligible); + /** + * @notice Returns if an account's margin is eligible for liquidation. + * @return isEligible + */ + function canLiquidateMarginOnly(uint128 accountId) external view returns (bool isEligible); + /** * @notice Current liquidation capacity for the market * @return capacity market can liquidate up to this # diff --git a/markets/perps-market/contracts/interfaces/IPerpsAccountModule.sol b/markets/perps-market/contracts/interfaces/IPerpsAccountModule.sol index 8c26667abd..ab844743c3 100644 --- a/markets/perps-market/contracts/interfaces/IPerpsAccountModule.sol +++ b/markets/perps-market/contracts/interfaces/IPerpsAccountModule.sol @@ -19,6 +19,8 @@ interface IPerpsAccountModule { address indexed sender ); + event DebtPaid(uint128 indexed accountId, uint256 amount, address indexed sender); + /** * @notice Gets thrown when the amount delta is zero. */ @@ -60,9 +62,9 @@ interface IPerpsAccountModule { function getAccountOpenPositions(uint128 accountId) external view returns (uint256[] memory); /** - * @notice Gets the account's total collateral value. + * @notice Gets the account's total collateral value without the discount applied. * @param accountId Id of the account. - * @return collateralValue total collateral value of the account. USD denominated. + * @return collateralValue total collateral value of the account without discount. USD denominated. */ function totalCollateralValue(uint128 accountId) external view returns (uint256); @@ -135,4 +137,11 @@ interface IPerpsAccountModule { uint256 requiredMaintenanceMargin, uint256 maxLiquidationReward ); + + /** + * @notice Allows anyone to pay an account's debt + * @param accountId Id of the account. + * @param amount debt amount to pay off + */ + function payDebt(uint128 accountId, uint256 amount) external; } diff --git a/markets/perps-market/contracts/interfaces/external/ISpotMarketSystem.sol b/markets/perps-market/contracts/interfaces/external/ISpotMarketSystem.sol index fe6a37b3dd..aaf123c3a2 100644 --- a/markets/perps-market/contracts/interfaces/external/ISpotMarketSystem.sol +++ b/markets/perps-market/contracts/interfaces/external/ISpotMarketSystem.sol @@ -3,6 +3,11 @@ pragma solidity >=0.8.11 <0.9.0; import {IAtomicOrderModule} from "@synthetixio/spot-market/contracts/interfaces/IAtomicOrderModule.sol"; import {ISpotMarketFactoryModule} from "@synthetixio/spot-market/contracts/interfaces/ISpotMarketFactoryModule.sol"; +import {IMarketConfigurationModule} from "@synthetixio/spot-market/contracts/interfaces/IMarketConfigurationModule.sol"; // solhint-disable-next-line no-empty-blocks -interface ISpotMarketSystem is IAtomicOrderModule, ISpotMarketFactoryModule {} +interface ISpotMarketSystem is + IAtomicOrderModule, + ISpotMarketFactoryModule, + IMarketConfigurationModule +{} diff --git a/markets/perps-market/contracts/interfaces/external/ISynthetixSystem.sol b/markets/perps-market/contracts/interfaces/external/ISynthetixSystem.sol index c4c73b7261..65dcf4db72 100644 --- a/markets/perps-market/contracts/interfaces/external/ISynthetixSystem.sol +++ b/markets/perps-market/contracts/interfaces/external/ISynthetixSystem.sol @@ -6,6 +6,7 @@ import {IMarketManagerModule} from "@synthetixio/main/contracts/interfaces/IMark import {IMarketCollateralModule} from "@synthetixio/main/contracts/interfaces/IMarketCollateralModule.sol"; import {IUtilsModule} from "@synthetixio/main/contracts/interfaces/IUtilsModule.sol"; import {ICollateralConfigurationModule} from "@synthetixio/main/contracts/interfaces/ICollateralConfigurationModule.sol"; +import {IVaultModule} from "@synthetixio/main/contracts/interfaces/IVaultModule.sol"; // solhint-disable-next-line no-empty-blocks interface ISynthetixSystem is @@ -13,5 +14,6 @@ interface ISynthetixSystem is IMarketCollateralModule, IMarketManagerModule, IUtilsModule, - ICollateralConfigurationModule + ICollateralConfigurationModule, + IVaultModule {} diff --git a/markets/perps-market/contracts/mocks/MockPerpsRewardDistributor.sol b/markets/perps-market/contracts/mocks/MockPerpsRewardDistributor.sol new file mode 100644 index 0000000000..c1dc7efc55 --- /dev/null +++ b/markets/perps-market/contracts/mocks/MockPerpsRewardDistributor.sol @@ -0,0 +1,10 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {PerpsRewardDistributor as BasePerpsRewardDistributor} from "@synthetixio/perps-reward-distributor/contracts/PerpsRewardDistributor.sol"; + +/** + * @title Mocked PerpsRewardDistributor. + * See perps-reward-distributor/../PerpsRewardDistributor + */ +contract MockPerpsRewardDistributor is BasePerpsRewardDistributor {} diff --git a/markets/perps-market/contracts/mocks/MockPerpsRewardDistributorV2.sol b/markets/perps-market/contracts/mocks/MockPerpsRewardDistributorV2.sol new file mode 100644 index 0000000000..7d65999557 --- /dev/null +++ b/markets/perps-market/contracts/mocks/MockPerpsRewardDistributorV2.sol @@ -0,0 +1,16 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {PerpsRewardDistributor as BasePerpsRewardDistributor} from "@synthetixio/perps-reward-distributor/contracts/PerpsRewardDistributor.sol"; + +/** + * @title Mocked PerpsRewardDistributor. + * See perps-reward-distributor/../PerpsRewardDistributor + */ +contract MockPerpsRewardDistributorV2 is BasePerpsRewardDistributor { + string private constant _version = "2.0.0"; + + function version() external pure virtual override returns (string memory) { + return _version; + } +} diff --git a/markets/perps-market/contracts/modules/AsyncOrderSettlementPythModule.sol b/markets/perps-market/contracts/modules/AsyncOrderSettlementPythModule.sol index c216c2698e..5b4c908669 100644 --- a/markets/perps-market/contracts/modules/AsyncOrderSettlementPythModule.sol +++ b/markets/perps-market/contracts/modules/AsyncOrderSettlementPythModule.sol @@ -88,13 +88,7 @@ contract AsyncOrderSettlementPythModule is (runtime.pnl, , runtime.chargedInterest, runtime.accruedFunding, , ) = oldPosition.getPnl( runtime.fillPrice ); - runtime.pnlUint = MathUtil.abs(runtime.pnl); - - if (runtime.pnl > 0) { - perpsAccount.updateCollateralAmount(SNX_USD_MARKET_ID, runtime.pnl); - } else if (runtime.pnl < 0) { - runtime.amountToDeduct += runtime.pnlUint; - } + perpsAccount.applyPnl(runtime.pnl); // after pnl is realized, update position runtime.updateData = PerpsMarket.loadValid(runtime.marketId).updatePositionData( diff --git a/markets/perps-market/contracts/modules/CollateralConfigurationModule.sol b/markets/perps-market/contracts/modules/CollateralConfigurationModule.sol new file mode 100644 index 0000000000..a9e382294c --- /dev/null +++ b/markets/perps-market/contracts/modules/CollateralConfigurationModule.sol @@ -0,0 +1,218 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {ICollateralConfigurationModule} from "../interfaces/ICollateralConfigurationModule.sol"; +import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; +import {GlobalPerpsMarketConfiguration} from "../storage/GlobalPerpsMarketConfiguration.sol"; +import {GlobalPerpsMarket} from "../storage/GlobalPerpsMarket.sol"; +import {LiquidationAssetManager} from "../storage/LiquidationAssetManager.sol"; +import {OwnableStorage} from "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol"; +import {ParameterError} from "@synthetixio/core-contracts/contracts/errors/ParameterError.sol"; +import {AddressError} from "@synthetixio/core-contracts/contracts/errors/AddressError.sol"; +import {AddressUtil} from "@synthetixio/core-contracts/contracts/utils/AddressUtil.sol"; +import {CollateralConfiguration} from "../storage/CollateralConfiguration.sol"; +import {IPerpRewardDistributor} from "@synthetixio/perps-reward-distributor/contracts/interfaces/IPerpsRewardDistributor.sol"; +import {PerpsMarketFactory} from "../storage/PerpsMarketFactory.sol"; +import {Clones} from "../utils/Clones.sol"; +import {ERC165Helper} from "@synthetixio/core-contracts/contracts/utils/ERC165Helper.sol"; +import {IDistributorErrors} from "../interfaces/IDistributorErrors.sol"; + +/** + * @title Module for collateral configuration setters/getters. + * @dev See ICollateralConfigurationModule. + */ +contract CollateralConfigurationModule is ICollateralConfigurationModule { + using GlobalPerpsMarketConfiguration for GlobalPerpsMarketConfiguration.Data; + using GlobalPerpsMarket for GlobalPerpsMarket.Data; + using SetUtil for SetUtil.UintSet; + using LiquidationAssetManager for LiquidationAssetManager.Data; + using Clones for address; + using CollateralConfiguration for CollateralConfiguration.Data; + + /** + * @inheritdoc ICollateralConfigurationModule + */ + function setCollateralConfiguration( + uint128 synthMarketId, + uint256 maxCollateralAmount, + uint256 upperLimitDiscount, + uint256 lowerLimitDiscount, + uint256 discountScalar + ) external override { + OwnableStorage.onlyOwner(); + GlobalPerpsMarketConfiguration.load().updateCollateralMax( + synthMarketId, + maxCollateralAmount + ); + + CollateralConfiguration.load(synthMarketId).setDiscounts( + upperLimitDiscount, + lowerLimitDiscount, + discountScalar + ); + + emit CollateralConfigurationSet( + synthMarketId, + maxCollateralAmount, + upperLimitDiscount, + lowerLimitDiscount, + discountScalar + ); + } + + /** + * @inheritdoc ICollateralConfigurationModule + */ + function getCollateralConfiguration( + uint128 synthMarketId + ) + external + view + override + returns ( + uint256 maxCollateralAmount, + uint256 upperLimitDiscount, + uint256 lowerLimitDiscount, + uint256 discountScalar + ) + { + return CollateralConfiguration.load(synthMarketId).getConfig(); + } + + /** + * @inheritdoc ICollateralConfigurationModule + */ + function setCollateralLiquidateRewardRatio( + uint128 collateralLiquidateRewardRatioD18 + ) external override { + OwnableStorage.onlyOwner(); + GlobalPerpsMarketConfiguration + .load() + .collateralLiquidateRewardRatioD18 = collateralLiquidateRewardRatioD18; + + emit CollateralLiquidateRewardRatioSet(collateralLiquidateRewardRatioD18); + } + + /** + * @inheritdoc ICollateralConfigurationModule + */ + function getCollateralLiquidateRewardRatio() + external + view + override + returns (uint128 collateralLiquidateRewardRatioD18) + { + return GlobalPerpsMarketConfiguration.load().collateralLiquidateRewardRatioD18; + } + + /** + * @inheritdoc ICollateralConfigurationModule + */ + function setRewardDistributorImplementation( + address rewardDistributorImplementation + ) external override { + if (rewardDistributorImplementation == address(0)) { + revert AddressError.ZeroAddress(); + } + + if (!AddressUtil.isContract(rewardDistributorImplementation)) { + revert AddressError.NotAContract(rewardDistributorImplementation); + } + + if ( + !ERC165Helper.safeSupportsInterface( + rewardDistributorImplementation, + type(IPerpRewardDistributor).interfaceId + ) + ) { + revert IDistributorErrors.InvalidDistributorContract(rewardDistributorImplementation); + } + + OwnableStorage.onlyOwner(); + GlobalPerpsMarketConfiguration + .load() + .rewardDistributorImplementation = rewardDistributorImplementation; + + emit RewardDistributorImplementationSet(rewardDistributorImplementation); + } + + /** + * @inheritdoc ICollateralConfigurationModule + */ + function getRewardDistributorImplementation() + external + view + override + returns (address rewardDistributorImplementation) + { + return GlobalPerpsMarketConfiguration.load().rewardDistributorImplementation; + } + + /** + * @inheritdoc ICollateralConfigurationModule + */ + function registerDistributor( + uint128 poolId, + address token, + address previousDistributor, + string calldata name, + uint128 collateralId, + address[] calldata poolDelegatedCollateralTypes + ) external override returns (address) { + OwnableStorage.onlyOwner(); + // Using loadValid here to ensure we are tying the distributor to a valid collateral. + LiquidationAssetManager.Data storage lam = CollateralConfiguration + .loadValid(collateralId) + .lam; + + lam.id = collateralId; + + // validate and set poolDelegatedCollateralTypes + lam.setValidPoolDelegatedCollateralTypes(poolDelegatedCollateralTypes); + + // reuse current or clone distributor + lam.setValidDistributor(previousDistributor); + + // A reward token to distribute must exist. + if (token == address(0)) { + revert AddressError.ZeroAddress(); + } + + IPerpRewardDistributor distributor = IPerpRewardDistributor(lam.distributor); + distributor.initialize( + address(PerpsMarketFactory.load().synthetix), + address(this), + poolId, + token, + name + ); + + emit RewardDistributorRegistered(lam.distributor); + return lam.distributor; + } + + /** + * @inheritdoc ICollateralConfigurationModule + */ + function isRegistered(address distributor) external view override returns (bool) { + return distributor != address(0) && IPerpRewardDistributor(distributor).getPoolId() != 0; + } + + /** + * @inheritdoc ICollateralConfigurationModule + */ + function getRegisteredDistributor( + uint128 collateralId + ) + external + view + override + returns (address distributor, address[] memory poolDelegatedCollateralTypes) + { + LiquidationAssetManager.Data storage lam = CollateralConfiguration.loadValidLam( + collateralId + ); + distributor = lam.distributor; + poolDelegatedCollateralTypes = lam.poolDelegatedCollateralTypes; + } +} diff --git a/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol b/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol index d4d0bad8b9..c1471cba82 100644 --- a/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol +++ b/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol @@ -25,29 +25,6 @@ contract GlobalPerpsMarketModule is IGlobalPerpsMarketModule { using SetUtil for SetUtil.UintSet; using KeeperCosts for KeeperCosts.Data; - /** - * @inheritdoc IGlobalPerpsMarketModule - */ - function setCollateralConfiguration( - uint128 synthMarketId, - uint256 maxCollateralAmount - ) external override { - OwnableStorage.onlyOwner(); - GlobalPerpsMarketConfiguration.load().updateCollateral(synthMarketId, maxCollateralAmount); - - emit CollateralConfigurationSet(synthMarketId, maxCollateralAmount); - } - - /** - * @inheritdoc IGlobalPerpsMarketModule - */ - function getCollateralConfiguration( - uint128 synthMarketId - ) external view override returns (uint256 maxCollateralAmount) { - GlobalPerpsMarketConfiguration.Data storage store = GlobalPerpsMarketConfiguration.load(); - maxCollateralAmount = store.maxCollateralAmounts[synthMarketId]; - } - /** * @inheritdoc IGlobalPerpsMarketModule */ diff --git a/markets/perps-market/contracts/modules/LiquidationModule.sol b/markets/perps-market/contracts/modules/LiquidationModule.sol index 342314c4e5..3b8b5d5bba 100644 --- a/markets/perps-market/contracts/modules/LiquidationModule.sol +++ b/markets/perps-market/contracts/modules/LiquidationModule.sol @@ -55,7 +55,7 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { ) = account.isEligibleForLiquidation(PerpsPrice.Tolerance.STRICT); if (isEligible) { - (uint256 flagCost, uint256 marginCollected) = account.flagForLiquidation(); + (uint256 flagCost, uint256 seizedMarginValue) = account.flagForLiquidation(); emit AccountFlaggedForLiquidation( accountId, @@ -65,7 +65,7 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { flagCost ); - liquidationReward = _liquidateAccount(account, flagCost, marginCollected, true); + liquidationReward = _liquidateAccount(account, flagCost, seizedMarginValue, true); } else { revert NotEligibleForLiquidation(accountId); } @@ -74,6 +74,34 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { } } + function liquidateMarginOnly( + uint128 accountId + ) external override returns (uint256 liquidationReward) { + FeatureFlag.ensureAccessToFeature(Flags.PERPS_SYSTEM); + + PerpsAccount.Data storage account = PerpsAccount.load(accountId); + + if (account.hasOpenPositions()) { + revert AccountHasOpenPositions(accountId); + } + + (bool isEligible, ) = account.isEligibleForMarginLiquidation(PerpsPrice.Tolerance.STRICT); + if (isEligible) { + // margin is sent to liquidation rewards distributor in getMarginLiquidationCostAndSeizeMargin + (uint256 marginLiquidateCost, uint256 seizedMarginValue) = account + .getMarginLiquidationCostAndSeizeMargin(); + // keeper is rewarded in _liquidateAccount + liquidationReward = _liquidateAccount( + account, + marginLiquidateCost, + seizedMarginValue, + true + ); + } else { + revert NotEligibleForMarginLiquidation(accountId); + } + } + /** * @inheritdoc ILiquidationModule */ @@ -136,6 +164,14 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { ); } + function canLiquidateMarginOnly( + uint128 accountId + ) external view override returns (bool isEligible) { + (isEligible, ) = PerpsAccount.load(accountId).isEligibleForMarginLiquidation( + PerpsPrice.Tolerance.DEFAULT + ); + } + /** * @inheritdoc ILiquidationModule */ @@ -236,6 +272,21 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { ); } + if ( + ERC2771Context._msgSender() != + PerpsMarketConfiguration.load(runtime.positionMarketId).endorsedLiquidator + ) { + // Use max of collateral or positions flag rewards + uint256 totalCollateralLiquidateRewards = GlobalPerpsMarketConfiguration + .load() + .calculateCollateralLiquidateReward(totalCollateralValue); + + runtime.totalFlaggingRewards = totalCollateralLiquidateRewards > + runtime.totalFlaggingRewards + ? totalCollateralLiquidateRewards + : runtime.totalFlaggingRewards; + } + runtime.totalLiquidationCost = KeeperCosts.load().getLiquidateKeeperCosts() + costOfFlagExecution; @@ -246,7 +297,10 @@ contract LiquidationModule is ILiquidationModule, IMarketEvents { totalCollateralValue ); runtime.accountFullyLiquidated = account.openPositionMarketIds.length() == 0; - if (runtime.accountFullyLiquidated) { + if ( + runtime.accountFullyLiquidated && + GlobalPerpsMarket.load().liquidatableAccounts.contains(runtime.accountId) + ) { GlobalPerpsMarket.load().liquidatableAccounts.remove(runtime.accountId); } } diff --git a/markets/perps-market/contracts/modules/PerpsAccountModule.sol b/markets/perps-market/contracts/modules/PerpsAccountModule.sol index a2840addfd..dc439d77fc 100644 --- a/markets/perps-market/contracts/modules/PerpsAccountModule.sol +++ b/markets/perps-market/contracts/modules/PerpsAccountModule.sol @@ -82,11 +82,28 @@ contract PerpsAccountModule is IPerpsAccountModule { emit CollateralModified(accountId, synthMarketId, amountDelta, ERC2771Context._msgSender()); } + // 1. call depositMarketUsd and deposit amount directly to core system + // 2. look up account and reduce debt by amount + // 3. transfer synth to sender + // 3b. quoteUnwrap() -> inchQuote -> returnAmount + function payDebt(uint128 accountId, uint256 amount) external override { + Account.exists(accountId); + PerpsAccount.Data storage account = PerpsAccount.load(accountId); + + account.payDebt(amount); + + emit DebtPaid(accountId, amount, ERC2771Context._msgSender()); + } + /** * @inheritdoc IPerpsAccountModule */ function totalCollateralValue(uint128 accountId) external view override returns (uint256) { - return PerpsAccount.load(accountId).getTotalCollateralValue(PerpsPrice.Tolerance.DEFAULT); + return + PerpsAccount.load(accountId).getTotalCollateralValue( + PerpsPrice.Tolerance.DEFAULT, + false + ); } /** @@ -148,13 +165,7 @@ contract PerpsAccountModule is IPerpsAccountModule { uint128 accountId ) external view override returns (int256 withdrawableMargin) { PerpsAccount.Data storage account = PerpsAccount.load(accountId); - int256 availableMargin = account.getAvailableMargin(PerpsPrice.Tolerance.DEFAULT); - (uint256 initialRequiredMargin, , uint256 liquidationReward) = account - .getAccountRequiredMargins(PerpsPrice.Tolerance.DEFAULT); - - uint256 requiredMargin = initialRequiredMargin + liquidationReward; - - withdrawableMargin = availableMargin - requiredMargin.toInt(); + withdrawableMargin = account.getWithdrawableMargin(PerpsPrice.Tolerance.DEFAULT); } /** diff --git a/markets/perps-market/contracts/storage/CollateralConfiguration.sol b/markets/perps-market/contracts/storage/CollateralConfiguration.sol new file mode 100644 index 0000000000..3277215027 --- /dev/null +++ b/markets/perps-market/contracts/storage/CollateralConfiguration.sol @@ -0,0 +1,148 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {DecimalMath} from "@synthetixio/core-contracts/contracts/utils/DecimalMath.sol"; +import {MathUtil} from "../utils/MathUtil.sol"; +import {PerpsPrice} from "./PerpsPrice.sol"; +import {Price} from "@synthetixio/spot-market/contracts/storage/Price.sol"; +import {ISpotMarketSystem} from "../interfaces/external/ISpotMarketSystem.sol"; +import {LiquidationAssetManager} from "./LiquidationAssetManager.sol"; + +/** + * @title Configuration of all multi collateral assets used for trader margin + */ +library CollateralConfiguration { + using DecimalMath for uint256; + + /** + * @notice Thrown when attempting to access a not registered id + */ + error InvalidId(uint128 id); + + struct Data { + /** + * @dev Collateral Id (same as synth id) + */ + uint128 id; + /** + * @dev Max amount of collateral that can be used for margin + */ + uint256 maxAmount; + /** + * @dev Collateral value is discounted and capped at this value. In % units. + */ + uint256 upperLimitDiscount; + /** + * @dev Collateral value is discounted and at minimum, this value. In % units. + */ + uint256 lowerLimitDiscount; + /** + * @dev This value is used to scale the impactOnSkew of the collateral. + */ + uint256 discountScalar; + /** + * @dev Liquidation Asset Manager data. (see LiquidationAssetManager.Data struct). + */ + LiquidationAssetManager.Data lam; + } + + /** + * @dev Load the collateral configuration data using collateral/synth id + */ + function load(uint128 collateralId) internal pure returns (Data storage collateralConfig) { + bytes32 s = keccak256( + abi.encode("io.synthetix.perps-market.CollateralConfiguration", collateralId) + ); + assembly { + collateralConfig.slot := s + } + } + + /** + * @dev Load a valid collateral configuration data using collateral/synth id + */ + function loadValid(uint128 collateralId) internal view returns (Data storage collateralConfig) { + collateralConfig = load(collateralId); + if (collateralConfig.id == 0) { + revert InvalidId(collateralId); + } + } + + /** + * @dev Load a valid collateral LiquidationAssetManager configuration data using collateral/synth id + */ + function loadValidLam( + uint128 collateralId + ) internal view returns (LiquidationAssetManager.Data storage collateralLAMConfig) { + collateralLAMConfig = load(collateralId).lam; + if (collateralLAMConfig.id == 0) { + revert InvalidId(collateralId); + } + } + + function setMax(Data storage self, uint128 synthId, uint256 maxAmount) internal { + if (self.id == 0) self.id = synthId; + self.maxAmount = maxAmount; + } + + function setDiscounts( + Data storage self, + uint256 upperLimitDiscount, + uint256 lowerLimitDiscount, + uint256 discountScalar + ) internal { + self.upperLimitDiscount = upperLimitDiscount; + self.lowerLimitDiscount = lowerLimitDiscount; + self.discountScalar = discountScalar; + } + + function getConfig( + Data storage self + ) + internal + view + returns ( + uint256 maxAmount, + uint256 upperLimitDiscount, + uint256 lowerLimitDiscount, + uint256 discountScalar + ) + { + maxAmount = self.maxAmount; + upperLimitDiscount = self.upperLimitDiscount; + lowerLimitDiscount = self.lowerLimitDiscount; + discountScalar = self.discountScalar; + } + + function isSupported(Data storage self) internal view returns (bool) { + return self.maxAmount != 0; + } + + function valueInUsd( + Data storage self, + uint256 collateralAmount, + ISpotMarketSystem spotMarket, + PerpsPrice.Tolerance stalenessTolerance, + bool useDiscount + ) internal view returns (uint256 collateralValueInUsd, uint256 discount) { + uint256 skewScale = spotMarket.getMarketSkewScale(self.id); + uint256 impactOnSkew = useDiscount && skewScale != 0 + ? collateralAmount.divDecimal(skewScale).mulDecimal(self.discountScalar) + : 0; + discount = + DecimalMath.UNIT - + ( + MathUtil.max( + MathUtil.min(impactOnSkew, self.lowerLimitDiscount), + self.upperLimitDiscount + ) + ); + uint256 discountedCollateralAmount = collateralAmount.mulDecimal(discount); + + (collateralValueInUsd, ) = spotMarket.quoteSellExactIn( + self.id, + discountedCollateralAmount, + Price.Tolerance(uint256(stalenessTolerance)) // solhint-disable-line numcast/safe-cast + ); + } +} diff --git a/markets/perps-market/contracts/storage/GlobalPerpsMarket.sol b/markets/perps-market/contracts/storage/GlobalPerpsMarket.sol index 64e36657f5..6342a8d83c 100644 --- a/markets/perps-market/contracts/storage/GlobalPerpsMarket.sol +++ b/markets/perps-market/contracts/storage/GlobalPerpsMarket.sol @@ -1,6 +1,7 @@ //SPDX-License-Identifier: MIT pragma solidity >=0.8.11 <0.9.0; +import {ISpotMarketSystem} from "../interfaces/external/ISpotMarketSystem.sol"; import {DecimalMath} from "@synthetixio/core-contracts/contracts/utils/DecimalMath.sol"; import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; import {MathUtil} from "../utils/MathUtil.sol"; @@ -10,7 +11,7 @@ import {Price} from "@synthetixio/spot-market/contracts/storage/Price.sol"; import {PerpsAccount, SNX_USD_MARKET_ID} from "./PerpsAccount.sol"; import {PerpsMarket} from "./PerpsMarket.sol"; import {PerpsMarketFactory} from "./PerpsMarketFactory.sol"; -import {ISpotMarketSystem} from "../interfaces/external/ISpotMarketSystem.sol"; +import {CollateralConfiguration} from "./CollateralConfiguration.sol"; /** * @title This library contains all global perps market data @@ -154,9 +155,7 @@ library GlobalPerpsMarket { ) internal view { uint256 collateralAmount = self.collateralAmounts[synthMarketId]; if (synthAmount > 0) { - uint256 maxAmount = GlobalPerpsMarketConfiguration.load().maxCollateralAmounts[ - synthMarketId - ]; + uint256 maxAmount = CollateralConfiguration.load(synthMarketId).maxAmount; if (maxAmount == 0) { revert SynthNotEnabledForCollateral(synthMarketId); } diff --git a/markets/perps-market/contracts/storage/GlobalPerpsMarketConfiguration.sol b/markets/perps-market/contracts/storage/GlobalPerpsMarketConfiguration.sol index 03f96c6c71..adb6b85c01 100644 --- a/markets/perps-market/contracts/storage/GlobalPerpsMarketConfiguration.sol +++ b/markets/perps-market/contracts/storage/GlobalPerpsMarketConfiguration.sol @@ -8,6 +8,7 @@ import {DecimalMath} from "@synthetixio/core-contracts/contracts/utils/DecimalMa import {MathUtil} from "../utils/MathUtil.sol"; import {IFeeCollector} from "../interfaces/external/IFeeCollector.sol"; import {PerpsMarketFactory} from "./PerpsMarketFactory.sol"; +import {CollateralConfiguration} from "./CollateralConfiguration.sol"; /** * @title This library contains all global perps market configuration data @@ -15,6 +16,7 @@ import {PerpsMarketFactory} from "./PerpsMarketFactory.sol"; library GlobalPerpsMarketConfiguration { using DecimalMath for uint256; using PerpsMarketFactory for PerpsMarketFactory.Data; + using CollateralConfiguration for CollateralConfiguration.Data; using SetUtil for SetUtil.UintSet; using SafeCastU128 for uint128; @@ -32,10 +34,9 @@ library GlobalPerpsMarketConfiguration { */ mapping(address => uint256) referrerShare; /** - * @dev mapping of configured synthMarketId to max collateral amount. - * @dev USD token synth market id = 0 + * @dev previously maxCollateralAmounts[synthMarketId] was used in storage slot */ - mapping(uint128 => uint256) maxCollateralAmounts; + mapping(uint128 => uint256) __unused_1; /** * @dev when deducting from user's margin which is made up of many synths, this priority governs which synth to sell for deduction */ @@ -84,6 +85,14 @@ library GlobalPerpsMarketConfiguration { * @dev interest rate gradient applied to utilization after hitting the gradient breakpoint */ uint128 highUtilizationInterestRateGradient; + /** + * @dev ratio of the collateral liquidation reward. 1e18 is 100%. + */ + uint128 collateralLiquidateRewardRatioD18; + /** + * @dev reward distributor implementation. This is used as a base to be cloned to distribute rewards to the liquidator. + */ + address rewardDistributorImplementation; } function load() internal pure returns (Data storage globalMarketConfig) { @@ -186,12 +195,19 @@ library GlobalPerpsMarketConfiguration { return (referralFees, feeCollectorQuote); } - function updateCollateral( + function calculateCollateralLiquidateReward( + Data storage self, + uint256 notionalValue + ) internal view returns (uint256) { + return notionalValue.mulDecimal(self.collateralLiquidateRewardRatioD18); + } + + function updateCollateralMax( Data storage self, uint128 synthMarketId, uint256 maxCollateralAmount ) internal { - self.maxCollateralAmounts[synthMarketId] = maxCollateralAmount; + CollateralConfiguration.load(synthMarketId).setMax(synthMarketId, maxCollateralAmount); bool isSupportedCollateral = self.supportedCollateralTypes.contains(synthMarketId); if (maxCollateralAmount > 0 && !isSupportedCollateral) { diff --git a/markets/perps-market/contracts/storage/LiquidationAssetManager.sol b/markets/perps-market/contracts/storage/LiquidationAssetManager.sol new file mode 100644 index 0000000000..dbf1642a27 --- /dev/null +++ b/markets/perps-market/contracts/storage/LiquidationAssetManager.sol @@ -0,0 +1,143 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import {ERC165Helper} from "@synthetixio/core-contracts/contracts/utils/ERC165Helper.sol"; +import {ITokenModule} from "@synthetixio/core-modules/contracts/interfaces/ITokenModule.sol"; +import {IPerpRewardDistributor} from "@synthetixio/perps-reward-distributor/contracts/interfaces/IPerpsRewardDistributor.sol"; +import {ISynthetixSystem} from "../interfaces/external/ISynthetixSystem.sol"; +import {GlobalPerpsMarketConfiguration} from "./GlobalPerpsMarketConfiguration.sol"; +import {PerpsMarketFactory} from "./PerpsMarketFactory.sol"; +import {DecimalMath} from "@synthetixio/core-contracts/contracts/utils/DecimalMath.sol"; +import {AddressUtil} from "@synthetixio/core-contracts/contracts/utils/AddressUtil.sol"; +import {AddressError} from "@synthetixio/core-contracts/contracts/errors/AddressError.sol"; +import {ParameterError} from "@synthetixio/core-contracts/contracts/errors/ParameterError.sol"; +import {Clones} from "../utils/Clones.sol"; +import {IDistributorErrors} from "../interfaces/IDistributorErrors.sol"; + +/** + * @title LiquidationAssetManager send liquidity to the reward distributor according to each collateral type + */ +library LiquidationAssetManager { + using DecimalMath for uint256; + using Clones for address; + + struct Data { + /** + * @dev Collateral Id (same as synth id) + */ + uint128 id; + /** + * @dev Distributor address used for reward distribution. If address is 0x0, a new distributor will be created. + */ + address distributor; + /** + * @dev Addresses of collateral types delegated to the pool. Used to distribute rewards. + * @dev Needs to be manually maintained in synch with pool configuration to distribute proportionally to all LPs. + */ + address[] poolDelegatedCollateralTypes; + } + + function setValidPoolDelegatedCollateralTypes( + Data storage self, + address[] calldata poolDelegatedCollateralTypes + ) internal { + self.poolDelegatedCollateralTypes = poolDelegatedCollateralTypes; + + // Collaterals in a V3 pool can be delegated to a specific market. `collateralTypes` are the pool collateral + // addresses delegated to this market. They're tracked here so downstream operations post creation can infer + // pct of `token` to distribute amongst delegated collaterals. For example, during liquidation we calc to total + // dollar value of delegated collateral and distribute the reward token proportionally to each collateral. + // + // There must be at least one pool collateral type available otherwise this reward distribute cannot distribute. + uint256 collateralTypesLength = self.poolDelegatedCollateralTypes.length; + if (collateralTypesLength == 0) { + revert ParameterError.InvalidParameter("collateralTypes", "must not be empty"); + } + for (uint256 i = 0; i < collateralTypesLength; ) { + if (self.poolDelegatedCollateralTypes[i] == address(0)) { + revert AddressError.ZeroAddress(); + } + unchecked { + ++i; + } + } + } + + function setValidDistributor(Data storage self, address distributor) internal { + if (distributor != address(0)) { + if ( + !ERC165Helper.safeSupportsInterface( + distributor, + type(IPerpRewardDistributor).interfaceId + ) + ) { + revert IDistributorErrors.InvalidDistributorContract(distributor); + } + + // Reuse the previous distributor. + self.distributor = distributor; + } else { + // Create a new distributor by cloning an existing implementation. + self.distributor = GlobalPerpsMarketConfiguration + .load() + .rewardDistributorImplementation + .clone(); + } + } + + function distrubuteCollateral(Data storage self, address tokenAddres, uint256 amount) internal { + IPerpRewardDistributor distributor = IPerpRewardDistributor(self.distributor); + + if (distributor.token() != tokenAddres) { + revert IDistributorErrors.InvalidDistributor(self.id, tokenAddres); + } + + uint256 poolCollateralTypesLength = self.poolDelegatedCollateralTypes.length; + ISynthetixSystem synthetix = PerpsMarketFactory.load().synthetix; + + // Transfer collateral to the distributor + ITokenModule(tokenAddres).transfer(self.distributor, amount); + + // Calculate the USD value of each collateral delegated to pool. + uint128 poolId = distributor.getPoolId(); + uint256[] memory collateralValuesUsd = new uint256[](poolCollateralTypesLength); + uint256 totalCollateralValueUsd; + for (uint256 i = 0; i < poolCollateralTypesLength; ) { + (, uint256 collateralValueUsd) = synthetix.getVaultCollateral( + poolId, + self.poolDelegatedCollateralTypes[i] + ); + totalCollateralValueUsd += collateralValueUsd; + collateralValuesUsd[i] = collateralValueUsd; + + unchecked { + ++i; + } + } + + // Infer the ratio of size to distribute, proportional to value of each delegated collateral. + uint256 remainingAmountToDistribute = amount; + for (uint256 j = 0; j < poolCollateralTypesLength; ) { + // Ensure total amounts fully distributed, the last collateral receives the remainder. + if (j == poolCollateralTypesLength - 1) { + distributor.distributeRewards( + self.poolDelegatedCollateralTypes[j], + remainingAmountToDistribute + ); + } else { + uint256 amountToDistribute = amount.mulDecimal( + collateralValuesUsd[j].divDecimal(totalCollateralValueUsd) + ); + remainingAmountToDistribute -= amountToDistribute; + distributor.distributeRewards( + self.poolDelegatedCollateralTypes[j], + amountToDistribute + ); + } + + unchecked { + ++j; + } + } + } +} diff --git a/markets/perps-market/contracts/storage/PerpsAccount.sol b/markets/perps-market/contracts/storage/PerpsAccount.sol index 41d1237997..ee994a1ff9 100644 --- a/markets/perps-market/contracts/storage/PerpsAccount.sol +++ b/markets/perps-market/contracts/storage/PerpsAccount.sol @@ -1,6 +1,7 @@ //SPDX-License-Identifier: MIT pragma solidity >=0.8.11 <0.9.0; +import {ERC2771Context} from "@synthetixio/core-contracts/contracts/utils/ERC2771Context.sol"; import {Price} from "@synthetixio/spot-market/contracts/storage/Price.sol"; import {DecimalMath} from "@synthetixio/core-contracts/contracts/utils/DecimalMath.sol"; import {SafeCastI256, SafeCastU256, SafeCastU128} from "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; @@ -13,11 +14,11 @@ import {PerpsPrice} from "./PerpsPrice.sol"; import {MarketUpdate} from "./MarketUpdate.sol"; import {PerpsMarketFactory} from "./PerpsMarketFactory.sol"; import {GlobalPerpsMarket} from "./GlobalPerpsMarket.sol"; -import {InterestRate} from "./InterestRate.sol"; import {GlobalPerpsMarketConfiguration} from "./GlobalPerpsMarketConfiguration.sol"; import {PerpsMarketConfiguration} from "./PerpsMarketConfiguration.sol"; import {KeeperCosts} from "../storage/KeeperCosts.sol"; import {AsyncOrder} from "../storage/AsyncOrder.sol"; +import {CollateralConfiguration} from "./CollateralConfiguration.sol"; uint128 constant SNX_USD_MARKET_ID = 0; @@ -36,6 +37,7 @@ library PerpsAccount { using PerpsMarketFactory for PerpsMarketFactory.Data; using GlobalPerpsMarket for GlobalPerpsMarket.Data; using GlobalPerpsMarketConfiguration for GlobalPerpsMarketConfiguration.Data; + using CollateralConfiguration for CollateralConfiguration.Data; using DecimalMath for int256; using DecimalMath for uint256; using KeeperCosts for KeeperCosts.Data; @@ -50,11 +52,13 @@ library PerpsAccount { SetUtil.UintSet activeCollateralTypes; // @dev set of open position market ids SetUtil.UintSet openPositionMarketIds; + // @dev account's debt accrued from previous positions + uint256 debt; } error InsufficientCollateralAvailableForWithdraw( - uint256 availableUsdDenominated, - uint256 requiredUsdDenominated + int256 withdrawableMarginUsd, + uint256 requestedMarginUsd ); error InsufficientSynthCollateral( @@ -67,6 +71,8 @@ library PerpsAccount { error AccountLiquidatable(uint128 accountId); + error AccountMarginLiquidatable(uint128 accountId); + error MaxPositionsPerAccountReached(uint128 maxPositionsPerAccount); error MaxCollateralsPerAccountReached(uint128 maxCollateralsPerAccount); @@ -113,6 +119,38 @@ library PerpsAccount { } } + /** + * @notice This function applies the pnl of a closing position to the account + * @dev It will either reduce the account's debt or increase the account's debt + * @dev It will also update the account's collateral amount if the debt is fully paid off + */ + function applyPnl(Data storage self, int256 pnl) internal { + if (pnl > 0) { + int256 leftoverDebt = self.debt.toInt() - pnl; + if (leftoverDebt > 0) { + self.debt = leftoverDebt.toUint(); + } else { + self.debt = 0; + updateCollateralAmount(self, SNX_USD_MARKET_ID, -leftoverDebt); + } + } else { + // if snxUSD exists, use it first. Notice, pnl at this point is negative, so we need to use modulo to compare (or just -pnl) + if (self.collateralAmounts[SNX_USD_MARKET_ID].toInt() >= -pnl) { + updateCollateralAmount(self, SNX_USD_MARKET_ID, pnl); + } else { + self.debt += (-pnl).toUint(); + } + } + } + + function isEligibleForMarginLiquidation( + Data storage self, + PerpsPrice.Tolerance stalenessTolerance + ) internal view returns (bool isEligible, int256 availableMargin) { + availableMargin = getAvailableMargin(self, stalenessTolerance); + isEligible = availableMargin < 0; + } + function isEligibleForLiquidation( Data storage self, PerpsPrice.Tolerance stalenessTolerance @@ -139,7 +177,7 @@ library PerpsAccount { function flagForLiquidation( Data storage self - ) internal returns (uint256 flagKeeperCost, uint256 marginCollected) { + ) internal returns (uint256 flagKeeperCost, uint256 seizedMarginValue) { SetUtil.UintSet storage liquidatableAccounts = GlobalPerpsMarket .load() .liquidatableAccounts; @@ -147,11 +185,20 @@ library PerpsAccount { if (!liquidatableAccounts.contains(self.id)) { flagKeeperCost = KeeperCosts.load().getFlagKeeperCosts(self.id); liquidatableAccounts.add(self.id); - marginCollected = convertAllCollateralToUsd(self); + seizedMarginValue = transferAllCollateral(self); AsyncOrder.load(self.id).reset(); } } + function getMarginLiquidationCostAndSeizeMargin( + Data storage self + ) internal returns (uint256 marginLiquidateCost, uint256 seizedMarginValue) { + // notice: using getFlagKeeperCosts here since the logic is the same, but with no positions. + marginLiquidateCost = KeeperCosts.load().getFlagKeeperCosts(self.id); + + seizedMarginValue = transferAllCollateral(self); + } + function updateOpenPositions( Data storage self, uint256 positionMarketId, @@ -183,6 +230,22 @@ library PerpsAccount { GlobalPerpsMarket.load().updateCollateralAmount(synthMarketId, amountDelta); } + function payDebt(Data storage self, uint256 amount) internal { + PerpsMarketFactory.Data storage perpsMarketFactory = PerpsMarketFactory.load(); + perpsMarketFactory.synthetix.depositMarketUsd( + perpsMarketFactory.perpsMarketId, + ERC2771Context._msgSender(), + amount + ); + + if (self.debt < amount) { + self.debt = 0; + updateCollateralAmount(self, SNX_USD_MARKET_ID, (amount - self.debt).toInt()); + } else { + self.debt -= amount; + } + } + /** * @notice This function validates you have enough margin to withdraw without being liquidated. * @dev This is done by checking your collateral value against your initial maintenance value. @@ -194,28 +257,13 @@ library PerpsAccount { uint128 synthMarketId, uint256 amountToWithdraw, ISpotMarketSystem spotMarket - ) internal view returns (uint256 availableWithdrawableCollateralUsd) { + ) internal view { uint256 collateralAmount = self.collateralAmounts[synthMarketId]; if (collateralAmount < amountToWithdraw) { revert InsufficientSynthCollateral(synthMarketId, collateralAmount, amountToWithdraw); } - ( - bool isEligible, - int256 availableMargin, - uint256 initialRequiredMargin, - , - uint256 liquidationReward - ) = isEligibleForLiquidation(self, PerpsPrice.Tolerance.STRICT); - - if (isEligible) { - revert AccountLiquidatable(self.id); - } - - uint256 requiredMargin = initialRequiredMargin + liquidationReward; - // availableMargin can be assumed to be positive since we check for isEligible for liquidation prior - availableWithdrawableCollateralUsd = availableMargin.toUint() - requiredMargin; - + int256 withdrawableMarginUsd = getWithdrawableMargin(self, PerpsPrice.Tolerance.STRICT); uint256 amountToWithdrawUsd; if (synthMarketId == SNX_USD_MARKET_ID) { amountToWithdrawUsd = amountToWithdraw; @@ -223,21 +271,51 @@ library PerpsAccount { (amountToWithdrawUsd, ) = spotMarket.quoteSellExactIn( synthMarketId, amountToWithdraw, - Price.Tolerance.DEFAULT + Price.Tolerance.STRICT ); } - if (amountToWithdrawUsd > availableWithdrawableCollateralUsd) { + if (amountToWithdrawUsd.toInt() > withdrawableMarginUsd) { revert InsufficientCollateralAvailableForWithdraw( - availableWithdrawableCollateralUsd, + withdrawableMarginUsd, amountToWithdrawUsd ); } } - function getTotalCollateralValue( + /** + * @notice Withdrawable amount depends on if the account has active positions or not + * @dev If the account has no active positions and no debt, the withdrawable margin is the total collateral value + * @dev If the account has no active positions but has debt, the withdrawable margin is the available margin (which is debt reduced) + * @dev If the account has active positions, the withdrawable margin is the available margin - required margin - potential liquidation reward + */ + function getWithdrawableMargin( Data storage self, PerpsPrice.Tolerance stalenessTolerance + ) internal view returns (int256 withdrawableMargin) { + bool hasActivePositions = hasOpenPositions(self); + + if (hasActivePositions) { + ( + uint256 requiredInitialMargin, + , + uint256 liquidationReward + ) = getAccountRequiredMargins(self, stalenessTolerance); + uint256 requiredMargin = requiredInitialMargin + liquidationReward; + withdrawableMargin = + getAvailableMargin(self, stalenessTolerance) - + requiredMargin.toInt(); + } else { + withdrawableMargin = self.debt > 0 + ? getAvailableMargin(self, stalenessTolerance) + : getTotalCollateralValue(self, stalenessTolerance, false).toInt(); + } + } + + function getTotalCollateralValue( + Data storage self, + PerpsPrice.Tolerance stalenessTolerance, + bool useDiscountedValue ) internal view returns (uint256) { uint256 totalCollateralValue; ISpotMarketSystem spotMarket = PerpsMarketFactory.load().spotMarket; @@ -249,10 +327,11 @@ library PerpsAccount { if (synthMarketId == SNX_USD_MARKET_ID) { amountToAdd = amount; } else { - (amountToAdd, ) = spotMarket.quoteSellExactIn( - synthMarketId, + (amountToAdd, ) = CollateralConfiguration.load(synthMarketId).valueInUsd( amount, - Price.Tolerance(uint256(stalenessTolerance)) // solhint-disable-line numcast/safe-cast + spotMarket, + stalenessTolerance, + useDiscountedValue ); } totalCollateralValue += amountToAdd; @@ -274,14 +353,20 @@ library PerpsAccount { } } + /** + * @notice This function returns the available margin for an account (this is not withdrawable margin which takes into account, margin requirements for open positions) + * @dev The available margin is the total collateral value + account pnl - account debt + * @dev The total collateral value is always based on the discounted value of the collateral + */ function getAvailableMargin( Data storage self, PerpsPrice.Tolerance stalenessTolerance ) internal view returns (int256) { - int256 totalCollateralValue = getTotalCollateralValue(self, stalenessTolerance).toInt(); + int256 totalCollateralValue = getTotalCollateralValue(self, stalenessTolerance, true) + .toInt(); int256 accountPnl = getAccountPnl(self, stalenessTolerance); - return totalCollateralValue + accountPnl; + return totalCollateralValue + accountPnl - self.debt.toInt(); } function getTotalNotionalOpenInterest( @@ -391,7 +476,7 @@ library PerpsAccount { uint256 liquidateAndFlagCost = globalConfig.keeperReward( accumulatedLiquidationRewards, costOfFlagging, - getTotalCollateralValue(self, PerpsPrice.Tolerance.DEFAULT) + getTotalCollateralValue(self, PerpsPrice.Tolerance.DEFAULT, false) ); uint256 liquidateWindowsCosts = numOfWindows == 0 ? 0 @@ -424,6 +509,31 @@ library PerpsAccount { } } + function transferAllCollateral( + Data storage self + ) internal returns (uint256 seizedCollateralValue) { + uint256[] memory activeCollateralTypes = self.activeCollateralTypes.values(); + + for (uint256 i = 0; i < activeCollateralTypes.length; i++) { + uint128 synthMarketId = activeCollateralTypes[i].to128(); + if (synthMarketId == SNX_USD_MARKET_ID) { + seizedCollateralValue += self.collateralAmounts[synthMarketId]; + } else { + // transfer to liquidation asset manager + seizedCollateralValue += PerpsMarketFactory.load().transferLiquidatedSynth( + synthMarketId, + self.collateralAmounts[synthMarketId] + ); + } + + updateCollateralAmount( + self, + synthMarketId, + -(self.collateralAmounts[synthMarketId].toInt()) + ); + } + } + /** * @notice This function deducts snxUSD from an account * @dev It uses the synth deduction priority to determine which synth to deduct from first @@ -603,8 +713,9 @@ library PerpsAccount { // 3. deposit snxUSD into market manager factory.depositMarketUsd(amountUsd); + } - // 4. update account collateral amount - updateCollateralAmount(self, synthMarketId, -(amount.toInt())); + function hasOpenPositions(Data storage self) internal view returns (bool) { + return self.openPositionMarketIds.length() > 0; } } diff --git a/markets/perps-market/contracts/storage/PerpsMarketFactory.sol b/markets/perps-market/contracts/storage/PerpsMarketFactory.sol index 63b2d6fe1e..0704ab342a 100644 --- a/markets/perps-market/contracts/storage/PerpsMarketFactory.sol +++ b/markets/perps-market/contracts/storage/PerpsMarketFactory.sol @@ -7,10 +7,11 @@ import {ISynthetixSystem} from "../interfaces/external/ISynthetixSystem.sol"; import {ISpotMarketSystem} from "../interfaces/external/ISpotMarketSystem.sol"; import {GlobalPerpsMarket} from "../storage/GlobalPerpsMarket.sol"; import {PerpsMarket} from "../storage/PerpsMarket.sol"; -import {NodeOutput} from "@synthetixio/oracle-manager/contracts/storage/NodeOutput.sol"; -import {NodeDefinition} from "@synthetixio/oracle-manager/contracts/storage/NodeDefinition.sol"; +import {CollateralConfiguration} from "../storage/CollateralConfiguration.sol"; +import {LiquidationAssetManager} from "../storage/LiquidationAssetManager.sol"; import {SafeCastI256, SafeCastU256} from "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; +import {Price} from "@synthetixio/spot-market/contracts/storage/Price.sol"; /** * @title Main factory library that registers perps markets. Also houses global configuration for all perps markets. @@ -21,6 +22,7 @@ library PerpsMarketFactory { using SetUtil for SetUtil.UintSet; using GlobalPerpsMarket for GlobalPerpsMarket.Data; using PerpsMarket for PerpsMarket.Data; + using LiquidationAssetManager for LiquidationAssetManager.Data; bytes32 private constant _SLOT_PERPS_MARKET_FACTORY = keccak256(abi.encode("io.synthetix.perps-market.PerpsMarketFactory")); @@ -41,6 +43,10 @@ library PerpsMarketFactory { ISpotMarketSystem spotMarket; uint128 perpsMarketId; string name; + /** + * @dev all liquidated account's assets are sent to this address + */ + address liquidationAssetManager; } function onlyIfInitialized(Data storage self) internal view { @@ -101,4 +107,21 @@ library PerpsMarketFactory { function withdrawMarketUsd(Data storage self, address to, uint256 amount) internal { self.synthetix.withdrawMarketUsd(self.perpsMarketId, to, amount); } + + function transferLiquidatedSynth( + Data storage self, + uint128 synthMarketId, + uint256 amount + ) internal returns (uint256 synthValue) { + address synth = self.spotMarket.getSynth(synthMarketId); + self.synthetix.withdrawMarketCollateral(self.perpsMarketId, synth, amount); + + (synthValue, ) = self.spotMarket.quoteSellExactIn( + synthMarketId, + amount, + Price.Tolerance.DEFAULT + ); + + CollateralConfiguration.loadValidLam(synthMarketId).distrubuteCollateral(synth, amount); + } } diff --git a/markets/perps-market/contracts/utils/Clones.sol b/markets/perps-market/contracts/utils/Clones.sol new file mode 100644 index 0000000000..0855b386a9 --- /dev/null +++ b/markets/perps-market/contracts/utils/Clones.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT +// Based on OpenZeppelin Clons. see https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/proxy/Clones.sol +pragma solidity >=0.8.11 <0.9.0; + +/** + * @dev https://eips.ethereum.org/EIPS/eip-1167[ERC-1167] is a standard for + * deploying minimal proxy contracts, also known as "clones". + * + * > To simply and cheaply clone contract functionality in an immutable way, this standard specifies + * > a minimal bytecode implementation that delegates all calls to a known, fixed address. + * + * The library includes only function to deploy a proxy using `create` (traditional deployment). `create2` is excluded. + */ +library Clones { + /** + * @dev A clone instance deployment failed. + */ + error ERC1167FailedCreateClone(); + + /** + * @dev Deploys and returns the address of a clone that mimics the behaviour of `implementation`. + * + * This function uses the create opcode, which should never revert. + */ + function clone(address implementation) internal returns (address instance) { + /// @solidity memory-safe-assembly + assembly { + // Cleans the upper 96 bits of the `implementation` word, then packs the first 3 bytes + // of the `implementation` address with the bytecode before the address. + mstore( + 0x00, + or( + shr(0xe8, shl(0x60, implementation)), + 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000 + ) + ) + // Packs the remaining 17 bytes of `implementation` with the bytecode after the address. + mstore(0x20, or(shl(0x78, implementation), 0x5af43d82803e903d91602b57fd5bf3)) + instance := create(0, 0x09, 0x37) + } + if (instance == address(0)) { + revert ERC1167FailedCreateClone(); + } + } +} 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 d92043d1af..4ace185af8 100644 --- a/markets/perps-market/test/integration/Account/ModifyCollateral.deposit.test.ts +++ b/markets/perps-market/test/integration/Account/ModifyCollateral.deposit.test.ts @@ -42,7 +42,7 @@ describe('ModifyCollateral Deposit', () => { before('owner sets limits to max', async () => { await systems() .PerpsMarket.connect(owner()) - .setCollateralConfiguration(synthBTCMarketId, ethers.constants.MaxUint256); + .setCollateralConfiguration(synthBTCMarketId, ethers.constants.MaxUint256, 0, 0, 0); }); before('trader1 buys 1 snxBTC', async () => { 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 9f559430fc..b96774f5e4 100644 --- a/markets/perps-market/test/integration/Account/ModifyCollateral.failures.test.ts +++ b/markets/perps-market/test/integration/Account/ModifyCollateral.failures.test.ts @@ -49,12 +49,12 @@ describe('ModifyCollateral', () => { before('set setCollateralConfiguration to 1 btc', async () => { await systems() .PerpsMarket.connect(owner()) - .setCollateralConfiguration(synthBTCMarketId, bn(1)); + .setCollateralConfiguration(synthBTCMarketId, bn(1), 0, 0, 0); }); before('set setCollateralConfiguration to 0 link', async () => { await systems() .PerpsMarket.connect(owner()) - .setCollateralConfiguration(synthLINKMarketId, bn(0)); + .setCollateralConfiguration(synthLINKMarketId, bn(0), 0, 0, 0); }); before('trader1 buys 100 snxLink', async () => { const usdAmount = bn(100); @@ -126,7 +126,7 @@ describe('ModifyCollateral', () => { it('reverts if the trader does not have enough allowance', async () => { await systems() .PerpsMarket.connect(owner()) - .setCollateralConfiguration(synthETHMarketId, oneBTC); + .setCollateralConfiguration(synthETHMarketId, oneBTC, 0, 0, 0); await assertRevert( systems() @@ -139,7 +139,7 @@ describe('ModifyCollateral', () => { it('reverts if the trader does not have enough spot balance', async () => { await systems() .PerpsMarket.connect(owner()) - .setCollateralConfiguration(synthBTCMarketId, oneBTC); + .setCollateralConfiguration(synthBTCMarketId, oneBTC, 0, 0, 0); await synthMarkets()[0] .synth() 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 f453c2d35b..b14dcd837d 100644 --- a/markets/perps-market/test/integration/Account/ModifyCollateral.withdraw.test.ts +++ b/markets/perps-market/test/integration/Account/ModifyCollateral.withdraw.test.ts @@ -39,12 +39,10 @@ describe('ModifyCollateral Withdraw', () => { const restoreToSetup = snapshotCheckpoint(provider); describe('withdraw without open position modifyCollateral() from another account', () => { - before(restoreToSetup); - before('owner sets limits to max', async () => { await systems() .PerpsMarket.connect(owner()) - .setCollateralConfiguration(synthBTCMarketId, ethers.constants.MaxUint256); + .setCollateralConfiguration(synthBTCMarketId, ethers.constants.MaxUint256, 0, 0, 0); }); before('trader1 buys 1 snxBTC', async () => { @@ -97,7 +95,7 @@ describe('ModifyCollateral Withdraw', () => { before('owner sets limits to max', async () => { await systems() .PerpsMarket.connect(owner()) - .setCollateralConfiguration(synthBTCMarketId, ethers.constants.MaxUint256); + .setCollateralConfiguration(synthBTCMarketId, ethers.constants.MaxUint256, 0, 0, 0); }); before('trader1 buys 1 snxBTC', async () => { diff --git a/markets/perps-market/test/integration/Liquidation/Liquidation.marginOnly.test.ts b/markets/perps-market/test/integration/Liquidation/Liquidation.marginOnly.test.ts new file mode 100644 index 0000000000..6ac0784a16 --- /dev/null +++ b/markets/perps-market/test/integration/Liquidation/Liquidation.marginOnly.test.ts @@ -0,0 +1,336 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import { bn, bootstrapMarkets } from '../bootstrap'; +import { depositCollateral, openPosition } from '../helpers'; +import { SynthMarkets } from '@synthetixio/spot-market/test/common'; +import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import { ethers } from 'ethers'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import assert from 'assert/strict'; + +describe('Liquidation - liquidateMarginOnly', () => { + const perpsMarketConfigs = [ + { + requestedMarketId: 50, + name: 'Bitcoin', + token: 'BTC', + price: bn(30_000), + fundingParams: { skewScale: bn(100), maxFundingVelocity: bn(0) }, + liquidationParams: { + initialMarginFraction: bn(2), + minimumInitialMarginRatio: bn(0.01), + maintenanceMarginScalar: bn(0.5), + maxLiquidationLimitAccumulationMultiplier: bn(1), + liquidationRewardRatio: bn(0.01), + maxSecondsInLiquidationWindow: ethers.BigNumber.from(10), + minimumPositionMargin: bn(0), + }, + settlementStrategy: { + settlementReward: bn(0), + }, + }, + { + requestedMarketId: 51, + name: 'Ether', + token: 'ETH', + price: bn(2000), + fundingParams: { skewScale: bn(1000), maxFundingVelocity: bn(0) }, + liquidationParams: { + initialMarginFraction: bn(2), + minimumInitialMarginRatio: bn(0.01), + maintenanceMarginScalar: bn(0.5), + maxLiquidationLimitAccumulationMultiplier: bn(1), + liquidationRewardRatio: bn(0.02), + maxSecondsInLiquidationWindow: ethers.BigNumber.from(10), + minimumPositionMargin: bn(0), + }, + settlementStrategy: { + settlementReward: bn(0), + }, + }, + { + requestedMarketId: 52, + name: 'Link', + token: 'LINK', + price: bn(5), + fundingParams: { skewScale: bn(100_000), maxFundingVelocity: bn(0) }, + liquidationParams: { + initialMarginFraction: bn(2), + minimumInitialMarginRatio: bn(0.01), + maintenanceMarginScalar: bn(0.5), + maxLiquidationLimitAccumulationMultiplier: bn(1), + liquidationRewardRatio: bn(0.05), + maxSecondsInLiquidationWindow: ethers.BigNumber.from(10), + minimumPositionMargin: bn(0), + }, + settlementStrategy: { + settlementReward: bn(0), + }, + }, + ]; + + const { systems, provider, trader1, synthMarkets, keeper, superMarketId, perpsMarkets } = + bootstrapMarkets({ + liquidationGuards: { + minLiquidationReward: bn(10), + minKeeperProfitRatioD18: bn(0), + maxLiquidationReward: bn(1000), + maxKeeperScalingRatioD18: bn(0.5), + }, + synthMarkets: [ + { + name: 'Bitcoin', + token: 'snxBTC', + buyPrice: bn(30_000), + sellPrice: bn(30_000), + }, + { + name: 'Ethereum', + token: 'snxETH', + buyPrice: bn(2000), + sellPrice: bn(2000), + }, + ], + perpsMarkets: perpsMarketConfigs, + traderAccountIds: [2, 3], + collateralLiquidateRewardRatio: bn(0.01), + }); + + let btcSynth: SynthMarkets[number], + ethSynth: SynthMarkets[number], + startingWithdrawableUsd: ethers.BigNumber; + + before('identify actors', async () => { + btcSynth = synthMarkets()[0]; + ethSynth = synthMarkets()[1]; + startingWithdrawableUsd = await systems().Core.getWithdrawableMarketUsd(superMarketId()); + }); + + before('add collateral to margin', async () => { + await depositCollateral({ + systems, + trader: trader1, + accountId: () => 2, + collaterals: [ + { + synthMarket: () => btcSynth, + snxUSDAmount: () => bn(10_000), + }, + { + synthMarket: () => ethSynth, + snxUSDAmount: () => bn(4000), + }, + ], + }); + }); + + const restore = snapshotCheckpoint(provider); + + // sanity check + it('has correct total collateral value', async () => { + assertBn.near( + await systems().PerpsMarket.totalCollateralValue(2), + bn(10_000 + 4000), + bn(0.00001) + ); + }); + + describe('with open positions', () => { + before('open positions', async () => { + await openPosition({ + systems, + provider, + trader: trader1(), + accountId: 2, + keeper: trader1(), + marketId: perpsMarkets()[0].marketId(), + sizeDelta: bn(1), + settlementStrategyId: perpsMarkets()[0].strategyId(), + price: perpsMarketConfigs[0].price, + }); + }); + + after(restore); + + it('should revert attempting to liquidate account with open positions', async () => { + await assertRevert( + systems().PerpsMarket.connect(keeper()).liquidateMarginOnly(2), + 'AccountHasOpenPositions' + ); + }); + }); + + describe('after some loses and with no open positions', () => { + before('make the user increase their debt', async () => { + // open a position + await openPosition({ + systems, + provider, + trader: trader1(), + accountId: 2, + keeper: trader1(), + marketId: perpsMarkets()[0].marketId(), + sizeDelta: bn(1), + settlementStrategyId: perpsMarkets()[0].strategyId(), + price: perpsMarketConfigs[0].price, + }); + + const newBTCPrice = bn(29_000); + // should incurr in a loss of 1000 (1 * 30_000 - 1 * 29_000) + // move price in the oposite direction (to have a negative pnl) + await perpsMarkets()[0].aggregator().mockSetCurrentPrice(newBTCPrice); + + // close the position (open same as negative size) + await openPosition({ + systems, + provider, + trader: trader1(), + accountId: 2, + keeper: trader1(), + marketId: perpsMarkets()[0].marketId(), + sizeDelta: bn(-1), + settlementStrategyId: perpsMarkets()[0].strategyId(), + price: newBTCPrice, + }); + }); + + before('move collateral prices to a border zone', async () => { + // we have 1000 of debt. + // and .3 BTC + 2 ETH as collateral. In order to make it liquidatable we need to move the price of the collateral + + // notice we are not taking into account any fees or funding or pd + + await btcSynth.sellAggregator().mockSetCurrentPrice(bn(2000)); // .3 * 2000 = 600 + await ethSynth.sellAggregator().mockSetCurrentPrice(bn(200)); // 2 * 200 = 400 + }); + + it('shows account is not liquidatable', async () => { + assert.equal(await systems().PerpsMarket.canLiquidateMarginOnly(2), false); + }); + + it('cannot liquidate if account is not liquidatable', async () => { + await assertRevert( + systems().PerpsMarket.connect(keeper()).liquidateMarginOnly(2), + 'NotEligibleForMarginLiquidation' + ); + }); + + describe('make account liquidatable', () => { + before('move collateral prices a bit more', async () => { + await ethSynth.sellAggregator().mockSetCurrentPrice(bn(150)); // 2 * 150 = 300 + // now it should be liquidatable + }); + + it('should be liquidatable', async () => { + assert.equal(await systems().PerpsMarket.canLiquidateMarginOnly(2), true); + }); + + describe('liquidate the account', () => { + let liquidateTxn: ethers.providers.TransactionResponse; + before('liquidate account', async () => { + liquidateTxn = await systems().PerpsMarket.connect(keeper()).liquidateMarginOnly(2); + }); + + it('empties account margin', async () => { + assertBn.equal(await systems().PerpsMarket.totalCollateralValue(2), 0); + }); + + it('empties open interest', async () => { + assertBn.equal(await systems().PerpsMarket.totalAccountOpenInterest(2), 0); + }); + + it('emits account liquidated event', async () => { + await assertEvent( + liquidateTxn, + `AccountLiquidationAttempt(2, ${bn(10)}, true)`, // liquidation reward $1000 * 0.01 = 10 + systems().PerpsMarket + ); + }); + + it('sent reward to keeper', async () => { + assertBn.equal(await systems().USD.balanceOf(await keeper().getAddress()), bn(10)); + }); + + it('sold all market collateral for usd', async () => { + assertBn.equal( + await systems().Core.getMarketCollateralAmount( + superMarketId(), + btcSynth.synthAddress() + ), + bn(0) + ); + + assertBn.equal( + await systems().Core.getMarketCollateralAmount( + superMarketId(), + ethSynth.synthAddress() + ), + bn(0) + ); + }); + + // all collateral is still in the core system + it('has correct market usd', async () => { + // $14_000 total collateral value, but was liquidated so it should be 0 + // $10 paid to liquidation reward + assertBn.near( + await systems().Core.getWithdrawableMarketUsd(superMarketId()), + startingWithdrawableUsd.sub(bn(10)), + bn(0.00001) + ); + }); + + it('should not have a pending order', async () => { + const order = await systems().PerpsMarket.getOrder(2); + assertBn.equal(order.request.accountId, 2); + assertBn.equal(order.request.sizeDelta, bn(0)); + }); + }); + }); + + describe('sanity check. Can deposit more collateral and open a position', () => { + before('set prices as before to simplify test', async () => { + await btcSynth.sellAggregator().mockSetCurrentPrice(bn(30_000)); + await ethSynth.sellAggregator().mockSetCurrentPrice(bn(2000)); + }); + + before('add collateral to margin', async () => { + await depositCollateral({ + systems, + trader: trader1, + accountId: () => 2, + collaterals: [ + { + synthMarket: () => btcSynth, + snxUSDAmount: () => bn(10_000), + }, + { + synthMarket: () => ethSynth, + snxUSDAmount: () => bn(4000), + }, + ], + }); + }); + + it('has correct total collateral value and can open a position', async () => { + assertBn.near( + await systems().PerpsMarket.totalCollateralValue(2), + bn(10_000 + 4000), + bn(0.00001) + ); + + await openPosition({ + systems, + provider, + trader: trader1(), + accountId: 2, + keeper: trader1(), + marketId: perpsMarkets()[0].marketId(), + sizeDelta: bn(1), + settlementStrategyId: perpsMarkets()[0].strategyId(), + price: perpsMarketConfigs[0].price, + }); + }); + }); + }); +}); 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 3c9192e431..57203895f5 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', () => { +describe.skip('Liquidation - multi collateral', () => { const perpsMarketConfigs = [ { requestedMarketId: 50, diff --git a/markets/perps-market/test/integration/Market/Market.RewardDistributor.test.ts b/markets/perps-market/test/integration/Market/Market.RewardDistributor.test.ts new file mode 100644 index 0000000000..26a0e346ec --- /dev/null +++ b/markets/perps-market/test/integration/Market/Market.RewardDistributor.test.ts @@ -0,0 +1,517 @@ +import { ethers } from 'ethers'; +import { bn, bootstrapMarkets } from '../bootstrap'; +import hre from 'hardhat'; +import assert from 'assert'; +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 { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; + +describe('PerpsMarket: Reward Distributor configuration test', () => { + const { systems, signers, owner, synthMarkets, provider } = bootstrapMarkets({ + synthMarkets: [ + { + name: 'Bitcoin', + token: 'snxBTC', + buyPrice: bn(10_000), + sellPrice: bn(10_000), + }, + ], + perpsMarkets: [], // don't create a market in bootstrap + traderAccountIds: [2, 3], + collateralLiquidateRewardRatio: bn(0.42), + skipRegisterDistributors: true, + }); + + let randomAccount: ethers.Signer; + let perpsRewardDistributorV2: ethers.Contract; + + let synthBTCMarketId: ethers.BigNumber; + + before('identify actors', async () => { + [, , , , randomAccount] = signers(); + synthBTCMarketId = synthMarkets()[0].marketId(); // 2 + }); + + before('deploy upgraded distributor', async () => { + const PerpsRewardDistributorV2 = await hre.ethers.getContractFactory( + 'MockPerpsRewardDistributorV2' + ); + perpsRewardDistributorV2 = await PerpsRewardDistributorV2.deploy(); + }); + + const restore = snapshotCheckpoint(provider); + + describe('initial configuration', () => { + it('collateral liquidate reward ratio', async () => { + assertBn.equal(await systems().PerpsMarket.getCollateralLiquidateRewardRatio(), bn(0.42)); + }); + + it('reward distributor implementation is not zero address', async () => { + assert.notEqual( + await systems().PerpsMarket.getRewardDistributorImplementation(), + ethers.constants.AddressZero + ); + }); + + it('reward distributor implementation', async () => { + assert.equal( + await systems().PerpsMarket.getRewardDistributorImplementation(), + systems().PerpsRewardDistributor.address + ); + }); + }); + + describe('attempt to change configuration errors', () => { + it('reverts setting collateral liquidate reward ratio as non-owner', async () => { + await assertRevert( + systems().PerpsMarket.connect(randomAccount).setCollateralLiquidateRewardRatio(bn(0.1337)), + 'Unauthorized' + ); + }); + + it('reverts setting reward distributor implementation as non-owner', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(randomAccount) + .setRewardDistributorImplementation(await perpsRewardDistributorV2.address), + 'Unauthorized' + ); + }); + + it('reverts setting reward distributor implementation with zero address', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .setRewardDistributorImplementation(ethers.constants.AddressZero), + 'ZeroAddress' + ); + }); + + it('reverts setting reward distributor implementation with a not-contract address', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .setRewardDistributorImplementation(await randomAccount.getAddress()), + 'NotAContract' + ); + }); + + it('reverts setting reward distributor implementation with a wrong contract', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .setRewardDistributorImplementation(systems().PerpsMarket.address), + 'InvalidDistributorContract' + ); + }); + + it('reverts registering a new distributor as non-owner', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(randomAccount) + .registerDistributor( + 1, + await randomAccount.getAddress(), + ethers.constants.AddressZero, + 'clone 42', + 1, + [] + ), + 'Unauthorized' + ); + }); + + it('reverts registering a new distributor with wrong data: collateralId', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .registerDistributor( + 1, + await randomAccount.getAddress(), + ethers.constants.AddressZero, + 'clone 42', + 42, + [] + ), + 'InvalidId("42")' + ); + }); + + it('reverts registering a new distributor with wrong data: poolDelegatedCollateralTypes empty', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .registerDistributor( + 1, + await randomAccount.getAddress(), + ethers.constants.AddressZero, + 'clone 42', + synthBTCMarketId, + [] + ), + 'InvalidParameter("collateralTypes", "must not be empty")' + ); + }); + + it('reverts registering a new distributor with wrong data: poolDelegatedCollateralTypes includes zeroAddress', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .registerDistributor( + 1, + await randomAccount.getAddress(), + ethers.constants.AddressZero, + 'clone 42', + synthBTCMarketId, + [ethers.constants.AddressZero] + ), + 'ZeroAddress' + ); + }); + + it('reverts registering a new distributor with wrong data: token is zeroAddress', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .registerDistributor( + 1, + ethers.constants.AddressZero, + ethers.constants.AddressZero, + '', + synthBTCMarketId, + [await randomAccount.getAddress()] + ), + 'ZeroAddress' + ); + }); + + it('reverts registering a new distributor with wrong data: wrong distributor address', async () => { + await assertRevert( + systems() + .PerpsMarket.connect(owner()) + .registerDistributor( + 1, + await randomAccount.getAddress(), + await randomAccount.getAddress(), + '', + synthBTCMarketId, + [await randomAccount.getAddress()] + ), + 'InvalidDistributorContract' + ); + }); + }); + + describe('update configuration', () => { + describe('set reward distributor implementation', () => { + let tx: ethers.ContractTransaction; + before(restore); + + before('set reward distributor', async () => { + tx = await systems() + .PerpsMarket.connect(owner()) + .setRewardDistributorImplementation(perpsRewardDistributorV2.address); + }); + + it('emits event', async () => { + await assertEvent( + tx, + `RewardDistributorImplementationSet("${perpsRewardDistributorV2.address}")`, + systems().PerpsMarket + ); + }); + + it('reward distributor implementation is set', async () => { + assert.equal( + await systems().PerpsMarket.getRewardDistributorImplementation(), + perpsRewardDistributorV2.address + ); + }); + }); + + describe('set collateral liquidate reward ratio', () => { + let tx: ethers.ContractTransaction; + + before(restore); + + before('set collateral liquidate reward ratio', async () => { + tx = await systems() + .PerpsMarket.connect(owner()) + .setCollateralLiquidateRewardRatio(bn(0.1337)); + }); + + it('emits event', async () => { + await assertEvent( + tx, + `CollateralLiquidateRewardRatioSet(${bn(0.1337).toString()})`, + systems().PerpsMarket + ); + }); + + it('collateral liquidate reward ratio is set', async () => { + assertBn.equal(await systems().PerpsMarket.getCollateralLiquidateRewardRatio(), bn(0.1337)); + }); + }); + + describe('register distributor', () => { + let tx: ethers.ContractTransaction; + let distributorAddress: string; + before(restore); + + before('register distributor', async () => { + distributorAddress = await systems() + .PerpsMarket.connect(owner()) + .callStatic.registerDistributor( + 1, + await randomAccount.getAddress(), + ethers.constants.AddressZero, + 'clone 42', + synthBTCMarketId, + [await randomAccount.getAddress()] + ); + tx = await systems() + .PerpsMarket.connect(owner()) + .registerDistributor( + 1, + await randomAccount.getAddress(), + ethers.constants.AddressZero, + 'clone 42', + synthBTCMarketId, + [await randomAccount.getAddress()] + ); + }); + + it('distribution address is not zero', async () => { + assert.notEqual(distributorAddress, ethers.constants.AddressZero); + }); + + it('emits event', async () => { + await assertEvent( + tx, + `RewardDistributorRegistered("${distributorAddress}")`, + systems().PerpsMarket + ); + }); + + it('distributor is registered', async () => { + const registeredDistributorData = + await systems().PerpsMarket.getRegisteredDistributor(synthBTCMarketId); + assert.equal(registeredDistributorData.distributor, distributorAddress); + + assert.equal(registeredDistributorData.poolDelegatedCollateralTypes.length, 1); + assert.equal( + registeredDistributorData.poolDelegatedCollateralTypes[0], + await randomAccount.getAddress() + ); + }); + + it('clone has the right information', async () => { + const distributor = await hre.ethers.getContractAt( + 'MockPerpsRewardDistributor', + distributorAddress + ); + assert.equal(await distributor.version(), '1.0.0'); + assert.equal(await distributor.name(), 'clone 42'); + }); + + describe('update distributor reusing the clone', () => { + let previouCloneAddress: string; + let newCloneAddress: string; + + before('get previous clone data', async () => { + previouCloneAddress = ( + await systems().PerpsMarket.getRegisteredDistributor(synthBTCMarketId) + ).distributor; + }); + + before('update config using previous distributor address', async () => { + newCloneAddress = await systems() + .PerpsMarket.connect(owner()) + .callStatic.registerDistributor( + 1, + await randomAccount.getAddress(), + previouCloneAddress, + 'clone 1337', + synthBTCMarketId, + [await randomAccount.getAddress(), await randomAccount.getAddress()] + ); + + await systems() + .PerpsMarket.connect(owner()) + .registerDistributor( + 1, + await randomAccount.getAddress(), + previouCloneAddress, + 'clone 1337', + synthBTCMarketId, + [await randomAccount.getAddress(), await randomAccount.getAddress()] + ); + }); + + it('uses the same clone address', async () => { + assert.equal(newCloneAddress, previouCloneAddress); + }); + + it('distributor is registered', async () => { + const registeredDistributorData = + await systems().PerpsMarket.getRegisteredDistributor(synthBTCMarketId); + assert.equal(registeredDistributorData.distributor, newCloneAddress); + + assert.equal(registeredDistributorData.poolDelegatedCollateralTypes.length, 2); + assert.equal( + registeredDistributorData.poolDelegatedCollateralTypes[0], + await randomAccount.getAddress() + ); + assert.equal( + registeredDistributorData.poolDelegatedCollateralTypes[1], + await randomAccount.getAddress() + ); + }); + + it('clone has the right information', async () => { + const distributor = await hre.ethers.getContractAt( + 'MockPerpsRewardDistributor', + newCloneAddress + ); + assert.equal(await distributor.version(), '1.0.0'); + assert.equal(await distributor.name(), 'clone 1337'); + }); + }); + + describe('update distributor with a new clone', () => { + let previouCloneAddress: string; + let newCloneAddress: string; + + before('get previous clone data', async () => { + previouCloneAddress = ( + await systems().PerpsMarket.getRegisteredDistributor(synthBTCMarketId) + ).distributor; + }); + + before('update config using previous distributor address', async () => { + newCloneAddress = await systems() + .PerpsMarket.connect(owner()) + .callStatic.registerDistributor( + 1, + await randomAccount.getAddress(), + ethers.constants.AddressZero, + 'clone updated', + synthBTCMarketId, + [await randomAccount.getAddress(), await randomAccount.getAddress()] + ); + + await systems() + .PerpsMarket.connect(owner()) + .registerDistributor( + 1, + await randomAccount.getAddress(), + ethers.constants.AddressZero, + 'clone updated', + synthBTCMarketId, + [await randomAccount.getAddress(), await randomAccount.getAddress()] + ); + }); + + it('uses a different clone', async () => { + assert.notEqual(newCloneAddress, previouCloneAddress); + }); + + it('distributor is registered', async () => { + const registeredDistributorData = + await systems().PerpsMarket.getRegisteredDistributor(synthBTCMarketId); + assert.equal(registeredDistributorData.distributor, newCloneAddress); + + assert.equal(registeredDistributorData.poolDelegatedCollateralTypes.length, 2); + assert.equal( + registeredDistributorData.poolDelegatedCollateralTypes[0], + await randomAccount.getAddress() + ); + assert.equal( + registeredDistributorData.poolDelegatedCollateralTypes[1], + await randomAccount.getAddress() + ); + }); + + it('clone has the right information', async () => { + const distributor = await hre.ethers.getContractAt( + 'MockPerpsRewardDistributor', + newCloneAddress + ); + assert.equal(await distributor.version(), '1.0.0'); + assert.equal(await distributor.name(), 'clone updated'); + }); + }); + + describe('updates the implementation and then clones it', () => { + let previouCloneAddress: string; + let newCloneAddress: string; + + before('get previous clone data', async () => { + previouCloneAddress = ( + await systems().PerpsMarket.getRegisteredDistributor(synthBTCMarketId) + ).distributor; + }); + + before('set new reward distributor', async () => { + tx = await systems() + .PerpsMarket.connect(owner()) + .setRewardDistributorImplementation(perpsRewardDistributorV2.address); + }); + + before('update config to just replace the clone', async () => { + newCloneAddress = await systems() + .PerpsMarket.connect(owner()) + .callStatic.registerDistributor( + 1, + await randomAccount.getAddress(), + ethers.constants.AddressZero, + 'clone updated', + synthBTCMarketId, + [await randomAccount.getAddress(), await randomAccount.getAddress()] + ); + + await systems() + .PerpsMarket.connect(owner()) + .registerDistributor( + 1, + await randomAccount.getAddress(), + ethers.constants.AddressZero, + 'clone updated', + synthBTCMarketId, + [await randomAccount.getAddress(), await randomAccount.getAddress()] + ); + }); + + it('uses a different clone', async () => { + assert.notEqual(newCloneAddress, previouCloneAddress); + }); + + it('distributor is registered', async () => { + const registeredDistributorData = + await systems().PerpsMarket.getRegisteredDistributor(synthBTCMarketId); + assert.equal(registeredDistributorData.distributor, newCloneAddress); + + assert.equal(registeredDistributorData.poolDelegatedCollateralTypes.length, 2); + assert.equal( + registeredDistributorData.poolDelegatedCollateralTypes[0], + await randomAccount.getAddress() + ); + assert.equal( + registeredDistributorData.poolDelegatedCollateralTypes[1], + await randomAccount.getAddress() + ); + }); + + it('clone has the right information', async () => { + const distributor = await hre.ethers.getContractAt( + 'MockPerpsRewardDistributor', + newCloneAddress + ); + assert.equal(await distributor.version(), '2.0.0'); + assert.equal(await distributor.name(), 'clone updated'); + }); + }); + }); + }); +}); diff --git a/markets/perps-market/test/integration/Markets/GlobalPerpsMarket.test.ts b/markets/perps-market/test/integration/Markets/GlobalPerpsMarket.test.ts index 38c0a3d5c7..f982314dd6 100644 --- a/markets/perps-market/test/integration/Markets/GlobalPerpsMarket.test.ts +++ b/markets/perps-market/test/integration/Markets/GlobalPerpsMarket.test.ts @@ -22,7 +22,10 @@ describe('GlobalPerpsMarket', () => { async () => { await systems().PerpsMarket.setCollateralConfiguration( perpsMarkets()[0].marketId(), - bn(10000) + bn(10000), + 0, + 0, + 0 ); await systems().PerpsMarket.setSynthDeductionPriority([1, 2]); await systems().PerpsMarket.setKeeperRewardGuards(100, bn(0.001), 500, bn(0.005)); @@ -59,7 +62,7 @@ describe('GlobalPerpsMarket', () => { }); it('returns maxCollateralAmount and strictStalenessTolerance for synth market id', async () => { - const maxCollateralAmount = await systems().PerpsMarket.getCollateralConfiguration( + const { maxCollateralAmount } = await systems().PerpsMarket.getCollateralConfiguration( perpsMarkets()[0].marketId() ); assertBn.equal(maxCollateralAmount, bn(10000)); @@ -90,7 +93,7 @@ describe('GlobalPerpsMarket', () => { await assertRevert( systems() .PerpsMarket.connect(trader1()) - .setCollateralConfiguration(perpsMarkets()[0].marketId(), bn(10000)), + .setCollateralConfiguration(perpsMarkets()[0].marketId(), bn(10000), 0, 0, 0), `Unauthorized("${await trader1().getAddress()}")` ); await assertRevert( @@ -167,7 +170,13 @@ describe('GlobalPerpsMarket', () => { describe('remove a supported collaterals', () => { before('remove a supported collateral by setting its max to zero', async () => { - await systems().PerpsMarket.setCollateralConfiguration(perpsMarkets()[0].marketId(), bn(0)); + await systems().PerpsMarket.setCollateralConfiguration( + perpsMarkets()[0].marketId(), + bn(0), + 0, + 0, + 0 + ); }); it('the removed market was gone from supportedCollaterals', async () => { diff --git a/markets/perps-market/test/integration/bootstrap/bootstrap.ts b/markets/perps-market/test/integration/bootstrap/bootstrap.ts index e57f22a6ed..d3508fa2bc 100644 --- a/markets/perps-market/test/integration/bootstrap/bootstrap.ts +++ b/markets/perps-market/test/integration/bootstrap/bootstrap.ts @@ -15,6 +15,7 @@ import { bootstrapPerpsMarkets, bootstrapTraders, PerpsMarketData } from './'; import { createKeeperCostNode } from './createKeeperCostNode'; import { MockGasPriceNode } from '../../../typechain-types/contracts/mocks/MockGasPriceNode'; import { MockPythERC7412Wrapper } from '../../../typechain-types/contracts/mocks/MockPythERC7412Wrapper'; +import { MockPerpsRewardDistributor } from '../../../typechain-types/contracts/mocks/MockPerpsRewardDistributor'; type Proxies = { ['synthetix.CoreProxy']: CoreProxy; @@ -28,6 +29,7 @@ type Proxies = { ['synthetix.trusted_multicall_forwarder.TrustedMulticallForwarder']: TrustedMulticallForwarder; ['MockPythERC7412Wrapper']: MockPythERC7412Wrapper; ['FeeCollectorMock']: FeeCollectorMock; + ['MockPerpsRewardDistributor']: MockPerpsRewardDistributor; }; export type Systems = { @@ -41,6 +43,7 @@ export type Systems = { Account: AccountProxy; TrustedMulticallForwarder: TrustedMulticallForwarder; FeeCollectorMock: FeeCollectorMock; + PerpsRewardDistributor: MockPerpsRewardDistributor; Synth: (address: string) => SynthRouter; }; @@ -68,6 +71,7 @@ export function bootstrap() { Account: getContract('AccountProxy'), MockPythERC7412Wrapper: getContract('MockPythERC7412Wrapper'), FeeCollectorMock: getContract('FeeCollectorMock'), + PerpsRewardDistributor: getContract('MockPerpsRewardDistributor'), Synth: (address: string) => getContract('spotMarket.SynthRouter', address), }; }); @@ -76,7 +80,10 @@ export function bootstrap() { // set max collateral amt for snxUSD to maxUINT await contracts.PerpsMarket.connect(getSigners()[0]).setCollateralConfiguration( 0, // snxUSD - ethers.constants.MaxUint256 + ethers.constants.MaxUint256, + 0, // upperLimitDiscount + 0, // lowerLimitDiscount + 0 // discountScalar ); }); @@ -105,7 +112,9 @@ type BootstrapArgs = { }; maxPositionsPerAccount?: ethers.BigNumber; maxCollateralsPerAccount?: ethers.BigNumber; + collateralLiquidateRewardRatio?: ethers.BigNumber; skipKeeperCostOracleNode?: boolean; + skipRegisterDistributors?: boolean; }; export function bootstrapMarkets(data: BootstrapArgs) { @@ -113,8 +122,17 @@ export function bootstrapMarkets(data: BootstrapArgs) { const { synthMarkets } = bootstrapSynthMarkets(data.synthMarkets, chainStateWithPerpsMarkets); - const { systems, signers, provider, owner, perpsMarkets, poolId, superMarketId, staker } = - chainStateWithPerpsMarkets; + const { + systems, + signers, + provider, + owner, + perpsMarkets, + poolId, + collateralAddress, + superMarketId, + staker, + } = chainStateWithPerpsMarkets; const { trader1, trader2, trader3, keeper } = bootstrapTraders({ systems, signers, @@ -160,7 +178,7 @@ export function bootstrapMarkets(data: BootstrapArgs) { for (const { marketId } of synthMarkets()) { await systems() .PerpsMarket.connect(owner()) - .setCollateralConfiguration(marketId(), ethers.constants.MaxUint256); + .setCollateralConfiguration(marketId(), ethers.constants.MaxUint256, 0, 0, 0); } }); @@ -192,6 +210,45 @@ export function bootstrapMarkets(data: BootstrapArgs) { const synthIds = [bn(0), ...synthMarkets().map((s) => s.marketId())]; await systems().PerpsMarket.connect(owner()).setSynthDeductionPriority(synthIds); }); + + before('set reward distributor', async () => { + await systems() + .PerpsMarket.connect(owner()) + .setRewardDistributorImplementation(systems().PerpsRewardDistributor.address); + + const { collateralLiquidateRewardRatio } = data; + await systems() + .PerpsMarket.connect(owner()) + .setCollateralLiquidateRewardRatio( + collateralLiquidateRewardRatio ? collateralLiquidateRewardRatio : 0 // set to zero means no rewards based on collateral only + ); + + if (!data.skipRegisterDistributors) { + for (const { marketId, synthAddress } of synthMarkets()) { + await systems() + .PerpsMarket.connect(owner()) + .registerDistributor( + poolId, + synthAddress(), + '0x0000000000000000000000000000000000000000', + `Distributor for ${marketId()}`, + marketId(), + [collateralAddress()] + ); + + // get distributor address + const distributorAddress = ( + await systems().PerpsMarket.connect(owner()).getRegisteredDistributor(marketId()) + )[0]; + + // Register distributor for collateral + await systems() + .Core.connect(owner()) + .registerRewardsDistributor(poolId, collateralAddress(), distributorAddress); + } + } + }); + const { liquidationGuards } = data; if (liquidationGuards) { before('set liquidation guards', async () => { diff --git a/markets/perps-market/test/integration/bootstrap/bootstrapPerpsMarkets.ts b/markets/perps-market/test/integration/bootstrap/bootstrapPerpsMarkets.ts index 9fe0ce60b0..12f2c34db8 100644 --- a/markets/perps-market/test/integration/bootstrap/bootstrapPerpsMarkets.ts +++ b/markets/perps-market/test/integration/bootstrap/bootstrapPerpsMarkets.ts @@ -219,6 +219,7 @@ export const bootstrapPerpsMarkets = ( systems: () => contracts, perpsMarkets: () => perpsMarkets, poolId: r.poolId, + collateralAddress: r.collateralAddress, }; }; diff --git a/sandboxes/andromeda/cannonfile.toml b/sandboxes/andromeda/cannonfile.toml new file mode 100644 index 0000000000..376f813e24 --- /dev/null +++ b/sandboxes/andromeda/cannonfile.toml @@ -0,0 +1,636 @@ +name = "andromeda-sandbox" +preset = "main" +version = "2" +description = "Andromeda Perps Sandbox" + +[setting.owner] +# Matches owner of core system that's provisions on Cannon network +defaultValue = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266" # PK 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 +# We can change the owner of pools and modules deployed in the Sandbox if we want +#defaultValue = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" # PK 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a +description = "Hardhat/Anvil first test account" + +[setting.version] +defaultValue = "3.3.3-dev.249e185f" + +# +# +# TODO: We will need to use Pyth Mock, so these settings will change. +# +# +[setting.pyth_price_verification_address] +defaultValue = "0x5955C1478F0dAD753C7E2B4dD1b4bC530C64749f" + +[setting.pyth_eth_feed_id] +defaultValue = "0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace" + +[setting.pyth_btc_feed_id] +defaultValue = "0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43" + +[import.pyth_erc7412_wrapper] +# options.pythAddress = "0xEbe57e8045F2F230872523bbff7374986E45C486" +source = "pyth-erc7412-wrapper:<%= settings.version %>" + +# +# +# Provision Synthetix V3 Core +# +# +[import.synthetix] +source = "synthetix:<%= settings.version %>" + +[invoke.configure_minimum_liquidity_ratio] +target = ["synthetix.CoreProxy"] +fromCall.func = "owner" +func = "setMinLiquidityRatio(uint256)" +args = ["<%= parseEther('2') %>"] + +[invoke.configure_withdraw_timeout] +target = ["synthetix.CoreProxy"] +fromCall.func = "owner" +func = "setConfig" +args = [ + "<%= formatBytes32String('accountTimeoutWithdraw') %>", + # seconds + "<%= hexZeroPad(hexlify(0), 32) %>", +] + +# +# +# Create Spartan Council pool +# +# +[invoke.permit_owner_to_create_pools] +target = ["synthetix.CoreProxy"] +fromCall.func = "owner" +func = "addToFeatureFlagAllowlist" +args = ["<%= formatBytes32String('createPool') %>", "<%= settings.owner %>"] + +[invoke.create_spartan_council_pool] +target = ["synthetix.CoreProxy"] +func = "createPool" +from = "<%= settings.owner %>" +args = ["1", "<%= settings.owner %>"] +extra.spartan_council_pool_id.event = "PoolCreated" +extra.spartan_council_pool_id.arg = 0 +depends = ["invoke.permit_owner_to_create_pools"] + +[invoke.configure_spartan_council_pool_name] +target = ["synthetix.CoreProxy"] +fromCall.func = "getPoolOwner" +fromCall.args = ["<%= extras.spartan_council_pool_id %>"] +func = "setPoolName" +args = ["<%= extras.spartan_council_pool_id %>", "Spartan Council Pool"] + +[invoke.make_spartan_council_pool_a_preferred_pool] +target = ["synthetix.CoreProxy"] +fromCall.func = "owner" +func = "setPreferredPool" +args = ["<%= extras.spartan_council_pool_id %>"] + +[invoke.configure_spartan_council_pool] +target = ["synthetix.CoreProxy"] +fromCall.func = "getPoolOwner" +fromCall.args = ["<%= extras.spartan_council_pool_id %>"] +func = "setPoolConfiguration" +args = [ + "<%= extras.spartan_council_pool_id %>", + [ + { marketId = "<%= imports.perps_factory.extras.superMarketId %>", weightD18 = 1, maxDebtShareValueD18 = "<%= parseEther('1') %>" }, + ], +] + +# +# +# Provions Spot factory +# +# +[import.spot_factory] +source = "synthetix-spot-market:<%= settings.version %>" + +# +# +# Provision Mintable Sandbox token $USDC +# +# +[provision.usdc_token] +source = "mintable-token:latest@permissionless-mint" +options.symbol = "USDC" +options.name = "Mintable USD Coin" + +# +# +# Synth USDC +# +# +[invoke.create_synth_usdc] +target = ["spot_factory.SpotMarketProxy"] +fromCall.func = "owner" +func = "createSynth" +args = ["Synthetic USD Coin", "sUSDC", "<%= settings.owner %>"] +extra.synth_usdc_market_id.event = "SynthRegistered" +extra.synth_usdc_market_id.arg = 0 +extra.synth_usdc_token_address.event = "SynthRegistered" +extra.synth_usdc_token_address.arg = 1 + +[invoke.register_usdc_oracle_node_with_constant_price] +target = ["synthetix.oracle_manager.Proxy"] +func = "registerNode" +args = [ + 8, # 8 = constant + "<%= defaultAbiCoder.encode(['int256'], [parseEther('1')]) %>", # 1 parameter: price, always $1 + [ + ], +] +extra.usdc_oracle_id.event = "NodeRegistered" +extra.usdc_oracle_id.arg = 0 + +[invoke.configure_price_data_for_synth_usdc] +target = ["spot_factory.SpotMarketProxy"] +fromCall.func = "getMarketOwner" +fromCall.args = ["<%= extras.synth_usdc_market_id %>"] +func = "updatePriceData" +args = [ + "<%= extras.synth_usdc_market_id %>", + "<%= extras.usdc_oracle_id %>", + "<%= extras.usdc_oracle_id %>", + # staleness tolerance + "3600", +] + +[setting.max_market_collateral_for_synth_usdc] +defaultValue = "5000000" + +[invoke.configure_maximum_market_collateral_for_synth_usdc] +target = ["synthetix.CoreProxy"] +fromCall.func = "owner" +func = "configureMaximumMarketCollateral" +args = [ + "<%= extras.synth_usdc_market_id %>", + "<%= imports.usdc_token.contracts.MintableToken.address %>", + "<%= parseEther(settings.max_market_collateral_for_synth_usdc) %>", +] + +[invoke.initialise_synth_usdc_wrapper_with_unlimited_cap] +target = ["spot_factory.SpotMarketProxy"] +fromCall.func = "getMarketOwner" +fromCall.args = ["<%= extras.synth_usdc_market_id %>"] +func = "setWrapper" +args = [ + "<%= extras.synth_usdc_market_id %>", + "<%= imports.usdc_token.contracts.MintableToken.address %>", + "<%= parseEther(settings.max_market_collateral_for_synth_usdc) %>", +] + +[invoke.configure_usdc_token_as_collateral_with_disabled_deposits] +target = ["synthetix.CoreProxy"] +fromCall.func = "owner" +func = "configureCollateral" # "args" see below + +[[invoke.configure_usdc_token_as_collateral_with_disabled_deposits.args]] +tokenAddress = "<%= imports.usdc_token.contracts.MintableToken.address %>" +oracleNodeId = "<%= extras.usdc_oracle_id %>" +issuanceRatioD18 = "<%= MaxUint256 %>" +liquidationRatioD18 = "<%= parseEther('1.1') %>" +liquidationRewardD18 = "<%= parseEther('0.01') %>" +minDelegationD18 = "<%= parseEther('1') %>" +depositingEnabled = false + +[invoke.configure_synth_usdc_collateral] +target = ["synthetix.CoreProxy"] +fromCall.func = "owner" +func = "configureCollateral" # "args" see below + +[[invoke.configure_synth_usdc_collateral.args]] +tokenAddress = "<%= extras.synth_usdc_token_address %>" +oracleNodeId = "<%= extras.usdc_oracle_id %>" +issuanceRatioD18 = "<%= MaxUint256 %>" +liquidationRatioD18 = "<%= parseEther('1.1') %>" +liquidationRewardD18 = "<%= parseEther('0.01') %>" +minDelegationD18 = "<%= parseEther('1') %>" +depositingEnabled = true + +# +# +# Provision Perps keeper oracle +# +# +[invoke.register_perps_keeper_oracle_node_with_constant_price] +target = ["synthetix.oracle_manager.Proxy"] +func = "registerNode" +args = [ + 8, # 8 = constant + "<%= defaultAbiCoder.encode(['int256'], [parseEther('0.1')]) %>", # 1 parameter: price, always 10c + [ + ], +] +extra.perps_keeper_cost_usd_oracle_id.event = "NodeRegistered" +extra.perps_keeper_cost_usd_oracle_id.arg = 0 + +# +# +# Provision Perps factory +# +# +[import.perps_factory] +source = "synthetix-perps-market:<%= settings.version %>" +depends = ["import.spot_factory"] + +[invoke.permit_owner_to_register_markets] +target = ["synthetix.CoreProxy"] +fromCall.func = "owner" +func = "addToFeatureFlagAllowlist" +args = ["<%= formatBytes32String('registerMarket') %>", "<%= settings.owner %>"] + +[invoke.permit_everyone_to_use_perps] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setFeatureFlagAllowAll" +args = ["<%= formatBytes32String('perpsSystem') %>", true] + +# +# +# Global Perps configuration +# +# +[invoke.configure_perps_liquidation_reward_limits] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setKeeperRewardGuards" +args = [ + # uint256 minKeeperRewardUsd + "<%= parseEther('1') %>", + # uint256 minKeeperProfitRatioD18 + "<%= parseEther('1') %>", + # uint256 maxKeeperRewardUsd + "<%= parseEther('500') %>", + # uint256 maxKeeperScalingRatioD18 + "<%= parseEther('1') %>", +] + +[invoke.configure_perps_max_collateral_for_susd] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setCollateralConfiguration" +args = [ + # 0 - sUSD + "0", + "<%= MaxUint256 %>", + "0", + "0", + "0", +] + +[invoke.configure_perps_synth_deduction_priority] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setSynthDeductionPriority" +args = [ + [ + # 0 - sUSD + "0", + ], +] + +[invoke.configure_per_account_caps_perps] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setPerAccountCaps" +args = [ + # uint128 maxPositionsPerAccount, + "5", + # uint128 maxCollateralsPerAccount + "3", +] + +[invoke.configure_perps_market_keeper_cost_node_id] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "updateKeeperCostNodeId" +args = ["<%= extras.perps_keeper_cost_usd_oracle_id %>"] + +# +# +# +# +# +# +# +# +# +# +# +# +# +# ETH stuff +# +# +# +# +# +# +# +# +# +# +# +# +# +# +# +# +# +# +[invoke.create_eth_perps_market] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "createMarket" +args = ["100", "Ethereum", "ETH"] +extra.perps_eth_market_id.event = "MarketCreated" +extra.perps_eth_market_id.arg = 0 +depends = [ + 'invoke.permit_owner_to_register_markets', + 'invoke.permit_everyone_to_use_perps', +] + +[invoke.register_eth_oracle_node_with_constant_price] +target = ["synthetix.oracle_manager.Proxy"] +func = "registerNode" +args = [ + 8, # 8 = constant + "<%= defaultAbiCoder.encode(['int256'], [parseEther('1000')]) %>", # 1 parameter: price + [ + ], +] +extra.eth_oracle_id.event = "NodeRegistered" +extra.eth_oracle_id.arg = 0 + +[invoke.configure_price_data_for_perps_eth] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "updatePriceData" +args = [ + "<%= extras.perps_eth_market_id %>", + "<%= extras.eth_oracle_id %>", + # big_cap_strict_staleness_tolerance + "3600", +] + +[invoke.configure_settlement_strategy_for_perps_eth] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "addSettlementStrategy" +args = [ + "<%= extras.perps_eth_market_id %>", + # strategyType = 0 (pyth) + { strategyType = "0", settlementDelay = "15", settlementWindowDuration = "60", priceVerificationContract = "<%= imports.pyth_erc7412_wrapper.contracts.PythERC7412Wrapper.address %>", feedId = "<%= settings.pyth_eth_feed_id %>", settlementReward = "<%= parseEther('0.1') %>", disabled = false }, +] +extra.synth_eth_settlement_strategy.event = "SettlementStrategyAdded" +extra.synth_eth_settlement_strategy.arg = 1 + +[invoke.configure_funding_parameters_for_perps_eth] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setFundingParameters" +args = [ + # uint128 marketId + "<%= extras.perps_eth_market_id %>", + # uint256 skewScale + "<%= parseEther('1000000') %>", + # uint256 maxFundingVelocity + "<%= parseEther('9') %>", +] + +[invoke.configure_order_fees_for_perps_eth] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setOrderFees" +args = [ + # uint128 marketId + "<%= extras.perps_eth_market_id %>", + # uint256 makerFeeRatio + "<%= parseEther('0.0002') %>", + # uint256 takerFeeRatio + "<%= parseEther('0.0005') %>", +] + +[invoke.configure_max_market_size_for_perps_eth] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setMaxMarketSize" +args = [ + # uint128 marketId + "<%= extras.perps_eth_market_id %>", + # uint256 maxMarketSize + "<%= parseEther(String(100_000)) %>", +] + +[invoke.configure_max_liquidation_parameters_for_perps_eth] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setMaxLiquidationParameters" +args = [ + # uint128 marketId + "<%= extras.perps_eth_market_id %>", + # uint256 maxLiquidationLimitAccumulationMultiplier + "<%= parseEther('1') %>", + # uint256 maxSecondsInLiquidationWindow + "30", + # uint256 maxLiquidationPd + "<%= parseEther('0.0016') %>", + # address endorsedLiquidator + "0x0000000000000000000000000000000000000000", +] + +[invoke.configure_liquidation_parameters_for_perps_eth] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setLiquidationParameters" +args = [ + # uint128 marketId + "<%= extras.perps_eth_market_id %>", + # uint256 initialMarginRatioD18 + "<%= parseEther('1') %>", + # uint256 minimumInitialMarginRatioD18 + "<%= parseEther('0.02') %>", + # uint256 maintenanceMarginScalarD18 + "<%= parseEther('0.5') %>", + # uint256 flagRewardRatioD18 + "<%= parseEther('0.01') %>", + # uint256 minimumPositionMargin + "<%= parseEther('0') %>", +] + +[invoke.configure_locked_oi_ratio_for_perps_eth] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setLockedOiRatio" +args = [ + # uint128 marketId + "<%= extras.perps_eth_market_id %>", + # uint256 lockedOiRatioD18 + "<%= parseEther('0.5') %>", +] + +# +# +# +# +# +# +# +# +# +# +# +# +# +# BTC stuff +# +# +# +# +# +# +# +# +# +# +# +# +# +# +# +# +# +# +[invoke.create_btc_perps_market] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "createMarket" +args = ["200", "Bitcoin", "BTC"] +extra.perps_btc_market_id.event = "MarketCreated" +extra.perps_btc_market_id.arg = 0 +depends = [ + 'invoke.permit_owner_to_register_markets', + 'invoke.permit_everyone_to_use_perps', +] + +[invoke.register_btc_oracle_node_with_constant_price] +target = ["synthetix.oracle_manager.Proxy"] +func = "registerNode" +args = [ + 8, # 8 = constant + "<%= defaultAbiCoder.encode(['int256'], [parseEther(String(30_000))]) %>", # 1 parameter: price + [ + ], +] +extra.btc_oracle_id.event = "NodeRegistered" +extra.btc_oracle_id.arg = 0 + +[invoke.configure_price_data_for_perps_btc] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "updatePriceData" +args = [ + "<%= extras.perps_btc_market_id %>", + "<%= extras.btc_oracle_id %>", + # big_cap_strict_staleness_tolerance + "3600", +] + +[invoke.configure_settlement_strategy_for_perps_btc] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "addSettlementStrategy" +args = [ + "<%= extras.perps_btc_market_id %>", + # strategyType = 0 (pyth) + { strategyType = "0", settlementDelay = "15", settlementWindowDuration = "60", priceVerificationContract = "<%= imports.pyth_erc7412_wrapper.contracts.PythERC7412Wrapper.address %>", feedId = "<%= settings.pyth_btc_feed_id %>", settlementReward = "<%= parseEther('0.1') %>", disabled = false }, +] +extra.btc_pyth_settlement_strategy.event = "SettlementStrategyAdded" +extra.btc_pyth_settlement_strategy.arg = 2 + +[invoke.configure_funding_parameters_for_perps_btc] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setFundingParameters" +args = [ + # uint128 marketId + "<%= extras.perps_btc_market_id %>", + # uint256 skewScale + "<%= parseEther('1000000') %>", + # uint256 maxFundingVelocity + "<%= parseEther('9') %>", +] + +[invoke.configure_order_fees_for_perps_btc] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setOrderFees" +args = [ + # uint128 marketId + "<%= extras.perps_btc_market_id %>", + # uint256 makerFeeRatio + "<%= parseEther('0.0007') %>", + # uint256 takerFeeRatio + "<%= parseEther('0.0003') %>", +] + +[invoke.configure_max_market_size_for_perps_btc] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setMaxMarketSize" +args = [ + # uint128 marketId + "<%= extras.perps_btc_market_id %>", + # uint256 maxMarketSize + "<%= parseEther(String(30)) %>", +] + +[invoke.configure_max_liquidation_parameters_for_perps_btc] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setMaxLiquidationParameters" +args = [ + # uint128 marketId + "<%= extras.perps_btc_market_id %>", + # uint256 maxLiquidationLimitAccumulationMultiplier + "<%= parseEther('1') %>", + # uint256 maxSecondsInLiquidationWindow + "30", + # uint256 maxLiquidationPd + "<%= parseEther('0.0016') %>", + # address endorsedLiquidator + "0x0000000000000000000000000000000000000000", +] + +[invoke.configure_liquidation_parameters_for_perps_btc] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setLiquidationParameters" +args = [ + # uint128 marketId + "<%= extras.perps_btc_market_id %>", + # uint256 initialMarginRatioD18 + "<%= parseEther('1') %>", + # uint256 minimumInitialMarginRatioD18 + "<%= parseEther('0.02') %>", + # uint256 maintenanceMarginScalarD18 + "<%= parseEther('0.5') %>", + # uint256 flagRewardRatioD18 + "<%= parseEther('0.01') %>", + # uint256 minimumPositionMargin + "<%= parseEther('0') %>", +] + +[invoke.configure_locked_oi_ratio_for_perps_btc] +target = ["perps_factory.PerpsMarketProxy"] +fromCall.func = "owner" +func = "setLockedOiRatio" +args = [ + # uint128 marketId + "<%= extras.perps_btc_market_id %>", + # uint256 lockedOiRatioD18 + "<%= parseEther('0.5') %>", +] diff --git a/yarn.lock b/yarn.lock index 308f804c39..c414b0763c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3213,6 +3213,21 @@ __metadata: languageName: unknown linkType: soft +"@synthetixio/perps-reward-distributor@workspace:auxiliary/PerpsRewardDistributor": + version: 0.0.0-use.local + resolution: "@synthetixio/perps-reward-distributor@workspace:auxiliary/PerpsRewardDistributor" + dependencies: + "@synthetixio/common-config": "workspace:*" + "@synthetixio/core-contracts": "workspace:*" + "@synthetixio/core-modules": "workspace:*" + "@synthetixio/docgen": "workspace:*" + hardhat: "npm:^2.19.5" + solidity-docgen: "npm:^0.6.0-beta.36" + ts-node: "npm:^10.9.2" + typescript: "npm:^5.3.3" + languageName: unknown + linkType: soft + "@synthetixio/pyth-erc7412-wrapper@workspace:auxiliary/PythERC7412Wrapper": version: 0.0.0-use.local resolution: "@synthetixio/pyth-erc7412-wrapper@workspace:auxiliary/PythERC7412Wrapper"