Введение
1. Изучаем теорию
Термины и определения
Элементы криптографии
Структура данных
Сетевая организация
Консенсус
2. Проектируем блокчейн
Алгоритм консенсуса
Архитектура
Компоненты
Технологии и протоколы
Выбор узлов для консенсуса
Создание транзакции
Хранение транзакций
Предварительная валидация
Сетевое взаимодействие
Публичные и приватные узлы
Широковещательная рассылка
Коллектор
Обнаружение узлов
Ветвления
Слияние ветвей
Синхронизация
Полные и облегченные узлы
Защита от DDoS инцидентов
Моделирование атак
Награда за запрос данных
Динамическая модификация кода
Утечка памяти
3. Проводим ретроспективу
Особенности децентрализованных систем
Отладка
Процесс разработки
Введение
На протяжении нескольких лет я участвовал в разработке блокчейн-платформы, вначале как разработчик, затем как руководитель команды (team lead, как это принято сейчас называть). За это время мне посчастливилось пройти все стадии жизненного цикла продукта, от идеи до выхода в продакшн. И это был уникальный опыт, в каком-то смысле драматический. Дело в том, что в процессе своей довольно длительной карьеры я участвовал в огромном множестве самых различных проектов, но до сих мне не приходилось сталкиваться с таким количеством нетривиальных задач, которые требовали нестандартных решений. И я хотел бы поделиться этим опытом.
Для кого? В первую очередь для специалистов, занимающихся проектированием блокчейн-систем – ведь всегда полезно посмотреть, что происходит у коллег по цеху. Руководителям команд, мне кажется, будет интересно понаблюдать за борьбой идей, процессом выработки решений, допущенными ошибками и их исправлениями. Остальным категориям разработчиков ПО представится возможность заглянуть во внутреннюю кухню блокчейна, увидеть, как это работает «под капотом», и применить полученные знания в других областях, не обязательно связанных с блокчейном.
Платформа разрабатывалась на языке C++, но это не должно останавливать читателей, которые не используют этот язык. Наш рассказ не про программирование, а про проектирование. Проектирование представляет собой построение моделей, которые могут описываться самыми различными способами – как на естественном языке, так и с помощью специализированных инструментов, таких, как, к примеру, UML. В этом процессе главное – правильно поставить задачу, понять, как ее можно реализовать, как увязать взаимоисключающие требования, какое решение будет эффективным, как оно повлияет на остальные части системы, какие подводные камни потенциально могут возникнуть. А после того, как на поставленные вопросы получены ответы, разработана концепция, детализирован алгоритм, программирование становится делом техники. В нашем рассказе немногочисленные вставки кода будут использоваться только для иллюстрации разрабатываемых моделей, и, мне кажется, даже те читатели, кто не знаком с C++, без особых усилий поймут их суть.
Предварительно я очень кратко обрисую теоретические основы блокчейна, поскольку это совершенно необходимо для понимания излагаемого материала. Разработчики, знакомые с указанной предметной областью, могут эту часть пропустить, и сразу приступить ко второму разделу, где речь идет непосредственно про разработку платформы.
1. Изучаем теорию
Термины и определения
В настоящее время отсутствует академическое определение термина «блокчейн», и под ним понимают различные области знаний.
Блокчейн как структура данных представляет собой выстроенную по определённым правилам цепочку блоков (связный список), содержащих информацию. Но, в отличие от обычного связанного списка, в блокчейне каждый блок зависит от предыдущего.
Блокчейн как технология представляет собой сочетание структуры данных, алгоритмов, криптографии, методов обеспечения безопасности.
Блокчейн как система представляет собой распределенную пиринговую программную систему, использующую технологии блокчейна.
Рассмотрим теперь базовые понятия блокчейна.
Токен. Единица учёта, предназначенная для представления цифрового баланса. Токеном могут быть различные понятия, определяющие какие-либо активы. Это, например, криптовалюта, акции, доли в уставном фонде, и т. п.
Транзакция. Описание какой-либо операции. Это может быть, к примеру, пересылка денежных сумм, передача прав владения, и т. п.
Блок. Структура данных для хранения транзакций.
Цепочка блоков. Набор блоков, в котором содержимое каждого блока зависит от предыдущего.
Состояние. Совокупность данных на момент выполнения последней транзакции.
Узел. Участник сети, который хранит цепочку блоков и обменивается информацией с другими участниками. Здесь под информацией понимаются блоки и транзакции.
Полезным будет подробнее рассмотреть состояние блокчейна. Здесь нужно понимать, что если транзакции определяют операции, то состояние определяет значения переменных, которые изменились в результате выполнения транзакции. Так, например, если пользователь пересылал какую-то денежную сумму со своего счета на другой счет, то транзакция описывает факт пересылки и его характеристики, а состояние указывает новые балансы на счетах отправителя и получателя. В теории цепочка блоков сама по себе определяет состояние, т. е. узнать текущие значения переменных можно, пробежав по всей цепочке и обрабатывая транзакции, которые там хранятся. Но, понятное дело, в реальной системе так делают: в этом случае процесс обработки каждой новой транзакции занимал бы такое огромное время, что система быстро стала бы неработоспособной. Поэтому, любой блокчейн хранит в себе не только цепочку блоков, но и состояние на момент обработки последней транзакции.
Элементы криптографии
Конечно же, мы не будем изучать криптографию целиком, мы рассмотрим только те понятия, которые используются в блокчейне.
Фундаментом, на котором основан блокчейн, является математический аппарат для вычисления хэш-суммы, под которой понимают результат вычисления хэш-функции. Аргументом хэш-функции выступает набор данных, выходом является набор байт фиксированной длинны.
Хэш-функция обладает следующими свойствами.
Необратимость. По значению хэш-суммы невозможно вычислить набор исходных данных, из которых получен результат.
Закрытость от вычислений. Изменение входных данных приводит к непредсказуемому изменению результата. Если в данных изменится хотя бы один бит, практически все компоненты хэш-суммы будут изменены. Невозможно вычислить закономерность, как изменение исходных данных влияет на результат.
Устойчивость к коллизиям. Идеальная хэш-функция для каждого набора данных выдает уникальное значение. Однако, размер входных данных ничем не ограничен, т. е. число комбинаций входных данных представляет собой бесконечное множество. В то же время размер хэш суммы имеет фиксированный размер, т. е. число всех возможных значений хэш-функции конечно. По этой причине существует вероятность получения одинакового хэша для двух или более наборов исходных данных, т. е. возникает коллизия. Вероятность возникновения коллизии определяет криптостойкость хэш функции. Полностью избавиться от коллизий принципиально невозможно, но можно минимизировать вероятность ее возникновения, для чего усложняют алгоритм и увеличивают размер хэш-суммы.
Если хэш-функцию можно назвать фундаментом блокчейна, то электронную подпись можно назвать его краеугольным камнем. Электронная подпись позволяет проверить валидность информации, передаваемой по сети, т. е. подтвердить, что данные были созданы конкретным участником и не были искажены в процессе передачи.
Работает электронная подпись с помощью алгоритмов асимметричного шифрования. Если в симметричном шифровании мы используем один и тот же ключ для шифрования / дешифрования, то в ассиметричном шифровании используются два ключа, т. е. пара ключей. Данные шифруются одним ключом, а расшифровываются другим. Эта пара ключей именуется как «открытый» и «закрытый» ключ. Предполагается, что открытый ключ известен всем участникам сети, а закрытый известен только одному конкретному участнику и должен сохраняться в тайне. Необходимо отметить, что ключи в паре эквиваленты, т. е. не имеет значения, какой ключ в паре принимается открытым, а какой закрытым.
Когда нужно создать электронную подпись для какого-либо набора данных, для этого набора вычисляется хэш. Вычисленный хэш шифруется закрытым ключом, результат шифрования является подписью и передается вместе с данными.
Чтобы проверить, что данные сформировал владелец ключа, и что они не были искажены, электронная подпись расшифровывается открытым ключом. Таким образом, мы извлекаем хэш, который был зашифрован при создании подписи. Из принятых данных, в свою очередь, также вычисляется хэш сумма, и, естественно, расшифрованная и вычисленная хэш-суммы должны совпадать. В противном случае подпись считается невалидной.
Структура данных
Итак, поскольку все необходимое из криптографии нам известно, рассмотрим теперь, как формируется структура данных в блокчейне. Как мы уже упоминали, блокчейн представляет собой цепочку блоков. Каждый блок имеет заголовок и данные. В заголовке хранятся атрибуты блока, а в данных хранится набор транзакций. По каждой транзакции вычисляется хэш-сумма, которая хранится вместе с транзакцией.
Состав атрибутов блока зависит от реализации, но в нем всегда имеются два ключевых значения, обеспечивающих уникальные свойства блокчейна: хэш блока и хэш предыдущего блока. Когда формируется новый блок, в него записывается хэш предыдущего блока, после чего вычисляется хэш нового блока по всем компонентам, находящимся в блоке (атрибуты и данные). После этого блок добавляется в цепочку и становится ее последим звеном, и теперь подмена становится невозможной: при попытке изменения блока один из его компонентов становится невалидным. Так, например, при изменении какой-либо транзакции становится невалидным ее хэш. При попытке изменения транзакции вместе с ее хэшем становится невалидным хэш блока. Аналогично, хэш блока становится невалидным при попытке изменения хэша предыдущего блока. Таким образом, если кто-то попытается изменить какой-нибудь компонент блока в цепочке, ему придется модифицировать все остальные блоки в цепочке, т. е. пересчитать все хэши, подписи и т. п. Это потребует таких огромных вычислительных и временных ресурсов, что становится нерентабельным.
Сетевая организация
Теперь рассмотрим функционирование блокчейна как сети. Сеть состоит из участников, которые в терминологии блокчейна называются узлами. Узел представляет собой программный модуль, который создает транзакции, формирует блоки и сохраняет блоки в своем локальном хранилище. Узлы связаны каналами связи, через которые они обмениваются транзакциями и блоками. Создание транзакции инициирует владелец узла, которым может быть либо физическое лицо, либо отдельный программный компонент. Для создания транзакции владелец узла передает последнему команду, т. е. владелец должен поддерживать определенный протокол обмена. Неразумно требовать реализации протокола от каждого владельца; для данной цели используется специализированные программные компоненты, которые называются кошельками. Таким образом, кошелек также можно рассматривать как часть сети.
Если узел создал блок и распространяет его по сети, то, когда этот блок поступает к другим узлам, последние сохраняют его в своем локальном хранилище. Таким образом, все узлы поддерживают одну и ту же копию данных.
Исходя из вышеописанного, можно отметить следующие преимущества децентрализованной архитектуры, которая недостижима в классических централизованных системах.
100% сохранность данных. Каждый узел имеет собственную копию данных. Невозможно представить ситуацию, когда вдруг на всех узлах в сети исчезли все данные.
100% Доступность. Невозможно представить ситуацию, когда вдруг абсолютно все узлы в сети прекратили свою работу. Если работает хотя-бы один узел, блокчейн как сеть остается работоспособным.
Консенсус
После того, как накоплено определенное количество транзакций, создается блок. В устоявшейся терминологии процесс создания блока называется майнингом, а участник сети, создающий блок, называется майнером. Создатель блока получает вознаграждение, поэтому не должно вызывать удивления, что каждый узел стремиться стать майнером. И-за этого в блокчейне зачастую возникают ситуации, когда несколько узлов создают одинаковые блоки. В результате описанной ситуации возникают ветвления, или, в терминологии блокчейна, форки. Под разрешением форков понимается объединение ветвлений в единую согласованную цепочку блоков.
Создание блока подчиняется строго определенным и однозначным правилам, следуя которым, участники сети могут проверить валидность созданного блока. Совокупность этих правил называется консенсус. Неформально этот термин можно определить как соглашение о признании блока действительным.
Когда новый созданный блок распространяется по сети, каждый участник проверяет полученный блок с помощью алгоритма консенсуса, и при несоблюдении правил на каком-либо этапе выполнения алгоритма блок признается невалидным. В результате этот блок отбрасывается и не добавляется в актуальную цепочку блоков.
На сегодняшний день наибольшее распространение получили два алгоритма консенсуса: PoW и PoS.
Исторически самым первым реализованным блокчейном был биткоин, а самым первым изобретенным консенсусом, который в нем использовался, был proof of work (PoW), доказательство проделанной работы. Суть его очень проста. Блок имеет поле nonce, которое может иметь произвольное значение. Варьируя это поле, майнер пытается получить хэш блока, в котором первые несколько байт содержат нули. Поскольку хэш-функция обладает свойством закрытости от вычислений, нет другого способа получить нужное значение nonce, кроме как перебором. То есть, перебирая значения nonce, майнер каждый раз вынужден вычислять хэш, надеясь, что он подойдет требуемым правилам. Майнер, который первый подобрал нужное значение nonce, распространяет блок по сети, и получает вознаграждение.
Теперь понятно, почему PoW требует огромных вычислительных ресурсов. Майнеры, имеющие эти ресурсы, получают преимущество перед остальными участниками блокчейна. Попытки решить эту проблему привели к созданию Proof of Stake (PoS), доказательство доли владения. В этом консенсусе на этапе создания блока выбирается группа узлов – валидаторов, которые проверяют созданный блок, и впоследствии получают вознаграждение. Валидаторы выбираются случайным образом, но вероятность выбора повышается с увеличением доли принадлежащей им криптовалюты.
По мере развития технологий блокчейна было разработано множество различных алгоритмов консенсуса. Вначале это были модификации классических алгоритмов, затем появились алгоритмы, основанные на принципиально иных математических принципах. Некоторые из них были реализованы в различных проектах, некоторые остались только в теории. Общепринятой классификации алгоритмов в настоящее время не разработано; технологии не стоят на месте, и регулярно появляются новые алгоритмы.
С чем связано такое разнообразие алгоритмов консенсуса? Дело в том, что мы сталкиваемся с так называемой трилеммой блокчейна. Истоки этого понятия кроятся в так называемой теореме CAP, которая утверждает, что в любой реализации распределённых вычислений возможно обеспечить не более двух из трёх следующих свойств:
согласованность данных (англ. Consistency) – во всех вычислительных узлах в один момент времени данные не противоречат друг другу;
доступность (англ. Availability) – любой запрос к распределённой системе завершается откликом, однако без гарантии, что ответы всех узлов системы совпадают;
устойчивость к фрагментации (англ. Partition tolerance) – расщепление распределённой системы на изолированные секции не приводит к некорректности отклика от каждой из секций.
Следствием теоремы является тот факт, что, улучшая одну из трех характеристик, мы ухудшаем другие, поскольку создать систему, удовлетворяющую всем трем характеристикам, невозможно. Разработка различных консенсусов преследует попытки найти некий компромисс, который в той или иной мере удовлетворяет требованиям теоремы CAP.
2. Проектируем блокчейн
Итак, мы изучили теорию. Теперь приступаем к разработке.
Алгоритм консенсуса
Специалист, услышав про новый проект на базе блокчейна, в первую очередь задаст вопрос: «А какой консенсус там используется?» И из ответа ему станут понятными общие принципы работы системы. Ведь консенсус является центральным звеном любой блокчейн-платформы, он определяет ее архитектуру и свойства. Поэтому вполне логично, что наш рассказ начинается с описания алгоритма консенсуса.
Необходимо отметить, что сам алгоритм мы не разрабатывали, его получили от заказчика уже в готовом виде. Поэтому вы не услышите здесь захватывающие истории про битву идей, про мозговые штурмы и т. п. – об этом чуть позднее. А пока знакомьтесь: PoE, Proof of Ethic, что в вольном переводе можно перевести как «доказательство на основе этических правил». Основная цель данного консенсуса заключается в обеспечении равноправия всех участников сети, чтобы никто не имел преимуществ перед другими.
Алгоритм работает следующим образом.
Узел создает транзакцию (создатель транзакции)
Создатель транзакции выбирает участника сети – партнера.
Партнер проверяет корректность транзакции и подписывает ее. Транзакция считается созданной и распространяется по сети.
Когда в сети создано некоторое количество транзакций, выбирается некоторое подмножество узлов (генераторов), выбранные узлы формируют блоки.
Генераторы выбирают участников сети (валидаторов), которые будут проверять формируемый блок.
Валидаторы проверяют корректность созданного блока и подписывают его. После этого блок считается созданным, добавляется в блокчейн и распространяется по сети.
Какими свойствами обладает описанный алгоритм, как он решает трилемму блокчейна?
Надежность. Транзакции и блоки подписываются партнерами и валидаторами, выбор которых осуществляется по псевдослучайному закону и может быть верифицирован. Невозможно заранее предсказать, какой узел будет выбран, что исключает использование заранее подготовленных партеров или валидаторов.
Масштабируемость. Основные вычислительные затраты приходятся на вычисления электронных подписей, что не требует значительных мощностей. Таким образом, мы, во-первых, получаем высокую скорость обработки транзакций, а, во-вторых, в консенсусе могут участвовать любые узлы, даже со слабыми вычислительными возможностями.
Децентрализация. Любой узел имеет одинаковую вероятность стать партнером, генератором или валидатором. Поскольку, как уже упоминалось, выбор узлов осуществляется по псевдослучайному закону, ни у одного из участников сети нет возможности получить преимущество перед другими, даже путем сговора группы узлов. Все вопросы работы сети, такие, как изменение параметров, величины вознаграждения, и т. п., решаются путем голосования.
Безопасность. Реализована система оценок, которая формирует рейтинг узла. Рейтинг выражается числовым значением; за правомерные действия он увеличивается, за неправомерные – уменьшается. Например, рейтинг понижается за пропуск транзакций при формировании блока, за создание пустых блоков, за использование одного и того же валидатора, и т. п. Если он падает ниже определенного значения, узел попадает в черный список и больше не может участвовать в работе сети. Все операции с таким узлом считаются невалидными.
Мотивация. Награду получают не только генераторы блоков, но также партнеры и валидаторы. Таким образом, вознаграждается простое участие в работе сети, даже если узел не создает блоки. Кроме того, с повышением рейтинга узел попадает в категорию привилегированных узлов и получает право выполнять дополнительные операции в сети, такие, как инициировать увеличение размера блока, предлагать изменение параметров сети, проверять доказательства и т. п.
Как видим, наш консенсус моделирует социальное взаимодействие. Все участники сети работают и получают за это компенсацию. Создание блока – это не единоличный, а коллективный процесс, в нем участвуют множество узлов – партнеров и валидаторов. Добросовестные участники получают продвижение в социальной иерархии, недобросовестные участники теряют свой статус, систематическое нарушение правил приводит в итоге к изоляции. Управление сетью осуществляется по принципам эгалитаризма.
Архитектура
Рассмотрим, как заданный консенсус формирует архитектуру нашей блокчейн-системы.
Пользователь (user, физическое лицо либо какой-либо программный компонент) запускает (1) кошелек (wallet), который подключается к узлу. Затем user формирует команду создания транзакции, эта команда передается (2) на сервер удаленного вызова процедуры (RPC). В соответствии с переданной командой RPC формирует транзакцию и передает (3) ее модулю Partnering. Partnering выбирает участника сети – партнера и через сервер сетевого взаимодействия (P2P) отправляет (4) выбранному узлу транзакцию на подпись. Партнер возвращает подпись, она добавляется в описание транзакции, транзакция отправляется (5) в Mempool и распространяется (6) по сети.
Когда Mempool накопил некоторое количество транзакций, можно генерировать блок. Если узел выбран в качестве генератора, то транзакции из mempool отправляются (6) в Generator. Генератор формирует блок и отправляет его (7) в модуль Enforcer. Последний выбирает узлы – валидаторы и отправляет (8) им сформированный блок. Валидаторы проверяют блок, формируют подписи и отправляют их в Generator. Подписи сохраняются в блоке, сформированный блок сохраняется (9) в хранилище (Blocks) и распространяется (10) по сети. Процесс формирования блока закончен, блок добавлен в цепочку.
Если узел не выбран в качестве генератора, то он ожидает поступления блока из сети. Полученный блок поступает (11) в хранилище, при этом транзакции, которые есть в полученном блоке, удаляются из Mempool.
Компоненты
Рассмотрим особенности работы некоторых компонентов, которые помогут лучше понять процессы, происходящие внутри узла.
Mempool выступает временным хранилищем транзакций. Дело в том, что нерационально для каждой транзакции заводить отдельный блок – в этом случае размер метаданных (информация о блоке) будет превышать размер самих данных (транзакций), и дисковое пространство будет расходоваться впустую. Поэтому, необходимо накопить определенное количество транзакций, чтобы они заполнили выделенное пространство для данных блока. Но вот выбор размера этого пространства, проще говоря, размера блока, это совсем не тривиальная задача. Если размер блока будет слишком мал, то дисковое пространство будет расходоваться неэффективно. А если размер слишком велик, то возрастает время ожидания попадания транзакции в блок.
Для решения поставленной задачи в нашем блокчейне был реализован специальный механизм DBS (Dynamic Block Size). Изначально, размер выделенного пространства блока задается настроечным параметром сети. DBS мониторит поток транзакций и ведет статистику, высчитывая усредненный размер полученного набора транзакций в единицу времени. Если это значение превышает текущее заданное, DBS открывает голосование, предлагая увеличить размер выделенного пространства блока. В случае успешного голосования (большинство узлов голосует положительно) настроечный параметр изменяется. Аналогично описанный механизм работает в обратную сторону в случае уменьшения интенсивности потока транзакций.
Хранилище состоит из двух разделов: хранилище блоков и хранилище состояния. Как понятно из названия, хранилище блоков хранит цепочку блоков, сформированных блокчейном, а хранилище состояния хранит значения объектов, которые изменяются в процессе обработки транзакций. При добавлении нового блока в цепочку данные объектов состояния изменяются в соответствии с набором транзакций, хранящихся в блоке.
Технологии и протоколы
RPC API. Кошелек взаимодействует с узлом посредством API, выбор API влияет на способ описания транзакций. В настоящее время существуют различные модели API, каждый из которых имеет определенные преимущества и недостатки. Мы рассматривали наиболее популярные на сегодняшний день API, такие, как REST, WebSocket, gRPC, и необходимо было выбрать оптимальный для требований проектируемой системы
HTTP/1.1 REST на сегодняшний день является, пожалуй, самым популярным используемым API, особенно в web–среде. Но мы отказались от него по следующим причинам. Во-первых, HTTP достаточно тяжеловесный в связи с необходимостью передавать заголовки, которые в нашем случае не несут никакой информации и оказываются избыточными. И, во-вторых, он ориентирован на последовательную загрузку ресурсов, т. е. нельзя получить следующий ресурс, пока не получен предыдущий.
От указанных проблем избавлен протокол HTTP/2.0, но там используется двоичный формат, что делает его не читабельным для пользователя. В результате становится невозможным описать транзакции без специализированного ПО, что нам совершенно не подходит.
gRPC – привлекательный протокол, обладает хорошей гибкостью, надежностью, компактностью и быстродействием. Но он использует protobuf, который также, как и HTTP/2.0, не читабельный для пользователя. Кроме того, существуют определенные проблемы с поддержкой этого API бразуерами.
В конце-концов, остановились на Websocket, он показался наиболее подходящим для наших целей. А сами транзакции описываются в формате json RPC, что, в общем, уже фактически стало стандартом в мире блокчейна.
Хранилище. Хранилище представляет собой базу данных, которая сохраняет информацию блокчейна. Реляционные СУБД отметаются сразу по многим причинам. Во-первых, они недостаточно производительные для наших требований. Во-вторых, для своей работы они требуют много ресурсов, что особенно критично для мобильных платформ. В-третьих, структуры данных, описывающих блоки и транзакции, имеют много уровней вложенности; для их хранения потребовалось бы создавать множество таблиц, что усложнило бы разработку как схемы базы, так и сериализации объектов. Для нашего блокчейна наилучшим выбором будет использование NoSQL базы данных формата «ключ-значение» (key-value database): таблица для хранения блоков и набор таблиц для хранения объектов состояния.
P2P взаимодействие. Здесь используется TCP/IP, альтернатив для глобальной сети особо не просматривается. Существуют варианты построения сетей на базе UDP, но нам они не подошли; об этом будет разговор в разделе, посвященном разработке сетевого взаимодействия.
Выбор узлов для консенсуса
Рассмотрим, как осуществляется выбор узлов для партнера, валидатора и генератора.
Для создания узла в соответствии с алгоритмом асимметричного шифрования необходимо сгенерировать пару ключей. Приватный ключ хранится на стороне узла и никому не сообщается, а публичный ключ выступает идентификатором узла и известен остальным участникам сети. Участники хранят идентификаторы всех узлов, зарегистрированных в блокчейне. Прежде чем подключиться к сети, узел должен пройти аутентификацию по протоколу «challenge-response»
Фундаментом, на котором базируется рассматриваемый консенсус, является DDN (Data Dependable Number) – псевдослучайное число, зависящее от данных. Это число вычисляется как свертка результата сложения двух величин – хэш блока и идентификатор узла. Полученное число используется для рандомизации выбора участников консенсуса.
Множество участников сети разбивается на группы. Размер группы определяется параметром сети, порядок следования узлов в группе определяется порядком их создания. Узел – создатель транзакции на основании своего идентификатора (напомним, им является публичный ключ) и хэша последнего блока в цепочке вычисляет DDN. Вычисленным DDN инициализируется генератор случайных чисел, который генерирует число в диапазоне 1...N, где N – количество групп. Это число определяет номер группы, из которой формируется подмножество узлов, активных в данный момент, и уже из этого подмножества выбирается партнер. Для верификации необходимо повторить описанные вычисления и определить, имеется ли выбранный узел в соответствующей группе.
Когда mempool заполнен и можно генерировать блок, узел на основании своего идентификатора вычисляет номер группы и проверяет, находится ли он сам в соответствующей группе. Если результат положительный, узел начинает генерировать блок; выбор валидатора блока происходит по точно такой же схеме, как и выбор партнера.
Необходимо отметить, что транзакции, даже сгенерированные одним и тем же узлом, будут выбирать партнеров из разных групп. Это связано с тем, что каждая новая транзакция модифицирует вычисленный DDN, добавляя к нему значение NONCE (число, которое изменяется с каждой новой транзакцией; подробно об этом мы поговорим об этом в следующем разделе). Таким образом, генератор номера группы для каждой новой транзакции будет давать различные значения.
Как видим, выбор участников консенсуса рандомизирован, и невозможно заранее предсказать, какой узел будет выбран на соответствующем шаге. Это обеспечивает невозможность сговора узлов для получения преимуществ перед остальными.
Возникает вопрос: а что произойдет, если все узлы в выбранной группе окажутся неактивными? В этом случае генерации блока не произойдет. Существуют привилегированные узлы, которые отслеживают данную ситуацию. Если они видят, что mempool заполнен, а блок никто не сгенерировал, эти узлы создают пустой блок и распространяют его по сети. В результате изменяется хэш последнего блока в цепочке, и теперь на основании нового значения DDN будет выбираться другая группа.
Вообще-то, генерация пустого блока – это незаконная операция, и за нее понижается рейтинг. Но в этом случае привилегированные узлы жертвуют своим рейтингом ради поддержки работоспособности сети. Что ж, привилегии – это не только права, это еще и обязанности.
Создание транзакции
Прежде, чем приступить к рассмотрению процесса создания транзакции, рассмотрим, как они описываются в коде. Описание транзакции можно разделить на две части: атрибуты транзакции и тело транзакции. Атрибуты определяют свойства транзакции, они одинаковы для всех типов транзакций. А тело транзакции описывает выполняемые операции, и для каждого типа оно будет различным.
К основным атрибутам транзакции относятся:
Тип;
Идентификатор узла – создателя;
Подпись партнера;
DDN;
NONCE.
Для описания транзакции используется базовый класс, в котором описаны атрибуты транзакции, а в наследуемых классах описывается тело. В коде это выглядит примерно так:
class transaction
{
public:
int type;
node_id creator;
unsigned short ddn;
unsigned int nonce;
}
// transfer transaction
class tx_send : public tx
{
public:
node_id to = {};
object_id token_id = 0;
balance_t quantity = 0;
}
В реальном коде атрибутов намного больше, но описанных здесь достаточно для дальнейшего обсуждения технических проблем, связанных с транзакциями.
Следует подробнее остановиться на поле NONCE (аббревиатура от Number sed ONCE). Оно представляет собой целое число; все транзакции имеют уникальное значение данного поля. Текущее значение NONCE хранится в эккаунте создателя транзакции и увеличивается с каждой новой транзакцией. Данное поле, во-первых, определяет порядок следования транзакций, а, во-вторых, исключает появление дубликатов (транзакций с одинаковым содержимым), что защищает от атаки повторного использования. Вроде бы здесь нет ничего особенного, но, как оказалось, необходимость поддержки последовательности NONCE порождало множество проблем, которые требовали нетривиальных решений. По ходу изложения мы про них будем рассказывать.
Итак, нам поступает команда для создания транзакции в виде json rpc строки. Как преобразовать ее во внутреннее представление? Очевидно, необходимо разобрать полученную строку, узнать название метода, создать необходимую структуру, из строки извлечь значения полей, и записать их в эту структуру.
{
"jsonrpc": "2.0",
"method": "send",
"id": "2",
"params":
{
"account" : "0.12"0,
"token ": "Dollar",
"amount": "10",
}
}
Первоначально описанную последовательность шагов мы реализовали через формализованное описание команды. Каждая команда описывается отдельным классом, наследуемым от общего предка. В классе имеется виртуальный метод parse, осуществляющий разбор. При получении json-строки читается название метода и создается соответствующий класс. В метод parse этого класса передается json-строка, реализация осуществляет разбор строки, и аргументы команды записываются в соответствующие поля. Далее созданный класс передается в нужный метод, который из внутреннего описания команды создает транзакцию.
class command
{
public:
virtual void parse(const std::string&);
string method;
int cmd_id;
};
class command_send: public command
{
public:
void parse(const std::string&) override;
account_id receiver;
string token;
unsigned int amount;
};
void on_transaction(const command_send& cmd)
{
tx_send tx;
//fill tx appropriate fields with received cmd description
…
}
К достоинствам данного подхода можно отнести то, что у нас явно описывается структура команды. То есть, взглянув на класс, описывающий команду, мы сразу можем понять, какие ей необходимы аргументы. Кроме того, внутреннее представление команды изолировано от текстового представления, т. е. имеется возможность модифицировать внутреннее представление независимо от описания команды. При необходимости можно легко создавать текстовое представление из формализованного описания – достаточно добавить еще один виртуальный метод и переопределять его в наследуемых классах. Появляется возможность использовать библиотеки для сериализации из текста в структуру и из структуры в текст. На первый взгляд, все смотрится красиво, но вскоре выяснилось, что возникают сложности с разбором команд, допускающих вариацию ключевых слов и параметров.
В рассмотренном выше примере описания команды трансфера все поля структуры, описывающей команду, можно описать строго определенными типами. Но наш RPC-протокол выставляет следующие требования:
1) эккаунт может задаваться либо идентификатором в базе, либо именем эккаунта, либо идентификатором узла – владельца эккаунта;
2) токен может задаваться идентификатором или именем;
3) если токен не описан, то используется токен по умолчанию.
И тут, что называется, приплыли. Как хранить параметры различных типов? Использовать несколько полей для одного параметра, и ставить флаг, какой из параметров валидный? Использовать std::variant c его громоздкими методами доступа? А если параметр не задан, как это обозначить в структуре описания команды? А если в будущем понадобится описать команду, в которой будет несколько условий? В общем, мы решили, что формализованное описание не подходит для наших целей.
Лирическое отступление. Нельзя сказать, что описанные проблемы неразрешимы. Но их решение выглядит громоздким, запутанным и сложным. Одним из показателей профессионализма разработчика является то, что он может вовремя отказаться от выбранного решения, если предвидит, что его использование ведет к хаосу.
В итоге мы решили использовать принципиально иной подход, а именно – динамический разбор команды. Заключается он в том, что структура команды явно нигде не описывается, а прямо в коде ее разбора извлекаются необходимые поля и выполняются требуемые действия.
Так же, как и в первоначальной методике, при получении текстового описания нам в начале необходимо узнать тип команды. Но затем мы не создаем никаких структур, описывающих эту команду; взамен мы вызываем соответствующий метод, в котором осуществляется разбор и обработка команды.
В примере ниже показан разбор команды по описанной методике.
//command contains preliminary parsed json; it is a map of <json-key> <json-value>
void cmd_send (const command& cmd)
{
auto account = cmd.args().find("account");
if (account == cmd.args().end()) //account is mandatory
return cmd.make_reply(error_t::param_count);
tx_send tx;
// ref is a parameter parser. We assign which type is allowed
param acc = param::parse(account, pt_id | pt_pk | pt_name);
tx.acc = get_account(param); //depending on type we search appropriate id
auto token = cmd.args().find("token");
if (token != cmd.args().end())
{
param tkn = param::parse(token, pt_id | pt_name);
tx.token_id = get_token(tkn); //depending on type we search appropriate id
}
else
{
tx.token_id = get_default_token();
}
//The same way we extract amount and assign it to the tx.quantity
// At last, tx is created
}
Рассмотренный подход позволяет гибко реализовать обработку команды, анализируя при разборе команды сколь угодно сложное ее описание. К недостаткам можно отнести отсутствие явно заданной структуры: понять, какие команда содержит поля, возможно только после анализа кода ее обработки. Соответственно, невозможно использовать библиотеки сериализации.
Практика показала, что использование динамического разбора было правильным решением. Модификация и расширение набора команд осуществлялись без особых проблем, обработчики команды были изолированы от остальных компонентов системы. Это говорит о том, что была выбрана удачная архитектура, которая обеспечивает выполнение требований.
Хранение транзакций
После обработки команды и формирования транзакции последняя отправляется на партнеринг, а затем размещается в mempool, где хранится до формирования блока. Реализация партнеринга концептуальных сложностей не вызывала, там в соответствии с правилами консенсуса нужно выбрать валидатора, отправить ему транзакцию, и дождаться подпись. А вот с mempool пришлось повозиться. Дело в том, что транзакции, складываемые в mempool, на всех узлах должны храниться в одном и том же порядке. Это связано с тем, что валидаторы осуществляют проверку полноты использования пространства блока: если какие-либо транзакции, хранящиеся в mempool, не были размещены в блоке, то узел, который сформировал этот блок, наказывается, т. е. у него понижается рейтинг. Такая защита необходима для того, чтобы у узлов не было соблазна создавать «недозагруженные» блоки, в которых остается неиспользуемое пространство. Узел-то получает вознаграждение за каждый созданный блок, но для всей сети это плохо: падает общая производительность и возрастает расход памяти для хранения блоков.
Порядок хранения транзакций оказывается важен также еще по причине некоммутативности, т. е. результат обработки транзакций зависит от порядка их поступления. Поясним это на примере.
Предположим, у нас есть три узла: A, B и C. На балансе узла B на данный момент средства отсутствуют. Узел А пересылает средства узлу B, а узел В затем пересылает средства узлу С. Эти пересылки формируют две транзакции: 1) пересылка от A к B; 2) пересылка от B к C. Если транзакция 2 будет обрабатываться первой, до обработки транзакции 1, то транзакция 2 будет невалидной, потому что на балансе узла В до обработки транзакции 1 средства отсутствуют. А если в блоке хоть одна транзакция будет невалидной, то весь блок считается невалидным – ведь в блоке не должны находиться невалидные транзакции.
Как видим, очередность транзакций важна, но обеспечить ее оказалось не так просто. Мы перепробовали несколько стратегий хранения транзакций в mempool, пока не нашли подходящее решение.
Отправной точкой здесь является то, что необходим какой-то критерий упорядочивания транзакций. Первоначально мы выбрали самый простой путь – упорядочивание транзакций в порядке возрастания их размера, что давало оптимальное использование пространства блока. На начальном этапе разработки, когда количество узлов было небольшим, а транзакции создавались только по команде пользователя и были преимущественно одного и того же типа, описанный подход работал. Затем, когда сделали скрипты, генерирующие поток транзакций заданной интенсивности, сразу вылезла проблема некоммутативности, после чего сделали хранение транзакций в порядке возрастания их NONCE. Это выглядело логичным, ведь NONCE является строго возрастающей последовательностью и увеличивается с каждой новой транзакцией.
Но все оказалось не так радужно. Когда начались проверки на потоках высокой интенсивности, опять проявилась проблема некоммутативности. Ведь последовательность NONCE строго возрастает только для одного эккаунта, выступающего создателем транзакций, для двух разных эккаунтов это правило не действует. В результате транзакция 2, созданная после транзакции 1, может иметь меньшее значение NONCE, чем у транзакции 1, и порядок создания транзакций оказывается нарушен.
После долгих размышлений и споров остановились на сортировке поступающих транзакций по двум полям: по создателю транзакции и по ее NONCE. Здесь по-прежнему существует вероятность возникновения ошибки некоммутативности, если, например, идентификаторы создателей транзакции не соответствуют порядку генерации транзакций. Данная ошибка может возникнуть при сочетании двух условий:
1) несколько эккаунтов одновременно генерируют потоки транзакций высокой интенсивности;
2) потоки следования транзакций пересекаются между собой, т. е. эккаунты осуществляют пересылки друг другу.
В тестах указанные условия можно воспроизвести с помощью скриптов, но в реальной работе вероятность возникновения подобной ситуации очень мала. В конце концов, если пользователь хочет гарантированно исключить ошибки некоммутативности, он не должен создавать следующую транзакцию до тех пор, пока предыдущая созданная им транзакция не попала в блок.
Предварительная валидация
Итак, транзакция попадает в mempool, но перед этим она должна быть провалидирована с учетом транзакций, уже хранящихся в нем. Предварительная валидация необходима по причине отложенного изменения состояния блокчейна. Поясним это на примере.
Представим, что узел А переводит узлу B все свои деньги, которые у него имеются на счету. Транзакция попадает в mempool, но состояние блокчейна в этот момент остается неизменным – оно изменяется только после обработки транзакции. А обработка осуществляется тогда, когда блок, содержащий эту транзакцию, добавляется в цепочку блоков. Таким образом, пока транзакция перевода находится в mempool, баланс узла А остается неизменным, и этот узел получает возможность сделать еще одну пересылку. Но когда транзакции будут обрабатываться, после обработки первой транзакции средства на балансе отсутствуют, и вторая транзакция получается невалидной со всеми вытекающими последствиями, а именно – блок считается невалидным и отбрасывается. Все транзакции, которые хранились в невалидном блоке, в обработку не поступают, т. е. они теряются.
Как видим, предварительная валидация обязательна. Первоначально мы реализовали ее следующим образом. Для валидации транзакций вводится специализированный класс – валидатор. Для каждого типа транзакций, которые нужно валидировать, заводится свой контейнер. При поступлении новой транзакции проверяется ее валидность в соответствии с текущим содержимым контейнера и соответствующей бизнес-логикой, далее принимаются решение о валидности транзакции. В коде это выглядит примерно следующим образом:
class mempool_validator
{
public:
/// Сheck if transaction can be added to mempool
void apply(api::tx_ptr tx);
private:
db::table _speedster_table;
db::table _storer_table;
db::table _account_table;
db::table _token_table;
db::table _change_mode_table;
db::table _oracle_table;
};
void mempool_validator::apply(api::tx_ptr tx)
{
switch(tx->type())
{
case api::tx_type::tx_change_mode_id:
{
//Check if there is transaction in _change_mode_table
//If yes, throw exception. Otherwise, add to container
break;
}
case api::tx_type::tx_token_create_id:
{
//Scan_token_table, check if exists token with the same name
//If yes, throw exception. Otherwise, add to container
break;
}
case api::tx_type::tx_send_id:
{
//Check _freeze_account balance is sufficient. Correct _freeze_account
break;
}
case api::tx_type::tx_athena_create_id:
{
//Get a number active funds in state
//Get a number of funds that are going to be created with stored transactions
//Check limits
break;
}
//Many other cases for transactions of different types
}
}
При небольшом количестве типов транзакций схема рабочая, но, когда число типов исчисляется десятками, сопровождать это становится практически невозможным. Во-первых, код становится очень громоздким: необходим контейнер для каждого типа, в операторе switch также необходим своя отдельная ветка case для каждого типа. Кроме того, мы вынуждены дублировать бизнес-логику, т. е. здесь приходится повторять те же правила, которые используются при обработке транзакций из блока. И эта логика здесь усложняется, потому что мы должны учитывать предыдущие транзакции и то, как они изменяют состояние. Нам приходится либо создавать копии переменных состояния, либо извлекать текущее состояние и пробегаться по всем транзакциям, хранящимся на тот момент в контейнерах, чтобы проверить состояние для последней транзакции. К тому же, для каждого типа транзакций необходимо делать свой отдельный тест, в котором, опять же в соответствии с бизнес-правилами, подбирать исходные данные. В результате код становится крайне сложным для сопровождения, модификации и отладки. Требовалось какое-то другое решение.
И такое решение было найдено: организация временного хранилища.
Временное хранилище хранит объекты состояния, которые изменяются при добавлении транзакций. Перед тем, как добавить новую транзакцию в mempool, она обрабатывается точно таким образом, как если бы это была обработка транзакции при добавлении блока, различие заключается только в организации чтения и записи объектов состояния. Если при обработке транзакции требуется чтение какого-нибудь объекта (например, эккаунта, чтобы узнать текущий баланс), в начале проверяется, имеется ли этот объект во временном хранилище. Если да, то он оттуда извлекается; если нет, объект запрашивается из постоянного хранилища. Если производится запись объекта, то этот объект записывается во временное хранилище, и при запросе вернется сохраненный объект. Таким образом, все операции, осуществляющиеся при обработке транзакций перед поступлением в mempool, не затрагивают объекты в постоянном хранилище. В то же время изменившиеся объекты во временном хранилище отражают будущее состояние объектов, каким оно будет при обработке транзакций при добавлении блока в цепочку. А при операции добавлении блока в цепочку обработка транзакций осуществляется на актуальном хранилище, после чего временное хранилище очищается.
В качестве временного хранилища вначале хотели сделать копию базы, но потом пришли к выводу, что это нерационально: ведь количество объектов состояния, которые будут изменены при обработке транзакций в блоке, не слишком велико. Поэтому целесообразно изменяемые объекты хранить в памяти – обращение к ним будет осуществляться намного быстрее.
В коде это выглядит следующим образом.
struct i_state
{
virtual account get_account(object_id id) const = 0;
virtual void set(const account& acc) = 0;
//Many others methods for access to the state objects
};
//Database storage
class state : public i_state
{
public:
account get_account(object_id id) const override
{
return _accounts->get(id);
}
void set(const account& acc) override
{
_accounts->set(acc);
}
//Many others overridden methods
private:
statedb::accounts _accounts; //account table
//Many others database tables for object’s store
};
//Temporary storage
class temporary_state : public i_state
{
public:
account get_account(object_id id) const override
{
auto it = _accounts.find(id);
return it == _accounts.end() ? //Is account located in the temporary storage?
_state.get_account(id) : //Negative, take account from permanent storage
it->second; //Positive, take account from temporary storage
}
void set(db::wfactory& w, const account& acc) override
{
_accounts.insert_or_assign(acc.id, acc); //write account to the temporary
}
//Many others overridden methods
private:
state& _state; //Database storage
std::map<api::object_id, objects::account> _accounts; //account container
//Many others containers for different object types
}
class transaction
{
public:
virtual void validate(i_state&) const = 0; //Check transaction
virtual void apply(i_state&) = 0; //Process transaction
}
Методы для валидации и обработки транзакций принимают на вход интерфейс хранилища состояния. При предварительной обработке им на вход передается объект временного хранилища, а при обработке из блока – объект постоянного хранилища.
Хотелось бы отметить один интересный момент. Интерфейс хранилища состояния очень большой, он насчитывает порядка сотни методов. Это связано с тем, что количество типов объектов состояния очень велико, и для каждого типа необходим набор методов доступа (чтение/добавление/обновление/удаление), плюс различные вспомогательные методы. Еще до создания временного хранилища в воздухе витала идея разделить один большой интерфейс на подмножество маленьких, по отдельному интерфейсу для каждого типа объектов. К счастью, этого сделать не успели. Почему к счастью – при реализации временного хранилища оказалось, что в этом случае намного удобнее и проще работать с единым, пусть и большим, интерфейсом, чем если был набор маленьких: в этом случае пришлось бы реализовывать набор отдельных интерфейсов для временного хранилища, и связывать эти интерфейсы с постоянным, что значительно усложнило бы задачу.
Лирическое отступление. Это один из редких случаев, когда разработанное решение кажется настолько совершенным, что придумать что-то лучшее навряд-ли возможно. Приятное чувство.
Сетевое взаимодействие
Итак, транзакция поступила в mempool и распространяется по сети. Как организовать коммуникацию между участниками? Мы имеем множество узлов, и необходимо построить обмен сообщениями между ними. В качестве транспортного протокола выбор небольшой: это либо TCP/IP, либо UDP. Выбор однозначно в сторону TCP/IP: здесь и надежная доставка, и отсутствие ограничений на размер сообщений. Но как осуществить адресную пересылку от одного узла к другому? Можно, например, установить соединения всех узлов друг с другом и при передаче выбирать нужное подключение. В локальной сети с небольшим количеством узлов схема рабочая, но в глобальной сети с неопределенным количеством узлов так не получится: каждый новый узел вынужден устанавливать соединение с остальными узлами, и общее количество подключений растет как квадрат от количества узлов. Таким образом, в глобальной сети необходимо построить маршрут передачи сообщений, для чего необходимо какое-то упорядочивание топологии.
Стандартный способ упорядочивания – это использование расстояния между узлами, когда каждый узел устанавливает некоторое количество подключений к ближайшим узлам. В этом случае при составлении маршрута можно последовательно идти от узла к узлу, выбирая ближайший по отношению к текущему. Первоначально мы сделали именно таким способом. Однако в описанной схеме возникает множество вопросов, например: какое максимальное количество подключений должно быть у одного узла; как изменять конфигурацию сети при подключении/отключении узлов; как предотвратить построение кольцевых маршрутов; множество других проблем. Требовался какой-то фундаментальный алгоритм.
Поиск по просторам интернета показал, что существуют различные схемы построения топологии сети, имеющие те или иные преимущества и недостатки. В результате мы выбрали протокол Kademlia, который хорошо зарекомендовал себя в P2P сетях. Академическое описание алгоритма можно прочитать здесь, но для дальнейшего описания проблем, возникших при реализации, необходимо кратко рассмотреть принцип его работы.
Каждому узлу назначается уникальный идентификатор, или, как его называют в kademlia, ключ. Характеристикой узла является его расстояние от других узлов. Но здесь имеется в виду не географическое расстояние, а близость значений ключей, что определяется результатом логической операции «исключающее или» между ними.
Все пространство ключей разбивается в группы, которые в kademlia называются бакетами. Емкость каждого очередного бакета в два раза больше предыдущего, в бакете хранится диапазон ключей с расстояниями от 2 в степени i до 2 в степени i+1. Таким образом, у нас образуется дерево ключей. Для четырехбитного адреса дерево выглядит следующим образом:
Это всего лишь графическая реализация, в программном коде строить подобное дерево нет необходимости. Узел, который входит в сеть, должен установить подключение с произвольным узлом из каждого непустого бакета. Узел, который передает сообщение другому узлу, вначале высчитывает, в каком бакете находится требуемый узел, и передает сообщение подключенному узлу из этого бакета. Узел, который получает это сообщение, также производит передачу в своем дереве ключей. Процесс повторяется итеративно, пока не достигнет адресата.
Лирическое отступление. Очень элегантный и красивый рекурсивный алгоритм. Не знаю, сколько пядей во лбу нужно иметь, чтобы придумать такое.
Публичные и приватные узлы
При реализации протокола маршрутизации возникла одна проблема. Что делать, если узел не имеет публичного адреса? Происходить это может по разным причинам. Во-первых, для обычного рядового пользователя получение и настройка публичного адреса – это достаточно сложная операция, требующая настройки трансляции адресов. Во-вторых, пользователь обычно не имеет постоянного (статического) адреса, эта услуга, как правило, предоставляется провайдером за дополнительную плату. А при динамическом распределении адресов внешний адрес приходится определять с помощью каких-то процедур, например, UPNP, и в силу различных нюансов работы сетевых протоколов эта процедура срабатывает далеко не всегда (например, UPNP в маршрутизаторе может быть отключен). Кроме того, узлы, работающие на мобильных платформах, как правило, публичного адреса не имеют, это связано с особенностями реализации сетевых мобильных протоколов. Но если узел не имеет публичного адреса, то к нему никто не может установить подключение, и нарушается топология сети, что чревато некорректным построением маршрутов.
Узел, не имеющий публичного адреса, мы назвали приватным. Чтобы организовать работу с такими узлами, мы решили использовать узлы-провайдеры, которыми выступают публичные узлы. Приватные узлы подключаются к публичным узлам из каждого бакета, но из маршрутизации они исключаются, т. е. в формировании бакетов не участвуют. Таким образом, маршрутизация осуществляется только через публичные узлы. А когда сообщение проходит через публичный узел, последний проверяет, есть ли у него подключение к требуемому приватному узлу, и отправляет последнему сообщение.
А как проверить, что узел действительно является публичным, и его адреса работают корректно? Когда какой-либо узел запрашивает подключение и объявляет себя публичным, то узел-приемник, к которому стучится новый узел, пытается установить соединение по полученному публичному адресу. Если соединение установить не удалось, значит, узел не может работать как публичный, и маршрутизация через него не разрешена.
Широковещательная рассылка
Работа блокчейна предполагает не только адресную рассылку, но и распространение информации. А именно, сгенерированные транзакции и блоки должны быть доставлены всем участникам, т. е. сеть должна поддерживать широковещательную рассылку. Но реализация такой рассылки является совсем не тривиальной задачей. Мы перепробовали различные подходы, прежде чем нашли подходящее решение.
В начальной стадии разработки рассылку реализовали самым простым способом: каждый узел рассылал сообщение всем своим подключенным узлам. Однако быстро выяснилось, что в результате узлы получают множество дубликатов сообщений; сообщение мог получить даже тот узел, который его отправил.
В качестве улучшения в рассылаемое сообщение добавили список посещенных узлов, и каждый узел при передаче дополнял этот список. Дубликаты пропали, но это решение также не было рабочим. Во-первых, возникли проблемы с подписью сообщения: при проходе сообщения через очередной узел список посещенных узлов пополнялся, стало быть, сообщение изменялось, и подпись становилась недействительной. Узел, которому пришло сообщение, мог бы подписать его вновь, но тогда теряется подпись создателя сообщения. Во-вторых, размер сообщения возрастает при передаче от узла к узлу, и в большой сети сообщение разрасталось бы до весьма внушительных размеров, что просто засоряло бы сеть.
Думали внедрить еще один способ: по сети распространяются маленькие уведомления о возникновении нового блока или транзакции, а узлы, получившие эти уведомления, запрашивают транзакции или блоки, если они у них отсутствуют. В некоторых реализациях блокчейна используется описанный способ, но при таком подходе информация по сети распространяется очень медленно, что снижает общую производительность блокчейна. А производительность у нас была одним из главных требований.
Волею случая один из членов команды, бродя по просторам интернета, наткнулся на протокол kadcast. И это оказалось спасением.
Kadcast, по-сути, является надстройкой над Kademlia, так что, если в системе kademlia уже реализована, разработка kadcast оказывается несложной. Мы имеем то же дерево подключений, что и при адресной маршрутизации. Узел, который осуществляет широковещательную рассылку, выбирает подключенный узел из каждого бакета и высылает ему сообщение. Этому сообщению присваивается высота, равная количеству бакетов в дереве. Узлы, получившие сообщение, повторяют процесс для своих бакетов, но уже с высотой, на единицу меньше, чем полученная. Процесс повторяется итеративно, пока высота не станет равной нулю. В коде это выглядит примерно так.
void send_broadcast(message_ptr msg)
{
for (auto height = static_cast<int>(msg->height()); height > 0;)
{
auto next = _routing_table->get_height_connection(--height);
auto new_msg = std::make_shared<message>(msg);
new_msg->set_height(height);
next->send(new_msg);
}
}
Лирическое отступление. Изначально я полагал, что хорошего решения не существует. Ведь если рассматривать сеть как граф, то широковещательная рассылка представляет собой обход графа с однократным посещением каждой вершины, а алгоритмы на графах для решения описанной задачи предполагают, что топология графа известна полностью. В нашем случае мы знаем топологию только ближайших соседних узлов, к которым установлены подключения, топология остальных узлов нам не известна. Но kadcast использует принципиально иной математический аппарат – распределенные хэш-таблицы (DHT). Что ж, хороший урок: тщательно изучи матчасть, прежде чем что-то утверждать. Возможно, гораздо более умные люди все уже придумали за тебя.
Kadcast, казалось, решал все наши проблемы. Сообщения проходят через каждый узел только один раз, информация распространяется с максимально возможной скоростью для заданной топологии. Но все оказалось не так просто.
Во-первых, не гарантирован порядок следования сообщений. Это связано с тем, что при передаче сообщения узел из бакета выбирается случайным образом, поэтому маршруты следования сообщений к одному и тому же узлу с каждой новой передачей будут различаться. Случайный выбор предназначен для противодействия атакам «отказ в обслуживании».
Представим, что в сети имеется нехороший узел, который отказывается передавать сообщения по маршруту. Если сообщение будет передано этому узлу, оно потеряется. Но, поскольку при каждой новой передаче выбирается случайный узел из бакета, злоумышленник не сможет парализовать работу сети. Да, некоторые сообщения будут теряться, но сеть останется работоспособной. Платой за эту надежность является негарантированный порядок поступления сообщений.
Вторая проблема связана с тем, что после подключения/отключения узлов происходит реконфигурация сети, и если в этот момент через реконфигурируемые сегменты проходят сообщения, то они могут потеряться. Так, например, когда новый узел входит в сеть, он должен установить подключения с бакетами (точнее, с узлами из каждого бакета), но это не происходит одномоментно. Какое-то время узел находится в переходном состоянии, когда часть бакетов подключена, а часть еще нет. Если через в этот момент в узел поступит сообщение, которое по маршруту должно идти через неподключенный бакет, то это сообщение потеряется.
Для транзакций описанные ситуации не являются критическими. Если транзакции от одного создателя придут не по порядку, они при размещении в mempool будут отсортированы. Если транзакция не получена каким-нибудь узлом, то она будет обработана другими узлами. А вот в случае передачи блоков потеря блока, или перепутанная очередность блоков приведет к тому, что узел окажется неработоспособным, ведь для него все блоки, поступающие после пропущенного, будут являться невалидными.
Необходимо было какое-то комплексное решение, для чего понадобилась разработка специального модуля – коллектора.
Коллектор
Коллектор амортизирует неравномерность поступления блоков. Работает он следующим образом.
Для каждого поступившего блока проверяется наличие в базе предыдущего блока. Если блок существует, он сразу отправляется на обработку. Если не существует, то заводятся слоты для полученного и предыдущего пропущенного блока. Коллектор ожидает некоторое время, надеясь, что у нас просто перепутан порядок поступления блоков. Если в течение этого времени блок не получен, это означает, что он потерян, и блок необходимо запросить у какого-нибудь узла. Данный узел выбирается случайным образом из списка активных узлов.
enum status_t
{
received = 0 // block received and ready to be processed
waiting = 1, // block is waiting to come
query = 2, // block has been requested
processed = 3 // block was processed and can be removed
};
struct slot_t
{
hash_t block; // block hash
status_t status; // block status
timestamp_t origin_time; // time point when it was detected block absent
timestamp_t query_time; // time point when block was requested
};
Концептуально описанный алгоритм выглядит простым, но его реализация потребовала немало времени для разработки, тестирования и отладки. Как обычно, дьявол кроется в деталях: нужно реализовать очередь слотов, организовать state-машину для изменения состояния слотов, корректно освобождать слоты, отслеживать таймауты ожидания поступления блока и ответа на запросы, организовать повторные запросы при срабатывании таймаута, реализовать протокол запроса /ответа блоков, множество других операций.
Похожая ситуация возникла с транзакциями. Неравномерность поступления транзакций изначально нивелируется mempool, поскольку он сортирует их по мере поступления. Но при нагрузочном тестировании выяснилось, что при интенсивном потоке амортизации в mempool может оказаться недостаточной. Например, у нас транзакция с бОльшим NONCE поступила первой, mempool заполнен, и транзакция отправилась на обработку. Стало быть, у запоздалой транзакции NONCE будет меньше, чем текущий, и она окажется невалидной. Поэтому, на базе коллектора блоков решили реализовать также коллектор транзакций.
Коллектор транзакций амортизирует последовательность поступления транзакций в рамках одного и того же создателя. Если поступила транзакция с NONCE больше чем на единицу от текущего, то коллектор не отправляет ее сразу в mempool, а ждет некоторое время, надеясь, что она просто не дошла. Но если таймаут закончился, а транзакция не поступила, то, в отличие от блока, запрос транзакции не происходит. Это сильно усложнило бы процесс и засоряло бы сеть, а потеря транзакции не так критична, как потеря блока.
Лирическое отступление. Широко распространено мнение, что художественная литература бесполезна для профессионального развития программиста. Но это не так. Литература содержит безграничную кладезь навыков, рассуждений, убеждений, эмоций, которые оказывают влияние на все сферы человеческой деятельности. Рассмотрим, например, такой навык, как воображение. Нужно ли оно в работе инженера? Несомненно. Из истории науки известно, что множество открытий и изобретений случились благодаря воображению и умению строить образные ассоциации.
Применительно к нашему примеру, идея коллектора возникла благодаря визуализации процесса в виде узкой трубы, в верхнюю часть которой бросаются шарики разных цветов, и нужно выстроить заданную последовательность цветов. А если шарик определенного цвета отсутствует? Начинаешь думать, как это исправить, где взять нужный шарик. Так, шаг за шагом, вырисовывается алгоритм.
А как развить умение воображать и строить ассоциации? Нет лучшего способа, чем чтение хорошей художественной литературы.
Обнаружение узлов
Каждый узел хранит список всех узлов, зарегистрированных в блокчейне. Но, понятное дело, не все они одновременно участвуют в работе сети. Как определить, какие узлы активны в данный момент?
Для решения поставленной задачи Kademlia предлагает алгоритм lookup. суть которого заключается в следующем. Все узлы соединены общим UDP каналом. Узел, который входит в сеть, посылает некоторому уже работающему узлу в сети запрос FIND. В ответ на этот запрос ему высылается некоторое количество узлов из каждого бакета. Далее каждому полученному узлу также высылается запрос FIND. Процедура повторяется итеративно, пока остаются не просмотренные узлы.
В процессе работы конфигурация может меняться: некоторые узлы входят в сеть, некоторые выходят. Поэтому требуется периодически проверять их активность. Для этого процедура lookup повторяется с помощью сообщения PING. Если узел не высылает ответа, то он удаляется из списка. Особенность этой процедуры такова, что, если узел получил сообщение PING, значит, этот запрос проходил через узлы, ближайшие к получателю. Следовательно, эти узлы активные и не требуют процедуры PING.
Поначалу описанный алгоритм выглядел очень привлекательным. Благодаря использованию UDP обеспечивается высокая скорость обмена служебными сообщениями. Оптимизируется нагрузка на сеть, ведь заголовки пакетов UDP занимают всего 8 байт. Благодаря особенностям алгоритма количество циркулирующих служебных сообщений минимизировано: ведь узлам, через которые проходило сообщение PING, уже не нужно тестировать работоспособность отправителя и соседних узлов по маршруту, ведь сам факт получения сообщений от них говорит о том, что указанные узлы работоспособны.
Алгоритм хорошо показал себя в локальной сети, но, когда стали подключать узлы из внешних источников, оказалось, что решение не подходит.
Во-первых, UDP не гарантирует доставку сообщений. Если мы не получили ответ от узла, то неизвестно, в чем причина: либо узел действительно вышел из сети, либо сообщение не доставлено. Нужно либо повторять отправку сообщений несколько раз, либо вводить квитирование.
Во-вторых. Если какой-нибудь узел не ответит на переданное сообщение, то мы не увидим участников сети, подключенных к этому узлу. В результате будет нарушен консенсус, потому что в соответствии с его правилами узлы должны знать про всех активных участников.
И, в-третьих, для всех узлов требуется наличие внешнего адреса, что само по себе представляет собой проблему, о чем мы подробно останавливались в разделе, посвященном взаимодействию публичных и приватных узлов.
Наряду с вышеописанным пользователь должен контролировать состояние UDP портов. Тестировщики периодически сталкивались с такой проблемой: запускается узел, и он не видит других узлов в сети, хотя адреса портов настроены правильно. Проблема заключается в том, что в операционной системе зачастую оказывались заблокированы нужные UDP порты, и требовались специальные действия, чтобы их открыть. Рассматривался вариант при запуске приложения проверять, доступен ли порт, но это очень непросто. Нет универсального способа, для каждой платформы приходится выполнять свои системные вызовы, для выполнения этих вызовов нужны соответствующие права, и т. п.
Проблем оказалось множество, и в итоге от процедуры lookup отказались. Вместо этого реализовали принципиально другой подход обнаружения узлов в сети, а именно – обнаружение не через запросы, а через объявления.
Теперь, узлы не отправляют никаких запросов для тестирования состояния участников сети. Вместо этого они сами объявляют о своей активности путем широковещательной рассылки служебных сообщений. Эти сообщения рассылаются периодически, получение сообщения подтверждает активность соответствующего узла. Чтобы не сильно загружать сеть, период рассылки выбирается достаточно большой, в нашей системе он выбран один раз в 30 секунд. UDP канал здесь оказывается не нужен, коммуникация происходит по тем же самым TCP соединениям, через которые передаются блоки и транзакции. В результате сеть становится гомогенной, что значительно упрощает сетевые взаимодействия, которые осуществляются по единому протоколу.
class announce : public msg::payload
{
public:
uint_least32_t address; // node address
unsigned short port; // node port
node_id account; // node id
endpoint_t endpoint; // node IP address
std::optional<pubkey_t>
provider; // provider (valid only for private node)
api::mode_t mode; // light or full mode
bool is_ready; // is node ready to work?
bool is_public // is node public ?
};
Итак, каждый узел рассылает широковещательное сообщение о своей активности, и слушает такие же сообщения от других узлов. При получении сообщения он помечает соответствующий узел как активный. Когда узел выходит из сети, то он рассылает уведомление, и остальные узлы помечают соответствующий узел как неактивный.
Но ведь возможны ситуации, когда узел выключился, но уведомления об этом не выслал. В этих ситуациях срабатывает сторожевой таймер: если в течение определенного интервала времени от узла не было никаких сообщений, этот узел считается нерабочим и помечается как неактивный.
Все вышесказанное относится к мониторингу активности в рабочем режиме. Но как получить список работающих узлов при входе в сеть? Для входа необходимо указать сетевой адрес узла seed, т. е. какого-нибудь узла, уже работающего в сети. В алгоритме lookup этому узлу посылается запрос FIND, и далее итеративно рассылаются запросы узлам из получаемых списков. В нашем случае по аналогии использовать уведомления не получится: нет способа определить момент, когда получены сообщения от всех функционирующих узлов. Поэтому при входе в сеть узел запрашивает у seed список имеющихся у него активных узлов, после чего осуществляет подключения к нужным узлам по правилам kademlia.
Ветвления
Рассмотрим теперь такую проблему, как ветвления, или, по устоявшейся терминологии блокчейна, форки (транслитерация англ. fork). Этот феномен присущ всем блокчейнам, он является одним из его фундаментальных понятий. Форк возникает, когда несколько узлов одновременно генерируют блоки на одной и той же высоте. В этом случае у нас появляются ветвления, т. е. на одной и одной же высоте возникают расходящиеся ветви блоков. Но блокчейн должен прийти к согласованному состоянию, поэтому необходимо решить, какая ветвь будет актуальной. Алгоритм разрешения ветвлений учитывает множество факторов, таких, как количество транзакций в блоках, значение DDN, у какой ветви больше высота, и другие. Когда в качестве актуальной выбирается какая-либо ветвь, вторая ветвь отбрасывается. Но чтобы транзакции в отбрасываемой ветви не потерялись, проверяется, существуют ли они в актуальной ветви, и если нет, то отправляются на повторную обработку.
Здесь есть существенное замечание, важность которого будет показана в дальнейшем. Транзакции всегда создаются на высоте LIB. LIB (last irreversible block) – это самый верхний блок в цепочке, который изменению не подлежит. Если блоки, созданные выше LIB, могут попасть в форки и быть удалены, то блоки на высоте меньше или равной LIB гарантированно находятся в блокчейне. Таким образом, если транзакции отправляются на повторную обработку, то они остаются актуальными, поскольку все их вычисленные DDN ссылаются на блоки, высота которых не превышает текущий LIB.
Но при разрешении ветвлений появляются неприятности. Например, узел создал две транзакции, которые попали в разные ветви. Если актуальной принята ветвь, в которой находится транзакция с бОльшим nonce, то транзакция из отбрасываемой ветви становится невалидной, потому что при ее обработке выяснится, что ее NONCE меньше, чем актуальный. Но мы не можем просто изменить NONCE, потому что при этом все подписи – и создателя, и партнера, становятся невалидными. Мы долго, очень долго обсуждали описанную проблему, проводили неоднократные мозговые штурмы, пробовали те или иные варианты… Ожидаете, что наконец-то придумали гениальное решение? Не тут-то было. Несмотря на все старания, решение так и не было найдено.
Проблема заключается в том, что в описанной ситуации создатель транзакции должен изменить NONCE и сделать новые подписи, что означает, что ему фактически требуется пересоздать транзакцию. Делать это в автоматическом режиме нельзя, потому что в этом случае на стороне пользователя дело будет выглядеть так, как будто у него теряются одни транзакции, а вместо них возникают другие. Поэтому в нашей системе остается пусть и небольшая, но не нулевая вероятность потери транзакций. Для ее минимизации мы привязали NONCE не к эккаунту, а к типу транзакции. Т. е., если раньше последовательность NONCE была уникальной для конкретного создателя транзакции, то теперь эта последовательность уникальна в рамках создателя и типа транзакции.
Но если пользователю все ж таки нужна 100-процентная гарантия, что транзакции не пропадут, то решается это очень просто: не нужно создавать новую транзакцию одного и того же типа, пока не были обработаны предыдущие созданные им транзакции.
Добавляет оптимизма тот факт, что описанные потери транзакций воспроизводились в процессе моделирования интенсивных потоков данных, генерируемых одновременно всеми работающими узлами, причем узлы работали на одной тестовой машине. То есть, система эксплуатировалась в экстремальном режиме. В реально работающей сети подобные всплески активности очень и очень маловероятны хотя бы в силу того, что в глобальной сети задержки передачи данных несоизмеримо выше, чем в тестовых испытаниях.
Слияние ветвей
Ближе к окончанию этапа активной разработки код отдавался на аудит и верификацию сторонней компании, специализирующейся в данной сфере деятельности. Замечаний оказалось не слишком много, и большинство из них не требовало значительных усилий по исправлению. Но одно замечание попортило немало крови. Вопрос звучал следующим образом: «представьте, что у вас блокчейн разделился на сегменты, долгое времени сегменты работали независимо друг от друга. Что произойдет, если сегменты начнут объединяться?»
Гипотетически, описанная ситуация может произойти при некорректной работе сети. Например, есть шлюз, который соединяет две подсети, и этот шлюз перестал работать. В этот момент две подсети не видят друг друга, и блокчейны строятся независимо друг от друга. Если затем шлюз включился, то блокчейны из разных сегментов не смогут принимать блоки друг друга, поскольку для друг друга они будут невалидными. Таким образом, требовалось реализовать слияние ветвей.
Основные проблемы здесь следующие. Мы не знаем, какой у ветвей общий корень, поэтому запрашивать блоки получится только сверху вниз, ожидая, когда придет блок, у которого предыдущий имеется в нашей цепочке (этот предыдущий блок и будет общим корнем). При этом блоки могут запрашиваться только последовательно, один за другим, потому что только после получения очередного блока мы можем узнать, какой запрашивать предыдущий. Учитывая, что длина ветви может быть достаточно большой, слияние ветвей при таком подходе будет работать медленно, и может растягиваться на неопределенно долгое время. Учитывая, что во время слияния узлы в обеих сегментах продолжают работу, результат непредсказуем: ветви будут одновременно сливаться и расти, и неизвестно, какой процесс будет быстрее.
Вторая проблема заключается в том, что в целях безопасности мы не можем запрашивать все блоки у одного и того же узла. Если это допустить, то злоумышленник сможет сгенерировать поддельную цепочку блоков и распространять ее по сети. Для предотвращения подобного рода атак блоки всегда запрашиваются у различных узлов, выбираемых случайным образом. Но в нашем случае мы не знаем, каким сегментам принадлежали работающие узлы. В результате, если, к примеру, размер какого-нибудь сегмента будет в несколько раз превышать размер другого, может случиться, что мы несколько раз запрашиваем один и тот же блок, и каждый раз будем выбирать узел из одного и тот же сегмента, в котором этот блок отсутствует.
Пришлось провести многочасовые размышления и множество мозговых штурмов, прежде чем нашлось эффективное решение. Заключается оно в предварительном формировании списков узлов и нисходящей цепочки хэшей блоков.
Представим, что сегменты сетей объединились, в каждом сегменте сгенерировался блок. Эти блоки распространяются по сети, и узлы из каждого сегмента получат блок, у которого отсутствует предыдущий.
Вначале узел работает в обычном режиме. Он ожидает предыдущий блок, предполагая, что тот просто запаздывает. Если блок не получен, то он запрашивается у случайно выбранного узла. Если блок по-прежнему не получен, то запускается алгоритм слияния ветвей.
При старте алгоритма рассылается широковещательный запрос «у кого есть требуемый блок». После этого узел ожидает некоторое время, собирая ответы. Формируется список узлов, у которых есть запрашиваемый блок. Затем последовательно, начиная от хэша очередного полученного блока, из списка выбирается узел, у которого запрашивается хэш предыдущего блока. Процесс останавливается, когда мы получаем хэш блока, который есть в нашем блокчейне. Этот хэш является общим корнем, от которого разошлись ветви. Таким образом, мы сформировали цепочку хэшей блоков, которые у нас отсутствуют.
На каждом шаге узел из списка выбирается случайным образом. Этим обеспечивается безопасность построения цепочки хэшей. Если какой-нибудь нехороший узел попытается прислать невалидный хэш, то при запросе предыдущего хэша будет возвращен ответ «хэш отсутствует». А если нехороший узел специально вернет такой ответ, узел будет пытаться запрашивать хэш у другого узла. Таким образом, попытки подмены хэшей сразу будут обнаружены.
Почему мы запрашиваем хэши блоков, а не сами блоки? Сообщения о хэше блока имеют маленький размер по сравнению с самим блоком. Время ожидания ответа невелико, ведь время, затрачиваемое на чтение и передачу хэша, намного меньше, как если бы передавался блок целиком. Поэтому цепочка хэшей строится быстро; даже если узлы в соответствующем сегменте продолжают работу, они не успеют сгенерировать значительное количество новых блоков за то время, как запрашиваются нужные хэши.
Когда вся цепочка построена, можно запрашивать сами блоки. Теперь появляется возможность параллельных запросов, т. е., зная хэши всех блоков, мы можем одновременно запрашивать блоки у различных узлов. Выборка узлов также осуществляются случайным образом, что равномерно распределяет нагрузку на сеть и обеспечивает дополнительную безопасность.
Лирическое отступление. Могу представить, что после описания может возникнуть непонимание, почему сразу не додумались до такого простого решения, ведь здесь нет никакой сложной математики или запутанных алгоритмов. Но это впечатление обманчиво. Решение выглядит простым именно по той причине, что ему предшествовала куча проделанной работы. Было перепробовано множество вариантов, прорабатывались различные подходы, эти подходы обсуждались, критиковались, обнаруживались подводные камни, предлагалось что-то другое, и опять обсуждение, и вновь критика… Это сложный командный процесс.
Когда все блоки получены, срабатывает алгоритм разрешения форков, такой же, как и для обычного форка. Казалось бы, самое сложное – это получить всю ветвь цепочки блоков, а разрешить форк – это уже дело техники. Но здесь тоже оказались подводные камни.
После того, как мы выбрали актуальную ветвь, транзакции из отбрасываемой ветви нужно отправить на повторную обработку. Вспомним механизм разрешения форков в обычном случае: транзакции могут потеряться, если у нас транзакции от одного создателя с разными NONCE попали в разные ветви. В нашем случае это не актуально, поскольку сегменты были изолированы, и транзакции от одного создателя находятся либо в одной, либо в другой ветви сегмента, но не в двух одновременно. Но зато теперь у нас изменяется LIB, и по этой причине транзакции, созданные для другого LIB, становятся невалидными: ведь DDN зависит от предыдущего блока, а этот блок теперь удаляется. Но если мы запишем в транзакцию новый DDN в соответствии с новым LIB, становятся невалидными подписи транзакций. Единственный выход – повторно отправить транзакцию на подпись. Но при этом изменяется хэш транзакции.
И изменение хэша оказывается проблемой. Дело в том, что хэш используется как уникальный идентификатор транзакции. Когда клиент отправляет описание транзакции на узел, ему возвращается хэш созданной транзакции, и клиент получает возможность отслеживать судьбу этой транзакции. Но если хэш изменился, клиент эту транзакцию теряет – он не знает, что с ней происходит.
И опять раздумья, и опять мозговые штурмы… Решение следующее. Вводится промежуточный слой, или таблица отображения. В качестве идентификатора транзакции теперь используется не хэш, а комбинация «создатель-тип-NONCE». Когда создается транзакция, клиенту возвращается эта комбинация в качестве идентификатора. Причем, клиент этих изменений даже не замечает – ему возвращается набор байт такого же размера, как и для хэша, просто в этот набор записывается упомянутая комбинация из трех компонентов. А в базе хранится соответствие идентификатора транзакции и ее хэша. Таким образом, мы можем изменить хэш транзакции, но ее идентификатор остается прежним.
Лирическое отступление. Простое, эффективное, а главное – очень элегантное решение.
Синхронизация
Рассмотрим теперь такой очень важный функционал узлов, как синхронизация состояния.
При подключении узла к сети ему нужно привести состояние своего блокчейна до актуального, в противном случае он не сможет функционировать. То есть, узлу нужно проверить последний блок в своем блокчейне, получить недостающие блоки, которые были сгенерированы за время его отсутствия в сети, и обработать эти блоки, чтобы получить актуальное состояние. Если новый узел первый раз входит в сеть, процедура не меняется, только в этом случае он запрашивает все блоки, которые есть сейчас в сети.
На словах все звучит просто, но на практике поставленная задача оказалась одной из самых сложных.
В самой начальной, пробной реализации при старте блоки запрашивались у того узла, к которому производится подключение. Однако быстро выяснилось, что это работает медленно, и при большом количестве блоков синхронизация будет работать очень долго, процесс будет длиться многие часы, возможно, десятки часов. Кроме того, если несколько узлов начинают синхронизацию с одним и тем же узлом, то последний оказывается перегруженным, все его время будет уходить на обслуживание запросов блоков.
Как в современных компьютерах ускоряются вычислительные операции? Правильно, распараллеливаются. Мы применили такой же принцип: пусть блоки запрашиваются одновременно из множества узлов.
При старте узел рассылает широковещательное сообщение «дайте список своих блоков». В ответ участники сети высылают списки хэшей блоков, которые у них хранятся. Некоторое время узел ожидает ответов, формируя списки хэшей блоков, которые у него отсутствуют. Затем он запрашивает нужные блоки у соответствующих узлов, причем запросы рассылаются одновременно к различным узлам.
Реализация в коде выглядит следующим образом. У нас есть так называемый пул, который хранит списки хэшей блоков и узлов, приславших соответствующий хэш. Для каждой высоты заводится ячейка, которая хранит следующие данные:
список узлов, которые ожидают обработки;
список узлов, которые сейчас загружаются;
список узлов, которые содержат такой же блок;
список узлов, которые уже загрузили блок.
Модуль синхронизации получает списки хэшей от узлов и формирует соответствующие ячейки. Затем происходит загрузка блоков в соответствии с содержимым ячеек, нужные блоки запрашиваются у соответствующих узлов. Если узел не ответил на запрос, он удаляется из списка, и блок запрашивается у какого-либо из узлов, хранящихся в списке дубликатов.
class pool
{
private:
struct item
{
node_id node;
hash_t hash;
std::chrono::time_point<std::chrono::steady_clock> time; //answer waiting
};
std::list<item> _storage;
pool_id _id;
};
class cell
{
private:
pool _wait_pool; //nodes that are waiting of processing
pool _load_pool; //nodes that are loading now
pool _duplicate_pool; //nodes that content the same block
pool _ready_pool; //nodes that have loaded a block, the block is ready to be processed
};
Лирическое отступление. Программисту (как, впрочем, любому инженеру) очень важно иметь эстетическое чувство. В самом общем виде эстетическое чувство помогает оценить то или иное произведение искусства, понять его красоту, увидеть, что именно придает ему изящество и совершенство. Разработка программного продукта – это ведь тоже произведение искусства, которое может прекрасным или безобразным. В нашем случае оценка с точки зрения эстетики сразу бы заставила насторожиться: решение выглядит переусложненным, громоздким, и неуклюжим.
Пробная эксплуатация показала, что решение работает, но оно имеет следующие недостатки.
Во-первых, списки хэшей могут быть очень велики, размер сообщения от одного узла может составлять десятки мегабайт. При синхронизации сеть наводняется множеством подобных сообщений. Если, допустим, у нас стартует одновременно несколько новых узлов, которые начинают синхронизацию с самого первого блока, то на какое-то время сеть окажется перегруженной.
Передаваемые сообщения от разных узлов в большинстве своем дублируют друг друга. Ведь все узлы сети хранят одни и те же блоки, различия могут быть только при возникновении форков. Но, поскольку высота форков ограничена, количество блоков, отличающееся в различных узлах, относительно небольшое.
Далее. Для синхронизации требуется большой объем оперативной памяти. Как мы видели из реализации, ячейка хранит множество списков. Один элемент списка содержит хэш блока (32 байта), идентификатор узла (публичный ключ, 32 байта), временная метка (8 байт). Суммарно элемент списка занимает 72 байта. Для одной ячейки суммарное количество элементов в списках равно количеству активных узлов, количество ячеек равно высоте блокчейна. Если, допустим, количество узлов равно 1000, и высота блокчейна составляет 1000, то для хранения всех ячеек потребуется 72*1000*1000, что составляет около 70 Мб. И это без учета метаданных (переменные для организации списков, идентификаторы пулов, и т. п.). В итоге расход памяти может составлять сотни мегабайт.
Затем. Если мы получили списки хэшей для форков, то к тому времени, как мы их начнем запрашивать, форки уже могут разрешиться, и эти хэши станут неактуальными. Получается, мы будем запрашивать блоки, которые не существуют. А учитывая, что форки могут разрешаться неодновременно, может возникнуть неопределенное поведение – часть узлов может содержать блоки из форка, часть нет.
И, наконец, возникают проблемы с безопасностью. Нехороший узел может прислать недействительный список хэшей, но мы не имеем возможности его проверить, пока не запросим блоки по полученным хэшам.
Можно было пытаться улучшить существующее решение, но мы сочли это бесперспективным. Взамен мы разработали принципиально иной подход, а именно – вместо синхронизации на основании списков хэшей использовать синхронизацию по высоте.
Теперь, при старте узел рассылает широковещательное сообщение «какая у вас высота». Полученные значения сортируются, из списка выбирается медианное значение. Медианное значение необходимо, чтобы отфильтровать нехорошие узлы, присылающие недействительные высоты. Далее запрашиваются блоки по каждой высоте, узлы выбираются случайным образом из списка активных.
В результате мы избавились от недостатков, присущих первоначальному решению. Сообщения о текущей высоте имеют малый размер и не засоряют сеть. Память, требуемая для хранения данных синхронизации, относительно невелика, ведь для каждой высоты мы храним только временную метку для контроля получения ответа. Узел получает только актуальные блоки, а случайный выбор узла не позволяет сформировать недействительную цепочку хэшей.
Лирическое отступление. Это как раз тот случай, когда благодаря упрощению все только улучшается. Да и с эстетической точки зрения, результат выглядит намного более привлекательным.
Я вот раньше считал, что при оценке качества решения сложность реализации – это последнее, что должно приниматься во внимание. Но опыт разработки блокчейна со всей ясностью показал, что данная установка не является правильной. Простота реализации – один из важнейших факторов, который должен приниматься во внимание. Если решение оказывается слишком сложным, то сразу следует насторожиться – возможно, это является именно тем звоночком, который свидетельствует о неэффективности предлагаемого пути.
Полные и облегченные узлы
Узлы блокчейна могут быть различных типов, и каждый тип выполняет определенный набор операций. В зависимости от архитектуры набор типов может быть различным, но, как правило, все современные блокчейны поддерживают как минимум два типа узлов: полные и облегченные.
Полные узлы сохраняют все данные блоков с начала осуществления первой операции в системе. Эти узлы представляют собой фундамент блокчейна, поскольку наличие истории транзакций обеспечивает невозможность ее подделки в силу зависимости каждого блока от предыдущего. Облегченные узлы хранят только информацию о блоках, но данные блока они не сохраняют. В результате эти узлы не могут проверять валидность блокчейна и осуществлять операции, где необходимо проверять историю транзакций; также, они не могут участвовать в синхронизации сети. С другой стороны, облегченным узлам требуется много меньше памяти и вычислительных ресурсов; для синхронизации и получения информации о содержимом блоков они обращаются к полным узлам.
Как простимулировать узлы работать в полном режиме? Очевидно, нужно им что-то предложить за это. Поэтому, вводится правило, что за соответствующие операции (генерация, партнеринг и валидация) полные узлы получают бОльшую награду, чем облегченные. Однако здесь сразу встает проблема: как доказать, что узел полный? Что это не облегченный узел, который всего лишь притворяется полным? Для решения этой задачи мы использовали идеи доказательства хранения данных, которые переработали и адаптировали применительно к нашему блокчейну.
Новый блок, сгенерированный на высоте H, имеет свой DDN. В диапазоне от 0 до LIB генерируется псевдослучайное число, источником которого является указанный DDN. Это число будет высотой блока (target), хэш которого нужно использовать. Затем генерируется хэш от трех величин: DDN, высота нового блока, хэш блока на высоте target. Данный хэш сохраняется в блоке как доказательство. Любой узел может повторить описанные действия и верифицировать полученное доказательство. Предполагается, что легковесный узел не имеет содержимого блока, и не имеет возможности проверить его хэш.
Но в процессе реализации выяснилась неприятная вещь. На стороне клиента работа с облегченным и полным узлом должна быть единообразной: облегченный узел должен иметь доступ к содержимому блоков, для чего он запрашивает их у полного узла. Запрос блока также может понадобиться для синхронизации, когда, допустим, узел не получил какой-нибудь новый сгенерированный блок. Как видим, запрос блока является допустимой операцией, и в данном контексте это представляет уязвимость: облегченный узел получает возможность сгенерировать доказательство, запросив нужный блок у полновесного узла и вычислив его хэш.
К счастью, как показали эксперименты, скорость чтения блока из локального хранилища в десятки раз превышает скорость получения блока из сети. Таким образом, если увеличить число блоков, для которых требуется вычислить хэш, то в случае запроса этих блоков по сети генерация доказательства займет недопустимо долгое время. Для этого необходимо организовать цепочное вычисление хэшей, чтобы каждый вычисленный хэш зависел от предыдущего. Выглядит это следующимобразом.
sha256 hasher;
height_t target_height = distribution(ddn);
api::hash_t round{};
for (size_t i = 0; i < PROOF_COMPLEXITY; ++i)
{
block block = get_block(target_height);
hasher.update(block);
round = hasher.final();
size_t xored_hash = xor_hash(round);
target_height = xored_hash % _lib_height;
}
return round;
Пояснения.
(2) Вычисляется псевдослучайное значение высоты блока таким же образом, как и при генерации доказательства для одиночного блока.
(4) Запускается цикл. На каждом шаге производится добавление очередного блока к набору данных, вычисляется хэш нового набора.
(8) Вычисляется промежуточное значение хэш.
(10) Из полученного хэша вычисляется свертка (xor_hash). Свертка хэша представляет собой число, вычисляемое на основе содержимого хэша. Вычисления производятся путем просмотра всех байтов хэша, на каждом шаге вычисляется величина как операция «исключающее или» с очередным байтом и значением, вычисленным на предыдущем шаге.(11) Целочисленное деление приводит значение свертки к текущему диапазону высоты блокчейна. Таким образом, свертка хэша определяет высоту блока, содержимое которого нужно добавить к набору данных на следующем шаге алгоритма.
(12) С увеличением количества итераций цикла увеличивается время, требуемое для генерации доказательства. Невозможно заранее предсказать набор блоков, требуемых для генерации, поскольку высота очередного блока, добавляемого в набор данных, зависит от хэша, полученного для набора данных на предыдущем шаге.
Когда реализовали описанный алгоритм, вдруг оказалось, что генерация доказательства на полном узле стала занимать недопустимо долгое время. Так, для количества итераций 1024 время генерации занимало около 4 минут! В процессе анализа выяснилось, что в основном время тратилось на десериализацию данных, т. е. преобразование двоичных данных из хранилища во внутреннее представление. Никто не мог предположить, что такая вроде-бы нетребовательная операция, как десериализация, при циклической работе занимает столь продолжительное время! Пришлось доработать интерфейс доступа к хранилищу, чтобы иметь возможность извлекать сырые данные блока, и уже по ним вычислять хэши, опуская этап преобразования данных.
Но приключения на этом не закончились. В процессе верификации алгоритма пришло понимание, что полностью все уязвимости закрыть не удается. Допустим, облегченный узел все-таки решил генерировать доказательство путем запроса блоков. Если интенсивность транзакций высокая, и новые блоки генерируются достаточно часто, то за время, пока облегченный узел будет запрашивать блоки, высота блокчейна уйдет далеко вперед, и блок будет отброшен, поскольку он окажется на высоте намного ниже текущей высоты LIB. Но если у нас низкая интенсивность транзакций, то, пускай теоретически, но существует некоторая вероятность, что легковесный узел успеет запросить все необходимые блоки и вычислить доказательство.
Для защиты от описанных махинаций было придумано следующее решение: использовать DDN не генерируемого блока, а вычислять DDN из атрибутов валидатора.
Работает это следующим образом. Генератор передает блок валидатору, последний возвращает генератору свои атрибуты. К атрибутам относятся, например, текущий DDN валидатора, его LIB, следующий выбранный валидатор, и т. п. Из атрибутов вычисляется DDN как первые байты хэша от совокупности значений атрибутов, и уже на основании полученного DDN вычисляется доказательство по вышеописанному алгоритму. Вычисленное доказательство высылается валидатору, который только после этого подписывает блок. Атрибуты валидатора сохраняются в блоке, по ним производится верификация доказательства.
Валидатор ожидает получения ответа ограниченное время. Если облегченный узел попытается запрашивать блоки, то время генерации доказательства превысит время ожидания валидатора, и последний не вышлет подпись, без которой блок считается недействительным.
Итак, как мы видим, облегченный узел не может притворяться полным и незаслуженно получать награду. Однако само наличие облегченных узлов потенциально приводит к уязвимости. Это связано с особенностями консенсуса, используемого в нашем блокчейне. Каждый узел имеет одинаковую вероятность быть выбранным для генерации либо валидации, но следствием этого является преимущество владения: чем больше узлов принадлежит конкретному пользователю, тем выше вероятность выбора узла из принадлежащего уму набора. В результате возникает соблазн запустить множество легковесных узлов, не требующих больших вычислительных ресурсов, в результате чего размер набора увеличивается, и владелец узлов получает вознаграждение за счет остальных. Описанная уязвимость закрывается следующими мерами.
Как мы уже неоднократно упоминали, каждый узел идентифицируется уникальным идентификатором, в качестве которого выступает публичный ключ. С этим ключом связывается эккаунт, то есть каждый узел может владеть только одним эккаунтом. Но пользователь не может создавать эккаунты как вздумается. Эккаунты создаются специальными узлами с соответствующими правами, так называемыми суперузлами. Если суперузел создает эккаунт, эту операцию должны подтвердить остальные суперузлы, подтверждение производится путем голосования.
Второе ограничение, связанное с эккаунтами, – это обязательное требование прохождения процедуры KYС. Пока для эккаунта не получена запись KYC, он является заблокированным и не может участвовать в работе блокчейна.
Как видим, создать множество облегченных узлов с различными эккаунтами не представляется возможным. А вхождение в сеть несколько узлов с одинаковыми эккаунтами отслеживается. Узел, входящий в сеть, передает идентификатор эккаунта (публичный ключ), с которым он собирается работать; узел, принимающий соединение, проверяет, является ли этот эккаунт активным, и если да, то в соединении отказывается.
Защита от DDoS инцидентов
Одной из важнейших задач при разработке сетевых приложений является защита от DDoS атак, и блокчейн здесь не исключение. В общем случае организация такой защиты представляет серьезную проблему, которая должна решаться как на сетевом, так и на прикладном уровнях. Сетевой уровень мы здесь не рассматриваем, эти вопросы решаются инструментами организации и администрирования сетей. А вот прикладной уровень – это уже исключительно область ответственности блокчейна.
Главная задача отражения DDoS атаки – это идентификация ее источника. В нашем случае источником выступает либо сетевой адрес, либо идентификатор узла, генерирующего сообщения.
Идентификация атаки осуществляется путем мониторинга частоты возникновения инцидентов. Под инцидентом здесь понимается как факт поступления сообщения, так и ошибки, возникающие при его обработке, например, неправильный формат сообщения, неверная подпись, невалидные данные, и т. п. Для каждого типа инцидентов назначается допустимая частота возникновения, т. е. число инцидентов в единицу времени. Если это значение превышает допустимое пороговое значение, то TCP-соединение разрывается, и сетевой адрес блокируется.
В плане программирования описанная концепция особых сложностей не представляет, поэтому приводить фрагменты кода нет необходимости. Реализуется класс-детектор, в который стекаются все возникающие инциденты. Этот класс ведет списки количества инцидентов, т. е. набирает статистику, а при срабатывании таймера вычисляется частота возникновения инцидентов за единицу времени. При превышении заданного значения узел добавляется в черный список. Здесь работа скорее бухгалтерская, чем творческая: определить набор инцидентов (а он достаточно большой), реализовать настройку их частоты, провести по коду вызовы детектора.
Моделирование атак
Поскольку узлы работают в открытой публичной сети, то защита от некорректных или злонамеренных действий – одна из важнейших функций как отдельного узла, так и блокчейна в целом. Однако, тестирование этой защиты представляет проблему. Как проверить, что некорректные блоки и транзакции отбрасываются, что не пропускаются сообщения с невалидной подписью, что детектируются попытки подмены данных, что срабатывает защита от DDoS, и т.п ситуации? Ведь сами по себе узлы не генерируют описанные ситуации, для этого требуется модификация исходного кода.
Первое, что приходит в голову, – это сделать узел-имитатор, который выполнял бы требуемые действия, например, генерировал сообщения с неверной подписью. Но после анализа стало понятным, что для нормальной работы в узле-имитаторе требовалось бы повторить практически весь функционал обычного узла. Поэтому, пробовали пойти в другом направлении, а именно – делать отдельные версии узлов, в каждой из которых генерировалось бы какое-нибудь атакующее воздействие. Мысленные эксперименты показали, что такой подход громоздкий и неудобный: количество воздействий исчисляется десятками, и для каждого потребуется отдельная версия узла. Поддерживать весь этот набор весьма затруднительно, особенно если строить автоматизированные тесты. В конце концов, возникла следующая идея: а почему бы не внедрить моделирующий код непосредственно в узел?
Реализация выглядит следующим образом. Вводится специальный класс simulator, который хранит флаги для выполнения различных моделирующих действий. Эти флаги настраиваются через специальную RPC-команду, обработчик команды настраивает нужный флаг. В нужных участках кода вводится проверка этих флагов и выполняются соответствующие действия.
enum sim_action_t
{
bad_msg_header, //invalid message header
bad_msg_sign, //invalid message sign
bad_enforcer_sign, //invalid block enforcer (validator) sign
bad_msg_creator, //invalid message creator
//many others malicious action
};
class simulator
{
public:
#ifdef _DEBUG //we use the simulator only in debug configuration
static simulator& instance();
#endif
bool action(sim_action_t act) const; //check if it is necessary to simulate appropriate action
void on_command(const rpc::command& cmd); //simulator RPC command processing
std::bitset<action_count> _flags;
};
simulator реализован как single tone. Многие разработчики испытывают предубеждение к использованию указанного паттерна, но в нашем случае это оправдано. Требуется один, и только один экземпляр класса simulator. А вот его вызов может понадобиться где угодно, и повсюду тянуть зависимости от simulator нет никакого смысла – это неоправданное загромождение и усложнение кода.
В следующем примере показано, как моделируется некорректная подпись сообщения. Все достаточно просто: проверяется флаг, и, если он выставлен, подпись портится.
void message::sign(const private_key& skey)
{
eddsa::state signer;
pack(signer);
_signature = signer.sign(skey);
#ifdef _DEBUG
if (simulator::instance().action(bad_msg_sign))
_signature[13] = ~_signature[13]; //corrupt msg signature
#endif
}
Награда за запрос данных
Не всегда разработка происходит гладко и без шероховатостей, иногда случаются и неудачи, и провалы. В этом нет ничего катастрофического, в общем-то, это неотъемлемая часть процесса. Подозреваю, что у каждого опытного разработчика в багаже имеется пара-тройка подобных случаев. В инженерной практике иногда случается даже так, что сложность и объем проблем нарастают лавинообразно, разработка заходит в тупик, и в результате проект закрывается. К счастью, в нашем случае неудачные решения не имели таких серьезных последствий, но, тем не менее, анализ причин возникновения провалов поставляет бесценный опыт, который не получить другими путями.
Для начала я расскажу о попытке реализации награждения полного узла за обработку запросов от облегченных узлов. Как мы уже упоминали, облегченный узел не хранит данные блоков, он хранит только заголовки блоков. Если такому узлу понадобятся данные, он запрашивает их у полного узла. Предполагалось, что полный узел будет получать вознаграждение за обработку запроса, что будет дополнительно мотивировать узлы работать в полном режиме.
На словах все выглядит красиво, но при детальном анализе оказалось, что на уровне сетевых запросов нет надежного способа доказать корректное поведение узлов. Как к этому пришли? Алгоритм разбивался на шаги, и на каждом шаге пытались определить критерии правильного поведения узла.
В упрощенном виде это выглядело примерно так. Облегченный узел посылает запрос – как доказать, что он действительно его выслал? Полный узел высылает блок в ответ – как доказать, что это был именно ответ, а не просто пересылка без предшествующего запроса? Если облегченный узел утверждает, что данные блока не валидны, как доказать, что это утверждение не голословно, и что полный узел действительно вернул некорректный блок? Как верифицировать процесс, чтобы подтвердить, что полный узел действительно выполнил обработку запроса от облегченного узла?
Что бы мы ни делали, как бы мы ни пытались усложнить сетевой протокол, всегда оставались уязвимости, используя которые, полные либо облегченные узлы могли мошенничать. К примеру, полный узел мог заявлять, что он получил запрос и выслал данные, и не было однозначного способа это подтвердить или опровергнуть. В свою очередь, облегченный узел мог утверждать, что он не получил ответа, либо получил некорректный блок, и здесь также отсутствовало надежное подтверждение. Когда закрывали одни лазейки, взамен обнаруживались другие.
Причина здесь в следующем. В блокчейне существуют два архитектурных уровня: сетевой уровень, т. е. передача данных по сети, и транзакционный уровень, т. е. уровень обратки данных. Несмотря на то, что на сегодняшний момент отсутствует строгое теоретическое обоснование, в литературе все авторы крайне не рекомендуют смешивать сетевой и транзакционный уровень. А в данном случае мы нарушаем этот принцип, т. е., мы пытаемся формировать транзакции на основании операций в сетевом уровне. По-видимому, это связано с какой-то фундаментальной особенностью блокчейна, которая на сегодняшний день еще не исследована.
В конце концов мы оставили попытки генерировать доказательство на сетевом уровне, поскольку устранить все возможные уязвимости не представлялось возможным. Взамен мы сосредоточились на транзакционном уровне, когда все запросы и ответы должны проходят через обработку транзакций. В результате был спроектирован следующий алгоритм.
Через текущий DDN облегченный узел осуществляет выбор полного узла для запроса данных, аналогично выбору генераторов и валидаторов. Создается транзакция «запрос данных», в которой указывается выбранный полный узел – поставщик данных. Указанная транзакция начинает распространяться по сети. Узлы, которые не выбраны в качестве поставщика, сохраняют эту транзакцию в своих mempool. А когда транзакция поступает в узел-поставщик, последний в ответ генерирует транзакцию «выдача данных», в которую записывается требуемый блок. На основании сгенерированной пары транзакций полному узлу назначается награда, и остальные узлы могут это верифицировать.
К счастью, до реализации дела не дошло. Еще на этапе анализа алгоритма стало понятным, что решение нежизнеспособно.
Во-первых, если на каждый запрос-ответ создавать отдельные транзакции, то у нас блокчейн будет просто засоряться такими транзакциями. Получается, что клиент просто просматривает историю, а при этом незаметно для него в блокчейне создается куча транзакций, причем они не изменяют состояние.
Во-вторых. У нас в теперь в транзакции хранится целый блок. То есть, в блокчейне хранится блок, в котором хранится транзакция, в которой хранится блок. Само по себе это звучит нелепо, учитывая, что в блоке из транзакции тоже может находиться блок. Но еще хуже оказывается то, что размер блока получается недетерминированным, и мы не можем задавать ограничение на его размер. Как следствие, мы не можем задать ограничение на максимальный размер сообщения, а это уже ведет к уязвимости DDoS.
Дело в том, что в протоколе TCP/IP для чтения сообщения необходимо знать его размер. Поэтому перед передачей сообщения передается заголовок, в котором хранится размер этого сообщения. Приемная сторона читает заголовок, и в соответствии с переданным размером читает само сообщение. Если в полученном заголовке размер превышает максимально допустимый, то сообщение отбрасывается как невалидное. В случае, если ограничение на размер сообщения отсутствует, то достаточно прислать заголовок с максимальным значением разрядной сетки и при чтении высылать мусор, в результате чего сеть окажется парализованной.
И, наконец. Любая транзакция подписывается, сохраняется в mempool, и только потом распространяется по сети. Можно представить, сколько времени пользователь будет ожидать получение всего лишь одного блока! Дополнительно возникают сопутствующие проблемы: транзакции могут попасть в форки, транзакция может не дойти до узла-поставщика или потребителя, и т. п.
Из-за вышеописанных проблем, первоначально кажущаяся такой привлекательной и блестящей идея была признана нежизнеспособной.
Динамическая модификация кода
В предыдущей истории решение было отвергнуто на этапе анализа, до реализации дело так и не дошло. А сейчас я расскажу про гораздо худший случай, когда понимание, что решение нерабочее, пришло уже после того, как было затрачено множество усилий для его реализации. Речь идет о динамической модификации кода, т. е. возможности модификации бизнес-правил без необходимости выпуска новой версии приложения. В терминологии блокчейна, это попытка избежать необходимости выпуска hard fork.
Концептуально реализация выглядит следующим образом. Все бизнес-правила выносятся в отдельный модуль. Не принципиально, как реализован этот модуль - в виде динамически разделяемой библиотеки, в виде интерпретируемого кода, в виде плагина. Единственное требование – должна быть возможность вызова функций из этого модуля. Для модификации создается транзакция, в которой размещается код, при обработке транзакции модуль подменяется новой версией.
Первоначально рассматривалось использование интерпретируемого языка, который не требует компиляции, как, например, Python. Но от идеи отказались по следующим соображениям.
Во-первых, для работы узла требуется установка исполняемой среды, т. е. интерпретатора, виртуальной машины или чего-то другого. Эта среда должна работать на всех поддерживаемых платформах.
Во-вторых, существуют сложности организации взаимодействия принципиально различных исполняемых подсистем. Например, как из кода C++ вызвать код на питоне? Есть множество способов, но на различных платформах они работают по-разному, и нельзя заранее предсказать, какие подводные камни могут возникнуть.
И, наконец, использование описанной концепции повлечет за собой падение производительности, ведь теперь при обработке каждой транзакции придется делать межплатформенные вызовы. А производительность – это одно из главных требований в нашей системе.
В силу описанных проблем разработали другой подход. Реализуется динамически загружаемая библиотека, которая пишется на нативном языке программирования, в нашем случае это C++. Если необходима модификация поведения, пользователь должен изменить исходный код библиотеки, скомпилировать ее для различных платформ и передать все необходимые файлы на узел. Узел формирует транзакцию, в которую записывается исходный код и файлы реализации. Когда транзакция обрабатывается, версия библиотеки извлекается и сохраняется в локальном хранилище. При выполнении бизнес-правил происходит вызов соответствующей импортируемой функций библиотеки.
В коде это реализовано следующим образом.
#ifndef WINDOWS
using HINSTANCE = void*;
#endif
using fptr_evaluate_reward = int(*)(app*, block*);
class hook
{
public:
void load(); //load DLL library
void unload(); //unload library
//Wrappers
bool evaluate_reward(const block_ptr)
{
if (_evaluate_reward)
{
_evaluate_reward(&_app, b);
return true;
}
return false;
}
private:
HINSTANCE _handle {nullptr};
fptr_evaluate_reward _evaluate_reward {nullptr};
api::app& _app;
private:
HINSTANCE load_symbol(const std::string &name)
{
#if WINDOWS
return GetProcAddress(_handle, name.c_str());
#else
return dlsym(_handle, name.c_str());
#endif
}
};
void hook::load(const std::string& library_name)
{
#if WINDOWS
_handle = LoadLibrary(library_name.c_str());
#else
_handle = dlopen(library_name.c_str(), RTLD_LAZY);
#endif
If (!_handle)
return;
HINSTANCE h = load_symbol("evaluate_reward");
_evaluate_reward = reinterpret_cast<fptr_evaluate_reward>(h);
}
void hook::unload()
{
#if WINDOWS
FreeLibrary(_handle);
#else
dlclose(_handle);
#endif
_handle = nullptr;
_evaluate_reward = nullptr;
}
void rewards::evaluate(const block_ptr blk)
{
if (get_hook()->evaluate_reward(blk)
return;
//...Execute original code
}
Класс hook отвечает за загрузку динамической библиотеки и импорт функций. В нем объявляются идентификатор загруженной библиотеки и указатели на импортируемые функции. Здесь для примера показано объявление только одной функции для начисления наград, в реальном коде таких функций множество.
Метод load осуществляет загрузку библиотеки с помощью соответствующих системных вызовов. Здесь приходится использовать платформенно-зависимый код, поскольку системные вызовы на разных платформах различаются. В начале мы загружаем библиотеку, а затем настраиваем указатель с помощью метода load_symbol, в котором осуществляется импорт функции по ее имени. Полученный указатель на функцию приводится к нужному типу. Метод unload, соответственно, осуществляет выгрузку функции.
В этом же классе объявлены функции-обертки (в нашем примере это evaluate_reward, подобных функций в коде множество), которые обращаются к импортируемым функциям через указатель. В обертках проверяется, установлен ли соответствующий указатель, и если да, то происходит вызов функции. Здесь в нашу функцию дополнительно передается класс app, через который функция, реализованная в библиотеке, может обратиться к всем требуемым компонентам приложения.
На примере метода rewards::evaluate показано, как осуществляется модификация поведения. В начале вызывается метод – обертка, возвращаемое значение показывает, был ли осуществлен вызов импортируемой функции. Если да, то происходит возврат управления. В противном случае выполняется оригинальный код.
Что получаем в итоге? Быстродействие не страдает, потому что дополнительные расходы на вызов функции по указателю совершенно не заметны на фоне остального кода – это всего лишь несколько дополнительных машинных команд. Кроме того, функции не ограничены контекстом выполнения – в качестве параметра они получают класс app, через который можно получить необходимые данные либо изменить поведение любого компонента.
На первый взгляд, вроде все хорошо, все красиво. Но по мере реализации вал проблем накрывал все сильнее и сильнее.
Во-первых, нельзя заранее предсказать, какие компоненты кода в будущем понадобится модифицировать. Потенциально, модифицированы могут быть любые участки, и невозможно предусмотреть заранее внедрение библиотечной функции, покрывающей все возможные случаи.
Во-вторых, мы имеем очень громоздкий механизм. Необходимо разработать библиотеку, откомпилировать ее под разные платформы (а это Windows, linux, MacOs, несколько сборок для android), протестировать, передать на узел, который сделает транзакцию огромного размера с исходным кодом и двоичными файлами для всех платформ.
В-третьих, размер транзакции не определен, потому что мы не можем предсказать размер получаемого двоичного кода. Как следствие, размер блока также не определен, что приводит к уязвимости, описанной в предыдущем разделе.
В-четвертых, верификация исходного кода возможна только вручную, автоматически верифицировать код невозможно. Поэтому, необходимо принимать специальные организационные меры, чтобы какое-то сообщество разработчиков провело код ревью и пришло к выводу, что он корректный.
В-пятых, в транзакции мы передаем исходный и двоичный код. Как подтвердить, что двоичный код соответствует исходному? Как верифицировать, что он корректный, работает правильно, и не выполняет недопустимых операций? Задача не может быть автоматизирована, представляет огромные сложности (если вообще выполнима), и требует привлечения экспертных знаний.
И, в-шестых. Если окажется, что в код закралась фатальная ошибка, то это приводит к катастрофе. В какой-то момент упадет вся сеть, т. е. все узлы одновременно выйдут из строя. И непонятно, как потом сеть восстанавливать – ведь ошибочный код уже содержится в недрах блокчейна в какой-то из транзакций.
В результате, несмотря на то что основной функционал был уже реализован, и решение казалось работоспособным, от идеи динамической модификации поведения отказались, и код, связанный с этим, из проекта был удален.
Лирическое отступление. С самого начала предложенная идея показалась сложной, громоздкой, и вычурной, и ее ценность вызывала большие сомнения. Даже эстетическое чувство подсказывало, что здесь что-то не так. Но, заказчик настаивал, а у нас на тот момент не хватило аргументов, чтобы его убедить.
Утечка памяти
Баги – это настолько рутинные ситуации в разработке ПО, что нет особого смысла рассказывать про них – все программисты знают, что такое отладка, и сами могут рассказать много интересного из своей практики. Но запомнился один баг, связанный с утечкой памяти.
Когда узлы стали работать более-менее устойчиво и могли оставаться работоспособными достаточно длительное время, выяснилось, что в процессе их работы растет потребляемая память. Здесь налицо классический случай утечки памяти, которая, как правило, легко обнаруживается с помощью специализированных инструментов. Но не в нашем случае – санитайзеры ничего не показывали, но память, тем не менее, росла. Пусть медленными темпами, но устойчиво.
Мы перепробовали различные способы. Проводился анализ кода, испытания в различных режимах, пробовали статические анализаторы, но ничего не помогало. Утечка была, но выявить ее никак не удавалось. После многочисленных попыток приняли следующее решение: монитор потребляемой памяти по всем внутренним компонентам приложения.
Реализовано это было следующим образом.
1. В каждый компонент приложения добавляем метод, возвращающий объем потребляемой памяти.
size_t mempool::consumed_memory() const
{
return
subscriber::consumed_memory() +
_trx_table.size() * sizeof(decltype(_trx_table)::value_type) +
_acc_to_nonce.size() * sizeof(decltype(_acc_to_nonce)::value_type);
}
Объявляем структуру, в которой хранятся переменные, хранящие потребляемую память для каждого компонента.
struct memtrack_t
{
size_t mempool;
size_t routing_table;
//many others variable
};
Заводим класс, осуществляющий мониторинг.
class memtracker
{
public:
memtrack_t consumed_memory();
private:
app& _app;
}
memtrack_t memtracker::consumed_memory() const
{
memtrack_t track;
track.mempool = _app.get_mempool().consumed_memory();
track.routing_table = _app.get_routing_table().consumed_memory()
//many others calls
}
В этом классе мы объявляем метод, который заполняет нашу структуру. Таким образом, мы можем видеть потребление памяти компонентами приложения. Для визуализации этих данных мы реализовали RPC команду, возвращающую требуемую структуру в формате json.
Теперь, используя описанный механизм, мы можем наблюдать потребление памяти по компонентам.
Реализация оказывается несложной, но объемной, потому что количество компонентов велико, компоненты могут иметь зависимости, и для каждого компонента необходимо реализовать соответствующие методы. Но результат стоил того: после мониторинга выяснилось, что в некоторых компонентах после выполнения требуемых операций в контейнеры добавлялись объекты, которые в последствии не удалялись. Стало понятным, почему санитайзеры не показывали утечек: с их точки зрения все корректно, после закрытия приложения все объекты уничтожаются, утечка отсутствует.
3. Проводим ретроспективу
Много всякого разного еще происходило на проекте, рассказывать об этом можно бесконечно. Но самое интересное вы уже знаете, пора бы остановиться и подвести итоги.
Итак, что нового мы узнали, какой опыт получили, какие выводы сделали?
Особенности децентрализованных систем
С точки зрения архитектуры, отдельный узел представляет собой клиент-серверную систему, которая осуществляет обслуживание пользователя – владельца узла. Клиент-серверные архитектуры представляют собой класс централизованных систем, принципы проектирвания которых хорошо известны. А вот множество узлов, объединенных в сеть, образуют децентрализованную систему, и в плане разработки это приносит множество специфических проблем, обусловленных природой таких систем. К числу таких проблем можно отнести следующие.
Нестабильность коммуникации. Сообщения, распространяемые по сети, могут теряться, либо приходить не в том порядке, в котором они были переданы. Система должна уметь справляться с такими ситуациями.
Переходные процессы. Изменение состояния одного узла не приводит к одномоментному изменению состояния в других узлах. Изменения распространяются постепенно, т. е. в системе возникает переходный процесс. Разные узлы могут генерировать разные переходные процессы, которые, в свою очередь, могут пересекаться. Это может приводить к конфликтам. В качестве примера: узел сгенерировал блок и распространяет его по сети; какой-то из узлов сгенерировал новый блок и выбирает валидатора, до которого предыдущий блок еще не дошел. Система должна уметь разрешать конфликты подобного рода.
Рассогласование данных. В каждый момент времени состав данных в различных узлах может быть различен, например, из-за возникновения форков, либо из-за разделения сети на сегменты. Система должна уметь приходить к согласованному состоянию.
Безопасность. Поскольку блокчейн работает в открытой среде, безопасности должно уделяться самое пристальное внимание. Безопасность должна обеспечиваться как на сетевом, так и на транзакционном уровнях.
Безопасность сетевого уровня требует, чтобы любое действие в сети проходило верификацию. Так, например, при получении сообщения необходимо проверить его подпись, чтобы убедиться, что данные не были изменены в процессе передачи.
Безопасность транзакционного уровня требует, чтобы любое изменение данных имело доказательство валидности. Так, например, доказательством валидности перевода финансов служит наличие денег на балансе отправителя в момент создания транзакции, а также наличие подписей создателя транзакции и партнера.
Отладка
Процессы, протекающие в системе, не детерминированы. Когда имеется множество узлов, взаимодействующих между собой, практически невозможно с точностью воспроизвести условия, которые предшествовали каким-либо аварийным ситуациям. Отладчики помогают мало: под отладчиком удобно смотреть какой-нибудь детерминированный процесс шаг за шагом, например, юнит-тест. Но в работе блокчейна как сети отладчики становятся непригодными, потому что запуск узла под отладчиком и внедрение точек останова модифицирует процессы, которые начинают протекать совершенно по-другому. Здесь мы имеем своего рода «эффект наблюдателя», хорошо известный в квантовой физике: наблюдение за процессом изменяет протекание самого процесса. По указанной причине основным инструментом отладки становится трассировка и последующий анализ результатов, т. е. логов. В результате отладка становится намного сложнее, и требует намного бОльших усилий.
Процесс разработки
При разработке любого инновационного продукта возникает множество проблемных областей, для которых отсутствуют готовые типовые решения. Это в полной мере проявилось и в нашем случае. Постоянно приходилось сталкиваться с нетривиальными задачами. Требовался глубокий анализ, неоднократные обсуждения с командой, многочисленные мозговые штурмы, прежде чем получалось выработать подходящее решение. И это решение могло и не сработать: случалось, что только в процессе реализации выявлялся подводный камень, который требовал пересмотра всей концепции.
А вот что касается написания кода, здесь было все наоборот. Когда перед глазами лежит детальный и проработанный алгоритм, то его программирование превращается в утилитарную задачу. Решить, какие конструкции языка использовать, какие классы, интерфейсы, указатели, и т. п. – это все в рассматриваемом контексте вторично, и сложностей не вызывало.
Исходя из описанных реалий, можно понять, что оценка сроков проектирования подобных систем носит в большинстве своем вероятностный характер. Начиная делать задачу, никогда с точностью нельзя предсказать, сколько времени понадобится для ее реализации, это можно только предположить. Конечно, предположения делаются не на пустом месте. Производится декомпозиция задачи, оцениваются сроки реализации подзадач, проблемы, с которыми, вероятно, придется столкнуться. Но точное попадание в запланированные сроки случалось крайне редко; сроки реализации, как правило, смещались в бОльшую сторону. Хотя, справедливости ради, нужно отметить, что бывало и так, что срок реализации задачи оказывался короче, чем запланированный, но все-таки это были единичные случаи.
Можно ли устранить указанную неопределенность? На мой взгляд, это невозможно. Такова природа инноваций: мы не обладаем даром предвидения и не знаем, с чем можем столкнуться, когда делаем то, что никто никогда раньше не делал. Единственное, что здесь может помочь, это грамотный риск-менеджмент на стадии планирования.
Лирическое заключение. К сожалению, в настоящее время большинство программистов работают как ремесленники: они приходят на проект, делают какие-то задачи, и уходят в новый проект. Это не их вина, такова сейчас ситуация в индустрии, которая все больше тяготеет к производственной бизнес-модели, с их регламентированными процессами и количественно-оценочными показателями. Иногда разработчики задерживаются на более длительный срок, в этом случае они могут увидеть и оценить эволюцию проекта. Но тем ценнее редкие ситуации, когда получается зайти на проект с самого начала, и работать с ним до стадии зрелости, пройдя все этапы жизненного цикла. В этом случае можно с уверенностью утверждать, что в проект приходит не просто программист, и даже не разработчик, а именно инженер. А ведь кто такой инженер? По сути, это творец. Который создает что-то новое, которое никогда раньше не существовало. И самое приятное в работе инженера происходит тогда, когда он, вспомнив, как все начиналось, и через что пришлось пройти на этом длинном пути, окидывает взглядом свое творение, и говорит: «А ведь хорошо получилось!» И все члены команды испытают такое же чувство.