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 geth
can 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.g0xb07695741477d468fd1DD3269607040e69E4B9e0
). 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 of1
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.sendTransaction
again 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.