diff --git a/contracts/0.8.25/utils/PausableUntilWithRoles.sol b/contracts/0.8.25/utils/PausableUntilWithRoles.sol new file mode 100644 index 000000000..e8c2d831b --- /dev/null +++ b/contracts/0.8.25/utils/PausableUntilWithRoles.sol @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 + +// See contracts/COMPILERS.md +pragma solidity 0.8.25; + +import {PausableUntil} from "contracts/common/utils/PausableUntil.sol"; +import {AccessControlEnumerableUpgradeable} from "contracts/openzeppelin/5.0.2/upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; + +/** + * @title PausableUntilWithRoles + * @notice a `PausableUntil` implementation using OpenZeppelin's `AccessControlEnumerableUpgradeable` + * @dev the inheriting contract must use `whenNotPaused` modifier from `PausableUntil` to block some functions on pause + */ +abstract contract PausableUntilWithRoles is PausableUntil, AccessControlEnumerableUpgradeable { + /// @notice role that allows to pause the contract + bytes32 public constant PAUSE_ROLE = keccak256("PausableUntilWithRoles.PauseRole"); + /// @notice role that allows to resume the contract + bytes32 public constant RESUME_ROLE = keccak256("PausableUntilWithRoles.ResumeRole"); + + /** + * @notice Resume the contract + * @dev Reverts if contracts is not paused + * @dev Reverts if sender has no `RESUME_ROLE` + */ + function resume() external onlyRole(RESUME_ROLE) { + _resume(); + } + + /** + * @notice Pause the contract for a specified period + * @param _duration pause duration in seconds (use `PAUSE_INFINITELY` for unlimited) + * @dev Reverts if contract is already paused + * @dev Reverts if sender has no `PAUSE_ROLE` + * @dev Reverts if zero duration is passed + */ + function pauseFor(uint256 _duration) external onlyRole(PAUSE_ROLE) { + _pauseFor(_duration); + } + + /** + * @notice Pause the contract until a specified timestamp + * @param _pauseUntilInclusive the last second to pause until inclusive + * @dev Reverts if the timestamp is in the past + * @dev Reverts if sender has no `PAUSE_ROLE` + * @dev Reverts if contract is already paused + */ + function pauseUntil(uint256 _pauseUntilInclusive) external onlyRole(PAUSE_ROLE) { + _pauseUntil(_pauseUntilInclusive); + } +} diff --git a/contracts/0.8.25/vaults/VaultHub.sol b/contracts/0.8.25/vaults/VaultHub.sol index b8e6af96d..8ce4527fd 100644 --- a/contracts/0.8.25/vaults/VaultHub.sol +++ b/contracts/0.8.25/vaults/VaultHub.sol @@ -5,13 +5,14 @@ pragma solidity 0.8.25; import {IBeacon} from "@openzeppelin/contracts-v5.0.2/proxy/beacon/IBeacon.sol"; -import {AccessControlEnumerableUpgradeable} from "contracts/openzeppelin/5.0.2/upgradeable/access/extensions/AccessControlEnumerableUpgradeable.sol"; import {OwnableUpgradeable} from "contracts/openzeppelin/5.0.2/upgradeable/access/OwnableUpgradeable.sol"; import {IStakingVault} from "./interfaces/IStakingVault.sol"; import {ILido as IStETH} from "../interfaces/ILido.sol"; import {IBeaconProxy} from "./interfaces/IBeaconProxy.sol"; +import {PausableUntilWithRoles} from "../utils/PausableUntilWithRoles.sol"; + import {Math256} from "contracts/common/lib/Math256.sol"; /// @notice VaultHub is a contract that manages vaults connected to the Lido protocol @@ -19,7 +20,7 @@ import {Math256} from "contracts/common/lib/Math256.sol"; /// It also allows to force rebalance of the vaults /// Also, it passes the report from the accounting oracle to the vaults and charges fees /// @author folkyatina -abstract contract VaultHub is AccessControlEnumerableUpgradeable { +abstract contract VaultHub is PausableUntilWithRoles { /// @custom:storage-location erc7201:VaultHub struct VaultHubStorage { /// @notice vault sockets with vaults connected to the hub @@ -217,7 +218,7 @@ abstract contract VaultHub is AccessControlEnumerableUpgradeable { /// @param _vault vault address /// @dev msg.sender should be vault's owner /// @dev vault's `mintedShares` should be zero - function voluntaryDisconnect(address _vault) external { + function voluntaryDisconnect(address _vault) external whenResumed { if (_vault == address(0)) revert ZeroArgument("_vault"); _vaultAuth(_vault, "disconnect"); @@ -229,7 +230,7 @@ abstract contract VaultHub is AccessControlEnumerableUpgradeable { /// @param _recipient address of the receiver /// @param _amountOfShares amount of stETH shares to mint /// @dev msg.sender should be vault's owner - function mintSharesBackedByVault(address _vault, address _recipient, uint256 _amountOfShares) external { + function mintSharesBackedByVault(address _vault, address _recipient, uint256 _amountOfShares) external whenResumed { if (_vault == address(0)) revert ZeroArgument("_vault"); if (_recipient == address(0)) revert ZeroArgument("_recipient"); if (_amountOfShares == 0) revert ZeroArgument("_amountOfShares"); @@ -268,7 +269,7 @@ abstract contract VaultHub is AccessControlEnumerableUpgradeable { /// @param _amountOfShares amount of shares to burn /// @dev msg.sender should be vault's owner /// @dev VaultHub must have all the stETH on its balance - function burnSharesBackedByVault(address _vault, uint256 _amountOfShares) public { + function burnSharesBackedByVault(address _vault, uint256 _amountOfShares) public whenResumed { if (_vault == address(0)) revert ZeroArgument("_vault"); if (_amountOfShares == 0) revert ZeroArgument("_amountOfShares"); _vaultAuth(_vault, "burn"); @@ -334,7 +335,7 @@ abstract contract VaultHub is AccessControlEnumerableUpgradeable { /// @notice rebalances the vault by writing off the amount of ether equal /// to `msg.value` from the vault's minted stETH /// @dev msg.sender should be vault's contract - function rebalance() external payable { + function rebalance() external payable whenResumed { if (msg.value == 0) revert ZeroArgument("msg.value"); VaultSocket storage socket = _connectedSocket(msg.sender); diff --git a/contracts/common/lib/UnstructuredStorage.sol b/contracts/common/lib/UnstructuredStorage.sol new file mode 100644 index 000000000..04d9cbb6f --- /dev/null +++ b/contracts/common/lib/UnstructuredStorage.sol @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2023 Lido , Aragon +// SPDX-License-Identifier: MIT + +// solhint-disable-next-line lido/fixed-compiler-version +pragma solidity ^0.8.9; + +library UnstructuredStorage { + function getStorageBool(bytes32 position) internal view returns (bool data) { + assembly { data := sload(position) } + } + + function getStorageAddress(bytes32 position) internal view returns (address data) { + assembly { data := sload(position) } + } + + function getStorageBytes32(bytes32 position) internal view returns (bytes32 data) { + assembly { data := sload(position) } + } + + function getStorageUint256(bytes32 position) internal view returns (uint256 data) { + assembly { data := sload(position) } + } + + function setStorageBool(bytes32 position, bool data) internal { + assembly { sstore(position, data) } + } + + function setStorageAddress(bytes32 position, address data) internal { + assembly { sstore(position, data) } + } + + function setStorageBytes32(bytes32 position, bytes32 data) internal { + assembly { sstore(position, data) } + } + + function setStorageUint256(bytes32 position, uint256 data) internal { + assembly { sstore(position, data) } + } +} diff --git a/contracts/common/utils/PausableUntil.sol b/contracts/common/utils/PausableUntil.sol new file mode 100644 index 000000000..4ef0988a7 --- /dev/null +++ b/contracts/common/utils/PausableUntil.sol @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: 2025 Lido +// SPDX-License-Identifier: GPL-3.0 +// solhint-disable-next-line lido/fixed-compiler-version +pragma solidity ^0.8.9; + +import {UnstructuredStorage} from "contracts/common/lib/UnstructuredStorage.sol"; + +/** + * @title PausableUntil + * @notice allows to pause the contract for a specific duration or indefinitely + */ +abstract contract PausableUntil { + using UnstructuredStorage for bytes32; + + /// Contract resume/pause control storage slot + bytes32 internal constant RESUME_SINCE_TIMESTAMP_POSITION = keccak256("lido.PausableUntil.resumeSinceTimestamp"); + /// Special value for the infinite pause + uint256 public constant PAUSE_INFINITELY = type(uint256).max; + + /// @notice Emitted when paused by the `pauseFor` or `pauseUntil` call + event Paused(uint256 duration); + /// @notice Emitted when resumed by the `resume` call + event Resumed(); + + error ZeroPauseDuration(); + error PausedExpected(); + error ResumedExpected(); + error PauseUntilMustBeInFuture(); + + /// @notice Reverts if paused + modifier whenResumed() { + _checkResumed(); + _; + } + + /// @notice Returns whether the contract is paused + function isPaused() public view returns (bool) { + return block.timestamp < RESUME_SINCE_TIMESTAMP_POSITION.getStorageUint256(); + } + + /// @notice Returns one of: + /// - PAUSE_INFINITELY if paused infinitely returns + /// - the timestamp when the contract get resumed if paused for specific duration + /// - some timestamp in past if not paused + function getResumeSinceTimestamp() external view returns (uint256) { + return RESUME_SINCE_TIMESTAMP_POSITION.getStorageUint256(); + } + + function _checkPaused() internal view { + if (!isPaused()) { + revert PausedExpected(); + } + } + + function _checkResumed() internal view { + if (isPaused()) { + revert ResumedExpected(); + } + } + + function _resume() internal { + _checkPaused(); + RESUME_SINCE_TIMESTAMP_POSITION.setStorageUint256(block.timestamp); + emit Resumed(); + } + + function _pauseFor(uint256 _duration) internal { + _checkResumed(); + if (_duration == 0) revert ZeroPauseDuration(); + + uint256 resumeSince; + if (_duration == PAUSE_INFINITELY) { + resumeSince = PAUSE_INFINITELY; + } else { + resumeSince = block.timestamp + _duration; + } + _setPausedState(resumeSince); + } + + function _pauseUntil(uint256 _pauseUntilInclusive) internal { + _checkResumed(); + if (_pauseUntilInclusive < block.timestamp) revert PauseUntilMustBeInFuture(); + + uint256 resumeSince; + if (_pauseUntilInclusive != PAUSE_INFINITELY) { + resumeSince = _pauseUntilInclusive + 1; + } else { + resumeSince = PAUSE_INFINITELY; + } + _setPausedState(resumeSince); + } + + function _setPausedState(uint256 _resumeSince) internal { + RESUME_SINCE_TIMESTAMP_POSITION.setStorageUint256(_resumeSince); + if (_resumeSince == PAUSE_INFINITELY) { + emit Paused(PAUSE_INFINITELY); + } else { + emit Paused(_resumeSince - block.timestamp); + } + } +} diff --git a/hardhat.config.ts b/hardhat.config.ts index a8a1af019..15aa0a7f4 100644 --- a/hardhat.config.ts +++ b/hardhat.config.ts @@ -52,7 +52,7 @@ function loadAccounts(networkName: string) { const config: HardhatUserConfig = { defaultNetwork: "hardhat", gasReporter: { - enabled: true, + enabled: process.env.SKIP_GAS_REPORT ? false : true, }, networks: { "hardhat": { @@ -198,7 +198,10 @@ const config: HardhatUserConfig = { }, watcher: { test: { - tasks: [{ command: "test", params: { testFiles: ["{path}"] } }], + tasks: [ + { command: "compile", params: { quiet: true } }, + { command: "test", params: { noCompile: true, testFiles: ["{path}"] } }, + ], files: ["./test/**/*"], clearOnStart: true, start: "echo Running tests...", @@ -225,7 +228,7 @@ const config: HardhatUserConfig = { contractSizer: { alphaSort: false, disambiguatePaths: false, - runOnCompile: true, + runOnCompile: process.env.SKIP_CONTRACT_SIZE ? false : true, strict: true, except: ["template", "mocks", "@aragon", "openzeppelin", "test"], }, diff --git a/package.json b/package.json index a8711c17c..a276bfcf9 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test:sequential": "hardhat test test/**/*.test.ts", "test:trace": "hardhat test test/**/*.test.ts --trace --disabletracer", "test:fulltrace": "hardhat test test/**/*.test.ts --fulltrace --disabletracer", - "test:watch": "hardhat watch test", + "test:watch": "SKIP_GAS_REPORT=true SKIP_CONTRACT_SIZE=true hardhat watch test", "test:integration": "hardhat test test/integration/**/*.ts", "test:integration:trace": "hardhat test test/integration/**/*.ts --trace --disabletracer", "test:integration:fulltrace": "hardhat test test/integration/**/*.ts --fulltrace --disabletracer", @@ -90,7 +90,7 @@ "lint-staged": "15.2.10", "prettier": "3.4.1", "prettier-plugin-solidity": "1.4.1", - "solhint": "5.0.3", + "solhint": "5.0.4", "solhint-plugin-lido": "0.0.4", "solidity-coverage": "0.8.14", "ts-node": "10.9.2", diff --git a/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts b/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts new file mode 100644 index 000000000..feb145fa0 --- /dev/null +++ b/test/0.8.25/vaults/vaulthub/vaulthub.pausable.test.ts @@ -0,0 +1,187 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; + +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; +import { time } from "@nomicfoundation/hardhat-network-helpers"; + +import { StETH__HarnessForVaultHub, VaultHub } from "typechain-types"; + +import { ether, MAX_UINT256 } from "lib"; + +import { deployLidoLocator } from "test/deploy"; +import { Snapshot } from "test/suite"; + +describe("VaultHub.sol:pausableUntil", () => { + let deployer: HardhatEthersSigner; + let user: HardhatEthersSigner; + let stranger: HardhatEthersSigner; + + let vaultHub: VaultHub; + let steth: StETH__HarnessForVaultHub; + + let originalState: string; + + before(async () => { + [deployer, user, stranger] = await ethers.getSigners(); + + const locator = await deployLidoLocator(); + steth = await ethers.deployContract("StETH__HarnessForVaultHub", [user], { value: ether("1.0") }); + + const vaultHubImpl = await ethers.deployContract("Accounting", [locator, steth]); + const proxy = await ethers.deployContract("OssifiableProxy", [vaultHubImpl, deployer, new Uint8Array()]); + + const accounting = await ethers.getContractAt("Accounting", proxy); + await accounting.initialize(deployer); + + vaultHub = await ethers.getContractAt("Accounting", proxy, user); + await accounting.grantRole(await vaultHub.PAUSE_ROLE(), user); + await accounting.grantRole(await vaultHub.RESUME_ROLE(), user); + }); + + beforeEach(async () => (originalState = await Snapshot.take())); + + afterEach(async () => await Snapshot.restore(originalState)); + + context("Constants", () => { + it("Returns the PAUSE_INFINITELY variable", async () => { + expect(await vaultHub.PAUSE_INFINITELY()).to.equal(MAX_UINT256); + }); + }); + + context("initialState", () => { + it("isPaused returns false", async () => { + expect(await vaultHub.isPaused()).to.equal(false); + }); + + it("getResumeSinceTimestamp returns 0", async () => { + expect(await vaultHub.getResumeSinceTimestamp()).to.equal(0); + }); + }); + + context("pauseFor", () => { + it("reverts if no PAUSE_ROLE", async () => { + await expect(vaultHub.connect(stranger).pauseFor(1000n)) + .to.be.revertedWithCustomError(vaultHub, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await vaultHub.PAUSE_ROLE()); + }); + + it("reverts if zero pause duration", async () => { + await expect(vaultHub.pauseFor(0n)).to.be.revertedWithCustomError(vaultHub, "ZeroPauseDuration"); + }); + + it("reverts if paused", async () => { + await expect(vaultHub.pauseFor(1000n)).to.emit(vaultHub, "Paused"); + + await expect(vaultHub.pauseFor(1000n)).to.be.revertedWithCustomError(vaultHub, "ResumedExpected"); + }); + + it("emits Paused event and change state", async () => { + await expect(vaultHub.pauseFor(1000n)).to.emit(vaultHub, "Paused").withArgs(1000n); + + expect(await vaultHub.isPaused()).to.equal(true); + expect(await vaultHub.getResumeSinceTimestamp()).to.equal((await time.latest()) + 1000); + }); + + it("works for MAX_UINT256 duration", async () => { + await expect(vaultHub.pauseFor(MAX_UINT256)).to.emit(vaultHub, "Paused").withArgs(MAX_UINT256); + + expect(await vaultHub.isPaused()).to.equal(true); + expect(await vaultHub.getResumeSinceTimestamp()).to.equal(MAX_UINT256); + }); + }); + + context("pauseUntil", () => { + it("reverts if no PAUSE_ROLE", async () => { + await expect(vaultHub.connect(stranger).pauseUntil(1000n)) + .to.be.revertedWithCustomError(vaultHub, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await vaultHub.PAUSE_ROLE()); + }); + + it("reverts if timestamp is in the past", async () => { + await expect(vaultHub.pauseUntil(0)).to.be.revertedWithCustomError(vaultHub, "PauseUntilMustBeInFuture"); + }); + + it("emits Paused event and change state", async () => { + const timestamp = await time.latest(); + + await expect(vaultHub.pauseUntil(timestamp + 1000)).to.emit(vaultHub, "Paused"); + // .withArgs(timestamp + 1000 - await time.latest()); // how to use last block timestamp in assertions + + expect(await vaultHub.isPaused()).to.equal(true); + expect(await vaultHub.getResumeSinceTimestamp()).to.greaterThanOrEqual((await time.latest()) + 1000); + }); + + it("works for MAX_UINT256 timestamp", async () => { + await expect(vaultHub.pauseUntil(MAX_UINT256)).to.emit(vaultHub, "Paused").withArgs(MAX_UINT256); + + expect(await vaultHub.isPaused()).to.equal(true); + expect(await vaultHub.getResumeSinceTimestamp()).to.equal(MAX_UINT256); + }); + }); + + context("resume", () => { + it("reverts if no RESUME_ROLE", async () => { + await expect(vaultHub.connect(stranger).resume()) + .to.be.revertedWithCustomError(vaultHub, "AccessControlUnauthorizedAccount") + .withArgs(stranger, await vaultHub.RESUME_ROLE()); + }); + + it("reverts if not paused", async () => { + await expect(vaultHub.resume()).to.be.revertedWithCustomError(vaultHub, "PausedExpected"); + }); + + it("reverts if already resumed", async () => { + await expect(vaultHub.pauseFor(1000n)).to.emit(vaultHub, "Paused"); + await expect(vaultHub.resume()).to.emit(vaultHub, "Resumed"); + + await expect(vaultHub.resume()).to.be.revertedWithCustomError(vaultHub, "PausedExpected"); + }); + + it("emits Resumed event and change state", async () => { + await expect(vaultHub.pauseFor(1000n)).to.emit(vaultHub, "Paused"); + + await expect(vaultHub.resume()).to.emit(vaultHub, "Resumed"); + + expect(await vaultHub.isPaused()).to.equal(false); + expect(await vaultHub.getResumeSinceTimestamp()).to.equal(await time.latest()); + }); + }); + + context("isPaused", () => { + beforeEach(async () => { + await expect(vaultHub.pauseFor(1000n)).to.emit(vaultHub, "Paused"); + expect(await vaultHub.isPaused()).to.equal(true); + }); + + it("reverts voluntaryDisconnect() if paused", async () => { + await expect(vaultHub.voluntaryDisconnect(user)).to.be.revertedWithCustomError(vaultHub, "ResumedExpected"); + }); + + it("reverts mintSharesBackedByVault() if paused", async () => { + await expect(vaultHub.mintSharesBackedByVault(stranger, user, 1000n)).to.be.revertedWithCustomError( + vaultHub, + "ResumedExpected", + ); + }); + + it("reverts burnSharesBackedByVault() if paused", async () => { + await expect(vaultHub.burnSharesBackedByVault(stranger, 1000n)).to.be.revertedWithCustomError( + vaultHub, + "ResumedExpected", + ); + }); + + it("reverts rebalance() if paused", async () => { + await expect(vaultHub.rebalance()).to.be.revertedWithCustomError(vaultHub, "ResumedExpected"); + }); + + it("reverts transferAndBurnSharesBackedByVault() if paused", async () => { + await steth.connect(user).approve(vaultHub, 1000n); + + await expect(vaultHub.transferAndBurnSharesBackedByVault(stranger, 1000n)).to.be.revertedWithCustomError( + vaultHub, + "ResumedExpected", + ); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index c910ac91b..a8657fefc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8095,7 +8095,7 @@ __metadata: openzeppelin-solidity: "npm:2.0.0" prettier: "npm:3.4.1" prettier-plugin-solidity: "npm:1.4.1" - solhint: "npm:5.0.3" + solhint: "npm:5.0.4" solhint-plugin-lido: "npm:0.0.4" solidity-coverage: "npm:0.8.14" ts-node: "npm:10.9.2" @@ -10638,11 +10638,11 @@ __metadata: languageName: node linkType: hard -"solhint@npm:5.0.3": - version: 5.0.3 - resolution: "solhint@npm:5.0.3" +"solhint@npm:5.0.4": + version: 5.0.4 + resolution: "solhint@npm:5.0.4" dependencies: - "@solidity-parser/parser": "npm:^0.18.0" + "@solidity-parser/parser": "npm:^0.19.0" ajv: "npm:^6.12.6" antlr4: "npm:^4.13.1-patch-1" ast-parents: "npm:^0.0.1" @@ -10666,7 +10666,7 @@ __metadata: optional: true bin: solhint: solhint.js - checksum: 10c0/262e86a8932d7d4d6ebae2a9d7317749e5068092e7cdf4caf07ac39fc72bd2c94f3907daaedcad37592ec001b57caed6dc5ed7c3fd6cd18b6443182f38c1715e + checksum: 10c0/70058b23c8746762fc88d48b571c4571719913ca7f3c582a55c123ad9ba38976a2338782025fbb9643bb75bfad18bf3dce1b71e500df6d99589e9814fbcce1d7 languageName: node linkType: hard