diff --git a/contracts/access/roles/WhitelistedRole.sol b/contracts/access/roles/WhitelistedRole.sol new file mode 100644 index 00000000000..68a826197d6 --- /dev/null +++ b/contracts/access/roles/WhitelistedRole.sol @@ -0,0 +1,50 @@ +pragma solidity ^0.4.24; + +import "../Roles.sol"; +import "./WhitelisterRole.sol"; + +/** + * @title WhitelistedRole + * @dev Whitelisted accounts have been approved by a Whitelister to perform certain actions (e.g. participate in a + * crowdsale). This role is special in that the only accounts that can add it are Whitelisters (who can also remove it), + * and not Whitelisteds themselves. + */ +contract WhitelistedRole is WhitelisterRole { + using Roles for Roles.Role; + + event WhitelistedAdded(address indexed account); + event WhitelistedRemoved(address indexed account); + + Roles.Role private _whitelisteds; + + modifier onlyWhitelisted() { + require(isWhitelisted(msg.sender)); + _; + } + + function isWhitelisted(address account) public view returns (bool) { + return _whitelisteds.has(account); + } + + function addWhitelisted(address account) public onlyWhitelister { + _addWhitelisted(account); + } + + function removeWhitelisted(address account) public onlyWhitelister { + _removeWhitelisted(account); + } + + function renounceWhitelisted() public { + _removeWhitelisted(msg.sender); + } + + function _addWhitelisted(address account) internal { + _whitelisteds.add(account); + emit WhitelistedAdded(account); + } + + function _removeWhitelisted(address account) internal { + _whitelisteds.remove(account); + emit WhitelistedRemoved(account); + } +} diff --git a/contracts/access/roles/WhitelisterRole.sol b/contracts/access/roles/WhitelisterRole.sol new file mode 100644 index 00000000000..0abdeaaf7e1 --- /dev/null +++ b/contracts/access/roles/WhitelisterRole.sol @@ -0,0 +1,47 @@ +pragma solidity ^0.4.24; + +import "../Roles.sol"; + +/** + * @title WhitelisterRole + * @dev Whitelisters are responsible for assigning and removing Whitelisted accounts. + */ +contract WhitelisterRole { + using Roles for Roles.Role; + + event WhitelisterAdded(address indexed account); + event WhitelisterRemoved(address indexed account); + + Roles.Role private _whitelisters; + + constructor () internal { + _addWhitelister(msg.sender); + } + + modifier onlyWhitelister() { + require(isWhitelister(msg.sender)); + _; + } + + function isWhitelister(address account) public view returns (bool) { + return _whitelisters.has(account); + } + + function addWhitelister(address account) public onlyWhitelister { + _addWhitelister(account); + } + + function renounceWhitelister() public { + _removeWhitelister(msg.sender); + } + + function _addWhitelister(address account) internal { + _whitelisters.add(account); + emit WhitelisterAdded(account); + } + + function _removeWhitelister(address account) internal { + _whitelisters.remove(account); + emit WhitelisterRemoved(account); + } +} diff --git a/contracts/crowdsale/validation/WhitelistCrowdsale.sol b/contracts/crowdsale/validation/WhitelistCrowdsale.sol new file mode 100644 index 00000000000..d93028d5913 --- /dev/null +++ b/contracts/crowdsale/validation/WhitelistCrowdsale.sol @@ -0,0 +1,21 @@ +pragma solidity ^0.4.24; +import "../Crowdsale.sol"; +import "../../access/roles/WhitelistedRole.sol"; + + +/** + * @title WhitelistCrowdsale + * @dev Crowdsale in which only whitelisted users can contribute. + */ +contract WhitelistCrowdsale is WhitelistedRole, Crowdsale { + /** + * @dev Extend parent behavior requiring beneficiary to be whitelisted. Note that no + * restriction is imposed on the account sending the transaction. + * @param _beneficiary Token beneficiary + * @param _weiAmount Amount of wei contributed + */ + function _preValidatePurchase(address _beneficiary, uint256 _weiAmount) internal view { + require(isWhitelisted(_beneficiary)); + super._preValidatePurchase(_beneficiary, _weiAmount); + } +} diff --git a/contracts/mocks/WhitelistCrowdsaleImpl.sol b/contracts/mocks/WhitelistCrowdsaleImpl.sol new file mode 100644 index 00000000000..ab8bd73fa50 --- /dev/null +++ b/contracts/mocks/WhitelistCrowdsaleImpl.sol @@ -0,0 +1,10 @@ +pragma solidity ^0.4.24; + +import "../token/ERC20/IERC20.sol"; +import "../crowdsale/validation/WhitelistCrowdsale.sol"; +import "../crowdsale/Crowdsale.sol"; + + +contract WhitelistCrowdsaleImpl is Crowdsale, WhitelistCrowdsale { + constructor (uint256 _rate, address _wallet, IERC20 _token) Crowdsale(_rate, _wallet, _token) public {} +} diff --git a/contracts/mocks/WhitelistedRoleMock.sol b/contracts/mocks/WhitelistedRoleMock.sol new file mode 100644 index 00000000000..b710c38d403 --- /dev/null +++ b/contracts/mocks/WhitelistedRoleMock.sol @@ -0,0 +1,8 @@ +pragma solidity ^0.4.24; + +import "../access/roles/WhitelistedRole.sol"; + +contract WhitelistedRoleMock is WhitelistedRole { + function onlyWhitelistedMock() public view onlyWhitelisted { + } +} diff --git a/contracts/mocks/WhitelisterRoleMock.sol b/contracts/mocks/WhitelisterRoleMock.sol new file mode 100644 index 00000000000..06ea7dd63bf --- /dev/null +++ b/contracts/mocks/WhitelisterRoleMock.sol @@ -0,0 +1,17 @@ +pragma solidity ^0.4.24; + +import "../access/roles/WhitelisterRole.sol"; + +contract WhitelisterRoleMock is WhitelisterRole { + function removeWhitelister(address account) public { + _removeWhitelister(account); + } + + function onlyWhitelisterMock() public view onlyWhitelister { + } + + // Causes a compilation error if super._removeWhitelister is not internal + function _removeWhitelister(address account) internal { + super._removeWhitelister(account); + } +} diff --git a/test/access/roles/PublicRole.behavior.js b/test/access/roles/PublicRole.behavior.js index c18b6097410..d558c1300d8 100644 --- a/test/access/roles/PublicRole.behavior.js +++ b/test/access/roles/PublicRole.behavior.js @@ -8,7 +8,7 @@ function capitalize (str) { return str.replace(/\b\w/g, l => l.toUpperCase()); } -function shouldBehaveLikePublicRole (authorized, otherAuthorized, [anyone], rolename) { +function shouldBehaveLikePublicRole (authorized, otherAuthorized, [anyone], rolename, manager) { rolename = capitalize(rolename); describe('should behave like public role', function () { @@ -18,11 +18,13 @@ function shouldBehaveLikePublicRole (authorized, otherAuthorized, [anyone], role (await this.contract[`is${rolename}`](anyone)).should.equal(false); }); - it('emits events during construction', async function () { - await expectEvent.inConstruction(this.contract, `${rolename}Added`, { - account: authorized, + if (manager === undefined) { // Managed roles are only assigned by the manager, and none are set at construction + it('emits events during construction', async function () { + await expectEvent.inConstruction(this.contract, `${rolename}Added`, { + account: authorized, + }); }); - }); + } it('reverts when querying roles for the null account', async function () { await shouldFail.reverting(this.contract[`is${rolename}`](ZERO_ADDRESS)); @@ -47,43 +49,52 @@ function shouldBehaveLikePublicRole (authorized, otherAuthorized, [anyone], role }); describe('add', function () { - it('adds role to a new account', async function () { - await this.contract[`add${rolename}`](anyone, { from: authorized }); - (await this.contract[`is${rolename}`](anyone)).should.equal(true); - }); + const from = manager === undefined ? authorized : manager; - it(`emits a ${rolename}Added event`, async function () { - const { logs } = await this.contract[`add${rolename}`](anyone, { from: authorized }); - expectEvent.inLogs(logs, `${rolename}Added`, { account: anyone }); - }); + context(`from ${manager ? 'the manager' : 'a role-haver'} account`, function () { + it('adds role to a new account', async function () { + await this.contract[`add${rolename}`](anyone, { from }); + (await this.contract[`is${rolename}`](anyone)).should.equal(true); + }); - it('reverts when adding role to an already assigned account', async function () { - await shouldFail.reverting(this.contract[`add${rolename}`](authorized, { from: authorized })); - }); + it(`emits a ${rolename}Added event`, async function () { + const { logs } = await this.contract[`add${rolename}`](anyone, { from }); + expectEvent.inLogs(logs, `${rolename}Added`, { account: anyone }); + }); - it('reverts when adding role to the null account', async function () { - await shouldFail.reverting(this.contract[`add${rolename}`](ZERO_ADDRESS, { from: authorized })); + it('reverts when adding role to an already assigned account', async function () { + await shouldFail.reverting(this.contract[`add${rolename}`](authorized, { from })); + }); + + it('reverts when adding role to the null account', async function () { + await shouldFail.reverting(this.contract[`add${rolename}`](ZERO_ADDRESS, { from })); + }); }); }); describe('remove', function () { - it('removes role from an already assigned account', async function () { - await this.contract[`remove${rolename}`](authorized); - (await this.contract[`is${rolename}`](authorized)).should.equal(false); - (await this.contract[`is${rolename}`](otherAuthorized)).should.equal(true); - }); + // Non-managed roles have no restrictions on the mocked '_remove' function (exposed via 'remove'). + const from = manager || anyone; + + context(`from ${manager ? 'the manager' : 'any'} account`, function () { + it('removes role from an already assigned account', async function () { + await this.contract[`remove${rolename}`](authorized, { from }); + (await this.contract[`is${rolename}`](authorized)).should.equal(false); + (await this.contract[`is${rolename}`](otherAuthorized)).should.equal(true); + }); - it(`emits a ${rolename}Removed event`, async function () { - const { logs } = await this.contract[`remove${rolename}`](authorized); - expectEvent.inLogs(logs, `${rolename}Removed`, { account: authorized }); - }); + it(`emits a ${rolename}Removed event`, async function () { + const { logs } = await this.contract[`remove${rolename}`](authorized, { from }); + expectEvent.inLogs(logs, `${rolename}Removed`, { account: authorized }); + }); - it('reverts when removing from an unassigned account', async function () { - await shouldFail.reverting(this.contract[`remove${rolename}`](anyone)); - }); + it('reverts when removing from an unassigned account', async function () { + await shouldFail.reverting(this.contract[`remove${rolename}`](anyone), { from }); + }); - it('reverts when removing role from the null account', async function () { - await shouldFail.reverting(this.contract[`remove${rolename}`](ZERO_ADDRESS)); + it('reverts when removing role from the null account', async function () { + await shouldFail.reverting(this.contract[`remove${rolename}`](ZERO_ADDRESS), { from }); + }); }); }); diff --git a/test/access/roles/WhitelistedRole.test.js b/test/access/roles/WhitelistedRole.test.js new file mode 100644 index 00000000000..2290424cd5f --- /dev/null +++ b/test/access/roles/WhitelistedRole.test.js @@ -0,0 +1,12 @@ +const { shouldBehaveLikePublicRole } = require('../../access/roles/PublicRole.behavior'); +const WhitelistedRoleMock = artifacts.require('WhitelistedRoleMock'); + +contract('WhitelistedRole', function ([_, whitelisted, otherWhitelisted, whitelister, ...otherAccounts]) { + beforeEach(async function () { + this.contract = await WhitelistedRoleMock.new({ from: whitelister }); + await this.contract.addWhitelisted(whitelisted, { from: whitelister }); + await this.contract.addWhitelisted(otherWhitelisted, { from: whitelister }); + }); + + shouldBehaveLikePublicRole(whitelisted, otherWhitelisted, otherAccounts, 'whitelisted', whitelister); +}); diff --git a/test/access/roles/WhitelisterRole.test.js b/test/access/roles/WhitelisterRole.test.js new file mode 100644 index 00000000000..39e6644074d --- /dev/null +++ b/test/access/roles/WhitelisterRole.test.js @@ -0,0 +1,11 @@ +const { shouldBehaveLikePublicRole } = require('../../access/roles/PublicRole.behavior'); +const WhitelisterRoleMock = artifacts.require('WhitelisterRoleMock'); + +contract('WhitelisterRole', function ([_, whitelister, otherWhitelister, ...otherAccounts]) { + beforeEach(async function () { + this.contract = await WhitelisterRoleMock.new({ from: whitelister }); + await this.contract.addWhitelister(otherWhitelister, { from: whitelister }); + }); + + shouldBehaveLikePublicRole(whitelister, otherWhitelister, otherAccounts, 'whitelister'); +}); diff --git a/test/crowdsale/WhitelistCrowdsale.test.js b/test/crowdsale/WhitelistCrowdsale.test.js new file mode 100644 index 00000000000..713de675ee4 --- /dev/null +++ b/test/crowdsale/WhitelistCrowdsale.test.js @@ -0,0 +1,57 @@ +require('../helpers/setup'); +const { ether } = require('../helpers/ether'); +const shouldFail = require('../helpers/shouldFail'); + +const BigNumber = web3.BigNumber; + +const WhitelistCrowdsale = artifacts.require('WhitelistCrowdsaleImpl'); +const SimpleToken = artifacts.require('SimpleToken'); + +contract('WhitelistCrowdsale', function ([_, wallet, whitelister, whitelisted, otherWhitelisted, anyone]) { + const rate = 1; + const value = ether(42); + const tokenSupply = new BigNumber('1e22'); + + beforeEach(async function () { + this.token = await SimpleToken.new({ from: whitelister }); + this.crowdsale = await WhitelistCrowdsale.new(rate, wallet, this.token.address, { from: whitelister }); + await this.token.transfer(this.crowdsale.address, tokenSupply, { from: whitelister }); + }); + + async function purchaseShouldSucceed (crowdsale, beneficiary, value) { + await crowdsale.buyTokens(beneficiary, { from: beneficiary, value }); + await crowdsale.sendTransaction({ from: beneficiary, value }); + } + + async function purchaseShouldFail (crowdsale, beneficiary, value) { + await shouldFail.reverting(crowdsale.buyTokens(beneficiary, { from: beneficiary, value })); + await shouldFail.reverting(crowdsale.sendTransaction({ from: beneficiary, value })); + } + + context('with no whitelisted addresses', function () { + it('rejects all purchases', async function () { + await purchaseShouldFail(this.crowdsale, anyone, value); + await purchaseShouldFail(this.crowdsale, whitelisted, value); + }); + }); + + context('with whitelisted addresses', function () { + beforeEach(async function () { + await this.crowdsale.addWhitelisted(whitelisted, { from: whitelister }); + await this.crowdsale.addWhitelisted(otherWhitelisted, { from: whitelister }); + }); + + it('accepts purchases with whitelisted beneficiaries', async function () { + await purchaseShouldSucceed(this.crowdsale, whitelisted, value); + await purchaseShouldSucceed(this.crowdsale, otherWhitelisted, value); + }); + + it('rejects purchases from whitelisted addresses with non-whitelisted beneficiaries', async function () { + await shouldFail(this.crowdsale.buyTokens(anyone, { from: whitelisted, value })); + }); + + it('rejects purchases with non-whitelisted beneficiaries', async function () { + await purchaseShouldFail(this.crowdsale, anyone, value); + }); + }); +});