RuCore.NET

Криптовалюта: сериализация данных





В этой статье я бы хотел начать описывать более конкретно, с примерами и пояснениями, всё то, что было в статье выше. Не знаю как вам, а мне всегда было проще учиться на конкретных примерах. Поэтому я предлагаю вам эксперимент: мы всем каналом создадим криптовалюту с нуля!

 



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

Итак

Первый этап любой криптовалюты – это определиться с данными, которые будут ходить внутри сети.

Список данных такой:

  • транзакции
  • блоки
  • адреса

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

Транзакции

Для примера – транзакции в биткоине выглядят как последовательность следующих параметров:

  • версия version
  • векторный массив входов tx_in
  • векторный массив выходов tx_out
  • переменная Lock_time (то есть блокировка по времени)

version

Что касается версии транзакции: данное поле нужно для совместимости. Возможно, в будущих версиях транзакции что-то будет поменяться, как, например, в биткоине добавилась поддержка segwit (segregate witness). Это позволило сделать транзакции с подписями и транзакции без подписей, что позволило хранить информацию о подписях. То есть основную часть массива данных, которая хранится в сети биткойна. Таким образом, ноды могут как хранить информацию о транзакциях, так и не хранить, что существенно уменьшает размер блокчейна, ведь подписи – примерно 70% от данных, которые хранятся нодами. Соответственно, этот segwit создал необходимость в двух хешах транзакций. Один хеш, как и в оригинальной версии транзакций – генерируется от всей транзакции, а второй – от транзакции без подписей.

Подпись вынесена за основные поля транзакции. В итоге получилось два хэша, связанные с одной транзакцией. Следовательно, зная версию транзакции, мы можем понять, с чем мы имеем дело. Если транзакция не содержит подписи, мы можем обратиться в блокчейн к другим нодам и получить подписи для этой транзакции. Это очень краткое и обобщенное описание segwit. Конечно же, там еще много моментов, но в контексте нейминга транзакций всё выглядит так.

Транзакции с segwit и без – имеют разное значение поля version. Кроме того, софт форки в сети (применение обновлений) прямо связаное с изменением структуры транзакций – прямо влияет на значение поля version.

Tx_in[]

Массив входов определяет из каких неиспользованных выходов мы будет тратить. Вообще, неиспользованные выходы (UTXO) – это тема для отдельной статьи, но если кратко: неиспользуемые выходы связаны с вашими монетами, которые ещё не потрачены. Для того чтобы их потратить необходимо указать транзакцию, с помощью которой вам отправили монеты и индекс в списке выходов предыдущей транзакции.

То есть, нужно указать, в какой именно транзакции (hash) и выходе (index) отправили вам монеты, и подписать эти данные своим приватным ключом. Связка hash:index является UTXO и связывается с адресом, на который эти монеты уходят. Соответственно все адреса в сети, имеющие хоть немного монет – имеют такие связки в UTXO индексе. Соответственно каждый элемент tx_in выглядит как хеш транзакции и индекс выхода в этой транзакции, где монеты, которые вы собрались тратить – были получены, а так же цифровая подпись от хеша транзакции и публичный ключ от приватного, которым были подписаны эти данные — для сверки подписи.

Так как монеты отсылаются на публичный ключ (адрес в конечном счете это публичный ключ, правда в сети биткоина – немного модифицированный хешем), подпись приватным ключом от именно этого публичного ключа получателя монет гарантирует всем участникам обмена, что эти монеты действительно могут быть потрачены этим участником. Массив входов формирует сумму, которую мы тратим в этой транзакции.

Tx_out[]

Если говорить просто, то массив выходов – это список адресатов и сумм, которые мы отправляем. Перечисление адресатов, которым необходимо отправить, и количество монет, которые необходимо отправить. Именно из этого массива формируется будущие UTXO, когда транзакция попадает в сеть.

Получается, входы и выходы транзакции формируют входную и выходную сумму соответственно, а разница между этими суммами – это комиссия майнера.

lock_time

Последний параметр, который описан в протоколе биткоина, это lock_time, так называемая переменная блокировки. Она указывает с какого блока данная транзакция может быть разблокирована и добавлена в общую сеть, или с какого времени. Всё зависит от значения этой величины. Сейчас по умолчанию данное значение ставится как 0 и не используется, но оно может быть задействовано и если оно будет меньше 500 000 000, то оно будет означать высоту блока, до которого эта транзакция заблокирована и не может быть добавлена в сеть. Если же значение будет больше этого числа, значит, это время в unixtime формате, после которого данная транзакция разблокируется и будет добавлена в ближайший блок.

Блоки

С транзакциями понятно, переходим к блокам. Я опишу блоки в сети биткоина, а далее мы перейдем к реализации нашей криптовалюты.

Информация о блоке делится на две части: заголовок блока и тело блока. С телом блока всё просто – это список транзакций, которые входят в блок. С заголовком блока всё гораздо сложнее. Итак, начнем. Поля блока – это:

  • Версия;
  • Хеш предыдущего блока;
  • Так называемый merkle root, корень дерева меркла;
  • timestamp, то есть время, когда этот блок был произведен и добавлен в сеть;
  • Поле bits, которое описывает сложность создания данного блока в консенсус;
  • Случайное число, необходимое при майнинге и проверки достоверности.

Version

С полем версии всё то же самое, как и в транзакции. От версии к версии в нодах формат блока может меняться. Поэтому версия необходима для дальнейших возможностей изменения сети и формата общения. Например, официальные клиенты биткоина сигнализируют о том, что пришла неизвестная версия блока, если в сети что-то изменилось. Последний раз такое было, когда ввели segregate witness. И те люди, у которых клиент был не обновлен, получили уведомление о том, что необходимо обновиться.

Prev Block hash

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

Merkle

Так называемый merkle root, или корень дерева хешей, представляет собой двоичное дерево, в листовые вершины которого помещены хеши транзакций, которые записаны в теле блока. Внутренние вершины содержат хеши сложения значений дочерних вершин. Таким образом, корневой узел содержит весь набор данных в этом дереве. Он описывает сумму всех транзакций, а также позволяет получить отпечаток всех транзакций в блоке. Зачем это нужно? Данная структура позволяет получить информацию о том, содержится ли какая-либо транзакция в блоке, не перечисляя все транзакции. Это необходимо для так называемых узких клиентов, которые не скачивают полный блокчейн, а скачивают заголовки без тела блока. Следовательно, получая какую-либо транзакцию, они могут проверить, принадлежит ли она блоку, не скачивая весь блокчейн. Если имеется четыре транзакции, то чтобы проверить одну транзакцию на принадлежность к merkle tree, нужно запросить у полной ноды всего лишь два промежуточных значения этого дерева. Кроме того, корень дерева хешей позволяет эффективно описать все транзакции одним хешем, что ускоряет работу майнера. Это избавляет его от необходимости перебирать все транзакции, что оптимизирует работу всей сети в целом.

time

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

Остается обсудить два параметра: bits – параметр для майнинга, а также nonce — случайное число. Предлагаю рассмотреть метод формирования блока майнером, потому что именно от этого зависят последние два параметра.

Формирование блока, bits, nonce

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

Итак, майнер берет уже имеющуюся у него информацию о существующих транзакциях, добавляет в тело блока первой транзакцией — coinbase транзакцию со своими данными, генерирует merkleroot для обновившегося списка транзакций блока и генерирует header блока hash(version, prevblock, merkleroot, time,bits,nonce), где nonce – это 0. После этого он начинает поиск блока путем перебора значения nonce.

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

Суть алгоритма сводится к тому, что мы берем список последних n блоков нашего блокчейна и сверяем среднее время создания блока. Если время блока меньше расчетного (для биткоина это 1 блок в 10 минут), мы увеличиваем bits, тем самым усложняя поиск хеша. Если же время больше расчетного, то сложность уменьшается пропорционально времени, тем самым облегчая поиск нового хеша и нового блока для майнера (сам процесс майнинга я опишу в отдельной статье, не переключайтесь). Таким образом, информация для проверки хеша записана в header блока, из которого этот хеш и генерируется. Для проверки блока не нужен полный блокчейн, достаточно знать хеш и поле bits, из которого можно получить сложность и узнать, является ли сложность подходящей для этого хеша. И хеш, и сложность – это большие числа unsigned int 128. Значит, проверка сложности – просто операция сравнения двух чисел. Больших, но чисел. Поиск блока является операцией хеширования нашего хедера с изменением последнего поля, поля случайного числа, nonce. Изменение происходит каждую миллисекунду (или чаще, зависит от хешрейта устройства), до тех пор, пока итоговый хеш не подойдет по сложности. Когда это происходит – майнер отправляет блок в сеть, и все участники сети могут проверить этот блок. Это и есть алгоритм майнинга в сжатом виде и кратко. Получается, данный алгоритм может быть произведен обычной ручкой на тетрадном листочке, и для этого не нужны вычислительные средства, но тогда скорость добавления нового блока в сеть будет, как вы понимаете, значительно ниже. Поэтому и используются компьютеры!

Адреса

Третьим блоком данных являются адреса. Например, в блокчейне эфира адреса – это просто публичный ключ. В блокчейне биткоина адрес – это кодированный в base58 хеш ripemd160 от публичного ключа (один из вариантов). Это было сделано для сохранения приватности и анонимности, но современные реалии позволяют определять источники транзакции. Эта мера теперь считается избыточной. В более глобальном смысле адрес в bitcoin это scriptPubKey – скрипт входного ключа, который может быть довольно разнообразным, но используются всего пару вариантов.  Кодировка base58 была выбрана неспроста. Она позволяет выделять кликом мышки весь адрес. В отличие от распространённой base64, base58 не содержит символов нижнего подчеркивания и некоторых других символов, которые разделяют клик мышкой на две части. Создатель биткоина, Сатоши Накомото, предусмотрел эту возможность изначально и сделал адреса более читабельными и удобными. В адресе биткоина содержится контрольная сумма для проверки правильности адреса. Кроме того, содержится и версия адреса, что очень помогает при создании segwit адресов. Как я уже писал, segwit позволяет разделить блокчейн на две части: с подписями транзакций и без них, но при этом оставить их связанными. Для блокчейна без подписи используются адреса другой версии. Тот, кто получается биткоины, сразу понимает, какую версию и какой протокол использовать. В этом суть версии адресов. Возможно, при отправке или при получении биткоина вы видели, что бывают адреса, которые начинаются с тройки. Это и есть версия адреса.

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

P2PKH (pay-to-public-key-hash)

OP_DUP

OP_HASH160

[hash160(public_key)]

OP_EQUALVERIFY

OP_CHECKSIG

P2SH (pay-to-script-hash)

OP_HASH160 OP_DATA_20 OP_EQUAL

P2PK (pay-to-public-key)

OP_DATA_65 OP_CHECKSIG

Multisignature

OP_1 OP_DATA_65 <…> OP_DATA_65 OP_3 OP_CHECKMULTISIG

Создание

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

Предлагаю избавиться от некоторых полей.

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

Далее я бы оставил список входов транзакций, но изменил бы его: вместо замудрёного формата биткоина я бы оставил всего четыре значения:

  • Хеш входной транзакции;
  • Индекс выхода транзакции, с которой получены монеты, которые мы сейчас отправляем;
  • Подпись, которая связана с адресом, на которые были получены монеты;
  • Публичный ключ от приватного ключа, на который мы получили монеты.

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

Далее необходимо определиться, каким образом эти данные будут преобразовываться в хеш, то есть каким образом будет получаться хеш от заголовка блока и хеш от транзакции. Это необходимо для того, чтобы получать верные хеши от наборов данных отсортированных по-разному, к примеру. Конечно, можно было использовать хеш от json объекта, но если поменять пару ключей местами – правильность json объекта не изменится, а вот хеш может поменяться полностью. Предлагаю следующий формат: все данные, предоставленные в нем, преобразуются в бинарный формат, группируются вместе, и производится хеширование алгоритмом sha256.

Итоговый формат транзакции

{

             version: uint32,

             tx_in: vector_txin,

             tx_out: vector_txout

}

 

Tx_in

{

             Hash: char[32],

             Index: uint32,

             sign: var_str,

             pub: var_str

}

 

Tx_Out

{

             amout: uint64

             address: var_str

}

 

Итоговый формат блока

{

             version: uint32,

             prev: char[32],

             merkle: char[32],

             time: uint32

             bits: uint32

             nonce: uint32,

             txlist: vector_tx

}

Итоговый формат сериализации данных

Block:

Version + prev + merkle + time + bits + nonce + txlist

Tx:

Version + tx_in[] + txout[]

Tx_in:

Hash + index + sign + pubkey

Tx_out:

Address + amount

Отдельно стоит остановиться на типах данных. Uint[8-64] – это числа с разными разрядностями, например uint8 – 1 байт, uint16 – 2 байта, итд. Char[n] – строка фиксированной длины n.

Var_int – специальное поле, которое означает число переменной длины. Данное поле может хранить как uint8, так и uint64 в зависимости от значения. Если значение меньше 0xfd – то это uint8, если меньше 0xffff – uint16, меньше 0xffffffff – uint32, и больше – uint64. Таким образом это компактная форма записи чисел в байт-последовательностях, что актуально для bitcoin протокола, в котором могут участвовать легкие клиенты с ограниченным трафиком, например телефоны – где каждый байт на счету.

Var_str представляет из себя строку переменной длины, она выглядит как count(var_int)+str(char). Где длина char указана в параметре count и может быть произвольной.

Кроме того, векторные форматы представляют из себя var_int + type_data. Т.е. первым этапом указывается количество элементов типа type, а далее идет перечисление этих элементов.

Все примеры с рабочим исходным кодом можно посмотреть на github:

//github.com/gettocat/coinreview

Код первой части:

//github.com/gettocat/coinreview/blob/master/examples/serialize.js

Подробнее про этот код. Мы создали в нашем приложении модуль chain, в котором пока есть только 2 субмодуля block и tx, которые доступны во всем приложении через app.chain.TX/app.chain.BLOCK. На данный момент эти субмодули имеют всего пару методов serialize и unserialize, которые пакуют данные в byte-строку и распаковывают в json соответственно.

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

 

Источник: Телеграмм



Поделись статьей с друзьями


61 просмотров



Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: