Мемпул и валидация: переход состояния
Пришло время написать сердце узла: функцию перехода состояния со всеми правилами консенсуса и мемпул, который решает, какие транзакции вообще попадут в блок. Тут каждая строчка проверки это правило, нарушив которое узел форкнется от сети.
Идея
У узла два связанных хранилища. Мемпул это зал ожидания: транзакции, которые узел получил и проверил, но ещё не положил в блок. И само состояние на вершине цепи. Когда узел майнит, он берёт транзакции из мемпула и применяет к состоянию через функцию перехода.
Главная мысль урока: валидация идёт в два рубежа. На входе в мемпул быстрая проверка (подпись, не повтор), чтобы отсеять явный мусор и не ретранслировать его. И жёсткая проверка при применении к состоянию (баланс, точный 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-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.