From 659899c3c1768b39c5ec385df9db3f97c1a60e3c Mon Sep 17 00:00:00 2001 From: Daniel Beal Date: Mon, 18 Nov 2024 22:16:21 -0800 Subject: [PATCH 1/2] add some new read-only functions to market and pool should make it much easier to understand the underlying computations that go behind capacity locked or similar issues in the future --- .../interfaces/IMarketManagerModule.sol | 30 +++++++++++++++ .../contracts/interfaces/IPoolModule.sol | 14 +++++++ .../modules/core/MarketManagerModule.sol | 37 +++++++++++++++++++ .../contracts/modules/core/PoolModule.sol | 16 ++++++++ .../synthetix/contracts/storage/Market.sol | 18 +++++++-- protocol/synthetix/contracts/storage/Pool.sol | 18 ++++++--- 6 files changed, 124 insertions(+), 9 deletions(-) diff --git a/protocol/synthetix/contracts/interfaces/IMarketManagerModule.sol b/protocol/synthetix/contracts/interfaces/IMarketManagerModule.sol index dad485e0f6..abf522a139 100644 --- a/protocol/synthetix/contracts/interfaces/IMarketManagerModule.sol +++ b/protocol/synthetix/contracts/interfaces/IMarketManagerModule.sol @@ -246,10 +246,40 @@ interface IMarketManagerModule { */ function getMinLiquidityRatio(uint128 marketId) external view returns (uint256 minRatioD18); + /** + * @notice Retrieves a list of pool ids supplying liquidity to the market. Additionally, returns a list of pool ids registered to the market, but not actively providing liquidity + * @param marketId the id of the market + */ function getMarketPools( uint128 marketId ) external returns (uint128[] memory inRangePoolIds, uint128[] memory outRangePoolIds); + /** + * @notice Retrieves the maximum value per share tolerated by a pool before it will bumped out of the pool + */ + function getMarketPoolMaxDebtPerShare( + uint128 marketId, + uint128 poolId + ) external view returns (int256); + + /** + * @notice Retrieves the amount of credit capacity added to the total provided by a single pool attached the market + * @param marketId the id of the market + * @param poolId the id of the specific pool to retrieve capacity contribution for + */ + function getMarketCapacityContributionFromPool( + uint128 marketId, + uint128 poolId + ) external view returns (uint256); + + /** + * @notice Retrieves internal data about the debt distribution on the market + * @param marketId the id of the market + * @param poolId the id of the specific pool to retrieve sharesD18 for + * @return sharesD18 the number of shares (USD denominated) supplied by the supplied pool in the market + * @return totalSharesD18 the number of shares (USD denominated) supplied by all pools attached to the market + * @return valuePerShareD27 the current value per share of the debt distribution + */ function getMarketPoolDebtDistribution( uint128 marketId, uint128 poolId diff --git a/protocol/synthetix/contracts/interfaces/IPoolModule.sol b/protocol/synthetix/contracts/interfaces/IPoolModule.sol index 1f6249f1eb..5c65e77331 100644 --- a/protocol/synthetix/contracts/interfaces/IPoolModule.sol +++ b/protocol/synthetix/contracts/interfaces/IPoolModule.sol @@ -217,6 +217,20 @@ interface IPoolModule { */ function getNominatedPoolOwner(uint128 poolId) external view returns (address nominatedOwner); + /** + * @notice Returns the current pool debt + * @param poolId The id of the pool whose total debt is being queried + * @return totalDebtD18 The total debt of all vaults put together + */ + function getPoolTotalDebt(uint128 poolId) external view returns (int256 totalDebtD18); + + /** + * @notice Returns the current pool debt divided by the computed value of the underlying vault liquidity + * @param poolId The id of the pool whose total debt is being queried + * @return debtPerShareD18 The total debt of all vaults put together divided by the computed collateral value of those vaults + */ + function getPoolDebtPerShare(uint128 poolId) external view returns (int256 debtPerShareD18); + /** * @notice Allows the system owner (not the pool owner) to set the system-wide minimum liquidity ratio. * @param minLiquidityRatio The new system-wide minimum liquidity ratio, denominated with 18 decimals of precision. (100% is represented by 1 followed by 18 zeros.) diff --git a/protocol/synthetix/contracts/modules/core/MarketManagerModule.sol b/protocol/synthetix/contracts/modules/core/MarketManagerModule.sol index a36df20fd2..af0c088039 100644 --- a/protocol/synthetix/contracts/modules/core/MarketManagerModule.sol +++ b/protocol/synthetix/contracts/modules/core/MarketManagerModule.sol @@ -172,6 +172,43 @@ contract MarketManagerModule is IMarketManagerModule { } } + /** + * @inheritdoc IMarketManagerModule + */ + function getMarketPoolMaxDebtPerShare( + uint128 marketId, + uint128 poolId + ) external view override returns (int256) { + Market.Data storage market = Market.load(marketId); + return market.getPoolMaxDebtPerShare(poolId); + } + + /** + * @inheritdoc IMarketManagerModule + */ + function getMarketCapacityContributionFromPool( + uint128 marketId, + uint128 poolId + ) external view override returns (uint256) { + Market.Data storage market = Market.load(marketId); + + int256 currentValuePerShare = market.poolsDebtDistribution.getValuePerShare(); + int256 poolMaxValuePerShare = market.getPoolMaxDebtPerShare(poolId); + + // the getCreditCapacityContribution function could throw a confusing error if the max value per share is less than value per share + if (currentValuePerShare > poolMaxValuePerShare) { + return 0; + } + + return + market + .getCreditCapacityContribution( + market.getPoolCreditCapacity(poolId), + poolMaxValuePerShare + ) + .toUint(); + } + /** * @inheritdoc IMarketManagerModule */ diff --git a/protocol/synthetix/contracts/modules/core/PoolModule.sol b/protocol/synthetix/contracts/modules/core/PoolModule.sol index 1cd2784b73..be1fd22c7d 100644 --- a/protocol/synthetix/contracts/modules/core/PoolModule.sol +++ b/protocol/synthetix/contracts/modules/core/PoolModule.sol @@ -305,6 +305,22 @@ contract PoolModule is IPoolModule { return Pool.load(poolId).name; } + /** + * @inheritdoc IPoolModule + */ + function getPoolTotalDebt(uint128 poolId) external view override returns (int256 totalDebtD18) { + return Pool.load(poolId).totalVaultDebtsD18; + } + + /** + * @inheritdoc IPoolModule + */ + function getPoolDebtPerShare( + uint128 poolId + ) external view override returns (int256 debtPerShareD18) { + (, debtPerShareD18) = Pool.load(poolId).getCurrentCreditCapacityAndDebtPerShare(); + } + /** * @inheritdoc IPoolModule */ diff --git a/protocol/synthetix/contracts/storage/Market.sol b/protocol/synthetix/contracts/storage/Market.sol index 06c1e19abb..fe1084275b 100644 --- a/protocol/synthetix/contracts/storage/Market.sol +++ b/protocol/synthetix/contracts/storage/Market.sol @@ -263,7 +263,7 @@ library Market { /** * @dev Returns the amount of credit capacity that a certain pool provides to the market. - + * * This credit capacity is obtained by reading the amount of shares that the pool has in the market's debt distribution, which represents the amount of USD denominated credit capacity that the pool has provided to the market. */ function getPoolCreditCapacity( @@ -343,6 +343,16 @@ library Market { return self.poolsDebtDistribution.getValuePerShare(); } + /** + * @dev Returns the debt per share at which a pool will be bumped from a market + */ + function getPoolMaxDebtPerShare( + Data storage self, + uint128 poolId + ) internal view returns (int256 maxShareValueD18) { + return -self.inRangePools.getById(poolId).priority; + } + /** * @dev Determine the amount of debt the pool would assume if its lastValue was updated * Needed for optimization. @@ -395,7 +405,7 @@ library Market { int256 newPoolMaxShareValueD18 ) internal returns (int256 debtChangeD18) { uint256 oldCreditCapacityD18 = getPoolCreditCapacity(self, poolId); - int256 oldPoolMaxShareValueD18 = -self.inRangePools.getById(poolId).priority; + int256 oldPoolMaxShareValueD18 = getPoolMaxDebtPerShare(self, poolId); // Sanity checks // require(oldPoolMaxShareValue == 0, "value is not 0"); @@ -537,10 +547,10 @@ library Market { // 2 cases where we want to break out of this loop if ( // If there is no pool in range, and we are going down - (maxDistributedD18 - actuallyDistributedD18 > 0 && - self.poolsDebtDistribution.totalSharesD18 == 0) || // If there is a pool in ragne, and the lowest max value per share does not hit the limit, exit // Note: `-edgePool.priority` is actually the max value per share limit of the pool + (maxDistributedD18 - actuallyDistributedD18 > 0 && + self.poolsDebtDistribution.totalSharesD18 == 0) || (self.poolsDebtDistribution.totalSharesD18 > 0 && -edgePool.priority >= k * getTargetValuePerShare(self, (maxDistributedD18 - actuallyDistributedD18))) diff --git a/protocol/synthetix/contracts/storage/Pool.sol b/protocol/synthetix/contracts/storage/Pool.sol index c67c384d02..9737c89d04 100644 --- a/protocol/synthetix/contracts/storage/Pool.sol +++ b/protocol/synthetix/contracts/storage/Pool.sol @@ -194,11 +194,10 @@ library Pool { // Read from storage once, before entering the loop below. // These values should not change while iterating through each market. - uint256 totalCreditCapacityD18 = self.vaultsDebtDistribution.totalSharesD18; - int128 debtPerShareD18 = totalCreditCapacityD18 > 0 // solhint-disable-next-line numcast/safe-cast - ? int256(self.totalVaultDebtsD18).divDecimal(totalCreditCapacityD18.toInt()).to128() // solhint-disable-next-line numcast/safe-cast - : int128(0); - + ( + uint256 totalCreditCapacityD18, + int128 debtPerShareD18 + ) = getCurrentCreditCapacityAndDebtPerShare(self); uint256 systemMinLiquidityRatioD18 = SystemPoolConfiguration.load().minLiquidityRatioD18; // Loop through the pool's markets, applying market weights, and tracking how this changes the amount of debt that this pool is responsible for. @@ -245,6 +244,15 @@ library Pool { } } + function getCurrentCreditCapacityAndDebtPerShare( + Data storage self + ) internal view returns (uint256 totalCreditCapacityD18, int128 debtPerShareD18) { + totalCreditCapacityD18 = self.vaultsDebtDistribution.totalSharesD18; + debtPerShareD18 = totalCreditCapacityD18 > 0 // solhint-disable-next-line numcast/safe-cast + ? int256(self.totalVaultDebtsD18).divDecimal(totalCreditCapacityD18.toInt()).to128() // solhint-disable-next-line numcast/safe-cast + : int128(0); + } + /** * @dev Determines the resulting maximum value per share for a market, according to a system-wide minimum liquidity ratio. This prevents markets from assigning more debt to pools than they have collateral to cover. * From 5e8bd8ff58ef1b332889425412ca5fd5a658ed49 Mon Sep 17 00:00:00 2001 From: Daniel Beal Date: Wed, 20 Nov 2024 06:48:37 -0800 Subject: [PATCH 2/2] add tests --- .../contracts/interfaces/IPoolModule.sol | 4 +- .../contracts/modules/core/PoolModule.sol | 12 +- .../modules/core/MarketManagerModule.test.ts | 27 ++++ .../modules/core/PoolModuleFundAdmin.test.ts | 127 ++++++++++++++---- 4 files changed, 136 insertions(+), 34 deletions(-) diff --git a/protocol/synthetix/contracts/interfaces/IPoolModule.sol b/protocol/synthetix/contracts/interfaces/IPoolModule.sol index 5c65e77331..a64ba5a2cf 100644 --- a/protocol/synthetix/contracts/interfaces/IPoolModule.sol +++ b/protocol/synthetix/contracts/interfaces/IPoolModule.sol @@ -222,14 +222,14 @@ interface IPoolModule { * @param poolId The id of the pool whose total debt is being queried * @return totalDebtD18 The total debt of all vaults put together */ - function getPoolTotalDebt(uint128 poolId) external view returns (int256 totalDebtD18); + function getPoolTotalDebt(uint128 poolId) external returns (int256 totalDebtD18); /** * @notice Returns the current pool debt divided by the computed value of the underlying vault liquidity * @param poolId The id of the pool whose total debt is being queried * @return debtPerShareD18 The total debt of all vaults put together divided by the computed collateral value of those vaults */ - function getPoolDebtPerShare(uint128 poolId) external view returns (int256 debtPerShareD18); + function getPoolDebtPerShare(uint128 poolId) external returns (int256 debtPerShareD18); /** * @notice Allows the system owner (not the pool owner) to set the system-wide minimum liquidity ratio. diff --git a/protocol/synthetix/contracts/modules/core/PoolModule.sol b/protocol/synthetix/contracts/modules/core/PoolModule.sol index be1fd22c7d..b18fc2db4a 100644 --- a/protocol/synthetix/contracts/modules/core/PoolModule.sol +++ b/protocol/synthetix/contracts/modules/core/PoolModule.sol @@ -308,8 +308,10 @@ contract PoolModule is IPoolModule { /** * @inheritdoc IPoolModule */ - function getPoolTotalDebt(uint128 poolId) external view override returns (int256 totalDebtD18) { - return Pool.load(poolId).totalVaultDebtsD18; + function getPoolTotalDebt(uint128 poolId) external override returns (int256 totalDebtD18) { + Pool.Data storage pool = Pool.loadExisting(poolId); + pool.distributeDebtToVaults(address(0)); + return pool.totalVaultDebtsD18; } /** @@ -317,8 +319,10 @@ contract PoolModule is IPoolModule { */ function getPoolDebtPerShare( uint128 poolId - ) external view override returns (int256 debtPerShareD18) { - (, debtPerShareD18) = Pool.load(poolId).getCurrentCreditCapacityAndDebtPerShare(); + ) external override returns (int256 debtPerShareD18) { + Pool.Data storage pool = Pool.loadExisting(poolId); + pool.distributeDebtToVaults(address(0)); + (, debtPerShareD18) = pool.getCurrentCreditCapacityAndDebtPerShare(); } /** diff --git a/protocol/synthetix/test/integration/modules/core/MarketManagerModule.test.ts b/protocol/synthetix/test/integration/modules/core/MarketManagerModule.test.ts index efc9d3f7ca..41ecfb4c98 100644 --- a/protocol/synthetix/test/integration/modules/core/MarketManagerModule.test.ts +++ b/protocol/synthetix/test/integration/modules/core/MarketManagerModule.test.ts @@ -625,4 +625,31 @@ describe('MarketManagerModule', function () { assertBn.equal(result.valuePerShareD27, bn(1000000000)); }); }); + + describe('getMarketPoolMaxDebtPerShare()', () => { + before(restore); + + it('returns the correct pool max debt per share', async () => { + const result = await systems().Core.callStatic.getMarketPoolMaxDebtPerShare( + marketId(), + poolId + ); + + assertBn.equal(result, ethers.utils.parseEther('1')); + }); + }); + + describe('getMarketCapacityContributionFromPool()', () => { + before(restore); + + it('returns the correct pool max debt per share', async () => { + const result = await systems().Core.callStatic.getMarketCapacityContributionFromPool( + marketId(), + poolId + ); + + // there is only one pool so the credit capacity and that returned by the capacity contribution should be the same + assertBn.equal(result, await systems().Core.Market_get_creditCapacityD18(marketId())); + }); + }); }); diff --git a/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.test.ts b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.test.ts index e05fb940ff..6f88ba1912 100644 --- a/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.test.ts +++ b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.test.ts @@ -1,12 +1,13 @@ /* eslint-disable no-unexpected-multiline */ -import assert from 'node:assert'; import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; import { ethers } from 'ethers'; import hre from 'hardhat'; +import assert from 'node:assert'; + import { bn, bootstrapWithMockMarketAndPool } from '../../bootstrap'; -import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; describe('PoolModule Admin', function () { const { @@ -179,8 +180,16 @@ describe('PoolModule Admin', function () { systems() .Core.connect(owner) .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, - { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, + { + marketId: marketId(), + weightD18: 1, + maxDebtShareValueD18: One, + }, + { + marketId: marketId2, + weightD18: 3, + maxDebtShareValueD18: One, + }, ]), `MinDelegationTimeoutPending("${poolId}",`, systems().Core @@ -197,7 +206,11 @@ describe('PoolModule Admin', function () { await systems() .Core.connect(owner) .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, + { + marketId: marketId(), + weightD18: 1, + maxDebtShareValueD18: One, + }, { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, ]); }); @@ -211,8 +224,16 @@ describe('PoolModule Admin', function () { await systems() .Core.connect(owner) .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, - { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, + { + marketId: marketId(), + weightD18: 1, + maxDebtShareValueD18: One, + }, + { + marketId: marketId2, + weightD18: 3, + maxDebtShareValueD18: One, + }, ]); }); @@ -266,9 +287,18 @@ describe('PoolModule Admin', function () { systems() .Core.connect(owner) .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, - // increase the weight of market2 to make the first market lower liquidity overall - { marketId: marketId2, weightD18: 9, maxDebtShareValueD18: One }, + { + marketId: marketId(), + weightD18: 1, + maxDebtShareValueD18: One, + }, + // increase the weight of market2 to make the first + // market lower liquidity overall + { + marketId: marketId2, + weightD18: 9, + maxDebtShareValueD18: One, + }, ]), `CapacityLocked("${marketId()}")`, systems().Core @@ -277,12 +307,21 @@ describe('PoolModule Admin', function () { // reduce market lock await MockMarket().setLocked(ethers.utils.parseEther('105')); - // now the call should work (call static to not modify the state) + // now the call should work (call static to not modify the + // state) await systems() .Core.connect(owner) .callStatic.setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, - { marketId: marketId2, weightD18: 9, maxDebtShareValueD18: One }, + { + marketId: marketId(), + weightD18: 1, + maxDebtShareValueD18: One, + }, + { + marketId: marketId2, + weightD18: 9, + maxDebtShareValueD18: One, + }, ]); // but a full pull-out shouldn't work @@ -291,10 +330,14 @@ describe('PoolModule Admin', function () { .Core.connect(owner) .setPoolConfiguration(poolId, [ // completely remove the first market - { marketId: marketId2, weightD18: 9, maxDebtShareValueD18: One }, + { + marketId: marketId2, + weightD18: 9, + maxDebtShareValueD18: One, + }, ]), `CapacityLocked("${marketId()}")` - //systems().Core + // systems().Core ); // undo lock change @@ -308,7 +351,11 @@ describe('PoolModule Admin', function () { await systems() .Core.connect(owner) .setPoolConfiguration(poolId, [ - { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, + { + marketId: marketId2, + weightD18: 3, + maxDebtShareValueD18: One, + }, ]); }); @@ -321,8 +368,8 @@ describe('PoolModule Admin', function () { }); it('markets have same available liquidity', async () => { - // marketId() gets to keep its available liquidity because when - // the market exited when it did it "committed" + // marketId() gets to keep its available liquidity because + // when the market exited when it did it "committed" assertBn.equal( await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), debtAmount @@ -344,7 +391,11 @@ describe('PoolModule Admin', function () { await systems() .Core.connect(owner) .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 2, maxDebtShareValueD18: One.mul(2) }, + { + marketId: marketId(), + weightD18: 2, + maxDebtShareValueD18: One.mul(2), + }, ]); }); @@ -357,7 +408,8 @@ describe('PoolModule Admin', function () { }); it('available liquidity taken away from second market', async () => { - // marketId2 never reported an increased balance so its liquidity is 0 as ever + // marketId2 never reported an increased balance so its + // liquidity is 0 as ever assertBn.equal(await systems().Core.connect(owner).getMarketCollateral(marketId2), 0); }); @@ -381,14 +433,15 @@ describe('PoolModule Admin', function () { }); it('markets have same available liquidity', async () => { - // marketId() gets to keep its available liquidity because when - // the market exited when it did it "committed" + // marketId() gets to keep its available liquidity because + // when the market exited when it did it "committed" assertBn.equal( await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), debtAmount ); - // marketId2 never reported an increased balance so its liquidity is 0 as ever + // marketId2 never reported an increased balance so its + // liquidity is 0 as ever assertBn.equal(await systems().Core.connect(owner).getMarketCollateral(marketId2), 0); }); }); @@ -468,7 +521,8 @@ describe('PoolModule Admin', function () { await systems().USD.connect(user1).approve(MockMarket().address, Hundred); await MockMarket().connect(user1).buySynth(Hundred); - // "bump" the vault to get it to accept the position (in case there is a bug) + // "bump" the vault to get it to accept the position (in case + // there is a bug) await systems().Core.connect(user1).getVaultDebt(poolId, collateralAddress()); }); @@ -497,8 +551,8 @@ describe('PoolModule Admin', function () { it('has accurate amount withdrawable usd', async () => { // should be exactly 102 (market2 2 + 100 deposit) - // (market1 assumes no new debt as a result of balance going down, - // but accounts/can pool can withdraw at a profit) + // (market1 assumes no new debt as a result of balance going + // down, but accounts/can pool can withdraw at a profit) assertBn.equal( await systems().Core.connect(owner).getWithdrawableMarketUsd(marketId()), Hundred.add(One.mul(2)) @@ -513,6 +567,10 @@ describe('PoolModule Admin', function () { }); describe('and then the market reports 50 balance', () => { + let prePoolDebt: ethers.BigNumber; + before('save pre pool debt', async () => { + prePoolDebt = await systems().Core.callStatic.getPoolTotalDebt(poolId); + }); before('', async () => { await MockMarket().connect(user1).setReportedDebt(Hundred.div(2)); }); @@ -531,6 +589,18 @@ describe('PoolModule Admin', function () { ); }); + it('pool is debted', async () => { + assertBn.equal( + await systems().Core.callStatic.getPoolTotalDebt(poolId), + prePoolDebt.add(Hundred.div(4)) + ); + + assertBn.equal( + await systems().Core.callStatic.getPoolDebtPerShare(poolId), + prePoolDebt.add(Hundred.div(4)).mul(ethers.utils.parseEther('1')).div(depositAmount) + ); + }); + const restore = snapshotCheckpoint(provider); describe('and then the market reports balance above limit again', () => { @@ -555,8 +625,9 @@ describe('PoolModule Admin', function () { }); it('vault 2 assumes expected amount of debt', async () => { - // vault 2 assumes the 1 dollar in debt that was not absorbed by the first pool - // still below limit though + // vault 2 assumes the 1 dollar in debt that was not + // absorbed by the first pool still below limit + // though assertBn.equal( await systems().Core.callStatic.getVaultDebt(secondPoolId, collateralAddress()), One