golang

Cоздание Приватной Сети Блокчейн на Go. Часть 1

  • понедельник, 20 января 2025 г. в 00:00:07
https://habr.com/ru/articles/874758/

Привет, Хабр!

Меня зовут Олег, я работаю разработчиком в одной крупной IT-компании и недавно в разговоре со знакомыми логистами, я узнал, что у них в штате работает блокчейн-специалист. Для меня мир логистики был максимально далек от цепочки блоков, как и цепочка блоков от меня, поэтому я решил погрузиться в эту технологию.

Прочитав множество статей и несколько книг, я выяснил, что теория с практикой идут рядышком, но понимание того, как же блокчейн работает на самом деле, не пришло, поэтому было решено создать что-то с нуля своими ручками. 

Нужно понимать, что между наработками, которые я буду рассматривать, и «настоящим» блокчейном есть большая разница: на ранних этапах разработки блокчейн-сеть может иметь низкую производительность, уязвимости и неоптимизированные механизмы консенсуса, которые улучшатся в финальной версии. Но в любом случае этот проект будет основан на ключевых принципах децентрализованных систем. Мой пост будет полезен для таких же новичков, как я, которые имеют некоторый опыт разработки, но с технологией блокчейн не были знакомы или слышали про него краем уха. В этой статье мы рассмотрим, как создать простую приватную сеть блокчейн с использованием языка Go.

Примечание *Почему Go?*

Потому что я люблю Go Go (или Golang) стал одним из самых популярных языков для разработки блокчейн-платформ благодаря своей простоте, высокой производительности и встроенной поддержке параллелизма. Именно эти особенности делают его отличным выбором для создания децентрализованных систем.

Но на самом деле эта статья будет полезна разработчикам с любыми знаниями ЯП. Здесь я разберу базовые компоненты блокчейна без привязки к какой-либо технологии. Итак, поехали.


Архитектура блокчейна

Прежде чем приступить к разработке, кратко рассмотрим, из чего состоит блокчейн:

Транзакции и реестр
Транзакции и реестр

1. Транзакции:  Операции сохранения данных в блокчейне. Это основная единица работы сети, обеспечивающая перемещение активов или данных между участниками сети. Эти транзакции используют криптографию и механизмы консенсуса для обеспечения безопасности, прозрачности и неизменяемости данных.

2. Реестр: Хранилище всех операций, произведенных в сети блокчейн. Оно позволяет участникам сети вести учёт и обмениваться данными без необходимости в централизованных доверенных посредниках.

  1. Блоки: Основные элементы данных, которые содержат записи транзакций. Каждый блок содержит транзакции, связанные с предыдущими блоками, и благодаря этому блокчейн остается защищенным от фальсификаций и атак.

  2. Цепь блоков: Каждый блок в блокчейне содержит часть реестра, а сами блоки связаны между собой с помощью криптографических хешей, создавая тем самым непрерывную цепочку данных.

    Блоки и их цепь
    Блоки и их цепь
    Механизм консенсуса
    Механизм консенсуса

    5. Механизм консенсуса: Алгоритм, который решает задачу обеспечения доверия между участниками сети, несмотря на отсутствие центрального авторитета, и гарантирует, что все копии блокчейна в сети остаются синхронизированными и идентичными.

    6. Сеть взаимодействия между узлами.

    Классификация блокчейна

    Сначала нужно определиться, какую конкретно сеть мы хотим создать. С 2008 года и публикации работы “Bitcoin Whitepaper” различные команды в различных компаниях, да и энтузиасты одиночки, создали бесчисленное множество децентрализованных систем со схожими принципами, но с разными решениями. Блокчейн системы можно разделить на несколько видов по двум критериям: структура реестра и механизм консенсуса. Нас будет интересовать соответствие основным свойствам блокчейна: прозрачность, неизменяемость, надежность, децентрализация, а также метрики качества распределенных систем: эффективность и гибкость.

    Структура реестра:

    1. Классический вид реестра был описан как раз в работе Сатоши Накамото «Bitcoin Whitepaper», в которой он был представлен в виде списка блоков, доступного для всех пользователей сети. В английской литературе такие реестры называют «Global list of blocks», но узлы биткоина фактически записывают блокчейн в виде дерева блоков. Более короткие ветви, присоединенные к основной цепи, представляют альтернативные конкурирующие вариации состояния системы (вспоминаем про то, что блокам еще нужно договориться между собой и прийти к консенсусу). Однако древовидная структура данных актуальна в основном для узлов, определяющих консенсус, с точки зрения пользователя, блокчейн представляет собой список блоков.

      Global List of Blocks
      Global List of Blocks
    2. Из первого пункта стало ясно, что хоть список и удобен для восприятия, он не всегда отражает верное состояние системы, а про эффективность при ожидании всех подсчетов лучше даже не думать. Поэтому был предложен вид направленного ациклического графа (DAG), на основе которого можно получить информацию о каждом объекте системы в любом состоянии.

      Directed Acyclic Graph of Blocks
      Directed Acyclic Graph of Blocks
    3. Однако не все системы подразумевают общую историю транзакций, доступную для всех участников сети. Такие платформы, как Hyperledger Fabric или Corda, предоставляют возможность работы нескольким конкурирующим компаниям в одной блокчейн сети, и тогда для хранения информации о транзакциях используются несколько реестров, информация в которые попадает по так называемым “каналам”. В таких системах участники получают доступ к своему “личному” реестру, в котором содержатся транзакции только открытые для этого участника. Очевидно, что такие меры приводят к нарушению большого количества принципов распределенных систем, однако они также показывают самые выгодные показатели эффективности и гибкости.

      Several Ledgers
      Several Ledgers

В нашем случае мы будем использовать классический список блоков в сети, защищенный криптографическими методами, чтобы сохранить приватность нашего блокчейна.

Механизм консенсуса:

  1. Классический общий подход называется консенсусом Накамото (Nakamoto Consensus). Он основан на том, что истинным состоянием системы считают самую длинную цепочку блоков, которая наблюдается в каждый момент времени. В Биткойне новые блоки генерируются с помощью механизма доказательства работы (Proof-of-Work). В качестве доказательства используется криптографическая головоломка, у которой легко проверить корректность решения, но решить ее сложно, и на это требуется фактически рандомное время. Те самые майнеры биткоина соревнуются в решении такой головоломки для каждого блока, используя большое количество вычислительных мощностей (и, следовательно, электроэнергии), чтобы увеличить свои шансы на победу в “соревновании за блок”. Как только головоломка решена - новый блок создан и ее создатель получает вознаграждение.

  2. Доказательство владения (Proof-of-Stake) - это распространенная альтернатива механизму консенсуса Накамото, который определяет следующий блок в цепи на основе факта владения цифровой валютой сети блокчейн. Например, майнеры Peercoin должны доказать владение определенным количеством валюты Peercoin, чтобы майнить блоки.

  3. Протокол Practical Byzantine Fault Tolerance (PBFT) применяется для консенсуса в приватных блокчейнах, например, в Stellar. PBFT обеспечивает консенсус, несмотря на произвольное поведение некоторой части участников. По сравнению с консенсусом Накамото, это более традиционный подход в распределенных системах. Грубо говоря, блокчейн на основе PBFT обеспечивает гораздо более сильную гарантию согласованности и меньшую задержку, но поддерживает при этом меньшее количество участников.

    Consensus Mechanisms
    Consensus Mechanisms

Реализация блокчейна

Шаг 0: Структура проекта

В дальнейшей работе я буду ссылаться на файловую структуру своего проекта. Она поможет чуть лучше понять то, о чем я буду говорить ниже.

Шаг 1: Создание сети

Предлагаю начать построение нашего решения с сети для взаимодействия будущих блоков. Протокол взаимодействия можно выбрать любой или даже реализовать несколько различных интерфейсов для передачи различных типов информации в сети.
Мы остановимся на rpc решении, так как оно хорошо подходит для передачи данных разного типа, а также гибко настраивается. С этой целью создадим саму структуру взаимодействия RPC в файле rpc.go. Не думаю, что имеет смысл ее усложнять, поэтому добавим в нее только поля адресанта и сообщения.

type RPC struct {
    From NetAddr                // Отправитель нашего сообщения - по сути адрес-строка
    Payload io.Reader           // Полученная информация - считывает поток отправленных сообщений
}
 
type NetAddr string

Любую транзакцию в сети блокчейн можно назвать сообщением между блоками с некоторым названием и содержанием. Соответственно создадим следующую структуру Message:

type Message struct {
    Header MessageType          // Заголовок сообщения - набор байт для определения типа сообщения
    Data []byte                 // Зашифрованные данные, переданные в сообщении - слайс (массив) байтов
}
 
type MessageType byte

Всю информацию, хранящуюся в нашей сети, мы хотим оберегать, поэтому атрибут Data является байтовым закодированным представлением внутренней информации. А декодированное сообщение DecodedMessage в свою очередь будет иметь вид:

type DecodedMessage struct {
    From NetAddr                // Отправитель нашего сообщения - по сути адрес-строка
    Data any                    // Данные, переданные в сообщении - любая информация любого типа
}

Для работы с шифрованием сообщений пользователей я буду использовать встроенную библиотеку "encoding".

func DefaultRPCDecodeFunc (rpc RPC) (*DecodedMessage, error) {
    msg := Message{}
    if err := gob.NewDecoder(rpc.Payload).Decode(&msg); err != nil {
        return nil, fmt.Errorf("failed to decode message from %s: %s", rpc.From, err)
    }
 
    switch msg.Header {
    case MessageTypeTx:
        tx := new(core.Transaction)
        if err := tx.Decode(core.NewGobTxDecoder(bytes.NewReader(msg.Data))); err != nil {
            return nil, err
        }
        return &DecodedMessage{
            From: rpc.From,
            Data: tx,
        }, nil
 
    default:
        return nil, fmt.Errorf("invalid message header %x", msg.Header)
    }
}

Код выше не делает никаких магических преобразований, а лишь считывает байтовое представление сообщения в сети и на основе полученного заголовка расшифровывает его.

Сеть выполняет транспортную функцию - под этим можно понимать подключение блоков, получение сообщений из внешней сети, а также распространение информации внутри блокчейна. Для транспортного уровня создадим интерфейс Transport в файле transport.go.

type Transport interface {
    Consume() <-chan RPC                     // Метод получения сообщения
    Connect(Transport) error                 // Метод подключения к сети
    SendMessage(NetAddr,[]byte) error        // Метод отправки зашифрованного сообщения
    Broadcast([]byte) error                  // Метод распространения сообщений по сети
    Addr() NetAddr                           // Метод получения адреса в сети
}

Шаг 1.5: Создание локального транспортного слоя

Для того, чтобы тестирование, отладка и вообще функционирование проекта не требовало подготовки инфраструктуры или владения несколькими пк, я решил создать своего рода эмулятор взаимодействия  блоков LocalTransport, который реализует интерфейс Transport. Важно, что для предотвращения конкурентных обращений к одному блоку нам потребуется мьютекс на чтение/запись, а также явно указать канал, по которому сообщения будут переданы.

type LocalTransport struct {
    addr      NetAddr                           // Адрес локального участника сети - строка
    consumeCh chan RPC                          // Канал, по которому участник будет получать сообщения
    lock      sync.RWMutex                      // Мьютекс на чтение и запись для избежания последствий конкурентного доступа
    peers     map[NetAddr]*LocalTransport       // Другие участники сети для обмена информацией - мапа, связывающая адрес участника с транспортным слоем
}
func (t *LocalTransport) Consume() <-chan RPC {
    return t.consumeCh
}
 
func (t *LocalTransport) Connect(tr Transport) error {
    trans := tr.(*LocalTransport)
    t.lock.Lock()
    defer t.lock.Unlock()
 
    t.peers[tr.Addr()] = trans
 
    return nil
}
 
func (t *LocalTransport) SendMessage(to NetAddr, payload []byte) error {
    t.lock.RLock()
    defer t.lock.RUnlock()
 
    peer, ok := t.peers[to]
    if !ok {
        return fmt.Errorf("%s: could not send message to %s", t.addr, to)
    }
 
    peer.consumeCh <- RPC{
        From:    t.addr,
        Payload: bytes.NewReader(payload),
    }
 
    return nil
}
 
func (t *LocalTransport) Broadcast(payload []byte) error {
    for _, peer := range t.peers {
        if err := t.SendMessage(peer.Addr(), payload); err != nil {
            return err
        }
    }
    return nil
}
func (t *LocalTransport) Addr() NetAddr {
    return t.addr
}

Шаг 2: Создание транзакции

Теперь перейдем к тому, что же будем передавать в нашей сети. Для того, чтобы в нашу сеть не попадали случайные транзакции злоумышленников, обязательной частью будет являться электронная подпись этой операции. Также для описания транзакции мы будем хранить в ней некоторую информацию и адресанта в нашей сети, идентифицируемого по приватному ключу. Чтобы была возможность быстро находить операцию и работать с хранилищем, одним из полей нашей структуры будет являться хеш операции. Также, как мы уже знаем, новая транзакция и транзакция, уже зарегистрированная в реестре, провоцируют разное поведение системы, соответственно нам нужен указатель на самый первый блок, который провалидировал данную операцию. Прежде чем блоки подтвердят корректность операции, они будут помещены в так называемый пул транзакций, где будут ждать своей очереди. 

Для этого создадим структуру Transaction в файле transaction.go,  а также реализуем методы подписи и проверки транзакции.

type Transaction struct {
    Data []byte                      // Зашифрованные данные, переданные в транзакции - слайс (массив) байтов
    From crypto.PublicKey            // Инициатор транзакции в нашей сети - публичный ключ, который идентифицирует данного пользователя
    Signature *crypto.Signature      // Подпись инициатора транзакции - указатель на big int комбинацию параметров пользователя
    hash types.Hash                  // Хеш для идентификации и быстрого доступа к транзакции
    firstSeen int64                  // Указатель на первое вхождение транзакции в блокчейн - является номером блока-обработчика
}
 
type TxPool struct {
    transactions map[types.Hash]*core.Transaction    // Список инициированных, но не обработанных транзакций - мапа, где в соответствие ставится хеш транзакции и указатель на нее
}
 
type Hash [32]uint8
 
func (tx *Transaction) Sign(privKey crypto.PrivateKey) error {
    sig, err := privKey.Sign(tx.Data)
    if err != nil {
        return err
    }
 
    tx.From = privKey.PublicKey()
    tx.Signature = sig
 
    return nil
}
 
func (tx *Transaction) Verify() error {
    if tx.Signature == nil {
        return fmt.Errorf("transaction has no signature")
    }
 
    if !tx.Signature.Verify(tx.From, tx.Data) {
        return fmt.Errorf("invalid transaction signature")
    }
 
    return nil
}

Шаг 3: Создание структуры блока

Как уже было сказано, блоки будут хранить в себе транзакции, но также важно хранить информацию о самом блоке в сети - всю эту информацию мы будем хранить в заголовке нашего блока Header в файле block.go.Кроме этого мы все еще хотим сделать нашу приватную сеть максимально безопасной, поэтому мы также подпишем блоки и добавим валидацию этих подписей.

type Header struct {
    Version uint32                  // Версия данного блока - дефолтное значение 1
    DataHash types.Hash             // Хеш информации, хранящейся в блоке
    PrevBlockHash types.Hash        // Хеш предыдущего блока для поддержания связности
    Timestamp int64                 // Временная метка создания блока - имеет значение Unix Nano
    Height uint32                   // Высота блока - значение указывает на глубину дерева блоков от Genesis Block
}
 
type Block struct {
    *Header
    Transactions []Transaction      // Список транзакций, записанных на блок - слайс (массив) транзакций
    Validator crypto.PublicKey      // Значение публичного ключа, относительно которого будем валидировать подписанный блок
    Signature *crypto.Signature     // Подпись пользователя, предлагающего блок
    hash types.Hash                 // Хеш блока для идентификации
}
 
func (b *Block) Sign(privKey crypto.PrivateKey) error {
    sig, err := privKey.Sign(b.Header.Bytes())
    if err != nil {
        return err
    }
 
    b.Validator = privKey.PublicKey()
    b.Signature = sig
 
    return nil
}
 
func (b *Block) Verify() error {
    if b.Signature == nil {
        return fmt.Errorf("block has no signature")
    }
 
    if !b.Signature.Verify(b.Validator, b.Header.Bytes()) {
        return fmt.Errorf("block has invalid signature")
    }
 
    for _, tx := range b.Transactions {
        if err := tx.Verify(); err != nil {
            return err
        }
    }
 
    return nil
}

Шаг 4: Реализация цепочки блоков

Теперь когда блоки готовы появляться в сети, создадим долгожданный блокчейн в файле blockchain.go.В блокчейне мы будем хранить не целиком блоки, а все те же заголовки, которые по сути являются идентификаторами наших блоков. Как я уже говорил, нам нужно хранилище, нам нужен мьютекс для предотвращения одновременной работы с блокчейном и валидация блоков для безопасности нашей сети. Проверка корректности предложенного блока делится на несколько этапов: 

  • Проверка на уникальность блока в сети

  • Проверка на корректное расположение блока в графе

  • Проверка на связность блока с предшественником

  • Проверка подписи

type Blockchain struct {
    store Storage                   // Хранилище блоков в сети блокчейн для дальнейшего обращения к ним
    lock sync.RWMutex               // Мьютекс на чтение и запись для избежания последствий конкурентного доступа
    headers []*Header               // Список блоков в сети - слайс (массив) заголовков
    validator Validator             // Валидатор при добавлении блоков
}
 
type Storage interface{
    Put(*Block) error
}
 
func (s *MemoryStore) Put(b *Block) error {
    return nil
}
 
type Validator interface{
    ValidateBlock(*Block) error
}
 
func (v *BlockValidator) ValidateBlock(b *Block) error{
    if v.bc.HasBlock(b.Height){
        return fmt.Errorf("chain already contains block (%d) with hash (%s)",
            b.Height, b.Hash(BlockHasher{}))
    }
 
    if b.Height != v.bc.Height()+1 {
        return fmt.Errorf("block (%s) too high", b.Hash(BlockHasher{}))
    }
 
    prevHeader, err := v.bc.GetHeader(b.Height - 1)
    if err != nil {
        return err
    }
 
    hash := BlockHasher{}.Hash(prevHeader)
    if hash != b.PrevBlockHash {
        return fmt.Errorf("the hash of the previous block (%s) is invalid", b.PrevBlockHash)
    }
 
    if err := b.Verify(); err != nil {
        return err
    }
 
    return nil
}
 
func (bc *Blockchain) addBlockWithoutValidation(b *Block) error {
    bc.lock.Lock()
    bc.headers = append(bc.headers, b.Header)
    bc.lock.Unlock()
    logrus.WithFields(logrus.Fields{
        "height": b.Height,
        "hash": b.Hash(BlockHasher{}),
    }).Info("adding new block")
    return bc.store.Put(b)
}
 
func (bc *Blockchain) AddBlock(b *Block) error {
    if err := bc.validator.ValidateBlock(b); err != nil {
        return err
    }
 
    bc.addBlockWithoutValidation(b)
 
    return nil
}

Шаг 5: Запуск и тестирование

Когда все основные уровни матрешки блокчейна были нами описаны в коде, пришла пора тестировать, что же получилось. Для этого создадим сервер в файле server.go, который будет использовать LocalTransport и поддерживать саму сеть доступной. Структура Server определяет сам сервер, а ServerOpts - его настройки.

type Server struct {
    ServerOpts
    memPool     *TxPool                     // Пул транзакций
    isValidator bool                        // Флаг наличия ключа для проверки корректности блоков
    rpcCh       chan RPC                    // Канал для получения сообщений по RPC
    quitCh      chan struct{}               // Канал для поддержания graceful shutdown сервера - при получении сообщения в этом канале сервер отключается
}
type ServerOpts struct{
    ID      string                          // ID сервера в сети
    Logger  log.Logger
    RPCDecodeFunc   RPCDecodeFunc           // Алгоритм декодирования сообщений в сети
    RPCProcessor    RPCProcessor            // Алгоритм обработки сообщений в сети
    Transports  []Transport                 // Виды поддерживаемого транспорта
    BlockTime   time.Duration               // Время, необходимое для создания блока
    PrivateKey  *crypto.PrivateKey          // Приватный ключ для валидации объектов в сети
}
 
func NewServer(opts ServerOpts) *Server {
    if opts.BlockTime == time.Duration(0) {
        opts.BlockTime = defaultBlockTime
    }
 
    if opts.RPCDecodeFunc == nil {
        opts.RPCDecodeFunc = DefaultRPCDecodeFunc
    }
    if opts.Logger == nil {
        opts.Logger = log.NewLogfmtLogger(os.Stderr)
        opts.Logger = log.With(opts.Logger, "ID", opts.ID)
    }
 
    s := &Server{
        ServerOpts: opts,
        memPool: NewTxPool(),
        isValidator: opts.PrivateKey != nil,
        rpcCh: make(chan RPC),
        quitCh: make(chan struct{}, 1),
    }
 
    if s.RPCProcessor == nil {
        s.RPCProcessor = s
    }
 
    if s.isValidator {
        go s.validatorLoop()
    }
 
    return s
}
 
func (s *Server) Start(){
    s.initTransports()
 
free:
    for {
        select{
        case rpc := <- s.rpcCh:
            msg, err := s.RPCDecodeFunc(rpc)
            if err != nil {
                s.Logger.Log("error", err)
            }
 
            if err := s.RPCProcessor.ProcessMessage(msg); err != nil {
                s.Logger.Log("error", err)
            }
 
        case <-s.quitCh:
            break free
        }
    }
    s.Logger.Log("msg", "Server shutdown")
}
 
func (s *Server) validatorLoop() {
    ticker := time.NewTicker(s.BlockTime)
 
    s.Logger.Log("msg", "Starting validator loop", "blocktime", s.BlockTime)
 
    for {
        <-ticker.C
        s.createNewBlock()
    }
}
 
func (s *Server) ProcessMessage(msg *DecodedMessage) error {
 
    switch t := msg.Data.(type) {
    case *core.Transaction:
        return s.processTransaction(t)
    }
    return nil
}
 
func (s *Server) broadcast(payload []byte) error {
    for _, tr := range s.Transports {
        if err := tr.Broadcast(payload); err != nil {
            return err
        }
    }
    return nil
}
 
func (s *Server) processTransaction (tx *core.Transaction) error {
    hash := tx.Hash(core.TxHasher{})
 
    if s.memPool.Has(hash) {
        return nil
    }
 
    if err := tx.Verify(); err != nil {
        return err
    }
 
    tx.SetFirstSeen(time.Now().UnixNano())
 
    logrus.WithFields(logrus.Fields{
        "hash": hash,
        "mempool length": s.memPool.Len(),
    }).Info("adding new tx to the mempool")
     
    s.Logger.Log("msg", "adding new tx to mempool",
        "hash", hash,
        "mempoolLength", s.memPool.Len())
 
    go s.broadcastTx(tx)
 
 
    return s.memPool.Add(tx)
}
 
func (s *Server) broadcastTx(tx *core.Transaction) error {
    buf := &bytes.Buffer{}
    if err := tx.Encode(core.NewGobTxEncoder(buf)); err != nil {
        return err
    }
 
    msg := NewMessage(MessageTypeTx, buf.Bytes())
    return s.broadcast(msg.Bytes())
}
 
func (s *Server) createNewBlock() error {
    fmt.Println("creating a new block")
    return nil
}
 
func (s *Server) initTransports(){
    for _, tr := range s.Transports{
        go func(tr Transport) {
            for rpc := range tr.Consume(){
                s.rpcCh <- rpc
            }
        }(tr)
    }
}

Заключение

Итак, после большого количества слов и кода, пора подводить итоги, что же мы сделали. Во-первых, постарались разобраться в базовых элементах блокчейн сети, узнали, чем блокчейны различаются и на каких общих китах стоят. Также сделали простую систему, которая содержит все элементы децентрализованной системы, эмулирует поведение работы блоков и будет в дальнейшем расширяться. Во второй части я покажу, как на основе такой простой системы создать платформу для взаимодействия нескольких пользователей, площадку для реализации smart контрактов и как все это обезопасить от внешних угроз. Буду рад услышать обратную связь, почитать комменты и понять, что ж я все-таки неправильно понял.

Всем большое спасибо и до скорого!