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

P2P-сеть: gossip и распространение

senior~40 мин

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

P2P-сеть: gossip и распространение

Узел умеет майнить и проверять блоки, но он одинок. Чтобы получился блокчейн, узлам надо обмениваться блоками и транзакциями без центрального сервера и сходиться к одной истории. Разберём gossip, осознаем, что цепь это дерево, а не линия, и научим узлы разрешать конфликт самостоятельно.

Идея

В блокчейне нет главного сервера, который раздаёт правду. Есть равноправные узлы (peer-to-peer), и каждый знает лишь горстку соседей. Новость (блок или транзакция) должна доползти до всех через эти связи. Способ называется gossip: получил новое, проверил, переслал соседям; получил повтор, замолчал.

Транспорт тут вторичен. Реальные сети шлют данные по TCP или UDP или через libp2p. Но логика распространения и разрешения конфликтов от транспорта не зависит. Поэтому мы заменяем сокеты на прямые вызовы: детерминированная симуляция в памяти, набор узлов и широковещание. Сетевой транспорт ты уже собирал в разделе Rust, от проектирования протокола до надёжной доставки поверх UDP, и повторять его здесь значило бы отвлечься от главного.

Цепь это дерево, а не линия

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

Узел изнутри: где всё сходится

Прежде чем соединять узлы в сеть, соберём сам узел. Это ключевая интеграция всего проекта: дерево блоков, мемпул и транзакции здесь впервые работают вместе. Майнинг набирает транзакции из мемпула, печатает coinbase с наградой и собранными комиссиями, ищет nonce и применяет блок к собственной цепи; приём чужого блока прогоняет его через те же правила. Типы Blockchain, AddOutcome, ChainError и метод sync_mempool мы допишем в уроках 17 и 19 (константы BLOCK_REWARD и MAX_BLOCK_TXS, лимит транзакций в блоке, тоже живут там), но их роль ясна уже из сигнатур.

/// Узел сети: своя копия цепи и свой мемпул.
pub struct Node {
    chain: Blockchain,
    mempool: Mempool,
}

impl Node {
    pub fn new(genesis: Block) -> Result<Node, ChainError> {
        Ok(Node {
            chain: Blockchain::new(genesis)?,
            mempool: Mempool::new(),
        })
    }

    pub fn tip(&self) -> Hash {
        self.chain.tip()
    }

    pub fn height(&self) -> u64 {
        self.chain.height()
    }

    pub fn balance(&self, addr: &Address) -> u64 {
        self.chain.tip_state().balance(addr)
    }

    /// Принимает транзакцию от пользователя или из сети в мемпул, проверив её
    /// относительно состояния на вершине.
    pub fn submit_tx(&mut self, tx: Transaction) -> Result<Hash, MempoolError> {
        let state = self.chain.tip_state();
        self.mempool.add(tx, &state)
    }

    /// Майнит следующий блок: набирает транзакции из мемпула, добавляет coinbase
    /// с наградой и комиссиями, ищет nonce под текущую сложность и применяет
    /// блок к своей цепи. Возвращает намайненный блок для рассылки соседям.
    pub fn mine_block(&mut self, miner: Address, timestamp: u64) -> Result<Block, ChainError> {
        let tip = self.chain.tip();
        let tip_block = self.chain.block(&tip).expect("вершина существует");
        let height = tip_block.header.height + 1;
        let bits = self.chain.expected_bits(tip_block);

        let state = self.chain.tip_state();
        let selected = self.mempool.select_for_block(&state, MAX_BLOCK_TXS - 1);
        let fees: u64 = selected.iter().map(|t| t.fee).sum();

        let mut txs = vec![Transaction::coinbase(miner, BLOCK_REWARD + fees, height)];
        txs.extend(selected);
        let mut block = Block::assemble(tip, height, bits, timestamp, txs);
        mine(&mut block.header);

        let outcome = self.chain.add_block(block.clone())?;
        self.sync_mempool(&block, &outcome);
        Ok(block)
    }

    /// Принимает блок из сети. Возвращает исход (или ошибку валидации, которую
    /// gossip-слой использует, чтобы не ретранслировать мусор).
    pub fn receive_block(&mut self, block: Block) -> Result<AddOutcome, ChainError> {
        let outcome = self.chain.add_block(block.clone())?;
        self.sync_mempool(&block, &outcome);
        Ok(outcome)
    }
}

Присмотрись к mine_block: coinbase печатает BLOCK_REWARD + fees, ровно столько, сколько разрешит проверка блока из следующего урока, а отбор оставляет одно место под саму coinbase (MAX_BLOCK_TXS - 1). И заметь симметрию: свой блок и чужой блок проходят через один и тот же add_block. Узел не доверяет даже себе, и это правильно: правило одно для всех.

Сеть как набор узлов

Теперь сеть. Она хранит узлы по индексам, а кто с кем «связан» в конкретный момент, задаёт вызывающий. Это позволит смоделировать и нормальную работу, и раздел сети.

/// Сеть узлов. Хранит узлы по индексам; кто с кем связан в конкретный момент,
/// задаёт вызывающий, передавая список получателей (так моделируется partition).
pub struct Network {
    nodes: Vec<Node>,
}

impl Network {
    /// Поднимает count узлов из одного и того же genesis-блока.
    pub fn new(genesis: Block, count: usize) -> Network {
        let nodes = (0..count)
            .map(|_| Node::new(genesis.clone()).expect("genesis валиден"))
            .collect();
        Network { nodes }
    }

    pub fn len(&self) -> usize {
        self.nodes.len()
    }

    pub fn node(&self, i: usize) -> &Node {
        &self.nodes[i]
    }

    pub fn node_mut(&mut self, i: usize) -> &mut Node {
        &mut self.nodes[i]
    }
}

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

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

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