diff --git a/contracts/0.8.25/Accounting.sol b/contracts/0.8.25/Accounting.sol index a875110afa..86c25b854c 100644 --- a/contracts/0.8.25/Accounting.sol +++ b/contracts/0.8.25/Accounting.sol @@ -69,6 +69,8 @@ contract Accounting is VaultHub { uint256 postTotalPooledEther; /// @notice amount of ether to be locked in the vaults uint256[] vaultsLockedEther; + /// @notice amount of ether to be locked in the vaults + uint256[] vaultsThresholdEther; /// @notice amount of shares to be minted as vault fees to the treasury uint256[] vaultsTreasuryFeeShares; /// @notice total amount of shares to be minted as vault fees to the treasury @@ -225,7 +227,12 @@ contract Accounting is VaultHub { // Calculate the amount of ether locked in the vaults to back external balance of stETH // and the amount of shares to mint as fees to the treasury for each vaults - (update.vaultsLockedEther, update.vaultsTreasuryFeeShares, update.totalVaultsTreasuryFeeShares) = + ( + update.vaultsLockedEther, + update.vaultsThresholdEther, + update.vaultsTreasuryFeeShares, + update.totalVaultsTreasuryFeeShares + ) = _calculateVaultsRebase( update.postTotalShares, update.postTotalPooledEther, @@ -339,6 +346,7 @@ contract Accounting is VaultHub { _report.vaultValues, _report.inOutDeltas, _update.vaultsLockedEther, + _update.vaultsThresholdEther, _update.vaultsTreasuryFeeShares ); diff --git a/contracts/0.8.25/vaults/Dashboard.sol b/contracts/0.8.25/vaults/Dashboard.sol index 369f933c25..2e11110ed5 100644 --- a/contracts/0.8.25/vaults/Dashboard.sol +++ b/contracts/0.8.25/vaults/Dashboard.sol @@ -168,11 +168,11 @@ contract Dashboard is Permissions { } /** - * @notice Returns the force withdrawal unlock time of the vault. - * @return The force withdrawal unlock time as a uint40. + * @notice Returns the time when the vault became unbalanced. + * @return The time when the vault became unbalanced as a uint40. */ - function forceWithdrawalUnlockTime() external view returns (uint40) { - return vaultSocket().forceWithdrawalUnlockTime; + function unbalancedSince() external view returns (uint40) { + return vaultSocket().unbalancedSince; } /** diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index d7e9ea5020..298b1c9458 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -307,7 +307,6 @@ contract StakingVault is IStakingVault, OwnableUpgradeable { ERC7201Storage storage $ = _getStorage(); if (owner() == msg.sender || (_valuation < $.locked && msg.sender == address(VAULT_HUB))) { - $.inOutDelta -= int128(int256(_ether)); emit Withdrawn(msg.sender, address(VAULT_HUB), _ether); diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index a27da31768..8159a0dd4d 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -49,9 +49,9 @@ abstract contract VaultHub is PausableUntilWithRoles { uint16 treasuryFeeBP; /// @notice if true, vault is disconnected and fee is not accrued bool isDisconnected; - /// @notice timestamp when the vault can force withdraw in case it is unbalanced + /// @notice timestamp when the vault became unbalanced /// @dev 0 if the vault is currently balanced - uint40 forceWithdrawalUnlockTime; + uint40 unbalancedSince; // ### we have 64 bits left in this slot } @@ -73,7 +73,7 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256 internal constant CONNECT_DEPOSIT = 1 ether; /// @notice Time-lock for force validator withdrawal - uint256 public constant FORCE_WITHDRAWAL_TIMELOCK = 3 days; + uint40 public constant FORCE_WITHDRAWAL_TIMELOCK = 3 days; /// @notice Lido stETH contract IStETH public immutable STETH; @@ -166,7 +166,7 @@ abstract contract VaultHub is PausableUntilWithRoles { uint16(_reserveRatioThresholdBP), uint16(_treasuryFeeBP), false, // isDisconnected - 0 // forceWithdrawalUnlockTime + 0 // unbalancedSince ); $.vaultIndex[_vault] = $.sockets.length; $.sockets.push(vr); @@ -233,10 +233,11 @@ abstract contract VaultHub is PausableUntilWithRoles { if (vaultSharesAfterMint > shareLimit) revert ShareLimitExceeded(_vault, shareLimit); uint256 reserveRatioBP = socket.reserveRatioBP; - uint256 maxMintableShares = _maxMintableShares(_vault, reserveRatioBP, shareLimit); + uint256 valuation = IStakingVault(_vault).valuation(); + uint256 maxMintableShares = _maxMintableShares(valuation, reserveRatioBP, shareLimit); if (vaultSharesAfterMint > maxMintableShares) { - revert InsufficientValuationToMint(_vault, IStakingVault(_vault).valuation()); + revert InsufficientValuationToMint(_vault, valuation); } socket.sharesMinted = uint96(vaultSharesAfterMint); @@ -272,7 +273,7 @@ abstract contract VaultHub is PausableUntilWithRoles { STETH.burnExternalShares(_amountOfShares); - _updateUnbalancedState(_vault, socket); + _vaultAssessment(_vault, socket); emit BurnedSharesOnVault(_vault, _amountOfShares); } @@ -293,7 +294,8 @@ abstract contract VaultHub is PausableUntilWithRoles { VaultSocket storage socket = _connectedSocket(_vault); - uint256 threshold = _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); + uint256 valuation = IStakingVault(_vault).valuation(); + uint256 threshold = _maxMintableShares(valuation, socket.reserveRatioThresholdBP, socket.shareLimit); uint256 sharesMinted = socket.sharesMinted; if (sharesMinted <= threshold) { // NOTE!: on connect vault is always balanced @@ -316,13 +318,12 @@ abstract contract VaultHub is PausableUntilWithRoles { // reserveRatio = BPS_BASE - maxMintableRatio // X = (mintedStETH * BPS_BASE - vault.valuation() * maxMintableRatio) / reserveRatio - uint256 amountToRebalance = (mintedStETH * TOTAL_BASIS_POINTS - - IStakingVault(_vault).valuation() * maxMintableRatio) / reserveRatioBP; + uint256 amountToRebalance = (mintedStETH * TOTAL_BASIS_POINTS - valuation * maxMintableRatio) / reserveRatioBP; // TODO: add some gas compensation here IStakingVault(_vault).rebalance(amountToRebalance); - // NB: check _updateUnbalancedState is calculated in rebalance() triggered from the `StakingVault`. + // NB: check _updateUnbalancedSince is calculated in rebalance() triggered from the `StakingVault`. } /// @notice rebalances the vault by writing off the amount of ether equal @@ -341,8 +342,7 @@ abstract contract VaultHub is PausableUntilWithRoles { STETH.rebalanceExternalEtherToInternal{value: msg.value}(); - // Check if vault is still unbalanced after rebalance - _updateUnbalancedState(msg.sender, socket); + _vaultAssessment(msg.sender, socket); emit VaultRebalanced(msg.sender, sharesToBurn); } @@ -351,29 +351,31 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @param _vault vault address /// @return bool whether the vault can force withdraw function canForceValidatorWithdrawal(address _vault) public view returns (bool) { - uint40 forceWithdrawalUnlockTime = _connectedSocket(_vault).forceWithdrawalUnlockTime; + uint40 unbalancedSince = _connectedSocket(_vault).unbalancedSince; - if (forceWithdrawalUnlockTime == 0) return false; + if (unbalancedSince == 0) return false; - return block.timestamp >= forceWithdrawalUnlockTime; + return block.timestamp >= unbalancedSince + FORCE_WITHDRAWAL_TIMELOCK; } /// @notice forces validator withdrawal from the beacon chain in case the vault is unbalanced /// @param _vault vault address /// @param _pubkeys pubkeys of the validators to withdraw function forceValidatorWithdrawal(address _vault, bytes calldata _pubkeys) external payable { + if (msg.value == 0) revert ZeroArgument("msg.value"); if (_vault == address(0)) revert ZeroArgument("_vault"); if (_pubkeys.length == 0) revert ZeroArgument("_pubkeys"); VaultSocket storage socket = _connectedSocket(_vault); - uint256 threshold = _maxMintableShares(_vault, socket.reserveRatioThresholdBP, socket.shareLimit); + uint256 valuation = IStakingVault(_vault).valuation(); + uint256 threshold = _maxMintableShares(valuation, socket.reserveRatioThresholdBP, socket.shareLimit); if (socket.sharesMinted <= threshold) { revert AlreadyBalanced(_vault, socket.sharesMinted, threshold); } if (!canForceValidatorWithdrawal(_vault)) { - revert ForceWithdrawalTimelockActive(_vault, socket.forceWithdrawalUnlockTime); + revert ForceWithdrawalTimelockActive(_vault, socket.unbalancedSince + FORCE_WITHDRAWAL_TIMELOCK); } IStakingVault(_vault).forceValidatorWithdrawal{value: msg.value}(_pubkeys); @@ -403,7 +405,12 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256 _preTotalShares, uint256 _preTotalPooledEther, uint256 _sharesToMintAsFees - ) internal view returns (uint256[] memory lockedEther, uint256[] memory treasuryFeeShares, uint256 totalTreasuryFeeShares) { + ) internal view returns ( + uint256[] memory lockedEther, + uint256[] memory thresholdEther, + uint256[] memory treasuryFeeShares, + uint256 totalTreasuryFeeShares + ) { /// HERE WILL BE ACCOUNTING DRAGON // \||/ @@ -424,6 +431,7 @@ abstract contract VaultHub is PausableUntilWithRoles { treasuryFeeShares = new uint256[](length); lockedEther = new uint256[](length); + thresholdEther = new uint256[](length); for (uint256 i = 0; i < length; ++i) { VaultSocket memory socket = $.sockets[i + 1]; @@ -444,6 +452,9 @@ abstract contract VaultHub is PausableUntilWithRoles { (mintedStETH * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - socket.reserveRatioBP), CONNECT_DEPOSIT ); + + // Minimum amount of ether that should be in the vault to avoid unbalanced state + thresholdEther[i] = (mintedStETH * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - socket.reserveRatioThresholdBP); } } } @@ -472,7 +483,7 @@ abstract contract VaultHub is PausableUntilWithRoles { // TODO: optimize potential rewards calculation uint256 potentialRewards = ((chargeableValue * (_postTotalPooledEther * _preTotalShares)) / - (_postTotalSharesNoFees * _preTotalPooledEther) - chargeableValue); + (_postTotalSharesNoFees * _preTotalPooledEther) - chargeableValue); uint256 treasuryFee = (potentialRewards * _socket.treasuryFeeBP) / TOTAL_BASIS_POINTS; treasuryFeeShares = (treasuryFee * _preTotalShares) / _preTotalPooledEther; @@ -482,6 +493,7 @@ abstract contract VaultHub is PausableUntilWithRoles { uint256[] memory _valuations, int256[] memory _inOutDeltas, uint256[] memory _locked, + uint256[] memory _thresholds, uint256[] memory _treasureFeeShares ) internal { VaultHubStorage storage $ = _getVaultHubStorage(); @@ -496,7 +508,8 @@ abstract contract VaultHub is PausableUntilWithRoles { socket.sharesMinted += uint96(treasuryFeeShares); } - _updateUnbalancedState(socket.vault, socket); + _epicrisis(_valuations[i], _thresholds[i], socket); + IStakingVault(socket.vault).report(_valuations[i], _inOutDeltas[i], _locked[i]); } @@ -517,21 +530,25 @@ abstract contract VaultHub is PausableUntilWithRoles { } } - function _updateUnbalancedState(address _vault, VaultSocket storage _socket) internal { - uint256 threshold = _maxMintableShares(_vault, _socket.reserveRatioThresholdBP, _socket.shareLimit); - bool isUnbalanced = _socket.sharesMinted > threshold; - uint40 currentUnlockTime = _socket.forceWithdrawalUnlockTime; + /// @notice Evaluates if vault's valuation meets minimum threshold and marks it as unbalanced if below threshold + function _vaultAssessment(address _vault, VaultSocket storage _socket) internal { + uint256 valuation = IStakingVault(_vault).valuation(); + uint256 threshold = (_socket.sharesMinted * TOTAL_BASIS_POINTS) / (TOTAL_BASIS_POINTS - _socket.reserveRatioThresholdBP); + + _epicrisis(valuation, threshold, _socket); + } - if (isUnbalanced) { - if (currentUnlockTime == 0) { - uint40 newUnlockTime = uint40(block.timestamp + FORCE_WITHDRAWAL_TIMELOCK); - _socket.forceWithdrawalUnlockTime = newUnlockTime; - emit VaultBecameUnbalanced(_vault, newUnlockTime); + /// @notice Updates vault's unbalanced state based on if valuation is above/below threshold + function _epicrisis(uint256 _valuation, uint256 _threshold, VaultSocket storage _socket) internal { + if (_valuation < _threshold) { + if (_socket.unbalancedSince == 0) { + _socket.unbalancedSince = uint40(block.timestamp); + emit VaultBecameUnbalanced(address(_socket.vault), _socket.unbalancedSince + FORCE_WITHDRAWAL_TIMELOCK); } } else { - if (currentUnlockTime != 0) { - _socket.forceWithdrawalUnlockTime = 0; - emit VaultBecameBalanced(_vault); + if (_socket.unbalancedSince != 0) { + _socket.unbalancedSince = 0; + emit VaultBecameBalanced(address(_socket.vault)); } } } @@ -549,9 +566,8 @@ abstract contract VaultHub is PausableUntilWithRoles { /// @dev returns total number of stETH shares that is possible to mint on the provided vault with provided reserveRatio /// it does not count shares that is already minted, but does count shareLimit on the vault - function _maxMintableShares(address _vault, uint256 _reserveRatio, uint256 _shareLimit) internal view returns (uint256) { - uint256 maxStETHMinted = (IStakingVault(_vault).valuation() * (TOTAL_BASIS_POINTS - _reserveRatio)) / - TOTAL_BASIS_POINTS; + function _maxMintableShares(uint256 _valuation, uint256 _reserveRatio, uint256 _shareLimit) internal view returns (uint256) { + uint256 maxStETHMinted = (_valuation * (TOTAL_BASIS_POINTS - _reserveRatio)) / TOTAL_BASIS_POINTS; return Math256.min(STETH.getSharesByPooledEth(maxStETHMinted), _shareLimit); } diff --git a/test/0.8.25/vaults/dashboard/dashboard.test.ts b/test/0.8.25/vaults/dashboard/dashboard.test.ts index fa08b4f2a8..31b4a7da11 100644 --- a/test/0.8.25/vaults/dashboard/dashboard.test.ts +++ b/test/0.8.25/vaults/dashboard/dashboard.test.ts @@ -179,7 +179,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -190,7 +190,7 @@ describe("Dashboard.sol", () => { expect(await dashboard.reserveRatioBP()).to.equal(sockets.reserveRatioBP); expect(await dashboard.thresholdReserveRatioBP()).to.equal(sockets.reserveRatioThresholdBP); expect(await dashboard.treasuryFee()).to.equal(sockets.treasuryFeeBP); - expect(await dashboard.forceWithdrawalUnlockTime()).to.equal(sockets.forceWithdrawalUnlockTime); + expect(await dashboard.unbalancedSince()).to.equal(sockets.unbalancedSince); }); }); @@ -217,7 +217,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -240,7 +240,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -261,7 +261,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -282,7 +282,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 0n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -311,7 +311,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -338,7 +338,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -362,7 +362,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -384,7 +384,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); @@ -409,7 +409,7 @@ describe("Dashboard.sol", () => { reserveRatioThresholdBP: 800n, treasuryFeeBP: 500n, isDisconnected: false, - forceWithdrawalUnlockTime: 0n, + unbalancedSince: 0n, }; await hub.mock__setVaultSocket(vault, sockets); diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts index ca7ab0ba1c..0a926253e9 100644 --- a/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts +++ b/test/0.8.25/vaults/vaulthub/vaulthub.withdrawals.test.ts @@ -115,6 +115,22 @@ describe("VaultHub.sol:withdrawals", () => { }; context("canForceValidatorWithdrawal", () => { + it("reverts if the vault is not connected to the hub", async () => { + await expect(vaultHub.canForceValidatorWithdrawal(stranger)).to.be.revertedWithCustomError( + vaultHub, + "NotConnectedToHub", + ); + }); + + it("reverts if called on a disconnected vault", async () => { + await vaultHub.connect(user).disconnect(vaultAddress); + + await expect(vaultHub.canForceValidatorWithdrawal(stranger)).to.be.revertedWithCustomError( + vaultHub, + "NotConnectedToHub", + ); + }); + it("returns false if the vault is balanced", async () => { expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.false; }); @@ -127,34 +143,69 @@ describe("VaultHub.sol:withdrawals", () => { it("returns true if the vault is unbalanced and the time is reached", async () => { const unbalancedUntil = await reportUnbalancedVault(); + const future = unbalancedUntil + 1000n; - await advanceChainTime(unbalancedUntil + 1n); + await advanceChainTime(future); + + expect(await getCurrentBlockTimestamp()).to.be.gt(future); + expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.true; + }); + + it("returns correct values for border cases", async () => { + const unbalancedUntil = await reportUnbalancedVault(); + + // 1 second before the unlock time + await advanceChainTime(unbalancedUntil - (await getCurrentBlockTimestamp()) - 1n); + expect(await getCurrentBlockTimestamp()).to.be.lt(unbalancedUntil); + expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.false; + + // exactly the unlock time + await advanceChainTime(1n); + expect(await getCurrentBlockTimestamp()).to.be.eq(unbalancedUntil); + expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.true; + + // 1 second after the unlock time + await advanceChainTime(1n); + expect(await getCurrentBlockTimestamp()).to.be.gt(unbalancedUntil); expect(await vaultHub.canForceValidatorWithdrawal(vaultAddress)).to.be.true; }); }); context("forceValidatorWithdrawal", () => { + it("reverts if msg.value is 0", async () => { + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 0n })) + .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") + .withArgs("msg.value"); + }); + it("reverts if the vault is zero address", async () => { - await expect(vaultHub.forceValidatorWithdrawal(ZeroAddress, SAMPLE_PUBKEY)) + await expect(vaultHub.forceValidatorWithdrawal(ZeroAddress, SAMPLE_PUBKEY, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") .withArgs("_vault"); }); it("reverts if zero pubkeys", async () => { - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, "0x")).to.be.revertedWithCustomError( - vaultHub, - "ZeroArgument", - ); + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, "0x", { value: 1n })) + .to.be.revertedWithCustomError(vaultHub, "ZeroArgument") + .withArgs("_pubkeys"); }); it("reverts if vault is not connected to the hub", async () => { - await expect(vaultHub.forceValidatorWithdrawal(stranger, SAMPLE_PUBKEY)) + await expect(vaultHub.forceValidatorWithdrawal(stranger, SAMPLE_PUBKEY, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") .withArgs(stranger.address); }); + it("reverts if called for a disconnected vault", async () => { + await vaultHub.connect(user).disconnect(vaultAddress); + + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 1n })) + .to.be.revertedWithCustomError(vaultHub, "NotConnectedToHub") + .withArgs(vaultAddress); + }); + it("reverts if called for a balanced vault", async () => { - await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY)) + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, SAMPLE_PUBKEY, { value: 1n })) .to.be.revertedWithCustomError(vaultHub, "AlreadyBalanced") .withArgs(vaultAddress, 0n, 0n); }); @@ -189,48 +240,101 @@ describe("VaultHub.sol:withdrawals", () => { .to.emit(vaultHub, "VaultForceWithdrawalInitiated") .withArgs(vaultAddress, SAMPLE_PUBKEY); }); + + it("initiates force validator withdrawal with multiple pubkeys", async () => { + const numPubkeys = 3; + const pubkeys = "0x" + "ab".repeat(numPubkeys * 48); + await advanceChainTime(unbalancedUntil - 1n); + + await expect(vaultHub.forceValidatorWithdrawal(vaultAddress, pubkeys, { value: FEE * BigInt(numPubkeys) })) + .to.emit(vaultHub, "VaultForceWithdrawalInitiated") + .withArgs(vaultAddress, pubkeys); + }); }); }); - context("_updateUnbalancedState", () => { + context("_vaultAssessment & _epicrisis", () => { beforeEach(async () => await makeVaultUnbalanced()); it("sets the unlock time and emits the event if the vault is unbalanced (via rebalance)", async () => { - const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); - // Hacky way to get the unlock time right + const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); const events = await findEvents((await tx.wait()) as ContractTransactionReceipt, "VaultBecameUnbalanced"); const unbalancedUntil = events[0].args.unlockTime; expect(unbalancedUntil).to.be.gte((await getCurrentBlockTimestamp()) + FORCE_WITHDRAWAL_TIMELOCK); await expect(tx).to.emit(vaultHub, "VaultBecameUnbalanced").withArgs(vaultAddress, unbalancedUntil); + + expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq( + unbalancedUntil - FORCE_WITHDRAWAL_TIMELOCK, + ); }); it("does not change the unlock time if the vault is already unbalanced and the unlock time is already set", async () => { - await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); // report the vault as unbalanced + // report the vault as unbalanced + const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); + const events = await findEvents((await tx.wait()) as ContractTransactionReceipt, "VaultBecameUnbalanced"); + const unbalancedUntil = events[0].args.unlockTime; - await expect(vaultHub.connect(vaultSigner).rebalance({ value: 1n })).to.not.emit( + await expect(await vaultHub.connect(vaultSigner).rebalance({ value: 1n })).to.not.emit( vaultHub, "VaultBecameUnbalanced", ); + + expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq( + unbalancedUntil - FORCE_WITHDRAWAL_TIMELOCK, + ); }); it("resets the unlock time if the vault becomes balanced", async () => { - await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); // report the vault as unbalanced + // report the vault as unbalanced + await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); + // report the vault as balanced await expect(vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") })) .to.emit(vaultHub, "VaultBecameBalanced") .withArgs(vaultAddress); + + expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq(0n); }); it("does not change the unlock time if the vault is already balanced", async () => { - await vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") }); // report the vault as balanced + // report the vault as balanced + await vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") }); + // report the vault as balanced again await expect(vaultHub.connect(vaultSigner).rebalance({ value: ether("0.1") })).to.not.emit( vaultHub, "VaultBecameBalanced", ); + + expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq(0n); + }); + + it("maintains the same unbalanced unlock time across multiple rebalance calls while still unbalanced", async () => { + // report the vault as unbalanced + const tx = await vaultHub.connect(vaultSigner).rebalance({ value: 1n }); + const events = await findEvents((await tx.wait()) as ContractTransactionReceipt, "VaultBecameUnbalanced"); + const unbalancedSince = events[0].args.unlockTime - FORCE_WITHDRAWAL_TIMELOCK; + + // Advance time by less than FORCE_WITHDRAWAL_TIMELOCK. + await advanceChainTime(1000n); + + await expect(vaultHub.connect(vaultSigner).rebalance({ value: 1n })).to.not.emit( + vaultHub, + "VaultBecameUnbalanced", + ); + + expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq(unbalancedSince); + + // report the vault as unbalanced again + await expect(vaultHub.connect(vaultSigner).rebalance({ value: 1n })).to.not.emit( + vaultHub, + "VaultBecameUnbalanced", + ); + + expect((await vaultHub["vaultSocket(address)"](vaultAddress)).unbalancedSince).to.be.eq(unbalancedSince); }); }); });