diff --git a/contracts/mocks/PausableMock.sol b/contracts/mocks/PausableMock.sol new file mode 100644 index 00000000..7b4eeed6 --- /dev/null +++ b/contracts/mocks/PausableMock.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {PausableUntil} from "../utils/PausableUntil.sol"; + +abstract contract PausableUntilMock is PausableUntil { + function clock() public view virtual override returns (uint48) { + return SafeCast.toUint48(block.timestamp); + } + + // solhint-disable-next-line func-name-mixedcase + function CLOCK_MODE() public view virtual override returns (string memory) { + return "mode=timestamp"; + } + + function canCallWhenNotPaused() external whenNotPaused {} + function canCallWhenPaused() external whenPaused {} +} diff --git a/contracts/utils/PausableUntil.sol b/contracts/utils/PausableUntil.sol new file mode 100644 index 00000000..fec812bd --- /dev/null +++ b/contracts/utils/PausableUntil.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {IERC6372} from "@openzeppelin/contracts/interfaces/IERC6372.sol"; + +/** + * @title Pausable + * @author @CarlosAlegreUr + * + * @dev Contract module which allows children to implement an emergency stop + * mechanism that can be triggered by an authorized account. + * + * Stops can be of undefined duration or for a certain amount of time. + * + * This module is used through inheritance. It will make available the + * modifiers `whenNotPaused` and `whenPaused`, which can be applied to + * the functions of your contract. Note that they will not be `Pausable` by + * simply including this module, only once the modifiers are put in place + * and access to call the internal `_pause()`, `_unpause()`, `_pauseUntil()` + * functions is coded. + * + * [ ⚠️ WARNING ⚠️ ] + * This version should be backwards compatible with previous OpenZeppelin `Pausable` + * versions as it uses the same 1 storage slot in a backwards compatible way. + * + * However this has not been tested yet. Please test locally before updating any + * contract to use this version. + */ +abstract contract PausableUntil is Pausable, IERC6372 { + /** + * @dev Storage slot is structured like so: + * + * - Least significant 8 bits: signal pause state. + * 1 for paused, 0 for unpaused. + * + * - After, the following 48 bits: signal timestamp at which the contract + * will be automatically unpaused if the pause had a duration set. + */ + uint48 private _pausedUntil; + + /** + * @dev Emitted when the pause is triggered by `account`. `unpauseDeadline` is 0 if the pause is indefinite. + */ + event Paused(address account, uint48 unpauseDeadline); + + /** + * @inheritdoc IERC6372 + */ + function clock() public view virtual returns (uint48); + + /** + * @dev Returns the time date at which the contract will be automatically unpaused. + * + * If returned 0, the contract might or might not be paused. + * This function must not be used for checking paused state. + */ + function _unpauseDeadline() internal view virtual returns (uint48) { + return _pausedUntil; + } + + /** + * @dev Triggers stopped state while `unpauseDeadline` date is still in the future. + * + * This function should be used to prevent eternally pausing contracts in complex + * permissioned systems. + * + * Requirements: + * + * - The contract must not be paused. + * - `unpauseDeadline` must be in the future. + * - `clock()` return value and `unpauseDeadline` must be in the same time units. + * - If pausing with an `unpauseDeadline` in the past this function will not pause neither revert. + */ + function _pauseUntil(uint48 unpauseDeadline) internal virtual whenNotPaused { + _pausedUntil = unpauseDeadline; + emit Paused(_msgSender(), unpauseDeadline); + } + + /** + * @inheritdoc Pausable + */ + function paused() public view virtual override returns (bool) { + // exit early without an sload if normal paused is enabled + if (super.paused()) return true; + + uint48 unpauseDeadline = _unpauseDeadline(); + return unpauseDeadline != 0 && this.clock() < unpauseDeadline; + } + + /** + * @inheritdoc Pausable + */ + function _pause() internal virtual override { + super._pause(); + delete _pausedUntil; + } + + /** + * @inheritdoc Pausable + */ + function _unpause() internal virtual override { + super._unpause(); + delete _pausedUntil; + } +} diff --git a/test/utils/PausableUntil.test.js b/test/utils/PausableUntil.test.js new file mode 100644 index 00000000..44496d34 --- /dev/null +++ b/test/utils/PausableUntil.test.js @@ -0,0 +1,134 @@ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); +const time = require('@openzeppelin/contracts/test/helpers/time'); +const { MAX_UINT48 } = require('@openzeppelin/contracts/test/helpers/constants'); + +const PAUSE_DURATION = 10n; + +async function checkPaused(withDeadline = false) { + it('reported state is correct', async function () { + await expect(this.mock.paused()).to.eventually.be.true; + }); + + it('check deadline value', async function () { + await expect(this.mock.$_unpauseDeadline()).to.eventually.equal(withDeadline ? this.deadline : 0n); + }); + + it('whenNotPaused modifier reverts', async function () { + await expect(this.mock.canCallWhenNotPaused()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + }); + + it('whenPaused modifier does not reverts', async function () { + await expect(this.mock.canCallWhenPaused()).to.be.not.reverted; + }); + + it('reverts when pausing with _pause', async function () { + await expect(this.mock.$_pause()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + }); + + it('reverts when pausing with _pauseUntil', async function () { + await expect(this.mock.$_pauseUntil(MAX_UINT48)).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + }); +} + +async function checkUnpaused(strictDealine = true) { + it('reported state is correct', async function () { + await expect(this.mock.paused()).to.eventually.be.false; + }); + + if (strictDealine) { + it('deadline is cleared', async function () { + await expect(this.mock.$_unpauseDeadline()).to.eventually.equal(0n); + }); + } + + it('whenNotPaused modifier does not revert', async function () { + await expect(this.mock.canCallWhenNotPaused()).to.be.not.reverted; + }); + + it('whenPaused modifier reverts', async function () { + await expect(this.mock.canCallWhenPaused()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); + }); + + it('reverts when unpausing with _unpause', async function () { + await expect(this.mock.$_unpause()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); + }); +} + +async function fixture() { + const [pauser] = await ethers.getSigners(); + const mock = await ethers.deployContract('$PausableUntilMock'); + + return { pauser, mock }; +} + +describe('Pausable', function () { + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('_pause()', function () { + beforeEach(async function () { + await expect(this.mock.$_pause()).to.emit(this.mock, 'Paused(address)').withArgs(this.pauser); + }); + + checkPaused(false); + + describe('unpause by function call', function () { + beforeEach(async function () { + await expect(this.mock.$_unpause()).to.emit(this.mock, 'Unpaused').withArgs(this.pauser); + }); + + checkUnpaused(); + }); + }); + + describe('_pausedUntil(uint48)', function () { + beforeEach(async function () { + this.clock = await this.mock.clock(); + this.deadline = this.clock + PAUSE_DURATION; + await expect(this.mock.$_pauseUntil(this.deadline)) + .to.emit(this.mock, 'Paused(address,uint48)') + .withArgs(this.pauser, this.deadline); + }); + + checkPaused(true); + + describe('unpause by function call', function () { + beforeEach(async function () { + await expect(this.mock.$_unpause()).to.emit(this.mock, 'Unpaused').withArgs(this.pauser); + }); + + checkUnpaused(); + }); + + describe('unpause by time passing', function () { + beforeEach(async function () { + await time.increaseTo.timestamp(this.deadline); + }); + + checkUnpaused(false); + + describe('paused after pause duration passed', function () { + beforeEach(async function () { + await expect(this.mock.$_pause()).to.emit(this.mock, 'Paused(address)').withArgs(this.pauser); + }); + + checkPaused(false); + }); + + describe('pausedUntil after pause duration passed', function () { + beforeEach(async function () { + this.clock = await this.mock.clock(); + this.deadline = this.clock + PAUSE_DURATION; + await expect(this.mock.$_pauseUntil(this.deadline)) + .to.emit(this.mock, 'Paused(address,uint48)') + .withArgs(this.pauser, this.deadline); + }); + + checkPaused(true); + }); + }); + }); +});