From aa6f9cf7d762b8b4de06cc21b9a15e70dd7de733 Mon Sep 17 00:00:00 2001 From: Seth Charles Date: Tue, 8 Mar 2022 18:55:25 -0600 Subject: [PATCH] Add Datetime utilities (#1531) Add date and datetime utility functions to convert from civil date time to Unix time. --- contracts/mocks/DatetimeMock.sol | 26 ++++++ contracts/utils/Datetime.sol | 115 +++++++++++++++++++++++++ docs/modules/ROOT/pages/utilities.adoc | 31 ++++++- test/utils/Datetime.test.js | 79 +++++++++++++++++ 4 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 contracts/mocks/DatetimeMock.sol create mode 100644 contracts/utils/Datetime.sol create mode 100644 test/utils/Datetime.test.js diff --git a/contracts/mocks/DatetimeMock.sol b/contracts/mocks/DatetimeMock.sol new file mode 100644 index 00000000000..6e802b3cfb3 --- /dev/null +++ b/contracts/mocks/DatetimeMock.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.0; + +import "../utils/Datetime.sol"; + +contract DatetimeMock { + function date( + int32 _year, + uint8 _month, + uint8 _day + ) external pure returns (int256) { + return Datetime.date(_year, _month, _day); + } + + function datetime( + int32 _year, + uint8 _month, + uint8 _day, + uint8 _hour, + uint8 _minute, + uint8 _second + ) public pure returns (int256) { + return Datetime.datetime(_year, _month, _day, _hour, _minute, _second); + } +} diff --git a/contracts/utils/Datetime.sol b/contracts/utils/Datetime.sol new file mode 100644 index 00000000000..d396becd49f --- /dev/null +++ b/contracts/utils/Datetime.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts v4.4.1 (utils/Datetime.sol) + +pragma solidity ^0.8.0; + +/// @title Datetime utility functions +/// @author Seth Charles (@lesserhatch) +/// @dev All function calls are currently implemented without side effects +library Datetime { + /// @notice Convert civil date to Unix time + /// @param _year Civil year where negative years are prior to 0 A.D. + /// @param _month Numeric month representation in the range [1, 12] + /// @param _day Day of month + /// @dev Invalid dates will revert + /// @return Number of seconds elapsed since the Unix epoch + function date( + int32 _year, + uint32 _month, + uint32 _day + ) public pure returns (int256) { + _validDate(_year, _month, _day); + return _daysFromCivil(_year, _month, _day) * 1 days; + } + + /// @notice Convert civil date and time to Unix time + /// @param _year Civil year where negative years are prior to 0 A.D. + /// @param _month Numeric month representation in the range [1, 12] + /// @param _day Day of month + /// @param _hour Hour of day in the range [0, 23] + /// @param _minute Minute of hour in the range [0, 59] + /// @param _second Second of minute in the range of [0, 59] + /// @dev Invalid dates and times will revert + /// @return Number of seconds elapsed since the Unix epoch + function datetime( + int32 _year, + uint32 _month, + uint32 _day, + uint32 _hour, + uint32 _minute, + uint32 _second + ) public pure returns (int256) { + _validTime(_hour, _minute, _second); + return + date(_year, _month, _day) + + int256(int32(_hour * 1 hours)) + + int256(int32(_minute * 1 minutes)) + + int256(int32(_second * 1 seconds)); + } + + function _daysFromCivil( + int256 _year, + uint256 _month, + uint256 _day + ) private pure returns (int256) { + // Inspired by the days_from_civil algorithm at https://howardhinnant.github.io/date_algorithms.html + if (_month <= 2) { + _year = _year - 1; + } + + int256 era; + if (_year >= 0) { + era = _year; + } else { + era = _year - 399; + } + era = era / 400; + + uint256 yoe = uint256(_year - era * 400); + + uint256 doy = 153; + if (_month > 2) { + doy = doy * (_month - 3); + } else { + doy = doy * (_month + 9); + } + doy = (doy + 2) / 5 + (_day - 1); + + uint256 doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + + return era * 146097 + int256(doe) - 719468; + } + + function _isLeapYear(int32 _year) private pure returns (bool) { + return (_year % 4 == 0 && (_year % 100 != 0 || _year % 400 == 0)); + } + + function _validDate( + int32 _year, + uint32 _month, + uint32 _day + ) private pure { + require(_month > 0 && _month <= 12, "Month must be in [1, 12]"); + require(_day > 0 && _day <= 31, "Invalid day"); + if (_month == 2) { + if (_isLeapYear(_year)) { + require(_day <= 29, "Invalid day"); + } else { + require(_day <= 28, "Invalid day"); + } + } + if ((_month < 8 && _month % 2 == 0) || (_month >= 8 && _month % 2 == 1)) { + require(_day <= 30, "Invalid day"); + } + } + + function _validTime( + uint32 _hour, + uint32 _minute, + uint32 _second + ) private pure { + require(_hour < 24, "Invalid hour"); + require(_minute < 60, "Invalid minute"); + require(_second < 60, "Invalid second"); + } +} diff --git a/docs/modules/ROOT/pages/utilities.adoc b/docs/modules/ROOT/pages/utilities.adoc index 2f23ceb70b0..7a7a2fab76d 100644 --- a/docs/modules/ROOT/pages/utilities.adoc +++ b/docs/modules/ROOT/pages/utilities.adoc @@ -101,7 +101,7 @@ Want to keep track of some numbers that increment by 1 every time you want anoth === Base64 -xref:api:utils.adoc#Base64[`Base64`] util allows you to transform `bytes32` data into its Base64 `string` representation. +xref:api:utils.adoc#Base64[`Base64`] util allows you to transform `bytes32` data into its Base64 `string` representation. This is specially useful to build URL-safe tokenURIs for both xref:api:token/ERC721.adoc#IERC721Metadata-tokenURI-uint256-[`ERC721`] or xref:api:token/ERC1155.adoc#IERC1155MetadataURI-uri-uint256-[`ERC1155`]. This library provides a clever way to serve URL-safe https://developer.mozilla.org/docs/Web/HTTP/Basics_of_HTTP/Data_URIs/[Data URI] compliant strings to serve on-chain data structures. @@ -120,7 +120,7 @@ contract My721Token is ERC721 { using Strings for uint256; constructor() ERC721("My721Token", "MTK") {} - + ... function tokenURI(uint256 tokenId) @@ -138,7 +138,7 @@ contract My721Token is ERC721 { return string( abi.encodePacked( - "data:application/json;base64,", + "data:application/json;base64,", Base64.encode(dataURI) ) ); @@ -184,3 +184,28 @@ await instance.multicall([ instance.contract.methods.bar().encodeABI() ]); ---- + +=== Datetime + +xref:api:utils.adoc#Datetime[`Datetime`] util allows you to convert human readable dates and times into unix time. + +Consider this example demonstrating datetime: +[source, solidity] +---- +// contracts/Widget.sol +// SPDX-License-Identifier: MIT + +import "@openzeppelin/contracts/utils/Datetime.sol"; + +contract Widget { + int256 _birthday; + + function setBirthday( + int32 _year, + uint8 _month, + uint8 _day + ) public { + _birthday = Datetime.date(_year, _month, _day); + } +} +---- \ No newline at end of file diff --git a/test/utils/Datetime.test.js b/test/utils/Datetime.test.js new file mode 100644 index 00000000000..bda6f8976b4 --- /dev/null +++ b/test/utils/Datetime.test.js @@ -0,0 +1,79 @@ +const { expectRevert } = require('@openzeppelin/test-helpers'); +const { expect } = require('chai'); + +const Datetime = artifacts.require('Datetime'); +const DatetimeMock = artifacts.require('DatetimeMock'); + +const seconds = 1; +const minutes = 60 * seconds; +const hours = 60 * minutes; +const days = 24 * hours; + +contract('Datetime', function () { + before(async function () { + const datetimeLib = await Datetime.new(); + await DatetimeMock.link(datetimeLib); + }); + + beforeEach(async function () { + this.datetime = await DatetimeMock.new(); + }); + + describe('leap day', function () { + it('year not divisible by 4 is not a leap year', async function () { + await expectRevert(this.datetime.date(1970, 2, 29), 'Invalid day'); + }); + + it('test first leap day after unix epoch', async function () { + expect(await this.datetime.date(1972, 2, 29)).to.be.bignumber.equal(`${(365 + 365 + 31 + 28) * days}`); + expect(await this.datetime.date(1972, 3, 1)).to.be.bignumber.equal(`${(365 + 365 + 31 + 29) * days}`); + }); + + it('year divisible by 400 is a leap year', async function () { + expect(await this.datetime.date(2000, 2, 29)).to.be.bignumber.equal('951782400'); + }); + + it('year divisible by 100 is not a leap year', async function () { + await expectRevert(this.datetime.date(2100, 2, 29), 'Invalid day'); + }); + }); + + describe('invalid dates and times', function () { + it('cannot have more than 24 hours in a day', async function () { + await expectRevert(this.datetime.datetime(1970, 1, 1, 25, 0, 0), 'Invalid hour'); + }); + + it('cannot have more than 60 minutes in an hour', async function () { + await expectRevert(this.datetime.datetime(1970, 1, 1, 0, 60, 0), 'Invalid minute'); + }); + + it('cannot have more than 60 seconds in a minute', async function () { + await expectRevert(this.datetime.datetime(1970, 1, 1, 0, 0, 60), 'Invalid second'); + }); + + it('months cannot have extra days', async function () { + await expectRevert(this.datetime.date(1970, 1, 32), 'Invalid day'); + await expectRevert(this.datetime.date(1970, 2, 29), 'Invalid day'); + await expectRevert(this.datetime.date(1970, 3, 32), 'Invalid day'); + await expectRevert(this.datetime.date(1970, 4, 31), 'Invalid day'); + await expectRevert(this.datetime.date(1970, 5, 32), 'Invalid day'); + await expectRevert(this.datetime.date(1970, 6, 31), 'Invalid day'); + await expectRevert(this.datetime.date(1970, 7, 32), 'Invalid day'); + await expectRevert(this.datetime.date(1970, 8, 32), 'Invalid day'); + await expectRevert(this.datetime.date(1970, 9, 31), 'Invalid day'); + await expectRevert(this.datetime.date(1970, 10, 32), 'Invalid day'); + await expectRevert(this.datetime.date(1970, 11, 31), 'Invalid day'); + await expectRevert(this.datetime.date(1970, 12, 32), 'Invalid day'); + }); + }); + + describe('2038 bug', function () { + it('2038-01-19 3:14:07 is Y2K38', async function () { + expect(await this.datetime.datetime(2038, 1, 19, 3, 14, 7)).to.be.bignumber.equal('2147483647'); + }); + + it('2038-01-19 3:14:08 is 2**31', async function () { + expect(await this.datetime.datetime(2038, 1, 19, 3, 14, 8)).to.be.bignumber.equal('2147483648'); + }); + }); +});