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 Block
s in Rust and declare it like so:
struct BlockChain {
chain: Vec<Block>
}
Now that we have a chain that holds Block
s we'll need to define the Block
itself:
struct Block {
index: usize,
timestamp: i64,
data: Vec<BlockData>,
hash: String,
previous_hash: String,
proof: i64
}
For most blockchains (and this one included), the block contains pretty standard fields:
- index: the index of the block on the chain
- timestamp: when this block was mined
- 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)
- hash: the SHA256 hash of this block
- previous_hash: the SHA256 hash of the block that came before
- 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
}
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
}
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);
}
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);
}
}
The hash
method looks simple, but we're doing a lot here:
- the
digest
method is using thesha256
crate, so that's installed via ourcargo.toml
file, and declared byuse sha256::digest
at the top of the file. - We'll need to also add the
serde
package that allows us to serialize and deserialize data so we can save theBlockData
as a String format (using theformat!
macro). Be sure to installserde
and also add the following traits to theBlockData
struct:#[derive(Serialize, Deserialize, Debug, Clone)]
. We'll want to add those traits to both theBlock
andBlockchain
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>
}
In our main
function, if we do the following:
let mut chain = BlockChain {
chain: [].to_vec()
};
chain.genesis_block();
println!("{:#?}", chain);
The output should be similar to the following:
BlockChain {
chain: [
Block {
index: 0,
timestamp: 1644628637,
data: [],
hash: "e7bcf22ad4e5d6c4387626c6ab70db8d52ba5977e85ac73e6ddcc51517a1a2ba",
previous_hash: "",
proof: 0,
},
],
}
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);
}
}
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;
}
}
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 0
s - to reduce, remove 0
s.
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);
}
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,
},
],
}
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:
-
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. - 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
Top comments (0)