diff --git a/contracts/Bounty.sol b/contracts/Bounty.sol index fb8d7b937ca..5c4dec3e173 100644 --- a/contracts/Bounty.sol +++ b/contracts/Bounty.sol @@ -35,7 +35,7 @@ contract Bounty is PullPayment, Destructible { } /** - * @dev Sends the contract funds to the researcher that proved the contract is broken. + * @dev Transfers the contract funds to the researcher that proved the contract is broken. * @param target contract */ function claim(Target target) public { @@ -43,7 +43,7 @@ contract Bounty is PullPayment, Destructible { require(researcher != address(0)); // Check Target contract invariants require(!target.checkInvariant()); - asyncSend(researcher, address(this).balance); + asyncTransfer(researcher, address(this).balance); claimed = true; } diff --git a/contracts/crowdsale/distribution/RefundableCrowdsale.sol b/contracts/crowdsale/distribution/RefundableCrowdsale.sol index 19e5791ca16..0342173dce0 100644 --- a/contracts/crowdsale/distribution/RefundableCrowdsale.sol +++ b/contracts/crowdsale/distribution/RefundableCrowdsale.sol @@ -3,14 +3,13 @@ pragma solidity ^0.4.24; import "../../math/SafeMath.sol"; import "./FinalizableCrowdsale.sol"; -import "./utils/RefundVault.sol"; +import "../../payment/RefundEscrow.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; @@ -18,16 +17,16 @@ contract RefundableCrowdsale is FinalizableCrowdsale { // 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; + // refund escrow used to hold funds while crowdsale is running + RefundEscrow private escrow; /** - * @dev Constructor, creates RefundVault. + * @dev Constructor, creates RefundEscrow. * @param _goal Funding goal */ constructor(uint256 _goal) public { require(_goal > 0); - vault = new RefundVault(wallet); + escrow = new RefundEscrow(wallet); goal = _goal; } @@ -38,7 +37,7 @@ contract RefundableCrowdsale is FinalizableCrowdsale { require(isFinalized); require(!goalReached()); - vault.refund(msg.sender); + escrow.withdraw(msg.sender); } /** @@ -50,23 +49,24 @@ contract RefundableCrowdsale is FinalizableCrowdsale { } /** - * @dev vault finalization task, called when owner calls finalize() + * @dev escrow finalization task, called when owner calls finalize() */ function finalization() internal { if (goalReached()) { - vault.close(); + escrow.close(); + escrow.beneficiaryWithdraw(); } else { - vault.enableRefunds(); + escrow.enableRefunds(); } super.finalization(); } /** - * @dev Overrides Crowdsale fund forwarding, sending funds to vault. + * @dev Overrides Crowdsale fund forwarding, sending funds to escrow. */ function _forwardFunds() internal { - vault.deposit.value(msg.value)(msg.sender); + escrow.deposit.value(msg.value)(msg.sender); } } diff --git a/contracts/crowdsale/distribution/utils/RefundVault.sol b/contracts/crowdsale/distribution/utils/RefundVault.sol deleted file mode 100644 index 38a120977ce..00000000000 --- a/contracts/crowdsale/distribution/utils/RefundVault.sol +++ /dev/null @@ -1,66 +0,0 @@ -pragma solidity ^0.4.24; - -import "../../../math/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); - - /** - * @param _wallet Vault address - */ - constructor(address _wallet) public { - require(_wallet != address(0)); - wallet = _wallet; - state = State.Active; - } - - /** - * @param investor Investor address - */ - function deposit(address investor) onlyOwner public payable { - require(state == State.Active); - deposited[investor] = deposited[investor].add(msg.value); - } - - function close() onlyOwner public { - require(state == State.Active); - state = State.Closed; - emit Closed(); - wallet.transfer(address(this).balance); - } - - function enableRefunds() onlyOwner public { - require(state == State.Active); - state = State.Refunding; - emit RefundsEnabled(); - } - - /** - * @param investor Investor address - */ - function refund(address investor) public { - require(state == State.Refunding); - uint256 depositedValue = deposited[investor]; - deposited[investor] = 0; - investor.transfer(depositedValue); - emit Refunded(investor, depositedValue); - } -} diff --git a/contracts/mocks/ConditionalEscrowMock.sol b/contracts/mocks/ConditionalEscrowMock.sol new file mode 100644 index 00000000000..dfcab519503 --- /dev/null +++ b/contracts/mocks/ConditionalEscrowMock.sol @@ -0,0 +1,18 @@ +pragma solidity ^0.4.24; + + +import "../payment/ConditionalEscrow.sol"; + + +// mock class using ConditionalEscrow +contract ConditionalEscrowMock is ConditionalEscrow { + mapping(address => bool) public allowed; + + function setAllowed(address _payee, bool _allowed) public { + allowed[_payee] = _allowed; + } + + function withdrawalAllowed(address _payee) public view returns (bool) { + return allowed[_payee]; + } +} diff --git a/contracts/mocks/PullPaymentMock.sol b/contracts/mocks/PullPaymentMock.sol index 305b923d960..db2203e4726 100644 --- a/contracts/mocks/PullPaymentMock.sol +++ b/contracts/mocks/PullPaymentMock.sol @@ -9,9 +9,9 @@ contract PullPaymentMock is PullPayment { constructor() public payable { } - // test helper function to call asyncSend - function callSend(address dest, uint256 amount) public { - asyncSend(dest, amount); + // test helper function to call asyncTransfer + function callTransfer(address dest, uint256 amount) public { + asyncTransfer(dest, amount); } } diff --git a/contracts/payment/ConditionalEscrow.sol b/contracts/payment/ConditionalEscrow.sol new file mode 100644 index 00000000000..9278a69a39e --- /dev/null +++ b/contracts/payment/ConditionalEscrow.sol @@ -0,0 +1,22 @@ +pragma solidity ^0.4.23; + +import "./Escrow.sol"; + + +/** + * @title ConditionalEscrow + * @dev Base abstract escrow to only allow withdrawal if a condition is met. + */ +contract ConditionalEscrow is Escrow { + /** + * @dev Returns whether an address is allowed to withdraw their funds. To be + * implemented by derived contracts. + * @param _payee The destination address of the funds. + */ + function withdrawalAllowed(address _payee) public view returns (bool); + + function withdraw(address _payee) public { + require(withdrawalAllowed(_payee)); + super.withdraw(_payee); + } +} diff --git a/contracts/payment/Escrow.sol b/contracts/payment/Escrow.sol new file mode 100644 index 00000000000..dfa762ebb9f --- /dev/null +++ b/contracts/payment/Escrow.sol @@ -0,0 +1,51 @@ +pragma solidity ^0.4.23; + +import "../math/SafeMath.sol"; +import "../ownership/Ownable.sol"; + + +/** + * @title Escrow + * @dev Base escrow contract, holds funds destinated to a payee until they + * withdraw them. The contract that uses the escrow as its payment method + * should be its owner, and provide public methods redirecting to the escrow's + * deposit and withdraw. + */ +contract Escrow is Ownable { + using SafeMath for uint256; + + event Deposited(address indexed payee, uint256 weiAmount); + event Withdrawn(address indexed payee, uint256 weiAmount); + + mapping(address => uint256) private deposits; + + function depositsOf(address _payee) public view returns (uint256) { + return deposits[_payee]; + } + + /** + * @dev Stores the sent amount as credit to be withdrawn. + * @param _payee The destination address of the funds. + */ + function deposit(address _payee) public onlyOwner payable { + uint256 amount = msg.value; + deposits[_payee] = deposits[_payee].add(amount); + + emit Deposited(_payee, amount); + } + + /** + * @dev Withdraw accumulated balance for a payee. + * @param _payee The address whose funds will be withdrawn and transferred to. + */ + function withdraw(address _payee) public onlyOwner { + uint256 payment = deposits[_payee]; + assert(address(this).balance >= payment); + + deposits[_payee] = 0; + + _payee.transfer(payment); + + emit Withdrawn(_payee, payment); + } +} diff --git a/contracts/payment/PullPayment.sol b/contracts/payment/PullPayment.sol index a1ddb8c0096..e117fc49eb6 100644 --- a/contracts/payment/PullPayment.sol +++ b/contracts/payment/PullPayment.sol @@ -1,43 +1,42 @@ pragma solidity ^0.4.24; - -import "../math/SafeMath.sol"; +import "./Escrow.sol"; /** * @title PullPayment * @dev Base contract supporting async send for pull payments. Inherit from this - * contract and use asyncSend instead of send or transfer. + * contract and use asyncTransfer instead of send or transfer. */ contract PullPayment { - using SafeMath for uint256; + Escrow private escrow; - mapping(address => uint256) public payments; - uint256 public totalPayments; + constructor() public { + escrow = new Escrow(); + } /** * @dev Withdraw accumulated balance, called by payee. */ function withdrawPayments() public { address payee = msg.sender; - uint256 payment = payments[payee]; - - require(payment != 0); - require(address(this).balance >= payment); - - totalPayments = totalPayments.sub(payment); - payments[payee] = 0; + escrow.withdraw(payee); + } - payee.transfer(payment); + /** + * @dev Returns the credit owed to an address. + * @param _dest The creditor's address. + */ + function payments(address _dest) public view returns (uint256) { + return escrow.depositsOf(_dest); } /** * @dev Called by the payer to store the sent amount as credit to be pulled. - * @param dest The destination address of the funds. - * @param amount The amount to transfer. + * @param _dest The destination address of the funds. + * @param _amount The amount to transfer. */ - function asyncSend(address dest, uint256 amount) internal { - payments[dest] = payments[dest].add(amount); - totalPayments = totalPayments.add(amount); + function asyncTransfer(address _dest, uint256 _amount) internal { + escrow.deposit.value(_amount)(_dest); } } diff --git a/contracts/payment/RefundEscrow.sol b/contracts/payment/RefundEscrow.sol new file mode 100644 index 00000000000..9ba5fc60032 --- /dev/null +++ b/contracts/payment/RefundEscrow.sol @@ -0,0 +1,74 @@ +pragma solidity ^0.4.23; + +import "./ConditionalEscrow.sol"; +import "../ownership/Ownable.sol"; + + +/** + * @title RefundEscrow + * @dev Escrow that holds funds for a beneficiary, deposited from multiple parties. + * The contract owner may close the deposit period, and allow for either withdrawal + * by the beneficiary, or refunds to the depositors. + */ +contract RefundEscrow is Ownable, ConditionalEscrow { + enum State { Active, Refunding, Closed } + + event Closed(); + event RefundsEnabled(); + + State public state; + address public beneficiary; + + /** + * @dev Constructor. + * @param _beneficiary The beneficiary of the deposits. + */ + constructor(address _beneficiary) public { + require(_beneficiary != address(0)); + beneficiary = _beneficiary; + state = State.Active; + } + + /** + * @dev Stores funds that may later be refunded. + * @param _refundee The address funds will be sent to if a refund occurs. + */ + function deposit(address _refundee) public payable { + require(state == State.Active); + super.deposit(_refundee); + } + + /** + * @dev Allows for the beneficiary to withdraw their funds, rejecting + * further deposits. + */ + function close() public onlyOwner { + require(state == State.Active); + state = State.Closed; + emit Closed(); + } + + /** + * @dev Allows for refunds to take place, rejecting further deposits. + */ + function enableRefunds() public onlyOwner { + require(state == State.Active); + state = State.Refunding; + emit RefundsEnabled(); + } + + /** + * @dev Withdraws the beneficiary's funds. + */ + function beneficiaryWithdraw() public { + require(state == State.Closed); + beneficiary.transfer(address(this).balance); + } + + /** + * @dev Returns whether refundees can withdraw their deposits (be refunded). + */ + function withdrawalAllowed(address _payee) public view returns (bool) { + return state == State.Refunding; + } +} diff --git a/test/crowdsale/RefundVault.test.js b/test/crowdsale/RefundVault.test.js deleted file mode 100644 index e3b88dfcede..00000000000 --- a/test/crowdsale/RefundVault.test.js +++ /dev/null @@ -1,59 +0,0 @@ -import ether from '../helpers/ether'; -import EVMRevert from '../helpers/EVMRevert'; - -const BigNumber = web3.BigNumber; - -require('chai') - .use(require('chai-as-promised')) - .use(require('chai-bignumber')(BigNumber)) - .should(); - -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(EVMRevert); - }); - - it('only owner can enter refund mode', async function () { - await this.vault.enableRefunds({ from: _ }).should.be.rejectedWith(EVMRevert); - 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(EVMRevert); - 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/examples/SampleCrowdsale.test.js b/test/examples/SampleCrowdsale.test.js index e55afcd8a39..7daa4868823 100644 --- a/test/examples/SampleCrowdsale.test.js +++ b/test/examples/SampleCrowdsale.test.js @@ -14,7 +14,6 @@ require('chai') const SampleCrowdsale = artifacts.require('SampleCrowdsale'); const SampleCrowdsaleToken = artifacts.require('SampleCrowdsaleToken'); -const RefundVault = artifacts.require('RefundVault'); contract('SampleCrowdsale', function ([owner, wallet, investor]) { const RATE = new BigNumber(10); @@ -32,12 +31,10 @@ contract('SampleCrowdsale', function ([owner, wallet, investor]) { this.afterClosingTime = this.closingTime + duration.seconds(1); this.token = await SampleCrowdsaleToken.new({ from: owner }); - this.vault = await RefundVault.new(wallet, { from: owner }); this.crowdsale = await SampleCrowdsale.new( this.openingTime, this.closingTime, RATE, wallet, CAP, this.token.address, GOAL ); await this.token.transferOwnership(this.crowdsale.address); - await this.vault.transferOwnership(this.crowdsale.address); }); it('should create crowdsale with correct parameters', async function () { diff --git a/test/payment/ConditionalEscrow.test.js b/test/payment/ConditionalEscrow.test.js new file mode 100644 index 00000000000..3d8dfccfc2d --- /dev/null +++ b/test/payment/ConditionalEscrow.test.js @@ -0,0 +1,41 @@ +import shouldBehaveLikeEscrow from './Escrow.behaviour'; +import EVMRevert from '../helpers/EVMRevert'; + +const BigNumber = web3.BigNumber; + +require('chai') + .use(require('chai-bignumber')(BigNumber)) + .should(); + +const ConditionalEscrowMock = artifacts.require('ConditionalEscrowMock'); + +contract('ConditionalEscrow', function (accounts) { + const owner = accounts[0]; + + beforeEach(async function () { + this.escrow = await ConditionalEscrowMock.new({ from: owner }); + }); + + context('when withdrawal is allowed', function () { + beforeEach(async function () { + await Promise.all(accounts.map(payee => this.escrow.setAllowed(payee, true))); + }); + + shouldBehaveLikeEscrow(owner, accounts.slice(1)); + }); + + context('when withdrawal is disallowed', function () { + const amount = web3.toWei(23.0, 'ether'); + const payee = accounts[1]; + + beforeEach(async function () { + await this.escrow.setAllowed(payee, false); + }); + + it('reverts on withdrawals', async function () { + await this.escrow.deposit(payee, { from: owner, value: amount }); + + await this.escrow.withdraw(payee, { from: owner }).should.be.rejectedWith(EVMRevert); + }); + }); +}); diff --git a/test/payment/Escrow.behaviour.js b/test/payment/Escrow.behaviour.js new file mode 100644 index 00000000000..ab3947578fb --- /dev/null +++ b/test/payment/Escrow.behaviour.js @@ -0,0 +1,98 @@ +import expectEvent from '../helpers/expectEvent'; +import EVMRevert from '../helpers/EVMRevert'; + +const BigNumber = web3.BigNumber; + +require('chai') + .use(require('chai-bignumber')(BigNumber)) + .should(); + +export default function (owner, [payee1, payee2]) { + const amount = web3.toWei(42.0, 'ether'); + + describe('as an escrow', function () { + describe('deposits', function () { + it('can accept a single deposit', async function () { + await this.escrow.deposit(payee1, { from: owner, value: amount }); + + const balance = await web3.eth.getBalance(this.escrow.address); + const deposit = await this.escrow.depositsOf(payee1); + + balance.should.be.bignumber.equal(amount); + deposit.should.be.bignumber.equal(amount); + }); + + it('can accept an empty deposit', async function () { + await this.escrow.deposit(payee1, { from: owner, value: 0 }); + }); + + it('only the owner can deposit', async function () { + await this.escrow.deposit(payee1, { from: payee2 }).should.be.rejectedWith(EVMRevert); + }); + + it('emits a deposited event', async function () { + const receipt = await this.escrow.deposit(payee1, { from: owner, value: amount }); + + const event = await expectEvent.inLogs(receipt.logs, 'Deposited', { payee: payee1 }); + event.args.weiAmount.should.be.bignumber.equal(amount); + }); + + it('can add multiple deposits on a single account', async function () { + await this.escrow.deposit(payee1, { from: owner, value: amount }); + await this.escrow.deposit(payee1, { from: owner, value: amount * 2 }); + + const balance = await web3.eth.getBalance(this.escrow.address); + const deposit = await this.escrow.depositsOf(payee1); + + balance.should.be.bignumber.equal(amount * 3); + deposit.should.be.bignumber.equal(amount * 3); + }); + + it('can track deposits to multiple accounts', async function () { + await this.escrow.deposit(payee1, { from: owner, value: amount }); + await this.escrow.deposit(payee2, { from: owner, value: amount * 2 }); + + const balance = await web3.eth.getBalance(this.escrow.address); + const depositPayee1 = await this.escrow.depositsOf(payee1); + const depositPayee2 = await this.escrow.depositsOf(payee2); + + balance.should.be.bignumber.equal(amount * 3); + depositPayee1.should.be.bignumber.equal(amount); + depositPayee2.should.be.bignumber.equal(amount * 2); + }); + }); + + describe('withdrawals', async function () { + it('can withdraw payments', async function () { + const payeeInitialBalance = await web3.eth.getBalance(payee1); + + await this.escrow.deposit(payee1, { from: owner, value: amount }); + await this.escrow.withdraw(payee1, { from: owner }); + + const escrowBalance = await web3.eth.getBalance(this.escrow.address); + const finalDeposit = await this.escrow.depositsOf(payee1); + const payeeFinalBalance = await web3.eth.getBalance(payee1); + + escrowBalance.should.be.bignumber.equal(0); + finalDeposit.should.be.bignumber.equal(0); + payeeFinalBalance.sub(payeeInitialBalance).should.be.bignumber.equal(amount); + }); + + it('can do an empty withdrawal', async function () { + await this.escrow.withdraw(payee1, { from: owner }); + }); + + it('only the owner can withdraw', async function () { + await this.escrow.withdraw(payee1, { from: payee1 }).should.be.rejectedWith(EVMRevert); + }); + + it('emits a withdrawn event', async function () { + await this.escrow.deposit(payee1, { from: owner, value: amount }); + const receipt = await this.escrow.withdraw(payee1, { from: owner }); + + const event = await expectEvent.inLogs(receipt.logs, 'Withdrawn', { payee: payee1 }); + event.args.weiAmount.should.be.bignumber.equal(amount); + }); + }); + }); +}; diff --git a/test/payment/Escrow.test.js b/test/payment/Escrow.test.js new file mode 100644 index 00000000000..0b2319efcc9 --- /dev/null +++ b/test/payment/Escrow.test.js @@ -0,0 +1,13 @@ +import shouldBehaveLikeEscrow from './Escrow.behaviour'; + +const Escrow = artifacts.require('Escrow'); + +contract('Escrow', function (accounts) { + const owner = accounts[0]; + + beforeEach(async function () { + this.escrow = await Escrow.new({ from: owner }); + }); + + shouldBehaveLikeEscrow(owner, accounts.slice(1)); +}); diff --git a/test/payment/PullPayment.test.js b/test/payment/PullPayment.test.js index 8f5a4e41ac2..68b440ed837 100644 --- a/test/payment/PullPayment.test.js +++ b/test/payment/PullPayment.test.js @@ -1,71 +1,62 @@ -var PullPaymentMock = artifacts.require('PullPaymentMock'); +const BigNumber = web3.BigNumber; + +require('chai') + .use(require('chai-bignumber')(BigNumber)) + .should(); + +const PullPaymentMock = artifacts.require('PullPaymentMock'); contract('PullPayment', function (accounts) { - let ppce; - let amount = 17 * 1e18; + const amount = web3.toWei(17.0, 'ether'); beforeEach(async function () { - ppce = await PullPaymentMock.new({ value: amount }); + this.contract = await PullPaymentMock.new({ value: amount }); }); it('can\'t call asyncSend externally', async function () { - assert.isUndefined(ppce.asyncSend); + assert.isUndefined(this.contract.asyncSend); }); it('can record an async payment correctly', async function () { - let AMOUNT = 100; - await ppce.callSend(accounts[0], AMOUNT); - let paymentsToAccount0 = await ppce.payments(accounts[0]); - let totalPayments = await ppce.totalPayments(); + const AMOUNT = 100; + await this.contract.callTransfer(accounts[0], AMOUNT); - assert.equal(totalPayments, AMOUNT); - assert.equal(paymentsToAccount0, AMOUNT); + const paymentsToAccount0 = await this.contract.payments(accounts[0]); + paymentsToAccount0.should.be.bignumber.equal(AMOUNT); }); it('can add multiple balances on one account', async function () { - await ppce.callSend(accounts[0], 200); - await ppce.callSend(accounts[0], 300); - let paymentsToAccount0 = await ppce.payments(accounts[0]); - let totalPayments = await ppce.totalPayments(); - - assert.equal(totalPayments, 500); - assert.equal(paymentsToAccount0, 500); + await this.contract.callTransfer(accounts[0], 200); + await this.contract.callTransfer(accounts[0], 300); + const paymentsToAccount0 = await this.contract.payments(accounts[0]); + paymentsToAccount0.should.be.bignumber.equal(500); }); it('can add balances on multiple accounts', async function () { - await ppce.callSend(accounts[0], 200); - await ppce.callSend(accounts[1], 300); + await this.contract.callTransfer(accounts[0], 200); + await this.contract.callTransfer(accounts[1], 300); - let paymentsToAccount0 = await ppce.payments(accounts[0]); - assert.equal(paymentsToAccount0, 200); + const paymentsToAccount0 = await this.contract.payments(accounts[0]); + paymentsToAccount0.should.be.bignumber.equal(200); - let paymentsToAccount1 = await ppce.payments(accounts[1]); - assert.equal(paymentsToAccount1, 300); - - let totalPayments = await ppce.totalPayments(); - assert.equal(totalPayments, 500); + const paymentsToAccount1 = await this.contract.payments(accounts[1]); + paymentsToAccount1.should.be.bignumber.equal(300); }); it('can withdraw payment', async function () { - let payee = accounts[1]; - let initialBalance = web3.eth.getBalance(payee); - - await ppce.callSend(payee, amount); - - let payment1 = await ppce.payments(payee); - assert.equal(payment1, amount); + const payee = accounts[1]; + const initialBalance = web3.eth.getBalance(payee); - let totalPayments = await ppce.totalPayments(); - assert.equal(totalPayments, amount); + await this.contract.callTransfer(payee, amount); - await ppce.withdrawPayments({ from: payee }); - let payment2 = await ppce.payments(payee); - assert.equal(payment2, 0); + const payment1 = await this.contract.payments(payee); + payment1.should.be.bignumber.equal(amount); - totalPayments = await ppce.totalPayments(); - assert.equal(totalPayments, 0); + await this.contract.withdrawPayments({ from: payee }); + const payment2 = await this.contract.payments(payee); + payment2.should.be.bignumber.equal(0); - let balance = web3.eth.getBalance(payee); - assert(Math.abs(balance - initialBalance - amount) < 1e16); + const balance = web3.eth.getBalance(payee); + Math.abs(balance - initialBalance - amount).should.be.lt(1e16); }); }); diff --git a/test/payment/RefundEscrow.test.js b/test/payment/RefundEscrow.test.js new file mode 100644 index 00000000000..fadabc0b095 --- /dev/null +++ b/test/payment/RefundEscrow.test.js @@ -0,0 +1,104 @@ +import EVMRevert from '../helpers/EVMRevert'; +import expectEvent from '../helpers/expectEvent'; + +const BigNumber = web3.BigNumber; + +require('chai') + .use(require('chai-bignumber')(BigNumber)) + .should(); + +const RefundEscrow = artifacts.require('RefundEscrow'); + +contract('RefundEscrow', function ([owner, beneficiary, refundee1, refundee2]) { + const amount = web3.toWei(54.0, 'ether'); + const refundees = [refundee1, refundee2]; + + beforeEach(async function () { + this.escrow = await RefundEscrow.new(beneficiary, { from: owner }); + }); + + context('active state', function () { + it('accepts deposits', async function () { + await this.escrow.deposit(refundee1, { from: owner, value: amount }); + + const deposit = await this.escrow.depositsOf(refundee1); + deposit.should.be.bignumber.equal(amount); + }); + + it('does not refund refundees', async function () { + await this.escrow.deposit(refundee1, { from: owner, value: amount }); + await this.escrow.withdraw(refundee1).should.be.rejectedWith(EVMRevert); + }); + + it('does not allow beneficiary withdrawal', async function () { + await this.escrow.deposit(refundee1, { from: owner, value: amount }); + await this.escrow.beneficiaryWithdraw().should.be.rejectedWith(EVMRevert); + }); + }); + + it('only owner can enter closed state', async function () { + await this.escrow.close({ from: beneficiary }).should.be.rejectedWith(EVMRevert); + + const receipt = await this.escrow.close({ from: owner }); + + await expectEvent.inLogs(receipt.logs, 'Closed'); + }); + + context('closed state', function () { + beforeEach(async function () { + await Promise.all(refundees.map(refundee => this.escrow.deposit(refundee, { from: owner, value: amount }))); + + await this.escrow.close({ from: owner }); + }); + + it('rejects deposits', async function () { + await this.escrow.deposit(refundee1, { from: owner, value: amount }).should.be.rejectedWith(EVMRevert); + }); + + it('does not refund refundees', async function () { + await this.escrow.withdraw(refundee1).should.be.rejectedWith(EVMRevert); + }); + + it('allows beneficiary withdrawal', async function () { + const beneficiaryInitialBalance = await web3.eth.getBalance(beneficiary); + await this.escrow.beneficiaryWithdraw(); + const beneficiaryFinalBalance = await web3.eth.getBalance(beneficiary); + + beneficiaryFinalBalance.sub(beneficiaryInitialBalance).should.be.bignumber.equal(amount * refundees.length); + }); + }); + + it('only owner can enter refund state', async function () { + await this.escrow.enableRefunds({ from: beneficiary }).should.be.rejectedWith(EVMRevert); + + const receipt = await this.escrow.enableRefunds({ from: owner }); + + await expectEvent.inLogs(receipt.logs, 'RefundsEnabled'); + }); + + context('refund state', function () { + beforeEach(async function () { + await Promise.all(refundees.map(refundee => this.escrow.deposit(refundee, { from: owner, value: amount }))); + + await this.escrow.enableRefunds({ from: owner }); + }); + + it('rejects deposits', async function () { + await this.escrow.deposit(refundee1, { from: owner, value: amount }).should.be.rejectedWith(EVMRevert); + }); + + it('refunds refundees', async function () { + for (let refundee of [refundee1, refundee2]) { + const refundeeInitialBalance = await web3.eth.getBalance(refundee); + await this.escrow.withdraw(refundee); + const refundeeFinalBalance = await web3.eth.getBalance(refundee); + + refundeeFinalBalance.sub(refundeeInitialBalance).should.be.bignumber.equal(amount); + } + }); + + it('does not allow beneficiary withdrawal', async function () { + await this.escrow.beneficiaryWithdraw().should.be.rejectedWith(EVMRevert); + }); + }); +});