Creating a Blockchain: Part 6 - Transaction Mempool and Tx encoding

Creating a Blockchain: Part 6 - Transaction Mempool and Tx encoding

Synchronization of data

We'd implemented blockchain data structure but we have to look after the situation that whenever data is going to be fetched ow written from blockchain, it should be on latest blochchain state.

To take care of that, we implemented mutex,

core/blockchain.go

package core

import (
    "fmt"
    "sync"

    "github.com/sirupsen/logrus"
)

type Blockchain struct {
    // other properties
    lock sync.RWMutex
}

func (bc *Blockchain) GetHeader(height uint32) (*Header, error){

    if height > bc.Height(){
        return nil, fmt.Errorf("given height %d is too high", height)
    }
    bc.lock.Lock()
    defer bc.lock.Unlock()

    return bc.headers[height], nil
}

func (bc *Blockchain) addBlockWithoutValidation(b *Block) error {
    bc.lock.Lock()
    defer bc.lock.Unlock()
    bc.headers = append(bc.headers,b.Header)

    logrus.WithFields(logrus.Fields{
        "height": b.Height, 
        "hash": b.Hash(BlockHasher{}),
    }).Info("adding new block")

    return bc.store.Put(b)
}
func (bc *Blockchain) Height() uint32 {
    bc.lock.RLock()
    defer bc.lock.RUnlock()
    return uint32(len(bc.headers) - 1)
}

// other methods of blockchain

we updated only necessary functions.


Modifications

Before, we go ahead, we did some changes in previous code to decrease overhead of small changes while implementing important and useful code.

Generally we found this changes while coding future parts, but to make sequence of code in blog more understandable, we modified the code first.

Make Key in Public Key accessible to other code

crypto/keypair.go

type PublicKey struct {
    Key *ecdsa.PublicKey
}

type Signature struct {
    R,S *big.Int
}
//also, updated the code which has used Public key and Signature 
//with Capital letters starting Key in Public Key and R , S in Signature.

RandomTransaction and RandomBlock

core/transaction_test.go

added assert.Nil to make sure tx.sign runs successfully.

func randomTxWithSignature(t *testing.T) *Transaction { 
    privKey := crypto.GeneratePrivateKey()

    tx := &Transaction{
        Data: []byte("tx data"),
    }
    assert.Nil(t, tx.Sign(privKey));
    return tx;
}

due to changes in randomTxWithSignature, few other code should be update which use randomTxWithSignature function in their code,

core/block_test.go

func randomBlock(height uint32, prevBlockHash types.Hash) *Block {
    header := &Header{
        Version: 1,
        PrevBlockHash: prevBlockHash,
        Timestamp: time.Now().UnixNano(),
        Height: height,
    }
    return NewBlock(header, []Transaction{})
}

func randomBlockWithSignature(t *testing.T, height uint32, prevBlockHash types.Hash) *Block {
    privKey := crypto.GeneratePrivateKey()

    b := randomBlock(height, prevBlockHash)
    tx := randomTxWithSignature(t)
    b.AddTransaction(tx)
    assert.Nil(t, b.Sign(privKey))

    return b
}

Encoding and Decoding of Transactions

In previous code, we left encoding and decoding part but now it's time to make it work so it can be useful in transaction pool implementation.

  1. Interface Definitions:

    • Encoder[T] and Decoder[T] are interfaces that define methods for encoding and decoding data of type T.

    • Encode(T) error is a method that takes an object of type T and encodes it, returning an error if any issue occurs.

    • Decode(T) error is a method that takes an empty object of type T, decodes data into it, and returns an error if any issue occurs.

  2. GobTxEncoder:

    • GobTxEncoder is a struct that implements the Encoder interface for transactions using the Gob encoding format.

    • It has a writer (w) where the encoded data will be written.

    • NewGobTxEncoder(w io.Writer) is a constructor that initializes a new GobTxEncoder with a specified writer.

    • Encode(tx *Transaction) error encodes a transaction using Gob and writes the encoded data to the specified writer.

  3. GobTxDecoder:

    • GobTxDecoder is a struct that implements the Decoder interface for transactions using the Gob encoding format.

    • It has a reader (r) from which the encoded data will be read.

    • NewGobTxDecoder(r io.Reader) is a constructor that initializes a new GobTxDecoder with a specified reader.

    • Decode(tx *Transaction) error decodes data from the specified reader using Gob and populates the provided transaction object.

core/encoding.go

package core

import (
    "crypto/elliptic"
    "encoding/gob"
    "io"
)

type Encoder[T any] interface{
    Encode(T) error
}

type Decoder[T any] interface{
    Decode(T) error
}

type GobTxEncoder struct {
    w io.Writer
}

func NewGobTxEncoder(w io.Writer) *GobTxEncoder {
    gob.Register(elliptic.P256())
    return &GobTxEncoder{
        w : w,
    }
}

func (e *GobTxEncoder) Encode(tx *Transaction) error {
    return gob.NewEncoder(e.w).Encode(tx)
}

type GobTxDecoder struct {
    r io.Reader
}

func NewGobTxDecoder(r io.Reader) *GobTxDecoder {
    gob.Register(elliptic.P256())
    return &GobTxDecoder{
        r : r,
    }
}

func (d *GobTxDecoder) Decode(tx *Transaction) error {
    return gob.NewDecoder(d.r).Decode(tx)
}

As we've changed Encoder and Decoder interface, we've to also update affected portion of code as well as use code.

core/block.go

func (b *Block) Encode(enc Encoder[*Block]) error{
    return enc.Encode(b)
}
func (b *Block) Decode(dec Decoder[*Block]) error{
    return dec.Decode(b)
}

core/transactions

func (t *Transaction) Encode(enc Encoder[*Transaction]) error{
    return enc.Encode(t)
}
func (t *Transaction) Decode(dec Decoder[*Transaction]) error{
    return dec.Decode(t)
}

Testing of Transaction encoding and decoding,

core/transaction_test.go

func TestEncodeDecodeTransact(t *testing.T){
    tx := randomTxWithSignature(t)
    buf := &bytes.Buffer{}
    assert.Nil(t, tx.Encode(NewGobTxEncoder(buf)))

    txDecoded := new(Transaction)
    assert.Nil(t, txDecoded.Decode(NewGobTxDecoder(buf)))
    assert.Equal(t, tx, txDecoded)
}
make test

It worked!!


Transaction Hashing and First Seen

Hasher : For hashing the transaction, it accepts hasher of interface Hasher[*Transaction] .

firstSeen : firstSeen for transaction mempool purposes, it denotes the value of appearance of particular transaction in local node's tx pool. It'll be required in transaction pool code.

firstSeen and SetFirstSeen are getter and setter function of that transaction

core/transaction.go

type Transaction struct {
// other
    firstSeen int64
}

func (t *Transaction) Hash(hasher Hasher[*Transaction]) types.Hash {
    if t.hash.IsZero() {
        t.hash = hasher.Hash(t)
    }
    return t.hash
}

func NewTransaction(data []byte) *Transaction {
    return &Transaction{
        Data: data,
    }
}

func (t *Transaction) SetFirstSeen(val int64) {
    t.firstSeen = val;
}

func (t *Transaction) FirstSeen() int64 {
    return t.firstSeen;
}
//other remaining functions

core/hasher.go


type TxHasher struct{}

func (TxHasher) Hash(tx *Transaction) types.Hash {
    return types.Hash(sha256.Sum256(tx.Data))
}

Transaction Mempool

Transaction mempool need to be implement as server or node level,

Here's a brief explanation of each function:

  1. NewTxPool:

    • Constructor function that creates a new transaction pool with an empty map to store transactions.
  2. NewTxMapSorter:

    • Constructor function for a sorter of transactions based on their first seen timestamp.

    • Takes a map of transactions, converts it to a slice, sorts it based on the first seen timestamp, and returns a TxMapSorter instance.

  3. Less (TxMapSorter):

    • Implements the sort.Interface method Less for sorting transactions by their first seen timestamp.
  4. Len (TxMapSorter):

    • Implements the sort.Interface method Len to get the length of the transactions slice.
  5. Swap (TxMapSorter):

    • Implements the sort.Interface method Swap for swapping two transactions in the slice.
  6. Transactions (TxPool):

    • Retrieves all transactions from the transaction pool, sorted by their first seen timestamp.
  7. Add (TxPool):

    • Adds a transaction to the pool.

    • Computes the hash of the transaction using a transaction hasher and uses it as the key in the map.

  8. Has (TxPool):

    • Checks if a transaction with a given hash exists in the pool.
  9. Len (TxPool):

    • Gets the number of transactions currently in the pool.
  10. Flush (TxPool):

    • Clears all transactions from the pool by creating a new empty map.

network/txpool.go

package network

import (
    "ProjectX/core"
    "ProjectX/types"
    "sort"
)

type TxPool struct {
    transactions map[types.Hash]*core.Transaction
}

func NewTxPool() *TxPool{
    return &TxPool{
        transactions: make(map[types.Hash]*core.Transaction),
    }
}

type TxMapSorter struct{
    transactions []*core.Transaction
}

func NewTxMapSorter(txMap map[types.Hash]*core.Transaction) *TxMapSorter{

    txx := make([]*core.Transaction, len(txMap))

    i := 0
    for _, tx := range txMap{
        txx[i] = tx
        i++
    }

    s := &TxMapSorter{
        transactions : txx,
    }
    sort.Sort(s);

    return s
}

func (s *TxMapSorter) Less(i,j int) bool { 
    return s.transactions[i].FirstSeen() < s.transactions[j].FirstSeen();
}

func (s *TxMapSorter) Len() int { 
    return len(s.transactions) 
}

func (s *TxMapSorter) Swap(i, j int)  { 
    s.transactions[i], s.transactions[j] = s.transactions[j], s.transactions[i]
}

func (tp *TxPool) Transactions() []*core.Transaction {
    s := NewTxMapSorter(tp.transactions)
    return s.transactions
}

//Add adds transaction to the pool, caller is responsible to check transaction
// whether that transaction already exists or not.
func (tp *TxPool) Add(tx *core.Transaction) error {
    hash := tx.Hash(core.TxHasher{})

    tp.transactions[hash] = tx
    return nil
}


func (tp *TxPool) Has(hash types.Hash) bool{
    _, ok := tp.transactions[hash]
    return ok
}

func (tp *TxPool) Len() int{
    return len(tp.transactions)
}

func (tp *TxPool) Flush() {
    tp.transactions = make(map[types.Hash]*core.Transaction)
}

network/txpool_test.go

Here's a brief explanation for each test function:

  1. TestTxPool:

    • Creates a new transaction pool (tp) using NewTxPool.

    • Asserts that the initial length of the pool is 0.

  2. TestTxPoolAddTx:

    • Creates a new transaction pool (tp) using NewTxPool.

    • Adds two new transactions to the pool using Add and checks if the length increases accordingly.

    • Flushes the pool using Flush and verifies that the length becomes 0 again.

  3. TestTxSort:

    • Creates a new transaction pool (tp) using NewTxPool.

    • Adds 1000 new transactions to the pool, each with a unique timestamp set through SetFirstSeen.

    • Verifies that the length of the pool matches the expected count.

    • Retrieves the transactions sorted by their first seen timestamp using Transactions.

    • Checks if the transactions are indeed sorted by comparing consecutive timestamps.

package network

import (
    "ProjectX/core"
    "math/rand"
    "strconv"
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestTxPool(t *testing.T){
    tp := NewTxPool()
    assert.Equal(t, tp.Len(), 0)
}

func TestTxPoolAddTx(t *testing.T){
    tp := NewTxPool()
    assert.Equal(t, tp.Len(), 0)

    tx := core.NewTransaction([]byte("hello new tx"))
    err := tp.Add(tx)
    assert.Nil(t,err)
    assert.Equal(t,tp.Len(),1)

    tx2 := core.NewTransaction([]byte("hello new txxx"))
    err2 := tp.Add(tx2)
    assert.Nil(t,err2)
    assert.Equal(t,tp.Len(),2)

    tp.Flush()
    assert.Equal(t,tp.Len(),0)
}

func TestTxSort(t *testing.T){
    tp := NewTxPool()

    end := 1000
    for i := 0; i < end; i++ { 
        newTx := core.NewTransaction([]byte(strconv.FormatInt(int64(i), 10)))
        newTx.SetFirstSeen(int64(i * rand.Intn(10000)))
        assert.Nil(t, tp.Add(newTx))
    }

    assert.Equal(t,tp.Len(),end)

    sortedTx := tp.Transactions()
    assert.Equal(t, len(sortedTx), end)

    for i := 0; i < len(sortedTx) - 1; i++ { 
        assert.True(t,  sortedTx[i].FirstSeen() < sortedTx[i + 1].FirstSeen())  
    }
}
make test

Done!

The following blog post will explore the code related to RPC protocol


In this blog series, I'll be sharing code snippets related to blockchain architecture. While the code will be available on my GitHub, I want to highlight that the entire architecture isn't solely my own. I'm learning as I go, drawing inspiration and knowledge from various sources, including a helpful YouTube playlist that has contributed to my learning process.

Did you find this article valuable?

Support Siddharth Patel by becoming a sponsor. Any amount is appreciated!