From 43b25da30561eb2f1ad896233b9dc280c1dde837 Mon Sep 17 00:00:00 2001 From: CarlosAlegreUr Date: Mon, 30 Dec 2024 12:07:05 +0100 Subject: [PATCH 1/2] pauseUntil tested, coded, documented --- contracts/mocks/PausableMock.sol | 45 +++ .../utils/pausability/DefaultPausable.sol | 34 +++ contracts/utils/pausability/Pausable.sol | 201 ++++++++++++ test/utils/Pausable.test.js | 286 ++++++++++++++++++ 4 files changed, 566 insertions(+) create mode 100644 contracts/mocks/PausableMock.sol create mode 100644 contracts/utils/pausability/DefaultPausable.sol create mode 100644 contracts/utils/pausability/Pausable.sol create mode 100644 test/utils/Pausable.test.js diff --git a/contracts/mocks/PausableMock.sol b/contracts/mocks/PausableMock.sol new file mode 100644 index 0000000..5f35e57 --- /dev/null +++ b/contracts/mocks/PausableMock.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.20; + +import {DefaultPausable} from "../utils/pausability/DefaultPausable.sol"; + +contract PausableMock is DefaultPausable { + // solhint-disable-next-line openzeppelin/private-variables + bool public drasticMeasureTaken; + // solhint-disable-next-line openzeppelin/private-variables + uint256 public count; + + constructor() { + drasticMeasureTaken = false; + count = 0; + } + + function normalProcess() external whenNotPaused { + count++; + } + + function drasticMeasure() external whenPaused { + drasticMeasureTaken = true; + } + + function pause() external { + _pause(); + } + + function unpause() external { + _unpause(); + } + + function pauseUntil(uint256 duration) external { + _pauseUntil(uint48(duration)); + } + + function getPausedUntilDeadline() external view returns (uint256) { + return _unpauseDeadline(); + } + + function getPausedUntilDeadlineAndTimestamp() external view returns (uint256, uint256) { + return (_unpauseDeadline(), clock()); + } +} diff --git a/contracts/utils/pausability/DefaultPausable.sol b/contracts/utils/pausability/DefaultPausable.sol new file mode 100644 index 0000000..d3f9d12 --- /dev/null +++ b/contracts/utils/pausability/DefaultPausable.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.0.0) (utils/Pausable.sol) + +pragma solidity ^0.8.20; + +import {Pausable} from "./Pausable.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +/** + * @title DefaultPausable + * @author @CarlosAlegreUr + * + * @dev A default implementation of {Pausable} that uses a `block.timestamp` based `clock()`. + */ +abstract contract DefaultPausable is Pausable { + /** + * @dev Clock is used here for time checkings on pauses with defined end-date. + * + * @dev IERC6372 implementation of a clock() based on native `block.timestamp`. + */ + function clock() public view virtual override returns (uint48) { + return SafeCast.toUint48(block.timestamp); + } + + /** + * @dev IERC6372 implementation of a CLOCK_MODE() based on timestamp. + * + * Override this function to implement a different clock mode, if so must be done following {IERC6372} specification. + */ + // solhint-disable-next-line func-name-mixedcase + function CLOCK_MODE() public view virtual override returns (string memory) { + return "mode=timestamp"; + } +} diff --git a/contracts/utils/pausability/Pausable.sol b/contracts/utils/pausability/Pausable.sol new file mode 100644 index 0000000..a7353c7 --- /dev/null +++ b/contracts/utils/pausability/Pausable.sol @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.0.0) (utils/Pausable.sol) + +pragma solidity ^0.8.20; + +import {Context} from "@openzeppelin/contracts/utils/Context.sol"; +import {IERC6372} from "@openzeppelin/contracts/interfaces/IERC6372.sol"; + +uint8 constant PAUSED = 1; +uint8 constant UNPAUSED = 0; +uint48 constant NO_DEADLINE = 0; +uint8 constant PAUSE_DEADLINE_OFFSET = 8; + +/** + * @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 Pausable is Context, 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. + */ + uint256 private _pausedInfo; + + /** + * @dev Emitted when the pause is triggered by `account`. `unpauseDeadline` is 0 if the pause is indefinite. + */ + event Paused(address account, uint48 unpauseDeadline); + + /** + * @dev Emitted when the pause is lifted by `account`. + */ + event Unpaused(address account); + + /** + * @dev The operation failed because the contract is paused. + */ + error EnforcedPause(); + + /** + * @dev The operation failed because the contract is not paused. + */ + error ExpectedPause(); + + /** + * @dev Initializes the contract in unpaused state. + */ + constructor() { + _pausedInfo = UNPAUSED; + } + + /** + * @dev Modifier to make a function callable only when the contract is not paused. + * + * Requirements: + * + * - The contract must not be paused. + */ + modifier whenNotPaused() { + _requireNotPaused(); + _; + } + + /** + * @dev Modifier to make a function callable only when the contract is paused. + * + * Requirements: + * + * - The contract must be paused. + */ + modifier whenPaused() { + _requirePaused(); + _; + } + + /** + * @dev Clock is used here for time checkings on pauses with defined end-date. + * + * Override this function to implement a customed clock, if so must be done following + * {IERC6372} specification. + * + * Default native `block.timetmap` clock implementation can be found at {DefaultPausable}. + */ + function clock() public view virtual returns (uint48); + + /** + * @dev IERC6372 implementation of a CLOCK_MODE(). + */ + // solhint-disable-next-line func-name-mixedcase + function CLOCK_MODE() public view virtual returns (string memory); + + /** + * @dev Returns true if the contract is paused, and false otherwise. + * + * A contract is paused (returns true) if: + * + * - It was paused by `_pause()` + * - Or if it was paused by `_pauseUntil(uint256 unpauseDeadline)` and `unpauseDeadline` + * is still in the future. + */ + function paused() public view virtual returns (bool) { + uint48 unpauseDeadline = _unpauseDeadline(); + return _pausedInfo == PAUSED || (unpauseDeadline != 0 && clock() < unpauseDeadline); + } + + /** + * @dev Throws if the contract is paused. + */ + function _requireNotPaused() internal view virtual { + if (paused()) { + revert EnforcedPause(); + } + } + + /** + * @dev Throws if the contract is not paused. + */ + function _requirePaused() internal view virtual { + if (!paused()) { + revert ExpectedPause(); + } + } + + /** + * @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 uint48(_pausedInfo >> PAUSE_DEADLINE_OFFSET); + } + + /** + * @dev Triggers stopped state indefinitely. + * + * Requirements: + * + * - The contract must not be paused. + */ + function _pause() internal virtual whenNotPaused { + _pausedInfo = PAUSED; + emit Paused(_msgSender(), NO_DEADLINE); + } + + /** + * @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 { + if (unpauseDeadline > clock()) { + _pausedInfo = (uint256(unpauseDeadline) << PAUSE_DEADLINE_OFFSET) | PAUSED; + emit Paused(_msgSender(), unpauseDeadline); + } + } + + /** + * @dev Returns to normal state. + * + * Requirements: + * + * - The contract must be paused. + */ + function _unpause() internal virtual whenPaused { + _pausedInfo = UNPAUSED; + emit Unpaused(_msgSender()); + } +} diff --git a/test/utils/Pausable.test.js b/test/utils/Pausable.test.js new file mode 100644 index 0000000..04c9bf3 --- /dev/null +++ b/test/utils/Pausable.test.js @@ -0,0 +1,286 @@ +/* global network */ +const { ethers } = require('hardhat'); +const { expect } = require('chai'); +const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); + +async function fixture() { + const [pauser] = await ethers.getSigners(); + + const mock = await ethers.deployContract('PausableMock'); + + return { pauser, mock }; +} + +describe('Pausable', function () { + let pauseDuration = 10; + + beforeEach(async function () { + Object.assign(this, await loadFixture(fixture)); + }); + + describe('when unpaused', function () { + beforeEach(async function () { + expect(await this.mock.paused()).to.be.false; + }); + + it('can perform normal process in non-pause', async function () { + expect(await this.mock.count()).to.equal(0n); + + await this.mock.normalProcess(); + expect(await this.mock.count()).to.equal(1n); + }); + + it('cannot take drastic measure in non-pause', async function () { + await expect(this.mock.drasticMeasure()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); + + expect(await this.mock.drasticMeasureTaken()).to.be.false; + }); + + describe('when paused', function () { + beforeEach(async function () { + this.tx = await this.mock.pause(); + }); + + it('emits a Paused event', async function () { + await expect(this.tx).to.emit(this.mock, 'Paused').withArgs(this.pauser, 0); + }); + + it('does not set pause deadline duration', async function () { + expect(await this.mock.getPausedUntilDeadline()).to.equal(0); + }); + + it('cannot perform normal process in pause', async function () { + await expect(this.mock.normalProcess()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + }); + + it('can take a drastic measure in a pause', async function () { + await this.mock.drasticMeasure(); + expect(await this.mock.drasticMeasureTaken()).to.be.true; + }); + + it('reverts when re-pausing with pause', async function () { + await expect(this.mock.pause()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + }); + + it('reverts when re-pausing with pauseUntil', async function () { + await expect(this.mock.pauseUntil(pauseDuration)).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + }); + + describe('unpause', function () { + it('is unpausable by the pauser', async function () { + await this.mock.unpause(); + expect(await this.mock.paused()).to.be.false; + }); + + describe('when unpaused', function () { + beforeEach(async function () { + this.tx = await this.mock.unpause(); + }); + + it('emits an Unpaused event', async function () { + await expect(this.tx).to.emit(this.mock, 'Unpaused').withArgs(this.pauser); + }); + + it('does not set pause deadline duration', async function () { + expect(await this.mock.getPausedUntilDeadline()).to.equal(0); + }); + + it('should resume allowing normal process', async function () { + expect(await this.mock.count()).to.equal(0n); + await this.mock.normalProcess(); + expect(await this.mock.count()).to.equal(1n); + }); + + it('should prevent drastic measure', async function () { + await expect(this.mock.drasticMeasure()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); + }); + + it('reverts when re-unpausing with unpause', async function () { + await expect(this.mock.unpause()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); + }); + }); + }); + }); + + describe('when pausedUntil', function () { + beforeEach(async function () { + const [, executionTimestamp] = await this.mock.getPausedUntilDeadlineAndTimestamp(); + const pauseDeadline = parseInt(pauseDuration) + parseInt(executionTimestamp); + this.tx = await this.mock.pauseUntil(pauseDeadline); + }); + + it('emits a Paused event', async function () { + const [unpauseDeadline] = await this.mock.getPausedUntilDeadlineAndTimestamp(); + await expect(this.tx).to.emit(this.mock, 'Paused').withArgs(this.pauser, unpauseDeadline); + }); + + it('sets pause deadline and is equal to desired deadline', async function () { + const [unpauseDeadline, executionTimestamp] = await this.mock.getPausedUntilDeadlineAndTimestamp(); + expect(unpauseDeadline).to.not.equal(0); + // wee need to do -1 because in the before each the pauseUntil tx increases the timestamp by 1 + const expectedUnpauseDeadline = parseInt(pauseDuration) + parseInt(executionTimestamp) - 1; + const unpausedDeadlineInt = parseInt(unpauseDeadline); + expect(unpausedDeadlineInt).to.equal(expectedUnpauseDeadline); + }); + + it('cannot perform normal process in pause', async function () { + await expect(this.mock.normalProcess()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + }); + + it('can take a drastic measure in a pause', async function () { + await this.mock.drasticMeasure(); + expect(await this.mock.drasticMeasureTaken()).to.be.true; + }); + + it('reverts when re-pausing with pause', async function () { + await expect(this.mock.pause()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + }); + + it('reverts when re-pausing with pauseUntil', async function () { + // as it should revert we dont care in this test if pauseDuration is used instead of a deadline + await expect(this.mock.pauseUntil(pauseDuration)).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + // checking for pausing 0 seconds too + await expect(this.mock.pauseUntil(pauseDuration - pauseDuration)).to.be.revertedWithCustomError( + this.mock, + 'EnforcedPause', + ); + }); + + describe('unpaused', function () { + it('is unpausable by the pauser', async function () { + await this.mock.unpause(); + expect(await this.mock.paused()).to.be.false; + }); + + describe('before pause duration passed', function () { + beforeEach(async function () { + this.tx = await this.mock.unpause(); + }); + + it('emits an Unpaused event', async function () { + await expect(this.tx).to.emit(this.mock, 'Unpaused').withArgs(this.pauser); + }); + + it('does not set pause deadline', async function () { + expect(await this.mock.getPausedUntilDeadline()).to.equal(0); + }); + + it('should resume allowing normal process', async function () { + expect(await this.mock.count()).to.equal(0n); + await this.mock.normalProcess(); + expect(await this.mock.count()).to.equal(1n); + }); + + it('should prevent drastic measure', async function () { + await expect(this.mock.drasticMeasure()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); + }); + + it('reverts when re-unpausing with unpause', async function () { + await expect(this.mock.unpause()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); + }); + }); + + describe('after pause duration passed', function () { + beforeEach(async function () { + await network.provider.send('evm_increaseTime', [pauseDuration]); + await network.provider.send('evm_mine'); + }); + + it('reverts as contract automatically unpauses', async function () { + await expect(this.mock.unpause()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); + }); + }); + }); + + describe('paused after pause duration passed', function () { + beforeEach(async function () { + await network.provider.send('evm_increaseTime', [pauseDuration]); + await network.provider.send('evm_mine'); + this.tx = await this.mock.pause(); + }); + + it('emits a Paused event', async function () { + await expect(this.tx).to.emit(this.mock, 'Paused').withArgs(this.pauser, 0); + }); + + it('does not set pause deadline', async function () { + expect(await this.mock.getPausedUntilDeadline()).to.equal(0); + }); + + it('cannot perform normal process in pause', async function () { + await expect(this.mock.normalProcess()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + }); + + it('can take a drastic measure in a pause', async function () { + await this.mock.drasticMeasure(); + expect(await this.mock.drasticMeasureTaken()).to.be.true; + }); + + it('reverts when re-pausing with pause', async function () { + await expect(this.mock.pause()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + }); + + it('reverts when re-pausing with pauseUntil', async function () { + // as it should revert we dont care in this test if pauseDuration is used instead of a deadline + await expect(this.mock.pauseUntil(pauseDuration)).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + // checking for pausing 0 seconds too + await expect(this.mock.pauseUntil(pauseDuration - pauseDuration)).to.be.revertedWithCustomError( + this.mock, + 'EnforcedPause', + ); + }); + }); + + describe('pausedUntil after pause duration passed', function () { + beforeEach(async function () { + // Increase time and mine a block + await network.provider.send('evm_increaseTime', [pauseDuration]); + await network.provider.send('evm_mine'); + // Fetch the updated execution timestamp after mining + const [, executionTimestampAfter] = await this.mock.getPausedUntilDeadlineAndTimestamp(); + const pauseDeadline = parseInt(pauseDuration) + parseInt(executionTimestampAfter); + this.tx = await this.mock.pauseUntil(pauseDeadline); + }); + + it('emits a Paused event', async function () { + const [, executionTimestamp] = await this.mock.getPausedUntilDeadlineAndTimestamp(); + // wee need to do -1 because in the before each the pauseUntil tx increases the timestamp by 1 + const expectedUnpauseDeadline = parseInt(pauseDuration) + parseInt(executionTimestamp) - 1; + await expect(this.tx).to.emit(this.mock, 'Paused').withArgs(this.pauser, expectedUnpauseDeadline); + }); + + it('sets pause deadline and is equal to desired deadline', async function () { + const [unpauseDeadline, executionTimestamp] = await this.mock.getPausedUntilDeadlineAndTimestamp(); + expect(unpauseDeadline).to.not.equal(0); + // wee need to do -1 because in the before each the pauseUntil tx increases the timestamp by 1 + const expectedUnpauseDeadline = parseInt(pauseDuration) + parseInt(executionTimestamp) - 1; + const unpausedDeadlineInt = parseInt(unpauseDeadline); + expect(unpausedDeadlineInt).to.equal(expectedUnpauseDeadline); + }); + + it('cannot perform normal process in pause', async function () { + await expect(this.mock.normalProcess()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + }); + + it('can take a drastic measure in a pause', async function () { + await this.mock.drasticMeasure(); + expect(await this.mock.drasticMeasureTaken()).to.be.true; + }); + + it('reverts when re-pausing with pause', async function () { + await expect(this.mock.pause()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + }); + + it('reverts when re-pausing with pauseUntil', async function () { + // as it should revert we dont care in this test if pauseDuration is used instead of a deadline + await expect(this.mock.pauseUntil(pauseDuration)).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); + // checking for pausing 0 seconds too + await expect(this.mock.pauseUntil(pauseDuration - pauseDuration)).to.be.revertedWithCustomError( + this.mock, + 'EnforcedPause', + ); + }); + }); + }); + }); +}); From 588c681af5a4c60e9fc9cff57b43cdd200ef0d4c Mon Sep 17 00:00:00 2001 From: Hadrien Croubois Date: Mon, 6 Jan 2025 12:29:33 +0100 Subject: [PATCH 2/2] deep refactor: inherit @openzeppelin/contracts/utils/Pausable.sol --- contracts/mocks/PausableMock.sol | 45 +-- contracts/utils/PausableUntil.sol | 107 +++++++ .../utils/pausability/DefaultPausable.sol | 34 --- contracts/utils/pausability/Pausable.sol | 201 ------------ test/utils/Pausable.test.js | 286 ------------------ test/utils/PausableUntil.test.js | 134 ++++++++ 6 files changed, 251 insertions(+), 556 deletions(-) create mode 100644 contracts/utils/PausableUntil.sol delete mode 100644 contracts/utils/pausability/DefaultPausable.sol delete mode 100644 contracts/utils/pausability/Pausable.sol delete mode 100644 test/utils/Pausable.test.js create mode 100644 test/utils/PausableUntil.test.js diff --git a/contracts/mocks/PausableMock.sol b/contracts/mocks/PausableMock.sol index 5f35e57..7b4eeed 100644 --- a/contracts/mocks/PausableMock.sol +++ b/contracts/mocks/PausableMock.sol @@ -2,44 +2,19 @@ pragma solidity ^0.8.20; -import {DefaultPausable} from "../utils/pausability/DefaultPausable.sol"; +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import {PausableUntil} from "../utils/PausableUntil.sol"; -contract PausableMock is DefaultPausable { - // solhint-disable-next-line openzeppelin/private-variables - bool public drasticMeasureTaken; - // solhint-disable-next-line openzeppelin/private-variables - uint256 public count; - - constructor() { - drasticMeasureTaken = false; - count = 0; - } - - function normalProcess() external whenNotPaused { - count++; - } - - function drasticMeasure() external whenPaused { - drasticMeasureTaken = true; - } - - function pause() external { - _pause(); - } - - function unpause() external { - _unpause(); +abstract contract PausableUntilMock is PausableUntil { + function clock() public view virtual override returns (uint48) { + return SafeCast.toUint48(block.timestamp); } - function pauseUntil(uint256 duration) external { - _pauseUntil(uint48(duration)); + // solhint-disable-next-line func-name-mixedcase + function CLOCK_MODE() public view virtual override returns (string memory) { + return "mode=timestamp"; } - function getPausedUntilDeadline() external view returns (uint256) { - return _unpauseDeadline(); - } - - function getPausedUntilDeadlineAndTimestamp() external view returns (uint256, uint256) { - return (_unpauseDeadline(), clock()); - } + 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 0000000..fec812b --- /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/contracts/utils/pausability/DefaultPausable.sol b/contracts/utils/pausability/DefaultPausable.sol deleted file mode 100644 index d3f9d12..0000000 --- a/contracts/utils/pausability/DefaultPausable.sol +++ /dev/null @@ -1,34 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.0.0) (utils/Pausable.sol) - -pragma solidity ^0.8.20; - -import {Pausable} from "./Pausable.sol"; -import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; - -/** - * @title DefaultPausable - * @author @CarlosAlegreUr - * - * @dev A default implementation of {Pausable} that uses a `block.timestamp` based `clock()`. - */ -abstract contract DefaultPausable is Pausable { - /** - * @dev Clock is used here for time checkings on pauses with defined end-date. - * - * @dev IERC6372 implementation of a clock() based on native `block.timestamp`. - */ - function clock() public view virtual override returns (uint48) { - return SafeCast.toUint48(block.timestamp); - } - - /** - * @dev IERC6372 implementation of a CLOCK_MODE() based on timestamp. - * - * Override this function to implement a different clock mode, if so must be done following {IERC6372} specification. - */ - // solhint-disable-next-line func-name-mixedcase - function CLOCK_MODE() public view virtual override returns (string memory) { - return "mode=timestamp"; - } -} diff --git a/contracts/utils/pausability/Pausable.sol b/contracts/utils/pausability/Pausable.sol deleted file mode 100644 index a7353c7..0000000 --- a/contracts/utils/pausability/Pausable.sol +++ /dev/null @@ -1,201 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.0.0) (utils/Pausable.sol) - -pragma solidity ^0.8.20; - -import {Context} from "@openzeppelin/contracts/utils/Context.sol"; -import {IERC6372} from "@openzeppelin/contracts/interfaces/IERC6372.sol"; - -uint8 constant PAUSED = 1; -uint8 constant UNPAUSED = 0; -uint48 constant NO_DEADLINE = 0; -uint8 constant PAUSE_DEADLINE_OFFSET = 8; - -/** - * @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 Pausable is Context, 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. - */ - uint256 private _pausedInfo; - - /** - * @dev Emitted when the pause is triggered by `account`. `unpauseDeadline` is 0 if the pause is indefinite. - */ - event Paused(address account, uint48 unpauseDeadline); - - /** - * @dev Emitted when the pause is lifted by `account`. - */ - event Unpaused(address account); - - /** - * @dev The operation failed because the contract is paused. - */ - error EnforcedPause(); - - /** - * @dev The operation failed because the contract is not paused. - */ - error ExpectedPause(); - - /** - * @dev Initializes the contract in unpaused state. - */ - constructor() { - _pausedInfo = UNPAUSED; - } - - /** - * @dev Modifier to make a function callable only when the contract is not paused. - * - * Requirements: - * - * - The contract must not be paused. - */ - modifier whenNotPaused() { - _requireNotPaused(); - _; - } - - /** - * @dev Modifier to make a function callable only when the contract is paused. - * - * Requirements: - * - * - The contract must be paused. - */ - modifier whenPaused() { - _requirePaused(); - _; - } - - /** - * @dev Clock is used here for time checkings on pauses with defined end-date. - * - * Override this function to implement a customed clock, if so must be done following - * {IERC6372} specification. - * - * Default native `block.timetmap` clock implementation can be found at {DefaultPausable}. - */ - function clock() public view virtual returns (uint48); - - /** - * @dev IERC6372 implementation of a CLOCK_MODE(). - */ - // solhint-disable-next-line func-name-mixedcase - function CLOCK_MODE() public view virtual returns (string memory); - - /** - * @dev Returns true if the contract is paused, and false otherwise. - * - * A contract is paused (returns true) if: - * - * - It was paused by `_pause()` - * - Or if it was paused by `_pauseUntil(uint256 unpauseDeadline)` and `unpauseDeadline` - * is still in the future. - */ - function paused() public view virtual returns (bool) { - uint48 unpauseDeadline = _unpauseDeadline(); - return _pausedInfo == PAUSED || (unpauseDeadline != 0 && clock() < unpauseDeadline); - } - - /** - * @dev Throws if the contract is paused. - */ - function _requireNotPaused() internal view virtual { - if (paused()) { - revert EnforcedPause(); - } - } - - /** - * @dev Throws if the contract is not paused. - */ - function _requirePaused() internal view virtual { - if (!paused()) { - revert ExpectedPause(); - } - } - - /** - * @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 uint48(_pausedInfo >> PAUSE_DEADLINE_OFFSET); - } - - /** - * @dev Triggers stopped state indefinitely. - * - * Requirements: - * - * - The contract must not be paused. - */ - function _pause() internal virtual whenNotPaused { - _pausedInfo = PAUSED; - emit Paused(_msgSender(), NO_DEADLINE); - } - - /** - * @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 { - if (unpauseDeadline > clock()) { - _pausedInfo = (uint256(unpauseDeadline) << PAUSE_DEADLINE_OFFSET) | PAUSED; - emit Paused(_msgSender(), unpauseDeadline); - } - } - - /** - * @dev Returns to normal state. - * - * Requirements: - * - * - The contract must be paused. - */ - function _unpause() internal virtual whenPaused { - _pausedInfo = UNPAUSED; - emit Unpaused(_msgSender()); - } -} diff --git a/test/utils/Pausable.test.js b/test/utils/Pausable.test.js deleted file mode 100644 index 04c9bf3..0000000 --- a/test/utils/Pausable.test.js +++ /dev/null @@ -1,286 +0,0 @@ -/* global network */ -const { ethers } = require('hardhat'); -const { expect } = require('chai'); -const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers'); - -async function fixture() { - const [pauser] = await ethers.getSigners(); - - const mock = await ethers.deployContract('PausableMock'); - - return { pauser, mock }; -} - -describe('Pausable', function () { - let pauseDuration = 10; - - beforeEach(async function () { - Object.assign(this, await loadFixture(fixture)); - }); - - describe('when unpaused', function () { - beforeEach(async function () { - expect(await this.mock.paused()).to.be.false; - }); - - it('can perform normal process in non-pause', async function () { - expect(await this.mock.count()).to.equal(0n); - - await this.mock.normalProcess(); - expect(await this.mock.count()).to.equal(1n); - }); - - it('cannot take drastic measure in non-pause', async function () { - await expect(this.mock.drasticMeasure()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); - - expect(await this.mock.drasticMeasureTaken()).to.be.false; - }); - - describe('when paused', function () { - beforeEach(async function () { - this.tx = await this.mock.pause(); - }); - - it('emits a Paused event', async function () { - await expect(this.tx).to.emit(this.mock, 'Paused').withArgs(this.pauser, 0); - }); - - it('does not set pause deadline duration', async function () { - expect(await this.mock.getPausedUntilDeadline()).to.equal(0); - }); - - it('cannot perform normal process in pause', async function () { - await expect(this.mock.normalProcess()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); - }); - - it('can take a drastic measure in a pause', async function () { - await this.mock.drasticMeasure(); - expect(await this.mock.drasticMeasureTaken()).to.be.true; - }); - - it('reverts when re-pausing with pause', async function () { - await expect(this.mock.pause()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); - }); - - it('reverts when re-pausing with pauseUntil', async function () { - await expect(this.mock.pauseUntil(pauseDuration)).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); - }); - - describe('unpause', function () { - it('is unpausable by the pauser', async function () { - await this.mock.unpause(); - expect(await this.mock.paused()).to.be.false; - }); - - describe('when unpaused', function () { - beforeEach(async function () { - this.tx = await this.mock.unpause(); - }); - - it('emits an Unpaused event', async function () { - await expect(this.tx).to.emit(this.mock, 'Unpaused').withArgs(this.pauser); - }); - - it('does not set pause deadline duration', async function () { - expect(await this.mock.getPausedUntilDeadline()).to.equal(0); - }); - - it('should resume allowing normal process', async function () { - expect(await this.mock.count()).to.equal(0n); - await this.mock.normalProcess(); - expect(await this.mock.count()).to.equal(1n); - }); - - it('should prevent drastic measure', async function () { - await expect(this.mock.drasticMeasure()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); - }); - - it('reverts when re-unpausing with unpause', async function () { - await expect(this.mock.unpause()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); - }); - }); - }); - }); - - describe('when pausedUntil', function () { - beforeEach(async function () { - const [, executionTimestamp] = await this.mock.getPausedUntilDeadlineAndTimestamp(); - const pauseDeadline = parseInt(pauseDuration) + parseInt(executionTimestamp); - this.tx = await this.mock.pauseUntil(pauseDeadline); - }); - - it('emits a Paused event', async function () { - const [unpauseDeadline] = await this.mock.getPausedUntilDeadlineAndTimestamp(); - await expect(this.tx).to.emit(this.mock, 'Paused').withArgs(this.pauser, unpauseDeadline); - }); - - it('sets pause deadline and is equal to desired deadline', async function () { - const [unpauseDeadline, executionTimestamp] = await this.mock.getPausedUntilDeadlineAndTimestamp(); - expect(unpauseDeadline).to.not.equal(0); - // wee need to do -1 because in the before each the pauseUntil tx increases the timestamp by 1 - const expectedUnpauseDeadline = parseInt(pauseDuration) + parseInt(executionTimestamp) - 1; - const unpausedDeadlineInt = parseInt(unpauseDeadline); - expect(unpausedDeadlineInt).to.equal(expectedUnpauseDeadline); - }); - - it('cannot perform normal process in pause', async function () { - await expect(this.mock.normalProcess()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); - }); - - it('can take a drastic measure in a pause', async function () { - await this.mock.drasticMeasure(); - expect(await this.mock.drasticMeasureTaken()).to.be.true; - }); - - it('reverts when re-pausing with pause', async function () { - await expect(this.mock.pause()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); - }); - - it('reverts when re-pausing with pauseUntil', async function () { - // as it should revert we dont care in this test if pauseDuration is used instead of a deadline - await expect(this.mock.pauseUntil(pauseDuration)).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); - // checking for pausing 0 seconds too - await expect(this.mock.pauseUntil(pauseDuration - pauseDuration)).to.be.revertedWithCustomError( - this.mock, - 'EnforcedPause', - ); - }); - - describe('unpaused', function () { - it('is unpausable by the pauser', async function () { - await this.mock.unpause(); - expect(await this.mock.paused()).to.be.false; - }); - - describe('before pause duration passed', function () { - beforeEach(async function () { - this.tx = await this.mock.unpause(); - }); - - it('emits an Unpaused event', async function () { - await expect(this.tx).to.emit(this.mock, 'Unpaused').withArgs(this.pauser); - }); - - it('does not set pause deadline', async function () { - expect(await this.mock.getPausedUntilDeadline()).to.equal(0); - }); - - it('should resume allowing normal process', async function () { - expect(await this.mock.count()).to.equal(0n); - await this.mock.normalProcess(); - expect(await this.mock.count()).to.equal(1n); - }); - - it('should prevent drastic measure', async function () { - await expect(this.mock.drasticMeasure()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); - }); - - it('reverts when re-unpausing with unpause', async function () { - await expect(this.mock.unpause()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); - }); - }); - - describe('after pause duration passed', function () { - beforeEach(async function () { - await network.provider.send('evm_increaseTime', [pauseDuration]); - await network.provider.send('evm_mine'); - }); - - it('reverts as contract automatically unpauses', async function () { - await expect(this.mock.unpause()).to.be.revertedWithCustomError(this.mock, 'ExpectedPause'); - }); - }); - }); - - describe('paused after pause duration passed', function () { - beforeEach(async function () { - await network.provider.send('evm_increaseTime', [pauseDuration]); - await network.provider.send('evm_mine'); - this.tx = await this.mock.pause(); - }); - - it('emits a Paused event', async function () { - await expect(this.tx).to.emit(this.mock, 'Paused').withArgs(this.pauser, 0); - }); - - it('does not set pause deadline', async function () { - expect(await this.mock.getPausedUntilDeadline()).to.equal(0); - }); - - it('cannot perform normal process in pause', async function () { - await expect(this.mock.normalProcess()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); - }); - - it('can take a drastic measure in a pause', async function () { - await this.mock.drasticMeasure(); - expect(await this.mock.drasticMeasureTaken()).to.be.true; - }); - - it('reverts when re-pausing with pause', async function () { - await expect(this.mock.pause()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); - }); - - it('reverts when re-pausing with pauseUntil', async function () { - // as it should revert we dont care in this test if pauseDuration is used instead of a deadline - await expect(this.mock.pauseUntil(pauseDuration)).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); - // checking for pausing 0 seconds too - await expect(this.mock.pauseUntil(pauseDuration - pauseDuration)).to.be.revertedWithCustomError( - this.mock, - 'EnforcedPause', - ); - }); - }); - - describe('pausedUntil after pause duration passed', function () { - beforeEach(async function () { - // Increase time and mine a block - await network.provider.send('evm_increaseTime', [pauseDuration]); - await network.provider.send('evm_mine'); - // Fetch the updated execution timestamp after mining - const [, executionTimestampAfter] = await this.mock.getPausedUntilDeadlineAndTimestamp(); - const pauseDeadline = parseInt(pauseDuration) + parseInt(executionTimestampAfter); - this.tx = await this.mock.pauseUntil(pauseDeadline); - }); - - it('emits a Paused event', async function () { - const [, executionTimestamp] = await this.mock.getPausedUntilDeadlineAndTimestamp(); - // wee need to do -1 because in the before each the pauseUntil tx increases the timestamp by 1 - const expectedUnpauseDeadline = parseInt(pauseDuration) + parseInt(executionTimestamp) - 1; - await expect(this.tx).to.emit(this.mock, 'Paused').withArgs(this.pauser, expectedUnpauseDeadline); - }); - - it('sets pause deadline and is equal to desired deadline', async function () { - const [unpauseDeadline, executionTimestamp] = await this.mock.getPausedUntilDeadlineAndTimestamp(); - expect(unpauseDeadline).to.not.equal(0); - // wee need to do -1 because in the before each the pauseUntil tx increases the timestamp by 1 - const expectedUnpauseDeadline = parseInt(pauseDuration) + parseInt(executionTimestamp) - 1; - const unpausedDeadlineInt = parseInt(unpauseDeadline); - expect(unpausedDeadlineInt).to.equal(expectedUnpauseDeadline); - }); - - it('cannot perform normal process in pause', async function () { - await expect(this.mock.normalProcess()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); - }); - - it('can take a drastic measure in a pause', async function () { - await this.mock.drasticMeasure(); - expect(await this.mock.drasticMeasureTaken()).to.be.true; - }); - - it('reverts when re-pausing with pause', async function () { - await expect(this.mock.pause()).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); - }); - - it('reverts when re-pausing with pauseUntil', async function () { - // as it should revert we dont care in this test if pauseDuration is used instead of a deadline - await expect(this.mock.pauseUntil(pauseDuration)).to.be.revertedWithCustomError(this.mock, 'EnforcedPause'); - // checking for pausing 0 seconds too - await expect(this.mock.pauseUntil(pauseDuration - pauseDuration)).to.be.revertedWithCustomError( - this.mock, - 'EnforcedPause', - ); - }); - }); - }); - }); -}); diff --git a/test/utils/PausableUntil.test.js b/test/utils/PausableUntil.test.js new file mode 100644 index 0000000..44496d3 --- /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); + }); + }); + }); +});