Peer to Peer Networks: An Introduction to Blockchains
In this part, we'll construct a Peer to peer-to-peer(P2P) network to decentralize our blockchain. We'll:
Learn about networks that are formed over the internet. How traditionally the connections in that network are to a centralized server, and how decentralized peer-to-peer networks can help with some of the issues that they bring.
Go into some of the problems that arise when we implement a blockchain into a P2P network and how we can go about solving them.
Start building our P2P network, and integrate our blockchain in this environment.
Run our blockchain in a P2P network.
This series is comprised of the following posts:
Networks
When you connect to the internet you're becoming part of a network of many computers. Each computer is a node on that network where they all become connected.
Traditionally when you use a service over the internet you'd be one of the many clients connecting to a powerful server or cluster of servers. A lot of services that people typically use every day, like email services, social media platforms and cloud storage services use this architecture.
These servers are the central authority over the network, they have great hardware and bandwidth to handle all connections from clients wanting to use their services, they can store all user data, and can be very reliable since they are optimized for a single purpose.
There are however downsides to taking this approach, one of them being that the entire network is dependent on a single node if the server were to go down for any reason, let it be technical issues, the service provider deciding to terminate the service or even an attack by some malicious third party, the entire network stops functioning, as is represented in the image below.
There are also other disadvantages to these systems, they are hard to scale since as more and more clients enter the network this server has to handle more connections and requires more computing power to handle more requests for its services. There are also many privacy concerns, since these servers control all user data the users themselves have very little control over what can be done with their data. This also means that there is a single point of failure for user data to be leaked.
There are other approaches to creating networks on the internet one of them being peer-to-peer (P2P) networks. In this kind of network, there is no single central server node to which all other nodes connect, each user on the network acts both as the server and client, no being more important than the next.
These networks have been around for decades. An example of this is file sharing by torrents. By allowing users to connect to a network and share files, these networks facilitate high-speed downloads, with each participant contributing a small portion without relying on a centralized server.
These networks are also very resilient to nodes leaving, either by choice or due to attacks, in the image below we can see that even if two of the most connected nodes on the network disappear, all nodes on the network are still able to communicate with each other.
These networks also offer scalability, since as more users join the network each user contributes resources and bandwidth to the network. They can also be more efficient as they can distribute the load equally among peers.
There are also disadvantages to such networks. There can be inconsistent performance since this depends on the number of users connected to the network at any given time and their computing power. There can also be security concerns as malicious peers can for example introduce malware into the network.
The fundamental principle behind most blockchains is the idea of creating a P2P network. We don't want a single entity to be able to control our blockchain this entity could potentially shut down services or selectively censor transactions, undermining the core principles of a decentralized ledger. To mitigate this risk, blockchains are designed to operate within a network of P2P nodes. Within this decentralized framework, even if a node operator decides to cease operations or censor transactions, numerous other independent node operators step in, ensuring the system's resilience and continuous functionality. This approach not only enhances security but also upholds the fundamental principles of transparency, immutability, and trustlessness that blockchain technology aims to maintain.
Consensus
Before we start implementing our p2p network, there is one scenario mentioned before that we must deal with, which is what happens if many nodes start entering the network with malicious intent. They could start flooding the network with valid blocks with no transactions inside them, impeding users from getting across their transactions since their blocks with the transaction have a lower chance of being added to the blockchain. Even inside that scenario, there is another problem how do we know which blocks we should add to our blockchain, if several hundred blocks come in every second how should the network decide which one to add? This kind of attack does not even require much resources since a single computer can run several nodes at the same time, what is known as a Sybil attack.
Solving this problem is one focal point of research for blockchains, resulting in several approaches being suggested with the two most widely adopted ones being Proof of Work(PoW) and Proof of Stake(PoS).
The common purpose of these approaches is to introduce some cost to participate in the process selecting of the next block in the blockchain. In PoW, this is achieved by requiring that a valid block must be "mined" by repeatedly computing the hash of the block until that hash has a certain property for example it starts with "0x0000". Therefore only nodes with sufficient computing power can propose valid blocks, requiring attackers to have very powerful computational resources to successfully mine blocks and influence the network's consensus.
PoS, takes a different approach by introducing a monetary cost for participation. Instead of relying on computational power, PoS requires participants to lock up a certain amount of cryptocurrency as collateral, known as a stake. Only those who hold and commit a significant stake are eligible to propose and validate new blocks. The security mechanism here is based on the idea that participants with a financial stake in the network are less likely to engage in malicious activities, as their assets are at risk of being slashed in case of dishonest behavior.
While these approaches are the most commonly used, they are not perfect. PoW requires extensive computing to find the valid hash which translates into high energy consumption by node operators. PoS, on the other hand, requires high monetary investment to participate which may limit the amount of people that can participate in the network. To mitigate these issues other approaches like proof of authority have been proposed, this one makes it so that certain authorized users can participate in the consensus, although this approach is not widely used since it leans back to the problems of centralization.
Implementing PoW
In our blockchain, we'll be implementing Proof of Work since it will be the simplest and most intuitive one to implement.
We'll start by adding a method to our block class that will mine the block searching for a certain hash that starts with some digits dictated by the protocol, for example, we only accept blocks whose hash begins with "0x0000".
We'll add a nonce field to our block so that we have a number that we can change, and then we need to change our hash function so that it also takes into account the nonce of the block. With this whenever we change the nonce field on the block we'll have a new hash letting us iterate through this number searching for a valid hash.
Now we create a new method called mineBlock
will take as an argument the digits that the protocol requires at the beginning of the hash. We'll create a loop where we calculate the hash of the block and check if it starts with digits dictated by the protocol. If so then we break the loop and set the nonce of the block, if not we increment the nonce and try again until we find it.
...
export class Block{
height: number
data: Transaction[]
prevBlockHash: string
nonce: number
hash: string
constructor(height: number, data: Transaction[], prevBlockHash: string, nonce: number, hash: string){
this.height = height
this.data = data
this.prevBlockHash = prevBlockHash
this.nonce = nonce
this.hash = hash
}
static initialize(height: number, data: Transaction[], prevBlockHash: string){
const newBlock = new Block(height,data, prevBlockHash,0, "")
newBlock.hash = newBlock.calculateHash()
return newBlock
}
calculateHash(){
return sha256(this.height + this.data.toString() + this.prevBlockHash + this.nonce)
}
mineBlock(blockDifficulty: string){
while(true){
const hash = this.calculateHash()
if(hash.slice(0, blockDifficulty.length) === blockDifficulty){
this.hash = hash
break
}
this.nonce += 1
}
}
...
Now we create a test to check that it works, we simply create a block, set the difficulty and call the mineBlock method, then check that the hash of the block leading digits do match the difficulty we set.
test("Mined block should have hash first characters equal to difficulty",() =>{
const block = Block.initialize(0,[], "prevHash")
const difficulty = "0000"
block.mineBlock(difficulty)
expect(block.hash.slice(0, difficulty.length)).toBe(difficulty)
})
You can also try to increase the number of digits in the difficulty field, you'll observe that as you increase the number of digits it takes longer and longer to find a hash that fits the criteria. This is a way that we can increase the requirements to participate in the network as we increase the difficulty more computing power is necessary to find the hash in a useful amount of time.
Next up let's include the requirement of PoW in the blockchain. We'll add a new field called blockDifficulty
that will define the leading digits that the hash of blocks in our blockchain must possess. We'll need to modify the addBlock
method so that it checks that the block hash matches what's required by the difficulty. We'll also change the validateChain
method, checking that the block hash matches the difficulty.
...
export class Blockchain{
blocks: Block[]
accounts: Map<string, AccountDetails>
blockDifficulty: string
constructor( blocks: Block[], accounts:
Map<string, AccountDetails>,blockDifficulty: string){
this.blocks = blocks
this.accounts = accounts
this.blockDifficulty = blockDifficulty
}
static initialize(blockDifficulty: string, initialBalances?:Map<string, AccountDetails>){
const genesisBlock = Block.initialize(0, [], "")
if (initialBalances){
return new Blockchain( [genesisBlock],initialBalances, blockDifficulty)
}else{
return new Blockchain( [genesisBlock],new Map<string, AccountDetails>(), blockDifficulty)
}
}
addBlock(block: Block){
block.verify()
const height = this.blocks.length
const prevBlockHash = this.blocks[height - 1].hash
if (block.height !== height){
throw new Error(`Blockhain current height is ${height - 1},
provided Block height is ${block.height}`)
}
if(block.prevBlockHash !== prevBlockHash){
throw new Error(`Last block hash is ${prevBlockHash},
provided Block height is ${block.height}`)
}
if(block.hash.slice(0, this.blockDifficulty.length) !==
this.blockDifficulty){
throw new Error(`Invalid Proof of Work, block hash leading bytes
must be ${this.blockDifficulty}, hash provided ${block.hash}`)
}
...
validateChain(){
for(let i = 1; i < this.blocks.length; i++){
const block = this.blocks[i]
const prevBlockHash = this.blocks[i-1].hash
if(block.height !== i){
throw new Error(`Block ${i} height should be ${i},
found ${block.height}`)
}
if(block.prevBlockHash !== prevBlockHash){
throw new Error(`Block ${i} prevBlockHash should
be ${prevBlockHash}, found${block.prevBlockHash}`)
}
if(block.hash.slice(0, this.blockDifficulty.length) !==
this.blockDifficulty){
throw new Error(`Invalid Proof of Work, block hash leading bytes
must be ${this.blockDifficulty}, hash provided ${block.hash}`)
}
block.verify()
}
}
...
As usual, we'll create a test to verify that everything is working properly. We'll begin by defining a difficulty and creating a new blockchain with that difficulty. Then we'll create a block and try to add it to our blockchain. Since the hash does not match the difficulty it throws an error (although in this case there was a 1/16^4 chance that the block hash would match the difficulty without mining it since the difficulty set has 4 hexadecimal digits). Then we'll mine the block and try to add it to the blockchain again, and then validate the chain. Now no errors are thrown and our test passes.
test('Blockchain should not allow block incorreclty mined',() =>{
const difficulty = "ffff"
const blockchain = Blockchain.initialize(difficulty)
const block = Block.initialize(1,[],blockchain.blocks[0].hash)
expect(() => blockchain.addBlock(block)).toThrowError()
block.mineBlock(difficulty)
blockchain.addBlock(block)
blockchain.validateChain()
})
This test also serves to demonstrate another important point. To produce the block with the valid hash, we needed to iterate the nonce many times and therefore also compute the hash as many times. However, to verify that the block was valid we only calculated the hash one time. This means that producing blocks and participating in the network requires a lot of computing power, however just verifying the validity of the blockchain is much easier and takes very little computing power so the majority of people can perform this action and be assured of the integrity of the blockchain that holds their funds.
P2P Network
Now that we've implemented proof of work in our blockchain and have a way to stop our blockchain from being flooded with blocks, it's time to build our p2p network.
Setup
To create a p2p network we'll be using a package called libp2p. This is a modular system of protocols, specifications and libraries that enable the development of p2p network applications. It was developed as a part of the IPFS project, which is used to store files in a decentralized network. If you've heard of Non Fungible Tokens (NFTs), IPFS is a service that's normally used to store the NFT metadata such as the image.
Install the following packages that we'll use to create our p2p network.
npm i libp2p @libp2p/tcp @libp2p/mplex @libp2p/mdns @libp2p/floodsub @chainsafe/libp2p-yamux @chainsafe/libp2p-noise dotenv
We won't be diving too deep into the details of how this library works and all of the things you can do with it, but if you're curious about learning more you can read their documentation here and find examples of what you can do with this library here.
Implementation
So let's begin building it, to start we'll create a new file that we'll name server.ts
, and we'll create a function where we'll define the settings for how nodes operate in the network.
Libp2p works in a modular fashion, allowing you to selectively combine various components to construct a functional node within the network.
First up we need to specify the address that will listen to communication, libp2p uses a convention called multiaddress for encoding multiple layers of information, so using /ip4/0.0.0.0/tcp/0
is configured to listen on all available network interfaces on the IPv4 protocol while allowing the system to select an available TCP port.
When you communicate over the internet the exchange of data occurs in the form of bits. Transports determine how data is physically sent and received over the network. In our case, we're using the Transmission Control Protocol(TCP) which is widely used on the internet.
Imagine setting up a node and the communication having only one connection meaning that you could only connect to one peer. This would inevitably lead to scalability issues. To overcome this we employ stream multiplexing. Multiplexing allows for the creation of multiple “virtual” connections within a single connection. This enables nodes to send multiple streams of messages to several peers over separate virtual connections.
Security is essential in internet traffic, as sensitive data transmission demands encryption. Noise Protocol Framework is used to encrypt data between nodes and provide forward secrecy. It establishes a secure channel by exchanging keys and encrypting traffic during the libp2p handshake process, ensuring the confidentiality and integrity of data. This video has a great explanation of how this process works.
We also need a way to discover and connect to new peers in the network. Multicast Domain Name System (mDNS), is a way for nodes to use IP multicast to publish and receive DNS records within a local network. mDNS is commonly used on home networks to allow devices such as computers, printers, and smart TVs to discover each other and connect.
Lastly, we set up our services which are higher-level protocols or features that our node can provide. First, identify
allows our node to identify itself to other peers on the network, sharing information such as supported protocols, peer ID, and public keys. Then we have floodsub
which is a Publish/Subscribe (pubsub) system where peers congregate around topics they are interested in and Peers can send messages to topics. Each message gets delivered to all peers subscribed to the topic.
In summary, this node listens on available interfaces and ports, uses TCP for reliable data transmission, encrypts connections with the noise protocol, discovers peers on the local network with mDNS, and provides services for both pubsub messaging and peer identification.
import { Blockchain } from "./blockchain.js";
import { Block } from "./block.js";
import { Wallet } from "./wallet.js";
import { Transaction } from "./transaction.js";
import { createLibp2p } from 'libp2p'
import { noise } from '@chainsafe/libp2p-noise'
import { yamux } from '@chainsafe/libp2p-yamux'
import { floodsub } from '@libp2p/floodsub'
import { mdns } from '@libp2p/mdns'
import { tcp } from '@libp2p/tcp'
import { identifyService } from 'libp2p/identify'
const createNode = async () => {
const node = await createLibp2p({
addresses: {
listen: ['/ip4/0.0.0.0/tcp/0']
},
transports: [tcp()],
streamMuxers: [yamux()],
connectionEncryption: [noise()],
peerDiscovery: [
mdns({
interval: 20e3
})
],
services: {
pubsub: floodsub(),
identify: identifyService()
}
})
return node
}
Now that we have our node configured let's program how our server will behave by defining how we'll handle connections, how to transmit blocks and share the blockchain.
As mentioned above our node will feature pubsub, in which we'll subscribe to topics to listen to in communications that occur in our network with those topics. We'll create two different topics, one named block, to listen to new blocks created and another called chain that will be used to transmit the entire blockchain over the network so that new users entering the network can have a copy of the blockchain, and verify its validity in their computer.
Then we'll create our Server class, we'll have two elements the first being our local copy of the blockchain, and the second array of Wallets. This second field is there so that we can easily experiment with sending tokens between users on our network, it is not essential for the protocol.
Now let's create a method that will start up the server, and define its behavior. We'll begin by using our earlier function to create a node with the discussed specifications. Then we'll set up an event listener so that we log in to the console every time we detect a new peer in the network. We'll also subscribe to the aforementioned topics.
const TOPICS = {
block: "BLOCK",
chain: "CHAIN"
}
export class Server{
blockchain: Blockchain
wallets: Wallet[]
private constructor(blockchain: Blockchain, wallets: Wallet[]){
this.blockchain = blockchain
this.wallets = wallets
}
static initialize(blockchain: Blockchain, wallets: Wallet[]){
return new Server(blockchain, wallets)
}
async start(){
const node = await createNode()
node.addEventListener('peer:discovery', (evt) => console.log("\x1b[34m%s\x1b[0m",'Discovered:', evt.detail.id.toString()))
node.services.pubsub.subscribe(TOPICS.block)
node.services.pubsub.subscribe(TOPICS.chain)
....
Before we define more behavior, let's first define what kind of actions a user running the server will be able to make.
To start, they need to be able to create blocks and share them over the network, they should also be able to include transactions inside the blocks. So let's create a method called addAndTransmitBlock
that will help us with this action.
For this method we must first define how to build our transaction, to facilitate the process we'll be using the wallets
element we defined in our server class. As a transaction is sending tokens from one account to another, we can define the from and to fields from the array of wallets by defining their position in the array. For the amount and nonce of the transaction we also just need an input of a number. For example an array with [0, 1, 22, 0]
would represent sending tokens from the wallet with index 0 to the wallet with index 1, sending 22 tokens with transaction nonce 0.
So if we have all these elements we can build a transaction, if don't have them then we build a block without transactions.
Building a block is something we've done many times now, we just take the height and hash from the last block on the blockchain and use that information to build a new block.
Now there's one last detail we need to deal with, as mentioned above communication over the internet occurs simply as sending bytes, if we try to pass our block object to the communication layer of our node it won't know how to transmit that. Therefore we need to transform it. Javascript offers a method that converts an object into a JSON string called JSON.stringify()
. Since a string is essentially just an array of bytes, we can then easily convert it to a Buffer
object that is used to represent a fixed-length sequence of bytes, which we can use to transmit over the network.
addAndTransmitBlock(data: string[]){
let tx: Transaction | undefined = undefined
if(data[1] && data[2] && data[3]&& data[4]){
try {
tx = Transaction.initialize(this.wallets[Number(data[1])].pubKey, this.wallets[data[2]].pubKey, Number(data[3]), Number(data[4]))
tx.sign(this.wallets[Number(data[1])])
} catch (error) {
console.log(error)
}
}
let block: Block
const height = this.blockchain.blocks.length
const prevHash = this.blockchain.blocks[height-1].hash
if(tx){
block = Block.initialize(height,[tx],prevHash)
}else{
block = Block.initialize(height,[],prevHash)
}
block.mineBlock(this.blockchain.blockDifficulty)
try {
this.blockchain.addBlock(block)
return Buffer.from(JSON.stringify(block))
} catch (error) {
console.log(error)
}
}
The next action a user needs to be able to perform is sending their local copy of the blockchain over the network. This is needed to share the current blockchain with new users who joined the network or to compare versions with other users to determine which version the network should follow.
This process is more straightforward than sending a block since we'll only be sending an already existing element object, instead of creating a new one. However same as before if we just try to send the blockchain object we'll run into errors, therefore we also need to convert it into a Buffer
. We'll use the same method as before by using JSON.stringify()
but we'll have an issue if we do nothing else. This will come from the fact that this method doesn't deal well with maps, which we use in our blockchain to keep track of account balances and nonces. Since problems like these occur JSON.stringify()
takes in an optional argument which is a function that dictates how the object will be converted into a string.
export function replacer(key: any ,value: any) {
if(value instanceof Map) {
return {
dataType: 'Map',
value: Array.from(value.entries()),
};
} else {
return value;
}
}
This function will make it so that if the element of the object is a map, then we'll save in the string its data type and convert it into an array.
The final action we'll create for the users is the ability to print their local copy of the blockchain on the console. Which is nothing more than a console.log
.
Now we need a way for a user running the server to be able to perform these actions. To achieve this we'll be using the process.stdin
which is an inbuilt application programming interface of the process module which listens for the user input. So using this we can read what the user types into the terminal and run the actions depending on what was written. So given this we'll define the following:
If user types
b
into the console, then a block without transactions will be added to the blockchain and transmitted on the P2P network.If the user types
b
followed by 4 numbers, representing respectively the account, from, account to, value, and nonce, as described previously, this information will be used to construct a transaction that will be included in a block, we'll then add the block to the blockchain and transmit the block on the P2P network.If the user types
c
then the local copy of the blockchain will be transmitted, through the P2P network.If the user types
p
then the local copy of the blockchain will be printed onto the console.
The implementation of these actions results in the following:
process.stdin.on('data', async data => {
let stringData = data.toString()
if(stringData.slice(0,1) === 'b'){
console.log('\x1b[36m%s\x1b[0m',"Adding block to chain and transmitting");
let array = stringData.split(" ")
console.log('\x1b[36m%s\x1b[0m','Sent Block on P2P network',
await node.services.pubsub.publish(TOPICS.block, this.addAndTransmitBlock(array)))
} else if(stringData.slice(0,1) === 'c'){
console.log('\x1b[32m%s\x1b[0m',"Transamitting chain over p2p network")
console.log('\x1b[32m%s\x1b[0m',"Sent Local Blockchain on P2P network",
await node.services.pubsub.publish(TOPICS.chain, Buffer.from(JSON.stringify(this.blockchain,replacer))))
} else if(stringData.slice(0,1) === 'p'){
console.log('\x1b[33m%s\x1b[0m',"Printing Local Blockchain \n",this.blockchain)
} else{
console.log(`Invalid command you've typed ${data.toString()}`);
}
});
To send the data over the network we simply send our Buffer
using the publish
method given by libp2p under the proper topic, so for transmitting blocks with publish under the topic BLOCK
, and for transmitting the blockchain we publish under the topic CHAIN
.
As a side note, the strings at the beginning of the console.log
, such as '\x1b[36m%s\x1b[0m'
, are used to print the following string in a different color, helping us better visualize what actions were taken.
We now can send data through the P2P network, but we have yet to implement a way to handle data when we receive it. So we'll take care of that next. There are two topics that we have subscribed to, BLOCK
and CHAIN
, so we need to handle what to do in each of those cases.
Let's start with BLOCK
, when we receive a message with this topic it should be a block that we can add to our blockchain. So upon receiving a message with this topic, we'll need to verify that the message does indeed contain a valid block, in which case we'll try to add it to our blockchain.
Since the data we sent out was in the form of a Buffer
it is only natural that when receiving the data it will also be as a Buffer
, therefore the first thing we must do is convert it into a string, which we easily do with the .toString()
method. As you recall we used the JSON.stringify()
method to turn our original block into a string, and javascript offers a method to reverse that process JSON.parse()
which parses a JSON string, constructing the JavaScript value or object described by the string.
However, this leaves us with a problem, although JSON.parse()
returns our object with the correct elements, it does not include the methods we made in the class such as verify()
. So after we parse we need to use the class constructor to build a new Block using the elements given by the JSON.parse()
, this same process must also be done for all transactions inside the block.
After this we have our block with all the methods we created, so we'll run the verify()
method to validate the block and if everything is ok we return it.
export function parseBlock(msg: string): Block{
try {
const parsedObject = JSON.parse(msg)
const block: Block = new Block(parsedObject.height,parsedObject.data,parsedObject.prevBlockHash, parsedObject.nonce, parsedObject.hash)
for (let i = 0; i< block.data.length; i++){
let tx = block.data[i]
block.data[i] = new Transaction(tx.from, tx.to, tx.amount, tx.nonce,tx.hash, tx.signature)
}
block.verify()
console.log("block received",block)
return block
} catch (error) {
throw new Error(error)
}
}
That concludes the setup we needed for the BLOCK
topic so now let's move on to messages on the CHAIN
topic. As with BLOCK
the message we receive will be in the Buffer
format so the first thing we must do is convert it to a string. Since we also passed the blockchain through JSON.stringify()
, we should also be able to use the JSON.parse()
method to return it to an object. However, as mentioned before passing a map to JSON.stringify()
, could lead to problems so we had to use a function to specify how the conversion should be made. Now we need to use a second function to specify how to reverse that process.
export function reviver(key: any, value: any) {
if(typeof value === 'object' && value !== null) {
if (value.dataType === 'Map') {
return new Map(value.value);
}
}
return value;
}
Now we have all the pieces we need to handle incoming messages. To receive messages we'll use the addEventListener
method of libp2p, and handle the message depending on the topic.
For the BLOCK
topic, we'll use the parseBlock
function we just created, and try to add it to the blockchain. In case of any error, we'll print it out on the console, so we know what went wrong.
For the CHAIN
topic, we'll be using JSON.parse()
with the reviver
function we just created, to get an object back. We'll then use the parseBlock
function on each of the blocks of the blockchain to recover the methods necessary for verification. Then just as with the blocks and transactions, we'll be using the blockchain constructor so that we have access to the blockchain methods we built, and then we'll run validateChain()
to ensure that everything is valid. Lastly, we need to decide if this blockchain we just received should replace our local version. The factor that we'll use to make the decision will be the length of the chain, so if the blockchain we just received has more blocks than our local version we'll replace it, otherwise we'll keep our local version. This method of selection comes from the fact that the longest blockchain is the one that more "work" has been put into, as more blocks have been mined therefore more hashes have been done in the longest chain and that means that the majority of the computing power in the network supports the longest chain, therefore that's the version we'll follow.
Here is the full version of the start
method in our server that will define its behavior:
async start(){
const node = await createNode()
node.addEventListener('peer:discovery', (evt) => console.log('Discovered:', evt.detail.id.toString()))
node.services.pubsub.subscribe(TOPICS.block)
node.services.pubsub.subscribe(TOPICS.chain)
node.services.pubsub.addEventListener("message", (evt) => {
console.log(`node received: ${Buffer.from(evt.detail.data).toString()} on topic ${evt.detail.topic}`)
if(evt.detail.topic === TOPICS.block){
const block: Block = parseBlock(Buffer.from(evt.detail.data).toString())
try {
this.blockchain.addBlock(block)
console.log("\x1b[34m%s\x1b[0m","Block from P2P network added")
} catch (error) {
console.log("\x1b[31m%s\x1b[0m","Failed to add block", error)
}
}
if(evt.detail.topic === TOPICS.chain){
try {
const chain = JSON.parse(Buffer.from(evt.detail.data).toString(),reviver)
let blocks: Block[] = []
for(let i = 0; i < chain.blocks.length; i++){
blocks.push(parseBlock(JSON.stringify(chain.blocks[i])))
}
const blockchain = new Blockchain(blocks, chain.accounts, chain.blockDifficulty)
blockchain.validateChain()
if(blockchain.blocks.length > this.blockchain.blocks.length){
this.blockchain = blockchain
console.log("\x1b[32m%s\x1b[0m","Blockchain from P2P network replaced local blockchain")
}
} catch (error) {
console.log(error)
}
}
})
process.stdin.on('data', async data => {
let stringData = data.toString()
if(stringData.slice(0,1) === 'b'){
console.log('\x1b[36m%s\x1b[0m',"Adding block to chain and transmitting");
let array = stringData.split(" ")
console.log('\x1b[36m%s\x1b[0m','Sent Block on P2P network',
await node.services.pubsub.publish(TOPICS.block, this.addAndTransmitBlock(array)))
} else if(stringData.slice(0,1) === 'c'){
console.log('\x1b[32m%s\x1b[0m',"Transamitting chain over p2p network")
console.log('\x1b[32m%s\x1b[0m',"Sent Local Blockchain on P2P network",
await node.services.pubsub.publish(TOPICS.chain, Buffer.from(JSON.stringify(this.blockchain,replacer))))
} else if(stringData.slice(0,1) === 'p'){
console.log('\x1b[33m%s\x1b[0m',"Printing Local Blockchain \n",this.blockchain)
} else{
console.log(`Invalid command you've typed ${data.toString()}`);
}
});
}
Running the Blockchain
Now that we've completed our server module we can get it up and running, and see how our blockchain behaves in a P2P network.
Before we start it up, remember that our server has an element which is an array of wallets. These wallets will be the ones that we'll be using to test transferring funds, therefore every local copy of the blockchain in the nodes must agree that these wallets have enough funds. So we need a way for every node on startup to get the information of what wallet address has funds. We could paste some wallet's private keys in our index.ts
file, but this is bad practice since these keys could easily be leaked when for example uploading the code to your Git Hub. So what we'll do is create a file named .env
, and set our private keys there. Then using a package called dotenv we can bring these private keys into scope in our program. This .env
file should always be added to the .gitignore
file when working on a project. This practice ensures that sensitive information remains protected and doesn't leak into public repositories or expose security vulnerabilities.
Below is an example of what the .env
should look
PRIVATE_KEY_1 = "a4c0d6433db025db401b239765fbe44157a695e1d6cfafb4efd7367f05521b19"
PRIVATE_KEY_2 = "9538c3112ed97018dd664d140690bec175dcf451ac5ed07e5ca6c2996a63003e"
PRIVATE_KEY_3 = "9664fae8248e46f197b6e9345b3f462a280933f0f7b9cb2f46916d7864bbf3f1"
PRIVATE_KEY_4 = "467d59db1b0d64fb395ba48a387d7e435311b541a6eec119a3356e253098296a"
We're now ready to run our blockchain in a peer-to-peer network.
Let's rewrite our index.ts
file. The first thing we'll do is create wallets based on the private keys on the .env
file. Then we'll set those wallets to have some balance in our blockchain. This way every node that we start up will have the same initial state.
Next, we define the block difficulty and initialize our blockchain, lastly, we initialize the server with our blockchain pass the wallets we just created, and use the start method.
import { Blockchain, AccountDetails } from "./blockchain.js";
import { Wallet } from "./wallet.js";
import { Server } from "./server.js";
import 'dotenv/config'
async function main(){
const wallet1 = new Wallet(Buffer.from(process.env.PRIVATE_KEY_1,'hex'))
const wallet2 = new Wallet(Buffer.from(process.env.PRIVATE_KEY_2,'hex'))
const map = new Map<string,AccountDetails>()
map.set(wallet1.pubKey,{nonce:0, balance: 100})
map.set(wallet2.pubKey,{nonce:0, balance: 100})
const blockDifficulty = '0000'
const blockchain = Blockchain.initialize(blockDifficulty,map)
const server = Server.initialize(blockchain,[wallet1,wallet2])
server.start()
}
main()
Now insert npm run dev
on the terminal to start up a node, then open a second terminal and once again insert npm run dev
to start a second node. If everything is working you'll see displayed on both terminals that a peer has been discovered.
You now have two peers connected on the network running the same copy of a blockchain. So now let's try to add a block in one of them and verify that it gets transmitted, type in b
in the terminal and press enter, you should see that a block has been sent on the P2P network. On the other terminal, there will appear a message stating that a block has been received and added to the blockchain.
You can now type p
and press enter in both terminals and verify that the blockchains are exactly the same.
Now let's try to perform a token transfer. To perform this type in b 0 1 10 0
in the terminal, this will mine a block with a transfer from wallet1
to wallet2
with the amount of 10 tokens using the nonce 0. The block should then be transmitted on the network and you can once again insert p
on the terminal to check that the balances of each account have been updated on both nodes. With this, we have just made our first cryptographically verified token transfer on a decentralized blockchain.
Now let's test our last functionality of transmitting the blockchain, opening up a third terminal and type npm run dev
to start up a new node. This node will have a blockchain with only the genesis block and therefore be out of sync with the other two nodes. To solve this go back to the first terminal type c
and press enter. This will transmit the blockchain in the first node over the network, since the second node has the same blockchain as the first node nothing will change there. However, the new third node upon receiving the blockchain, will verify its validity and then compare the length with its local version. Since the version it just received is longer it will replace the local version with the one it just received. You can verify this by printing the blockchain on the third terminal. Now any block produced by any of the nodes will be sent over the network and added to their local version, keeping all blockchains run in sync.
Conclusion
We covered a lot in this post. We started by learning the basics of networks over the Internet and discussed the advantages and disadvantages of centralized vs. decentralized networks. Then learned the issues with reaching a consensus of state in a decentralized network and discussed possible solutions.
We implemented Proof of Work into our blockchain and created a p2p network enabling users to share the blocks they generate within the network. This collaborative effort ensures that multiple nodes remain synchronized with the latest state of the blockchain.
In the next post, we'll bring this series to a close by introducing the concepts of virtual machines and smart contracts within blockchain technology. We'll then finalize our blockchain by implementing these crucial elements.
The code for this post can be found here.
This series is comprised of the following posts: