diff --git a/contracts/0.8.25/vaults/Delegation.sol b/contracts/0.8.25/vaults/Delegation.sol index 08429de3c..564f23661 100644 --- a/contracts/0.8.25/vaults/Delegation.sol +++ b/contracts/0.8.25/vaults/Delegation.sol @@ -40,7 +40,9 @@ contract Delegation is Dashboard { * - votes operator fee; * - votes on vote lifetime; * - votes on ownership transfer; - * - claims curator due. + * - claims curator due; + * - pauses deposits to beacon chain; + * - resumes deposits to beacon chain. */ bytes32 public constant CURATOR_ROLE = keccak256("Vault.Delegation.CuratorRole"); @@ -346,6 +348,20 @@ contract Delegation is Dashboard { _voluntaryDisconnect(); } + /** + * @notice Pauses deposits to beacon chain from the StakingVault. + */ + function pauseBeaconDeposits() external onlyRole(CURATOR_ROLE) { + IStakingVault(stakingVault).pauseBeaconDeposits(); + } + + /** + * @notice Resumes deposits to beacon chain from the StakingVault. + */ + function resumeBeaconDeposits() external onlyRole(CURATOR_ROLE) { + IStakingVault(stakingVault).resumeBeaconDeposits(); + } + /** * @dev Modifier that implements a mechanism for multi-role committee approval. * Each unique function call (identified by msg.data: selector + arguments) requires diff --git a/contracts/0.8.25/vaults/StakingVault.sol b/contracts/0.8.25/vaults/StakingVault.sol index bc6e585d9..29dcd97a8 100644 --- a/contracts/0.8.25/vaults/StakingVault.sol +++ b/contracts/0.8.25/vaults/StakingVault.sol @@ -36,6 +36,8 @@ import {ERC1967Utils} from "@openzeppelin/contracts-v5.0.2/proxy/ERC1967/ERC1967 * - `withdraw()` * - `requestValidatorExit()` * - `rebalance()` + * - `pauseDeposits()` + * - `resumeDeposits()` * - Operator: * - `depositToBeaconChain()` * - VaultHub: @@ -60,12 +62,14 @@ contract StakingVault is IStakingVault, IBeaconProxy, BeaconChainDepositLogistic * @custom:locked Amount of ether locked on StakingVault by VaultHub and cannot be withdrawn by owner * @custom:inOutDelta Net difference between ether funded and withdrawn from StakingVault * @custom:operator Address of the node operator + * @custom:depositsPaused Whether beacon deposits are paused by the vault owner */ struct ERC7201Storage { Report report; uint128 locked; int128 inOutDelta; address operator; + bool depositsPaused; } /** @@ -217,8 +221,15 @@ contract StakingVault is IStakingVault, IBeaconProxy, BeaconChainDepositLogistic * @return Report struct containing valuation and inOutDelta from last report */ function latestReport() external view returns (IStakingVault.Report memory) { - ERC7201Storage storage $ = _getStorage(); - return $.report; + return _getStorage().report; + } + + /** + * @notice Returns whether deposits are paused by the vault owner + * @return True if deposits are paused + */ + function areBeaconDepositsPaused() external view returns (bool) { + return _getStorage().depositsPaused; } /** @@ -317,6 +328,7 @@ contract StakingVault is IStakingVault, IBeaconProxy, BeaconChainDepositLogistic if (_numberOfDeposits == 0) revert ZeroArgument("_numberOfDeposits"); if (!isBalanced()) revert Unbalanced(); if (msg.sender != _getStorage().operator) revert NotAuthorized("depositToBeaconChain", msg.sender); + if (_getStorage().depositsPaused) revert BeaconChainDepositsNotAllowed(); _makeBeaconChainDeposits32ETH(_numberOfDeposits, bytes.concat(withdrawalCredentials()), _pubkeys, _signatures); emit DepositedToBeaconChain(msg.sender, _numberOfDeposits, _numberOfDeposits * 32 ether); @@ -389,6 +401,26 @@ contract StakingVault is IStakingVault, IBeaconProxy, BeaconChainDepositLogistic emit Reported(_valuation, _inOutDelta, _locked); } + /** + * @notice Pauses deposits to beacon chain + * @dev Can only be called by the vault owner + */ + function pauseBeaconDeposits() external onlyOwner { + _getStorage().depositsPaused = true; + + emit BeaconDepositsPaused(); + } + + /** + * @notice Resumes deposits to beacon chain + * @dev Can only be called by the vault owner + */ + function resumeBeaconDeposits() external onlyOwner { + _getStorage().depositsPaused = false; + + emit BeaconDepositsResumed(); + } + function _getStorage() private pure returns (ERC7201Storage storage $) { assembly { $.slot := ERC721_STORAGE_LOCATION @@ -449,6 +481,16 @@ contract StakingVault is IStakingVault, IBeaconProxy, BeaconChainDepositLogistic */ event OnReportFailed(bytes reason); + /** + * @notice Emitted when deposits to beacon chain are paused + */ + event BeaconDepositsPaused(); + + /** + * @notice Emitted when deposits to beacon chain are resumed + */ + event BeaconDepositsResumed(); + /** * @notice Thrown when an invalid zero value is passed * @param name Name of the argument that was zero @@ -511,4 +553,9 @@ contract StakingVault is IStakingVault, IBeaconProxy, BeaconChainDepositLogistic * @notice Thrown when the onReport() hook reverts with an Out of Gas error */ error UnrecoverableError(); + + /** + * @notice Thrown when trying to deposit to beacon chain while deposits are paused + */ + error BeaconChainDepositsNotAllowed(); } diff --git a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol index 54d597073..395222944 100644 --- a/contracts/0.8.25/vaults/interfaces/IStakingVault.sol +++ b/contracts/0.8.25/vaults/interfaces/IStakingVault.sol @@ -29,6 +29,7 @@ interface IStakingVault { function isBalanced() external view returns (bool); function unlocked() external view returns (uint256); function inOutDelta() external view returns (int256); + function areBeaconDepositsPaused() external view returns (bool); function withdrawalCredentials() external view returns (bytes32); function fund() external payable; function withdraw(address _recipient, uint256 _ether) external; @@ -40,6 +41,8 @@ interface IStakingVault { function requestValidatorExit(bytes calldata _pubkeys) external; function lock(uint256 _locked) external; function rebalance(uint256 _ether) external; + function pauseBeaconDeposits() external; + function resumeBeaconDeposits() external; function latestReport() external view returns (Report memory); function report(uint256 _valuation, int256 _inOutDelta, uint256 _locked) external; } diff --git a/test/0.8.25/vaults/delegation/delegation.test.ts b/test/0.8.25/vaults/delegation/delegation.test.ts index 5ad7b08ea..4acfd7503 100644 --- a/test/0.8.25/vaults/delegation/delegation.test.ts +++ b/test/0.8.25/vaults/delegation/delegation.test.ts @@ -623,4 +623,32 @@ describe("Delegation.sol", () => { expect(await vault.owner()).to.equal(newOwner); }); }); + + context("pauseBeaconDeposits", () => { + it("reverts if the caller is not a curator", async () => { + await expect(delegation.connect(stranger).pauseBeaconDeposits()).to.be.revertedWithCustomError( + delegation, + "AccessControlUnauthorizedAccount", + ); + }); + + it("pauses the beacon deposits", async () => { + await expect(delegation.connect(curator).pauseBeaconDeposits()).to.emit(vault, "BeaconDepositsPaused"); + expect(await vault.areBeaconDepositsPaused()).to.be.true; + }); + }); + + context("resumeBeaconDeposits", () => { + it("reverts if the caller is not a curator", async () => { + await expect(delegation.connect(stranger).resumeBeaconDeposits()).to.be.revertedWithCustomError( + delegation, + "AccessControlUnauthorizedAccount", + ); + }); + + it("resumes the beacon deposits", async () => { + await expect(delegation.connect(curator).resumeBeaconDeposits()).to.emit(vault, "BeaconDepositsResumed"); + expect(await vault.areBeaconDepositsPaused()).to.be.false; + }); + }); }); diff --git a/test/0.8.25/vaults/staking-vault/staking-vault.test.ts b/test/0.8.25/vaults/staking-vault/staking-vault.test.ts index eb4b27468..3e51db69f 100644 --- a/test/0.8.25/vaults/staking-vault/staking-vault.test.ts +++ b/test/0.8.25/vaults/staking-vault/staking-vault.test.ts @@ -130,6 +130,7 @@ describe("StakingVault", () => { ); expect(await stakingVault.valuation()).to.equal(0n); expect(await stakingVault.isBalanced()).to.be.true; + expect(await stakingVault.areBeaconDepositsPaused()).to.be.false; }); }); @@ -294,6 +295,40 @@ describe("StakingVault", () => { }); }); + context("pauseBeaconDeposits", () => { + it("reverts if called by a non-owner", async () => { + await expect(stakingVault.connect(stranger).pauseBeaconDeposits()) + .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") + .withArgs(await stranger.getAddress()); + }); + + it("allows to pause deposits", async () => { + await expect(stakingVault.connect(vaultOwner).pauseBeaconDeposits()).to.emit( + stakingVault, + "BeaconDepositsPaused", + ); + expect(await stakingVault.areBeaconDepositsPaused()).to.be.true; + }); + }); + + context("resumeBeaconDeposits", () => { + it("reverts if called by a non-owner", async () => { + await expect(stakingVault.connect(stranger).resumeBeaconDeposits()) + .to.be.revertedWithCustomError(stakingVault, "OwnableUnauthorizedAccount") + .withArgs(await stranger.getAddress()); + }); + + it("allows to resume deposits", async () => { + await stakingVault.connect(vaultOwner).pauseBeaconDeposits(); + + await expect(stakingVault.connect(vaultOwner).resumeBeaconDeposits()).to.emit( + stakingVault, + "BeaconDepositsResumed", + ); + expect(await stakingVault.areBeaconDepositsPaused()).to.be.false; + }); + }); + context("depositToBeaconChain", () => { it("reverts if called by a non-operator", async () => { await expect(stakingVault.connect(stranger).depositToBeaconChain(1, "0x", "0x")) @@ -315,6 +350,14 @@ describe("StakingVault", () => { ); }); + it("reverts if the deposits are paused", async () => { + await stakingVault.connect(vaultOwner).pauseBeaconDeposits(); + await expect(stakingVault.connect(operator).depositToBeaconChain(1, "0x", "0x")).to.be.revertedWithCustomError( + stakingVault, + "BeaconChainDepositsNotAllowed", + ); + }); + it("makes deposits to the beacon chain and emits the DepositedToBeaconChain event", async () => { await stakingVault.fund({ value: ether("32") });