Раздел 30 · Web3 на Rust

Блоки, Merkle root и связывание хешем

senior~40 мин

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

Блоки, Merkle root и связывание хешем

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

Идея

Блок состоит из двух частей. Заголовок мал и фиксирован: ссылка на родителя, корень Меркла транзакций, время, высота, сложность и nonce. Тело это список транзакций. Хитрость в том, что хешируется только заголовок, а к телу он привязан одним полем merkle_root. Поэтому id блока это 32 байта, сколько бы транзакций ни лежало внутри.

Связывание в цепь делает поле parent: каждый заголовок несёт хеш предыдущего. Поменяй древний блок, его хеш поедет, перестанет сходиться parent следующего, и так до самой вершины. Один хеш вершины фиксирует всю историю. Это и есть «блок-чейн», цепь блоков.

Детерминированная сериализация

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

Тут развилка, которую стоит проговорить, а не проскочить. Можно было взять готовое: serde с bincode или protobuf, и не писать ни строчки кодека. Но у готовых форматов сериализация это деталь реализации: байтовое представление может поменяться с версией библиотеки, флагом конфигурации или порядком полей в структуре. Для обычного сервиса это мелочь, а у нас байты это часть консенсуса: они обязаны быть детерминированы до последнего бита и не меняться никогда. Поэтому пишем свой плоский кодер и контролируем формат сами, как в уроках Rust про кодирование битиков и проектирование игрового протокола: целые фиксированной ширины как есть, переменные длины как varint, всё в строго заданном порядке. Цена решения честная: свой кодек надо тестировать самому (roundtrip-тесты, обрезанные буферы, мусор на входе), и обратная совместимость не достаётся бесплатно, любое изменение формата это хард-форк сети.

Сам write_varint мы уже видели в прошлом уроке, когда собирали подписываемые байты транзакции. Теперь он дома, и рядом с ним второй помощник для полей переменной длины:

/// Кодирует u64 как LEB128 varint (семь бит на байт, старший бит это
/// «продолжение»). Маленькие числа занимают один байт.
pub fn write_varint(out: &mut Vec<u8>, mut value: u64) {
    loop {
        let mut byte = (value & 0x7F) as u8;
        value >>= 7;
        if value != 0 {
            byte |= 0x80;
        }
        out.push(byte);
        if value == 0 {
            break;
        }
    }
}

/// Дописывает байтовый блок с префиксом длины. Так декодер знает границу,
/// не угадывая её по разделителю.
pub fn write_bytes(out: &mut Vec<u8>, bytes: &[u8]) {
    write_varint(out, bytes.len() as u64);
    out.extend_from_slice(bytes);
}

Важная деталь: мы никогда не сериализуем HashMap напрямую. У хеш-карты нет порядка, а порядок тут часть контракта. Состояние всегда выводится из упорядоченной истории блоков, а не пишется как есть. Это прямое следствие требования детерминизма из прошлого урока.

Дерево Меркла в деле

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

const LEAF_PREFIX: u8 = 0x00;
const NODE_PREFIX: u8 = 0x01;

/// Хеш листа: префикс домена плюс сами байты.
fn hash_leaf(data: &[u8]) -> Hash {
    let mut buf = Vec::with_capacity(1 + data.len());
    buf.push(LEAF_PREFIX);
    buf.extend_from_slice(data);
    Hash::of(&buf)
}

/// Хеш внутреннего узла из пары детей.
fn hash_node(left: &Hash, right: &Hash) -> Hash {
    let mut buf = [0u8; 1 + 32 + 32];
    buf[0] = NODE_PREFIX;
    buf[1..33].copy_from_slice(&left.0);
    buf[33..].copy_from_slice(&right.0);
    Hash::of(&buf)
}

/// Корень Меркла по списку хешей листьев (id транзакций). Пустой список даёт
/// нулевой корень. На нечётном уровне последний узел дублируется (схема Bitcoin).
pub fn root(leaves: &[Hash]) -> Hash {
    if leaves.is_empty() {
        return Hash::ZERO;
    }
    let mut level: Vec<Hash> = leaves.to_vec();
    while level.len() > 1 {
        let mut next = Vec::with_capacity(level.len().div_ceil(2));
        for pair in level.chunks(2) {
            let left = pair[0];
            let right = if pair.len() == 2 { pair[1] } else { pair[0] };
            next.push(hash_node(&left, &right));
        }
        level = next;
    }
    level[0]
}

Обрати внимание на префиксы 0x00 для листа и 0x01 для узла. Это та самая защита доменов из урока про хеши: без неё дерево уязвимо к подмене, где внутренний узел выдают за лист. Разные домены, разные хеши.

И тут же честное признание про наш собственный код. Присмотрись: hash_leaf в пути блока не участвует. Листом дерева у нас выступает голый txid, уже готовый 32-байтовый двойной SHA-256 транзакции (увидишь ниже в merkle_root_of), без префикса 0x00. Не дыра ли это? В нашей схеме нет, и полезно понять почему. Внутренние узлы всё равно хешируются с префиксом 0x01 от ровно 65 байт, а txid это хеш сериализованной транзакции, которая по нашему формату не может весить 65 байт (одни адреса, ключ и подпись дают за сотню байт). Значит, предъявить внутренний узел как «транзакцию» и наоборот не выйдет: их прообразы не пересекаются. У Bitcoin, где ни префиксов, ни такой страховки нет, двусмысленность дерева выстрелила по-настоящему: дублирование нечётного хвоста позволило двум разным спискам транзакций дать один корень, и это стало уязвимостью CVE-2012-2459, мы разбирали её в уроке про хеши и деревья Меркла. Наше дерево дублирует хвост так же, как Bitcoin, поэтому та же двусмысленность списков есть и у нас; спасает то, что блок с дублированной транзакцией не пройдёт переход состояния (повторный nonce), а невалидные блоки мы не кешируем. А hash_leaf в модуле остаётся: он пригодится там, где листом будут произвольные байты, а не готовый хеш.

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

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.