Cryptocurrencies: An Introduction to Blockchains
In this part, we'll be adding a cryptocurrency to our blockchain. We'll:
Learn about digital signatures which are used in the vast majority of Internet communications.
Use digital signatures to authenticate users in our blockchain, and create new modules that will allow users to transfer tokens to each other.
Integrate those modules into the blockchain we built in the previous part, and introduce a cryptocurrency into the blockchain.
Run our improved blockchain and see its new features in action.
This series is comprised of the following posts:
Digital signatures
Digital signatures are used to verify the authenticity of messages in the digital world. They provide a means for the recipient of a message to verify that the message was indeed created by the claimed sender and that it wasn't altered during transmission.
To exemplify how this process works let's take two people, Alice and Bob. Alice wants to send a message to Bob while ensuring that upon Bob receiving the message can verify that the message was indeed created by Alice and that it wasn't tampered with along the way. We'll make this process by using only 3 functions:
GenerateKeys()
Firstly Alice uses randomness to create a Private Key that no one else knows and must always remain secret. From the Private Key, a Public Key is derived and sent to Bob. This public key serves as a form to identify Alice since only she knows the Private Key that generates that particular public key.
Sign(Message, Private Key)
Then Alice takes the message that she wants to send to Bob and uses her private key to sign the message producing a signature. Now the message and the signature are sent to Bob.
Verify(Message, Public Key, Signature)
Upon receiving the Message and the signature from Alice, Bob can take them and using Alice's public key he can verify that this message was indeed signed by Alice since it guarantees that her Private key was used to perform the signature.
Using this scheme we have a way for users to have an identity in the form of the public key and a way to prove their identity by the signing and verification of messages
There are many different signature schemes such as ElGammal, RSA, and Elliptic Curve Cryptography(ECC), that all implement this set of functions, and all have their advantage and disadvantages. Blockchains typically use ECC for signature verification so that's what we'll be using.
Setup
In this part, we'll be using a package that implements the digital signature operations we require using the elliptic curve secp256k1 which is the same curve used in the Bitcoin blockchain for signature verification. We'll also be using a second package named crypto
to generate random numbers to create private keys. Install them using the following commands:
npm i secp256k1 crypto
npm i --save-dev @types/secp256k1
Creating a Wallet
To implement digital signatures we'll create an object class that will hold the private and public keys of the user. These objects that store private keys are typically called wallets since they hold the ability to control funds inside blockchains.
Let's create a new file name wallet.ts
and code our new class. There are only two elements that we'll need which will be the private key and the associated public key. To generate the private key we'll be using a pseudorandom function from the crypto package, and for deriving the public key and performing the operations of signing and verification we'll use the secp256k1 package. We'll be setting our private key field as private since this is something that must never be revealed as a leak could potentially lead to a loss of the entire funds controlled by the wallet. Please note that in a real system, much more care would be put into securing the private key.
We'll also add a method to sign a message. We'll take as a parameter the message we wish to sign and use the private key to perform the action returning the signed message.
import { randomBytes } from "crypto"
import pkg from 'secp256k1';
const { privateKeyVerify, publicKeyCreate, ecdsaSign } = pkg;
export class Wallet{
pubKey: string
private readonly privKey: Buffer
constructor(privKey: Buffer){
this.privKey = privKey
this.pubKey = Buffer.from(publicKeyCreate(privKey)).toString('hex')
}
static initialize(){
let privKey: Buffer
do {
privKey = randomBytes(32)
} while (!privateKeyVerify(privKey))
return new Wallet(privKey)
}
sign(msg: Buffer){
return ecdsaSign(msg, this.privKey)
}
}
Adding Transactions
Now that we have our wallet we'll create a class that will represent the action of transferring tokens from one user to the other. There are several different ways that we could construct this, and we'll discuss two possible implementations, the first being Unspent Transaction Output (UTXO).
In this implementation, transactions are not represented as a balance of an account like in traditional banking systems. Instead, they are represented as a collection of individual coins or tokens called UTXOs. Each UTXO represents a specific amount of cryptocurrency that has not been spent in any previous transaction, they are associated with a specific transaction output and can only be spent as a whole. For example, if Alice has a UTXO with a value of 1 BTC and wants to send 0.5 BTC to Bob, the transaction will spend Alice's UTXO, making it invalid, and create two new UTXOs one of 0.5 BTC in Bob's ownership and a second of 0.5 BTC in Alice ownership.
The second method we'll discuss is the Account Model. In the account model, users on the blockchain are represented by accounts. Each account has a unique address, and these addresses are used to send and receive cryptocurrency and interact with smart contracts on the blockchain. Accounts in this model are similar to traditional bank accounts, where the account balance is maintained. When a user initiates a transaction in the account model, they specify the recipient's account address and the amount to send. Unlike the UTXO model, there is no need to create new UTXOs or specify the exact coins or tokens to spend. Instead, the blockchain system checks if the sender's account balance is sufficient to cover the transaction amount.
Each of these methods has advantages and drawbacks, the selection of which one to use comes down to low-level details that are out of scope from this series, anyone interested can read further about it here.
For our blockchain, we'll be using the account model since it is the most straightforward and intuitive implementation
Let's create a new file called transaction.ts
and define our class. In the account model when we wish to transfer funds to another account we'll need to specify who is the sender, the receiver and the amount of tokens to transfer. We'll also need a form of proper authorization from the user that is sending funds by having a signature made with their private key on the transaction. We'll use the hash of the transaction as the message to sign since it contains all the information about the transaction.
With this, we have all the elements necessary to perform the transfer of tokens from one user to another. There is however a fatal flaw in our current transaction model. Imagine the following, a transaction is created to transfer funds, it is correctly signed and the transfer is successful. But what if the receiver wants to take more funds, there is nothing to stop them from just using the same transaction, with the same signature, once again and taking more funds from the sender's wallet. This is known as a replay attack, where the same transaction is used more than once.
Fortunately, there is a simple solution to this problem, we introduce a new field called nonce. This field represents a number only used once in a cryptographic transaction. In account models besides keeping track of the balance of each user, we also keep track of the nonce field. For a transaction to be valid the nonce of the transaction must match the nonce of the user on the blockchain, and when the transaction is completed the nonce on the blockchain is incremented making it impossible for the same transaction to be used more than one time.
So the elements in our transaction object will be:
from
: the public key of the user sending the tokens.to
: the public key of the user receiving the tokens.amount
: the number of tokens being transferred.nonce
: the nonce of the transaction according to the record on the blockchain.hash
: the hash of the above elements of the transaction.signature
: the signature of the hash using the private key of the user sending the tokens.
With these elements let's now create methods to support our transaction class.
We'll first create a method for hashing the transaction, taking as input the from
, to
, amount
, and nonce
elements, and using once again the sha256 hash function.
Then we'll create a method to sign the transaction taking as argument the Wallet object we created in the above section. It'll verify that the public key matches the from
field of the transaction, and then use the secp256k1 package that was also used in the Wallet to perform the signature and sign the hash of the transaction.
Finally, we'll create a method that verifies the transaction. As this method is inside the Transaction class, we have no access to the balances or nonce of the users since this will be stored in the blockchain, therefore here we'll validate that the hash was correctly computed, that the signature is valid and was indeed signed by the user sending the tokens.
import { sha256 } from "js-sha256"
import { Wallet } from "./wallet.js"
import pkg from 'secp256k1';
const { ecdsaVerify } = pkg;
export class Transaction{
from: string
to: string
amount: number
nonce: number
hash: string
signature: string
constructor(from: string, to:string, amount:number, nonce: number,hash:string, signature: string){
this.from = from
this.to = to
this.amount = amount
this.nonce = nonce
this.hash = this.calculateHash()
this.signature = signature
}
static initialize(from: string, to:string, amount:number, nonce: number){
const newTx = new Transaction(from, to, amount, nonce, "" ,"")
newTx.hash = newTx.calculateHash()
return newTx
}
calculateHash(){
return sha256(this.from +
this.to + this.amount + this.nonce )
}
sign(signer: Wallet){
if(signer.pubKey !== this.from){
throw new Error(`Tx from: ${this.from} does not match signer:
${signer.pubKey}`)
}
const sigObj = signer.sign(Buffer.from(this.hash,'hex'))
this.signature = Buffer.from(sigObj.signature).toString('hex')
}
verify(){
const hash = this.calculateHash()
if(hash !== this.hash){
throw new Error(`hash does not match, expected:${this.hash},
found:${hash}`)
}
if(!ecdsaVerify(Buffer.from(this.signature,'hex'),
Buffer.from(hash,'hex'), Buffer.from(this.from,'hex'))){
throw new Error("Signature verification failed")
}
}
}
We'll now write tests for our new wallet and transaction modules. First, create a new file named transaction.test.ts
, and we'll make a test to ensure that everything is working as expected. We'll instantiate two wallets, and create a transaction that sends funds from one to the other. Then we'll sign the transaction and verify its validity. If everything is done correctly the fields will all match what we expect, and no errors will be thrown.
For the second test, we'll verify that only the wallet that is sending funds can sign the transaction. Once again we'll create two wallets and create a transaction object as we did before. However, now we'll attempt to sign in with the incorrect wallet and we'll verify that an error is being thrown.
Lastly, we'll test that a tampered transaction is considered invalid. We'll create 3 wallets create a transaction that transfers funds between the first two, and sign the transaction. Now we alter the transaction by changing the recipient of the tokens to the third wallet, and we'll attempt to verify the transaction. If everything is as we expect an error will be thrown and the test will pass.
import {describe, expect, test} from '@jest/globals';
import {Transaction} from "../src/transaction.js"
import {Wallet} from "../src/wallet.js";
describe("Transaction tests", () => {
test("Transaction should correclt initialize and valid signature should be validated", ()=>{
const wallet1 = Wallet.initialize()
const wallet2 = Wallet.initialize()
const tx = Transaction.initialize(wallet1.pubKey, wallet2.pubKey, 10, 0)
expect(tx.from).toBe(wallet1.pubKey)
expect(tx.to).toBe(wallet2.pubKey)
expect(tx.amount).toBe(10)
expect(tx.nonce).toBe(0)
expect(tx.hash).toBe(tx.calculateHash())
tx.sign(wallet1)
tx.verify()
})
test('Wallet with pubKey not equal to From should not be able to sign', ()=>{
const wallet1 = Wallet.initialize()
const wallet2 = Wallet.initialize()
const tx = Transaction.initialize(wallet1.pubKey, wallet2.pubKey, 10, 0)
expect(() =>tx.sign(wallet2)).toThrowError()
})
test('Tamapered transaction should not validate', ()=>{
const wallet1 = Wallet.initialize()
const wallet2 = Wallet.initialize()
const wallet3 = Wallet.initialize()
const tx = Transaction.initialize(wallet1.pubKey, wallet2.pubKey, 10, 0)
tx.sign(wallet1)
tx.to = wallet3.pubKey
expect(() => tx.verify()).toThrow(Error)
})
})
This once again shows the strength of digital signatures, any attempt to tamper with the transaction after being signed will immediately result in an invalid transaction as the signature will not match what's set on the fields.
We'll now modify our block.ts
file that we made in the previous post to include the transactions. We'll modify the data field so that it now takes an array of transactions, and the verification method will also verify all the transactions included in the block.
import {sha256} from "js-sha256"
import { Transaction } from "./transaction.js"
export class Block{
height: number
data: Transaction[]
prevBlockHash: string
hash: string
constructor(height: number, data: Transaction[], prevBlockHash: string, hash: string){
this.height = height
this.data = data
this.prevBlockHash = prevBlockHash
this.hash = hash
}
static initialize(height: number, data: Transaction[], prevBlockHash: string){
const newBlock = new Block(height,data, prevBlockHash, "")
newBlock.hash = newBlock.calculateHash()
return newBlock
}
calculateHash(){
return sha256(this.height + this.data.toString() + this.prevBlockHash)
}
verify(){
for(let i= 0; i < this.data.length; i++){
this.data[i].verify()
}
const hash = this.calculateHash()
if(hash !== this.hash){
throw new Error(`Block ${this.height} hash should be ${hash},
found ${this.hash}`)
}
}
}
With this change, we'll need to update our block tests. Since the data field is now an array of transactions instead of a string we need to replace it in all places that it's used. This is a simple replacement you can just replace the string with an empty array []
. To confirm that the update to the block class is correct let's modify the first test by adding a transaction to the block and we'll use the verify
method, confirming that it works as intended.
test("Block should initialize and pass verification", () => {
const wallet1 = Wallet.initialize()
const wallet2 = Wallet.initialize()
const tx = Transaction.initialize(wallet1.pubKey, wallet2.pubKey, 10, 0)
tx.sign(wallet1)
const block = Block.initialize(0,[tx], "prevHash")
expect(block.height).toBe(0)
expect(block.data).toStrictEqual([tx])
expect(block.prevBlockHash).toBe("prevHash")
expect(block.hash).toBe(block.calculateHash())
block.verify()
});
Adding Cryptocurrency to the Blockchain
Now with all these new elements in place, we can add a cryptocurrency to our blockchain. Let's open the blockchain.ts
file and we'll first create a new type called AccountDetails
to indicate the state of each account. As discussed for each account we'll need to keep track of not only the balance but also a nonce to prevent replay attacks.
Now we add a new element to our blockchain class that will keep track of the users, this will be a Map that will take as a key the public key of the user and the value will be the new AccountDetails
type.
We'll modify our initialize
method to take as an argument an optional Map account so that we can begin our blockchain with some users having balance so that we can experiment with transferring tokens.
Then we'll modify our addBlock
method. We'll need to update the checks of a block to verify the validity of the transfer of funds. Since we already added to our block verify
method the validation for the transaction signature, here in our blockchain what we need to check for is that the users have enough balance for the transfer they're performing, and also if the nonce of the transaction matches the nonce recorded. Having both of these checks passed we then need to update the user's AccountDetails, we'll decrease the balance of the user that sent the funds, increment their nonce and increase the funds of the user that received the funds. We don't increment the nonce of the user that received the funds since they did not sign any transaction.
import { Block } from "./block.js";
export type AccountDetails = {
balance: number
nonce: number
}
export class Blockchain{
blocks: Block[]
accounts: Map<string, AccountDetails>
constructor(blocks: Block[], accounts:
Map<string, AccountDetails>){
this.blocks = blocks
this.accounts = accounts
}
static initialize(initialBalances?:Map<string, AccountDetails>){
const genesisBlock = Block.initialize(0, [], "")
if (initialBalances){
return new Blockchain( [genesisBlock],initialBalances)
}else{
return new Blockchain( [genesisBlock],new Map<string, AccountDetails>())
}
}
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}`)
}
for(let i = 0; i< block.data.length; i++){
const tx = block.data[i]
const accountFrom = this.accounts.get(tx.from)
const accountTo = this.accounts.get(tx.to)
if(!accountFrom){
throw new Error(`Account from: ${tx.from}
does not have any balance`)
}else if (accountFrom.balance < tx.amount ){
throw new Error(`Account from: ${tx.from}
does not enough balance, current balance:
${accountFrom.balance}, transfer amount: ${tx.amount}`)
}else if(accountFrom.nonce !== tx.nonce){
throw new Error(`Invalid nonce, account current nonce:
${accountFrom.nonce}, tx nonce: ${tx.nonce}`)
}
accountFrom.balance -= tx.amount
accountFrom.nonce += 1
this.accounts.set(tx.from, accountFrom)
if(accountTo){
accountTo.balance += tx.amount
this.accounts.set(tx.to, accountTo)
}else{
this.accounts.set(tx.to,{nonce: 0, balance: tx.amount})
}
}
this.blocks.push(block)
}
....
It is important to notice that the order by which the transactions are ordered is extremely important. Imagine the following scenario, a user, Alice wants to send 10 tokens to Bob, which currently holds 5 tokens. Now at the same time, Bob also wants to send 10 tokens to a third user called Carol. So if these transactions take place in the order as they were described, Alice sends tokens to Bob and then Bob sends tokens to Carol everything works out. But if the transactions are ordered differently, Bob sends to Carol and Alice sends to Bob, then the first transaction will be considered invalid as Bob does not have enough balance to send 10 tokens to Charlie. This act of ordering transactions is part of a field called Maximal Extractable Value (MEV) and is an enormous active field of discussion and research in blockchains. More about this can be read here.
Now let's update our blockchain tests to include transactions. On our first test, we'll create three wallets and set the account balance of the first to 100 tokens so that we can experiment with transfers. Then we'll create a transaction transferring funds from the first account to the second account without tokens. We'll also create another transaction from the second to the third account, and create a block that includes both of those transactions. By setting them in the correct order we can make it as discussed previously and have the tokens from the first account transferred to the second account and then have the newly received tokens in the second account transferred to the third account. Then we'll add the block to the chain and validate ensuring that is working as expected.
We'll also add a test to ensure that a transfer when a user has insufficient balance or the nonce is incorrect then the transaction is considered invalid and an error is thrown. We'll have the same setup as before, but now we'll create a transaction sending funds from an empty account, and create a second transaction sending funds from an account with sufficient balance but with the incorrect nonce set on the transaction. We'll create two blocks for these transactions and try to add the blocks to the blockchain. Both attempts of adding blocks to the blockchain should throw an error.
test('Blochain should add valid block, with valid transaction',() =>{
const wallet1 = Wallet.initialize()
const wallet2 = Wallet.initialize()
const wallet3 = Wallet.initialize()
const balances = new Map<string,AccountDetails>()
balances.set(wallet1.pubKey, {balance: 100, nonce: 0})
const blockchain = Blockchain.initialize(balances)
const tx = Transaction.initialize(wallet1.pubKey, wallet2.pubKey, 10, 0)
tx.sign(wallet1)
const tx2 = Transaction.initialize(wallet2.pubKey, wallet3.pubKey, 5, 0)
tx2.sign(wallet2)
const block = Block.initialize(1,[tx,tx2],blockchain.blocks[0].hash)
blockchain.addBlock(block)
expect(blockchain.accounts.get(wallet1.pubKey)).toStrictEqual({balance:90, nonce: 1})
expect(blockchain.accounts.get(wallet2.pubKey)).toStrictEqual({balance:5, nonce: 1})
expect(blockchain.accounts.get(wallet3.pubKey)).toStrictEqual({balance:5, nonce: 0})
blockchain.validateChain()
})
test('Blochain should not add block with transaction with insufficient balance or incorrect nonce',() =>{
const wallet1 = Wallet.initialize()
const wallet2 = Wallet.initialize()
const balances = new Map<string,AccountDetails>()
balances.set(wallet1.pubKey, {balance: 100, nonce: 0})
const blockchain = Blockchain.initialize(balances)
const txInvalidBalance = Transaction.initialize(wallet2.pubKey, wallet1.pubKey, 10, 0)
txInvalidBalance.sign(wallet2)
const blockInvalidBalance = Block.initialize(1,[txInvalidBalance],blockchain.blocks[0].hash)
const txInvalidNonce = Transaction.initialize(wallet1.pubKey, wallet2.pubKey, 10, 1)
txInvalidNonce.sign(wallet1)
const blockInvalidNonce = Block.initialize(1,[txInvalidNonce],blockchain.blocks[0].hash)
expect(() => blockchain.addBlock(blockInvalidBalance)).toThrowError()
expect(() => blockchain.addBlock(blockInvalidNonce)).toThrowError()
})
Running the Blockchain
Now that we know that our wallets and transactions work correctly and that we've properly integrated them let's run our improved blockchain. We'll modify our index.ts
file to generate new wallets and create a new Map where the balance of these accounts is 100. Then we'll instantiate our blockchain by passing this Map.
Just as we did in the previous part we'll create a couple of blocks but now we'll add a transaction where we transfer 20 tokens between the accounts. Then we'll run a verification on blockchain and print on our console the updated balances of the accounts. If everything works correctly our blockchain will be valid, the balances of the accounts will reflect the 20 tokens transfer, and the nonce of the account that made the transfer will increase.
To finish up, let's try to perform a replay attack. We'll take the transaction we made earlier and try to insert a new block on the blockchain that contains that same transaction. We'll see printed out on the console that the block could not be added to the blockchain since the nonce of the transaction was different from the nonce recorded on the account.
import { Block } from "./block";
import { Blockchain, AccountDetails } from "./blockchain";
import { Transaction } from "./transaction";
import { Wallet } from "./wallet";
function main(){
const wallet1 = new Wallet()
const wallet2 = new Wallet()
const balances = new Map<Buffer, AccountDetails>()
balances.set(wallet1.pubKey, {balance:100,nonce:0})
balances.set(wallet2.pubKey, {balance:100,nonce:0})
const blockchain = new Blockchain(balances)
console.log(blockchain.accounts.get(wallet1.pubKey))
console.log(blockchain.accounts.get(wallet2.pubKey))
const block1 = new Block(1, [], blockchain.blocks[0].hash)
blockchain.addBlock(block1)
const tx = new Transaction(wallet1.pubKey,wallet2.pubKey, 20, 0)
tx.sign(wallet1)
const block2 = new Block(2, [tx], blockchain.blocks[1].hash)
blockchain.addBlock(block2)
blockchain.validateChain()
console.log("Blockchain is valid")
console.log(blockchain.accounts.get(wallet1.pubKey))
console.log(blockchain.accounts.get(wallet2.pubKey))
const block3 = new Block(3,[tx],blockchain.blocks[2].hash)
try {
blockchain.addBlock(block3)
} catch (error) {
if(error instanceof Error){
console.error("Cannot add block, cause:",error.message)
}
}
}
main()
Once again you're encouraged to try and make other invalid transactions, such as having insufficient balance for transfer or tampering with a transaction to see what kind of errors are created to solidify everything we learned.
Conclusion
In this post, we've learned about digital signatures and how they can be used to verify the identity of users. We saw how there are different ways that the transfer of tokens can be performed on a blockchain and implemented the Account Model. We then upgraded our blockchain to include a cryptocurrency and made possible the transfer of tokens between users.
In the next part of this series, we'll start by learning about decentralized networks, and how blockchains are used in such a setting, as a way to keep track of the balance of users without having any party that can remove or steal the user's funds.
The code in this post can be found here.
This series is comprised of the following posts: