From ba17dfa26a75d880159ad9560025efdf8794f2bf Mon Sep 17 00:00:00 2001 From: Ariel Barmat Date: Tue, 29 Aug 2017 03:36:47 -0300 Subject: [PATCH] Add a SplitPayment contract to distribute funds among multiple beneficiaries --- contracts/payment/SplitPayment.sol | 148 +++++++++++++++++++++++++++++ test/SplitPayment.js | 102 ++++++++++++++++++++ test/helpers/SplitPaymentMock.sol | 9 ++ 3 files changed, 259 insertions(+) create mode 100644 contracts/payment/SplitPayment.sol create mode 100644 test/SplitPayment.js create mode 100644 test/helpers/SplitPaymentMock.sol diff --git a/contracts/payment/SplitPayment.sol b/contracts/payment/SplitPayment.sol new file mode 100644 index 00000000000..85c19bff91c --- /dev/null +++ b/contracts/payment/SplitPayment.sol @@ -0,0 +1,148 @@ +pragma solidity ^0.4.15; + +import '../ReentrancyGuard.sol'; +import '../math/SafeMath.sol'; + +/** + * @title SplitPayment + * @dev Base contract supporting the distribution of funds send to this contract to multiple payees. + */ +contract SplitPayment is ReentrancyGuard { + using SafeMath for uint256; + + struct Payee { + address addr; + uint256 shares; + } + + uint256 public totalShares = 0; + uint256 public maxPayees = 0; + + mapping(address => uint256) payeeIndex; + Payee[] payees; + + /** + * @dev Constructor + * @param _maxPayees Total number of payees allowed. Zero for no limit. + */ + function SplitPayment(uint256 _maxPayees) { + maxPayees = _maxPayees; + } + + /** + * @dev Modifier that throws if you want to distribute funds and you are not a payee. + */ + modifier canDistribute() { + require(isPayee(msg.sender)); + _; + } + + /** + * @dev Modifier that throws if not allowed to update payees. + * Override from child contract with your own requirements for access control. + */ + modifier canUpdate() { + _; + } + + /** + * @dev Add a new payee to the contract. + * @param _payee The address of the payee to add. + * @param _shares The number of shares owned by the payee. + */ + function addPayee(address _payee, uint256 _shares) public canUpdate { + require(_payee != address(0)); + require(_shares > 0); + require(!isPayee(_payee)); + require(maxPayees == 0 || payees.length.add(1) <= maxPayees); + + payees.push(Payee(_payee, _shares)); + payeeIndex[_payee] = payees.length; + totalShares = totalShares.add(_shares); + } + + /** + * @dev Add multiple payees to the contract. + * @param _payees An array of addresses of payees to add. + * @param _shares An array of the shares corresponding to each payee in the _payees array. + */ + function addPayeeMany(address[] _payees, uint256[] _shares) public canUpdate { + require(_payees.length == _shares.length); + require(maxPayees == 0 || payees.length.add(_payees.length) <= maxPayees); + + for (uint256 i = 0; i < _payees.length; i++) { + addPayee(_payees[i], _shares[i]); + } + } + + /** + * @dev Return true if the payee is in the contract. + * @param _payee The address of the payee to check. + */ + function isPayee(address _payee) public constant returns (bool) { + return payeeIndex[_payee] > 0; + } + + /** + * @dev Return the number of payees in the contract. + */ + function getPayeeCount() public constant returns (uint256) { + return payees.length; + } + + /** + * @dev Return the address of the payee and its shares. + * Throws if the payee is not in the contract. + * @param _payee The address of the payee to get. + */ + function getPayee(address _payee) public constant returns (address, uint256) { + require(isPayee(_payee)); + + return getPayeeAtIndex(payeeIndex[_payee] - 1); + } + + /** + * @dev Return the address of the payee and its shares by index. + * Allows iterating through the payee list from a client by knowing the payee count. + * @param _idx The index of the payee in the internal list. + */ + function getPayeeAtIndex(uint256 _idx) public constant returns (address, uint256) { + require(_idx < payees.length); + + return (payees[_idx].addr, payees[_idx].shares); + } + + /** + * @dev Perform the payment to a payee. + * This can be overriden to provide different transfer mechanisms. + * @param _payee The address of the payee to be paid. + * @param _amount The amount for the payment. + */ + function pay(address _payee, uint256 _amount) internal { + _payee.transfer(_amount); + } + + /** + * @dev Return the total amount of funds available for distribution. + */ + function toDistribute() internal returns (uint256) { + return this.balance; + } + + /** + * @dev Send payments to the registered payees according to their shares and the total + * amount of funds to distribute. + */ + function distributeFunds() public canDistribute nonReentrant { + uint256 amountDistribute = toDistribute(); + assert(amountDistribute > 0); + + Payee memory payee; + for (uint256 i = 0; i < payees.length; i++) { + payee = payees[i]; + + uint256 amount = amountDistribute.mul(payee.shares).div(totalShares); + pay(payee.addr, amount); + } + } +} diff --git a/test/SplitPayment.js b/test/SplitPayment.js new file mode 100644 index 00000000000..a12df2348c1 --- /dev/null +++ b/test/SplitPayment.js @@ -0,0 +1,102 @@ +const BigNumber = web3.BigNumber + +const should = require('chai') + .use(require('chai-as-promised')) + .use(require('chai-bignumber')(BigNumber)) + .should() + +const EVMThrow = require('./helpers/EVMThrow.js') +const SplitPaymentMock = artifacts.require('./helpers/SplitPaymentMock.sol') + +contract('SplitPayment', function ([owner, payee1, payee2, payee3, nonpayee1, payer1]) { + const amount = web3.toWei(1.0, 'ether') + + beforeEach(async function () { + this.payees = [payee1, payee2, payee3] + this.shares = [20, 10, 70] + + this.contract = await SplitPaymentMock.new() + await this.contract.addPayeeMany(this.payees, this.shares) + }) + + it('should accept payments', async function () { + await web3.eth.sendTransaction({ from: owner, to: this.contract.address, value: amount }) + + const balance = web3.eth.getBalance(this.contract.address) + balance.should.be.bignumber.equal(amount) + }) + + it('should return if address is payee', async function () { + const isPayee = await this.contract.isPayee.call(payee1) + isPayee.should.equal(true) + }) + + it('should return if address is not payee', async function () { + const isPayee = await this.contract.isPayee.call(nonpayee1) + isPayee.should.equal(false) + }) + + it('should return the correct payee by address', async function () { + const payeeIdx = 0 + const [payee, shares] = await this.contract.getPayee.call(this.payees[payeeIdx]) + payee.should.be.equal(payee1) + shares.should.be.bignumber.equal(this.shares[payeeIdx]) + }) + + it('should return the correct payee by index', async function () { + const payeeIdx = 1 + const [payee, shares] = await this.contract.getPayeeAtIndex.call(payeeIdx) + payee.should.be.equal(payee2) + shares.should.be.bignumber.equal(this.shares[payeeIdx]) + }) + + it('should throw if payees and shares array have different sizes', async function () { + const payees = [payee1, payee2, payee3] + const shares = [50, 50] + await this.contract.addPayeeMany(payees, shares).should.be.rejectedWith(EVMThrow) + }) + + it('should throw if try to add same payee multiple times', async function () { + const payees = [payee1, payee1] + const shares = [50, 50] + await this.contract.addPayeeMany(payees, shares).should.be.rejectedWith(EVMThrow) + }) + + it('should throw if try to add payee with zero shares', async function () { + await this.contract.addPayee(nonpayee1, 0).should.be.rejectedWith(EVMThrow) + }) + + it('should throw if no funds to distribute', async function () { + await this.contract.distributeFunds({from: payee1}).should.be.rejectedWith(EVMThrow) + }) + + it('should distribute funds to payees', async function () { + await web3.eth.sendTransaction({from: payer1, to: this.contract.address, value: amount}) + + const initBalance = web3.eth.getBalance(this.contract.address) + initBalance.should.be.bignumber.equal(amount) + + const initAmount1 = web3.eth.getBalance(payee1) + const initAmount2 = web3.eth.getBalance(payee2) + const initAmount3 = web3.eth.getBalance(payee3) + + await this.contract.distributeFunds({from: payee1}) + + // Contract should have zero balance after distribution + const afterBalance = web3.eth.getBalance(this.contract.address) + afterBalance.should.be.bignumber.equal(0) + + const profit1 = web3.eth.getBalance(payee1) - initAmount1 + const profit2 = web3.eth.getBalance(payee2) - initAmount2 + const profit3 = web3.eth.getBalance(payee3) - initAmount3 + + assert(Math.abs(profit1 - web3.toWei(0.20, 'ether')) < 1e16); + assert(Math.abs(profit2 - web3.toWei(0.10, 'ether')) < 1e16); + assert(Math.abs(profit3 - web3.toWei(0.70, 'ether')) < 1e16); + }) + + it('should throw if non-payee want to distribute funds', async function () { + await web3.eth.sendTransaction({from: payer1, to: this.contract.address, value: amount}) + await this.contract.distributeFunds({from: nonpayee1}).should.be.rejectedWith(EVMThrow) + }) +}) diff --git a/test/helpers/SplitPaymentMock.sol b/test/helpers/SplitPaymentMock.sol new file mode 100644 index 00000000000..80ce5a35557 --- /dev/null +++ b/test/helpers/SplitPaymentMock.sol @@ -0,0 +1,9 @@ +pragma solidity ^0.4.15; + +import '../../contracts/payment/SplitPayment.sol'; + +// mock class using SplitPayment +contract SplitPaymentMock is SplitPayment { + function SplitPaymentMock() SplitPayment(0) payable { } + function () payable {} +}