From fb8c65121732a0384d8a8d451e995db5629ecf0f Mon Sep 17 00:00:00 2001 From: Facu Spagnuolo Date: Tue, 3 Oct 2023 07:12:43 -0300 Subject: [PATCH] tasks: implement new time lock modes --- .../contracts/AuthorizedHelpers.sol | 8 + packages/authorizer/package.json | 2 +- .../tasks/contracts/base/TimeLockedTask.sol | 269 ++++-- .../interfaces/base/ITimeLockedTask.sol | 58 +- .../interfaces/swap/IOneInchV5Swapper.sol | 2 +- .../interfaces/swap/IUniswapV2Swapper.sol | 2 +- .../interfaces/swap/IUniswapV3Swapper.sol | 2 +- packages/tasks/package.json | 5 +- .../tasks/test/base/TimeLockedTask.test.ts | 774 ++++++++++-------- yarn.lock | 18 + 10 files changed, 708 insertions(+), 432 deletions(-) diff --git a/packages/authorizer/contracts/AuthorizedHelpers.sol b/packages/authorizer/contracts/AuthorizedHelpers.sol index 04bf69bc..7a8a2ed6 100644 --- a/packages/authorizer/contracts/AuthorizedHelpers.sol +++ b/packages/authorizer/contracts/AuthorizedHelpers.sol @@ -84,6 +84,14 @@ contract AuthorizedHelpers { r[2] = p3; } + function authParams(uint256 p1, uint256 p2, uint256 p3, uint256 p4) internal pure returns (uint256[] memory r) { + r = new uint256[](4); + r[0] = p1; + r[1] = p2; + r[2] = p3; + r[3] = p4; + } + function authParams(address p1, address p2, uint256 p3, uint256 p4) internal pure returns (uint256[] memory r) { r = new uint256[](4); r[0] = uint256(uint160(p1)); diff --git a/packages/authorizer/package.json b/packages/authorizer/package.json index 061a56a1..92848b3d 100644 --- a/packages/authorizer/package.json +++ b/packages/authorizer/package.json @@ -1,6 +1,6 @@ { "name": "@mimic-fi/v3-authorizer", - "version": "0.1.0", + "version": "0.1.1", "license": "GPL-3.0", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/tasks/contracts/base/TimeLockedTask.sol b/packages/tasks/contracts/base/TimeLockedTask.sol index 0f20ad04..f908cbae 100644 --- a/packages/tasks/contracts/base/TimeLockedTask.sol +++ b/packages/tasks/contracts/base/TimeLockedTask.sol @@ -14,32 +14,60 @@ pragma solidity ^0.8.3; +import '@quant-finance/solidity-datetime/contracts/DateTime.sol'; import '@mimic-fi/v3-authorizer/contracts/Authorized.sol'; + import '../interfaces/base/ITimeLockedTask.sol'; /** * @dev Time lock config for tasks. It allows limiting the frequency of a task. */ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { - // Period in seconds that must pass after a task has been executed - uint256 public override timeLockDelay; + using DateTime for uint256; + + uint256 private constant DAYS_28 = 60 * 60 * 24 * 28; + + /** + * @dev Time-locks supports different frequency modes + * @param Seconds To indicate the execution must occur every certain number of seconds + * @param OnDay To indicate the execution must occur on day number from 1 to 28 + * @param OnLastMonthDay To indicate the execution must occur on the last day of the month + * @param EverySomeMonths To indicate the execution must occur every certain number of months + */ + enum Mode { + Seconds, + OnDay, + OnLastMonthDay, + EverySomeMonths + } + + // Time lock mode + Mode internal _mode; + + // Time lock frequency + uint256 internal _frequency; - // Future timestamp in which the task can be executed - uint256 public override timeLockExpiration; + // Future timestamp since when the task can be executed + uint256 internal _allowedAt; - // Period in seconds during when a time-locked task can be executed right after it becomes executable - uint256 public override timeLockExecutionPeriod; + // Next future timestamp since when the task can be executed to be set, only used internally + uint256 internal _nextAllowedAt; + + // Period in seconds during when a time-locked task can be executed since the allowed timestamp + uint256 internal _window; /** * @dev Time lock config params. Only used in the initializer. - * @param delay Period in seconds that must pass after a task has been executed - * @param nextExecutionTimestamp Next time when the task can be executed - * @param executionPeriod Period in seconds during when a time-locked task can be executed + * @param mode Time lock mode + * @param frequency Time lock frequency value + * @param allowedAt Time lock allowed date + * @param window Time lock execution window */ struct TimeLockConfig { - uint256 delay; - uint256 nextExecutionTimestamp; - uint256 executionPeriod; + uint8 mode; + uint256 frequency; + uint256 allowedAt; + uint256 window; } /** @@ -55,100 +83,203 @@ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { * @param config Time locked task config */ function __TimeLockedTask_init_unchained(TimeLockConfig memory config) internal onlyInitializing { - _setTimeLockDelay(config.delay); - _setTimeLockExpiration(config.nextExecutionTimestamp); - _setTimeLockExecutionPeriod(config.executionPeriod); + _setTimeLock(config.mode, config.frequency, config.allowedAt, config.window); } /** - * @dev Sets the time-lock delay - * @param delay New delay to be set + * @dev Tells the time-lock related information */ - function setTimeLockDelay(uint256 delay) external override authP(authParams(delay)) { - _setTimeLockDelay(delay); + function getTimeLock() external view returns (uint8 mode, uint256 frequency, uint256 allowedAt, uint256 window) { + return (uint8(_mode), _frequency, _allowedAt, _window); } /** - * @dev Sets the time-lock expiration timestamp - * @param expiration New expiration timestamp to be set + * @dev Sets a new time lock */ - function setTimeLockExpiration(uint256 expiration) external override authP(authParams(expiration)) { - _setTimeLockExpiration(expiration); + function setTimeLock(uint8 mode, uint256 frequency, uint256 allowedAt, uint256 window) + external + override + authP(authParams(mode, frequency, allowedAt, window)) + { + _setTimeLock(mode, frequency, allowedAt, window); } /** - * @dev Sets the time-lock execution period - * @param period New execution period to be set + * @dev Before time locked task hook */ - function setTimeLockExecutionPeriod(uint256 period) external override authP(authParams(period)) { - _setTimeLockExecutionPeriod(period); + function _beforeTimeLockedTask(address, uint256) internal virtual { + // Load storage variables + Mode mode = _mode; + uint256 frequency = _frequency; + uint256 allowedAt = _allowedAt; + uint256 window = _window; + + // First we check the current timestamp is not in the past + if (block.timestamp < allowedAt) revert TaskTimeLockActive(block.timestamp, allowedAt); + + if (mode == Mode.Seconds && frequency > 0) { + // If no window is set, the next allowed date is simply moved the number of seconds set as frequency. + // Otherwise, the offset must be validated and the next allowed date is set to the next period. + if (window == 0) _nextAllowedAt = block.timestamp + frequency; + else { + uint256 diff = block.timestamp - allowedAt; + uint256 periods = diff / frequency; + uint256 offset = diff - (periods * frequency); + if (offset > window) revert TaskTimeLockActive(block.timestamp, allowedAt); + _nextAllowedAt = allowedAt + ((periods + 1) * frequency); + } + } else { + uint256 day; + uint256 monthsToAdd; + + if (mode == Mode.EverySomeMonths) { + // Check the difference in months matches the frequency value + uint256 diff = allowedAt.diffMonths(block.timestamp); + if (diff % frequency != 0) revert TaskTimeLockActive(block.timestamp, allowedAt); + day = allowedAt.getDay(); + monthsToAdd = frequency; + } else { + if (mode == Mode.OnDay) { + day = allowedAt.getDay(); + } else if (mode == Mode.OnLastMonthDay) { + day = block.timestamp.getDaysInMonth(); + } else { + revert TaskInvalidFrequencyMode(uint8(mode)); + } + + // Check the current day matches the one in the configuration + if (block.timestamp.getDay() != day) revert TaskTimeLockActive(block.timestamp, allowedAt); + monthsToAdd = 1; + } + + // Construct when would be the current allowed timestamp only considering the current month and year + uint256 currentAllowedAt = _getCurrentAllowedDateForMonthlyRelativeFrequency(allowedAt, day); + + // Since we already checked the current timestamp is not before the allowed timestamp set, + // we simply need to check we are withing the allowed execution window + if (block.timestamp - currentAllowedAt > window) revert TaskTimeLockActive(block.timestamp, allowedAt); + + // Finally set the next allowed date to the corresponding number of months from the current date + _nextAllowedAt = _getNextAllowedDateForMonthlyRelativeFrequency(currentAllowedAt, monthsToAdd); + } } /** - * @dev Tells the number of delay periods passed between the last expiration timestamp and the current timestamp + * @dev After time locked task hook */ - function _getDelayPeriods() internal view returns (uint256) { - uint256 diff = block.timestamp - timeLockExpiration; - return diff / timeLockDelay; + function _afterTimeLockedTask(address, uint256) internal virtual { + if (_nextAllowedAt == 0) return; + _setTimeLockAllowedAt(_nextAllowedAt); + _nextAllowedAt = 0; } /** - * @dev Before time locked task hook + * @dev Sets a new time lock */ - function _beforeTimeLockedTask(address, uint256) internal virtual { - if (block.timestamp < timeLockExpiration) revert TaskTimeLockNotExpired(timeLockExpiration, block.timestamp); + function _setTimeLock(uint8 mode, uint256 frequency, uint256 allowedAt, uint256 window) internal { + if (mode == uint8(Mode.Seconds)) { + // The execution window and timestamp are optional, but both must be given or none + // If given the execution window cannot be larger than the number of seconds + // Also, if these are given the frequency must be checked as well, otherwise it could be unsetting the lock + if (window > 0 || allowedAt > 0) { + if (frequency == 0) revert TaskInvalidFrequency(mode, frequency); + if (window == 0 || window > frequency) revert TaskInvalidAllowedWindow(mode, window); + if (allowedAt == 0) revert TaskInvalidAllowedDate(mode, allowedAt); + } + } else if (mode == uint8(Mode.OnDay)) { + // It must be valid for every month, then the frequency value cannot be larger than 28 days + if (frequency == 0 || frequency > 28) revert TaskInvalidFrequency(mode, frequency); + + // The execution window that cannot be larger than 28 days + if (window == 0 || window > DAYS_28) revert TaskInvalidAllowedWindow(mode, window); + + // The allowed date must match the specified frequency value + if (allowedAt == 0 || allowedAt.getDay() != frequency) revert TaskInvalidAllowedDate(mode, allowedAt); + } else if (mode == uint8(Mode.OnLastMonthDay)) { + // There must be no frequency value in this case + if (frequency != 0) revert TaskInvalidFrequency(mode, frequency); + + // The execution window that cannot be larger than 28 days + if (window == 0 || window > DAYS_28) revert TaskInvalidAllowedWindow(mode, window); - if (timeLockExecutionPeriod > 0) { - uint256 diff = block.timestamp - timeLockExpiration; - uint256 periods = diff / timeLockDelay; - uint256 offset = diff - (periods * timeLockDelay); - if (offset > timeLockExecutionPeriod) revert TaskTimeLockWaitNextPeriod(offset, timeLockExecutionPeriod); + // The allowed date timestamp must be the last day of the month + if (allowedAt == 0) revert TaskInvalidAllowedDate(mode, allowedAt); + if (allowedAt.getDay() != allowedAt.getDaysInMonth()) revert TaskInvalidAllowedDate(mode, allowedAt); + } else if (mode == uint8(Mode.EverySomeMonths)) { + // There is no limit on the number of months + if (frequency == 0) revert TaskInvalidFrequency(mode, frequency); + + // The execution window cannot be larger than the number of months considering months of 28 days + if (window == 0 || window > frequency * DAYS_28) revert TaskInvalidAllowedWindow(mode, window); + + // The execution allowed at timestamp and the day cannot be greater than the 28th + if (allowedAt == 0 || allowedAt.getDay() > 28) revert TaskInvalidAllowedDate(mode, allowedAt); + } else { + revert TaskInvalidFrequencyMode(mode); } + + _mode = Mode(mode); + _frequency = frequency; + _allowedAt = allowedAt; + _window = window; + + emit TimeLockSet(mode, frequency, allowedAt, window); } /** - * @dev After time locked task hook + * @dev Sets the time-lock execution allowed timestamp + * @param allowedAt New execution allowed timestamp to be set */ - function _afterTimeLockedTask(address, uint256) internal virtual { - if (timeLockDelay > 0) { - uint256 nextExpirationTimestamp; - if (timeLockExpiration == 0) { - nextExpirationTimestamp = block.timestamp + timeLockDelay; - } else { - uint256 diff = block.timestamp - timeLockExpiration; - uint256 nextPeriod = (diff / timeLockDelay) + 1; - nextExpirationTimestamp = timeLockExpiration + (nextPeriod * timeLockDelay); - } - _setTimeLockExpiration(nextExpirationTimestamp); - } + function _setTimeLockAllowedAt(uint256 allowedAt) internal { + _allowedAt = allowedAt; + emit TimeLockAllowedAtSet(allowedAt); } /** - * @dev Sets the time-lock delay - * @param delay New delay to be set + * @dev Tells the allowed date based on a current allowed date considering the current timestamp and a specific day. + * It builds a new date using the current timestamp's month and year, following by the specified day, and using + * the current allowed date hours, minutes, and seconds. */ - function _setTimeLockDelay(uint256 delay) internal { - if (delay < timeLockExecutionPeriod) revert TaskExecutionPeriodGtDelay(timeLockExecutionPeriod, delay); - timeLockDelay = delay; - emit TimeLockDelaySet(delay); + function _getCurrentAllowedDateForMonthlyRelativeFrequency(uint256 allowedAt, uint256 day) + private + view + returns (uint256) + { + (uint256 year, uint256 month, ) = block.timestamp.timestampToDate(); + return _getAllowedDateFor(allowedAt, year, month, day); } /** - * @dev Sets the time-lock expiration timestamp - * @param expiration New expiration timestamp to be set + * @dev Tells the next allowed date based on a current allowed date considering a number of months to increase */ - function _setTimeLockExpiration(uint256 expiration) internal { - timeLockExpiration = expiration; - emit TimeLockExpirationSet(expiration); + function _getNextAllowedDateForMonthlyRelativeFrequency(uint256 allowedAt, uint256 monthsToIncrease) + private + view + returns (uint256) + { + (uint256 year, uint256 month, uint256 day) = allowedAt.timestampToDate(); + uint256 nextMonth = month + (monthsToIncrease % 12); + uint256 nextYear = nextMonth > month ? year : (year + 1); + uint256 nextDay = _mode == Mode.OnLastMonthDay ? DateTime._getDaysInMonth(nextYear, nextMonth) : day; + return _getAllowedDateFor(allowedAt, nextYear, nextMonth, nextDay); } /** - * @dev Sets the time-lock execution period - * @param period New execution period to be set + * @dev Builds an allowed date using a specific year, month, and day */ - function _setTimeLockExecutionPeriod(uint256 period) internal { - if (period > timeLockDelay) revert TaskExecutionPeriodGtDelay(period, timeLockDelay); - timeLockExecutionPeriod = period; - emit TimeLockExecutionPeriodSet(period); + function _getAllowedDateFor(uint256 allowedAt, uint256 year, uint256 month, uint256 day) + private + pure + returns (uint256) + { + return + DateTime.timestampFromDateTime( + year, + month, + day, + allowedAt.getHour(), + allowedAt.getMinute(), + allowedAt.getSecond() + ); } } diff --git a/packages/tasks/contracts/interfaces/base/ITimeLockedTask.sol b/packages/tasks/contracts/interfaces/base/ITimeLockedTask.sol index 9c0a228f..69016b44 100644 --- a/packages/tasks/contracts/interfaces/base/ITimeLockedTask.sol +++ b/packages/tasks/contracts/interfaces/base/ITimeLockedTask.sol @@ -21,65 +21,51 @@ import './IBaseTask.sol'; */ interface ITimeLockedTask is IBaseTask { /** - * @dev The time-lock has not expired + * @dev The time lock frequency mode requested is invalid */ - error TaskTimeLockNotExpired(uint256 expiration, uint256 currentTimestamp); + error TaskInvalidFrequencyMode(uint8 mode); /** - * @dev The execution period has expired + * @dev The time lock frequency is not valid */ - error TaskTimeLockWaitNextPeriod(uint256 offset, uint256 executionPeriod); + error TaskInvalidFrequency(uint8 mode, uint256 frequency); /** - * @dev The execution period is greater than the time-lock delay + * @dev The time lock allowed date is not valid */ - error TaskExecutionPeriodGtDelay(uint256 executionPeriod, uint256 delay); + error TaskInvalidAllowedDate(uint8 mode, uint256 date); /** - * @dev Emitted every time a new time-lock delay is set + * @dev The time lock allowed window is not valid */ - event TimeLockDelaySet(uint256 delay); + error TaskInvalidAllowedWindow(uint8 mode, uint256 window); /** - * @dev Emitted every time a new expiration timestamp is set - */ - event TimeLockExpirationSet(uint256 expiration); - - /** - * @dev Emitted every time a new execution period is set + * @dev The time lock is still active */ - event TimeLockExecutionPeriodSet(uint256 period); + error TaskTimeLockActive(uint256 currentTimestamp, uint256 expiration); /** - * @dev Tells the time-lock delay in seconds + * @dev Emitted every time a new time lock is set */ - function timeLockDelay() external view returns (uint256); + event TimeLockSet(uint8 mode, uint256 frequency, uint256 allowedAt, uint256 window); /** - * @dev Tells the time-lock expiration timestamp - */ - function timeLockExpiration() external view returns (uint256); - - /** - * @dev Tells the time-lock execution period - */ - function timeLockExecutionPeriod() external view returns (uint256); - - /** - * @dev Sets the time-lock delay - * @param delay New delay to be set + * @dev Emitted every time a new expiration timestamp is set */ - function setTimeLockDelay(uint256 delay) external; + event TimeLockAllowedAtSet(uint256 allowedAt); /** - * @dev Sets the time-lock expiration timestamp - * @param expiration New expiration timestamp to be set + * @dev Tells all the time-lock related information */ - function setTimeLockExpiration(uint256 expiration) external; + function getTimeLock() external view returns (uint8 mode, uint256 frequency, uint256 allowedAt, uint256 window); /** - * @dev Sets the time-lock execution period - * @param period New execution period to be set + * @dev Sets the time-lock + * @param mode Time lock mode + * @param frequency Time lock frequency + * @param allowedAt Future timestamp since when the task can be executed + * @param window Period in seconds during when a time-locked task can be executed since the allowed timestamp */ - function setTimeLockExecutionPeriod(uint256 period) external; + function setTimeLock(uint8 mode, uint256 frequency, uint256 allowedAt, uint256 window) external; } diff --git a/packages/tasks/contracts/interfaces/swap/IOneInchV5Swapper.sol b/packages/tasks/contracts/interfaces/swap/IOneInchV5Swapper.sol index e3833c96..6fe6bf2b 100644 --- a/packages/tasks/contracts/interfaces/swap/IOneInchV5Swapper.sol +++ b/packages/tasks/contracts/interfaces/swap/IOneInchV5Swapper.sol @@ -23,5 +23,5 @@ interface IOneInchV5Swapper is IBaseSwapTask { /** * @dev Execution function */ - function call(address tokenIn, uint256 amountIn, uint256 minAmountOut, bytes memory data) external; + function call(address tokenIn, uint256 amountIn, uint256 slippage, bytes memory data) external; } diff --git a/packages/tasks/contracts/interfaces/swap/IUniswapV2Swapper.sol b/packages/tasks/contracts/interfaces/swap/IUniswapV2Swapper.sol index 7286594c..da22261b 100644 --- a/packages/tasks/contracts/interfaces/swap/IUniswapV2Swapper.sol +++ b/packages/tasks/contracts/interfaces/swap/IUniswapV2Swapper.sol @@ -23,5 +23,5 @@ interface IUniswapV2Swapper is IBaseSwapTask { /** * @dev Execution function */ - function call(address tokenIn, uint256 amountIn, uint256 minAmountOut, address[] memory hopTokens) external; + function call(address tokenIn, uint256 amountIn, uint256 slippage, address[] memory hopTokens) external; } diff --git a/packages/tasks/contracts/interfaces/swap/IUniswapV3Swapper.sol b/packages/tasks/contracts/interfaces/swap/IUniswapV3Swapper.sol index 7a4922f0..3d6475f7 100644 --- a/packages/tasks/contracts/interfaces/swap/IUniswapV3Swapper.sol +++ b/packages/tasks/contracts/interfaces/swap/IUniswapV3Swapper.sol @@ -26,7 +26,7 @@ interface IUniswapV3Swapper is IBaseSwapTask { function call( address tokenIn, uint256 amountIn, - uint256 minAmountOut, + uint256 slippage, uint24 fee, address[] memory hopTokens, uint24[] memory hopFees diff --git a/packages/tasks/package.json b/packages/tasks/package.json index 75bfc0e1..70cca31b 100644 --- a/packages/tasks/package.json +++ b/packages/tasks/package.json @@ -21,12 +21,13 @@ "prepare": "yarn build" }, "dependencies": { - "@mimic-fi/v3-authorizer": "0.1.0", + "@mimic-fi/v3-authorizer": "0.1.1", "@mimic-fi/v3-connectors": "0.1.0", "@mimic-fi/v3-helpers": "0.1.0", "@mimic-fi/v3-price-oracle": "0.1.0", "@mimic-fi/v3-smart-vault": "0.1.0", - "@openzeppelin/contracts": "4.9.3" + "@openzeppelin/contracts": "4.9.3", + "@quant-finance/solidity-datetime": "2.2.0" }, "devDependencies": { "@nomiclabs/hardhat-ethers": "^2.2.3", diff --git a/packages/tasks/test/base/TimeLockedTask.test.ts b/packages/tasks/test/base/TimeLockedTask.test.ts index ad82456a..3effff4d 100644 --- a/packages/tasks/test/base/TimeLockedTask.test.ts +++ b/packages/tasks/test/base/TimeLockedTask.test.ts @@ -1,23 +1,31 @@ import { advanceTime, assertEvent, - assertNoEvent, + BigNumberish, currentTimestamp, DAY, deployProxy, getSigners, - MONTH, + HOUR, + MINUTE, setNextBlockTimestamp, ZERO_BYTES32, } from '@mimic-fi/v3-helpers' import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address' import { expect } from 'chai' -import { BigNumber, Contract } from 'ethers' +import { Contract } from 'ethers' import { deployEnvironment } from '../../src/setup' /* eslint-disable no-secrets/no-secrets */ +const MODE = { + SECONDS: 0, + ON_DAY: 1, + ON_LAST_DAY: 2, + EVERY_X_MONTH: 3, +} + describe('TimeLockedTask', () => { let task: Contract let smartVault: Contract, authorizer: Contract, owner: SignerWithAddress @@ -40,441 +48,565 @@ describe('TimeLockedTask', () => { nextBalanceConnectorId: ZERO_BYTES32, }, timeLockConfig: { - delay: 0, - nextExecutionTimestamp: 0, - executionPeriod: 0, + mode: 0, + frequency: 0, + allowedAt: 0, + window: 0, }, }, ] ) }) - describe('setTimeLockDelay', () => { - const delay = MONTH - - context('when the sender is authorized', () => { + describe('setTimeLock', () => { + context('when the sender is allowed', () => { beforeEach('authorize sender', async () => { - const setTimeLockDelayRole = task.interface.getSighash('setTimeLockDelay') - await authorizer.connect(owner).authorize(owner.address, task.address, setTimeLockDelayRole, []) + const setTimeLockRole = task.interface.getSighash('setTimeLock') + await authorizer.connect(owner).authorize(owner.address, task.address, setTimeLockRole, []) task = task.connect(owner) }) - context('when the delay is larger than the execution period', () => { - const period = 0 + function itSetsTheTimeLockProperly(mode: number, frequency: number, allowedAt: number, window: number) { + it('sets the time lock', async () => { + await task.setTimeLock(mode, frequency, allowedAt, window) - beforeEach('set execution period', async () => { - const setTimeLockExecutionPeriodRole = task.interface.getSighash('setTimeLockExecutionPeriod') - await authorizer.connect(owner).authorize(owner.address, task.address, setTimeLockExecutionPeriodRole, []) - await task.setTimeLockExecutionPeriod(period) - }) - - it('sets the time lock delay', async () => { - const previousExpiration = await task.timeLockExpiration() - const previousTimeLockExecutionPeriod = await task.timeLockExecutionPeriod() - - await task.setTimeLockDelay(delay) - - expect(await task.timeLockDelay()).to.be.equal(delay) - expect(await task.timeLockExpiration()).to.be.equal(previousExpiration) - expect(await task.timeLockExecutionPeriod()).to.be.equal(previousTimeLockExecutionPeriod) + const timeLock = await task.getTimeLock() + expect(timeLock.mode).to.be.equal(mode) + expect(timeLock.frequency).to.be.equal(frequency) + expect(timeLock.allowedAt).to.be.equal(allowedAt) + expect(timeLock.window).to.be.equal(window) }) it('emits an event', async () => { - const tx = await task.setTimeLockDelay(delay) + const tx = await task.setTimeLock(mode, frequency, allowedAt, window) - await assertEvent(tx, 'TimeLockDelaySet', { delay }) - }) - }) - - context('when the delay is shorter than the execution period', () => { - const period = delay * 2 - - beforeEach('set execution period', async () => { - await task.setTimeLockDelay(period) - const setTimeLockExecutionPeriodRole = task.interface.getSighash('setTimeLockExecutionPeriod') - await authorizer.connect(owner).authorize(owner.address, task.address, setTimeLockExecutionPeriodRole, []) - await task.setTimeLockExecutionPeriod(period) + await assertEvent(tx, 'TimeLockSet', { mode, frequency, allowedAt, window }) }) + } + function itReverts(mode: number, frequency: number, allowedAt: number, window: number, error: string) { it('reverts', async () => { - await expect(task.setTimeLockDelay(delay)).to.be.revertedWith('TaskExecutionPeriodGtDelay') + await expect(task.setTimeLock(mode, frequency, allowedAt, window)).to.be.revertedWith(error) }) - }) - }) + } - context('when the sender is not authorized', () => { - it('reverts', async () => { - await expect(task.setTimeLockDelay(delay)).to.be.revertedWith('AuthSenderNotAllowed') - }) - }) - }) + context('seconds mode', () => { + const mode = MODE.SECONDS - describe('setTimeLockExpiration', () => { - const expiration = '123719273' + context('when a frequency is given', () => { + const frequency = 100 - context('when the sender is authorized', () => { - beforeEach('authorize sender', async () => { - const setTimeLockExpirationRole = task.interface.getSighash('setTimeLockExpiration') - await authorizer.connect(owner).authorize(owner.address, task.address, setTimeLockExpirationRole, []) - task = task.connect(owner) - }) + context('when a window is given', () => { + context('when the window is shorter than the frequency', () => { + const window = frequency - 1 - it('sets the time lock expiration', async () => { - const previousTimeLockDelay = await task.timeLockDelay() - const previousTimeLockExecutionPeriod = await task.timeLockExecutionPeriod() + context('when an allowed date is given', () => { + const allowedAt = 1000 - await task.setTimeLockExpiration(expiration) + itSetsTheTimeLockProperly(mode, frequency, allowedAt, window) + }) - expect(await task.timeLockDelay()).to.be.equal(previousTimeLockDelay) - expect(await task.timeLockExpiration()).to.be.equal(expiration) - expect(await task.timeLockExecutionPeriod()).to.be.equal(previousTimeLockExecutionPeriod) - }) + context('when no allowed date is given', () => { + const allowedAt = 0 - it('emits an event', async () => { - const tx = await task.setTimeLockExpiration(expiration) + itReverts(mode, frequency, allowedAt, window, 'TaskInvalidAllowedDate') + }) + }) - await assertEvent(tx, 'TimeLockExpirationSet', { expiration }) - }) - }) + context('when the window is larger than the frequency', () => { + const window = frequency + 1 - context('when the sender is not authorized', () => { - it('reverts', async () => { - await expect(task.setTimeLockExpiration(0)).to.be.revertedWith('AuthSenderNotAllowed') - }) - }) - }) + itReverts(mode, frequency, 0, window, 'TaskInvalidAllowedWindow') + }) + }) - describe('setTimeLockExecutionPeriod', () => { - const period = MONTH + context('when no window is given', () => { + const window = 0 - context('when the sender is authorized', () => { - beforeEach('authorize sender', async () => { - const setTimeLockExecutionPeriodRole = task.interface.getSighash('setTimeLockExecutionPeriod') - await authorizer.connect(owner).authorize(owner.address, task.address, setTimeLockExecutionPeriodRole, []) - task = task.connect(owner) - }) + context('when an allowed date is given', () => { + const allowedAt = 1000 - context('when the period is shorter than the delay', () => { - const delay = period * 2 + itReverts(mode, frequency, allowedAt, window, 'TaskInvalidAllowedWindow') + }) - beforeEach('set delay', async () => { - const setTimeLockDelayRole = task.interface.getSighash('setTimeLockDelay') - await authorizer.connect(owner).authorize(owner.address, task.address, setTimeLockDelayRole, []) - await task.connect(owner).setTimeLockDelay(delay) + context('when no allowed date is given', () => { + const allowedAt = 0 + + itSetsTheTimeLockProperly(mode, frequency, allowedAt, window) + }) + }) }) - it('sets the time lock execution period', async () => { - const previousTimeLockDelay = await task.timeLockDelay() - const previousTimeLockExpiration = await task.timeLockExpiration() + context('when no frequency is given', () => { + const frequency = 0 - await task.setTimeLockExecutionPeriod(period) + context('when a window is given', () => { + const window = 10 - expect(await task.timeLockDelay()).to.be.equal(previousTimeLockDelay) - expect(await task.timeLockExpiration()).to.be.equal(previousTimeLockExpiration) - expect(await task.timeLockExecutionPeriod()).to.be.equal(period) - }) + context('when an allowed date is given', () => { + const allowedAt = 1000 - it('emits an event', async () => { - const tx = await task.setTimeLockExecutionPeriod(period) + itReverts(mode, frequency, allowedAt, window, 'TaskInvalidFrequency') + }) - await assertEvent(tx, 'TimeLockExecutionPeriodSet', { period }) - }) - }) + context('when no allowed date is given', () => { + const allowedAt = 0 - context('when the period is larger than the delay', () => { - const delay = period / 2 + itReverts(mode, frequency, allowedAt, window, 'TaskInvalidFrequency') + }) + }) - beforeEach('set delay', async () => { - const setTimeLockDelayRole = task.interface.getSighash('setTimeLockDelay') - await authorizer.connect(owner).authorize(owner.address, task.address, setTimeLockDelayRole, []) - await task.connect(owner).setTimeLockDelay(delay) - }) + context('when no window is given', () => { + const window = 0 - it('reverts', async () => { - await expect(task.setTimeLockExecutionPeriod(period)).to.be.revertedWith('TaskExecutionPeriodGtDelay') + context('when an allowed date is given', () => { + const allowedAt = 1000 + + itReverts(mode, frequency, allowedAt, window, 'TaskInvalidFrequency') + }) + + context('when no allowed date is given', () => { + const allowedAt = 0 + + itSetsTheTimeLockProperly(mode, frequency, allowedAt, window) + }) + }) }) }) - }) - context('when the sender is not authorized', () => { - it('reverts', async () => { - await expect(task.setTimeLockExecutionPeriod(0)).to.be.revertedWith('AuthSenderNotAllowed') - }) - }) - }) + context('on-day mode', () => { + const mode = MODE.ON_DAY - describe('call', () => { - beforeEach('authorize sender', async () => { - const setTimeLockDelayRole = task.interface.getSighash('setTimeLockDelay') - await authorizer.connect(owner).authorize(owner.address, task.address, setTimeLockDelayRole, []) - const setTimeLockExpirationRole = task.interface.getSighash('setTimeLockExpiration') - await authorizer.connect(owner).authorize(owner.address, task.address, setTimeLockExpirationRole, []) - const setTimeLockExecutionPeriodRole = task.interface.getSighash('setTimeLockExecutionPeriod') - await authorizer.connect(owner).authorize(owner.address, task.address, setTimeLockExecutionPeriodRole, []) - }) + context('when a frequency is given', () => { + context('when the frequency is lower than or equal to 28', () => { + const frequency = 28 - context('when no initial expiration timestamp is set', () => { - const nextExecutionTimestamp = 0 + context('when a window is given', () => { + context('when the window is shorter than the frequency', () => { + const window = frequency * DAY - 1 - beforeEach('set time-lock expiration', async () => { - await task.connect(owner).setTimeLockExpiration(nextExecutionTimestamp) - }) + context('when an allowed date is given', () => { + context('when the allowed day matches the frequency', () => { + const allowedAt = new Date(`2023-10-${frequency}`).getTime() / 1000 - context('without time-lock delay', () => { - const delay = 0 + itSetsTheTimeLockProperly(mode, frequency, allowedAt, window) + }) - beforeEach('set time-lock delay', async () => { - await task.connect(owner).setTimeLockDelay(delay) - }) + context('when the allowed day does not match the frequency', () => { + const allowedAt = new Date(`2023-10-${frequency + 1}`).getTime() / 1000 - it('has no time-lock delay', async () => { - expect(await task.timeLockDelay()).to.be.equal(0) - expect(await task.timeLockExpiration()).to.be.equal(0) - }) + itReverts(mode, frequency, allowedAt, window, 'TaskInvalidAllowedDate') + }) + }) - it('has no initial delay', async () => { - await expect(task.call()).not.to.be.reverted - }) + context('when no allowed date is given', () => { + const allowedAt = 0 - it('does not update the expiration date', async () => { - const tx = await task.call() - await assertNoEvent(tx, 'TimeLockExpirationSet') + itReverts(mode, frequency, allowedAt, window, 'TaskInvalidAllowedDate') + }) + }) - expect(await task.timeLockDelay()).to.be.equal(0) - expect(await task.timeLockExpiration()).to.be.equal(0) - expect(await task.timeLockExecutionPeriod()).to.be.equal(0) - }) + context('when the window is larger than the frequency', () => { + const window = frequency * DAY + 1 - it('can be updated at any time in the future', async () => { - const newTimeLockDelay = DAY - await task.connect(owner).setTimeLockDelay(newTimeLockDelay) + itReverts(mode, frequency, 0, window, 'TaskInvalidAllowedWindow') + }) + }) - expect(await task.timeLockDelay()).to.be.equal(newTimeLockDelay) - expect(await task.timeLockExpiration()).to.be.equal(0) - expect(await task.timeLockExecutionPeriod()).to.be.equal(0) + context('when no window is given', () => { + const window = 0 - const tx = await task.call() - await assertEvent(tx, 'TimeLockExpirationSet') - await expect(task.call()).to.be.revertedWith('TaskTimeLockNotExpired') + itReverts(mode, frequency, 0, window, 'TaskInvalidAllowedWindow') + }) + }) - const previousExpiration = await task.timeLockExpiration() - await advanceTime(newTimeLockDelay) - const tx2 = await task.call() - await assertEvent(tx2, 'TimeLockExpirationSet') + context('when the frequency is greater than 28', () => { + const frequency = 29 - expect(await task.timeLockDelay()).to.be.equal(newTimeLockDelay) - expect(await task.timeLockExpiration()).to.be.equal(previousExpiration.add(newTimeLockDelay)) - expect(await task.timeLockExecutionPeriod()).to.be.equal(0) + itReverts(mode, frequency, 0, 0, 'TaskInvalidFrequency') + }) }) - }) - context('with an initial delay', () => { - const delay = MONTH + context('when no frequency is given', () => { + const frequency = 0 - beforeEach('set time-lock delay', async () => { - await task.connect(owner).setTimeLockDelay(delay) + itReverts(mode, frequency, 0, 0, 'TaskInvalidFrequency') }) + }) - it('has a time-lock delay', async () => { - expect(await task.timeLockDelay()).to.be.equal(delay) - expect(await task.timeLockExpiration()).to.be.equal(0) - }) + context('on-last-day mode', () => { + const mode = MODE.ON_LAST_DAY + + context('when a frequency is given', () => { + const frequency = 1 - it('has no initial delay', async () => { - await expect(task.call()).not.to.be.reverted + itReverts(mode, frequency, 0, 0, 'TaskInvalidFrequency') }) - it('must wait to be valid again after the first execution', async () => { - await task.call() - await expect(task.call()).to.be.revertedWith('TaskTimeLockNotExpired') + context('when no frequency is given', () => { + const frequency = 0 - const previousExpiration = await task.timeLockExpiration() - await advanceTime(delay) - const tx = await task.call() - await assertEvent(tx, 'TimeLockExpirationSet') + context('when a window is given', () => { + context('when the window is shorter than 28 days', () => { + const window = 28 * DAY - 1 - expect(await task.timeLockDelay()).to.be.equal(delay) - expect(await task.timeLockExpiration()).to.be.equal(previousExpiration.add(delay)) - }) + context('when an allowed date is given', () => { + context('when the allowed date is a last day of a month', () => { + const allowedDates = ['2022-06-30', '2023-10-31', '2021-12-31', '2020-02-29', '2021-02-28'] - it('can be changed at any time in the future without affecting the previous expiration date', async () => { - await task.call() - const initialExpiration = await task.timeLockExpiration() + allowedDates.forEach((date) => { + context(`for ${date}`, () => { + const allowedAt = new Date(date).getTime() / 1000 - const newTimeLockDelay = DAY - await task.connect(owner).setTimeLockDelay(newTimeLockDelay) + itSetsTheTimeLockProperly(mode, frequency, allowedAt, window) + }) + }) + }) - expect(await task.timeLockDelay()).to.be.equal(newTimeLockDelay) - expect(await task.timeLockExpiration()).to.be.equal(initialExpiration) + context('when the allowed date is not the last day of a month', () => { + const notAllowedDates = ['2022-08-30', '2020-02-28'] - const secondExpiration = await task.timeLockExpiration() - await setNextBlockTimestamp(secondExpiration) - const tx = await task.call() - await assertEvent(tx, 'TimeLockExpirationSet') + notAllowedDates.forEach((date) => { + context(`for ${date}`, () => { + const allowedAt = new Date(date).getTime() / 1000 - expect(await task.timeLockDelay()).to.be.equal(newTimeLockDelay) - expect(await task.timeLockExpiration()).to.be.equal(secondExpiration.add(newTimeLockDelay)) - }) + itReverts(mode, frequency, allowedAt, window, 'TaskInvalidAllowedDate') + }) + }) + }) + }) + + context('when no allowed date is given', () => { + const allowedAt = 0 - it('can be unset at any time in the future without affecting the previous expiration date', async () => { - await task.call() - const initialExpiration = await task.timeLockExpiration() + itReverts(mode, frequency, allowedAt, window, 'TaskInvalidAllowedDate') + }) + }) - await task.connect(owner).setTimeLockDelay(0) + context('when the window is larger than 28 days', () => { + const window = 28 * DAY + 1 - expect(await task.timeLockDelay()).to.be.equal(0) - expect(await task.timeLockExpiration()).to.be.equal(initialExpiration) + itReverts(mode, frequency, 0, window, 'TaskInvalidAllowedWindow') + }) + }) - const secondExpiration = await task.timeLockExpiration() - await setNextBlockTimestamp(initialExpiration) - const tx = await task.call() - await assertNoEvent(tx, 'TimeLockExpirationSet') + context('when no window is given', () => { + const window = 0 - expect(await task.timeLockDelay()).to.be.equal(0) - expect(await task.timeLockExpiration()).to.be.equal(secondExpiration) + itReverts(mode, frequency, 0, window, 'TaskInvalidAllowedWindow') + }) }) }) - }) - context('when an initial expiration timestamp is set', () => { - let initialExpirationTimestamp: BigNumber - const initialDelay = 2 * MONTH + context('every-month mode', () => { + const mode = MODE.EVERY_X_MONTH - beforeEach('set time-lock expiration', async () => { - initialExpirationTimestamp = (await currentTimestamp()).add(initialDelay) - await task.connect(owner).setTimeLockExpiration(initialExpirationTimestamp) - }) + context('when a frequency is given', () => { + const frequency = 3 - context('without time-lock delay', () => { - const delay = 0 + context('when a window is given', () => { + context('when the window is shorter than months of 28 days', () => { + const window = 28 * DAY * frequency - 1 - beforeEach('set time-lock delay', async () => { - await task.connect(owner).setTimeLockDelay(delay) - }) + context('when an allowed date is given', () => { + context('when the allowed day is lower than or equal to 28', () => { + const allowedDates = ['2022-06-10', '2023-10-01', '2021-02-28'] - it('has an initial expiration timestamp', async () => { - expect(await task.timeLockDelay()).to.be.equal(0) - expect(await task.timeLockExpiration()).to.be.equal(initialExpirationTimestamp) - }) + allowedDates.forEach((date) => { + context(`for ${date}`, () => { + const allowedAt = new Date(date).getTime() / 1000 - it('can be validated any number of times right after the initial delay', async () => { - const initialDelay = await task.timeLockDelay() - const initialExpiration = await task.timeLockExpiration() + itSetsTheTimeLockProperly(mode, frequency, allowedAt, window) + }) + }) + }) - await expect(task.call()).to.be.revertedWith('TaskTimeLockNotExpired') + context('when the allowed day is greater than 28', () => { + const notAllowedDates = ['2022-08-30', '2020-02-29', '2023-10-31', '2023-06-30'] - await advanceTime(initialExpirationTimestamp) - const tx = await task.call() - await assertNoEvent(tx, 'TimeLockExpirationSet') + notAllowedDates.forEach((date) => { + context(`for ${date}`, () => { + const allowedAt = new Date(date).getTime() / 1000 - expect(await task.timeLockDelay()).to.be.equal(initialDelay) - expect(await task.timeLockExpiration()).to.be.equal(initialExpiration) - }) - - it('can be changed at any time in the future without affecting the previous expiration date', async () => { - const initialExpiration = await task.timeLockExpiration() + itReverts(mode, frequency, allowedAt, window, 'TaskInvalidAllowedDate') + }) + }) + }) + }) - const newTimeLockDelay = DAY - await task.connect(owner).setTimeLockDelay(newTimeLockDelay) + context('when no allowed date is given', () => { + const allowedAt = 0 - expect(await task.timeLockDelay()).to.be.equal(newTimeLockDelay) - expect(await task.timeLockExpiration()).to.be.equal(initialExpiration) + itReverts(mode, frequency, allowedAt, window, 'TaskInvalidAllowedDate') + }) + }) - await setNextBlockTimestamp(initialExpiration) - const tx = await task.call() - await assertEvent(tx, 'TimeLockExpirationSet') + context('when the window is larger than months of 28 days', () => { + const window = 28 * DAY * frequency + 1 - const now = await currentTimestamp() - expect(await task.timeLockDelay()).to.be.equal(newTimeLockDelay) - expect(await task.timeLockExpiration()).to.be.equal(now.add(newTimeLockDelay)) - }) - }) + itReverts(mode, frequency, 0, window, 'TaskInvalidAllowedWindow') + }) + }) - context('with a time-lock delay', () => { - const delay = MONTH - const executionPeriod = DAY + context('when no window is given', () => { + const window = 0 - beforeEach('set time-lock delay and execution period', async () => { - await task.connect(owner).setTimeLockDelay(delay) - await task.connect(owner).setTimeLockExecutionPeriod(executionPeriod) + itReverts(mode, frequency, 0, window, 'TaskInvalidAllowedWindow') + }) }) - it('has a time-lock with an initial delay', async () => { - expect(await task.timeLockDelay()).to.be.equal(delay) - expect(await task.timeLockExpiration()).to.be.equal(initialExpirationTimestamp) - expect(await task.timeLockExecutionPeriod()).to.be.equal(executionPeriod) - }) + context('when no frequency is given', () => { + const frequency = 0 - it('can be validated once right after the initial delay', async () => { - await expect(task.call()).to.be.revertedWith('TaskTimeLockNotExpired') + itReverts(mode, frequency, 0, 0, 'TaskInvalidFrequency') + }) + }) - await setNextBlockTimestamp(initialExpirationTimestamp) - const tx = await task.call() - await assertEvent(tx, 'TimeLockExpirationSet') + context('on another mode', () => { + const mode = 888999 - expect(await task.timeLockDelay()).to.be.equal(delay) - expect(await task.timeLockExpiration()).to.be.equal(initialExpirationTimestamp.add(delay)) - expect(await task.timeLockExecutionPeriod()).to.be.equal(executionPeriod) + it('reverts', async () => { + await expect(task.setTimeLock(mode, 0, 0, 0)).to.be.reverted + }) + }) + }) - await expect(task.call()).to.be.revertedWith('TaskTimeLockNotExpired') + context('when the sender is not allowed', () => { + it('reverts', async () => { + await expect(task.setTimeLock(0, 0, 0, 0)).to.be.revertedWith('AuthSenderNotAllowed') + }) + }) + }) - await setNextBlockTimestamp(initialExpirationTimestamp.add(delay * 2).add(executionPeriod)) - const tx2 = await task.call() - await assertEvent(tx2, 'TimeLockExpirationSet') + describe('call', () => { + beforeEach('authorize sender', async () => { + const setTimeLockRole = task.interface.getSighash('setTimeLock') + await authorizer.connect(owner).authorize(owner.address, task.address, setTimeLockRole, []) + task = task.connect(owner) + }) - expect(await task.timeLockDelay()).to.be.equal(delay) - expect(await task.timeLockExpiration()).to.be.equal(initialExpirationTimestamp.add(delay * 3)) - expect(await task.timeLockExecutionPeriod()).to.be.equal(executionPeriod) + async function assertItCannotBeExecuted() { + await expect(task.call()).to.be.revertedWith('TaskTimeLockActive') + } + + async function assertItCanBeExecuted(expectedNextAllowedDate: BigNumberish) { + const tx = await task.call() + await assertEvent(tx, 'TimeLockAllowedAtSet', { allowedAt: expectedNextAllowedDate }) + + const { allowedAt } = await task.getTimeLock() + expect(allowedAt).to.be.equal(expectedNextAllowedDate) + } + + async function moveToDate(timestamp: string, delta = 0): Promise { + const date = new Date(`${timestamp}T00:00:00Z`).getTime() / 1000 + const now = await currentTimestamp() + const diff = date - now.toNumber() + delta + await advanceTime(diff) + } + + context('seconds mode', () => { + const mode = MODE.SECONDS + const frequency = HOUR * 2 + + async function getNextAllowedDate(delayed: number) { + const previousTimeLock = await task.getTimeLock() + const now = await currentTimestamp() + return (previousTimeLock.window.eq(0) ? now : previousTimeLock.allowedAt).add(delayed) + } + + context('without execution window', () => { + const window = 0 + const allowedAt = 0 + + beforeEach('set time lock', async () => { + await task.setTimeLock(mode, frequency, allowedAt, window) }) - it('cannot be validated after the execution period', async () => { - await expect(task.call()).to.be.revertedWith('TaskTimeLockNotExpired') - - await setNextBlockTimestamp(initialExpirationTimestamp.add(executionPeriod).add(1)) - await expect(task.call()).to.be.revertedWith('TaskTimeLockWaitNextPeriod') + it('locks the task properly', async () => { + // It can be executed immediately + await assertItCanBeExecuted(await getNextAllowedDate(frequency + 1)) + + // It is locked for a period equal to the frequency set + await assertItCannotBeExecuted() + await advanceTime(frequency / 2) + await assertItCannotBeExecuted() + await advanceTime(frequency / 2) + await assertItCanBeExecuted(await getNextAllowedDate(frequency + 1)) + + // It is locked for a period equal to the frequency set again + await assertItCannotBeExecuted() + await advanceTime(frequency - 10) + await assertItCannotBeExecuted() + + // It can be executed at any point in time in the future + await advanceTime(frequency * 1000) + await assertItCanBeExecuted(await getNextAllowedDate(frequency + 1)) + }) + }) - await setNextBlockTimestamp(initialExpirationTimestamp.add(delay)) - const tx = await task.call() - await assertEvent(tx, 'TimeLockExpirationSet') + context('with execution window', () => { + const window = MINUTE * 30 + const allowedAt = new Date('2023-10-05').getTime() / 1000 - expect(await task.timeLockDelay()).to.be.equal(delay) - expect(await task.timeLockExpiration()).to.be.equal(initialExpirationTimestamp.add(delay * 2)) - expect(await task.timeLockExecutionPeriod()).to.be.equal(executionPeriod) + beforeEach('set time lock', async () => { + await task.setTimeLock(mode, frequency, allowedAt, window) + }) - await expect(task.call()).to.be.revertedWith('TaskTimeLockNotExpired') + it('locks the task properly', async () => { + // Move to an executable window + const now = await currentTimestamp() + const periods = now.sub(allowedAt).div(frequency).toNumber() + const nextAllowedDate = allowedAt + (periods + 1) * frequency + await setNextBlockTimestamp(nextAllowedDate) + + // It can be executed immediately + await assertItCanBeExecuted(await getNextAllowedDate(frequency * (periods + 2))) + + // It is locked for a period equal to the frequency set + await assertItCannotBeExecuted() + await advanceTime(frequency / 2) + await assertItCannotBeExecuted() + await advanceTime(frequency / 2) + await assertItCanBeExecuted(await getNextAllowedDate(frequency)) + + // It is locked for a period equal to the frequency set again + await assertItCannotBeExecuted() + await advanceTime(frequency - 10) + await assertItCannotBeExecuted() + + // It cannot be executed after the execution window + const timeLock = await task.getTimeLock() + await setNextBlockTimestamp(timeLock.allowedAt.add(window).add(1)) + await assertItCannotBeExecuted() + + // It can be executed one period after + await setNextBlockTimestamp(timeLock.allowedAt.add(frequency)) + await assertItCanBeExecuted(await getNextAllowedDate(frequency * 2)) }) + }) + }) - it('can be changed at any time in the future without affecting the previous expiration date', async () => { - const newTimeLockDelay = DAY - await task.connect(owner).setTimeLockDelay(newTimeLockDelay) + context('on-day mode', () => { + const mode = MODE.ON_DAY + const frequency = 5 + const allowedAt = new Date('2028-10-05T00:00:00Z').getTime() / 1000 + const window = 1 * DAY - expect(await task.timeLockDelay()).to.be.equal(newTimeLockDelay) - expect(await task.timeLockExpiration()).to.be.equal(initialExpirationTimestamp) - expect(await task.timeLockExecutionPeriod()).to.be.equal(executionPeriod) + beforeEach('set time lock', async () => { + await task.setTimeLock(mode, frequency, allowedAt, window) + }) - await setNextBlockTimestamp(initialExpirationTimestamp) - const tx = await task.call() - await assertEvent(tx, 'TimeLockExpirationSet') + async function getNextAllowedDate(): Promise { + const now = new Date((await currentTimestamp()).toNumber() * 1000) + const month = now.getMonth() + const nextMonth = (month + 1) % 12 + const nextYear = nextMonth > month ? now.getFullYear() : now.getFullYear() + 1 + return new Date(`${nextYear}-${(nextMonth + 1).toString().padStart(2, '0')}-05T00:00:00Z`).getTime() / 1000 + } + + it('locks the task properly', async () => { + // Move to an executable window + await moveToDate('2028-10-05') + + // It can be executed immediately + await assertItCanBeExecuted(await getNextAllowedDate()) + + // It is locked for at least a month + await assertItCannotBeExecuted() + await moveToDate('2028-10-20') + await assertItCannotBeExecuted() + + // It cannot be executed after the execution window + await moveToDate('2028-11-05', window + 1) + await assertItCannotBeExecuted() + + // It can be executed one period after + await moveToDate('2028-12-05', window - 10) + await assertItCanBeExecuted(await getNextAllowedDate()) + }) + }) - expect(await task.timeLockDelay()).to.be.equal(newTimeLockDelay) - expect(await task.timeLockExpiration()).to.be.equal(initialExpirationTimestamp.add(newTimeLockDelay)) - expect(await task.timeLockExecutionPeriod()).to.be.equal(executionPeriod) - }) + context('on-last-day mode', () => { + const mode = MODE.ON_LAST_DAY + const frequency = 0 + const allowedAt = new Date('2030-10-31T00:00:00Z').getTime() / 1000 + const window = 1 * DAY - it('can be unset at any time in the future without affecting the previous expiration date', async () => { - await task.connect(owner).setTimeLockExecutionPeriod(0) - await task.connect(owner).setTimeLockDelay(0) + beforeEach('set time lock', async () => { + await task.setTimeLock(mode, frequency, allowedAt, window) + }) + + async function getNextAllowedDate(): Promise { + const now = new Date((await currentTimestamp()).toNumber() * 1000) + const month = now.getMonth() + const nextMonth = (month + 1) % 12 + const nextYear = nextMonth > month ? now.getFullYear() : now.getFullYear() + 1 + const lastDayOfMonth = new Date(nextYear, nextMonth + 1, 0).getDate() + const date = `${nextYear}-${(nextMonth + 1).toString().padStart(2, '0')}-${lastDayOfMonth}T00:00:00Z` + return new Date(date).getTime() / 1000 + } + + it('locks the task properly', async () => { + // Move to an executable window + await moveToDate('2030-10-31') + + // It can be executed immediately + await assertItCanBeExecuted(await getNextAllowedDate()) + + // It is locked for at least a month + await assertItCannotBeExecuted() + await moveToDate('2030-11-20') + await assertItCannotBeExecuted() + + // It cannot be executed after the execution window + await moveToDate('2030-12-31', window + 1) + await assertItCannotBeExecuted() + + // It can be executed one period after + await moveToDate('2031-01-31', window - 10) + await assertItCanBeExecuted(await getNextAllowedDate()) + }) + }) - expect(await task.timeLockDelay()).to.be.equal(0) - expect(await task.timeLockExpiration()).to.be.equal(initialExpirationTimestamp) + context('every-month mode', () => { + const mode = MODE.EVERY_X_MONTH + const frequency = 3 + const allowedAt = new Date('2032-10-06T00:00:00Z').getTime() / 1000 + const window = 1 * DAY - await setNextBlockTimestamp(initialExpirationTimestamp) - const tx = await task.call() - await assertNoEvent(tx, 'TimeLockExpirationSet') + beforeEach('set time lock', async () => { + await task.setTimeLock(mode, frequency, allowedAt, window) + }) - expect(await task.timeLockDelay()).to.be.equal(0) - expect(await task.timeLockExpiration()).to.be.equal(initialExpirationTimestamp) - }) + async function getNextAllowedDate(): Promise { + const now = new Date((await currentTimestamp()).toNumber() * 1000) + const month = now.getMonth() + const nextMonth = (month + frequency) % 12 + const nextYear = nextMonth > month ? now.getFullYear() : now.getFullYear() + 1 + return new Date(`${nextYear}-${(nextMonth + 1).toString().padStart(2, '0')}-06T00:00:00Z`).getTime() / 1000 + } + + it('locks the task properly', async () => { + // Move to an executable window + await moveToDate('2032-10-06') + + // It can be executed immediately + await assertItCanBeExecuted(await getNextAllowedDate()) + + // It is locked for at least the number of set months + await assertItCannotBeExecuted() + await moveToDate('2032-11-06') + await assertItCannotBeExecuted() + await moveToDate('2032-12-06') + await assertItCannotBeExecuted() + + // It cannot be executed after the execution window + await moveToDate('2033-01-06', window + 1) + await assertItCannotBeExecuted() + + // It can be executed one period after + await moveToDate('2033-04-06', window - 1) + await assertItCanBeExecuted(await getNextAllowedDate()) }) }) }) diff --git a/yarn.lock b/yarn.lock index 9da41101..fbcbcab0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -903,6 +903,14 @@ tweetnacl "^1.0.3" tweetnacl-util "^0.15.1" +"@mimic-fi/v3-authorizer@0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@mimic-fi/v3-authorizer/-/v3-authorizer-0.1.0.tgz#10fa3b9c5ac7c3ab95d6c77764376a6707703d09" + integrity sha512-iuJH2u2a73WS4mHj/W6xjl/tqkKB4upRnSKlN8E1YcpE0waVZwSk6vlX5ANPPkYnWzNbaX89y4pQUNjC5cLB+A== + dependencies: + "@mimic-fi/v3-helpers" "0.1.0" + "@openzeppelin/contracts" "4.7.0" + "@noble/hashes@1.2.0", "@noble/hashes@~1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.2.0.tgz#a3150eeb09cc7ab207ebf6d7b9ad311a9bdbed12" @@ -1157,11 +1165,21 @@ resolved "https://registry.yarnpkg.com/@openzeppelin/contracts-upgradeable/-/contracts-upgradeable-4.9.3.tgz#ff17a80fb945f5102571f8efecb5ce5915cc4811" integrity sha512-jjaHAVRMrE4UuZNfDwjlLGDxTHWIOwTJS2ldnc278a0gevfXfPr8hxKEVBGFBE96kl2G3VHDZhUimw/+G3TG2A== +"@openzeppelin/contracts@4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.7.0.tgz#3092d70ea60e3d1835466266b1d68ad47035a2d5" + integrity sha512-52Qb+A1DdOss8QvJrijYYPSf32GUg2pGaG/yCxtaA3cu4jduouTdg4XZSMLW9op54m1jH7J8hoajhHKOPsoJFw== + "@openzeppelin/contracts@4.9.3": version "4.9.3" resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-4.9.3.tgz#00d7a8cf35a475b160b3f0293a6403c511099364" integrity sha512-He3LieZ1pP2TNt5JbkPA4PNT9WC3gOTOlDcFGJW4Le4QKqwmiNJCRt44APfxMxvq7OugU/cqYuPcSBzOw38DAg== +"@quant-finance/solidity-datetime@2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@quant-finance/solidity-datetime/-/solidity-datetime-2.2.0.tgz#50f2d00a571d8cc2d962257b40b70fc44450dcaa" + integrity sha512-iO0EnqPKTzGCgQOkI9lerpJc0XKUhMNurSjHcA7p7nlP2K2z3U4kk9OC9eQkZUrdBtltft+kIibiDdIOYWuQMg== + "@resolver-engine/core@^0.3.3": version "0.3.3" resolved "https://registry.yarnpkg.com/@resolver-engine/core/-/core-0.3.3.tgz#590f77d85d45bc7ecc4e06c654f41345db6ca967"