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:
-
Deploy the
UserStorage
andTweetStorage
contracts (as we do now) -
Deploy the
UserController
andTweetController
contracts -
Get the deployed instances of
UserStorage
andTweetStorage
(so that we can interact with them in our migration files) -
Set the
controllerAddr
in the deployedUserStorage
andTweetStorage
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:
-
Add new key-value records, with a string (ex:
"UserStorage"
) pointing to an address (ex:"0xde0b295669a9fd93d5"
) -
Get an address based on the string key.
-
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 (setAddress
, getAddress
, 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:
-
Deploy their dedicated controller contract
-
Set the
ContractManager
's address in the controller -
Set the controller's address in the
ContractManager
-
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!