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

Майнинг: proof-of-work и сложность

senior~30 мин

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

Майнинг: proof-of-work и сложность

Блоки связаны хешем, но слепить блок пока ничего не стоит, а значит, и переписать историю тоже. Добавим цену. Proof-of-work делает создание блока дорогим, а проверку мгновенной, и сеть сама держит темп блоков постоянным, как бы ни менялась суммарная мощность.

Идея

Мы уже видели механику PoW в уроке 11, теперь встроим её в свой узел. Чтобы блок стоил работы, его хеш обязан уложиться в порог. У нас порог это «хеш заголовка начинается не меньше чем с bits нулевых бит». Найти такой nonce можно только перебором (хеш ведёт себя случайно), а проверить мгновенно.

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

Проверка работы

Сложность у нас это число требуемых ведущих нулевых бит хеша. Считать их умеет сам тип Hash:

impl Hash {
    /// Сколько ведущих нулевых бит в хеше. На этом стоит проверка работы:
    /// чем больше нулей требует сеть, тем выше сложность.
    pub fn leading_zero_bits(&self) -> u32 {
        let mut zeros = 0u32;
        for &byte in &self.0 {
            if byte == 0 {
                zeros += 8;
            } else {
                zeros += byte.leading_zeros();
                break;
            }
        }
        zeros
    }
}

/// Заголовок удовлетворяет своей сложности? Блок носит порог, под который замайнен.
pub fn header_meets_target(header: &BlockHeader) -> bool {
    header.hash().leading_zero_bits() >= header.bits
}

Майнинг это честный перебор: крутим nonce, пока хеш не уложится в порог.

/// Майнит заголовок: крутит nonce, пока хеш не уложится в порог. Возвращает
/// число попыток. На учебной сложности это секунды.
pub fn mine(header: &mut BlockHeader) -> u64 {
    let mut attempts = 0u64;
    loop {
        attempts += 1;
        if header_meets_target(header) {
            return attempts;
        }
        header.nonce = header.nonce.wrapping_add(1);
    }
}

Покрути ползунок сложности и посмотри, как растёт число попыток, а проверка остаётся мгновенной. Это и есть асимметрия PoW вживую.

ожидаемых попыток ~ 216 = 65 536

Накопленная работа

Тонкий момент, который понадобится при выборе цепи в 30-web3/19 · Консенсус и выбор цепи. Когда сравниваешь две ветки, сравнивать надо не их длину (число блоков), а накопленную работу. При смене сложности эти величины расходятся: десять лёгких блоков могут стоить меньше, чем три тяжёлых.

Работа одного блока это ожидаемое число попыток хеша, то есть 2^bits. Её суммируют по всей цепи.

/// Работа одного блока: ожидаемое число попыток хеша, то есть 2^bits.
/// Её суммируют по цепи, чтобы сравнивать ветки по накопленной работе,
/// а не по длине. u128, чтобы сумма не переполнилась.
pub fn work(bits: u32) -> u128 {
    1u128 << bits
}

Запомни это: «самая длинная цепь» это бытовое упрощение, а настоящее правило это «самая тяжёлая по работе».

Подстройка сложности

Если бы сложность была фиксированной, рост хешрейта сети ускорял бы блоки без предела: они рождались бы быстрее, чем успевают разойтись по сети, и вместе с ними росла бы доля осиротевших блоков. Поэтому сеть подстраивает сложность сама, без координатора, по часам блоков. Раз в RETARGET_INTERVAL блоков она смотрит, сколько реально заняли последние блоки, и двигает порог: шли быстро, сложность растёт; медленно, падает.

pub const RETARGET_INTERVAL: u64 = 16;  // у Bitcoin 2016
pub const TARGET_BLOCK_TIME: u64 = 10;  // целевой интервал, секунды

/// Нижняя и верхняя границы сложности. Снизу, чтобы genesis майнился мгновенно,
/// сверху, чтобы учебный CPU не завис на одном блоке.
pub const MIN_BITS: u32 = 8;
pub const MAX_BITS: u32 = 32;

/// Новая сложность по фактическому времени последнего окна. Сдвиг равен
/// log2(ожидаемое / фактическое), зажатому в коридор от -2 до +2 (то есть
/// отношение времён зажато в 0.25..4, ровно как у Bitcoin). Затем bits
/// клампится в рабочие границы.
pub fn retarget(old_bits: u32, actual_timespan: u64) -> u32 {
    let expected = (RETARGET_INTERVAL * TARGET_BLOCK_TIME).max(1) as f64;
    let actual = (actual_timespan.max(1)) as f64;
    // Блоки шли быстрее (actual < expected) -> ratio > 1 -> сложность растёт.
    let shift = (expected / actual).log2();
    let shift = shift.clamp(-2.0, 2.0).round() as i32;
    let new_bits = old_bits as i32 + shift;
    new_bits.clamp(MIN_BITS as i32, MAX_BITS as i32) as u32
}

Коридор 0.25..4 это защита из реального Bitcoin: один скачок мощности не должен выбросить сложность в крайность, за один период она не меняется больше чем вчетверо в любую сторону. У нас это и вовсе «не больше двух бит за период», потому что сложность у нас целочисленная (каждый бит вдвое дороже предыдущего).

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

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

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

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