From 43b25da30561eb2f1ad896233b9dc280c1dde837 Mon Sep 17 00:00:00 2001 From: CarlosAlegreUr Date: Mon, 30 Dec 2024 12:07:05 +0100 Subject: [PATCH] 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', + ); + }); + }); + }); + }); +});