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

Мемпул и валидация: переход состояния

senior~45 мин

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

Мемпул и валидация: переход состояния

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

Идея

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

Главная мысль урока: валидация идёт в два рубежа. На входе в мемпул быстрая проверка (подпись, не повтор), чтобы отсеять явный мусор и не ретранслировать его. И жёсткая проверка при применении к состоянию (баланс, точный nonce, сумма coinbase), которая и есть правила консенсуса.

Функция перехода: транзакция

Вот она, обещанная функция перехода. Каждая проверка тут это правило сети. Меняем состояние только при успехе: проверки идут до записи, поэтому отвергнутая транзакция не оставляет следов. Это атомарность перехода.

/// Почему переход отвергнут. Каждая ветка это нарушенное правило.
pub enum StateError {
    BadSignature,
    BadNonce { expected: u64, got: u64 },
    InsufficientFunds { have: u64, need: u64 },
    BadCoinbasePlacement,
    BadCoinbaseReward { allowed: u64, got: u64 },
    Overflow,
}

impl State {
    /// Списывает amount + fee с отправителя и зачисляет amount получателю.
    /// Комиссия тут сгорает из обращения, а забирает её майнер выше, в coinbase
    /// блока (reward + сумма комиссий). Так деньги сходятся: за блок их
    /// становится ровно на reward больше.
    pub fn apply_tx(&mut self, tx: &Transaction) -> Result<(), StateError> {
        if !tx.verify_signature() {
            return Err(StateError::BadSignature);
        }
        let expected = self.nonce(&tx.from);
        if tx.nonce != expected {
            return Err(StateError::BadNonce { expected, got: tx.nonce });
        }
        let need = tx.amount.checked_add(tx.fee).ok_or(StateError::Overflow)?;
        let have = self.balance(&tx.from);
        if have < need {
            return Err(StateError::InsufficientFunds { have, need });
        }
        // Все проверки прошли, теперь пишем.
        self.credit(&tx.to, tx.amount)?;
        *self.balances.entry(tx.from).or_default() -= need;
        *self.nonces.entry(tx.from).or_default() += 1;
        Ok(())
    }
}

Помощник credit из кода выше это единственное место, где балансы растут, и он не доверяет даже сложению: checked_add превращает переполнение u64 в StateError::Overflow вместо тихого заворота по модулю. Блок, который довёл бы чей-то баланс до переполнения, невалиден целиком.

impl State {
    fn credit(&mut self, addr: &Address, amount: u64) -> Result<(), StateError> {
        let slot = self.balances.entry(*addr).or_default();
        *slot = slot.checked_add(amount).ok_or(StateError::Overflow)?;
        Ok(())
    }
}

Заметь две тонкости. Первая: nonce сверяется на точное равенство, не «не меньше». Транзакция с nonce на будущее ждёт своей очереди, а не применяется вне порядка. Вторая: комиссия здесь именно сгорает у отправителя, а не дарится напрямую майнеру. Почему так, станет ясно на уровне блока.

Функция перехода: блок и сохранение денег

Блок применяется целиком или никак. Сначала coinbase (эмиссия плюс комиссии), потом обычные транзакции по порядку. И тут важнейшая проверка: сумму coinbase узел не принимает на веру.

impl State {
    /// Применяет блок: сперва coinbase (эмиссия + комиссии), затем обычные
    /// транзакции. block_reward это разрешённая эмиссия без комиссий.
    pub fn apply_block(&mut self, block: &Block, block_reward: u64) -> Result<(), StateError> {
        let Some((coinbase, rest)) = block.txs.split_first() else {
            return Err(StateError::BadCoinbasePlacement);
        };
        if !coinbase.is_coinbase() || rest.iter().any(Transaction::is_coinbase) {
            return Err(StateError::BadCoinbasePlacement);
        }
        let total_fees: u64 = rest
            .iter()
            .try_fold(0u64, |acc, tx| acc.checked_add(tx.fee))
            .ok_or(StateError::Overflow)?;
        let allowed = block_reward.checked_add(total_fees).ok_or(StateError::Overflow)?;
        if coinbase.amount != allowed {
            return Err(StateError::BadCoinbaseReward { allowed, got: coinbase.amount });
        }

        // Работаем на копии: блок применяется целиком или никак.
        let mut next = self.clone();
        next.credit(&coinbase.to, coinbase.amount)?;
        for tx in rest {
            next.apply_tx(tx)?;
        }
        *self = next;
        Ok(())
    }
}

Теперь видно, как сходятся деньги. Каждая транзакция сожгла комиссию у отправителя (минус fee из обращения), а coinbase напечатала reward + сумма всех комиссий майнеру. Итог по блоку: денег стало ровно на reward больше, ни монетой больше. Комиссии не исчезли и не задвоились, их просто переложили от отправителей майнеру через coinbase. Попробуй майнер напечатать лишнее, BadCoinbaseReward, блок отвергнут всеми.

Это и есть «правило это код»: контроль эмиссии живёт не в документации, а в одном if.

Мемпул: первый рубеж

На входе в мемпул проверка лёгкая и быстрая: это не место для жёсткого контроля баланса, ведь к моменту блока баланс может измениться. Задача входа отсечь явный мусор: чужую подпись, устаревший nonce, дубликат, coinbase с улицы.

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

use std::collections::HashMap;

/// Почему транзакцию не пустили в мемпул.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MempoolError {
    /// Подпись не подлинная или ключ не от отправителя.
    BadSignature,
    /// nonce уже пройден этим аккаунтом: это повтор.
    StaleNonce { account_nonce: u64, got: u64 },
    /// Такая транзакция (по txid) уже лежит в мемпуле.
    Duplicate,
    /// Coinbase в мемпул не принимается: её создаёт только майнер в блоке.
    CoinbaseRejected,
}

/// Пул неподтверждённых транзакций, ключ это txid.
#[derive(Default)]
pub struct Mempool {
    txs: HashMap<Hash, Transaction>,
}

impl Mempool {
    pub fn new() -> Mempool {
        Mempool::default()
    }
}
impl Mempool {
    /// Кладёт транзакцию в пул после входной проверки относительно state
    /// (состояние на вершине). Баланс тут не финальный фильтр: его проверит отбор.
    pub fn add(&mut self, tx: Transaction, state: &State) -> Result<Hash, MempoolError> {
        if tx.is_coinbase() {
            return Err(MempoolError::CoinbaseRejected);
        }
        if !tx.verify_signature() {
            return Err(MempoolError::BadSignature);
        }
        let account_nonce = state.nonce(&tx.from);
        if tx.nonce < account_nonce {
            return Err(MempoolError::StaleNonce { account_nonce, got: tx.nonce });
        }
        let txid = tx.txid();
        if self.txs.contains_key(&txid) {
            return Err(MempoolError::Duplicate);
        }
        self.txs.insert(txid, tx);
        Ok(txid)
    }
}

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

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

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