Создание криптовалюты на языке программирования Crystal
Опубликовано: 2022-03-11Этот пост — моя попытка понять ключевые аспекты блокчейна, исследуя его внутренности. Я начал с прочтения оригинального технического описания биткойнов, но понял, что единственный способ по-настоящему понять блокчейн — создать новую криптовалюту с нуля.
Вот почему я решил создать криптовалюту с использованием нового языка программирования Crystal и назвал ее CrystalCoin . В этой статье не будут обсуждаться варианты алгоритмов, сложность хеширования или подобные темы. Вместо этого основное внимание будет уделено подробному описанию конкретного примера, который должен обеспечить более глубокое практическое понимание сильных и слабых сторон блокчейнов.
Если вы еще не читали ее, для получения дополнительной информации об алгоритмах и хешировании я предлагаю вам взглянуть на статью Демира Сельмановича «Криптовалюта для чайников: Биткойн и не только».
Почему я выбрал язык программирования Crystal
Для лучшей демонстрации я хотел использовать продуктивный язык, такой как Ruby, без ущерба для производительности. Криптовалюта требует много трудоемких вычислений (а именно майнинг и хеширование ), и поэтому компилируемые языки, такие как C++ и Java, являются предпочтительными языками для создания «настоящих» криптовалют. При этом я хотел использовать язык с более чистым синтаксисом, чтобы я мог получать удовольствие от разработки и обеспечивать лучшую читабельность. Производительность кристалла в любом случае имеет тенденцию быть хорошей.
Итак, почему я решил использовать язык программирования Crystal? Синтаксис Crystal во многом вдохновлен Ruby, поэтому для меня он кажется естественным для чтения и легким для написания. Дополнительным преимуществом является более низкая кривая обучения, особенно для опытных разработчиков Ruby.
Вот как команда Crystal lang пишет на своем официальном сайте:
Быстрый как C, ловкий как Ruby.
Однако, в отличие от Ruby или JavaScript, которые являются интерпретируемыми языками, Crystal является компилируемым языком, что делает его намного быстрее и требует меньше памяти. Под капотом он использует LLVM для компиляции в собственный код.
Crystal также статически типизирован, что означает, что компилятор поможет вам отловить ошибки типов во время компиляции.
Я не буду объяснять, почему я считаю язык Crystal потрясающим, потому что это выходит за рамки этой статьи, но если мой оптимизм не кажется вам убедительным, не стесняйтесь прочитать эту статью для лучшего обзора потенциала Crystal.
Примечание. В этой статье предполагается, что у вас уже есть базовые знания об объектно-ориентированном программировании (ООП).
Блокчейн
Итак, что такое блокчейн? Это список (цепочка) блоков, связанных и защищенных цифровыми отпечатками (также известными как крипто-хэши).
Самый простой способ представить это как структуру данных связанного списка. При этом для связанного списка требуется только ссылка на предыдущий элемент; блок должен иметь идентификатор, зависящий от идентификатора предыдущего блока, а это означает, что вы не можете заменить блок, не пересчитывая каждый последующий блок.
На данный момент думайте о блокчейне как о серии блоков с некоторыми данными, связанными с цепочкой, где цепочка представляет собой хэш предыдущего блока.
Весь блокчейн будет существовать на каждом узле, который хочет с ним взаимодействовать, то есть он будет скопирован на каждом из узлов в сети. Ни один сервер не размещает его, но все компании-разработчики блокчейна используют его, что делает его децентрализованным .
Да, это странно по сравнению с обычными централизованными системами. Каждый из узлов будет иметь копию всей цепочки блоков (> 149 Гб в цепочке биткойнов к декабрю 2017 года).
Хэширование и цифровая подпись
Итак, что же это за хэш-функция? Думайте о хэше как о функции, которая возвращает уникальный отпечаток пальца, когда мы передаем ему текст/объект. Даже самое маленькое изменение во входном объекте резко изменит отпечаток пальца.
Существуют разные алгоритмы хэширования, и в этой статье мы будем использовать алгоритм хэширования SHA256
, который используется в Bitcoin
.
Используя SHA256
, мы всегда будем получать 64 шестнадцатеричных символа (256 бит) в длину, даже если входные данные меньше 256 бит или намного больше 256 бит:
Вход | Хэшированные результаты |
---|---|
ОЧЕНЬ ДЛИННЫЙ ТЕКСТ ОЧЕНЬ ДЛИННЫЙ ТЕКСТ ОЧЕНЬ ДЛИННЫЙ ТЕКСТ ОЧЕНЬ ДЛИННЫЙ ТЕКСТ ОЧЕНЬ ДЛИННЫЙ ТЕКСТ ОЧЕНЬ ДЛИННЫЙ ТЕКСТ ОЧЕНЬ ДЛИННЫЙ ТЕКСТ ОЧЕНЬ ДЛИННЫЙ ТЕКСТ ОЧЕНЬ ДЛИННЫЙ ТЕКСТ ОЧЕНЬ ДЛИННЫЙ ТЕКСТ | cf49bbb21c8b7c078165919d7e57c145ccb7f398e7b58d9a3729de368d86294a |
Топталь | 2e4e500e20f1358224c08c7fb7d3e0e9a5e4ab7a013bfd6774dfa54d7684dd21 |
Топтал. | 12075307ce09a6859601ce9d451d385053be80238ea127c5df6e6611eed7c6f0 |
Обратите внимание, что в последнем примере просто добавление файла .
(точка) привело к резкому изменению хеша.
Таким образом, в блокчейне цепочка строится путем передачи данных блока в алгоритм хеширования, который будет генерировать хеш, который будет связан со следующим блоком, в дальнейшем образуя серию блоков, связанных с хешами предыдущих блоков.
Создание криптовалюты в Crystal
Теперь давайте начнем создавать наш проект Crystal и построим наше шифрование SHA256
.
Предполагая, что у вас установлен язык программирования Crystal, давайте создадим скелет кодовой базы CrystalCoin
с помощью встроенного в Crystal инструментария проекта crystal init app [name]
:
% crystal init app crystal_coin create crystal_coin/.gitignore create crystal_coin/.editorconfig create crystal_coin/LICENSE create crystal_coin/README.md create crystal_coin/.travis.yml create crystal_coin/shard.yml create crystal_coin/src/crystal_coin.cr create crystal_coin/src/crystal_coin/version.cr create crystal_coin/spec/spec_helper.cr create crystal_coin/spec/crystal_coin_spec.cr Initialized empty Git repository in /Users/eki/code/crystal_coin/.git/
Эта команда создаст базовую структуру проекта с уже инициализированным репозиторием git, лицензией и файлами readme. Он также поставляется с заглушками для тестов и файлом shard.yml
для описания проекта и управления зависимостями, также известными как осколки.
Добавим осколок openssl
, необходимый для построения алгоритма SHA256
:
# shard.yml dependencies: openssl: github: datanoise/openssl.cr
Как только это будет сделано, вернитесь в свой терминал и запустите crystal deps
. Это позволит нам использовать openssl
и его зависимости.
Теперь у нас есть необходимая библиотека, установленная в нашем коде, давайте начнем с определения класса Block
, а затем создадим хеш-функцию.
# src/crystal_coin/block.cr require "openssl" module CrystalCoin class Block def initialize(data : String) @data = data end def hash hash = OpenSSL::Digest.new("SHA256") hash.update(@data) hash.hexdigest end end end puts CrystalCoin::Block.new("Hello, Cryptos!").hash
Теперь вы можете протестировать свое приложение, запустив crystal run crystal src/crystal_coin/block.cr
со своего терминала.
crystal_coin [master●] % crystal src/crystal_coin/block.cr 33eedea60b0662c66c289ceba71863a864cf84b00e10002ca1069bf58f9362d5
Проектирование нашего блокчейна
Каждый блок хранится с timestamp
и, необязательно, с index
. В CrystalCoin
мы собираемся хранить и то, и другое. Чтобы обеспечить целостность всей цепочки блоков, каждый блок будет иметь самоидентифицирующийся хэш . Как и в биткойнах, хеш каждого блока будет криптографическим хэшем блока ( index
, timestamp
, data
и хэшем хэша предыдущего блока, previous_hash
). Данные могут быть любыми, которые вы хотите на данный момент.
module CrystalCoin class Block property current_hash : String def initialize(index = 0, data = "data", previous_hash = "hash") @data = data @index = index @timestamp = Time.now @previous_hash = previous_hash @current_hash = hash_block end private def hash_block hash = OpenSSL::Digest.new("SHA256") hash.update("#{@index}#{@timestamp}#{@data}#{@previous_hash}") hash.hexdigest end end end puts CrystalCoin::Block.new(data: "Same Data").current_hash
В языке Crystal мы заменяем методы Ruby attr_accessor
, attr_getter
и attr_setter
новыми ключевыми словами:
Рубиновое ключевое слово | Кристалл Ключевое слово |
---|---|
attr_accessor | имущество |
attr_reader | добытчик |
attr_writer | сеттер |
Еще одна вещь, которую вы, возможно, заметили в Crystal, заключается в том, что мы хотим намекнуть компилятору о конкретных типах через наш код. Crystal выводит типы, но всякий раз, когда у вас есть неоднозначность, вы также можете явно объявлять типы. Вот почему мы добавили типы String
для current_hash
.
Теперь давайте запустим block.cr
дважды и заметим, что одни и те же данные будут генерировать разные хэши из-за разных timestamp
:
crystal_coin [master●] % crystal src/crystal_coin/block.cr 361d0df74e28d37b71f6c5f579ee182dd3d41f73f174dc88c9f2536172d3bb66 crystal_coin [master●] % crystal src/crystal_coin/block.cr b1fafd81ba13fc21598fb083d9429d1b8a7e9a7120dbdacc7e461791b96b9bf3
Теперь у нас есть блочная структура, но мы создаем блокчейн. Нам нужно начать добавлять блоки, чтобы сформировать настоящую цепочку. Как я упоминал ранее, каждый блок требует информации из предыдущего блока. Но как туда попадает первый блок в блокчейне? Что ж, первый блок, или блок genesis
, — это особый блок (блок без предшественников). Во многих случаях он добавляется вручную или имеет уникальную логику, позволяющую добавить его.
Мы создадим новую функцию, которая возвращает исходный блок. Этот блок имеет index=0
и имеет произвольное значение данных и произвольное значение в параметре previous_hash
.
Давайте создадим или классифицируем метод Block.first
, который генерирует блок генезиса:
module CrystalCoin class Block ... def self.first(data="Genesis Block") Block.new(data: data, previous_hash: "0") end ... end end
И давайте проверим это с помощью p CrystalCoin::Block.first
:
#<CrystalCoin::Block:0x10b33ac80 @current_hash="acb701a9b70cff5a0617d654e6b8a7155a8c712910d34df692db92455964d54e", @data="Genesis Block", @index=0, @timestamp=2018-05-13 17:54:02 +03:00, @previous_hash="0">
Теперь, когда мы можем создать блок генезиса , нам нужна функция, которая будет генерировать последующие блоки в блокчейне.
Эта функция примет предыдущий блок в цепочке в качестве параметра, создаст данные для генерируемого блока и вернет новый блок с соответствующими данными. Когда новые блоки хэшируют информацию из предыдущих блоков, целостность блокчейна увеличивается с каждым новым блоком.
Важным следствием этого является то, что блок не может быть изменен без изменения хэша каждого последующего блока. Это продемонстрировано в примере ниже. Если данные в блоке 44 изменены с LOOP
на EAST
, все хэши последовательных блоков должны быть изменены. Это связано с тем, что хэш блока зависит от значения previous_hash
(среди прочего).
Если бы мы этого не сделали, сторонней стороне было бы проще изменить данные и заменить нашу цепочку совершенно новой собственной. Эта цепочка хэшей действует как криптографическое доказательство и помогает гарантировать, что после добавления блока в блокчейн его нельзя будет заменить или удалить. Создадим метод класса Block.next
:
module CrystalCoin class Block ... def self.next(previous_node, data = "Transaction Data") Block.new( data: "Transaction data number (#{previous_node.index + 1})", index: previous_node.index + 1, previous_hash: previous_hash.hash ) end ... end end
Чтобы попробовать все вместе, мы создадим простую цепочку блоков. Первым элементом списка является блок генезиса. И, конечно же, нам нужно добавить последующие блоки. Мы создадим пять новых блоков для демонстрации CrystalCoin
:
blockchain = [ CrystalCoin::Block.first ] previous_block = blockchain[0] 5.times do new_block = CrystalCoin::Block.next(previous_block: previous_block) blockchain << new_block previous_block = new_block end p blockchain
[#<CrystalCoin::Block:0x108c57c80 @current_hash= "df7f9d47bee95c9158e3043ddd17204e97ccd6e8958e4e41dacc7f0c6c0df610", @index=0, @previous_hash="0", @timestamp=2018-06-04 12:13:21 +03:00, @data="Genesis Block>, #<CrystalCoin::Block:0x109c89740 @current_hash= "d188fcddd056c044c783d558fd6904ceeb9b2366339af491a293d369de4a81f6", @index=1, @previous_hash= "df7f9d47bee95c9158e3043ddd17204e97ccd6e8958e4e41dacc7f0c6c0df610", @timestamp=2018-06-04 12:13:21 +03:00, @data="Transaction data number (1)">, #<CrystalCoin::Block:0x109cc8500 @current_hash= "0b71b61118b9300b4fe8cdf4a7cbcc1dac4da7a8a3150aa97630994805354107", @index=2, @previous_hash= "d188fcddd056c044c783d558fd6904ceeb9b2366339af491a293d369de4a81f6", @timestamp=2018-06-04 12:13:21 +03:00, @transactions="Transaction data number (2)">, #<CrystalCoin::Block:0x109ccbe40 @current_hash= "9111406deea4f07f807891405078a3f8537416b31ab03d78bda3f86d9ae4c584", @index=3, @previous_hash= "0b71b61118b9300b4fe8cdf4a7cbcc1dac4da7a8a3150aa97630994805354107", @timestamp=2018-06-04 12:13:21 +03:00, @transactions="Transaction data number (3)">, #<CrystalCoin::Block:0x109cd0980 @current_hash= "0063bfc5695c0d49b291a8813c566b047323f1808a428e0eb1fca5c399547875", @index=4, @previous_hash= "9111406deea4f07f807891405078a3f8537416b31ab03d78bda3f86d9ae4c584", @timestamp=2018-06-04 12:13:21 +03:00, @transactions="Transaction data number (4)">, #<CrystalCoin::Block:0x109cd0100 @current_hash= "00a0c70e5412edd7389a0360b48c407ce4ddc8f14a0bcf16df277daf3c1a00c7", @index=5, @previous_hash= "0063bfc5695c0d49b291a8813c566b047323f1808a428e0eb1fca5c399547875", @timestamp=2018-06-04 12:13:21 +03:00, @transactions="Transaction data number (5)">
Доказательство работы
Алгоритм Proof of Work (PoW) — это то, как новые блоки создаются или добываются в блокчейне. Цель PoW — найти число, которое решает проблему. Это число должно быть трудно найти, но легко проверить любым пользователем в сети. Это основная идея Proof of Work.
Давайте продемонстрируем на примере, чтобы убедиться, что все понятно. Предположим, что хэш некоторого целого числа x, умноженного на другое число y, должен начинаться с 00
. Так:
hash(x * y) = 00ac23dc...
И для этого упрощенного примера давайте исправим x=5
и реализуем это в Crystal:
x = 5 y = 0 while hash((x*y).to_s)[0..1] != "00" y += 1 end puts "The solution is y = #{y}" puts "Hash(#{x}*#{y}) = #{hash((x*y).to_s)}"
Запустим код:
crystal_coin [master●●] % time crystal src/crystal_coin/pow.cr The solution is y = 530 Hash(5*530) = 00150bc11aeeaa3cdbdc1e27085b0f6c584c27e05f255e303898dcd12426f110 crystal src/crystal_coin/pow.cr 1.53s user 0.23s system 160% cpu 1.092 total
Как видите, это число y=530
было трудно найти (грубым перебором), но легко проверить с помощью хеш-функции.
Зачем возиться с этим алгоритмом PoW? Почему бы нам просто не создать один хэш на блок и все? Хэш должен быть действительным . В нашем случае хэш будет действительным, если первые два символа нашего хеша равны 00
. Если наш хэш начинается с 00......
, он считается действительным. Это называется сложностью . Чем выше сложность, тем больше времени требуется для получения действительного хэша.
Но, если хэш недействителен в первый раз, что-то должно измениться в данных, которые мы используем. Если мы используем одни и те же данные снова и снова, мы будем получать один и тот же хэш снова и снова, и наш хэш никогда не будет действительным. Мы используем что-то, называемое nonce
в нашем хэше (в нашем предыдущем примере это y
). Это просто число, которое мы увеличиваем каждый раз, когда хэш недействителен. Мы получаем наши данные (дата, сообщение, предыдущий хеш, индекс) и одноразовый номер, равный 1. Если хеш, который мы получаем с ними, недействителен, мы пытаемся использовать одноразовый номер, равный 2. И мы увеличиваем одноразовый номер, пока не получим действительный хэш. .
В Биткойне алгоритм Proof of Work называется Hashcash. Давайте добавим доказательство работы в наш класс Block и начнем майнинг , чтобы найти одноразовый номер. Мы будем использовать жестко запрограммированную сложность из двух начальных нулей «00»:
Теперь давайте изменим наш класс Block, чтобы он поддерживал это. Наш блок CrystalCoin
будет содержать следующие атрибуты:
1) index: indicates the index of the block ex: 0,1 2) timestamp: timestamp in epoch, number of seconds since 1 Jan 1970 3) data: the actual data that needs to be stored on the blockchain. 4) previous_hash: the hash of the previous block, this is the chain/link between the blocks 5) nonce: this is the number that is to be mined/found. 6) current_hash: The hash value of the current block, this is generated by combining all the above attributes and passing it to a hashing algorithm
Я создам отдельный модуль для хеширования и поиска nonce
, чтобы наш код оставался чистым и модульным. Я назову его proof_of_work.cr
:
require "openssl" module CrystalCoin module ProofOfWork private def proof_of_work(difficulty = "00") nonce = 0 loop do hash = calc_hash_with_nonce(nonce) if hash[0..1] == difficulty return nonce else nonce += 1 end end end private def calc_hash_with_nonce(nonce = 0) sha = OpenSSL::Digest.new("SHA256") sha.update("#{nonce}#{@index}#{@timestamp}#{@data}#{@previous_hash}") sha.hexdigest end end end
Наш класс Block
будет выглядеть примерно так:
require "./proof_of_work" module CrystalCoin class Block include ProofOfWork property current_hash : String property index : Int32 property nonce : Int32 property previous_hash : String def initialize(index = 0, data = "data", previous_hash = "hash") @data = data @index = index @timestamp = Time.now @previous_hash = previous_hash @nonce = proof_of_work @current_hash = calc_hash_with_nonce(@nonce) end def self.first(data = "Genesis Block") Block.new(data: data, previous_hash: "0") end def self.next(previous_block, data = "Transaction Data") Block.new( data: "Transaction data number (#{previous_block.index + 1})", index: previous_block.index + 1, previous_hash: previous_block.current_hash ) end end end
Несколько замечаний о коде Crystal и примерах языка Crystal в целом. В Crystal методы общедоступны по умолчанию. Crystal требует, чтобы перед каждым закрытым методом стояло ключевое слово private, что может сбивать с толку из-за Ruby.
Вы могли заметить, что типы Integer в Crystal — это Int8
, Int16
, Int32
, Int64
, UInt8
, UInt16
, UInt32
или UInt64
по сравнению с Fixnum
в Ruby. true
и false
— это значения в классе Bool
, а не значения в классах TrueClass
или FalseClass
в Ruby.
Crystal имеет необязательные и именованные аргументы метода в качестве основных функций языка и не требует написания специального кода для обработки аргументов, что довольно круто. Проверьте Block#initialize(index = 0, data = "data", previous_hash = "hash")
а затем вызовите его с чем-то вроде Block.new(data: data, previous_hash: "0")
.
Более подробный список различий между языками программирования Crystal и Ruby см. в Crystal for Rubyists.
Теперь давайте попробуем создать пять транзакций, используя:
blockchain = [ CrystalCoin::Block.first ] puts blockchain.inspect previous_block = blockchain[0] 5.times do |i| new_block = CrystalCoin::Block.next(previous_block: previous_block) blockchain << new_block previous_block = new_block puts new_block.inspect end
[#<CrystalCoin::Block:0x108f8fea0 @current_hash="0088ca080a49334e1cb037ed4c42795d635515ef1742e6bcf439bf0f95711759", @index=0, @nonce=17, @timestamp=2018-05-14 17:20:46 +03:00, @data="Genesis Block", @previous_hash="0">] #<CrystalCoin::Block:0x108f8f660 @current_hash="001bc2b04d7ad8ef25ada30e2bde19d7bbbbb3ad42348017036b0d4974d0ccb0", @index=1, @nonce=24, @timestamp=2018-05-14 17:20:46 +03:00, @data="Transaction data number (1)", @previous_hash="0088ca080a49334e1cb037ed4c42795d635515ef1742e6bcf439bf0f95711759"> #<CrystalCoin::Block:0x109fc5ba0 @current_hash="0019256c998028111838b872a437cd8adced53f5e0f8f43388a1dc4654844fe5", @index=2, @nonce=61, @timestamp=2018-05-14 17:20:46 +03:00, @data="Transaction data number (2)", @previous_hash="001bc2b04d7ad8ef25ada30e2bde19d7bbbbb3ad42348017036b0d4974d0ccb0"> #<CrystalCoin::Block:0x109fdc300 @current_hash="0080a30d0da33426a1d4f36d870d9eb709eaefb0fca62cc68e497169c5368b97", @index=3, @nonce=149, @timestamp=2018-05-14 17:20:46 +03:00, @data="Transaction data number (3)", @previous_hash="0019256c998028111838b872a437cd8adced53f5e0f8f43388a1dc4654844fe5"> #<CrystalCoin::Block:0x109ff58a0 @current_hash="00074399d51c700940e556673580a366a37dec16671430141f6013f04283a484", @index=4, @nonce=570, @timestamp=2018-05-14 17:20:46 +03:00, @data="Transaction data number (4)", @previous_hash="0080a30d0da33426a1d4f36d870d9eb709eaefb0fca62cc68e497169c5368b97"> #<CrystalCoin::Block:0x109fde120 @current_hash="00720bb6e562a25c19ecd2b277925057626edab8981ff08eb13773f9bb1cb842", @index=5, @nonce=475, @timestamp=2018-05-14 17:20:46 +03:00, @data="Transaction data number (5)", @previous_hash="00074399d51c700940e556673580a366a37dec16671430141f6013f04283a484">
Увидеть разницу? Теперь все хэши начинаются с 00
. Это магия доказательства работы. С помощью ProofOfWork
мы нашли nonce
номер, а доказательство — это хеш с соответствующей сложностью, то есть два начальных нуля 00
.
Обратите внимание, что в первом созданном нами блоке мы попробовали 17 одноразовых номеров, пока не нашли подходящее счастливое число:
Блокировать | Циклы/количество хэш-вычислений |
---|---|
#0 | 17 |
#1 | 24 |
#2 | 61 |
#3 | 149 |
#4 | 570 |
#5 | 475 |
Теперь давайте попробуем сложность из четырех начальных нулей ( difficulty="0000"
):

Блокировать | Циклы/количество хэш-вычислений |
---|---|
#1 | 26 762 |
#2 | 68 419 |
#3 | 23 416 |
#4 | 15 353 |
В первом блоке перепробовано 26762 одноразовых номера (сравните 17 одноразовых номеров со сложностью «00»), пока не будет найдено подходящее счастливое число.
Наш блокчейн как API
Все идет нормально. Мы создали нашу простую цепочку блоков, и это было относительно легко сделать. Но проблема здесь в том, что CrystalCoin
может работать только на одной машине (она не распределена/децентрализована).
С этого момента мы начнем использовать данные JSON для CrystalCoin
. Данные будут транзакциями, поэтому поле данных каждого блока будет списком транзакций.
Каждая транзакция будет представлять собой объект JSON с подробным описанием sender
монеты, receiver
монеты и amount
передаваемых CrystalCoin:
{ "from": "71238uqirbfh894-random-public-key-a-alkjdflakjfewn204ij", "to": "93j4ivnqiopvh43-random-public-key-b-qjrgvnoeirbnferinfo", "amount": 3 }
Несколько модификаций нашего класса Block
для поддержки нового формата transaction
(ранее называвшегося data
). Итак, чтобы избежать путаницы и сохранить согласованность, мы будем использовать термин transaction
для обозначения data
, размещенных в нашем примере приложения.
Мы представим новый простой класс Transaction
:
module CrystalCoin class Block class Transaction property from : String property to : String property amount : Int32 def initialize(@from, @to, @amount) end end end end
Транзакции упакованы в блоки, поэтому блок может содержать только одну или несколько транзакций. Блоки, содержащие транзакции, часто генерируются и добавляются в блокчейн.
Блокчейн должен быть набором блоков. Мы можем хранить все блоки в списке кристаллов, поэтому мы вводим новый класс Blockchain
:
Blockchain
будет иметь массивы chain
и uncommitted_transactions
. chain
будет включать в себя все добытые блоки в блокчейне, а в uncommitted_transactions
будут все транзакции, которые не были добавлены в блокчейн (все еще не добытые). После инициализации Blockchain
мы создаем блок генезиса с помощью Block.first
и добавляем его в массив chain
, а также добавляем пустой массив uncommitted_transactions
.
Мы создадим метод Blockchain#add_transaction
для добавления транзакций в массив uncommitted_transactions
.
Давайте создадим наш новый класс Blockchain
:
require "./block" require "./transaction" module CrystalCoin class Blockchain getter chain getter uncommitted_transactions def initialize @chain = [ Block.first ] @uncommitted_transactions = [] of Block::Transaction end def add_transaction(transaction) @uncommitted_transactions << transaction end end end
В классе Block
мы начнем использовать transactions
вместо data
:
module CrystalCoin class Block include ProofOfWork def initialize(index = 0, transactions = [] of Transaction, previous_hash = "hash") @transactions = transactions ... end .... def self.next(previous_block, transactions = [] of Transaction) Block.new( transactions: transactions, index: previous_block.index + 1, previous_hash: previous_block.current_hash ) end end end
Теперь, когда мы знаем, как будут выглядеть наши транзакции, нам нужен способ добавить их на один из компьютеров в нашей сети блокчейнов, называемый node
. Для этого мы создадим простой HTTP-сервер.
Мы создадим четыре конечных точки:
- [POST]
/transactions/new
: создать новую транзакцию в блоке - [GET]
/mine
: сообщить нашему серверу о необходимости майнить новый блок. - [GET]
/chain
: чтобы вернуть полную цепочку блоков в форматеJSON
. - [GET]
/pending
: вернуть ожидающие транзакции (uncommitted_transactions
).
Мы собираемся использовать веб-фреймворк Kemal. Это микроплатформа, упрощающая сопоставление конечных точек с функциями Crystal. Кемаль находится под сильным влиянием Sinatra for Rubyists и работает очень похожим образом. Если вы ищете аналог Ruby on Rails, обратите внимание на Amber.
Наш сервер сформирует единый узел в нашей сети блокчейн. Давайте сначала добавим Kemal
в файл shard.yml
как и установим зависимость:
dependencies: kemal: github: kemalcr/kemal
Теперь давайте построим скелет нашего HTTP-сервера:
# src/server.cr require "kemal" require "./crystal_coin" # Generate a globally unique address for this node node_identifier = UUID.random.to_s # Create our Blockchain blockchain = Blockchain.new get "/chain" do "Send the blockchain as json objects" end get "/mine" do "We'll mine a new Block" end get "/pending" do "Send pending transactions as json objects" end post "/transactions/new" do "We'll add a new transaction" end Kemal.run
И запустите сервер:
crystal_coin [master●●] % crystal run src/server.cr [development] Kemal is ready to lead at http://0.0.0.0:3000
Убедимся, что сервер работает нормально:
% curl http://0.0.0.0:3000/chain Send the blockchain as json objects%
Все идет нормально. Теперь мы можем приступить к реализации каждой из конечных точек. Начнем с реализации конечных точек /transactions/new
и pending
:
get "/pending" do { transactions: blockchain.uncommitted_transactions }.to_json end post "/transactions/new" do |env| transaction = CrystalCoin::Block::Transaction.new( from: env.params.json["from"].as(String), to: env.params.json["to"].as(String), amount: env.params.json["amount"].as(Int64) ) blockchain.add_transaction(transaction) "Transaction #{transaction} has been added to the node" end
Наглядная реализация. Мы просто создаем объект CrystalCoin::Block::Transaction
и добавляем транзакцию в массив uncommitted_transactions
, используя Blockchain#add_transaction
.
На данный момент транзакции изначально хранятся в пуле uncommitted_transactions
. Процесс помещения неподтвержденных транзакций в блок и вычисления Proof of Work (PoW) известен как майнинг блоков. Как только nonce
, удовлетворяющий нашим ограничениям, мы можем сказать, что блок был добыт, и новый блок помещается в цепочку блоков.
В CrystalCoin
мы будем использовать простой алгоритм Proof-of-Work, который мы создали ранее. Для создания нового блока компьютер майнера должен будет:
- Найдите последний блок в
chain
. - Найти ожидающие транзакции (
uncommitted_transactions
). - Создайте новый блок, используя
Block.next
. - Добавьте добытый блок в массив
chain
. - Очистить массив
uncommitted_transactions
.
Итак, чтобы реализовать конечную точку /mine
, давайте сначала реализуем описанные выше шаги в Blockchain#mine
:
module CrystalCoin class Blockchain include Consensus BLOCK_SIZE = 25 ... def mine raise "No transactions to be mined" if @uncommitted_transactions.empty? new_block = Block.next( previous_block: @chain.last, transactions: @uncommitted_transactions.shift(BLOCK_SIZE) ) @chain << new_block end end end
Сначала мы убеждаемся, что у нас есть несколько ожидающих транзакций для майнинга. Затем мы получаем последний блок, используя @chain.last
, и первые 25
транзакций, которые нужно добыть (мы используем Array#shift(BLOCK_SIZE)
для возврата массива первых 25 uncommitted_transactions
, а затем удаляем элементы, начиная с индекса 0) .
Теперь давайте реализуем конечную точку /mine
:
get "/mine" do blockchain.mine "Block with index=#{blockchain.chain.last.index} is mined." end
И для конечной точки /chain
:
get "/chain" do { chain: blockchain.chain }.to_json end
Взаимодействие с нашим блокчейном
Мы будем использовать cURL
для взаимодействия с нашим API по сети.
Сначала запустим сервер:
crystal_coin [master] % crystal run src/server.cr [development] Kemal is ready to lead at http://0.0.0.0:3000
Затем давайте создадим две новые транзакции, отправив запрос POST
на http://localhost:3000/transactions/new
с телом, содержащим нашу структуру транзакции:
crystal_coin [master●] % curl -X POST http://0.0.0.0:3000/transactions/new -H "Content-Type: application/json" -d '{"from": "eki", "to":"iron_man", "amount": 1000}' Transaction #<CrystalCoin::Block::Transaction:0x10c4159f0 @from="eki", @to="iron_man", @amount=1000_i64> has been added to the node% crystal_coin [master●] % curl -X POST http://0.0.0.0:3000/transactions/new -H "Content-Type: application/json" -d '{"from": "eki", "to":"hulk", "amount": 700}' Transaction #<CrystalCoin::Block::Transaction:0x10c415810 @from="eki", @to="hulk", @amount=700_i64> has been added to the node%
Теперь давайте перечислим ожидающие транзакции (то есть транзакции, которые еще не были добавлены в блок):
crystal_coin [master●] % curl http://0.0.0.0:3000/pendings { "transactions":[ { "from":"ekis", "to":"huslks", "amount":7090 }, { "from":"ekis", "to":"huslks", "amount":70900 } ] }
Как мы видим, две созданные ранее транзакции были добавлены в uncommitted_transactions
.
Теперь давайте проанализируем две транзакции, отправив запрос GET
на http://0.0.0.0:3000/mine
:
crystal_coin [master●] % curl http://0.0.0.0:3000/mine Block with index=1 is mined.
Похоже, мы успешно добыли первый блок и добавили его в нашу chain
. Давайте дважды проверим нашу chain
и если она включает в себя добытый блок:
crystal_coin [master●] % curl http://0.0.0.0:3000/chain { "chain": [ { "index": 0, "current_hash": "00d469d383005b4303cfa7321c02478ce76182564af5d16e1a10d87e31e2cb30", "nonce": 363, "previous_hash": "0", "transactions": [ ], "timestamp": "2018-05-23T01:59:52+0300" }, { "index": 1, "current_hash": "003c05da32d3672670ba1e25ecb067b5bc407e1d5f8733b5e33d1039de1a9bf1", "nonce": 320, "previous_hash": "00d469d383005b4303cfa7321c02478ce76182564af5d16e1a10d87e31e2cb30", "transactions": [ { "from": "ekis", "to": "huslks", "amount": 7090 }, { "from": "ekis", "to": "huslks", "amount": 70900 } ], "timestamp": "2018-05-23T02:02:38+0300" } ] }
Консенсус и децентрализация
Это здорово. Мы получили базовый блокчейн, который принимает транзакции и позволяет нам добывать новые блоки. Но код, который мы реализовали до сих пор, предназначен для работы на одном компьютере, а весь смысл блокчейнов в том, что они должны быть децентрализованы. Но если они децентрализованы, как мы гарантируем, что все они отражают одну и ту же цепочку?
Это проблема Consensus
.
Нам придется реализовать алгоритм консенсуса, если мы хотим иметь более одного узла в нашей сети.
Регистрация новых узлов
Чтобы реализовать алгоритм консенсуса, нам нужен способ сообщить узлу о соседних узлах в сети. Каждый узел в нашей сети должен вести реестр других узлов в сети. Поэтому нам понадобится больше конечных точек:
- [POST]
/nodes/register
: чтобы принять список новых узлов в виде URL-адресов. - [GET]
/nodes/resolve
: для реализации нашего алгоритма консенсуса, который разрешает любые конфликты, чтобы убедиться, что узел имеет правильную цепочку.
Нам нужно изменить конструктор нашего блокчейна и предоставить метод для регистрации узлов:
--- a/src/crystal_coin/blockchain.cr +++ b/src/crystal_coin/blockchain.cr @@ -7,10 +7,12 @@ module CrystalCoin getter chain getter uncommitted_transactions + getter nodes def initialize @chain = [ Block.first ] @uncommitted_transactions = [] of Block::Transaction + @nodes = Set(String).new [] of String end def add_transaction(transaction)
Обратите внимание, что мы использовали структуру данных Set
с типом String
для хранения списка узлов. Это дешевый способ гарантировать, что добавление новых узлов является идемпотентным и что независимо от того, сколько раз мы добавляем конкретный узел, он появляется ровно один раз.
Теперь добавим в Consensus
новый модуль и реализуем первый метод register_node(address)
:
require "uri" module CrystalCoin module Consensus def register_node(address : String) uri = URI.parse(address) node_address = "#{uri.scheme}:://#{uri.host}" node_address = "#{node_address}:#{uri.port}" unless uri.port.nil? @nodes.add(node_address) rescue raise "Invalid URL" end end end
Функция register_node
будет анализировать URL-адрес узла и форматировать его.
И здесь давайте создадим конечную точку /nodes/register
:
post "/nodes/register" do |env| nodes = env.params.json["nodes"].as(Array) raise "Empty array" if nodes.empty? nodes.each do |node| blockchain.register_node(node.to_s) end "New nodes have been added: #{blockchain.nodes}" end
Теперь с этой реализацией мы можем столкнуться с проблемой нескольких узлов. Копия цепочек из нескольких узлов может отличаться. В таком случае нам нужно согласовать какую-то версию цепочки, чтобы сохранить целостность всей системы. Нам нужно достичь консенсуса.
Чтобы решить эту проблему, мы установим правило, согласно которому будет использоваться самая длинная допустимая цепочка. Используя этот алгоритм, мы достигаем консенсуса среди узлов в нашей сети. Причина этого подхода заключается в том, что самая длинная цепочка является хорошей оценкой наибольшего объема выполненной работы.
module CrystalCoin module Consensus ... def resolve updated = false @nodes.each do |node| node_chain = parse_chain(node) return unless node_chain.size > @chain.size return unless valid_chain?(node_chain) @chain = node_chain updated = true rescue IO::Timeout puts "Timeout!" end updated end ... end end
Имейте в виду, что resolve
— это метод, который перебирает все наши соседние узлы, загружает их цепочки и проверяет их с помощью valid_chain?
метод. If a valid chain is found, whose length is greater than ours, we replace ours.
Now let's implement parse_chain()
and valid_chain?()
private methods:
module CrystalCoin module Consensus ... private def parse_chain(node : String) node_url = URI.parse("#{node}/chain") node_chain = HTTP::Client.get(node_url) node_chain = JSON.parse(node_chain.body)["chain"].to_json Array(CrystalCoin::Block).from_json(node_chain) end private def valid_chain?(node_chain) previous_hash = "0" node_chain.each do |block| current_block_hash = block.current_hash block.recalculate_hash return false if current_block_hash != block.current_hash return false if previous_hash != block.previous_hash return false if current_block_hash[0..1] != "00" previous_hash = block.current_hash end return true end end end
For parse_chain()
we:
- Issue a
GET
HTTP request usingHTTP::Client.get
to/chain
end-point. - Parse the
/chain
JSON response usingJSON.parse
. - Extract an array of
CrystalCoin::Block
objects from the JSON blob that was returned usingArray(CrystalCoin::Block).from_json(node_chain)
.
There is more than one way of parsing JSON in Crystal. The preferred method is to use Crystal's super-handy JSON.mapping(key_name: Type)
functionality that gives us the following:
- A way to create an instance of that class from a JSON string by running
Class.from_json
. - A way to serialize an instance of that class into a JSON string by running
instance.to_json
. - Getters and setters for keys defined in that class.
In our case, we had to define JSON.mapping
in CrystalCoin::Block
object, and we removed property
usage in the class, like so:
module CrystalCoin class Block JSON.mapping( index: Int32, current_hash: String, nonce: Int32, previous_hash: String, transactions: Array(Transaction), timestamp: Time ) ... end end
Now for Blockchain#valid_chain?
, we iterate through all of the blocks, and for each we:
- Recalculate the hash for the block using
Block#recalculate_hash
and check that the hash of the block is correct:
module CrystalCoin class Block ... def recalculate_hash @nonce = proof_of_work @current_hash = calc_hash_with_nonce(@nonce) end end end
- Check each of the blocks linked correctly with their previous hashes.
- Check the block's hash is valid for the number of zeros (
difficulty
in our case00
).
And finally we implement /nodes/resolve
end-point:
get "/nodes/resolve" do if blockchain.resolve "Successfully updated the chain" else "Current chain is up-to-date" end end
Готово! You can find the final code on GitHub.
The structure of our project should look like this:
crystal_coin [master●] % tree src/ src/ ├── crystal_coin │ ├── block.cr │ ├── blockchain.cr │ ├── consensus.cr │ ├── proof_of_work.cr │ ├── transaction.cr │ └── version.cr ├── crystal_coin.cr └── server.cr
Let's Try it Out
- Grab a different machine, and run different nodes on your network. Or spin up processes using different ports on the same machine. In my case, I created two nodes on my machine, on a different port to have two nodes:
http://localhost:3000
andhttp://localhost:3001
. - Register the second node address to the first node using:
crystal_coin [master●●] % curl -X POST http://0.0.0.0:3000/nodes/register -H "Content-Type: application/json" -d '{"nodes": ["http://0.0.0.0:3001"]}' New nodes have been added: Set{"http://0.0.0.0:3001"}%
- Let's add a transaction to the second node:
crystal_coin [master●●] % curl -X POST http://0.0.0.0:3001/transactions/new -H "Content-Type: application/json" -d '{"from": "eqbal", "to":"spiderman", "amount": 100}' Transaction #<CrystalCoin::Block::Transaction:0x1039c29c0> has been added to the node%
- Let's mine transactions into a block at the second node:
crystal_coin [master●●] % curl http://0.0.0.0:3001/mine Block with index=1 is mined.%
- At this point, the first node has only one block (genesis block), and the second node has two nodes (genesis and the mined block):
crystal_coin [master●●] % curl http://0.0.0.0:3000/chain {"chain":[{"index":0,"current_hash":"00fe9b1014901e3a00f6d8adc6e9d9c1df03344dda84adaeddc8a1c2287fb062","nonce":157,"previous_hash":"0","transactions":[],"timestamp":"2018-05-24T00:21:45+0300"}]}%
crystal_coin [master●●] % curl http://0.0.0.0:3001/chain {"chain":[{"index":0,"current_hash":"007635d82950bc4b994a91f8b0b20afb73a3939e660097c9ea8416ad614faf8e","nonce":147,"previous_hash":"0","transactions":[],"timestamp":"2018-05-24T00:21:38+0300"},{"index":1,"current_hash":"00441a4d9a4dfbab0b07acd4c7639e53686944953fa3a6c64d2333a008627f7d","nonce":92,"previous_hash":"007635d82950bc4b994a91f8b0b20afb73a3939e660097c9ea8416ad614faf8e","transactions":[{"from":"eqbal","to":"spiderman","amount":100}],"timestamp":"2018-05-24T00:23:57+0300"}]}%
- Our goal is to update the chain in the first node to include the newly generated block at the second one. So let's resolve the first node:
crystal_coin [master●●] % curl http://0.0.0.0:3000/nodes/resolve Successfully updated the chain%
Let's check if the chain in the first node has updated:
crystal_coin [master●●] % curl http://0.0.0.0:3000/chain {"chain":[{"index":0,"current_hash":"007635d82950bc4b994a91f8b0b20afb73a3939e660097c9ea8416ad614faf8e","nonce":147,"previous_hash":"0","transactions":[],"timestamp":"2018-05-24T00:21:38+0300"},{"index":1,"current_hash":"00441a4d9a4dfbab0b07acd4c7639e53686944953fa3a6c64d2333a008627f7d","nonce":92,"previous_hash":"007635d82950bc4b994a91f8b0b20afb73a3939e660097c9ea8416ad614faf8e","transactions":[{"from":"eqbal","to":"spiderman","amount":100}],"timestamp":"2018-05-24T00:23:57+0300"}]}%
Ура! Our Crystal language example works like a charm, and I hope you found this lengthy tutorial crystal clear, pardon the pun.
Подведение итогов
This Crystal language tutorial covered the fundamentals of a public blockchain. If you followed along, you implemented a blockchain from scratch and built a simple application allowing users to share information on the blockchain.
We've made a fairly sized blockchain at this point. Now, CrystalCoin
can be launched on multiple machines to create a network, and real CrystalCoins
can be mined.
I hope that this has inspired you to create something new, or at the very least take a closer look at Crystal programming.
Note: The code in this tutorial is not ready for real-world use. Please refer to this as a general guide only.