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 diff --git a/contracts/crowdsale/CappedCrowdsale.sol b/contracts/crowdsale/CappedCrowdsale.sol new file mode 100644 index 000000000..9142efb37 --- /dev/null +++ b/contracts/crowdsale/CappedCrowdsale.sol @@ -0,0 +1,33 @@ +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#validPurchase to add extra cap logic + // @return true if investors can buy at the moment + function validPurchase() internal constant returns (bool) { + bool withinCap = weiRaised.add(msg.value) <= cap; + return super.validPurchase() && 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..8c4a00395 --- /dev/null +++ b/contracts/crowdsale/Crowdsale.sol @@ -0,0 +1,108 @@ +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; + + // 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); + + + 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(beneficiary != 0x0); + require(validPurchase()); + + 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 validPurchase() 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..6bf3b0e5b --- /dev/null +++ b/contracts/crowdsale/RefundableCrowdsale.sol @@ -0,0 +1,59 @@ +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(isFinalized); + 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/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/contracts/token/PausableToken.sol b/contracts/token/PausableToken.sol index b2bf79907..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 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); } } 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 diff --git a/scripts/test.sh b/scripts/test.sh index f6a788f35..5314877d2 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -4,10 +4,22 @@ 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 +./node_modules/truffle/cli.js test "$@" if [ ! $trpc_running ]; then kill -9 $trpc_pid fi 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/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() { 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')) +}