diff --git a/contracts/token/TokenTimelock.sol b/contracts/token/TokenTimelock.sol new file mode 100644 index 000000000..5b661962d --- /dev/null +++ b/contracts/token/TokenTimelock.sol @@ -0,0 +1,41 @@ +pragma solidity ^0.4.11; + + +import './ERC20Basic.sol'; + +/** + * @title TokenTimelock + * @dev TokenTimelock is a token holder contract that will allow a + * beneficiary to extract the tokens after a time has passed + */ +contract TokenTimelock { + + // ERC20 basic token contract being held + ERC20Basic token; + + // beneficiary of tokens after they are released + address beneficiary; + + // timestamp where token release is enabled + uint releaseTime; + + function TokenTimelock(ERC20Basic _token, address _beneficiary, uint _releaseTime) { + require(_releaseTime > now); + token = _token; + beneficiary = _beneficiary; + releaseTime = _releaseTime; + } + + /** + * @dev beneficiary claims tokens held by time lock + */ + function claim() { + require(msg.sender == beneficiary); + require(now >= releaseTime); + + uint amount = token.balanceOf(this); + require(amount > 0); + + token.transfer(beneficiary, amount); + } +} diff --git a/package.json b/package.json index e182124b5..520f56ef7 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,13 @@ "babel-preset-stage-2": "^6.18.0", "babel-preset-stage-3": "^6.17.0", "babel-register": "^6.23.0", + "chai": "^4.0.2", + "chai-as-promised": "^7.0.0", + "chai-bignumber": "^2.0.0", "coveralls": "^2.13.1", "ethereumjs-testrpc": "^3.0.2", "mocha-lcov-reporter": "^1.3.0", + "moment": "^2.18.1", "solidity-coverage": "^0.1.0", "truffle": "3.2.2" } diff --git a/test/TokenTimelock.js b/test/TokenTimelock.js new file mode 100644 index 000000000..894ebae92 --- /dev/null +++ b/test/TokenTimelock.js @@ -0,0 +1,58 @@ +const BigNumber = web3.BigNumber + +require('chai') + .use(require('chai-as-promised')) + .use(require('chai-bignumber')(BigNumber)) + .should() + +import moment from 'moment' + +import latestTime from './helpers/latestTime' +import increaseTime from './helpers/increaseTime' + +const MintableToken = artifacts.require('MintableToken') +const TokenTimelock = artifacts.require('TokenTimelock') + +contract('TokenTimelock', function ([_, owner, beneficiary]) { + + const amount = new BigNumber(100) + + beforeEach(async function () { + this.token = await MintableToken.new({from: owner}) + this.releaseTime = latestTime().add(1, 'year').unix() + this.timelock = await TokenTimelock.new(this.token.address, beneficiary, this.releaseTime) + await this.token.mint(this.timelock.address, amount, {from: owner}) + }) + + it('cannot be claimed before time limit', async function () { + await this.timelock.claim({from: beneficiary}).should.be.rejected + }) + + it('cannot be claimed just before time limit', async function () { + await increaseTime(moment.duration(0.99, 'year')) + await this.timelock.claim({from: beneficiary}).should.be.rejected + }) + + it('can be claimed just after limit', async function () { + await increaseTime(moment.duration(1.01, 'year')) + await this.timelock.claim({from: beneficiary}).should.be.fulfilled + const balance = await this.token.balanceOf(beneficiary) + balance.should.be.bignumber.equal(amount) + }) + + it('can be claimed after time limit', async function () { + await increaseTime(moment.duration(2, 'year')) + await this.timelock.claim({from: beneficiary}).should.be.fulfilled + const balance = await this.token.balanceOf(beneficiary) + balance.should.be.bignumber.equal(amount) + }) + + it('cannot be claimed twice', async function () { + await increaseTime(moment.duration(2, 'year')) + await this.timelock.claim({from: beneficiary}).should.be.fulfilled + await this.timelock.claim({from: beneficiary}).should.be.rejected + const balance = await this.token.balanceOf(beneficiary) + balance.should.be.bignumber.equal(amount) + }) + +}) diff --git a/test/helpers/increaseTime.js b/test/helpers/increaseTime.js new file mode 100644 index 000000000..cd1a756fd --- /dev/null +++ b/test/helpers/increaseTime.js @@ -0,0 +1,23 @@ +// Increases testrpc time by the passed duration (a moment.js instance) +export default function increaseTime(duration) { + const id = Date.now() + + return new Promise((resolve, reject) => { + web3.currentProvider.sendAsync({ + jsonrpc: '2.0', + method: 'evm_increaseTime', + params: [duration.asSeconds()], + id: id, + }, err1 => { + if (err1) return reject(err1) + + web3.currentProvider.sendAsync({ + jsonrpc: '2.0', + method: 'evm_mine', + id: id+1, + }, (err2, res) => { + return err2 ? reject(err2) : resolve(res) + }) + }) + }) +} diff --git a/test/helpers/latestTime.js b/test/helpers/latestTime.js new file mode 100644 index 000000000..1461a227c --- /dev/null +++ b/test/helpers/latestTime.js @@ -0,0 +1,6 @@ +import moment from 'moment' + +// Returns a moment.js instance representing the time of the last mined block +export default function latestTime() { + return moment.unix(web3.eth.getBlock('latest').timestamp) +}