From e2450d2778a0cc6b1f5ba09b194533c4b25514fb Mon Sep 17 00:00:00 2001 From: Facu Spagnuolo Date: Tue, 3 Oct 2023 07:12:43 -0300 Subject: [PATCH 1/8] tasks: implement new time lock modes --- .../contracts/AuthorizedHelpers.sol | 8 + packages/authorizer/package.json | 2 +- .../tasks/contracts/base/TimeLockedTask.sol | 271 ++++-- .../interfaces/base/ITimeLockedTask.sol | 58 +- .../interfaces/swap/IOneInchV5Swapper.sol | 2 +- .../interfaces/swap/IUniswapV2Swapper.sol | 2 +- .../interfaces/swap/IUniswapV3Swapper.sol | 2 +- packages/tasks/hardhat.config.ts | 2 +- packages/tasks/package.json | 5 +- packages/tasks/src/setup.ts | 14 +- .../tasks/test/base/TimeLockedTask.test.ts | 774 ++++++++++-------- yarn.lock | 18 + 12 files changed, 719 insertions(+), 439 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..fe6debfa 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,205 @@ 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) { + if (frequency == 0) return; + + // 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); - 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 execution window that cannot be larger than 28 days + if (window == 0 || window > DAYS_28) revert TaskInvalidAllowedWindow(mode, window); + + // 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/hardhat.config.ts b/packages/tasks/hardhat.config.ts index fca1e0b0..9fa7469e 100644 --- a/packages/tasks/hardhat.config.ts +++ b/packages/tasks/hardhat.config.ts @@ -13,7 +13,7 @@ export default { settings: { optimizer: { enabled: true, - runs: 10000, + runs: 500, }, }, }, 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/src/setup.ts b/packages/tasks/src/setup.ts index be43617f..c76955f3 100644 --- a/packages/tasks/src/setup.ts +++ b/packages/tasks/src/setup.ts @@ -80,9 +80,10 @@ export type TaskConfig = { txCostLimitPct: BigNumberish } timeLockConfig: { - delay: BigNumberish - nextExecutionTimestamp: BigNumberish - executionPeriod: BigNumberish + mode: BigNumberish + frequency: BigNumberish + allowedAt: BigNumberish + window: BigNumberish } tokenIndexConfig: { acceptanceType: BigNumberish @@ -128,9 +129,10 @@ export function buildEmptyTaskConfig(owner: SignerWithAddress, smartVault: Contr txCostLimitPct: 0, }, timeLockConfig: { - delay: 0, - nextExecutionTimestamp: 0, - executionPeriod: 0, + mode: 0, + frequency: 0, + allowedAt: 0, + window: 0, }, tokenIndexConfig: { acceptanceType: 0, 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" From deb1f9d33d72d2137827756927e8d80980c5a462 Mon Sep 17 00:00:00 2001 From: Facu Spagnuolo Date: Tue, 17 Oct 2023 15:06:00 -0300 Subject: [PATCH 2/8] task: fix time lock module --- packages/tasks/contracts/base/TimeLockedTask.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tasks/contracts/base/TimeLockedTask.sol b/packages/tasks/contracts/base/TimeLockedTask.sol index fe6debfa..c007e12d 100644 --- a/packages/tasks/contracts/base/TimeLockedTask.sol +++ b/packages/tasks/contracts/base/TimeLockedTask.sol @@ -260,7 +260,7 @@ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { returns (uint256) { (uint256 year, uint256 month, uint256 day) = allowedAt.timestampToDate(); - uint256 nextMonth = month + (monthsToIncrease % 12); + 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); From ebd1e8083d15f91c7312c5f5db7c573670841d49 Mon Sep 17 00:00:00 2001 From: Facu Spagnuolo Date: Tue, 17 Oct 2023 15:06:38 -0300 Subject: [PATCH 3/8] tasks: fix typo Co-authored-by: lgalende <63872655+lgalende@users.noreply.github.com> --- packages/tasks/contracts/base/TimeLockedTask.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tasks/contracts/base/TimeLockedTask.sol b/packages/tasks/contracts/base/TimeLockedTask.sol index c007e12d..a5186cb1 100644 --- a/packages/tasks/contracts/base/TimeLockedTask.sol +++ b/packages/tasks/contracts/base/TimeLockedTask.sol @@ -158,7 +158,7 @@ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { 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 + // we simply need to check we are within 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 From 03cf4780a40a5c1f75abb8c2a479b3b1773ce17e Mon Sep 17 00:00:00 2001 From: Facu Spagnuolo Date: Wed, 18 Oct 2023 23:16:30 -0300 Subject: [PATCH 4/8] tasks: add more time lock tests --- .../tasks/contracts/base/TimeLockedTask.sol | 8 +- .../tasks/test/base/TimeLockedTask.test.ts | 209 +++++++----------- 2 files changed, 87 insertions(+), 130 deletions(-) diff --git a/packages/tasks/contracts/base/TimeLockedTask.sol b/packages/tasks/contracts/base/TimeLockedTask.sol index a5186cb1..8d12d67e 100644 --- a/packages/tasks/contracts/base/TimeLockedTask.sol +++ b/packages/tasks/contracts/base/TimeLockedTask.sol @@ -156,10 +156,12 @@ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { // Construct when would be the current allowed timestamp only considering the current month and year uint256 currentAllowedAt = _getCurrentAllowedDateForMonthlyRelativeFrequency(allowedAt, day); + if (block.timestamp < currentAllowedAt) revert TaskTimeLockActive(block.timestamp, currentAllowedAt); - // Since we already checked the current timestamp is not before the allowed timestamp set, - // we simply need to check we are within the allowed execution window - if (block.timestamp - currentAllowedAt > window) revert TaskTimeLockActive(block.timestamp, allowedAt); + // Otherwise, we simply need to check we are within the allowed execution window + uint256 finalCurrentAllowedAt = currentAllowedAt + window; + bool exceedsExecutionWindow = block.timestamp > finalCurrentAllowedAt; + if (exceedsExecutionWindow) revert TaskTimeLockActive(block.timestamp, finalCurrentAllowedAt); // Finally set the next allowed date to the corresponding number of months from the current date _nextAllowedAt = _getNextAllowedDateForMonthlyRelativeFrequency(currentAllowedAt, monthsToAdd); diff --git a/packages/tasks/test/base/TimeLockedTask.test.ts b/packages/tasks/test/base/TimeLockedTask.test.ts index 3effff4d..04a97212 100644 --- a/packages/tasks/test/base/TimeLockedTask.test.ts +++ b/packages/tasks/test/base/TimeLockedTask.test.ts @@ -8,7 +8,6 @@ import { getSigners, HOUR, MINUTE, - setNextBlockTimestamp, ZERO_BYTES32, } from '@mimic-fi/v3-helpers' import { SignerWithAddress } from '@nomiclabs/hardhat-ethers/dist/src/signer-with-address' @@ -385,11 +384,29 @@ describe('TimeLockedTask', () => { task = task.connect(owner) }) - async function assertItCannotBeExecuted() { + async function setInitialTimeLock(mode: number, frequency: BigNumberish, timestamp: string, window: number) { + const allowedAt = new Date(timestamp).getTime() / 1000 + await task.setTimeLock(mode, frequency, allowedAt, window) + } + + async function moveToDate(timestamp: string, delta = 0): Promise { + const date = new Date(timestamp).getTime() / 1000 + const now = await currentTimestamp() + const diff = date - now.toNumber() + delta + await advanceTime(diff) + } + + async function assertItCannotBeExecuted(currentAllowedTimestamp: string) { await expect(task.call()).to.be.revertedWith('TaskTimeLockActive') + + const expectedCurrentAllowedDate = new Date(currentAllowedTimestamp).getTime() / 1000 + const { allowedAt } = await task.getTimeLock() + expect(allowedAt).to.be.equal(expectedCurrentAllowedDate) } - async function assertItCanBeExecuted(expectedNextAllowedDate: BigNumberish) { + async function assertItCanBeExecuted(nextAllowedTimestamp: string) { + const expectedNextAllowedDate = new Date(nextAllowedTimestamp).getTime() / 1000 + const tx = await task.call() await assertEvent(tx, 'TimeLockAllowedAtSet', { allowedAt: expectedNextAllowedDate }) @@ -397,91 +414,67 @@ describe('TimeLockedTask', () => { 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('locks the task properly', async () => { // It can be executed immediately - await assertItCanBeExecuted(await getNextAllowedDate(frequency + 1)) + await task.setTimeLock(mode, frequency, allowedAt, window) + await moveToDate('2025-01-01T10:20:29Z') + await assertItCanBeExecuted('2025-01-01T12:20:30Z') // 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)) + await assertItCannotBeExecuted('2025-01-01T12:20:30Z') + await moveToDate('2025-01-01T11:20:30Z') + await assertItCannotBeExecuted('2025-01-01T12:20:30Z') + await moveToDate('2025-01-01T12:20:29Z') + await assertItCanBeExecuted('2025-01-01T14:20:30Z') // It is locked for a period equal to the frequency set again - await assertItCannotBeExecuted() - await advanceTime(frequency - 10) - await assertItCannotBeExecuted() + await assertItCannotBeExecuted('2025-01-01T14:20:30Z') + await moveToDate('2025-01-01T14:20:28Z') + await assertItCannotBeExecuted('2025-01-01T14:20:30Z') // It can be executed at any point in time in the future - await advanceTime(frequency * 1000) - await assertItCanBeExecuted(await getNextAllowedDate(frequency + 1)) + await moveToDate('2026-01-01T01:02:03Z') + await assertItCanBeExecuted('2026-01-01T03:02:04Z') }) }) context('with execution window', () => { const window = MINUTE * 30 - const allowedAt = new Date('2023-10-05').getTime() / 1000 - - beforeEach('set time lock', async () => { - await task.setTimeLock(mode, frequency, allowedAt, window) - }) + const allowedAt = new Date('2026-06-01T08:22:34Z').getTime() / 1000 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))) + await task.setTimeLock(mode, frequency, allowedAt, window) + await moveToDate('2026-06-01T08:22:34Z') + await assertItCanBeExecuted('2026-06-01T10:22:34Z') // 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)) + await assertItCannotBeExecuted('2026-06-01T10:22:34Z') + await moveToDate('2026-06-01T09:22:34Z') + await assertItCannotBeExecuted('2026-06-01T10:22:34Z') + await moveToDate('2026-06-01T10:22:34Z') + await assertItCanBeExecuted('2026-06-01T12:22:34Z') // It is locked for a period equal to the frequency set again - await assertItCannotBeExecuted() - await advanceTime(frequency - 10) - await assertItCannotBeExecuted() + await assertItCannotBeExecuted('2026-06-01T12:22:34Z') + await moveToDate('2026-06-01T12:20:34Z') + await assertItCannotBeExecuted('2026-06-01T12:22:34Z') // It cannot be executed after the execution window - const timeLock = await task.getTimeLock() - await setNextBlockTimestamp(timeLock.allowedAt.add(window).add(1)) - await assertItCannotBeExecuted() + await moveToDate('2026-06-01T12:52:35Z') + await assertItCannotBeExecuted('2026-06-01T12:22:34Z') // It can be executed one period after - await setNextBlockTimestamp(timeLock.allowedAt.add(frequency)) - await assertItCanBeExecuted(await getNextAllowedDate(frequency * 2)) + await moveToDate('2026-06-01T14:22:34Z') + await assertItCanBeExecuted('2026-06-01T16:22:34Z') }) }) }) @@ -489,124 +482,86 @@ describe('TimeLockedTask', () => { 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 - 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 - 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') + await setInitialTimeLock(mode, frequency, '2028-10-05T01:02:03Z', window) + await moveToDate('2028-10-05T01:02:03Z') // It can be executed immediately - await assertItCanBeExecuted(await getNextAllowedDate()) + await assertItCanBeExecuted('2028-11-05T01:02:03Z') // It is locked for at least a month - await assertItCannotBeExecuted() - await moveToDate('2028-10-20') - await assertItCannotBeExecuted() + await assertItCannotBeExecuted('2028-11-05T01:02:03Z') + await moveToDate('2028-10-20T01:02:03Z') + await assertItCannotBeExecuted('2028-11-05T01:02:03Z') // It cannot be executed after the execution window - await moveToDate('2028-11-05', window + 1) - await assertItCannotBeExecuted() + await moveToDate('2028-11-06T01:02:03Z') + await assertItCannotBeExecuted('2028-11-05T01:02:03Z') // It can be executed one period after - await moveToDate('2028-12-05', window - 10) - await assertItCanBeExecuted(await getNextAllowedDate()) + await moveToDate('2028-12-05T01:02:03Z') + await assertItCanBeExecuted('2029-01-05T01:02:03Z') }) }) 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 - 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') + await setInitialTimeLock(mode, frequency, '2030-10-31T10:32:20Z', window) + await moveToDate('2030-10-31T10:32:20Z') // It can be executed immediately - await assertItCanBeExecuted(await getNextAllowedDate()) + await assertItCanBeExecuted('2030-11-30T10:32:20Z') // It is locked for at least a month - await assertItCannotBeExecuted() - await moveToDate('2030-11-20') - await assertItCannotBeExecuted() + await assertItCannotBeExecuted('2030-11-30T10:32:20Z') + await moveToDate('2030-11-20T10:32:20Z') + await assertItCannotBeExecuted('2030-11-30T10:32:20Z') // It cannot be executed after the execution window - await moveToDate('2030-12-31', window + 1) - await assertItCannotBeExecuted() + await moveToDate('2031-01-01T10:32:20Z') + await assertItCannotBeExecuted('2030-11-30T10:32:20Z') // It can be executed one period after - await moveToDate('2031-01-31', window - 10) - await assertItCanBeExecuted(await getNextAllowedDate()) + await moveToDate('2031-01-31T10:32:20Z') + await assertItCanBeExecuted('2031-02-28T10:32:20Z') }) }) 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 frequency = 2 const window = 1 * DAY - 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 + 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') + await setInitialTimeLock(mode, frequency, '2032-01-01T10:05:20Z', window) + await moveToDate('2032-01-01T10:05:20Z') // It can be executed immediately - await assertItCanBeExecuted(await getNextAllowedDate()) + await assertItCanBeExecuted('2032-03-01T10:05:20Z') // 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() + await assertItCannotBeExecuted('2032-03-01T10:05:20Z') + await moveToDate('2032-02-01T10:05:20Z') + await assertItCannotBeExecuted('2032-03-01T10:05:20Z') + await moveToDate('2032-02-28T10:05:20Z') + await assertItCannotBeExecuted('2032-03-01T10:05:20Z') // It cannot be executed after the execution window - await moveToDate('2033-01-06', window + 1) - await assertItCannotBeExecuted() + await moveToDate('2032-03-02T10:05:21Z') + await assertItCannotBeExecuted('2032-03-01T10:05:20Z') // It can be executed one period after - await moveToDate('2033-04-06', window - 1) - await assertItCanBeExecuted(await getNextAllowedDate()) + await moveToDate('2032-05-02T10:05:19Z') + await assertItCanBeExecuted('2032-07-01T10:05:20Z') }) }) }) From 937529da6f373c78d1a07f82c5b843301b417fa6 Mon Sep 17 00:00:00 2001 From: Facu Spagnuolo Date: Thu, 19 Oct 2023 07:08:26 -0300 Subject: [PATCH 5/8] tasks: fix every months mode for more than a year --- .../tasks/contracts/base/TimeLockedTask.sol | 2 +- .../tasks/test/base/TimeLockedTask.test.ts | 34 +++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/tasks/contracts/base/TimeLockedTask.sol b/packages/tasks/contracts/base/TimeLockedTask.sol index 8d12d67e..381dab70 100644 --- a/packages/tasks/contracts/base/TimeLockedTask.sol +++ b/packages/tasks/contracts/base/TimeLockedTask.sol @@ -263,7 +263,7 @@ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { { (uint256 year, uint256 month, uint256 day) = allowedAt.timestampToDate(); uint256 nextMonth = (month + monthsToIncrease) % 12; - uint256 nextYear = nextMonth > month ? year : (year + 1); + uint256 nextYear = year + ((month + monthsToIncrease) / 12); uint256 nextDay = _mode == Mode.OnLastMonthDay ? DateTime._getDaysInMonth(nextYear, nextMonth) : day; return _getAllowedDateFor(allowedAt, nextYear, nextMonth, nextDay); } diff --git a/packages/tasks/test/base/TimeLockedTask.test.ts b/packages/tasks/test/base/TimeLockedTask.test.ts index 04a97212..21161a0e 100644 --- a/packages/tasks/test/base/TimeLockedTask.test.ts +++ b/packages/tasks/test/base/TimeLockedTask.test.ts @@ -399,19 +399,18 @@ describe('TimeLockedTask', () => { async function assertItCannotBeExecuted(currentAllowedTimestamp: string) { await expect(task.call()).to.be.revertedWith('TaskTimeLockActive') - const expectedCurrentAllowedDate = new Date(currentAllowedTimestamp).getTime() / 1000 const { allowedAt } = await task.getTimeLock() - expect(allowedAt).to.be.equal(expectedCurrentAllowedDate) + expect(new Date(allowedAt * 1000)).to.be.deep.equal(new Date(currentAllowedTimestamp)) } async function assertItCanBeExecuted(nextAllowedTimestamp: string) { - const expectedNextAllowedDate = new Date(nextAllowedTimestamp).getTime() / 1000 - const tx = await task.call() - await assertEvent(tx, 'TimeLockAllowedAtSet', { allowedAt: expectedNextAllowedDate }) const { allowedAt } = await task.getTimeLock() - expect(allowedAt).to.be.equal(expectedNextAllowedDate) + expect(new Date(allowedAt * 1000)).to.be.deep.equal(new Date(nextAllowedTimestamp)) + + const expectedNextAllowedDate = new Date(nextAllowedTimestamp).getTime() / 1000 + await assertEvent(tx, 'TimeLockAllowedAtSet', { allowedAt: expectedNextAllowedDate }) } context('seconds mode', () => { @@ -426,6 +425,9 @@ describe('TimeLockedTask', () => { // It can be executed immediately await task.setTimeLock(mode, frequency, allowedAt, window) await moveToDate('2025-01-01T10:20:29Z') + + // Note the allowed date is off 1 second, that's correct since there is no initial allowed date, + // it simply uses the current timestamp which is mined one second after the previous block. await assertItCanBeExecuted('2025-01-01T12:20:30Z') // It is locked for a period equal to the frequency set @@ -481,12 +483,10 @@ describe('TimeLockedTask', () => { context('on-day mode', () => { const mode = MODE.ON_DAY - const frequency = 5 - const window = 1 * DAY it('locks the task properly', async () => { // Move to an executable window - await setInitialTimeLock(mode, frequency, '2028-10-05T01:02:03Z', window) + await setInitialTimeLock(mode, 5, '2028-10-05T01:02:03Z', DAY) await moveToDate('2028-10-05T01:02:03Z') // It can be executed immediately @@ -509,12 +509,10 @@ describe('TimeLockedTask', () => { context('on-last-day mode', () => { const mode = MODE.ON_LAST_DAY - const frequency = 0 - const window = 1 * DAY it('locks the task properly', async () => { // Move to an executable window - await setInitialTimeLock(mode, frequency, '2030-10-31T10:32:20Z', window) + await setInitialTimeLock(mode, 0, '2030-10-31T10:32:20Z', DAY) await moveToDate('2030-10-31T10:32:20Z') // It can be executed immediately @@ -537,12 +535,10 @@ describe('TimeLockedTask', () => { context('every-month mode', () => { const mode = MODE.EVERY_X_MONTH - const frequency = 2 - const window = 1 * DAY it('locks the task properly', async () => { // Move to an executable window - await setInitialTimeLock(mode, frequency, '2032-01-01T10:05:20Z', window) + await setInitialTimeLock(mode, 2, '2032-01-01T10:05:20Z', DAY) await moveToDate('2032-01-01T10:05:20Z') // It can be executed immediately @@ -562,6 +558,14 @@ describe('TimeLockedTask', () => { // It can be executed one period after await moveToDate('2032-05-02T10:05:19Z') await assertItCanBeExecuted('2032-07-01T10:05:20Z') + + // Change time lock to 24 months + await setInitialTimeLock(mode, 24, '2033-01-01T05:04:03Z', DAY) + await assertItCannotBeExecuted('2033-01-01T05:04:03Z') + + // Move to an executable window + await moveToDate('2033-01-01T05:04:03Z') + await assertItCanBeExecuted('2035-01-01T05:04:03Z') }) }) }) From a8d2b504688c663739708ef8bb2a5a0e74f88c85 Mon Sep 17 00:00:00 2001 From: Facu Spagnuolo Date: Thu, 19 Oct 2023 09:10:35 -0300 Subject: [PATCH 6/8] tasks: simplify time locks modes --- .../tasks/contracts/base/TimeLockedTask.sol | 104 ++----- .../tasks/test/base/TimeLockedTask.test.ts | 268 ++++++++---------- 2 files changed, 156 insertions(+), 216 deletions(-) diff --git a/packages/tasks/contracts/base/TimeLockedTask.sol b/packages/tasks/contracts/base/TimeLockedTask.sol index 381dab70..0ddf668b 100644 --- a/packages/tasks/contracts/base/TimeLockedTask.sol +++ b/packages/tasks/contracts/base/TimeLockedTask.sol @@ -30,15 +30,13 @@ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { /** * @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 + * @param OnDay To indicate the execution must occur on day number from 1 to 28 every certain months + * @param OnLastMonthDay To indicate the execution must occur on the last day of the month every certain months */ enum Mode { Seconds, OnDay, - OnLastMonthDay, - EverySomeMonths + OnLastMonthDay } // Time lock mode @@ -131,40 +129,18 @@ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { _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); - if (block.timestamp < currentAllowedAt) revert TaskTimeLockActive(block.timestamp, currentAllowedAt); + // Check the current timestamp is not before the current allowed date + uint256 currentAllowedDateDay = mode == Mode.OnDay ? allowedAt.getDay() : block.timestamp.getDaysInMonth(); + uint256 currentAllowedDate = _getCurrentAllowedDate(allowedAt, currentAllowedDateDay); + if (block.timestamp < currentAllowedDate) revert TaskTimeLockActive(block.timestamp, currentAllowedDate); - // Otherwise, we simply need to check we are within the allowed execution window - uint256 finalCurrentAllowedAt = currentAllowedAt + window; - bool exceedsExecutionWindow = block.timestamp > finalCurrentAllowedAt; - if (exceedsExecutionWindow) revert TaskTimeLockActive(block.timestamp, finalCurrentAllowedAt); + // Check the current timestamp has not passed the allowed execution window + uint256 extendedCurrentAllowedDate = currentAllowedDate + window; + bool exceedsExecutionWindow = block.timestamp > extendedCurrentAllowedDate; + if (exceedsExecutionWindow) revert TaskTimeLockActive(block.timestamp, extendedCurrentAllowedDate); // Finally set the next allowed date to the corresponding number of months from the current date - _nextAllowedAt = _getNextAllowedDateForMonthlyRelativeFrequency(currentAllowedAt, monthsToAdd); + _nextAllowedAt = _getNextAllowedDate(currentAllowedDate, frequency); } } @@ -190,36 +166,26 @@ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { 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); - - // 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 + } else { + // The other modes can be "on-day" or "on-last-day" where the frequency represents a number of months + // There is no limit for the frequency, it simply cannot be zero 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); + // The allowed date cannot be zero + if (allowedAt == 0) revert TaskInvalidAllowedDate(mode, allowedAt); + + // If the mode is "on-day", the allowed date must be valid for every month, then the allowed day cannot be + // larger than 28. But if the mode is "on-last-day", the allowed date day must be the last day of the month + if (mode == uint8(Mode.OnDay)) { + if (allowedAt.getDay() > 28) revert TaskInvalidAllowedDate(mode, allowedAt); + } else if (mode == uint8(Mode.OnLastMonthDay)) { + if (allowedAt.getDay() != allowedAt.getDaysInMonth()) revert TaskInvalidAllowedDate(mode, allowedAt); + } else { + revert TaskInvalidFrequencyMode(mode); + } } _mode = Mode(mode); @@ -240,15 +206,9 @@ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { } /** - * @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. + * @dev Tells the corresponding allowed date based on a current timestamp */ - function _getCurrentAllowedDateForMonthlyRelativeFrequency(uint256 allowedAt, uint256 day) - private - view - returns (uint256) - { + function _getCurrentAllowedDate(uint256 allowedAt, uint256 day) private view returns (uint256) { (uint256 year, uint256 month, ) = block.timestamp.timestampToDate(); return _getAllowedDateFor(allowedAt, year, month, day); } @@ -256,11 +216,7 @@ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { /** * @dev Tells the next allowed date based on a current allowed date considering a number of months to increase */ - function _getNextAllowedDateForMonthlyRelativeFrequency(uint256 allowedAt, uint256 monthsToIncrease) - private - view - returns (uint256) - { + function _getNextAllowedDate(uint256 allowedAt, uint256 monthsToIncrease) private view returns (uint256) { (uint256 year, uint256 month, uint256 day) = allowedAt.timestampToDate(); uint256 nextMonth = (month + monthsToIncrease) % 12; uint256 nextYear = year + ((month + monthsToIncrease) / 12); diff --git a/packages/tasks/test/base/TimeLockedTask.test.ts b/packages/tasks/test/base/TimeLockedTask.test.ts index 21161a0e..c3f28530 100644 --- a/packages/tasks/test/base/TimeLockedTask.test.ts +++ b/packages/tasks/test/base/TimeLockedTask.test.ts @@ -177,81 +177,15 @@ describe('TimeLockedTask', () => { const mode = MODE.ON_DAY context('when a frequency is given', () => { - context('when the frequency is lower than or equal to 28', () => { - const frequency = 28 - - context('when a window is given', () => { - context('when the window is shorter than the frequency', () => { - const window = frequency * DAY - 1 - - context('when an allowed date is given', () => { - context('when the allowed day matches the frequency', () => { - const allowedAt = new Date(`2023-10-${frequency}`).getTime() / 1000 - - itSetsTheTimeLockProperly(mode, frequency, allowedAt, window) - }) - - context('when the allowed day does not match the frequency', () => { - const allowedAt = new Date(`2023-10-${frequency + 1}`).getTime() / 1000 - - itReverts(mode, frequency, allowedAt, window, 'TaskInvalidAllowedDate') - }) - }) - - context('when no allowed date is given', () => { - const allowedAt = 0 - - itReverts(mode, frequency, allowedAt, window, 'TaskInvalidAllowedDate') - }) - }) - - context('when the window is larger than the frequency', () => { - const window = frequency * DAY + 1 - - itReverts(mode, frequency, 0, window, 'TaskInvalidAllowedWindow') - }) - }) - - context('when no window is given', () => { - const window = 0 - - itReverts(mode, frequency, 0, window, 'TaskInvalidAllowedWindow') - }) - }) - - context('when the frequency is greater than 28', () => { - const frequency = 29 - - itReverts(mode, frequency, 0, 0, 'TaskInvalidFrequency') - }) - }) - - context('when no frequency is given', () => { - const frequency = 0 - - itReverts(mode, frequency, 0, 0, 'TaskInvalidFrequency') - }) - }) - - context('on-last-day mode', () => { - const mode = MODE.ON_LAST_DAY - - context('when a frequency is given', () => { - const frequency = 1 - - itReverts(mode, frequency, 0, 0, 'TaskInvalidFrequency') - }) - - context('when no frequency is given', () => { - const frequency = 0 + const frequency = 10 context('when a window is given', () => { - context('when the window is shorter than 28 days', () => { - const window = 28 * DAY - 1 + context('when the window is shorter than months of 28 days', () => { + const window = frequency * DAY * 28 - 1 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'] + context('when the allowed date day is lower than or equal to 28', () => { + const allowedDates = ['2022-06-01', '2023-10-11', '2021-12-21', '2020-02-28'] allowedDates.forEach((date) => { context(`for ${date}`, () => { @@ -262,8 +196,8 @@ describe('TimeLockedTask', () => { }) }) - context('when the allowed date is not the last day of a month', () => { - const notAllowedDates = ['2022-08-30', '2020-02-28'] + context('when the allowed date day is not greater than 28', () => { + const notAllowedDates = ['2022-08-30', '2032-02-29', '2020-07-31'] notAllowedDates.forEach((date) => { context(`for ${date}`, () => { @@ -282,8 +216,8 @@ describe('TimeLockedTask', () => { }) }) - context('when the window is larger than 28 days', () => { - const window = 28 * DAY + 1 + context('when the window is larger than months of 28 days', () => { + const window = frequency * DAY * 28 + 1 itReverts(mode, frequency, 0, window, 'TaskInvalidAllowedWindow') }) @@ -295,21 +229,27 @@ describe('TimeLockedTask', () => { itReverts(mode, frequency, 0, window, 'TaskInvalidAllowedWindow') }) }) + + context('when no frequency is given', () => { + const frequency = 0 + + itReverts(mode, frequency, 0, 0, 'TaskInvalidFrequency') + }) }) - context('every-month mode', () => { - const mode = MODE.EVERY_X_MONTH + context('on-last-day mode', () => { + const mode = MODE.ON_LAST_DAY context('when a frequency is given', () => { - const frequency = 3 + const frequency = 10 context('when a window is given', () => { context('when the window is shorter than months of 28 days', () => { const window = 28 * DAY * frequency - 1 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'] + 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'] allowedDates.forEach((date) => { context(`for ${date}`, () => { @@ -320,8 +260,8 @@ describe('TimeLockedTask', () => { }) }) - context('when the allowed day is greater than 28', () => { - const notAllowedDates = ['2022-08-30', '2020-02-29', '2023-10-31', '2023-06-30'] + context('when the allowed date is not the last day of a month', () => { + const notAllowedDates = ['2022-08-30', '2020-02-28'] notAllowedDates.forEach((date) => { context(`for ${date}`, () => { @@ -340,7 +280,7 @@ describe('TimeLockedTask', () => { }) }) - context('when the window is larger than months of 28 days', () => { + context('when the window is larger than 28 days', () => { const window = 28 * DAY * frequency + 1 itReverts(mode, frequency, 0, window, 'TaskInvalidAllowedWindow') @@ -484,88 +424,132 @@ describe('TimeLockedTask', () => { context('on-day mode', () => { const mode = MODE.ON_DAY - it('locks the task properly', async () => { - // Move to an executable window - await setInitialTimeLock(mode, 5, '2028-10-05T01:02:03Z', DAY) - await moveToDate('2028-10-05T01:02:03Z') + context('with 1 month frequency', () => { + const frequency = 1 + + it('locks the task properly', async () => { + // Move to an executable window + await setInitialTimeLock(mode, frequency, '2028-10-05T01:02:03Z', DAY) + await moveToDate('2028-10-05T01:02:03Z') + + // It can be executed immediately + await assertItCanBeExecuted('2028-11-05T01:02:03Z') + + // It is locked for at least a month + await assertItCannotBeExecuted('2028-11-05T01:02:03Z') + await moveToDate('2028-10-20T01:02:03Z') + await assertItCannotBeExecuted('2028-11-05T01:02:03Z') + + // It cannot be executed after the execution window + await moveToDate('2028-11-06T01:02:03Z') + await assertItCannotBeExecuted('2028-11-05T01:02:03Z') + + // It can be executed one period after + await moveToDate('2028-12-05T01:02:03Z') + await assertItCanBeExecuted('2029-01-05T01:02:03Z') + }) + }) + + context('with 2 months frequency', () => { + const frequency = 2 + + it('locks the task properly', async () => { + // Move to an executable window + await setInitialTimeLock(mode, frequency, '2032-01-01T10:05:20Z', DAY) + await moveToDate('2032-01-01T10:05:20Z') - // It can be executed immediately - await assertItCanBeExecuted('2028-11-05T01:02:03Z') + // It can be executed immediately + await assertItCanBeExecuted('2032-03-01T10:05:20Z') + + // It is locked for at least the number of set months + await assertItCannotBeExecuted('2032-03-01T10:05:20Z') + await moveToDate('2032-02-01T10:05:20Z') + await assertItCannotBeExecuted('2032-03-01T10:05:20Z') + await moveToDate('2032-02-28T10:05:20Z') + await assertItCannotBeExecuted('2032-03-01T10:05:20Z') + + // It cannot be executed after the execution window + await moveToDate('2032-03-02T10:05:21Z') + await assertItCannotBeExecuted('2032-03-01T10:05:20Z') - // It is locked for at least a month - await assertItCannotBeExecuted('2028-11-05T01:02:03Z') - await moveToDate('2028-10-20T01:02:03Z') - await assertItCannotBeExecuted('2028-11-05T01:02:03Z') + // It can be executed one period after + await moveToDate('2032-05-02T10:05:19Z') + await assertItCanBeExecuted('2032-07-01T10:05:20Z') - // It cannot be executed after the execution window - await moveToDate('2028-11-06T01:02:03Z') - await assertItCannotBeExecuted('2028-11-05T01:02:03Z') + // Change time lock to 24 months + await setInitialTimeLock(mode, 24, '2033-01-01T05:04:03Z', DAY) + await assertItCannotBeExecuted('2033-01-01T05:04:03Z') - // It can be executed one period after - await moveToDate('2028-12-05T01:02:03Z') - await assertItCanBeExecuted('2029-01-05T01:02:03Z') + // Move to an executable window + await moveToDate('2033-01-01T05:04:03Z') + await assertItCanBeExecuted('2035-01-01T05:04:03Z') + }) }) }) context('on-last-day mode', () => { const mode = MODE.ON_LAST_DAY - it('locks the task properly', async () => { - // Move to an executable window - await setInitialTimeLock(mode, 0, '2030-10-31T10:32:20Z', DAY) - await moveToDate('2030-10-31T10:32:20Z') + context('with 1 month frequency', () => { + const frequency = 1 - // It can be executed immediately - await assertItCanBeExecuted('2030-11-30T10:32:20Z') + it('locks the task properly', async () => { + // Move to an executable window + await setInitialTimeLock(mode, frequency, '2030-10-31T10:32:20Z', DAY) + await moveToDate('2030-10-31T10:32:20Z') + + // It can be executed immediately + await assertItCanBeExecuted('2030-11-30T10:32:20Z') - // It is locked for at least a month - await assertItCannotBeExecuted('2030-11-30T10:32:20Z') - await moveToDate('2030-11-20T10:32:20Z') - await assertItCannotBeExecuted('2030-11-30T10:32:20Z') + // It is locked for at least a month + await assertItCannotBeExecuted('2030-11-30T10:32:20Z') + await moveToDate('2030-11-20T10:32:20Z') + await assertItCannotBeExecuted('2030-11-30T10:32:20Z') - // It cannot be executed after the execution window - await moveToDate('2031-01-01T10:32:20Z') - await assertItCannotBeExecuted('2030-11-30T10:32:20Z') + // It cannot be executed after the execution window + await moveToDate('2031-01-01T10:32:20Z') + await assertItCannotBeExecuted('2030-11-30T10:32:20Z') - // It can be executed one period after - await moveToDate('2031-01-31T10:32:20Z') - await assertItCanBeExecuted('2031-02-28T10:32:20Z') + // It can be executed one period after + await moveToDate('2031-01-31T10:32:20Z') + await assertItCanBeExecuted('2031-02-28T10:32:20Z') + }) }) - }) - context('every-month mode', () => { - const mode = MODE.EVERY_X_MONTH + context('with 3 months frequency', () => { + const frequency = 3 - it('locks the task properly', async () => { - // Move to an executable window - await setInitialTimeLock(mode, 2, '2032-01-01T10:05:20Z', DAY) - await moveToDate('2032-01-01T10:05:20Z') + it('locks the task properly', async () => { + // Move to an executable window + await setInitialTimeLock(mode, frequency, '2032-01-31T10:05:20Z', DAY) + await moveToDate('2032-01-31T10:05:20Z') - // It can be executed immediately - await assertItCanBeExecuted('2032-03-01T10:05:20Z') + // It can be executed immediately + await assertItCanBeExecuted('2032-04-30T10:05:20Z') - // It is locked for at least the number of set months - await assertItCannotBeExecuted('2032-03-01T10:05:20Z') - await moveToDate('2032-02-01T10:05:20Z') - await assertItCannotBeExecuted('2032-03-01T10:05:20Z') - await moveToDate('2032-02-28T10:05:20Z') - await assertItCannotBeExecuted('2032-03-01T10:05:20Z') + // It is locked for at least the number of set months + await assertItCannotBeExecuted('2032-04-30T10:05:20Z') + await moveToDate('2032-02-28T10:05:20Z') + await assertItCannotBeExecuted('2032-04-30T10:05:20Z') + await moveToDate('2032-03-31T10:05:20Z') + await assertItCannotBeExecuted('2032-04-30T10:05:20Z') - // It cannot be executed after the execution window - await moveToDate('2032-03-02T10:05:21Z') - await assertItCannotBeExecuted('2032-03-01T10:05:20Z') + // It cannot be executed after the execution window + await moveToDate('2032-05-01T10:05:20Z') + await assertItCannotBeExecuted('2032-04-30T10:05:20Z') - // It can be executed one period after - await moveToDate('2032-05-02T10:05:19Z') - await assertItCanBeExecuted('2032-07-01T10:05:20Z') + // It can be executed one period after + await moveToDate('2032-06-30T10:05:20Z') + await assertItCanBeExecuted('2032-09-30T10:05:20Z') - // Change time lock to 24 months - await setInitialTimeLock(mode, 24, '2033-01-01T05:04:03Z', DAY) - await assertItCannotBeExecuted('2033-01-01T05:04:03Z') + // Change time lock to 24 months + await setInitialTimeLock(mode, 24, '2033-01-31T05:04:03Z', DAY) + await assertItCannotBeExecuted('2033-01-31T05:04:03Z') - // Move to an executable window - await moveToDate('2033-01-01T05:04:03Z') - await assertItCanBeExecuted('2035-01-01T05:04:03Z') + // Move to an executable window + await moveToDate('2033-01-31T05:04:03Z') + await assertItCanBeExecuted('2035-01-31T05:04:03Z') + }) }) }) }) From 7493676a60f4f20cbe9acbd82d13df5e4977db8e Mon Sep 17 00:00:00 2001 From: Facu Spagnuolo Date: Thu, 19 Oct 2023 09:59:10 -0300 Subject: [PATCH 7/8] tasks: contemplate current allowed date --- .../tasks/contracts/base/TimeLockedTask.sol | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/tasks/contracts/base/TimeLockedTask.sol b/packages/tasks/contracts/base/TimeLockedTask.sol index 0ddf668b..0639e7fc 100644 --- a/packages/tasks/contracts/base/TimeLockedTask.sol +++ b/packages/tasks/contracts/base/TimeLockedTask.sol @@ -129,18 +129,23 @@ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { _nextAllowedAt = allowedAt + ((periods + 1) * frequency); } } else { - // Check the current timestamp is not before the current allowed date - uint256 currentAllowedDateDay = mode == Mode.OnDay ? allowedAt.getDay() : block.timestamp.getDaysInMonth(); - uint256 currentAllowedDate = _getCurrentAllowedDate(allowedAt, currentAllowedDateDay); - if (block.timestamp < currentAllowedDate) revert TaskTimeLockActive(block.timestamp, currentAllowedDate); - - // Check the current timestamp has not passed the allowed execution window - uint256 extendedCurrentAllowedDate = currentAllowedDate + window; - bool exceedsExecutionWindow = block.timestamp > extendedCurrentAllowedDate; - if (exceedsExecutionWindow) revert TaskTimeLockActive(block.timestamp, extendedCurrentAllowedDate); - - // Finally set the next allowed date to the corresponding number of months from the current date - _nextAllowedAt = _getNextAllowedDate(currentAllowedDate, frequency); + if (block.timestamp >= allowedAt && block.timestamp <= allowedAt + window) { + // Check the current timestamp has not passed the allowed at set + _nextAllowedAt = _getNextAllowedDate(allowedAt, frequency); + } else { + // Check the current timestamp is not before the current allowed date + uint256 currentAllowedDay = mode == Mode.OnDay ? allowedAt.getDay() : block.timestamp.getDaysInMonth(); + uint256 currentAllowedAt = _getCurrentAllowedDate(allowedAt, currentAllowedDay); + if (block.timestamp < currentAllowedAt) revert TaskTimeLockActive(block.timestamp, currentAllowedAt); + + // Check the current timestamp has not passed the allowed execution window + uint256 extendedCurrentAllowedAt = currentAllowedAt + window; + bool exceedsExecutionWindow = block.timestamp > extendedCurrentAllowedAt; + if (exceedsExecutionWindow) revert TaskTimeLockActive(block.timestamp, extendedCurrentAllowedAt); + + // Finally set the next allowed date to the corresponding number of months from the current date + _nextAllowedAt = _getNextAllowedDate(currentAllowedAt, frequency); + } } } From 0afb342d6da6044d52cb8bb84e1d1889148b7dbc Mon Sep 17 00:00:00 2001 From: Facu Spagnuolo Date: Thu, 19 Oct 2023 13:50:16 -0300 Subject: [PATCH 8/8] tasks: extract variable --- packages/tasks/contracts/base/TimeLockedTask.sol | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/tasks/contracts/base/TimeLockedTask.sol b/packages/tasks/contracts/base/TimeLockedTask.sol index 0639e7fc..2f5b6297 100644 --- a/packages/tasks/contracts/base/TimeLockedTask.sol +++ b/packages/tasks/contracts/base/TimeLockedTask.sol @@ -130,7 +130,7 @@ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { } } else { if (block.timestamp >= allowedAt && block.timestamp <= allowedAt + window) { - // Check the current timestamp has not passed the allowed at set + // Check the current timestamp has not passed the allowed date set _nextAllowedAt = _getNextAllowedDate(allowedAt, frequency); } else { // Check the current timestamp is not before the current allowed date @@ -223,8 +223,9 @@ abstract contract TimeLockedTask is ITimeLockedTask, Authorized { */ function _getNextAllowedDate(uint256 allowedAt, uint256 monthsToIncrease) private view returns (uint256) { (uint256 year, uint256 month, uint256 day) = allowedAt.timestampToDate(); - uint256 nextMonth = (month + monthsToIncrease) % 12; - uint256 nextYear = year + ((month + monthsToIncrease) / 12); + uint256 increasedMonth = month + monthsToIncrease; + uint256 nextMonth = increasedMonth % 12; + uint256 nextYear = year + (increasedMonth / 12); uint256 nextDay = _mode == Mode.OnLastMonthDay ? DateTime._getDaysInMonth(nextYear, nextMonth) : day; return _getAllowedDateFor(allowedAt, nextYear, nextMonth, nextDay); }