From 2ce67a25ef84e8134d9f4acae5cd6e54467aa5b9 Mon Sep 17 00:00:00 2001 From: Francisco Giordano Date: Mon, 21 May 2018 15:19:58 -0300 Subject: [PATCH] add more contracts from openzeppelin-solidity --- contracts/access/SignatureBouncer.sol | 97 +++++++++++++++++++++ contracts/mocks/BouncerMock.sol | 29 +++++++ contracts/payment/SplitPayment.sol | 76 ++++++++++++++++ test/access/SignatureBouncer.test.js | 120 ++++++++++++++++++++++++++ test/payment/SplitPayment.test.js | 79 +++++++++++++++++ zos.json | 5 +- 6 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 contracts/access/SignatureBouncer.sol create mode 100644 contracts/mocks/BouncerMock.sol create mode 100644 contracts/payment/SplitPayment.sol create mode 100644 test/access/SignatureBouncer.test.js create mode 100644 test/payment/SplitPayment.test.js diff --git a/contracts/access/SignatureBouncer.sol b/contracts/access/SignatureBouncer.sol new file mode 100644 index 000000000..43185855e --- /dev/null +++ b/contracts/access/SignatureBouncer.sol @@ -0,0 +1,97 @@ +pragma solidity ^0.4.18; + +import "../ownership/Ownable.sol"; +import "../ownership/rbac/RBAC.sol"; +import "../ECRecovery.sol"; + +/** + * @title SignatureBouncer + * @author PhABC and Shrugs + * @dev Bouncer allows users to submit a signature as a permission to do an action. + * @dev If the signature is from one of the authorized bouncer addresses, the signature + * @dev is valid. The owner of the contract adds/removes bouncers. + * @dev Bouncer addresses can be individual servers signing grants or different + * @dev users within a decentralized club that have permission to invite other members. + * @dev + * @dev This technique is useful for whitelists and airdrops; instead of putting all + * @dev valid addresses on-chain, simply sign a grant of the form + * @dev keccak256(`:contractAddress` + `:granteeAddress`) using a valid bouncer address. + * @dev Then restrict access to your crowdsale/whitelist/airdrop using the + * @dev `onlyValidSignature` modifier (or implement your own using isValidSignature). + * @dev + * @dev See the tests Bouncer.test.js for specific usage examples. + */ +contract SignatureBouncer is Migratable, Ownable, RBAC { + using ECRecovery for bytes32; + + string public constant ROLE_BOUNCER = "bouncer"; + + function initialize(address _sender) + isInitializer("SignatureBouncer", "1.9.0-beta") + public + { + Ownable.initialize(_sender); + } + + /** + * @dev requires that a valid signature of a bouncer was provided + */ + modifier onlyValidSignature(bytes _sig) + { + require(isValidSignature(msg.sender, _sig)); + _; + } + + /** + * @dev allows the owner to add additional bouncer addresses + */ + function addBouncer(address _bouncer) + onlyOwner + public + { + require(_bouncer != address(0)); + addRole(_bouncer, ROLE_BOUNCER); + } + + /** + * @dev allows the owner to remove bouncer addresses + */ + function removeBouncer(address _bouncer) + onlyOwner + public + { + require(_bouncer != address(0)); + removeRole(_bouncer, ROLE_BOUNCER); + } + + /** + * @dev is the signature of `this + sender` from a bouncer? + * @return bool + */ + function isValidSignature(address _address, bytes _sig) + internal + view + returns (bool) + { + return isValidDataHash( + keccak256(address(this), _address), + _sig + ); + } + + /** + * @dev internal function to convert a hash to an eth signed message + * @dev and then recover the signature and check it against the bouncer role + * @return bool + */ + function isValidDataHash(bytes32 hash, bytes _sig) + internal + view + returns (bool) + { + address signer = hash + .toEthSignedMessageHash() + .recover(_sig); + return hasRole(signer, ROLE_BOUNCER); + } +} diff --git a/contracts/mocks/BouncerMock.sol b/contracts/mocks/BouncerMock.sol new file mode 100644 index 000000000..3bc826583 --- /dev/null +++ b/contracts/mocks/BouncerMock.sol @@ -0,0 +1,29 @@ +pragma solidity ^0.4.18; + +import "../access/SignatureBouncer.sol"; + + +contract SignatureBouncerMock is SignatureBouncer { + function initialize(address _sender) + isInitializer("SignatureBouncerMock", "1.9.0-beta") + public + { + SignatureBouncer.initialize(_sender); + } + + function checkValidSignature(address _address, bytes _sig) + public + view + returns (bool) + { + return isValidSignature(_address, _sig); + } + + function onlyWithValidSignature(bytes _sig) + onlyValidSignature(_sig) + public + view + { + + } +} diff --git a/contracts/payment/SplitPayment.sol b/contracts/payment/SplitPayment.sol new file mode 100644 index 000000000..3cb5b9243 --- /dev/null +++ b/contracts/payment/SplitPayment.sol @@ -0,0 +1,76 @@ +pragma solidity ^0.4.21; + +import "../math/SafeMath.sol"; +import "zos-lib/contracts/migrations/Migratable.sol"; + + +/** + * @title SplitPayment + * @dev Base contract that supports multiple payees claiming funds sent to this contract + * according to the proportion they own. + */ +contract SplitPayment is Migratable { + using SafeMath for uint256; + + uint256 public totalShares = 0; + uint256 public totalReleased = 0; + + mapping(address => uint256) public shares; + mapping(address => uint256) public released; + address[] public payees; + + /** + * @dev Constructor + */ + function initialize(address[] _payees, uint256[] _shares) + isInitializer("SplitPayment", "1.9.0-beta") + public + payable + { + require(_payees.length == _shares.length); + + for (uint256 i = 0; i < _payees.length; i++) { + addPayee(_payees[i], _shares[i]); + } + } + + /** + * @dev payable fallback + */ + function () public payable {} + + /** + * @dev Claim your share of the balance. + */ + function claim() public { + address payee = msg.sender; + + require(shares[payee] > 0); + + uint256 totalReceived = address(this).balance.add(totalReleased); + uint256 payment = totalReceived.mul(shares[payee]).div(totalShares).sub(released[payee]); + + require(payment != 0); + require(address(this).balance >= payment); + + released[payee] = released[payee].add(payment); + totalReleased = totalReleased.add(payment); + + payee.transfer(payment); + } + + /** + * @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) internal { + require(_payee != address(0)); + require(_shares > 0); + require(shares[_payee] == 0); + + payees.push(_payee); + shares[_payee] = _shares; + totalShares = totalShares.add(_shares); + } +} diff --git a/test/access/SignatureBouncer.test.js b/test/access/SignatureBouncer.test.js new file mode 100644 index 000000000..e4f5d39ec --- /dev/null +++ b/test/access/SignatureBouncer.test.js @@ -0,0 +1,120 @@ + +import assertRevert from '../helpers/assertRevert'; +import { signHex } from '../helpers/sign'; + +const Bouncer = artifacts.require('SignatureBouncerMock'); + +require('chai') + .use(require('chai-as-promised')) + .should(); + +export const getSigner = (contract, signer, data = '') => (addr) => { + // via: https://github.com/OpenZeppelin/zeppelin-solidity/pull/812/files + const message = contract.address.substr(2) + addr.substr(2) + data; + // ^ substr to remove `0x` because in solidity the address is a set of byes, not a string `0xabcd` + return signHex(signer, message); +}; + +contract('Bouncer', ([_, owner, authorizedUser, anyone, bouncerAddress, newBouncer]) => { + before(async function () { + this.bouncer = await Bouncer.new(); + await this.bouncer.initialize(owner); + this.roleBouncer = await this.bouncer.ROLE_BOUNCER(); + this.genSig = getSigner(this.bouncer, bouncerAddress); + }); + + it('should have a default owner of self', async function () { + const theOwner = await this.bouncer.owner(); + theOwner.should.eq(owner); + }); + + it('should allow owner to add a bouncer', async function () { + await this.bouncer.addBouncer(bouncerAddress, { from: owner }); + const hasRole = await this.bouncer.hasRole(bouncerAddress, this.roleBouncer); + hasRole.should.eq(true); + }); + + it('should not allow anyone to add a bouncer', async function () { + await assertRevert( + this.bouncer.addBouncer(bouncerAddress, { from: anyone }) + ); + }); + + context('modifiers', () => { + it('should allow valid signature for sender', async function () { + await this.bouncer.onlyWithValidSignature( + this.genSig(authorizedUser), + { from: authorizedUser } + ); + }); + it('should not allow invalid signature for sender', async function () { + await assertRevert( + this.bouncer.onlyWithValidSignature( + 'abcd', + { from: authorizedUser } + ) + ); + }); + }); + + context('signatures', () => { + it('should accept valid message for valid user', async function () { + const isValid = await this.bouncer.checkValidSignature( + authorizedUser, + this.genSig(authorizedUser) + ); + isValid.should.eq(true); + }); + it('should not accept invalid message for valid user', async function () { + const isValid = await this.bouncer.checkValidSignature( + authorizedUser, + this.genSig(anyone) + ); + isValid.should.eq(false); + }); + it('should not accept invalid message for invalid user', async function () { + const isValid = await this.bouncer.checkValidSignature( + anyone, + 'abcd' + ); + isValid.should.eq(false); + }); + it('should not accept valid message for invalid user', async function () { + const isValid = await this.bouncer.checkValidSignature( + anyone, + this.genSig(authorizedUser) + ); + isValid.should.eq(false); + }); + }); + + context('management', () => { + it('should not allow anyone to add bouncers', async function () { + await assertRevert( + this.bouncer.addBouncer(newBouncer, { from: anyone }) + ); + }); + + it('should be able to add bouncers', async function () { + await this.bouncer.addBouncer(newBouncer, { from: owner }) + .should.be.fulfilled; + }); + + it('should not allow adding invalid address', async function () { + await assertRevert( + this.bouncer.addBouncer('0x0', { from: owner }) + ); + }); + + it('should not allow anyone to remove bouncer', async function () { + await assertRevert( + this.bouncer.removeBouncer(newBouncer, { from: anyone }) + ); + }); + + it('should be able to remove bouncers', async function () { + await this.bouncer.removeBouncer(newBouncer, { from: owner }) + .should.be.fulfilled; + }); + }); +}); diff --git a/test/payment/SplitPayment.test.js b/test/payment/SplitPayment.test.js new file mode 100644 index 000000000..d81d3e6a5 --- /dev/null +++ b/test/payment/SplitPayment.test.js @@ -0,0 +1,79 @@ +const BigNumber = web3.BigNumber; + +require('chai') + .use(require('chai-as-promised')) + .use(require('chai-bignumber')(BigNumber)) + .should(); + +const EVMThrow = require('../helpers/EVMThrow.js'); +const SplitPayment = artifacts.require('SplitPayment'); + +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 SplitPayment.new(); + await this.contract.initialize(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 store shares if address is payee', async function () { + const shares = await this.contract.shares.call(payee1); + shares.should.be.bignumber.not.equal(0); + }); + + it('should not store shares if address is not payee', async function () { + const shares = await this.contract.shares.call(nonpayee1); + shares.should.be.bignumber.equal(0); + }); + + it('should throw if no funds to claim', async function () { + await this.contract.claim({ from: payee1 }).should.be.rejectedWith(EVMThrow); + }); + + it('should throw if non-payee want to claim', async function () { + await web3.eth.sendTransaction({ from: payer1, to: this.contract.address, value: amount }); + await this.contract.claim({ from: nonpayee1 }).should.be.rejectedWith(EVMThrow); + }); + + it('should distribute funds to payees', async function () { + await web3.eth.sendTransaction({ from: payer1, to: this.contract.address, value: amount }); + + // receive funds + const initBalance = web3.eth.getBalance(this.contract.address); + initBalance.should.be.bignumber.equal(amount); + + // distribute to payees + const initAmount1 = web3.eth.getBalance(payee1); + await this.contract.claim({ from: payee1 }); + const profit1 = web3.eth.getBalance(payee1) - initAmount1; + assert(Math.abs(profit1 - web3.toWei(0.20, 'ether')) < 1e16); + + const initAmount2 = web3.eth.getBalance(payee2); + await this.contract.claim({ from: payee2 }); + const profit2 = web3.eth.getBalance(payee2) - initAmount2; + assert(Math.abs(profit2 - web3.toWei(0.10, 'ether')) < 1e16); + + const initAmount3 = web3.eth.getBalance(payee3); + await this.contract.claim({ from: payee3 }); + const profit3 = web3.eth.getBalance(payee3) - initAmount3; + assert(Math.abs(profit3 - web3.toWei(0.70, 'ether')) < 1e16); + + // end balance should be zero + const endBalance = web3.eth.getBalance(this.contract.address); + endBalance.should.be.bignumber.equal(0); + + // check correct funds released accounting + const totalReleased = await this.contract.totalReleased.call(); + totalReleased.should.be.bignumber.equal(initBalance); + }); +}); diff --git a/zos.json b/zos.json index c89e9ecd8..389b8b6ff 100644 --- a/zos.json +++ b/zos.json @@ -6,7 +6,8 @@ "DetailedPremintedToken": "DetailedPremintedToken", "MintableERC721Token": "MintableERC721Token", "TokenTimelock": "TokenTimelock", - "TokenVesting": "TokenVesting" + "TokenVesting": "TokenVesting", + "SplitPayment": "SplitPayment" }, "lib": true -} +} \ No newline at end of file