Discover Ethereum & Solidity

Back to All Courses

Lesson 9

The Contract Manager

The deployment orchestra

To make our storage and controller contracts work together, we now need to make sure that we deploy them in a very specific order.

The reason for this is that some functions in our storage contracts use the onlyController modifier. Therefore, they obviously need to be aware of what address their respective controllers have been uploaded to.

Based on the code, here's what we should do:

  1. Deploy the UserStorage and TweetStorage contracts (as we do now)

  2. Deploy the UserController and TweetController contracts

  3. Get the deployed instances of UserStorage and TweetStorage (so that we can interact with them in our migration files)

  4. Set the controllerAddr in the deployed UserStorage and TweetStorage contracts

Let's do this step by step. The first one is already done, so we'll start by creating a new migration file where we deploy our UserController and TweetController contracts. We can call it 3_deploy_controllers.js.

By using an array inside the deployer.deploy function, we get a resolved promise once they're both done:

// migrations/3_deploy_controllers.js

const UserController = artifacts.require('UserController')
const TweetController = artifacts.require('TweetController')

module.exports = (deployer) => {

  // Deploy controllers contracts:
  deployer.then(async () => {
    await deployer.deploy(UserController);
    await deployer.deploy(TweetController);
  })

}

After that, we want to get the deployed versions of our storage contracts. We can do this by calling the .deployed() method on the contracts:

// migrations/3_deploy_controllers.js

// ...

// Since we want to get the storage contract instances,
// we need to import them!
const UserStorage = artifacts.require('UserStorage');
const TweetStorage = artifacts.require('TweetStorage');

module.exports = (deployer) => {

  deployer.then(async () => {
    await deployer.deploy(UserController);
    await deployer.deploy(TweetController);
  })
  // Get the deployed storage contract instances:
  .then(() => {
    return Promise.all([
      UserStorage.deployed(),
      TweetStorage.deployed(),
    ]);
  })

}

Finally, we call the setControllerAddr function on the two storage contracts instances. In order to get the deployed addresses for UserStorage and TweetStorage, we simply type UserStorage.address and TweetStorage.address. The address property becomes available on the contract object after it's been deployed.

This is what the final code should look like:

// migrations/3_deploy_controllers.js

const UserController = artifacts.require('UserController')
const TweetController = artifacts.require('TweetController')

const UserStorage = artifacts.require('UserStorage')
const TweetStorage = artifacts.require('TweetStorage')

module.exports = (deployer) => {

  // Deploy controller contracts:
  deployer.then(async () => {
    await deployer.deploy(UserController);
    await deployer.deploy(TweetController);
  })
  // Get the deployed storage contract instances:
  .then(() => {
    return Promise.all([
      UserStorage.deployed(),
      TweetStorage.deployed(),
    ]);
  })
  // Set the controller address on both storage contracts:
  .then(storageContracts => {
    const [userStorage, tweetStorage] = storageContracts;

    return Promise.all([
      userStorage.setControllerAddr(UserController.address),
      tweetStorage.setControllerAddr(TweetController.address),
    ]);
  })

}

To see if the deployment works, you can try running truffle test again:

This time, the deployment worked, but all the tests are failing.

Handling errors in tests

Believe it or not, not all of our failing tests are actually bad. If a user tries to call createUser or createTweet by calling the storage contract directly for example, we should expect an error, since they're not going through the controller like we want them to.

To test this behaviour, let's create a new test at the very top of test/integration/users.js called "can't create user without controller". For now, we'll just log the returned error message to get an idea of how we can identify it:

// test/integration/users.js

// ...

contract('users', () => {

  it("can't create user without controller", async () => {
    const storage = await UserStorage.deployed()

    try {
      const username = web3.utils.fromAscii("tristan")
      await storage.createUser(username)
      assert.fail()
    } catch (err) {
      console.log(err);
    }
  })

  // ...

})

Run truffle test and look for the newly added test.

Aha, so it seems like we get an error object containing the string "VM Exception". Not exactly the easiest thing to test, but we can work with it. Let's look for that in our test by creating a function called assertVMException:

// test/integration/users.js

// ...

// Add this function
const assertVMException = error => {
  const hasException = error.toString().search("VM Exception");
  assert(hasException, "Should expect a VM Exception error");
}

contract('users', () => {

  it("can't create user without controller", async () => {
    const storage = await UserStorage.deployed()

    try {
      const username = web3.utils.fromAscii("tristan")
      await storage.createUser(username)
      assert.fail()
    } catch (err) {
      assertVMException(err); // <-- Call it here
    }
  })

  // ...

})

Note that if storage.createUser(username) doesn't throw an error, we intentionally make the test fail with assert.fail().

Now we have one test assertion passing at least!

Great! Next up, we want to do the same thing in test/integration/tweets.js.

Before adding the "can't create tweet without controller" test, we'll first remove the before()-part of the test for now. We will re-add the same functionality later by calling the controller instead.

// test/integration/tweets.js

const TweetStorage = artifacts.require('TweetStorage')

contract('tweets', () => {

  /*
  THIS PART CAN NOW BE REMOVED:
  before(async () => {
    const tweetStorage = await TweetStorage.deployed()
    await tweetStorage.createTweet(1, "Hello world!")
  })
  */

  // Add this test:
  it("can't create tweet without controller", async () => {
    const storage = await TweetStorage.deployed()

    try {
      const tx = await storage.createTweet(1, "tristan")
      assert.fail();
    } catch (err) {
      assertVMException(err);
    }
  })

  // ...

})

Since we want to use an assertVMException function here too, let's extract it into its own file -- that way, we can easily import it into any test in the future. We'll call the file utils.js and place it right in the test folder:

// test/utils.js

exports.assertVMException = error => {
  const hasException = error.toString().search("VM Exception");
  assert(hasException, "Should expect a VM Exception error");
}

Then we just import the function into users.js and tweets.js:

// test/integration/users.js

// ...

// Use these 2 lines instead of the function we created in this file earlier:
const utils = require('../utils')
const { assertVMException } = utils

contract('users', () => {
  // ...
})
// test/integration/tweets.js

// ...

// Add these 2 lines:
const utils = require('../utils')
const { assertVMException } = utils

contract('tweets', () => {
  // ...
})

Run the test again, and you'll see that we now have 2 tests passing!

Adding a contract manager

Since our controllers are supposed to call functions in our storage contracts, they obviously need to know what address these storage contracts are deployed to.

To make this work, we'll build a ContractManager that keeps track of all contracts' addresses.

The functions needed in the contract manager are the following:

  1. Add new key-value records, with a string (ex: "UserStorage") pointing to an address (ex: "0xde0b295669a9fd93d5")

  2. Get an address based on the string key.

  3. Delete the address of a string key.

All these functions should obviously only be available to the owner of the contract, so we'll first of all make our contract inherit from Owned:

// contracts/ContractManager.sol

pragma solidity ^0.5.10;

import './helpers/Owned.sol';

contract ContractManager is Owned {

}

Then we simply add three functions (setAddressgetAddress, and deleteAddress), which all use the onlyOwner modifier.

// contracts/ContractManager.sol

pragma solidity ^0.5.10;

import './helpers/Owned.sol';

contract ContractManager is Owned {
  mapping (string => address) addresses;

  function setAddress(string memory _name, address _address) public {
    addresses[_name] = _address;
  }

  function getAddress(string memory _name) public view returns (address) {
    return addresses[_name];
  }

  function deleteAddress(string memory _name) public {
    addresses[_name] = address(0);
  }

}

Rethinking our deployment strategy

As we've said earlier, immutability is an important aspect of the Ethereum ecosystem, so it's essential to plan ahead when writing Solidity contracts. This goes for migration files too.

In Truffle, we ideally want to **separate **our migration files so that we can run just one of them in isolation if some aspect of the code changes.

Some migration files, such as 2_deploy_storage.js will never be run more than once (since that would reset its stored data). When it comes to our controllers however, the best case scenario would be if when we update, say, our UserController, we could run just a single migration file which takes care of replacing our contract, set the new controller address in the storage and updating the contract manager -- all in one sweep.

Knowing this, you might see why having a migration file called 3_deploy_controllers.js isn't really as modular as we want it to be. Let's reorganise our migration files!

You can safely delete the 3_deploy_controllers.js file so that we can instead create one called 3_deploy_manager.js. Similarly to 2_deploy_storage.js, this one should only have to be deployed once.

// migrations/3_deploy_manager.js

const ContractManager = artifacts.require('ContractManager')
const UserStorage = artifacts.require('UserStorage');
const TweetStorage = artifacts.require('TweetStorage');

module.exports = (deployer) => {

  deployer.deploy(ContractManager)
  .then(() => {
    return ContractManager.deployed()
  })
  .then(manager => {
    return Promise.all([
      manager.setAddress("UserStorage", UserStorage.address),
      manager.setAddress("TweetStorage", TweetStorage.address),
    ])
  })

}

Next, we'll create two deployment files for our controllers -- one for the UserController and one for the TweetController.

In these migrations, we need to make sure that our controllers have the address of the deployed ContractManager. If they have that address, then they can get the address of every other deployed contract too.

For this, we'll first create a library called BaseController that our UserController and TweetController will inherit from:

// contracts/helpers/BaseController.sol

pragma solidity ^0.5.10;

import './Owned.sol';

contract BaseController is Owned {
   // The Contract Manager's address
  address managerAddr;

  function setManagerAddr(address _managerAddr) public onlyOwner {
    managerAddr = _managerAddr;
  }

}

As you can see, all BaseController does is set the managerAddr state variable using the setManagerAddr function. Now we just need to make sure that TweetController and UserController inherit from it:

// contracts/tweets/TweetController.sol

pragma solidity ^0.5.10;

import '../helpers/BaseController.sol';

contract TweetController is BaseController {

}
// contracts/users/UserController.sol

pragma solidity ^0.5.10;

import '../helpers/BaseController.sol';

contract UserController is BaseController {

}

We're ready to write our two migration files! These should:

  1. Deploy their dedicated controller contract

  2. Set the ContractManager's address in the controller

  3. Set the controller's address in the ContractManager

  4. Set the controller's address in the storage contract that goes with it

// migrations/4_deploy_usercontroller.js

const UserController = artifacts.require('UserController')
const UserStorage = artifacts.require('UserStorage');
const ContractManager = artifacts.require('ContractManager')

module.exports = (deployer) => {

  deployer.deploy(UserController)
  .then(() => {
    return UserController.deployed()
  })
  .then(userCtrl => {
    userCtrl.setManagerAddr(ContractManager.address)

    return Promise.all([
      ContractManager.deployed(),
      UserStorage.deployed(),
    ])
  })
  .then(([manager, storage]) => {
    return Promise.all([
      manager.setAddress("UserController", UserController.address),
      storage.setControllerAddr(UserController.address),
    ])
  })

}
// migrations/5_deploy_tweetcontroller.js

const TweetController = artifacts.require('TweetController')
const TweetStorage = artifacts.require('TweetStorage');
const ContractManager = artifacts.require('ContractManager')

module.exports = (deployer) => {

  deployer.deploy(TweetController)
  .then(() => {
    return TweetController.deployed()
  })
  .then(tweetCtrl => {
    tweetCtrl.setManagerAddr(ContractManager.address)

    return Promise.all([
      ContractManager.deployed(),
      TweetStorage.deployed(),
    ])
  })
  .then(([manager, storage]) => {
    return Promise.all([
      manager.setAddress("TweetController", TweetController.address),
      storage.setControllerAddr(TweetController.address),
    ])
  })

}

Run truffle test once more to make sure that the contracts are being deployed as expected (there should still be only 2 passing tests though).

This is a huge step forward for our migration architecture. If we later find a bug in our TweetController contract, we can simply edit it and run truffle migrate -f 5 (-f meaning "force" and 5 referring to the fifth migration file). That way, Truffle will only execute 5_deploy_tweetcontroller.js without touching the rest of your migration files.

We're almost there! Now we just need to make all our existing tests pass.

Updating our unit tests

First, we're going to focus on our unit tests. Remember, our unit tests run our contracts in isolation, so there's no interaction between our controller contracts and storage contracts. In other words, we can't make our controller contract call a function on the deployed storage contract in a unit test.

This problem becomes very apparent in TestUserStorage. We're trying to call the UserStorage contract's createUser function, even though we've explicitly specified that that function is controllerOnly, making it inaccessible to TestUserStorage.

The solution is to create a brand new instance of our UserStorage inside the test's constructor, and manually call setControllerAddr on it. That way, we can set the controllerAddr to the test contract's own address, which allows us to call functions like createUser without getting an error.

// test/unit/TestUserStorage.sol

pragma solidity ^0.5.10;

import "truffle/Assert.sol";
import "../../contracts/users/UserStorage.sol";

contract TestUserStorage {
  UserStorage userStorage;

  constructor() public {
    userStorage = new UserStorage();
    userStorage.setControllerAddr(address(this));
  }

  function testCreateFirstUser() public {
    uint _expectedId = 1;

    Assert.equal(userStorage.createUser("tristan"), _expectedId, "Should create user with ID 1");
  }

}

There we go! 3 passing tests, 4 to go.

For TestTweetStorage, we're going to do exactly the same thing -- create a new instance (this time of TweetStorage) and manually set the controller address before running the test functions:

// test/unit/TestTweetStorage.sol

pragma solidity ^0.5.10;

import "truffle/Assert.sol";
import "../../contracts/tweets/TweetStorage.sol";

contract TestTweetStorage {
  TweetStorage tweetStorage;

  constructor() public {
    tweetStorage = new TweetStorage();
    tweetStorage.setControllerAddr(address(this));
  }

  function testCreateTweet() public {
    uint _userId = 1;
    uint _expectedTweetId = 1;

    Assert.equal(
      tweetStorage.createTweet(_userId, "Hello world!"),
      _expectedTweetId,
      "Should create tweet with ID 1"
    );
  }
}

Now we only have 3 tests left!

Building our controllers

To round this up, we need to actually make our controllers work by adding some functions to them that will forward data to their respective storage contracts. We'll start with the user controller.

First of all, we'll go to the users' integration test file, remove the test called "can create user" and replace it with one called "can create user with controller".

// test/integration/users.js

// ...

const UserController = artifacts.require('UserController') // <-- Add this!

// ...

contract('users', () => {

  it("can create user with controller", async () => {
    const controller = await UserController.deployed()

    const username = web3.utils.fromAscii("tristan")
    const tx = await controller.createUser(username)

    assert.isOk(tx)
  })

  // ...

})

To make this pass, all we need to do is create the createUser function in the UserController. The function will fetch the deployed UserStorage instance through the ContractManager, and send its arguments to that instance's own createUser function:

// contracts/users/UserController.sol

pragma solidity ^0.5.10;

import '../helpers/BaseController.sol';
import '../ContractManager.sol';
import './UserStorage.sol';

contract UserController is BaseController {

  function createUser(bytes32 _username) public returns(uint) {
    ContractManager _manager = ContractManager(managerAddr);

    address _userStorageAddr = _manager.getAddress("UserStorage");
    UserStorage _userStorage = UserStorage(_userStorageAddr);

    return _userStorage.createUser(_username);
  }

}

Only one test left!

Finally, we're going to do exactly the same thing with our TweetController and the createTweet function:

// contracts/tweets/TweetController.sol

pragma solidity ^0.5.10;

import '../helpers/BaseController.sol';
import '../ContractManager.sol';
import './TweetStorage.sol';

contract TweetController is BaseController {

  function createTweet(uint _userId, string memory _text) public returns(uint) {
    ContractManager _manager = ContractManager(managerAddr);

    address _tweetStorageAddr = _manager.getAddress("TweetStorage");
    TweetStorage _tweetStorage = TweetStorage(_tweetStorageAddr);

    return _tweetStorage.createTweet(_userId, _text);
  }

}
// test/integration/tweets.js

// ...

const TweetController = artifacts.require('TweetController')

contract('tweets', () => {

  // Add this test:
  it("can create tweet with controller", async () => {
    const controller = await TweetController.deployed()

    const tx = await controller.createTweet(1, "Hello world!")

    assert.isOk(tx)
  })

  // ...

})

And there we go! 8 tests out of 8 passing!

We now have a nice mix of unit and integration tests that make sure our contracts are working as expected.

At first glance, it might seem overly tedious to separate your code into separate contracts like this with controllers, storages and a contract manager. As you continue to add new features, you'll probably be happy about it though, since you'll be able to swap out and upgrade parts of your application without losing precious data!