DEV Community

Aaron Cheng
Aaron Cheng

Posted on

A Toy Blockchain in 100 Lines or Less

Introduction

As the popularity and mainstream awareness of NFTs and blockchains proliferate, I was pretty confused about how they worked - I kept hearing about "mining" and "blocks", and so I figured the best way to learn about the technology was to create a small model of it to see how the internals functioned (or at least a small subset of it).

I also took the opportunity to use Rust to build it, as I've had my eye on it for a while, but haven't yet found the opportunity to use it for anything substantial.

First Principles

As with any new technology, let's start with the basics. For the blockchain, I know that we must be able to have some sort of data structure that contains a chain of blocks. Since we'll be adding to the chain, we know that our chain will need to be some sort of variable-length container.

For our purposes, we'll choose a Vec that holds Blocks in Rust and declare it like so:

struct BlockChain {
    chain: Vec<Block>
}
Enter fullscreen mode Exit fullscreen mode

Now that we have a chain that holds Blocks we'll need to define the Block itself:

struct Block {
    index: usize,
    timestamp: i64,
    data: Vec<BlockData>,
    hash: String,
    previous_hash: String,
    proof: i64
} 
Enter fullscreen mode Exit fullscreen mode

For most blockchains (and this one included), the block contains pretty standard fields:

  1. index: the index of the block on the chain
  2. timestamp: when this block was mined
  3. data: the data of the block itself - this is usually the list of transactions that this block is holding (normally this is all previous transactions + one additional transaction)
  4. hash: the SHA256 hash of this block
  5. previous_hash: the SHA256 hash of the block that came before
  6. proof: the number required for the Proof of Work algorithm (more on this later)

We'll also define the BlockData as the following:

struct BlockData {
    sender: String,
    recipient: String,
    amount: f64
}
Enter fullscreen mode Exit fullscreen mode

In the end, our chain will eventually be a bunch of blocks holding data about who is sending the amount of our coin where. Other blockchains (such as Ethereum or Solana) will have their blockchain data strucutured differently to allow for things such as smart contracts or NFTs - you can view the Ethereum documentation to see what their data is structured like here.

Second Steps

So we've defined our chain and the basic data structures we'll be using - the next steps will be to create the chain itself.

In Rust, we'll be using impl to add methods to the BlockChain struct:

struct BlockChain {
    chain: Vec<Block>
}

impl BlockChain {
    // methods go here
}
Enter fullscreen mode Exit fullscreen mode

The first method to add is genesis_block -- all this really does is create the very first block of the chain. Keep in mind that this is Rust - so we'll just be implementing functionality for the BlockChain type (and the various other types we have defined).

The genesis_block method is as follows:

fn genesis_block(&mut self) {
    let now = Utc::now();
    let mut new_block = Block {
        index: 0, 
        timestamp: now.timestamp(),
        data: [].to_vec(),
        hash: "".to_string(),
        previous_hash: "".to_string(),
        proof: 0
    };

    new_block.hash = new_block.hash();
    self.chain.push(new_block);
}
Enter fullscreen mode Exit fullscreen mode

One thing to note is we are marking the self variable to be mutable - this is because we are modifying the chain itself (i.e. adding a block to the chain). You'll also notice that almost everything in this block is empty - there is no data, no hash, the proof is set to 0 - this is because blocks are meant to be built from the previous block in the chain - if there is no other block, we must have some data somewhere in order for this block to be valid.

Note: We're using the chrono crate here (specifically Utc from chrono) so be sure to add the chrono package to your Cargo.toml file to have access to it.

One last thing to note is that we are calling a hash method on the new_block object - but we haven't defined one yet! So let's do that now:

impl Block {
    fn hash(self) -> String {
        let hashable_data = format!("{:?}{}{}{}{}{}", self.data, self.hash, self.index, self.previous_hash, self.proof, self.timestamp);
        return digest(hashable_data);
    }
}
Enter fullscreen mode Exit fullscreen mode

The hash method looks simple, but we're doing a lot here:

  1. the digest method is using the sha256 crate, so that's installed via our cargo.toml file, and declared by use sha256::digest at the top of the file.
  2. We'll need to also add the serde package that allows us to serialize and deserialize data so we can save the BlockData as a String format (using the format! macro). Be sure to install serde and also add the following traits to the BlockData struct: #[derive(Serialize, Deserialize, Debug, Clone)]. We'll want to add those traits to both the Block and Blockchain structs as well to allow us to print the contents to stdout.

In the end, your BlockData and Block structs should look something like this:

#[derive(Serialize, Deserialize, Debug, Clone)]
struct BlockData {
    sender: String,
    recipient: String,
    amount: i64
}

#[derive(Serialize, Deserialize, Debug, Clone)]
struct Block {
    index: usize,
    timestamp: i64,
    data: Vec<BlockData>,
    hash: String,
    previous_hash: String,
    proof: i64
}

#[derive(Serialize, Deserialize, Debug, Clone)]
struct BlockChain {
    chain: Vec<Block>
}

Enter fullscreen mode Exit fullscreen mode

In our main function, if we do the following:

let mut chain = BlockChain {
        chain: [].to_vec()
    };

chain.genesis_block();

println!("{:#?}", chain);
Enter fullscreen mode Exit fullscreen mode

The output should be similar to the following:

BlockChain {
    chain: [
        Block {
            index: 0,
            timestamp: 1644628637,
            data: [],
            hash: "e7bcf22ad4e5d6c4387626c6ab70db8d52ba5977e85ac73e6ddcc51517a1a2ba",
            previous_hash: "",
            proof: 0,
        },
    ],
}
Enter fullscreen mode Exit fullscreen mode

And that's great! We have our toy block chain that contains Blocks and we have the ability to create an chain with the genesis block on it. Our next step is to give us the ability to add a block and then perform work!

Why Work?

This is one of the defining characteristics of the blockchain - doing some sort of heavy computational work just to be able to add to the chain. It's part of the reason why verifying / adding blocks to the current Bitcoin chain is so slow (or any Blockchain network).

The question remains however - why do we do this? Why not just allow for the addition of blocks via just chain.push like we do for the genesis block - why perform this work?

The answer lies in the original Bitcoin protocol paper. In it, the author (Satoshi Nakamoto) proposes Proof of Work as a way of solving the double spend problem (i.e. multiple people can't push to the chain and say "Aaron got 1000 bucks") - and one way to ensure that is to have a central authority checking every transaction.

As blockchain is meant to be distributed, we need a way for everybody using the chain to come to an agreement about which blocks are valid, and which ones are not. This is where Proof of Work comes in.

As transactions are broadcast to the blockchain network (Bitcoin or otherwise), miners collect them and validate them based on the previous block. For our toy blockchain, that means doing something like the following:

impl BlockChain {
    ...
    fn add_block(&mut self, sender: String, recipient: String, amount: i64) {
            // get the previous block
            let previous_block = self.chain.last().unwrap();

            // create new block data based on the new transaction
            let mut chain_data = previous_block.data.to_vec();
            let new_data = BlockData {
                sender: sender.to_string(),
                recipient: recipient.to_string(),
                amount: amount
            };
            chain_data.push(new_data);

            let now = Utc::now();
            // create a new block with the new transaction
            let mut new_block = Block {
                index: previous_block.index + 1, 
                timestamp: now.timestamp(),
                data: chain_data,
                hash: "".to_string(),
                previous_hash: previous_block.hash.to_string(),
                proof: 0
            };

            // do the proof of work 
            new_block.proof = new_block.mine(previous_block.proof);
            new_block.hash = new_block.hash();
            self.chain.push(new_block);
        }
}
Enter fullscreen mode Exit fullscreen mode

You can see in the above add_block method we get the new transaction data, add it to the previous block's data and then perform the work in the mine method. The mine method looks like the following (it's based on the HashCash algorithm mentioned in the Bitcoin whitpaper):

impl Block {
    // other methods
    fn mine(&self, previous_proof: i64) -> i64 {
        let mut hashed_string = "".to_string();
        let mut proof = 0;
        while !hashed_string.starts_with("00000") {
            let work_string = format!("f{}{}", previous_proof, proof);
            hashed_string = digest(work_string);
            proof += 1;
        }
        return proof;
    }
}
Enter fullscreen mode Exit fullscreen mode

By using the previous block's proof, we've established a dependency on the previous block - assuming it is valid - there is no way for the current validator (or miner) to alter the blockchain without going through the whole chain and rehashing the proof for every block on the chain.

Even with small chains, this can prove to be prohibitively expensvie - although with this approach, just maintaining and adding new blocks can get more and more expensive over time - you can see this with Bitcoin, where the current energy expenditure of the Bitcoin network is greater than than Thailand.

The most important part of the above function is the starts_with("00000") part - that determines the "difficulty" of adding a block to the chain - i.e. how long it will take (on average) to add a block to the chain. This particular chain, on my particular computer will take roughly 8 seconds to finish the work for and add a block to the chain. Bitcoin takes roughly 10 minutes to add a block. To increase the difficulty of the mine, just add more 0s - to reduce, remove 0s.

What does this look like?

We now have a working instance of a Blockchain, consisting of multiple Blocks that can be mined and added to the BlockChain via our Proof of Work algorithm.

For our toy blockchain this looks like the following:

fn main() {
    let mut chain = BlockChain {
        chain: [].to_vec()
    };

    chain.genesis_block();
    chain.add_block("mr.magoo".to_string(), "mr.magee".to_string(), 1000);

    println!("{:#?}", chain);
}
Enter fullscreen mode Exit fullscreen mode

With the following output:

BlockChain {
    chain: [
        Block {
            index: 0,
            timestamp: 1644709286,
            data: [],
            hash: "223f430ce60c464d57fb13cae30c843b82260287dc329fc6be59cfc183fc48fb",
            previous_hash: "",
            proof: 0,
        },
        Block {
            index: 1,
            timestamp: 1644709286,
            data: [
                BlockData {
                    sender: "mr.magoo",
                    recipient: "mr.magee",
                    amount: 1000,
                },
            ],
            hash: "f315332a38dfcce0ce696f5371cf8a4de33db76d59f094322d0ddf68a7601ab6",
            previous_hash: "223f430ce60c464d57fb13cae30c843b82260287dc329fc6be59cfc183fc48fb",
            proof: 375214,
        },
    ],
}
Enter fullscreen mode Exit fullscreen mode

What is missing

We have a very basic blockchain implementation here, but there's a bunch of things missing which I'll list out in no particular order:

  1. Peer-to-peer network: our blockchain currently is only aware of itself, and will not mine blocks automatically - in fact, new blocks can only be added by hardcoding them into the main function.
  2. Chain verification: Our blockchain process is currently not validating the chain - this is usually accomplished by only accepting the chain that is longest - right now we don't do that

Even with those two big things missing (the whole decentralized concept really), to see a basic blockchain in action is super interesting - and the concepts between this blockchain and others isn't much different.

You can view our completed source code here

Oldest comments (0)