Привет, Хабр!
Меня зовут Олег, я работаю разработчиком в одной крупной IT-компании и недавно в разговоре со знакомыми логистами, я узнал, что у них в штате работает блокчейн-специалист. Для меня мир логистики был максимально далек от цепочки блоков, как и цепочка блоков от меня, поэтому я решил погрузиться в эту технологию.
Прочитав множество статей и несколько книг, я выяснил, что теория с практикой идут рядышком, но понимание того, как же блокчейн работает на самом деле, не пришло, поэтому было решено создать что-то с нуля своими ручками.
Нужно понимать, что между наработками, которые я буду рассматривать, и «настоящим» блокчейном есть большая разница: на ранних этапах разработки блокчейн-сеть может иметь низкую производительность, уязвимости и неоптимизированные механизмы консенсуса, которые улучшатся в финальной версии. Но в любом случае этот проект будет основан на ключевых принципах децентрализованных систем. Мой пост будет полезен для таких же новичков, как я, которые имеют некоторый опыт разработки, но с технологией блокчейн не были знакомы или слышали про него краем уха. В этой статье мы рассмотрим, как создать простую приватную сеть блокчейн с использованием языка Go.
Примечание *Почему Go?*
Потому что я люблю Go Go (или Golang) стал одним из самых популярных языков для разработки блокчейн-платформ благодаря своей простоте, высокой производительности и встроенной поддержке параллелизма. Именно эти особенности делают его отличным выбором для создания децентрализованных систем.
Но на самом деле эта статья будет полезна разработчикам с любыми знаниями ЯП. Здесь я разберу базовые компоненты блокчейна без привязки к какой-либо технологии. Итак, поехали.
Архитектура блокчейна
Прежде чем приступить к разработке, кратко рассмотрим, из чего состоит блокчейн:
1. Транзакции: Операции сохранения данных в блокчейне. Это основная единица работы сети, обеспечивающая перемещение активов или данных между участниками сети. Эти транзакции используют криптографию и механизмы консенсуса для обеспечения безопасности, прозрачности и неизменяемости данных.
2. Реестр: Хранилище всех операций, произведенных в сети блокчейн. Оно позволяет участникам сети вести учёт и обмениваться данными без необходимости в централизованных доверенных посредниках.
Блоки: Основные элементы данных, которые содержат записи транзакций. Каждый блок содержит транзакции, связанные с предыдущими блоками, и благодаря этому блокчейн остается защищенным от фальсификаций и атак.
-
Цепь блоков: Каждый блок в блокчейне содержит часть реестра, а сами блоки связаны между собой с помощью криптографических хешей, создавая тем самым непрерывную цепочку данных.
5. Механизм консенсуса: Алгоритм, который решает задачу обеспечения доверия между участниками сети, несмотря на отсутствие центрального авторитета, и гарантирует, что все копии блокчейна в сети остаются синхронизированными и идентичными.
6. Сеть взаимодействия между узлами.
Классификация блокчейна
Сначала нужно определиться, какую конкретно сеть мы хотим создать. С 2008 года и публикации работы “Bitcoin Whitepaper” различные команды в различных компаниях, да и энтузиасты одиночки, создали бесчисленное множество децентрализованных систем со схожими принципами, но с разными решениями. Блокчейн системы можно разделить на несколько видов по двум критериям: структура реестра и механизм консенсуса. Нас будет интересовать соответствие основным свойствам блокчейна: прозрачность, неизменяемость, надежность, децентрализация, а также метрики качества распределенных систем: эффективность и гибкость.
Структура реестра:
-
Классический вид реестра был описан как раз в работе Сатоши Накамото «Bitcoin Whitepaper», в которой он был представлен в виде списка блоков, доступного для всех пользователей сети. В английской литературе такие реестры называют «Global list of blocks», но узлы биткоина фактически записывают блокчейн в виде дерева блоков. Более короткие ветви, присоединенные к основной цепи, представляют альтернативные конкурирующие вариации состояния системы (вспоминаем про то, что блокам еще нужно договориться между собой и прийти к консенсусу). Однако древовидная структура данных актуальна в основном для узлов, определяющих консенсус, с точки зрения пользователя, блокчейн представляет собой список блоков.
-
Из первого пункта стало ясно, что хоть список и удобен для восприятия, он не всегда отражает верное состояние системы, а про эффективность при ожидании всех подсчетов лучше даже не думать. Поэтому был предложен вид направленного ациклического графа (DAG), на основе которого можно получить информацию о каждом объекте системы в любом состоянии.
-
Однако не все системы подразумевают общую историю транзакций, доступную для всех участников сети. Такие платформы, как Hyperledger Fabric или Corda, предоставляют возможность работы нескольким конкурирующим компаниям в одной блокчейн сети, и тогда для хранения информации о транзакциях используются несколько реестров, информация в которые попадает по так называемым “каналам”. В таких системах участники получают доступ к своему “личному” реестру, в котором содержатся транзакции только открытые для этого участника. Очевидно, что такие меры приводят к нарушению большого количества принципов распределенных систем, однако они также показывают самые выгодные показатели эффективности и гибкости.
-
В нашем случае мы будем использовать классический список блоков в сети, защищенный криптографическими методами, чтобы сохранить приватность нашего блокчейна.
Механизм консенсуса:
Классический общий подход называется консенсусом Накамото (Nakamoto Consensus). Он основан на том, что истинным состоянием системы считают самую длинную цепочку блоков, которая наблюдается в каждый момент времени. В Биткойне новые блоки генерируются с помощью механизма доказательства работы (Proof-of-Work). В качестве доказательства используется криптографическая головоломка, у которой легко проверить корректность решения, но решить ее сложно, и на это требуется фактически рандомное время. Те самые майнеры биткоина соревнуются в решении такой головоломки для каждого блока, используя большое количество вычислительных мощностей (и, следовательно, электроэнергии), чтобы увеличить свои шансы на победу в “соревновании за блок”. Как только головоломка решена - новый блок создан и ее создатель получает вознаграждение.
Доказательство владения (Proof-of-Stake) - это распространенная альтернатива механизму консенсуса Накамото, который определяет следующий блок в цепи на основе факта владения цифровой валютой сети блокчейн. Например, майнеры Peercoin должны доказать владение определенным количеством валюты Peercoin, чтобы майнить блоки.
-
Протокол Practical Byzantine Fault Tolerance (PBFT) применяется для консенсуса в приватных блокчейнах, например, в Stellar. PBFT обеспечивает консенсус, несмотря на произвольное поведение некоторой части участников. По сравнению с консенсусом Накамото, это более традиционный подход в распределенных системах. Грубо говоря, блокчейн на основе PBFT обеспечивает гораздо более сильную гарантию согласованности и меньшую задержку, но поддерживает при этом меньшее количество участников.
Реализация блокчейна
Шаг 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 контрактов и как все это обезопасить от внешних угроз. Буду рад услышать обратную связь, почитать комменты и понять, что ж я все-таки неправильно понял.
Всем большое спасибо и до скорого!