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
contractsGet 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:
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:
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:
To see if the deployment works, you can try running truffle test
again:
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:
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
:
Note that if storage.createUser(username)
doesn't throw an error, we intentionally make the test fail with assert.fail()
.
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.
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:
Then we just import the function into users.js
and tweets.js
:
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
:
Then we simply add three functions (setAddress
, getAddress
, and deleteAddress
), which all use the onlyOwner
modifier.
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.
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:
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:
We're ready to write our two migration files! These should:
Deploy their dedicated controller contract
Set the
ContractManager
's address in the controllerSet the controller's address in the
ContractManager
Set the controller's address in the storage contract that goes with it
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.
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:
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"
.
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:
Finally, we're going to do exactly the same thing with our TweetController
and the createTweet
function:
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!
Comments
You need a Ludu account in order to ask the instructor a question or post a comment.
at the end of the tests if "can get user"fails:
Contract: users
can get user:
TypeError: Cannot read property 'call' of undefined
add "public"to UserStorage.sol mapping:
before:
mapping(uint => Profile) profiles;
after:
mapping(uint => Profile) public profiles;
then it will pass