diff --git a/markets/legacy-market/contracts/LegacyMarket.sol b/markets/legacy-market/contracts/LegacyMarket.sol index 94fc340e70..631d5ea8fb 100644 --- a/markets/legacy-market/contracts/LegacyMarket.sol +++ b/markets/legacy-market/contracts/LegacyMarket.sol @@ -45,6 +45,9 @@ contract LegacyMarket is ILegacyMarket, Ownable, UUPSImplementation, IMarket, IE // NOTE: below field is now unused but we leave it here to reduce maintenance burden ISNXDistributor public rewardsDistributor; + // in case an account nft was not able to be transferred to a owner's address due to some error, we allow transferring it later using this structure. + mapping(uint256 => address) deferredAccounts; + error MigrationInProgress(); // redefine event so it can be catched by ethers @@ -162,7 +165,7 @@ contract LegacyMarket is ILegacyMarket, Ownable, UUPSImplementation, IMarket, IE uint256 afterDebt = iss.debtBalanceOf(address(this), "sUSD"); // approximately equal check because some rounding error can happen on the v2x side - if (beforeDebt - afterDebt < amount - 1) { + if (beforeDebt - afterDebt == 0 || beforeDebt - afterDebt < amount - 100) { revert V2xPaused(); } @@ -187,6 +190,10 @@ contract LegacyMarket is ILegacyMarket, Ownable, UUPSImplementation, IMarket, IE * @inheritdoc ILegacyMarket */ function migrateOnBehalf(address staker, uint128 accountId) external { + if (staker == ERC2771Context._msgSender() && pauseMigration) { + revert Paused(); + } + _migrate(staker, accountId); } @@ -277,16 +284,38 @@ contract LegacyMarket is ILegacyMarket, Ownable, UUPSImplementation, IMarket, IE debtValueMigrated ); + if (v3System.isVaultLiquidatable(preferredPoolId, address(oldSynthetix))) { + revert Paused(); + } + // send the built v3 account to the staker - IERC721(v3System.getAccountTokenAddress()).safeTransferFrom( - address(this), - staker, - accountId - ); + try + IERC721(v3System.getAccountTokenAddress()).safeTransferFrom( + address(this), + staker, + accountId + ) + {} catch { + deferredAccounts[accountId] = staker; + } emit AccountMigrated(staker, accountId, collateralMigrated, debtValueMigrated); } + /** + * @dev In case a previously migrated account was not able to be sent to a user during the migration, this function can be + * called in order to claim the token afterwards to any address. + */ + function transferDeferredAccount(uint256 accountId, address to) external { + if (deferredAccounts[accountId] != ERC2771Context._msgSender()) { + revert AccessError.Unauthorized(ERC2771Context._msgSender()); + } + + deferredAccounts[accountId] = address(0); + + IERC721(v3System.getAccountTokenAddress()).safeTransferFrom(address(this), to, accountId); + } + /** * @dev Moves the collateral and debt associated {staker} in the V2 system to this market. */ @@ -351,6 +380,10 @@ contract LegacyMarket is ILegacyMarket, Ownable, UUPSImplementation, IMarket, IE revert V2xPaused(); } + if (totalDebtShares == 0) { + return 0; + } + return (debtSharesMigrated * totalSystemDebt) / totalDebtShares; } diff --git a/protocol/synthetix/contracts/interfaces/ICollateralModule.sol b/protocol/synthetix/contracts/interfaces/ICollateralModule.sol index a7a358a8a1..0664416aa7 100644 --- a/protocol/synthetix/contracts/interfaces/ICollateralModule.sol +++ b/protocol/synthetix/contracts/interfaces/ICollateralModule.sol @@ -43,17 +43,10 @@ interface ICollateralModule { /** * @notice Emitted when a lock is cleared from an account due to expiration - * @param accountId The id of the account that has the expired lock - * @param collateralType The address of the collateral type that was unlocked * @param tokenAmount The amount of collateral that was unlocked, demoninated in system units (1e18) * @param expireTimestamp unix timestamp at which the unlock is due to expire */ - event CollateralLockExpired( - uint128 indexed accountId, - address indexed collateralType, - uint256 tokenAmount, - uint64 expireTimestamp - ); + event CollateralLockExpired(uint256 tokenAmount, uint64 expireTimestamp); /** * @notice Emitted when `tokenAmount` of collateral of type `collateralType` is withdrawn from account `accountId` by `sender`. @@ -72,6 +65,9 @@ interface ICollateralModule { /** * @notice Deposits `tokenAmount` of collateral of type `collateralType` into account `accountId`. * @dev Anyone can deposit into anyone's active account without restriction. + * @dev Depositing to account will automatically clear expired locks on a user's account. If there are an + * extremely large number of locks to process, it may not be possible to call `deposit` due to the block gas + * limit. In cases such as these, `cleanExpiredLocks` must be called first to clear any outstanding locks. * @param accountId The id of the account that is making the deposit. * @param collateralType The address of the token to be deposited. * @param tokenAmount The amount being deposited, denominated in the token's native decimal representation. @@ -132,7 +128,7 @@ interface ICollateralModule { address collateralType, uint256 offset, uint256 count - ) external returns (uint256 cleared); + ) external returns (uint256 cleared, uint256 remainingLockAmountD18); /** * @notice Get a list of locks existing in account. Lists all locks in storage, even if they are expired diff --git a/protocol/synthetix/contracts/interfaces/IVaultModule.sol b/protocol/synthetix/contracts/interfaces/IVaultModule.sol index e5f73cc775..2c7cbc8a10 100644 --- a/protocol/synthetix/contracts/interfaces/IVaultModule.sol +++ b/protocol/synthetix/contracts/interfaces/IVaultModule.sol @@ -22,6 +22,11 @@ interface IVaultModule { */ error InvalidCollateralAmount(); + /** + * @notice Thrown when there is insufficient c-ratio in the vault after delegating + */ + error InsufficientVaultCollateralRatio(uint128 poolId, address collateralType); + /** * @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. diff --git a/protocol/synthetix/contracts/modules/core/AssociateDebtModule.sol b/protocol/synthetix/contracts/modules/core/AssociateDebtModule.sol index 2c11bbb444..d64cc240fa 100644 --- a/protocol/synthetix/contracts/modules/core/AssociateDebtModule.sol +++ b/protocol/synthetix/contracts/modules/core/AssociateDebtModule.sol @@ -56,21 +56,22 @@ contract AssociateDebtModule is IAssociateDebtModule { revert AccessError.Unauthorized(ERC2771Context._msgSender()); } + // Remove the pro-rata debt we are about to assign from the market level (ensures it does not leak down to any other pools or vaults that may be connected) + marketData.poolsDebtDistribution.distributeValue(-amount.toInt()); + // Refresh latest account debt (do this before hasMarket check to verify max debt per share) poolData.updateAccountDebt(collateralType, accountId); + // Rebalance here because the pool may be bumped in/out of range of the market depending on + // the debt change that just happened + poolData.rebalanceMarketsInPool(); + // The market must appear in pool configuration of the specified position (and not be out of range) if (!poolData.hasMarket(marketId)) { revert NotFundedByPool(marketId, poolId); } - // rebalance here because this is a good opporitunity to do so, and because its required for correct debt accounting after account debt update - poolData.rebalanceMarketsInPool(); - - // Remove the debt we're about to assign to a specific position, pro-rata - epochData.distributeDebtToAccounts(-amount.toInt()); - - // Assign this debt to the specified position + // We can now reassign this debt to the specified position epochData.assignDebtToAccount(accountId, amount.toInt()); // since the reassignment of debt removed some debt form the user's account before it was added, a consoldation is necessary diff --git a/protocol/synthetix/contracts/modules/core/CollateralModule.sol b/protocol/synthetix/contracts/modules/core/CollateralModule.sol index c8ba90d742..bc647b1b03 100644 --- a/protocol/synthetix/contracts/modules/core/CollateralModule.sol +++ b/protocol/synthetix/contracts/modules/core/CollateralModule.sol @@ -56,6 +56,8 @@ contract CollateralModule is ICollateralModule { collateralType.safeTransferFrom(depositFrom, self, tokenAmount); + account.cleanAccountLocks(collateralType, 0, 999999999999999); + account.collaterals[collateralType].increaseAvailableCollateral( CollateralConfiguration.load(collateralType).convertTokenToSystemAmount(tokenAmount) ); @@ -134,42 +136,8 @@ contract CollateralModule is ICollateralModule { address collateralType, uint256 offset, uint256 count - ) external override returns (uint256 cleared) { - CollateralLock.Data[] storage locks = Account - .load(accountId) - .collaterals[collateralType] - .locks; - - uint64 currentTime = block.timestamp.to64(); - - uint256 len = locks.length; - - if (offset >= len) { - return 0; - } - - if (count == 0 || offset + count >= len) { - count = len - offset; - } - - uint256 index = offset; - for (uint256 i = 0; i < count; i++) { - if (locks[index].lockExpirationTime <= currentTime) { - emit CollateralLockExpired( - accountId, - collateralType, - locks[index].amountD18, - locks[index].lockExpirationTime - ); - - locks[index] = locks[locks.length - 1]; - locks.pop(); - } else { - index++; - } - } - - return count + offset - index; + ) external override returns (uint256 cleared, uint256 remainingLockAmountD18) { + return Account.load(accountId).collaterals[collateralType].cleanExpiredLocks(offset, count); } /** diff --git a/protocol/synthetix/contracts/modules/core/IssueUSDModule.sol b/protocol/synthetix/contracts/modules/core/IssueUSDModule.sol index 0d8796f72e..c3066296bc 100644 --- a/protocol/synthetix/contracts/modules/core/IssueUSDModule.sol +++ b/protocol/synthetix/contracts/modules/core/IssueUSDModule.sol @@ -78,15 +78,15 @@ contract IssueUSDModule is IIssueUSDModule { ); } + CollateralConfiguration.Data storage config = CollateralConfiguration.load(collateralType); + // If the resulting debt of the account is greater than zero, ensure that the resulting c-ratio is sufficient (, uint256 collateralValue) = pool.currentAccountCollateral(collateralType, accountId); - if (newDebt > 0) { - CollateralConfiguration.load(collateralType).verifyIssuanceRatio( - newDebt.toUint(), - collateralValue, - pool.collateralConfigurations[collateralType].issuanceRatioD18 - ); - } + config.verifyIssuanceRatio( + newDebt, + collateralValue, + pool.collateralConfigurations[collateralType].issuanceRatioD18 + ); // Increase the debt of the position pool.assignDebtToAccount(collateralType, accountId, amount.toInt()); @@ -94,6 +94,11 @@ contract IssueUSDModule is IIssueUSDModule { // Decrease the credit available in the vault pool.recalculateVaultCollateral(collateralType); + // Confirm that the vault debt is not in liquidation + int256 rawVaultDebt = pool.currentVaultDebt(collateralType); + (, uint256 vaultCollateralValue) = pool.currentVaultCollateral(collateralType); + config.verifyLiquidationRatio(rawVaultDebt, vaultCollateralValue); + AssociatedSystem.Data storage usdToken = AssociatedSystem.load(_USD_TOKEN); // Mint stablecoins to the core system diff --git a/protocol/synthetix/contracts/modules/core/LiquidationModule.sol b/protocol/synthetix/contracts/modules/core/LiquidationModule.sol index 97c0ae48a6..3fc01829a7 100644 --- a/protocol/synthetix/contracts/modules/core/LiquidationModule.sol +++ b/protocol/synthetix/contracts/modules/core/LiquidationModule.sol @@ -28,6 +28,7 @@ contract LiquidationModule is ILiquidationModule { using AssociatedSystem for AssociatedSystem.Data; using CollateralConfiguration for CollateralConfiguration.Data; using Collateral for Collateral.Data; + using Account for Account.Data; using Pool for Pool.Data; using Vault for Vault.Data; using VaultEpoch for VaultEpoch.Data; @@ -52,17 +53,15 @@ contract LiquidationModule is ILiquidationModule { // Ensure the account receiving rewards exists Account.exists(liquidateAsAccountId); - Pool.Data storage pool = Pool.load(poolId); CollateralConfiguration.Data storage collateralConfig = CollateralConfiguration.load( collateralType ); - VaultEpoch.Data storage epoch = pool.vaults[collateralType].currentEpoch(); + VaultEpoch.Data storage epoch = Pool.load(poolId).vaults[collateralType].currentEpoch(); - int256 rawDebt = pool.updateAccountDebt(collateralType, accountId); - (uint256 collateralAmount, uint256 collateralValue) = pool.currentAccountCollateral( - collateralType, - accountId - ); + int256 rawDebt = Pool.load(poolId).updateAccountDebt(collateralType, accountId); + (uint256 collateralAmount, uint256 collateralValue) = Pool + .load(poolId) + .currentAccountCollateral(collateralType, accountId); liquidationData.collateralLiquidated = collateralAmount; // Verify whether the position is eligible for liquidation @@ -92,9 +91,20 @@ contract LiquidationModule is ILiquidationModule { revert MustBeVaultLiquidated(); } + // distribute any outstanding rewards distributor value to the user who is about to be liquidated, since technically they are eligible. + Pool.load(poolId).updateRewardsToVaults( + Vault.PositionSelector(accountId, poolId, collateralType) + ); + // This will clear the user's account the same way as if they had withdrawn normally epoch.updateAccountPosition(accountId, 0, 0); + // in case the liquidation caused the user to have less collateral than is actually locked in their account, + // this will ensure their locks are good. + // NOTE: limit is set to 50 here to prevent the user from DoSsing their account liquidation by creating locks on their own account + // if the limit is surpassed, their locks wont be scaled upon liquidation and that is their problem + Account.load(accountId).cleanAccountLocks(collateralType, 0, 50); + // Distribute the liquidated collateral among other positions in the vault, minus the reward amount epoch.collateralAmounts.scale( liquidationData.collateralLiquidated.toInt() - liquidationData.amountRewarded.toInt() @@ -107,7 +117,7 @@ contract LiquidationModule is ILiquidationModule { epoch.distributeDebtToAccounts(liquidationData.debtLiquidated.toInt()); // The collateral is reduced by `amountRewarded`, so we need to reduce the stablecoins capacity available to the markets - pool.recalculateVaultCollateral(collateralType); + Pool.load(poolId).recalculateVaultCollateral(collateralType); // Send amountRewarded to the specified account Account.load(liquidateAsAccountId).collaterals[collateralType].increaseAvailableCollateral( @@ -201,6 +211,9 @@ contract LiquidationModule is ILiquidationModule { // Reduce the collateral of the remaining positions in the vault epoch.collateralAmounts.scale(-liquidationData.collateralLiquidated.toInt()); + + // ensure markets get accurate accounting of available collateral + pool.recalculateVaultCollateral(collateralType); } // Send liquidationData.collateralLiquidated to the specified account diff --git a/protocol/synthetix/contracts/modules/core/VaultModule.sol b/protocol/synthetix/contracts/modules/core/VaultModule.sol index bef7482e0a..b3cbd43532 100644 --- a/protocol/synthetix/contracts/modules/core/VaultModule.sol +++ b/protocol/synthetix/contracts/modules/core/VaultModule.sol @@ -111,6 +111,8 @@ contract VaultModule is IVaultModule { leverage ); + _verifyPoolCratio(poolId, collateralType); + _updateAccountCollateralPools( accountId, poolId, @@ -132,7 +134,7 @@ contract VaultModule is IVaultModule { // 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(), + debt, newCollateralAmountD18.mulDecimal(collateralPrice), minIssuanceRatioD18 ); @@ -302,6 +304,19 @@ contract VaultModule is IVaultModule { } } + function _verifyPoolCratio(uint128 poolId, address collateralType) internal { + Pool.Data storage pool = Pool.load(poolId); + int256 rawVaultDebt = pool.currentVaultDebt(collateralType); + (, uint256 collateralValue) = pool.currentVaultCollateral(collateralType); + if ( + rawVaultDebt > 0 && + collateralValue.divDecimal(rawVaultDebt.toUint()) < + CollateralConfiguration.load(collateralType).liquidationRatioD18 + ) { + revert InsufficientVaultCollateralRatio(poolId, collateralType); + } + } + /** * @dev Registers the pool in the given account's collaterals array. */ diff --git a/protocol/synthetix/contracts/storage/Account.sol b/protocol/synthetix/contracts/storage/Account.sol index 9eb3e811ff..36d896cd2e 100644 --- a/protocol/synthetix/contracts/storage/Account.sol +++ b/protocol/synthetix/contracts/storage/Account.sol @@ -42,6 +42,18 @@ library Account { uint256 requiredTime ); + /** + * @notice Emitted when all the locks in an account were scaled down proportionally due to insufficient balance + * @param totalDepositedD18 The observed deposited collateral + * @param totalLockedD18 The observed locked collateral total + */ + event AccountLocksScaled( + uint128 accountId, + address collateralType, + uint256 totalDepositedD18, + uint256 totalLockedD18 + ); + struct Data { /** * @dev Numeric identifier for the account. Must be unique. @@ -95,6 +107,49 @@ library Account { return a; } + /** + * @dev Performs any needed housekeeping on account locks, including: + * * removing any expired locks + * * scaling down all locks if their locked value is greater than total account value (ex. from liquidation) + * + * also returns total locked value as a convenience + */ + function cleanAccountLocks( + Data storage self, + address collateralType, + uint256 offset, + uint256 count + ) internal returns (uint256) { + CollateralLock.Data[] storage locks = self.collaterals[collateralType].locks; + + uint256 len = locks.length; + + (, uint256 totalLocked) = self.collaterals[collateralType].cleanExpiredLocks(offset, count); + + if (totalLocked == 0) { + return 0; + } + + uint256 totalDeposited = getAssignedCollateral(self, collateralType) + + self.collaterals[collateralType].amountAvailableForDelegationD18; + + if (offset == 0 && (count == 0 || count >= len) && totalLocked > totalDeposited) { + // something happened (ex. liquidation) and the amount of collateral in the account is greater than the total locked + // so scale the remaining locks down + // NOTE: ideally we would scale based on the time that the user's deposited balance got reduced, but if this function + // is called late we may not actually be able to scale it perfectly for the users situation. oh well. + uint256 updatedLocksLength = locks.length; + for (uint256 i = 0; i < updatedLocksLength; i++) { + locks[i].amountD18 = ((locks[i].amountD18 * totalDeposited) / totalLocked).to128(); + } + + emit AccountLocksScaled(self.id, collateralType, totalDeposited, totalLocked); + totalLocked = totalDeposited; + } + + return totalLocked; + } + /** * @dev Given a collateral type, returns information about the total collateral assigned, deposited, and locked by the account */ @@ -207,4 +262,21 @@ library Account { revert ICollateralModule.InsufficientAccountCollateral(amountD18); } } + + // from here are convenience functions for testing purposes + function increaseAvailableCollateral( + Data storage self, + address collateralType, + uint256 amountD18 + ) internal { + self.collaterals[collateralType].increaseAvailableCollateral(amountD18); + } + + function decreaseAvailableCollateral( + Data storage self, + address collateralType, + uint256 amountD18 + ) internal { + self.collaterals[collateralType].decreaseAvailableCollateral(amountD18); + } } diff --git a/protocol/synthetix/contracts/storage/Collateral.sol b/protocol/synthetix/contracts/storage/Collateral.sol index 4ab64f9991..07939bdab9 100644 --- a/protocol/synthetix/contracts/storage/Collateral.sol +++ b/protocol/synthetix/contracts/storage/Collateral.sol @@ -22,6 +22,13 @@ library Collateral { uint256 amountD18 ); + /** + * @notice Emitted when a lock is cleared from an account due to expiration + * @param tokenAmount The amount of collateral that was unlocked, demoninated in system units (1e18) + * @param expireTimestamp unix timestamp at which the unlock is due to expire + */ + event CollateralLockExpired(uint256 tokenAmount, uint64 expireTimestamp); + struct Data { /** * @dev The amount that can be withdrawn or delegated in this collateral. @@ -58,6 +65,43 @@ library Collateral { self.amountAvailableForDelegationD18 -= amountD18; } + function cleanExpiredLocks( + Data storage self, + uint256 offset, + uint256 count + ) internal returns (uint256 cleared, uint256 remainingLockAmountD18) { + uint64 currentTime = block.timestamp.to64(); + + uint256 len = self.locks.length; + + if (offset >= len) { + return (0, 0); + } + + if (count == 0 || offset + count >= len) { + count = len - offset; + } + + uint256 index = offset; + uint256 totalLocked = 0; + for (uint256 i = 0; i < count; i++) { + if (self.locks[index].lockExpirationTime <= currentTime) { + emit CollateralLockExpired( + self.locks[index].amountD18, + self.locks[index].lockExpirationTime + ); + + self.locks[index] = self.locks[self.locks.length - 1]; + self.locks.pop(); + } else { + totalLocked += self.locks[index].amountD18; + index++; + } + } + + return (offset + count - index, totalLocked); + } + /** * @dev Returns the total amount in this collateral entry that is locked. * diff --git a/protocol/synthetix/contracts/storage/CollateralConfiguration.sol b/protocol/synthetix/contracts/storage/CollateralConfiguration.sol index 5bb1ea464b..ba440a9ade 100644 --- a/protocol/synthetix/contracts/storage/CollateralConfiguration.sol +++ b/protocol/synthetix/contracts/storage/CollateralConfiguration.sol @@ -230,7 +230,7 @@ library CollateralConfiguration { */ function verifyIssuanceRatio( Data storage self, - uint256 debtD18, + int256 debtD18, uint256 collateralValueD18, uint256 minIssuanceRatioD18 ) internal view { @@ -239,18 +239,46 @@ library CollateralConfiguration { : minIssuanceRatioD18; if ( - debtD18 != 0 && - (collateralValueD18 == 0 || collateralValueD18.divDecimal(debtD18) < issuanceRatioD18) + debtD18 > 0 && + (collateralValueD18 == 0 || + collateralValueD18.divDecimal(debtD18.toUint()) < issuanceRatioD18) ) { revert InsufficientCollateralRatio( collateralValueD18, - debtD18, - collateralValueD18.divDecimal(debtD18), + debtD18.toUint(), + collateralValueD18.divDecimal(debtD18.toUint()), issuanceRatioD18 ); } } + /** + * @dev Reverts if the specified collateral and debt values produce a collateralization ratio which is below the liquidation ratio. + * @param self The CollateralConfiguration object whose collateral and settings are being queried. + * @param debtD18 The debt component of the ratio. + * @param collateralValueD18 The collateral component of the ratio. + */ + function verifyLiquidationRatio( + Data storage self, + int256 debtD18, + uint256 collateralValueD18 + ) internal view { + uint256 liquidationRatioD18 = self.liquidationRatioD18; + + if ( + debtD18 > 0 && + (collateralValueD18 == 0 || + collateralValueD18.divDecimal(debtD18.toUint()) < liquidationRatioD18) + ) { + revert InsufficientCollateralRatio( + collateralValueD18, + debtD18.toUint(), + collateralValueD18.divDecimal(debtD18.toUint()), + liquidationRatioD18 + ); + } + } + /** * @dev Converts token amounts with non-system decimal precisions, to 18 decimals of precision. * E.g: $TOKEN_A uses 6 decimals of precision, so this would upscale it by 12 decimals. diff --git a/protocol/synthetix/test/integration/modules/core/CollateralModule/CollateralModule.locks.test.ts b/protocol/synthetix/test/integration/modules/core/CollateralModule/CollateralModule.locks.test.ts index 87bea5a6d3..56dc95e5a5 100644 --- a/protocol/synthetix/test/integration/modules/core/CollateralModule/CollateralModule.locks.test.ts +++ b/protocol/synthetix/test/integration/modules/core/CollateralModule/CollateralModule.locks.test.ts @@ -151,9 +151,7 @@ describe('CollateralModule', function () { it('emits event', async () => { await assertEvent( txn, - `CollateralLockExpired(${accountId}, "${collateralAddress()}", ${depositAmount - .div(10) - .toString()}, ${ts + 200})`, + `CollateralLockExpired(${depositAmount.div(10).toString()}, ${ts + 200})`, systems().Core ); }); diff --git a/protocol/synthetix/test/integration/modules/core/IssueUSDModule.test.ts b/protocol/synthetix/test/integration/modules/core/IssueUSDModule.test.ts index 09368a0897..84304036b1 100644 --- a/protocol/synthetix/test/integration/modules/core/IssueUSDModule.test.ts +++ b/protocol/synthetix/test/integration/modules/core/IssueUSDModule.test.ts @@ -118,6 +118,50 @@ describe('IssueUSDModule', function () { ); }); + describe('when the vault has more debt', () => { + const user2AccountId = 47223; + before(async () => { + 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.Pool_assignDebt(poolId, depositAmount); + }); + + after(restore); + + it('verifies vault also has sufficient c-ratio', async () => { + // we have tons of collateral, but the vault is in so much debt that it can't take it anymore + await assertRevert( + ( + await systems() + .Core.connect(user2) + .mintUsd(user2AccountId, poolId, collateralAddress(), 1, { gasLimit: 10000000 }) + ).wait(), + `InsufficientCollateralRatio(`, + systems().Core + ); + }); + }); + it('verifies pool exists', async () => { await assertRevert( systems().Core.connect(user1).mintUsd( diff --git a/protocol/synthetix/test/integration/storage/AccountLocks.test.ts b/protocol/synthetix/test/integration/storage/AccountLocks.test.ts new file mode 100644 index 0000000000..f5e6806d1c --- /dev/null +++ b/protocol/synthetix/test/integration/storage/AccountLocks.test.ts @@ -0,0 +1,135 @@ +import assert from 'assert/strict'; +import assertBn from '@synthetixio/core-utils/utils/assertions/assert-bignumber'; +import { ethers } from 'ethers'; +import { snapshotCheckpoint } from '@synthetixio/core-utils/utils/mocha/snapshot'; +import { bootstrapWithStakedPool } from '../bootstrap'; +import { fastForwardTo, getTime } from '@synthetixio/core-utils/utils/hardhat/rpc'; + +describe('AccountLocks', () => { + const { systems, accountId, collateralAddress, provider, signers } = bootstrapWithStakedPool(); + + let lockTime = 0; + let collatInfo: { totalDeposited: ethers.BigNumber }; + + let user1: ethers.Signer; + + before('identify signers', async () => { + [, user1] = signers(); + }); + + before('create dummy locks', async () => { + lockTime = await getTime(provider()); + collatInfo = await systems().Core.getAccountCollateral(accountId, collateralAddress()); + await systems() + .Core.connect(user1) + .createLock( + accountId, + collateralAddress(), + collatInfo.totalDeposited.div(4), + lockTime + 1000 + ); + + await systems() + .Core.connect(user1) + .createLock( + accountId, + collateralAddress(), + collatInfo.totalDeposited.div(8), + lockTime + 2000 + ); + + await systems() + .Core.connect(user1) + .createLock( + accountId, + collateralAddress(), + collatInfo.totalDeposited.div(2), + lockTime + 3000 + ); + }); + + const restore = snapshotCheckpoint(provider); + + describe('cleanAccountLocks()', async function () { + afterEach(restore); + it('cleans nothing if there is nothing to clean', async () => { + // nothing should have expired yet + await systems().Core.Account_cleanAccountLocks(accountId, collateralAddress(), 0, 999); + + assert((await systems().Core.getLocks(accountId, collateralAddress(), 0, 999)).length === 3); + }); + + it('cleans in specified range', async () => { + await fastForwardTo(lockTime + 2001, provider()); + + await systems().Core.Account_cleanAccountLocks(accountId, collateralAddress(), 0, 999); + + assert((await systems().Core.getLocks(accountId, collateralAddress(), 0, 999)).length === 1); + }); + + describe('scaling', async () => { + beforeEach('reduce user balance', async () => { + const collatInfo2 = await systems().Core.getAccountCollateral( + accountId, + collateralAddress() + ); + // should result in 50% scaling + await systems().Core.Account_decreaseAvailableCollateral( + accountId, + collateralAddress(), + collatInfo2.totalDeposited.sub(collatInfo2.totalLocked.div(2)) + ); + }); + + it('does not scale when it cannot', async () => { + await systems().Core.Account_cleanAccountLocks(accountId, collateralAddress(), 1, 999); + let locks = await systems().Core.getLocks(accountId, collateralAddress(), 0, 999); + + assertBn.equal(locks[0].amountD18, collatInfo.totalDeposited.div(4)); + assertBn.equal(locks[1].amountD18, collatInfo.totalDeposited.div(8)); + assertBn.equal(locks[2].amountD18, collatInfo.totalDeposited.div(2)); + + // edge case: have one of the locks expire, shortening the array, but it should still not scale + await fastForwardTo(lockTime + 1001, provider()); + await systems().Core.Account_cleanAccountLocks(accountId, collateralAddress(), 0, 2); + locks = await systems().Core.getLocks(accountId, collateralAddress(), 0, 999); + + // only 2 of the locks are left and they are not scaled. also the order gets changed because of array replacement + assertBn.equal(locks[0].amountD18, collatInfo.totalDeposited.div(2)); + assertBn.equal(locks[1].amountD18, collatInfo.totalDeposited.div(8)); + }); + + it('scales properly', async () => { + await systems().Core.Account_cleanAccountLocks(accountId, collateralAddress(), 0, 3); + const locks = await systems().Core.getLocks(accountId, collateralAddress(), 0, 999); + + assertBn.equal(locks[0].amountD18, collatInfo.totalDeposited.div(4).div(2)); + assertBn.equal(locks[1].amountD18, collatInfo.totalDeposited.div(8).div(2)); + assertBn.equal(locks[2].amountD18, collatInfo.totalDeposited.div(2).div(2)); + }); + + it('scales if len set to 0', async () => { + // setting length to 0 means to iterate forever + await systems().Core.Account_cleanAccountLocks(accountId, collateralAddress(), 0, 0); + const locks = await systems().Core.getLocks(accountId, collateralAddress(), 0, 999); + + assertBn.equal(locks[0].amountD18, collatInfo.totalDeposited.div(4).div(2)); + assertBn.equal(locks[1].amountD18, collatInfo.totalDeposited.div(8).div(2)); + assertBn.equal(locks[2].amountD18, collatInfo.totalDeposited.div(2).div(2)); + }); + + it('works if scale and expire happen at the same time', async () => { + await fastForwardTo(lockTime + 1001, provider()); + + await systems().Core.Account_cleanAccountLocks(accountId, collateralAddress(), 0, 3); + const locks = await systems().Core.getLocks(accountId, collateralAddress(), 0, 999); + + assert(locks[0].length === 2); + assertBn.equal( + locks[0].amountD18.add(locks[1].amountD18), + collatInfo.totalDeposited.div(8).mul(7).div(2) + ); + }); + }); + }); +});