From 6f2de73005727a22d50ba94c834f00045f67c887 Mon Sep 17 00:00:00 2001 From: Marco Argentieri Date: Mon, 16 Oct 2017 17:25:58 +0200 Subject: [PATCH] Refactor folder and add TestAgiCrowdsale --- dao/contracts/AgiCrowdsale.sol | 39 ---- dao/contracts/foundation/AgiCrowdsale.sol | 251 ++++++++++++++++++++++ dao/test/TestAgiCrowdsale.js | 130 ++++++----- dao/test/helpers/AgiCrowdsaleMock.sol | 21 ++ dao/test/helpers/advanceToBlock.js | 22 -- dao/test/helpers/assertFail.js | 9 + dao/test/helpers/ether.js | 3 - dao/test/helpers/increaseTime.js | 48 ----- dao/test/helpers/latestTime.js | 14 +- 9 files changed, 356 insertions(+), 181 deletions(-) delete mode 100644 dao/contracts/AgiCrowdsale.sol create mode 100644 dao/contracts/foundation/AgiCrowdsale.sol create mode 100644 dao/test/helpers/AgiCrowdsaleMock.sol delete mode 100644 dao/test/helpers/advanceToBlock.js create mode 100644 dao/test/helpers/assertFail.js delete mode 100644 dao/test/helpers/ether.js delete mode 100644 dao/test/helpers/increaseTime.js diff --git a/dao/contracts/AgiCrowdsale.sol b/dao/contracts/AgiCrowdsale.sol deleted file mode 100644 index 7d4aacd..0000000 --- a/dao/contracts/AgiCrowdsale.sol +++ /dev/null @@ -1,39 +0,0 @@ -pragma solidity ^0.4.15; - -import './tokens/SingularityNetToken.sol'; -import 'zeppelin-solidity/contracts/crowdsale/CappedCrowdsale.sol'; -import "zeppelin-solidity/contracts/crowdsale/RefundableCrowdsale.sol"; -import 'zeppelin-solidity/contracts/token/MintableToken.sol'; - - -/** - * @title SampleCrowdsaleToken - * @dev Very simple ERC20 Token that can be minted. - * It is meant to be used in a crowdsale contract. - */ -contract AgiToken is MintableToken { - - string public constant NAME = "Artificial General Intelligence token"; - string public constant SYMBOL = "AGI"; - uint8 public constant DECIMALS = 18; - -} - -contract AgiCrowdsale is CappedCrowdsale, RefundableCrowdsale { - - function AgiCrowdsale(uint256 _startTime, uint256 _endTime, uint256 _rate, uint256 _goal, uint256 _cap, address _wallet) - CappedCrowdsale(_cap) - FinalizableCrowdsale() - RefundableCrowdsale(_goal) - Crowdsale(_startTime, _endTime, _rate, _wallet) - { - //As goal needs to be met for a successful crowdsale - //the value needs to less or equal than a cap which is limit for accepted funds - require(_goal <= _cap); - } - - function createTokenContract() internal returns (MintableToken) { - return new AgiToken(); - } - -} diff --git a/dao/contracts/foundation/AgiCrowdsale.sol b/dao/contracts/foundation/AgiCrowdsale.sol new file mode 100644 index 0000000..9a08460 --- /dev/null +++ b/dao/contracts/foundation/AgiCrowdsale.sol @@ -0,0 +1,251 @@ +pragma solidity ^0.4.15; + +import 'zeppelin-solidity/contracts/ownership/Ownable.sol'; +import 'zeppelin-solidity/contracts/math/SafeMath.sol'; +import 'zeppelin-solidity/contracts/crowdsale/RefundVault.sol'; +import 'zeppelin-solidity/contracts/token/StandardToken.sol'; + +/** + * @title AgiCrowdsale + * @dev Modified from OpenZeppelin's Crowdsale.sol, RefundableCrowdsale.sol, + * CappedCrowdsale.sol, and FinalizableCrowdsale.sol + * Uses PausableToken rather than MintableToken. + * + * Requires that tokens for sale (entire supply minus team's portion) be deposited. + */ +contract AgiCrowdsale is Ownable { + using SafeMath for uint256; + + // Token allocations + mapping (address => uint256) public allocations; + + // Whitelisted investors + mapping (address => bool) public whitelist; + + // manual early close flag + bool public isFinalized = false; + + // cap for crowdsale in wei + uint256 public cap; + + // minimum amount of funds to be raised in weis + uint256 public goal; + + // refund vault used to hold funds while crowdsale is running + RefundVault public vault; + + // The token being sold + StandardToken public token; + + // start and end timestamps where contributions are allowed (both inclusive) + uint256 public startTime; + uint256 public endTime; + + // address where funds are collected + address public wallet; + + // address to hold team / advisor tokens until vesting complete + address public safe; + + // how many token units a buyer gets per wei + uint256 public rate; + + // amount of raised money in wei + uint256 public weiRaised; + + /** + * event for token purchase logging + * @param purchaser who paid for the tokens + * @param beneficiary who got the tokens + * @param value weis paid for purchase + * @param amount amount of tokens purchased + */ + event TokenPurchase(address indexed purchaser, address indexed beneficiary, uint256 value, uint256 amount); + + /** + * event for token redemption logging + * @param beneficiary who got the tokens + * @param amount amount of tokens redeemed + */ + event TokenRedeem(address indexed beneficiary, uint256 amount); + + // termination early or otherwise + event Finalized(); + + function AgiCrowdsale(address _token, uint256 _startTime, uint256 _endTime, uint256 _rate, uint256 _cap, uint256 _goal, address _wallet) { + require(_startTime >= getBlockTimestamp()); + require(_endTime >= _startTime); + require(_rate > 0); + require(_cap > 0); + require(_wallet != 0x0); + require(_goal > 0); + + vault = new RefundVault(_wallet); + goal = _goal; + token = StandardToken(_token); + startTime = _startTime; + endTime = _endTime; + rate = _rate; + cap = _cap; + goal = _goal; + wallet = _wallet; + } + + // fallback function can be used to buy tokens + function() payable { + buyTokens(msg.sender); + } + + // Day 1: 1 ETH = 1,200 POLY + // Day 2: 1 ETH = 1,100 POLY + // Day 3: 1 ETH = 1,000 POLY + function calculateBonus(uint256 weiAmount) internal returns (uint256) { + uint256 DAY1 = startTime + 24 hours; + uint256 DAY2 = DAY1 + 24 hours; + uint256 DAY3 = DAY2 + 24 hours; + uint256 bonusTokens; + uint256 bonusRate; + + if (getBlockTimestamp() > startTime && getBlockTimestamp() < DAY1) { + bonusRate = 1200; + // bonusRate = 0.0000000000000012; + } else if (getBlockTimestamp() > DAY1 && getBlockTimestamp() < DAY2) { + bonusRate = 1100; + } else if (getBlockTimestamp() > DAY2 && getBlockTimestamp() < DAY3) { + bonusRate = 1000; + } + bonusTokens = weiAmount.mul(bonusRate); + return bonusTokens; + } + + /// @notice interface for founders to whitelist investors + /// @param _addresses array of investors + /// @param _status enable or disable + function whitelistAddresses(address[] _addresses, bool _status) public onlyOwner { + for (uint256 i = 0; i < _addresses.length; i++) { + address investorAddress = _addresses[i]; + if (whitelist[investorAddress] == _status) { + continue; + } + whitelist[investorAddress] = _status; + } + } + + // low level token purchase function + // caution: tokens must be redeemed by beneficiary address + function buyTokens(address beneficiary) payable { + require(whitelist[beneficiary]); + require(beneficiary != 0x0); + require(validPurchase()); + // calculate token amount to be purchased + uint256 weiAmount = msg.value; + uint256 tokens = weiAmount.mul(rate); + uint256 bonusTokens = calculateBonus(weiAmount); + tokens = tokens.add(bonusTokens); + + // update state + weiRaised = weiRaised.add(weiAmount); + + // allocate tokens to purchaser + allocations[beneficiary] = tokens; + + TokenPurchase(msg.sender, beneficiary, weiAmount, tokens); + + forwardFunds(); + } + + // redeem tokens + function claimTokens() { + require(isFinalized); + require(goalReached()); + + // confirm there are tokens remaining + uint256 amount = token.balanceOf(this); + require(amount > 0); + + // send tokens to purchaser + uint256 tokens = allocations[msg.sender]; + allocations[msg.sender] = 0; + require(token.transfer(msg.sender, tokens)); + + TokenRedeem(msg.sender, tokens); + } + + // redeem tokens (admin fallback) + function sendTokens(address beneficiary) onlyOwner { + require(isFinalized); + require(goalReached()); + + // confirm there are tokens remaining + uint256 amount = token.balanceOf(this); + require(amount > 0); + + // send tokens to purchaser + uint256 tokens = allocations[beneficiary]; + allocations[beneficiary] = 0; + require(token.transfer(beneficiary, tokens)); + + TokenRedeem(beneficiary, tokens); + } + + // send ether to the fund collection wallet + // override to create custom fund forwarding mechanisms + function forwardFunds() internal { + vault.deposit.value(msg.value)(msg.sender); + } + + // @return true if the transaction can buy tokens + function validPurchase() internal constant returns (bool) { + bool withinCap = weiRaised.add(msg.value) <= cap; + bool withinPeriod = getBlockTimestamp() >= startTime && getBlockTimestamp() <= endTime; + bool nonZeroPurchase = msg.value != 0; + return withinPeriod && nonZeroPurchase && withinCap; + } + + // @return true if crowdsale event has ended or cap reached + function hasEnded() public constant returns (bool) { + bool capReached = weiRaised >= cap; + bool passedEndTime = getBlockTimestamp() > endTime; + return passedEndTime || capReached; + } + + function getBlockTimestamp() internal constant returns (uint256) { + return block.timestamp; + } + + // if crowdsale is unsuccessful, contributors can claim refunds here + function claimRefund() { + require(isFinalized); + require(!goalReached()); + + vault.refund(msg.sender); + } + + function goalReached() public constant returns (bool) { + return weiRaised >= goal; + } + + // @dev does not require that crowdsale `hasEnded()` to leave safegaurd + // in place if ETH rises in price too much during crowdsale. + // Allows team to close early if cap is exceeded in USD in this event. + function finalize() onlyOwner { + require(!isFinalized); + if (goalReached()) { + vault.close(); + } else { + vault.enableRefunds(); + } + + Finalized(); + + isFinalized = true; + } + + function unsoldCleanUp() onlyOwner { + uint256 amount = token.balanceOf(this); + if(amount > 0) { + require(token.transfer(msg.sender, amount)); + } + + } +} diff --git a/dao/test/TestAgiCrowdsale.js b/dao/test/TestAgiCrowdsale.js index 3c9eaf0..b06376a 100644 --- a/dao/test/TestAgiCrowdsale.js +++ b/dao/test/TestAgiCrowdsale.js @@ -1,79 +1,77 @@ -const RefundableCrowdsale = artifacts.require('AgiCrowdsale.sol') -const MintableToken = artifacts.require('MintableToken') +const Crowdsale = artifacts.require('./helpers/AgiCrowdsaleMock.sol'); +const AGIToken = artifacts.require('SingularityNetToken.sol'); -const ether = require('./helpers/ether') -const { advanceBlock } = require('./helpers/advanceToBlock') -const { increaseTimeTo, duration } = require('./helpers/increaseTime') -const latestTime = require('./helpers/latestTime') -const EVMThrow = 'Invalid opcode' +const { latestTime, duration } = require('./helpers/latestTime'); -const BigNumber = web3.BigNumber - -contract('AgiCrowdsale', function ([_, owner, wallet, investor]) { - - const rate = new BigNumber(1000) - const goal = ether(800) - const lessThanGoal = ether(750) - - before(async function () { - //Advance to the next block to correctly read time in the solidity "now" function interpreted by testrpc - await advanceBlock() - }) +contract('AgiCrowdsale', async function ([miner, owner, investor, wallet]) { + let tokenOfferingDeployed; + let tokenDeployed; beforeEach(async function () { - this.startTime = latestTime() + duration.weeks(1) - this.endTime = this.startTime + duration.weeks(1) - this.afterEndTime = this.endTime + duration.seconds(1) - - this.crowdsale = await RefundableCrowdsale.new(this.startTime, this.endTime, rate, wallet, goal, { from: owner }) - }) - - describe('creating a valid crowdsale', function () { - - it('should fail with zero goal', async function () { - await RefundableCrowdsale.new(this.startTime, this.endTime, rate, wallet, 0, { from: owner }).should.be.rejectedWith(EVMThrow); - }) - + tokenDeployed = await AGIToken.new(); + const startTime = latestTime() + duration.seconds(1); + const endTime = startTime + duration.weeks(1); + const rate = new web3.BigNumber(1000); + const goal = new web3.BigNumber(3000 * Math.pow(10, 18)); + const cap = new web3.BigNumber(15000 * Math.pow(10, 18)); + console.log(startTime, endTime); + tokenOfferingDeployed = await Crowdsale.new(tokenDeployed.address, startTime, endTime, rate, cap, goal, wallet); + await tokenOfferingDeployed.setBlockTimestamp(startTime + duration.days(1)); + }); + it('should not be finalized', async function () { + const isFinalized = await tokenOfferingDeployed.isFinalized(); + assert.isFalse(isFinalized, "isFinalized should be false"); }); - it('should deny refunds before end', async function () { - await this.crowdsale.claimRefund({ from: investor }).should.be.rejectedWith(EVMThrow) - await increaseTimeTo(this.startTime) - await this.crowdsale.claimRefund({ from: investor }).should.be.rejectedWith(EVMThrow) - }) - - it('should deny refunds after end if goal was reached', async function () { - await increaseTimeTo(this.startTime) - await this.crowdsale.sendTransaction({ value: goal, from: investor }) - await increaseTimeTo(this.afterEndTime) - await this.crowdsale.claimRefund({ from: investor }).should.be.rejectedWith(EVMThrow) - }) - - it('should allow refunds after end if goal was not reached', async function () { - await increaseTimeTo(this.startTime) - await this.crowdsale.sendTransaction({ value: lessThanGoal, from: investor }) - await increaseTimeTo(this.afterEndTime) + it('goal should be 3000 ETH', async function () { + const goal = await tokenOfferingDeployed.goal(); + assert.equal(goal.toString(10), '3000000000000000000000', "goal is incorrect"); + }); - await this.crowdsale.finalize({ from: owner }) + it('cap should be 15000 ETH', async function () { + const cap = await tokenOfferingDeployed.cap(); + assert.equal(cap.toString(10), '15000000000000000000000', "cap is incorrect"); + }); - const pre = web3.eth.getBalance(investor) - await this.crowdsale.claimRefund({ from: investor, gasPrice: 0 }) - .should.be.fulfilled - const post = web3.eth.getBalance(investor) + describe('#whitelistAddresses', async function () { + let investors; + beforeEach(async function () { + investors = [ + '0x2718C59E08Afa3F8b1EaA0fCA063c566BA4EC98B', + '0x14ABEbe9064B73c63AEcd87942B0ED2Fef2F7B3B', + '0x5850f06700E92eDe92cb148734b3625DCB6A14d4', + '0xA38c9E212B46C58e05fCb678f0Ce62B5e1bc6c52', + '0x7e2392A0DDE190457e1e8b2c7fd50d46ACb6ad4f', + '0x0306D4C6ABC853bfDc711291032402CF8506422b', + '0x1a91022B10DCbB60ED14584dC66B7faC081A9691' + ]; + }); + it('should whitelist and blacklist', async function () { + let firstInvestorStatus = await tokenOfferingDeployed.whitelist(investors[0]); + assert.isFalse(firstInvestorStatus); + + await tokenOfferingDeployed.whitelistAddresses(investors, true); + firstInvestorStatus = await tokenOfferingDeployed.whitelist(investors[0]); + assert.isTrue(firstInvestorStatus); + + await tokenOfferingDeployed.whitelistAddresses(investors, false); + firstInvestorStatus = await tokenOfferingDeployed.whitelist(investors[0]); + assert.isFalse(firstInvestorStatus); + }) - post.minus(pre).should.be.bignumber.equal(lessThanGoal) - }) + it('allows to buy tokens', async function () { + let firstInvestorStatus = await tokenOfferingDeployed.whitelist(investors[0]); + assert.isFalse(firstInvestorStatus); - it('should forward funds to wallet after end if goal was reached', async function () { - await increaseTimeTo(this.startTime) - await this.crowdsale.sendTransaction({ value: goal, from: investor }) - await increaseTimeTo(this.afterEndTime) + await tokenOfferingDeployed.whitelistAddresses([investor], true); + let balance = await tokenDeployed.balanceOf(investor); + assert.equal(balance.toString(10), '0'); - const pre = web3.eth.getBalance(wallet) - await this.crowdsale.finalize({ from: owner }) - const post = web3.eth.getBalance(wallet) + const value = web3.toWei(1, 'ether'); + await tokenOfferingDeployed.sendTransaction({ from: investor, value, gas: '200000' }); + balance = await tokenOfferingDeployed.allocations(investor); + assert.isTrue(balance.toNumber(10) > 0, 'balanceOf is 0 for investor who just bought tokens'); + }) - post.minus(pre).should.be.bignumber.equal(goal) }) - -}) \ No newline at end of file +}); diff --git a/dao/test/helpers/AgiCrowdsaleMock.sol b/dao/test/helpers/AgiCrowdsaleMock.sol new file mode 100644 index 0000000..7c15c53 --- /dev/null +++ b/dao/test/helpers/AgiCrowdsaleMock.sol @@ -0,0 +1,21 @@ +pragma solidity ^0.4.15; + +import '../../contracts/foundation/AgiCrowdsale.sol'; + + +contract AgiCrowdsaleMock is AgiCrowdsale { + uint256 public timeStamp = now; + function setBlockTimestamp(uint256 _timeStamp) public onlyOwner { + timeStamp = _timeStamp; + } + + function getBlockTimestamp() internal constant returns (uint256) { + return timeStamp; + } + + function AgiCrowdsaleMock(address _token, uint256 _startTime, uint256 _endTime, uint256 _rate, uint256 _cap, uint256 _goal, address _wallet) + AgiCrowdsale(_token, _startTime, _endTime, _rate, _cap, _goal, _wallet) + { + } + +} \ No newline at end of file diff --git a/dao/test/helpers/advanceToBlock.js b/dao/test/helpers/advanceToBlock.js deleted file mode 100644 index 4820826..0000000 --- a/dao/test/helpers/advanceToBlock.js +++ /dev/null @@ -1,22 +0,0 @@ -exports.advanceBlock = function() { - return new Promise((resolve, reject) => { - web3.currentProvider.sendAsync({ - jsonrpc: '2.0', - method: 'evm_mine', - id: Date.now(), - }, (err, res) => { - return err ? reject(err) : resolve(res) - }) - }) -} - -// Advances the block number so that the last mined block is `number`. -exports.advanceToBlock = async function(number) { - if (web3.eth.blockNumber > number) { - throw Error(`block number ${number} is in the past (current is ${web3.eth.blockNumber})`) - } - - while (web3.eth.blockNumber < number) { - await advanceBlock() - } -} diff --git a/dao/test/helpers/assertFail.js b/dao/test/helpers/assertFail.js new file mode 100644 index 0000000..d274110 --- /dev/null +++ b/dao/test/helpers/assertFail.js @@ -0,0 +1,9 @@ +module.exports = async function assertFail(callback, message) { + let web3ErrorThrown = false + try { + await callback() + } catch (error) { + if (error.message.search('invalid opcode')) web3ErrorThrown = true + } + assert.ok(web3ErrorThrown, message || 'Transaction should fail') +} diff --git a/dao/test/helpers/ether.js b/dao/test/helpers/ether.js deleted file mode 100644 index 742c147..0000000 --- a/dao/test/helpers/ether.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = function(n) { - return new web3.BigNumber(web3.toWei(n, 'ether')) -} diff --git a/dao/test/helpers/increaseTime.js b/dao/test/helpers/increaseTime.js deleted file mode 100644 index d93def8..0000000 --- a/dao/test/helpers/increaseTime.js +++ /dev/null @@ -1,48 +0,0 @@ -const latestTime = require('./latestTime') - -// Increases testrpc time by the passed duration in seconds -exports.increaseTime = function(duration) { - const id = Date.now() - - return new Promise((resolve, reject) => { - web3.currentProvider.sendAsync({ - jsonrpc: '2.0', - method: 'evm_increaseTime', - params: [duration], - 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) - }) - }) - }) -} - -/** - * Beware that due to the need of calling two separate testrpc methods and rpc calls overhead - * it's hard to increase time precisely to a target point so design your test to tolerate - * small fluctuations from time to time. - * - * @param target time in seconds - */ -exports.increaseTimeTo = function(target) { - let now = latestTime(); - if (target < now) throw Error(`Cannot increase current time(${now}) to a moment in the past(${target})`); - let diff = target - now; - return increaseTime(diff); -} - -exports.duration = { - seconds: function(val) { return val}, - minutes: function(val) { return val * this.seconds(60) }, - hours: function(val) { return val * this.minutes(60) }, - days: function(val) { return val * this.hours(24) }, - weeks: function(val) { return val * this.days(7) }, - years: function(val) { return val * this.days(365)} -}; diff --git a/dao/test/helpers/latestTime.js b/dao/test/helpers/latestTime.js index 99aa701..5c18cef 100644 --- a/dao/test/helpers/latestTime.js +++ b/dao/test/helpers/latestTime.js @@ -1,4 +1,12 @@ -// Returns the time of the last mined block in seconds -module.exports = function() { - return web3.eth.getBlock('latest').timestamp; +exports.latestTime = function () { + return global.web3.eth.getBlock('latest').timestamp; } + +exports.duration = { + seconds: function (val) { return val }, + minutes: function (val) { return val * this.seconds(60) }, + hours: function (val) { return val * this.minutes(60) }, + days: function (val) { return val * this.hours(24) }, + weeks: function (val) { return val * this.days(7) }, + years: function (val) { return val * this.days(365) } +}; \ No newline at end of file