From de92a0212714b66bb56b05318412ac8a92d5cf90 Mon Sep 17 00:00:00 2001 From: Manuel Araoz Date: Tue, 27 Jun 2017 18:34:04 -0300 Subject: [PATCH 01/12] Add Crowdsale contracts --- contracts/crowdsale/CappedCrowdsale.sol | 35 ++++++ contracts/crowdsale/Crowdsale.sol | 107 +++++++++++++++++++ contracts/crowdsale/FinalizableCrowdsale.sol | 39 +++++++ contracts/crowdsale/RefundVault.sol | 56 ++++++++++ contracts/crowdsale/RefundableCrowdsale.sol | 61 +++++++++++ contracts/token/PausableToken.sol | 10 +- 6 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 contracts/crowdsale/CappedCrowdsale.sol create mode 100644 contracts/crowdsale/Crowdsale.sol create mode 100644 contracts/crowdsale/FinalizableCrowdsale.sol create mode 100644 contracts/crowdsale/RefundVault.sol create mode 100644 contracts/crowdsale/RefundableCrowdsale.sol diff --git a/contracts/crowdsale/CappedCrowdsale.sol b/contracts/crowdsale/CappedCrowdsale.sol new file mode 100644 index 000000000..10ed09e1d --- /dev/null +++ b/contracts/crowdsale/CappedCrowdsale.sol @@ -0,0 +1,35 @@ +pragma solidity ^0.4.11; + +import '../SafeMath.sol'; +import './Crowdsale.sol'; + +/** + * @title CappedCrowdsale + * @dev Extension of Crowsdale with a max amount of funds raised + */ +contract CappedCrowdsale is Crowdsale { + using SafeMath for uint256; + + uint256 public cap; + + function CappedCrowdsale( + uint256 _cap + ) { + cap = _cap; + } + + // overriding Crowdsale#canBuy to add extra cap logic + // @return true if investors can buy at the moment + function canBuy() internal constant returns (bool) { + bool withinCap = weiRaised.add(msg.value) <= cap; + return super.canBuy() && withinCap; + } + + // overriding Crowdsale#hasEnded to add cap logic + // @return true if crowdsale event has ended + function hasEnded() public constant returns (bool) { + bool capReached = weiRaised >= cap; + return super.hasEnded() || capReached; + } + +} diff --git a/contracts/crowdsale/Crowdsale.sol b/contracts/crowdsale/Crowdsale.sol new file mode 100644 index 000000000..72fe7722a --- /dev/null +++ b/contracts/crowdsale/Crowdsale.sol @@ -0,0 +1,107 @@ +pragma solidity ^0.4.11; + +import '../token/MintableToken.sol'; +import '../SafeMath.sol'; + +/** + * @title Crowdsale + * @dev Crowdsale is a base contract for managing a token crowdsale. + * Crowdsales have a start and end block, where investors can make + * token purchases and the crowdsale will assign them tokens based + * on a token per ETH rate. Funds collected are forwarded to a wallet + * as they arrive. + */ +contract Crowdsale { + using SafeMath for uint256; + + // The token being sold + MintableToken public token; + + // start and end block where investments are allowed (both inclusive) + uint256 public startBlock; + uint256 public endBlock; + + // address where funds are collected + address public wallet; + + // token to ETH conversion rate + 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); + + + function Crowdsale(uint256 _startBlock, uint256 _endBlock, uint256 _rate, address _wallet) { + require(_startBlock >= block.number); + require(_endBlock >= _startBlock); + require(_rate > 0); + require(_wallet != 0x0); + + token = createTokenContract(); + startBlock = _startBlock; + endBlock = _endBlock; + rate = _rate; + wallet = _wallet; + } + + // creates the token to be sold. + // override this method to have crowdsale of a specific mintable token. + function createTokenContract() internal returns (MintableToken) { + return new MintableToken(); + } + + + // fallback function can be used to buy tokens + function () payable { + buyTokens(msg.sender); + } + + // low level token purchase function + function buyTokens(address beneficiary) payable { + require(canBuy()); + + uint256 weiAmount = msg.value; + uint256 updatedWeiRaised = weiRaised.add(weiAmount); + + // calculate token amount to be created + uint256 tokens = weiAmount.mul(rate); + + // update state + weiRaised = updatedWeiRaised; + + token.mint(beneficiary, tokens); + TokenPurchase(msg.sender, beneficiary, weiAmount, tokens); + + forwardFunds(); + } + + // send ether to the fund collection wallet + // override to create custom fund forwarding mechanisms + function forwardFunds() internal { + wallet.transfer(msg.value); + } + + // @return true if the transaction can buy tokens + function canBuy() internal constant returns (bool) { + uint256 current = block.number; + bool withinPeriod = current >= startBlock && current <= endBlock; + bool nonZeroPurchase = msg.value != 0; + return withinPeriod && nonZeroPurchase; + } + + // @return true if crowdsale event has ended + function hasEnded() public constant returns (bool) { + return block.number > endBlock; + } + + +} diff --git a/contracts/crowdsale/FinalizableCrowdsale.sol b/contracts/crowdsale/FinalizableCrowdsale.sol new file mode 100644 index 000000000..c769f0229 --- /dev/null +++ b/contracts/crowdsale/FinalizableCrowdsale.sol @@ -0,0 +1,39 @@ +pragma solidity ^0.4.11; + +import '../SafeMath.sol'; +import '../ownership/Ownable.sol'; +import './Crowdsale.sol'; + +/** + * @title FinalizableCrowdsale + * @dev Extension of Crowsdale where an owner can do extra work + * after finishing. By default, it will end token minting. + */ +contract FinalizableCrowdsale is Crowdsale, Ownable { + using SafeMath for uint256; + + bool public isFinalized = false; + + event Finalized(); + + // should be called after crowdsale ends, to do + // some extra finalization work + function finalize() onlyOwner { + require(!isFinalized); + require(hasEnded()); + + finalization(); + Finalized(); + + isFinalized = true; + } + + // end token minting on finalization + // override this with custom logic if needed + function finalization() internal { + token.finishMinting(); + } + + + +} diff --git a/contracts/crowdsale/RefundVault.sol b/contracts/crowdsale/RefundVault.sol new file mode 100644 index 000000000..13de5d98e --- /dev/null +++ b/contracts/crowdsale/RefundVault.sol @@ -0,0 +1,56 @@ +pragma solidity ^0.4.11; + +import '../SafeMath.sol'; +import '../ownership/Ownable.sol'; + +/** + * @title RefundVault + * @dev This contract is used for storing funds while a crowdsale + * is in progress. Supports refunding the money if crowdsale fails, + * and forwarding it if crowdsale is successful. + */ +contract RefundVault is Ownable { + using SafeMath for uint256; + + enum State { Active, Refunding, Closed } + + mapping (address => uint256) public deposited; + address public wallet; + State public state; + + event Closed(); + event RefundsEnabled(); + event Refunded(address indexed beneficiary, uint256 weiAmount); + + function RefundVault(address _wallet) { + require(_wallet != 0x0); + wallet = _wallet; + state = State.Active; + } + + function deposit(address investor) onlyOwner payable { + require(state == State.Active); + deposited[investor] = deposited[investor].add(msg.value); + } + + function close() onlyOwner { + require(state == State.Active); + state = State.Closed; + Closed(); + wallet.transfer(this.balance); + } + + function enableRefunds() onlyOwner { + require(state == State.Active); + state = State.Refunding; + RefundsEnabled(); + } + + function refund(address investor) { + require(state == State.Refunding); + uint256 depositedValue = deposited[investor]; + deposited[investor] = 0; + investor.transfer(depositedValue); + Refunded(investor, depositedValue); + } +} diff --git a/contracts/crowdsale/RefundableCrowdsale.sol b/contracts/crowdsale/RefundableCrowdsale.sol new file mode 100644 index 000000000..a8270935f --- /dev/null +++ b/contracts/crowdsale/RefundableCrowdsale.sol @@ -0,0 +1,61 @@ +pragma solidity ^0.4.11; + + +import '../SafeMath.sol'; +import './FinalizableCrowdsale.sol'; +import './RefundVault.sol'; + + +/** + * @title RefundableCrowdsale + * @dev Extension of Crowdsale contract that adds a funding goal, and + * the possibility of users getting a refund if goal is not met. + * Uses a RefundVault as the crowdsale's vault. + */ +contract RefundableCrowdsale is FinalizableCrowdsale { + using SafeMath for uint256; + + // 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; + + function RefundableCrowdsale( + uint256 _goal + ) { + vault = new RefundVault(wallet); + goal = _goal; + } + + // We're overriding the fund forwarding from Crowdsale. + // In addition to sending the funds, we want to call + // the RefundVault deposit function + function forwardFunds() internal { + vault.deposit.value(msg.value)(msg.sender); + } + + // if crowdsale is unsuccessful, investors can claim refunds here + function claimRefund() { + require(hasEnded()); + require(!goalReached()); + + vault.refund(msg.sender); + } + + // vault finalization task, called when owner calls finalize() + function finalization() internal { + if (goalReached()) { + vault.close(); + } else { + vault.enableRefunds(); + } + + super.finalization(); + } + + function goalReached() public constant returns (bool) { + return weiRaised >= goal; + } + +} diff --git a/contracts/token/PausableToken.sol b/contracts/token/PausableToken.sol index 4372a80a8..8ee114e1d 100644 --- a/contracts/token/PausableToken.sol +++ b/contracts/token/PausableToken.sol @@ -7,19 +7,15 @@ import '../lifecycle/Pausable.sol'; * Pausable token * * Simple ERC20 Token example, with pausable token creation - * Issue: - * https://github.com/OpenZeppelin/zeppelin-solidity/issues/194 - * Based on code by BCAPtoken: - * https://github.com/BCAPtoken/BCAPToken/blob/5cb5e76338cc47343ba9268663a915337c8b268e/sol/BCAPToken.sol#L27 **/ -contract PausableToken is Pausable, StandardToken { +contract PausableToken is StandardToken, Pausable { - function transfer(address _to, uint256 _value) whenNotPaused { + function transfer(address _to, uint _value) whenNotPaused { super.transfer(_to, _value); } - function transferFrom(address _from, address _to, uint256 _value) whenNotPaused { + function transferFrom(address _from, address _to, uint _value) whenNotPaused { super.transferFrom(_from, _to, _value); } } From cd47fbe953fd60deb96328baf4eb10296531f9ce Mon Sep 17 00:00:00 2001 From: Manuel Araoz Date: Fri, 30 Jun 2017 11:23:28 -0300 Subject: [PATCH 02/12] Improve `Crowdsale#rate` documentation --- contracts/crowdsale/Crowdsale.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/crowdsale/Crowdsale.sol b/contracts/crowdsale/Crowdsale.sol index 72fe7722a..84548951b 100644 --- a/contracts/crowdsale/Crowdsale.sol +++ b/contracts/crowdsale/Crowdsale.sol @@ -24,7 +24,7 @@ contract Crowdsale { // address where funds are collected address public wallet; - // token to ETH conversion rate + // how many token units a buyer gets per wei uint256 public rate; // amount of raised money in wei From 4d55d8fa8e3b9b00088953d2f6dac22f25bf23cf Mon Sep 17 00:00:00 2001 From: Francisco Giordano Date: Fri, 30 Jun 2017 14:54:49 -0300 Subject: [PATCH 03/12] give more balance to testrpc accounts includes fix to MultisigWallet test because bigger balances result in floating point errors (see #204) --- scripts/test.sh | 14 +++++++++++++- test/MultisigWallet.js | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/test.sh b/scripts/test.sh index f6a788f35..cbbb6e15e 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -4,7 +4,19 @@ output=$(nc -z localhost 8545; echo $?) [ $output -eq "0" ] && trpc_running=true if [ ! $trpc_running ]; then echo "Starting our own testrpc node instance" - testrpc > /dev/null & + # we give each account 1M ether, needed for high-value tests + testrpc \ + --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501200,1000000000000000000000000" \ + --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501201,1000000000000000000000000" \ + --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501202,1000000000000000000000000" \ + --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501203,1000000000000000000000000" \ + --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501204,1000000000000000000000000" \ + --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501205,1000000000000000000000000" \ + --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501206,1000000000000000000000000" \ + --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501207,1000000000000000000000000" \ + --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501208,1000000000000000000000000" \ + --account="0x2bdd21761a483f71054e14f5b827213567971c676928d9a1808cbfa4b7501209,1000000000000000000000000" \ + > /dev/null & trpc_pid=$! fi ./node_modules/truffle/cli.js test diff --git a/test/MultisigWallet.js b/test/MultisigWallet.js index 9c78ee114..dd170f31b 100644 --- a/test/MultisigWallet.js +++ b/test/MultisigWallet.js @@ -52,7 +52,7 @@ contract('MultisigWallet', function(accounts) { //Balance of account2 should have increased let newAccountBalance = web3.eth.getBalance(accounts[2]); - assert.isTrue(newAccountBalance > accountBalance); + assert.isTrue(newAccountBalance.greaterThan(accountBalance)); }); it('should prevent execution of transaction if above daily limit', async function() { From fadb2cf47e33240271ec117c7d31f6d2afa54c25 Mon Sep 17 00:00:00 2001 From: Francisco Giordano Date: Fri, 30 Jun 2017 14:57:58 -0300 Subject: [PATCH 04/12] add tests for crowdsale contracts --- package.json | 3 + test/CappedCrowdsale.js | 81 ++++++++++++ test/Crowdsale.js | 143 ++++++++++++++++++++++ test/FinalizableCrowdsale.js | 61 +++++++++ test/RefundVault.js | 61 +++++++++ test/RefundableCrowdsale.js | 67 ++++++++++ test/helpers/CappedCrowdsaleImpl.sol | 21 ++++ test/helpers/EVMThrow.js | 1 + test/helpers/FinalizableCrowdsaleImpl.sol | 20 +++ test/helpers/RefundableCrowdsaleImpl.sol | 21 ++++ test/helpers/advanceToBlock.js | 22 ++++ test/helpers/ether.js | 3 + 12 files changed, 504 insertions(+) create mode 100644 test/CappedCrowdsale.js create mode 100644 test/Crowdsale.js create mode 100644 test/FinalizableCrowdsale.js create mode 100644 test/RefundVault.js create mode 100644 test/RefundableCrowdsale.js create mode 100644 test/helpers/CappedCrowdsaleImpl.sol create mode 100644 test/helpers/EVMThrow.js create mode 100644 test/helpers/FinalizableCrowdsaleImpl.sol create mode 100644 test/helpers/RefundableCrowdsaleImpl.sol create mode 100644 test/helpers/advanceToBlock.js create mode 100644 test/helpers/ether.js diff --git a/package.json b/package.json index e182124b5..d593fd04b 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,9 @@ "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", diff --git a/test/CappedCrowdsale.js b/test/CappedCrowdsale.js new file mode 100644 index 000000000..0a24800b4 --- /dev/null +++ b/test/CappedCrowdsale.js @@ -0,0 +1,81 @@ +import ether from './helpers/ether' +import advanceToBlock from './helpers/advanceToBlock' +import EVMThrow from './helpers/EVMThrow' + +const BigNumber = web3.BigNumber + +require('chai') + .use(require('chai-as-promised')) + .use(require('chai-bignumber')(BigNumber)) + .should() + +const CappedCrowdsale = artifacts.require('./helpers/CappedCrowdsaleImpl.sol') +const MintableToken = artifacts.require('MintableToken') + +contract('CappedCrowdsale', function ([_, wallet]) { + + const rate = new BigNumber(1000) + + const cap = ether(300) + const lessThanCap = ether(60) + + beforeEach(async function () { + this.startBlock = web3.eth.blockNumber + 10 + this.endBlock = web3.eth.blockNumber + 20 + + this.crowdsale = await CappedCrowdsale.new(this.startBlock, this.endBlock, rate, wallet, cap) + + this.token = MintableToken.at(await this.crowdsale.token()) + }) + + describe('accepting payments', function () { + + beforeEach(async function () { + await advanceToBlock(this.startBlock - 1) + }) + + it('should accept payments within cap', async function () { + await this.crowdsale.send(cap.minus(lessThanCap)).should.be.fulfilled + await this.crowdsale.send(lessThanCap).should.be.fulfilled + }) + + it('should reject payments outside cap', async function () { + await this.crowdsale.send(cap) + await this.crowdsale.send(1).should.be.rejectedWith(EVMThrow) + }) + + it('should reject payments that exceed cap', async function () { + await this.crowdsale.send(cap.plus(1)).should.be.rejectedWith(EVMThrow) + }) + + }) + + describe('ending', function () { + + beforeEach(async function () { + await advanceToBlock(this.startBlock - 1) + }) + + it('should not be ended if under cap', async function () { + let hasEnded = await this.crowdsale.hasEnded() + hasEnded.should.equal(false) + await this.crowdsale.send(lessThanCap) + hasEnded = await this.crowdsale.hasEnded() + hasEnded.should.equal(false) + }) + + it('should not be ended if just under cap', async function () { + await this.crowdsale.send(cap.minus(1)) + let hasEnded = await this.crowdsale.hasEnded() + hasEnded.should.equal(false) + }) + + it('should be ended if cap reached', async function () { + await this.crowdsale.send(cap) + let hasEnded = await this.crowdsale.hasEnded() + hasEnded.should.equal(true) + }) + + }) + +}) diff --git a/test/Crowdsale.js b/test/Crowdsale.js new file mode 100644 index 000000000..3e697b05a --- /dev/null +++ b/test/Crowdsale.js @@ -0,0 +1,143 @@ +import ether from './helpers/ether' +import advanceToBlock from './helpers/advanceToBlock' +import EVMThrow from './helpers/EVMThrow' + +const BigNumber = web3.BigNumber + +const should = require('chai') + .use(require('chai-as-promised')) + .use(require('chai-bignumber')(BigNumber)) + .should() + +const Crowdsale = artifacts.require('Crowdsale') +const MintableToken = artifacts.require('MintableToken') + +contract('Crowdsale', function ([_, investor, wallet, purchaser]) { + + const rate = new BigNumber(1000) + const value = ether(42) + + const expectedTokenAmount = rate.mul(value) + + beforeEach(async function () { + this.startBlock = web3.eth.blockNumber + 10 + this.endBlock = web3.eth.blockNumber + 20 + + this.crowdsale = await Crowdsale.new(this.startBlock, this.endBlock, rate, wallet) + + this.token = MintableToken.at(await this.crowdsale.token()) + }) + + it('should be token owner', async function () { + const owner = await this.token.owner() + owner.should.equal(this.crowdsale.address) + }) + + it('should be ended only after end', async function () { + let ended = await this.crowdsale.hasEnded() + ended.should.equal(false) + await advanceToBlock(this.endBlock + 1) + ended = await this.crowdsale.hasEnded() + ended.should.equal(true) + }) + + describe('accepting payments', function () { + + it('should reject payments before start', async function () { + await this.crowdsale.send(value).should.be.rejectedWith(EVMThrow) + await this.crowdsale.buyTokens(investor, value, {from: purchaser}).should.be.rejectedWith(EVMThrow) + }) + + it('should accept payments after start', async function () { + await advanceToBlock(this.startBlock - 1) + await this.crowdsale.send(value).should.be.fulfilled + await this.crowdsale.buyTokens(investor, {value: value, from: purchaser}).should.be.fulfilled + }) + + it('should reject payments after end', async function () { + await advanceToBlock(this.endBlock) + await this.crowdsale.send(value).should.be.rejectedWith(EVMThrow) + await this.crowdsale.buyTokens(investor, {value: value, from: purchaser}).should.be.rejectedWith(EVMThrow) + }) + + }) + + describe('high-level purchase', function () { + + beforeEach(async function() { + await advanceToBlock(this.startBlock) + }) + + it('should log purchase', async function () { + const {logs} = await this.crowdsale.sendTransaction({value: value, from: investor}) + + const event = logs.find(e => e.event === 'TokenPurchase') + + should.exist(event) + event.args.purchaser.should.equal(investor) + event.args.beneficiary.should.equal(investor) + event.args.value.should.be.bignumber.equal(value) + event.args.amount.should.be.bignumber.equal(expectedTokenAmount) + }) + + it('should increase totalSupply', async function () { + await this.crowdsale.send(value) + const totalSupply = await this.token.totalSupply() + totalSupply.should.be.bignumber.equal(expectedTokenAmount) + }) + + it('should assign tokens to sender', async function () { + await this.crowdsale.sendTransaction({value: value, from: investor}) + let balance = await this.token.balanceOf(investor); + balance.should.be.bignumber.equal(expectedTokenAmount) + }) + + it('should forward funds to wallet', async function () { + const pre = web3.eth.getBalance(wallet) + await this.crowdsale.sendTransaction({value, from: investor}) + const post = web3.eth.getBalance(wallet) + post.minus(pre).should.be.bignumber.equal(value) + }) + + }) + + describe('low-level purchase', function () { + + beforeEach(async function() { + await advanceToBlock(this.startBlock) + }) + + it('should log purchase', async function () { + const {logs} = await this.crowdsale.buyTokens(investor, {value: value, from: purchaser}) + + const event = logs.find(e => e.event === 'TokenPurchase') + + should.exist(event) + event.args.purchaser.should.equal(purchaser) + event.args.beneficiary.should.equal(investor) + event.args.value.should.be.bignumber.equal(value) + event.args.amount.should.be.bignumber.equal(expectedTokenAmount) + }) + + it('should increase totalSupply', async function () { + await this.crowdsale.buyTokens(investor, {value, from: purchaser}) + const totalSupply = await this.token.totalSupply() + totalSupply.should.be.bignumber.equal(expectedTokenAmount) + }) + + it('should assign tokens to beneficiary', async function () { + await this.crowdsale.buyTokens(investor, {value, from: purchaser}) + const balance = await this.token.balanceOf(investor) + balance.should.be.bignumber.equal(expectedTokenAmount) + }) + + it('should forward funds to wallet', async function () { + const pre = web3.eth.getBalance(wallet) + await this.crowdsale.buyTokens(investor, {value, from: purchaser}) + const post = web3.eth.getBalance(wallet) + post.minus(pre).should.be.bignumber.equal(value) + }) + + }) + +}) diff --git a/test/FinalizableCrowdsale.js b/test/FinalizableCrowdsale.js new file mode 100644 index 000000000..53224bb76 --- /dev/null +++ b/test/FinalizableCrowdsale.js @@ -0,0 +1,61 @@ +import advanceToBlock from './helpers/advanceToBlock' +import EVMThrow from './helpers/EVMThrow' + +const BigNumber = web3.BigNumber + +const should = require('chai') + .use(require('chai-as-promised')) + .use(require('chai-bignumber')(BigNumber)) + .should() + +const FinalizableCrowdsale = artifacts.require('./helpers/FinalizableCrowdsaleImpl.sol') +const MintableToken = artifacts.require('MintableToken') + +contract('FinalizableCrowdsale', function ([_, owner, wallet, thirdparty]) { + + const rate = new BigNumber(1000) + + beforeEach(async function () { + this.startBlock = web3.eth.blockNumber + 10 + this.endBlock = web3.eth.blockNumber + 20 + + this.crowdsale = await FinalizableCrowdsale.new(this.startBlock, this.endBlock, rate, wallet, {from: owner}) + + this.token = MintableToken.at(await this.crowdsale.token()) + }) + + it('cannot be finalized before ending', async function () { + await this.crowdsale.finalize({from: owner}).should.be.rejectedWith(EVMThrow) + }) + + it('cannot be finalized by third party after ending', async function () { + await advanceToBlock(this.endBlock) + await this.crowdsale.finalize({from: thirdparty}).should.be.rejectedWith(EVMThrow) + }) + + it('can be finalized by owner after ending', async function () { + await advanceToBlock(this.endBlock) + await this.crowdsale.finalize({from: owner}).should.be.fulfilled + }) + + it('cannot be finalized twice', async function () { + await advanceToBlock(this.endBlock + 1) + await this.crowdsale.finalize({from: owner}) + await this.crowdsale.finalize({from: owner}).should.be.rejectedWith(EVMThrow) + }) + + it('logs finalized', async function () { + await advanceToBlock(this.endBlock) + const {logs} = await this.crowdsale.finalize({from: owner}) + const event = logs.find(e => e.event === 'Finalized') + should.exist(event) + }) + + it('finishes minting of token', async function () { + await advanceToBlock(this.endBlock) + await this.crowdsale.finalize({from: owner}) + const finished = await this.token.mintingFinished() + finished.should.equal(true) + }) + +}) diff --git a/test/RefundVault.js b/test/RefundVault.js new file mode 100644 index 000000000..2102df980 --- /dev/null +++ b/test/RefundVault.js @@ -0,0 +1,61 @@ +const BigNumber = web3.BigNumber + +require('chai') + .use(require('chai-as-promised')) + .use(require('chai-bignumber')(BigNumber)) + .should() + +import ether from './helpers/ether' +import EVMThrow from './helpers/EVMThrow' + +const RefundVault = artifacts.require('RefundVault') + +contract('RefundVault', function ([_, owner, wallet, investor]) { + + const value = ether(42) + + beforeEach(async function () { + this.vault = await RefundVault.new(wallet, {from: owner}) + }) + + it('should accept contributions', async function () { + await this.vault.deposit(investor, {value, from: owner}).should.be.fulfilled + }) + + it('should not refund contribution during active state', async function () { + await this.vault.deposit(investor, {value, from: owner}) + await this.vault.refund(investor).should.be.rejectedWith(EVMThrow) + }) + + it('only owner can enter refund mode', async function () { + await this.vault.enableRefunds({from: _}).should.be.rejectedWith(EVMThrow) + await this.vault.enableRefunds({from: owner}).should.be.fulfilled + }) + + it('should refund contribution after entering refund mode', async function () { + await this.vault.deposit(investor, {value, from: owner}) + await this.vault.enableRefunds({from: owner}) + + const pre = web3.eth.getBalance(investor) + await this.vault.refund(investor) + const post = web3.eth.getBalance(investor) + + post.minus(pre).should.be.bignumber.equal(value) + }) + + it('only owner can close', async function () { + await this.vault.close({from: _}).should.be.rejectedWith(EVMThrow) + await this.vault.close({from: owner}).should.be.fulfilled + }) + + it('should forward funds to wallet after closing', async function () { + await this.vault.deposit(investor, {value, from: owner}) + + const pre = web3.eth.getBalance(wallet) + await this.vault.close({from: owner}) + const post = web3.eth.getBalance(wallet) + + post.minus(pre).should.be.bignumber.equal(value) + }) + +}) diff --git a/test/RefundableCrowdsale.js b/test/RefundableCrowdsale.js new file mode 100644 index 000000000..76f2e22c3 --- /dev/null +++ b/test/RefundableCrowdsale.js @@ -0,0 +1,67 @@ +import ether from './helpers/ether' +import advanceToBlock from './helpers/advanceToBlock' +import EVMThrow from './helpers/EVMThrow' + +const BigNumber = web3.BigNumber + +require('chai') + .use(require('chai-as-promised')) + .use(require('chai-bignumber')(BigNumber)) + .should() + +const RefundableCrowdsale = artifacts.require('./helpers/RefundableCrowdsaleImpl.sol') + +contract('RefundableCrowdsale', function ([_, owner, wallet, investor]) { + + const rate = new BigNumber(1000) + const goal = ether(800) + const lessThanGoal = ether(750) + + beforeEach(async function () { + this.startBlock = web3.eth.blockNumber + 10 + this.endBlock = web3.eth.blockNumber + 20 + + this.crowdsale = await RefundableCrowdsale.new(this.startBlock, this.endBlock, rate, wallet, goal, {from: owner}) + }) + + it('should deny refunds before end', async function () { + await this.crowdsale.claimRefund({from: investor}).should.be.rejectedWith(EVMThrow) + await advanceToBlock(this.endBlock - 1) + await this.crowdsale.claimRefund({from: investor}).should.be.rejectedWith(EVMThrow) + }) + + it('should deny refunds after end if goal was reached', async function () { + await advanceToBlock(this.startBlock - 1) + await this.crowdsale.sendTransaction({value: goal, from: investor}) + await advanceToBlock(this.endBlock) + await this.crowdsale.claimRefund({from: investor}).should.be.rejectedWith(EVMThrow) + }) + + it('should allow refunds after end if goal was not reached', async function () { + await advanceToBlock(this.startBlock - 1) + await this.crowdsale.sendTransaction({value: lessThanGoal, from: investor}) + await advanceToBlock(this.endBlock) + + await this.crowdsale.finalize({from: owner}) + + const pre = web3.eth.getBalance(investor) + await this.crowdsale.claimRefund({from: investor, gasPrice: 0}) + .should.be.fulfilled + const post = web3.eth.getBalance(investor) + + post.minus(pre).should.be.bignumber.equal(lessThanGoal) + }) + + it('should forward funds to wallet after end if goal was reached', async function () { + await advanceToBlock(this.startBlock - 1) + await this.crowdsale.sendTransaction({value: goal, from: investor}) + await advanceToBlock(this.endBlock) + + const pre = web3.eth.getBalance(wallet) + await this.crowdsale.finalize({from: owner}) + const post = web3.eth.getBalance(wallet) + + post.minus(pre).should.be.bignumber.equal(goal) + }) + +}) diff --git a/test/helpers/CappedCrowdsaleImpl.sol b/test/helpers/CappedCrowdsaleImpl.sol new file mode 100644 index 000000000..6a255dca3 --- /dev/null +++ b/test/helpers/CappedCrowdsaleImpl.sol @@ -0,0 +1,21 @@ +pragma solidity ^0.4.11; + + +import '../../contracts/crowdsale/CappedCrowdsale.sol'; + + +contract CappedCrowdsaleImpl is CappedCrowdsale { + + function CappedCrowdsaleImpl ( + uint256 _startBlock, + uint256 _endBlock, + uint256 _rate, + address _wallet, + uint256 _cap + ) + Crowdsale(_startBlock, _endBlock, _rate, _wallet) + CappedCrowdsale(_cap) + { + } + +} diff --git a/test/helpers/EVMThrow.js b/test/helpers/EVMThrow.js new file mode 100644 index 000000000..c75924f52 --- /dev/null +++ b/test/helpers/EVMThrow.js @@ -0,0 +1 @@ +export default 'invalid opcode' diff --git a/test/helpers/FinalizableCrowdsaleImpl.sol b/test/helpers/FinalizableCrowdsaleImpl.sol new file mode 100644 index 000000000..b35b633a9 --- /dev/null +++ b/test/helpers/FinalizableCrowdsaleImpl.sol @@ -0,0 +1,20 @@ +pragma solidity ^0.4.11; + + +import '../../contracts/crowdsale/FinalizableCrowdsale.sol'; + + +contract FinalizableCrowdsaleImpl is FinalizableCrowdsale { + + function FinalizableCrowdsaleImpl ( + uint256 _startBlock, + uint256 _endBlock, + uint256 _rate, + address _wallet + ) + Crowdsale(_startBlock, _endBlock, _rate, _wallet) + FinalizableCrowdsale() + { + } + +} diff --git a/test/helpers/RefundableCrowdsaleImpl.sol b/test/helpers/RefundableCrowdsaleImpl.sol new file mode 100644 index 000000000..5250f56f8 --- /dev/null +++ b/test/helpers/RefundableCrowdsaleImpl.sol @@ -0,0 +1,21 @@ +pragma solidity ^0.4.11; + + +import '../../contracts/crowdsale/RefundableCrowdsale.sol'; + + +contract RefundableCrowdsaleImpl is RefundableCrowdsale { + + function RefundableCrowdsaleImpl ( + uint256 _startBlock, + uint256 _endBlock, + uint256 _rate, + address _wallet, + uint256 _goal + ) + Crowdsale(_startBlock, _endBlock, _rate, _wallet) + RefundableCrowdsale(_goal) + { + } + +} diff --git a/test/helpers/advanceToBlock.js b/test/helpers/advanceToBlock.js new file mode 100644 index 000000000..97d723f19 --- /dev/null +++ b/test/helpers/advanceToBlock.js @@ -0,0 +1,22 @@ +export function advanceBlock() { + 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`. +export default async function advanceToBlock(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/test/helpers/ether.js b/test/helpers/ether.js new file mode 100644 index 000000000..e35f90320 --- /dev/null +++ b/test/helpers/ether.js @@ -0,0 +1,3 @@ +export default function ether(n) { + return new web3.BigNumber(web3.toWei(n, 'ether')) +} From 59e96099267ffed5ac2a93ba489ed35b06de83f2 Mon Sep 17 00:00:00 2001 From: Francisco Giordano Date: Fri, 30 Jun 2017 14:59:27 -0300 Subject: [PATCH 05/12] remove CrowdsaleToken it is superseded by the new crowdsale contracts --- contracts/token/CrowdsaleToken.sol | 60 ------------------------------ docs/source/crowdsaletoken.rst | 14 ------- 2 files changed, 74 deletions(-) delete mode 100644 contracts/token/CrowdsaleToken.sol delete mode 100644 docs/source/crowdsaletoken.rst diff --git a/contracts/token/CrowdsaleToken.sol b/contracts/token/CrowdsaleToken.sol deleted file mode 100644 index 0f75d3275..000000000 --- a/contracts/token/CrowdsaleToken.sol +++ /dev/null @@ -1,60 +0,0 @@ -pragma solidity ^0.4.11; - - -import "./StandardToken.sol"; - - -/** - * @title CrowdsaleToken - * - * @dev Simple ERC20 Token example, with crowdsale token creation - * @dev IMPORTANT NOTE: do not use or deploy this contract as-is. It needs some changes to be - * production ready. - */ -contract CrowdsaleToken is StandardToken { - - string public constant name = "CrowdsaleToken"; - string public constant symbol = "CRW"; - uint256 public constant decimals = 18; - // replace with your fund collection multisig address - address public constant multisig = 0x0; - - - // 1 ether = 500 example tokens - uint256 public constant PRICE = 500; - - /** - * @dev Fallback function which receives ether and sends the appropriate number of tokens to the - * msg.sender. - */ - function () payable { - createTokens(msg.sender); - } - - /** - * @dev Creates tokens and send to the specified address. - * @param recipient The address which will recieve the new tokens. - */ - function createTokens(address recipient) payable { - if (msg.value == 0) { - throw; - } - - uint256 tokens = msg.value.mul(getPrice()); - totalSupply = totalSupply.add(tokens); - - balances[recipient] = balances[recipient].add(tokens); - - if (!multisig.send(msg.value)) { - throw; - } - } - - /** - * @dev replace this with any other price function - * @return The price per unit of token. - */ - function getPrice() constant returns (uint256 result) { - return PRICE; - } -} diff --git a/docs/source/crowdsaletoken.rst b/docs/source/crowdsaletoken.rst deleted file mode 100644 index 98a06f190..000000000 --- a/docs/source/crowdsaletoken.rst +++ /dev/null @@ -1,14 +0,0 @@ -CrowdsaleToken -============================================= - -Simple ERC20 Token example, with crowdsale token creation. - -Inherits from contract StandardToken. - -createTokens(address recipient) payable -""""""""""""""""""""""""""""""""""""""""" -Creates tokens based on message value and credits to the recipient. - -getPrice() constant returns (uint result) -""""""""""""""""""""""""""""""""""""""""" -Returns the amount of tokens per 1 ether. \ No newline at end of file From 0791e6639a52f04946e11adc51ea1e7dd5b82dca Mon Sep 17 00:00:00 2001 From: Francisco Giordano Date: Fri, 30 Jun 2017 15:52:11 -0300 Subject: [PATCH 06/12] add extra arguments to test command for development testing --- scripts/test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test.sh b/scripts/test.sh index cbbb6e15e..5314877d2 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -19,7 +19,7 @@ if [ ! $trpc_running ]; then > /dev/null & trpc_pid=$! fi -./node_modules/truffle/cli.js test +./node_modules/truffle/cli.js test "$@" if [ ! $trpc_running ]; then kill -9 $trpc_pid fi From 50a903a62d5d3f79ad8574b49dba85272ec679e8 Mon Sep 17 00:00:00 2001 From: Francisco Giordano Date: Fri, 30 Jun 2017 17:40:34 -0300 Subject: [PATCH 07/12] use npm test script for travis --- .travis.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1fd9a067d..33ea9aae1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,7 +7,6 @@ node_js: cache: yarn: true script: - - testrpc > /dev/null & - - truffle test + - yarn test after_script: - yarn run coveralls From 657c56c6504c76e641e9468bbfc9d45cd5fdd08c Mon Sep 17 00:00:00 2001 From: Francisco Giordano Date: Fri, 30 Jun 2017 17:54:07 -0300 Subject: [PATCH 08/12] remove unecessary whitespace --- contracts/crowdsale/CappedCrowdsale.sol | 4 +--- contracts/crowdsale/RefundableCrowdsale.sol | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/contracts/crowdsale/CappedCrowdsale.sol b/contracts/crowdsale/CappedCrowdsale.sol index 10ed09e1d..8e9f58b8f 100644 --- a/contracts/crowdsale/CappedCrowdsale.sol +++ b/contracts/crowdsale/CappedCrowdsale.sol @@ -12,9 +12,7 @@ contract CappedCrowdsale is Crowdsale { uint256 public cap; - function CappedCrowdsale( - uint256 _cap - ) { + function CappedCrowdsale(uint256 _cap) { cap = _cap; } diff --git a/contracts/crowdsale/RefundableCrowdsale.sol b/contracts/crowdsale/RefundableCrowdsale.sol index a8270935f..ad5631495 100644 --- a/contracts/crowdsale/RefundableCrowdsale.sol +++ b/contracts/crowdsale/RefundableCrowdsale.sol @@ -21,9 +21,7 @@ contract RefundableCrowdsale is FinalizableCrowdsale { // refund vault used to hold funds while crowdsale is running RefundVault public vault; - function RefundableCrowdsale( - uint256 _goal - ) { + function RefundableCrowdsale(uint256 _goal) { vault = new RefundVault(wallet); goal = _goal; } From 54d74b1c265ce29c35a19b81c13dd6f6f3587cdd Mon Sep 17 00:00:00 2001 From: Francisco Giordano Date: Fri, 30 Jun 2017 17:54:26 -0300 Subject: [PATCH 09/12] rename canBuy to purchaseValid --- contracts/crowdsale/CappedCrowdsale.sol | 6 +++--- contracts/crowdsale/Crowdsale.sol | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/crowdsale/CappedCrowdsale.sol b/contracts/crowdsale/CappedCrowdsale.sol index 8e9f58b8f..8b8ed82f8 100644 --- a/contracts/crowdsale/CappedCrowdsale.sol +++ b/contracts/crowdsale/CappedCrowdsale.sol @@ -16,11 +16,11 @@ contract CappedCrowdsale is Crowdsale { cap = _cap; } - // overriding Crowdsale#canBuy to add extra cap logic + // overriding Crowdsale#purchaseValid to add extra cap logic // @return true if investors can buy at the moment - function canBuy() internal constant returns (bool) { + function purchaseValid() internal constant returns (bool) { bool withinCap = weiRaised.add(msg.value) <= cap; - return super.canBuy() && withinCap; + return super.purchaseValid() && withinCap; } // overriding Crowdsale#hasEnded to add cap logic diff --git a/contracts/crowdsale/Crowdsale.sol b/contracts/crowdsale/Crowdsale.sol index 84548951b..7e071f11e 100644 --- a/contracts/crowdsale/Crowdsale.sol +++ b/contracts/crowdsale/Crowdsale.sol @@ -67,7 +67,7 @@ contract Crowdsale { // low level token purchase function function buyTokens(address beneficiary) payable { - require(canBuy()); + require(purchaseValid()); uint256 weiAmount = msg.value; uint256 updatedWeiRaised = weiRaised.add(weiAmount); @@ -91,7 +91,7 @@ contract Crowdsale { } // @return true if the transaction can buy tokens - function canBuy() internal constant returns (bool) { + function purchaseValid() internal constant returns (bool) { uint256 current = block.number; bool withinPeriod = current >= startBlock && current <= endBlock; bool nonZeroPurchase = msg.value != 0; From 46fe7ee76d63aa027d139e6f122ec8ebb9608be0 Mon Sep 17 00:00:00 2001 From: Francisco Giordano Date: Sun, 2 Jul 2017 16:51:26 -0300 Subject: [PATCH 10/12] guard against beneficiary == 0x0 --- contracts/crowdsale/Crowdsale.sol | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/crowdsale/Crowdsale.sol b/contracts/crowdsale/Crowdsale.sol index 7e071f11e..04244dca7 100644 --- a/contracts/crowdsale/Crowdsale.sol +++ b/contracts/crowdsale/Crowdsale.sol @@ -67,6 +67,7 @@ contract Crowdsale { // low level token purchase function function buyTokens(address beneficiary) payable { + require(beneficiary != 0x0); require(purchaseValid()); uint256 weiAmount = msg.value; From 070bcbcdbd7beaaec694265ef42c3f31867aa907 Mon Sep 17 00:00:00 2001 From: Francisco Giordano Date: Sun, 2 Jul 2017 17:05:06 -0300 Subject: [PATCH 11/12] rename purchaseValid to validPurchase --- contracts/crowdsale/CappedCrowdsale.sol | 6 +++--- contracts/crowdsale/Crowdsale.sol | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/crowdsale/CappedCrowdsale.sol b/contracts/crowdsale/CappedCrowdsale.sol index 8b8ed82f8..9142efb37 100644 --- a/contracts/crowdsale/CappedCrowdsale.sol +++ b/contracts/crowdsale/CappedCrowdsale.sol @@ -16,11 +16,11 @@ contract CappedCrowdsale is Crowdsale { cap = _cap; } - // overriding Crowdsale#purchaseValid to add extra cap logic + // overriding Crowdsale#validPurchase to add extra cap logic // @return true if investors can buy at the moment - function purchaseValid() internal constant returns (bool) { + function validPurchase() internal constant returns (bool) { bool withinCap = weiRaised.add(msg.value) <= cap; - return super.purchaseValid() && withinCap; + return super.validPurchase() && withinCap; } // overriding Crowdsale#hasEnded to add cap logic diff --git a/contracts/crowdsale/Crowdsale.sol b/contracts/crowdsale/Crowdsale.sol index 04244dca7..8c4a00395 100644 --- a/contracts/crowdsale/Crowdsale.sol +++ b/contracts/crowdsale/Crowdsale.sol @@ -68,7 +68,7 @@ contract Crowdsale { // low level token purchase function function buyTokens(address beneficiary) payable { require(beneficiary != 0x0); - require(purchaseValid()); + require(validPurchase()); uint256 weiAmount = msg.value; uint256 updatedWeiRaised = weiRaised.add(weiAmount); @@ -92,7 +92,7 @@ contract Crowdsale { } // @return true if the transaction can buy tokens - function purchaseValid() internal constant returns (bool) { + function validPurchase() internal constant returns (bool) { uint256 current = block.number; bool withinPeriod = current >= startBlock && current <= endBlock; bool nonZeroPurchase = msg.value != 0; From b961eea89dbeb37f4b5d5afe36fe2f05be130b46 Mon Sep 17 00:00:00 2001 From: Francisco Giordano Date: Sun, 2 Jul 2017 17:10:11 -0300 Subject: [PATCH 12/12] fix claimRefund precondition --- contracts/crowdsale/RefundableCrowdsale.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/crowdsale/RefundableCrowdsale.sol b/contracts/crowdsale/RefundableCrowdsale.sol index ad5631495..6bf3b0e5b 100644 --- a/contracts/crowdsale/RefundableCrowdsale.sol +++ b/contracts/crowdsale/RefundableCrowdsale.sol @@ -35,7 +35,7 @@ contract RefundableCrowdsale is FinalizableCrowdsale { // if crowdsale is unsuccessful, investors can claim refunds here function claimRefund() { - require(hasEnded()); + require(isFinalized); require(!goalReached()); vault.refund(msg.sender);