diff --git a/markets/bfp-market/storage.dump.sol b/markets/bfp-market/storage.dump.sol index 9e1762929b..0c14278ffe 100644 --- a/markets/bfp-market/storage.dump.sol +++ b/markets/bfp-market/storage.dump.sol @@ -119,8 +119,9 @@ library Account { AccountRBAC.Data rbac; uint64 lastInteraction; uint64 __slotAvailableForFutureUse; - uint128 __slot2AvailableForFutureUse; + uint128 currentDelegationIntentsEpoch; mapping(address => Collateral.Data) collaterals; + mapping(uint128 => AccountDelegationIntents.Data) delegationIntents; } function load(uint128 id) internal pure returns (Data storage account) { bytes32 s = keccak256(abi.encode("io.synthetix.synthetix.Account", id)); @@ -130,6 +131,16 @@ library Account { } } +// @custom:artifact @synthetixio/main/contracts/storage/AccountDelegationIntents.sol:AccountDelegationIntents +library AccountDelegationIntents { + struct Data { + SetUtil.UintSet intentsId; + mapping(bytes32 => SetUtil.UintSet) intentsByPair; + SetUtil.AddressSet delegatedCollaterals; + mapping(address => int256) netDelegatedAmountPerCollateral; + } +} + // @custom:artifact @synthetixio/main/contracts/storage/AccountRBAC.sol:AccountRBAC library AccountRBAC { bytes32 internal constant _ADMIN_PERMISSION = "ADMIN"; @@ -199,6 +210,25 @@ library Config { } } +// @custom:artifact @synthetixio/main/contracts/storage/DelegationIntent.sol:DelegationIntent +library DelegationIntent { + bytes32 private constant _ATOMIC_VALUE_LATEST_ID = "delegateIntent_idAsNonce"; + struct Data { + uint128 accountId; + uint128 poolId; + address collateralType; + int256 deltaCollateralAmountD18; + uint256 leverage; + uint32 declarationTime; + } + function load(uint256 id) internal pure returns (Data storage delegationIntent) { + bytes32 s = keccak256(abi.encode("io.synthetix.synthetix.DelegationIntent", id)); + assembly { + delegationIntent.slot := s + } + } +} + // @custom:artifact @synthetixio/main/contracts/storage/Distribution.sol:Distribution library Distribution { struct Data { @@ -231,10 +261,12 @@ library Market { DepositedCollateral[] depositedCollateral; mapping(address => uint256) maximumDepositableD18; uint32 minDelegateTime; + uint32 undelegateCollateralDelay; + uint32 undelegateCollateralWindow; + uint32 delegateCollateralDelay; + uint32 delegateCollateralWindow; uint32 __reservedForLater1; uint64 __reservedForLater2; - uint64 __reservedForLater3; - uint64 __reservedForLater4; uint256 minLiquidityRatioD18; } struct DepositedCollateral { @@ -283,6 +315,8 @@ library OracleManager { // @custom:artifact @synthetixio/main/contracts/storage/Pool.sol:Pool library Pool { bytes32 private constant _CONFIG_SET_MARKET_MIN_DELEGATE_MAX = "setMarketMinDelegateTime_max"; + bytes32 private constant _CONFIG_DELEGATE_COLLATERAL_DELAY_MIN = "delegateCollateralDelay_min"; + bytes32 private constant _CONFIG_DELEGATE_COLLATERAL_WINDOW_MAX = "delegateCollateralWindow_max"; struct Data { uint128 id; string name; diff --git a/markets/bfp-market/test/bootstrap.ts b/markets/bfp-market/test/bootstrap.ts index 1d745a0756..510b38bf42 100644 --- a/markets/bfp-market/test/bootstrap.ts +++ b/markets/bfp-market/test/bootstrap.ts @@ -137,7 +137,7 @@ export const bootstrap = (args: GeneratedBootstrap) => { const stakedCollateralAmount = wei(50_000_000).div(stakedCollateralPrice).toBN(); // Create a pool which makes `args.markets.length` with all equal weighting. - const stakedPool = createStakedPool(core, stakedCollateralPrice, stakedCollateralAmount); + const stakedPool = createStakedPool(core, stakedCollateralPrice, stakedCollateralAmount, true); let ethOracleNodeId: string; let ethOracleAgg: AggregatorV3Mock; diff --git a/markets/legacy-market/package.json b/markets/legacy-market/package.json index ca47fb69b1..0725e0a62a 100644 --- a/markets/legacy-market/package.json +++ b/markets/legacy-market/package.json @@ -21,6 +21,7 @@ "@synthetixio/common-config": "workspace:*", "@synthetixio/core-utils": "workspace:*", "@synthetixio/docgen": "workspace:*", + "@synthetixio/main": "workspace:*", "@synthetixio/wei": "^2.74.4", "ethers": "^5.7.2", "hardhat": "^2.19.5", diff --git a/markets/legacy-market/test/integration/LegacyMarket.iosiroInfiniteMoney.ts b/markets/legacy-market/test/integration/LegacyMarket.iosiroInfiniteMoney.ts index 7ff2f1a3c2..bb56e2261e 100644 --- a/markets/legacy-market/test/integration/LegacyMarket.iosiroInfiniteMoney.ts +++ b/markets/legacy-market/test/integration/LegacyMarket.iosiroInfiniteMoney.ts @@ -6,6 +6,7 @@ import { wei } from '@synthetixio/wei'; import { snapshotCheckpoint } from '../utils'; import { fastForward } from '@synthetixio/core-utils/utils/hardhat/rpc'; import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { delegateCollateral } from '@synthetixio/main/test/common'; async function getImpersonatedSigner( provider: ethers.providers.JsonRpcProvider, @@ -274,15 +275,18 @@ describe('LegacyMarket (iosiro)', function () { await v3System .connect(whaleAccount) .deposit(whaleAccountId, collateralType, whaleDelegationAmount); - await v3System - .connect(whaleAccount) - .delegateCollateral( - whaleAccountId, - otherPoolId, - collateralType, - whaleDelegationAmount, - wei(1).toBN() - ); + await delegateCollateral( + () => ({ + Core: v3System, + }), + owner, + whaleAccount, + whaleAccountId, + otherPoolId, + collateralType, + whaleDelegationAmount, + wei(1).toBN() + ); await v3System .connect(whaleAccount) .mintUsd(whaleAccountId, otherPoolId, collateralType, wei(3333).toBN()); @@ -325,9 +329,18 @@ describe('LegacyMarket (iosiro)', function () { const amountToWithdraw = accountCollateralDetails.totalDeposited; await fastForward(605000, cannonProvider); - await v3System - .connect(attacker) - .delegateCollateral(accountID, poolId, collateralType, 0, wei(1).toBN()); + await delegateCollateral( + () => ({ + Core: v3System, + }), + owner, + attacker, + accountID, + poolId, + collateralType, + wei(0).toBN(), + wei(1).toBN() + ); await v3System.connect(attacker).withdraw(accountID, collateralType, amountToWithdraw); // attacker restakes in v2 await snxV2.connect(attacker).issueMaxSynths(); diff --git a/markets/legacy-market/test/integration/LegacyMarket.ts b/markets/legacy-market/test/integration/LegacyMarket.ts index 9c0e306aa4..a16800700c 100644 --- a/markets/legacy-market/test/integration/LegacyMarket.ts +++ b/markets/legacy-market/test/integration/LegacyMarket.ts @@ -11,6 +11,7 @@ import { LegacyMarket } from '../../typechain-types/contracts/LegacyMarket'; import Wei, { wei } from '@synthetixio/wei'; import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; import { snapshotCheckpoint } from '../utils'; +import { delegateCollateral } from '@synthetixio/main/test/common'; async function getImpersonatedSigner( provider: ethers.providers.JsonRpcProvider, @@ -356,15 +357,17 @@ describe('LegacyMarket', function () { await v3System.connect(owner).deposit(accountId, snxToken.address, delegateAmount); // invest in the pool - await v3System - .connect(owner) - .delegateCollateral( - accountId, - await v3System.getPreferredPool(), - snxToken.address, - delegateAmount, - ethers.utils.parseEther('1') - ); + await delegateCollateral( + () => ({ + Core: v3System, + }), + owner, + accountId, + await v3System.getPreferredPool(), + snxToken.address, + delegateAmount, + ethers.utils.parseEther('1') + ); // sanity assertBn.equal( diff --git a/markets/perps-market/storage.dump.sol b/markets/perps-market/storage.dump.sol index e71f5aa0a8..c0bab4a01a 100644 --- a/markets/perps-market/storage.dump.sol +++ b/markets/perps-market/storage.dump.sol @@ -118,8 +118,9 @@ library Account { AccountRBAC.Data rbac; uint64 lastInteraction; uint64 __slotAvailableForFutureUse; - uint128 __slot2AvailableForFutureUse; + uint128 currentDelegationIntentsEpoch; mapping(address => Collateral.Data) collaterals; + mapping(uint128 => AccountDelegationIntents.Data) delegationIntents; } function load(uint128 id) internal pure returns (Data storage account) { bytes32 s = keccak256(abi.encode("io.synthetix.synthetix.Account", id)); @@ -129,6 +130,16 @@ library Account { } } +// @custom:artifact @synthetixio/main/contracts/storage/AccountDelegationIntents.sol:AccountDelegationIntents +library AccountDelegationIntents { + struct Data { + SetUtil.UintSet intentsId; + mapping(bytes32 => SetUtil.UintSet) intentsByPair; + SetUtil.AddressSet delegatedCollaterals; + mapping(address => int256) netDelegatedAmountPerCollateral; + } +} + // @custom:artifact @synthetixio/main/contracts/storage/AccountRBAC.sol:AccountRBAC library AccountRBAC { bytes32 internal constant _ADMIN_PERMISSION = "ADMIN"; @@ -198,6 +209,25 @@ library Config { } } +// @custom:artifact @synthetixio/main/contracts/storage/DelegationIntent.sol:DelegationIntent +library DelegationIntent { + bytes32 private constant _ATOMIC_VALUE_LATEST_ID = "delegateIntent_idAsNonce"; + struct Data { + uint128 accountId; + uint128 poolId; + address collateralType; + int256 deltaCollateralAmountD18; + uint256 leverage; + uint32 declarationTime; + } + function load(uint256 id) internal pure returns (Data storage delegationIntent) { + bytes32 s = keccak256(abi.encode("io.synthetix.synthetix.DelegationIntent", id)); + assembly { + delegationIntent.slot := s + } + } +} + // @custom:artifact @synthetixio/main/contracts/storage/Distribution.sol:Distribution library Distribution { struct Data { @@ -230,10 +260,12 @@ library Market { DepositedCollateral[] depositedCollateral; mapping(address => uint256) maximumDepositableD18; uint32 minDelegateTime; + uint32 undelegateCollateralDelay; + uint32 undelegateCollateralWindow; + uint32 delegateCollateralDelay; + uint32 delegateCollateralWindow; uint32 __reservedForLater1; uint64 __reservedForLater2; - uint64 __reservedForLater3; - uint64 __reservedForLater4; uint256 minLiquidityRatioD18; } struct DepositedCollateral { @@ -282,6 +314,8 @@ library OracleManager { // @custom:artifact @synthetixio/main/contracts/storage/Pool.sol:Pool library Pool { bytes32 private constant _CONFIG_SET_MARKET_MIN_DELEGATE_MAX = "setMarketMinDelegateTime_max"; + bytes32 private constant _CONFIG_DELEGATE_COLLATERAL_DELAY_MIN = "delegateCollateralDelay_min"; + bytes32 private constant _CONFIG_DELEGATE_COLLATERAL_WINDOW_MAX = "delegateCollateralWindow_max"; struct Data { uint128 id; string name; diff --git a/markets/perps-market/test/integration/Insolvent.test.ts b/markets/perps-market/test/integration/Insolvent.test.ts index dea32659f2..60e630d3ae 100644 --- a/markets/perps-market/test/integration/Insolvent.test.ts +++ b/markets/perps-market/test/integration/Insolvent.test.ts @@ -4,6 +4,7 @@ import Wei, { wei } from '@synthetixio/wei'; import { ethers } from 'ethers'; import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import { delegateCollateral } from '@synthetixio/main/test/common'; import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; const _SECONDS_IN_DAY = 24 * 60 * 60; @@ -19,7 +20,7 @@ const interestRateParams = { }; describe('Insolvent test', () => { - const { systems, perpsMarkets, superMarketId, provider, trader1, keeper, staker } = + const { systems, perpsMarkets, superMarketId, provider, owner, trader1, keeper, staker } = bootstrapMarkets({ interestRateParams: { lowUtilGradient: interestRateParams.lowUtilGradient.toBN(), @@ -90,15 +91,16 @@ describe('Insolvent test', () => { ); console.log(currentCollateralAmount); // very low amount to make market insolvent - await systems() - .Core.connect(staker()) - .delegateCollateral( - 1, - 1, - systems().CollateralMock.address, - wei(currentCollateralAmount).mul(wei(0.1)).toBN(), - ethers.utils.parseEther('1') - ); + await delegateCollateral( + systems, + owner(), + staker(), + 1, + 1, + systems().CollateralMock.address, + wei(currentCollateralAmount).mul(wei(0.1)).toBN(), + ethers.utils.parseEther('1') + ); // this ends up being total delegated collateral value delegatedCollateralValue = wei(200_000); }); diff --git a/markets/perps-market/test/integration/Position/InterestRate.test.ts b/markets/perps-market/test/integration/Position/InterestRate.test.ts index 5a2c70e5ac..3e2f0cf5ac 100644 --- a/markets/perps-market/test/integration/Position/InterestRate.test.ts +++ b/markets/perps-market/test/integration/Position/InterestRate.test.ts @@ -5,6 +5,7 @@ import { ethers } from 'ethers'; import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; import assertEvent from '@synthetixio/core-utils/utils/assertions/assert-event'; +import { delegateCollateral } from '@synthetixio/main/test/common'; const _SECONDS_IN_DAY = 24 * 60 * 60; const _SECONDS_IN_YEAR = 31557600; @@ -26,32 +27,41 @@ const interestRateParams = { const proportionalTime = (seconds: number) => wei(seconds).div(_SECONDS_IN_YEAR); describe('Position - interest rates', () => { - const { systems, perpsMarkets, superMarketId, provider, trader1, trader2, keeper, staker } = - bootstrapMarkets({ - interestRateParams: { - lowUtilGradient: interestRateParams.lowUtilGradient.toBN(), - gradientBreakpoint: interestRateParams.gradientBreakpoint.toBN(), - highUtilGradient: interestRateParams.highUtilGradient.toBN(), + const { + systems, + perpsMarkets, + superMarketId, + provider, + owner, + trader1, + trader2, + keeper, + staker, + } = bootstrapMarkets({ + interestRateParams: { + lowUtilGradient: interestRateParams.lowUtilGradient.toBN(), + gradientBreakpoint: interestRateParams.gradientBreakpoint.toBN(), + highUtilGradient: interestRateParams.highUtilGradient.toBN(), + }, + synthMarkets: [], + perpsMarkets: [ + { + lockedOiRatioD18: _ETH_LOCKED_OI_RATIO.toBN(), + requestedMarketId: 25, + name: 'Ether', + token: 'snxETH', + price: _ETH_PRICE.toBN(), }, - synthMarkets: [], - perpsMarkets: [ - { - lockedOiRatioD18: _ETH_LOCKED_OI_RATIO.toBN(), - requestedMarketId: 25, - name: 'Ether', - token: 'snxETH', - price: _ETH_PRICE.toBN(), - }, - { - lockedOiRatioD18: _BTC_LOCKED_OI_RATIO.toBN(), - requestedMarketId: 50, - name: 'Bitcoin', - token: 'snxBTC', - price: _BTC_PRICE.toBN(), - }, - ], - traderAccountIds: [2, 3], - }); + { + lockedOiRatioD18: _BTC_LOCKED_OI_RATIO.toBN(), + requestedMarketId: 50, + name: 'Bitcoin', + token: 'snxBTC', + price: _BTC_PRICE.toBN(), + }, + ], + traderAccountIds: [2, 3], + }); let ethMarket: PerpsMarket, btcMarket: PerpsMarket; @@ -254,15 +264,16 @@ describe('Position - interest rates', () => { systems().CollateralMock.address ); // current assumption = 1000 collateral at $2000 price == $2M delegated collateral value - await systems() - .Core.connect(staker()) - .delegateCollateral( - 1, - 1, - systems().CollateralMock.address, - wei(currentCollateralAmount).mul(wei(0.9)).toBN(), - ethers.utils.parseEther('1') - ); + await delegateCollateral( + systems, + owner(), + staker(), + 1, + 1, + systems().CollateralMock.address, + wei(currentCollateralAmount).mul(wei(0.9)).toBN(), + ethers.utils.parseEther('1') + ); }); let updateTxn: ethers.providers.TransactionResponse; diff --git a/protocol/synthetix/contracts/interfaces/IMarketManagerModule.sol b/protocol/synthetix/contracts/interfaces/IMarketManagerModule.sol index dad485e0f6..d8f9c07a28 100644 --- a/protocol/synthetix/contracts/interfaces/IMarketManagerModule.sol +++ b/protocol/synthetix/contracts/interfaces/IMarketManagerModule.sol @@ -77,6 +77,22 @@ interface IMarketManagerModule { */ event SetMinDelegateTime(uint128 indexed marketId, uint32 minDelegateTime); + /** + * @notice Emitted when a market sets its delegation and undelegation configuration + * @param marketId The id of the market that the setting is applied to + * @param delegateCollateralDelay The minimum amount of time to delegate collateral + * @param delegateCollateralWindow The maximum window of time to delegate collateral + * @param undelegateCollateralDelay The minimum amount of time to undelegate collateral + * @param undelegateCollateralWindow The maximum window of time to undelegate collateral + */ + event SetDelegateCollateralConfiguration( + uint128 indexed marketId, + uint32 delegateCollateralDelay, + uint32 delegateCollateralWindow, + uint32 undelegateCollateralDelay, + uint32 undelegateCollateralWindow + ); + /** * @notice Emitted when a market-specific minimum liquidity ratio is set * @param marketId The id of the market that the setting is applied to @@ -232,6 +248,38 @@ interface IMarketManagerModule { */ function getMarketMinDelegateTime(uint128 marketId) external view returns (uint32); + /** + * @notice allows for a market to set its delegation and undelegation delay and window times. (See SIP-366). By default, there is no delay and infinite windows. + * @param marketId the id of the market that wants to set delegation times. + * @param delegateCollateralDelay the minimum number of delay seconds to delegation + * @param delegateCollateralWindow the maximum number of seconds that an delegation can be executed after the delay. + * @param undelegateCollateralDelay the minimum number of delay seconds to un-delegation + * @param undelegateCollateralWindow the maximum number of seconds that an undelegation can be executed after the delay. + */ + function setDelegationCollateralConfiguration( + uint128 marketId, + uint32 delegateCollateralDelay, + uint32 delegateCollateralWindow, + uint32 undelegateCollateralDelay, + uint32 undelegateCollateralWindow + ) external; + + /** + * @notice Retrieve the delegation and undelegation delay and window times of a market + * @param marketId the id of the market + */ + function getDelegationCollateralConfiguration( + uint128 marketId + ) + external + view + returns ( + uint32 delegateCollateralDelay, + uint32 delegateCollateralWindow, + uint32 undelegateCollateralDelay, + uint32 undelegateCollateralWindow + ); + /** * @notice Allows the system owner (not the pool owner) to set a market-specific minimum liquidity ratio. * @param marketId the id of the market diff --git a/protocol/synthetix/contracts/interfaces/IVaultModule.sol b/protocol/synthetix/contracts/interfaces/IVaultModule.sol index e5f73cc775..b42c7cc773 100644 --- a/protocol/synthetix/contracts/interfaces/IVaultModule.sol +++ b/protocol/synthetix/contracts/interfaces/IVaultModule.sol @@ -22,6 +22,51 @@ interface IVaultModule { */ error InvalidCollateralAmount(); + /** + * @notice Thrown when the specified intent is not related to the account id. + */ + error InvalidDelegationIntent(); + + /** + * @notice Thrown when the specified intent does not exist. + */ + error DelegationIntentNotExists(); + + /** + * @notice Thrown when the specified intent is ready to be executed yet. + */ + error DelegationIntentNotReady(uint32 declarationTime, uint32 processingStartTime); + + /** + * @notice Thrown when the specified intent is already expired. + */ + error DelegationIntentExpired(uint32 declarationTime, uint32 processingEndTime); + + /** + * @notice Thrown when the specified intent is not expired yet. + */ + error DelegationIntentNotExpired(uint256 intentId); + + /** + * @notice Thrown when the specified intent is not in current epoch (it was nuked in a liquidation or administrative fix). + */ + error DelegationIntentNotInCurrentEpoch(uint256 intentId); + + /** + * @notice Thrown when the specified intent is not executable due to pending intents. + */ + error ExceedingUndelegateAmount( + int256 deltaCollateralAmountD18, + int256 cachedDeltaCollateralAmountD18, + int256 totalDeltaCollateralAmountD18, + uint256 currentCollateralAmount + ); + + /** + * @notice Thrown when the both legacy and two steps delegation is enabled. + */ + error LegacyAndTwoStepsDelegateCollateralEnabled(); + /** * @notice Emitted when {sender} updates the delegation of collateral in the specified liquidity position. * @param accountId The id of the account whose position was updated. @@ -40,6 +85,74 @@ interface IVaultModule { address indexed sender ); + /** + * @notice Emitted when {sender} updates the delegation of collateral in the specified liquidity position. + * @param accountId The id of the account whose position was updated. + * @param poolId The id of the pool in which the position was updated. + * @param collateralType The address of the collateral associated to the position. + * @param collateralDeltaAmount The new amount of the position, denominated with 18 decimals of precision. + * @param leverage The new leverage value of the position, denominated with 18 decimals of precision. + * @param intentId The id of the intent to update the position. + * @param declarationTime The time at which the intent was declared. + * @param processingStartTime The time at which the intent can be processed. + * @param processingEndTime The time at which the intent will no longer be able to be processed. + * @param sender The address that triggered the update of the position. + */ + event DelegationIntentDeclared( + uint128 indexed accountId, + uint128 indexed poolId, + address collateralType, + int256 collateralDeltaAmount, + uint256 leverage, + uint256 intentId, + uint32 declarationTime, + uint32 processingStartTime, + uint32 processingEndTime, + address indexed sender + ); + + /** + * @notice Emitted when an intent is removed (due to succesful execution or expiration). + * @param intentId The id of the intent to update the position. + * @param accountId The id of the account whose position was updated. + * @param poolId The id of the pool in which the position was updated. + * @param collateralType The address of the collateral associated to the position. + */ + event DelegationIntentRemoved( + uint256 intentId, + uint128 indexed accountId, + uint128 indexed poolId, + address collateralType + ); + + /** + * @notice Emitted when an intent is skipped due to the intent not being executable at that time. + * @param intentId The id of the intent to update the position. + * @param accountId The id of the account whose position was updated. + * @param poolId The id of the pool in which the position was updated. + * @param collateralType The address of the collateral associated to the position. + */ + event DelegationIntentSkipped( + uint256 intentId, + uint128 indexed accountId, + uint128 indexed poolId, + address collateralType + ); + + /** + * @notice Emitted when an intent is processed. + * @param intentId The id of the intent to update the position. + * @param accountId The id of the account whose position was updated. + * @param poolId The id of the pool in which the position was updated. + * @param collateralType The address of the collateral associated to the position. + */ + event DelegationIntentProcessed( + uint256 intentId, + uint128 indexed accountId, + uint128 indexed poolId, + address collateralType + ); + /** * @notice Updates an account's delegated collateral amount for the specified pool and collateral type pair. * @param accountId The id of the account associated with the position that will be updated. @@ -64,6 +177,201 @@ interface IVaultModule { uint256 leverage ) external; + /** + * @notice Declare an intent to update the delegated amount for the specified pool and collateral type pair. + * @param accountId The id of the account associated with the position that intends to update the collateral amount. + * @param poolId The id of the pool associated with the position. + * @param collateralType The address of the collateral used in the position. + * @param deltaAmountD18 The delta amount of collateral delegated in the position, denominated with 18 decimals of precision. + * @param leverage The new leverage amount used in the position, denominated with 18 decimals of precision. + * @return intentId The id of the new intent to update the delegated amount. + * Requirements: + * + * - `ERC2771Context._msgSender()` must be the owner of the account, have the `ADMIN` permission, or have the `DELEGATE` permission. + * - If increasing the amount delegated, it must not exceed the available collateral (`getAccountAvailableCollateral`) associated with the account. + * - If decreasing the amount delegated, the liquidity position must have a collateralization ratio greater than the target collateralization ratio for the corresponding collateral type. + * + * Emits a {DelegationUpdated} event. + */ + function declareIntentToDelegateCollateral( + uint128 accountId, + uint128 poolId, + address collateralType, + int256 deltaAmountD18, + uint256 leverage + ) external returns (uint256 intentId); + + /** + * @notice Attempt to process the outstanding intents to update the delegated amount of collateral by intent ids. + * @param accountId The id of the account associated with the position that intends to update the collateral amount. + * @param intentIds An array of intents to attempt to process. + * @dev The intents that are not executable at this time will be ignored and an event will be emitted to show that. + * Requirements: + * + * Emits a {DelegationUpdated} event. + */ + function processIntentToDelegateCollateralByIntents( + uint128 accountId, + uint256[] calldata intentIds + ) external; + + /** + * @notice Attempt to process the outstanding intents to update the delegated amount of collateral by pool/collateral pair. + * @param accountId The id of the account associated with the position that intends to update the collateral amount. + * @param poolId The ID of the pool for which the intent of the account to delegate a new amount of collateral is being processed + * @param collateralType The address of the collateral used in the position. + * @dev The intents that are not executable at this time will be ignored and am event will be emitted to show that. + * Requirements: + * + * Emits a {DelegationUpdated} event. + */ + function processIntentToDelegateCollateralByPair( + uint128 accountId, + uint128 poolId, + address collateralType + ) external; + + /** + * @notice Attempt to delete delegation intents. + * @param accountId The id of the account owning the intents. + * @param intentIds Array of ids to attempt to delete. + * @dev It will only delete expired intents. + */ + function deleteExpiredIntents(uint128 accountId, uint256[] calldata intentIds) external; + + /** + * @notice Attempt to delete all expired delegation intents from an account. + * @param accountId The id of the account owning the intents. + * @dev It will only delete expired intents. + */ + function deleteAllExpiredIntents(uint128 accountId) external; + + /** + * @notice Attempt to delete delegation intents. + * @param accountId The id of the account owning the intents. + * @param intentIds Array of ids to attempt to delete. + * @dev It will delete any existent intent on the list (expired or not). + * @dev Only the vault owner can execute this call. + */ + function forceDeleteIntents(uint128 accountId, uint256[] calldata intentIds) external; + + /** + * @notice Attempt to delete delegation intents. + * @param accountId The id of the account owning the intents. + * @dev It will delete all the existent intents for the account (expired or not). + * @dev Only the vault owner can execute this call. + */ + function forceDeleteAllAccountIntents(uint128 accountId) external; + + /** + * @notice Returns details of the requested intent. + * @param accountId The id of the account owning the intent. + * @param intentId The id of the intents. + * @return poolId The id of the pool associated with the position. + * @return collateralType The address of the collateral used in the position. + * @return deltaCollateralAmountD18 The delta amount of collateral delegated in the position, denominated with 18 decimals of precision. + * @return leverage The new leverage amount used in the position, denominated with 18 decimals of precision. + * @return declarationTime The time at which the intent was declared. + * @return processingStartTime The time at which the intent execution window starts. + * @return processingEndTime The time at which the intent execution window ends. + */ + function getAccountIntent( + uint128 accountId, + uint256 intentId + ) + external + view + returns ( + uint128 poolId, + address collateralType, + int256 deltaCollateralAmountD18, + uint256 leverage, + uint32 declarationTime, + uint32 processingStartTime, + uint32 processingEndTime + ); + + /** + * @notice Returns the total (positive and negative) amount of collateral intended to be delegated to the vault by the account. + * @param accountId The id of the account owning the intents. + * @param collateralType The address of the collateral. + * @return netDelegatedPerCollateral The total amount of collateral intended to be delegated to the vault by the account, denominated with 18 decimals of precision. + */ + function getNetDelegatedPerCollateral( + uint128 accountId, + address collateralType + ) external view returns (int256 netDelegatedPerCollateral); + + /** + * @notice Returns the total executable (not expired) amount of collateral intended to be delegated to the vault by the account. + * @param accountId The id of the account owning the intents. + * @param poolId The id of the pool associated with the position. + * @param collateralType The address of the collateral. + * @return accumulatedIntentDelta The total amount of collateral intended to be delegated that is not expired, denominated with 18 decimals of precision. + */ + function getExecutableDelegationAccumulated( + uint128 accountId, + uint128 poolId, + address collateralType + ) external view returns (int256 accumulatedIntentDelta); + + /** + * @notice Returns the amount of debt that needs to be repaid, which allows execution of intents that aim at undelegating collalteral, ensuring complyiance with the issuance ratio requirements + * @param accountId The id of the account owning the position. + * @param poolId The id of the pool associated with the position. + * @param collateralType The address of the collateral. + * @param deltaCollateralAmountD18 The delta collateral to be delegated, denominated with 18 decimals of precision. + * @param collateralPrice Reference price of the collateral. + * @return howMuchToRepayD18 The debt to repay. + * + */ + function requiredDebtRepaymentForUndelegation( + uint128 accountId, + uint128 poolId, + address collateralType, + int256 deltaCollateralAmountD18, + uint256 collateralPrice + ) external view returns (uint256 howMuchToRepayD18); + + /** + * @notice Returns the list of executable (by timing) intents for the account. + * @param accountId The id of the account owning the intents. + * @param maxProcessableIntent The maximum number of intents to process. + * @return intentIds The list of intents. + * @return foundIntents The number of found intents. + * + * @dev The array of intent ids might have empty items at the end, use `foundIntents` to know the actual number + * of valid intents. + */ + function getAccountExecutableIntentIds( + uint128 accountId, + uint256 maxProcessableIntent + ) external view returns (uint256[] memory intentIds, uint256 foundIntents); + + /** + * @notice Returns the list of expired (by timing) intents for the account. + * @param accountId The id of the account owning the intents. + * @param maxProcessableIntent The maximum number of intents to process. + * @return intentIds The list of intents. + * @return foundIntents The number of found intents. + * + * @dev The array of intent ids might have empty items at the end, use `foundIntents` to know the actual number + * of valid intents. + */ + function getAccountExpiredIntentIds( + uint128 accountId, + uint256 maxProcessableIntent + ) external view returns (uint256[] memory intentIds, uint256 foundIntents); + + /** + * @notice Returns the list of intents for the account. + * @param accountId The id of the account owning the intents. + * @return intentIds The list of intents. + */ + function getAccountIntentIds( + uint128 accountId + ) external view returns (uint256[] memory intentIds); + /** * @notice Returns the collateralization ratio of the specified liquidity position. If debt is negative, this function will return 0. * @dev Call this function using `callStatic` to treat it as a view function. diff --git a/protocol/synthetix/contracts/mocks/MockMarket.sol b/protocol/synthetix/contracts/mocks/MockMarket.sol index 063e5a76bc..d8a662b774 100644 --- a/protocol/synthetix/contracts/mocks/MockMarket.sol +++ b/protocol/synthetix/contracts/mocks/MockMarket.sol @@ -106,6 +106,29 @@ contract MockMarket is IMarket { IMarketManagerModule(_proxy).setMarketMinDelegateTime(_marketId, minDelegationTime); } + function setDelegationCollateralConfiguration( + uint32 delegateCollateralDelay, + uint32 delegateCollateralWindow, + uint32 undelegateCollateralDelay, + uint32 undelegateCollateralWindow + ) external { + IMarketManagerModule(_proxy).setDelegationCollateralConfiguration( + _marketId, + delegateCollateralDelay, + delegateCollateralWindow, + undelegateCollateralDelay, + undelegateCollateralWindow + ); + } + + function getDelegationCollateralConfiguration() + external + view + returns (uint32, uint32, uint32, uint32) + { + return IMarketManagerModule(_proxy).getDelegationCollateralConfiguration(_marketId); + } + function price() external view returns (uint256) { return _price; } diff --git a/protocol/synthetix/contracts/modules/core/LiquidationModule.sol b/protocol/synthetix/contracts/modules/core/LiquidationModule.sol index 97c0ae48a6..c203af7edb 100644 --- a/protocol/synthetix/contracts/modules/core/LiquidationModule.sol +++ b/protocol/synthetix/contracts/modules/core/LiquidationModule.sol @@ -33,6 +33,7 @@ contract LiquidationModule is ILiquidationModule { using VaultEpoch for VaultEpoch.Data; using Distribution for Distribution.Data; using ScalableMapping for ScalableMapping.Data; + using Account for Account.Data; bytes32 private constant _USD_TOKEN = "USDToken"; @@ -114,6 +115,9 @@ contract LiquidationModule is ILiquidationModule { liquidationData.amountRewarded ); + // Clean any outstanding intents to delegate collateral + Account.load(accountId).cleanAllIntents(); + emit Liquidation( accountId, poolId, diff --git a/protocol/synthetix/contracts/modules/core/MarketManagerModule.sol b/protocol/synthetix/contracts/modules/core/MarketManagerModule.sol index 54868e7df1..dc9a9707eb 100644 --- a/protocol/synthetix/contracts/modules/core/MarketManagerModule.sol +++ b/protocol/synthetix/contracts/modules/core/MarketManagerModule.sol @@ -327,6 +327,59 @@ contract MarketManagerModule is IMarketManagerModule { maxMinDelegateTime < marketMinDelegateTime ? maxMinDelegateTime : marketMinDelegateTime; } + /** + * @inheritdoc IMarketManagerModule + */ + function setDelegationCollateralConfiguration( + uint128 marketId, + uint32 delegateCollateralDelay, + uint32 delegateCollateralWindow, + uint32 undelegateCollateralDelay, + uint32 undelegateCollateralWindow + ) external override { + Market.Data storage market = Market.load(marketId); + + if (ERC2771Context._msgSender() != market.marketAddress) + revert AccessError.Unauthorized(ERC2771Context._msgSender()); + + market.delegateCollateralDelay = delegateCollateralDelay; + market.delegateCollateralWindow = delegateCollateralWindow; + market.undelegateCollateralDelay = undelegateCollateralDelay; + market.undelegateCollateralWindow = undelegateCollateralWindow; + + emit SetDelegateCollateralConfiguration( + marketId, + delegateCollateralDelay, + delegateCollateralWindow, + undelegateCollateralDelay, + undelegateCollateralWindow + ); + } + + /** + * @inheritdoc IMarketManagerModule + */ + function getDelegationCollateralConfiguration( + uint128 marketId + ) + external + view + override + returns ( + uint32 delegateCollateralDelay, + uint32 delegateCollateralWindow, + uint32 undelegateCollateralDelay, + uint32 undelegateCollateralWindow + ) + { + Market.Data storage market = Market.load(marketId); + + delegateCollateralDelay = market.delegateCollateralDelay; + delegateCollateralWindow = market.delegateCollateralWindow; + undelegateCollateralDelay = market.undelegateCollateralDelay; + undelegateCollateralWindow = market.undelegateCollateralWindow; + } + /** * @inheritdoc IMarketManagerModule */ diff --git a/protocol/synthetix/contracts/modules/core/VaultModule.sol b/protocol/synthetix/contracts/modules/core/VaultModule.sol index bef7482e0a..53dd96bb5a 100644 --- a/protocol/synthetix/contracts/modules/core/VaultModule.sol +++ b/protocol/synthetix/contracts/modules/core/VaultModule.sol @@ -1,12 +1,14 @@ //SPDX-License-Identifier: MIT pragma solidity >=0.8.11 <0.9.0; +import "@synthetixio/core-contracts/contracts/ownership/OwnableStorage.sol"; import "@synthetixio/core-contracts/contracts/utils/DecimalMath.sol"; import "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; import "@synthetixio/core-contracts/contracts/utils/ERC2771Context.sol"; import "../../storage/Account.sol"; import "../../storage/Pool.sol"; +import "../../storage/AccountDelegationIntents.sol"; import "@synthetixio/core-modules/contracts/storage/FeatureFlag.sol"; @@ -34,8 +36,13 @@ contract VaultModule is IVaultModule { using SafeCastU256 for uint256; using SafeCastI128 for int128; using SafeCastI256 for int256; + using AccountDelegationIntents for AccountDelegationIntents.Data; + using DelegationIntent for DelegationIntent.Data; + using Account for Account.Data; bytes32 private constant _DELEGATE_FEATURE_FLAG = "delegateCollateral"; + bytes32 private constant _DECLARE_DELEGATE_FEATURE_FLAG = "declareIntentToDelegateColl"; + bytes32 private constant _PROCESS_DELEGATE_FEATURE_FLAG = "processIntentToDelegateColl"; /** * @inheritdoc IVaultModule @@ -49,14 +56,8 @@ contract VaultModule is IVaultModule { ) external override { FeatureFlag.ensureAccessToFeature(_DELEGATE_FEATURE_FLAG); Account.loadAccountAndValidatePermission(accountId, AccountRBAC._DELEGATE_PERMISSION); - - // Each collateral type may specify a minimum collateral amount that can be delegated. - // See CollateralConfiguration.minDelegationD18. - if (newCollateralAmountD18 > 0) { - CollateralConfiguration.requireSufficientDelegation( - collateralType, - newCollateralAmountD18 - ); + if (FeatureFlag.hasAccess(_DECLARE_DELEGATE_FEATURE_FLAG, ERC2771Context._msgSender())) { + revert LegacyAndTwoStepsDelegateCollateralEnabled(); } // System only supports leverage of 1.0 for now. @@ -67,13 +68,76 @@ contract VaultModule is IVaultModule { uint256 currentCollateralAmount = vault.currentAccountCollateral(accountId); - // Conditions for collateral amount + int256 deltaCollateralAmountD18 = newCollateralAmountD18.toInt() - + currentCollateralAmount.toInt(); - // Ensure current collateral amount differs from the new collateral amount. if (newCollateralAmountD18 == currentCollateralAmount) revert InvalidCollateralAmount(); + + _delegateCollateral(accountId, poolId, collateralType, deltaCollateralAmountD18, leverage); + } + + /** + * @inheritdoc IVaultModule + */ + function declareIntentToDelegateCollateral( + uint128 accountId, + uint128 poolId, + address collateralType, + int256 deltaCollateralAmountD18, + uint256 leverage + ) external override returns (uint256 intentId) { + // Ensure the caller is authorized to represent the account. + FeatureFlag.ensureAccessToFeature(_DECLARE_DELEGATE_FEATURE_FLAG); + Account.Data storage account = Account.loadAccountAndValidatePermission( + accountId, + AccountRBAC._DELEGATE_PERMISSION + ); + if (FeatureFlag.hasAccess(_DELEGATE_FEATURE_FLAG, ERC2771Context._msgSender())) { + revert LegacyAndTwoStepsDelegateCollateralEnabled(); + } + + // Input checks + // System only supports leverage of 1.0 for now. + if (leverage != DecimalMath.UNIT) revert InvalidLeverage(leverage); + // Ensure current collateral amount differs from the new collateral amount. + if (deltaCollateralAmountD18 == 0) revert InvalidCollateralAmount(); + + // Verify the account holds enough collateral to execute the intent. + // Get previous intents cache + AccountDelegationIntents.Data storage accountIntents = account.delegationIntents[ + account.currentDelegationIntentsEpoch + ]; + + // Identify the vault that corresponds to this collateral type and pool id. + Vault.Data storage vault = Pool.loadExisting(poolId).vaults[collateralType]; + + uint256 currentCollateralAmount = vault.currentAccountCollateral(accountId); + int256 accumulatedDelta = deltaCollateralAmountD18 + + accountIntents.netDelegatedAmountPerCollateral[collateralType]; + if (accumulatedDelta < 0 && currentCollateralAmount < (-1 * accumulatedDelta).toUint()) { + revert ExceedingUndelegateAmount( + deltaCollateralAmountD18, + accountIntents.netDelegatedAmountPerCollateral[collateralType], + accumulatedDelta, + currentCollateralAmount + ); + } + + uint256 newCollateralAmountD18 = (currentCollateralAmount.toInt() + accumulatedDelta) + .toUint(); + + // Each collateral type may specify a minimum collateral amount that can be delegated. + // See CollateralConfiguration.minDelegationD18. + if (newCollateralAmountD18 > 0) { + CollateralConfiguration.requireSufficientDelegation( + collateralType, + newCollateralAmountD18 + ); + } + // Check the validity of the collateral amount to be delegated, respecting the caches that track outstanding intents to delegate or undelegate collateral. // If increasing delegated collateral amount, // Check that the account has sufficient collateral. - else if (newCollateralAmountD18 > currentCollateralAmount) { + if (newCollateralAmountD18 > currentCollateralAmount) { // Check if the collateral is enabled here because we still want to allow reducing delegation for disabled collaterals. CollateralConfiguration.collateralEnabled(collateralType); @@ -87,74 +151,176 @@ contract VaultModule is IVaultModule { collateralType, newCollateralAmountD18 - currentCollateralAmount ); - - // if decreasing delegation amount, ensure min time has elapsed - } else { - Pool.loadExisting(poolId).requireMinDelegationTimeElapsed( - vault.currentEpoch().lastDelegationTime[accountId] - ); } - // distribute any outstanding rewards distributor value to vaults prior to updating positions - Pool.load(poolId).updateRewardsToVaults( - Vault.PositionSelector(accountId, poolId, collateralType) - ); - - // Update the account's position for the given pool and collateral type, - // Note: This will trigger an update in the entire debt distribution chain. - uint256 collateralPrice = _updatePosition( + // Create a new delegation intent. + intentId = DelegationIntent.nextId(); + DelegationIntent.Data storage intent = DelegationIntent.load(intentId); + intent.accountId = accountId; + intent.poolId = poolId; + intent.collateralType = collateralType; + intent.deltaCollateralAmountD18 = deltaCollateralAmountD18; + intent.leverage = leverage; + intent.declarationTime = block.timestamp.to32(); + + // Add intent to the account's delegation intents. + accountIntents.addIntent(intent, intentId); + + // emit an event + emit DelegationIntentDeclared( accountId, poolId, collateralType, - newCollateralAmountD18, - currentCollateralAmount, - leverage + deltaCollateralAmountD18, + leverage, + intentId, + intent.declarationTime, + intent.processingStartTime(), + intent.processingEndTime(), + ERC2771Context._msgSender() ); + } - _updateAccountCollateralPools( - accountId, - poolId, - collateralType, - newCollateralAmountD18 > 0 - ); + /** + * @inheritdoc IVaultModule + */ + function processIntentToDelegateCollateralByIntents( + uint128 accountId, + uint256[] memory intentIds + ) public override { + FeatureFlag.ensureAccessToFeature(_PROCESS_DELEGATE_FEATURE_FLAG); + if (FeatureFlag.hasAccess(_DELEGATE_FEATURE_FLAG, ERC2771Context._msgSender())) { + revert LegacyAndTwoStepsDelegateCollateralEnabled(); + } - // If decreasing the delegated collateral amount, - // check the account's collateralization ratio. - // Note: This is the best time to do so since the user's debt and the collateral's price have both been updated. - if (newCollateralAmountD18 < currentCollateralAmount) { - int256 debt = vault.currentEpoch().consolidatedDebtAmountsD18[accountId]; + AccountDelegationIntents.Data storage accountIntents = Account + .load(accountId) + .getDelegationIntents(); + + for (uint256 i = 0; i < intentIds.length; i++) { + uint256 intentId = intentIds[i]; + DelegationIntent.Data storage intent = DelegationIntent.load(intentId); + if (!accountIntents.isInCurrentEpoch(intentId)) { + revert DelegationIntentNotInCurrentEpoch(intentId); + } + + if (!intent.isExecutable()) { + // emit an Skipped event + emit DelegationIntentSkipped( + intentId, + accountId, + intent.poolId, + intent.collateralType + ); + + // If expired, remove the intent. + if (intent.intentExpired()) { + accountIntents.removeIntent(intent, intentId); + emit DelegationIntentRemoved( + intentId, + accountId, + intent.poolId, + intent.collateralType + ); + } + + // skip to the next intent + continue; + } + + // Ensure the intent is valid. + if (intent.accountId != accountId) revert InvalidDelegationIntent(); + + // Process the intent. + _delegateCollateral( + accountId, + intent.poolId, + intent.collateralType, + intent.deltaCollateralAmountD18, + intent.leverage + ); - uint256 minIssuanceRatioD18 = Pool - .loadExisting(poolId) - .collateralConfigurations[collateralType] - .issuanceRatioD18; + // Remove the intent. + accountIntents.removeIntent(intent, intentId); + emit DelegationIntentRemoved(intentId, accountId, intent.poolId, intent.collateralType); - // Minimum collateralization ratios are configured in the system per collateral type.abi - // Ensure that the account's updated position satisfies this requirement. - CollateralConfiguration.load(collateralType).verifyIssuanceRatio( - debt < 0 ? 0 : debt.toUint(), - newCollateralAmountD18.mulDecimal(collateralPrice), - minIssuanceRatioD18 + // emit an event + emit DelegationIntentProcessed( + intentId, + accountId, + intent.poolId, + intent.collateralType ); - - // Accounts cannot reduce collateral if any of the pool's - // connected market has its capacity locked. - _verifyNotCapacityLocked(poolId); } + } - // solhint-disable-next-line numcast/safe-cast - vault.currentEpoch().lastDelegationTime[accountId] = uint64(block.timestamp); - - emit DelegationUpdated( + /** + * @inheritdoc IVaultModule + */ + function processIntentToDelegateCollateralByPair( + uint128 accountId, + uint128 poolId, + address collateralType + ) external override { + processIntentToDelegateCollateralByIntents( accountId, - poolId, - collateralType, - newCollateralAmountD18, - leverage, - ERC2771Context._msgSender() + Account.load(accountId).getDelegationIntents().intentIdsByPair(poolId, collateralType) ); } + /** + * @inheritdoc IVaultModule + */ + function forceDeleteAllAccountIntents(uint128 accountId) external override { + OwnableStorage.onlyOwner(); + Account.load(accountId).cleanAllIntents(); + } + + /** + * @inheritdoc IVaultModule + */ + function forceDeleteIntents(uint128 accountId, uint256[] calldata intentIds) external override { + OwnableStorage.onlyOwner(); + AccountDelegationIntents.Data storage accountIntents = Account + .load(accountId) + .getDelegationIntents(); + for (uint256 i = 0; i < intentIds.length; i++) { + uint256 intentId = intentIds[i]; + DelegationIntent.Data storage intent = DelegationIntent.load(intentId); + accountIntents.removeIntent(intent, intentId); + } + } + + /** + * @inheritdoc IVaultModule + */ + function deleteAllExpiredIntents(uint128 accountId) external override { + Account.load(accountId).getDelegationIntents().cleanAllExpiredIntents(); + } + + /** + * @inheritdoc IVaultModule + */ + function deleteExpiredIntents( + uint128 accountId, + uint256[] calldata intentIds + ) external override { + AccountDelegationIntents.Data storage accountIntents = Account + .load(accountId) + .getDelegationIntents(); + for (uint256 i = 0; i < intentIds.length; i++) { + uint256 intentId = intentIds[i]; + DelegationIntent.Data storage intent = DelegationIntent.load(intentId); + if (intent.accountId != accountId) { + revert InvalidDelegationIntent(); + } + if (!intent.intentExpired()) { + revert DelegationIntentNotExpired(intentId); + } + accountIntents.removeIntent(intent, intentId); + } + } + /** * @inheritdoc IVaultModule */ @@ -245,6 +411,288 @@ contract VaultModule is IVaultModule { return Pool.loadExisting(poolId).currentVaultDebt(collateralType); } + /** + * @inheritdoc IVaultModule + */ + function getAccountIntent( + uint128 accountId, + uint256 intentId + ) external view override returns (uint128, address, int256, uint256, uint32, uint32, uint32) { + DelegationIntent.Data storage intent = Account + .load(accountId) + .getDelegationIntents() + .getIntent(intentId); + return ( + intent.poolId, + intent.collateralType, + intent.deltaCollateralAmountD18, + intent.leverage, + intent.declarationTime, + intent.processingStartTime(), + intent.processingEndTime() + ); + } + + /** + * @inheritdoc IVaultModule + */ + function getAccountIntentIds( + uint128 accountId + ) external view override returns (uint256[] memory) { + return Account.load(accountId).getDelegationIntents().intentsId.values(); + } + + /** + * @inheritdoc IVaultModule + */ + function getAccountExpiredIntentIds( + uint128 accountId, + uint256 maxProcessableIntent + ) external view override returns (uint256[] memory expiredIntents, uint256 foundItems) { + uint256[] memory allIntents = Account + .load(accountId) + .getDelegationIntents() + .intentsId + .values(); + uint256 max = maxProcessableIntent > allIntents.length + ? allIntents.length + : maxProcessableIntent; + expiredIntents = new uint256[](max); + for (uint256 i = 0; i < max; i++) { + if (DelegationIntent.load(allIntents[i]).intentExpired()) { + expiredIntents[foundItems] = allIntents[i]; + foundItems++; + } + } + } + + /** + * @inheritdoc IVaultModule + */ + function getAccountExecutableIntentIds( + uint128 accountId, + uint256 maxProcessableIntent + ) external view override returns (uint256[] memory executableIntents, uint256 foundItems) { + uint256[] memory allIntents = Account + .load(accountId) + .getDelegationIntents() + .intentsId + .values(); + uint256 max = maxProcessableIntent > allIntents.length + ? allIntents.length + : maxProcessableIntent; + executableIntents = new uint256[](max); + for (uint256 i = 0; i < max; i++) { + if (DelegationIntent.load(allIntents[i]).isExecutable()) { + executableIntents[foundItems] = allIntents[i]; + foundItems++; + } + } + } + + /** + * @inheritdoc IVaultModule + */ + function getNetDelegatedPerCollateral( + uint128 accountId, + address collateralType + ) external view override returns (int256) { + return + Account.load(accountId).getDelegationIntents().netDelegatedAmountPerCollateral[ + collateralType + ]; + } + + /** + * @inheritdoc IVaultModule + */ + function getExecutableDelegationAccumulated( + uint128 accountId, + uint128 poolId, + address collateralType + ) external view override returns (int256 accumulatedIntentDelta) { + uint256[] memory intentIds = Account.load(accountId).getDelegationIntents().intentIdsByPair( + poolId, + collateralType + ); + accumulatedIntentDelta = 0; + for (uint256 i = 0; i < intentIds.length; i++) { + DelegationIntent.Data storage intent = DelegationIntent.load(intentIds[i]); + if (!intent.intentExpired()) { + accumulatedIntentDelta += intent.deltaCollateralAmountD18; + } + } + } + + /** + * @inheritdoc IVaultModule + */ + function requiredDebtRepaymentForUndelegation( + uint128 accountId, + uint128 poolId, + address collateralType, + int256 deltaCollateralAmountD18, + uint256 collateralPrice + ) external view override returns (uint256) { + // Identify the vault that corresponds to this collateral type and pool id. + if (deltaCollateralAmountD18 > 0) { + return 0; + } + + Vault.Data storage vault = Pool.loadExisting(poolId).vaults[collateralType]; + + int256 debt = vault.currentEpoch().consolidatedDebtAmountsD18[accountId]; + uint256 effectiveDebt = debt < 0 ? 0 : debt.toUint(); + if (effectiveDebt == 0) { + return 0; + } + + uint256 currentCollateralAmount = vault.currentAccountCollateral(accountId); + + if (currentCollateralAmount < (-1 * deltaCollateralAmountD18).toUint()) { + revert ExceedingUndelegateAmount( + deltaCollateralAmountD18, + 0, + deltaCollateralAmountD18, + currentCollateralAmount + ); + } + + uint256 newCollateralAmountD18 = (currentCollateralAmount.toInt() + + deltaCollateralAmountD18).toUint(); + + uint256 minIssuanceRatioD18 = Pool + .loadExisting(poolId) + .collateralConfigurations[collateralType] + .issuanceRatioD18; + + uint256 effectiveIssuanceRatioD18 = CollateralConfiguration + .load(collateralType) + .getEffectiveIssuanceRatio(minIssuanceRatioD18); + + // edge case. Issuance set to max + if (effectiveIssuanceRatioD18 == type(uint256).max) { + return effectiveDebt; + } + + uint256 collateralValue = newCollateralAmountD18.mulDecimal(collateralPrice); + + uint256 maxDebt = effectiveIssuanceRatioD18.mulDecimal(collateralValue); + if (maxDebt >= effectiveDebt) { + return 0; + } + return maxDebt - effectiveDebt; + } + + function _delegateCollateral( + uint128 accountId, + uint128 poolId, + address collateralType, + int256 deltaCollateralAmountD18, + uint256 leverage + ) internal { + // Identify the vault that corresponds to this collateral type and pool id. + Vault.Data storage vault = Pool.loadExisting(poolId).vaults[collateralType]; + + uint256 currentCollateralAmount = vault.currentAccountCollateral(accountId); + + uint256 newCollateralAmountD18 = (currentCollateralAmount.toInt() + + deltaCollateralAmountD18).toUint(); + + // Each collateral type may specify a minimum collateral amount that can be delegated. + // See CollateralConfiguration.minDelegationD18. + if (newCollateralAmountD18 > 0) { + CollateralConfiguration.requireSufficientDelegation( + collateralType, + newCollateralAmountD18 + ); + } + + // Conditions for collateral amount + + // If increasing delegated collateral amount, + // Check that the account has sufficient collateral. + if (deltaCollateralAmountD18 > 0) { + // Check if the collateral is enabled here because we still want to allow reducing delegation for disabled collaterals. + CollateralConfiguration.collateralEnabled(collateralType); + + Account.requireSufficientCollateral( + accountId, + collateralType, + deltaCollateralAmountD18.toUint() + ); + + Pool.loadExisting(poolId).checkPoolCollateralLimit( + collateralType, + deltaCollateralAmountD18.toUint() + ); + // if decreasing delegation amount, ensure min time has elapsed + } else { + Pool.loadExisting(poolId).requireMinDelegationTimeElapsed( + vault.currentEpoch().lastDelegationTime[accountId] + ); + } + + // distribute any outstanding rewards distributor value to vaults prior to updating positions + Pool.load(poolId).updateRewardsToVaults( + Vault.PositionSelector(accountId, poolId, collateralType) + ); + + // Update the account's position for the given pool and collateral type, + // Note: This will trigger an update in the entire debt distribution chain. + uint256 collateralPrice = _updatePosition( + accountId, + poolId, + collateralType, + newCollateralAmountD18, + currentCollateralAmount, + leverage + ); + + _updateAccountCollateralPools( + accountId, + poolId, + collateralType, + newCollateralAmountD18 > 0 + ); + + // If decreasing the delegated collateral amount, + // check the account's collateralization ratio. + // Note: This is the best time to do so since the user's debt and the collateral's price have both been updated. + if (deltaCollateralAmountD18 < 0) { + int256 debt = vault.currentEpoch().consolidatedDebtAmountsD18[accountId]; + + uint256 minIssuanceRatioD18 = Pool + .loadExisting(poolId) + .collateralConfigurations[collateralType] + .issuanceRatioD18; + + // Minimum collateralization ratios are configured in the system per collateral type.abi + // Ensure that the account's updated position satisfies this requirement. + CollateralConfiguration.load(collateralType).verifyIssuanceRatio( + debt < 0 ? 0 : debt.toUint(), + newCollateralAmountD18.mulDecimal(collateralPrice), + minIssuanceRatioD18 + ); + + // Accounts cannot reduce collateral if any of the pool's + // connected market has its capacity locked. + _verifyNotCapacityLocked(poolId); + } + + // solhint-disable-next-line numcast/safe-cast + vault.currentEpoch().lastDelegationTime[accountId] = uint64(block.timestamp); + + emit DelegationUpdated( + accountId, + poolId, + collateralType, + newCollateralAmountD18, + leverage, + ERC2771Context._msgSender() // this is the executor address, not the account owner or authorized (the one that posted the intent) + ); + } + /** * @dev Updates the given account's position regarding the given pool and collateral type, * with the new amount of delegated collateral. diff --git a/protocol/synthetix/contracts/storage/Account.sol b/protocol/synthetix/contracts/storage/Account.sol index 9eb3e811ff..d9c398a4c0 100644 --- a/protocol/synthetix/contracts/storage/Account.sol +++ b/protocol/synthetix/contracts/storage/Account.sol @@ -4,6 +4,7 @@ pragma solidity >=0.8.11 <0.9.0; import "./AccountRBAC.sol"; import "./Collateral.sol"; import "./Pool.sol"; +import "./AccountDelegationIntents.sol"; import "../interfaces/ICollateralModule.sol"; @@ -54,11 +55,18 @@ library Account { AccountRBAC.Data rbac; uint64 lastInteraction; uint64 __slotAvailableForFutureUse; - uint128 __slot2AvailableForFutureUse; + /** + * @dev Account Delegation Intents index is used to nuke previous intents using a new epoch (useful on liquidations). + */ + uint128 currentDelegationIntentsEpoch; /** * @dev Address set of collaterals that are being used in the system by this account. */ mapping(address => Collateral.Data) collaterals; + /** + * @dev Delegation Intents by epoch. Will use `currentDelegationIndentsEpoch` to point to the latest active delegation intents for this account. + */ + mapping(uint128 => AccountDelegationIntents.Data) delegationIntents; } /** @@ -207,4 +215,17 @@ library Account { revert ICollateralModule.InsufficientAccountCollateral(amountD18); } } + + function getDelegationIntents( + Data storage self + ) internal view returns (AccountDelegationIntents.Data storage) { + return self.delegationIntents[self.currentDelegationIntentsEpoch]; + } + + /** + * @dev It "deletes" all the account intents by moving to a new delegation intents epoch + */ + function cleanAllIntents(Data storage self) internal { + self.currentDelegationIntentsEpoch += 1; + } } diff --git a/protocol/synthetix/contracts/storage/AccountDelegationIntents.sol b/protocol/synthetix/contracts/storage/AccountDelegationIntents.sol new file mode 100644 index 0000000000..eb99d655b7 --- /dev/null +++ b/protocol/synthetix/contracts/storage/AccountDelegationIntents.sol @@ -0,0 +1,115 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import "./DelegationIntent.sol"; +import "@synthetixio/core-contracts/contracts/utils/SetUtil.sol"; +import "@synthetixio/core-contracts/contracts/utils/SafeCast.sol"; +import "../interfaces/IVaultModule.sol"; +import "./Account.sol"; + +/** + * @title Represents a delegation (or undelegation) intent. + */ +library AccountDelegationIntents { + using SafeCastI256 for int256; + using SafeCastU128 for uint128; + using SafeCastU256 for uint256; + using SetUtil for SetUtil.UintSet; + using SetUtil for SetUtil.AddressSet; + using DelegationIntent for DelegationIntent.Data; + using Account for Account.Data; + + struct Data { + SetUtil.UintSet intentsId; + mapping(bytes32 => SetUtil.UintSet) intentsByPair; // poolId/collateralType => intentIds[] + // accounting for the intents collateral delegated + // Per Collateral + SetUtil.AddressSet delegatedCollaterals; + mapping(address => int256) netDelegatedAmountPerCollateral; // collateralType => net delegatedCollateralAmount + } + + function addIntent( + Data storage self, + DelegationIntent.Data storage delegationIntent, + uint256 intentId + ) internal { + self.intentsId.add(intentId); + self + .intentsByPair[ + keccak256( + abi.encodePacked(delegationIntent.poolId, delegationIntent.collateralType) + ) + ] + .add(intentId); + + self.netDelegatedAmountPerCollateral[delegationIntent.collateralType] += delegationIntent + .deltaCollateralAmountD18; + + if (!self.delegatedCollaterals.contains(delegationIntent.collateralType)) { + self.delegatedCollaterals.add(delegationIntent.collateralType); + } + } + + function removeIntent( + Data storage self, + DelegationIntent.Data storage delegationIntent, + uint256 intentId + ) internal { + if (!self.intentsId.contains(intentId)) { + return; + } + + self.intentsId.remove(intentId); + self + .intentsByPair[ + keccak256( + abi.encodePacked(delegationIntent.poolId, delegationIntent.collateralType) + ) + ] + .remove(intentId); + + self.netDelegatedAmountPerCollateral[delegationIntent.collateralType] -= delegationIntent + .deltaCollateralAmountD18; + } + + function getIntent( + Data storage self, + uint256 intentId + ) internal view returns (DelegationIntent.Data storage) { + if (!self.intentsId.contains(intentId)) { + revert IVaultModule.InvalidDelegationIntent(); + } + return DelegationIntent.load(intentId); + } + + /** + * @dev Returns the delegation intent stored at the specified nonce id. + */ + function intentIdsByPair( + Data storage self, + uint128 poolId, + address collateralType + ) internal view returns (uint256[] memory intentIds) { + return self.intentsByPair[keccak256(abi.encodePacked(poolId, collateralType))].values(); + } + + function isInCurrentEpoch(Data storage self, uint256 intentId) internal view returns (bool) { + // Notice: not checking that `self.delegationIntentsEpoch == account.currentDelegationIntentsEpoch` since + // it was loadValid and getValid use it at load time + return self.intentsId.contains(intentId); + } + + /** + * @dev Cleans all expired intents related to the account. + */ + function cleanAllExpiredIntents(Data storage self) internal { + uint256[] memory intentIds = self.intentsId.values(); + for (uint256 i = 0; i < intentIds.length; i++) { + uint256 intentId = intentIds[i]; + DelegationIntent.Data storage intent = DelegationIntent.load(intentId); + if (intent.intentExpired()) { + removeIntent(self, intent, intentId); + } + } + } +} diff --git a/protocol/synthetix/contracts/storage/CollateralConfiguration.sol b/protocol/synthetix/contracts/storage/CollateralConfiguration.sol index 5bb1ea464b..4d80a7b9c6 100644 --- a/protocol/synthetix/contracts/storage/CollateralConfiguration.sol +++ b/protocol/synthetix/contracts/storage/CollateralConfiguration.sol @@ -222,6 +222,21 @@ library CollateralConfiguration { return node.price.toUint(); } + /** + * @dev Gets the effective issuance ratio taking capped to the minimum sent as parameter. + * @param self The CollateralConfiguration object whose collateral and settings are being queried. + * @param minIssuanceRatioD18 The minimum cap on issuanceRatio. + * @return issuanceRatioD18 The effective issuance ratio. + */ + function getEffectiveIssuanceRatio( + Data storage self, + uint256 minIssuanceRatioD18 + ) internal view returns (uint256 issuanceRatioD18) { + issuanceRatioD18 = self.issuanceRatioD18 > minIssuanceRatioD18 + ? self.issuanceRatioD18 + : minIssuanceRatioD18; + } + /** * @dev Reverts if the specified collateral and debt values produce a collateralization ratio which is below the amount required for new issuance of snxUSD. * @param self The CollateralConfiguration object whose collateral and settings are being queried. @@ -234,9 +249,7 @@ library CollateralConfiguration { uint256 collateralValueD18, uint256 minIssuanceRatioD18 ) internal view { - uint256 issuanceRatioD18 = self.issuanceRatioD18 > minIssuanceRatioD18 - ? self.issuanceRatioD18 - : minIssuanceRatioD18; + uint256 issuanceRatioD18 = getEffectiveIssuanceRatio(self, minIssuanceRatioD18); if ( debtD18 != 0 && diff --git a/protocol/synthetix/contracts/storage/DelegationIntent.sol b/protocol/synthetix/contracts/storage/DelegationIntent.sol new file mode 100644 index 0000000000..13ba078e95 --- /dev/null +++ b/protocol/synthetix/contracts/storage/DelegationIntent.sol @@ -0,0 +1,145 @@ +//SPDX-License-Identifier: MIT +pragma solidity >=0.8.11 <0.9.0; + +import "./Config.sol"; +import "./Pool.sol"; + +import "../interfaces/IVaultModule.sol"; + +/** + * @title Represents a delegation (or undelegation) intent. + */ +library DelegationIntent { + using Pool for Pool.Data; + + bytes32 private constant _ATOMIC_VALUE_LATEST_ID = "delegateIntent_idAsNonce"; + + /** + * Intent Lifecycle: + * + * |<---- Delay ---->|<-- Processing Window -->| + * Time ----|-----------------|-------------------------|----> + * ^ ^ ^ + * | | | + * declarationTime processingStartTime processingEndTime + * + * Key: + * - declarationTime: Timestamp at which the intent is declared. + * - processingStartTime: Timestamp from which the intent can start being processed. + * - processingEndTime: Timestamp after which the intent cannot be processed. + * + * The intent can be processed only between processingStartTime and processingEndTime. + */ + struct Data { + /** + * @notice The ID of the account that has an outstanding intent to delegate a new amount of collateral to + */ + uint128 accountId; + /** + * @notice The ID of the pool for which the account has an outstanding intent to delegate a new amount of collateral to + */ + uint128 poolId; + /** + * @notice The address of the collateral type that the account has an outstanding intent to delegate a new amount of + */ + address collateralType; + /** + * @notice The delta amount of collateral that the account has an + * outstanding intent to delegate/undelegate to the pool, + * denominated with 18 decimals of precision + */ + int256 deltaCollateralAmountD18; + /** + * @notice The intended amount of leverage associated with the new + * amount of collateral that the account has an outstanding intent + * to delegate to the pool + * @dev The system currently only supports 1x leverage + */ + uint256 leverage; + /** + * @notice The timestamp at which the intent was declared + */ + uint32 declarationTime; + } + + /** + * @dev Returns the delegation intent stored at the specified nonce id. + */ + function load(uint256 id) internal pure returns (Data storage delegationIntent) { + bytes32 s = keccak256(abi.encode("io.synthetix.synthetix.DelegationIntent", id)); + assembly { + delegationIntent.slot := s + } + } + + /** + * @dev Returns the delegation intent stored at the specified nonce id. Checks if it's valid + */ + function loadValid(uint256 id) internal view returns (Data storage delegationIntent) { + delegationIntent = load(id); + + // Notice, using declarationTime as a proxy for existence to reduce the struct by one slot removing the id + if (delegationIntent.declarationTime == 0) { + revert IVaultModule.DelegationIntentNotExists(); + } + } + + function latestId() internal view returns (uint256) { + return Config.readUint(_ATOMIC_VALUE_LATEST_ID, 0); + } + + function nextId() internal returns (uint256 id) { + id = Config.readUint(_ATOMIC_VALUE_LATEST_ID, 0) + 1; + Config.put(_ATOMIC_VALUE_LATEST_ID, bytes32(id)); + } + + function processingStartTime(Data storage self) internal view returns (uint32) { + (uint32 _processingStartTime, ) = getProcessingWindow(self); + return _processingStartTime; + } + + function processingEndTime(Data storage self) internal view returns (uint32) { + (, uint32 _processingEndTime) = getProcessingWindow(self); + return _processingEndTime; + } + + function checkIsExecutable(Data storage self) internal view { + (uint32 _processingStartTime, uint32 _processingEndTime) = getProcessingWindow(self); + + if (block.timestamp < _processingStartTime) + revert IVaultModule.DelegationIntentNotReady( + self.declarationTime, + _processingStartTime + ); + if (block.timestamp >= _processingEndTime) + revert IVaultModule.DelegationIntentExpired(self.declarationTime, _processingEndTime); + } + + function isExecutable(Data storage self) internal view returns (bool) { + (uint32 _processingStartTime, uint32 _processingEndTime) = getProcessingWindow(self); + + return block.timestamp >= _processingStartTime && block.timestamp < _processingEndTime; + } + + function intentExpired(Data storage self) internal view returns (bool) { + (, uint32 _processingEndTime) = getProcessingWindow(self); + + return block.timestamp >= _processingEndTime; + } + + function getProcessingWindow(Data storage self) internal view returns (uint32, uint32) { + (uint32 requiredDelayTime, uint32 requiredWindowTime) = Pool + .loadExisting(self.poolId) + .getRequiredDelegationDelayAndWindow(self.deltaCollateralAmountD18 < 0); + + // Apply default (forever) window time if not set + if (requiredWindowTime == 0) { + requiredWindowTime = 86400 * 360; // 1 year + } + + uint32 _processingStartTime = self.declarationTime + requiredDelayTime; + uint32 _processingEndTime = _processingStartTime + requiredWindowTime; + + return (_processingStartTime, _processingEndTime); + } +} diff --git a/protocol/synthetix/contracts/storage/Market.sol b/protocol/synthetix/contracts/storage/Market.sol index bb64f0bf66..bf16b9f94b 100644 --- a/protocol/synthetix/contracts/storage/Market.sol +++ b/protocol/synthetix/contracts/storage/Market.sol @@ -138,11 +138,16 @@ library Market { * @dev The maximum amount of market provided collateral, per type, that this market can deposit. */ mapping(address => uint256) maximumDepositableD18; - uint32 minDelegateTime; - uint32 __reservedForLater1; - uint64 __reservedForLater2; - uint64 __reservedForLater3; - uint64 __reservedForLater4; + /** + * @dev Delegation/Undelegation frontrunning protection. + */ + uint32 minDelegateTime; // Accumulated Alignment 32 + uint32 undelegateCollateralDelay; // Accumulated Alignment 64 + uint32 undelegateCollateralWindow; // Accumulated Alignment 96 + uint32 delegateCollateralDelay; // Accumulated Alignment 128 + uint32 delegateCollateralWindow; // Accumulated Alignment 160 + uint32 __reservedForLater1; // Accumulated Alignment 192 + uint64 __reservedForLater2; // Accumulated Alignment 256 /** * @dev Market-specific override of the minimum liquidity ratio */ diff --git a/protocol/synthetix/contracts/storage/Pool.sol b/protocol/synthetix/contracts/storage/Pool.sol index 1ac1c62fe9..3d82a0199f 100644 --- a/protocol/synthetix/contracts/storage/Pool.sol +++ b/protocol/synthetix/contracts/storage/Pool.sol @@ -65,6 +65,9 @@ library Pool { ); bytes32 private constant _CONFIG_SET_MARKET_MIN_DELEGATE_MAX = "setMarketMinDelegateTime_max"; + bytes32 private constant _CONFIG_DELEGATE_COLLATERAL_DELAY_MIN = "delegateCollateralDelay_min"; + bytes32 private constant _CONFIG_DELEGATE_COLLATERAL_WINDOW_MAX = + "delegateCollateralWindow_max"; struct Data { /** @@ -536,6 +539,47 @@ library Pool { : requiredMinDelegateTime; } + function getRequiredDelegationDelayAndWindow( + Data storage self, + bool isUndelegation + ) internal view returns (uint32 requiredDelayTime, uint32 requiredWindowTime) { + // solhint-disable-next-line numcast/safe-cast + uint32 globalMinDelegateDelay = uint32( + Config.readUint(_CONFIG_DELEGATE_COLLATERAL_DELAY_MIN, 0) + ); + + // solhint-disable-next-line numcast/safe-cast + uint32 globalMaxDelegateWindow = uint32( + Config.readUint(_CONFIG_DELEGATE_COLLATERAL_WINDOW_MAX, 0) + ); + + for (uint256 i = 0; i < self.marketConfigurations.length; i++) { + Market.Data storage market = Market.load(self.marketConfigurations[i].marketId); + uint32 marketDelayTime = isUndelegation + ? market.undelegateCollateralDelay + : market.delegateCollateralDelay; + + // Find the most restrictive delay time market and use that market to get the delay and window configured times. + if (marketDelayTime > requiredDelayTime) { + requiredDelayTime = marketDelayTime; + + // Pull the window time from the same market. + requiredWindowTime = isUndelegation + ? market.undelegateCollateralWindow + : market.delegateCollateralWindow; + } + } + + // Apply global limits if set. + if (globalMinDelegateDelay > 0 && globalMinDelegateDelay > requiredDelayTime) { + requiredDelayTime = globalMinDelegateDelay; + } + + if (globalMaxDelegateWindow > 0 && globalMaxDelegateWindow < requiredWindowTime) { + requiredWindowTime = globalMaxDelegateWindow; + } + } + /** * @dev Returns the debt of the vault that tracks the given collateral type. * diff --git a/protocol/synthetix/storage.dump.sol b/protocol/synthetix/storage.dump.sol index 358c85733e..1ac3bbabb3 100644 --- a/protocol/synthetix/storage.dump.sol +++ b/protocol/synthetix/storage.dump.sol @@ -377,6 +377,8 @@ contract UtilsModule { // @custom:artifact contracts/modules/core/VaultModule.sol:VaultModule contract VaultModule { bytes32 private constant _DELEGATE_FEATURE_FLAG = "delegateCollateral"; + bytes32 private constant _DECLARE_DELEGATE_FEATURE_FLAG = "declareIntentToDelegateColl"; + bytes32 private constant _PROCESS_DELEGATE_FEATURE_FLAG = "processIntentToDelegateColl"; } // @custom:artifact contracts/modules/usd/USDTokenModule.sol:USDTokenModule @@ -391,8 +393,9 @@ library Account { AccountRBAC.Data rbac; uint64 lastInteraction; uint64 __slotAvailableForFutureUse; - uint128 __slot2AvailableForFutureUse; + uint128 currentDelegationIntentsEpoch; mapping(address => Collateral.Data) collaterals; + mapping(uint128 => AccountDelegationIntents.Data) delegationIntents; } function load(uint128 id) internal pure returns (Data storage account) { bytes32 s = keccak256(abi.encode("io.synthetix.synthetix.Account", id)); @@ -402,6 +405,16 @@ library Account { } } +// @custom:artifact contracts/storage/AccountDelegationIntents.sol:AccountDelegationIntents +library AccountDelegationIntents { + struct Data { + SetUtil.UintSet intentsId; + mapping(bytes32 => SetUtil.UintSet) intentsByPair; + SetUtil.AddressSet delegatedCollaterals; + mapping(address => int256) netDelegatedAmountPerCollateral; + } +} + // @custom:artifact contracts/storage/AccountRBAC.sol:AccountRBAC library AccountRBAC { bytes32 internal constant _ADMIN_PERMISSION = "ADMIN"; @@ -488,6 +501,25 @@ library CrossChain { } } +// @custom:artifact contracts/storage/DelegationIntent.sol:DelegationIntent +library DelegationIntent { + bytes32 private constant _ATOMIC_VALUE_LATEST_ID = "delegateIntent_idAsNonce"; + struct Data { + uint128 accountId; + uint128 poolId; + address collateralType; + int256 deltaCollateralAmountD18; + uint256 leverage; + uint32 declarationTime; + } + function load(uint256 id) internal pure returns (Data storage delegationIntent) { + bytes32 s = keccak256(abi.encode("io.synthetix.synthetix.DelegationIntent", id)); + assembly { + delegationIntent.slot := s + } + } +} + // @custom:artifact contracts/storage/Distribution.sol:Distribution library Distribution { struct Data { @@ -520,10 +552,12 @@ library Market { DepositedCollateral[] depositedCollateral; mapping(address => uint256) maximumDepositableD18; uint32 minDelegateTime; + uint32 undelegateCollateralDelay; + uint32 undelegateCollateralWindow; + uint32 delegateCollateralDelay; + uint32 delegateCollateralWindow; uint32 __reservedForLater1; uint64 __reservedForLater2; - uint64 __reservedForLater3; - uint64 __reservedForLater4; uint256 minLiquidityRatioD18; } struct DepositedCollateral { @@ -587,6 +621,8 @@ library OracleManager { // @custom:artifact contracts/storage/Pool.sol:Pool library Pool { bytes32 private constant _CONFIG_SET_MARKET_MIN_DELEGATE_MAX = "setMarketMinDelegateTime_max"; + bytes32 private constant _CONFIG_DELEGATE_COLLATERAL_DELAY_MIN = "delegateCollateralDelay_min"; + bytes32 private constant _CONFIG_DELEGATE_COLLATERAL_WINDOW_MAX = "delegateCollateralWindow_max"; struct Data { uint128 id; string name; diff --git a/protocol/synthetix/test/common/delegateCollateral.ts b/protocol/synthetix/test/common/delegateCollateral.ts new file mode 100644 index 0000000000..65541d8b54 --- /dev/null +++ b/protocol/synthetix/test/common/delegateCollateral.ts @@ -0,0 +1,96 @@ +import type { CoreProxy } from '../generated/typechain'; +import { BigNumber, ethers, Contract } from 'ethers'; + +type SystemArgs = { + Core: CoreProxy | Contract; +}; + +export async function expectedToDeltaDelegatedCollateral( + systems: () => SystemArgs, + accountId: number, + poolId: number, + collateralAddress: string, + fixedDepositAmount: BigNumber +): Promise { + const currentPositionCollateral = await systems().Core.getPositionCollateral( + accountId, + poolId, + collateralAddress + ); + const deltaCollateral = fixedDepositAmount.sub(currentPositionCollateral); + return deltaCollateral; +} + +export async function declareDelegateIntent( + systems: () => SystemArgs, + owner: ethers.Signer, + signer: ethers.Signer, + accountId: number, + poolId: number, + collateralAddress: string, + fixedDepositAmount: BigNumber, + leverage: BigNumber, + shouldCleanBefore: boolean = true +): Promise { + if (shouldCleanBefore) { + await systems().Core.connect(owner).forceDeleteAllAccountIntents(accountId); + } + const intentId: BigNumber = await systems() + .Core.connect(signer) + .callStatic.declareIntentToDelegateCollateral( + accountId, + poolId, + collateralAddress, + await expectedToDeltaDelegatedCollateral( + systems, + accountId, + poolId, + collateralAddress, + fixedDepositAmount + ), + leverage + ); + await systems() + .Core.connect(signer) + .declareIntentToDelegateCollateral( + accountId, + poolId, + collateralAddress, + await expectedToDeltaDelegatedCollateral( + systems, + accountId, + poolId, + collateralAddress, + fixedDepositAmount + ), + leverage + ); + return intentId; +} + +export async function delegateCollateral( + systems: () => SystemArgs, + owner: ethers.Signer, + signer: ethers.Signer, + accountId: number, + poolId: number, + collateralAddress: string, + fixedDepositAmount: BigNumber, + leverage: BigNumber, + shouldCleanBefore: boolean = true +): Promise { + const intentId = await declareDelegateIntent( + systems, + owner, + signer, + accountId, + poolId, + collateralAddress, + fixedDepositAmount, + leverage, + shouldCleanBefore + ); + await systems() + .Core.connect(signer) + .processIntentToDelegateCollateralByIntents(accountId, [intentId]); +} diff --git a/protocol/synthetix/test/common/index.ts b/protocol/synthetix/test/common/index.ts index 9abf62d221..916a7d3053 100644 --- a/protocol/synthetix/test/common/index.ts +++ b/protocol/synthetix/test/common/index.ts @@ -1,2 +1,3 @@ export * from './stakers'; export * from './stakedPool'; +export * from './delegateCollateral'; diff --git a/protocol/synthetix/test/common/stakedPool.ts b/protocol/synthetix/test/common/stakedPool.ts index 99fb10194f..043b188f88 100644 --- a/protocol/synthetix/test/common/stakedPool.ts +++ b/protocol/synthetix/test/common/stakedPool.ts @@ -8,11 +8,19 @@ import { bootstrap } from '../integration/bootstrap'; export const bn = (n: number) => wei(n).toBN(); const POOL_FEATURE_FLAG = ethers.utils.formatBytes32String('createPool'); +const LEGACY_DELEGATION_FEATURE_FLAG = ethers.utils.formatBytes32String('delegateCollateral'); +const DECLARE_DELEGATE_FEATURE_FLAG = ethers.utils.formatBytes32String( + 'declareIntentToDelegateColl' +); +const PROCESS_DELEGATE_FEATURE_FLAG = ethers.utils.formatBytes32String( + 'processIntentToDelegateColl' +); export const createStakedPool = ( r: ReturnType, stakedCollateralPrice: ethers.BigNumber = bn(1), - stakedAmount: ethers.BigNumber = bn(1000) + stakedAmount: ethers.BigNumber = bn(1000), + useLegacyDelegateCollateral: boolean = false ) => { let aggregator: ethers.Contract; @@ -26,6 +34,19 @@ export const createStakedPool = ( .Core.addToFeatureFlagAllowlist(POOL_FEATURE_FLAG, await r.owner().getAddress()); }); + before('set permissions according to mode', async () => { + // set FF + await r + .systems() + .Core.setFeatureFlagAllowAll(LEGACY_DELEGATION_FEATURE_FLAG, useLegacyDelegateCollateral); + await r + .systems() + .Core.setFeatureFlagAllowAll(DECLARE_DELEGATE_FEATURE_FLAG, !useLegacyDelegateCollateral); + await r + .systems() + .Core.setFeatureFlagAllowAll(PROCESS_DELEGATE_FEATURE_FLAG, !useLegacyDelegateCollateral); + }); + before('setup oracle manager node', async () => { const results = await createOracleNode( r.signers()[0], @@ -64,7 +85,8 @@ export const createStakedPool = ( poolId, accountId, staker, - stakedAmount + stakedAmount, + useLegacyDelegateCollateral ); }); diff --git a/protocol/synthetix/test/common/stakers.ts b/protocol/synthetix/test/common/stakers.ts index e9ef9d3c68..56179af26b 100644 --- a/protocol/synthetix/test/common/stakers.ts +++ b/protocol/synthetix/test/common/stakers.ts @@ -59,7 +59,8 @@ export const stake = async ( poolId: number, accountId: number, user: ethers.Signer, - delegateAmount: ethers.BigNumber = depositAmount + delegateAmount: ethers.BigNumber = depositAmount, + useLegacyDelegateCollateral: boolean = false ) => { const { Core, CollateralMock } = systems; await CollateralMock.mint(await user.getAddress(), delegateAmount.mul(1000)); @@ -73,21 +74,53 @@ export const stake = async ( // stake collateral await Core.connect(user).deposit(accountId, CollateralMock.address, delegateAmount.mul(300)); - // invest in the pool - await Core.connect(user).delegateCollateral( - accountId, - poolId, - CollateralMock.address, - delegateAmount, - ethers.utils.parseEther('1') - ); + if (useLegacyDelegateCollateral) { + // invest in the pool + await Core.connect(user).delegateCollateral( + accountId, + poolId, + CollateralMock.address, + delegateAmount, + ethers.utils.parseEther('1') + ); + + // also for convenience invest in the 0 pool + await Core.connect(user).delegateCollateral( + accountId, + 0, + CollateralMock.address, + delegateAmount, + ethers.utils.parseEther('1') + ); + } else { + // invest in the pool + await Core.connect(user).declareIntentToDelegateCollateral( + accountId, + poolId, + CollateralMock.address, + delegateAmount, + ethers.utils.parseEther('1') + ); + + await Core.connect(user).processIntentToDelegateCollateralByPair( + accountId, + poolId, + CollateralMock.address + ); + + // also for convenience invest in the 0 pool + await Core.connect(user).declareIntentToDelegateCollateral( + accountId, + 0, + CollateralMock.address, + delegateAmount, + ethers.utils.parseEther('1') + ); - // also for convenience invest in the 0 pool - await Core.connect(user).delegateCollateral( - accountId, - 0, - CollateralMock.address, - delegateAmount, - ethers.utils.parseEther('1') - ); + await Core.connect(user).processIntentToDelegateCollateralByPair( + accountId, + 0, + CollateralMock.address + ); + } }; diff --git a/protocol/synthetix/test/integration/bootstrap.ts b/protocol/synthetix/test/integration/bootstrap.ts index 258c6ecf0a..62672dd2dc 100644 --- a/protocol/synthetix/test/integration/bootstrap.ts +++ b/protocol/synthetix/test/integration/bootstrap.ts @@ -58,8 +58,10 @@ export function bootstrap() { }; } -export function bootstrapWithStakedPool() { - return createStakedPool(bootstrap()); +export function bootstrapWithStakedPool(useLegacyMode: boolean = false) { + const r = createStakedPool(bootstrap(), bn(1), bn(1000), useLegacyMode); + + return r; } export function bootstrapWithMockMarketAndPool() { diff --git a/protocol/synthetix/test/integration/modules/core/AssociateDebtModule.test.ts b/protocol/synthetix/test/integration/modules/core/AssociateDebtModule.test.ts index 8968431223..6870435e0f 100644 --- a/protocol/synthetix/test/integration/modules/core/AssociateDebtModule.test.ts +++ b/protocol/synthetix/test/integration/modules/core/AssociateDebtModule.test.ts @@ -119,13 +119,17 @@ describe('AssociateDebtModule', function () { .Core.connect(user2) .deposit(user2AccountId, collateralAddress(), depositAmount.mul(2)); - await systems().Core.connect(user2).delegateCollateral( + await systems().Core.connect(user2).declareIntentToDelegateCollateral( user2AccountId, poolId, collateralAddress(), depositAmount, // user1 50%, user2 50% ethers.utils.parseEther('1') ); + + await systems() + .Core.connect(user2) + .processIntentToDelegateCollateralByPair(user2AccountId, poolId, collateralAddress()); }); describe('when the market reported debt is 100', function () { diff --git a/protocol/synthetix/test/integration/modules/core/LiquidationModule.test.ts b/protocol/synthetix/test/integration/modules/core/LiquidationModule.test.ts index 2714662f54..0f68e7b668 100644 --- a/protocol/synthetix/test/integration/modules/core/LiquidationModule.test.ts +++ b/protocol/synthetix/test/integration/modules/core/LiquidationModule.test.ts @@ -111,13 +111,16 @@ describe('LiquidationModule', function () { // use the zero pool to get minted USD await systems() .Core.connect(user2) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId2, poolId, collateralAddress(), depositAmount.mul(10), ethers.utils.parseEther('1') ); + await systems() + .Core.connect(user2) + .processIntentToDelegateCollateralByPair(accountId2, poolId, collateralAddress()); }); let txn: ethers.providers.TransactionResponse; @@ -276,13 +279,17 @@ describe('LiquidationModule', function () { .deposit(liquidatorAccountId, collateralAddress(), depositAmount.mul(50)); await systems() .Core.connect(user2) - .delegateCollateral( + .declareIntentToDelegateCollateral( liquidatorAccountId, 0, collateralAddress(), depositAmount.mul(50), ethers.utils.parseEther('1') ); + await systems() + .Core.connect(user2) + .processIntentToDelegateCollateralByPair(liquidatorAccountId, 0, collateralAddress()); + await systems() .Core.connect(user2) .mintUsd(liquidatorAccountId, 0, collateralAddress(), liquidatorAccountStartingBalance); diff --git a/protocol/synthetix/test/integration/modules/core/MarketManagerModule.test.ts b/protocol/synthetix/test/integration/modules/core/MarketManagerModule.test.ts index efc9d3f7ca..d5974896ea 100644 --- a/protocol/synthetix/test/integration/modules/core/MarketManagerModule.test.ts +++ b/protocol/synthetix/test/integration/modules/core/MarketManagerModule.test.ts @@ -335,23 +335,29 @@ describe('MarketManagerModule', function () { // delegate await systems() .Core.connect(user1) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId, poolId + 1, collateralAddress(), depositAmount, ethers.utils.parseEther('1') ); + await systems() + .Core.connect(user1) + .processIntentToDelegateCollateralByPair(accountId, poolId + 1, collateralAddress()); await systems() .Core.connect(user1) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId, poolId + 2, collateralAddress(), depositAmount, ethers.utils.parseEther('1') ); + await systems() + .Core.connect(user1) + .processIntentToDelegateCollateralByPair(accountId, poolId + 2, collateralAddress()); }); before('accumulate debt', async () => { @@ -394,37 +400,37 @@ describe('MarketManagerModule', function () { }); }); - describe('setMarketMinDelegateTime()', () => { + describe('setDelegationCollateralConfiguration()', () => { before(restore); it('only works for market', async () => { await assertRevert( - systems().Core.setMarketMinDelegateTime(marketId(), 86400), + systems().Core.setDelegationCollateralConfiguration(marketId(), 86400, 86400, 86400, 86400), 'Unauthorized', systems().Core ); }); - it('fails when min delegation time is unreasonably large', async () => { - await assertRevert( - MockMarket().setMinDelegationTime(100000000), - 'InvalidParameter("minDelegateTime"', - systems().Core - ); - }); - describe('success', () => { let tx: ethers.providers.TransactionResponse; before('exec', async () => { - tx = await MockMarket().setMinDelegationTime(86400); + tx = await MockMarket().setDelegationCollateralConfiguration(60, 61, 62, 63); }); - it('sets the value', async () => { - assertBn.equal(await systems().Core.getMarketMinDelegateTime(marketId()), 86400); + it('sets the values', async () => { + const config = await systems().Core.getDelegationCollateralConfiguration(marketId()); + assertBn.equal(config.delegateCollateralDelay, 60); + assertBn.equal(config.delegateCollateralWindow, 61); + assertBn.equal(config.undelegateCollateralDelay, 62); + assertBn.equal(config.undelegateCollateralWindow, 63); }); it('emits', async () => { - await assertEvent(tx, `SetMinDelegateTime(${marketId()}, 86400)`, systems().Core); + await assertEvent( + tx, + `SetDelegateCollateralConfiguration(${marketId()}, 60, 61, 62, 63)`, + systems().Core + ); }); }); }); @@ -559,23 +565,29 @@ describe('MarketManagerModule', function () { // delegate await systems() .Core.connect(user1) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId, poolId + 1, collateralAddress(), depositAmount, ethers.utils.parseEther('1') ); + await systems() + .Core.connect(user1) + .processIntentToDelegateCollateralByPair(accountId, poolId + 1, collateralAddress()); await systems() .Core.connect(user1) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId, poolId + 2, collateralAddress(), depositAmount, ethers.utils.parseEther('1') ); + await systems() + .Core.connect(user1) + .processIntentToDelegateCollateralByPair(accountId, poolId + 2, collateralAddress()); }); it('inRangePools and outRangePools are returned correctly', async () => { diff --git a/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.test.ts b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.test.ts index e05fb940ff..3d5fbf4b1e 100644 --- a/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.test.ts +++ b/protocol/synthetix/test/integration/modules/core/PoolModuleFundAdmin.test.ts @@ -6,7 +6,6 @@ import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot import { ethers } from 'ethers'; import hre from 'hardhat'; import { bn, bootstrapWithMockMarketAndPool } from '../../bootstrap'; -import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; describe('PoolModule Admin', function () { const { @@ -167,45 +166,6 @@ describe('PoolModule Admin', function () { ); }); - describe('if one of the markets has a min delegation time', () => { - const restore = snapshotCheckpoint(provider); - - before('set market min delegation time to something high', async () => { - await MockMarket().setMinDelegationTime(86400); - }); - - it('fails when min delegation timeout not elapsed', async () => { - await assertRevert( - systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, - { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, - ]), - `MinDelegationTimeoutPending("${poolId}",`, - systems().Core - ); - }); - - describe('after time passes', () => { - before('fast forward', async () => { - // for some reason `fastForward` doesn't seem to work with anvil - await fastForwardTo((await getTime(provider())) + 86400, provider()); - }); - - it('works', async () => { - await systems() - .Core.connect(owner) - .setPoolConfiguration(poolId, [ - { marketId: marketId(), weightD18: 1, maxDebtShareValueD18: One }, - { marketId: marketId2, weightD18: 3, maxDebtShareValueD18: One }, - ]); - }); - }); - - after(restore); - }); - describe('pool changes staking position to add another market', async () => { before('set pool position', async () => { await systems() @@ -421,7 +381,7 @@ describe('PoolModule Admin', function () { // the second pool is here to test the calculation weighted average // and to test pool entering/joining after debt shifts before('set second pool position position', async () => { - await systems().Core.connect(user1).delegateCollateral( + await systems().Core.connect(user1).declareIntentToDelegateCollateral( accountId, secondPoolId, collateralAddress(), @@ -430,6 +390,9 @@ describe('PoolModule Admin', function () { depositAmount, ethers.utils.parseEther('1') ); + await systems() + .Core.connect(user1) + .processIntentToDelegateCollateralByPair(accountId, secondPoolId, collateralAddress()); await systems() .Core.connect(user1) diff --git a/protocol/synthetix/test/integration/modules/core/RewardsManagerModule.test.ts b/protocol/synthetix/test/integration/modules/core/RewardsManagerModule.test.ts index bbb0f77893..0619631b28 100644 --- a/protocol/synthetix/test/integration/modules/core/RewardsManagerModule.test.ts +++ b/protocol/synthetix/test/integration/modules/core/RewardsManagerModule.test.ts @@ -17,7 +17,7 @@ import { verifyUsesFeatureFlag } from '../../verifications'; describe('RewardsManagerModule', function () { this.timeout(120000); const { provider, signers, systems, poolId, collateralAddress, accountId } = - bootstrapWithStakedPool(); + bootstrapWithStakedPool(true); let owner: ethers.Signer, user1: ethers.Signer, user2: ethers.Signer; diff --git a/protocol/synthetix/test/integration/modules/core/VaultModule.legacyDelagate.test.ts b/protocol/synthetix/test/integration/modules/core/VaultModule.legacyDelagate.test.ts new file mode 100644 index 0000000000..12cd411450 --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/VaultModule.legacyDelagate.test.ts @@ -0,0 +1,959 @@ +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import assertRevert from '@synthetixio/core-utils/utils/assertions/assert-revert'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import assert from 'assert/strict'; +import { BigNumber, constants, ethers } from 'ethers'; +import hre from 'hardhat'; +import { bn, bootstrapWithStakedPool } from '../../bootstrap'; +import Permissions from '../../mixins/AccountRBACMixin.permissions'; +import { verifyUsesFeatureFlag } from '../../verifications'; +import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { wei } from '@synthetixio/wei'; + +describe('VaultModule Legacy Delegate', function () { + const { + signers, + systems, + provider, + accountId, + poolId, + depositAmount, + collateralContract, + collateralAddress, + oracleNodeId, + } = bootstrapWithStakedPool(true); + + const MAX_UINT = ethers.constants.MaxUint256; + + let owner: ethers.Signer, user1: ethers.Signer, user2: ethers.Signer; + + let MockMarket: ethers.Contract; + let marketId: BigNumber; + + before('identify signers', async () => { + [owner, user1, user2] = signers(); + }); + + before('give user1 permission to register market', async () => { + await systems() + .Core.connect(owner) + .addToFeatureFlagAllowlist( + ethers.utils.formatBytes32String('registerMarket'), + await user1.getAddress() + ); + }); + + before('deploy and connect fake market', async () => { + const factory = await hre.ethers.getContractFactory('MockMarket'); + + MockMarket = await factory.connect(owner).deploy(); + + marketId = await systems().Core.connect(user1).callStatic.registerMarket(MockMarket.address); + + await systems().Core.connect(user1).registerMarket(MockMarket.address); + + await MockMarket.connect(owner).initialize( + systems().Core.address, + marketId, + ethers.utils.parseEther('1') + ); + + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { + marketId: marketId, + weightD18: ethers.utils.parseEther('1'), + maxDebtShareValueD18: ethers.utils.parseEther('10000000000000000'), + }, + ]); + }); + + before('add second collateral type', async () => { + // add collateral + await ( + await systems().Core.connect(owner).configureCollateral({ + tokenAddress: systems().Collateral2Mock.address, + oracleNodeId: oracleNodeId(), + issuanceRatioD18: '5000000000000000000', + liquidationRatioD18: '1500000000000000000', + liquidationRewardD18: '20000000000000000000', + minDelegationD18: '20000000000000000000', + depositingEnabled: true, + }) + ).wait(); + + await systems() + .Core.connect(owner) + .configureCollateral({ + tokenAddress: await systems().Core.getUsdToken(), + oracleNodeId: ethers.utils.formatBytes32String(''), + issuanceRatioD18: bn(1.5), + liquidationRatioD18: bn(1.1), + liquidationRewardD18: 0, + minDelegationD18: 0, + depositingEnabled: true, + }); + }); + + const restore = snapshotCheckpoint(provider); + + function getExpectedCollateralizationRatio( + collateralAmount: ethers.BigNumberish, + debt: ethers.BigNumberish + ) { + const debtBN = ethers.BigNumber.from(debt); + if (debtBN.isZero()) { + return MAX_UINT; + } + + const collateralBN = ethers.BigNumber.from(collateralAmount); + const decimalBN = ethers.BigNumber.from(10).pow(18); + + return collateralBN.mul(decimalBN).div(debtBN); + } + + // eslint-disable-next-line max-params + function verifyAccountState( + accountId: number, + poolId: number, + collateralAmount: ethers.BigNumberish, + debt: ethers.BigNumberish + ) { + return async () => { + assertBn.equal( + await systems().Core.getPositionCollateral(accountId, poolId, collateralAddress()), + collateralAmount + ); + + assertBn.equal( + await systems().Core.callStatic.getPositionDebt(accountId, poolId, collateralAddress()), + debt + ); + assertBn.equal( + await systems().Core.callStatic.getPositionCollateralRatio( + accountId, + poolId, + collateralAddress() + ), + getExpectedCollateralizationRatio(collateralAmount, debt) + ); + }; + } + + describe('fresh vault', async () => { + const fakeFreshVaultId = 209372; + + before('create empty vault', async () => { + await systems().Core.createPool(fakeFreshVaultId, await user1.getAddress()); + }); + + it('returns 0 debt', async () => { + assertBn.equal( + await systems().Core.callStatic.getVaultDebt(fakeFreshVaultId, collateralAddress()), + 0 + ); + }); + + it('returns 0 collateral', async () => { + assertBn.equal( + ( + await systems().Core.callStatic.getVaultCollateral(fakeFreshVaultId, collateralAddress()) + )[0], + 0 + ); + }); + + it('returns 0 collateral ratio', async () => { + assertBn.equal( + await systems().Core.callStatic.getVaultCollateralRatio( + fakeFreshVaultId, + collateralAddress() + ), + 0 + ); + }); + }); + + describe('delegateCollateral()', async () => { + it( + 'after bootstrap have correct amounts', + verifyAccountState(accountId, poolId, depositAmount, 0) + ); + + it('has max cratio', async function () { + assertBn.equal( + await systems().Core.callStatic.getPositionCollateralRatio( + accountId, + poolId, + collateralAddress() + ), + MAX_UINT + ); + }); + + it('after bootstrap liquidity is delegated all the way back to the market', async () => { + assertBn.gt(await systems().Core.callStatic.getMarketCollateral(marketId), 0); + }); + + it('verifies permission for account', async () => { + await assertRevert( + systems() + .Core.connect(user2) + .delegateCollateral( + accountId, + poolId, + collateralAddress(), + depositAmount.mul(2), + ethers.utils.parseEther('1') + ), + `PermissionDenied("1", "${Permissions.DELEGATE}", "${await user2.getAddress()}")`, + systems().Core + ); + }); + + it('verifies leverage', async () => { + const leverage = ethers.utils.parseEther('1.1'); + await assertRevert( + systems() + .Core.connect(user1) + .delegateCollateral( + accountId, + poolId, + collateralAddress(), + depositAmount.mul(2), + leverage + ), + `InvalidLeverage("${leverage}")`, + systems().Core + ); + }); + + it('fails when trying to delegate less than minDelegation amount', async () => { + await assertRevert( + systems().Core.connect(user1).delegateCollateral( + accountId, + 0, // 0 pool is just easy way to test another pool + collateralAddress(), + depositAmount.div(51), + ethers.utils.parseEther('1') + ), + 'InsufficientDelegation("20000000000000000000")', + systems().Core + ); + }); + + it('fails when new collateral amount equals current collateral amount', async () => { + await assertRevert( + systems() + .Core.connect(user1) + .delegateCollateral( + accountId, + poolId, + collateralAddress(), + depositAmount, + ethers.utils.parseEther('1') + ), + 'InvalidCollateralAmount()', + systems().Core + ); + }); + + it('fails when pool does not exist', async () => { + await assertRevert( + systems() + .Core.connect(user1) + .delegateCollateral( + accountId, + 42, + collateralAddress(), + depositAmount.div(50), + ethers.utils.parseEther('1') + ), + 'PoolNotFound("42")', + systems().Core + ); + }); + + verifyUsesFeatureFlag( + () => systems().Core, + 'delegateCollateral', + () => + systems() + .Core.connect(user1) + .delegateCollateral( + accountId, + 42, + collateralAddress(), + depositAmount.div(50), + ethers.utils.parseEther('1') + ) + ); + + describe('when collateral is disabled by system', async () => { + const restore = snapshotCheckpoint(provider); + after(restore); + + const fakeVaultId = 93729028; + + before('create empty vault', async () => { + await systems().Core.createPool(fakeVaultId, await user1.getAddress()); + }); + + before('disable collateral', async () => { + const beforeConfiguration = + await systems().Core.getCollateralConfiguration(collateralAddress()); + + await systems() + .Core.connect(owner) + .configureCollateral({ ...beforeConfiguration, depositingEnabled: false }); + }); + + it('fails when trying to open delegation position with disabled collateral', async () => { + await assertRevert( + systems() + .Core.connect(user1) + .delegateCollateral( + accountId, + fakeVaultId, + collateralAddress(), + depositAmount.div(50), + ethers.utils.parseEther('1') + ), + `CollateralDepositDisabled("${collateralAddress()}")`, + systems().Core + ); + }); + }); + + describe('when collateral is disabled by pool owner', async () => { + const restore = snapshotCheckpoint(provider); + after(restore); + + const fakeVaultId = 93729021; + + before('create empty vault', async () => { + await systems().Core.createPool(fakeVaultId, await user1.getAddress()); + }); + + before('enable collateral for the system', async () => { + const beforeConfiguration = + await systems().Core.getCollateralConfiguration(collateralAddress()); + + await systems() + .Core.connect(owner) + .configureCollateral({ ...beforeConfiguration, depositingEnabled: true }); + }); + + // fails when collateral is disabled for the pool by pool owner + before('disable collateral for the pool by the pool owner', async () => { + await systems() + .Core.connect(user1) + .setPoolCollateralConfiguration(fakeVaultId, collateralAddress(), { + collateralLimitD18: bn(10), + issuanceRatioD18: bn(0), + }); + }); + + // fails when collateral is disabled for the pool by pool owner + it('fails when trying to open delegation position with disabled collateral', async () => { + await assertRevert( + systems() + .Core.connect(user1) + .delegateCollateral( + accountId, + fakeVaultId, + collateralAddress(), + depositAmount.div(50), + ethers.utils.parseEther('1') + ), + `PoolCollateralLimitExceeded("${fakeVaultId}", "${collateralAddress()}", "${depositAmount + .div(50) + .toString()}", "${bn(10).toString()}")`, + systems().Core + ); + }); + + it('collateral is enabled by the pool owner', async () => { + await systems() + .Core.connect(user1) + .setPoolCollateralConfiguration(fakeVaultId, collateralAddress(), { + collateralLimitD18: bn(1000000), + issuanceRatioD18: bn(0), + }); + }); + + it('the delegation works as expected with the enabled collateral', async () => { + await systems() + .Core.connect(user1) + .delegateCollateral( + accountId, + fakeVaultId, + collateralAddress(), + depositAmount.div(50), + ethers.utils.parseEther('1') + ); + }); + }); + + describe('when pool has limited collateral deposit', async () => { + before('set pool limit', async () => { + await systems() + .Core.connect(owner) + .setPoolCollateralConfiguration(poolId, collateralAddress(), { + collateralLimitD18: depositAmount.div(2), + issuanceRatioD18: bn(0), + }); + }); + + it('fails when pool does not allow sufficient deposit amount', async () => { + await assertRevert( + systems() + .Core.connect(user1) + .delegateCollateral( + accountId, + poolId, + collateralAddress(), + depositAmount.mul(2), + ethers.utils.parseEther('1') + ), + `PoolCollateralLimitExceeded("${poolId}", "${collateralAddress()}", "${depositAmount + .mul(2) + .toString()}", "${depositAmount.div(2).toString()}")`, + systems().Core + ); + }); + }); + + it( + 'user1 has expected initial position', + verifyAccountState(accountId, poolId, depositAmount, 0) + ); + + describe('market debt accumulation', () => { + const startingDebt = ethers.utils.parseEther('100'); + + before('user1 goes into debt', async () => { + await MockMarket.connect(user1).setReportedDebt(startingDebt); + }); + + it('has allocated debt to vault', async () => { + assertBn.equal( + await systems().Core.connect(user2).callStatic.getVaultDebt(poolId, collateralAddress()), + startingDebt + ); + }); + + it( + 'user1 has become indebted', + verifyAccountState(accountId, poolId, depositAmount, startingDebt) + ); + + it('vault c-ratio is affected', async () => { + assertBn.equal( + await systems().Core.callStatic.getVaultCollateralRatio(poolId, collateralAddress()), + depositAmount.mul(ethers.utils.parseEther('1')).div(startingDebt) + ); + }); + + describe('second user delegates', async () => { + const user2AccountId = 283847; + + before('set pool limit', async () => { + await systems() + .Core.connect(owner) + .setPoolCollateralConfiguration(poolId, collateralAddress(), { + collateralLimitD18: depositAmount.mul(10), + issuanceRatioD18: bn(0), + }); + }); + + before('second user delegates and mints', async () => { + // user1 has extra collateral available + await collateralContract() + .connect(user1) + .transfer(await user2.getAddress(), depositAmount.mul(2)); + + await systems().Core.connect(user2)['createAccount(uint128)'](user2AccountId); + + await collateralContract() + .connect(user2) + .approve(systems().Core.address, depositAmount.mul(2)); + + await systems() + .Core.connect(user2) + .deposit(user2AccountId, collateralAddress(), depositAmount.mul(2)); + + await systems().Core.connect(user2).delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(3), // user1 75%, user2 25% + ethers.utils.parseEther('1') + ); + + await systems().Core.connect(user2).mintUsd( + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(100) // should be enough collateral to mint this + ); + + await systems() + .Core.connect(user2) + .withdraw(user2AccountId, await systems().Core.getUsdToken(), depositAmount.div(100)); + }); + + // lock enough collateral that the market will *become* capacity locked when the user + // withdraws + const locked = ethers.utils.parseEther('1400'); + + // NOTE: if you are looking at this block and wondering if it would affect your test, + // this is to ensure all below cases are covered with locking. + // when position is increased, it should not be affected by locking + // when a position is decreased, it should only be allowed if the capacity does + // not become locked + before('market locks some capacity', async () => { + await MockMarket.setLocked(locked); + }); + + it( + 'user1 still has correct position', + verifyAccountState(accountId, poolId, depositAmount, startingDebt) + ); + it( + 'user2 still has correct position', + verifyAccountState(user2AccountId, poolId, depositAmount.div(3), depositAmount.div(100)) + ); + + describe('if one of the markets has a min delegation time', () => { + const restore = snapshotCheckpoint(provider); + + before('set market min delegation time to something high', async () => { + await MockMarket.setMinDelegationTime(86400); + }); + + describe('without time passing', async () => { + it('fails when min delegation timeout not elapsed', async () => { + await assertRevert( + systems().Core.connect(user2).delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(4), // user1 50%, user2 50% + ethers.utils.parseEther('1') + ), + `MinDelegationTimeoutPending("${poolId}",`, + systems().Core + ); + }); + + it('can increase delegation without waiting', async () => { + await systems() + .Core.connect(user2) + .delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + depositAmount.mul(2), + ethers.utils.parseEther('1') + ); + }); + + after(restore); + }); + + describe('after time passes', () => { + before('fast forward', async () => { + // for some reason `fastForward` doesn't seem to work with anvil + await fastForwardTo((await getTime(provider())) + 86400, provider()); + }); + + it('works', async () => { + await systems() + .Core.connect(user2) + .delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(2), + ethers.utils.parseEther('1') + ); + }); + }); + + after(restore); + }); + + // these exposure tests should be enabled when exposures other + // than 1 are allowed (which might be something we want to do) + describe.skip('increase exposure', async () => { + before('delegate', async () => { + await systems().Core.connect(user2).delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(3), // user1 50%, user2 50% + ethers.utils.parseEther('1') + ); + }); + + it( + 'user1 still has correct position', + verifyAccountState(accountId, poolId, depositAmount, 0) + ); + it( + 'user2 still has correct position', + verifyAccountState(user2AccountId, poolId, depositAmount.div(3), depositAmount.div(100)) + ); + }); + + describe.skip('reduce exposure', async () => { + before('delegate', async () => { + await systems().Core.connect(user2).delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(3), // user1 50%, user2 50% + ethers.utils.parseEther('1') + ); + }); + + it( + 'user1 still has correct position', + verifyAccountState(accountId, poolId, depositAmount, startingDebt) + ); + it( + 'user2 still has correct position', + verifyAccountState(user2AccountId, poolId, depositAmount.div(3), depositAmount.div(100)) + ); + }); + + describe('remove exposure', async () => { + before('delegate', async () => { + await systems().Core.connect(user2).delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(3), // user1 50%, user2 50% + ethers.utils.parseEther('1') + ); + }); + }); + + describe('increase collateral', async () => { + it('fails when not enough available collateral in account', async () => { + const wanted = depositAmount.mul(3); + const missing = wanted.sub(depositAmount.div(3)); + + await assertRevert( + systems() + .Core.connect(user2) + .delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + wanted, + ethers.utils.parseEther('1') + ), + `InsufficientAccountCollateral("${missing}")`, + systems().Core + ); + }); + + describe('when collateral is disabled', async () => { + const restore = snapshotCheckpoint(provider); + after(restore); + + before('disable collatearal', async () => { + const beforeConfiguration = + await systems().Core.getCollateralConfiguration(collateralAddress()); + + await systems() + .Core.connect(owner) + .configureCollateral({ ...beforeConfiguration, depositingEnabled: false }); + }); + + it('fails when trying to open delegation position with disabled collateral', async () => { + await assertRevert( + systems().Core.connect(user2).delegateCollateral( + user2AccountId, + 0, + collateralAddress(), + depositAmount, // user1 50%, user2 50% + ethers.utils.parseEther('1') + ), + `CollateralDepositDisabled("${collateralAddress()}")`, + systems().Core + ); + }); + }); + + describe('success', () => { + before('delegate', async () => { + await systems().Core.connect(user2).delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + depositAmount, // user1 50%, user2 50% + ethers.utils.parseEther('1') + ); + }); + + it( + 'user1 still has correct position', + verifyAccountState(accountId, poolId, depositAmount, startingDebt) + ); + it( + 'user2 position is increased', + verifyAccountState(user2AccountId, poolId, depositAmount, depositAmount.div(100)) + ); + }); + }); + + describe('decrease collateral', async () => { + it('fails when insufficient c-ratio', async () => { + const { issuanceRatioD18 } = + await systems().Core.getCollateralConfiguration(collateralAddress()); + const price = await systems().Core.getCollateralPrice(collateralAddress()); + const deposit = depositAmount.div(50); + const debt = depositAmount.div(100); + + await assertRevert( + systems() + .Core.connect(user2) + .delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(50), + ethers.utils.parseEther('1') + ), + `InsufficientCollateralRatio("${deposit}", "${debt}", "${deposit + .mul(price) + .div(debt)}", "${issuanceRatioD18}")`, + systems().Core + ); + }); + + it('fails when reducing to below minDelegation amount', async () => { + await assertRevert( + systems() + .Core.connect(user2) + .delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(51), + ethers.utils.parseEther('1') + ), + 'InsufficientDelegation("20000000000000000000")', + systems().Core + ); + }); + + it('fails when market becomes capacity locked', async () => { + // sanity + assert.ok( + !(await systems().Core.connect(user2).callStatic.isMarketCapacityLocked(marketId)) + ); + + await assertRevert( + systems() + .Core.connect(user2) + .delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(10), + ethers.utils.parseEther('1') + ), + `CapacityLocked("${marketId}")`, + systems().Core + ); + + // allow future tests to work without being locked + await MockMarket.setLocked(ethers.utils.parseEther('500')); + }); + + describe('when collateral is disabled', async () => { + const restore = snapshotCheckpoint(provider); + after(restore); + + before('disable collateral', async () => { + const beforeConfiguration = + await systems().Core.getCollateralConfiguration(collateralAddress()); + + await systems() + .Core.connect(owner) + .configureCollateral({ ...beforeConfiguration, depositingEnabled: false }); + }); + + describe('success', () => { + before('delegate', async () => { + await systems() + .Core.connect(user2) + .delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(10), + ethers.utils.parseEther('1') + ); + }); + + it( + 'user1 still has correct position', + verifyAccountState(accountId, poolId, depositAmount, startingDebt) + ); + it( + 'user2 position is decreased', + verifyAccountState( + user2AccountId, + poolId, + depositAmount.div(10), + depositAmount.div(100) + ) + ); + }); + }); + }); + + describe('remove collateral', async () => { + before('repay debt', async () => { + await systems() + .USD.connect(user2) + .approve(systems().Core.address, constants.MaxUint256.toString()); + + await systems() + .Core.connect(user2) + .deposit(user2AccountId, await systems().Core.getUsdToken(), depositAmount.div(100)); + + await systems() + .Core.connect(user2) + .burnUsd(user2AccountId, poolId, collateralAddress(), depositAmount.div(100)); + }); + + before('delegate', async () => { + await systems() + .Core.connect(user2) + .delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + 0, + ethers.utils.parseEther('1') + ); + }); + + it( + 'user1 still has correct position', + verifyAccountState(accountId, poolId, depositAmount, startingDebt) + ); + it('user2 position is closed', verifyAccountState(user2AccountId, poolId, 0, 0)); + + it('lets user2 re-stake again', async () => { + await systems().Core.connect(user2).delegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(3), // user1 75%, user2 25% + ethers.utils.parseEther('1') + ); + }); + }); + }); + }); + + describe('first user leaves', async () => { + before(restore); + before('erase debt', async () => { + await MockMarket.connect(user1).setReportedDebt(0); + }); + + before('undelegate', async () => { + await systems() + .Core.connect(user1) + .delegateCollateral( + accountId, + poolId, + collateralAddress(), + 0, + ethers.utils.parseEther('1') + ); + }); + + // now the pool is empty + it('exited user1 position', verifyAccountState(accountId, poolId, 0, 0)); + + it('vault is empty', async () => { + assertBn.equal( + (await systems().Core.callStatic.getVaultCollateral(poolId, collateralAddress())).amount, + 0 + ); + assertBn.equal( + await systems().Core.callStatic.getVaultDebt(poolId, collateralAddress()), + 0 + ); + }); + }); + }); + + describe('distribution chain edge cases', async () => { + beforeEach(restore); + it('edge case: double USD printing on market by not fully flushing with 2 collaterals', async () => { + const startingWithdrawable = await systems().Core.getWithdrawableMarketUsd(marketId); + + assertBn.gt(startingWithdrawable, 0); + + // first, mint max debt + await MockMarket.withdrawUsd(startingWithdrawable); + + // sanity + assertBn.equal(await systems().Core.getWithdrawableMarketUsd(marketId), 0); + + // next flush and rebalance (these methods are both write despite appearance) + await systems().Core.getVaultDebt(poolId, systems().Collateral2Mock.address); + await systems().Core.getVaultDebt(poolId, systems().Collateral2Mock.address); + + // finally, we shouldn't be able to mint + await assertRevert( + MockMarket.withdrawUsd(wei(1).toBN()), + 'NotEnoughLiquidity(', + systems().Core + ); + + assertBn.equal(await systems().Core.getWithdrawableMarketUsd(marketId), 0); + }); + + it('edge case: double USD printing on market by not fully flushing with `rebalancePool`', async () => { + const startingWithdrawable = await systems().Core.getWithdrawableMarketUsd(marketId); + + assertBn.gt(startingWithdrawable, 0); + + // first, mint max debt + await MockMarket.withdrawUsd(startingWithdrawable); + + // sanity + assertBn.equal(await systems().Core.getWithdrawableMarketUsd(marketId), 0); + + // next flush and rebalance + // two rebalancePool() required because the debt is accumulated on first call, but not actually assumed by market + // further rebalancePool() calls have no effect, but another call to `withdrawUsd()` can be made and then this repeated. + // NOTE: this attack could also be executed in a pool which has 2 vaults. Just sync only one of the vaults, and the debt from + // the other unsynced vault will not have its debt updated. so the security issue is not exclusive to being + // caused by the addition of this function, just more convenient + await systems().Core.rebalancePool(poolId, ethers.constants.AddressZero); + await systems().Core.rebalancePool(poolId, ethers.constants.AddressZero); + + // finally, we shouldn't be able to mint + await assertRevert( + MockMarket.withdrawUsd(wei(1).toBN()), + 'NotEnoughLiquidity(', + systems().Core + ); + + assertBn.equal(await systems().Core.getWithdrawableMarketUsd(marketId), 0); + }); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/VaultModule.test.ts b/protocol/synthetix/test/integration/modules/core/VaultModule.test.ts index d9924dea39..b53c20f7ec 100644 --- a/protocol/synthetix/test/integration/modules/core/VaultModule.test.ts +++ b/protocol/synthetix/test/integration/modules/core/VaultModule.test.ts @@ -7,7 +7,11 @@ import hre from 'hardhat'; import { bn, bootstrapWithStakedPool } from '../../bootstrap'; import Permissions from '../../mixins/AccountRBACMixin.permissions'; import { verifyUsesFeatureFlag } from '../../verifications'; -import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; +import { + delegateCollateral, + declareDelegateIntent, + expectedToDeltaDelegatedCollateral, +} from '../../../common'; import { wei } from '@synthetixio/wei'; describe('VaultModule', function () { @@ -175,7 +179,7 @@ describe('VaultModule', function () { }); }); - describe('delegateCollateral()', async () => { + describe('intent delegate', async () => { it( 'after bootstrap have correct amounts', verifyAccountState(accountId, poolId, depositAmount, 0) @@ -200,7 +204,7 @@ describe('VaultModule', function () { await assertRevert( systems() .Core.connect(user2) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId, poolId, collateralAddress(), @@ -217,7 +221,7 @@ describe('VaultModule', function () { await assertRevert( systems() .Core.connect(user1) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId, poolId, collateralAddress(), @@ -231,13 +235,21 @@ describe('VaultModule', function () { it('fails when trying to delegate less than minDelegation amount', async () => { await assertRevert( - systems().Core.connect(user1).delegateCollateral( - accountId, - 0, // 0 pool is just easy way to test another pool - collateralAddress(), - depositAmount.div(51), - ethers.utils.parseEther('1') - ), + systems() + .Core.connect(user1) + .declareIntentToDelegateCollateral( + accountId, + 0, // 0 pool is just easy way to test another pool + collateralAddress(), + await expectedToDeltaDelegatedCollateral( + systems, + accountId, + poolId, + collateralAddress(), + depositAmount.div(51) + ), + ethers.utils.parseEther('1') + ), 'InsufficientDelegation("20000000000000000000")', systems().Core ); @@ -247,11 +259,17 @@ describe('VaultModule', function () { await assertRevert( systems() .Core.connect(user1) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId, poolId, collateralAddress(), - depositAmount, + await expectedToDeltaDelegatedCollateral( + systems, + accountId, + poolId, + collateralAddress(), + depositAmount + ), ethers.utils.parseEther('1') ), 'InvalidCollateralAmount()', @@ -263,11 +281,17 @@ describe('VaultModule', function () { await assertRevert( systems() .Core.connect(user1) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId, 42, collateralAddress(), - depositAmount.div(50), + await expectedToDeltaDelegatedCollateral( + systems, + accountId, + poolId, + collateralAddress(), + depositAmount.div(50) + ), ethers.utils.parseEther('1') ), 'PoolNotFound("42")', @@ -277,19 +301,96 @@ describe('VaultModule', function () { verifyUsesFeatureFlag( () => systems().Core, - 'delegateCollateral', - () => + 'declareIntentToDelegateColl', + async () => systems() .Core.connect(user1) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId, 42, collateralAddress(), - depositAmount.div(50), + await expectedToDeltaDelegatedCollateral( + systems, + accountId, + poolId, + collateralAddress(), + depositAmount.div(50) + ), ethers.utils.parseEther('1') ) ); + describe('when both FF are enabled', async () => { + const LEGACY_DELEGATION_FEATURE_FLAG = ethers.utils.formatBytes32String('delegateCollateral'); + const DECLARE_DELEGATE_FEATURE_FLAG = ethers.utils.formatBytes32String( + 'declareIntentToDelegateColl' + ); + + const restore = snapshotCheckpoint(provider); + after(restore); + + before('enable both FFs', async () => { + await systems().Core.setFeatureFlagAllowAll(LEGACY_DELEGATION_FEATURE_FLAG, true); + await systems().Core.setFeatureFlagAllowAll(DECLARE_DELEGATE_FEATURE_FLAG, true); + }); + + it('fails when trying to use declareIntentToDelegateCollateral()', async () => { + await assertRevert( + systems() + .Core.connect(user1) + .declareIntentToDelegateCollateral( + accountId, + poolId, + collateralAddress(), + await expectedToDeltaDelegatedCollateral( + systems, + accountId, + poolId, + collateralAddress(), + depositAmount + ), + ethers.utils.parseEther('1') + ), + 'LegacyAndTwoStepsDelegateCollateralEnabled()', + systems().Core + ); + }); + + it('fails when trying to use processIntentToDelegateCollateralByIntents()', async () => { + await assertRevert( + systems().Core.connect(user2).processIntentToDelegateCollateralByIntents(accountId, [0]), + 'LegacyAndTwoStepsDelegateCollateralEnabled()', + systems().Core + ); + }); + + it('fails when trying to use processIntentToDelegateCollateralByPair()', async () => { + await assertRevert( + systems() + .Core.connect(user2) + .processIntentToDelegateCollateralByPair(accountId, poolId, collateralAddress()), + 'LegacyAndTwoStepsDelegateCollateralEnabled()', + systems().Core + ); + }); + + it('fails when trying to use delegateCollateral() (legacy delegation)', async () => { + await assertRevert( + systems() + .Core.connect(user1) + .delegateCollateral( + accountId, + poolId, + collateralAddress(), + depositAmount, + ethers.utils.parseEther('1') + ), + 'LegacyAndTwoStepsDelegateCollateralEnabled()', + systems().Core + ); + }); + }); + describe('when collateral is disabled by system', async () => { const restore = snapshotCheckpoint(provider); after(restore); @@ -313,11 +414,17 @@ describe('VaultModule', function () { await assertRevert( systems() .Core.connect(user1) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId, fakeVaultId, collateralAddress(), - depositAmount.div(50), + await expectedToDeltaDelegatedCollateral( + systems, + accountId, + fakeVaultId, + collateralAddress(), + depositAmount.div(50) + ), ethers.utils.parseEther('1') ), `CollateralDepositDisabled("${collateralAddress()}")`, @@ -345,7 +452,7 @@ describe('VaultModule', function () { .configureCollateral({ ...beforeConfiguration, depositingEnabled: true }); }); - // fails when collateral is disabled for the pool by pool owner + // fails when collateral limit is zero (disabled for the pool by pool owner) before('disable collateral for the pool by the pool owner', async () => { await systems() .Core.connect(user1) @@ -355,16 +462,21 @@ describe('VaultModule', function () { }); }); - // fails when collateral is disabled for the pool by pool owner it('fails when trying to open delegation position with disabled collateral', async () => { await assertRevert( systems() .Core.connect(user1) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId, fakeVaultId, collateralAddress(), - depositAmount.div(50), + await expectedToDeltaDelegatedCollateral( + systems, + accountId, + fakeVaultId, + collateralAddress(), + depositAmount.div(50) + ), ethers.utils.parseEther('1') ), `PoolCollateralLimitExceeded("${fakeVaultId}", "${collateralAddress()}", "${depositAmount @@ -384,15 +496,16 @@ describe('VaultModule', function () { }); it('the delegation works as expected with the enabled collateral', async () => { - await systems() - .Core.connect(user1) - .delegateCollateral( - accountId, - fakeVaultId, - collateralAddress(), - depositAmount.div(50), - ethers.utils.parseEther('1') - ); + await delegateCollateral( + systems, + owner, + user1, + accountId, + fakeVaultId, + collateralAddress(), + depositAmount.div(50), + ethers.utils.parseEther('1') + ); }); }); @@ -410,11 +523,17 @@ describe('VaultModule', function () { await assertRevert( systems() .Core.connect(user1) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId, poolId, collateralAddress(), - depositAmount.mul(2), + await expectedToDeltaDelegatedCollateral( + systems, + accountId, + poolId, + collateralAddress(), + depositAmount.mul(2) + ), ethers.utils.parseEther('1') ), `PoolCollateralLimitExceeded("${poolId}", "${collateralAddress()}", "${depositAmount @@ -484,7 +603,10 @@ describe('VaultModule', function () { .Core.connect(user2) .deposit(user2AccountId, collateralAddress(), depositAmount.mul(2)); - await systems().Core.connect(user2).delegateCollateral( + await delegateCollateral( + systems, + owner, + user2, user2AccountId, poolId, collateralAddress(), @@ -526,70 +648,14 @@ describe('VaultModule', function () { verifyAccountState(user2AccountId, poolId, depositAmount.div(3), depositAmount.div(100)) ); - describe('if one of the markets has a min delegation time', () => { - const restore = snapshotCheckpoint(provider); - - before('set market min delegation time to something high', async () => { - await MockMarket.setMinDelegationTime(86400); - }); - - describe('without time passing', async () => { - it('fails when min delegation timeout not elapsed', async () => { - await assertRevert( - systems().Core.connect(user2).delegateCollateral( - user2AccountId, - poolId, - collateralAddress(), - depositAmount.div(4), // user1 50%, user2 50% - ethers.utils.parseEther('1') - ), - `MinDelegationTimeoutPending("${poolId}",`, - systems().Core - ); - }); - - it('can increase delegation without waiting', async () => { - await systems() - .Core.connect(user2) - .delegateCollateral( - user2AccountId, - poolId, - collateralAddress(), - depositAmount.mul(2), - ethers.utils.parseEther('1') - ); - }); - - after(restore); - }); - - describe('after time passes', () => { - before('fast forward', async () => { - // for some reason `fastForward` doesn't seem to work with anvil - await fastForwardTo((await getTime(provider())) + 86400, provider()); - }); - - it('works', async () => { - await systems() - .Core.connect(user2) - .delegateCollateral( - user2AccountId, - poolId, - collateralAddress(), - depositAmount.div(2), - ethers.utils.parseEther('1') - ); - }); - }); - - after(restore); - }); - // these exposure tests should be enabled when exposures other // than 1 are allowed (which might be something we want to do) describe.skip('increase exposure', async () => { before('delegate', async () => { - await systems().Core.connect(user2).delegateCollateral( + await delegateCollateral( + systems, + owner, + user2, user2AccountId, poolId, collateralAddress(), @@ -610,7 +676,10 @@ describe('VaultModule', function () { describe.skip('reduce exposure', async () => { before('delegate', async () => { - await systems().Core.connect(user2).delegateCollateral( + await delegateCollateral( + systems, + owner, + user2, user2AccountId, poolId, collateralAddress(), @@ -631,7 +700,10 @@ describe('VaultModule', function () { describe('remove exposure', async () => { before('delegate', async () => { - await systems().Core.connect(user2).delegateCollateral( + await delegateCollateral( + systems, + owner, + user2, user2AccountId, poolId, collateralAddress(), @@ -649,11 +721,17 @@ describe('VaultModule', function () { await assertRevert( systems() .Core.connect(user2) - .delegateCollateral( + .declareIntentToDelegateCollateral( user2AccountId, poolId, collateralAddress(), - wanted, + await expectedToDeltaDelegatedCollateral( + systems, + user2AccountId, + poolId, + collateralAddress(), + wanted + ), ethers.utils.parseEther('1') ), `InsufficientAccountCollateral("${missing}")`, @@ -676,13 +754,21 @@ describe('VaultModule', function () { it('fails when trying to open delegation position with disabled collateral', async () => { await assertRevert( - systems().Core.connect(user2).delegateCollateral( - user2AccountId, - 0, - collateralAddress(), - depositAmount, // user1 50%, user2 50% - ethers.utils.parseEther('1') - ), + systems() + .Core.connect(user2) + .declareIntentToDelegateCollateral( + user2AccountId, + poolId, + collateralAddress(), + await expectedToDeltaDelegatedCollateral( + systems, + user2AccountId, + poolId, + collateralAddress(), + depositAmount + ), + ethers.utils.parseEther('1') + ), `CollateralDepositDisabled("${collateralAddress()}")`, systems().Core ); @@ -691,7 +777,10 @@ describe('VaultModule', function () { describe('success', () => { before('delegate', async () => { - await systems().Core.connect(user2).delegateCollateral( + await delegateCollateral( + systems, + owner, + user2, user2AccountId, poolId, collateralAddress(), @@ -719,16 +808,21 @@ describe('VaultModule', function () { const deposit = depositAmount.div(50); const debt = depositAmount.div(100); + const intentId = await declareDelegateIntent( + systems, + owner, + user2, + user2AccountId, + poolId, + collateralAddress(), + deposit, + ethers.utils.parseEther('1') + ); + await assertRevert( systems() .Core.connect(user2) - .delegateCollateral( - user2AccountId, - poolId, - collateralAddress(), - depositAmount.div(50), - ethers.utils.parseEther('1') - ), + .processIntentToDelegateCollateralByIntents(user2AccountId, [intentId]), `InsufficientCollateralRatio("${deposit}", "${debt}", "${deposit .mul(price) .div(debt)}", "${issuanceRatioD18}")`, @@ -737,14 +831,21 @@ describe('VaultModule', function () { }); it('fails when reducing to below minDelegation amount', async () => { + await systems().Core.connect(owner).forceDeleteAllAccountIntents(user2AccountId); await assertRevert( systems() .Core.connect(user2) - .delegateCollateral( + .declareIntentToDelegateCollateral( user2AccountId, poolId, collateralAddress(), - depositAmount.div(51), + await expectedToDeltaDelegatedCollateral( + systems, + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(51) + ), ethers.utils.parseEther('1') ), 'InsufficientDelegation("20000000000000000000")', @@ -758,16 +859,21 @@ describe('VaultModule', function () { !(await systems().Core.connect(user2).callStatic.isMarketCapacityLocked(marketId)) ); + const intentId = await declareDelegateIntent( + systems, + owner, + user2, + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(10), + ethers.utils.parseEther('1') + ); + await assertRevert( systems() .Core.connect(user2) - .delegateCollateral( - user2AccountId, - poolId, - collateralAddress(), - depositAmount.div(10), - ethers.utils.parseEther('1') - ), + .processIntentToDelegateCollateralByIntents(user2AccountId, [intentId]), `CapacityLocked("${marketId}")`, systems().Core ); @@ -791,15 +897,16 @@ describe('VaultModule', function () { describe('success', () => { before('delegate', async () => { - await systems() - .Core.connect(user2) - .delegateCollateral( - user2AccountId, - poolId, - collateralAddress(), - depositAmount.div(10), - ethers.utils.parseEther('1') - ); + await delegateCollateral( + systems, + owner, + user2, + user2AccountId, + poolId, + collateralAddress(), + depositAmount.div(10), + ethers.utils.parseEther('1') + ); }); it( @@ -835,15 +942,16 @@ describe('VaultModule', function () { }); before('delegate', async () => { - await systems() - .Core.connect(user2) - .delegateCollateral( - user2AccountId, - poolId, - collateralAddress(), - 0, - ethers.utils.parseEther('1') - ); + await delegateCollateral( + systems, + owner, + user2, + user2AccountId, + poolId, + collateralAddress(), + BigNumber.from(0), + ethers.utils.parseEther('1') + ); }); it( @@ -853,7 +961,10 @@ describe('VaultModule', function () { it('user2 position is closed', verifyAccountState(user2AccountId, poolId, 0, 0)); it('lets user2 re-stake again', async () => { - await systems().Core.connect(user2).delegateCollateral( + await delegateCollateral( + systems, + owner, + user2, user2AccountId, poolId, collateralAddress(), @@ -872,15 +983,16 @@ describe('VaultModule', function () { }); before('undelegate', async () => { - await systems() - .Core.connect(user1) - .delegateCollateral( - accountId, - poolId, - collateralAddress(), - 0, - ethers.utils.parseEther('1') - ); + await delegateCollateral( + systems, + owner, + user1, + accountId, + poolId, + collateralAddress(), + BigNumber.from(0), + ethers.utils.parseEther('1') + ); }); // now the pool is empty diff --git a/protocol/synthetix/test/integration/modules/core/VaultModuleDelegationIntentViews.test.ts b/protocol/synthetix/test/integration/modules/core/VaultModuleDelegationIntentViews.test.ts new file mode 100644 index 0000000000..fd849c4742 --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/VaultModuleDelegationIntentViews.test.ts @@ -0,0 +1,292 @@ +import assert from 'assert/strict'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import { BigNumber, ethers } from 'ethers'; +import hre from 'hardhat'; +import { bn, bootstrapWithStakedPool } from '../../bootstrap'; +import { declareDelegateIntent } from '../../../common'; +import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; + +describe('VaultModule Two-step Delegation views', function () { + const { + signers, + systems, + provider, + accountId, + poolId, + depositAmount, + collateralAddress, + oracleNodeId, + } = bootstrapWithStakedPool(); + + let owner: ethers.Signer, user1: ethers.Signer; + + let MockMarket: ethers.Contract; + let marketId: BigNumber; + + before('identify signers', async () => { + [owner, user1] = signers(); + }); + + before('give user1 permission to register market', async () => { + await systems() + .Core.connect(owner) + .addToFeatureFlagAllowlist( + ethers.utils.formatBytes32String('registerMarket'), + await user1.getAddress() + ); + }); + + before('deploy and connect fake market', async () => { + const factory = await hre.ethers.getContractFactory('MockMarket'); + + MockMarket = await factory.connect(owner).deploy(); + + marketId = await systems().Core.connect(user1).callStatic.registerMarket(MockMarket.address); + + await systems().Core.connect(user1).registerMarket(MockMarket.address); + + await MockMarket.connect(owner).initialize( + systems().Core.address, + marketId, + ethers.utils.parseEther('1') + ); + + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { + marketId: marketId, + weightD18: ethers.utils.parseEther('1'), + maxDebtShareValueD18: ethers.utils.parseEther('10000000000000000'), + }, + ]); + }); + + before('add second collateral type', async () => { + // add collateral + await ( + await systems().Core.connect(owner).configureCollateral({ + tokenAddress: systems().Collateral2Mock.address, + oracleNodeId: oracleNodeId(), + issuanceRatioD18: '5000000000000000000', + liquidationRatioD18: '1500000000000000000', + liquidationRewardD18: '20000000000000000000', + minDelegationD18: '20000000000000000000', + depositingEnabled: true, + }) + ).wait(); + + await systems() + .Core.connect(owner) + .configureCollateral({ + tokenAddress: await systems().Core.getUsdToken(), + oracleNodeId: ethers.utils.formatBytes32String(''), + issuanceRatioD18: bn(1.5), + liquidationRatioD18: bn(1.1), + liquidationRewardD18: 0, + minDelegationD18: 0, + depositingEnabled: true, + }); + }); + + before('set initial market window times', async () => { + await MockMarket.setDelegationCollateralConfiguration(200, 200, 200, 200); + }); + + const intentIds = new Array(); + let accumulatedDeposit = depositAmount; + let accummulatedTime = 0; + + before('add timed delegation intents', async () => { + // add some intents separated by 20 seconds each to test the timed views + accummulatedTime = await getTime(provider()); + + for (let i = 0; i < 5; i++) { + accumulatedDeposit = accumulatedDeposit.add(depositAmount.div(10)); + accummulatedTime += 20; + + await fastForwardTo(accummulatedTime, provider()); + const intentId = await declareDelegateIntent( + systems, + owner, + user1, + accountId, + poolId, + collateralAddress(), + accumulatedDeposit, + ethers.utils.parseEther('1'), + false + ); + + intentIds.push(intentId); + } + }); + + const restore = snapshotCheckpoint(provider); + + it('sanity check. It should be 5 intents', async () => { + assert.equal(intentIds.length, 5); + }); + + describe('Intents Views at initial time (none executable neither expired)', async () => { + before(restore); + + it('should have all intents', async () => { + const accountIntentsId = await systems().Core.getAccountIntentIds(accountId); + assertBn.equal(accountIntentsId.length, 5); + assert.deepEqual(accountIntentsId, intentIds); + }); + + it('should have no intents as executable', async () => { + const pendingExecutableIntents = await systems().Core.getAccountExecutableIntentIds( + accountId, + 100 + ); + assertBn.equal(pendingExecutableIntents.foundItems, 0); + // 5 is the total of intents, so all are in the array + assertBn.equal(pendingExecutableIntents.executableIntents.length, 5); + // all the intents ids are zero, it means none found as expired + assert.deepEqual(pendingExecutableIntents.executableIntents, [ + bn(0), + bn(0), + bn(0), + bn(0), + bn(0), + ]); + }); + + it('should have no intents as expired', async () => { + const pendingExecutableIntents = await systems().Core.getAccountExpiredIntentIds( + accountId, + 100 + ); + assertBn.equal(pendingExecutableIntents.foundItems, 0); + // 5 is the total of intents, so all are in the array + assertBn.equal(pendingExecutableIntents.expiredIntents.length, 5); + // all the intents ids are zero, it means none found as expired + assert.deepEqual(pendingExecutableIntents.expiredIntents, [ + bn(0), + bn(0), + bn(0), + bn(0), + bn(0), + ]); + }); + }); + + describe('Intents Views at window opened for all (all executable none expired)', async () => { + before(restore); + + before('fast forward to mid time', async () => { + await fastForwardTo(accummulatedTime + 100 + 110, provider()); + }); + + it('should have all intents', async () => { + const accountIntentsId = await systems().Core.getAccountIntentIds(accountId); + assertBn.equal(accountIntentsId.length, 5); + assert.deepEqual(accountIntentsId, intentIds); + }); + + it('should have all intents as pending and executable', async () => { + const pendingExecutableIntents = await systems().Core.getAccountExecutableIntentIds( + accountId, + 100 + ); + assertBn.equal(pendingExecutableIntents.foundItems, 5); + assertBn.equal(pendingExecutableIntents.executableIntents.length, 5); + assert.deepEqual(pendingExecutableIntents.executableIntents, intentIds); + }); + + it('should have no intents as expired', async () => { + const pendingExecutableIntents = await systems().Core.getAccountExpiredIntentIds( + accountId, + 100 + ); + assertBn.equal(pendingExecutableIntents.foundItems, 0); + // 5 is the total of intents, so all are in the array + assertBn.equal(pendingExecutableIntents.expiredIntents.length, 5); + // all the intents ids are zero, it means none found as expired + assert.deepEqual(pendingExecutableIntents.expiredIntents, [ + bn(0), + bn(0), + bn(0), + bn(0), + bn(0), + ]); + }); + }); + + describe('Intents Views at mid time (3 expired)', async () => { + before(restore); + + before('fast forward to mid time', async () => { + await fastForwardTo(accummulatedTime + 100 + 200 + 70, provider()); + }); + + it('should have all intents', async () => { + const accountIntentsId = await systems().Core.getAccountIntentIds(accountId); + assertBn.equal(accountIntentsId.length, 5); + assert.deepEqual(accountIntentsId, intentIds); + }); + + it('should have some as pending and executable', async () => { + const pendingExecutableIntents = await systems().Core.getAccountExecutableIntentIds( + accountId, + 100 + ); + assertBn.equal(pendingExecutableIntents.foundItems, 2); + assertBn.equal(pendingExecutableIntents.executableIntents[0], intentIds[3]); + assertBn.equal(pendingExecutableIntents.executableIntents[1], intentIds[4]); + }); + + it('should have some intents as expired', async () => { + const pendingExecutableIntents = await systems().Core.getAccountExpiredIntentIds( + accountId, + 100 + ); + assertBn.equal(pendingExecutableIntents.foundItems, 3); + assertBn.equal(pendingExecutableIntents.expiredIntents[0], intentIds[0]); + assertBn.equal(pendingExecutableIntents.expiredIntents[1], intentIds[1]); + assertBn.equal(pendingExecutableIntents.expiredIntents[2], intentIds[2]); + }); + }); + + describe('Intents Views at closed window for all (all expired)', async () => { + before(restore); + + before('fast forward to mid time', async () => { + await fastForwardTo(accummulatedTime + 100 + 200 + 110, provider()); + }); + + it('should have all intents', async () => { + const accountIntentsId = await systems().Core.getAccountIntentIds(accountId); + assertBn.equal(accountIntentsId.length, 5); + assert.deepEqual(accountIntentsId, intentIds); + }); + + it('should have some as pending and executable', async () => { + const pendingExecutableIntents = await systems().Core.getAccountExecutableIntentIds( + accountId, + 100 + ); + assertBn.equal(pendingExecutableIntents.foundItems, 0); + assert.deepEqual(pendingExecutableIntents.executableIntents, [ + bn(0), + bn(0), + bn(0), + bn(0), + bn(0), + ]); + }); + + it('should have some intents as expired', async () => { + const pendingExecutableIntents = await systems().Core.getAccountExpiredIntentIds( + accountId, + 100 + ); + assertBn.equal(pendingExecutableIntents.foundItems, 5); + assert.deepEqual(pendingExecutableIntents.expiredIntents, intentIds); + }); + }); +}); diff --git a/protocol/synthetix/test/integration/modules/core/VaultModuleDelegationTiming.test.ts b/protocol/synthetix/test/integration/modules/core/VaultModuleDelegationTiming.test.ts new file mode 100644 index 0000000000..04f904e45a --- /dev/null +++ b/protocol/synthetix/test/integration/modules/core/VaultModuleDelegationTiming.test.ts @@ -0,0 +1,714 @@ +import assert from 'assert/strict'; +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'; +import { BigNumber, ethers } from 'ethers'; +import hre from 'hardhat'; +import { bn, bootstrapWithStakedPool } from '../../bootstrap'; +import { delegateCollateral, declareDelegateIntent } from '../../../common'; +import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; + +describe('VaultModule Two-step Delegation timing', function () { + const { + signers, + systems, + provider, + accountId, + poolId, + depositAmount, + collateralAddress, + oracleNodeId, + } = bootstrapWithStakedPool(); + + let owner: ethers.Signer, user1: ethers.Signer, user2: ethers.Signer; + + let MockMarket: ethers.Contract; + let marketId: BigNumber; + + before('identify signers', async () => { + [owner, user1, user2] = signers(); + }); + + before('give user1 permission to register market', async () => { + await systems() + .Core.connect(owner) + .addToFeatureFlagAllowlist( + ethers.utils.formatBytes32String('registerMarket'), + await user1.getAddress() + ); + }); + + before('deploy and connect fake market', async () => { + const factory = await hre.ethers.getContractFactory('MockMarket'); + + MockMarket = await factory.connect(owner).deploy(); + + marketId = await systems().Core.connect(user1).callStatic.registerMarket(MockMarket.address); + + await systems().Core.connect(user1).registerMarket(MockMarket.address); + + await MockMarket.connect(owner).initialize( + systems().Core.address, + marketId, + ethers.utils.parseEther('1') + ); + + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { + marketId: marketId, + weightD18: ethers.utils.parseEther('1'), + maxDebtShareValueD18: ethers.utils.parseEther('10000000000000000'), + }, + ]); + }); + + before('add second collateral type', async () => { + // add collateral + + await systems().Core.connect(owner).configureCollateral({ + tokenAddress: systems().Collateral2Mock.address, + oracleNodeId: oracleNodeId(), + issuanceRatioD18: '5000000000000000000', + liquidationRatioD18: '1500000000000000000', + liquidationRewardD18: '20000000000000000000', + minDelegationD18: '20000000000000000000', + depositingEnabled: true, + }); + + await systems() + .Core.connect(owner) + .configureCollateral({ + tokenAddress: await systems().Core.getUsdToken(), + oracleNodeId: ethers.utils.formatBytes32String(''), + issuanceRatioD18: bn(1.5), + liquidationRatioD18: bn(1.1), + liquidationRewardD18: 0, + minDelegationD18: 0, + depositingEnabled: true, + }); + }); + + const restore = snapshotCheckpoint(provider); + + it('sanity check (to ensure describes happen after the snapshot)', async () => { + assert(true); + }); + + // Note: Generic Delegation tests uses the default configuration where the delegation window is open immediately and forever. + // The tests below will test the delegation timing configuration. + describe('Delegation Timing failures', async () => { + let intentId: BigNumber; + let declareDelegateIntentTime: number; + before(restore); + before('set market window times', async () => { + const previousConfiguration = await MockMarket.getDelegationCollateralConfiguration(); + await MockMarket.setDelegationCollateralConfiguration( + 100, + 20, + previousConfiguration[2], + previousConfiguration[3] + ); + }); + + before('declare intent to delegate', async () => { + intentId = await declareDelegateIntent( + systems, + owner, + user1, + accountId, + poolId, + collateralAddress(), + depositAmount.mul(2), + ethers.utils.parseEther('1') + ); + + declareDelegateIntentTime = await getTime(provider()); + }); + + it('skips the execution of a delegation if window is not open (too soon)', async () => { + await fastForwardTo(declareDelegateIntentTime + 95, provider()); + + const tx = await systems() + .Core.connect(user2) + .processIntentToDelegateCollateralByIntents(accountId, [intentId]); + + await assertEvent( + tx, + `DelegationIntentSkipped(${intentId}, ${accountId}, ${poolId}, "${collateralAddress()}")`, + systems().Core + ); + }); + + it('removes a delegation if window is already closed (too late)', async () => { + await fastForwardTo(declareDelegateIntentTime + 121, provider()); + + const tx = await systems() + .Core.connect(user2) + .processIntentToDelegateCollateralByIntents(accountId, [intentId]); + + await assertEvent( + tx, + `DelegationIntentRemoved(${intentId}, ${accountId}, ${poolId}, "${collateralAddress()}")`, + systems().Core + ); + }); + }); + + describe('Un-Delegation Timing failures', async () => { + let intentId: BigNumber; + let declareDelegateIntentTime: number; + before(restore); + + before('set market window times', async () => { + const previousConfiguration = await MockMarket.getDelegationCollateralConfiguration(); + await MockMarket.setDelegationCollateralConfiguration( + previousConfiguration[0], + previousConfiguration[1], + 100, + 20 + ); + }); + + before('first delegete some', async () => { + // Delegeta something that can be undelegated later + await delegateCollateral( + systems, + owner, + user1, + accountId, + poolId, + collateralAddress(), + depositAmount.mul(2), + ethers.utils.parseEther('1') + ); + }); + + before('declare intent to un-delegate', async () => { + intentId = await declareDelegateIntent( + systems, + owner, + user1, + accountId, + poolId, + collateralAddress(), + depositAmount.div(10), + ethers.utils.parseEther('1') + ); + + declareDelegateIntentTime = await getTime(provider()); + }); + + it('skips the execution of an un-delegation if window is not open (too soon)', async () => { + await fastForwardTo(declareDelegateIntentTime + 95, provider()); + + const tx = await systems() + .Core.connect(user2) + .processIntentToDelegateCollateralByIntents(accountId, [intentId]); + + await assertEvent( + tx, + `DelegationIntentSkipped(${intentId}, ${accountId}, ${poolId}, "${collateralAddress()}")`, + systems().Core + ); + }); + + it('removes an un-delegation if window is already closed (too late)', async () => { + await fastForwardTo(declareDelegateIntentTime + 121, provider()); + + const tx = await systems() + .Core.connect(user2) + .processIntentToDelegateCollateralByIntents(accountId, [intentId]); + + await assertEvent( + tx, + `DelegationIntentRemoved(${intentId}, ${accountId}, ${poolId}, "${collateralAddress()}")`, + systems().Core + ); + }); + }); + + describe('Delegation Timing failures with global params', async () => { + let intentId: BigNumber; + let declareDelegateIntentTime: number; + before(restore); + + before('set global window times', async () => { + await systems() + .Core.connect(owner) + .setConfig( + ethers.utils.formatBytes32String('delegateCollateralDelay_min'), + ethers.utils.hexZeroPad(ethers.BigNumber.from(120).toHexString(), 32) + ); // use 120 as the global min delay + await systems() + .Core.connect(owner) + .setConfig( + ethers.utils.formatBytes32String('delegateCollateralWindow_max'), + ethers.utils.hexZeroPad(ethers.BigNumber.from(10).toHexString(), 32) + ); // use 10 as the global max window + }); + + before('set market window times', async () => { + const previousConfiguration = await MockMarket.getDelegationCollateralConfiguration(); + await MockMarket.setDelegationCollateralConfiguration( + 10, + 200, + previousConfiguration[2], + previousConfiguration[3] + ); + }); + + before('declare intent to delegate', async () => { + intentId = await declareDelegateIntent( + systems, + owner, + user1, + accountId, + poolId, + collateralAddress(), + depositAmount.mul(2), + ethers.utils.parseEther('1') + ); + + declareDelegateIntentTime = await getTime(provider()); + }); + + it('skips the execution of a delegation if window is not open (too soon)', async () => { + await fastForwardTo(declareDelegateIntentTime + 115, provider()); + + const tx = await systems() + .Core.connect(user2) + .processIntentToDelegateCollateralByIntents(accountId, [intentId]); + + await assertEvent( + tx, + `DelegationIntentSkipped(${intentId}, ${accountId}, ${poolId}, "${collateralAddress()}")`, + systems().Core + ); + }); + + it('removes a delegation if window is already closed (too late)', async () => { + await fastForwardTo(declareDelegateIntentTime + 131, provider()); + + const tx = await systems() + .Core.connect(user2) + .processIntentToDelegateCollateralByIntents(accountId, [intentId]); + + await assertEvent( + tx, + `DelegationIntentRemoved(${intentId}, ${accountId}, ${poolId}, "${collateralAddress()}")`, + systems().Core + ); + }); + }); + + describe('Force Delete intents (only system owner)', async () => { + let intentId: BigNumber; + before(restore); + + before('declare intent to delegate', async () => { + intentId = await declareDelegateIntent( + systems, + owner, + user1, + accountId, + poolId, + collateralAddress(), + depositAmount.mul(2), + ethers.utils.parseEther('1') + ); + }); + + const restoreToDeclare = snapshotCheckpoint(provider); + + it('fails to call the functions as non-owner', async () => { + await assertRevert( + systems().Core.connect(user1).forceDeleteAllAccountIntents(accountId), + `Unauthorized("${await user1.getAddress()}")`, + systems().Core + ); + }); + + it('fails to call the functions as non-owner', async () => { + await assertRevert( + systems().Core.connect(user1).forceDeleteIntents(accountId, [intentId]), + `Unauthorized("${await user1.getAddress()}")`, + systems().Core + ); + }); + + describe('Force Delete intents by ID', async () => { + before(restoreToDeclare); + it('sanity check. The intent exists', async () => { + const intent = await systems().Core.connect(owner).getAccountIntent(accountId, intentId); + assertBn.equal(intent[0], accountId); + }); + + it('can force delete an intent by id', async () => { + await systems().Core.connect(owner).forceDeleteIntents(accountId, [intentId]); + }); + + it('intent is deleted', async () => { + await assertRevert( + systems().Core.connect(owner).getAccountIntent(accountId, intentId), + 'InvalidDelegationIntent()', + systems().Core + ); + }); + }); + + describe('Force Delete intents by Account', async () => { + before(restoreToDeclare); + it('sanity check. The intent exists', async () => { + const intent = await systems().Core.connect(owner).getAccountIntent(accountId, intentId); + assertBn.equal(intent[0], accountId); + }); + + it('can force delete all expired account intents', async () => { + await systems().Core.connect(owner).forceDeleteAllAccountIntents(accountId); + }); + + it('intent is deleted', async () => { + await assertRevert( + systems().Core.connect(owner).getAccountIntent(accountId, intentId), + 'InvalidDelegationIntent()', + systems().Core + ); + }); + }); + }); + + describe('Self Delete intents (only account owner)', async () => { + let intentId: BigNumber; + let declareDelegateIntentTime: number; + before(restore); + + before('set market window times', async () => { + const previousConfiguration = await MockMarket.getDelegationCollateralConfiguration(); + await MockMarket.setDelegationCollateralConfiguration( + 100, + 20, + previousConfiguration[2], + previousConfiguration[3] + ); + }); + + before('declare intent to delegate', async () => { + intentId = await declareDelegateIntent( + systems, + owner, + user1, + accountId, + poolId, + collateralAddress(), + depositAmount.mul(2), + ethers.utils.parseEther('1') + ); + + declareDelegateIntentTime = await getTime(provider()); + }); + + const restoreToDeclare = snapshotCheckpoint(provider); + + it("fails to delete an intent that didn't expire", async () => { + await fastForwardTo(declareDelegateIntentTime + 115, provider()); + await assertRevert( + systems().Core.connect(user1).deleteExpiredIntents(accountId, [intentId]), + `DelegationIntentNotExpired`, + systems().Core + ); + }); + + describe('can delete an intent by id (as another user)', async () => { + before(restoreToDeclare); + + it('sanity check. The intent exists', async () => { + const intent = await systems().Core.connect(user2).getAccountIntent(accountId, intentId); + assertBn.equal(intent[0], accountId); + }); + + it('can delete an expired intent', async () => { + await fastForwardTo(declareDelegateIntentTime + 121, provider()); + await systems().Core.connect(user2).deleteExpiredIntents(accountId, [intentId]); + }); + + it('intent is deleted', async () => { + await assertRevert( + systems().Core.connect(user2).getAccountIntent(accountId, intentId), + 'InvalidDelegationIntent()', + systems().Core + ); + }); + }); + + describe('can delete all expired intents', async () => { + before(restoreToDeclare); + + it('sanity check. The intent exists', async () => { + const intent = await systems().Core.connect(user2).getAccountIntent(accountId, intentId); + assertBn.equal(intent[0], accountId); + }); + + it('can delete all account expired intents', async () => { + await fastForwardTo(declareDelegateIntentTime + 121, provider()); + await systems().Core.connect(user2).deleteAllExpiredIntents(accountId); + }); + + it('intent is deleted', async () => { + await assertRevert( + systems().Core.connect(user2).getAccountIntent(accountId, intentId), + 'InvalidDelegationIntent()', + systems().Core + ); + }); + }); + }); + + describe('Edge case - Self delete after configuration change', async () => { + let intentId: BigNumber; + let declareDelegateIntentTime: number; + before(restore); + + before('set market window times', async () => { + const previousConfiguration = await MockMarket.getDelegationCollateralConfiguration(); + await MockMarket.setDelegationCollateralConfiguration( + 10000, + 2000, + previousConfiguration[2], + previousConfiguration[3] + ); + }); + + before('declare intent to delegate', async () => { + intentId = await declareDelegateIntent( + systems, + owner, + user1, + accountId, + poolId, + collateralAddress(), + depositAmount.mul(2), + ethers.utils.parseEther('1') + ); + + declareDelegateIntentTime = await getTime(provider()); + }); + + before('set market window times', async () => { + const previousConfiguration = await MockMarket.getDelegationCollateralConfiguration(); + await MockMarket.setDelegationCollateralConfiguration( + 100, + 20, + previousConfiguration[2], + previousConfiguration[3] + ); + }); + + it('sanity check. The intent exists', async () => { + const intent = await systems().Core.connect(user1).getAccountIntent(accountId, intentId); + assertBn.equal(intent[0], accountId); + }); + + it('can force delete all expired account intents', async () => { + await fastForwardTo(declareDelegateIntentTime + 121, provider()); + await systems().Core.connect(user1).deleteAllExpiredIntents(accountId); + }); + + it('intent is deleted', async () => { + await assertRevert( + systems().Core.connect(user1).getAccountIntent(accountId, intentId), + 'InvalidDelegationIntent()', + systems().Core + ); + }); + }); + + describe('Edge case - Self delete non configured (default expiration is after delay)', async () => { + let intentId: BigNumber; + let declareDelegateIntentTime: number; + before(restore); + before('set market window times', async () => { + const previousConfiguration = await MockMarket.getDelegationCollateralConfiguration(); + await MockMarket.setDelegationCollateralConfiguration( + 150, + 0, + previousConfiguration[2], + previousConfiguration[3] + ); + // Note: not setting the window size to zero - forever expiration to execute, immediate expiration to delete + }); + + before('declare intent to delegate', async () => { + intentId = await declareDelegateIntent( + systems, + owner, + user1, + accountId, + poolId, + collateralAddress(), + depositAmount.mul(2), + ethers.utils.parseEther('1') + ); + + declareDelegateIntentTime = await getTime(provider()); + }); + + it('sanity check 1. The intent exists', async () => { + const intent = await systems().Core.connect(user1).getAccountIntent(accountId, intentId); + assertBn.equal(intent[0], poolId); + assert.equal(intent[1], collateralAddress()); + }); + + it('fails to delete before window starts', async () => { + await fastForwardTo(declareDelegateIntentTime + 145, provider()); + await systems().Core.connect(user1).deleteAllExpiredIntents(accountId); + }); + + it('sanity check 2. The intent exists', async () => { + const intent = await systems().Core.connect(user1).getAccountIntent(accountId, intentId); + assertBn.equal(intent[0], poolId); + assert.equal(intent[1], collateralAddress()); + }); + + it('fails to delete after the window starts', async () => { + await fastForwardTo(declareDelegateIntentTime + 200, provider()); + await systems().Core.connect(user1).deleteAllExpiredIntents(accountId); + }); + + it('sanity check 3. The intent exists', async () => { + const intent = await systems().Core.connect(user1).getAccountIntent(accountId, intentId); + assertBn.equal(intent[0], poolId); + assert.equal(intent[1], collateralAddress()); + }); + + it('can force delete all expired account intents', async () => { + await fastForwardTo(declareDelegateIntentTime + 150 + 86400 * 360 + 1, provider()); + await systems().Core.connect(user1).deleteAllExpiredIntents(accountId); + }); + + it('intent is deleted', async () => { + await assertRevert( + systems().Core.connect(user1).getAccountIntent(accountId, intentId), + 'InvalidDelegationIntent()', + systems().Core + ); + }); + }); + + describe('Edge case - Multiple markets with different timing configuration', async () => { + // note: with 2 markets with different config, will use the longest delay time configuration + + let SecondMockMarket: ethers.Contract; + let secondMarketId: BigNumber; + let intentId: BigNumber; + let declareDelegateIntentTime: number; + + before(restore); + + before('deploy and connect a second fake market', async () => { + const factory = await hre.ethers.getContractFactory('MockMarket'); + + SecondMockMarket = await factory.connect(owner).deploy(); + + secondMarketId = await systems() + .Core.connect(user1) + .callStatic.registerMarket(SecondMockMarket.address); + + await systems().Core.connect(user1).registerMarket(SecondMockMarket.address); + + await SecondMockMarket.connect(owner).initialize( + systems().Core.address, + secondMarketId, + ethers.utils.parseEther('1') + ); + + await systems() + .Core.connect(owner) + .setPoolConfiguration(poolId, [ + { + marketId: secondMarketId, + weightD18: ethers.utils.parseEther('1'), + maxDebtShareValueD18: ethers.utils.parseEther('10000000000000000'), + }, + ]); + }); + + before('set both market window times', async () => { + await MockMarket.setDelegationCollateralConfiguration(100, 0, 100, 0); + + await SecondMockMarket.setDelegationCollateralConfiguration(200, 20, 200, 20); + }); + + before('declare intent to delegate', async () => { + intentId = await declareDelegateIntent( + systems, + owner, + user1, + accountId, + poolId, + collateralAddress(), + depositAmount.mul(2), + ethers.utils.parseEther('1') + ); + + declareDelegateIntentTime = await getTime(provider()); + }); + + it('intent is skipped to execute an intent based on the shortest delay', async () => { + await fastForwardTo(declareDelegateIntentTime + 120, provider()); + + const tx = await systems() + .Core.connect(user2) + .processIntentToDelegateCollateralByIntents(accountId, [intentId]); + + await assertEvent( + tx, + `DelegationIntentSkipped(${intentId}, ${accountId}, ${poolId}, "${collateralAddress()}")`, + systems().Core + ); + }); + + it('can execute using the longest delay', async () => { + await fastForwardTo(declareDelegateIntentTime + 210, provider()); + + await systems() + .Core.connect(user2) + .processIntentToDelegateCollateralByIntents(accountId, [intentId]); + }); + + it('intent is removed (skipped) to execute an intent based on the wrong window', async () => { + const newIntentId = await declareDelegateIntent( + systems, + owner, + user1, + accountId, + poolId, + collateralAddress(), + depositAmount.mul(1), + ethers.utils.parseEther('1') + ); + + const newDeclareDelegateIntentTime = await getTime(provider()); + + await fastForwardTo(newDeclareDelegateIntentTime + 250, provider()); + + const tx = await systems() + .Core.connect(user2) + .processIntentToDelegateCollateralByIntents(accountId, [newIntentId]); + + await assertEvent( + tx, + `DelegationIntentSkipped(${newIntentId}, ${accountId}, ${poolId}, "${collateralAddress()}")`, + systems().Core + ); + + await assertEvent( + tx, + `DelegationIntentRemoved(${newIntentId}, ${accountId}, ${poolId}, "${collateralAddress()}")`, + systems().Core + ); + }); + }); +}); diff --git a/protocol/synthetix/test/integration/storage/Pool.test.ts b/protocol/synthetix/test/integration/storage/Pool.test.ts index 64c5c825ea..74ac2b33c4 100644 --- a/protocol/synthetix/test/integration/storage/Pool.test.ts +++ b/protocol/synthetix/test/integration/storage/Pool.test.ts @@ -105,13 +105,16 @@ describe('Pool', function () { before('increase collateral', async () => { await systems() .Core.connect(user1) - .delegateCollateral( + .declareIntentToDelegateCollateral( accountId, poolId, collateralAddress(), depositAmount.mul(2), ethers.utils.parseEther('1') ); + await systems() + .Core.connect(user1) + .processIntentToDelegateCollateralByPair(accountId, poolId, collateralAddress()); }); it('the ultimate capacity of the pool ends up higher', async () => { diff --git a/yarn.lock b/yarn.lock index 3f6ed4c5a5..698f2e41e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3182,6 +3182,7 @@ __metadata: "@synthetixio/common-config": "workspace:*" "@synthetixio/core-utils": "workspace:*" "@synthetixio/docgen": "workspace:*" + "@synthetixio/main": "workspace:*" "@synthetixio/wei": "npm:^2.74.4" ethers: "npm:^5.7.2" hardhat: "npm:^2.19.5"