How to create and deploy a hello world smart contract in Solidity

After searching the internets, to my surprise, I haven't been able to find an up-to-date Solidity hello world tutorial that would use web3.js and geth to deploy a smart contract. In this article, I'll share the process of creating a simple smart contract, compiling it, deploying it to a private geth node and interacting with it.

Setting up the environment

For this tutorial we would need a private geth node, a node.js environment, Solidity compiler and a web3.js library. The instructions below are for macOS, for other operating systems refer to respective installation guides: Solidity, node.js. I have written about spinning up a private geth node in my previous article, so check that one out first before proceeding.

To install Solidity compiler run these commands:

brew tap ethereum/ethereum
brew install solidity

# verify installation
solc --version
solc, the solidity compiler commandline interface
Version: 0.8.7+commit.e28d00a7.Darwin.appleclang

To install node.js, run this command (or download an installer from the node.js website):

brew install node

# verify installation
node --version
v16.5.0

Install web3.js library for node.js:

npm install web3

Modify package.json to add "type": "module", line which is required to run top level async/await functions in node.js:

{
  "type": "module",
  ...
}

Launch an Ethereum node

If you followed the article I linked in the beginning, then you should be to launch the node with this command (make sure you're executing the command in the directory that holds your geth data):

geth --datadir data --networkid 15123 --nodiscover js script.js

Create a hello world Solidity smart contract

We are going to create a very basic smart contract that allows clients to read a variable and to increment its value. Create a hello.sol file with the following contents:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract Counter {
    uint256 public count = 1;

    function increment() public {
        count += 1;
    }
}

Before we can deploy this contract to an Ethereum node we have to compile it first:

solc hello.sol --combined-json abi,bin > contracts.json

We're instructing the compiler to compile the Solidity program and save both the binary code (that is going to be executed by EVM) and ABI in a single JSON file. ABI essentially describes the smart contract functionality so that web3.js knows which variables and functions the contract has and how to use them.

Create a web3 node.js script

We are going to create a simple JavaScript program that would connect to the geth node with web3.js library, deploy previously compiled smart contract to the node and interact with the smart contract by invoking its increment function and reading the count state variable. Create an index.js file:

import fs from 'fs';
import net from 'net';
import Web3 from 'web3';

// substitute this path with real IPC path
let ipcPath = '/path/to/your/geth/directory/data/geth.ipc';
let web3 = new Web3(new Web3.providers.IpcProvider(ipcPath, net));

// solc hello.sol --combined-json abi,bin > contracts.json
let source = fs.readFileSync("contracts.json");
let contract = JSON.parse(source)["contracts"]['hello.sol:Counter'];

let abi = contract.abi;
let code = contract.bin;
let CounterContract = new web3.eth.Contract(abi);

// unlock the account
let coinbase = await web3.eth.getCoinbase();
// make sure to replace with real password
let password = "your_account_password";
await web3.eth.personal.unlockAccount(coinbase, password);

CounterContract
    .deploy({data: code})
    .send({from: coinbase, gas: 1000000})
    .on('transactionHash', function(transactionHash){
        console.log("Your contract is being deployed, transaction:", transactionHash);
    })
    .then(async function(newContractInstance){
        console.log('contract address:', newContractInstance.options.address)
        var count;

        count = await newContractInstance.methods.count().call();
        console.log('count:', count)

        // invoking a smart contract increment function, from field is required
        await newContractInstance.methods.increment().send({from: coinbase});
        count = await newContractInstance.methods.count().call();
        console.log('count:', count)
    });

Make sure to replace the IPC path with the real one, you can find it by looking at the logs of the geth node when you start it:

INFO [09-07|23:50:51.646] IPC endpoint opened   url=/Projects/eth/data/geth.ipc

Finally, don't forget to provide the password to your account.

let password = "your_account_password";

We're using a coinbase account which is usually the first account in the node, feel free to replace it with the address of another account if you like.

How does it all work?

From a high level perspective, the JavaScript code essentially packages the contract's binary into a transaction, signs it, and sends it to the Ethereum node via JSON-RPC interface under the hood. Once the contract is deployed it sends more JSON-RPC messages to either fetch or update the contract's state that is running on Ethereum node.

The code is pretty straightforward. We read the contents of the file produced by Solidity compiler and extract the compiled contract binary code and ABI. Then we initialize a web3.eth.Contract instance with the ABI, which allows us to read state variables and call functions defined in the the contract via JavaScript. This is a sort of a bridge between a regular program and an Ethereum smart contract.

Before we can actually execute a smart contract, we need to deploy it to Ethereum node. Deployment is essentially an EVM transaction that contains code as its payload, signed by a private key that belongs to the account specified here send({from: coinbase, gas: 1000000}), that's why the account has to be unlocked first.

Smart contract deployment is an asynchronous process, so after we send the transaction that contains the contract code, we start listening to the events from the node that would fire when the contract is deployed. The last handler receives a new contract instance as an argument function(newContractInstance){, the reason we don't use the CounterContract object is that it doesn't know where the deployed contract lives on the blockchain. And if you tried to call a function on CounterContract object directly it would throw this exception:

This contract object doesn't have address set yet, please set an address first

We could still use the original CounterContract object by specifying the deployed contract address:

CounterContract
    .deploy({data: code})
    .send({from: coinbase, gas: 1000000})
    .then(async function(newContractInstance){
        CounterContract.options.address = newContractInstance.options.address;
        console.log('count:', await CounterContract.methods.count().call());
    });

So the new pre-configured contract object passed via an argument is basically a convenience to avoid manually configuring the contract object like we did above.

As soon as the contract is deployed, we fetch the value of a contract's count state variable by calling its getter function:

await newContractInstance.methods.count().call();

In a Solidity contract, every state variable that has a public access modifier, will have an auto-generated getter function with the same name (in our case it's count()). It's not trivial to fetch the contents of a state variable that doesn't have a getter function (there is a web3.eth.getStorageAt function, but it is pretty low level and hard to use). So in general, if you want to be able to access variables outside of the contract, you need to have a corresponding getter function. The result of the above call will return 1 as this is the value that we initialized the variable in the contract with.

Function calls that fetch data don't require any gas to execute because they don't modify the blockchain state. However, functions that do modify state, require a different approach. For those functions we have to use send() method (rather than call()) because we essentially create a new transaction that updates smart contract state which lives on a blockchain, it requires gas and has to be mined. That's how we invoke the increment function to update the count state variable:

await newContractInstance.methods.increment().send({from: coinbase});

We have to specify the account which will be used for the gas payment. Additionally, in a real world, we would also have to set the gasPrice and gas parameters, however this is out of scope of this article.

After modifying the count variable, we are making another call to a getter function to confirm that the state has been updated. If you did everything correctly you should get an output that looks like this:

Your contract is being deployed, transaction: 0x5f62fdc8214571fda17dc7b335f1cb9b6fce84445a8c3d566c19fba57ef8f304
contract address: 0xE738251e609A34B04bFb4E00DdB38C74545116Fd
count: 1
count: 2

Summary

We have now deployed our first smart contract to the blockchain. The process for deploying this contract to an Ethereum mainnet or testnet is somewhat similar: if you run your own geth node, then you only have to change the IPC path and it should work. Realistically the chances that you're running your own node are quite low, so you would most likely use a hosted node. In that case, the code has to be modified to manually sign a smart contract transaction with your private key and broadcast it to some 3rd party hosted node like Infura.