diff --git a/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol b/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol index fa95e0fa07..fb2f190d9d 100644 --- a/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol +++ b/markets/perps-market/contracts/interfaces/IGlobalPerpsMarketModule.sol @@ -75,4 +75,10 @@ interface IGlobalPerpsMarketModule { external view returns (uint256 minLiquidationRewardUsd, uint256 maxLiquidationRewardUsd); + + /** + * @notice Gets the total collateral value of all deposited collateral from all traders. + * @return totalCollateralValue value of all collateral + */ + function totalGlobalCollateralValue() external view returns (uint256 totalCollateralValue); } diff --git a/markets/perps-market/contracts/interfaces/IPerpsAccountModule.sol b/markets/perps-market/contracts/interfaces/IPerpsAccountModule.sol index b49c5d0ee4..e34f75bdbf 100644 --- a/markets/perps-market/contracts/interfaces/IPerpsAccountModule.sol +++ b/markets/perps-market/contracts/interfaces/IPerpsAccountModule.sol @@ -61,14 +61,14 @@ interface IPerpsAccountModule { * @notice Gets the details of an open position. * @param accountId Id of the account. * @param marketId Id of the position market. - * @return pnl pnl of the position. + * @return totalPnl pnl of the entire position including funding. * @return accruedFunding accrued funding of the position. - * @return size size of the position. + * @return positionSize size of the position. */ function getOpenPosition( uint128 accountId, uint128 marketId - ) external view returns (int pnl, int accruedFunding, int size); + ) external view returns (int256 totalPnl, int256 accruedFunding, int128 positionSize); /** * @notice Gets the available margin of an account. It can be negative due to pnl. diff --git a/markets/perps-market/contracts/modules/AsyncOrderSettlementModule.sol b/markets/perps-market/contracts/modules/AsyncOrderSettlementModule.sol index 2378eccbbe..c1216a73d9 100644 --- a/markets/perps-market/contracts/modules/AsyncOrderSettlementModule.sol +++ b/markets/perps-market/contracts/modules/AsyncOrderSettlementModule.sol @@ -140,7 +140,7 @@ contract AsyncOrderSettlementModule is IAsyncOrderSettlementModule, IMarketEvent PerpsAccount.Data storage perpsAccount = PerpsAccount.load(runtime.accountId); // use fill price to calculate realized pnl - (runtime.pnl, , , ) = oldPosition.getPnl(fillPrice); + (runtime.pnl, , , , ) = oldPosition.getPnl(fillPrice); runtime.pnlUint = MathUtil.abs(runtime.pnl); if (runtime.pnl > 0) { diff --git a/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol b/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol index 8f3ad29ac5..26528b9ae8 100644 --- a/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol +++ b/markets/perps-market/contracts/modules/GlobalPerpsMarketModule.sol @@ -2,6 +2,7 @@ pragma solidity >=0.8.11 <0.9.0; import {GlobalPerpsMarketConfiguration} from "../storage/GlobalPerpsMarketConfiguration.sol"; +import {GlobalPerpsMarket} from "../storage/GlobalPerpsMarket.sol"; import {IGlobalPerpsMarketModule} from "../interfaces/IGlobalPerpsMarketModule.sol"; import {OwnableStorage} from "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol"; @@ -11,6 +12,7 @@ import {OwnableStorage} from "@synthetixio/core-contracts/contracts/ownership/Ow */ contract GlobalPerpsMarketModule is IGlobalPerpsMarketModule { using GlobalPerpsMarketConfiguration for GlobalPerpsMarketConfiguration.Data; + using GlobalPerpsMarket for GlobalPerpsMarket.Data; /** * @inheritdoc IGlobalPerpsMarketModule @@ -81,4 +83,16 @@ contract GlobalPerpsMarketModule is IGlobalPerpsMarketModule { minLiquidationRewardUsd = store.minLiquidationRewardUsd; maxLiquidationRewardUsd = store.maxLiquidationRewardUsd; } + + /** + * @inheritdoc IGlobalPerpsMarketModule + */ + function totalGlobalCollateralValue() + external + view + override + returns (uint256 totalCollateralValue) + { + return GlobalPerpsMarket.load().totalCollateralValue(); + } } diff --git a/markets/perps-market/contracts/modules/PerpsAccountModule.sol b/markets/perps-market/contracts/modules/PerpsAccountModule.sol index 7a5d8f1f6a..01d08febf2 100644 --- a/markets/perps-market/contracts/modules/PerpsAccountModule.sol +++ b/markets/perps-market/contracts/modules/PerpsAccountModule.sol @@ -90,15 +90,15 @@ contract PerpsAccountModule is IPerpsAccountModule { function getOpenPosition( uint128 accountId, uint128 marketId - ) external view override returns (int, int, int) { + ) external view override returns (int256 totalPnl, int256 accruedFunding, int128 positionSize) { PerpsMarket.Data storage perpsMarket = PerpsMarket.loadValid(marketId); Position.Data storage position = perpsMarket.positions[accountId]; - (, int pnl, int accruedFunding, , ) = position.getPositionData( + (, totalPnl, , accruedFunding, , ) = position.getPositionData( PerpsPrice.getCurrentPrice(marketId) ); - return (pnl, accruedFunding, position.size); + return (totalPnl, accruedFunding, position.size); } /** diff --git a/markets/perps-market/contracts/modules/PerpsMarketFactoryModule.sol b/markets/perps-market/contracts/modules/PerpsMarketFactoryModule.sol index 5f622ebbb8..98dd4ebf72 100644 --- a/markets/perps-market/contracts/modules/PerpsMarketFactoryModule.sol +++ b/markets/perps-market/contracts/modules/PerpsMarketFactoryModule.sol @@ -23,6 +23,8 @@ import {ParameterError} from "@synthetixio/core-contracts/contracts/errors/Param import {MathUtil} from "../utils/MathUtil.sol"; import {PerpsMarketConfiguration} from "../storage/PerpsMarketConfiguration.sol"; import {IMarket} from "@synthetixio/main/contracts/interfaces/external/IMarket.sol"; +import {SetUtil} from "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; +import {SafeCastU256, SafeCastI256, SafeCastU128} from "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; /** * @title Module for registering perpetual futures markets. The factory tracks all markets in the system and consolidates implementation. @@ -34,6 +36,11 @@ contract PerpsMarketFactoryModule is IPerpsMarketFactoryModule { using GlobalPerpsMarket for GlobalPerpsMarket.Data; using PerpsPrice for PerpsPrice.Data; using DecimalMath for uint256; + using SafeCastU256 for uint256; + using SafeCastU128 for uint128; + using SafeCastI256 for int256; + using SetUtil for SetUtil.UintSet; + using PerpsMarket for PerpsMarket.Data; bytes32 private constant _CREATE_MARKET_FEATURE_FLAG = "createMarket"; @@ -101,11 +108,33 @@ contract PerpsMarketFactoryModule is IPerpsMarketFactoryModule { function name(uint128 perpsMarketId) external view override returns (string memory) { // todo: set name on initialize? + perpsMarketId; // silence unused variable warning return "Perps Market"; } function reportedDebt(uint128 perpsMarketId) external view override returns (uint256) { - // TODO + PerpsMarketFactory.Data storage factory = PerpsMarketFactory.load(); + + if (factory.perpsMarketId == perpsMarketId) { + // debt is the total debt of all markets + // can be computed as total collateral value - sum_each_market( debt ) + uint totalCollateralValue = GlobalPerpsMarket.load().totalCollateralValue(); + int totalMarketDebt; + + SetUtil.UintSet storage activeMarkets = GlobalPerpsMarket.load().activeMarkets; + uint256 activeMarketsLength = activeMarkets.length(); + for (uint i = 1; i <= activeMarketsLength; i++) { + uint128 marketId = activeMarkets.valueAt(i).to128(); + totalMarketDebt += PerpsMarket.load(marketId).marketDebt( + PerpsPrice.getCurrentPrice(marketId) + ); + } + + int totalDebt = totalCollateralValue.toInt() + totalMarketDebt; + return totalDebt < 0 ? 0 : totalDebt.toUint(); + } + + // TODO Should revert if perpsMarketId is not correct??? return 0; } diff --git a/markets/perps-market/contracts/storage/GlobalPerpsMarket.sol b/markets/perps-market/contracts/storage/GlobalPerpsMarket.sol index 36dd7a74a8..77ad14a244 100644 --- a/markets/perps-market/contracts/storage/GlobalPerpsMarket.sol +++ b/markets/perps-market/contracts/storage/GlobalPerpsMarket.sol @@ -66,7 +66,7 @@ library GlobalPerpsMarket { ISpotMarketSystem spotMarket = PerpsMarketFactory.load().spotMarket; SetUtil.UintSet storage activeCollateralTypes = self.activeCollateralTypes; uint256 activeCollateralLength = activeCollateralTypes.length(); - for (uint i = 1; i < activeCollateralLength; i++) { + for (uint i = 1; i <= activeCollateralLength; i++) { uint128 synthMarketId = activeCollateralTypes.valueAt(i).to128(); if (synthMarketId == 0) { diff --git a/markets/perps-market/contracts/storage/PerpsAccount.sol b/markets/perps-market/contracts/storage/PerpsAccount.sol index 5fe986cd90..d49d72eee5 100644 --- a/markets/perps-market/contracts/storage/PerpsAccount.sol +++ b/markets/perps-market/contracts/storage/PerpsAccount.sol @@ -188,7 +188,7 @@ library PerpsAccount { for (uint i = 1; i <= self.openPositionMarketIds.length(); i++) { uint128 marketId = self.openPositionMarketIds.valueAt(i).to128(); Position.Data storage position = PerpsMarket.load(marketId).positions[self.id]; - (int pnl, , , ) = position.getPnl(PerpsPrice.getCurrentPrice(marketId)); + (int pnl, , , , ) = position.getPnl(PerpsPrice.getCurrentPrice(marketId)); totalPnl += pnl; } } @@ -207,7 +207,7 @@ library PerpsAccount { uint128 marketId = self.openPositionMarketIds.valueAt(i).to128(); Position.Data storage position = PerpsMarket.load(marketId).positions[self.id]; - (uint openInterest, , , , ) = position.getPositionData( + (uint openInterest, , , , , ) = position.getPositionData( PerpsPrice.getCurrentPrice(marketId) ); totalAccountOpenInterest += openInterest; diff --git a/markets/perps-market/contracts/storage/PerpsMarket.sol b/markets/perps-market/contracts/storage/PerpsMarket.sol index 5efebe9ed4..696bfa1c4b 100644 --- a/markets/perps-market/contracts/storage/PerpsMarket.sol +++ b/markets/perps-market/contracts/storage/PerpsMarket.sol @@ -36,12 +36,18 @@ library PerpsMarket { uint128 id; int256 skew; uint256 size; - int lastFundingRate; - int lastFundingValue; + // TODO: move to new data structure? + int256 lastFundingRate; + int256 lastFundingValue; uint256 lastFundingTime; // liquidation data uint128 lastTimeLiquidationCapacityUpdated; uint128 lastUtilizedLiquidationCapacity; + // debt calculation + // accumulates total notional size of the market including accrued funding until the last time any position changed + int256 debtCorrectionAccumulator; + // accountId => asyncOrder + mapping(uint => AsyncOrder.Data) asyncOrders; // accountId => position mapping(uint => Position.Data) positions; } @@ -139,7 +145,9 @@ library PerpsMarket { } /** - * @dev If you call this method, please ensure you emit an event so offchain solution can index market state history properly + * @dev Use this function to update both market/position size/skew. + * @dev Size and skew should not be updated directly. + * @dev The return value is used to emit a MarketUpdated event. */ function updatePositionData( Data storage self, @@ -147,12 +155,24 @@ library PerpsMarket { Position.Data memory newPosition ) internal returns (MarketUpdateData memory) { Position.Data storage oldPosition = self.positions[accountId]; + int128 oldPositionSize = oldPosition.size; int128 newPositionSize = newPosition.size; self.size = (self.size + MathUtil.abs(newPositionSize)) - MathUtil.abs(oldPositionSize); self.skew += newPositionSize - oldPositionSize; + uint currentPrice = newPosition.latestInteractionPrice; + (int totalPositionPnl, , , , ) = oldPosition.getPnl(currentPrice); + + int sizeDelta = newPositionSize - oldPositionSize; + int fundingDelta = calculateNextFunding(self, currentPrice).mulDecimal(sizeDelta); + int notionalDelta = currentPrice.toInt().mulDecimal(sizeDelta); + + // update the market debt correction accumulator before losing oldPosition details + // by adding the new updated notional (old - new size) plus old position pnl + self.debtCorrectionAccumulator += fundingDelta + notionalDelta + totalPositionPnl; + oldPosition.update(newPosition); return @@ -280,4 +300,17 @@ library PerpsMarket { } } } + + /** + * @dev Returns the market debt incurred by all positions + * @notice Market debt is the sum of all position sizes multiplied by the price, and old positions pnl that is included in the debt correction accumulator. + */ + function marketDebt(Data storage self, uint price) internal view returns (int) { + // all positions sizes multiplied by the price is equivalent to skew times price + // and the debt correction accumulator is the sum of all positions pnl + int traderUnrealizedPnl = self.skew.mulDecimal(price.toInt()); + int unrealizedFunding = self.skew.mulDecimal(calculateNextFunding(self, price)); + + return traderUnrealizedPnl + unrealizedFunding - self.debtCorrectionAccumulator; + } } diff --git a/markets/perps-market/contracts/storage/Position.sol b/markets/perps-market/contracts/storage/Position.sol index b333102473..61df4de0d9 100644 --- a/markets/perps-market/contracts/storage/Position.sol +++ b/markets/perps-market/contracts/storage/Position.sol @@ -47,26 +47,38 @@ library Position { view returns ( uint256 notionalValue, - int pnl, + int totalPnl, + int pricePnl, int accruedFunding, int netFundingPerUnit, int nextFunding ) { - (pnl, accruedFunding, netFundingPerUnit, nextFunding) = getPnl(self, price); + (totalPnl, pricePnl, accruedFunding, netFundingPerUnit, nextFunding) = getPnl(self, price); notionalValue = getNotionalValue(self, price); } function getPnl( Data storage self, uint price - ) internal view returns (int pnl, int accruedFunding, int netFundingPerUnit, int nextFunding) { + ) + internal + view + returns ( + int totalPnl, + int pricePnl, + int accruedFunding, + int netFundingPerUnit, + int nextFunding + ) + { nextFunding = PerpsMarket.load(self.marketId).calculateNextFunding(price); netFundingPerUnit = nextFunding - self.latestInteractionFunding; accruedFunding = self.size.mulDecimal(netFundingPerUnit); int priceShift = price.toInt() - self.latestInteractionPrice.toInt(); - pnl = self.size.mulDecimal(priceShift) + accruedFunding; + pricePnl = self.size.mulDecimal(priceShift); + totalPnl = pricePnl + accruedFunding; } function getNotionalValue(Data storage self, uint256 price) internal view returns (uint256) { diff --git a/markets/perps-market/storage.dump.sol b/markets/perps-market/storage.dump.sol index c5d93f6943..5579446c2d 100644 --- a/markets/perps-market/storage.dump.sol +++ b/markets/perps-market/storage.dump.sol @@ -575,11 +575,13 @@ library PerpsMarket { uint128 id; int256 skew; uint256 size; - int lastFundingRate; - int lastFundingValue; + int256 lastFundingRate; + int256 lastFundingValue; uint256 lastFundingTime; uint128 lastTimeLiquidationCapacityUpdated; uint128 lastUtilizedLiquidationCapacity; + int256 debtCorrectionAccumulator; + mapping(uint => AsyncOrder.Data) asyncOrders; mapping(uint => Position.Data) positions; } struct MarketUpdateData { diff --git a/markets/perps-market/test/integration/Market/MarketDebt.test.ts b/markets/perps-market/test/integration/Market/MarketDebt.test.ts new file mode 100644 index 0000000000..4db8929b31 --- /dev/null +++ b/markets/perps-market/test/integration/Market/MarketDebt.test.ts @@ -0,0 +1,341 @@ +import { PerpsMarket, bn, bootstrapMarkets } from '../bootstrap'; +import { OpenPositionData, depositCollateral, openPosition } from '../helpers'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import { Signer, ethers } from 'ethers'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; + +describe('Market Debt - single market', () => { + const orderFees = { + makerFee: bn(0.0), // 0bps no fees + takerFee: bn(0.0), // 0bps no fees + }; + + const { systems, superMarketId, perpsMarkets, provider, trader1, trader2, trader3, keeper } = + bootstrapMarkets({ + synthMarkets: [ + { + name: 'Bitcoin', + token: 'snxBTC', + buyPrice: bn(10_000), + sellPrice: bn(10_000), + }, + ], + perpsMarkets: [ + { + requestedMarketId: bn(25), + name: 'Ether', + token: 'snxETH', + price: bn(1000), + // setting to 0 to avoid funding and p/d price change affecting pnl + fundingParams: { skewScale: bn(0), maxFundingVelocity: bn(0) }, + orderFees, + settlementStrategy: { + settlementReward: bn(0), + priceDeviationTolerance: bn(50), + }, + }, + ], + traderAccountIds: [2, 3, 4], + }); + + let perpsMarket: PerpsMarket; + + before('identify actors', async () => { + perpsMarket = perpsMarkets()[0]; + }); + + const marketActivities: Array<{ + name: string; + user?: { + trader: () => Signer; + accountId: number; + collateralDelta?: ethers.BigNumber; // amount of collateral to deposit or withdraw in the step + sizeDelta?: ethers.BigNumber; // position size change to open or close in the step + }; + market?: { price: ethers.BigNumber }; // market to change the price and new price + expected?: { + // expected results to check on the step + marketDebt: ethers.BigNumber; + }; + }> = [ + { + name: 'initial state', + market: { + price: bn(1000), + }, + expected: { + marketDebt: bn(0), + }, + }, + { + name: 'acc1 deposits 1000', + user: { + trader: trader1, + accountId: 2, + collateralDelta: bn(1000), + }, + market: { + price: bn(1000), + }, + expected: { + marketDebt: bn(1000), // 1000 deposited + }, + }, + { + name: 'acc1 opens 10x 10 long eth at 1000', + user: { + trader: trader1, + accountId: 2, + sizeDelta: bn(10), + }, + market: { + price: bn(1000), + }, + expected: { + marketDebt: bn(1000), // 1000 deposited + }, + }, + { + name: 'eth price change to 1200', + market: { + price: bn(1200), + }, + expected: { + marketDebt: bn(3000), // 1000 deposited + 2000 pnl + }, + }, + { + name: 'acc2 deposits 1200', + user: { + trader: trader2, + accountId: 3, + collateralDelta: bn(1200), + }, + market: { + price: bn(1200), + }, + expected: { + marketDebt: bn(4200), // 2200 deposited + 2000 pnl + }, + }, + { + name: 'acc2 opens 10x short 5 eth at 1200', + user: { + trader: trader2, + accountId: 3, + sizeDelta: bn(-5), + }, + market: { + price: bn(1200), + }, + expected: { + marketDebt: bn(4200), // 2200 deposited + 2000 pnl + 0 pnl + }, + }, + { + name: 'eht price change to 1100', + market: { + price: bn(1100), + }, + expected: { + marketDebt: bn(3700), // 2200 deposited + 1000 pnl + 500 pnl + }, + }, + { + name: 'acc3 deposits 3300', + user: { + trader: trader3, + accountId: 4, + collateralDelta: bn(3300), + }, + market: { + price: bn(1100), + }, + expected: { + marketDebt: bn(7000), // 5500 deposited + 1000 pnl + 500 pnl + }, + }, + { + name: 'acc3 opens 10x short 10 eth at 1100', + user: { + trader: trader3, + accountId: 4, + sizeDelta: bn(-10), + }, + market: { + price: bn(1100), + }, + expected: { + marketDebt: bn(7000), // 5500 deposited + 1000 pnl + 500 pnl + 0 pnl + }, + }, + { + name: 'eth price change to 1000', + market: { + price: bn(1000), + }, + expected: { + marketDebt: bn(7500), // 5500 deposited + 0 pnl + 1000 pnl + 1000 pnl + }, + }, + { + name: 'acc2 closes short', + user: { + trader: trader2, + accountId: 3, + sizeDelta: bn(5), + }, + market: { + price: bn(1000), + }, + expected: { + marketDebt: bn(7500), // 5500 deposited + 0 pnl + 1000 pnl_fixed + 1000 pnl + }, + }, + { + name: 'acc2 withdraw 1200 + 1000 (pnl)', + user: { + trader: trader2, + accountId: 3, + collateralDelta: bn(-2200), + }, + market: { + price: bn(1000), + }, + expected: { + marketDebt: bn(5300), // 4300 deposited + 0 pnl + 1000 pnl + }, + }, + { + name: 'eth price change to 1200', + market: { + price: bn(1200), + }, + expected: { + marketDebt: bn(5300), // 4300 deposited + 2000 pnl - 1000 pnl + }, + }, + { + name: 'acc3 closes short', + user: { + trader: trader3, + accountId: 4, + sizeDelta: bn(10), + }, + market: { + price: bn(1200), + }, + expected: { + marketDebt: bn(5300), // 4300 deposited + 2000 pnl - 1000 pnl_fixed + }, + }, + { + name: 'acc3 withdraw 3300 - 1000 (pnl)', + user: { + trader: trader3, + accountId: 4, + collateralDelta: bn(-2300), + }, + market: { + price: bn(1200), + }, + expected: { + marketDebt: bn(3000), // 1000 deposited + 2000 pnl + }, + }, + { + name: 'eth price change to 900', + market: { + price: bn(900), + }, + expected: { + marketDebt: bn(0), // 1000 deposited - 1000 pnl + }, + }, + { + name: 'acc1 closes long', + user: { + trader: trader1, + accountId: 2, + sizeDelta: bn(-10), + }, + market: { + price: bn(900), + }, + expected: { + marketDebt: bn(0), // 1000 deposited - 1000 pnl + }, + }, + ]; + + describe(`Using snxUSD as collateral`, () => { + let commonOpenPositionProps: Pick< + OpenPositionData, + 'systems' | 'provider' | 'keeper' | 'settlementStrategyId' | 'marketId' + >; + const restoreActivities = snapshotCheckpoint(provider); + + before('identify common props', async () => { + commonOpenPositionProps = { + systems, + provider, + settlementStrategyId: perpsMarket.strategyId(), + keeper: keeper(), + marketId: perpsMarket.marketId(), + }; + }); + + marketActivities.forEach((marketActivity) => { + describe(`Market Activity Step: ${marketActivity.name}`, () => { + before('set price', async () => { + if ( + marketActivity.market && + marketActivity.market.price && + !marketActivity.market.price.isZero() + ) { + await perpsMarket.aggregator().mockSetCurrentPrice(marketActivity.market.price); + } + }); + + before('update collateral', async () => { + if ( + marketActivity.user && + marketActivity.user.collateralDelta && + !marketActivity.user.collateralDelta.isZero() + ) { + await depositCollateral({ + systems, + trader: marketActivity.user!.trader, + accountId: () => marketActivity.user!.accountId, + collaterals: [{ snxUSDAmount: () => marketActivity.user!.collateralDelta! }], + }); + } + }); + + before('update position', async () => { + if ( + marketActivity.user && + marketActivity.user.sizeDelta && + !marketActivity.user.sizeDelta.isZero() + ) { + await openPosition({ + ...commonOpenPositionProps, + trader: marketActivity.user!.trader(), + accountId: marketActivity.user!.accountId, + sizeDelta: marketActivity.user!.sizeDelta!, + price: marketActivity.market!.price, + }); + } + }); + + it('should have correct market debt', async () => { + assertBn.equal( + await systems().PerpsMarket.reportedDebt(superMarketId()), + marketActivity.expected!.marketDebt + ); + }); + }); + }); + after(restoreActivities); + }); +}); diff --git a/markets/perps-market/test/integration/Market/MarketDebt.withFunding.test.ts b/markets/perps-market/test/integration/Market/MarketDebt.withFunding.test.ts new file mode 100644 index 0000000000..7b231ac665 --- /dev/null +++ b/markets/perps-market/test/integration/Market/MarketDebt.withFunding.test.ts @@ -0,0 +1,298 @@ +import { fastForwardTo, getTxTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { PerpsMarket, bn, bootstrapMarkets } from '../bootstrap'; +import { openPosition } from '../helpers'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import { ethers } from 'ethers'; + +const _SKEW_SCALE = bn(25_000); +const _MAX_FUNDING_VELOCITY = bn(3); +const _SECONDS_IN_DAY = 24 * 60 * 60; + +describe('Market Debt - with funding', () => { + const traderAccountIds = [2, 3, 4]; + const { systems, superMarketId, perpsMarkets, provider, trader1, trader2, trader3, keeper } = + bootstrapMarkets({ + synthMarkets: [ + { + name: 'Bitcoin', + token: 'snxBTC', + buyPrice: bn(10_000), + sellPrice: bn(10_000), + }, + ], + perpsMarkets: [ + { + requestedMarketId: bn(25), + name: 'Ether', + token: 'snxETH', + price: bn(1000), + // setting to 0 to avoid funding and p/d price change affecting pnl + orderFees: { + makerFee: bn(0.0005), // 0bps no fees + takerFee: bn(0.00025), + }, + fundingParams: { skewScale: _SKEW_SCALE, maxFundingVelocity: _MAX_FUNDING_VELOCITY }, + liquidationParams: { + initialMarginFraction: bn(3), + maintenanceMarginFraction: bn(2), + maxLiquidationLimitAccumulationMultiplier: bn(1), + liquidationRewardRatio: bn(0.05), + maxSecondsInLiquidationWindow: ethers.BigNumber.from(10), + minimumPositionMargin: bn(0), + }, + settlementStrategy: { + settlementReward: bn(0), + }, + }, + ], + traderAccountIds, + }); + + let perpsMarket: PerpsMarket; + before('identify actors', () => { + perpsMarket = perpsMarkets()[0]; + }); + + before('add collateral to margin', async () => { + await systems().PerpsMarket.connect(trader1()).modifyCollateral(2, 0, bn(10_000)); + await systems().PerpsMarket.connect(trader2()).modifyCollateral(3, 0, bn(10_000)); + await systems().PerpsMarket.connect(trader3()).modifyCollateral(4, 0, bn(100_000)); + }); + + describe('with no positions', () => { + it('should report total collateral value as debt', async () => { + const debt = await systems().PerpsMarket.reportedDebt(superMarketId()); + assertBn.equal(debt, bn(120_000)); + }); + }); + + let openPositionTime: number; + describe('open positions', () => { + before(async () => { + await openPosition({ + systems, + provider, + trader: trader1(), + accountId: 2, + keeper: keeper(), + marketId: perpsMarket.marketId(), + sizeDelta: bn(150), + settlementStrategyId: perpsMarket.strategyId(), + price: bn(1000), + }); + await openPosition({ + systems, + provider, + trader: trader2(), + accountId: 3, + keeper: keeper(), + marketId: perpsMarket.marketId(), + sizeDelta: bn(-50), + settlementStrategyId: perpsMarket.strategyId(), + price: bn(1000), + }); + openPositionTime = await openPosition({ + systems, + provider, + trader: trader3(), + accountId: 4, + keeper: keeper(), + marketId: perpsMarket.marketId(), + sizeDelta: bn(200), + settlementStrategyId: perpsMarket.strategyId(), + price: bn(1000), + }); + }); + + let unrealizedTraderPnl: ethers.BigNumber, totalCollateralValue: ethers.BigNumber; + before('get unrealized trader pnl', async () => { + const [trader1Pnl] = await systems().PerpsMarket.getOpenPosition(2, perpsMarket.marketId()); + const [trader2Pnl] = await systems().PerpsMarket.getOpenPosition(3, perpsMarket.marketId()); + const [trader3Pnl] = await systems().PerpsMarket.getOpenPosition(4, perpsMarket.marketId()); + unrealizedTraderPnl = trader1Pnl.add(trader2Pnl).add(trader3Pnl); + totalCollateralValue = await systems().PerpsMarket.totalGlobalCollateralValue(); + }); + + it('reports correct debt', async () => { + const debt = await systems().PerpsMarket.reportedDebt(superMarketId()); + assertBn.near(debt, totalCollateralValue.add(unrealizedTraderPnl), bn(0.0001)); + }); + }); + + describe('a day goes by', () => { + before('fast forward', async () => { + await fastForwardTo(openPositionTime + _SECONDS_IN_DAY, provider()); + }); + + let unrealizedTraderPnl: ethers.BigNumber, totalCollateralValue: ethers.BigNumber; + before('get unrealized trader pnl', async () => { + const [trader1Pnl] = await systems().PerpsMarket.getOpenPosition(2, perpsMarket.marketId()); + const [trader2Pnl] = await systems().PerpsMarket.getOpenPosition(3, perpsMarket.marketId()); + const [trader3Pnl] = await systems().PerpsMarket.getOpenPosition(4, perpsMarket.marketId()); + unrealizedTraderPnl = trader1Pnl.add(trader2Pnl).add(trader3Pnl); + totalCollateralValue = await systems().PerpsMarket.totalGlobalCollateralValue(); + }); + + it('reports correct debt', async () => { + const debt = await systems().PerpsMarket.reportedDebt(superMarketId()); + assertBn.near(debt, totalCollateralValue.add(unrealizedTraderPnl), bn(0.0001)); + }); + }); + + describe('price change', () => { + before('change price', async () => { + await perpsMarket.aggregator().mockSetCurrentPrice(bn(1050)); + }); + + let unrealizedTraderPnl: ethers.BigNumber, totalCollateralValue: ethers.BigNumber; + before('get unrealized trader pnl', async () => { + const [trader1Pnl] = await systems().PerpsMarket.getOpenPosition(2, perpsMarket.marketId()); + const [trader2Pnl] = await systems().PerpsMarket.getOpenPosition(3, perpsMarket.marketId()); + const [trader3Pnl] = await systems().PerpsMarket.getOpenPosition(4, perpsMarket.marketId()); + unrealizedTraderPnl = trader1Pnl.add(trader2Pnl).add(trader3Pnl); + totalCollateralValue = await systems().PerpsMarket.totalGlobalCollateralValue(); + }); + + it('reports correct debt', async () => { + const debt = await systems().PerpsMarket.reportedDebt(superMarketId()); + assertBn.near(debt, totalCollateralValue.add(unrealizedTraderPnl), bn(0.0001)); + }); + }); + + describe('reduce trader 3 position', () => { + before(async () => { + await openPosition({ + systems, + provider, + trader: trader3(), + accountId: 4, + keeper: keeper(), + marketId: perpsMarket.marketId(), + sizeDelta: bn(-50), + settlementStrategyId: perpsMarket.strategyId(), + price: bn(1050), + }); + }); + + let unrealizedTraderPnl: ethers.BigNumber, totalCollateralValue: ethers.BigNumber; + before('get unrealized trader pnl', async () => { + const [trader1Pnl] = await systems().PerpsMarket.getOpenPosition(2, perpsMarket.marketId()); + const [trader2Pnl] = await systems().PerpsMarket.getOpenPosition(3, perpsMarket.marketId()); + const [trader3Pnl] = await systems().PerpsMarket.getOpenPosition(4, perpsMarket.marketId()); + totalCollateralValue = await systems().PerpsMarket.totalGlobalCollateralValue(); + unrealizedTraderPnl = trader1Pnl.add(trader2Pnl).add(trader3Pnl); + }); + + it('reports correct debt', async () => { + const debt = await systems().PerpsMarket.reportedDebt(superMarketId()); + assertBn.equal(debt, totalCollateralValue.add(unrealizedTraderPnl)); + }); + }); + + describe('short more eth trader 2 position', () => { + before('trader 2 adds more size', async () => { + await openPosition({ + systems, + provider, + trader: trader2(), + accountId: 3, + keeper: keeper(), + marketId: perpsMarket.marketId(), + sizeDelta: bn(-50), + settlementStrategyId: perpsMarket.strategyId(), + price: bn(1050), + }); + }); + + let unrealizedTraderPnl: ethers.BigNumber, totalCollateralValue: ethers.BigNumber; + before('get unrealized trader pnl', async () => { + const [trader1Pnl] = await systems().PerpsMarket.getOpenPosition(2, perpsMarket.marketId()); + const [trader2Pnl] = await systems().PerpsMarket.getOpenPosition(3, perpsMarket.marketId()); + const [trader3Pnl] = await systems().PerpsMarket.getOpenPosition(4, perpsMarket.marketId()); + totalCollateralValue = await systems().PerpsMarket.totalGlobalCollateralValue(); + unrealizedTraderPnl = trader1Pnl.add(trader2Pnl).add(trader3Pnl); + }); + + it('reports correct debt', async () => { + const debt = await systems().PerpsMarket.reportedDebt(superMarketId()); + assertBn.equal(debt, totalCollateralValue.add(unrealizedTraderPnl)); + }); + }); + + describe('trader 2 gets liquidated', () => { + before('change price', async () => { + await perpsMarket.aggregator().mockSetCurrentPrice(bn(1135)); + await systems().PerpsMarket.connect(keeper()).liquidate(3); + }); + + let unrealizedTraderPnl: ethers.BigNumber, totalCollateralValue: ethers.BigNumber; + before('get unrealized trader pnl', async () => { + const [trader1Pnl] = await systems().PerpsMarket.getOpenPosition(2, perpsMarket.marketId()); + const [trader2Pnl] = await systems().PerpsMarket.getOpenPosition(3, perpsMarket.marketId()); + const [trader3Pnl] = await systems().PerpsMarket.getOpenPosition(4, perpsMarket.marketId()); + totalCollateralValue = await systems().PerpsMarket.totalGlobalCollateralValue(); + unrealizedTraderPnl = trader1Pnl.add(trader2Pnl).add(trader3Pnl); + }); + + it('reports correct debt', async () => { + const debt = await systems().PerpsMarket.reportedDebt(superMarketId()); + assertBn.equal(debt, totalCollateralValue.add(unrealizedTraderPnl)); + }); + }); + + let partialLiquidationTime: number; + describe('trader 1 gets partially liquidated', () => { + before('change price', async () => { + await perpsMarket.aggregator().mockSetCurrentPrice(bn(950)); + const liquidateTxn = await systems().PerpsMarket.connect(keeper()).liquidate(2); + partialLiquidationTime = await getTxTime(provider(), liquidateTxn); + }); + + let unrealizedTraderPnl: ethers.BigNumber, totalCollateralValue: ethers.BigNumber; + before('get unrealized trader pnl', async () => { + const [trader1Pnl] = await systems().PerpsMarket.getOpenPosition(2, perpsMarket.marketId()); + const [trader2Pnl] = await systems().PerpsMarket.getOpenPosition(3, perpsMarket.marketId()); + const [trader3Pnl] = await systems().PerpsMarket.getOpenPosition(4, perpsMarket.marketId()); + totalCollateralValue = await systems().PerpsMarket.totalGlobalCollateralValue(); + unrealizedTraderPnl = trader1Pnl.add(trader2Pnl).add(trader3Pnl); + }); + + it('reports correct debt', async () => { + const debt = await systems().PerpsMarket.reportedDebt(superMarketId()); + assertBn.near(debt, totalCollateralValue.add(unrealizedTraderPnl), bn(0.000001)); + }); + }); + + describe('trader 1 gets fully liquidated', () => { + before('move forward by a day', async () => { + await fastForwardTo(partialLiquidationTime + _SECONDS_IN_DAY, provider()); + }); + + before('price change', async () => { + await perpsMarket.aggregator().mockSetCurrentPrice(bn(930)); + }); + + before('liquidate trader 1 again', async () => { + await systems().PerpsMarket.connect(keeper()).liquidate(2); + }); + + let unrealizedTraderPnl: ethers.BigNumber, totalCollateralValue: ethers.BigNumber; + before('get unrealized trader pnl', async () => { + const [trader1Pnl] = await systems().PerpsMarket.getOpenPosition(2, perpsMarket.marketId()); + const [trader2Pnl] = await systems().PerpsMarket.getOpenPosition(3, perpsMarket.marketId()); + const [trader3Pnl] = await systems().PerpsMarket.getOpenPosition(4, perpsMarket.marketId()); + totalCollateralValue = await systems().PerpsMarket.totalGlobalCollateralValue(); + unrealizedTraderPnl = trader1Pnl.add(trader2Pnl).add(trader3Pnl); + }); + + it('reports correct debt', async () => { + const debt = await systems().PerpsMarket.reportedDebt(superMarketId()); + assertBn.near(debt, totalCollateralValue.add(unrealizedTraderPnl), bn(0.000001)); + }); + + it('fully liquidated trader 1', async () => { + const positionData = await systems().PerpsMarket.getOpenPosition(2, perpsMarket.marketId()); + assertBn.equal(positionData.positionSize, bn(0)); + }); + }); +}); diff --git a/markets/perps-market/test/integration/bootstrap/bootstrap.ts b/markets/perps-market/test/integration/bootstrap/bootstrap.ts index c6d3cca9df..a1af4ce9e1 100644 --- a/markets/perps-market/test/integration/bootstrap/bootstrap.ts +++ b/markets/perps-market/test/integration/bootstrap/bootstrap.ts @@ -91,7 +91,7 @@ export function bootstrapMarkets(data: BootstrapArgs) { const { systems, signers, provider, owner, perpsMarkets, marketOwner, poolId, superMarketId } = chainStateWithPerpsMarkets; - const { trader1, trader2, keeper, restore } = bootstrapTraders({ + const { trader1, trader2, trader3, keeper, restore } = bootstrapTraders({ systems, signers, provider, @@ -145,6 +145,7 @@ export function bootstrapMarkets(data: BootstrapArgs) { restore, trader1, trader2, + trader3, keeper, owner, perpsMarkets, diff --git a/markets/perps-market/test/integration/bootstrap/bootstrapTraders.ts b/markets/perps-market/test/integration/bootstrap/bootstrapTraders.ts index 200d299b93..1ff8908279 100644 --- a/markets/perps-market/test/integration/bootstrap/bootstrapTraders.ts +++ b/markets/perps-market/test/integration/bootstrap/bootstrapTraders.ts @@ -19,10 +19,10 @@ export function bootstrapTraders(data: Data) { const { systems, signers, provider, accountIds, owner } = data; bootstrapStakers(systems, signers, bn(100_000)); - let trader1: ethers.Signer, trader2: ethers.Signer, keeper: ethers.Signer; + let trader1: ethers.Signer, trader2: ethers.Signer, trader3: ethers.Signer, keeper: ethers.Signer; before('provide access to create account', async () => { - [, , , trader1, trader2, keeper] = signers(); + [, , , trader1, trader2, trader3, keeper] = signers(); await systems() .PerpsMarket.connect(owner()) .addToFeatureFlagAllowlist( @@ -35,10 +35,16 @@ export function bootstrapTraders(data: Data) { ethers.utils.formatBytes32String('createAccount'), trader2.getAddress() ); + await systems() + .PerpsMarket.connect(owner()) + .addToFeatureFlagAllowlist( + ethers.utils.formatBytes32String('createAccount'), + trader3.getAddress() + ); }); before('infinite approve to perps/spot market proxy', async () => { - [, , , trader1, trader2] = signers(); + [, , , trader1, trader2, trader3] = signers(); await systems() .USD.connect(trader1) .approve(systems().PerpsMarket.address, ethers.constants.MaxUint256); @@ -51,11 +57,19 @@ export function bootstrapTraders(data: Data) { await systems() .USD.connect(trader2) .approve(systems().SpotMarket.address, ethers.constants.MaxUint256); + await systems() + .USD.connect(trader3) + .approve(systems().PerpsMarket.address, ethers.constants.MaxUint256); + await systems() + .USD.connect(trader3) + .approve(systems().SpotMarket.address, ethers.constants.MaxUint256); }); accountIds.forEach((id, idx) => { before(`create account ${id}`, async () => { - await systems().PerpsMarket.connect([trader1, trader2][idx])['createAccount(uint128)'](id); + await systems() + .PerpsMarket.connect([trader1, trader2, trader3][idx]) + ['createAccount(uint128)'](id); // eslint-disable-line no-unexpected-multiline }); }); @@ -64,6 +78,7 @@ export function bootstrapTraders(data: Data) { return { trader1: () => trader1, trader2: () => trader2, + trader3: () => trader3, keeper: () => keeper, restore, }; diff --git a/protocol/synthetix/contracts/interfaces/external/IMarket.sol b/protocol/synthetix/contracts/interfaces/external/IMarket.sol index 79776a442f..e4abc6a4ed 100644 --- a/protocol/synthetix/contracts/interfaces/external/IMarket.sol +++ b/protocol/synthetix/contracts/interfaces/external/IMarket.sol @@ -8,7 +8,7 @@ interface IMarket is IERC165 { /// @notice returns a human-readable name for a given market function name(uint128 marketId) external view returns (string memory); - /// @notice returns amount of USD that the market would try to mint256 if everything was withdrawn + /// @notice returns amount of USD that the market would try to mint if everything was withdrawn function reportedDebt(uint128 marketId) external view returns (uint256); /// @notice prevents reduction of available credit capacity by specifying this amount, for which withdrawals will be disallowed diff --git a/protocol/synthetix/test/common/stakers.ts b/protocol/synthetix/test/common/stakers.ts index 3c76aff847..61b5bd07d6 100644 --- a/protocol/synthetix/test/common/stakers.ts +++ b/protocol/synthetix/test/common/stakers.ts @@ -13,11 +13,11 @@ export function bootstrapStakers( signers: () => ethers.Signer[], delegateAmount: ethers.BigNumber = depositAmount ) { - let staker1: ethers.Signer, staker2: ethers.Signer; + let staker1: ethers.Signer, staker2: ethers.Signer, staker3: ethers.Signer; before('identify stakers', () => { - [, , , staker1, staker2] = signers(); + [, , , staker1, staker2, staker3] = signers(); }); - // create new bool + // create new pool before('create separate pool', async () => { const [owner] = signers(); await systems() @@ -28,6 +28,7 @@ export function bootstrapStakers( before('create traders', async () => { await stake(systems(), 2, 1000, staker1, delegateAmount); await stake(systems(), 2, 1001, staker2, delegateAmount); + await stake(systems(), 2, 1002, staker3, delegateAmount); }); before('mint usd', async () => { @@ -38,6 +39,9 @@ export function bootstrapStakers( await systems() .Core.connect(staker2) .mintUsd(1001, 2, collateralAddress, delegateAmount.mul(200)); + await systems() + .Core.connect(staker3) + .mintUsd(1002, 2, collateralAddress, delegateAmount.mul(200)); }); }