🌉❄️📝/Writing/A minimal Bitcoin-inspired implementation/📶Nodes
Get Notion free

Nodes

In the previous post, we introduced a single miner. Now let's set up 3 nodes that mine and broadcast to each other.

Context

Let's start by sketching out what each node would do. In mining mode, the node tries 1,000 nonce values at a time and extends the blockchain if a solution is found. In listening mode, the node listens to the network and replaces its blockchain if the broadcasted blockchain is valid and longer than the existing blockchain. To keep things simple, the node would alternate between the two modes instead of running both in parallel.

while True:
    try:
        # Listen for incoming messages.
        #     If message received, check blockchain received is valid and longer      
        #     than existing blockchain.
        #         If true, replace existing blockchain.
        #         Otherwise, continue listening.
        #     Otherwise, switch to mining mode.
    except:
        # Run proof-of-work.
        #     If not solved after 1000 nonce values, switch to listening mode.
        #     Otherwise, update blockchain and broadcast to network.

Broadcasters

Since most of our focus so far has been on mining, let's switch gears briefly to set up nodes that broadcast to each other. To ensure our nodes are set up correctly, we create a .env file with the IP address by running the following command in a terminal window.

echo 'NODE_IP='"$(ipconfig getifaddr en0)" > .env

Next we load the IP address as an environment variable, and enumerate all the ports to be used.

import dotenv
import os

dotenv.load_dotenv()

NODE_IP = os.getenv("NODE_IP")
NODE_PORTS = [7000, 8000, 9000]
node.py

We introduce a helper function to bind the network socket to the IP address-port pair.

import socket

def bind_socket(ip_address: str, port: int) -> socket.socket:
    """ """
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.bind((ip_address, port))
    return sock
node.py

Let's wrap the socket in a class and create functions to (i) initialize the node, and (ii) broadcast to other nodes. The node class also stores the blockchain state - we'll use this in the next section.

import dataclasses

@dataclasses.dataclass
class Node:
    """ """

    port: int
    sock: socket.socket
    blockchain: bytes
    block_counter: int

def init_node(
    port: int, blockchain: bytes = b"", block_counter: int = 0
) -> Node:
    """ """
    assert NODE_IP is not None
    sock = bind_socket(NODE_IP, port)

    return Node(
        port=port,
        sock=sock,
        blockchain=blockchain,
        block_counter=block_counter,
    )

def broadcast(node: Node, message: bytes):
    """ """
    for node_port in NODE_PORTS:
        if node_port == node.port:
            continue

        node.sock.sendto(message, (NODE_IP, node_port))
node.py

In the placeholder version, we set up the nodes to broadcast a simple message to the network and then sleep for a random time.

import random
import time

def run(node: Node):
    """ """
    while True:
        try:
            node.sock.settimeout(1)
            message, _ = node.sock.recvfrom(9216)

            node_port = int.from_bytes(message[:2], byteorder="big")
            sleep_time = message[2]

            print(f"OTHER {node_port} sleep for {sleep_time} seconds...")

        except socket.timeout:
            sleep_time = random.randint(1, 9)
            message = node.port.to_bytes(
                2, byteorder="big"
            ) + sleep_time.to_bytes(1, byteorder="big")

            broadcast(node, message)
            print(f"SELF {node.port} sleep for {sleep_time} seconds...")

            time.sleep(sleep_time)
node.py - placeholder version

We set up main to take in the port number as a command line argument.

import sys

if __name__ == "__main__":
    port = int(sys.argv[1])
    assert port in NODE_PORTS

    node = init_node(port)
    run(node)
node.py

That's a fair amount of setup! Now respectively run each line in different terminal windows. A sample run can be seen here, and the code in a single file here.

python node.py 7000
python node.py 8000
python node.py 9000

Miners

Now that we've done the heavy-lifting of setting up nodes that broadcast to each other, we can incorporate mining into the nodes.

We modify the node initialization so that the blockchain starts with the genesis block.

import sys

import blocks

genesis_hash, genesis_block = blocks.init_genesis_block()

if __name__ == "__main__":
    port = int(sys.argv[1])
    assert port in NODE_PORTS

    node = init_node(port, genesis_block, 1)
    run(node)
blocks.py

At the start of this post, we discussed alternating between mining mode and listening mode. The steps in mining mode is similar to the previous post; the switch from mining to listening mode happens after trying 1,000 nonce values. The steps in listening mode are as follows.

  1. Listen to incoming messages from other nodes.
  1. If message is received, check broadcasted blockchain in the message is valid and longer than the existing blockchain.
    1. If the broadcasted blockchain is valid and longer than the existing blockchain, replace the stored blockchain with the broadcasted blockchain.
    1. If the broadcasted blockchain is invalid or not longer than the existing blockchain, ignore the broadcasted blockchain.
  1. If no message is received after listening for 1 second, switch to mining mode.

Since the blockchain is an array of bytes, we can broadcast it directly! For simplicity, the whole blockchain is sent over the wire instead of only the latest block.

import binascii
import time

def run(node: Node):
    """ """
    previous_hash = GENESIS_HASH
    timestamp = int(time.time())
    nonce = 0

    while True:
        try:
            # Listen for incoming messages, with maximum size set at OS X
            # maximum UDP package size of 9216 bytes.
            node.sock.settimeout(1)
            blockchain, _ = node.sock.recvfrom(9216)

            # Check blockchain received is valid.
            (
                is_new_block,
                block_counter,
                current_hash,
            ) = blocks.validate_blockchain(blockchain)

            # Skip if invalid or not longer than existing blockchain.
            if not is_new_block or block_counter <= node.block_counter:
                print("IGNORE blockchain...")
                continue

            # Replace if valid and longer than existing.
            node.blockchain = blockchain
            node.block_counter = block_counter

            assert current_hash is not None
            print(
                f"COPY block {node.block_counter - 1}: {binascii.hexlify(current_hash).decode()}!"
            )

            # Force sleep to randomize timestamp.
            sleep_time = (node.port + block_counter) % 3 + 1
            print(f"SLEEP for {sleep_time} seconds...")
            time.sleep(sleep_time)

        except socket.timeout:
            # Run proof-of-work.
            print(f"TRY up to {nonce}...")
            is_new_block, _, current_hash, header = blocks.run_proof_of_work(
                previous_hash, timestamp, nonce, 1000
            )

            # Switch to listening mode if not solved after 1000 nonce values.
            if not is_new_block:
                nonce += 1000
                continue

            # Create block reward if solved.
            assert current_hash is not None and header is not None
            transaction_counter, transactions = blocks.init_transactions(
                node.port
            )
            block = blocks.init_block(header, transaction_counter, transactions)

            # Append new block to blockchain.
            node.blockchain += block
            node.block_counter += 1

            # Broadcast full blockchain to network.
            broadcast(node, node.blockchain)
            print(
                f"CREATE block {node.block_counter - 1}: {binascii.hexlify(current_hash).decode()}!"
            )

        # Reset values for next block header.
        previous_hash = current_hash
        timestamp = int(time.time())
        nonce = 0
node.py - actual version

With the changes in place, we re-run each line below in different terminal windows. A sample run is shown below.

python node.py 7000
python node.py 8000
python node.py 9000

In the next series of posts, we'll take a closer look at transactions and validating that each coin is only spent once.