diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e864ab13..93d08b038 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * `ERC20Metadata`: added internal `_setTokenURI(string memory tokenURI)`. ([#1618](https://github.com/OpenZeppelin/openzeppelin-solidity/pull/1618)) * `ERC20Snapshot`: create snapshots on demand of the token balances and total supply, to later retrieve and e.g. calculate dividends at a past time. ([#1617](https://github.com/OpenZeppelin/openzeppelin-solidity/pull/1617)) * `SafeERC20`: `ERC20` contracts with no return value (i.e. that revert on failure) are now supported. ([#1655](https://github.com/OpenZeppelin/openzeppelin-solidity/pull/)) + * `TimedCrowdsale`: added internal `_extendTime(uint256 newClosingTime)` as well as `TimedCrowdsaleExtended(uint256 prevClosingTime, uint256 newClosingTime)` event allowing to extend the crowdsale, as long as it hasn't already closed. ### Improvements: * Upgraded the minimum compiler version to v0.5.2: this removes many Solidity warnings that were false positives. ([#1606](https://github.com/OpenZeppelin/openzeppelin-solidity/pull/1606)) diff --git a/contracts/crowdsale/validation/TimedCrowdsale.sol b/contracts/crowdsale/validation/TimedCrowdsale.sol index 3ce60e4b5..f8505f6f0 100644 --- a/contracts/crowdsale/validation/TimedCrowdsale.sol +++ b/contracts/crowdsale/validation/TimedCrowdsale.sol @@ -13,6 +13,13 @@ contract TimedCrowdsale is Crowdsale { uint256 private _openingTime; uint256 private _closingTime; + /** + * Event for crowdsale extending + * @param newClosingTime new closing time + * @param prevClosingTime old closing time + */ + event TimedCrowdsaleExtended(uint256 prevClosingTime, uint256 newClosingTime); + /** * @dev Reverts if not in crowdsale time range. */ @@ -74,4 +81,16 @@ contract TimedCrowdsale is Crowdsale { function _preValidatePurchase(address beneficiary, uint256 weiAmount) internal onlyWhileOpen view { super._preValidatePurchase(beneficiary, weiAmount); } + + /** + * @dev Extend crowdsale + * @param newClosingTime Crowdsale closing time + */ + function _extendTime(uint256 newClosingTime) internal { + require(!hasClosed()); + require(newClosingTime > _closingTime); + + emit TimedCrowdsaleExtended(_closingTime, newClosingTime); + _closingTime = newClosingTime; + } } diff --git a/contracts/mocks/TimedCrowdsaleImpl.sol b/contracts/mocks/TimedCrowdsaleImpl.sol index ecf07f245..33e5cc6a4 100644 --- a/contracts/mocks/TimedCrowdsaleImpl.sol +++ b/contracts/mocks/TimedCrowdsaleImpl.sol @@ -11,4 +11,8 @@ contract TimedCrowdsaleImpl is TimedCrowdsale { { // solhint-disable-previous-line no-empty-blocks } + + function extendTime(uint256 closingTime) public { + _extendTime(closingTime); + } } diff --git a/test/crowdsale/TimedCrowdsale.test.js b/test/crowdsale/TimedCrowdsale.test.js index b68ea3e8b..be42ba0ff 100644 --- a/test/crowdsale/TimedCrowdsale.test.js +++ b/test/crowdsale/TimedCrowdsale.test.js @@ -1,4 +1,4 @@ -const { BN, ether, shouldFail, time } = require('openzeppelin-test-helpers'); +const { BN, ether, expectEvent, shouldFail, time } = require('openzeppelin-test-helpers'); const TimedCrowdsaleImpl = artifacts.require('TimedCrowdsaleImpl'); const SimpleToken = artifacts.require('SimpleToken'); @@ -73,5 +73,62 @@ contract('TimedCrowdsale', function ([_, investor, wallet, purchaser]) { await shouldFail.reverting(this.crowdsale.buyTokens(investor, { value: value, from: purchaser })); }); }); + + describe('extending closing time', function () { + it('should not reduce duration', async function () { + // Same date + await shouldFail.reverting(this.crowdsale.extendTime(this.closingTime)); + + // Prescending date + const newClosingTime = this.closingTime.sub(time.duration.seconds(1)); + await shouldFail.reverting(this.crowdsale.extendTime(newClosingTime)); + }); + + context('before crowdsale start', function () { + beforeEach(async function () { + (await this.crowdsale.isOpen()).should.equal(false); + await shouldFail.reverting(this.crowdsale.send(value)); + }); + + it('it extends end time', async function () { + const newClosingTime = this.closingTime.add(time.duration.days(1)); + const { logs } = await this.crowdsale.extendTime(newClosingTime); + expectEvent.inLogs(logs, 'TimedCrowdsaleExtended', { + prevClosingTime: this.closingTime, + newClosingTime: newClosingTime, + }); + (await this.crowdsale.closingTime()).should.be.bignumber.equal(newClosingTime); + }); + }); + + context('after crowdsale start', function () { + beforeEach(async function () { + await time.increaseTo(this.openingTime); + (await this.crowdsale.isOpen()).should.equal(true); + await this.crowdsale.send(value); + }); + + it('it extends end time', async function () { + const newClosingTime = this.closingTime.add(time.duration.days(1)); + const { logs } = await this.crowdsale.extendTime(newClosingTime); + expectEvent.inLogs(logs, 'TimedCrowdsaleExtended', { + prevClosingTime: this.closingTime, + newClosingTime: newClosingTime, + }); + (await this.crowdsale.closingTime()).should.be.bignumber.equal(newClosingTime); + }); + }); + + context('after crowdsale end', function () { + beforeEach(async function () { + await time.increaseTo(this.afterClosingTime); + }); + + it('it reverts', async function () { + const newClosingTime = await time.latest(); + await shouldFail.reverting(this.crowdsale.extendTime(newClosingTime)); + }); + }); + }); }); });