How to spin up a private Ethereum node?

In this guide we'll spin up a private Ethereum node, create two accounts, allocate them with funds and make a transaction transferring some ETH between accounts.

Why would I want to create a private Ethereum network?

I think the main benefit of running a private network is to be able to get a feel of how Ethereum works. It is also a great way to bootstrap a development environment for running and testing smart contracts locally.

What about a testnet like Ropsten?

Well, using a testnet for development is also an option, however it is tricky to get ETH on Ropsten testnet (the closest to what a mainnet Ethereum is) because the faucets that send ETH are broken most of the time. Without ETH you won't be able to deploy smart contracts.

Wouldn't it make more sense to use a tool like Remix or Ganache?

I think those are great tools, however I prefer to have less amount of abstraction layers between me and the actual thing — I believe that leads to a better understanding of how things actually work.

What is an Ethereum node?

Ethereum node is a computer that runs an Ethereum client. To be precise an Ethereum a node is usually both a client and a server: it can request information from other nodes, and at the same time it can also serve info to other nodes. There are currently two major clients: Go Ethereum (geth) and Parity Ethereum. While geth is an official one, both can be used to connect to Ethereum network. For this guide we're going to use geth.

Installation

I'm using MacBook M1 to run an Ethereum node, for other operating systems like Linux refer to geth installation instructions. On macOS gethcan be easily installed via brew package manager. If you don't have brew, head over to the Brew website for installation instructions. Execute those two commands in the terminal:

brew tap ethereum/ethereum
brew install ethereum

After they finish running, verify that geth has been installed:

geth --help

NAME:
   geth - the go-ethereum command line interface

   Copyright 2013-2021 The go-ethereum Authors

USAGE:
   geth [options] [command] [command options] [arguments...]
...

Create wallets

Create a directory that would hold your Ethereum related info:

mkdir ~/ethereum-priv
cd ~/ethereum-priv

After that, execute the command that creates a wallet:

geth account new --datadir data

--datadir specifies the location where geth would store its data: wallets, blocks with transactions, etc. The command will prompt you to enter a password and will output something like this:

Your new key was generated

Public address of the key:   0xb07695741477d468fd1DD3269607040e69E4B9e0
Path of the secret key file: data/keystore/UTC--2021-09-07T14-39-09.205466000Z--b07695741477d468fd1dd3269607040e69e4b9e0

- You can share your public address with anyone. Others need it to interact with you.
- You must NEVER share the secret key with anyone! The key controls access to your funds!
- You must BACKUP your key file! Without the key, it's impossible to access account funds!
- You must REMEMBER your password! Without the password, it's impossible to decrypt the key!

The public address of the key is basically an account number (or a wallet) which is used for sending money, creating smart contracts, etc. Run the same geth account new --datadir data command to create a second wallet, and keep the public addresses, we are going to need them shortly.

Initialize the node, allocate funds

Before starting the node we need to initialize the node by creating a genesis block and setting some config values. A genesis block is essentially a first block in a blockchain, which defines initial ETH allocation, mining difficulty etc.

Create a genesis.json file with the following contents:

{
  "alloc": {
    "<first-public-address>": { "balance": "5000000000000000000000000" },
    "<second-public-address>": { "balance": "4000000000000000000000000" }
  },
  "config": {
    "chainId": 11111,
    "homesteadBlock": 0,
    "eip150Block": 0,
    "eip155Block": 0,
    "eip158Block": 0,
    "byzantiumBlock": 0,
    "constantinopleBlock": 0,
    "petersburgBlock": 0,
    "ethash": {}
  },
  "difficulty": "1",
  "gasLimit": "8000000"
}

Here's an explanation for some of the values:

  • alloc - is a list of wallets that will be allocated with ETH funds in the first block when the network is initialized. Replace <first-public-address> and <second-public-address> with the actual account address you got from the previous step (e.g 0xb07695741477d468fd1DD3269607040e69E4B9e0). The balance is in Wei, 5000000000000000000000000 Wei equals to 5,000,000 ETH.
  • chainId - was introduced to differentiate from Ethereum Classic (ETC). ETH mainnet has a value of 1 for example. For private network it doesn't matter much, so just use a random number.
  • homesteadBlock, eip150Block, ... xBlock - block number at which a corresponding Ethereum hard fork with new protocol will be activated, 0 means that we'll activate them from the first block. These parameters are not really important for brand new networks because they start from scratch.
  • ethash - means that we're going to use a Proof of Work consensus algorithm. Ethereum also support Proof of Authority algorithm. We're using Proof of Work because that's what Ethereum currently uses

OK, once you created the file and specified the accounts, run this command:

geth init --datadir data genesis.json

If you did everything correctly, you'll get an output like this:

INFO [09-07|23:45:14.175] Maximum peer count                       ETH=50 LES=0 total=50
INFO [09-07|23:45:14.176] Set global gas cap                       cap=50,000,000
INFO [09-07|23:45:14.176] Allocated cache and file handles         database=/Projects/eth/data/geth/chaindata cache=16.00MiB handles=16
INFO [09-07|23:45:14.253] Writing custom genesis block 
INFO [09-07|23:45:14.254] Persisted trie from memory database      nodes=3 size=413.00B time="140.708µs" gcnodes=0 gcsize=0.00B gctime=0s livenodes=1 livesize=0.00B
INFO [09-07|23:45:14.254] Successfully wrote genesis state         database=chaindata hash=98e2c9..3185f2
INFO [09-07|23:45:14.254] Allocated cache and file handles         database=/Projects/eth/data/geth/lightchaindata cache=16.00MiB handles=16
INFO [09-07|23:45:14.330] Writing custom genesis block 
INFO [09-07|23:45:14.331] Persisted trie from memory database      nodes=3 size=413.00B time="132.25µs"  gcnodes=0 gcsize=0.00B gctime=0s livenodes=1 livesize=0.00B
INFO [09-07|23:45:14.331] Successfully wrote genesis state         database=lightchaindata hash=98e2c9..3185f2

Great, now we can launch Ethereum node for the first time!

Running a node

Once the node has been initialized and configured, we can boot the node. To do that execute the following command:

geth --datadir data --networkid 11111 --nodiscover

--networkid is used by peers when discovering nodes. This value is not really important when it comes to private networks, just use some random number, ideally not one of the ids that are used by Ethereum. I passed the same value I used for chainId: 11111.

--nodiscover means that we don't want other peers to automatically discover us. We would have to add nodes manually.

--datadir must be the same as the one we used during initialization process because that's where the configuration and the initial data are stored

On successful launch it should print the following output to the terminal:

INFO [09-07|23:50:51.524] Maximum peer count                       ETH=50 LES=0 total=50
INFO [09-07|23:50:51.525] Set global gas cap                       cap=50,000,000
INFO [09-07|23:50:51.525] Allocated trie memory caches             clean=154.00MiB dirty=256.00MiB
INFO [09-07|23:50:51.525] Allocated cache and file handles         database=/Projects/eth/data/geth/chaindata cache=512.00MiB handles=5120
INFO [09-07|23:50:51.603] Opened ancient database                  database=/Projects/eth/data/geth/chaindata/ancient readonly=false
INFO [09-07|23:50:51.604] Initialised chain configuration          config="{ChainID: 11111 Homestead: 0 DAO: <nil> DAOSupport: false EIP150: 0 EIP155: 0 EIP158: 0 Byzantium: 0 Constantinople: 0 Petersburg: 0 Istanbul: <nil>, Muir Glacier: <nil>, Berlin: <nil>, London: <nil>, Engine: ethash}"
INFO [09-07|23:50:51.604] Disk storage enabled for ethash caches   dir=/Projects/eth/data/geth/ethash count=3
INFO [09-07|23:50:51.604] Disk storage enabled for ethash DAGs     dir=/Users/knivets/Library/Ethash count=2
INFO [09-07|23:50:51.604] Initialising Ethereum protocol           network=11111 dbversion=8
INFO [09-07|23:50:51.605] Loaded most recent local header          number=0 hash=98e2c9..3185f2 td=1 age=52y5mo1w
INFO [09-07|23:50:51.605] Loaded most recent local full block      number=0 hash=98e2c9..3185f2 td=1 age=52y5mo1w
INFO [09-07|23:50:51.605] Loaded most recent local fast block      number=0 hash=98e2c9..3185f2 td=1 age=52y5mo1w
INFO [09-07|23:50:51.607] Loaded local transaction journal         transactions=0 dropped=0
INFO [09-07|23:50:51.607] Regenerated local transaction journal    transactions=0 accounts=0
INFO [09-07|23:50:51.607] Gasprice oracle is ignoring threshold set threshold=2
INFO [09-07|23:50:51.608] Starting peer-to-peer node               instance=Geth/v1.10.8-stable/darwin-arm64/go1.16.6
INFO [09-07|23:50:51.645] New local node record                    seq=3 id=5219e213c17a74a3 ip=127.0.0.1 udp=0 tcp=30303
INFO [09-07|23:50:51.645] Started P2P networking                   self="enode://31dca827e1c65ea37b42fab4ff8e950cfbe1bcf24f35e9ab24118052fc7ac29899db241d9e2f7eb8253dc9c958dcc4fdb8aefef32bc57bd9e1556ddf1488a834@127.0.0.1:30303?discport=0"
INFO [09-07|23:50:51.646] IPC endpoint opened                      url=/Projects/eth/data/geth.ipc

Looks great, however it is not doing much currently.

Sending money

In order to send money we have to first connect to the running node so that we can interact with it. Open a new terminal tab and run geth attach <ipc-path>, where <ipc-path> is the path that was printed in the output when the node started. In my case, the log line looks like this:

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

So the actual command looks like this:

geth attach /Projects/eth/data/geth.ipc

This command launches a JavaScript console which we can use to create transactions, accounts, setting config values etc. You can read more about the available commands here and here (check out various namespaces in a sidebar on the left).

Let's define the receiver and sender wallet addresses:

// replace the addresses with the ones you specified in the genesis.json
var receiver = '0x96a815a1ff519738138138ad8092f93ea1730d96'
var sender = '0x5ba634ce372e894a8aa7c33dc02e183f49a069c2'

Finally, let's create a transaction that sends 0.05 ETH from receiver to sender:

> eth.sendTransaction({to: receiver, from: sender, value: web3.toWei("0.05", "ether"), gas: 100000})
Error: authentication needed: password or unlock
	at web3.js:6357:37(47)
	at web3.js:5091:62(37)
	at <eval>:1:20(17)

Uh-oh, the error tells us that we can't send money from account unless it is unlocked. By default, all wallets are locked or in other words encrypted with a password you specified earlier. For geth to be able to send money from one of the accounts, it has to be decrypted so that geth could read the private key of that wallet. Run this:

web3.personal.unlockAccount(sender)

It will prompt for the password and if the password is correct, it should return true. Let's run the eth.sendTransactionagain now:

> eth.sendTransaction({to: receiver, from: sender, value: web3.toWei("0.05", "ether"), gas: 100000})
"0x1135d572d50d34fc82b82dc263c99395232a630b15aeefd15c328e566d2ae5ab"

Awesome. The value returned by the above command is the transaction ID. In real world, sending money by unlocking account in geth might not be the most secure option (an alternative is to craft a transaction manually and sign it with your private key, potentially outside geth)

Let's inspect the transaction:

> eth.getTransaction('0x1135d572d50d34fc82b82dc263c99395232a630b15aeefd15c328e566d2ae5ab')
{
  blockHash: null,
  blockNumber: null,
  from: "0x5ba634ce372e894a8aa7c33dc02e183f49a069c2",
  gas: 100000,
  gasPrice: 1000000000,
  hash: "0x1135d572d50d34fc82b82dc263c99395232a630b15aeefd15c328e566d2ae5ab",
  input: "0x",
  nonce: 5,
  r: "0x6b800405cb547f27ebfbfab17eb4a50fdd314870292c89fdf5f8deaca87261e3",
  s: "0x7f88893252b1064a5623cff1a64f0ae37eee12da9ef08ed2b0baf8dcf31800a5",
  to: "0x96a815a1ff519738138138ad8092f93ea1730d96",
  transactionIndex: null,
  type: "0x0",
  v: "0x764a",
  value: 50000000000000000
}

You can see that from and to field match the wallet numbers specified earlier. However, somehow the blockNumber is null. Why? Because the transaction hasn't been mined yet, in other words it is not yet part of any blocks, so the money haven't been transferred anywhere yet.

Mining

The geth node we are running doesn't mine any blocks. In this mode it can only sync state with other nodes, return data to other nodes or send transactions to other nodes for processing. In order to process transactions on our node, we have to pass some additional configuration when starting it. Here's the command:

geth --datadir data --networkid 11111 --nodiscover --mine --miner.threads 1

We need to specify the number of threads explicitly because the default value is 0 and it won't mine anything. You should see logs like these:

INFO [09-08|18:17:24.465] Commit new mining work                   number=121 sealhash=162465..def927 uncles=0 txs=0 gas=0 fees=0 elapsed="86.5µs"
INFO [09-08|18:17:26.452] Successfully sealed new block            number=121 sealhash=162465..def927 hash=25a4d8..293c2e elapsed=1.987s
INFO [09-08|18:17:26.452] 🔨 mined potential block                  number=121 hash=25a4d8..293c2e
INFO [09-08|18:17:26.452] Commit new mining work                   number=122 sealhash=2f6a55..127d65 uncles=0 txs=0 gas=0 fees=0 elapsed="86µs"
INFO [09-08|18:17:27.006] Successfully sealed new block            number=122 sealhash=2f6a55..127d65 hash=d31efe..31f8fa elapsed=554.158ms
INFO [09-08|18:17:27.006] 🔨 mined potential block                  number=122 hash=d31efe..31f8fa
INFO [09-08|18:17:27.006] Commit new mining work                   number=123 sealhash=dd5bc9..760070 uncles=0 txs=0 gas=0 fees=0 elapsed="92.25µs"
INFO [09-08|18:17:36.450] Successfully sealed new block            number=123 sealhash=dd5bc9..760070 hash=6d3a29..563ea5 elapsed=9.443s
INFO [09-08|18:17:36.450] 🔨 mined potential block                  number=123 hash=6d3a29..563ea5

Let's check whether our transaction has been mined and added to a block yet:

> eth.getTransaction('0x1135d572d50d34fc82b82dc263c99395232a630b15aeefd15c328e566d2ae5ab')
{
  blockHash: "0xa8c76949c1b7a6b472a1953be8cacac96d837c0d6963d5b49a0099ff4cdb10d9",
  blockNumber: 116,
  from: "0x5ba634ce372e894a8aa7c33dc02e183f49a069c2",
  gas: 100000,
  gasPrice: 1000000000,
  hash: "0x1135d572d50d34fc82b82dc263c99395232a630b15aeefd15c328e566d2ae5ab",
  input: "0x",
  nonce: 5,
  r: "0x6b800405cb547f27ebfbfab17eb4a50fdd314870292c89fdf5f8deaca87261e3",
  s: "0x7f88893252b1064a5623cff1a64f0ae37eee12da9ef08ed2b0baf8dcf31800a5",
  to: "0x96a815a1ff519738138138ad8092f93ea1730d96",
  transactionIndex: 1,
  type: "0x0",
  v: "0x764a",
  value: 50000000000000000
}

The blockNumber is no longer null, which means that the transaction has been processed and the money transferred. At this point you might be wondering why did geth kept mining if there was only one transaction? Turns out that geth will mine blocks regardless of whether there are any pending transactions to process.

(Bonus) Configure geth to perform mining only when there are transactions

It is possible to control geth mining logic via console. However, in order to not input the script manually each time we start the node, we can specify which script to run when the geth node starts. Create a file with the following contents:

var miningThreads = 1

function checkWork() {
    if (eth.pendingTransactions.length > 0) {
        if (eth.mining) return;
        console.log("== Pending transactions! Mining...");
        miner.start(miningThreads);
    } else {
        miner.stop();
        console.log("== No transactions! Mining stopped.");
    }
}

eth.filter("latest", checkWork);
eth.filter("pending", checkWork);

checkWork();

Finally, use this command to start the node:

geth --datadir data --networkid 11111 --nodiscover --mine --miner.threads 1 js script.js

where script.js is the path to the file you created earlier.

Now geth is going to mine transactions only when there are any.

Summary

At this point we know how to configure and run a private Ethereum node, mine blocks, and send ETH between wallets. I haven't covered running multiple nodes, but the process is not different from the above, except that we have to connect them together. I'll leave this as an exercise to the reader.