Ethereum Apps with Web3.js
- How Do Ethereum Smart Contracts Work?
- Writing Smart Contracts in Remix
- Setting Up Truffle to Deploy Smart Contracts
- Truffle Console and Truffle Tests
- Ethereum Apps with Web3.js
Welcome to the fifth part of this series on how to get started writing smart contracts. This is the final post of this series, and I’ll guide you through writing a command line application in JavaScript with web3.js, but without Truffle. I’ll go in detail on how to find and invoke the methods available in a smart contract, and use this to make a basic application. Let’s get started!
Setup
Tests are useful, but they are nothing more than a script. For a more interactive use of your app, you may wish to create a shell that offers a REPL interface to the contract. This is much harder to do than using Truffle.
Ultimately, we interact with the smart contract by sending our requests in JSON-RPC messages, so the best way is to get a decent library for that. You can use any language you like, and you can create your own library if you need. My experience is limited to web3.js on a node.js application, so that is what I’ll explain here. In these examples, I am using Ganache-cli to simulate the blockchain, running at http://127.0.0.1:8545
.
First of all, currently it is not trivial to install web3.js. You need Python 2 installed in your system beforehand. Then, in your project create a package.json
file establishing the dependency on web3. For example:
{
"name": "your_dapp",
"version": "1.0.0",
"main": "main.js",
"dependencies": {
"web3": "^1.2.1"
}
and then execute npm install
. This should install web3 and all its dependencies in the project’s directory, under node_modules
.
Basic Structure
Now, create a new file, for example main.js
where, we will define the app. The first things to do are to include the Web3
library, and obtain access to the contract that we want to interact with. For these examples, I will keep using the same contract I’ve used throughout this series:
const Web3 = require('Web3'); const jsonInterface = require('./build/contracts/TestContract.json') const defaultNode = 'http://127.0.0.1:8545';
We still need to properly initialize Web3
by telling the code which provider to use. We’re not running in a browser in this example, so we won’t have any provider available by default. You need to reach a blockchain node when initializing Web3
, so do remember to start Ganache before proceeding.
function initializeWeb3() { if (typeof web3 !== 'undefined') { web3 = new Web3(web3.currentProvider); // Using existing web3, from Metamask or similar. } else { web3 = new Web3(new Web3.providers.HttpProvider(defaultNode)); // linking } console.log("Web3 version:", web3.version); }
It is also useful to access the accounts that Ganache makes available to you:
async function initializeAccounts() { accounts = await web3.eth.getAccounts(); console.log("Accounts:\n", accounts); }
We’ll see below how to create a contract instance, but for now let’s hide that away in a convenient function. If we bring all of these together, we’ll have the skeleton for a contract-driver:
async function main() { initializeWeb3(); await initializeAccounts(); await getContractInstance(); } return main();
The Contract Objects in JavaScript
I had to search the web3.js documentation for a good while to understand how to create a contract instance. It is confusing, and a few terms are overloaded or badly explained.
There are two levels on which you can interact with your contract: the first is kind of an archetype, it describes the essence of the contract as it is specified in code. This is the contract before it gets deployed and run. It includes the representation of its interface, the methods to interact with it, but is not bound to any concrete network nor any actual running instance. However, web3js calls it a contract instance.
I find that Truffle’s documentation on Contract Abstractions details this much better. They distinguish between the static Contract Abstraction API and the Contract Instance API.
An instance is what you get when you deploy your contract to the network. You can even have several instances of the same contract deployed to the same network, and each one of them will have its own address and its own state, but they will still be the same contract, and have the same code, bytecode and interface.
For ease of communication, I’ll use Truffle’s terminology, and talk of the contract abstraction and of the contract instance.
Contract Abstractions
You get the contract abstraction by creating a new contract if you have access to its ABI. This can be obtained from the contract json file, its jsonInterface, which is created by Truffle when it compiles the contracts. By default, that is located in build/contracts
.
async function getContractInstance() { var jsonInterface = require('./build/contracts/TestContract'); contract = new web3.eth.Contract(jsonInterface.abi); }
When you do this, you get a contract without address, so next we have to deploy it. That can be done by using the method deploy
to create a suitable transaction object. We can then send this transction to the network to actually deploy the contract. The code looks like this:
deployTx = contract.deploy({data: jsonInterface.bytecode}); instance = await deployTx.send({from: accounts[0], gas: 6e6, gasPrice: 10e9});
You must send a suitable amount of gas (enough to deploy and smaller than the network limit) for the transaction to go through. Using Ganache, I get this reply:
Transaction: 0xfd1f1bffb57e83ab532ebf178e3db6ea69db93c9464917fd540389813d1ad5f7 Contract created: 0xaf0b81c784e339d85ab0ea0b5e41e689c5e96332 Gas usage: 193073 Block Number: 17 Block Time: [...]
Contract Instances
The return is a contract instance. Superficially, it looks the same as the contract abstraction, but now its address is defined.
You can also invoke the contract’s methods and expect a result.
This is the most confusing part of the whole process. You can list the contract’s methods with console.log("Methods", Object.keys(instance.methods));
For my example, I get this:
Methods
[ 'testCallNoArgs',
'0xab38b233',
'testCallNoArgs()',
'testCallNoArgsNotView',
'0x6ff645dd',
'testCallNoArgsNotView()',
'testCallWithArgs',
'0xe9c9eb08',
'testCallWithArgs(uint256)',
'testMethod',
'0xcbef6d0e',
'testMethod(uint256)',
'testUpdate',
'0x84d6f4a1',
'testUpdate(uint256)' ]
The methods
Property
According to the documentation, the members of methods
create transactions for the corresponding method that can then be called, sent or estimated. Each method can be accessed in 3 ways:
- by name,
- by name with parameters,
- and by signature.
‘Signature’ means the hexadecimal numbers you see above. Calling these a ‘signature’ is misleading, as that usually referst to the name of a function and its parameter list. Instead, this is the function selector, which as per the ABI definition is “the first four bytes of the Keccak-256 (SHA-3) hash of the signature of the function“. It is a mouthful and I can see why we use the shortened name instead.
Picking only the simplest case (by name) we create a transaction to invoke the method with a specific choice of parameters by using something like this:
instance.methods.testCallWithArgs(23)
This, by itself, does nothing. To obtain the result we now need to invoke it in the proper way, either with call
or send
. This is a view
function, so we call it:
await instance.methods.testCallWithArgs(23).call())
Notice we have to use await
. This is because blockchain requests are asynchronous. In fact, on the main net, you may have to wait on the order of a few minutes before your transaction is included in a new block.
We can also send state-changing transactions to the blockchain. For that, we send the transaction
await instance.methods.testMethod(12).send(parameters); console.log("Value of a: ", await instance.methods.testCallNoArgs().call()); // Value of a: 12
The above snippet shows two invocations in a row. The first sends
a transaction to invoke testMethod
, which changes the value of a
to 12. The second call testCallNoArgs
, which shows that the value has indeed changed.
Digging Deeper Into Overloaded Methods
If you have overloaded methods, they will be distinguished in this list by their differing types, but this raises the question of which one corresponds to the short version, with just the name and no argument list.
We can investigate this by adding an extra method to our contract:
function testCallWithArgs(uint8 b) public view returns (uint) { return 100; }
and the following to our main script:
console.log("Methods\n", Object.keys(instance.methods));
console.log("testCallWithArgs", await instance.methods.testCallWithArgs(23).call());
console.log("testCallWithArgs(uint8)", await instance.methods['testCallWithArgs(uint8)'](23).call());
console.log("testCallWithArgs(uint256)", await instance.methods['testCallWithArgs(uint256)'](23).call());
Notice that these methods, although overloads with the same name, have different logic so we can distinguish the results. The original method, with a uint256
argument, multiplies its input by the value of the state variable a
, which by default is 10. The new method, taking a uint8
input, simply returns 100
. The result I get is
Methods
[ 'testCallNoArgs',
'0xab38b233',
'testCallNoArgs()',
'testCallNoArgsNotView',
'0x6ff645dd',
'testCallNoArgsNotView()',
'testCallWithArgs',
'0x7a53511c',
'testCallWithArgs(uint8)',
'0xe9c9eb08',
'testCallWithArgs(uint256)',
'testMethod',
'0xcbef6d0e',
'testMethod(uint256)',
'testUpdate',
'0x84d6f4a1',
'testUpdate(uint256)' ]
testCallWithArgs 230
testCallWithArgs(uint8) 100
testCallWithArgs(uint256) 230
This is unambiguous, and shows the default function corresponds to the function with type uint256
.
If you look carefully at the method list above, you will notice some entries in hexadecimal. As I said above, these are function selectors. There is exactly one per each full method specification, and there is a logic to it.
First they are all grouped by name, with all the overloads together. For each group, a default method is listed first, without an argument list. Then, we have pairs of methods, one indicated by a function selector and another by its full signature. Logic suggests each pair corresponds to the same method, that is:
0x7a53511c <=> testCallWithArgs(uint8)
0xe9c9eb08 <=> testCallWithArgs(uint256)
Let’s confirm the default method corresponds to the second one. We can obtain its function selected in this way:
var tx = instance.methods.testCallWithArgs(23) console.log("Tx.abi", tx.encodeABI()); // Tx.abi 0xe9c9eb080000000000000000000000000000000000000000000000000000000000000017
You can check this with the methods list above, and see the first 4 bytes (the function selector) match those of
testCallWithArgs(uint256)
Does this mean the default method corresponds to the overloaded method with the last function selector in alphabetical order? Maybe. Let’s search for proof.
I did some experiments with more overloaded versions of the testCallWithArgs
method, and it seems there is a rule. Methods are sorted alphabetically, first by their function name and then by their function selector, and the default method without arguments will correspond to the highest-valued (alphabetically latest) function selector among all the overloaded methods. For an example, consider these new methods:
[Contract code] function testCallWithArgs(uint8 b) public view returns (uint) { return 100; } function testCallWithArgs(uint16 b) public view returns (uint) { return 200; } function testCallWithArgs(uint24 b) public view returns (uint) { return 300; } function testCallWithArgs(uint32 b) public view returns (uint) { return 400; }
[JavaScript code]
var nameList = ['testCallWithArgs', 'testCallWithArgs(uint8)', 'testCallWithArgs(uint16)', 'testCallWithArgs(uint24)', 'testCallWithArgs(uint32)'];
nameList.forEach((item) => {
console.log(item, instance.methods[item](10).encodeABI());
});
[Results]
Methods
[ ...,
'testCallWithArgs',
'0x4203ae06',
'testCallWithArgs(uint32)',
'0x7a53511c',
'testCallWithArgs(uint8)',
'0x8e10d594',
'testCallWithArgs(uint16)',
'0xa35063dd',
'testCallWithArgs(uint24)',
...]
testCallWithArgs 0xa35063dd000000000000000000000000000000000000000000000000000000000000000a
testCallWithArgs(uint8) 0x7a53511c000000000000000000000000000000000000000000000000000000000000000a
testCallWithArgs(uint16) 0x8e10d594000000000000000000000000000000000000000000000000000000000000000a
testCallWithArgs(uint24) 0xa35063dd000000000000000000000000000000000000000000000000000000000000000a
testCallWithArgs(uint32) 0x4203ae06000000000000000000000000000000000000000000000000000000000000000a
This was a deeper dive than I expected into the entrails of smart contracts, but I hope it can help those more detail-minded readers.
This series provided an introduction to Solidity smart contracts. Looking back, this covered a lot of ground, from a basic explanation of the concept to intricate programming details. If you’ve accompanied me this far, you must have leveled up a couple of times!
But there are surely more topics to explore, and more coding adventures around the corner. See you on my next journeys.