Bläddra i källkod

Escrows (#1014)

* Added basic Escrow

* PullPayment now uses an Escrow, removing all trust from the contract

* Abstracted the Escrow tests to a behaviour

* Added ConditionalEscrow

* Added RefundableEscrow.

* RefundableCrowdsale now uses a RefundEscrow, removed RefundVault.

* Renaming after code review.

* Added log test helper.

* Now allowing empty deposits and withdrawals.

* Style fixes.

* Minor review comments.

* Add Deposited and Withdrawn events, removed Refunded

* The base Escrow is now Ownable, users of it (owners) must provide methods to access it.
Nicolás Venturo 7 år sedan
förälder
incheckning
8fd072cf8e

+ 2 - 2
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;
   }
 

+ 12 - 12
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);
   }
 
 }

+ 0 - 66
contracts/crowdsale/distribution/utils/RefundVault.sol

@@ -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);
-  }
-}

+ 18 - 0
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];
+  }
+}

+ 3 - 3
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);
   }
 
 }

+ 22 - 0
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);
+  }
+}

+ 51 - 0
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);
+  }
+}

+ 18 - 19
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);
   }
 }

+ 74 - 0
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;
+  }
+}

+ 0 - 59
test/crowdsale/RefundVault.test.js

@@ -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);
-  });
-});

+ 0 - 3
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 () {

+ 41 - 0
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);
+    });
+  });
+});

+ 98 - 0
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);
+      });
+    });
+  });
+};

+ 13 - 0
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));
+});

+ 34 - 43
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);
   });
 });

+ 104 - 0
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);
+    });
+  });
+});