Building Ethereum DApps: Whitelisting & Testing a Story DAO

In part 3 of this tutorial series on building DApps with Ethereum, we built and deployed our token to the Ethereum testnet Rinkeby. In this part, we’ll start writing the Story DAO code.

We’ll use the conditions laid out in the intro post to guide us.

Contract Outline

Let’s create a new contract, StoryDao.sol, with this skeleton:

pragma solidity ^0.4.24;

import "../node_modules/openzeppelin-solidity/contracts/math/SafeMath.sol";
import "../node_modules/openzeppelin-solidity/contracts/ownership/Ownable.sol";

contract StoryDao is Ownable {
    using SafeMath for uint256;

    mapping(address => bool) whitelist;
    uint256 public whitelistedNumber = 0;
    mapping(address => bool) blacklist;
    event Whitelisted(address addr, bool status);
    event Blacklisted(address addr, bool status);

    uint256 public daofee = 100; // hundredths of a percent, i.e. 100 is 1%
    uint256 public whitelistfee = 10000000000000000; // in Wei, this is 0.01 ether

    event SubmissionCommissionChanged(uint256 newFee);
    event WhitelistFeeChanged(uint256 newFee);

    uint256 public durationDays = 21; // duration of story's chapter in days
    uint256 public durationSubmissions = 1000; // duration of story's chapter in entries

    function changedaofee(uint256 _fee) onlyOwner external {
        require(_fee < daofee, "New fee must be lower than old fee.");
        daofee = _fee;
        emit SubmissionCommissionChanged(_fee);
    }

    function changewhitelistfee(uint256 _fee) onlyOwner external {
        require(_fee < whitelistfee, "New fee must be lower than old fee.");
        whitelistfee = _fee;
        emit WhitelistFeeChanged(_fee);
    }

    function lowerSubmissionFee(uint256 _fee) onlyOwner external {
        require(_fee < submissionZeroFee, "New fee must be lower than old fee.");
        submissionZeroFee = _fee;
        emit SubmissionFeeChanged(_fee);
    }

    function changeDurationDays(uint256 _days) onlyOwner external {
        require(_days >= 1);
        durationDays = _days;
    }

    function changeDurationSubmissions(uint256 _subs) onlyOwner external {
        require(_subs > 99);
        durationSubmissions = _subs;
    }
}

We’re importing SafeMath to have safe calculations again, but this time we’re also using Zeppelin’s Ownable contract, which lets someone “own” the story and execute certain admin-only functions. Simply saying that our StoryDao is Ownable is enough; feel free to inspect the contract to see how it works.

We also use the onlyOwner modifier from this contract. Function modifiers are basically extensions, plugins for functions. The onlyOwner modifier looks like this:

modifier onlyOwner() {
  require(msg.sender == owner);
  _;
}

When onlyOwner is added to a function, then that function’s body is pasted into the part where the _; part is, and everything before it executes first. So by using this modifier, the function automatically checks if the message sender is also the owner of the contract and then continues as usual if so. If not, it crashes.

By using the onlyOwner modifier on the functions that change the fees and other parameters of our story DAO, we make sure that only the admin can do these changes.

Testing

Let’s test the initial functions.

Create the folder test if it doesn’t exist. Then inside it, create the files TestStoryDao.sol and TestStoryDao.js. Because there’s no native way to test for exceptions in Truffle, also create helpers/expectThrow.js with the content:

export default async promise => {
    try {
      await promise;
    } catch (error) {
      const invalidOpcode = error.message.search('invalid opcode') >= 0;
      const outOfGas = error.message.search('out of gas') >= 0;
      const revert = error.message.search('revert') >= 0;
      assert(
        invalidOpcode || outOfGas || revert,
        'Expected throw, got \'' + error + '\' instead',
      );
      return;
    }
    assert.fail('Expected throw not received');
  };

Note: Solidity tests are generally used to test low-level, contract-based functions, the internals of a smart contract. JS tests are generally used to test if the contract can be properly interacted with from the outside, which is something our end users will be doing.

In TestStoryDao.sol, put the following content:

pragma solidity ^0.4.24;

import "truffle/Assert.sol";
import "truffle/DeployedAddresses.sol";
import "../contracts/StoryDao.sol";

contract TestStoryDao {

    function testDeploymentIsFine() public {
        StoryDao sd = StoryDao(DeployedAddresses.StoryDao());

        uint256 daofee = 100; // hundredths of a percent, i.e. 100 is 1%
        uint256 whitelistfee = 10000000000000000; // in Wei, this is 0.01 ether

        uint256 durationDays = 21; // duration of story's chapter in days
        uint256 durationSubmissions = 1000; // duration of story's chapter in entries

        Assert.equal(sd.daofee(), daofee, "Initial DAO fee should be 100");
        Assert.equal(sd.whitelistfee(), whitelistfee, "Initial whitelisting fee should be 0.01 ether");
        Assert.equal(sd.durationDays(), durationDays, "Initial day duration should be set to 3 weeks");
        Assert.equal(sd.durationSubmissions(), durationSubmissions, "Initial submission duration should be set to 1000 entries");
    }
}

This checks that the StoryDao contract gets deployed properly with the right numbers for fees and duration. The first line makes sure it’s deployed by reading it from the list of deployed addresses, and last section does some assertions — checking that a claim is true or false. In our case, we’re comparing numbers to initial values of the deployed contract. Whenever it’s “true”, the Assert.equals part will emit an event that says “True”, which is what Truffle is listening for when testing.

In TestStoryDao.js, put the following content:

import expectThrow from './helpers/expectThrow';

const StoryDao = artifacts.require("StoryDao");

contract('StoryDao Test', async (accounts) => {

    it("should make sure environment is OK by checking that the first 3 accounts have over 20 eth", async () =>{
        assert.equal(web3.eth.getBalance(accounts[0]).toNumber() > 2e+19, true, "Account 0 has more than 20 eth");
        assert.equal(web3.eth.getBalance(accounts[1]).toNumber() > 2e+19, true, "Account 1 has more than 20 eth");
        assert.equal(web3.eth.getBalance(accounts[2]).toNumber() > 2e+19, true, "Account 2 has more than 20 eth");
    });

    it("should make the deployer the owner", async () => {
        let instance = await StoryDao.deployed();
        assert.equal(await instance.owner(), accounts[0]);
    });

    it("should let owner change fee and duration", async () => {
        let instance = await StoryDao.deployed();

        let newDaoFee = 50;
        let newWhitelistFee = 1e+10; // 1 ether
        let newDayDuration = 42;
        let newSubsDuration = 1500;

        instance.changedaofee(newDaoFee, {from: accounts[0]});
        instance.changewhitelistfee(newWhitelistFee, {from: accounts[0]});
        instance.changedurationdays(newDayDuration, {from: accounts[0]});
        instance.changedurationsubmissions(newSubsDuration, {from: accounts[0]});

        assert.equal(await instance.daofee(), newDaoFee);
        assert.equal(await instance.whitelistfee(), newWhitelistFee);
        assert.equal(await instance.durationDays(), newDayDuration);
        assert.equal(await instance.durationSubmissions(), newSubsDuration);
    });

    it("should forbid non-owners from changing fee and duration", async () => {
        let instance = await StoryDao.deployed();

        let newDaoFee = 50;
        let newWhitelistFee = 1e+10; // 1 ether
        let newDayDuration = 42;
        let newSubsDuration = 1500;

        await expectThrow(instance.changedaofee(newDaoFee, {from: accounts[1]}));
        await expectThrow(instance.changewhitelistfee(newWhitelistFee, {from: accounts[1]}));
        await expectThrow(instance.changedurationdays(newDayDuration, {from: accounts[1]}));
        await expectThrow(instance.changedurationsubmissions(newSubsDuration, {from: accounts[1]}));
    });

    it("should make sure the owner can only change fees and duration to valid values", async () =>{
        let instance = await StoryDao.deployed();

        let invalidDaoFee = 20000;
        let invalidDayDuration = 0;
        let invalidSubsDuration = 98;

        await expectThrow(instance.changedaofee(invalidDaoFee, {from: accounts[0]}));
        await expectThrow(instance.changedurationdays(invalidDayDuration, {from: accounts[0]}));
        await expectThrow(instance.changedurationsubmissions(invalidSubsDuration, {from: accounts[0]}));
    })
});

In order for our tests to successfully run, we also need to tell Truffle that we want the StoryDao deployed — because it’s not going to do it for us. So let’s create 3_deploy_storydao.js in migrations with content almost identical to the previous migration we wrote:

var Migrations = artifacts.require("./Migrations.sol");
var StoryDao = artifacts.require("./StoryDao.sol");

module.exports = function(deployer, network, accounts) {
  if (network == "development") {
    deployer.deploy(StoryDao, {from: accounts[0]});
  } else {
    deployer.deploy(StoryDao);
  }
};

At this point, we should also update (or create, if it’s not present) a package.json file in the root of our project folder with the dependencies we needed so far and may need in the near future:

{
  "name": "storydao",
  "devDependencies": {
    "babel-preset-es2015": "^6.18.0",
    "babel-preset-stage-2": "^6.24.1",
    "babel-preset-stage-3": "^6.17.0",
    "babel-polyfill": "^6.26.0",
    "babel-register": "^6.23.0",
    "dotenv": "^6.0.0",
    "truffle": "^4.1.12",
    "openzeppelin-solidity": "^1.10.0",
    "openzeppelin-solidity-metadata": "^1.2.0",
    "openzeppelin-zos": "",
    "truffle-wallet-provider": "^0.0.5",
    "ethereumjs-wallet": "^0.6.0",
    "web3": "^1.0.0-beta.34",
    "truffle-assertions": "^0.3.1"
  }
}

And a .babelrc file with the content:

{
  "presets": ["es2015", "stage-2", "stage-3"]
}

And we also need to require Babel in our Truffle configuration so it knows it should use it when compiling tests.

Note: Babel is an add-on for NodeJS which lets us use next-generation JavaScript in current-generation NodeJS, so we can write things like import etc. If this is beyond your understanding, simply ignore it and just paste this verbatim. You’ll probably never have to deal with this again after installing it this way.

require('dotenv').config();

================== ADD THESE TWO LINES ================
require('babel-register');
require('babel-polyfill');
=======================================================

const WalletProvider = require("truffle-wallet-provider");
const Wallet = require('ethereumjs-wallet');

// ...

Now, finally run truffle test. The output should be similar to this one:

A successful test

For more information about testing, see this tutorial, which we prepared specifically to cover testing of smart contracts.

In subsequent parts of this course, we’ll be skipping the tests, as typing them out would make the tutorials too long, but please refer to the final source code of the project to inspect them all. The process we just went through has set up the environment for testing, so you can just write the tests with zero further setup.

Whitelist

Let’s build the whitelisting mechanism now, which lets users participate in building the story. Add the following function skeletons to StoryDao.sol:

function whitelistAddress(address _add) public payable {
    // whitelist sender if enough money was sent
}

function() external payable {
    // if not whitelisted, whitelist if paid enough
    // if whitelisted, but X tokens at X price for amount
}

The unnamed function function() is called a fallback function, and that’s the function that gets called when money is sent to this contract without a specific instruction (i.e. without calling another function specifically). This lets people join the StoryDao by just sending Ether to the DAO and either getting whitelisted instantly, or buying tokens, depending on whether or not they are already whitelisted.

The whitelistSender function is there for whitelisting and can be called directly, but we’ll make sure the fallback function automatically calls it when it receives some ether if the sender has not yet been whitelisted. The whitelistAddress function is declared public because it should be callable from other contracts as well, and the fallback function is external because money will be going to this address only from external addresses. Contracts calling this contract can easily call required functions directly.

Let’s deal with the fallback function first.

function() external payable {

    if (!whitelist[msg.sender]) {
        whitelistAddress(msg.sender);
    } else {
        // buyTokens(msg.sender, msg.value);
    }
}

We check if the sender isn’t already on the whitelist, and delegate the call to the whitelistAddress function. Notice that we commented out our buyTokens function because we don’t yet have it.

Next, let’s handle the whitelisting.

function whitelistAddress(address _add) public payable {
    require(!whitelist[_add], "Candidate must not be whitelisted.");
    require(!blacklist[_add], "Candidate must not be blacklisted.");
    require(msg.value >= whitelistfee, "Sender must send enough ether to cover the whitelisting fee.");

    whitelist[_add] = true;
    whitelistedNumber++;
    emit Whitelisted(_add, true);

    if (msg.value > whitelistfee) {
        // buyTokens(_add, msg.value.sub(whitelistfee));
    }
}

Notice that this function accepts the address as a parameter and doesn’t extract it from the message (from the transaction). This has the added advantage of people being able to whitelist other people, if someone can’t afford to join the DAO for example.

We start the function with some sanity checks: the sender must not be whitelisted already or blacklisted (banned) and must have sent in enough to cover the fee. If these conditions are satisfactory, the address is added to the whitelist, the Whitelisted event is emitted, and, finally, if the amount of ether sent in is greater than the amount of ether needed to cover the whitelisting fee, the remainder is used to buy the tokens.

Note: we’re using sub instead of - to subtract, because that’s a SafeMath function for safe calculations.

Users can now get themselves or others whitelisted as long as they send 0.01 ether or more to the StoryDao contract.

Conclusion

We built the initial part of our DAO in this tutorial, but a lot more work remains. Stay tuned: in the next part we’ll deal with adding content to the story!

Sponsors