iuna

iuna - experimental devnet protocol
git clone https://iuna.jhx.app/git/iuna.git
Log | Files | Refs | README | LICENSE

commit ca36ec19fb6ff5b185034a757cf7ff79bdbbba7e
parent 0d483943a77c4b63275dd1a501ead04d085f247c
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue,  4 Aug 2026 10:59:20 +0200

Release v0.2.14

Diffstat:
MCargo.lock | 2+-
MCargo.toml | 2+-
Mdocs/protocol.md | 36++++++++++++++++++++++++------------
Msrc/adapters/chain_store.rs | 656++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/adapters/http.rs | 128+++++--------------------------------------------------------------------------
Msrc/adapters/p2p.rs | 1196+++++--------------------------------------------------------------------------
Msrc/adapters/stratum.rs | 4+++-
Msrc/app.rs | 571++++++++++++++++++++++++++++++-------------------------------------------------
Msrc/domain.rs | 123++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/main.rs | 9+++++----
Mtests/iuna.rs | 590++++++++++++++++++++++++++++++++++---------------------------------------------
Mtests/properties.rs | 85++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
12 files changed, 1389 insertions(+), 2013 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "iuna" -version = "0.2.13" +version = "0.2.14" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iuna" -version = "0.2.13" +version = "0.2.14" edition = "2024" license = "Apache-2.0" diff --git a/docs/protocol.md b/docs/protocol.md @@ -31,7 +31,7 @@ A burn does not immediately select its own block. Instead: 3. The ticket stays eligible for a short expiry window. 4. Its lottery weight is the burned amount. -On the current devnet profile, tickets mature after `3` blocks and remain eligible for `3` block heights. +In the devnet profile, tickets mature after `3` blocks and remain eligible for `3` block heights. The lottery draw for the next height is deterministic. Nodes rank all eligible burn tickets using the parent block hash, the parent VDF output, the target height, and the ticket amounts. More burned IUNA means more weight, but the winner is still drawn by the protocol. @@ -44,7 +44,9 @@ For each block height, eligible tickets are ranked: The selected finalizer must prove ownership of the selected ticket, respect its rank time slot, and run the required VDF work. A block is valid only if the finalizer matches its ranked ticket, carries the correct leader proof, has a valid timestamp for its rank, includes a valid VDF output, and follows the transaction selection rules. -Every normal block must include at least one plaintext burn. A blinded transaction envelope does not satisfy that rule, because the finalizer and validators cannot know whether the encrypted payload is a burn until reveal. Plaintext transaction fees go to the block finalizer immediately; blinded transaction fees are paid to the finalizer that committed the envelope when the payload is revealed and executed. +Every normal block must include at least one plaintext burn. A blinded transaction envelope does not satisfy that rule, because the finalizer and validators cannot know whether the encrypted payload is a burn until reveal. Block-producing nodes create this plaintext burn locally from the finalizer wallet during block construction; it is not part of the gossiped mempool. + +Plaintext transaction fees go to the block finalizer immediately. Blinded transaction fees are paid to the finalizer that committed the envelope when the payload is revealed and executed. ## VDF Timing @@ -78,11 +80,11 @@ Rank slots depend on block timestamps, so timestamps are constrained by consensu - it must not be too far in the future relative to the validating node's network-adjusted clock; - for fallback ticket blocks, it must be at or after the finalizer rank slot. -The current future drift limit is `2 minutes`. A finalizer can lie within that small margin, but cannot skip an entire `10 minute` rank slot by claiming a far-future timestamp. P2P treats too-early future/slot blocks as temporal errors rather than peer-banning evidence. +The future drift limit is `2 minutes`. A finalizer can lie within that small margin, but cannot skip an entire `10 minute` rank slot by claiming a far-future timestamp. P2P treats too-early future/slot blocks as temporal errors rather than peer-banning evidence. ## Recovery Blocks -If selected ticket finalizers do not publish for long enough, recovery finalization becomes available. The current delay is `6` target block times. +If selected ticket finalizers do not publish for long enough, recovery finalization becomes available. The recovery delay is `6` target block times. A recovery block: @@ -104,7 +106,7 @@ Difficulty targets about one mine action per block: - The retarget window is `10` blocks. - The target is `10` mine actions per window. - Difficulty can move by at most `2` bits per window. -- Difficulty is clamped between `1` and `32` bits on the current devnet. +- Difficulty is clamped between `1` and `32` bits in the devnet profile. - Mine actions expire when their anchor is too old. This keeps issuance separate from finalization. PoW miners compete to create mine actions; burn-ticket finalizers decide blocks. @@ -113,7 +115,7 @@ This keeps issuance separate from finalization. PoW miners compete to create min The central censorship risk is simple: what if a finalizer only includes its own burns and ignores everyone else's burns? -iuna now supports blinded transaction content for this path. A wallet can encrypt a normal transaction payload and gossip a `BlindedTransaction` envelope instead of the plaintext transaction. The envelope exposes only: +Normal mempool traffic uses blinded transaction content. A wallet encrypts a normal transaction payload and gossips a `BlindedTransaction` envelope. The plaintext payload is not exposed before reveal. The envelope exposes only: - a commitment hash; - the declared fee; @@ -123,30 +125,40 @@ iuna now supports blinded transaction content for this path. A wallet can encryp The finalizer can rank the envelope by fee per encrypted byte, but cannot see whether the encrypted payload is a transfer or a burn before committing it to a block. -Reveal is a later step. A `BlindedReveal` carries only the commitment and decryption key. When a valid reveal is included, nodes decrypt the earlier payload, check the commitment and payload hash, decode the normal transaction, validate it against the current UTXO set, and execute it. If the decrypted transaction is a burn, it creates burn tickets at the reveal height, just like a plaintext burn would. +Reveal is a later step. A `BlindedReveal` carries only the commitment and decryption key. When a valid reveal is included, nodes decrypt the earlier payload, check the commitment and payload hash, decode the normal transaction, validate it against the current UTXO set, and execute it. If the decrypted transaction is a burn, it creates burn tickets at the reveal height, not the earlier envelope-commit height. Fees are paid without inflating the reveal block reward. The decrypted transaction must pay the same fee declared by the blinded envelope. When it executes, that fee is credited to the finalizer that originally included the blinded envelope, using a deterministic fee output tied to the commitment. -Expiry is exclusive: a blinded envelope with expiry height `H` can be included only in blocks below height `H`, and revealed only while the current chain height is below `H`. Expired envelopes and reveals are dropped from local selection. +Expiry is exclusive: a blinded envelope with expiry height `H` can be included only in blocks below height `H`, and revealed only while the current chain height is below `H`. The expiry height must be within `20` blocks of the node's current chain height when the envelope is accepted or selected. Expired envelopes and reveals are dropped from local selection. This does not make censorship impossible. A finalizer can still ignore all blinded traffic, or censor based on network metadata. But it removes the cheap strategy of inspecting plaintext mempool transactions and excluding third-party burns while including other fee-paying transactions. +## P2P Mempool Gossip + +The P2P mempool gossips only: + +- blinded transaction envelopes; +- blinded reveal keys; +- block inventory and blocks. + +It does not gossip plaintext transfers, burns, or mine actions. Wallet-created transfers, burns, and mine actions enter the network as blinded envelopes first, and are only decoded after a reveal. The one plaintext burn required for every normal block is produced locally by the finalizer and appears in the block itself. + ## Block Selection When a node builds a block, it selects transactions in this order: 1. Include valid blinded reveals first, so already committed encrypted payloads can execute. -2. Ensure the block has at least one plaintext burn. +2. Ensure the block has at least one plaintext burn from local block construction. 3. For recovery blocks, ensure at least one plaintext burn is from the recovery finalizer. -4. Fill remaining space with valid plaintext transactions and blinded transactions ordered by fee rate. +4. Fill remaining space with valid blinded transaction envelopes ordered by fee rate. -Blocks are bounded by transaction count and serialized byte size. The current devnet maximum block size is `100,000` bytes. +Blocks are bounded by transaction count and serialized byte size. The devnet maximum block size is `100,000` bytes. ## Genesis and Joining Genesis is explicit. A normal node without a chain starts in setup mode and waits to join an existing chain from peers rather than silently creating a separate chain. -The current genesis flow bootstraps the devnet with an initial burn ticket and an initial reward for the genesis wallet. New nodes fetch and validate chain snapshots from peers, then continue with normal block validation. +The genesis flow bootstraps the devnet with an initial burn ticket and an initial reward for the genesis wallet. New nodes fetch and validate chain snapshots from peers, then continue with normal block validation. ## What This Design Is Trying to Achieve diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -4,13 +4,15 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use rusqlite::{Connection, OptionalExtension, params}; use serde::Serialize; use crate::domain::{ - Amount, ChainSnapshot, Ledger, MINE_REWARD, Transaction, revealed_blinded_transactions, + Amount, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, FinalizerMode, LaunchProfile, + LeaderProof, Ledger, MINE_REWARD, OutPoint, Transaction, TxInput, TxOutput, + revealed_blinded_transactions, }; const SCHEMA: &str = r#" @@ -18,7 +20,7 @@ CREATE TABLE IF NOT EXISTS chain_snapshots ( id INTEGER PRIMARY KEY CHECK (id = 1), height INTEGER NOT NULL, tip_hash TEXT NOT NULL, - snapshot_json TEXT NOT NULL, + snapshot_blob BLOB NOT NULL, updated_at_ms INTEGER NOT NULL ); @@ -95,19 +97,19 @@ impl SqliteChainStore { pub fn load(&self) -> Result<Option<ChainSnapshot>> { self.with_connection(|connection| { - let snapshot_json = connection + let snapshot_blob = connection .query_row( - "SELECT snapshot_json FROM chain_snapshots WHERE id = 1", + "SELECT snapshot_blob FROM chain_snapshots WHERE id = 1", [], - |row| row.get::<_, String>(0), + |row| row.get::<_, Vec<u8>>(0), ) .optional() .context("failed to load chain snapshot from database")?; - snapshot_json - .map(|json| { - serde_json::from_str(&json) - .context("failed to parse chain snapshot from database") + snapshot_blob + .map(|blob| { + decode_compact_snapshot(&blob) + .context("failed to parse compact chain snapshot from database") }) .transpose() }) @@ -119,8 +121,8 @@ impl SqliteChainStore { pub fn save_with_metrics(&self, snapshot: &ChainSnapshot, keep_metrics: bool) -> Result<()> { let (height, tip_hash) = snapshot_tip(snapshot).context("cannot persist empty chain")?; - let snapshot_json = - serde_json::to_string(snapshot).context("failed to serialize chain snapshot")?; + let snapshot_blob = + encode_compact_snapshot(snapshot).context("failed to encode compact chain snapshot")?; let updated_at_ms = unix_ms(); let metrics = if keep_metrics { Some(metrics_from_snapshot(snapshot)?) @@ -135,15 +137,15 @@ impl SqliteChainStore { transaction .execute( r#" -INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_json, updated_at_ms) +INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_blob, updated_at_ms) VALUES (1, ?1, ?2, ?3, ?4) ON CONFLICT(id) DO UPDATE SET height = excluded.height, tip_hash = excluded.tip_hash, - snapshot_json = excluded.snapshot_json, + snapshot_blob = excluded.snapshot_blob, updated_at_ms = excluded.updated_at_ms "#, - params![height, tip_hash, snapshot_json, updated_at_ms], + params![height, tip_hash, snapshot_blob, updated_at_ms], ) .context("failed to persist chain snapshot")?; match metrics { @@ -296,6 +298,548 @@ fn clear_metrics_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Resu Ok(()) } +const COMPACT_SNAPSHOT_MAGIC: &[u8] = b"IUNA-SNAPSHOT"; +const COMPACT_SNAPSHOT_VERSION: u8 = 1; + +fn encode_compact_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<u8>> { + let mut writer = CompactWriter::default(); + writer.bytes(COMPACT_SNAPSHOT_MAGIC); + writer.u8(COMPACT_SNAPSHOT_VERSION); + writer.varint(snapshot.genesis_allocations.len() as u64); + for (address, amount) in &snapshot.genesis_allocations { + writer.hex(address)?; + writer.varint(*amount); + } + writer.varint(snapshot.vdf_rounds); + encode_launch_profile(&mut writer, &snapshot.launch_profile); + writer.varint(snapshot.blocks.len() as u64); + let mut expected_prev_hash = "0".repeat(64); + for (height, block) in snapshot.blocks.iter().enumerate() { + if block.height != height as u64 { + bail!( + "chain snapshot block height {} does not match compact position {}", + block.height, + height + ); + } + if block.prev_hash != expected_prev_hash { + bail!( + "chain snapshot block {} has non-canonical previous hash", + height + ); + } + encode_block_body(&mut writer, block)?; + expected_prev_hash = block.hash.clone(); + } + Ok(writer.into_inner()) +} + +fn decode_compact_snapshot(bytes: &[u8]) -> Result<ChainSnapshot> { + let mut reader = CompactReader::new(bytes); + reader.magic(COMPACT_SNAPSHOT_MAGIC)?; + let version = reader.u8()?; + if version != COMPACT_SNAPSHOT_VERSION { + bail!("unsupported compact chain snapshot version {version}"); + } + let genesis_count = reader.usize()?; + let mut genesis_allocations = std::collections::BTreeMap::new(); + for _ in 0..genesis_count { + let address = reader.hex()?; + let amount = reader.varint()?; + genesis_allocations.insert(address, amount); + } + let vdf_rounds = reader.varint()?; + let launch_profile = decode_launch_profile(&mut reader)?; + let block_count = reader.usize()?; + let mut blocks = Vec::with_capacity(block_count); + let mut prev_hash = "0".repeat(64); + for height in 0..block_count { + let block = decode_block_body(&mut reader, height as u64, prev_hash)?; + prev_hash = block.hash.clone(); + blocks.push(block); + } + reader.finish()?; + Ok(ChainSnapshot { + genesis_allocations, + vdf_rounds, + launch_profile, + blocks, + }) +} + +fn encode_launch_profile(writer: &mut CompactWriter, profile: &LaunchProfile) { + writer.string(&profile.profile_id); + writer.varint(profile.ticket_maturity_delay_heights); + writer.varint(profile.ticket_expiry_window_heights); + writer.varint(u64::from(profile.mine_difficulty_bits)); + writer.varint(profile.max_pending_transactions as u64); + writer.varint(profile.max_block_transactions as u64); + writer.varint(profile.max_block_bytes as u64); +} + +fn decode_launch_profile(reader: &mut CompactReader<'_>) -> Result<LaunchProfile> { + Ok(LaunchProfile { + profile_id: reader.string()?, + ticket_maturity_delay_heights: reader.varint()?, + ticket_expiry_window_heights: reader.varint()?, + mine_difficulty_bits: reader.u32()?, + max_pending_transactions: reader.usize()?, + max_block_transactions: reader.usize()?, + max_block_bytes: reader.usize()?, + }) +} + +fn encode_block_body(writer: &mut CompactWriter, block: &Block) -> Result<()> { + writer.varint(block.timestamp_ms); + writer.hex(&block.miner)?; + writer.u8(match block.finalizer_mode { + FinalizerMode::Ticket => 0, + FinalizerMode::Recovery => 1, + }); + writer.varint(u64::from(block.finalizer_rank)); + writer.varint(block.reward); + writer.varint(block.vdf_rounds); + writer.string(&block.vdf_output); + writer.bool(block.leader_proof.is_some()); + if let Some(proof) = &block.leader_proof { + writer.hexish(&proof.ticket_id)?; + writer.hex(&proof.public_key)?; + writer.hex(&proof.signature)?; + } + writer.varint(block.blinded_transactions.len() as u64); + for transaction in &block.blinded_transactions { + encode_blinded_transaction(writer, transaction)?; + } + writer.varint(block.blinded_reveals.len() as u64); + for reveal in &block.blinded_reveals { + encode_blinded_reveal(writer, reveal)?; + } + writer.varint(block.transactions.len() as u64); + for transaction in &block.transactions { + encode_transaction(writer, transaction)?; + } + writer.hex(&block.hash)?; + Ok(()) +} + +fn decode_block_body( + reader: &mut CompactReader<'_>, + height: u64, + prev_hash: String, +) -> Result<Block> { + let timestamp_ms = reader.varint()?; + let miner = reader.hex()?; + let finalizer_mode = match reader.u8()? { + 0 => FinalizerMode::Ticket, + 1 => FinalizerMode::Recovery, + other => bail!("invalid finalizer mode tag {other}"), + }; + let finalizer_rank = reader.u32()?; + let reward = reader.varint()?; + let vdf_rounds = reader.varint()?; + let vdf_output = reader.string()?; + let leader_proof = if reader.bool()? { + Some(LeaderProof { + ticket_id: reader.hexish()?, + public_key: reader.hex()?, + signature: reader.hex()?, + }) + } else { + None + }; + let blinded_transactions = decode_vec(reader, decode_blinded_transaction)?; + let blinded_reveals = decode_vec(reader, decode_blinded_reveal)?; + let transactions = decode_vec(reader, decode_transaction)?; + let hash = reader.hex()?; + Ok(Block { + height, + prev_hash, + timestamp_ms, + miner, + finalizer_mode, + finalizer_rank, + reward, + vdf_rounds, + vdf_output, + leader_proof, + blinded_transactions, + blinded_reveals, + transactions, + hash, + }) +} + +fn encode_blinded_transaction( + writer: &mut CompactWriter, + transaction: &BlindedTransaction, +) -> Result<()> { + writer.hex(&transaction.commitment)?; + writer.varint(transaction.fee); + writer.varint(u64::from(transaction.encrypted_size)); + writer.varint(transaction.expires_at_height); + writer.hex(&transaction.nonce)?; + writer.hex(&transaction.ciphertext)?; + writer.hex(&transaction.payload_hash)?; + Ok(()) +} + +fn decode_blinded_transaction(reader: &mut CompactReader<'_>) -> Result<BlindedTransaction> { + Ok(BlindedTransaction { + commitment: reader.hex()?, + fee: reader.varint()?, + encrypted_size: reader.u32()?, + expires_at_height: reader.varint()?, + nonce: reader.hex()?, + ciphertext: reader.hex()?, + payload_hash: reader.hex()?, + }) +} + +fn encode_blinded_reveal(writer: &mut CompactWriter, reveal: &BlindedReveal) -> Result<()> { + writer.hex(&reveal.commitment)?; + writer.hex(&reveal.key)?; + Ok(()) +} + +fn decode_blinded_reveal(reader: &mut CompactReader<'_>) -> Result<BlindedReveal> { + Ok(BlindedReveal { + commitment: reader.hex()?, + key: reader.hex()?, + }) +} + +fn encode_transaction(writer: &mut CompactWriter, transaction: &Transaction) -> Result<()> { + match transaction { + Transaction::Transfer { + inputs, + outputs, + fee, + signature, + } => { + writer.u8(0); + encode_inputs(writer, inputs)?; + encode_outputs(writer, outputs)?; + writer.varint(*fee); + writer.hex(signature)?; + } + Transaction::Burn { + inputs, + change, + amount, + fee, + signature, + } => { + writer.u8(1); + encode_inputs(writer, inputs)?; + encode_outputs(writer, change)?; + writer.varint(*amount); + writer.varint(*fee); + writer.hexish(signature)?; + } + Transaction::Mine { + recipient, + anchor, + salt, + nonce, + difficulty_bits, + proof_header, + signature, + } => { + writer.u8(2); + writer.hex(recipient)?; + writer.hex(anchor)?; + writer.varint(*salt); + writer.varint(*nonce); + writer.varint(u64::from(*difficulty_bits)); + writer.bool(proof_header.is_some()); + if let Some(proof_header) = proof_header { + writer.hex(proof_header)?; + } + writer.hex(signature)?; + } + } + Ok(()) +} + +fn decode_transaction(reader: &mut CompactReader<'_>) -> Result<Transaction> { + match reader.u8()? { + 0 => Ok(Transaction::Transfer { + inputs: decode_inputs(reader)?, + outputs: decode_outputs(reader)?, + fee: reader.varint()?, + signature: reader.hex()?, + }), + 1 => Ok(Transaction::Burn { + inputs: decode_inputs(reader)?, + change: decode_outputs(reader)?, + amount: reader.varint()?, + fee: reader.varint()?, + signature: reader.hexish()?, + }), + 2 => { + let recipient = reader.hex()?; + let anchor = reader.hex()?; + let salt = reader.varint()?; + let nonce = reader.varint()?; + let difficulty_bits = reader.u32()?; + let proof_header = if reader.bool()? { + Some(reader.hex()?) + } else { + None + }; + let signature = reader.hex()?; + Ok(Transaction::Mine { + recipient, + anchor, + salt, + nonce, + difficulty_bits, + proof_header, + signature, + }) + } + other => bail!("invalid transaction tag {other}"), + } +} + +fn encode_inputs(writer: &mut CompactWriter, inputs: &[TxInput]) -> Result<()> { + writer.varint(inputs.len() as u64); + for input in inputs { + writer.hexish(&input.outpoint.txid)?; + writer.varint(u64::from(input.outpoint.index)); + writer.hex(&input.owner)?; + writer.hexish(&input.signature)?; + } + Ok(()) +} + +fn decode_inputs(reader: &mut CompactReader<'_>) -> Result<Vec<TxInput>> { + decode_vec(reader, |reader| { + Ok(TxInput { + outpoint: OutPoint { + txid: reader.hexish()?, + index: reader.u32()?, + }, + owner: reader.hex()?, + signature: reader.hexish()?, + }) + }) +} + +fn encode_outputs(writer: &mut CompactWriter, outputs: &[TxOutput]) -> Result<()> { + writer.varint(outputs.len() as u64); + for output in outputs { + writer.hex(&output.address)?; + writer.varint(output.amount); + } + Ok(()) +} + +fn decode_outputs(reader: &mut CompactReader<'_>) -> Result<Vec<TxOutput>> { + decode_vec(reader, |reader| { + Ok(TxOutput { + address: reader.hex()?, + amount: reader.varint()?, + }) + }) +} + +fn decode_vec<T>( + reader: &mut CompactReader<'_>, + mut decode: impl FnMut(&mut CompactReader<'_>) -> Result<T>, +) -> Result<Vec<T>> { + let len = reader.usize()?; + let mut values = Vec::with_capacity(len); + for _ in 0..len { + values.push(decode(reader)?); + } + Ok(values) +} + +#[derive(Default)] +struct CompactWriter { + bytes: Vec<u8>, +} + +impl CompactWriter { + fn into_inner(self) -> Vec<u8> { + self.bytes + } + + fn bytes(&mut self, bytes: &[u8]) { + self.bytes.extend_from_slice(bytes); + } + + fn u8(&mut self, value: u8) { + self.bytes.push(value); + } + + fn bool(&mut self, value: bool) { + self.u8(u8::from(value)); + } + + fn varint(&mut self, mut value: u64) { + while value >= 0x80 { + self.u8((value as u8) | 0x80); + value >>= 7; + } + self.u8(value as u8); + } + + fn string(&mut self, value: &str) { + self.varint(value.len() as u64); + self.bytes(value.as_bytes()); + } + + fn hex(&mut self, value: &str) -> Result<()> { + let bytes = decode_hex(value)?; + self.varint(bytes.len() as u64); + self.bytes(&bytes); + Ok(()) + } + + fn hexish(&mut self, value: &str) -> Result<()> { + if value.len() % 2 == 0 && value.as_bytes().iter().all(|byte| byte.is_ascii_hexdigit()) { + self.u8(1); + self.hex(value)?; + } else { + self.u8(0); + self.string(value); + } + Ok(()) + } +} + +struct CompactReader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> CompactReader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + fn finish(&self) -> Result<()> { + if self.offset != self.bytes.len() { + bail!("compact chain snapshot has trailing bytes"); + } + Ok(()) + } + + fn magic(&mut self, magic: &[u8]) -> Result<()> { + let bytes = self.take(magic.len())?; + if bytes != magic { + bail!("invalid compact chain snapshot magic"); + } + Ok(()) + } + + fn take(&mut self, len: usize) -> Result<&'a [u8]> { + let end = self + .offset + .checked_add(len) + .context("compact chain snapshot offset overflow")?; + if end > self.bytes.len() { + bail!("unexpected end of compact chain snapshot"); + } + let bytes = &self.bytes[self.offset..end]; + self.offset = end; + Ok(bytes) + } + + fn u8(&mut self) -> Result<u8> { + Ok(self.take(1)?[0]) + } + + fn bool(&mut self) -> Result<bool> { + match self.u8()? { + 0 => Ok(false), + 1 => Ok(true), + other => bail!("invalid compact bool tag {other}"), + } + } + + fn varint(&mut self) -> Result<u64> { + let mut value = 0_u64; + let mut shift = 0_u32; + loop { + let byte = self.u8()?; + value |= u64::from(byte & 0x7f) + .checked_shl(shift) + .context("compact varint shift overflow")?; + if byte & 0x80 == 0 { + return Ok(value); + } + shift += 7; + if shift >= 64 { + bail!("compact varint is too large"); + } + } + } + + fn usize(&mut self) -> Result<usize> { + self.varint()? + .try_into() + .context("compact integer does not fit usize") + } + + fn u32(&mut self) -> Result<u32> { + self.varint()? + .try_into() + .context("compact integer does not fit u32") + } + + fn string(&mut self) -> Result<String> { + let len = self.usize()?; + let bytes = self.take(len)?; + String::from_utf8(bytes.to_vec()).context("compact string is not valid UTF-8") + } + + fn hex(&mut self) -> Result<String> { + let len = self.usize()?; + Ok(hex_encode(self.take(len)?)) + } + + fn hexish(&mut self) -> Result<String> { + match self.u8()? { + 0 => self.string(), + 1 => self.hex(), + other => bail!("invalid compact hexish tag {other}"), + } + } +} + +fn decode_hex(input: &str) -> Result<Vec<u8>> { + if input.len() % 2 != 0 { + bail!("hex string has odd length"); + } + let mut bytes = Vec::with_capacity(input.len() / 2); + for pair in input.as_bytes().chunks_exact(2) { + let high = hex_value(pair[0])?; + let low = hex_value(pair[1])?; + bytes.push((high << 4) | low); + } + Ok(bytes) +} + +fn hex_value(byte: u8) -> Result<u8> { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + b'A'..=b'F' => Ok(byte - b'A' + 10), + _ => bail!("invalid hex character"), + } +} + +fn hex_encode(bytes: impl AsRef<[u8]>) -> String { + bytes + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + fn metrics_from_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<BlockMetricRow>> { let ledger = Ledger::from_persisted_snapshot(snapshot.clone()) .context("failed to rebuild ledger for metrics")?; @@ -436,7 +980,10 @@ mod tests { use crate::domain::{BLOCK_REWARD, GenesisBurn, Ledger, Wallet}; - use super::{BlockMetricRow, SqliteChainStore, replace_metrics}; + use super::{ + BlockMetricRow, SqliteChainStore, decode_compact_snapshot, encode_compact_snapshot, + replace_metrics, + }; #[test] fn sqlite_chain_store_roundtrips_snapshot() { @@ -452,6 +999,75 @@ mod tests { store.save(&ledger.snapshot()).unwrap(); assert_eq!(store.load().unwrap(), Some(ledger.snapshot())); + store + .with_connection(|connection| { + let columns = connection + .prepare("PRAGMA table_info(chain_snapshots)")? + .query_map([], |row| row.get::<_, String>(1))? + .collect::<std::result::Result<Vec<_>, _>>()?; + assert!(columns.contains(&"snapshot_blob".to_string())); + assert!(!columns.contains(&"snapshot_json".to_string())); + Ok(()) + }) + .unwrap(); + } + + #[test] + fn compact_snapshot_roundtrips_and_is_smaller_than_json() { + let alice = Wallet::from_seed("compact-alice"); + let bob = Wallet::from_seed("compact-bob"); + let carol = Wallet::from_seed("compact-carol"); + let wallets = [alice.clone(), bob.clone()]; + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), 10_000_000); + genesis.insert(bob.address().to_string(), 10_000_000); + genesis.insert(carol.address().to_string(), 10_000_000); + let mut ledger = Ledger::new_with_genesis_burns( + genesis, + vec![ + GenesisBurn::new(alice.address(), 1_000_000), + GenesisBurn::new(bob.address(), 1_000_000), + ], + 1, + ) + .unwrap(); + let blinded = ledger + .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction) + .unwrap(); + let leader = ledger.expected_leader_for_next_block().unwrap(); + let wallet = wallets + .iter() + .find(|wallet| wallet.address() == leader) + .unwrap(); + let burn = ledger.build_burn(wallet, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(wallet, 1).unwrap(); + ledger.apply_locally_mined_block(block).unwrap(); + ledger.submit_blinded_reveal(blinded.reveal).unwrap(); + let leader = ledger.expected_leader_for_next_block().unwrap(); + let wallet = wallets + .iter() + .find(|wallet| wallet.address() == leader) + .unwrap(); + let burn = ledger.build_burn(wallet, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(wallet, 2).unwrap(); + ledger.apply_locally_mined_block(block).unwrap(); + + let snapshot = ledger.snapshot(); + let compact = encode_compact_snapshot(&snapshot).unwrap(); + let json = serde_json::to_vec(&snapshot).unwrap(); + + assert_eq!(decode_compact_snapshot(&compact).unwrap(), snapshot); + assert!( + compact.len() < json.len(), + "compact snapshot should be smaller than JSON: compact={} JSON={}", + compact.len(), + json.len() + ); } #[test] @@ -643,15 +1259,15 @@ mod tests { } #[test] - fn sqlite_chain_store_reports_invalid_snapshot_json() { + fn sqlite_chain_store_reports_invalid_compact_snapshot() { let dir = tempdir().unwrap(); let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); store .with_connection(|connection| { connection.execute( r#" -INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_json, updated_at_ms) -VALUES (1, 9, 'bad-tip', '{"not":"a chain"}', 0) +INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_blob, updated_at_ms) +VALUES (1, 9, 'bad-tip', x'00010203', 0) "#, [], )?; @@ -662,7 +1278,7 @@ VALUES (1, 9, 'bad-tip', '{"not":"a chain"}', 0) let error = store.load().unwrap_err(); assert!( - format!("{error:#}").contains("failed to parse chain snapshot from database"), + format!("{error:#}").contains("failed to parse compact chain snapshot from database"), "{error:#}" ); } diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -121,9 +121,6 @@ struct NetworkHealthResponse { stale_peers: usize, banned_peers: usize, pending_transactions: usize, - mempool_known_peers: usize, - mempool_divergent_peers: usize, - mempool_missing_transactions: usize, network_time_offset_ms: Option<i64>, bad_clock_peers: usize, last_error: Option<String>, @@ -1410,18 +1407,6 @@ fn network_health_at( .iter() .filter(|peer| peer.is_banned_at(now_ms)) .count(); - let mempool_known_peers = peers - .iter() - .filter(|peer| peer.last_known_mempool_count.is_some()) - .count(); - let mempool_divergent_peers = peers - .iter() - .filter(|peer| peer.last_known_mempool_missing.unwrap_or(0) > 0) - .count(); - let mempool_missing_transactions = peers - .iter() - .map(|peer| peer.last_known_mempool_missing.unwrap_or(0)) - .sum(); let network_time_offset_ms = median_peer_clock_offset(peers, now_ms); let bad_clock_peers = peers .iter() @@ -1436,7 +1421,6 @@ fn network_health_at( let last_error = peers.iter().rev().find_map(|peer| { peer.last_error .as_ref() - .or(peer.last_transaction_rejection.as_ref()) .map(|error| format!("{}: {error}", peer.address)) }); @@ -1446,8 +1430,6 @@ fn network_health_at( "banned" } else if lag_blocks > 0 { "syncing" - } else if mempool_missing_transactions > 0 { - "mempool syncing" } else if failed_peers > 0 && healthy_peers == 0 { "peer errors" } else if stale_peers > 0 && healthy_peers == stale_peers { @@ -1460,10 +1442,7 @@ fn network_health_at( .to_string(); NetworkHealthResponse { - ok: !peers.is_empty() - && lag_blocks == 0 - && mempool_missing_transactions == 0 - && healthy_peers > stale_peers, + ok: !peers.is_empty() && lag_blocks == 0 && healthy_peers > stale_peers, state, local_height, best_known_height, @@ -1476,9 +1455,6 @@ fn network_health_at( stale_peers, banned_peers, pending_transactions: status.chain.pending_transactions, - mempool_known_peers, - mempool_divergent_peers, - mempool_missing_transactions, network_time_offset_ms, bad_clock_peers, last_error, @@ -3240,7 +3216,7 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> <div class="table-wrap"> <table> - <thead><tr><th>Status</th><th>Address</th><th>Direction</th><th>Last Contact</th><th>Clock</th><th>Ban</th><th>Score</th><th>Height</th><th>Delta</th><th>Tip</th><th>Mempool</th><th>Shared</th><th>Missing</th><th>Root</th><th>Sent</th><th>Received</th><th>Last Error</th><th>Actions</th></tr></thead> + <thead><tr><th>Status</th><th>Address</th><th>Direction</th><th>Last Contact</th><th>Clock</th><th>Ban</th><th>Score</th><th>Height</th><th>Delta</th><th>Tip</th><th>Sent</th><th>Received</th><th>Last Error</th><th>Actions</th></tr></thead> <tbody> <template x-for="peer in peers" :key="peer.address"> <tr> @@ -3254,24 +3230,20 @@ const INDEX_HTML: &str = r#"<!doctype html> <td x-text="peer.last_known_height ?? '-'"></td> <td x-text="peerHeightDelta(peer)"></td> <td><code x-text="short(peer.last_known_tip_hash)"></code></td> - <td x-text="peer.last_known_mempool_count ?? '-'"></td> - <td x-text="peer.last_known_mempool_shared ?? '-'"></td> - <td x-text="peer.last_known_mempool_missing ?? '-'"></td> - <td><code x-text="short(peer.last_known_mempool_root)"></code></td> <td x-text="peer.messages_sent"></td> <td x-text="peer.messages_received"></td> - <td x-text="peer.last_error || peer.last_transaction_rejection || ''"></td> + <td x-text="peer.last_error || ''"></td> <td><div class="peer-actions"><button class="peer-remove" type="button" x-show="canRemovePeer(peer)" @click="removePeer(peer)">Remove</button><span class="muted" x-show="!canRemovePeer(peer)">Observed</span></div></td> </tr> </template> <tr class="skeleton-card" x-show="peerPage.loading" aria-hidden="true"> - <td colspan="18"><div class="skeleton-table-cell"></div></td> + <td colspan="14"><div class="skeleton-table-cell"></div></td> </tr> <tr class="skeleton-card" x-show="peerPage.loading" aria-hidden="true"> - <td colspan="18"><div class="skeleton-table-cell"></div></td> + <td colspan="14"><div class="skeleton-table-cell"></div></td> </tr> - <tr x-show="peerPage.hasMore"><td colspan="18"><div class="page-sentinel" x-init="$nextTick(() => observePageSentinel('peer', $el))"></div></td></tr> - <tr x-show="peers.length === 0 && !peerPage.loading"><td colspan="18">No peers</td></tr> + <tr x-show="peerPage.hasMore"><td colspan="14"><div class="page-sentinel" x-init="$nextTick(() => observePageSentinel('peer', $el))"></div></td></tr> + <tr x-show="peers.length === 0 && !peerPage.loading"><td colspan="14">No peers</td></tr> </tbody> </table> </div> @@ -3295,19 +3267,6 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="metric"><div class="label">Inventory Rx</div><div class="value" x-text="p2pMetrics.inventory_envelopes_received ?? 0"></div></div> <div class="metric"><div class="label">Data Rx</div><div class="value" x-text="p2pMetrics.data_envelopes_received ?? 0"></div></div> <div class="metric"><div class="label">Control Rx</div><div class="value" x-text="p2pMetrics.control_envelopes_received ?? 0"></div></div> - <div class="metric"><div class="label">Tx Ack Sent</div><div class="value" x-text="p2pMetrics.transaction_ack_envelopes_sent ?? 0"></div></div> - <div class="metric"><div class="label">Tx Ack Rx</div><div class="value" x-text="p2pMetrics.transaction_ack_envelopes_received ?? 0"></div></div> - <div class="metric"><div class="label">Tx Accepted Sent</div><div class="value" x-text="p2pMetrics.transactions_accepted_sent ?? 0"></div></div> - <div class="metric"><div class="label">Tx Accepted Rx</div><div class="value" x-text="p2pMetrics.transactions_accepted_received ?? 0"></div></div> - <div class="metric"><div class="label">Tx Rejected Sent</div><div class="value" x-text="p2pMetrics.transactions_rejected_sent ?? 0"></div></div> - <div class="metric"><div class="label">Tx Rejected Rx</div><div class="value" x-text="p2pMetrics.transactions_rejected_received ?? 0"></div></div> - <div class="metric"><div class="label">Tx Retries</div><div class="value" x-text="p2pMetrics.transaction_retries_sent ?? 0"></div></div> - <div class="metric"><div class="label">Tx Ack Pending</div><div class="value" x-text="p2pMetrics.transaction_ack_pending ?? 0"></div></div> - <div class="metric"><div class="label">Mempool Status Rx</div><div class="value" x-text="p2pMetrics.mempool_statuses_received ?? 0"></div></div> - <div class="metric"><div class="label">Mempool Tx Seen</div><div class="value" x-text="p2pMetrics.mempool_status_transactions_received ?? 0"></div></div> - <div class="metric"><div class="label">Mempool Mismatch</div><div class="value" x-text="p2pMetrics.mempool_status_mismatches ?? 0"></div></div> - <div class="metric"><div class="label">Mempool Requests</div><div class="value" x-text="p2pMetrics.mempool_transaction_requests_sent ?? 0"></div></div> - <div class="metric"><div class="label">Mempool Requested Tx</div><div class="value" x-text="p2pMetrics.mempool_transaction_request_signatures_sent ?? 0"></div></div> </div> <div class="metric-context"> <div class="tx-field"><span class="tx-label">Last Failure</span><span class="tx-value text" x-text="p2pMetrics.last_session_failure || '-'"></span></div> @@ -4391,16 +4350,6 @@ mod tests { assert_eq!(isolated.local_height, 0); assert_eq!(isolated.best_known_height, 0); - let mut mempool_peers = PeerBook::from_addresses(vec!["127.0.0.1:9444".to_string()]); - mempool_peers.record_status("127.0.0.1:9444", 0, "tip".to_string()); - mempool_peers.record_mempool_status("127.0.0.1:9444", 2, "remote-root".to_string(), 1, 1); - let mempool_syncing = super::network_health(&status, &mempool_peers.list()); - assert!(!mempool_syncing.ok); - assert_eq!(mempool_syncing.state, "mempool syncing"); - assert_eq!(mempool_syncing.mempool_known_peers, 1); - assert_eq!(mempool_syncing.mempool_divergent_peers, 1); - assert_eq!(mempool_syncing.mempool_missing_transactions, 1); - let mut clock_peers = PeerBook::from_addresses(vec![ "127.0.0.1:9450".to_string(), "127.0.0.1:9451".to_string(), @@ -4432,20 +4381,13 @@ mod tests { messages_received: 1, last_known_height: Some(3), last_known_tip_hash: Some("remote-tip".to_string()), - last_known_mempool_count: None, - last_known_mempool_root: None, - last_known_mempool_shared: None, - last_known_mempool_missing: None, - last_mempool_status_ms: None, last_clock_offset_ms: None, last_clock_offset_accepted: None, last_clock_observed_ms: None, last_error: None, - last_transaction_rejection: None, last_contact_ms: Some(10_000), last_success_ms: Some(10_000), last_error_ms: None, - last_transaction_rejection_ms: None, misbehavior_score: 0, banned_until_ms: None, ban_reason: None, @@ -4465,20 +4407,13 @@ mod tests { messages_received: 0, last_known_height: None, last_known_tip_hash: None, - last_known_mempool_count: None, - last_known_mempool_root: None, - last_known_mempool_shared: None, - last_known_mempool_missing: None, - last_mempool_status_ms: None, last_clock_offset_ms: None, last_clock_offset_accepted: None, last_clock_observed_ms: None, last_error: Some("connection refused".to_string()), - last_transaction_rejection: None, last_contact_ms: Some(10_000), last_success_ms: None, last_error_ms: Some(10_000), - last_transaction_rejection_ms: None, misbehavior_score: 1, banned_until_ms: None, ban_reason: Some("connection refused".to_string()), @@ -4491,41 +4426,6 @@ mod tests { Some("127.0.0.1:9446: connection refused") ); - let tx_rejection = super::network_health( - &status, - &[PeerInfo { - address: "127.0.0.1:9449".to_string(), - direction: PeerDirection::Outbound, - messages_sent: 1, - messages_received: 1, - last_known_height: Some(0), - last_known_tip_hash: Some("tip".to_string()), - last_known_mempool_count: None, - last_known_mempool_root: None, - last_known_mempool_shared: None, - last_known_mempool_missing: None, - last_mempool_status_ms: None, - last_clock_offset_ms: None, - last_clock_offset_accepted: None, - last_clock_observed_ms: None, - last_error: None, - last_transaction_rejection: Some( - "peer rejected transaction abc: conflict".to_string(), - ), - last_contact_ms: Some(10_000), - last_success_ms: Some(10_000), - last_error_ms: None, - last_transaction_rejection_ms: Some(10_000), - misbehavior_score: 0, - banned_until_ms: None, - ban_reason: None, - }], - ); - assert_eq!( - tx_rejection.last_error.as_deref(), - Some("127.0.0.1:9449: peer rejected transaction abc: conflict") - ); - let stale = super::network_health_at( &status, &[PeerInfo { @@ -4535,20 +4435,13 @@ mod tests { messages_received: 1, last_known_height: Some(0), last_known_tip_hash: Some("tip".to_string()), - last_known_mempool_count: None, - last_known_mempool_root: None, - last_known_mempool_shared: None, - last_known_mempool_missing: None, - last_mempool_status_ms: None, last_clock_offset_ms: None, last_clock_offset_accepted: None, last_clock_observed_ms: None, last_error: None, - last_transaction_rejection: None, last_contact_ms: Some(1), last_success_ms: Some(1), last_error_ms: None, - last_transaction_rejection_ms: None, misbehavior_score: 0, banned_until_ms: None, ban_reason: None, @@ -4568,20 +4461,13 @@ mod tests { messages_received: 0, last_known_height: None, last_known_tip_hash: None, - last_known_mempool_count: None, - last_known_mempool_root: None, - last_known_mempool_shared: None, - last_known_mempool_missing: None, - last_mempool_status_ms: None, last_clock_offset_ms: None, last_clock_offset_accepted: None, last_clock_observed_ms: None, last_error: Some("invalid block".to_string()), - last_transaction_rejection: None, last_contact_ms: Some(10), last_success_ms: None, last_error_ms: Some(10), - last_transaction_rejection_ms: None, misbehavior_score: 3, banned_until_ms: Some(1_000), ban_reason: Some("invalid block".to_string()), diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -25,11 +25,10 @@ use tokio::{ use crate::{ app::{ - BlockInventory, GossipEnvelope, MEMPOOL_STATUS_LIMIT, NETWORK_ID, NodeCore, - PROTOCOL_VERSION, PeerDirection, ProtocolHello, SharedNode, SharedPeerBook, - TRANSACTION_BATCH_LIMIT, TransactionRejection, debug_logging_enabled, now_ms, + BlockInventory, GossipEnvelope, NETWORK_ID, PROTOCOL_VERSION, PeerDirection, ProtocolHello, + SharedNode, SharedPeerBook, TRANSACTION_BATCH_LIMIT, debug_logging_enabled, now_ms, }, - domain::{Block, ChainSnapshot, Ledger, Transaction, TransactionSubmitOutcome, verify_vdf}, + domain::{Block, ChainSnapshot, Ledger, verify_vdf}, }; const MAX_BLOCK_BATCH: usize = 128; @@ -46,7 +45,6 @@ const PEER_QUEUE_SIZE: usize = 256; const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); const SESSION_SYNC_INTERVAL: Duration = Duration::from_secs(2); -const TRANSACTION_ACK_RETRY_INTERVAL: Duration = Duration::from_secs(3); const JOIN_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5); const MAX_JOIN_RESPONSE_ENVELOPES: usize = 16; const MAX_PEER_VERIFICATION_ENVELOPES: usize = 8; @@ -59,9 +57,6 @@ struct PeerStatus { height: u64, tip_hash: String, time_ms: u64, - mempool_count: usize, - mempool_root: String, - mempool_txs: Vec<String>, request_snapshot: bool, push_snapshot: bool, } @@ -76,29 +71,16 @@ impl PeerStatus { height, tip_hash, time_ms, - mempool_count: 0, - mempool_root: String::new(), - mempool_txs: Vec::new(), request_snapshot: false, push_snapshot: false, } } - fn from_envelope( - height: u64, - tip_hash: String, - mempool_count: usize, - mempool_root: String, - mempool_txs: Vec<String>, - time_ms: u64, - ) -> Self { + fn from_envelope(height: u64, tip_hash: String, time_ms: u64) -> Self { Self { height, tip_hash, time_ms, - mempool_count, - mempool_root, - mempool_txs, request_snapshot: false, push_snapshot: false, } @@ -109,9 +91,6 @@ impl PeerStatus { height, tip_hash, time_ms, - mempool_count: 0, - mempool_root: String::new(), - mempool_txs: Vec::new(), request_snapshot: true, push_snapshot: false, } @@ -122,9 +101,6 @@ impl PeerStatus { height, tip_hash, time_ms, - mempool_count: 0, - mempool_root: String::new(), - mempool_txs: Vec::new(), request_snapshot: false, push_snapshot: true, } @@ -146,19 +122,11 @@ struct GossipNetworkInner { node_id: String, accept_task: Mutex<Option<JoinHandle<()>>>, sessions: Mutex<BTreeMap<String, mpsc::Sender<OutboundBatch>>>, - tx_delivery: Mutex<BTreeMap<String, PeerTransactionDelivery>>, inbound_limiter: Arc<StdMutex<InboundConnectionLimiter>>, metrics: P2pMetricsCounters, } #[derive(Default)] -struct PeerTransactionDelivery { - accepted: BTreeSet<String>, - rejected: BTreeSet<String>, - sent: BTreeMap<String, Instant>, -} - -#[derive(Default)] struct InboundConnectionLimiter { active: usize, peers: BTreeMap<IpAddr, InboundPeerLimit>, @@ -283,18 +251,6 @@ struct P2pMetricsCounters { self_peer_skips: AtomicU64, outbound_queue_full: AtomicU64, outbound_queue_closed: AtomicU64, - transaction_ack_envelopes_sent: AtomicU64, - transaction_ack_envelopes_received: AtomicU64, - transactions_accepted_sent: AtomicU64, - transactions_accepted_received: AtomicU64, - transactions_rejected_sent: AtomicU64, - transactions_rejected_received: AtomicU64, - transaction_retries_sent: AtomicU64, - mempool_statuses_received: AtomicU64, - mempool_status_transactions_received: AtomicU64, - mempool_status_mismatches: AtomicU64, - mempool_transaction_requests_sent: AtomicU64, - mempool_transaction_request_signatures_sent: AtomicU64, last_session_failure: StdMutex<Option<String>>, last_empty_frame_remote: StdMutex<Option<String>>, last_parse_error: StdMutex<Option<String>>, @@ -324,19 +280,6 @@ pub struct P2pMetrics { pub self_peer_skips: u64, pub outbound_queue_full: u64, pub outbound_queue_closed: u64, - pub transaction_ack_envelopes_sent: u64, - pub transaction_ack_envelopes_received: u64, - pub transactions_accepted_sent: u64, - pub transactions_accepted_received: u64, - pub transactions_rejected_sent: u64, - pub transactions_rejected_received: u64, - pub transaction_retries_sent: u64, - pub mempool_statuses_received: u64, - pub mempool_status_transactions_received: u64, - pub mempool_status_mismatches: u64, - pub mempool_transaction_requests_sent: u64, - pub mempool_transaction_request_signatures_sent: u64, - pub transaction_ack_pending: u64, pub last_session_failure: Option<String>, pub last_empty_frame_remote: Option<String>, pub last_parse_error: Option<String>, @@ -383,33 +326,6 @@ impl P2pMetricsCounters { self_peer_skips: self.self_peer_skips.load(Ordering::Relaxed), outbound_queue_full: self.outbound_queue_full.load(Ordering::Relaxed), outbound_queue_closed: self.outbound_queue_closed.load(Ordering::Relaxed), - transaction_ack_envelopes_sent: self - .transaction_ack_envelopes_sent - .load(Ordering::Relaxed), - transaction_ack_envelopes_received: self - .transaction_ack_envelopes_received - .load(Ordering::Relaxed), - transactions_accepted_sent: self.transactions_accepted_sent.load(Ordering::Relaxed), - transactions_accepted_received: self - .transactions_accepted_received - .load(Ordering::Relaxed), - transactions_rejected_sent: self.transactions_rejected_sent.load(Ordering::Relaxed), - transactions_rejected_received: self - .transactions_rejected_received - .load(Ordering::Relaxed), - transaction_retries_sent: self.transaction_retries_sent.load(Ordering::Relaxed), - mempool_statuses_received: self.mempool_statuses_received.load(Ordering::Relaxed), - mempool_status_transactions_received: self - .mempool_status_transactions_received - .load(Ordering::Relaxed), - mempool_status_mismatches: self.mempool_status_mismatches.load(Ordering::Relaxed), - mempool_transaction_requests_sent: self - .mempool_transaction_requests_sent - .load(Ordering::Relaxed), - mempool_transaction_request_signatures_sent: self - .mempool_transaction_request_signatures_sent - .load(Ordering::Relaxed), - transaction_ack_pending: 0, last_session_failure: self .last_session_failure .lock() @@ -441,7 +357,6 @@ impl GossipNetwork { node_id: new_node_id(), accept_task: Mutex::new(None), sessions: Mutex::new(BTreeMap::new()), - tx_delivery: Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new(StdMutex::new(InboundConnectionLimiter::default())), metrics: P2pMetricsCounters::default(), }), @@ -464,7 +379,6 @@ impl GossipNetwork { node_id: new_node_id(), accept_task: Mutex::new(None), sessions: Mutex::new(BTreeMap::new()), - tx_delivery: Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new(StdMutex::new(InboundConnectionLimiter::default())), metrics: P2pMetricsCounters::default(), }), @@ -527,19 +441,7 @@ impl GossipNetwork { } pub fn metrics(&self) -> P2pMetrics { - let mut metrics = self.inner.metrics.snapshot(); - metrics.transaction_ack_pending = self - .inner - .tx_delivery - .try_lock() - .map(|delivery| { - delivery - .values() - .map(|peer_delivery| peer_delivery.sent.len() as u64) - .sum() - }) - .unwrap_or(0); - metrics + self.inner.metrics.snapshot() } fn try_acquire_inbound_session( @@ -586,121 +488,12 @@ impl GossipNetwork { Ok(()) } - async fn record_sent_transactions(&self, peer: &str, envelopes: &[GossipEnvelope]) { - let now = Instant::now(); - let mut delivery = self.inner.tx_delivery.lock().await; - let peer_delivery = delivery.entry(peer.to_string()).or_default(); - for (signature, _) in transactions_in_envelopes(envelopes) { - if peer_delivery.accepted.contains(&signature) - || peer_delivery.rejected.contains(&signature) - { - continue; - } - peer_delivery.sent.insert(signature, now); - } - } - - async fn record_transaction_ack( - &self, - peer: &str, - accepted: &[String], - rejected: &[TransactionRejection], - ) { - if !accepted.is_empty() || !rejected.is_empty() { - P2pMetricsCounters::inc(&self.inner.metrics.transaction_ack_envelopes_received); - P2pMetricsCounters::add( - &self.inner.metrics.transactions_accepted_received, - accepted.len() as u64, - ); - P2pMetricsCounters::add( - &self.inner.metrics.transactions_rejected_received, - rejected.len() as u64, - ); - } - let mut delivery = self.inner.tx_delivery.lock().await; - let peer_delivery = delivery.entry(peer.to_string()).or_default(); - for signature in accepted { - peer_delivery.accepted.insert(signature.clone()); - peer_delivery.rejected.remove(signature); - peer_delivery.sent.remove(signature); - } - for rejection in rejected { - peer_delivery.rejected.insert(rejection.signature.clone()); - peer_delivery.accepted.remove(&rejection.signature); - peer_delivery.sent.remove(&rejection.signature); - } - let last_rejection = rejected.last().map(|rejection| { - format!( - "peer rejected transaction {}: {}", - short_signature(&rejection.signature), - rejection.reason - ) - }); - drop(delivery); - if let Some(reason) = last_rejection { - self.inner - .peers - .lock() - .await - .record_transaction_rejection(peer, reason); - } - } - - async fn pending_transactions_for_retry(&self, peer: &str) -> Vec<Transaction> { - let pending = self.inner.node.lock().await.pending_transactions(); - let pending_signatures = pending - .iter() - .map(|tx| tx.signature().to_string()) - .collect::<BTreeSet<_>>(); - let now = Instant::now(); - let mut delivery = self.inner.tx_delivery.lock().await; - let peer_delivery = delivery.entry(peer.to_string()).or_default(); - peer_delivery - .sent - .retain(|signature, _| pending_signatures.contains(signature)); - - let retry = pending - .into_iter() - .filter(|tx| { - let signature = tx.signature(); - if peer_delivery.accepted.contains(signature) - || peer_delivery.rejected.contains(signature) - { - return false; - } - peer_delivery.sent.get(signature).is_none_or(|last_sent| { - now.duration_since(*last_sent) >= TRANSACTION_ACK_RETRY_INTERVAL - }) - }) - .collect::<Vec<_>>(); - - for tx in &retry { - peer_delivery.sent.insert(tx.signature().to_string(), now); - } - retry - } - async fn prepare_gossip(&self, envelopes: Vec<GossipEnvelope>) -> Vec<GossipEnvelope> { - let mut full_transactions = Vec::new(); - let mut txs = Vec::new(); let mut blocks = Vec::new(); let mut passthrough = Vec::new(); for envelope in envelopes { match envelope { - GossipEnvelope::Transaction(tx) => { - txs.push(tx.signature().to_string()); - full_transactions.push(tx); - } - GossipEnvelope::Transactions { transactions } => { - txs.extend( - transactions - .iter() - .map(|tx| tx.signature().to_string()) - .collect::<Vec<_>>(), - ); - passthrough.extend(transaction_batch_envelopes(transactions)); - } GossipEnvelope::Block(block) => blocks.push(BlockInventory { height: block.height, hash: block.hash, @@ -711,23 +504,11 @@ impl GossipNetwork { hash: block.hash, })); } - GossipEnvelope::Inventory { - txs: inv_txs, - blocks: inv_blocks, - } => { - txs.extend(inv_txs); - blocks.extend(inv_blocks); - } + GossipEnvelope::Inventory { blocks: inv_blocks } => blocks.extend(inv_blocks), other => passthrough.push(other), } } - if !full_transactions.is_empty() { - passthrough.extend(transaction_batch_envelopes(full_transactions)); - } - - txs.sort(); - txs.dedup(); blocks.sort_by(|left, right| { left.height .cmp(&right.height) @@ -735,8 +516,8 @@ impl GossipNetwork { }); blocks.dedup_by(|left, right| left.hash == right.hash); - if !txs.is_empty() || !blocks.is_empty() { - passthrough.push(GossipEnvelope::Inventory { txs, blocks }); + if !blocks.is_empty() { + passthrough.push(GossipEnvelope::Inventory { blocks }); } passthrough } @@ -1038,28 +819,10 @@ async fn session_loop( height, tip_hash, time_ms, - mempool_count, - mempool_root, - mempool_txs, } = envelope { - let status = PeerStatus::from_envelope( - height, - tip_hash, - mempool_count, - mempool_root, - mempool_txs, - time_ms, - ); + let status = PeerStatus::from_envelope(height, tip_hash, time_ms); record_peer_status(&network, &known_peer, remote_addr, &status).await; - maybe_request_mempool_catchup( - &network, - &mut writer, - &known_peer, - remote_addr, - &status, - ) - .await?; peer_status = Some(status); maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; } else if respond_to_peer_verification_challenge(&network, &mut writer, &envelope) @@ -1096,7 +859,6 @@ async fn session_loop( ).await; write_payload(&mut writer, &payload).await?; if let Some(peer) = &known_peer { - network.record_sent_transactions(peer, &payload).await; network.inner.peers.lock().await.record_sent(peer, payload.len() as u64); } } @@ -1111,22 +873,6 @@ async fn session_loop( *status = updated_status; } } - if let Some(peer) = &known_peer { - let transactions = network.pending_transactions_for_retry(peer).await; - if !transactions.is_empty() { - let retry_envelopes = transaction_batch_envelopes(transactions); - for retry in &retry_envelopes { - write_envelope(&mut writer, retry).await?; - } - P2pMetricsCounters::inc(&network.inner.metrics.transaction_retries_sent); - network - .inner - .peers - .lock() - .await - .record_sent(peer, retry_envelopes.len() as u64); - } - } } envelope = read_session_envelope(&network, &connection_label, &mut reader) => { let Some(envelope) = envelope? else { @@ -1155,22 +901,10 @@ async fn session_loop( height, tip_hash, time_ms, - mempool_count, - mempool_root, - mempool_txs, } = &envelope { - let status = PeerStatus::from_envelope( - *height, - tip_hash.clone(), - *mempool_count, - mempool_root.clone(), - mempool_txs.clone(), - *time_ms, - ); + let status = PeerStatus::from_envelope(*height, tip_hash.clone(), *time_ms); record_peer_status(&network, &known_peer, remote_addr, &status).await; - maybe_request_mempool_catchup(&network, &mut writer, &known_peer, remote_addr, &status) - .await?; peer_status = Some(status); maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; continue; @@ -1244,30 +978,19 @@ async fn process_envelope( .blocks_from(from_height, limit.min(MAX_BLOCK_BATCH)); write_envelope(writer, &GossipEnvelope::Blocks { blocks }).await?; } - GossipEnvelope::TransactionRequest { signatures } => { - let transactions = network - .inner - .node - .lock() - .await - .transactions_by_signature(&signatures); - for envelope in transaction_batch_envelopes(transactions) { - write_envelope(writer, &envelope).await?; - } - } GossipEnvelope::BlockRequest { hashes } => { let blocks = network.inner.node.lock().await.blocks_by_hash(&hashes); if !blocks.is_empty() { write_envelope(writer, &GossipEnvelope::Blocks { blocks }).await?; } } - GossipEnvelope::Inventory { txs, blocks } => { + GossipEnvelope::Inventory { blocks } => { let requests = network .inner .node .lock() .await - .missing_inventory_requests(&txs, &blocks); + .missing_inventory_requests(&blocks); write_payload(writer, &requests).await?; } GossipEnvelope::PeerAnnouncement { address, node_id } => { @@ -1290,12 +1013,6 @@ async fn process_envelope( GossipEnvelope::PeerList { peers } => { apply_peer_list(network, remote_addr, peers).await?; } - GossipEnvelope::Transaction(tx) => { - process_transactions(network, writer, remote_addr, known_peer, vec![tx]).await?; - } - GossipEnvelope::Transactions { transactions } => { - process_transactions(network, writer, remote_addr, known_peer, transactions).await?; - } GossipEnvelope::BlindedTransaction(tx) => { process_blinded_transactions(network, remote_addr, known_peer, vec![tx]).await; } @@ -1308,15 +1025,6 @@ async fn process_envelope( GossipEnvelope::BlindedReveals { reveals } => { process_blinded_reveals(network, remote_addr, known_peer, reveals).await; } - GossipEnvelope::TransactionAck { accepted, rejected } => { - let peer = known_peer - .clone() - .unwrap_or_else(|| remote_addr.to_string()); - network - .record_transaction_ack(&peer, &accepted, &rejected) - .await; - record_inbound_result(network, known_peer, remote_addr, Ok(())).await; - } GossipEnvelope::Block(block) => { let adjusted_time_ms = network_adjusted_time_ms(network).await; let needs_vdf = { @@ -1386,90 +1094,6 @@ async fn process_envelope( Ok(()) } -async fn process_transactions( - network: &GossipNetwork, - writer: &mut OwnedWriteHalf, - remote_addr: SocketAddr, - known_peer: &Option<String>, - transactions: Vec<Transaction>, -) -> Result<()> { - let (accepted, rejected) = { - let mut node = network.inner.node.lock().await; - receive_transactions_for_ack(&mut node, transactions) - }; - - if !accepted.is_empty() || !rejected.is_empty() { - let ack = GossipEnvelope::TransactionAck { - accepted: accepted.clone(), - rejected: rejected.clone(), - }; - write_envelope(writer, &ack).await?; - P2pMetricsCounters::inc(&network.inner.metrics.transaction_ack_envelopes_sent); - P2pMetricsCounters::add( - &network.inner.metrics.transactions_accepted_sent, - accepted.len() as u64, - ); - P2pMetricsCounters::add( - &network.inner.metrics.transactions_rejected_sent, - rejected.len() as u64, - ); - } - - for rejection in &rejected { - let mut peers = network.inner.peers.lock().await; - match known_peer.as_deref() { - Some(peer) if transaction_rejection_counts_as_misbehavior(&rejection.reason) => { - peers.record_misbehavior(peer, rejection.reason.clone()); - } - Some(peer) => { - peers.record_inbound_transaction_rejection(peer, rejection.reason.clone()); - } - None if transaction_rejection_counts_as_misbehavior(&rejection.reason) => { - peers - .record_inbound_misbehavior(&remote_addr.to_string(), rejection.reason.clone()); - } - None => { - peers.record_inbound_transaction_rejection( - &remote_addr.to_string(), - rejection.reason.clone(), - ); - } - } - } - network.forward_outbox().await; - if rejected.is_empty() { - record_inbound_result(network, known_peer, remote_addr, Ok(())).await; - } - Ok(()) -} - -fn receive_transactions_for_ack( - node: &mut NodeCore, - transactions: Vec<Transaction>, -) -> (Vec<String>, Vec<TransactionRejection>) { - let mut accepted = Vec::new(); - let mut rejected = Vec::new(); - for tx in transactions { - let signature = tx.signature().to_string(); - match node.receive_transaction(tx) { - Ok(TransactionSubmitOutcome::Added | TransactionSubmitOutcome::AlreadyKnown) => { - accepted.push(signature); - } - Ok(TransactionSubmitOutcome::ConflictsWithPending) => { - rejected.push(TransactionRejection { - signature, - reason: "transaction conflicts with pending mempool inputs".to_string(), - }); - } - Err(error) => rejected.push(TransactionRejection { - signature, - reason: format!("{error:#}"), - }), - } - } - (accepted, rejected) -} - async fn process_blinded_transactions( network: &GossipNetwork, remote_addr: SocketAddr, @@ -1553,77 +1177,6 @@ async fn maybe_request_catchup( Ok(()) } -async fn maybe_request_mempool_catchup( - network: &GossipNetwork, - writer: &mut OwnedWriteHalf, - known_peer: &Option<String>, - remote_addr: SocketAddr, - peer_status: &PeerStatus, -) -> Result<()> { - P2pMetricsCounters::inc(&network.inner.metrics.mempool_statuses_received); - P2pMetricsCounters::add( - &network.inner.metrics.mempool_status_transactions_received, - peer_status.mempool_txs.len() as u64, - ); - - let (local_root, local_inventory, requests) = { - let node = network.inner.node.lock().await; - ( - node.mempool_root(), - node.mempool_inventory(MEMPOOL_STATUS_LIMIT), - node.missing_inventory_requests(&peer_status.mempool_txs, &[]), - ) - }; - let local_txs = local_inventory.into_iter().collect::<BTreeSet<_>>(); - let shared = peer_status - .mempool_txs - .iter() - .filter(|signature| local_txs.contains(*signature)) - .count(); - let missing = peer_status.mempool_txs.len().saturating_sub(shared); - - if peer_status.mempool_root != local_root { - P2pMetricsCounters::inc(&network.inner.metrics.mempool_status_mismatches); - } - record_peer_mempool_status( - network, - known_peer, - remote_addr, - peer_status, - shared, - missing, - ) - .await; - - let requested_signatures = requests - .iter() - .map(|envelope| match envelope { - GossipEnvelope::TransactionRequest { signatures } => signatures.len(), - _ => 0, - }) - .sum::<usize>(); - if requested_signatures > 0 { - write_payload(writer, &requests).await?; - P2pMetricsCounters::inc(&network.inner.metrics.mempool_transaction_requests_sent); - P2pMetricsCounters::add( - &network - .inner - .metrics - .mempool_transaction_request_signatures_sent, - requested_signatures as u64, - ); - if let Some(peer) = known_peer { - network - .inner - .peers - .lock() - .await - .record_sent(peer, requests.len() as u64); - } - } - Ok(()) -} - async fn push_catchup_to_peer( network: &GossipNetwork, writer: &mut OwnedWriteHalf, @@ -1711,74 +1264,6 @@ async fn write_payload(writer: &mut OwnedWriteHalf, payload: &[GossipEnvelope]) Ok(()) } -fn transactions_in_envelopes(envelopes: &[GossipEnvelope]) -> Vec<(String, Transaction)> { - let mut transactions = Vec::new(); - for envelope in envelopes { - match envelope { - GossipEnvelope::Transaction(tx) => { - transactions.push((tx.signature().to_string(), tx.clone())); - } - GossipEnvelope::Transactions { transactions: txs } => { - transactions.extend( - txs.iter() - .map(|tx| (tx.signature().to_string(), tx.clone())), - ); - } - _ => {} - } - } - transactions -} - -fn short_signature(signature: &str) -> String { - signature.chars().take(12).collect() -} - -fn transaction_rejection_counts_as_misbehavior(reason: &str) -> bool { - let reason = reason.to_ascii_lowercase(); - if transaction_rejection_is_state_dependent(&reason) { - return false; - } - transaction_rejection_is_structurally_invalid(&reason) -} - -fn transaction_rejection_is_state_dependent(reason: &str) -> bool { - [ - "mempool is full", - "anchor is not on this chain", - "anchor is too old", - "conflict", - "missing output", - "not spendable", - "insufficient funds", - "does not cover", - ] - .iter() - .any(|needle| reason.contains(needle)) -} - -fn transaction_rejection_is_structurally_invalid(reason: &str) -> bool { - [ - "signature", - "proof header is invalid", - "proof hash is invalid", - "proof does not meet difficulty", - "reward is invalid", - "required burn amount", - "difficulty is invalid", - "inputs do not balance", - "duplicate input", - "input owner does not match", - "has no inputs", - "inputs must have one owner", - "overflow", - "invalid public key", - "invalid transaction public key", - ] - .iter() - .any(|needle| reason.contains(needle)) -} - async fn write_envelope(writer: &mut OwnedWriteHalf, envelope: &GossipEnvelope) -> Result<()> { let line = serde_json::to_string(envelope)?; if line.len() > MAX_GOSSIP_LINE_BYTES { @@ -1887,9 +1372,7 @@ fn record_received_envelope_kind(metrics: &P2pMetricsCounters, envelope: &Gossip GossipEnvelope::Inventory { .. } => { P2pMetricsCounters::inc(&metrics.inventory_envelopes_received); } - GossipEnvelope::Transaction(_) - | GossipEnvelope::Transactions { .. } - | GossipEnvelope::BlindedTransaction(_) + GossipEnvelope::BlindedTransaction(_) | GossipEnvelope::BlindedTransactions { .. } | GossipEnvelope::BlindedReveal(_) | GossipEnvelope::BlindedReveals { .. } @@ -1900,9 +1383,7 @@ fn record_received_envelope_kind(metrics: &P2pMetricsCounters, envelope: &Gossip } GossipEnvelope::ChainSnapshotRequest | GossipEnvelope::BlockRangeRequest { .. } - | GossipEnvelope::TransactionRequest { .. } | GossipEnvelope::BlockRequest { .. } - | GossipEnvelope::TransactionAck { .. } | GossipEnvelope::PeerAnnouncement { .. } | GossipEnvelope::PeerVerificationChallenge { .. } | GossipEnvelope::PeerVerificationResponse { .. } @@ -1926,35 +1407,12 @@ fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> { GossipEnvelope::BlockRangeRequest { limit, .. } => { ensure_len("block range request", *limit, MAX_BLOCK_BATCH)?; } - GossipEnvelope::TransactionRequest { signatures } => { - ensure_len("transaction request", signatures.len(), MAX_OBJECT_REQUESTS)?; - } GossipEnvelope::BlockRequest { hashes } => { ensure_len("block request", hashes.len(), MAX_OBJECT_REQUESTS)?; } - GossipEnvelope::Inventory { txs, blocks } => { - ensure_len("transaction inventory", txs.len(), MAX_INVENTORY_ITEMS)?; + GossipEnvelope::Inventory { blocks } => { ensure_len("block inventory", blocks.len(), MAX_INVENTORY_ITEMS)?; } - GossipEnvelope::TransactionAck { accepted, rejected } => { - ensure_len( - "transaction ack accepted", - accepted.len(), - MAX_OBJECT_REQUESTS, - )?; - ensure_len( - "transaction ack rejected", - rejected.len(), - MAX_OBJECT_REQUESTS, - )?; - } - GossipEnvelope::Transactions { transactions } => { - ensure_len( - "transaction batch", - transactions.len(), - TRANSACTION_BATCH_LIMIT, - )?; - } GossipEnvelope::BlindedTransactions { transactions } => { ensure_len( "blinded transaction batch", @@ -1978,12 +1436,9 @@ fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> { GossipEnvelope::PeerList { peers } => { ensure_len("peer list", peers.len(), MAX_PEER_LIST)?; } - GossipEnvelope::PeerStatus { mempool_txs, .. } => { - ensure_len("mempool status", mempool_txs.len(), MEMPOOL_STATUS_LIMIT)?; - } GossipEnvelope::Hello(_) | GossipEnvelope::ChainSnapshotRequest - | GossipEnvelope::Transaction(_) + | GossipEnvelope::PeerStatus { .. } | GossipEnvelope::BlindedTransaction(_) | GossipEnvelope::BlindedReveal(_) | GossipEnvelope::Block(_) @@ -1994,15 +1449,6 @@ fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> { Ok(()) } -fn transaction_batch_envelopes(transactions: Vec<Transaction>) -> Vec<GossipEnvelope> { - transactions - .chunks(TRANSACTION_BATCH_LIMIT) - .map(|chunk| GossipEnvelope::Transactions { - transactions: chunk.to_vec(), - }) - .collect() -} - fn ensure_len(label: &str, len: usize, max: usize) -> Result<()> { if len > max { anyhow::bail!("{label} has {len} items, exceeding limit {max}"); @@ -2062,8 +1508,7 @@ async fn envelopes_for_peer( _ => true, }) .map(|envelope| match envelope { - GossipEnvelope::Inventory { txs, blocks } => GossipEnvelope::Inventory { - txs: txs.clone(), + GossipEnvelope::Inventory { blocks } => GossipEnvelope::Inventory { blocks: blocks .iter() .filter(|block| block.height > peer_status.height) @@ -2073,7 +1518,7 @@ async fn envelopes_for_peer( other => other.clone(), }) .filter(|envelope| match envelope { - GossipEnvelope::Inventory { txs, blocks } => !txs.is_empty() || !blocks.is_empty(), + GossipEnvelope::Inventory { blocks } => !blocks.is_empty(), _ => true, }) .collect() @@ -2123,17 +1568,7 @@ async fn fetch_peer_status(peer: &str) -> Result<PeerStatus> { height, tip_hash, time_ms, - mempool_count, - mempool_root, - mempool_txs, - } => Ok(PeerStatus::from_envelope( - height, - tip_hash, - mempool_count, - mempool_root, - mempool_txs, - time_ms, - )), + } => Ok(PeerStatus::from_envelope(height, tip_hash, time_ms)), other => anyhow::bail!("peer {peer} sent {other:?} instead of peer status"), } } @@ -2323,34 +1758,6 @@ async fn record_peer_status( } } -async fn record_peer_mempool_status( - network: &GossipNetwork, - known_peer: &Option<String>, - remote_addr: SocketAddr, - peer_status: &PeerStatus, - shared: usize, - missing: usize, -) { - let mut peers = network.inner.peers.lock().await; - if let Some(peer) = known_peer { - peers.record_mempool_status( - peer, - peer_status.mempool_count, - peer_status.mempool_root.clone(), - shared, - missing, - ); - } else { - peers.record_inbound_mempool_status( - &remote_addr.to_string(), - peer_status.mempool_count, - peer_status.mempool_root.clone(), - shared, - missing, - ); - } -} - async fn process_hello( network: &GossipNetwork, remote_addr: SocketAddr, @@ -3024,9 +2431,9 @@ mod tests { use crate::{ app::{ BlockInventory, GossipEnvelope, NETWORK_ID, NodeCore, PROTOCOL_VERSION, PeerBook, - PeerDirection, ProtocolHello, TRANSACTION_BATCH_LIMIT, + PeerDirection, ProtocolHello, }, - domain::{Amount, GenesisBurn, Ledger, Wallet}, + domain::{Amount, GenesisBurn, Ledger, Transaction, Wallet}, }; use tokio::io::AsyncWriteExt; @@ -3086,38 +2493,20 @@ mod tests { } #[test] - fn oversized_object_requests_are_rejected_before_processing() { - let envelope = GossipEnvelope::TransactionRequest { - signatures: vec!["sig".to_string(); MAX_OBJECT_REQUESTS + 1], - }; - - let error = validate_envelope_limits(&envelope).unwrap_err(); - - assert!(error.to_string().contains("transaction request")); - } - - #[test] fn oversized_inventory_is_rejected_before_processing() { let envelope = GossipEnvelope::Inventory { - txs: vec!["sig".to_string(); MAX_INVENTORY_ITEMS + 1], - blocks: Vec::new(), - }; - - let error = validate_envelope_limits(&envelope).unwrap_err(); - - assert!(error.to_string().contains("transaction inventory")); - } - - #[test] - fn oversized_transaction_ack_is_rejected_before_processing() { - let envelope = GossipEnvelope::TransactionAck { - accepted: vec!["sig".to_string(); MAX_OBJECT_REQUESTS + 1], - rejected: Vec::new(), + blocks: vec![ + BlockInventory { + height: 1, + hash: "hash".to_string() + }; + MAX_INVENTORY_ITEMS + 1 + ], }; let error = validate_envelope_limits(&envelope).unwrap_err(); - assert!(error.to_string().contains("transaction ack accepted")); + assert!(error.to_string().contains("block inventory")); } #[test] @@ -3151,44 +2540,11 @@ mod tests { height: 7, tip_hash: "tip".to_string(), time_ms: 0, - mempool_count: 0, - mempool_root: String::new(), - mempool_txs: Vec::new(), } ); } #[test] - fn oversized_mempool_status_is_rejected_before_processing() { - let envelope = GossipEnvelope::PeerStatus { - height: 7, - tip_hash: "tip".to_string(), - time_ms: 1_000, - mempool_count: super::MEMPOOL_STATUS_LIMIT + 1, - mempool_root: "root".to_string(), - mempool_txs: vec!["sig".to_string(); super::MEMPOOL_STATUS_LIMIT + 1], - }; - - let error = validate_envelope_limits(&envelope).unwrap_err(); - - assert!(error.to_string().contains("mempool status")); - } - - #[test] - fn mempool_status_can_advertise_full_pending_pool_beyond_inventory_batch_size() { - let envelope = GossipEnvelope::PeerStatus { - height: 7, - tip_hash: "tip".to_string(), - time_ms: 1_000, - mempool_count: MAX_INVENTORY_ITEMS + 1, - mempool_root: "root".to_string(), - mempool_txs: vec!["sig".to_string(); MAX_INVENTORY_ITEMS + 1], - }; - - validate_envelope_limits(&envelope).unwrap(); - } - - #[test] fn received_envelope_metrics_are_categorized() { let metrics = super::P2pMetricsCounters::default(); @@ -3198,36 +2554,23 @@ mod tests { height: 7, tip_hash: "tip".to_string(), time_ms: 1_000, - mempool_count: 0, - mempool_root: String::new(), - mempool_txs: Vec::new(), }, ); super::record_received_envelope_kind( &metrics, - &GossipEnvelope::Inventory { - txs: Vec::new(), - blocks: Vec::new(), - }, + &GossipEnvelope::Inventory { blocks: Vec::new() }, ); super::record_received_envelope_kind( &metrics, &GossipEnvelope::Blocks { blocks: Vec::new() }, ); - super::record_received_envelope_kind( - &metrics, - &GossipEnvelope::TransactionAck { - accepted: Vec::new(), - rejected: Vec::new(), - }, - ); super::record_received_envelope_kind(&metrics, &GossipEnvelope::ChainSnapshotRequest); let snapshot = metrics.snapshot(); assert_eq!(snapshot.peer_status_envelopes_received, 1); assert_eq!(snapshot.inventory_envelopes_received, 1); assert_eq!(snapshot.data_envelopes_received, 1); - assert_eq!(snapshot.control_envelopes_received, 2); + assert_eq!(snapshot.control_envelopes_received, 1); } #[tokio::test] @@ -3238,9 +2581,6 @@ mod tests { height: 7, tip_hash: "tip".to_string(), time_ms: 1_000, - mempool_count: 0, - mempool_root: String::new(), - mempool_txs: Vec::new(), }) .unwrap(); let split_at = line.len() / 2; @@ -3370,10 +2710,14 @@ mod tests { let alice = Wallet::from_seed("p2p-alice"); let bob = Wallet::from_seed("p2p-bob"); let allocations = allocations(&[alice.clone(), bob.clone()], 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let node = Arc::new(tokio::sync::Mutex::new(node( + "alice", + alice.clone(), + allocations, + ))); let block = { let mut node = node.lock().await; - node.burn(1).unwrap(); + queue_plaintext_burn(&mut node, &alice, 1); node.drain_outbox(); let block = node.mine_one_at(1).unwrap(); node.drain_outbox(); @@ -3398,417 +2742,25 @@ mod tests { } #[tokio::test] - async fn tx_and_block_gossip_sends_full_transaction_and_inventory() { - let alice = Wallet::from_seed("inventory-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node: Arc::clone(&node), - peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), - listen_addr: "127.0.0.1:0".parse().unwrap(), - p2p_announce_addr: tokio::sync::Mutex::new(Some("127.0.0.1:9544".parse().unwrap())), - node_id: super::new_node_id(), - accept_task: tokio::sync::Mutex::new(None), - sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - - let (tx_signature, block_hash) = { - let mut node = node.lock().await; - let tx = node.burn(1).unwrap(); - node.drain_outbox(); - let block = node.mine_one_at(1).unwrap(); - (tx.signature().to_string(), block.hash) - }; - let (tx, block) = { - let node = node.lock().await; - ( - node.transactions_by_signature(std::slice::from_ref(&tx_signature)) - .remove(0), - node.blocks_by_hash(std::slice::from_ref(&block_hash)) - .remove(0), - ) - }; - - let prepared = network - .prepare_gossip(vec![ - GossipEnvelope::Transaction(tx), - GossipEnvelope::Block(block), - ]) - .await; - - assert_eq!(prepared.len(), 2); - match &prepared[0] { - GossipEnvelope::Transactions { transactions } => { - assert_eq!(transactions.len(), 1); - assert_eq!(transactions[0].signature(), tx_signature); - } - other => panic!("expected transaction batch, got {other:?}"), - } - match &prepared[1] { - GossipEnvelope::Inventory { txs, blocks } => { - assert_eq!(txs, &[tx_signature]); - assert_eq!(blocks.len(), 1); - assert_eq!(blocks[0].hash, block_hash); - } - other => panic!("expected inventory, got {other:?}"), - } - } - - #[tokio::test] - async fn transaction_batch_gossip_keeps_full_transactions_for_mempool_repair() { - let alice = Wallet::from_seed("mempool-repair-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node: Arc::clone(&node), - peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), - listen_addr: "127.0.0.1:9544".parse().unwrap(), - p2p_announce_addr: tokio::sync::Mutex::new(None), - node_id: super::new_node_id(), - accept_task: tokio::sync::Mutex::new(None), - sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - - let (tx, signature) = { - let mut node = node.lock().await; - let tx = node.burn(1).unwrap(); - (tx.clone(), tx.signature().to_string()) - }; - - let prepared = network - .prepare_gossip(vec![GossipEnvelope::Transactions { - transactions: vec![tx], - }]) - .await; - - assert_eq!(prepared.len(), 2); - assert!(matches!( - &prepared[0], - GossipEnvelope::Transactions { transactions } if transactions.len() == 1 - )); - match &prepared[1] { - GossipEnvelope::Inventory { txs, blocks } => { - assert_eq!(txs, &[signature]); - assert!(blocks.is_empty()); - } - other => panic!("expected inventory, got {other:?}"), - } - } - - #[tokio::test] - async fn prepare_gossip_splits_transaction_batches_at_receiver_limit() { - let alice = Wallet::from_seed("mempool-repair-batch-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node: Arc::clone(&node), - peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), - listen_addr: "127.0.0.1:9544".parse().unwrap(), - p2p_announce_addr: tokio::sync::Mutex::new(None), - node_id: super::new_node_id(), - accept_task: tokio::sync::Mutex::new(None), - sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - - let transactions = { - let mut node = node.lock().await; - (0..(TRANSACTION_BATCH_LIMIT + 1)) - .map(|_| node.burn(1).unwrap()) - .collect::<Vec<_>>() - }; - - let prepared = network - .prepare_gossip(vec![GossipEnvelope::Transactions { transactions }]) - .await; - - let batch_sizes = prepared - .iter() - .filter_map(|envelope| match envelope { - GossipEnvelope::Transactions { transactions } => Some(transactions.len()), - _ => None, - }) - .collect::<Vec<_>>(); - assert_eq!(batch_sizes, vec![TRANSACTION_BATCH_LIMIT, 1]); - assert!(prepared.iter().all(|envelope| match envelope { - GossipEnvelope::Transactions { transactions } => - transactions.len() <= TRANSACTION_BATCH_LIMIT, - _ => true, - })); - assert!(matches!( - prepared.last(), - Some(GossipEnvelope::Inventory { txs, blocks }) - if txs.len() == TRANSACTION_BATCH_LIMIT + 1 && blocks.is_empty() - )); - } - - #[tokio::test] - async fn retry_transaction_batches_are_split_at_receiver_limit() { - let alice = Wallet::from_seed("tx-ack-retry-batch-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node: Arc::clone(&node), - peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), - listen_addr: "127.0.0.1:9544".parse().unwrap(), - p2p_announce_addr: tokio::sync::Mutex::new(None), - node_id: super::new_node_id(), - accept_task: tokio::sync::Mutex::new(None), - sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let peer = "127.0.0.1:9545"; - { - let mut node = node.lock().await; - for _ in 0..(TRANSACTION_BATCH_LIMIT + 1) { - node.burn(1).unwrap(); - } - } - - let retry = network.pending_transactions_for_retry(peer).await; - assert_eq!(retry.len(), TRANSACTION_BATCH_LIMIT + 1); - - let retry_batches = super::transaction_batch_envelopes(retry); - let batch_sizes = retry_batches - .iter() - .map(|envelope| match envelope { - GossipEnvelope::Transactions { transactions } => { - assert!(transactions.len() <= TRANSACTION_BATCH_LIMIT); - transactions.len() - } - other => panic!("expected transaction batch, got {other:?}"), - }) - .collect::<Vec<_>>(); - assert_eq!(batch_sizes, vec![TRANSACTION_BATCH_LIMIT, 1]); - } - - #[tokio::test] - async fn unacked_pending_transactions_retry_until_peer_accepts() { - let alice = Wallet::from_seed("tx-ack-retry-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node: Arc::clone(&node), - peers: Arc::new(tokio::sync::Mutex::new(PeerBook::default())), - listen_addr: "127.0.0.1:9544".parse().unwrap(), - p2p_announce_addr: tokio::sync::Mutex::new(None), - node_id: super::new_node_id(), - accept_task: tokio::sync::Mutex::new(None), - sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let peer = "127.0.0.1:9545"; - let signature = { - let mut node = node.lock().await; - node.burn(1).unwrap().signature().to_string() - }; - - let first_retry = network.pending_transactions_for_retry(peer).await; - assert_eq!(first_retry.len(), 1); - assert_eq!(first_retry[0].signature(), signature); - assert_eq!(network.metrics().transaction_ack_pending, 1); - let immediate_retry = network.pending_transactions_for_retry(peer).await; - assert!(immediate_retry.is_empty()); - - network - .record_transaction_ack(peer, std::slice::from_ref(&signature), &[]) - .await; - let after_ack_retry = network.pending_transactions_for_retry(peer).await; - - assert!(after_ack_retry.is_empty()); - let metrics = network.metrics(); - assert_eq!(metrics.transaction_ack_pending, 0); - assert_eq!(metrics.transaction_ack_envelopes_received, 1); - assert_eq!(metrics.transactions_accepted_received, 1); - } - - #[test] - fn conflicting_input_transaction_is_rejected_not_acked_as_accepted() { - let alice = Wallet::from_seed("tx-ack-conflict-alice"); - let bob = Wallet::from_seed("tx-ack-conflict-bob"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let ledger = Ledger::new(allocations, 25); - let first = ledger - .build_transfer(&alice, bob.address(), 100, 0) - .unwrap(); - let conflicting = ledger.build_burn(&alice, 100, 0).unwrap(); - let mut receiver = NodeCore::from_ledger(bob, ledger, 0); - - let (accepted, rejected) = super::receive_transactions_for_ack( - &mut receiver, - vec![first.clone(), conflicting.clone()], - ); - - assert_eq!(accepted, vec![first.signature().to_string()]); - assert_eq!(rejected.len(), 1); - assert_eq!(rejected[0].signature, conflicting.signature()); - assert!( - rejected[0].reason.contains("conflicts with pending"), - "{}", - rejected[0].reason - ); - assert_eq!(receiver.ledger().pending().len(), 1); - assert_eq!( - receiver.ledger().pending()[0].signature(), - first.signature() - ); - } - - #[test] - fn transaction_rejection_classifier_only_scores_structural_invalidity() { - for reason in [ - "transaction signature is invalid", - "mine transaction proof hash is invalid", - "mine transaction proof does not meet difficulty", - "mine required burn amount must be between", - "mine transaction difficulty is invalid", - "transaction inputs do not balance outputs, burn, and fee", - "duplicate input in transaction", - "transaction input owner does not match spent output", - "transaction has no inputs", - "transaction inputs must have one owner", - ] { - assert!( - super::transaction_rejection_counts_as_misbehavior(reason), - "{reason} should count as misbehavior" - ); - } - - for reason in [ - "mempool is full", - "mine transaction anchor is not on this chain", - "mine transaction anchor is too old", - "transaction conflicts with pending mempool inputs", - "transaction spends missing output abc:0", - "selected UTXOs do not cover transfer amount plus fee", - "insufficient funds for address", - ] { - assert!( - !super::transaction_rejection_counts_as_misbehavior(reason), - "{reason} should be treated as state-dependent" - ); - } - } - - #[test] - fn temporal_block_rejections_do_not_score_peer_misbehavior() { - for reason in [ - "block timestamp is too far in the future", - "block timestamp is before finalizer rank 1 time slot 1200000", - ] { - assert!( - !super::inbound_error_counts_as_misbehavior(reason), - "{reason} should be treated as temporal" - ); - } - - assert!(super::inbound_error_counts_as_misbehavior( - "block VDF output is invalid" - )); - } - - #[tokio::test] - async fn temporal_block_rejection_records_error_without_banning_peer() { - let wallet = Wallet::from_seed("temporal-block-peer-wallet"); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 1_000); - let ledger = Ledger::new(allocations, 100); - let node = Arc::new(tokio::sync::Mutex::new(NodeCore::from_ledger( - wallet, ledger, 0, - ))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ - "127.0.0.1:9444".to_string(), - ]))); - let network = super::GossipNetwork::new_for_tests(node, Arc::clone(&peers)); - let known_peer = Some("127.0.0.1:9444".to_string()); - let remote_addr: SocketAddr = "127.0.0.1:50000".parse().unwrap(); - - super::record_inbound_result( - &network, - &known_peer, - remote_addr, - Err(anyhow::anyhow!("block timestamp is too far in the future")), - ) - .await; - - let peers = peers.lock().await; - let peer = peers - .list() - .into_iter() - .find(|peer| peer.address == "127.0.0.1:9444") - .unwrap(); - assert_eq!(peer.misbehavior_score, 0); - assert_eq!( - peer.last_error.as_deref(), - Some("block timestamp is too far in the future") - ); - } - - #[tokio::test] async fn inventory_requests_only_missing_objects() { let alice = Wallet::from_seed("missing-inv-alice"); let bob = Wallet::from_seed("missing-inv-bob"); let allocations = allocations(&[alice.clone(), bob], 1_000); let mut local = node("local", alice.clone(), allocations.clone()); - let mut remote = node("remote", alice, allocations); - let tx = local.burn(1).unwrap(); + let mut remote = node("remote", alice.clone(), allocations); + queue_plaintext_burn(&mut local, &alice, 1); let block = local.mine_one_at(1).unwrap(); + let inventory = [BlockInventory { + height: block.height, + hash: block.hash.clone(), + }]; - let requests = remote.missing_inventory_requests( - &[tx.signature().to_string()], - &[BlockInventory { - height: block.height, - hash: block.hash.clone(), - }], - ); - assert!(matches!( - requests[0], - GossipEnvelope::TransactionRequest { .. } - )); - assert!(matches!(requests[1], GossipEnvelope::BlockRequest { .. })); - - remote.receive(GossipEnvelope::Transaction(tx)).unwrap(); - let requests = remote.missing_inventory_requests( - &[], - &[BlockInventory { - height: block.height, - hash: block.hash, - }], - ); + let requests = remote.missing_inventory_requests(&inventory); assert_eq!(requests.len(), 1); assert!(matches!(requests[0], GossipEnvelope::BlockRequest { .. })); + + remote.receive(GossipEnvelope::Block(block)).unwrap(); + assert!(remote.missing_inventory_requests(&inventory).is_empty()); } #[tokio::test] @@ -3816,23 +2768,20 @@ mod tests { let alice = Wallet::from_seed("gap-inv-alice"); let bob = Wallet::from_seed("gap-inv-bob"); let allocations = allocations(&[alice.clone(), bob.clone()], 1_000); - let mut local = node("local", alice, allocations.clone()); + let mut local = node("local", alice.clone(), allocations.clone()); let remote = node("remote", bob, allocations); let mut latest = None; for height in 1..=3 { - local.burn(1).unwrap(); + queue_plaintext_burn(&mut local, &alice, 1); latest = Some(local.mine_one_at(height).unwrap()); } let latest = latest.unwrap(); - let requests = remote.missing_inventory_requests( - &[], - &[BlockInventory { - height: latest.height, - hash: latest.hash, - }], - ); + let requests = remote.missing_inventory_requests(&[BlockInventory { + height: latest.height, + hash: latest.hash, + }]); assert_eq!(requests.len(), 1); match &requests[0] { @@ -3849,11 +2798,15 @@ mod tests { let alice = Wallet::from_seed("catchup-alice"); let bob = Wallet::from_seed("catchup-bob"); let allocations = allocations(&[alice.clone(), bob], 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let node = Arc::new(tokio::sync::Mutex::new(node( + "alice", + alice.clone(), + allocations, + ))); { let mut node = node.lock().await; for height in 1..=3 { - node.burn(1).unwrap(); + queue_plaintext_burn(&mut node, &alice, 1); node.drain_outbox(); node.mine_one_at(height).unwrap(); node.drain_outbox(); @@ -3892,7 +2845,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4000,7 +2952,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4060,7 +3011,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4132,7 +3082,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4205,7 +3154,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4251,7 +3199,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4386,7 +3333,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4471,7 +3417,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4523,7 +3468,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4571,7 +3515,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4603,9 +3546,6 @@ mod tests { height: 0, tip_hash: "tip".to_string(), time_ms: 1_000, - mempool_count: 0, - mempool_root: String::new(), - mempool_txs: Vec::new(), } ) .unwrap() @@ -4645,7 +3585,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4679,7 +3618,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4712,7 +3650,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4749,7 +3686,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4786,7 +3722,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4821,7 +3756,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4859,7 +3793,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4896,7 +3829,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4935,7 +3867,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -4975,7 +3906,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -5005,7 +3935,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -5042,7 +3971,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -5100,7 +4028,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -5157,7 +4084,6 @@ mod tests { node_id: super::new_node_id(), accept_task: tokio::sync::Mutex::new(None), sessions: tokio::sync::Mutex::new(BTreeMap::new()), - tx_delivery: tokio::sync::Mutex::new(BTreeMap::new()), inbound_limiter: Arc::new( StdMutex::new(super::InboundConnectionLimiter::default()), ), @@ -5272,6 +4198,12 @@ mod tests { NodeCore::from_ledger(wallet, ledger, 0) } + fn queue_plaintext_burn(node: &mut NodeCore, wallet: &Wallet, amount: Amount) -> Transaction { + let tx = node.ledger().build_burn(wallet, amount, 0).unwrap(); + node.receive_transaction(tx.clone()).unwrap(); + tx + } + fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> { wallets .iter() diff --git a/src/adapters/stratum.rs b/src/adapters/stratum.rs @@ -406,7 +406,9 @@ mod tests { ) .await; assert_eq!(read_id(&mut lines, 3).await["result"], json!(true)); - assert_eq!(node.lock().await.ledger().pending().len(), 1); + let node = node.lock().await; + assert!(node.ledger().pending().is_empty()); + assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); } #[test] diff --git a/src/app.rs b/src/app.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeMap, BTreeSet}, + collections::BTreeMap, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -15,9 +15,9 @@ use tokio::sync::Mutex; use crate::domain::{ Amount, BlindedReveal, BlindedTransaction, Block, BuiltBlindedTransaction, BurnLeaderRank, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE, Ledger, - MAX_PENDING_TRANSACTIONS, MINE_FINALIZER_FEE, OutPoint, PreparedBlock, StratumMineShare, - StratumMineTemplate, Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, - hex_hash, run_vdf, + MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MINE_FINALIZER_FEE, OutPoint, PreparedBlock, + StratumMineShare, StratumMineTemplate, Transaction, TransactionSubmitOutcome, + VDF_TARGET_BLOCK_MS, Wallet, run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -29,14 +29,12 @@ pub const PROTOCOL_VERSION: u32 = 1; pub const NETWORK_ID: &str = "iuna-devnet-v2"; pub const BLOCK_REQUEST_LIMIT: usize = 128; pub const TRANSACTION_BATCH_LIMIT: usize = 128; -pub const MEMPOOL_STATUS_LIMIT: usize = MAX_PENDING_TRANSACTIONS; const IMPORT_REBROADCAST_LIMIT: usize = 128; pub const PEER_MISBEHAVIOR_BAN_SCORE: u32 = 3; pub const PEER_MISBEHAVIOR_BAN_MS: u64 = 10 * 60 * 1_000; pub const PEER_CLOCK_OFFSET_ACCEPTANCE_MS: i64 = 10 * 60 * 1_000; const PEER_CLOCK_OFFSET_STALE_MS: u64 = 20 * 60 * 1_000; const AUTO_POW_NONCE_ATTEMPTS_PER_TICK: u64 = 8; -const AUTO_BLINDED_BURN_EXPIRY_HEIGHTS: u64 = 20; const AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS: u64 = 60_000; static DEBUG_LOGGING: AtomicBool = AtomicBool::new(false); @@ -103,36 +101,18 @@ pub enum GossipEnvelope { tip_hash: String, #[serde(default)] time_ms: u64, - #[serde(default)] - mempool_count: usize, - #[serde(default)] - mempool_root: String, - #[serde(default)] - mempool_txs: Vec<String>, }, ChainSnapshotRequest, BlockRangeRequest { from_height: u64, limit: usize, }, - TransactionRequest { - signatures: Vec<String>, - }, BlockRequest { hashes: Vec<String>, }, Inventory { - txs: Vec<String>, blocks: Vec<BlockInventory>, }, - TransactionAck { - accepted: Vec<String>, - rejected: Vec<TransactionRejection>, - }, - Transaction(Transaction), - Transactions { - transactions: Vec<Transaction>, - }, BlindedTransaction(BlindedTransaction), BlindedTransactions { transactions: Vec<BlindedTransaction>, @@ -187,12 +167,6 @@ pub struct BlockInventory { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct TransactionRejection { - pub signature: String, - pub reason: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct NodeStatus { pub app_version: String, pub wallet_address: String, @@ -272,6 +246,8 @@ pub struct NodeCore { last_auto_pow_mine_status: Option<String>, auto_pow_mine_cursor: Option<AutoPowMineCursor>, owned_blinded_reveals: BTreeMap<String, BlindedReveal>, + owned_blinded_payloads: BTreeMap<String, Transaction>, + local_block_anchor_burn: Option<(u64, Transaction)>, outbox: Vec<GossipEnvelope>, } @@ -358,6 +334,8 @@ impl NodeCore { last_auto_pow_mine_status: None, auto_pow_mine_cursor: None, owned_blinded_reveals: BTreeMap::new(), + owned_blinded_payloads: BTreeMap::new(), + local_block_anchor_burn: None, outbox: Vec::new(), } } @@ -377,6 +355,8 @@ impl NodeCore { self.last_auto_pow_mine_status = None; self.auto_pow_mine_cursor = None; self.owned_blinded_reveals.clear(); + self.owned_blinded_payloads.clear(); + self.local_block_anchor_burn = None; } pub fn ledger(&self) -> &Ledger { @@ -423,44 +403,8 @@ impl NodeCore { self.ledger.pending_blinded_reveals().to_vec() } - pub fn mempool_inventory(&self, limit: usize) -> Vec<String> { - let mut signatures = self - .ledger - .pending() - .iter() - .map(|tx| tx.signature().to_string()) - .collect::<BTreeSet<_>>() - .into_iter() - .collect::<Vec<_>>(); - signatures.truncate(limit); - signatures - } - - pub fn mempool_count(&self) -> usize { - self.ledger.pending().len() - } - - pub fn mempool_root(&self) -> String { - mempool_root_for_signatures( - &self - .ledger - .pending() - .iter() - .map(|tx| tx.signature().to_string()) - .collect::<BTreeSet<_>>() - .into_iter() - .collect::<Vec<_>>(), - ) - } - pub fn mempool_gossip(&self) -> Vec<GossipEnvelope> { - let transactions = self.ledger.pending().to_vec(); - let mut gossip = transactions - .chunks(TRANSACTION_BATCH_LIMIT) - .map(|chunk| GossipEnvelope::Transactions { - transactions: chunk.to_vec(), - }) - .collect::<Vec<_>>(); + let mut gossip = Vec::new(); gossip.extend( self.ledger .pending_blinded_transactions() @@ -500,14 +444,10 @@ impl NodeCore { pub fn peer_status(&self) -> GossipEnvelope { let status = self.ledger.status(); - let mempool_txs = self.mempool_inventory(MEMPOOL_STATUS_LIMIT); GossipEnvelope::PeerStatus { height: status.height, tip_hash: status.tip_hash, time_ms: now_ms(), - mempool_count: self.mempool_count(), - mempool_root: self.mempool_root(), - mempool_txs, } } @@ -515,13 +455,6 @@ impl NodeCore { self.ledger.blocks_from(from_height, limit) } - pub fn transactions_by_signature(&self, signatures: &[String]) -> Vec<Transaction> { - signatures - .iter() - .filter_map(|signature| self.ledger.transaction_by_signature(signature)) - .collect() - } - pub fn blocks_by_hash(&self, hashes: &[String]) -> Vec<Block> { hashes .iter() @@ -529,16 +462,7 @@ impl NodeCore { .collect() } - pub fn missing_inventory_requests( - &self, - txs: &[String], - blocks: &[BlockInventory], - ) -> Vec<GossipEnvelope> { - let missing_txs = txs - .iter() - .filter(|signature| !self.ledger.has_transaction(signature)) - .cloned() - .collect::<Vec<_>>(); + pub fn missing_inventory_requests(&self, blocks: &[BlockInventory]) -> Vec<GossipEnvelope> { let local_height = self.ledger.height(); let first_height_gap = blocks .iter() @@ -554,11 +478,6 @@ impl NodeCore { .collect::<Vec<_>>(); let mut requests = Vec::new(); - if !missing_txs.is_empty() { - requests.push(GossipEnvelope::TransactionRequest { - signatures: missing_txs, - }); - } if !missing_blocks.is_empty() { requests.push(GossipEnvelope::BlockRequest { hashes: missing_blocks, @@ -665,12 +584,9 @@ impl NodeCore { pub fn burn_with_fee(&mut self, amount: Amount, fee: Amount) -> Result<Transaction> { let tx = self - .ledger + .wallet_build_ledger()? .build_burn(self.wallet.unlocked()?, amount, fee)?; - if self.ledger.submit_transaction(tx.clone())? { - self.outbox.push(GossipEnvelope::Transaction(tx.clone())); - } - Ok(tx) + self.submit_transaction_as_owned_blinded(tx) } pub fn burn_with_fee_rate( @@ -678,10 +594,9 @@ impl NodeCore { amount: Amount, fee_per_byte: Amount, ) -> Result<(Transaction, FeeEstimate)> { - let (tx, estimate) = self.build_burn_with_fee_rate(amount, fee_per_byte)?; - if self.ledger.submit_transaction(tx.clone())? { - self.outbox.push(GossipEnvelope::Transaction(tx.clone())); - } + let (built, estimate) = self.build_blinded_burn_with_fee_rate(amount, fee_per_byte)?; + let tx = built.payload.clone(); + self.submit_owned_blinded_transaction(built)?; Ok((tx, estimate)) } @@ -691,7 +606,7 @@ impl NodeCore { fee: Amount, expires_at_height: u64, ) -> Result<BlindedTransaction> { - let built = self.ledger.build_blinded_burn( + let built = self.wallet_build_ledger()?.build_blinded_burn( self.wallet.unlocked()?, amount, fee, @@ -715,13 +630,10 @@ impl NodeCore { amount: Amount, fee: Amount, ) -> Result<Transaction> { - let tx = self - .ledger - .build_transfer(self.wallet.unlocked()?, to, amount, fee)?; - if self.ledger.submit_transaction(tx.clone())? { - self.outbox.push(GossipEnvelope::Transaction(tx.clone())); - } - Ok(tx) + let tx = + self.wallet_build_ledger()? + .build_transfer(self.wallet.unlocked()?, to, amount, fee)?; + self.submit_transaction_as_owned_blinded(tx) } pub fn transfer_with_fee_spending( @@ -731,17 +643,14 @@ impl NodeCore { fee: Amount, outpoints: &[OutPoint], ) -> Result<Transaction> { - let tx = self.ledger.build_transfer_with_inputs( + let tx = self.wallet_build_ledger()?.build_transfer_with_inputs( self.wallet.unlocked()?, to, amount, fee, outpoints, )?; - if self.ledger.submit_transaction(tx.clone())? { - self.outbox.push(GossipEnvelope::Transaction(tx.clone())); - } - Ok(tx) + self.submit_transaction_as_owned_blinded(tx) } pub fn blinded_transfer_with_fee( @@ -751,7 +660,7 @@ impl NodeCore { fee: Amount, expires_at_height: u64, ) -> Result<BlindedTransaction> { - let built = self.ledger.build_blinded_transfer( + let built = self.wallet_build_ledger()?.build_blinded_transfer( self.wallet.unlocked()?, to, amount, @@ -768,11 +677,10 @@ impl NodeCore { fee_per_byte: Amount, outpoints: &[OutPoint], ) -> Result<(Transaction, FeeEstimate)> { - let (tx, estimate) = - self.build_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints)?; - if self.ledger.submit_transaction(tx.clone())? { - self.outbox.push(GossipEnvelope::Transaction(tx.clone())); - } + let (built, estimate) = + self.build_blinded_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints)?; + let tx = built.payload.clone(); + self.submit_owned_blinded_transaction(built)?; Ok((tx, estimate)) } @@ -789,10 +697,7 @@ impl NodeCore { pub fn mine_pow_reward(&mut self) -> Result<Transaction> { let (tx, _) = self.build_mine_estimate()?; - if self.ledger.submit_transaction(tx.clone())? { - self.outbox.push(GossipEnvelope::Transaction(tx.clone())); - } - Ok(tx) + self.submit_transaction_as_owned_blinded(tx) } pub fn estimate_mine_fee(&self, _fee_per_byte: Amount) -> Result<FeeEstimate> { @@ -831,17 +736,11 @@ impl NodeCore { if tx.to() != Some(recipient.as_str()) { bail!("submitted mine recipient does not match worker"); } - if self.ledger.submit_transaction(tx.clone())? { - self.outbox.push(GossipEnvelope::Transaction(tx.clone())); - } - Ok(tx) + self.submit_transaction_as_owned_blinded(tx) } pub fn receive_transaction(&mut self, tx: Transaction) -> Result<TransactionSubmitOutcome> { let outcome = self.ledger.submit_transaction_with_outcome(tx.clone())?; - if outcome.added() { - self.outbox.push(GossipEnvelope::Transaction(tx.clone())); - } Ok(outcome) } @@ -864,6 +763,8 @@ impl NodeCore { built: BuiltBlindedTransaction, ) -> Result<BlindedTransaction> { let transaction = built.transaction; + self.owned_blinded_payloads + .insert(transaction.commitment.clone(), built.payload); self.owned_blinded_reveals .insert(transaction.commitment.clone(), built.reveal); if self @@ -876,6 +777,21 @@ impl NodeCore { Ok(transaction) } + fn submit_transaction_as_owned_blinded(&mut self, tx: Transaction) -> Result<Transaction> { + let built = self.ledger.build_blinded_transaction( + tx.clone(), + self.default_blinded_transaction_expiry_height(), + )?; + self.submit_owned_blinded_transaction(built)?; + Ok(tx) + } + + fn default_blinded_transaction_expiry_height(&self) -> u64 { + self.ledger + .height() + .saturating_add(MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS) + } + fn publish_owned_reveals_for_block(&mut self, block: &Block) -> Result<()> { for transaction in &block.blinded_transactions { let Some(reveal) = self.owned_blinded_reveals.remove(&transaction.commitment) else { @@ -888,13 +804,33 @@ impl NodeCore { Ok(()) } + fn prune_owned_blinded_payloads_for_block(&mut self, block: &Block) { + for reveal in &block.blinded_reveals { + self.owned_blinded_payloads.remove(&reveal.commitment); + } + self.owned_blinded_payloads + .retain(|commitment, _| self.ledger.has_unrevealed_blinded_transaction(commitment)); + } + fn build_burn_with_fee_rate( &self, amount: Amount, fee_per_byte: Amount, ) -> Result<(Transaction, FeeEstimate)> { + let (built, estimate) = self.build_blinded_burn_with_fee_rate(amount, fee_per_byte)?; + Ok((built.payload, estimate)) + } + + fn build_blinded_burn_with_fee_rate( + &self, + amount: Amount, + fee_per_byte: Amount, + ) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { + let ledger = self.wallet_build_ledger()?; + let expires_at_height = self.default_blinded_transaction_expiry_height(); converge_fee_by_byte(fee_per_byte, |fee| { - self.ledger.build_burn(self.wallet.unlocked()?, amount, fee) + let tx = ledger.build_burn(self.wallet.unlocked()?, amount, fee)?; + ledger.build_blinded_transaction(tx, expires_at_height) }) } @@ -905,20 +841,34 @@ impl NodeCore { fee_per_byte: Amount, outpoints: &[OutPoint], ) -> Result<(Transaction, FeeEstimate)> { + let (built, estimate) = + self.build_blinded_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints)?; + Ok((built.payload, estimate)) + } + + fn build_blinded_transfer_with_fee_rate( + &self, + to: impl Into<String>, + amount: Amount, + fee_per_byte: Amount, + outpoints: &[OutPoint], + ) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { let to = to.into(); + let ledger = self.wallet_build_ledger()?; + let expires_at_height = self.default_blinded_transaction_expiry_height(); converge_fee_by_byte(fee_per_byte, |fee| { - if outpoints.is_empty() { - self.ledger - .build_transfer(self.wallet.unlocked()?, to.clone(), amount, fee) + let tx = if outpoints.is_empty() { + ledger.build_transfer(self.wallet.unlocked()?, to.clone(), amount, fee) } else { - self.ledger.build_transfer_with_inputs( + ledger.build_transfer_with_inputs( self.wallet.unlocked()?, to.clone(), amount, fee, outpoints, ) - } + }?; + ledger.build_blinded_transaction(tx, expires_at_height) }) } @@ -933,6 +883,18 @@ impl NodeCore { )) } + fn wallet_build_ledger(&self) -> Result<Ledger> { + let mut ledger = self.ledger.clone(); + for (commitment, payload) in &self.owned_blinded_payloads { + if self.ledger.has_unrevealed_blinded_transaction(commitment) + && !ledger.has_transaction(payload.signature()) + { + let _ = ledger.submit_transaction(payload.clone())?; + } + } + Ok(ledger) + } + pub fn mine_one(&mut self) -> Result<Block> { self.mine_one_at(now_ms()) } @@ -1039,10 +1001,7 @@ impl NodeCore { return plan; } - match self - .ledger - .prepare_next_block(self.wallet.address(), timestamp_ms) - { + match self.prepare_next_block_with_local_anchor(timestamp_ms) { Ok(work) => { plan.work = Some(work); } @@ -1102,10 +1061,7 @@ impl NodeCore { .finalizer_rank_for_next_block(self.wallet.address()); if wallet_rank.is_none() { if self.ledger.recovery_block_available_at(timestamp_ms) { - match self - .ledger - .prepare_recovery_block(self.wallet.address(), timestamp_ms) - { + match self.prepare_recovery_block_with_local_anchor(timestamp_ms) { Ok(work) => { plan.work = Some(work); } @@ -1122,10 +1078,7 @@ impl NodeCore { return plan; } - match self - .ledger - .prepare_next_block(self.wallet.address(), timestamp_ms) - { + match self.prepare_next_block_with_local_anchor(timestamp_ms) { Ok(work) => { plan.work = Some(work); } @@ -1171,7 +1124,7 @@ impl NodeCore { .as_ref() .context("automatic PoW cursor was not initialized")? .clone(); - let outcome = self.ledger.search_mine( + let outcome = self.wallet_build_ledger()?.search_mine( wallet_address, cursor.salt, cursor.next_nonce, @@ -1191,17 +1144,12 @@ impl NodeCore { )); return Ok(None); }; - if self.ledger.submit_transaction(tx.clone())? { - self.last_auto_pow_mine_anchor = Some(anchor); - self.last_auto_pow_mine_status = Some(format!( - "queued mine action after {searched} PoW nonce attempts for the current tip" - )); - self.outbox.push(GossipEnvelope::Transaction(tx.clone())); - return Ok(Some(tx)); - } - self.last_auto_pow_mine_status = - Some("mine action was already known by the mempool".to_string()); - Ok(None) + self.submit_transaction_as_owned_blinded(tx.clone())?; + self.last_auto_pow_mine_anchor = Some(anchor); + self.last_auto_pow_mine_status = Some(format!( + "queued mine action after {searched} PoW nonce attempts for the current tip" + )); + Ok(Some(tx)) } fn prepare_automatic_burn(&mut self, timestamp_ms: u64) -> Result<Option<Transaction>> { @@ -1224,13 +1172,13 @@ impl NodeCore { let mut best = None; while low <= high { let amount = low + (high - low) / 2; - match self.build_burn_with_fee_rate(amount, fee_per_byte) { - Ok((tx, estimate)) => { + match self.build_blinded_burn_with_fee_rate(amount, fee_per_byte) { + Ok((built, estimate)) => { let fits = amount .checked_add(estimate.fee) .is_some_and(|required| required <= balance); if fits { - best = Some(tx); + best = Some(built); if amount == Amount::MAX { break; } @@ -1248,21 +1196,14 @@ impl NodeCore { self.last_auto_burn_height = Some(current_height); return Ok(None); }; + let burn = tx.payload.clone(); if self.automatic_burn_needs_plaintext_anchor(timestamp_ms) { - if self.ledger.submit_transaction(tx.clone())? { - self.outbox.push(GossipEnvelope::Transaction(tx.clone())); - } + self.local_block_anchor_burn = Some((current_height, burn.clone())); } else { - let built = self.ledger.build_blinded_burn( - self.wallet.unlocked()?, - tx.amount(), - tx.fee(), - current_height.saturating_add(AUTO_BLINDED_BURN_EXPIRY_HEIGHTS), - )?; - self.submit_owned_blinded_transaction(built)?; + self.submit_owned_blinded_transaction(tx)?; } self.last_auto_burn_height = Some(current_height); - Ok(Some(tx)) + Ok(Some(burn)) } fn automatic_burn_needs_plaintext_anchor(&self, timestamp_ms: u64) -> bool { @@ -1274,11 +1215,44 @@ impl NodeCore { >= self.ledger.recovery_block_min_timestamp() } + fn prepare_next_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> { + self.ledger_with_local_block_anchor()? + .prepare_next_block(self.wallet.address(), timestamp_ms) + } + + fn prepare_recovery_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> { + self.ledger_with_local_block_anchor()? + .prepare_recovery_block(self.wallet.address(), timestamp_ms) + } + + fn ledger_with_local_block_anchor(&self) -> Result<Ledger> { + let mut ledger = self.ledger.clone(); + let Some((height, burn)) = &self.local_block_anchor_burn else { + return Ok(ledger); + }; + if *height == ledger.height() && !ledger.has_transaction(burn.signature()) { + let _ = ledger.submit_transaction(burn.clone())?; + } + Ok(ledger) + } + + fn clear_stale_local_block_anchor(&mut self) { + if self + .local_block_anchor_burn + .as_ref() + .is_some_and(|(height, _)| *height != self.ledger.height()) + { + self.local_block_anchor_burn = None; + } + } + pub fn mine_one_at(&mut self, timestamp_ms: u64) -> Result<Block> { let block = self .ledger .mine_next_block(self.wallet.unlocked()?, timestamp_ms)?; self.ledger.apply_locally_mined_block(block.clone())?; + self.clear_stale_local_block_anchor(); + self.prune_owned_blinded_payloads_for_block(&block); self.outbox.push(GossipEnvelope::Block(block.clone())); self.publish_owned_reveals_for_block(&block)?; Ok(block) @@ -1300,6 +1274,8 @@ impl NodeCore { ) -> Result<Block> { let block = work.finish_at(self.wallet.unlocked()?, vdf_output, timestamp_ms); self.ledger.apply_locally_mined_block(block.clone())?; + self.clear_stale_local_block_anchor(); + self.prune_owned_blinded_payloads_for_block(&block); self.outbox.push(GossipEnvelope::Block(block.clone())); self.publish_owned_reveals_for_block(&block)?; Ok(block) @@ -1311,20 +1287,8 @@ impl NodeCore { | GossipEnvelope::PeerStatus { .. } | GossipEnvelope::ChainSnapshotRequest | GossipEnvelope::BlockRangeRequest { .. } - | GossipEnvelope::TransactionRequest { .. } | GossipEnvelope::BlockRequest { .. } - | GossipEnvelope::TransactionAck { .. } | GossipEnvelope::Inventory { .. } => Ok(()), - GossipEnvelope::Transaction(tx) => { - self.receive_transaction(tx)?; - Ok(()) - } - GossipEnvelope::Transactions { transactions } => { - for tx in transactions { - self.receive_transaction(tx)?; - } - Ok(()) - } GossipEnvelope::BlindedTransaction(tx) => self.receive_blinded_transaction(tx), GossipEnvelope::BlindedTransactions { transactions } => { for tx in transactions { @@ -1343,6 +1307,8 @@ impl NodeCore { let previous_height = self.ledger.height(); self.ledger.apply_block(block.clone())?; if self.ledger.height() > previous_height { + self.clear_stale_local_block_anchor(); + self.prune_owned_blinded_payloads_for_block(&block); self.publish_owned_reveals_for_block(&block)?; self.outbox.push(GossipEnvelope::Block(block)); } @@ -1354,6 +1320,8 @@ impl NodeCore { let previous_height = self.ledger.height(); self.ledger.apply_block(block.clone())?; if self.ledger.height() > previous_height { + self.clear_stale_local_block_anchor(); + self.prune_owned_blinded_payloads_for_block(&block); self.publish_owned_reveals_for_block(&block)?; imported.push(block); } @@ -1376,6 +1344,9 @@ impl NodeCore { self.ledger .apply_preverified_block_at(block.clone(), now_ms)?; if self.ledger.height() > previous_height { + self.clear_stale_local_block_anchor(); + self.prune_owned_blinded_payloads_for_block(&block); + self.publish_owned_reveals_for_block(&block)?; self.outbox.push(GossipEnvelope::Block(block)); } Ok(()) @@ -1398,6 +1369,7 @@ impl NodeCore { self.last_auto_pow_mine_anchor = None; self.last_auto_pow_mine_status = None; self.auto_pow_mine_cursor = None; + self.clear_stale_local_block_anchor(); self.enqueue_imported_blocks(previous_height)?; } Ok(()) @@ -1419,6 +1391,7 @@ impl NodeCore { self.last_auto_pow_mine_anchor = None; self.last_auto_pow_mine_status = None; self.auto_pow_mine_cursor = None; + self.clear_stale_local_block_anchor(); self.enqueue_imported_blocks(previous_height)?; Ok(true) } @@ -1435,6 +1408,7 @@ impl NodeCore { .ledger .blocks_from(previous_height + 1, IMPORT_REBROADCAST_LIMIT); for block in &blocks { + self.prune_owned_blinded_payloads_for_block(block); self.publish_owned_reveals_for_block(block)?; } if !blocks.is_empty() { @@ -1508,22 +1482,6 @@ impl PeerBook { .last_known_tip_hash .clone() .or(from_peer.last_known_tip_hash); - to_peer.last_known_mempool_count = to_peer - .last_known_mempool_count - .or(from_peer.last_known_mempool_count); - to_peer.last_known_mempool_root = to_peer - .last_known_mempool_root - .clone() - .or(from_peer.last_known_mempool_root); - to_peer.last_known_mempool_shared = to_peer - .last_known_mempool_shared - .or(from_peer.last_known_mempool_shared); - to_peer.last_known_mempool_missing = to_peer - .last_known_mempool_missing - .or(from_peer.last_known_mempool_missing); - to_peer.last_mempool_status_ms = to_peer - .last_mempool_status_ms - .max(from_peer.last_mempool_status_ms); if from_peer.last_clock_observed_ms > to_peer.last_clock_observed_ms { to_peer.last_clock_offset_ms = from_peer.last_clock_offset_ms; to_peer.last_clock_offset_accepted = from_peer.last_clock_offset_accepted; @@ -1535,12 +1493,6 @@ impl PeerBook { if to_peer.last_error.is_none() { to_peer.last_error = from_peer.last_error; } - if to_peer.last_transaction_rejection.is_none() { - to_peer.last_transaction_rejection = from_peer.last_transaction_rejection; - } - to_peer.last_transaction_rejection_ms = to_peer - .last_transaction_rejection_ms - .max(from_peer.last_transaction_rejection_ms); to_peer.misbehavior_score = to_peer .misbehavior_score .saturating_add(from_peer.misbehavior_score); @@ -1679,66 +1631,6 @@ impl PeerBook { .count() } - pub fn record_mempool_status( - &mut self, - address: &str, - mempool_count: usize, - mempool_root: String, - mempool_shared: usize, - mempool_missing: usize, - ) { - self.record_mempool_status_with_direction( - address, - PeerDirection::Outbound, - mempool_count, - mempool_root, - mempool_shared, - mempool_missing, - ); - } - - pub fn record_inbound_mempool_status( - &mut self, - address: &str, - mempool_count: usize, - mempool_root: String, - mempool_shared: usize, - mempool_missing: usize, - ) { - self.record_mempool_status_with_direction( - address, - PeerDirection::Inbound, - mempool_count, - mempool_root, - mempool_shared, - mempool_missing, - ); - } - - fn record_mempool_status_with_direction( - &mut self, - address: &str, - direction: PeerDirection, - mempool_count: usize, - mempool_root: String, - mempool_shared: usize, - mempool_missing: usize, - ) { - let now = now_ms(); - let peer = self.ensure(address, direction); - peer.last_known_mempool_count = Some(mempool_count); - peer.last_known_mempool_root = Some(mempool_root); - peer.last_known_mempool_shared = Some(mempool_shared); - peer.last_known_mempool_missing = Some(mempool_missing); - peer.last_mempool_status_ms = Some(now); - peer.last_contact_ms = Some(now); - peer.last_success_ms = Some(now); - if !peer.is_banned_at(now) { - peer.last_error = None; - peer.clear_misbehavior(); - } - } - pub fn record_error(&mut self, address: &str, error: impl Into<String>) { let now = now_ms(); let peer = self.ensure(address, PeerDirection::Outbound); @@ -1755,26 +1647,6 @@ impl PeerBook { peer.last_error = Some(error.into()); } - pub fn record_transaction_rejection(&mut self, address: &str, reason: impl Into<String>) { - let now = now_ms(); - let peer = self.ensure(address, PeerDirection::Outbound); - peer.last_contact_ms = Some(now); - peer.last_transaction_rejection_ms = Some(now); - peer.last_transaction_rejection = Some(reason.into()); - } - - pub fn record_inbound_transaction_rejection( - &mut self, - address: &str, - reason: impl Into<String>, - ) { - let now = now_ms(); - let peer = self.ensure(address, PeerDirection::Inbound); - peer.last_contact_ms = Some(now); - peer.last_transaction_rejection_ms = Some(now); - peer.last_transaction_rejection = Some(reason.into()); - } - pub fn record_received(&mut self, address: &str, count: u64) { let now = now_ms(); let peer = self.ensure(address, PeerDirection::Inbound); @@ -1844,27 +1716,15 @@ pub struct PeerInfo { pub last_known_height: Option<u64>, pub last_known_tip_hash: Option<String>, #[serde(default)] - pub last_known_mempool_count: Option<usize>, - #[serde(default)] - pub last_known_mempool_root: Option<String>, - #[serde(default)] - pub last_known_mempool_shared: Option<usize>, - #[serde(default)] - pub last_known_mempool_missing: Option<usize>, - #[serde(default)] - pub last_mempool_status_ms: Option<u64>, - #[serde(default)] pub last_clock_offset_ms: Option<i64>, #[serde(default)] pub last_clock_offset_accepted: Option<bool>, #[serde(default)] pub last_clock_observed_ms: Option<u64>, pub last_error: Option<String>, - pub last_transaction_rejection: Option<String>, pub last_contact_ms: Option<u64>, pub last_success_ms: Option<u64>, pub last_error_ms: Option<u64>, - pub last_transaction_rejection_ms: Option<u64>, pub misbehavior_score: u32, pub banned_until_ms: Option<u64>, pub ban_reason: Option<String>, @@ -1879,20 +1739,13 @@ impl PeerInfo { messages_received: 0, last_known_height: None, last_known_tip_hash: None, - last_known_mempool_count: None, - last_known_mempool_root: None, - last_known_mempool_shared: None, - last_known_mempool_missing: None, - last_mempool_status_ms: None, last_clock_offset_ms: None, last_clock_offset_accepted: None, last_clock_observed_ms: None, last_error: None, - last_transaction_rejection: None, last_contact_ms: None, last_success_ms: None, last_error_ms: None, - last_transaction_rejection_ms: None, misbehavior_score: 0, banned_until_ms: None, ban_reason: None, @@ -1933,14 +1786,6 @@ pub fn now_ms() -> u64 { .as_millis() as u64 } -pub fn mempool_root_for_signatures(signatures: &[String]) -> String { - if signatures.is_empty() { - String::new() - } else { - hex_hash(format!("iuna-mempool-root:{}", signatures.join("|"))) - } -} - fn auto_pow_salt(wallet_address: &str, anchor: &str) -> u64 { let digest = Sha256::digest(format!("iuna-auto-pow:{wallet_address}:{anchor}").as_bytes()); let mut bytes = [0_u8; 8]; @@ -1950,55 +1795,57 @@ fn auto_pow_salt(wallet_address: &str, anchor: &str) -> u64 { fn converge_fee_by_byte( fee_per_byte: Amount, - mut build: impl FnMut(Amount) -> Result<Transaction>, -) -> Result<(Transaction, FeeEstimate)> { + mut build: impl FnMut(Amount) -> Result<BuiltBlindedTransaction>, +) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { let mut fee = 0; let mut best = None; for _ in 0..64 { - let tx = build(fee)?; - let bytes = tx.economic_size_bytes(); + let built = build(fee)?; + let bytes = built.transaction.fee_rate_size_bytes(); let required_fee = fee_per_byte .checked_mul(bytes as Amount) - .context("fee per byte times transaction bytes overflows")?; + .context("fee per byte times blinded transaction bytes overflows")?; if fee == required_fee { - return Ok((tx, FeeEstimate { bytes, fee })); + return Ok((built, FeeEstimate { bytes, fee })); } if fee > required_fee && best .as_ref() - .is_none_or(|(_, estimate): &(Transaction, FeeEstimate)| fee < estimate.fee) + .is_none_or(|(_, estimate): &(BuiltBlindedTransaction, FeeEstimate)| { + fee < estimate.fee + }) { - best = Some((tx, FeeEstimate { bytes, fee })); + best = Some((built, FeeEstimate { bytes, fee })); } fee = required_fee; } - let tx = build(fee)?; - let bytes = tx.economic_size_bytes(); + let built = build(fee)?; + let bytes = built.transaction.fee_rate_size_bytes(); let required_fee = fee_per_byte .checked_mul(bytes as Amount) - .context("fee per byte times transaction bytes overflows")?; + .context("fee per byte times blinded transaction bytes overflows")?; if fee >= required_fee { if best .as_ref() - .is_none_or(|(_, estimate): &(Transaction, FeeEstimate)| fee < estimate.fee) + .is_none_or(|(_, estimate): &(BuiltBlindedTransaction, FeeEstimate)| fee < estimate.fee) { - best = Some((tx, FeeEstimate { bytes, fee })); + best = Some((built, FeeEstimate { bytes, fee })); } if let Some(best) = best { return Ok(best); } } - let tx = build(required_fee)?; - let bytes = tx.economic_size_bytes(); + let built = build(required_fee)?; + let bytes = built.transaction.fee_rate_size_bytes(); let final_required_fee = fee_per_byte .checked_mul(bytes as Amount) - .context("fee per byte times transaction bytes overflows")?; + .context("fee per byte times blinded transaction bytes overflows")?; if required_fee < final_required_fee { bail!("fee per byte did not converge"); } Ok(( - tx, + built, FeeEstimate { bytes, fee: required_fee, @@ -2021,7 +1868,7 @@ mod tests { fn same_height_verified_import_does_not_reset_auto_burn_guard() { let alice = Wallet::from_seed("same-height-import-alice"); let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(alice.address().to_string(), MICRO_IUNA); let mut node = NodeCore::new(NodeConfig { wallet: alice, genesis_allocations: allocations, @@ -2045,13 +1892,13 @@ mod tests { #[test] fn automatic_pow_mining_searches_bounded_nonce_batches_per_tip() { let wallet = Wallet::from_seed("automatic-pow-mining-wallet"); - let mut node = NodeCore::new(NodeConfig { - wallet: wallet.clone(), - genesis_allocations: BTreeMap::new(), - vdf_rounds: 10, - burn_per_block: 0, - burn_fee: 0, - }); + let ledger = Ledger::new_with_genesis_burns( + BTreeMap::from([(wallet.address().to_string(), 1)]), + vec![GenesisBurn::new(wallet.address(), 1)], + 10, + ) + .unwrap(); + let mut node = NodeCore::from_ledger(wallet.clone(), ledger, 0); let disabled = node.prepare_automatic_mining(1); assert!(disabled.pow_mined.is_none()); @@ -2062,7 +1909,8 @@ mod tests { node.set_pow_mining_enabled(true); let first = node.prepare_automatic_mining(2); - assert!(node.ledger().pending().len() <= 1); + assert!(node.ledger().pending().is_empty()); + assert!(node.ledger().pending_blinded_transactions().len() <= 1); let first = std::iter::once(first) .chain((3..10_000).map(|timestamp| node.prepare_automatic_mining(timestamp))) .find(|plan| plan.pow_mined.is_some()) @@ -2083,7 +1931,8 @@ mod tests { *difficulty_bits, node.ledger().current_mine_difficulty_bits() ); - assert_eq!(node.ledger().pending().len(), 1); + assert!(node.ledger().pending().is_empty()); + assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); assert!( node.status() .mining @@ -2101,7 +1950,8 @@ mod tests { second.pow_mined.as_ref().unwrap().signature(), first_mine.signature() ); - assert_eq!(node.ledger().pending().len(), 2); + assert!(node.ledger().pending().is_empty()); + assert_eq!(node.ledger().pending_blinded_transactions().len(), 2); } #[test] @@ -2251,15 +2101,25 @@ mod tests { let ledger = crate::domain::Ledger::new(genesis, 1); let mut node = NodeCore::from_ledger(alice, ledger, 0); - let (transfer, _) = node + let (transfer, transfer_estimate) = node .transfer_with_fee_rate(bob.address(), MICRO_IUNA, 2, &[]) .unwrap(); - let minimum_transfer_fee = transfer.economic_size_bytes() as u64 * 2; + let transfer_blinded_bytes = + node.ledger().pending_blinded_transactions()[0].fee_rate_size_bytes(); + assert_eq!(transfer_estimate.bytes, transfer_blinded_bytes); + let minimum_transfer_fee = transfer_blinded_bytes as u64 * 2; assert!(transfer.fee() >= minimum_transfer_fee); + assert!(node.ledger().pending().is_empty()); + assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); - let (burn, _) = node.burn_with_fee_rate(MICRO_IUNA, 3).unwrap(); - let minimum_burn_fee = burn.economic_size_bytes() as u64 * 3; + let (burn, burn_estimate) = node.burn_with_fee_rate(MICRO_IUNA, 3).unwrap(); + let burn_blinded_bytes = + node.ledger().pending_blinded_transactions()[1].fee_rate_size_bytes(); + assert_eq!(burn_estimate.bytes, burn_blinded_bytes); + let minimum_burn_fee = burn_blinded_bytes as u64 * 3; assert!(burn.fee() >= minimum_burn_fee); + assert!(node.ledger().pending().is_empty()); + assert_eq!(node.ledger().pending_blinded_transactions().len(), 2); } #[test] @@ -2420,12 +2280,11 @@ mod tests { let outbox = node.drain_outbox(); assert!(plan.burned.is_some()); - assert_eq!(node.ledger().pending().len(), 1); + assert!(node.ledger().pending().is_empty()); assert!(node.ledger().pending_blinded_transactions().is_empty()); - assert!(outbox.iter().any(|envelope| matches!( - envelope, - GossipEnvelope::Transaction(transaction) if transaction.is_burn() - ))); + assert!(node.local_block_anchor_burn.is_some()); + assert!(outbox.is_empty()); + assert!(node.prepare_automatic_finalization(1).work.is_some()); } } @@ -2496,9 +2355,7 @@ impl InMemoryNetwork { fn receive_in_memory_envelope(node: &mut NodeCore, envelope: GossipEnvelope) -> Result<()> { let transaction_like = matches!( envelope, - GossipEnvelope::Transaction(_) - | GossipEnvelope::Transactions { .. } - | GossipEnvelope::BlindedTransaction(_) + GossipEnvelope::BlindedTransaction(_) | GossipEnvelope::BlindedTransactions { .. } | GossipEnvelope::BlindedReveal(_) | GossipEnvelope::BlindedReveals { .. } diff --git a/src/domain.rs b/src/domain.rs @@ -26,6 +26,7 @@ pub const VDF_TARGET_BLOCK_MS: u64 = 10 * 60 * 1_000; pub const RECOVERY_BLOCK_DELAY_MS: u64 = VDF_TARGET_BLOCK_MS * 6; pub const MAX_VDF_ROUNDS: u64 = i64::MAX as u64; pub const MINE_DIFFICULTY_BITS: u32 = 12; +pub const MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS: u64 = 20; const MINE_RETARGET_WINDOW_BLOCKS: u64 = 10; const MINE_TARGET_ACTIONS_PER_BLOCK: u64 = 1; const MINE_MAX_RETARGET_STEP_BITS: u32 = 2; @@ -166,6 +167,7 @@ pub struct BlindedReveal { #[derive(Clone, Debug, Eq, PartialEq)] pub struct BuiltBlindedTransaction { + pub payload: Transaction, pub transaction: BlindedTransaction, pub reveal: BlindedReveal, } @@ -443,7 +445,8 @@ impl BlindedTransaction { } pub fn fee_rate_size_bytes(&self) -> usize { - self.encrypted_size as usize + self.serialized_size_bytes() + .unwrap_or(self.encrypted_size as usize) } pub fn serialized_size_bytes(&self) -> Result<usize> { @@ -1706,6 +1709,7 @@ impl Ledger { &self.chain[0], &self.launch_profile, )?; + let mut active_blinded = BTreeMap::<String, ActiveBlindedTransaction>::new(); for block in self .chain .iter() @@ -1714,6 +1718,40 @@ impl Ledger { { apply_finalizer_ticket_effects(block, &mut tickets)?; tickets.extend(tickets_created_by_block(block, &self.launch_profile)?); + let mut revealed_transactions = Vec::new(); + for reveal in &block.blinded_reveals { + let active = active_blinded.get(&reveal.commitment).with_context(|| { + format!( + "block {} reveals unknown blinded transaction {}", + block.height, reveal.commitment + ) + })?; + let transaction = decrypt_blinded_transaction(&active.transaction, reveal)?; + if transaction.fee() != active.transaction.fee { + bail!( + "block {} blinded reveal fee does not match envelope", + block.height + ); + } + revealed_transactions.push(transaction); + active_blinded.remove(&reveal.commitment); + } + tickets.extend(tickets_created_by_transactions( + block.height, + &revealed_transactions, + &self.launch_profile, + )?); + active_blinded.retain(|_, active| block.height < active.transaction.expires_at_height); + for transaction in &block.blinded_transactions { + active_blinded.insert( + transaction.commitment.clone(), + ActiveBlindedTransaction { + transaction: transaction.clone(), + included_height: block.height, + included_by: block.miner.clone(), + }, + ); + } } Ok(ranked_tickets_for_height(parent, height, &tickets) .into_iter() @@ -1824,6 +1862,13 @@ impl Ledger { }) } + pub fn has_unrevealed_blinded_transaction(&self, commitment: &str) -> bool { + self.pending_blinded + .iter() + .any(|transaction| transaction.commitment == commitment) + || self.active_blinded.contains_key(commitment) + } + pub fn has_blinded_reveal(&self, commitment: &str) -> bool { self.pending_reveals .iter() @@ -2013,6 +2058,15 @@ impl Ledger { self.blind_transaction(transaction, fee, expires_at_height) } + pub fn build_blinded_transaction( + &self, + transaction: Transaction, + expires_at_height: u64, + ) -> Result<BuiltBlindedTransaction> { + let fee = transaction.fee(); + self.blind_transaction(transaction, fee, expires_at_height) + } + fn blind_transaction( &self, transaction: Transaction, @@ -2022,12 +2076,20 @@ impl Ledger { if expires_at_height <= self.height() { bail!("blinded transaction expiry must be in the future"); } + if expires_at_height + > self + .height() + .saturating_add(MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS) + { + bail!("blinded transaction expiry is too far in the future"); + } if fee != transaction.fee() { bail!("blinded transaction fee must match plaintext transaction fee"); } let plaintext = serde_json::to_vec(&transaction) .context("failed to serialize transaction for blinded payload")?; let payload_hash = hex_hash(&plaintext); + let payload = transaction; let mut key = [0_u8; BLINDED_KEY_BYTES]; let mut nonce = [0_u8; BLINDED_NONCE_BYTES]; getrandom(&mut key) @@ -2053,6 +2115,7 @@ impl Ledger { }; self.validate_blinded_transaction(&transaction)?; Ok(BuiltBlindedTransaction { + payload, transaction, reveal: BlindedReveal { commitment, @@ -2951,6 +3014,13 @@ impl Ledger { if transaction.expires_at_height <= self.height() { bail!("blinded transaction is expired"); } + if transaction.expires_at_height + > self + .height() + .saturating_add(MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS) + { + bail!("blinded transaction expiry is too far in the future"); + } let expected = blinded_transaction_commitment(transaction)?; if transaction.commitment != expected { bail!("blinded transaction commitment is invalid"); @@ -4835,6 +4905,23 @@ mod tests { } #[test] + fn blinded_fee_rate_size_uses_visible_envelope_bytes() { + let alice = Wallet::from_seed("blinded-fee-size-alice"); + let ledger = ledger_with_wallet_utxos(&alice, &[10]); + let built = ledger + .build_blinded_burn(&alice, 1, 2, ledger.height() + 4) + .unwrap(); + + assert_eq!( + built.transaction.fee_rate_size_bytes(), + built.transaction.serialized_size_bytes().unwrap() + ); + assert!( + built.transaction.fee_rate_size_bytes() > built.transaction.encrypted_size as usize + ); + } + + #[test] fn transfer_rejects_invalid_recipient_address() { let alice = Wallet::from_seed("invalid-transfer-recipient-alice"); let ledger = ledger_with_wallet_utxos(&alice, &[10]); @@ -5597,6 +5684,12 @@ mod tests { ledger.balance_of(carol.address()), before_carol - burn_amount - fee ); + assert!(ledger.tickets.iter().any(|ticket| { + ticket.owner == carol.address() + && ticket.amount == burn_amount + && ticket.eligible_from_height + == reveal_block.height + ledger.launch_profile.ticket_maturity_delay_heights + })); let reveal_plaintext_burn_spent_by_inclusion_finalizer = reveal_block .transactions .iter() @@ -5696,6 +5789,34 @@ mod tests { } #[test] + fn blinded_transaction_expiry_cannot_exceed_protocol_window() { + let alice = Wallet::from_seed("blinded-window-finalizer-alice"); + let bob = Wallet::from_seed("blinded-window-finalizer-bob"); + let carol = Wallet::from_seed("blinded-window-carol"); + let finalizers = [alice, bob]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + let max_expiry = ledger + .height() + .saturating_add(MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS); + + ledger.build_blinded_burn(&carol, 3, 7, max_expiry).unwrap(); + + let error = ledger + .build_blinded_burn(&carol, 3, 7, max_expiry + 1) + .unwrap_err(); + assert!(format!("{error:#}").contains("expiry is too far in the future")); + + let mut forged = ledger + .build_blinded_burn(&carol, 3, 7, max_expiry) + .unwrap() + .transaction; + forged.expires_at_height = max_expiry + 1; + forged.commitment = blinded_transaction_commitment(&forged).unwrap(); + let error = ledger.submit_blinded_transaction(forged).unwrap_err(); + assert!(format!("{error:#}").contains("expiry is too far in the future")); + } + + #[test] fn blinded_transaction_does_not_satisfy_plaintext_burn_requirement() { let alice = Wallet::from_seed("blinded-no-burn-finalizer-alice"); let bob = Wallet::from_seed("blinded-no-burn-finalizer-bob"); diff --git a/src/main.rs b/src/main.rs @@ -1326,8 +1326,8 @@ mod tests { connection .execute( r#" -INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_json, updated_at_ms) -VALUES (1, 4, 'bad-tip', '{"not":"a chain snapshot"}', 0) +INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_blob, updated_at_ms) +VALUES (1, 4, 'bad-tip', x'00010203', 0) "#, [], ) @@ -1342,7 +1342,7 @@ VALUES (1, 4, 'bad-tip', '{"not":"a chain snapshot"}', 0) .unwrap_err(); assert!( - format!("{error:#}").contains("failed to parse chain snapshot from database"), + format!("{error:#}").contains("failed to parse compact chain snapshot from database"), "{error:#}" ); } @@ -1372,7 +1372,8 @@ VALUES (1, 4, 'bad-tip', '{"not":"a chain snapshot"}', 0) )); { let mut node = node.lock().await; - node.burn(1).unwrap(); + let burn = node.ledger().build_burn(&wallet, 1, 0).unwrap(); + node.receive_transaction(burn).unwrap(); node.mine_one_at(1_000).unwrap(); } diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -12,7 +12,8 @@ use iuna::{ domain::{ Amount, BLOCK_REWARD, DEFAULT_FEE_PER_BYTE, FinalizerMode, GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_IUNA, MINE_REWARD, RECOVERY_BLOCK_DELAY_MS, - TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf, + TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, revealed_blinded_transactions, + run_vdf, verify_vdf, }, }; use tempfile::tempdir; @@ -75,6 +76,27 @@ fn submit_burn(ledger: &mut Ledger, wallet: &Wallet, amount: Amount) { ledger.submit_transaction(tx).unwrap(); } +fn queue_plaintext_burn( + node: &mut NodeCore, + wallet: &Wallet, + amount: Amount, +) -> iuna::domain::Transaction { + let tx = node.ledger().build_burn(wallet, amount, 0).unwrap(); + node.receive_transaction(tx.clone()).unwrap(); + tx +} + +fn queue_plaintext_transfer( + node: &mut NodeCore, + wallet: &Wallet, + to: impl Into<String>, + amount: Amount, +) -> iuna::domain::Transaction { + let tx = node.ledger().build_transfer(wallet, to, amount, 0).unwrap(); + node.receive_transaction(tx.clone()).unwrap(); + tx +} + fn burn_tx(ledger: &Ledger, wallet: &Wallet, amount: Amount) -> iuna::domain::Transaction { ledger.build_burn(wallet, amount, 0).unwrap() } @@ -219,7 +241,7 @@ fn burn_in_block_creates_ticket_after_maturity_delay() { let alice = Wallet::from_seed("alice"); let bob = Wallet::from_seed("bob"); let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(alice.address().to_string(), MICRO_IUNA); allocations.insert(bob.address().to_string(), MICRO_IUNA); let mut ledger = Ledger::new(allocations, 10); @@ -285,7 +307,7 @@ fn forged_transaction_is_rejected() { let alice = Wallet::from_seed("alice"); let bob = Wallet::from_seed("bob"); let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(alice.address().to_string(), MICRO_IUNA); allocations.insert(bob.address().to_string(), MICRO_IUNA); let mut ledger = Ledger::new(allocations, 10); @@ -302,7 +324,7 @@ fn forged_transaction_is_rejected() { fn block_with_forged_transaction_is_rejected() { let alice = Wallet::from_seed("alice"); let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(alice.address().to_string(), MICRO_IUNA); let mut ledger = Ledger::new(allocations, 10); submit_burn(&mut ledger, &alice, 1); @@ -545,7 +567,7 @@ fn block_without_mature_ticket_cannot_be_mined() { fn vdf_work_requires_at_least_one_pending_burn() { let alice = Wallet::from_seed("alice"); let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(alice.address().to_string(), MICRO_IUNA); let ledger = Ledger::new(allocations, 10); let error = ledger.prepare_next_block(alice.address(), 1).unwrap_err(); @@ -608,7 +630,7 @@ fn automatic_mining_burns_configured_amount_once_per_height() { assert!(second.burned.is_some()); let burned = second.burned.as_ref().unwrap(); assert_eq!(burned.amount(), iuna(25)); - assert!(burned.fee() >= burned.economic_size_bytes() as u64 * DEFAULT_FEE_PER_BYTE); + assert!(burned.fee() > burned.economic_size_bytes() as u64 * DEFAULT_FEE_PER_BYTE); } #[test] @@ -636,7 +658,7 @@ fn default_automatic_mining_does_not_burn() { fn burn_per_block_can_be_set_to_zero() { let alice = Wallet::from_seed("alice"); let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(alice.address().to_string(), MICRO_IUNA); let mut node = node("alice", alice, allocations); let burned = node.set_burn_per_block(25).unwrap(); @@ -676,7 +698,7 @@ fn automatic_mining_uses_configured_burn_fee() { let burned = node.set_automatic_burn(50, 3).unwrap().unwrap(); assert_eq!(burned.amount(), 50); - assert_eq!(burned.fee(), burned.economic_size_bytes() as u64 * 3); + assert!(burned.fee() > burned.economic_size_bytes() as u64 * 3); } #[test] @@ -697,7 +719,7 @@ fn automatic_mining_caps_burn_to_spendable_balance_after_fee() { let burned = outcome.burned.as_ref().unwrap(); let unspent = BLOCK_REWARD - burned.amount() - burned.fee(); assert!(unspent <= DEFAULT_FEE_PER_BYTE); - assert!(burned.fee() >= burned.economic_size_bytes() as u64 * DEFAULT_FEE_PER_BYTE); + assert!(burned.fee() > burned.economic_size_bytes() as u64 * DEFAULT_FEE_PER_BYTE); assert!(outcome.block.is_some()); assert_eq!( node.ledger().balance_of(alice.address()), @@ -706,11 +728,11 @@ fn automatic_mining_caps_burn_to_spendable_balance_after_fee() { } #[test] -fn setting_burn_rate_after_running_at_zero_adds_mempool_burn() { +fn setting_burn_rate_after_running_at_zero_prepares_private_anchor_burn() { let alice = Wallet::from_seed("alice"); let bob = Wallet::from_seed("bob"); let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(alice.address().to_string(), MICRO_IUNA); allocations.insert(bob.address().to_string(), MICRO_IUNA); let mut ledger = Ledger::new(allocations.clone(), 25); @@ -718,21 +740,21 @@ fn setting_burn_rate_after_running_at_zero_adds_mempool_burn() { let first = ledger.mine_next_block(&alice, 1).unwrap(); ledger.apply_block(first).unwrap(); - let mut bob_node = node("bob", bob.clone(), allocations); - bob_node + let mut alice_node = node("alice", alice.clone(), allocations); + alice_node .receive(iuna::app::GossipEnvelope::ChainSnapshot(ledger.snapshot())) .unwrap(); - let skipped = bob_node.automatic_mine_once(2); + let skipped = alice_node.automatic_mine_once(2); assert!(skipped.burned.is_none()); assert!(skipped.block.is_none()); - let burned = bob_node.set_burn_per_block(1).unwrap(); + let burned = alice_node.set_burn_per_block(1).unwrap(); assert!(burned.is_some()); - assert_eq!(bob_node.ledger().pending().len(), 1); - assert_eq!(bob_node.ledger().pending()[0].sender(), bob.address()); - assert_eq!(bob_node.ledger().pending()[0].amount(), 1); + assert!(alice_node.ledger().pending().is_empty()); + let outcome = alice_node.automatic_mine_once(2); + assert!(outcome.block.is_some()); } #[test] @@ -778,7 +800,7 @@ fn waiting_wallet_gossips_pending_burn_to_selected_leader() { ); let mut alice_node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 1); let mut bob_node = - NodeCore::from_ledger_with_burn_fee_and_enabled(bob.clone(), ledger, true, 1, MICRO_IUNA); + NodeCore::from_ledger_with_burn_fee_and_enabled(bob.clone(), ledger, true, 1, 1); let alice_outcome = alice_node.automatic_mine_once(1); assert!(alice_outcome.burned.is_some()); @@ -850,13 +872,11 @@ fn pow_only_node_gossips_mine_action_to_pob_only_finalizer() { alice_node.receive(envelope).unwrap(); } - assert!( - alice_node - .ledger() - .pending() - .iter() - .any(|pending| pending.signature() == mine.signature()), - "A did not receive B's mine action" + assert!(alice_node.ledger().pending().is_empty()); + assert_eq!(alice_node.ledger().pending_blinded_transactions().len(), 1); + assert_eq!( + alice_node.ledger().pending_blinded_transactions()[0].fee, + mine.fee() ); } @@ -984,7 +1004,7 @@ fn fallback_finalizer_unblocks_network_when_primary_does_not_publish() { NodeCore::from_ledger(fallback.clone(), ledger, DEFAULT_BURN_PER_BLOCK), ); - network.node_mut("fallback").unwrap().burn(1).unwrap(); + queue_plaintext_burn(network.node_mut("fallback").unwrap(), fallback, 1); let block = network .node_mut("fallback") .unwrap() @@ -1335,9 +1355,9 @@ fn in_memory_network_syncs_nodes_without_tcp() { network.insert("alice", node("alice", alice.clone(), allocations.clone())); network.insert("bob", node("bob", bob.clone(), allocations)); - network.node_mut("alice").unwrap().burn(10).unwrap(); + queue_plaintext_burn(network.node_mut("alice").unwrap(), &alice, 10); network.deliver_until_idle().unwrap(); - assert_eq!(network.node("bob").unwrap().ledger().pending().len(), 1); + assert!(network.node("bob").unwrap().ledger().pending().is_empty()); network.node_mut("alice").unwrap().mine_one().unwrap(); network.deliver_until_idle().unwrap(); @@ -1351,7 +1371,7 @@ fn in_memory_network_syncs_nodes_without_tcp() { #[test] fn in_memory_network_delivers_transaction_to_multiple_peers() { let wallets = wallets(&["alice", "bob", "carol", "dave"]); - let allocations = allocations(&wallets, 1_000); + let allocations = allocations(&wallets, MICRO_IUNA); let mut network = InMemoryNetwork::default(); for (name, wallet) in ["alice", "bob", "carol", "dave"] @@ -1365,16 +1385,23 @@ fn in_memory_network_delivers_transaction_to_multiple_peers() { network.deliver_until_idle().unwrap(); for name in ["bob", "carol", "dave"] { - let pending = network.node(name).unwrap().ledger().pending(); - assert_eq!(pending.len(), 1, "{name} did not receive alice's burn"); - assert_eq!(pending[0].amount(), 15); + let ledger = network.node(name).unwrap().ledger(); + assert!( + ledger.pending().is_empty(), + "{name} received a plaintext transaction" + ); + assert_eq!( + ledger.pending_blinded_transactions().len(), + 1, + "{name} did not receive alice's blinded burn" + ); } } #[test] fn in_memory_network_syncs_mined_block_to_multiple_peers() { let wallets = wallets(&["alice", "bob", "carol", "dave"]); - let allocations = allocations(&wallets, 1_000); + let allocations = allocations(&wallets, MICRO_IUNA); let mut network = InMemoryNetwork::default(); for (name, wallet) in ["alice", "bob", "carol", "dave"] @@ -1384,7 +1411,7 @@ fn in_memory_network_syncs_mined_block_to_multiple_peers() { network.insert(*name, node(name, wallet, allocations.clone())); } - network.node_mut("alice").unwrap().burn(10).unwrap(); + queue_plaintext_burn(network.node_mut("alice").unwrap(), &wallets[0], 10); network.deliver_until_idle().unwrap(); network.node_mut("alice").unwrap().mine_one().unwrap(); network.deliver_until_idle().unwrap(); @@ -1413,10 +1440,9 @@ fn in_memory_network_range_syncs_node_that_missed_multiple_blocks() { network.insert("bob", node("bob", wallets[1].clone(), allocations)); for height in 1..=5 { - network.node_mut("alice").unwrap().burn(1).unwrap(); - network - .node_mut("alice") - .unwrap() + let alice_node = network.node_mut("alice").unwrap(); + queue_plaintext_burn(alice_node, &alice, 1); + alice_node .mine_one_at(height * VDF_TARGET_BLOCK_MS) .unwrap(); } @@ -1433,7 +1459,7 @@ fn in_memory_network_range_syncs_node_that_missed_multiple_blocks() { assert_eq!(bob_status.tip_hash, alice_tip); network.deliver_until_idle().unwrap(); - network.node_mut("alice").unwrap().burn(1).unwrap(); + queue_plaintext_burn(network.node_mut("alice").unwrap(), &alice, 1); network.deliver_until_idle().unwrap(); network .node_mut("alice") @@ -1449,7 +1475,7 @@ fn in_memory_network_range_syncs_node_that_missed_multiple_blocks() { } #[test] -fn joined_nodes_import_transfer_block_and_every_wallet_mines() { +fn joined_nodes_import_transfer_block_and_blinded_burn_reveal() { let alice = Wallet::from_seed("flow-alice"); let bob = Wallet::from_seed("flow-bob"); let carol = Wallet::from_seed("flow-carol"); @@ -1470,7 +1496,7 @@ fn joined_nodes_import_transfer_block_and_every_wallet_mines() { let mut mined_by = Vec::new(); for height in 1..=2 { - network.node_mut("a").unwrap().burn(1).unwrap(); + queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); network.deliver_until_idle().unwrap(); let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap(); mined_by.push(block.miner.clone()); @@ -1484,7 +1510,7 @@ fn joined_nodes_import_transfer_block_and_every_wallet_mines() { ); for height in 3..=4 { - network.node_mut("a").unwrap().burn(1).unwrap(); + queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); network.deliver_until_idle().unwrap(); let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap(); mined_by.push(block.miner.clone()); @@ -1497,13 +1523,13 @@ fn joined_nodes_import_transfer_block_and_every_wallet_mines() { NodeCore::from_ledger(carol.clone(), carol_ledger, DEFAULT_BURN_PER_BLOCK), ); - network - .node_mut("a") - .unwrap() - .transfer_with_fee(bob.address(), iuna(30), 0) - .unwrap(); - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); + queue_plaintext_transfer( + network.node_mut("a").unwrap(), + &alice, + bob.address(), + iuna(30), + ); + queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); let block5 = network.node_mut("a").unwrap().mine_one_at(5).unwrap(); assert!( block5 @@ -1512,22 +1538,7 @@ fn joined_nodes_import_transfer_block_and_every_wallet_mines() { .any(|tx| tx.to() == Some(bob.address()) && tx.amount() == iuna(30)) ); mined_by.push(block5.miner.clone()); - let block5_outbox = network.node_mut("a").unwrap().drain_outbox(); - for envelope in &block5_outbox { - network - .node_mut("b") - .unwrap() - .receive(envelope.clone()) - .unwrap(); - } - assert_eq!(network.node("b").unwrap().ledger().status().height, 5); - assert_eq!(network.node("c").unwrap().ledger().status().height, 4); - let catchup_snapshot = network.node("a").unwrap().chain_snapshot(); - network - .node_mut("c") - .unwrap() - .import_chain_snapshot(catchup_snapshot) - .unwrap(); + network.deliver_until_idle().unwrap(); for id in ["a", "b", "c"] { assert_eq!( @@ -1542,90 +1553,77 @@ fn joined_nodes_import_transfer_block_and_every_wallet_mines() { ); } - network.node_mut("b").unwrap().burn(10).unwrap(); - network.deliver_until_idle().unwrap(); - let block6 = network.node_mut("a").unwrap().mine_one_at(6).unwrap(); - mined_by.push(block6.miner.clone()); - network.deliver_until_idle().unwrap(); - - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let block7 = network.node_mut("a").unwrap().mine_one_at(7).unwrap(); - mined_by.push(block7.miner.clone()); - network.deliver_until_idle().unwrap(); - - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let block8 = network.node_mut("a").unwrap().mine_one_at(8).unwrap(); - mined_by.push(block8.miner.clone()); - network.deliver_until_idle().unwrap(); - - network - .node_mut("b") - .unwrap() - .transfer_with_fee(carol.address(), iuna(10), 0) - .unwrap(); - network.node_mut("b").unwrap().burn(1).unwrap(); + let bob_burn = network.node_mut("b").unwrap().burn(10).unwrap(); network.deliver_until_idle().unwrap(); assert_eq!( network .node("a") .unwrap() .ledger() - .expected_leader_for_next_block(), - Some(bob.address().to_string()) + .pending_blinded_transactions() + .len(), + 1 ); - let block9 = network.node_mut("b").unwrap().mine_one_at(9).unwrap(); - mined_by.push(block9.miner.clone()); - network.deliver_until_idle().unwrap(); - - network.node_mut("c").unwrap().burn(5).unwrap(); - network.deliver_until_idle().unwrap(); - let block10 = network.node_mut("a").unwrap().mine_one_at(10).unwrap(); - mined_by.push(block10.miner.clone()); - network.deliver_until_idle().unwrap(); - network.node_mut("a").unwrap().burn(1).unwrap(); + queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); + let commit_block = network.node_mut("a").unwrap().mine_one_at(6).unwrap(); + assert_eq!(commit_block.blinded_transactions.len(), 1); + assert_eq!(commit_block.blinded_transactions[0].fee, bob_burn.fee()); + mined_by.push(commit_block.miner.clone()); network.deliver_until_idle().unwrap(); - let block11 = network.node_mut("a").unwrap().mine_one_at(11).unwrap(); - mined_by.push(block11.miner.clone()); - network.deliver_until_idle().unwrap(); - - network.node_mut("b").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let block12 = network.node_mut("b").unwrap().mine_one_at(12).unwrap(); - mined_by.push(block12.miner.clone()); - network.deliver_until_idle().unwrap(); - assert_eq!( network .node("a") .unwrap() .ledger() - .expected_leader_for_next_block(), - Some(carol.address().to_string()) + .pending_blinded_reveals() + .len(), + 1 ); - network.node_mut("c").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let block13 = network.node_mut("c").unwrap().mine_one_at(13).unwrap(); - mined_by.push(block13.miner.clone()); + + queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); + let reveal_block = network.node_mut("a").unwrap().mine_one_at(7).unwrap(); + assert_eq!(reveal_block.blinded_reveals.len(), 1); + mined_by.push(reveal_block.miner.clone()); network.deliver_until_idle().unwrap(); + let revealed = revealed_blinded_transactions(&network.node("a").unwrap().chain_snapshot()) + .unwrap() + .into_iter() + .filter(|revealed| revealed.height == 7) + .collect::<Vec<_>>(); + assert_eq!(revealed.len(), 1); + assert!(revealed[0].transaction.is_burn(), "{revealed:?}"); + assert_eq!(revealed[0].transaction.amount(), 10); + + for height in 8..=9 { + queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); + let block = network.node_mut("a").unwrap().mine_one_at(height).unwrap(); + mined_by.push(block.miner.clone()); + network.deliver_until_idle().unwrap(); + } let final_tip = network.node("a").unwrap().ledger().status().tip_hash; for id in ["a", "b", "c"] { - assert_eq!(network.node(id).unwrap().ledger().status().height, 13); + assert_eq!(network.node(id).unwrap().ledger().status().height, 9); assert_eq!( network.node(id).unwrap().ledger().status().tip_hash, final_tip ); } - for wallet in [&alice, &bob, &carol] { - assert!( - mined_by.iter().any(|miner| miner == wallet.address()), - "{} never mined", - wallet.address() - ); - } + let ranks = network + .node("a") + .unwrap() + .burn_leader_ranks_for_block(10) + .unwrap(); + assert!( + ranks.iter().any(|rank| rank.owner == bob.address() + && rank.amount == 10 + && rank.eligible_from_height == 10), + "alice={} bob={} ranks={ranks:?}", + alice.address(), + bob.address() + ); + assert!(mined_by.iter().all(|miner| miner == alice.address())); } #[test] @@ -1646,7 +1644,7 @@ fn persisted_joined_nodes_restart_and_keep_syncing_without_tcp() { NodeCore::from_ledger(alice.clone(), alice_ledger, DEFAULT_BURN_PER_BLOCK), ); - network.node_mut("a").unwrap().burn(1).unwrap(); + queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); network.node_mut("a").unwrap().mine_one_at(1).unwrap(); network.deliver_until_idle().unwrap(); @@ -1664,12 +1662,8 @@ fn persisted_joined_nodes_restart_and_keep_syncing_without_tcp() { network.node("a").unwrap().ledger().status().tip_hash ); - network - .node_mut("a") - .unwrap() - .transfer(bob.address(), 10) - .unwrap(); - network.node_mut("a").unwrap().burn(1).unwrap(); + queue_plaintext_transfer(network.node_mut("a").unwrap(), &alice, bob.address(), 10); + queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); network.deliver_until_idle().unwrap(); network.node_mut("a").unwrap().mine_one_at(2).unwrap(); network.deliver_until_idle().unwrap(); @@ -1706,36 +1700,11 @@ fn persisted_joined_nodes_restart_and_keep_syncing_without_tcp() { NodeCore::from_ledger(carol, carol_joined_ledger, DEFAULT_BURN_PER_BLOCK), ); - network.node_mut("b").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - network.node_mut("a").unwrap().mine_one_at(3).unwrap(); - network.deliver_until_idle().unwrap(); - - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - network.node_mut("a").unwrap().mine_one_at(4).unwrap(); - network.deliver_until_idle().unwrap(); - - network.node_mut("a").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - network.node_mut("a").unwrap().mine_one_at(5).unwrap(); - network.deliver_until_idle().unwrap(); - - assert_eq!( - network - .node("a") - .unwrap() - .ledger() - .expected_leader_for_next_block() - .as_deref(), - Some(bob.address()) - ); - - network.node_mut("b").unwrap().burn(1).unwrap(); - network.deliver_until_idle().unwrap(); - let bob_block = network.node_mut("b").unwrap().mine_one_at(6).unwrap(); - assert_eq!(bob_block.miner, bob.address()); - network.deliver_until_idle().unwrap(); + for height in 3..=6 { + queue_plaintext_burn(network.node_mut("a").unwrap(), &alice, 1); + network.node_mut("a").unwrap().mine_one_at(height).unwrap(); + network.deliver_until_idle().unwrap(); + } let final_status = network.node("a").unwrap().ledger().status(); for id in ["b", "c"] { @@ -1772,7 +1741,7 @@ fn mined_block_gossip_does_not_include_full_chain_snapshot() { let alice = Wallet::from_seed("alice"); let bob = Wallet::from_seed("bob"); let wallets = vec![alice.clone(), bob.clone()]; - let allocations = allocations(&wallets, 1_000); + let allocations = allocations(&wallets, MICRO_IUNA); let mut alice_node = NodeCore::new(NodeConfig { wallet: alice, @@ -1784,11 +1753,7 @@ fn mined_block_gossip_does_not_include_full_chain_snapshot() { let plan = alice_node.prepare_automatic_mining(1); let burn_outbox = alice_node.drain_outbox(); - assert_eq!(burn_outbox.len(), 1); - assert!(matches!( - burn_outbox[0], - iuna::app::GossipEnvelope::Transaction(_) - )); + assert!(burn_outbox.is_empty()); let work = plan.work.unwrap(); let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); @@ -1805,110 +1770,27 @@ fn mined_block_gossip_does_not_include_full_chain_snapshot() { } #[test] -fn received_transaction_is_rebroadcast_to_other_peers_without_networking() { - let names = ["alice", "bob", "carol"]; - let wallets = wallets(&names); - let allocations = allocations(&wallets, 1_000); - let alice = wallets[0].clone(); - let bob = wallets[1].clone(); - let carol = wallets[2].clone(); - - let mut carol_node = node("carol", carol, allocations.clone()); - let mut hub = node("alice", alice, allocations.clone()); - let mut bob_node = node("bob", bob, allocations); - - let tx = carol_node.burn(25).unwrap(); - carol_node.drain_outbox(); - - hub.receive(iuna::app::GossipEnvelope::Transaction(tx.clone())) - .unwrap(); - let forwarded = hub.drain_outbox(); - assert_eq!(forwarded.len(), 1); - assert!(matches!( - forwarded[0], - iuna::app::GossipEnvelope::Transaction(_) - )); - - for envelope in forwarded { - bob_node.receive(envelope).unwrap(); - } - assert!( - bob_node - .ledger() - .pending() - .iter() - .any(|pending| pending.signature() == tx.signature()) - ); - - hub.receive(iuna::app::GossipEnvelope::Transaction(tx)) - .unwrap(); - assert!(hub.drain_outbox().is_empty()); -} - -#[test] -fn mempool_gossip_repairs_future_nonce_gap_without_networking() { - let alice = Wallet::from_seed("alice"); - let bob = Wallet::from_seed("bob"); - let wallets = vec![alice.clone(), bob.clone()]; - let allocations = allocations(&wallets, 1_000); - let mut alice_node = node("alice", alice, allocations.clone()); - let mut bob_node = node("bob", bob, allocations); - - let first = alice_node.burn(1).unwrap(); - let second = alice_node.burn(1).unwrap(); - alice_node.drain_outbox(); - - bob_node - .receive(iuna::app::GossipEnvelope::Transaction(second.clone())) - .unwrap(); - assert_eq!(bob_node.ledger().pending().len(), 0); - assert_eq!(bob_node.ledger().orphan_transactions().len(), 1); - - let mut requests = Vec::new(); - for envelope in alice_node.mempool_gossip() { - match envelope { - iuna::app::GossipEnvelope::Inventory { txs, blocks } => { - requests.extend(bob_node.missing_inventory_requests(&txs, &blocks)); - } - other => bob_node.receive(other).unwrap(), - } - } - for request in requests { - match request { - iuna::app::GossipEnvelope::TransactionRequest { signatures } => { - bob_node - .receive(iuna::app::GossipEnvelope::Transactions { - transactions: alice_node.transactions_by_signature(&signatures), - }) - .unwrap(); - } - other => bob_node.receive(other).unwrap(), - } - } - assert_eq!(bob_node.ledger().pending().len(), 2); - assert!(bob_node.ledger().orphan_transactions().is_empty()); - let block = alice_node.mine_one_at(1).unwrap(); - let signatures = block - .transactions - .iter() - .map(|tx| tx.signature()) - .collect::<Vec<_>>(); - - assert!(signatures.contains(&first.signature())); - assert!(signatures.contains(&second.signature())); -} - -#[test] -fn mempool_gossip_splits_transaction_batches_at_receiver_limit() { +fn mempool_gossip_splits_blinded_batches_at_receiver_limit() { let alice = Wallet::from_seed("mempool-batch-alice"); let bob = Wallet::from_seed("mempool-batch-bob"); - let wallets = vec![alice.clone(), bob.clone()]; + let burn_wallets = (0..=TRANSACTION_BATCH_LIMIT) + .map(|index| Wallet::from_seed(&format!("mempool-batch-burn-{index}"))) + .collect::<Vec<_>>(); + let mut wallets = vec![alice.clone(), bob.clone()]; + wallets.extend(burn_wallets.clone()); let allocations = allocations(&wallets, 10_000); let mut alice_node = node("alice", alice, allocations.clone()); let mut bob_node = node("bob", bob, allocations); - for _ in 0..(TRANSACTION_BATCH_LIMIT + 1) { - alice_node.burn(1).unwrap(); + for wallet in &burn_wallets { + let tx = alice_node.ledger().build_burn(wallet, 1, 0).unwrap(); + let built = alice_node + .ledger() + .build_blinded_transaction(tx, 20) + .unwrap(); + alice_node + .receive_blinded_transaction(built.transaction) + .unwrap(); } alice_node.drain_outbox(); @@ -1917,11 +1799,11 @@ fn mempool_gossip_splits_transaction_batches_at_receiver_limit() { let total_transactions = gossip .iter() .map(|envelope| match envelope { - GossipEnvelope::Transactions { transactions } => { + GossipEnvelope::BlindedTransactions { transactions } => { assert!(transactions.len() <= TRANSACTION_BATCH_LIMIT); transactions.len() } - other => panic!("expected transaction batch, got {other:?}"), + other => panic!("expected blinded transaction batch, got {other:?}"), }) .sum::<usize>(); assert_eq!(total_transactions, TRANSACTION_BATCH_LIMIT + 1); @@ -1929,48 +1811,14 @@ fn mempool_gossip_splits_transaction_batches_at_receiver_limit() { for envelope in gossip { bob_node.receive(envelope).unwrap(); } + assert!(bob_node.ledger().pending().is_empty()); assert_eq!( - bob_node.ledger().pending().len(), + bob_node.ledger().pending_blinded_transactions().len(), TRANSACTION_BATCH_LIMIT + 1 ); } #[test] -fn peer_status_advertises_mempool_and_drives_missing_transaction_request() { - let alice = Wallet::from_seed("mempool-status-alice"); - let bob = Wallet::from_seed("mempool-status-bob"); - let wallets = vec![alice.clone(), bob.clone()]; - let allocations = allocations(&wallets, 1_000); - let mut alice_node = node("alice", alice, allocations.clone()); - let bob_node = node("bob", bob, allocations); - - let tx = alice_node.burn(1).unwrap(); - let signature = tx.signature().to_string(); - - let GossipEnvelope::PeerStatus { - mempool_count, - mempool_root, - mempool_txs, - .. - } = alice_node.peer_status() - else { - panic!("expected peer status"); - }; - - assert_eq!(mempool_count, 1); - assert!(!mempool_root.is_empty()); - assert_eq!(mempool_txs, vec![signature.clone()]); - - let requests = bob_node.missing_inventory_requests(&mempool_txs, &[]); - assert_eq!( - requests, - vec![GossipEnvelope::TransactionRequest { - signatures: vec![signature] - }] - ); -} - -#[test] fn received_block_is_rebroadcast_to_other_peers_without_networking() { let names = ["alice", "bob", "carol"]; let wallets = wallets(&names); @@ -1979,11 +1827,11 @@ fn received_block_is_rebroadcast_to_other_peers_without_networking() { let bob = wallets[1].clone(); let carol = wallets[2].clone(); - let mut miner = node("alice", alice, allocations.clone()); + let mut miner = node("alice", alice.clone(), allocations.clone()); let mut hub = node("bob", bob, allocations.clone()); let mut carol_node = node("carol", carol, allocations); - miner.burn(10).unwrap(); + queue_plaintext_burn(&mut miner, &alice, 10); miner.drain_outbox(); let block = miner.mine_one_at(1).unwrap(); miner.drain_outbox(); @@ -2014,15 +1862,15 @@ fn imported_snapshot_blocks_are_rebroadcast_without_networking() { let bob = Wallet::from_seed("bob"); let wallets = vec![alice.clone(), bob.clone()]; let allocations = allocations(&wallets, 1_000); - let mut miner = node("alice", alice, allocations.clone()); + let mut miner = node("alice", alice.clone(), allocations.clone()); let mut hub = node("bob", bob, allocations); - miner.burn(1).unwrap(); + queue_plaintext_burn(&mut miner, &alice, 1); miner.drain_outbox(); miner.mine_one_at(1).unwrap(); miner.drain_outbox(); - miner.burn(1).unwrap(); + queue_plaintext_burn(&mut miner, &alice, 1); miner.drain_outbox(); miner.mine_one_at(2).unwrap(); miner.drain_outbox(); @@ -2041,36 +1889,109 @@ fn imported_snapshot_blocks_are_rebroadcast_without_networking() { } #[test] -fn multiple_peers_can_contribute_burns_to_the_same_lottery_block() { +fn multiple_peers_can_contribute_blinded_burns_to_lottery_ranks() { + let finalizer = Wallet::from_seed("blinded-burn-ranks-finalizer"); let names = ["alice", "bob", "carol", "dave"]; let wallets = wallets(&names); - let allocations = allocations(&wallets, 1_000); + let mut all_wallets = vec![finalizer.clone()]; + all_wallets.extend(wallets.clone()); + let allocations = allocations(&all_wallets, 1_000); + let ledger = Ledger::new_with_genesis_burns( + allocations.clone(), + vec![GenesisBurn::new(finalizer.address(), 1)], + 25, + ) + .unwrap(); let mut network = InMemoryNetwork::default(); + network.insert( + "finalizer", + NodeCore::from_ledger(finalizer.clone(), ledger.clone(), DEFAULT_BURN_PER_BLOCK), + ); for (name, wallet) in names.iter().zip(wallets.clone()) { - network.insert(*name, node(name, wallet, allocations.clone())); + network.insert( + *name, + NodeCore::from_ledger(wallet, ledger.clone(), DEFAULT_BURN_PER_BLOCK), + ); } - for (name, amount) in names.iter().zip([10, 20, 30, 40]) { - network.node_mut(name).unwrap().burn(amount).unwrap(); + for ((name, wallet), amount) in names.iter().zip(wallets.iter()).zip([10, 20, 30, 40]) { + let tx = network.node_mut(name).unwrap().burn(amount).unwrap(); + assert!(tx.is_burn()); + assert_eq!(tx.sender(), wallet.address()); } network.deliver_until_idle().unwrap(); - network.node_mut("alice").unwrap().mine_one().unwrap(); + assert_eq!( + network + .node("alice") + .unwrap() + .ledger() + .pending_blinded_transactions() + .len(), + 4 + ); + + queue_plaintext_burn(network.node_mut("finalizer").unwrap(), &finalizer, 1); + let commit_block = network + .node_mut("finalizer") + .unwrap() + .mine_one_at(1) + .unwrap(); + assert_eq!(commit_block.blinded_transactions.len(), 4); network.deliver_until_idle().unwrap(); + assert_eq!( + network + .node("alice") + .unwrap() + .ledger() + .pending_blinded_reveals() + .len(), + 4 + ); + queue_plaintext_burn(network.node_mut("finalizer").unwrap(), &finalizer, 1); + let reveal_block = network + .node_mut("finalizer") + .unwrap() + .mine_one_at(2) + .unwrap(); + assert_eq!(reveal_block.blinded_reveals.len(), 4); + network.deliver_until_idle().unwrap(); + + for height in 3..=4 { + queue_plaintext_burn(network.node_mut("finalizer").unwrap(), &finalizer, 1); + network + .node_mut("finalizer") + .unwrap() + .mine_one_at(height) + .unwrap(); + network.deliver_until_idle().unwrap(); + } + + let final_tip = network + .node("finalizer") + .unwrap() + .ledger() + .status() + .tip_hash; for name in names { let ledger = network.node(name).unwrap().ledger(); - let block = &ledger.chain()[1]; - let burned = block - .transactions - .iter() - .filter(|tx| tx.is_burn()) - .map(|tx| tx.amount()) - .sum::<Amount>(); - - assert_eq!(block.transactions.len(), 4); - assert_eq!(burned, 100); - assert!(ledger.expected_leader_for_next_block().is_some()); + assert_eq!(ledger.status().height, 4); + assert_eq!(ledger.status().tip_hash, final_tip); + let ranks = network + .node(name) + .unwrap() + .burn_leader_ranks_for_block(5) + .unwrap(); + for (wallet, amount) in wallets.iter().zip([10, 20, 30, 40]) { + assert!( + ranks.iter().any(|rank| rank.owner == wallet.address() + && rank.amount == amount + && rank.eligible_from_height == 5), + "{name} missing revealed burn ticket for {} in {ranks:?}", + wallet.address() + ); + } } } @@ -2084,7 +2005,6 @@ fn peer_book_tracks_multiple_peers_without_networking() { peers.record_sent("127.0.0.1:9444", 2); peers.record_status("127.0.0.1:9444", 12, "tip-hash".to_string()); - peers.record_mempool_status("127.0.0.1:9444", 3, "mempool-root".to_string(), 2, 1); peers.record_error("127.0.0.1:9445", "connection refused"); peers.record_received("127.0.0.1:9555", 1); peers.record_inbound_error("127.0.0.1:56666", "invalid nonce"); @@ -2106,14 +2026,6 @@ fn peer_book_tracks_multiple_peers_without_networking() { assert_eq!(sent_peer.messages_sent, 2); assert_eq!(sent_peer.last_known_height, Some(12)); assert_eq!(sent_peer.last_known_tip_hash.as_deref(), Some("tip-hash")); - assert_eq!(sent_peer.last_known_mempool_count, Some(3)); - assert_eq!( - sent_peer.last_known_mempool_root.as_deref(), - Some("mempool-root") - ); - assert_eq!(sent_peer.last_known_mempool_shared, Some(2)); - assert_eq!(sent_peer.last_known_mempool_missing, Some(1)); - assert!(sent_peer.last_mempool_status_ms.is_some()); assert_eq!(sent_peer.last_error, None); assert!(sent_peer.last_contact_ms.is_some()); assert!(sent_peer.last_success_ms.is_some()); @@ -2278,7 +2190,7 @@ fn friend_node_can_join_snapshot_from_started_chain() { alice_node .set_automatic_burn_settings(true, DEFAULT_BURN_PER_BLOCK, DEFAULT_FEE_PER_BYTE) .unwrap(); - alice_node.burn(1).unwrap(); + queue_plaintext_burn(&mut alice_node, &alice, 1); alice_node.automatic_mine_once(1); let joined_ledger = Ledger::from_snapshot(alice_node.chain_snapshot()).unwrap(); @@ -2576,8 +2488,8 @@ fn node_receives_chain_snapshot_envelope_when_joining_without_tcp() { let wallets = vec![alice.clone(), bob.clone()]; let shared_genesis = allocations(&wallets, 1_000); - let mut alice_node = node("alice", alice, shared_genesis.clone()); - alice_node.burn(1).unwrap(); + let mut alice_node = node("alice", alice.clone(), shared_genesis.clone()); + queue_plaintext_burn(&mut alice_node, &alice, 1); alice_node.mine_one().unwrap(); let mut bob_node = node("bob", bob, shared_genesis); diff --git a/tests/properties.rs b/tests/properties.rs @@ -71,6 +71,33 @@ fn genesis_burns(wallets: &[Wallet], amount: Amount) -> Vec<GenesisBurn> { .collect() } +fn queue_plaintext_burn(node: &mut NodeCore, wallet: &Wallet, amount: Amount, fee: Amount) -> bool { + node.ledger() + .build_burn(wallet, amount, fee) + .and_then(|tx| node.receive_transaction(tx).map(|_| ())) + .is_ok() +} + +fn queue_plaintext_transfer( + node: &mut NodeCore, + wallet: &Wallet, + recipient: impl Into<String>, + amount: Amount, + fee: Amount, +) -> bool { + node.ledger() + .build_transfer(wallet, recipient, amount, fee) + .and_then(|tx| node.receive_transaction(tx).map(|_| ())) + .is_ok() +} + +fn queue_plaintext_mine(node: &mut NodeCore, wallet: &Wallet) -> bool { + node.ledger() + .build_mine(wallet.address()) + .and_then(|tx| node.receive_transaction(tx).map(|_| ())) + .is_ok() +} + fn property_ledger(seed: u64, wallet_count: usize) -> (Vec<Wallet>, Ledger) { let wallets = test_wallets(seed, wallet_count); let ledger = Ledger::new_with_genesis_burns( @@ -624,22 +651,27 @@ fn in_memory_network_converges_under_generated_node_actions() { match rng.index(4) { 0 => { - let _ = network - .node_mut(&node_id) - .expect("node exists") - .transfer_with_fee(recipient, rng.amount(2 * MICRO_IUNA), 0); + let _ = queue_plaintext_transfer( + network.node_mut(&node_id).expect("node exists"), + &wallets[node_index], + recipient, + rng.amount(2 * MICRO_IUNA), + 0, + ); } 1 => { - let _ = network - .node_mut(&node_id) - .expect("node exists") - .burn_with_fee(MICRO_IUNA, 0); + let _ = queue_plaintext_burn( + network.node_mut(&node_id).expect("node exists"), + &wallets[node_index], + MICRO_IUNA, + 0, + ); } 2 => { - let _ = network - .node_mut(&node_id) - .expect("node exists") - .mine_pow_reward(); + let _ = queue_plaintext_mine( + network.node_mut(&node_id).expect("node exists"), + &wallets[node_index], + ); } _ => {} } @@ -808,22 +840,27 @@ fn in_memory_network_converges_after_generated_offline_and_reordered_delivery() let recipient = wallets[rng.index(wallets.len())].address().to_string(); match rng.index(3) { 0 => { - let _ = network - .node_mut(&actor_id) - .expect("actor node exists") - .transfer_with_fee(recipient, MICRO_IUNA + rng.amount(17), 0); + let _ = queue_plaintext_transfer( + network.node_mut(&actor_id).expect("actor node exists"), + &wallets[actor_index], + recipient, + MICRO_IUNA + rng.amount(17), + 0, + ); } 1 => { - let _ = network - .node_mut(&actor_id) - .expect("actor node exists") - .mine_pow_reward(); + let _ = queue_plaintext_mine( + network.node_mut(&actor_id).expect("actor node exists"), + &wallets[actor_index], + ); } _ => { - let _ = network - .node_mut("n0") - .expect("finalizer node exists") - .burn_with_fee(MICRO_IUNA, 0); + let _ = queue_plaintext_burn( + network.node_mut("n0").expect("finalizer node exists"), + &wallets[0], + MICRO_IUNA, + 0, + ); } }