iuna

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

commit 71325b1bbad0f2ead41b859cf0b3172e71a7e8b4
parent 43044bbba40e6075b9eb5ace2e3545a211de1ce7
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 11 Aug 2026 06:12:29 +0200

Split large modules

Diffstat:
MCargo.lock | 2+-
MCargo.toml | 2+-
Msrc/adapters/chain_store.rs | 1064+------------------------------------------------------------------------------
Asrc/adapters/chain_store/compact.rs | 590+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/chain_store/tests.rs | 465+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/adapters/http.rs | 6701++-----------------------------------------------------------------------------
Asrc/adapters/http/actions.rs | 461+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/http/api.rs | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/http/auth.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/http/auth_routes.rs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/http/index_html.rs | 1463+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/http/metrics.rs | 277+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/http/request_auth.rs | 316+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/http/state.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/http/static_assets.rs | 37+++++++++++++++++++++++++++++++++++++
Asrc/adapters/http/tests.rs | 1972+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/http/types.rs | 378+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/http/ui.rs | 876+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/http/wallet.rs | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/http/wallet_persistence.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/adapters/p2p.rs | 4566+------------------------------------------------------------------------------
Asrc/adapters/p2p/fetch.rs | 265+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/handshake.rs | 585+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/identity.rs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/inbound_limiter.rs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/line_codec.rs | 401+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/metrics.rs | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/network.rs | 388+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/peer_addr.rs | 249+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/peer_status.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/process.rs | 326+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/session.rs | 411+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/sync.rs | 476+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/test_support.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/tests.rs | 890+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/adapters/p2p/writer.rs | 33+++++++++++++++++++++++++++++++++
Msrc/app.rs | 4580++++---------------------------------------------------------------------------
Asrc/app/automatic_mining.rs | 434+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/automatic_mining/pow.rs | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/automatic_mining/tests.rs | 529+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/gossip.rs | 242+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/helpers.rs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/in_memory_network.rs | 664+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/ledger_view.rs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/node_lifecycle.rs | 142+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/owned_blinded.rs | 461+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/peer_book.rs | 397+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/receive.rs | 401+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/status.rs | 210+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/types.rs | 174+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/app/wallet.rs | 560+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/cli.rs | 269+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/domain.rs | 9154++-----------------------------------------------------------------------------
Asrc/domain/blinded.rs | 372+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/block.rs | 429+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/fork.rs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/genesis.rs | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/hex.rs | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/history.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/ledger_apply.rs | 397+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/ledger_builders.rs | 383+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/ledger_chain.rs | 335+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/ledger_consensus.rs | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/ledger_mempool.rs | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/ledger_ops.rs | 642+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/ledger_pending.rs | 602+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/ledger_prepare.rs | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/ledger_queries.rs | 377+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/ledger_reveal.rs | 261+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/ledger_state.rs | 35+++++++++++++++++++++++++++++++++++
Asrc/domain/mine_policy.rs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/mining.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/profile.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/protocol.rs | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/reveal.rs | 276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/selection.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/stratum.rs | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/tests.rs | 2898+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/ticket.rs | 288+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/transaction.rs | 611+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/validation.rs | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/vdf.rs | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/domain/wallet.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main.rs | 919++-----------------------------------------------------------------------------
Asrc/main_tests.rs | 648+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
85 files changed, 28023 insertions(+), 26504 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "iuna" -version = "0.2.42" +version = "0.2.43" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iuna" -version = "0.2.42" +version = "0.2.43" edition = "2024" license = "Apache-2.0" diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -5,20 +5,21 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result}; use rusqlite::{Connection, OptionalExtension, params}; use serde::Serialize; use crate::domain::{ AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT, Amount, BLINDED_COMMITTER_FEE_BPS, - BLINDED_FEE_BPS_DENOMINATOR, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal, - BlindedTransaction, Block, ChainSnapshot, FinalizerMode, LaunchProfile, LeaderProof, Ledger, - MINE_REWARD, MaskedBlindedReveal, OutPoint, REVEAL_COMMITTEE_SIZE, RevealBundleSection, - RevealBundleSignature, Transaction, TxInput, TxOutput, blinded_reveal_finalizer_fee, hex_hash, - revealed_blinded_transactions, + BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedTransaction, Block, ChainSnapshot, Ledger, + MINE_REWARD, OutPoint, REVEAL_COMMITTEE_SIZE, Transaction, TxInput, TxOutput, + blinded_reveal_finalizer_fee, hex_hash, revealed_blinded_transactions, }; +mod compact; +use compact::{blinded_fee_share, decode_compact_snapshot, encode_compact_snapshot}; + const SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS chain_snapshots ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -338,589 +339,6 @@ fn clear_metrics_in_transaction(transaction: &rusqlite::Transaction<'_>) -> Resu Ok(()) } -const COMPACT_SNAPSHOT_MAGIC: &[u8] = b"IUNA-SNAPSHOT"; -const COMPACT_SNAPSHOT_VERSION: u8 = 3; - -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)?; - } - encode_reveal_bundle_section(writer, &block.reveal_bundle_section)?; - 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 reveal_bundle_section = decode_reveal_bundle_section(reader)?; - 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, - reveal_bundle_section, - transactions, - hash, - }) -} - -fn encode_blinded_transaction( - writer: &mut CompactWriter, - transaction: &BlindedTransaction, -) -> Result<()> { - writer.hex(&transaction.commitment)?; - encode_inputs(writer, &transaction.inputs)?; - 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()?, - inputs: decode_inputs(reader)?, - 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 blinded_fee_share(fee: Amount, bps: u64) -> Amount { - ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount -} - -fn encode_reveal_bundle_section( - writer: &mut CompactWriter, - section: &RevealBundleSection, -) -> Result<()> { - writer.varint(section.signatures.len() as u64); - for signature in &section.signatures { - writer.varint(u64::from(signature.slot)); - writer.hex(&signature.member)?; - writer.hex(&signature.signature)?; - } - writer.varint(section.reveals.len() as u64); - for masked in &section.reveals { - encode_blinded_reveal(writer, &masked.reveal)?; - writer.u8(masked.bundle_mask); - } - Ok(()) -} - -fn decode_reveal_bundle_section(reader: &mut CompactReader<'_>) -> Result<RevealBundleSection> { - let signatures = decode_vec(reader, |reader| { - Ok(RevealBundleSignature { - slot: u8::try_from(reader.varint()?).context("reveal bundle slot does not fit u8")?, - member: reader.hex()?, - signature: reader.hex()?, - }) - })?; - let reveals = decode_vec(reader, |reader| { - Ok(MaskedBlindedReveal { - reveal: decode_blinded_reveal(reader)?, - bundle_mask: reader.u8()?, - }) - })?; - Ok(RevealBundleSection { - signatures, - reveals, - }) -} - -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")?; @@ -1403,470 +821,4 @@ fn unix_ms() -> u64 { } #[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use rusqlite::Connection; - use tempfile::tempdir; - - use crate::domain::{BLOCK_REWARD, GenesisBurn, Ledger, Wallet, run_vdf}; - - use super::{ - BlockMetricRow, SqliteChainStore, decode_compact_snapshot, encode_compact_snapshot, - replace_metrics, - }; - - #[test] - fn sqlite_chain_store_roundtrips_snapshot() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("nested/chain.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 1); - let ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - - 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.clone()) - .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(); - assert_eq!( - block.blinded_transactions, - vec![blinded.transaction.clone()] - ); - 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 bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - let wallet = wallets - .iter() - .find(|wallet| wallet.address() == member.owner) - .unwrap(); - ledger.build_reveal_bundle(wallet).unwrap() - }) - .collect::<Vec<_>>(); - assert!(!bundles.is_empty()); - let prepared = ledger - .prepare_next_block_with_reveal_bundles(wallet.address(), 2, bundles) - .unwrap(); - let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); - let block = prepared.finish(wallet, vdf_output); - assert_eq!(block.all_blinded_reveals().len(), 1); - 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] - fn sqlite_chain_store_overwrites_latest_snapshot() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 2); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - store.save(&ledger.snapshot()).unwrap(); - - let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(&wallet, 1_000).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - store.save(&ledger.snapshot()).unwrap(); - - let restored = store.load().unwrap().unwrap(); - assert_eq!(restored.blocks.last().unwrap().height, 1); - assert_eq!(restored, ledger.snapshot()); - } - - #[test] - fn sqlite_chain_store_saves_and_clears_block_metrics() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("metrics-alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 10); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - let burn = ledger.build_burn(&wallet, 2, 1).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(&wallet, 1_000).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - - store.save_with_metrics(&ledger.snapshot(), true).unwrap(); - let metrics = store.load_metrics().unwrap(); - - assert_eq!(metrics.last().unwrap().height, 1); - assert_eq!(metrics.last().unwrap().burn_count, 1); - assert_eq!(metrics.last().unwrap().burned_amount, 2); - assert_eq!(metrics.last().unwrap().fees_amount, 1); - assert_eq!( - metrics.last().unwrap().circulating_supply, - ledger.status().balances.values().copied().sum::<u64>() - ); - assert_eq!(metrics.last().unwrap().known_wallet_addresses, 1); - - store.clear_metrics().unwrap(); - assert!(store.load_metrics().unwrap().is_empty()); - } - - #[test] - fn sqlite_chain_store_migrates_known_wallet_address_metrics_column() { - let dir = tempdir().unwrap(); - let path = dir.path().join("chain.sqlite3"); - let connection = Connection::open(&path).unwrap(); - connection - .execute_batch( - r#" -CREATE TABLE block_metrics ( - height INTEGER PRIMARY KEY, - block_hash TEXT NOT NULL, - timestamp_ms INTEGER NOT NULL, - block_time_ms INTEGER, - mine_difficulty_bits INTEGER NOT NULL, - circulating_supply INTEGER NOT NULL, - transaction_count INTEGER NOT NULL, - transfer_count INTEGER NOT NULL, - burn_count INTEGER NOT NULL, - mine_count INTEGER NOT NULL, - burned_amount INTEGER NOT NULL, - total_burned_amount INTEGER NOT NULL, - fees_amount INTEGER NOT NULL, - reward_amount INTEGER NOT NULL, - vdf_rounds INTEGER NOT NULL, - finalizer_rank INTEGER NOT NULL -); -"#, - ) - .unwrap(); - drop(connection); - - let store = SqliteChainStore::open(&path).unwrap(); - - store - .with_connection(|connection| { - let count = connection - .query_row( - "SELECT COUNT(*) FROM pragma_table_info('block_metrics') WHERE name = 'known_wallet_addresses'", - [], - |row| row.get::<_, u64>(0), - ) - .unwrap(); - assert_eq!(count, 1); - Ok(()) - }) - .unwrap(); - } - - #[test] - fn sqlite_chain_store_metrics_supply_matches_wallet_balances_after_block_rewards() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - let alice = Wallet::from_seed("metrics-supply-alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 100); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1) - .unwrap(); - - for timestamp_ms in [1_000, 2_000, 3_000] { - let burn = ledger.build_burn(&alice, 1, 1).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(&alice, timestamp_ms).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - } - - store.save_with_metrics(&ledger.snapshot(), true).unwrap(); - let metrics = store.load_metrics().unwrap(); - let supply_from_balances = ledger.status().balances.values().copied().sum::<u64>(); - - assert_eq!(metrics.last().unwrap().height, 3); - assert_eq!( - metrics.last().unwrap().circulating_supply, - supply_from_balances - ); - } - - #[test] - fn sqlite_chain_store_metrics_count_known_wallet_addresses_seen_on_chain() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - let alice = Wallet::from_seed("metrics-address-alice"); - let bob = Wallet::from_seed("metrics-address-bob"); - let carol = Wallet::from_seed("metrics-address-carol"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 100); - let mut ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1) - .unwrap(); - - let burn = ledger.build_burn(&alice, 1, 1).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let transfer = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap(); - ledger.submit_transaction(transfer).unwrap(); - let mine = ledger.build_mine(carol.address()).unwrap(); - ledger.submit_transaction(mine).unwrap(); - let block = ledger.mine_next_block(&alice, 1_000).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - - store.save_with_metrics(&ledger.snapshot(), true).unwrap(); - let metrics = store.load_metrics().unwrap(); - - assert_eq!(metrics[0].known_wallet_addresses, 1); - assert_eq!(metrics.last().unwrap().known_wallet_addresses, 3); - } - - #[test] - fn sqlite_chain_store_metrics_include_revealed_blinded_burns() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - let alice = Wallet::from_seed("metrics-blinded-alice"); - let bob = Wallet::from_seed("metrics-blinded-bob"); - let carol = Wallet::from_seed("metrics-blinded-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(); - let supply_after_commit = ledger.status().balances.values().copied().sum::<u64>(); - 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 bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - let wallet = wallets - .iter() - .find(|wallet| wallet.address() == member.owner) - .unwrap(); - ledger.build_reveal_bundle(wallet).unwrap() - }) - .collect::<Vec<_>>(); - assert!(!bundles.is_empty()); - let prepared = ledger - .prepare_next_block_with_reveal_bundles(wallet.address(), 2, bundles) - .unwrap(); - let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); - let block = prepared.finish(wallet, vdf_output); - assert_eq!(block.all_blinded_reveals().len(), 1); - ledger.apply_locally_mined_block(block).unwrap(); - - store.save_with_metrics(&ledger.snapshot(), true).unwrap(); - let metrics = store.load_metrics().unwrap(); - let commit = metrics - .iter() - .find(|metric| metric.height == 1) - .expect("commit block metrics should exist"); - let last = metrics.last().unwrap(); - let supply_from_balances = ledger.status().balances.values().copied().sum::<u64>(); - - assert_eq!(commit.circulating_supply, supply_after_commit + 10_000_000); - assert_eq!(last.burn_count, 2); - assert_eq!(last.burned_amount, 4); - assert_eq!(last.fees_amount, 7); - assert_eq!(last.circulating_supply, supply_from_balances); - assert_eq!(last.known_wallet_addresses, 3); - } - - #[test] - fn sqlite_chain_store_roundtrips_vdf_round_metrics_above_legacy_u32_limit() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - let vdf_rounds = u64::from(u32::MAX) + 42; - - store - .with_connection_mut(|connection| { - let transaction = connection.transaction().unwrap(); - replace_metrics( - &transaction, - &[BlockMetricRow { - height: 1, - block_hash: "hash".to_string(), - timestamp_ms: 1_000, - block_time_ms: Some(415_000), - mine_difficulty_bits: 12, - circulating_supply: 100, - known_wallet_addresses: 1, - transaction_count: 0, - transfer_count: 0, - burn_count: 0, - mine_count: 0, - burned_amount: 0, - total_burned_amount: 0, - fees_amount: 0, - reward_amount: 0, - vdf_rounds, - finalizer_rank: 0, - }], - )?; - transaction.commit().unwrap(); - Ok(()) - }) - .unwrap(); - - let metrics = store.load_metrics().unwrap(); - assert_eq!(metrics.len(), 1); - assert_eq!(metrics[0].vdf_rounds, vdf_rounds); - } - - #[test] - fn sqlite_chain_store_metrics_include_genesis_reward_when_burn_consumes_allocation() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("metrics-genesis-reward"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 1); - let ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - - store.save_with_metrics(&ledger.snapshot(), true).unwrap(); - let metrics = store.load_metrics().unwrap(); - - assert_eq!(metrics.len(), 1); - assert_eq!(metrics[0].height, 0); - assert_eq!(metrics[0].burned_amount, 1); - assert_eq!(metrics[0].reward_amount, BLOCK_REWARD); - assert_eq!(metrics[0].circulating_supply, BLOCK_REWARD); - } - - #[test] - fn sqlite_chain_store_disabled_metrics_save_deletes_old_metrics() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("metrics-cleanup"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 10); - let ledger = - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap(); - - store.save_with_metrics(&ledger.snapshot(), true).unwrap(); - assert!(!store.load_metrics().unwrap().is_empty()); - - store.save_with_metrics(&ledger.snapshot(), false).unwrap(); - assert!(store.load_metrics().unwrap().is_empty()); - } - - #[test] - 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_blob, updated_at_ms) -VALUES (1, 9, 'bad-tip', x'00010203', 0) -"#, - [], - )?; - Ok(()) - }) - .unwrap(); - - let error = store.load().unwrap_err(); - - assert!( - format!("{error:#}").contains("failed to parse compact chain snapshot from database"), - "{error:#}" - ); - } -} +mod tests; diff --git a/src/adapters/chain_store/compact.rs b/src/adapters/chain_store/compact.rs @@ -0,0 +1,590 @@ +use anyhow::{Context, Result, bail}; + +use crate::domain::{ + Amount, BLINDED_FEE_BPS_DENOMINATOR, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, + FinalizerMode, LaunchProfile, LeaderProof, MaskedBlindedReveal, OutPoint, RevealBundleSection, + RevealBundleSignature, Transaction, TxInput, TxOutput, +}; + +const COMPACT_SNAPSHOT_MAGIC: &[u8] = b"IUNA-SNAPSHOT"; +const COMPACT_SNAPSHOT_VERSION: u8 = 3; + +pub(super) 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()) +} + +pub(super) 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)?; + } + encode_reveal_bundle_section(writer, &block.reveal_bundle_section)?; + 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 reveal_bundle_section = decode_reveal_bundle_section(reader)?; + 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, + reveal_bundle_section, + transactions, + hash, + }) +} + +fn encode_blinded_transaction( + writer: &mut CompactWriter, + transaction: &BlindedTransaction, +) -> Result<()> { + writer.hex(&transaction.commitment)?; + encode_inputs(writer, &transaction.inputs)?; + 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()?, + inputs: decode_inputs(reader)?, + 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()?, + }) +} + +pub(super) fn blinded_fee_share(fee: Amount, bps: u64) -> Amount { + ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount +} + +fn encode_reveal_bundle_section( + writer: &mut CompactWriter, + section: &RevealBundleSection, +) -> Result<()> { + writer.varint(section.signatures.len() as u64); + for signature in &section.signatures { + writer.varint(u64::from(signature.slot)); + writer.hex(&signature.member)?; + writer.hex(&signature.signature)?; + } + writer.varint(section.reveals.len() as u64); + for masked in &section.reveals { + encode_blinded_reveal(writer, &masked.reveal)?; + writer.u8(masked.bundle_mask); + } + Ok(()) +} + +fn decode_reveal_bundle_section(reader: &mut CompactReader<'_>) -> Result<RevealBundleSection> { + let signatures = decode_vec(reader, |reader| { + Ok(RevealBundleSignature { + slot: u8::try_from(reader.varint()?).context("reveal bundle slot does not fit u8")?, + member: reader.hex()?, + signature: reader.hex()?, + }) + })?; + let reveals = decode_vec(reader, |reader| { + Ok(MaskedBlindedReveal { + reveal: decode_blinded_reveal(reader)?, + bundle_mask: reader.u8()?, + }) + })?; + Ok(RevealBundleSection { + signatures, + reveals, + }) +} + +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() +} diff --git a/src/adapters/chain_store/tests.rs b/src/adapters/chain_store/tests.rs @@ -0,0 +1,465 @@ +use std::collections::BTreeMap; + +use rusqlite::Connection; +use tempfile::tempdir; + +use crate::domain::{BLOCK_REWARD, GenesisBurn, Ledger, Wallet, run_vdf}; + +use super::{ + BlockMetricRow, SqliteChainStore, decode_compact_snapshot, encode_compact_snapshot, + replace_metrics, +}; + +#[test] +fn sqlite_chain_store_roundtrips_snapshot() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("nested/chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("alice"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 1); + let ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) + .unwrap(); + + 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.clone()) + .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(); + assert_eq!( + block.blinded_transactions, + vec![blinded.transaction.clone()] + ); + 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 bundles = ledger + .reveal_committee_for_next_block() + .into_iter() + .filter_map(|member| { + let wallet = wallets + .iter() + .find(|wallet| wallet.address() == member.owner) + .unwrap(); + ledger.build_reveal_bundle(wallet).unwrap() + }) + .collect::<Vec<_>>(); + assert!(!bundles.is_empty()); + let prepared = ledger + .prepare_next_block_with_reveal_bundles(wallet.address(), 2, bundles) + .unwrap(); + let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); + let block = prepared.finish(wallet, vdf_output); + assert_eq!(block.all_blinded_reveals().len(), 1); + 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] +fn sqlite_chain_store_overwrites_latest_snapshot() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("alice"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 2); + let mut ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) + .unwrap(); + store.save(&ledger.snapshot()).unwrap(); + + let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(&wallet, 1_000).unwrap(); + ledger.apply_locally_mined_block(block).unwrap(); + store.save(&ledger.snapshot()).unwrap(); + + let restored = store.load().unwrap().unwrap(); + assert_eq!(restored.blocks.last().unwrap().height, 1); + assert_eq!(restored, ledger.snapshot()); +} + +#[test] +fn sqlite_chain_store_saves_and_clears_block_metrics() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("metrics-alice"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 10); + let mut ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) + .unwrap(); + let burn = ledger.build_burn(&wallet, 2, 1).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(&wallet, 1_000).unwrap(); + ledger.apply_locally_mined_block(block).unwrap(); + + store.save_with_metrics(&ledger.snapshot(), true).unwrap(); + let metrics = store.load_metrics().unwrap(); + + assert_eq!(metrics.last().unwrap().height, 1); + assert_eq!(metrics.last().unwrap().burn_count, 1); + assert_eq!(metrics.last().unwrap().burned_amount, 2); + assert_eq!(metrics.last().unwrap().fees_amount, 1); + assert_eq!( + metrics.last().unwrap().circulating_supply, + ledger.status().balances.values().copied().sum::<u64>() + ); + assert_eq!(metrics.last().unwrap().known_wallet_addresses, 1); + + store.clear_metrics().unwrap(); + assert!(store.load_metrics().unwrap().is_empty()); +} + +#[test] +fn sqlite_chain_store_migrates_known_wallet_address_metrics_column() { + let dir = tempdir().unwrap(); + let path = dir.path().join("chain.sqlite3"); + let connection = Connection::open(&path).unwrap(); + connection + .execute_batch( + r#" +CREATE TABLE block_metrics ( + height INTEGER PRIMARY KEY, + block_hash TEXT NOT NULL, + timestamp_ms INTEGER NOT NULL, + block_time_ms INTEGER, + mine_difficulty_bits INTEGER NOT NULL, + circulating_supply INTEGER NOT NULL, + transaction_count INTEGER NOT NULL, + transfer_count INTEGER NOT NULL, + burn_count INTEGER NOT NULL, + mine_count INTEGER NOT NULL, + burned_amount INTEGER NOT NULL, + total_burned_amount INTEGER NOT NULL, + fees_amount INTEGER NOT NULL, + reward_amount INTEGER NOT NULL, + vdf_rounds INTEGER NOT NULL, + finalizer_rank INTEGER NOT NULL +); +"#, + ) + .unwrap(); + drop(connection); + + let store = SqliteChainStore::open(&path).unwrap(); + + store + .with_connection(|connection| { + let count = connection + .query_row( + "SELECT COUNT(*) FROM pragma_table_info('block_metrics') WHERE name = 'known_wallet_addresses'", + [], + |row| row.get::<_, u64>(0), + ) + .unwrap(); + assert_eq!(count, 1); + Ok(()) + }) + .unwrap(); +} + +#[test] +fn sqlite_chain_store_metrics_supply_matches_wallet_balances_after_block_rewards() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let alice = Wallet::from_seed("metrics-supply-alice"); + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), 100); + let mut ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1) + .unwrap(); + + for timestamp_ms in [1_000, 2_000, 3_000] { + let burn = ledger.build_burn(&alice, 1, 1).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(&alice, timestamp_ms).unwrap(); + ledger.apply_locally_mined_block(block).unwrap(); + } + + store.save_with_metrics(&ledger.snapshot(), true).unwrap(); + let metrics = store.load_metrics().unwrap(); + let supply_from_balances = ledger.status().balances.values().copied().sum::<u64>(); + + assert_eq!(metrics.last().unwrap().height, 3); + assert_eq!( + metrics.last().unwrap().circulating_supply, + supply_from_balances + ); +} + +#[test] +fn sqlite_chain_store_metrics_count_known_wallet_addresses_seen_on_chain() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let alice = Wallet::from_seed("metrics-address-alice"); + let bob = Wallet::from_seed("metrics-address-bob"); + let carol = Wallet::from_seed("metrics-address-carol"); + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), 100); + let mut ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 1) + .unwrap(); + + let burn = ledger.build_burn(&alice, 1, 1).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let transfer = ledger.build_transfer(&alice, bob.address(), 10, 1).unwrap(); + ledger.submit_transaction(transfer).unwrap(); + let mine = ledger.build_mine(carol.address()).unwrap(); + ledger.submit_transaction(mine).unwrap(); + let block = ledger.mine_next_block(&alice, 1_000).unwrap(); + ledger.apply_locally_mined_block(block).unwrap(); + + store.save_with_metrics(&ledger.snapshot(), true).unwrap(); + let metrics = store.load_metrics().unwrap(); + + assert_eq!(metrics[0].known_wallet_addresses, 1); + assert_eq!(metrics.last().unwrap().known_wallet_addresses, 3); +} + +#[test] +fn sqlite_chain_store_metrics_include_revealed_blinded_burns() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let alice = Wallet::from_seed("metrics-blinded-alice"); + let bob = Wallet::from_seed("metrics-blinded-bob"); + let carol = Wallet::from_seed("metrics-blinded-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(); + let supply_after_commit = ledger.status().balances.values().copied().sum::<u64>(); + 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 bundles = ledger + .reveal_committee_for_next_block() + .into_iter() + .filter_map(|member| { + let wallet = wallets + .iter() + .find(|wallet| wallet.address() == member.owner) + .unwrap(); + ledger.build_reveal_bundle(wallet).unwrap() + }) + .collect::<Vec<_>>(); + assert!(!bundles.is_empty()); + let prepared = ledger + .prepare_next_block_with_reveal_bundles(wallet.address(), 2, bundles) + .unwrap(); + let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); + let block = prepared.finish(wallet, vdf_output); + assert_eq!(block.all_blinded_reveals().len(), 1); + ledger.apply_locally_mined_block(block).unwrap(); + + store.save_with_metrics(&ledger.snapshot(), true).unwrap(); + let metrics = store.load_metrics().unwrap(); + let commit = metrics + .iter() + .find(|metric| metric.height == 1) + .expect("commit block metrics should exist"); + let last = metrics.last().unwrap(); + let supply_from_balances = ledger.status().balances.values().copied().sum::<u64>(); + + assert_eq!(commit.circulating_supply, supply_after_commit + 10_000_000); + assert_eq!(last.burn_count, 2); + assert_eq!(last.burned_amount, 4); + assert_eq!(last.fees_amount, 7); + assert_eq!(last.circulating_supply, supply_from_balances); + assert_eq!(last.known_wallet_addresses, 3); +} + +#[test] +fn sqlite_chain_store_roundtrips_vdf_round_metrics_above_legacy_u32_limit() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let vdf_rounds = u64::from(u32::MAX) + 42; + + store + .with_connection_mut(|connection| { + let transaction = connection.transaction().unwrap(); + replace_metrics( + &transaction, + &[BlockMetricRow { + height: 1, + block_hash: "hash".to_string(), + timestamp_ms: 1_000, + block_time_ms: Some(415_000), + mine_difficulty_bits: 12, + circulating_supply: 100, + known_wallet_addresses: 1, + transaction_count: 0, + transfer_count: 0, + burn_count: 0, + mine_count: 0, + burned_amount: 0, + total_burned_amount: 0, + fees_amount: 0, + reward_amount: 0, + vdf_rounds, + finalizer_rank: 0, + }], + )?; + transaction.commit().unwrap(); + Ok(()) + }) + .unwrap(); + + let metrics = store.load_metrics().unwrap(); + assert_eq!(metrics.len(), 1); + assert_eq!(metrics[0].vdf_rounds, vdf_rounds); +} + +#[test] +fn sqlite_chain_store_metrics_include_genesis_reward_when_burn_consumes_allocation() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("metrics-genesis-reward"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 1); + let ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) + .unwrap(); + + store.save_with_metrics(&ledger.snapshot(), true).unwrap(); + let metrics = store.load_metrics().unwrap(); + + assert_eq!(metrics.len(), 1); + assert_eq!(metrics[0].height, 0); + assert_eq!(metrics[0].burned_amount, 1); + assert_eq!(metrics[0].reward_amount, BLOCK_REWARD); + assert_eq!(metrics[0].circulating_supply, BLOCK_REWARD); +} + +#[test] +fn sqlite_chain_store_disabled_metrics_save_deletes_old_metrics() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("metrics-cleanup"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 10); + let ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) + .unwrap(); + + store.save_with_metrics(&ledger.snapshot(), true).unwrap(); + assert!(!store.load_metrics().unwrap().is_empty()); + + store.save_with_metrics(&ledger.snapshot(), false).unwrap(); + assert!(store.load_metrics().unwrap().is_empty()); +} + +#[test] +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_blob, updated_at_ms) +VALUES (1, 9, 'bad-tip', x'00010203', 0) +"#, + [], + )?; + Ok(()) + }) + .unwrap(); + + let error = store.load().unwrap_err(); + + assert!( + format!("{error:#}").contains("failed to parse compact chain snapshot from database"), + "{error:#}" + ); +} diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -1,46 +1,89 @@ use std::{ - collections::{BTreeMap, BTreeSet}, + collections::BTreeMap, net::SocketAddr, - path::{Path, PathBuf}, sync::Arc, - time::{Duration, SystemTime, UNIX_EPOCH}, + time::{SystemTime, UNIX_EPOCH}, }; -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result}; use axum::{ Form, Json, Router, - body::Body, - extract::{ConnectInfo, Extension, Query, State}, - http::{HeaderMap, Method, Request, StatusCode, header}, - middleware::{self, Next}, - response::{Html, IntoResponse, Redirect, Response}, + extract::State, + middleware, routing::{get, post}, }; -use getrandom::getrandom; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use tokio::{net::TcpListener, sync::Mutex, time::sleep}; +use tokio::{net::TcpListener, sync::Mutex}; use crate::{ - adapters::{ - chain_store::{BlockMetricRow, SqliteChainStore}, - config_store, - config_store::UiConfig, - p2p::{GossipNetwork, P2pMetrics}, - wallet_store, - }, - app::{ - FeeEstimate, NodeStatus, PeerDirection, PeerInfo, SharedNode, SharedPeerBook, StratumStatus, - }, - domain::{ - Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, - BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal, BlindedTransaction, Block, - BurnLeaderRank, ChainSnapshot, Ledger, MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, - REVEAL_COMMITTEE_SIZE, RevealedBlindedTransaction, Transaction, TxInput, TxOutput, Wallet, - blinded_reveal_finalizer_fee, hex_hash, revealed_blinded_transactions, validate_address, - }, + adapters::{config_store, config_store::UiConfig, p2p::GossipNetwork}, + app::{SharedNode, SharedPeerBook}, + domain::validate_address, }; +mod actions; +mod api; +mod auth; +mod auth_routes; +mod index_html; +mod metrics; +mod request_auth; +mod state; +mod static_assets; +mod ui; +mod wallet; +mod wallet_persistence; +#[cfg(test)] +use actions::{ + add_peer, persist_burn_settings_config, persist_pow_mining_config, remove_address_book_entry, + remove_peer, set_burn_settings, set_keep_track_of_metrics, set_p2p_accept_inbound, + set_p2p_announce_addr, upsert_address_book_entry, +}; +use actions::{ + api_address_book_delete_form, api_address_book_form, api_burn_fee_estimate_form, + api_burn_per_block_form, api_metrics_settings_form, api_mine_fee_estimate_form, + api_p2p_announce_form, api_p2p_inbound_form, api_peer_delete_form, api_peer_form, + api_pow_mining_form, api_recovery_vdf_settings_form, api_transfer_fee_estimate_form, + api_transfer_form, api_wallet_generate_form, api_wallet_import_form, apply_config_form, + burn_per_block_form, peer_form, transfer_form, +}; +use api::{ + api_blocks, api_config, api_mempool, api_metrics, api_network_health, api_p2p_metrics, + api_peers, api_status, api_wallet_selectable_utxos, api_wallet_transactions, api_wallet_utxos, +}; +#[cfg(test)] +use api::{page_items, selectable_wallet_utxo_rows, wallet_utxo_rows}; +#[cfg(test)] +use auth::{hash_password, hex_encode, pbkdf2_sha256, validate_password, verify_password}; +use auth_routes::{ + api_auth_change_password_form, api_auth_login_form, api_auth_logout_form, api_auth_setup_form, + api_auth_status, require_auth_middleware, +}; +use index_html::INDEX_HTML; +#[cfg(test)] +use metrics::network_health_at; +use metrics::{metrics_response, network_health}; +use request_auth::wallet_password_for_request; +#[cfg(test)] +use request_auth::{auth_client_key, login_auth_password, same_origin_request}; +pub use state::ServeOptions; +use state::{AuthClientKey, AuthSession, HttpState, UiChainCache, UiChainView}; +use static_assets::{alpine_js, app_js, favicon, index}; +use ui::{ + add_pending_outputs, cached_chain_view, ui_blinded_reveal, ui_blinded_transaction, + ui_blocks_from_indexes, ui_pending_revealed_transaction, ui_transaction, + wallet_transaction_rows, +}; +#[cfg(test)] +use ui::{known_output_index, revealed_transactions_by_height, ui_block, ui_blocks}; +use wallet::{ + api_wallet_setup, estimate_burn_fee, estimate_mine_fee, estimate_transfer_fee, + fee_estimate_json, import_setup_wallet_seed, replace_setup_wallet_with_generated_seed, + required_fee_per_byte_burn, setup_requires_peer, transfer, wallet_setup_json, +}; +#[cfg(test)] +use wallet::{dev_seed_verify_bypass_allowed, validate_transfer_form}; +use wallet_persistence::run_owned_blinded_outbox_persistence; + const EXPLORER_LIMIT: usize = 50; const EXPLORER_PAGE_LIMIT: usize = 20; const DATASET_LIMIT: usize = 1_000; @@ -50,432 +93,17 @@ const AUTH_SESSION_TTL_MS: u64 = 12 * 60 * 60 * 1_000; const AUTH_MAX_FAILED_ATTEMPTS: u32 = 5; const AUTH_LOCKOUT_MS: u64 = 60 * 1_000; const UNKNOWN_CLIENT_KEY: &str = "unknown"; -const PASSWORD_KDF_ALGORITHM: &str = "pbkdf2-sha256"; -const PASSWORD_KDF_ITERATIONS: u32 = 120_000; const PEER_STALE_AFTER_MS: u64 = 20 * 60 * 1_000; -#[derive(Clone)] -struct HttpState { - node: SharedNode, - peers: SharedPeerBook, - gossip: GossipNetwork, - ui_config: Arc<Mutex<UiConfig>>, - config_path: PathBuf, - chain_store: SqliteChainStore, - wallet_path: PathBuf, - stratum: StratumStatus, - auth_sessions: Arc<Mutex<BTreeMap<String, AuthSession>>>, - auth_backoff: Arc<Mutex<BTreeMap<String, AuthBackoff>>>, - ui_cache: Arc<Mutex<UiChainCache>>, -} - -#[derive(Clone)] -struct AuthSession { - expires_at: u64, - wallet_password: String, -} - -#[derive(Clone, Debug)] -struct AuthClientKey(String); - -#[derive(Clone, Debug, Default)] -struct AuthBackoff { - failed_attempts: u32, - locked_until_ms: Option<u64>, -} - -#[derive(Clone, Debug, Default)] -struct UiChainCache { - tip_hash: Option<String>, - outputs: BTreeMap<OutPoint, TxOutput>, - revealed_by_height: BTreeMap<u64, Vec<RevealedBlindedTransaction>>, -} - -#[derive(Clone, Debug)] -struct UiChainView { - outputs: BTreeMap<OutPoint, TxOutput>, - revealed_by_height: BTreeMap<u64, Vec<RevealedBlindedTransaction>>, -} - -pub struct ServeOptions { - pub config_path: PathBuf, - pub chain_store: SqliteChainStore, - pub wallet_path: PathBuf, - pub stratum: StratumStatus, - pub addr: SocketAddr, -} - -#[derive(Debug, Deserialize)] -struct AuthForm { - password: String, -} - -#[derive(Debug, Deserialize)] -struct ChangePasswordForm { - old_password: String, - new_password: String, -} - -#[derive(Debug, Serialize)] -struct AuthStatusResponse { - configured: bool, - authenticated: bool, -} - -#[derive(Debug, Serialize)] -struct NetworkHealthResponse { - ok: bool, - state: String, - local_height: u64, - best_known_height: u64, - shared_height: u64, - lag_blocks: u64, - outbound_peers: usize, - inbound_peers: usize, - healthy_peers: usize, - failed_peers: usize, - stale_peers: usize, - banned_peers: usize, - pending_transactions: usize, - pending_plain_transactions: usize, - pending_blinded_transactions: usize, - pending_blinded_reveals: usize, - network_time_offset_ms: Option<i64>, - bad_clock_peers: usize, - last_error: Option<String>, -} - -#[derive(Clone, Copy, Debug, Default)] -struct MempoolCounts { - plain_transactions: usize, - blinded_transactions: usize, - blinded_reveals: usize, -} - -#[derive(Debug, Deserialize)] -struct BurnSettingsForm { - enabled: Option<bool>, - amount: Amount, - fee_per_byte: Option<Amount>, -} - -#[derive(Debug, Deserialize)] -struct RecoveryVdfSettingsForm { - top_rank_percent: u8, -} - -#[derive(Debug, Deserialize)] -struct PowMiningForm { - enabled: bool, - workers: Option<u8>, -} - -#[derive(Debug, Deserialize)] -struct MetricsSettingsForm { - enabled: bool, -} - -#[derive(Debug, Deserialize)] -struct P2pAnnounceForm { - addr: String, -} - -#[derive(Debug, Deserialize)] -struct P2pInboundForm { - enabled: bool, - bind_port: Option<u16>, -} - -#[derive(Debug, Serialize)] -struct ConfigResponse { - #[serde(flatten)] - config: UiConfig, - p2p_inbound_runtime_active: bool, - p2p_runtime_bind_addr: String, -} - -#[derive(Debug, Deserialize)] -struct TransferForm { - to: String, - amount: Amount, - fee_per_byte: Option<Amount>, - #[serde(default)] - utxos: String, -} - -#[derive(Debug, Deserialize)] -struct PeerForm { - peer: String, -} - -#[derive(Debug, Deserialize)] -struct AddressBookForm { - address: String, - name: String, - old_address: Option<String>, -} - -#[derive(Debug, Deserialize)] -struct AddressBookDeleteForm { - address: String, -} - -#[derive(Debug, Deserialize)] -struct ConfigForm { - setup_complete: bool, - #[serde(default)] - peer: String, -} - -#[derive(Debug, Deserialize)] -struct SeedPhraseForm { - seed_phrase: String, -} - -#[derive(Debug, Deserialize)] -struct BlocksQuery { - before_height: Option<u64>, - limit: Option<usize>, -} - -#[derive(Debug, Default, Deserialize)] -struct PageQuery { - offset: Option<usize>, - limit: Option<usize>, -} - -#[derive(Debug, Default, Deserialize)] -struct WalletTransactionsQuery { - tx: Option<bool>, - mine: Option<bool>, - burn: Option<bool>, - offset: Option<usize>, - limit: Option<usize>, -} - -impl WalletTransactionsQuery { - fn page(&self) -> PageQuery { - PageQuery { - offset: self.offset, - limit: self.limit, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct WalletTransactionFilters { - transfer: bool, - mine: bool, - burn: bool, -} - -impl Default for WalletTransactionFilters { - fn default() -> Self { - Self { - transfer: true, - mine: false, - burn: false, - } - } -} - -impl WalletTransactionFilters { - fn from_query(query: WalletTransactionsQuery) -> Self { - Self { - transfer: query.tx.unwrap_or(true), - mine: query.mine.unwrap_or(false), - burn: query.burn.unwrap_or(false), - } - } - - fn allows(self, transaction: &Transaction) -> bool { - match transaction { - Transaction::Transfer { .. } => self.transfer, - Transaction::Mine { .. } => self.mine, - Transaction::Burn { .. } => self.burn, - } - } -} - -#[derive(Debug, Serialize)] -struct ActionResponse { - ok: bool, - error: Option<String>, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct Page<T> { - items: Vec<T>, - offset: usize, - limit: usize, - total: usize, - has_more: bool, - next_offset: Option<usize>, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct MetricsResponse { - enabled: bool, - latest: Option<BlockMetricRow>, - charts: Vec<MetricsChart>, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct MetricsChart { - id: &'static str, - title: &'static str, - unit: &'static str, - value_kind: MetricsValueKind, - points: Vec<MetricsPoint>, -} - -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct MetricsPoint { - height: u64, - value: f64, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -enum MetricsValueKind { - Number, - Seconds, - Iuna, -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -struct FeeEstimateResponse { - ok: bool, - error: Option<String>, - bytes: Option<usize>, - fee: Option<Amount>, -} - -#[derive(Debug, Serialize)] -struct WalletSetupResponse { - ok: bool, - error: Option<String>, - address: Option<String>, - seed_phrase: Option<String>, - dev_verify_bypass: bool, - requires_peer: bool, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct WalletTransactionRow { - kind: &'static str, - from: String, - to: Option<String>, - amount: Amount, - fee: Amount, - inputs: Vec<UiTxInput>, - outputs: Vec<TxOutput>, - change: Vec<TxOutput>, - signature: String, - status: &'static str, - block_height: Option<u64>, - timestamp_ms: Option<u64>, - block_finalizer: Option<String>, - direction: &'static str, - blinded: bool, - difficulty_bits: Option<u32>, - proof_bits: Option<u32>, - proof_hash: Option<String>, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct WalletUtxoRow { - outpoint: OutPoint, - address: String, - amount: Amount, - spendable: bool, -} - -#[derive(Clone, Debug)] -struct WalletTransactionContext { - status: &'static str, - block_height: Option<u64>, - timestamp_ms: Option<u64>, - block_finalizer: Option<String>, - blinded: bool, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -struct UiBlock { - height: u64, - prev_hash: String, - timestamp_ms: u64, - miner: String, - finalizer_mode: crate::domain::FinalizerMode, - finalizer_rank: u32, - reward: Amount, - total_fees: Amount, - total_bytes: usize, - transaction_bytes: usize, - transaction_byte_breakdown: Vec<UiByteBreakdown>, - blinded_transaction_bytes: usize, - reveal_bundle_bytes: usize, - vdf_rounds: u64, - vdf_output: String, - leader_proof: Option<crate::domain::LeaderProof>, - burn_leader_ranks: Vec<BurnLeaderRank>, - transactions: Vec<UiTransaction>, - revealed_transactions: Vec<UiTransaction>, - reveal_bundles: Vec<UiRevealBundle>, - hash: String, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -struct UiByteBreakdown { - label: &'static str, - bytes: usize, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct UiRevealBundle { - slot: u8, - member: String, - hash: String, - byte_size: usize, - reveals: Vec<UiTransaction>, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -struct UiTransaction { - kind: &'static str, - from: String, - to: Option<String>, - amount: Amount, - fee: Amount, - inputs: Vec<UiTxInput>, - outputs: Vec<TxOutput>, - change: Vec<TxOutput>, - signature: String, - difficulty_bits: Option<u32>, - proof_bits: Option<u32>, - proof_hash: Option<String>, - commitment: Option<String>, - encrypted_size: Option<u32>, - expires_at_height: Option<u64>, - revealed: bool, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -struct UiTxInput { - outpoint: OutPoint, - owner: String, - signature: String, - amount: Option<Amount>, - address: Option<String>, -} +mod types; +use types::{ + ActionResponse, AuthForm, AuthStatusResponse, BlocksQuery, ChangePasswordForm, ConfigForm, + ConfigResponse, MempoolCounts, MetricsResponse, NetworkHealthResponse, Page, PageQuery, + UiBlock, UiTransaction, WalletTransactionFilters, WalletTransactionRow, + WalletTransactionsQuery, WalletUtxoRow, +}; +#[cfg(test)] +use types::{BurnSettingsForm, TransferForm}; pub async fn serve( node: SharedNode, @@ -577,6158 +205,39 @@ pub async fn serve( .context("serving HTTP management UI") } -async fn index() -> Html<&'static str> { - Html(INDEX_HTML) -} - -async fn favicon() -> impl IntoResponse { - ( - StatusCode::NO_CONTENT, - [(header::CACHE_CONTROL, "public, max-age=86400")], - ) -} - -async fn alpine_js() -> impl IntoResponse { - ( - [( - header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - )], - include_str!("../../www/assets/alpine.min.js"), - ) -} - -async fn app_js() -> impl IntoResponse { - ( - [( - header::CONTENT_TYPE, - "application/javascript; charset=utf-8", - )], - include_str!("../../www/assets/iuna-ui.js"), - ) -} - -async fn run_owned_blinded_outbox_persistence(state: HttpState) { - loop { - sleep(Duration::from_millis(500)).await; - let (version, entries) = { - let node = state.node.lock().await; - let version = node.owned_blinded_outbox_version(); - if version == 0 { - continue; - } - (version, node.owned_blinded_transactions()) - }; - let password = wallet_persistence_password(&state).await; - match wallet_store::replace_owned_blinded_transactions( - &state.wallet_path, - password.as_deref(), - entries, - ) { - Ok(()) => { - state - .node - .lock() - .await - .mark_owned_blinded_outbox_persisted(version); - } - Err(error) if format!("{error:#}").contains("wallet is encrypted") => {} - Err(error) => eprintln!("failed to persist owned blinded transactions: {error:#}"), - } - } -} - -async fn wallet_persistence_password(state: &HttpState) -> Option<String> { - let metadata = wallet_store::metadata(&state.wallet_path).ok().flatten()?; - if !metadata.encrypted { - return None; - } - let now = now_ms(); - state - .auth_sessions - .lock() - .await - .values() - .find(|session| session.expires_at > now) - .map(|session| session.wallet_password.clone()) -} - -async fn require_auth_middleware( - State(state): State<HttpState>, - headers: HeaderMap, - mut request: Request<Body>, - next: Next, -) -> Response { - let path = request.uri().path().to_string(); - if csrf_required(request.method()) && !same_origin_request(&headers) { - return csrf_error().into_response(); - } - let client_key = auth_client_key( - &headers, - request - .extensions() - .get::<ConnectInfo<SocketAddr>>() - .map(|info| info.0), - ); - request.extensions_mut().insert(AuthClientKey(client_key)); - if auth_exempt_path(&path) { - return next.run(request).await; - } - let configured = state.ui_config.lock().await.auth_password_hash.is_some(); - if !configured { - return auth_error("authentication setup is required").into_response(); - } - if request_is_authenticated(&state, &headers).await { - return next.run(request).await; - } - auth_error("authentication required").into_response() -} - -fn auth_exempt_path(path: &str) -> bool { - path == "/" - || path == "/favicon.ico" - || path == "/assets/alpine.min.js" - || path == "/assets/iuna-ui.js" - || path == "/api/auth/status" - || path == "/api/auth/setup" - || path == "/api/auth/login" -} - -fn csrf_required(method: &Method) -> bool { - !matches!(method, &Method::GET | &Method::HEAD | &Method::OPTIONS) -} - -fn same_origin_request(headers: &HeaderMap) -> bool { - let Some(request_host) = request_host(headers) else { - return false; - }; - let Some(origin_host) = origin_or_referer_host(headers) else { - return false; - }; - normalize_host(&origin_host) == normalize_host(&request_host) -} - -fn request_host(headers: &HeaderMap) -> Option<String> { - header_string(headers, "x-forwarded-host").or_else(|| header_string(headers, "host")) -} - -fn origin_or_referer_host(headers: &HeaderMap) -> Option<String> { - header_string(headers, "origin") - .and_then(|origin| url_host(&origin)) - .or_else(|| header_string(headers, "referer").and_then(|referer| url_host(&referer))) -} - -fn header_string(headers: &HeaderMap, name: &'static str) -> Option<String> { - headers - .get(name) - .and_then(|value| value.to_str().ok()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - -fn url_host(value: &str) -> Option<String> { - let (_, rest) = value.split_once("://")?; - rest.split(['/', '?', '#']) - .next() - .map(str::trim) - .filter(|authority| !authority.is_empty() && *authority != "null") - .map(|authority| { - authority - .rsplit('@') - .next() - .unwrap_or(authority) - .to_string() - }) -} - -fn normalize_host(host: &str) -> String { - host.trim().trim_end_matches('.').to_ascii_lowercase() -} - -async fn request_is_authenticated(state: &HttpState, headers: &HeaderMap) -> bool { - let Some(token) = auth_cookie(headers) else { - return false; - }; - let token_hash = session_token_hash(token); - let now = now_ms(); - let mut sessions = state.auth_sessions.lock().await; - sessions.retain(|_, session| session.expires_at > now); - sessions - .get(&token_hash) - .is_some_and(|session| session.expires_at > now) -} - -async fn wallet_password_for_request(state: &HttpState, headers: &HeaderMap) -> Option<String> { - let token = auth_cookie(headers)?; - let token_hash = session_token_hash(token); - let now = now_ms(); - let mut sessions = state.auth_sessions.lock().await; - sessions.retain(|_, session| session.expires_at > now); - sessions - .get(&token_hash) - .filter(|session| session.expires_at > now) - .map(|session| session.wallet_password.clone()) -} - -async fn api_auth_status( - State(state): State<HttpState>, - headers: HeaderMap, -) -> Json<AuthStatusResponse> { - let configured = state.ui_config.lock().await.auth_password_hash.is_some(); - let authenticated = configured && request_is_authenticated(&state, &headers).await; - Json(AuthStatusResponse { - configured, - authenticated, - }) -} - -async fn api_auth_setup_form( - State(state): State<HttpState>, - Extension(client_key): Extension<AuthClientKey>, - Form(form): Form<AuthForm>, -) -> Response { - match setup_auth_password(&state, &form.password, &client_key.0).await { - Ok(cookie) => ([(header::SET_COOKIE, cookie)], action_json(Ok(()))).into_response(), - Err(error) => action_json(Err(error)).into_response(), - } -} - -async fn api_auth_login_form( +async fn api_config_form( State(state): State<HttpState>, - Extension(client_key): Extension<AuthClientKey>, - Form(form): Form<AuthForm>, -) -> Response { - match login_auth_password(&state, &form.password, &client_key.0).await { - Ok(cookie) => ([(header::SET_COOKIE, cookie)], action_json(Ok(()))).into_response(), - Err(error) => action_json(Err(error)).into_response(), - } -} - -async fn api_auth_logout_form(State(state): State<HttpState>, headers: HeaderMap) -> Response { - if let Some(token) = auth_cookie(&headers) { - state - .auth_sessions - .lock() - .await - .remove(&session_token_hash(token)); - } - ( - [( - header::SET_COOKIE, - format!("{AUTH_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"), - )], - action_json(Ok(())), - ) - .into_response() + Form(form): Form<ConfigForm>, +) -> Json<ActionResponse> { + action_json(apply_config_form(&state, form).await) } -async fn api_auth_change_password_form( - State(state): State<HttpState>, - Extension(client_key): Extension<AuthClientKey>, - Form(form): Form<ChangePasswordForm>, -) -> Response { - match change_auth_password( - &state, - &form.old_password, - &form.new_password, - &client_key.0, - ) - .await - { - Ok(cookie) => ([(header::SET_COOKIE, cookie)], action_json(Ok(()))).into_response(), - Err(error) => action_json(Err(error)).into_response(), +fn action_json(result: Result<()>) -> Json<ActionResponse> { + match result { + Ok(_) => Json(ActionResponse { + ok: true, + error: None, + }), + Err(error) => Json(ActionResponse { + ok: false, + error: Some(format!("{error:#}")), + }), } } -async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> { - let mut status = state.node.lock().await.status(); - status.stratum = state.stratum.clone(); - Json(status) -} - -async fn api_blocks( - State(state): State<HttpState>, - Query(query): Query<BlocksQuery>, -) -> Json<Vec<UiBlock>> { - let limit = query - .limit - .unwrap_or(EXPLORER_PAGE_LIMIT) - .min(EXPLORER_LIMIT); - let (snapshot, pending, blocks, burn_leader_ranks) = { - let node = state.node.lock().await; - let snapshot = node.chain_snapshot(); - let pending = node.pending_transactions(); - let blocks = match query.before_height { - Some(before_height) => node.blocks_before(before_height, limit), - None => node.recent_blocks(limit), - }; - let burn_leader_ranks = blocks - .iter() - .map(|block| { - ( - block.hash.clone(), - node.burn_leader_ranks_for_block(block.height) - .unwrap_or_default(), - ) - }) - .collect::<BTreeMap<_, _>>(); - (snapshot, pending, blocks, burn_leader_ranks) - }; - let view = cached_chain_view(&state, &snapshot).await; - let mut outputs = view.outputs; - add_pending_outputs(&mut outputs, &pending); - Json(ui_blocks_from_indexes( - blocks, - &outputs, - &view.revealed_by_height, - &burn_leader_ranks, - )) -} - -async fn api_config(State(state): State<HttpState>) -> Json<ConfigResponse> { - Json(ConfigResponse { - config: state.ui_config.lock().await.clone(), - p2p_inbound_runtime_active: state.gossip.accepts_inbound().await, - p2p_runtime_bind_addr: state.gossip.listen_addr().to_string(), +fn api_error(error: anyhow::Error) -> Json<ActionResponse> { + Json(ActionResponse { + ok: false, + error: Some(format!("{error:#}")), }) } -async fn api_wallet_setup( - State(state): State<HttpState>, - headers: HeaderMap, -) -> Json<WalletSetupResponse> { - wallet_setup_json(wallet_setup_response(&state, &headers).await) -} - -async fn api_mempool( - State(state): State<HttpState>, - Query(query): Query<PageQuery>, -) -> Json<Page<UiTransaction>> { - let (snapshot, pending, pending_blinded, pending_reveals, pending_revealed) = { - let node = state.node.lock().await; - let snapshot = node.chain_snapshot(); - let pending = node.pending_transactions(); - let pending_blinded = node.pending_blinded_transactions(); - let pending_reveals = node.pending_blinded_reveals(); - let pending_revealed = node - .pending_revealed_blinded_transactions() - .into_iter() - .map(|revealed| (revealed.commitment.clone(), revealed)) - .collect::<BTreeMap<_, _>>(); - ( - snapshot, - pending, - pending_blinded, - pending_reveals, - pending_revealed, - ) - }; - let view = cached_chain_view(&state, &snapshot).await; - let mut outputs = view.outputs; - add_pending_outputs(&mut outputs, &pending); - let mut items = pending - .iter() - .map(|tx| ui_transaction(tx, &outputs)) - .collect::<Vec<_>>(); - items.extend( - pending_blinded - .iter() - .map(|transaction| ui_blinded_transaction(transaction, &outputs)), - ); - items.extend(pending_reveals.iter().map(|reveal| { - pending_revealed - .get(&reveal.commitment) - .map(|revealed| ui_pending_revealed_transaction(revealed, &outputs)) - .unwrap_or_else(|| ui_blinded_reveal(reveal)) - })); - items.reverse(); - Json(page_items(items, query)) -} - -async fn api_wallet_transactions( - State(state): State<HttpState>, - Query(query): Query<WalletTransactionsQuery>, -) -> Json<Page<WalletTransactionRow>> { - let (wallet, snapshot, pending, owned_blinded) = { - let node = state.node.lock().await; - ( - node.wallet_address().to_string(), - node.chain_snapshot(), - node.pending_transactions(), - node.owned_blinded_payloads(), - ) - }; - let view = cached_chain_view(&state, &snapshot).await; - let mut outputs = view.outputs; - add_pending_outputs(&mut outputs, &pending); - let page_query = query.page(); - let filters = WalletTransactionFilters::from_query(query); - Json(page_items( - wallet_transaction_rows( - &wallet, - pending, - owned_blinded, - &snapshot.blocks, - &view.revealed_by_height, - &outputs, - filters, - ), - page_query, - )) -} - -async fn api_wallet_utxos( - State(state): State<HttpState>, - Query(query): Query<PageQuery>, -) -> Json<Page<WalletUtxoRow>> { - let (ledger, wallet) = { - let node = state.node.lock().await; - ( - node.wallet_view_ledger() - .unwrap_or_else(|_| node.clone_ledger()), - node.wallet_address().to_string(), - ) - }; - Json(page_items(wallet_utxo_rows(&ledger, &wallet), query)) -} - -async fn api_wallet_selectable_utxos(State(state): State<HttpState>) -> Json<Vec<WalletUtxoRow>> { - let (ledger, wallet) = { - let node = state.node.lock().await; - ( - node.wallet_view_ledger() - .unwrap_or_else(|_| node.clone_ledger()), - node.wallet_address().to_string(), - ) - }; - Json(selectable_wallet_utxo_rows(&ledger, &wallet)) -} - -fn page_items<T>(items: Vec<T>, query: PageQuery) -> Page<T> { - let total = items.len(); - let offset = query.offset.unwrap_or(0).min(total); - let limit = query - .limit - .unwrap_or(DATASET_PAGE_LIMIT) - .clamp(1, DATASET_LIMIT); - let page_items = items - .into_iter() - .skip(offset) - .take(limit) - .collect::<Vec<_>>(); - let next_offset = offset + page_items.len(); - Page { - items: page_items, - offset, - limit, - total, - has_more: next_offset < total, - next_offset: (next_offset < total).then_some(next_offset), - } -} - -fn wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoRow> { - let spendable_outpoints = ledger - .available_utxos_for_address(wallet) +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) .unwrap_or_default() - .into_iter() - .map(|(outpoint, _)| outpoint) - .collect::<BTreeSet<_>>(); - let mut utxos = ledger - .utxos_for_address(wallet) - .into_iter() - .map(|(outpoint, output)| { - let spendable = spendable_outpoints.contains(&outpoint); - WalletUtxoRow { - outpoint, - address: output.address, - amount: output.amount, - spendable, - } - }) - .collect::<Vec<_>>(); - utxos.sort_by(|left, right| { - right - .amount - .cmp(&left.amount) - .then_with(|| left.outpoint.txid.cmp(&right.outpoint.txid)) - .then_with(|| left.outpoint.index.cmp(&right.outpoint.index)) - }); - utxos -} - -fn selectable_wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoRow> { - wallet_utxo_rows(ledger, wallet) - .into_iter() - .filter(|utxo| utxo.spendable) - .collect() -} - -async fn api_peers( - State(state): State<HttpState>, - Query(query): Query<PageQuery>, -) -> Json<Page<PeerInfo>> { - Json(page_items(state.peers.lock().await.list(), query)) -} - -async fn api_p2p_metrics(State(state): State<HttpState>) -> Json<P2pMetrics> { - Json(state.gossip.metrics()) -} - -async fn api_metrics(State(state): State<HttpState>) -> Json<MetricsResponse> { - let enabled = state.ui_config.lock().await.keep_track_of_metrics; - if !enabled { - return Json(MetricsResponse { - enabled, - latest: None, - charts: Vec::new(), - }); - } - let store = state.chain_store.clone(); - let rows = tokio::task::spawn_blocking(move || store.load_metrics()) - .await - .ok() - .and_then(Result::ok) - .unwrap_or_default(); - Json(metrics_response(enabled, rows)) -} - -async fn api_network_health(State(state): State<HttpState>) -> Json<NetworkHealthResponse> { - let (status, mempool) = { - let node = state.node.lock().await; - ( - node.status(), - MempoolCounts { - plain_transactions: node.pending_transactions().len(), - blinded_transactions: node.pending_blinded_transactions().len(), - blinded_reveals: node.pending_blinded_reveals().len(), - }, - ) - }; - let peers = state.peers.lock().await.list(); - Json(network_health(&status, &peers, mempool)) -} - -async fn api_config_form( - State(state): State<HttpState>, - Form(form): Form<ConfigForm>, -) -> Json<ActionResponse> { - action_json(apply_config_form(&state, form).await) -} - -async fn apply_config_form(state: &HttpState, form: ConfigForm) -> Result<()> { - let peer = form.peer.trim(); - if !peer.is_empty() { - add_peer(state, peer.to_string()).await?; - } - if form.setup_complete && setup_requires_peer(state).await { - let has_peer = !state.peers.lock().await.addresses().is_empty(); - if !has_peer { - bail!("add a bootstrap peer before completing setup"); - } - } - let mut config = state.ui_config.lock().await; - config.setup_complete = form.setup_complete; - config_store::save(&state.config_path, &config) -} - -async fn api_wallet_generate_form( - State(state): State<HttpState>, - headers: HeaderMap, -) -> Json<WalletSetupResponse> { - wallet_setup_json(replace_setup_wallet_with_generated_seed(&state, &headers).await) -} - -async fn api_wallet_import_form( - State(state): State<HttpState>, - headers: HeaderMap, - Form(form): Form<SeedPhraseForm>, -) -> Json<WalletSetupResponse> { - wallet_setup_json(import_setup_wallet_seed(&state, &headers, &form.seed_phrase).await) -} - -async fn api_transfer_fee_estimate_form( - State(state): State<HttpState>, - Form(form): Form<TransferForm>, -) -> Json<FeeEstimateResponse> { - fee_estimate_json(estimate_transfer_fee(&state, form).await) -} - -async fn api_burn_fee_estimate_form( - State(state): State<HttpState>, - Form(form): Form<BurnSettingsForm>, -) -> Json<FeeEstimateResponse> { - fee_estimate_json(estimate_burn_fee(&state, form).await) -} - -async fn api_mine_fee_estimate_form( - State(state): State<HttpState>, - Form(_form): Form<BTreeMap<String, String>>, -) -> Json<FeeEstimateResponse> { - fee_estimate_json(estimate_mine_fee(&state).await) -} - -async fn api_burn_per_block_form( - State(state): State<HttpState>, - Form(form): Form<BurnSettingsForm>, -) -> Json<ActionResponse> { - let enabled = form.enabled.unwrap_or(form.amount > 0); - let result = match required_fee_per_byte_burn(&form) { - Ok(fee_per_byte) => set_burn_settings(&state, enabled, form.amount, fee_per_byte).await, - Err(error) => Err(error), - }; - action_json(result) -} - -async fn api_pow_mining_form( - State(state): State<HttpState>, - Form(form): Form<PowMiningForm>, -) -> Json<ActionResponse> { - action_json(set_pow_mining(&state, form.enabled, form.workers).await) -} - -async fn api_metrics_settings_form( - State(state): State<HttpState>, - Form(form): Form<MetricsSettingsForm>, -) -> Json<ActionResponse> { - action_json(set_keep_track_of_metrics(&state, form.enabled).await) -} - -async fn api_recovery_vdf_settings_form( - State(state): State<HttpState>, - Form(form): Form<RecoveryVdfSettingsForm>, -) -> Json<ActionResponse> { - action_json(set_recovery_vdf_top_rank_percent(&state, form.top_rank_percent).await) -} - -async fn api_p2p_announce_form( - State(state): State<HttpState>, - Form(form): Form<P2pAnnounceForm>, -) -> Json<ActionResponse> { - action_json(set_p2p_announce_addr(&state, form.addr).await) -} - -async fn api_p2p_inbound_form( - State(state): State<HttpState>, - Form(form): Form<P2pInboundForm>, -) -> Json<ActionResponse> { - action_json(set_p2p_accept_inbound(&state, form.enabled, form.bind_port).await) -} - -async fn burn_per_block_form( - State(state): State<HttpState>, - Form(form): Form<BurnSettingsForm>, -) -> Response { - let enabled = form.enabled.unwrap_or(form.amount > 0); - let result = match required_fee_per_byte_burn(&form) { - Ok(fee_per_byte) => set_burn_settings(&state, enabled, form.amount, fee_per_byte).await, - Err(error) => Err(error), - }; - match result { - Ok(_) => Redirect::to("/").into_response(), - Err(error) => api_error(error).into_response(), - } -} - -async fn api_transfer_form( - State(state): State<HttpState>, - Form(form): Form<TransferForm>, -) -> Json<ActionResponse> { - let result = transfer(&state, form).await; - action_json(result) -} - -async fn transfer_form(State(state): State<HttpState>, Form(form): Form<TransferForm>) -> Response { - match transfer(&state, form).await { - Ok(_) => Redirect::to("/").into_response(), - Err(error) => api_error(error).into_response(), - } -} - -async fn api_peer_form( - State(state): State<HttpState>, - Form(form): Form<PeerForm>, -) -> Json<ActionResponse> { - let result = add_peer(&state, form.peer).await; - action_json(result) -} - -async fn api_peer_delete_form( - State(state): State<HttpState>, - Form(form): Form<PeerForm>, -) -> Json<ActionResponse> { - let result = remove_peer(&state, form.peer).await; - action_json(result) -} - -async fn api_address_book_form( - State(state): State<HttpState>, - Form(form): Form<AddressBookForm>, -) -> Json<ActionResponse> { - action_json(upsert_address_book_entry(&state, form.address, form.name, form.old_address).await) -} - -async fn api_address_book_delete_form( - State(state): State<HttpState>, - Form(form): Form<AddressBookDeleteForm>, -) -> Json<ActionResponse> { - action_json(remove_address_book_entry(&state, form.address).await) -} - -async fn peer_form(State(state): State<HttpState>, Form(form): Form<PeerForm>) -> Response { - match add_peer(&state, form.peer).await { - Ok(()) => Redirect::to("/").into_response(), - Err(error) => api_error(error).into_response(), - } -} - -async fn set_burn_settings( - state: &HttpState, - enabled: bool, - amount: Amount, - fee: Amount, -) -> Result<()> { - if enabled && amount == 0 { - bail!("IUNA per block must be greater than zero when finalization burns are on"); - } - let result = { - let mut node = state.node.lock().await; - let result = node.set_automatic_burn_settings(enabled, amount, fee); - let outbox = node.drain_outbox(); - (result, outbox) - }; - - match result.0 { - Ok(_) => { - persist_burn_settings_config( - &state.ui_config, - &state.config_path, - enabled, - amount, - fee, - ) - .await?; - state.gossip.broadcast(result.1).await - } - Err(error) => Err(error), - } -} - -async fn persist_burn_settings_config( - ui_config: &Arc<Mutex<UiConfig>>, - config_path: &Path, - enabled: bool, - amount: Amount, - fee: Amount, -) -> Result<()> { - let mut config = ui_config.lock().await; - config.mining_enabled = enabled; - config.burn_per_block = amount; - config.burn_fee = fee; - config_store::save(config_path, &config) -} - -async fn set_pow_mining(state: &HttpState, enabled: bool, workers: Option<u8>) -> Result<()> { - let workers = match workers { - Some(workers) => workers, - None => state.ui_config.lock().await.pow_mining_workers, - }; - { - let mut node = state.node.lock().await; - node.set_pow_mining_workers(workers); - node.set_pow_mining_enabled(enabled); - } - persist_pow_mining_config(&state.ui_config, &state.config_path, enabled, workers).await -} - -async fn persist_pow_mining_config( - ui_config: &Arc<Mutex<UiConfig>>, - config_path: &Path, - enabled: bool, - workers: u8, -) -> Result<()> { - let mut config = ui_config.lock().await; - config.pow_mining_enabled = enabled; - config.pow_mining_workers = config_store::clamp_pow_mining_workers(workers); - config_store::save(config_path, &config) -} - -async fn set_recovery_vdf_top_rank_percent(state: &HttpState, percent: u8) -> Result<()> { - let percent = percent.min(100); - { - let mut node = state.node.lock().await; - node.set_recovery_vdf_top_rank_percent(percent); - } - let mut config = state.ui_config.lock().await; - config.recovery_vdf_top_rank_percent = percent; - config_store::save(&state.config_path, &config) -} - -async fn set_keep_track_of_metrics(state: &HttpState, enabled: bool) -> Result<()> { - if enabled { - let snapshot = { - let node = state.node.lock().await; - node.has_real_chain().then(|| node.chain_snapshot()) - }; - if let Some(snapshot) = snapshot { - replace_metrics_for_snapshot(&state.chain_store, snapshot).await?; - } else { - clear_metrics(&state.chain_store).await?; - } - } else { - clear_metrics(&state.chain_store).await?; - } - - let mut config = state.ui_config.lock().await; - config.keep_track_of_metrics = enabled; - config_store::save(&state.config_path, &config) -} - -async fn set_p2p_announce_addr(state: &HttpState, addr: String) -> Result<()> { - let trimmed = addr.trim(); - let parsed = if trimmed.is_empty() { - None - } else { - Some( - trimmed - .parse::<SocketAddr>() - .with_context(|| format!("invalid P2P announce address {trimmed}"))?, - ) - }; - - let mut config = state.ui_config.lock().await; - let mut next_config = config.clone(); - next_config.p2p_announce_addr = parsed.map(|addr| addr.to_string()); - config_store::save(&state.config_path, &next_config)?; - *config = next_config; - drop(config); - state.gossip.set_p2p_announce_addr(parsed).await; - Ok(()) -} - -async fn set_p2p_accept_inbound( - state: &HttpState, - enabled: bool, - bind_port: Option<u16>, -) -> Result<()> { - let bind_port = bind_port.unwrap_or(config_store::DEFAULT_P2P_BIND_PORT); - if bind_port == 0 { - bail!("P2P bind port must be between 1 and 65535"); - } - let previous = state.gossip.accepts_inbound().await; - if enabled && previous { - state.gossip.set_accept_inbound(true).await?; - } - - let mut config = state.ui_config.lock().await; - let mut next_config = config.clone(); - next_config.p2p_accept_inbound = enabled; - next_config.p2p_bind_port = bind_port; - if let Err(error) = config_store::save(&state.config_path, &next_config) { - let _ = state.gossip.set_accept_inbound(previous).await; - return Err(error); - } - *config = next_config; - drop(config); - - if !enabled { - state.gossip.set_accept_inbound(false).await?; - } - - Ok(()) -} - -async fn replace_metrics_for_snapshot( - store: &SqliteChainStore, - snapshot: crate::domain::ChainSnapshot, -) -> Result<()> { - let store = store.clone(); - tokio::task::spawn_blocking(move || store.replace_metrics_for_snapshot(&snapshot)) - .await - .context("metrics worker failed")??; - Ok(()) -} - -async fn clear_metrics(store: &SqliteChainStore) -> Result<()> { - let store = store.clone(); - tokio::task::spawn_blocking(move || store.clear_metrics()) - .await - .context("metrics cleanup worker failed")??; - Ok(()) -} - -async fn add_peer(state: &HttpState, peer: String) -> Result<()> { - let peer = validate_peer_address(peer)?; - let addresses = { - let mut peers = state.peers.lock().await; - peers.add_peer(peer); - peers.addresses() - }; - let mut config = state.ui_config.lock().await; - config.peers = addresses; - config_store::save(&state.config_path, &config) -} - -async fn remove_peer(state: &HttpState, peer: String) -> Result<()> { - let peer = validate_peer_address(peer)?; - let addresses = { - let mut peers = state.peers.lock().await; - if !peers.remove_peer(&peer) { - bail!("peer is not configured as an outbound peer"); - } - peers.addresses() - }; - let mut config = state.ui_config.lock().await; - config.peers = addresses; - config_store::save(&state.config_path, &config) -} - -async fn upsert_address_book_entry( - state: &HttpState, - address: String, - name: String, - old_address: Option<String>, -) -> Result<()> { - let address = validate_address_book_address(address)?; - let name = validate_address_book_name(name)?; - let old_address = old_address.map(validate_address_book_address).transpose()?; - let mut config = state.ui_config.lock().await; - if let Some(old_address) = old_address.as_deref() { - if old_address != address { - if config.address_book.contains_key(&address) { - bail!("address is already saved"); - } - config.address_book.remove(old_address); - } - } else if config.address_book.contains_key(&address) { - bail!("address is already saved"); - } - config.address_book.insert(address, name); - config_store::save(&state.config_path, &config) -} - -async fn remove_address_book_entry(state: &HttpState, address: String) -> Result<()> { - let address = validate_address_book_address(address)?; - let mut config = state.ui_config.lock().await; - config.address_book.remove(&address); - config_store::save(&state.config_path, &config) -} - -fn validate_peer_address(peer: String) -> Result<String> { - let peer = peer.trim().to_string(); - if peer.is_empty() { - bail!("peer address is required"); - } - Ok(peer) -} - -fn validate_address_book_address(address: String) -> Result<String> { - let address = address.trim().to_string(); - if address.is_empty() { - bail!("address is required"); - } - validate_address(&address, "address book")?; - Ok(address.to_ascii_lowercase()) -} - -fn validate_address_book_name(name: String) -> Result<String> { - let name = name.trim().to_string(); - if name.is_empty() { - bail!("name is required"); - } - Ok(name) -} - -fn network_health( - status: &NodeStatus, - peers: &[PeerInfo], - mempool: MempoolCounts, -) -> NetworkHealthResponse { - network_health_at(status, peers, mempool, now_ms()) -} - -fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse { - let latest = rows.last().cloned(); - MetricsResponse { - enabled, - latest, - charts: vec![ - metrics_chart( - "block-time", - "Time per block", - "s", - MetricsValueKind::Seconds, - &rows, - |row| { - row.block_time_ms - .filter(|_| row.height > 1) - .map(|ms| ms as f64 / 1_000.0) - }, - ), - metrics_chart( - "difficulty", - "Difficulty", - "bits", - MetricsValueKind::Number, - &rows, - |row| Some(row.mine_difficulty_bits as f64), - ), - metrics_chart( - "supply", - "IUNA in circulation", - "IUNA", - MetricsValueKind::Iuna, - &rows, - |row| Some(micro_iuna_as_iuna(row.circulating_supply)), - ), - metrics_chart( - "known-wallet-addresses", - "Known wallet addresses", - "addresses", - MetricsValueKind::Number, - &rows, - |row| Some(row.known_wallet_addresses as f64), - ), - metrics_chart( - "transactions", - "Transactions", - "tx", - MetricsValueKind::Number, - &rows, - |row| Some(row.transaction_count as f64), - ), - metrics_chart( - "burn-count", - "Burn transactions", - "burns", - MetricsValueKind::Number, - &rows, - |row| Some(row.burn_count as f64), - ), - metrics_chart( - "burn-amount", - "Burn amount", - "IUNA", - MetricsValueKind::Iuna, - &rows, - |row| Some(micro_iuna_as_iuna(row.burned_amount)), - ), - metrics_chart( - "total-burn", - "Total burn", - "IUNA", - MetricsValueKind::Iuna, - &rows, - |row| Some(micro_iuna_as_iuna(row.total_burned_amount)), - ), - metrics_chart( - "fees", - "Fees", - "IUNA", - MetricsValueKind::Iuna, - &rows, - |row| Some(micro_iuna_as_iuna(row.fees_amount)), - ), - metrics_chart( - "mine-actions", - "Mine actions", - "mine", - MetricsValueKind::Number, - &rows, - |row| Some(row.mine_count as f64), - ), - metrics_chart( - "vdf-rounds", - "VDF rounds", - "rounds", - MetricsValueKind::Number, - &rows, - |row| (row.vdf_rounds > 0).then_some(row.vdf_rounds as f64), - ), - ], - } -} - -fn metrics_chart( - id: &'static str, - title: &'static str, - unit: &'static str, - value_kind: MetricsValueKind, - rows: &[BlockMetricRow], - value: impl Fn(&BlockMetricRow) -> Option<f64>, -) -> MetricsChart { - MetricsChart { - id, - title, - unit, - value_kind, - points: rows - .iter() - .filter_map(|row| { - value(row).map(|value| MetricsPoint { - height: row.height, - value, - }) - }) - .collect(), - } -} - -fn micro_iuna_as_iuna(amount: Amount) -> f64 { - amount as f64 / 1_000_000.0 -} - -fn network_health_at( - status: &NodeStatus, - peers: &[PeerInfo], - mempool: MempoolCounts, - now_ms: u64, -) -> NetworkHealthResponse { - let local_height = status.chain.height; - let remote_best_height = peers.iter().filter_map(|peer| peer.last_known_height).max(); - let best_known_height = remote_best_height.unwrap_or(local_height).max(local_height); - let healthy_heights = peers - .iter() - .filter(|peer| peer.last_error.is_none()) - .filter_map(|peer| peer.last_known_height) - .collect::<Vec<_>>(); - let shared_height = healthy_heights - .iter() - .copied() - .min() - .unwrap_or(local_height) - .min(local_height); - let outbound_peers = peers - .iter() - .filter(|peer| peer.direction != PeerDirection::Inbound) - .count(); - let inbound_peers = peers - .iter() - .filter(|peer| peer.direction == PeerDirection::Inbound) - .count(); - let healthy_peers = peers - .iter() - .filter(|peer| peer.last_error.is_none() && peer.last_known_height.is_some()) - .count(); - let failed_peers = peers - .iter() - .filter(|peer| peer.last_error.is_some()) - .count(); - let stale_peers = peers - .iter() - .filter(|peer| { - peer.last_success_ms.is_some_and(|last_success| { - now_ms.saturating_sub(last_success) > PEER_STALE_AFTER_MS - }) - }) - .count(); - let banned_peers = peers - .iter() - .filter(|peer| peer.is_banned_at(now_ms)) - .count(); - let network_time_offset_ms = median_peer_clock_offset(peers, now_ms); - let bad_clock_peers = peers - .iter() - .filter(|peer| { - peer.last_clock_observed_ms.is_some_and(|observed_ms| { - now_ms.saturating_sub(observed_ms) <= PEER_STALE_AFTER_MS - }) - }) - .filter(|peer| peer.last_clock_offset_accepted == Some(false)) - .count(); - let lag_blocks = best_known_height.saturating_sub(local_height); - let last_error = peers.iter().rev().find_map(|peer| { - peer.last_error - .as_ref() - .map(|error| format!("{}: {error}", peer.address)) - }); - - let state = if peers.is_empty() { - "isolated" - } else if banned_peers > 0 && healthy_peers == 0 { - "banned" - } else if lag_blocks > 0 { - "syncing" - } else if failed_peers > 0 && healthy_peers == 0 { - "peer errors" - } else if stale_peers > 0 && healthy_peers == stale_peers { - "stale" - } else if remote_best_height.is_some_and(|height| local_height > height) { - "ahead of peers" - } else { - "healthy" - } - .to_string(); - - NetworkHealthResponse { - ok: !peers.is_empty() && lag_blocks == 0 && healthy_peers > stale_peers, - state, - local_height, - best_known_height, - shared_height, - lag_blocks, - outbound_peers, - inbound_peers, - healthy_peers, - failed_peers, - stale_peers, - banned_peers, - pending_transactions: status.chain.pending_transactions, - pending_plain_transactions: mempool.plain_transactions, - pending_blinded_transactions: mempool.blinded_transactions, - pending_blinded_reveals: mempool.blinded_reveals, - network_time_offset_ms, - bad_clock_peers, - last_error, - } -} - -fn median_peer_clock_offset(peers: &[PeerInfo], now_ms: u64) -> Option<i64> { - let mut offsets = peers - .iter() - .filter(|peer| peer.last_error.is_none()) - .filter(|peer| !peer.is_banned_at(now_ms)) - .filter(|peer| peer.last_clock_offset_accepted == Some(true)) - .filter(|peer| { - peer.last_clock_observed_ms.is_some_and(|observed_ms| { - now_ms.saturating_sub(observed_ms) <= PEER_STALE_AFTER_MS - }) - }) - .filter_map(|peer| peer.last_clock_offset_ms) - .collect::<Vec<_>>(); - if offsets.is_empty() { - return None; - } - offsets.sort_unstable(); - Some(offsets[offsets.len() / 2]) -} - -async fn wallet_setup_response( - state: &HttpState, - headers: &HeaderMap, -) -> Result<WalletSetupResponse> { - let setup_complete = state.ui_config.lock().await.setup_complete; - let password = wallet_password_for_request(state, headers).await; - let seed_phrase = if setup_complete { - None - } else { - wallet_store::setup_seed_phrase_with_password(&state.wallet_path, password.as_deref())? - }; - let address = state.node.lock().await.wallet_address().to_string(); - Ok(WalletSetupResponse { - ok: true, - error: None, - address: Some(address), - seed_phrase, - dev_verify_bypass: dev_seed_verify_bypass_enabled(), - requires_peer: setup_requires_peer(state).await, - }) -} - -async fn setup_requires_peer(state: &HttpState) -> bool { - !state.node.lock().await.has_real_chain() -} - -fn wallet_transaction_rows( - wallet: &str, - pending: Vec<Transaction>, - owned_blinded: Vec<Transaction>, - chain: &[Block], - revealed_by_height: &BTreeMap<u64, Vec<RevealedBlindedTransaction>>, - outputs: &BTreeMap<OutPoint, TxOutput>, - filters: WalletTransactionFilters, -) -> Vec<WalletTransactionRow> { - let mut rows = Vec::new(); - let pending_context = WalletTransactionContext { - status: "pending", - block_height: None, - timestamp_ms: None, - block_finalizer: None, - blinded: false, - }; - - for (index, tx) in pending.iter().enumerate() { - if !filters.allows(tx) { - continue; - } - if let Some(row) = wallet_transaction_row(wallet, tx, outputs, &pending_context) { - rows.push((u128::MAX - index as u128, row)); - } - } - - let pending_blind_context = WalletTransactionContext { - blinded: true, - ..pending_context - }; - for (index, tx) in owned_blinded.iter().enumerate() { - if !filters.allows(tx) { - continue; - } - if let Some(row) = wallet_transaction_row(wallet, tx, outputs, &pending_blind_context) { - rows.push((u128::MAX - 10_000 - index as u128, row)); - } - } - - for block in chain { - for (index, tx) in block.transactions.iter().rev().enumerate() { - if !filters.allows(tx) { - continue; - } - if let Some(row) = wallet_transaction_row( - wallet, - tx, - outputs, - &WalletTransactionContext { - status: "confirmed", - block_height: Some(block.height), - timestamp_ms: Some(block.timestamp_ms), - block_finalizer: Some(block.miner.clone()), - blinded: false, - }, - ) { - rows.push((block.height as u128 * 10_000 + index as u128, row)); - } - } - if let Some(revealed_transactions) = revealed_by_height.get(&block.height) { - for (index, revealed) in revealed_transactions.iter().rev().enumerate() { - let tx = &revealed.transaction; - if !filters.allows(tx) { - continue; - } - if let Some(row) = wallet_transaction_row( - wallet, - tx, - outputs, - &WalletTransactionContext { - status: "confirmed", - block_height: Some(block.height), - timestamp_ms: Some(block.timestamp_ms), - block_finalizer: Some(block.miner.clone()), - blinded: false, - }, - ) { - rows.push((block.height as u128 * 10_000 + 5_000 + index as u128, row)); - } - } - } - } - - rows.sort_by(|left, right| right.0.cmp(&left.0)); - rows.into_iter().map(|(_, row)| row).collect() -} - -fn wallet_transaction_row( - wallet: &str, - tx: &Transaction, - outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, - context: &WalletTransactionContext, -) -> Option<WalletTransactionRow> { - match tx { - Transaction::Transfer { - inputs, - outputs, - fee, - signature, - } if tx.sender() == wallet || tx.to() == Some(wallet) => Some(WalletTransactionRow { - kind: "transfer", - from: tx.sender().to_string(), - to: tx.to().map(str::to_string), - amount: tx.amount(), - fee: *fee, - inputs: ui_inputs(inputs, outputs_by_outpoint), - outputs: outputs.clone(), - change: Vec::new(), - signature: signature.clone(), - status: context.status, - block_height: context.block_height, - timestamp_ms: context.timestamp_ms, - block_finalizer: context.block_finalizer.clone(), - direction: if tx.to() == Some(wallet) { - "received" - } else { - "sent" - }, - blinded: context.blinded, - difficulty_bits: None, - proof_bits: None, - proof_hash: None, - }), - Transaction::Burn { - inputs, - change, - amount, - fee, - signature, - } if tx.sender() == wallet => Some(WalletTransactionRow { - kind: "burn", - from: tx.sender().to_string(), - to: None, - amount: *amount, - fee: *fee, - inputs: ui_inputs(inputs, outputs_by_outpoint), - outputs: Vec::new(), - change: change.clone(), - signature: signature.clone(), - status: context.status, - block_height: context.block_height, - timestamp_ms: context.timestamp_ms, - block_finalizer: context.block_finalizer.clone(), - direction: "burned", - blinded: context.blinded, - difficulty_bits: None, - proof_bits: None, - proof_hash: None, - }), - Transaction::Mine { - recipient, - difficulty_bits, - signature, - .. - } if recipient == wallet => Some(WalletTransactionRow { - kind: "mine", - from: "pow".to_string(), - to: Some(recipient.clone()), - amount: MINE_REWARD, - fee: tx.fee(), - inputs: Vec::new(), - outputs: vec![TxOutput { - address: recipient.clone(), - amount: MINE_REWARD, - }], - change: Vec::new(), - signature: signature.clone(), - status: context.status, - block_height: context.block_height, - timestamp_ms: context.timestamp_ms, - block_finalizer: context.block_finalizer.clone(), - direction: "received", - blinded: context.blinded, - difficulty_bits: Some(*difficulty_bits), - proof_bits: Some(proof_bits(signature)), - proof_hash: Some(signature.clone()), - }), - _ => None, - } -} - -fn revealed_transactions_by_height( - snapshot: &ChainSnapshot, -) -> BTreeMap<u64, Vec<RevealedBlindedTransaction>> { - revealed_blinded_transactions(snapshot) - .unwrap_or_default() - .into_iter() - .fold( - BTreeMap::<u64, Vec<RevealedBlindedTransaction>>::new(), - |mut by_height, revealed| { - by_height.entry(revealed.height).or_default().push(revealed); - by_height - }, - ) -} - -#[cfg(test)] -fn ui_blocks( - blocks: Vec<Block>, - snapshot: &ChainSnapshot, - pending: &[Transaction], - burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, -) -> Vec<UiBlock> { - let outputs = known_output_index(snapshot, pending); - let revealed = revealed_transactions_by_height(snapshot); - ui_blocks_from_indexes(blocks, &outputs, &revealed, burn_leader_ranks) -} - -fn ui_blocks_from_indexes( - blocks: Vec<Block>, - outputs: &BTreeMap<OutPoint, TxOutput>, - revealed: &BTreeMap<u64, Vec<RevealedBlindedTransaction>>, - burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, -) -> Vec<UiBlock> { - blocks - .into_iter() - .map(|block| { - let revealed_transactions = revealed.get(&block.height).cloned().unwrap_or_default(); - ui_block(block, outputs, burn_leader_ranks, &revealed_transactions) - }) - .collect() -} - -fn ui_block( - block: Block, - outputs: &BTreeMap<OutPoint, TxOutput>, - burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, - revealed_transactions: &[RevealedBlindedTransaction], -) -> UiBlock { - let ranks = burn_leader_ranks - .get(&block.hash) - .cloned() - .unwrap_or_default(); - let revealed_fees = revealed_transactions.iter().fold(0_u64, |total, revealed| { - total.saturating_add(revealed.transaction.fee()) - }); - let transaction_bytes = block - .transactions - .iter() - .map(|tx| tx.serialized_size_bytes().unwrap_or_default()) - .sum::<usize>(); - let transaction_byte_breakdown = transaction_byte_breakdown(&block.transactions); - let blinded_transaction_bytes = block - .blinded_transactions - .iter() - .map(|tx| tx.serialized_size_bytes().unwrap_or_default()) - .sum::<usize>(); - let mut transactions = block - .transactions - .iter() - .map(|tx| ui_transaction(tx, outputs)) - .collect::<Vec<_>>(); - transactions.extend( - block - .blinded_transactions - .iter() - .map(|transaction| ui_blinded_transaction(transaction, outputs)), - ); - transactions.extend( - revealed_transactions - .iter() - .map(|revealed| ui_revealed_transaction(&revealed.transaction, outputs)), - ); - let revealed_by_commitment = revealed_transactions - .iter() - .map(|revealed| (revealed.commitment.clone(), revealed.transaction.clone())) - .collect::<BTreeMap<_, _>>(); - let reveal_bundles: Vec<UiRevealBundle> = block - .reveal_bundle_section - .expand(block.height, &block.prev_hash) - .into_iter() - .map(|bundle| UiRevealBundle { - slot: bundle.slot, - member: bundle.member.clone(), - hash: bundle.bundle_hash(), - byte_size: bundle.serialized_size_bytes().unwrap_or_default(), - reveals: bundle - .reveals - .iter() - .map(|reveal| { - revealed_by_commitment - .get(&reveal.commitment) - .map(|tx| ui_revealed_transaction(tx, outputs)) - .unwrap_or_else(|| ui_blinded_reveal(reveal)) - }) - .collect(), - }) - .collect(); - let reveal_bundle_bytes = reveal_bundles - .iter() - .map(|bundle: &UiRevealBundle| bundle.byte_size) - .sum::<usize>(); - let total_bytes = block.serialized_size_bytes().unwrap_or_else(|_| { - transaction_bytes - .saturating_add(blinded_transaction_bytes) - .saturating_add(reveal_bundle_bytes) - }); - UiBlock { - height: block.height, - prev_hash: block.prev_hash, - timestamp_ms: block.timestamp_ms, - miner: block.miner, - finalizer_mode: block.finalizer_mode, - finalizer_rank: block.finalizer_rank, - reward: block.reward, - total_fees: block.reward.saturating_add(revealed_fees), - total_bytes, - transaction_bytes, - transaction_byte_breakdown, - blinded_transaction_bytes, - reveal_bundle_bytes, - vdf_rounds: block.vdf_rounds, - vdf_output: block.vdf_output, - leader_proof: block.leader_proof, - burn_leader_ranks: ranks, - transactions, - revealed_transactions: revealed_transactions - .iter() - .map(|revealed| ui_revealed_transaction(&revealed.transaction, outputs)) - .collect(), - reveal_bundles, - hash: block.hash, - } -} - -fn ui_revealed_transaction( - transaction: &Transaction, - outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, -) -> UiTransaction { - let mut row = ui_transaction(transaction, outputs_by_outpoint); - row.revealed = true; - row -} - -fn transaction_byte_breakdown(transactions: &[Transaction]) -> Vec<UiByteBreakdown> { - let mut transfer_bytes = 0_usize; - let mut burn_bytes = 0_usize; - let mut mine_bytes = 0_usize; - for transaction in transactions { - let bytes = transaction.serialized_size_bytes().unwrap_or_default(); - match transaction { - Transaction::Transfer { .. } => transfer_bytes = transfer_bytes.saturating_add(bytes), - Transaction::Burn { .. } => burn_bytes = burn_bytes.saturating_add(bytes), - Transaction::Mine { .. } => mine_bytes = mine_bytes.saturating_add(bytes), - } - } - [ - ("transfer", transfer_bytes), - ("burn", burn_bytes), - ("mine", mine_bytes), - ] - .into_iter() - .filter_map(|(label, bytes)| (bytes > 0).then_some(UiByteBreakdown { label, bytes })) - .collect() -} - -fn ui_pending_revealed_transaction( - revealed: &RevealedBlindedTransaction, - outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, -) -> UiTransaction { - let mut row = ui_revealed_transaction(&revealed.transaction, outputs_by_outpoint); - row.commitment = Some(revealed.commitment.clone()); - row -} - -fn ui_transaction( - transaction: &Transaction, - outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, -) -> UiTransaction { - match transaction { - Transaction::Transfer { - inputs, - outputs, - fee, - signature, - } => UiTransaction { - kind: "transfer", - from: transaction.sender().to_string(), - to: transaction.to().map(str::to_string), - amount: transaction.amount(), - fee: *fee, - inputs: ui_inputs(inputs, outputs_by_outpoint), - outputs: outputs.clone(), - change: Vec::new(), - signature: signature.clone(), - difficulty_bits: None, - proof_bits: None, - proof_hash: None, - commitment: None, - encrypted_size: None, - expires_at_height: None, - revealed: false, - }, - Transaction::Burn { - inputs, - change, - amount, - fee, - signature, - } => UiTransaction { - kind: "burn", - from: transaction.sender().to_string(), - to: None, - amount: *amount, - fee: *fee, - inputs: ui_inputs(inputs, outputs_by_outpoint), - outputs: Vec::new(), - change: change.clone(), - signature: signature.clone(), - difficulty_bits: None, - proof_bits: None, - proof_hash: None, - commitment: None, - encrypted_size: None, - expires_at_height: None, - revealed: false, - }, - Transaction::Mine { - recipient, - difficulty_bits, - signature, - .. - } => UiTransaction { - kind: "mine", - from: "pow".to_string(), - to: Some(recipient.clone()), - amount: MINE_REWARD, - fee: transaction.fee(), - inputs: Vec::new(), - outputs: vec![TxOutput { - address: recipient.clone(), - amount: MINE_REWARD, - }], - change: Vec::new(), - signature: signature.clone(), - difficulty_bits: Some(*difficulty_bits), - proof_bits: Some(proof_bits(signature)), - proof_hash: Some(signature.clone()), - commitment: None, - encrypted_size: None, - expires_at_height: None, - revealed: false, - }, - } -} - -fn ui_blinded_transaction( - transaction: &BlindedTransaction, - outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, -) -> UiTransaction { - UiTransaction { - kind: "blinded", - from: transaction - .inputs - .first() - .map(|input| input.owner.clone()) - .unwrap_or_else(|| "encrypted".to_string()), - to: None, - amount: 0, - fee: transaction.fee, - inputs: ui_inputs(&transaction.inputs, outputs_by_outpoint), - outputs: Vec::new(), - change: Vec::new(), - signature: transaction.commitment.clone(), - difficulty_bits: None, - proof_bits: None, - proof_hash: None, - commitment: Some(transaction.commitment.clone()), - encrypted_size: Some(transaction.encrypted_size), - expires_at_height: Some(transaction.expires_at_height), - revealed: false, - } -} - -fn ui_blinded_reveal(reveal: &BlindedReveal) -> UiTransaction { - UiTransaction { - kind: "reveal", - from: "encrypted".to_string(), - to: None, - amount: 0, - fee: 0, - inputs: Vec::new(), - outputs: Vec::new(), - change: Vec::new(), - signature: reveal.commitment.clone(), - difficulty_bits: None, - proof_bits: None, - proof_hash: None, - commitment: Some(reveal.commitment.clone()), - encrypted_size: None, - expires_at_height: None, - revealed: false, - } -} - -fn ui_inputs( - inputs: &[TxInput], - outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, -) -> Vec<UiTxInput> { - inputs - .iter() - .map(|input| { - let spent_output = outputs_by_outpoint.get(&input.outpoint); - UiTxInput { - outpoint: input.outpoint.clone(), - owner: input.owner.clone(), - signature: input.signature.clone(), - amount: spent_output.map(|output| output.amount), - address: spent_output.map(|output| output.address.clone()), - } - }) - .collect() -} - -fn proof_bits(hex_hash: &str) -> u32 { - let mut bits = 0_u32; - for byte in hex_hash.as_bytes() { - let Some(nibble) = hex_nibble(*byte) else { - break; - }; - if nibble == 0 { - bits += 4; - continue; - } - bits += nibble.leading_zeros() - 4; - break; - } - bits -} - -fn hex_nibble(byte: u8) -> Option<u8> { - match byte { - b'0'..=b'9' => Some(byte - b'0'), - b'a'..=b'f' => Some(byte - b'a' + 10), - b'A'..=b'F' => Some(byte - b'A' + 10), - _ => None, - } + .as_millis() as u64 } #[cfg(test)] -fn known_output_index( - snapshot: &ChainSnapshot, - pending: &[Transaction], -) -> BTreeMap<OutPoint, TxOutput> { - let mut outputs = known_chain_output_index(snapshot); - add_pending_outputs(&mut outputs, pending); - outputs -} - -async fn cached_chain_view(state: &HttpState, snapshot: &ChainSnapshot) -> UiChainView { - let tip_hash = snapshot.blocks.last().map(|block| block.hash.clone()); - { - let cache = state.ui_cache.lock().await; - if cache.tip_hash == tip_hash { - return UiChainView { - outputs: cache.outputs.clone(), - revealed_by_height: cache.revealed_by_height.clone(), - }; - } - } - - let outputs = known_chain_output_index(snapshot); - let revealed_by_height = revealed_transactions_by_height(snapshot); - - let mut cache = state.ui_cache.lock().await; - if cache.tip_hash == tip_hash { - return UiChainView { - outputs: cache.outputs.clone(), - revealed_by_height: cache.revealed_by_height.clone(), - }; - } - - let view = UiChainView { - outputs, - revealed_by_height, - }; - cache.tip_hash = tip_hash; - cache.outputs = view.outputs.clone(); - cache.revealed_by_height = view.revealed_by_height.clone(); - UiChainView { - outputs: view.outputs, - revealed_by_height: view.revealed_by_height, - } -} - -fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOutput> { - let mut outputs = BTreeMap::new(); - for (address, amount) in &snapshot.genesis_allocations { - if *amount == 0 { - continue; - } - outputs.insert( - genesis_allocation_outpoint(address), - TxOutput { - address: address.clone(), - amount: *amount, - }, - ); - } - let revealed = revealed_blinded_transactions(snapshot).unwrap_or_default(); - let blocks_by_height = snapshot - .blocks - .iter() - .map(|block| (block.height, block)) - .collect::<BTreeMap<_, _>>(); - let reveal_bundle_slots_by_height = Ledger::from_persisted_snapshot(snapshot.clone()) - .ok() - .map(|ledger| { - snapshot - .blocks - .iter() - .map(|block| { - let slots = ledger - .burn_leader_ranks_for_block(block.height) - .map(|ranks| ranks.len()) - .unwrap_or(REVEAL_COMMITTEE_SIZE); - (block.height, slots) - }) - .collect::<BTreeMap<_, _>>() - }) - .unwrap_or_default(); - let blinded_by_commitment = snapshot - .blocks - .iter() - .flat_map(|block| block.blinded_transactions.iter()) - .map(|transaction| (transaction.commitment.clone(), transaction.clone())) - .collect::<BTreeMap<_, _>>(); - for block in &snapshot.blocks { - for transaction in &block.transactions { - index_transaction_outputs(&mut outputs, transaction); - } - if block.reward > 0 { - outputs.insert( - reward_outpoint(&block.hash), - TxOutput { - address: block.miner.clone(), - amount: block.reward, - }, - ); - } - } - for revealed in revealed { - index_transaction_outputs(&mut outputs, &revealed.transaction); - let fee = revealed.transaction.fee(); - if matches!(revealed.transaction, Transaction::Mine { .. }) { - if let Some(commit) = blinded_by_commitment.get(&revealed.commitment) { - index_blinded_collateral_change(&mut outputs, commit, fee); - } - } - if fee > 0 { - let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); - if committer_fee > 0 { - outputs.insert( - blinded_committer_fee_outpoint(&revealed.commitment), - TxOutput { - address: revealed.included_by, - amount: committer_fee, - }, - ); - } - if let Some(block) = blocks_by_height.get(&revealed.height) { - let reveal_finalizer_fee = blinded_reveal_finalizer_fee( - fee, - block.included_reveal_bundle_count(), - reveal_bundle_slots_by_height - .get(&revealed.height) - .copied() - .unwrap_or(REVEAL_COMMITTEE_SIZE), - ); - if reveal_finalizer_fee > 0 { - outputs.insert( - blinded_executor_fee_outpoint(&revealed.commitment), - TxOutput { - address: block.miner.clone(), - amount: reveal_finalizer_fee, - }, - ); - } - let reveal_bundle_signer_fee = - blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); - if reveal_bundle_signer_fee > 0 { - for signature in &block.reveal_bundle_section.signatures { - outputs.insert( - blinded_reveal_bundle_signer_fee_outpoint( - &revealed.commitment, - signature.slot, - ), - TxOutput { - address: signature.member.clone(), - amount: reveal_bundle_signer_fee, - }, - ); - } - } - } - } - } - index_expired_blinded_outputs(&mut outputs, snapshot); - outputs -} - -fn add_pending_outputs(outputs: &mut BTreeMap<OutPoint, TxOutput>, pending: &[Transaction]) { - for transaction in pending { - index_transaction_outputs(outputs, transaction); - } -} - -fn index_blinded_collateral_change( - outputs: &mut BTreeMap<OutPoint, TxOutput>, - transaction: &BlindedTransaction, - fee: Amount, -) { - let Some(first_input) = transaction.inputs.first() else { - return; - }; - let locked_total = transaction.inputs.iter().fold(0_u64, |total, input| { - total.saturating_add( - outputs - .get(&input.outpoint) - .map(|output| output.amount) - .unwrap_or_default(), - ) - }); - if fee >= locked_total { - return; - } - outputs.insert( - blinded_expiry_change_outpoint(&transaction.commitment), - TxOutput { - address: first_input.owner.clone(), - amount: locked_total - fee, - }, - ); -} - -fn index_expired_blinded_outputs( - outputs: &mut BTreeMap<OutPoint, TxOutput>, - snapshot: &ChainSnapshot, -) { - let mut active = BTreeMap::<String, (BlindedTransaction, Amount)>::new(); - for block in &snapshot.blocks { - let revealed = block - .all_blinded_reveals() - .into_iter() - .map(|reveal| reveal.commitment.clone()) - .collect::<BTreeSet<_>>(); - active.retain(|commitment, (transaction, locked_total)| { - if revealed.contains(commitment) { - return false; - } - if block.height >= transaction.expires_at_height { - if let Some(first_input) = transaction.inputs.first() { - if transaction.fee <= *locked_total { - let change = *locked_total - transaction.fee; - if change > 0 { - outputs.insert( - blinded_expiry_change_outpoint(commitment), - TxOutput { - address: first_input.owner.clone(), - amount: change, - }, - ); - } - } - } - return false; - } - true - }); - for transaction in &block.blinded_transactions { - let locked_total = transaction.inputs.iter().fold(0_u64, |total, input| { - total.saturating_add( - outputs - .get(&input.outpoint) - .map(|output| output.amount) - .unwrap_or_default(), - ) - }); - active.insert( - transaction.commitment.clone(), - (transaction.clone(), locked_total), - ); - } - } -} - -fn index_transaction_outputs( - outputs: &mut BTreeMap<OutPoint, TxOutput>, - transaction: &Transaction, -) { - let created_outputs = match transaction { - Transaction::Transfer { outputs, .. } => outputs.clone(), - Transaction::Burn { change, .. } => change.clone(), - Transaction::Mine { recipient, .. } => vec![TxOutput { - address: recipient.clone(), - amount: MINE_REWARD, - }], - }; - for (index, output) in created_outputs.iter().enumerate() { - outputs.insert( - OutPoint { - txid: transaction.signature().to_string(), - index: index as u32, - }, - output.clone(), - ); - } -} - -fn genesis_allocation_outpoint(address: &str) -> OutPoint { - OutPoint { - txid: hex_hash(format!("iuna-genesis-allocation:{address}")), - index: 0, - } -} - -fn reward_outpoint(block_hash: &str) -> OutPoint { - OutPoint { - txid: block_hash.to_string(), - index: u32::MAX, - } -} - -fn blinded_committer_fee_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 1, - } -} - -fn blinded_executor_fee_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 2, - } -} - -fn blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 3 - u32::from(slot), - } -} - -fn blinded_expiry_change_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: 0, - } -} - -fn blinded_fee_share(fee: Amount, bps: u64) -> Amount { - ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount -} - -async fn replace_setup_wallet_with_generated_seed( - state: &HttpState, - headers: &HeaderMap, -) -> Result<WalletSetupResponse> { - ensure_wallet_setup_open(state).await?; - let password = wallet_password_for_request(state, headers) - .await - .context("wallet password session is required")?; - let (wallet, seed_phrase) = - wallet_store::replace_with_generated_seed_phrase_encrypted(&state.wallet_path, &password)?; - let address = wallet.address().to_string(); - state.node.lock().await.replace_wallet(wallet); - Ok(WalletSetupResponse { - ok: true, - error: None, - address: Some(address), - seed_phrase: Some(seed_phrase), - dev_verify_bypass: dev_seed_verify_bypass_enabled(), - requires_peer: setup_requires_peer(state).await, - }) -} - -async fn import_setup_wallet_seed( - state: &HttpState, - headers: &HeaderMap, - seed_phrase: &str, -) -> Result<WalletSetupResponse> { - ensure_wallet_setup_open(state).await?; - let password = wallet_password_for_request(state, headers) - .await - .context("wallet password session is required")?; - let wallet = wallet_store::replace_with_imported_seed_phrase_encrypted( - &state.wallet_path, - seed_phrase, - &password, - )?; - let address = wallet.address().to_string(); - state.node.lock().await.replace_wallet(wallet); - Ok(WalletSetupResponse { - ok: true, - error: None, - address: Some(address), - seed_phrase: None, - dev_verify_bypass: dev_seed_verify_bypass_enabled(), - requires_peer: setup_requires_peer(state).await, - }) -} - -async fn ensure_wallet_setup_open(state: &HttpState) -> Result<()> { - let setup_complete = state.ui_config.lock().await.setup_complete; - if setup_complete { - bail!("wallet setup is already complete"); - } - Ok(()) -} - -fn wallet_setup_json(result: Result<WalletSetupResponse>) -> Json<WalletSetupResponse> { - match result { - Ok(response) => Json(response), - Err(error) => Json(WalletSetupResponse { - ok: false, - error: Some(format!("{error:#}")), - address: None, - seed_phrase: None, - dev_verify_bypass: dev_seed_verify_bypass_enabled(), - requires_peer: false, - }), - } -} - -fn dev_seed_verify_bypass_enabled() -> bool { - dev_seed_verify_bypass_allowed(std::env::var_os("IUNA_DEV_SKIP_SEED_VERIFY").is_some()) -} - -fn dev_seed_verify_bypass_allowed(env_present: bool) -> bool { - env_present -} - -async fn transfer(state: &HttpState, form: TransferForm) -> Result<()> { - let (to, amount, fee_per_byte, selected_utxos) = validate_transfer_form(form)?; - - let result = { - let mut node = state.node.lock().await; - let result = node.transfer_with_fee_rate(to, amount, fee_per_byte, &selected_utxos); - let outbox = node.drain_outbox(); - (result, outbox) - }; - - match result.0 { - Ok(_) => state.gossip.broadcast(result.1).await, - Err(error) => Err(error), - } -} - -fn validate_transfer_form(form: TransferForm) -> Result<(String, Amount, Amount, Vec<OutPoint>)> { - let to = form.to.trim(); - if to.is_empty() { - bail!("recipient is required"); - } - if form.amount == 0 { - bail!("amount must be greater than zero"); - } - let fee = required_fee_per_byte_transfer(&form)?; - let selected_utxos = form - .utxos - .lines() - .flat_map(|line| line.split(',')) - .map(str::trim) - .filter(|value| !value.trim().is_empty()) - .map(parse_outpoint) - .collect::<Result<Vec<_>>>()?; - Ok((to.to_string(), form.amount, fee, selected_utxos)) -} - -async fn estimate_transfer_fee(state: &HttpState, form: TransferForm) -> Result<FeeEstimate> { - let (to, amount, fee_per_byte, selected_utxos) = validate_transfer_form(form)?; - state - .node - .lock() - .await - .estimate_transfer_fee(to, amount, fee_per_byte, &selected_utxos) -} - -async fn estimate_burn_fee(state: &HttpState, form: BurnSettingsForm) -> Result<FeeEstimate> { - let fee_per_byte = required_fee_per_byte_burn(&form)?; - if form.amount == 0 { - bail!("amount must be greater than zero"); - } - state - .node - .lock() - .await - .estimate_burn_fee(form.amount, fee_per_byte) -} - -async fn estimate_mine_fee(state: &HttpState) -> Result<FeeEstimate> { - state - .node - .lock() - .await - .estimate_mine_fee(MINE_FINALIZER_FEE) -} - -fn required_fee_per_byte_transfer(form: &TransferForm) -> Result<Amount> { - form.fee_per_byte.context("fee per byte is required") -} - -fn required_fee_per_byte_burn(form: &BurnSettingsForm) -> Result<Amount> { - form.fee_per_byte.context("fee per byte is required") -} - -fn fee_estimate_json(result: Result<FeeEstimate>) -> Json<FeeEstimateResponse> { - match result { - Ok(estimate) => Json(FeeEstimateResponse { - ok: true, - error: None, - bytes: Some(estimate.bytes), - fee: Some(estimate.fee), - }), - Err(error) => Json(FeeEstimateResponse { - ok: false, - error: Some(format!("{error:#}")), - bytes: None, - fee: None, - }), - } -} - -fn parse_outpoint(value: &str) -> Result<OutPoint> { - let (txid, index) = value - .rsplit_once(':') - .with_context(|| format!("invalid UTXO reference {value}"))?; - if txid.is_empty() { - bail!("invalid UTXO reference {value}"); - } - Ok(OutPoint { - txid: txid.to_string(), - index: index - .parse::<u32>() - .with_context(|| format!("invalid UTXO reference {value}"))?, - }) -} - -fn action_json(result: Result<()>) -> Json<ActionResponse> { - match result { - Ok(_) => Json(ActionResponse { - ok: true, - error: None, - }), - Err(error) => Json(ActionResponse { - ok: false, - error: Some(format!("{error:#}")), - }), - } -} - -fn auth_error(message: &str) -> (StatusCode, Json<ActionResponse>) { - ( - StatusCode::UNAUTHORIZED, - Json(ActionResponse { - ok: false, - error: Some(message.to_string()), - }), - ) -} - -fn csrf_error() -> (StatusCode, Json<ActionResponse>) { - ( - StatusCode::FORBIDDEN, - Json(ActionResponse { - ok: false, - error: Some("same-origin request required".to_string()), - }), - ) -} - -fn api_error(error: anyhow::Error) -> Json<ActionResponse> { - Json(ActionResponse { - ok: false, - error: Some(format!("{error:#}")), - }) -} - -fn auth_client_key(headers: &HeaderMap, socket_addr: Option<SocketAddr>) -> String { - if let Some(addr) = socket_addr { - if !trusted_forwarding_peer(addr.ip()) { - return addr.ip().to_string(); - } - } - forwarded_for_client(headers) - .or_else(|| header_string(headers, "x-real-ip")) - .or_else(|| forwarded_header_client(headers)) - .or_else(|| socket_addr.map(|addr| addr.ip().to_string())) - .unwrap_or_else(|| UNKNOWN_CLIENT_KEY.to_string()) -} - -fn trusted_forwarding_peer(ip: std::net::IpAddr) -> bool { - match ip { - std::net::IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), - std::net::IpAddr::V6(ip) => { - ip.is_loopback() || ipv6_is_unique_local(ip) || ipv6_is_unicast_link_local(ip) - } - } -} - -fn ipv6_is_unique_local(ip: std::net::Ipv6Addr) -> bool { - (ip.segments()[0] & 0xfe00) == 0xfc00 -} - -fn ipv6_is_unicast_link_local(ip: std::net::Ipv6Addr) -> bool { - (ip.segments()[0] & 0xffc0) == 0xfe80 -} - -fn forwarded_for_client(headers: &HeaderMap) -> Option<String> { - header_string(headers, "x-forwarded-for").and_then(|value| { - value - .split(',') - .next() - .map(str::trim) - .filter(|client| !client.is_empty()) - .map(ToOwned::to_owned) - }) -} - -fn forwarded_header_client(headers: &HeaderMap) -> Option<String> { - let value = header_string(headers, "forwarded")?; - for item in value.split(';') { - let Some((name, value)) = item.split_once('=') else { - continue; - }; - if name.trim().eq_ignore_ascii_case("for") { - return Some( - value - .trim() - .trim_matches('"') - .trim_matches('[') - .trim_matches(']') - .to_string(), - ) - .filter(|client| !client.is_empty()); - } - } - None -} - -async fn setup_auth_password( - state: &HttpState, - password: &str, - client_key: &str, -) -> Result<String> { - check_auth_backoff(state, client_key).await?; - if let Err(error) = validate_password(password) { - record_auth_failure(state, client_key).await; - return Err(error); - } - let mut config = state.ui_config.lock().await; - if config.auth_password_hash.is_some() { - record_auth_failure(state, client_key).await; - bail!("authentication is already configured"); - } - config.auth_password_hash = Some(hash_password(password)?); - config_store::save(&state.config_path, &config)?; - drop(config); - wallet_store::encrypt_existing_with_password(&state.wallet_path, password)?; - let wallet = wallet_store::load_with_password(&state.wallet_path, password)?; - restore_node_wallet_from_store(state, wallet, Some(password)).await?; - clear_auth_backoff(state, client_key).await; - create_session_cookie(state, password).await -} - -async fn login_auth_password( - state: &HttpState, - password: &str, - client_key: &str, -) -> Result<String> { - check_auth_backoff(state, client_key).await?; - let hash = state - .ui_config - .lock() - .await - .auth_password_hash - .clone() - .context("authentication setup is required")?; - if !verify_password(password, &hash)? { - record_auth_failure(state, client_key).await; - bail!("invalid password"); - } - wallet_store::encrypt_existing_with_password(&state.wallet_path, password)?; - let wallet = wallet_store::load_with_password(&state.wallet_path, password)?; - restore_node_wallet_from_store(state, wallet, Some(password)).await?; - clear_auth_backoff(state, client_key).await; - create_session_cookie(state, password).await -} - -async fn change_auth_password( - state: &HttpState, - old_password: &str, - new_password: &str, - client_key: &str, -) -> Result<String> { - check_auth_backoff(state, client_key).await?; - validate_password(new_password)?; - let current_hash = state - .ui_config - .lock() - .await - .auth_password_hash - .clone() - .context("authentication setup is required")?; - if !verify_password(old_password, &current_hash)? { - record_auth_failure(state, client_key).await; - bail!("invalid current password"); - } - let wallet = - wallet_store::reencrypt_with_password(&state.wallet_path, old_password, new_password)?; - { - let mut config = state.ui_config.lock().await; - config.auth_password_hash = Some(hash_password(new_password)?); - config_store::save(&state.config_path, &config)?; - } - restore_node_wallet_from_store(state, wallet, Some(new_password)).await?; - state.auth_sessions.lock().await.clear(); - clear_auth_backoff(state, client_key).await; - create_session_cookie(state, new_password).await -} - -async fn restore_node_wallet_from_store( - state: &HttpState, - wallet: Wallet, - password: Option<&str>, -) -> Result<()> { - let owned_blinded_transactions = - wallet_store::load_owned_blinded_transactions(&state.wallet_path, password)?; - let mut node = state.node.lock().await; - node.replace_wallet(wallet); - node.restore_owned_blinded_transactions(owned_blinded_transactions) -} - -async fn check_auth_backoff(state: &HttpState, client_key: &str) -> Result<()> { - let now = now_ms(); - let mut backoffs = state.auth_backoff.lock().await; - let backoff = backoffs.entry(client_key.to_string()).or_default(); - if backoff - .locked_until_ms - .is_some_and(|locked_until| locked_until > now) - { - bail!("too many failed login attempts; try again later"); - } - if backoff.locked_until_ms.is_some() { - backoff.locked_until_ms = None; - backoff.failed_attempts = 0; - } - Ok(()) -} - -async fn record_auth_failure(state: &HttpState, client_key: &str) { - let mut backoffs = state.auth_backoff.lock().await; - let backoff = backoffs.entry(client_key.to_string()).or_default(); - backoff.failed_attempts = backoff.failed_attempts.saturating_add(1); - if backoff.failed_attempts >= AUTH_MAX_FAILED_ATTEMPTS { - backoff.locked_until_ms = Some(now_ms().saturating_add(AUTH_LOCKOUT_MS)); - } -} - -async fn clear_auth_backoff(state: &HttpState, client_key: &str) { - state.auth_backoff.lock().await.remove(client_key); -} - -fn validate_password(password: &str) -> Result<()> { - if password.len() < 12 { - bail!("password must be at least 12 characters"); - } - if password.len() > 1024 { - bail!("password is too long"); - } - Ok(()) -} - -async fn create_session_cookie(state: &HttpState, password: &str) -> Result<String> { - let token = random_hex(32)?; - let token_hash = session_token_hash(&token); - let expires_at = now_ms().saturating_add(AUTH_SESSION_TTL_MS); - state.auth_sessions.lock().await.insert( - token_hash, - AuthSession { - expires_at, - wallet_password: password.to_string(), - }, - ); - Ok(format!( - "{AUTH_COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}", - AUTH_SESSION_TTL_MS / 1000 - )) -} - -fn session_token_hash(token: &str) -> String { - hex_encode(Sha256::digest(format!("iuna-session:{token}").as_bytes())) -} - -fn auth_cookie(headers: &HeaderMap) -> Option<&str> { - let cookie = headers.get(header::COOKIE)?.to_str().ok()?; - cookie.split(';').find_map(|part| { - let (name, value) = part.trim().split_once('=')?; - (name == AUTH_COOKIE_NAME).then_some(value) - }) -} - -fn hash_password(password: &str) -> Result<String> { - let salt = random_bytes::<16>()?; - let hash = pbkdf2_sha256(password.as_bytes(), &salt, PASSWORD_KDF_ITERATIONS); - Ok(format!( - "{PASSWORD_KDF_ALGORITHM}${PASSWORD_KDF_ITERATIONS}${}${}", - hex_encode(salt), - hex_encode(hash) - )) -} - -fn verify_password(password: &str, encoded: &str) -> Result<bool> { - let parts = encoded.split('$').collect::<Vec<_>>(); - if parts.len() != 4 || parts[0] != PASSWORD_KDF_ALGORITHM { - bail!("unsupported password hash"); - } - let iterations = parts[1] - .parse::<u32>() - .context("invalid password hash iterations")?; - let salt = decode_hex(parts[2]).context("invalid password hash salt")?; - let expected = decode_hex(parts[3]).context("invalid password hash")?; - let actual = pbkdf2_sha256(password.as_bytes(), &salt, iterations); - Ok(constant_time_eq(&actual, &expected)) -} - -fn pbkdf2_sha256(password: &[u8], salt: &[u8], iterations: u32) -> [u8; 32] { - let mut block_salt = Vec::with_capacity(salt.len() + 4); - block_salt.extend_from_slice(salt); - block_salt.extend_from_slice(&1_u32.to_be_bytes()); - let hmac = HmacSha256Key::new(password); - let mut u = hmac.digest(&block_salt); - let mut output = u; - for _ in 1..iterations { - u = hmac.digest(&u); - for (left, right) in output.iter_mut().zip(u) { - *left ^= right; - } - } - output -} - -struct HmacSha256Key { - outer_key_pad: [u8; 64], - inner_key_pad: [u8; 64], -} - -impl HmacSha256Key { - fn new(key: &[u8]) -> Self { - let mut key_block = [0_u8; 64]; - if key.len() > 64 { - key_block[..32].copy_from_slice(&Sha256::digest(key)); - } else { - key_block[..key.len()].copy_from_slice(key); - } - - let mut outer_key_pad = [0x5c_u8; 64]; - let mut inner_key_pad = [0x36_u8; 64]; - for index in 0..64 { - outer_key_pad[index] ^= key_block[index]; - inner_key_pad[index] ^= key_block[index]; - } - Self { - outer_key_pad, - inner_key_pad, - } - } - - fn digest(&self, message: &[u8]) -> [u8; 32] { - let mut inner = Sha256::new(); - inner.update(self.inner_key_pad); - inner.update(message); - let inner_hash = inner.finalize(); - - let mut outer = Sha256::new(); - outer.update(self.outer_key_pad); - outer.update(inner_hash); - outer.finalize().into() - } -} - -fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { - if left.len() != right.len() { - return false; - } - left.iter() - .zip(right) - .fold(0_u8, |diff, (left, right)| diff | (left ^ right)) - == 0 -} - -fn random_bytes<const N: usize>() -> Result<[u8; N]> { - let mut bytes = [0_u8; N]; - getrandom(&mut bytes) - .map_err(|error| anyhow::anyhow!("secure random generation failed: {error}"))?; - Ok(bytes) -} - -fn random_hex(bytes: usize) -> Result<String> { - let mut value = vec![0_u8; bytes]; - getrandom(&mut value) - .map_err(|error| anyhow::anyhow!("secure random generation failed: {error}"))?; - Ok(hex_encode(value)) -} - -fn hex_encode(bytes: impl AsRef<[u8]>) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut encoded = String::with_capacity(bytes.as_ref().len() * 2); - for byte in bytes.as_ref() { - encoded.push(HEX[(byte >> 4) as usize] as char); - encoded.push(HEX[(byte & 0x0f) as usize] as char); - } - encoded -} - -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 = decode_hex_nibble(pair[0])?; - let low = decode_hex_nibble(pair[1])?; - bytes.push((high << 4) | low); - } - Ok(bytes) -} - -fn decode_hex_nibble(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 now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64 -} - -const INDEX_HTML: &str = r#"<!doctype html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <link rel="icon" href="data:,"> - <title>iuna</title> - <style> - [x-cloak] { display: none !important; } - .visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; overflow: hidden !important; clip: rect(0 0 0 0) !important; clip-path: inset(50%) !important; white-space: nowrap !important; } - :root { - color-scheme: dark; - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #0f1012; - color: #e8edf0; - } - * { box-sizing: border-box; } - html { width: 100%; max-width: 100%; overflow-x: hidden; } - body { margin: 0; min-height: 100vh; max-width: 100%; overflow-x: hidden; background: #0f1012; color: #e8edf0; } - .app-shell { width: 100%; max-width: 100%; min-height: 100vh; display: block; overflow-x: hidden; } - .sidebar { position: fixed; z-index: 5; inset: 0 auto 0 0; width: 84px; height: 100vh; display: flex; flex-direction: column; align-items: center; gap: 20px; padding: 16px 10px; background: #15171a; border-right: 1px solid #262b2f; } - .brand-mark { position: relative; width: 38px; height: 38px; display: grid; place-items: center; overflow: hidden; border: 1px solid #e8ff8d; border-radius: 8px; background: linear-gradient(145deg, #ecff8a 0%, #d5f55f 54%, #8de9cd 100%); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .42), 0 10px 24px rgba(213, 245, 95, .16); user-select: none; cursor: default; } - .brand-mark::after { content: ""; position: absolute; inset: -40% -70%; background: linear-gradient(100deg, transparent 42%, rgba(255, 255, 255, .34) 50%, transparent 58%); transform: translateX(-58%) rotate(8deg); opacity: 0; pointer-events: none; } - .brand-mark svg { position: relative; z-index: 1; width: 24px; height: 24px; display: block; } - .brand-mark .mark-loop { fill: none; stroke: #101315; stroke-width: 4.2; stroke-linecap: round; stroke-linejoin: round; } - .brand-mark .mark-dot { fill: #101315; } - .brand-mark:hover::after { animation: mark-sheen .72s ease both; } - @keyframes mark-sheen { from { opacity: 0; transform: translateX(-58%) rotate(8deg); } 32% { opacity: 1; } to { opacity: 0; transform: translateX(58%) rotate(8deg); } } - .side-nav { display: grid; gap: 10px; width: 100%; } - .nav-button { width: 64px; min-height: 58px; display: grid; place-items: center; gap: 4px; border: 1px solid transparent; border-radius: 8px; padding: 7px 4px; background: transparent; color: #9fa8ad; } - .nav-button svg { width: 21px; height: 21px; stroke: currentColor; stroke-width: 2; fill: none; } - .nav-button svg.chain-icon { stroke-width: 1.35; } - .nav-button span { font-size: 11px; font-weight: 800; } - .nav-button:hover, .nav-button.active { background: #202328; border-color: #3b4448; color: #d5f55f; } - .settings-button { margin-top: auto; width: 64px; min-height: 54px; display: grid; place-items: center; border: 1px solid transparent; border-radius: 8px; padding: 7px 4px; color: #9fa8ad; background: transparent; text-align: center; } - .settings-button svg { width: 23px; height: 23px; stroke: currentColor; stroke-width: 1.9; fill: none; } - .settings-button:hover, .settings-button.active { background: #202328; border-color: #3b4448; color: #d5f55f; } - .version-panel { width: 64px; display: grid; gap: 4px; justify-items: center; border: 1px solid transparent; border-radius: 8px; padding: 7px 4px; color: #7f888e; background: transparent; font-size: 10px; font-weight: 850; text-align: center; } - .version-panel.update { border-color: #566d25; color: #d5f55f; background: #1c2516; cursor: pointer; } - .version-panel.checking { color: #a8b2b8; } - .version-panel.failed { color: #ffb1a8; } - .version-dot { width: 6px; height: 6px; border-radius: 999px; background: #3a4248; } - .version-panel.update .version-dot { background: #d5f55f; box-shadow: 0 0 0 3px rgba(213, 245, 95, .12); } - .version-panel.failed .version-dot { background: #ff8f82; } - .version-label { line-height: 1; } - .version-update { color: #d5f55f; font-size: 9px; line-height: 1; text-transform: uppercase; } - .content { width: 100%; min-width: 0; overflow-x: hidden; padding: 22px 24px 48px 108px; } - main { width: 100%; } - main > section { width: 100%; } - header { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; padding: 0 0 18px; } - .header-actions { display: flex; gap: 10px; align-items: center; } - .basic-status-row { display: inline-flex; gap: 8px; align-items: center; margin-top: 5px; } - .basic-status { display: inline-flex; gap: 7px; align-items: center; border: 1px solid transparent; border-radius: 999px; padding: 3px 7px; color: #9eb3bc; font-size: 12px; font-weight: 800; } - .basic-status::before { content: ""; width: 6px; height: 6px; flex: 0 0 auto; border-radius: 999px; background: #7f888e; } - .basic-status.healthy { color: #d5f55f; } - .basic-status.healthy::before { background: #d5f55f; box-shadow: 0 0 12px rgba(213, 245, 95, .45); } - .basic-status.syncing { color: #ffd070; } - .basic-status.syncing::before { background: #ffd070; } - .basic-status.isolated, .basic-status.stale, .basic-status.banned, .basic-status.error { border-color: #6a332c; background: #261817; color: #ffb8ad; font-weight: 900; } - .basic-status.isolated::before, .basic-status.stale::before, .basic-status.banned::before, .basic-status.error::before { background: #ff7668; box-shadow: 0 0 12px rgba(255, 118, 104, .38); } - .basic-status-detail { padding: 3px 7px; border-color: #3a4248; background: #202328; color: #9fa8ad; font-size: 11px; } - .basic-status-detail:hover { border-color: #d5f55f; color: #d5f55f; } - .lock-button { padding: 5px 8px; border-color: #3a4248; background: #202328; color: #9fa8ad; font-size: 12px; } - .lock-button:hover { border-color: #d5f55f; color: #d5f55f; } - h1 { margin: 0 0 4px; font-size: 28px; } - h2 { margin: 0 0 12px; font-size: 18px; } - h3 { margin: 0 0 10px; font-size: 15px; } - button { border: 1px solid #3a4248; border-radius: 6px; padding: 8px 11px; font: inherit; font-weight: 700; background: #191c20; color: #e8edf0; cursor: pointer; } - button:hover { border-color: #d5f55f; color: #d5f55f; } - button.primary { background: #d5f55f; border-color: #d5f55f; color: #15171a; } - button.primary:hover { background: #e4ff83; color: #15171a; } - button.subtle { background: transparent; } - button:disabled { cursor: default; opacity: .5; } - .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; } - .metric, .panel { background: #181b1f; border: 1px solid #2a3035; border-radius: 8px; padding: 13px; } - .metric .label { color: #8d989f; font-size: 11px; text-transform: uppercase; } - .metric .value { margin-top: 7px; font-weight: 850; overflow-wrap: anywhere; } - .panel { margin-bottom: 12px; } - .split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, .72fr); gap: 12px; } - form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } - label { display: grid; gap: 5px; color: #a8b2b8; font-size: 13px; } - input, textarea { min-width: 180px; border: 1px solid #3a444b; border-radius: 6px; padding: 9px 10px; font: inherit; background: #101215; color: #edf2f5; } - textarea { min-height: 118px; resize: vertical; line-height: 1.45; } - input:focus, textarea:focus { outline: 2px solid #d5f55f; outline-offset: 1px; } - table { width: 100%; border-collapse: collapse; font-size: 13px; } - th, td { text-align: left; border-bottom: 1px solid #2a3035; padding: 8px; vertical-align: top; } - th { color: #8d989f; font-size: 11px; text-transform: uppercase; } - code { overflow-wrap: anywhere; color: #c7f5ea; } - .table-wrap { overflow-x: auto; } - .muted { color: #8d989f; } - .flash { position: fixed; top: 18px; right: 18px; z-index: 80; width: min(420px, calc(100vw - 36px)); border-radius: 6px; padding: 10px 12px; border: 1px solid; font-weight: 700; box-shadow: 0 18px 48px rgba(0, 0, 0, .38); } - .flash.success { color: #d5f55f; background: #1c2516; border-color: #566d25; } - .flash.error { color: #ffb1a8; background: #2a1717; border-color: #713434; } - .persistent-banner { border: 1px solid #566d25; border-radius: 8px; padding: 10px 12px; margin: -4px 0 16px; color: #d5f55f; background: #1c2516; font-weight: 800; } - .ok { color: #d5f55f; } - .page-title { margin-bottom: 16px; } - .setup-overlay { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; padding: 22px; background: rgba(8, 9, 10, .72); backdrop-filter: blur(8px); } - .transaction-overlay { z-index: 40; } - .setup-modal { width: min(980px, 100%); max-height: calc(100vh - 44px); overflow: auto; border: 1px solid #3b4448; border-radius: 8px; padding: 18px; background: #181b1f; box-shadow: 0 24px 80px rgba(0, 0, 0, .42); } - .setup-modal-head { display: grid; gap: 5px; margin-bottom: 16px; } - .setup-modal-head h2 { margin: 0; font-size: 24px; } - .setup-welcome { color: #d5f55f; font-size: 12px; font-weight: 900; text-transform: uppercase; } - .setup-copy { max-width: 620px; color: #a8b2b8; line-height: 1.45; } - .setup-feedback { border: 1px solid; border-radius: 8px; padding: 10px 12px; margin-bottom: 14px; font-weight: 800; } - .setup-feedback.success { color: #d5f55f; background: #1c2516; border-color: #566d25; } - .setup-feedback.error { color: #ffb1a8; background: #2a1717; border-color: #713434; } - .setup-grid { width: 100%; display: grid; grid-template-columns: minmax(0, .9fr) minmax(320px, .7fr); gap: 12px; align-items: start; } - .setup-section { border: 1px solid #2f363c; border-radius: 8px; padding: 13px; background: #111316; } - .setup-node-mode, .setup-network, .setup-wallet-section { grid-column: 1 / -1; } - .segmented.setup-mode-picker { width: 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } - .setup-mode-picker button { min-width: 0; min-height: 38px; white-space: normal; } - .setup-network-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; align-items: end; } - .setup-network-copy { margin-top: 8px; color: #a8b2b8; line-height: 1.45; } - .setup-network-link { color: #d5f55f; font-size: 12px; font-weight: 900; text-decoration: none; } - .setup-network-link:hover { text-decoration: underline; } - .setup-field { display: grid; gap: 6px; } - .setup-field-label { color: #8d989f; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0; } - .setup-address-box { display: flex; justify-content: space-between; gap: 10px; align-items: center; } - .setup-address-box code { min-width: 0; } - .setup-address-box button { flex: 0 0 auto; } - .setup-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; } - .segmented { display: inline-flex; gap: 4px; padding: 4px; border: 1px solid #2f363c; border-radius: 8px; background: #181b1f; } - .segmented button { border-color: transparent; background: transparent; color: #9fa8ad; } - .segmented button.active { background: #d5f55f; color: #15171a; } - .seed-panel { display: grid; gap: 12px; } - .seed-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } - .seed-word { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 7px; align-items: center; border: 1px solid #2f363c; border-radius: 6px; padding: 7px 8px; background: #181b1f; } - .seed-word .index { color: #8d989f; font-size: 11px; font-weight: 800; } - .seed-word .word { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 800; color: #c7f5ea; } - .verify-grid { display: grid; gap: 8px; } - .setup-status { border: 1px solid #566d25; border-radius: 8px; padding: 10px; background: #1c2516; color: #d5f55f; font-weight: 800; } - .auth-form { width: min(420px, 100%); display: grid; gap: 10px; } - .auth-form form { display: grid; gap: 10px; align-items: stretch; } - .auth-form input { width: 100%; } - .settings-grid { width: min(760px, 100%); display: grid; gap: 12px; } - .settings-mode-row { display: flex; justify-content: space-between; gap: 14px; align-items: center; } - .settings-mode-copy { min-width: 0; display: grid; gap: 4px; } - .settings-mode-title { color: #e8edf0; font-size: 15px; font-weight: 850; } - .settings-form { display: grid; gap: 10px; align-items: stretch; } - .public-p2p-form { margin-top: 14px; } - .settings-form label, .settings-form input { width: 100%; } - .metrics-shell { display: grid; gap: 12px; } - .metrics-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; } - .metrics-head h2 { margin: 0; } - .metrics-range { flex: 0 0 auto; } - .metrics-range button { padding: 5px 9px; font-size: 12px; white-space: nowrap; } - .metrics-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; } - .metrics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 430px), 1fr)); gap: 12px; } - .metric-chart-card { display: grid; gap: 10px; min-width: 0; border: 1px solid #2a3035; border-radius: 8px; padding: 12px; background: #181b1f; } - .metric-chart-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; } - .metric-chart-title { margin: 0; color: #e8edf0; font-size: 14px; font-weight: 850; } - .metric-chart-value { color: #d5f55f; font-size: 13px; font-weight: 850; font-variant-numeric: tabular-nums; } - .metric-chart-frame { display: grid; grid-template-columns: 50px minmax(0, 1fr); grid-template-rows: 156px 18px; column-gap: 6px; row-gap: 4px; min-width: 0; } - .metric-chart-plot { position: relative; min-width: 0; } - .metric-chart-svg { width: 100%; height: 156px; display: block; border: 1px solid #2f363c; border-radius: 8px; background: #111316; } - .metric-chart-gridline { stroke: #3a4248; stroke-width: .8; stroke-dasharray: 3 7; opacity: .58; } - .metric-chart-axis { stroke: #3a4248; stroke-width: 1.2; } - .metric-chart-axis-label { color: #8d989f; font-size: 10px; font-weight: 750; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .metric-chart-y-axis { position: relative; min-width: 0; } - .metric-chart-y-axis .metric-chart-axis-label { position: absolute; right: 0; transform: translateY(-50%); max-width: 100%; } - .metric-chart-x-axis { position: relative; grid-column: 2; min-width: 0; overflow: visible; } - .metric-chart-x-axis .metric-chart-axis-label { position: absolute; top: 0; transform: translateX(-50%); } - .metric-chart-line { fill: none; stroke: #d5f55f; stroke-width: 2.2; stroke-linejoin: round; stroke-linecap: round; } - .metric-chart-points { position: absolute; inset: 0; } - .metric-chart-point-hit { position: absolute; width: 18px; height: 18px; border: 0; border-radius: 50%; padding: 0; background: transparent; cursor: crosshair; transform: translate(-50%, -50%); } - .metric-chart-point-hit::after { content: ""; position: absolute; left: 50%; top: 50%; width: 5px; height: 5px; border-radius: 50%; background: #d5f55f; opacity: .2; transform: translate(-50%, -50%); } - .metric-chart-point-hit:hover::after, .metric-chart-point-hit:focus-visible::after, .metric-chart-point-hit.is-active::after { width: 8px; height: 8px; opacity: 1; } - .metric-chart-tooltip { position: absolute; z-index: 1; max-width: min(180px, 80%); border: 1px solid #566d25; border-radius: 6px; padding: 5px 7px; background: #202615; color: #e8edf0; font-size: 11px; font-weight: 850; font-variant-numeric: tabular-nums; line-height: 1.25; pointer-events: none; box-shadow: 0 8px 20px rgba(0, 0, 0, .28); white-space: nowrap; } - .metrics-empty { border: 1px dashed #3a4248; border-radius: 8px; padding: 14px; color: #8d989f; background: #111316; } - .wallet-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr); gap: 12px; align-items: start; } - .wallet-actions { display: grid; gap: 12px; } - .advanced-toggle { flex-basis: 100%; width: max-content; align-self: flex-start; border-color: #3a4248; padding: 4px 7px; background: #202328; color: #9fa8ad; font-size: 12px; } - .advanced-toggle:hover { border-color: #5a646b; color: #d6dee2; } - .send-utxo-list { display: grid; gap: 8px; max-height: 260px; overflow: auto; border: 1px solid #2f363c; border-radius: 8px; padding: 8px; background: #111316; } - .send-utxo-list-head { display: flex; justify-content: space-between; gap: 8px; align-items: center; color: #8d989f; font-size: 12px; font-weight: 800; } - .send-utxo-actions { display: flex; gap: 6px; align-items: center; } - .utxo-select-button { padding: 3px 7px; border-color: #3a4248; background: #202328; color: #9fa8ad; font-size: 12px; } - .utxo-select-button:hover { border-color: #5a646b; color: #d6dee2; } - .send-utxo-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: start; border: 1px solid #2f363c; border-radius: 8px; padding: 8px; background: #181b1f; } - .send-utxo-option.disabled { border-color: #262c31; background: #14171a; color: #687178; } - .send-utxo-option.disabled code, .send-utxo-option.disabled .utxo-node-amount { color: #687178; } - .send-utxo-option input { min-width: auto; margin-top: 3px; } - .utxo-status { color: #8d989f; font-size: 10px; font-weight: 850; text-transform: uppercase; } - .send-utxo-summary { flex-basis: 100%; width: 100%; display: grid; gap: 5px; color: #9eb3bc; font-size: 13px; } - .wallet-balance-line { display: inline-grid; grid-template-columns: auto auto; gap: 10px; align-items: baseline; padding: 8px 10px; border: 1px solid #2f363c; border-radius: 8px; background: #111316; color: inherit; cursor: pointer; } - .wallet-balance-line:hover, .wallet-balance-line:focus-visible { border-color: #d5f55f; outline: none; } - .wallet-balance-line .tx-value { font-size: 16px; font-weight: 850; } - .mining-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; align-items: start; } - .panel-description { max-width: 760px; margin: -4px 0 12px; color: #9eb3bc; font-size: 13px; line-height: 1.45; } - .mining-form { width: 100%; display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } - .burn-fields { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } - .mine-action-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 12px; align-items: center; } - .mine-settings-form { display: grid; gap: 10px; } - .mine-fee-fields { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } - .fee-preview { flex-basis: 100%; color: #9eb3bc; font-size: 12px; font-weight: 700; } - .mine-stats { display: grid; grid-template-columns: repeat(4, minmax(112px, 1fr)); gap: 8px; min-width: 0; } - .local-mining-stats { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } - .fee-history { grid-template-columns: repeat(3, minmax(112px, 1fr)); margin-top: 12px; } - .mine-stat { min-width: 0; border: 1px solid #2f363c; border-radius: 8px; padding: 9px 10px; background: #111316; } - .mine-stat-label { display: flex; gap: 5px; align-items: center; color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } - .mine-stat-value { margin-top: 5px; color: #dce4e7; font-size: 14px; font-weight: 850; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } - .mine-stat-value.money { color: #d5f55f; } - .mine-reward-control { display: grid; gap: 7px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } - .mine-reward-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; color: #a8b2b8; font-size: 13px; font-weight: 800; } - .mine-reward-head strong { color: #d5f55f; font-size: 14px; font-variant-numeric: tabular-nums; } - .mine-reward-control input[type="range"] { width: 100%; min-width: 0; padding: 0; accent-color: #d5f55f; } - .mine-slider-hints { display: flex; justify-content: space-between; gap: 10px; color: #879198; font-size: 11px; font-weight: 750; } - .mine-include-status { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 7px; color: #9eb3bc; font-size: 12px; font-weight: 800; } - .mine-include-status.waiting { color: #ffd280; } - .mine-include-status.ready { color: #d5f55f; } - .mine-include-status.muted { color: #879198; } - .mine-save-row { display: flex; justify-content: flex-start; } - .mining-event-log { display: grid; gap: 8px; max-height: 360px; overflow: auto; margin-top: 12px; border: 1px solid #2f363c; border-radius: 8px; padding: 8px; background: #0f1114; } - .mining-event-log-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } - .mining-event { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: start; border: 1px solid #30383d; border-radius: 8px; padding: 10px; background: #111316; } - .mining-event-dot { width: 8px; height: 8px; margin-top: 5px; border-radius: 999px; background: #7f888e; } - .mining-event.active .mining-event-dot { background: #d5f55f; box-shadow: 0 0 12px rgba(213, 245, 95, .45); } - .mining-event.warning .mining-event-dot { background: #ffd070; } - .mining-event.info .mining-event-dot { background: #8de9cd; } - .mining-event-title { color: #eef6f8; font-weight: 850; overflow-wrap: anywhere; } - .mining-event-detail { margin-top: 3px; color: #9fa8ad; font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; } - .mining-event-time { color: #7f888e; font-size: 11px; font-weight: 800; white-space: nowrap; } - .mining-event-empty { height: 42px; border: 1px solid #30383d; border-radius: 8px; background: #111316; } - .mining-event-empty .skeleton-line { width: 100%; height: 100%; border-radius: 8px; opacity: .55; } - .panel-separator { border-top: 1px solid #2f363c; margin: 14px 0 12px; } - .stratum-config { display: grid; gap: 10px; } - .stratum-note { max-width: 760px; color: #9eb3bc; font-size: 12px; line-height: 1.45; } - .stratum-note code { color: #dce4e7; } - .stratum-fields { display: grid; gap: 8px; } - .stratum-field { display: grid; grid-template-columns: 86px minmax(0, 1fr); gap: 10px; align-items: baseline; min-width: 0; } - .stratum-label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } - .stratum-value { min-width: 0; color: #dce4e7; font-size: 13px; font-weight: 650; overflow-wrap: anywhere; } - .stratum-value.hash { color: #9eb3bc; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; font-weight: 500; } - .info-button { display: inline-grid; place-items: center; width: 18px; height: 18px; padding: 0; border-radius: 999px; border-color: #3a4248; background: #181b1f; color: #9eb3bc; font-size: 11px; line-height: 1; } - .info-button:hover, .info-button:focus-visible { border-color: #d5f55f; color: #d5f55f; outline: none; } - .info-copy { display: grid; gap: 10px; color: #c3cbd0; line-height: 1.45; } - .info-copy p { margin: 0; } - .info-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; } - .info-fact { border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } - .info-fact .label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } - .info-fact .value { margin-top: 5px; color: #d5f55f; font-weight: 850; } - .mining-head { align-items: center; } - .toggle-switch { display: inline-flex; grid-template-columns: none; align-items: center; gap: 9px; color: #9fa8ad; font-size: 12px; font-weight: 850; cursor: pointer; user-select: none; } - .toggle-switch input { position: absolute; width: 1px; height: 1px; min-width: 0; margin: 0; opacity: 0; pointer-events: none; } - .toggle-track { position: relative; width: 46px; height: 26px; border: 1px solid #3a4248; border-radius: 999px; background: #101215; transition: background .16s ease, border-color .16s ease; } - .toggle-thumb { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 999px; background: #879198; transition: transform .16s ease, background .16s ease; } - .toggle-switch.active { color: #d5f55f; } - .toggle-switch.active .toggle-track { border-color: #d5f55f; background: #263219; } - .toggle-switch.active .toggle-thumb { transform: translateX(20px); background: #d5f55f; } - .toggle-switch:focus-within .toggle-track { outline: 2px solid #d5f55f; outline-offset: 2px; } - .toggle-text { min-width: 22px; text-align: right; } - .compact-number-field { display: inline-flex; align-items: center; gap: 8px; color: #9fa8ad; font-size: 12px; font-weight: 850; } - .compact-number-field input { width: 58px; min-width: 0; border: 1px solid #3a4248; border-radius: 8px; padding: 7px 8px; background: #101215; color: #dce4e7; font: inherit; font-variant-numeric: tabular-nums; } - .compact-number-field input:focus { border-color: #d5f55f; outline: 2px solid rgba(213,245,95,.2); outline-offset: 2px; } - .receive-address { display: grid; gap: 8px; } - .address-box { border: 1px solid #2f363c; border-radius: 8px; padding: 11px; background: #111316; } - .address-book-list { display: grid; gap: 8px; margin-top: 12px; } - .address-book-row { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; text-align: left; border: 1px solid #2f363c; border-radius: 8px; padding: 9px; background: #111316; color: inherit; } - .address-book-row:hover { border-color: #4c565c; background: #15181b; } - .address-book-row > svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; color: #9fa8ad; } - .address-book-name { font-weight: 800; color: #eef6f8; overflow-wrap: anywhere; } - .address-book-actions { display: flex; gap: 6px; align-items: center; } - .address-book-modal { width: min(560px, 100%); } - .address-book-modal form { width: 100%; } - .address-book-modal form label { flex: 1 0 100%; } - .address-book-modal-actions { flex: 1 0 100%; width: 100%; display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } - .address-book-picker-list { display: grid; gap: 8px; } - .address-book-picker-row { width: 100%; display: grid; gap: 3px; text-align: left; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; color: inherit; } - .address-book-picker-row:hover { border-color: #4c565c; background: #15181b; } - .recipient-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: end; } - .icon-button { display: inline-grid; place-items: center; width: 38px; height: 38px; padding: 0; line-height: 0; border-radius: 8px; } - .icon-button svg { width: 19px; height: 19px; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; } - .modal-delete-button { color: #ffb4b4; } - input.invalid { border-color: #e36a6a; outline: 2px solid rgba(227,106,106,.16); outline-offset: 2px; } - .panel-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; } - .panel-head h2, .panel-head h3 { margin-bottom: 0; } - .switch { display: inline-flex; grid-template-columns: none; align-items: center; gap: 8px; color: #d6dee2; font-weight: 700; } - .switch input { width: auto; min-width: 0; accent-color: #d5f55f; } - .wallet-tx-panel { min-width: 0; overflow: hidden; } - .wallet-tx-panel .panel-head { flex-wrap: wrap; } - .wallet-tx-filters { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; align-items: center; } - .tx-filter { display: inline-flex; align-items: center; gap: 6px; border: 1px solid #3a4248; border-radius: 999px; padding: 4px 8px; color: #9fa8ad; background: #111316; font-size: 12px; font-weight: 850; cursor: pointer; user-select: none; } - .tx-filter input { position: absolute; width: 1px; height: 1px; min-width: 0; margin: 0; opacity: 0; pointer-events: none; } - .tx-filter.active { border-color: #d5f55f; background: #202616; color: #d5f55f; } - .tx-filter:focus-within { outline: 2px solid #d5f55f; outline-offset: 2px; } - .wallet-tx-list { max-height: min(620px, calc(100vh - 220px)); min-width: 0; display: grid; gap: 8px; overflow-y: auto; overscroll-behavior-y: contain; padding-right: 4px; } - .wallet-tx-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; align-items: start; border: 1px solid #2f363c; border-radius: 8px; padding: 12px; background: #111316; cursor: pointer; text-align: left; } - .wallet-tx-row:hover, .wallet-tx-row:focus-visible, .tx-card:hover, .tx-card:focus-visible, .mempool-item:hover, .mempool-item:focus-visible { border-color: #d5f55f; box-shadow: 0 0 0 1px rgba(213, 245, 95, .22); outline: none; } - .wallet-tx-row.pending { border-color: #3a4147; background: #191c20; box-shadow: inset 3px 0 0 #6f7880; } - .wallet-tx-row .pill { position: absolute; top: 10px; right: 10px; } - .wallet-tx-main { display: grid; gap: 5px; min-width: 0; padding-right: 92px; } - .tx-field { display: grid; grid-template-columns: 74px minmax(0, 1fr); gap: 8px; align-items: baseline; min-width: 0; } - .tx-label { color: #879198; font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0; } - .tx-value { min-width: 0; color: #dce4e7; font-size: 13px; font-weight: 600; overflow-wrap: anywhere; } - .tx-value.money { color: #d5f55f; font-variant-numeric: tabular-nums; } - .tx-value.hash { color: #9eb3bc; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; font-weight: 500; } - .tx-value.number { color: #c7d0d5; font-variant-numeric: tabular-nums; } - .tx-value.text { color: #e8edf0; } - .metric-context { display: grid; gap: 5px; margin-top: 12px; } - .peer-toolbar { display: flex; justify-content: space-between; gap: 12px; align-items: start; flex-wrap: wrap; margin-bottom: 12px; } - .peer-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 8px; margin-bottom: 12px; } - .peer-summary-item { min-width: 0; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } - .peer-summary-label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } - .peer-summary-value { margin-top: 5px; color: #dce4e7; font-size: 15px; font-weight: 850; } - .peer-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } - .peer-form label { min-width: min(320px, 100%); } - .peer-form input { width: 100%; } - .peer-status { display: inline-flex; align-items: center; border: 1px solid #3a4248; border-radius: 999px; padding: 3px 8px; color: #a8b2b8; font-size: 11px; font-weight: 850; } - .peer-status.synced, .peer-status.active { border-color: #566d25; color: #d5f55f; background: #1c2516; } - .peer-status.stale { border-color: #5f5125; color: #ffe08a; background: #211d12; } - .peer-status.banned { border-color: #713434; color: #ffb1a8; background: #2a1717; } - .peer-status.error { border-color: #713434; color: #ffb1a8; background: #2a1717; } - .peer-actions { display: flex; gap: 6px; align-items: center; } - .peer-remove { padding: 4px 7px; border-color: #4f3737; background: #221717; color: #ffb1a8; font-size: 12px; } - .peer-remove:hover { border-color: #ffb1a8; color: #ffd4cf; } - .network-health { display: grid; grid-template-columns: minmax(180px, .8fr) minmax(0, 1.2fr); gap: 12px; align-items: stretch; margin-bottom: 12px; } - .network-health-state { display: grid; align-content: center; gap: 5px; border: 1px solid #3a4248; border-radius: 8px; padding: 12px; background: #111316; } - .network-health-state.healthy { border-color: #566d25; background: #182112; } - .network-health-state.syncing, .network-health-state.stale { border-color: #5f5125; background: #211d12; } - .network-health-state.isolated, .network-health-state.error, .network-health-state.banned { border-color: #713434; background: #241716; } - .network-health-label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } - .network-health-value { color: #e8edf0; font-size: 20px; font-weight: 900; text-transform: capitalize; } - .network-health-detail { color: #a8b2b8; font-size: 12px; } - .network-health-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(116px, 1fr)); gap: 8px; } - .panel .grid + form { margin-top: 12px; } - .explorer-shell { width: 100%; display: grid; gap: 12px; } - .block-rail-wrap { background: #181b1f; border: 1px solid #2a3035; border-radius: 8px; padding: 12px; overflow: hidden; } - .block-rail-head { display: flex; justify-content: space-between; gap: 10px; align-items: center; margin-bottom: 10px; } - .block-rail { display: flex; gap: 8px; overflow-x: auto; padding: 1px 0 10px; scroll-snap-type: x proximity; } - .block-card { flex: 0 0 122px; min-height: 100px; display: grid; gap: 6px; border: 1px solid #2f363c; border-radius: 8px; padding: 9px; background: #111316; color: #e8edf0; text-align: left; scroll-snap-align: start; } - .block-card:hover { border-color: #d5f55f; color: #d5f55f; } - .block-card.selected { background: #202616; border-color: #d5f55f; box-shadow: inset 0 0 0 1px #d5f55f; } - .block-card.new-block { animation: block-arrive .45s ease both; } - @keyframes block-arrive { from { opacity: .2; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } } - .block-height { font-size: 18px; font-weight: 900; } - .block-meta { display: flex; gap: 8px; color: #8d989f; font-size: 12px; } - .block-miner { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; overflow-wrap: anywhere; color: #9eb3bc; } - .skeleton-card { pointer-events: none; position: relative; overflow: hidden; } - .skeleton-card::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(213, 245, 95, .12), transparent); animation: skeleton-sweep 1.15s ease-in-out infinite; } - @keyframes skeleton-sweep { from { transform: translateX(-100%); } to { transform: translateX(100%); } } - .skeleton-line { height: 12px; border-radius: 6px; background: #2b3136; } - .skeleton-line.short { width: 42%; } - .skeleton-line.medium { width: 68%; } - .skeleton-line.long { width: 88%; } - .page-sentinel { min-height: 1px; } - .block-page-sentinel { flex: 0 0 1px; min-height: 100px; } - .dataset-loader { display: grid; gap: 8px; min-width: 0; } - .skeleton-table-cell { height: 12px; width: 100%; border-radius: 6px; background: #2b3136; } - tr.skeleton-card td { padding-top: 11px; padding-bottom: 11px; } - .detail-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 12px; } - .detail-kv { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 8px; font-size: 13px; margin: 7px 0; } - .detail-kv .key { color: #8d989f; } - .detail-link { width: fit-content; max-width: 100%; padding: 0; border: 0; background: transparent; color: #d7f2ff; font: inherit; text-align: left; cursor: pointer; } - .detail-link code { color: inherit; text-decoration: underline; text-underline-offset: 3px; } - .rank-list { display: grid; gap: 8px; } - .rank-row { display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 10px; align-items: start; border: 1px solid #30383d; border-radius: 8px; padding: 10px; background: #15191d; } - .rank-number { color: #d7f2ff; font-weight: 700; } - .rank-details { display: grid; gap: 6px; min-width: 0; } - .tx-list { display: grid; gap: 8px; } - .tx-section { display: grid; gap: 8px; } - .tx-section + .tx-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid #2f363c; } - .tx-section-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #f4f7f8; font-size: 13px; font-weight: 800; } - summary.tx-section-title { cursor: pointer; } - details.tx-section:not([open]) { gap: 0; } - .tx-section-meta { color: #8e979e; font-size: 12px; font-weight: 600; } - .tx-card, .mempool-item { position: relative; display: grid; align-content: start; grid-auto-rows: min-content; gap: 6px; border: 1px solid #2f363c; border-radius: 8px; padding: 12px; background: #111316; cursor: pointer; text-align: left; } - .mempool-item.before-last-block { opacity: .56; } - .mempool-item.new-since-block { background: #151a12; opacity: 1; } - .mempool-item.new-since-block::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; border-radius: 8px 0 0 8px; background: #d5f55f; } - .mempool-item.blinded-hidden { background: linear-gradient(135deg, #141218, #101316); border-color: #353040; } - .mempool-state { color: #d5f55f; font-size: 10px; font-weight: 850; text-transform: uppercase; } - .mempool-time { color: #8d989f; font-size: 11px; font-weight: 700; } - .mempool-top { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; min-width: 0; } - .mempool-top-meta { display: grid; gap: 3px; min-width: 0; } - .tx-card .pill, .mempool-item .pill { position: absolute; top: 10px; right: 10px; } - .mempool-item .pill { position: static; flex: 0 0 auto; } - .pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 8px; font-size: 12px; font-weight: 800; background: #2b3136; color: #d6dee2; } - .pill.burn { background: #332918; color: #ffd070; } - .pill.transfer { background: #17312a; color: #8de9cd; } - .pill.mine { background: #172a34; color: #8bdcff; } - .pill.blinded { background: #272433; color: #c8b8ff; } - .pill.reveal, .pill.revealed { background: #2b2f20; color: #d5f55f; } - .mempool-panel { min-width: 0; overflow: hidden; } - .mempool-strip { width: 100%; min-width: 0; display: flex; gap: 8px; overflow-x: auto; overscroll-behavior-x: contain; padding: 1px 0 10px; scroll-snap-type: x proximity; } - .mempool-item { flex: 0 0 220px; scroll-snap-align: start; align-self: stretch; } - .tx-modal { width: min(940px, 100%); max-height: calc(100vh - 44px); overflow: auto; border: 1px solid #3b4448; border-radius: 8px; padding: 16px; background: #181b1f; box-shadow: 0 24px 80px rgba(0, 0, 0, .46); } - .tx-modal-head { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 14px; } - .tx-modal-title { display: grid; justify-items: start; gap: 6px; min-width: 0; } - .tx-modal-title h2 { margin: 0; } - .tx-modal-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; margin-bottom: 12px; } - .utxo-flow { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 12px; align-items: stretch; } - .utxo-column { display: grid; align-content: start; gap: 8px; min-width: 0; } - .utxo-column h3 { margin: 0; color: #8d989f; font-size: 11px; text-transform: uppercase; } - .utxo-node { display: grid; gap: 5px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; min-width: 0; } - .utxo-node.burned { border-color: #5e4821; background: #1f1a12; } - .utxo-node.fee { border-color: #4b5260; background: #171a20; } - .utxo-node-label { display: flex; justify-content: space-between; gap: 8px; color: #8d989f; font-size: 11px; font-weight: 800; text-transform: uppercase; } - .utxo-node-amount { color: #d5f55f; font-weight: 850; font-variant-numeric: tabular-nums; } - .utxo-node-address, .utxo-node-ref { color: #9eb3bc; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; } - .utxo-arrow { display: grid; place-items: center; color: #d5f55f; font-size: 24px; font-weight: 900; } - .tx-modal-empty { border: 1px dashed #3a4248; border-radius: 8px; padding: 10px; color: #8d989f; } - .utxo-list { display: grid; gap: 8px; } - .wallet-utxo-row { display: grid; gap: 6px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } - @media (max-width: 760px) { .utxo-flow, .mine-action-row, .mine-stats { grid-template-columns: 1fr; } .utxo-arrow { min-height: 28px; transform: rotate(90deg); } .tx-modal-head { align-items: stretch; } } - @media (max-width: 920px) { .setup-grid, .wallet-grid, .mining-grid, .detail-grid, .network-health { grid-template-columns: 1fr; } } - @media (max-width: 760px) { - .app-shell { display: grid; grid-template-columns: 1fr; } - .sidebar { position: sticky; inset: auto; width: auto; height: auto; flex-direction: row; justify-content: space-between; padding: 8px; border-right: 0; border-bottom: 1px solid #262b2f; } - .brand-mark { width: 34px; height: 34px; } - .brand-mark svg { width: 22px; height: 22px; } - .side-nav { display: flex; width: auto; gap: 8px; } - .nav-button { width: 52px; min-height: 48px; } - .nav-button span { font-size: 10px; } - .settings-button, .version-panel { margin-top: 0; width: 48px; min-height: 48px; padding: 6px 3px; } - .settings-button svg { width: 21px; height: 21px; } - .content { padding: 16px 12px 36px; } - header, .split, .setup-grid, .wallet-grid, .mining-grid, .detail-grid, .wallet-tx-row { grid-template-columns: 1fr; } - header { display: grid; } - .settings-mode-row { align-items: flex-start; } - .metrics-head { align-items: flex-start; flex-direction: column; } - .metrics-range { width: 100%; } - .metrics-range button { flex: 1 1 0; } - .segmented.setup-mode-picker { grid-template-columns: 1fr; } - .metrics-grid { grid-template-columns: 1fr; } - input { min-width: 0; width: 100%; } - .switch input { width: auto; } - .seed-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .block-card { flex-basis: 108px; } - } - </style> - <script defer src="/assets/iuna-ui.js?v=97"></script> - <script defer src="/assets/alpine.min.js"></script> -</head> -<body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> - <div class="app-shell"> - <aside class="sidebar" aria-label="iuna navigation"> - <div class="brand-mark" title="iuna" aria-label="iuna"><svg viewBox="0 0 32 32" aria-hidden="true" focusable="false"><circle class="mark-dot" cx="9.4" cy="7.6" r="2.8"></circle><path class="mark-loop" d="M9.4 13v7.1c0 3.7 2.9 6.4 6.6 6.4s6.6-2.7 6.6-6.4V13"></path></svg></div> - <nav class="side-nav"> - <button class="nav-button" :class="{ active: tab === 'wallet' }" @click="setTab('wallet')" type="button" title="Wallet" aria-label="Wallet"> - <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7h16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H3z"></path><path d="M3 7V5a2 2 0 0 1 2-2h12"></path><path d="M16 13h3"></path></svg> - <span>Wallet</span> - </button> - <button class="nav-button" x-show="advancedMode()" :class="{ active: tab === 'mining' }" @click="setTab('mining')" type="button" title="Mining" aria-label="Mining"> - <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="M7 15l4-4 3 3 5-7"></path></svg> - <span>Mining</span> - </button> - <button class="nav-button" x-show="advancedMode()" :class="{ active: tab === 'p2p' }" @click="setTab('p2p')" type="button" title="P2P" aria-label="P2P"> - <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="6" r="3"></circle><circle cx="18" cy="18" r="3"></circle><path d="M8.5 10.5 15.5 7.5"></path><path d="M8.5 13.5 15.5 16.5"></path></svg> - <span>P2P</span> - </button> - <button class="nav-button" :class="{ active: tab === 'chain' }" @click="setTab('chain')" type="button" title="Explorer" aria-label="Explorer"> - <svg class="chain-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="1.5" y="9" width="5.5" height="5.5"></rect><rect x="9.25" y="9" width="5.5" height="5.5"></rect><rect x="17" y="9" width="5.5" height="5.5"></rect></svg> - <span>Chain</span> - </button> - <button class="nav-button" x-show="config.keep_track_of_metrics" :class="{ active: tab === 'metrics' }" @click="setTab('metrics')" type="button" title="Metrics" aria-label="Metrics"> - <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="M7 15l3-4 3 2 4-7"></path><path d="M7 17h10"></path></svg> - <span>Metrics</span> - </button> - </nav> - <button class="settings-button" :class="{ active: tab === 'settings' }" type="button" @click="setTab('settings')" title="Settings" aria-label="Settings"> - <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9.7 3.2 9.2 5.5a7.2 7.2 0 0 0-1.4.8L5.6 5.6 3.2 9.8l1.7 1.6a7.8 7.8 0 0 0 0 1.6l-1.7 1.6 2.4 4.2 2.2-.7a7.2 7.2 0 0 0 1.4.8l.5 2.3h4.8l.5-2.3a7.2 7.2 0 0 0 1.4-.8l2.2.7 2.4-4.2-1.7-1.6a7.8 7.8 0 0 0 0-1.6L21 9.8l-2.4-4.2-2.2.7a7.2 7.2 0 0 0-1.4-.8l-.5-2.3H9.7Z"></path><circle cx="12" cy="12.2" r="3.1"></circle></svg> - </button> - <button class="version-panel" type="button" :class="{ update: updateAvailable(), checking: releaseCheckState === 'checking', failed: releaseCheckState === 'failed' }" :title="versionPanelTitle()" @click="openLatestRelease"> - <span class="version-dot" aria-hidden="true"></span> - <span class="version-label" x-text="appVersionLabel()"></span> - <span class="version-update" x-show="updateAvailable()">Update</span> - </button> - </aside> - - <main class="content"> - <header> - <div> - <h1 x-text="pageTitle()">iuna</h1> - <div class="basic-status-row" x-show="basicMode()"> - <span class="basic-status" :class="networkHealthClass()" x-text="basicNetworkStatusLabel()"></span> - <button class="basic-status-detail" type="button" x-show="basicNetworkNeedsAttention()" @click="setUiMode('advanced'); setTab('p2p')">Details</button> - </div> - </div> - <div class="header-actions"> - <div class="muted" x-text="lastUpdatedLabel()"></div> - <button class="lock-button" type="button" x-show="auth.authenticated" @click="logout">Lock</button> - </div> - </header> - - <div class="flash" :class="flash?.kind" x-show="flash" x-transition x-text="flash?.message"></div> - <div class="persistent-banner" x-show="p2pRestartRequired()" x-transition x-text="p2pRestartMessage()"></div> - - <section x-show="tab === 'wallet'"> - <div class="page-title"> - <button class="wallet-balance-line" type="button" @click="openWalletUtxosModal" title="Show wallet UTXOs"> - <span class="tx-label">Balance</span> - <span class="tx-value money">IUNA <span x-text="amountLabel(status.wallet_balance)"></span></span> - </button> - </div> - <div class="wallet-grid"> - <div class="wallet-actions"> - <div class="panel"> - <h3>Send</h3> - <form @submit.prevent="sendTransfer"> - <div class="recipient-field"> - <label>Recipient<input x-model="transferTo" @input="scheduleFeeEstimates" autocomplete="off" required></label> - <button class="icon-button" type="button" @click="openAddressBookPicker()" title="Choose contact" aria-label="Choose contact"> - <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v18H6.5A2.5 2.5 0 0 1 4 18.5z"></path><path d="M8 7h8"></path><path d="M8 11h6"></path><path d="M8 15h4"></path></svg> - </button> - </div> - <label>Amount<input x-model="transferAmount" @input="scheduleFeeEstimates" type="number" min="0.000001" step="0.000001" required></label> - <label>Fee / byte<input x-model="transferFee" @input="scheduleFeeEstimates" type="number" min="0" step="0.000001" required></label> - <div class="fee-preview" x-text="feeEstimateLabel('transfer')"></div> - <button class="advanced-toggle" type="button" @click="toggleSendAdvanced" x-text="showSendAdvanced ? 'Hide UTXOs' : 'UTXOs'"></button> - <div class="send-utxo-summary" x-show="showSendAdvanced"> - <div>Selected UTXOs: <span x-text="selectedTransferUtxos.length"></span></div> - <div>Selected total: IUNA <span x-text="amountLabel(selectedTransferUtxoTotal())"></span></div> - <div>Required: IUNA <span x-text="amountLabel(transferRequiredTotal())"></span></div> - <div class="setup-feedback error" x-show="!selectedTransferUtxosCoverTransfer()">Selected UTXOs do not cover amount plus fee</div> - <div class="send-utxo-list"> - <div class="send-utxo-list-head"> - <span>UTXOs</span> - <span class="send-utxo-actions"> - <button class="utxo-select-button" type="button" @click="selectAllTransferUtxos" :disabled="walletUtxoPage.loading && walletUtxos.length === 0">Select all</button> - <button class="utxo-select-button" type="button" @click="clearTransferUtxos" :disabled="selectedTransferUtxos.length === 0">None</button> - </span> - </div> - <template x-for="utxo in walletUtxos" :key="utxoOutpoint(utxo)"> - <label class="send-utxo-option" :class="{ disabled: !utxo.spendable }"> - <input type="checkbox" :value="utxoOutpoint(utxo)" x-model="selectedTransferUtxos" @change="scheduleFeeEstimates" :disabled="!utxo.spendable"> - <span> - <span class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">IUNA <span x-text="amountLabel(utxo.amount)"></span></span></span> - <span class="utxo-status" x-show="!utxo.spendable">Pending</span> - <code class="tx-value hash" x-text="utxoOutpoint(utxo)"></code> - </span> - </label> - </template> - <div class="dataset-loader" x-show="walletUtxoPage.loading" aria-hidden="true"> - <div class="send-utxo-option skeleton-card"><span><span class="skeleton-line medium"></span><span class="skeleton-line long"></span></span></div> - <div class="send-utxo-option skeleton-card"><span><span class="skeleton-line short"></span><span class="skeleton-line long"></span></span></div> - </div> - <div class="page-sentinel" x-show="walletUtxoPage.hasMore" x-init="$nextTick(() => observePageSentinel('walletUtxo', $el))"></div> - <div class="tx-modal-empty" x-show="walletUtxos.length === 0 && !walletUtxoPage.loading">No UTXOs</div> - </div> - </div> - <button class="primary" type="submit">Send</button> - </form> - </div> - <div class="panel"> - <div class="panel-head"> - <h3>Receive</h3> - <button type="button" @click="copyAddress">Copy</button> - </div> - <div class="receive-address"> - <div class="muted">Public key / address</div> - <div class="address-box"><code x-text="status.wallet_address || '-'"></code></div> - </div> - </div> - <div class="panel"> - <div class="panel-head"> - <h3>Address Book</h3> - <div class="address-book-actions"> - <button class="icon-button" type="button" @click="openAddressBookModal()" title="Add contact" aria-label="Add contact"> - <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14"></path><path d="M5 12h14"></path></svg> - </button> - </div> - </div> - <div class="address-book-list"> - <template x-for="entry in addressBookEntries()" :key="entry.address"> - <button class="address-book-row" type="button" @click="editAddressBookEntry(entry)" :title="`Edit ${entry.name}`"> - <div> - <div class="address-book-name" x-text="entry.name"></div> - <code class="tx-value hash" x-text="short(entry.address)"></code> - </div> - <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18l6-6-6-6"></path></svg> - </button> - </template> - <div class="muted" x-show="addressBookEntries().length === 0">No saved addresses</div> - </div> - </div> - </div> - <div class="panel wallet-tx-panel"> - <div class="panel-head"> - <h3>Transactions</h3> - <div class="wallet-tx-filters" aria-label="Transaction filters"> - <label class="tx-filter" :class="{ active: walletTxFilters.transfer }"> - <input type="checkbox" x-model="walletTxFilters.transfer" @change="refreshWalletTransactions()"> - <span>Tx</span> - </label> - <label class="tx-filter" :class="{ active: walletTxFilters.mine }"> - <input type="checkbox" x-model="walletTxFilters.mine" @change="refreshWalletTransactions()"> - <span>Mine</span> - </label> - <label class="tx-filter" :class="{ active: walletTxFilters.burn }"> - <input type="checkbox" x-model="walletTxFilters.burn" @change="refreshWalletTransactions()"> - <span>Burn</span> - </label> - </div> - </div> - <div class="wallet-tx-list"> - <template x-for="tx in walletTransactions()" :key="tx.status + '-' + tx.signature"> - <div class="wallet-tx-row" :class="{ pending: tx.status === 'pending' }" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Wallet' })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Wallet' })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Wallet' })"> - <span class="pill" :class="tx.kind" x-text="tx.direction"></span> - <div class="wallet-tx-main"> - <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(tx.amount)"></span></span></div> - <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> - <div class="tx-field"><span class="tx-label">Status</span><span class="tx-value text" x-text="txTitle(tx)"></span></div> - <div class="tx-field"><span class="tx-label">Time</span><span class="tx-value text" x-text="walletTxTimeLabel(tx)"></span></div> - <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(tx.from)"></code></div> - <div class="tx-field" x-show="tx.to"><span class="tx-label">To</span><code class="tx-value hash" x-text="shortAddressLabel(tx.to)"></code></div> - <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> - <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="short(txProofHash(tx))"></code></div> - <div class="tx-field"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> - </div> - </div> - </template> - <div class="dataset-loader" x-show="walletTxPage.loading" aria-hidden="true"> - <div class="wallet-tx-row skeleton-card"><div class="wallet-tx-main"><div class="skeleton-line medium"></div><div class="skeleton-line short"></div><div class="skeleton-line long"></div></div></div> - <div class="wallet-tx-row skeleton-card"><div class="wallet-tx-main"><div class="skeleton-line short"></div><div class="skeleton-line medium"></div><div class="skeleton-line long"></div></div></div> - </div> - <div class="page-sentinel" x-show="walletTxPage.hasMore" x-init="$nextTick(() => observePageSentinel('walletTx', $el))"></div> - <div class="muted" x-show="walletTransactions().length === 0 && !walletTxPage.loading">No wallet transactions</div> - </div> - </div> - </div> - </section> - - <section x-show="tab === 'mining'"> - <div class="page-title"> - <div class="muted">PoB/VDF block production with PoW issuance actions</div> - </div> - <div class="mining-grid"> - <div class="panel"> - <h3>Status</h3> - <div class="mine-stats local-mining-stats" aria-label="Local mining status"> - <div class="mine-stat"> - <div class="mine-stat-label">PoB State</div> - <div class="mine-stat-value" x-text="pobStatusLabel()"></div> - </div> - <div class="mine-stat"> - <div class="mine-stat-label">PoW State</div> - <div class="mine-stat-value" x-text="powStatusShortLabel()"></div> - </div> - <div class="mine-stat"> - <div class="mine-stat-label">Selected Finalizer</div> - <code class="mine-stat-value" x-text="currentFinalizerLabel()"></code> - </div> - <div class="mine-stat"> - <div class="mine-stat-label">Mempool</div> - <div class="mine-stat-value" x-text="localMiningMempoolLabel()"></div> - </div> - </div> - <div class="mining-event-log" aria-label="Mining event log"> - <div class="mining-event-log-head"><span>Event log</span><span x-text="`${miningEventLog().length} lines`"></span></div> - <template x-if="miningEventLog().length === 0"> - <div class="mining-event-empty skeleton-card" aria-hidden="true"><div class="skeleton-line"></div></div> - </template> - <template x-for="event in miningEventLog()" :key="event.key"> - <div class="mining-event" :class="event.kind"> - <span class="mining-event-dot" aria-hidden="true"></span> - <div> - <div class="mining-event-title" x-text="event.title"></div> - <div class="mining-event-detail" x-text="event.detail"></div> - </div> - <div class="mining-event-time" x-text="event.time"></div> - </div> - </template> - </div> - </div> - <div class="panel"> - <div class="panel-head mining-head"> - <h3>Burn</h3> - <label class="toggle-switch" :class="{ active: miningEnabled }"> - <input type="checkbox" :checked="miningEnabled" @change="setMiningEnabled($event.target.checked)"> - <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> - <span class="toggle-text" x-text="miningEnabled ? 'On' : 'Off'"></span> - </label> - </div> - <div class="panel-description">Burn IUNA to compete for block finalization. Winning burns finalize PoB/VDF blocks and earn the transaction fees in those blocks.</div> - <form class="mining-form" @submit.prevent="saveBurn"> - <div class="burn-fields"> - <label>IUNA per block<input x-model="burnAmountDraft" @input="burnAmountDirty = true; scheduleFeeEstimates()" type="number" min="0.000001" step="0.000001"></label> - <label>Fee / byte<input x-model="burnFeeDraft" @input="burnAmountDirty = true; scheduleFeeEstimates()" type="number" min="0" step="0.000001" required></label> - <button class="primary" type="submit">Save</button> - </div> - <div class="fee-preview" x-text="feeEstimateLabel('burn')"></div> - </form> - <div class="mine-stats fee-history" aria-label="Recent block fees"> - <div class="mine-stat"> - <div class="mine-stat-label">Last block fees</div> - <div class="mine-stat-value money">IUNA <span x-text="amountLabel(recentBlockFeeAverage(1))"></span></div> - </div> - <div class="mine-stat"> - <div class="mine-stat-label">5 block avg</div> - <div class="mine-stat-value money">IUNA <span x-text="amountLabel(recentBlockFeeAverage(5))"></span></div> - </div> - <div class="mine-stat"> - <div class="mine-stat-label">30 block avg</div> - <div class="mine-stat-value money">IUNA <span x-text="amountLabel(recentBlockFeeAverage(30))"></span></div> - </div> - </div> - </div> - <div class="panel"> - <h3>Mine</h3> - <div class="panel-description">Search for PoW actions that mint a fixed IUNA reward.</div> - <div class="mine-settings-form"> - <div class="mine-action-row"> - <div class="mine-stats" aria-label="PoW issuance settings"> - <div class="mine-stat"> - <div class="mine-stat-label">You receive</div> - <div class="mine-stat-value money">IUNA <span x-text="amountLabel(powMineReward())"></span></div> - </div> - <div class="mine-stat"> - <div class="mine-stat-label">Finalizer earns</div> - <div class="mine-stat-value">IUNA <span x-text="amountLabel(feeEstimates.mine?.fee ?? 0)"></span></div> - </div> - <div class="mine-stat"> - <div class="mine-stat-label">Difficulty <button class="info-button" type="button" @click="openPowDifficultyInfo" title="How difficulty is adjusted" aria-label="How PoW difficulty is adjusted">i</button></div> - <div class="mine-stat-value"><span x-text="powDifficultyLabel()"></span> bits</div> - </div> - </div> - <label class="toggle-switch" :class="{ active: powMiningEnabled }" title="Continuously search for PoW mine actions with a small local work budget"> - <input type="checkbox" :checked="powMiningEnabled" @change="setPowMiningEnabled($event.target.checked)"> - <span class="toggle-track"><span class="toggle-thumb"></span></span> - <span class="toggle-text" x-text="powMiningEnabled ? 'On' : 'Off'"></span> - </label> - <label class="compact-number-field" title="Local PoW worker count"> - <span>Workers</span> - <input type="number" min="1" :max="maxPowMiningWorkers" :value="powMiningWorkers" @change="setPowMiningWorkers($event.target.value)"> - </label> - </div> - <div class="fee-preview" x-text="autoPowStatusLabel()"></div> - </div> - <div class="panel-separator"></div> - <div class="stratum-config"> - <div class="stratum-note">Start the node with <code>--stratum 0.0.0.0:3333</code> to expose a Stratum V1 endpoint for ASIC miners. Use the pool URL below in the miner configuration.</div> - <div class="stratum-fields" aria-label="Stratum settings"> - <div class="stratum-field"> - <div class="stratum-label">Status</div> - <div class="stratum-value" x-text="status.stratum?.enabled ? 'On' : 'Off'"></div> - </div> - <div class="stratum-field"> - <div class="stratum-label">Listener</div> - <code class="stratum-value hash" x-text="stratumListenAddr()"></code> - </div> - <div class="stratum-field"> - <div class="stratum-label">Pool URL</div> - <code class="stratum-value hash" x-text="stratumPoolUrl()"></code> - </div> - </div> - </div> - </div> - </div> - </section> - - <section x-show="tab === 'p2p'"> - <div class="panel"> - <div class="peer-toolbar"> - <div> - <h2>Peers</h2> - <div class="panel-description" x-text="p2pAcceptInbound ? 'Manage outbound peers and inspect inbound or outbound sessions. Public node is accepting inbound P2P connections.' : 'Manage outbound peers and inspect sync health. This node is outbound-only and does not open an inbound P2P port.'"></div> - </div> - <form class="peer-form" @submit.prevent="addPeer"> - <label>Peer address<input x-model="peerAddress" placeholder="seed.example:9444"></label> - <button class="primary" type="submit">Add</button> - </form> - </div> - <div class="network-health"> - <div class="network-health-state" :class="networkHealthClass()"> - <div class="network-health-label">Network Health</div> - <div class="network-health-value" x-text="networkHealth.state || '-'"></div> - <div class="network-health-detail" x-text="networkHealth.last_error || 'No peer errors reported'"></div> - </div> - <div class="network-health-grid"> - <div class="peer-summary-item"><div class="peer-summary-label">Local Height</div><div class="peer-summary-value" x-text="networkHealth.local_height ?? '-'"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Best Known</div><div class="peer-summary-value" x-text="networkHealth.best_known_height ?? '-'"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Lag</div><div class="peer-summary-value" x-text="networkLagLabel()"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Stale</div><div class="peer-summary-value" x-text="networkHealth.stale_peers ?? '-'"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Banned</div><div class="peer-summary-value" x-text="networkHealth.banned_peers ?? '-'"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Mempool</div><div class="peer-summary-value" x-text="networkHealth.pending_transactions ?? '-'"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Plain Tx</div><div class="peer-summary-value" x-text="networkHealth.pending_plain_transactions ?? '-'"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Commits</div><div class="peer-summary-value" x-text="networkHealth.pending_blinded_transactions ?? '-'"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Reveals</div><div class="peer-summary-value" x-text="networkHealth.pending_blinded_reveals ?? '-'"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Time Offset</div><div class="peer-summary-value" x-text="networkTimeOffsetLabel()"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Clock Warnings</div><div class="peer-summary-value" x-text="networkHealth.bad_clock_peers ?? '-'"></div></div> - </div> - </div> - <div class="peer-summary"> - <div class="peer-summary-item"><div class="peer-summary-label">Outbound</div><div class="peer-summary-value" x-text="outboundPeers().length"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Inbound</div><div class="peer-summary-value" x-text="inboundPeers().length"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Healthy</div><div class="peer-summary-value" x-text="healthyPeers().length"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Errors</div><div class="peer-summary-value" x-text="failedPeers().length"></div></div> - <div class="peer-summary-item"><div class="peer-summary-label">Shared Height</div><div class="peer-summary-value" x-text="sharedHeightLabel()"></div></div> - </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>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> - <td><span class="peer-status" :class="peerStatus(peer)" x-text="peerStatusLabel(peer)"></span></td> - <td><code x-text="peer.address"></code></td> - <td x-text="peer.direction"></td> - <td x-text="peerLastContactLabel(peer)"></td> - <td x-text="peerClockLabel(peer)"></td> - <td x-text="peerBanLabel(peer)"></td> - <td x-text="peer.misbehavior_score ?? 0"></td> - <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.messages_sent"></td> - <td x-text="peer.messages_received"></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="14"><div class="skeleton-table-cell"></div></td> - </tr> - <tr class="skeleton-card" x-show="peerPage.loading" aria-hidden="true"> - <td colspan="14"><div class="skeleton-table-cell"></div></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> - </div> - <div class="panel"> - <h2>Metrics</h2> - <div class="grid"> - <div class="metric"><div class="label">Inbound Sessions</div><div class="value" x-text="p2pMetrics.inbound_sessions_started ?? 0"></div></div> - <div class="metric"><div class="label">Inbound Rejects</div><div class="value" x-text="p2pMetrics.inbound_sessions_rejected ?? 0"></div></div> - <div class="metric"><div class="label">Outbound Attempts</div><div class="value" x-text="p2pMetrics.outbound_connect_attempts ?? 0"></div></div> - <div class="metric"><div class="label">Connect Failures</div><div class="value" x-text="p2pMetrics.outbound_connect_failures ?? 0"></div></div> - <div class="metric"><div class="label">Session Failures</div><div class="value" x-text="p2pMetrics.session_failures ?? 0"></div></div> - <div class="metric"><div class="label">Parse Errors</div><div class="value" x-text="p2pMetrics.parse_errors ?? 0"></div></div> - <div class="metric"><div class="label">Empty Frames</div><div class="value" x-text="p2pMetrics.empty_frames ?? 0"></div></div> - <div class="metric"><div class="label">Self Rejects</div><div class="value" x-text="p2pMetrics.self_peer_rejections ?? 0"></div></div> - <div class="metric"><div class="label">Self Skips</div><div class="value" x-text="p2pMetrics.self_peer_skips ?? 0"></div></div> - <div class="metric"><div class="label">Received</div><div class="value" x-text="p2pMetrics.envelopes_received ?? 0"></div></div> - <div class="metric"><div class="label">Bytes In</div><div class="value" x-text="p2pMetrics.bytes_received ?? 0"></div></div> - <div class="metric"><div class="label">Status Rx</div><div class="value" x-text="p2pMetrics.peer_status_envelopes_received ?? 0"></div></div> - <div class="metric"><div class="label">Hello Rx</div><div class="value" x-text="p2pMetrics.hello_envelopes_received ?? 0"></div></div> - <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">Commit Rx</div><div class="value" x-text="p2pMetrics.blinded_transactions_received ?? 0"></div></div> - <div class="metric"><div class="label">Commit Batches Rx</div><div class="value" x-text="p2pMetrics.blinded_transaction_envelopes_received ?? 0"></div></div> - <div class="metric"><div class="label">Reveal Rx</div><div class="value" x-text="p2pMetrics.blinded_reveals_received ?? 0"></div></div> - <div class="metric"><div class="label">Reveal Batches Rx</div><div class="value" x-text="p2pMetrics.blinded_reveal_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> - <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> - <div class="tx-field"><span class="tx-label">Last Empty</span><span class="tx-value text" x-text="p2pMetrics.last_empty_frame_remote || '-'"></span></div> - <div class="tx-field"><span class="tx-label">Last Parse</span><span class="tx-value text" x-text="p2pMetrics.last_parse_error || '-'"></span></div> - </div> - </div> - </section> - - <section x-show="tab === 'chain'"> - <div class="explorer-shell"> - <div class="block-rail-wrap"> - <div class="block-rail-head"> - <h2>Blocks</h2> - <div class="muted"><span x-text="blocks.length"></span> loaded</div> - </div> - <div class="block-rail" x-ref="blockRail" @scroll.debounce.200ms="maybeLoadOlderBlocks($event)"> - <template x-for="block in blocks" :key="block.hash"> - <button class="block-card" :class="{ selected: selectedBlock?.hash === block.hash, 'new-block': newBlockHashes.has(block.hash) }" @click="selectBlock(block)" type="button"> - <div class="block-height" x-text="block.height"></div> - <div class="block-meta"> - <span x-text="burnCountLabel(block)"></span> - <span x-text="transferCountLabel(block)"></span> - <span x-text="commitCountLabel(block)"></span> - <span x-text="mineCountLabel(block)"></span> - </div> - <div class="block-miner" x-text="blockFinalizerLabel(block)"></div> - </button> - </template> - <template x-if="loadingOlder"> - <div class="block-card skeleton-card" aria-hidden="true"> - <div class="skeleton-line short"></div> - <div class="skeleton-line medium"></div> - <div class="skeleton-line long"></div> - </div> - </template> - <div class="page-sentinel block-page-sentinel" x-show="hasMoreBlocks" x-init="$nextTick(() => observeBlockSentinel($el))"></div> - </div> - </div> - - <section class="panel"> - <h2>Block Detail</h2> - <template x-if="selectedBlock"> - <div class="detail-grid"> - <div> - <div class="detail-kv"><div class="key">Height</div><div x-text="selectedBlock.height"></div></div> - <div class="detail-kv"><div class="key">Time</div><div x-text="blockTimestampLabel(selectedBlock)"></div></div> - <div class="detail-kv"><div class="key">Hash</div><code x-text="selectedBlock.hash"></code></div> - <div class="detail-kv"><div class="key">Previous</div><code x-text="short(selectedBlock.prev_hash)"></code></div> - <div class="detail-kv"> - <div class="key">Finalizer</div> - <button class="detail-link" type="button" @click="openBurnLeaderRanksModal(selectedBlock)" title="Burn leader ranks"> - <code x-text="shortAddressLabel(selectedBlock.miner)"></code> - </button> - </div> - <div class="detail-kv"><div class="key">Mode</div><div x-text="selectedBlock.finalizer_mode === 'recovery' ? 'Recovery' : `Rank ${selectedBlock.finalizer_rank ?? 0}`"></div></div> - <div class="detail-kv"><div class="key">Reward</div><div>IUNA <span x-text="amountLabel(selectedBlock.reward)"></span></div></div> - <div class="detail-kv"><div class="key">Burns</div><div x-text="blockBurnCount(selectedBlock)"></div></div> - <div class="detail-kv"><div class="key">Transfers</div><div x-text="blockTransferCount(selectedBlock)"></div></div> - <div class="detail-kv"><div class="key">Total Burned</div><div>IUNA <span x-text="amountLabel(blockBurned(selectedBlock))"></span></div></div> - <div class="detail-kv"> - <div class="key">Bytes</div> - <button class="detail-link" type="button" @click="openBlockBytesModal(selectedBlock)" title="Block byte breakdown"> - <span x-text="blockTotalBytes(selectedBlock)"></span>B - </button> - </div> - <div class="detail-kv"><div class="key">VDF</div><div><span x-text="selectedBlock.vdf_rounds"></span> rounds</div></div> - </div> - <div class="tx-list"> - <h3>Transactions</h3> - <div class="tx-section"> - <div class="tx-section-title"><span>Envelope</span><span class="tx-section-meta" x-text="shortAddressLabel(selectedBlock.miner)"></span></div> - <template x-for="tx in selectedBlock.transactions" :key="tx.signature"> - <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })"> - <span class="pill" :class="txPillClass(tx)" x-text="txPillLabel(tx)"></span> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> - <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(txFrom(tx))"></code></div> - <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="shortAddressLabel(txTo(tx))"></code></div> - <div class="tx-field" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div> - <div class="tx-field" x-show="tx.encrypted_size || tx.encryptedSize"><span class="tx-label">Bytes</span><span class="tx-value number" x-text="tx.encrypted_size || tx.encryptedSize"></span></div> - <div class="tx-field" x-show="tx.expires_at_height || tx.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="tx.expires_at_height || tx.expiresAtHeight"></span></div> - <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> - <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="short(txProofHash(tx))"></code></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> - </div> - </template> - <div class="muted" x-show="selectedBlock.transactions.length === 0">No envelope transactions</div> - </div> - <template x-for="bundle in selectedBlock.reveal_bundles || selectedBlock.revealBundles || []" :key="bundle.hash"> - <details class="tx-section"> - <summary class="tx-section-title"><span x-text="`Reveal bundle ${bundle.slot}`"></span><span class="tx-section-meta"><span x-text="shortAddressLabel(bundle.member)"></span> · <span x-text="bundle.byte_size || bundle.byteSize || 0"></span>B</span></summary> - <template x-for="tx in bundle.reveals" :key="tx.signature"> - <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })"> - <span class="pill" :class="txPillClass(tx)" x-text="txPillLabel(tx)"></span> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> - <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(txFrom(tx))"></code></div> - <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="shortAddressLabel(txTo(tx))"></code></div> - <div class="tx-field" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> - </div> - </template> - <div class="muted" x-show="bundle.reveals.length === 0">No reveals in bundle</div> - </details> - </template> - <div class="muted" x-show="selectedBlock.transactions.length === 0 && !(selectedBlock.reveal_bundles || selectedBlock.revealBundles || []).length">No transactions</div> - </div> - </div> - </template> - <div class="muted" x-show="!selectedBlock">Select a block</div> - </section> - - <section class="panel mempool-panel" x-show="mempool.length > 0 || mempoolPage.loading"> - <h2>Mempool</h2> - <div class="mempool-strip"> - <template x-for="tx in mempool" :key="tx.signature"> - <div class="mempool-item" :class="mempoolItemClass(tx)" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Mempool' })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Mempool' })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Mempool' })"> - <div class="mempool-top"> - <div class="mempool-top-meta"> - <div class="mempool-state" x-show="mempoolItemClass(tx).includes('new-since-block')">New since last block</div> - <div class="mempool-time" x-show="mempoolSeenTimeLabel(tx)" x-text="mempoolSeenTimeLabel(tx)"></div> - </div> - <span class="pill" :class="tx.kind" x-text="tx.kind"></span> - </div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> - <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(txFrom(tx))"></code></div> - <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="shortAddressLabel(txTo(tx))"></code></div> - <div class="tx-field" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div> - <div class="tx-field" x-show="tx.encrypted_size || tx.encryptedSize"><span class="tx-label">Bytes</span><span class="tx-value number" x-text="tx.encrypted_size || tx.encryptedSize"></span></div> - <div class="tx-field" x-show="tx.expires_at_height || tx.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="tx.expires_at_height || tx.expiresAtHeight"></span></div> - <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> - <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="short(txProofHash(tx))"></code></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> - </div> - </template> - <template x-if="mempoolPage.loading"> - <div class="mempool-item skeleton-card" aria-hidden="true"> - <div class="skeleton-line short"></div> - <div class="skeleton-line medium"></div> - <div class="skeleton-line long"></div> - </div> - </template> - <div class="page-sentinel" x-show="mempoolPage.hasMore" x-init="$nextTick(() => observePageSentinel('mempool', $el))"></div> - </div> - </section> - </div> - </section> - <section x-show="tab === 'metrics'"> - <div class="metrics-shell"> - <div class="metrics-head"> - <h2>Metrics</h2> - <div class="segmented metrics-range" role="group" aria-label="Metrics block range"> - <button type="button" :class="{ active: metricsRange === 100 }" @click="setMetricsRange(100)">Last 100</button> - <button type="button" :class="{ active: metricsRange === 1000 }" @click="setMetricsRange(1000)">Last 1000</button> - <button type="button" :class="{ active: metricsRange === 'all' }" @click="setMetricsRange('all')">All</button> - </div> - </div> - <div class="metrics-summary"> - <div class="metric"><div class="label">Latest block</div><div class="value" x-text="metricsLatest().height ?? '-'"></div></div> - <div class="metric"><div class="label">Supply</div><div class="value" x-text="metricAmountLabel(metricsLatest().circulatingSupply)"></div></div> - <div class="metric"><div class="label">Known addresses</div><div class="value" x-text="metricsLatest().knownWalletAddresses ?? '-'"></div></div> - <div class="metric"><div class="label">Total burned</div><div class="value" x-text="metricAmountLabel(metricsLatest().totalBurnedAmount)"></div></div> - <div class="metric"><div class="label">Difficulty</div><div class="value" x-text="metricsLatest().mineDifficultyBits ?? '-'"></div></div> - </div> - <div class="metrics-empty" x-show="metricsCharts().length === 0">No metrics collected yet</div> - <div class="metrics-grid"> - <template x-for="chart in metricsCharts()" :key="chart.id"> - <article class="metric-chart-card"> - <div class="metric-chart-head"> - <h3 class="metric-chart-title" x-text="chart.title"></h3> - <div class="metric-chart-value" x-text="metricLatestValueLabel(chart)"></div> - </div> - <div class="metric-chart-frame"> - <div class="metric-chart-y-axis"> - <template x-for="tick in metricYAxisTicks(chart)" :key="`${chart.id}-y-${tick}`"> - <span class="metric-chart-axis-label" :style="metricYAxisLabelStyle(chart, tick)" x-text="metricAxisValueLabel(chart, tick)"></span> - </template> - </div> - <div class="metric-chart-plot" @mousemove="setMetricHoverFromPlot(chart, $event)" @mouseleave="clearMetricHover(chart)"> - <svg class="metric-chart-svg" viewBox="0 0 300 148" preserveAspectRatio="none" role="img" :aria-label="chart.title"> - <path class="metric-chart-gridline" :d="metricGridPath(chart)"></path> - <line class="metric-chart-axis" x1="4" y1="8" x2="4" y2="132"></line> - <line class="metric-chart-axis" x1="4" y1="132" x2="296" y2="132"></line> - <polyline class="metric-chart-line" :points="metricChartPoints(chart)"></polyline> - </svg> - <div class="metric-chart-points"> - <template x-for="marker in metricChartPointMarkers(chart)" :key="`${chart.id}-point-${marker.height}`"> - <button class="metric-chart-point-hit" type="button" :class="{ 'is-active': metricHover?.chartId === chart.id && metricHover?.height === marker.height }" :style="metricPointStyle(marker)" :title="marker.label" @focus="setMetricHover(chart, marker)" @blur="clearMetricHover(chart)" :aria-label="marker.label"></button> - </template> - </div> - <template x-if="metricHover?.chartId === chart.id"> - <div class="metric-chart-tooltip" :style="metricTooltipStyle(chart)" x-text="metricTooltipLabel(chart)"></div> - </template> - </div> - <div class="metric-chart-x-axis"> - <template x-for="tick in metricXAxisTicks(chart)" :key="`${chart.id}-x-${tick}`"> - <span class="metric-chart-axis-label" :style="metricXAxisLabelStyle(chart, tick)" x-text="`#${tick}`"></span> - </template> - </div> - </div> - </article> - </template> - </div> - </div> - </section> - <section x-show="tab === 'settings'"> - <div class="settings-grid"> - <div class="panel"> - <div class="panel-head"> - <h2>Settings</h2> - <span class="pill" x-text="advancedMode() ? 'Node mode' : 'Wallet mode'"></span> - </div> - <div class="settings-mode-row"> - <div class="settings-mode-copy"> - <div class="settings-mode-title">Mode</div> - <div class="muted" x-text="advancedMode() ? 'Node mode shows mining and peer controls.' : 'Wallet mode keeps the interface focused on wallet and chain views.'"></div> - </div> - <label class="toggle-switch" :class="{ active: advancedMode() }"> - <input type="checkbox" :checked="advancedMode()" @change="setUiMode($event.target.checked ? 'advanced' : 'basic')"> - <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> - <span class="toggle-text" x-text="advancedMode() ? 'Node' : 'Wallet'"></span> - </label> - </div> - </div> - <div class="panel"> - <div class="settings-mode-row"> - <div class="settings-mode-copy"> - <div class="settings-mode-title">Keep track of metrics</div> - <div class="muted" x-text="keepTrackOfMetrics ? 'Metrics are stored per block.' : 'Metrics storage is off.'"></div> - </div> - <label class="toggle-switch" :class="{ active: keepTrackOfMetrics }"> - <input type="checkbox" :checked="keepTrackOfMetrics" @change="setKeepTrackOfMetrics($event.target.checked)"> - <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> - <span class="toggle-text" x-text="keepTrackOfMetrics ? 'On' : 'Off'"></span> - </label> - </div> - </div> - <div class="panel" x-show="advancedMode()"> - <div class="settings-mode-row"> - <div class="settings-mode-copy"> - <div class="settings-mode-title">Recovery VDF</div> - <div class="muted">Top <span x-text="recoveryVdfTopRankPercent"></span>% threshold for fallback/recovery work.</div> - </div> - <label>Top ranks - <input type="range" min="0" max="100" step="5" :value="recoveryVdfTopRankPercent" @change="setRecoveryVdfTopRankPercent($event.target.value)"> - </label> - </div> - </div> - <div class="panel" x-show="advancedMode()"> - <h3>Node Networking</h3> - <div class="settings-mode-row"> - <div class="settings-mode-copy"> - <div class="settings-mode-title">Public node</div> - <div class="muted" x-text="p2pAcceptInbound ? 'Accepting inbound P2P connections.' : 'Outbound-only P2P; no inbound port is open.'"></div> - </div> - <label class="toggle-switch" :class="{ active: p2pAcceptInbound }"> - <input type="checkbox" :checked="p2pAcceptInbound" @change="setP2pAcceptInbound($event.target.checked)"> - <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> - <span class="toggle-text" x-text="p2pAcceptInbound ? 'Public' : 'Private'"></span> - </label> - </div> - <form class="settings-form public-p2p-form" x-show="p2pAcceptInbound" x-transition @submit.prevent="saveP2pAnnounce"> - <label>Bind port<input x-model.number="p2pBindPort" @input="p2pBindPortDirty = true" type="number" min="1" max="65535" step="1" required></label> - <label>Public P2P address<input x-model="p2pAnnounceAddr" @input="p2pAnnounceDirty = true" placeholder="203.0.113.10:9444"></label> - <div class="muted">Use this only when TCP port <span x-text="p2pBindPort"></span> is reachable from the internet.</div> - <div class="setup-actions"><button class="primary" type="submit">Save</button></div> - </form> - </div> - <div class="panel"> - <h3>Change Password</h3> - <div class="setup-feedback" :class="settingsFeedback?.kind" x-show="settingsFeedback" x-transition x-text="settingsFeedback?.message"></div> - <form class="settings-form" @submit.prevent="changePassword"> - <input class="visually-hidden" type="text" name="username" value="iuna" autocomplete="username" tabindex="-1" aria-hidden="true"> - <label>Current password<input x-model="settingsOldPassword" type="password" autocomplete="current-password" required></label> - <label>New password<input x-model="settingsNewPassword" type="password" autocomplete="new-password" minlength="12" required></label> - <label>Confirm new password<input x-model="settingsPasswordConfirm" type="password" autocomplete="new-password" minlength="12" required></label> - <div class="setup-actions"><button class="primary" type="submit">Change password</button></div> - </form> - </div> - </div> - </section> - </main> - </div> - <div class="setup-overlay" x-show="showingAuth()" x-transition.opacity role="dialog" aria-modal="true" aria-labelledby="auth-title"> - <section class="setup-modal auth-form"> - <div class="setup-modal-head"> - <div class="setup-welcome">iuna Access</div> - <h2 id="auth-title" x-text="auth.configured ? 'Unlock iuna' : 'Set Password'"></h2> - <div class="setup-copy" x-show="!auth.configured">Choose a local password before wallet setup continues.</div> - <div class="setup-copy" x-show="auth.configured">Enter the local password to unlock this node.</div> - </div> - <div class="setup-feedback" :class="authFeedback?.kind" x-show="authFeedback" x-transition x-text="authFeedback?.message"></div> - <form x-show="!auth.configured" @submit.prevent="setupPassword"> - <input class="visually-hidden" type="text" name="username" value="iuna" autocomplete="username" tabindex="-1" aria-hidden="true"> - <label>Password<input x-model="authPassword" type="password" autocomplete="new-password" minlength="12" required></label> - <label>Confirm password<input x-model="authPasswordConfirm" type="password" autocomplete="new-password" minlength="12" required></label> - <div class="setup-actions"><button class="primary" type="submit">Set password</button></div> - </form> - <form x-show="auth.configured && !auth.authenticated" @submit.prevent="login"> - <input class="visually-hidden" type="text" name="username" value="iuna" autocomplete="username" tabindex="-1" aria-hidden="true"> - <label>Password<input x-model="loginPassword" type="password" autocomplete="current-password" required></label> - <div class="setup-actions"><button class="primary" type="submit">Unlock</button></div> - </form> - </section> - </div> - <div class="setup-overlay transaction-overlay" x-show="showWalletUtxos" x-transition.opacity @click.self="closeWalletUtxosModal()" role="dialog" aria-modal="true" aria-labelledby="wallet-utxos-title"> - <section class="tx-modal"> - <div class="tx-modal-head"> - <div class="tx-modal-title"> - <h2 id="wallet-utxos-title">Wallet UTXOs</h2> - <div class="tx-field"><span class="tx-label">Total</span><span class="tx-value money">IUNA <span x-text="amountLabel(status.wallet_balance)"></span></span></div> - </div> - <button type="button" @click="closeWalletUtxosModal">Close</button> - </div> - <div class="utxo-list"> - <template x-for="utxo in walletUtxos" :key="`${utxo.outpoint.txid}:${utxo.outpoint.index}`"> - <div class="wallet-utxo-row"> - <div class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">IUNA <span x-text="amountLabel(utxo.amount)"></span></span></div> - <div class="tx-field"><span class="tx-label">Outpoint</span><code class="tx-value hash" x-text="txInputOutpoint({ outpoint: utxo.outpoint })"></code></div> - <div class="tx-field"><span class="tx-label">Address</span><code class="tx-value hash" x-text="addressLabel(utxo.address)"></code></div> - </div> - </template> - <div class="dataset-loader" x-show="walletUtxoPage.loading" aria-hidden="true"> - <div class="wallet-utxo-row skeleton-card"><div class="skeleton-line medium"></div><div class="skeleton-line long"></div></div> - <div class="wallet-utxo-row skeleton-card"><div class="skeleton-line short"></div><div class="skeleton-line long"></div></div> - </div> - <div class="page-sentinel" x-show="walletUtxoPage.hasMore" x-init="$nextTick(() => observePageSentinel('walletUtxo', $el))"></div> - <div class="tx-modal-empty" x-show="walletUtxos.length === 0 && !walletUtxoPage.loading">No wallet UTXOs</div> - </div> - </section> - </div> - <div class="setup-overlay transaction-overlay" x-show="showPowDifficultyInfo" x-transition.opacity @click.self="closePowDifficultyInfo()" role="dialog" aria-modal="true" aria-labelledby="pow-difficulty-title"> - <section class="tx-modal"> - <div class="tx-modal-head"> - <div class="tx-modal-title"> - <h2 id="pow-difficulty-title">PoW Difficulty</h2> - </div> - <button type="button" @click="closePowDifficultyInfo">Close</button> - </div> - <div class="info-copy"> - <p>Difficulty is adjusted to target about one mine action per block.</p> - <div class="info-facts"> - <div class="info-fact"><div class="label">Window</div><div class="value">10 blocks</div></div> - <div class="info-fact"><div class="label">Target</div><div class="value">10 mine actions</div></div> - <div class="info-fact"><div class="label">Max step</div><div class="value">2 bits</div></div> - </div> - <p>If a window includes more mine actions than the target, difficulty rises. If it includes fewer, difficulty falls. The initial difficulty is 12 bits.</p> - </div> - </section> - </div> - <div class="setup-overlay transaction-overlay" x-show="selectedByteBlock" x-transition.opacity @click.self="closeBlockBytesModal()" role="dialog" aria-modal="true" aria-labelledby="block-bytes-title"> - <section class="tx-modal"> - <div class="tx-modal-head"> - <div class="tx-modal-title"> - <h2 id="block-bytes-title" x-text="selectedByteBlock ? `Block ${selectedByteBlock.height} Bytes` : 'Block Bytes'"></h2> - <div class="tx-field"><span class="tx-label">Total</span><span class="tx-value number"><span x-text="blockTotalBytes(selectedByteBlock)"></span>B</span></div> - </div> - <button type="button" @click="closeBlockBytesModal">Close</button> - </div> - <div class="rank-list"> - <template x-for="row in blockByteBreakdown(selectedByteBlock)" :key="row[0]"> - <div class="rank-row"> - <div class="rank-number" x-text="`${row[1]}B`"></div> - <div class="rank-details"> - <div class="tx-field"> - <span class="tx-label">Category</span> - <span class="pill" x-show="row[2]" :class="row[2]" x-text="row[0]"></span> - <span class="tx-value text" x-show="!row[2]" x-text="row[0]"></span> - </div> - </div> - </div> - </template> - </div> - </section> - </div> - <div class="setup-overlay transaction-overlay" x-show="selectedBurnLeaderBlock" x-transition.opacity @click.self="closeBurnLeaderRanksModal()" role="dialog" aria-modal="true" aria-labelledby="burn-ranks-title"> - <section class="tx-modal"> - <div class="tx-modal-head"> - <div class="tx-modal-title"> - <h2 id="burn-ranks-title" x-text="burnLeaderRanksTitle(selectedBurnLeaderBlock)"></h2> - <div class="tx-field"><span class="tx-label">Finalizer</span><code class="tx-value hash" x-text="selectedBurnLeaderBlock ? addressLabel(selectedBurnLeaderBlock.miner) : '-'"></code></div> - </div> - <button type="button" @click="closeBurnLeaderRanksModal">Close</button> - </div> - <div class="rank-list"> - <template x-for="rank in burnLeaderRanks(selectedBurnLeaderBlock)" :key="`${selectedBurnLeaderBlock.hash}-${rank.rank}-${rank.ticket_id ?? rank.ticketId}`"> - <div class="rank-row"> - <div class="rank-number" x-text="burnLeaderRankLabel(rank)"></div> - <div class="rank-details"> - <div class="tx-field"><span class="tx-label">Owner</span><code class="tx-value hash" x-text="addressLabel(rank.owner)"></code></div> - <div class="tx-field"><span class="tx-label">Burn</span><span class="tx-value money">IUNA <span x-text="amountLabel(rank.amount)"></span></span></div> - <div class="tx-field"><span class="tx-label">Ticket</span><code class="tx-value hash" x-text="short(rank.ticket_id ?? rank.ticketId)"></code></div> - <div class="tx-field"><span class="tx-label">Eligible</span><span class="tx-value number" x-text="burnLeaderEligibilityLabel(rank)"></span></div> - </div> - </div> - </template> - <div class="tx-modal-empty" x-show="burnLeaderRanks(selectedBurnLeaderBlock).length === 0">No burn leader ranks</div> - </div> - </section> - </div> - <div class="setup-overlay transaction-overlay" x-show="selectedTransaction" x-transition.opacity @click.self="closeTransactionModal()" role="dialog" aria-modal="true" aria-labelledby="tx-modal-title"> - <section class="tx-modal"> - <div class="tx-modal-head"> - <div class="tx-modal-title"> - <span class="pill" :class="txPillClass(selectedTransaction?.tx)" x-text="txPillLabel(selectedTransaction?.tx)"></span> - <h2 id="tx-modal-title">Transaction</h2> - <code class="tx-value hash" x-text="selectedTransaction?.tx?.signature || '-'"></code> - </div> - <button type="button" @click="closeTransactionModal">Close</button> - </div> - <div class="tx-modal-summary"> - <div class="tx-field"><span class="tx-label">Source</span><span class="tx-value text" x-text="selectedTransactionLabel()"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(selectedTransaction?.tx || {}))"></span></span></div> - <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(selectedTransaction?.tx)"></span></div> - <div class="tx-field" x-show="!isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="addressLabel(txFrom(selectedTransaction?.tx || {}))"></code></div> - <div class="tx-field" x-show="txTo(selectedTransaction?.tx || {})"><span class="tx-label">To</span><code class="tx-value hash" x-text="addressLabel(txTo(selectedTransaction?.tx || {}))"></code></div> - <div class="tx-field" x-show="isBlindedMempoolItem(selectedTransaction?.tx) || selectedTransaction?.tx?.commitment"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="selectedTransaction?.tx?.commitment || selectedTransaction?.tx?.signature || '-'"></code></div> - <div class="tx-field" x-show="selectedTransaction?.tx?.encrypted_size || selectedTransaction?.tx?.encryptedSize"><span class="tx-label">Encrypted Bytes</span><span class="tx-value number" x-text="selectedTransaction?.tx?.encrypted_size || selectedTransaction?.tx?.encryptedSize"></span></div> - <div class="tx-field" x-show="selectedTransaction?.tx?.expires_at_height || selectedTransaction?.tx?.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="selectedTransaction?.tx?.expires_at_height || selectedTransaction?.tx?.expiresAtHeight"></span></div> - <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Difficulty</span><span class="tx-value number" x-text="txDifficultyBits(selectedTransaction?.tx) ?? '-'"></span></div> - <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number" x-text="txProofBits(selectedTransaction?.tx) ?? '-'"></span></div> - <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="txProofHash(selectedTransaction?.tx) || '-'"></code></div> - </div> - <div class="utxo-flow"> - <div class="utxo-column"> - <h3>Inputs</h3> - <template x-for="(input, index) in txInputs(selectedTransaction?.tx || {})" :key="txInputKey(input, index)"> - <div class="utxo-node"> - <div class="utxo-node-label"><span>Input <span x-text="index + 1"></span></span><span>spent</span></div> - <div class="utxo-node-ref" x-text="txInputOutpoint(input)"></div> - <div class="tx-field"><span class="tx-label">Value</span><span class="tx-value money" x-text="txInputAmountLabel(input)"></span></div> - <div class="tx-field"><span class="tx-label">Owner</span><code class="tx-value hash" x-text="addressLabel(input.owner)"></code></div> - <div class="tx-field"><span class="tx-label">Sig</span><code class="tx-value hash" x-text="short(input.signature)"></code></div> - </div> - </template> - <div class="tx-modal-empty" x-show="txInputs(selectedTransaction?.tx || {}).length === 0">No inputs</div> - </div> - <div class="utxo-arrow" aria-hidden="true">&rarr;</div> - <div class="utxo-column"> - <h3>Outputs</h3> - <template x-for="(output, index) in txVisualOutputs(selectedTransaction?.tx || {})" :key="txOutputKey(output, index)"> - <div class="utxo-node" :class="{ burned: output.kind === 'burned', fee: output.kind === 'fee' }"> - <div class="utxo-node-label"><span x-text="output.label"></span><span x-text="output.kind"></span></div> - <div class="utxo-node-amount">IUNA <span x-text="amountLabel(output.amount)"></span></div> - <template x-if="output.address"> - <div class="tx-field"><span class="tx-label">To</span><code class="tx-value hash" x-text="addressLabel(output.address)"></code></div> - </template> - <template x-if="output.detail"> - <div class="tx-field"><span class="tx-label" x-text="output.detailLabel"></span><code class="tx-value hash" x-text="output.detail"></code></div> - </template> - </div> - </template> - <div class="tx-modal-empty" x-show="txVisualOutputs(selectedTransaction?.tx || {}).length === 0">No outputs</div> - </div> - </div> - </section> - </div> - <div class="setup-overlay transaction-overlay" x-show="addressBookModalOpen" x-transition.opacity @click.self="closeAddressBookModal()" role="dialog" aria-modal="true" aria-label="Address Book"> - <section class="tx-modal address-book-modal"> - <div class="tx-modal-head"> - <div class="tx-modal-title"> - <span class="pill">Address Book</span> - </div> - <button class="icon-button" type="button" @click="closeAddressBookModal()" title="Close" aria-label="Close"> - <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg> - </button> - </div> - <form @submit.prevent="saveAddressBookEntry"> - <label>Name<input x-model="addressBookDraftName" autocomplete="off" required></label> - <label>Address<input x-model="addressBookDraftAddress" autocomplete="off" required :class="{ invalid: addressBookDraftAddress && !validAddressBookAddress(addressBookDraftAddress) }"></label> - <div class="setup-feedback error" x-show="addressBookDraftAddress && !validAddressBookAddress(addressBookDraftAddress)">Address must be a 64 character hex public key</div> - <div class="address-book-modal-actions"> - <button class="icon-button modal-delete-button" type="button" x-show="addressBookEditingAddress" @click="removeAddressBookEntry({ address: addressBookEditingAddress, name: addressBookDraftName || addressBookEditingAddress })" title="Delete contact" aria-label="Delete contact"> - <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M6 7l1 14h10l1-14"></path><path d="M9 7V4h6v3"></path></svg> - </button> - <button class="primary icon-button" type="submit" title="Save contact" aria-label="Save contact"> - <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"></path><path d="M7 3v6h8"></path><path d="M7 21v-8h10v8"></path></svg> - </button> - </div> - </form> - </section> - </div> - <div class="setup-overlay transaction-overlay" x-show="addressBookPickerOpen" x-transition.opacity @click.self="closeAddressBookPicker()" role="dialog" aria-modal="true" aria-labelledby="address-book-picker-title"> - <section class="tx-modal address-book-modal"> - <div class="tx-modal-head"> - <div class="tx-modal-title"> - <span class="pill">Send</span> - <h2 id="address-book-picker-title">Choose Contact</h2> - </div> - <button class="icon-button" type="button" @click="closeAddressBookPicker()" title="Close" aria-label="Close"> - <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg> - </button> - </div> - <div class="address-book-picker-list"> - <template x-for="entry in addressBookEntries()" :key="entry.address"> - <button class="address-book-picker-row" type="button" @click="selectTransferContact(entry.address)" :title="`Send to ${entry.name}`"> - <span class="address-book-name" x-text="entry.name"></span> - <code class="tx-value hash" x-text="short(entry.address)"></code> - </button> - </template> - </div> - </section> - </div> - <div class="setup-overlay" x-show="showingSetup()" x-transition.opacity role="dialog" aria-modal="true" aria-labelledby="setup-title"> - <section class="setup-modal"> - <div class="setup-modal-head"> - <div class="setup-welcome">Welcome to iuna</div> - <h2 id="setup-title">Initial Setup</h2> - <div class="setup-copy">Connect this node to the network, then set up the local wallet.</div> - </div> - <div class="setup-feedback" :class="setupFeedback?.kind" x-show="setupFeedback" x-transition x-text="setupFeedback?.message"></div> - <div class="setup-grid"> - <div class="setup-section setup-node-mode"> - <div class="panel-head"> - <h3>Mode</h3> - <span class="pill">Change later in Settings</span> - </div> - <div class="segmented setup-mode-picker" role="tablist" aria-label="Initial node mode"> - <button type="button" :class="{ active: setupNodeMode === 'wallet' }" @click="selectSetupNodeMode('wallet')">Wallet</button> - <button type="button" :class="{ active: setupNodeMode === 'non-listening' }" @click="selectSetupNodeMode('non-listening')">Non-listening node</button> - <button type="button" :class="{ active: setupNodeMode === 'listening' }" @click="selectSetupNodeMode('listening')">Listening node</button> - </div> - <div class="setup-network-copy" x-text="setupNodeModeCopy()"></div> - </div> - <div class="setup-section setup-network"> - <div class="panel-head"> - <h3>Network</h3> - <a class="setup-network-link" href="https://github.com/iuna-labs/iuna/blob/main/KNOWN_NODES.txt" target="_blank" rel="noreferrer">Known nodes</a> - </div> - <div class="setup-network-row"> - <label><span x-text="setupRequiresPeer() ? 'Bootstrap peer (required)' : 'Bootstrap peer'"></span><input x-model="setupPeerAddress" placeholder="iuna.jhx.app:9444"></label> - <label x-show="setupNodeMode === 'listening'" x-transition>Bind port<input x-model.number="p2pBindPort" @input="p2pBindPortDirty = true" type="number" min="1" max="65535" step="1" required></label> - </div> - <div class="setup-network-copy" x-text="setupRequiresPeer() ? 'A bootstrap peer is required before this node can join the network. Known nodes help discovery; they do not control your wallet or decide valid blocks.' : 'You can add a bootstrap peer now or later from the P2P screen. Known nodes help discovery; they do not control your wallet or decide valid blocks.'"></div> - </div> - <div class="setup-section setup-wallet-section seed-panel"> - <div class="panel-head"> - <h3>Wallet</h3> - </div> - <div class="segmented" role="tablist" aria-label="Wallet setup mode"> - <button type="button" :class="{ active: setupWalletMode === 'create' }" @click="selectSetupWalletMode('create')">Create</button> - <button type="button" :class="{ active: setupWalletMode === 'import' }" @click="selectSetupWalletMode('import')">Import</button> - </div> - <div x-show="setupWalletMode === 'create'" class="seed-panel"> - <div class="setup-field"> - <div class="setup-field-label">Address</div> - <div class="address-box setup-address-box"> - <code x-text="setupAddress()"></code> - <button type="button" @click="copyAddress">Copy</button> - </div> - </div> - <template x-if="setupSeedWords().length > 0 && setupSeedStep === 'write'"> - <div class="seed-panel"> - <div class="setup-field"> - <div class="setup-field-label">Recovery phrase</div> - <div class="seed-grid"> - <template x-for="(word, index) in setupSeedWords()" :key="index"> - <div class="seed-word"> - <span class="index" x-text="index + 1"></span> - <span class="word" x-text="word"></span> - </div> - </template> - </div> - </div> - <div class="setup-actions"> - <button type="button" class="subtle" @click="generateSetupSeed">Regenerate</button> - <button type="button" class="subtle" x-show="setupWallet.dev_verify_bypass" @click="skipSeedVerificationForDev">Skip verification</button> - <button type="button" class="primary" @click="beginSeedVerification">I wrote it down</button> - </div> - </div> - </template> - <template x-if="setupSeedWords().length === 0"> - <div class="seed-panel"> - <div class="muted">This wallet does not have a recovery phrase yet.</div> - <button type="button" class="primary" @click="generateSetupSeed">Generate recovery phrase</button> - </div> - </template> - <template x-if="setupSeedStep === 'verify'"> - <div class="seed-panel"> - <div class="verify-grid"> - <template x-for="challenge in verifyChallenges" :key="challenge.index"> - <label> - <span>Word <span x-text="challenge.position"></span></span> - <input x-model="verifyAnswers[challenge.index]" autocomplete="off"> - </label> - </template> - </div> - <div class="setup-actions"> - <button type="button" class="subtle" @click="setupSeedStep = 'write'">Back</button> - <button type="button" class="primary" @click="verifyGeneratedSeed">Verify</button> - </div> - </div> - </template> - <template x-if="setupSeedStep === 'verified' && walletVerified"> - <div class="setup-status">Recovery phrase verified</div> - </template> - </div> - <div x-show="setupWalletMode === 'import'" class="seed-panel"> - <form @submit.prevent="importSetupSeed"> - <label>Recovery phrase<textarea x-model="importSeedPhrase" autocomplete="off" spellcheck="false" placeholder="24 words, separated by spaces or new lines"></textarea></label> - <button class="primary" type="submit">Import</button> - </form> - <template x-if="walletVerified"> - <div class="setup-status">Recovery phrase imported</div> - </template> - </div> - </div> - </div> - <div class="setup-actions"> - <button class="primary" type="button" :disabled="!setupCanContinue()" @click="completeSetup">Continue</button> - </div> - </section> - </div> -</body> -</html>"#; - -#[cfg(test)] -mod tests { - use std::{collections::BTreeMap, sync::Arc}; - - use axum::{ - Router, - body::{Body, to_bytes}, - http::{HeaderMap, Method, Request, StatusCode, header}, - middleware, - routing::{get, post}, - }; - use tokio::sync::Mutex; - use tower::ServiceExt; - - use crate::{ - adapters::{ - chain_store::SqliteChainStore, config_store, config_store::UiConfig, - p2p::GossipNetwork, wallet_store, - }, - app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus}, - domain::{ - Amount, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, GenesisBurn, - LaunchProfile, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MaskedBlindedReveal, OutPoint, - RevealBundleSection, RevealBundleSignature, Transaction, TxInput, TxOutput, Wallet, - }, - }; - - use super::{ - AUTH_COOKIE_NAME, HttpState, PEER_STALE_AFTER_MS, TransferForm, WalletTransactionFilters, - WalletTransactionsQuery, api_auth_change_password_form, api_auth_login_form, - api_auth_setup_form, api_auth_status, auth_client_key, dev_seed_verify_bypass_allowed, - hash_password, hex_encode, pbkdf2_sha256, persist_burn_settings_config, - persist_pow_mining_config, require_auth_middleware, required_fee_per_byte_burn, - same_origin_request, validate_password, validate_transfer_form, verify_password, - wallet_transaction_rows, wallet_utxo_rows, - }; - - #[test] - fn dev_seed_verify_bypass_requires_env_flag() { - assert!(dev_seed_verify_bypass_allowed(true)); - assert!(!dev_seed_verify_bypass_allowed(false)); - } - - #[test] - fn mempool_ui_items_can_represent_blinded_transactions_and_reveals() { - let commitment = "a".repeat(64); - let owner = "c".repeat(64); - let input_outpoint = OutPoint { - txid: "d".repeat(64), - index: 0, - }; - let blinded = BlindedTransaction { - commitment: commitment.clone(), - inputs: vec![TxInput { - outpoint: input_outpoint.clone(), - owner: owner.clone(), - signature: "e".repeat(128), - }], - fee: 7, - encrypted_size: 123, - expires_at_height: 42, - nonce: "00".repeat(12), - ciphertext: "11".repeat(123), - payload_hash: "b".repeat(64), - }; - let reveal = BlindedReveal { - commitment: commitment.clone(), - key: "22".repeat(32), - }; - let outputs = BTreeMap::from([( - input_outpoint.clone(), - TxOutput { - address: owner.clone(), - amount: 99, - }, - )]); - - let blinded_row = super::ui_blinded_transaction(&blinded, &outputs); - let reveal_row = super::ui_blinded_reveal(&reveal); - let revealed_row = super::ui_pending_revealed_transaction( - &crate::domain::RevealedBlindedTransaction { - height: 2, - commitment: reveal.commitment.clone(), - included_by: owner.clone(), - transaction: Transaction::Burn { - inputs: blinded.inputs.clone(), - change: Vec::new(), - amount: 12, - fee: blinded.fee, - signature: "f".repeat(128), - }, - }, - &outputs, - ); - - assert_eq!(blinded_row.kind, "blinded"); - assert_eq!(blinded_row.from, owner); - assert_eq!(blinded_row.inputs.len(), 1); - assert_eq!(blinded_row.inputs[0].amount, Some(99)); - assert_eq!(blinded_row.signature, commitment); - assert_eq!(blinded_row.encrypted_size, Some(123)); - assert_eq!(blinded_row.expires_at_height, Some(42)); - assert_eq!(reveal_row.kind, "reveal"); - assert_eq!(reveal_row.commitment, blinded_row.commitment); - assert_eq!(revealed_row.kind, "burn"); - assert!(revealed_row.revealed); - assert_eq!(revealed_row.commitment, Some(reveal.commitment)); - assert_eq!(revealed_row.amount, 12); - assert_eq!(revealed_row.fee, 7); - assert_eq!(revealed_row.inputs[0].amount, Some(99)); - } - - #[test] - fn block_detail_transactions_include_blinded_and_revealed_items() { - let alice = Wallet::from_seed("block-detail-blind-alice"); - let bob = Wallet::from_seed("block-detail-blind-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 100); - allocations.insert(bob.address().to_string(), 100); - let ledger = Ledger::new(allocations.clone(), 1); - let transfer = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap(); - let built = ledger - .build_blinded_transaction(&alice, transfer.clone(), 20) - .unwrap(); - let mut block = fake_block(7, Vec::new()); - block.blinded_transactions = vec![built.transaction.clone()]; - - let revealed = crate::domain::RevealedBlindedTransaction { - height: 7, - commitment: built.transaction.commitment.clone(), - included_by: "miner".to_string(), - transaction: transfer, - }; - let ui_block = super::ui_block(block, &BTreeMap::new(), &BTreeMap::new(), &[revealed]); - - assert_eq!(ui_block.transactions.len(), 2); - assert_eq!(ui_block.transactions[0].kind, "blinded"); - assert_eq!( - ui_block.transactions[0].commitment.as_deref(), - Some(built.transaction.commitment.as_str()) - ); - assert!(!ui_block.transactions[0].revealed); - assert_eq!(ui_block.transactions[1].kind, "transfer"); - assert!(ui_block.transactions[1].revealed); - assert_eq!(ui_block.revealed_transactions.len(), 1); - assert!(ui_block.revealed_transactions[0].revealed); - assert!(ui_block.total_bytes > 0); - assert!(ui_block.blinded_transaction_bytes > 0); - assert_eq!(ui_block.reveal_bundle_bytes, 0); - - let burn = ledger.build_burn(&alice, 5, 1).unwrap(); - let mine = Transaction::Mine { - recipient: bob.address().to_string(), - anchor: "a".repeat(64), - salt: 1, - nonce: 2, - difficulty_bits: 12, - proof_header: None, - signature: "b".repeat(64), - }; - let typed_block = super::ui_block( - fake_block( - 8, - vec![ - ledger.build_transfer(&alice, bob.address(), 7, 1).unwrap(), - burn, - mine, - ], - ), - &BTreeMap::new(), - &BTreeMap::new(), - &[], - ); - let typed_bytes = typed_block - .transaction_byte_breakdown - .iter() - .map(|row| (row.label, row.bytes)) - .collect::<BTreeMap<_, _>>(); - assert!(typed_bytes["transfer"] > 0); - assert!(typed_bytes["burn"] > 0); - assert!(typed_bytes["mine"] > 0); - } - - #[test] - fn block_detail_reconstructs_revealed_items_from_snapshot_blocks() { - let alice = Wallet::from_seed("block-detail-reveal-alice"); - let bob = Wallet::from_seed("block-detail-reveal-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 100); - allocations.insert(bob.address().to_string(), 100); - let ledger = Ledger::new(allocations.clone(), 1); - let transfer = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap(); - let built = ledger - .build_blinded_transaction(&alice, transfer.clone(), 20) - .unwrap(); - let mut commit_block = fake_block(7, Vec::new()); - commit_block.blinded_transactions = vec![built.transaction.clone()]; - let mut reveal_block = fake_block(8, Vec::new()); - reveal_block.reveal_bundle_section = RevealBundleSection { - signatures: vec![RevealBundleSignature { - slot: 0, - member: reveal_block.miner.clone(), - signature: "11".repeat(64), - }], - reveals: vec![MaskedBlindedReveal { - reveal: built.reveal.clone(), - bundle_mask: 1, - }], - }; - let snapshot = fake_snapshot( - allocations, - vec![commit_block.clone(), reveal_block.clone()], - ); - - let blocks = super::ui_blocks( - vec![commit_block, reveal_block], - &snapshot, - &[], - &BTreeMap::new(), - ); - - assert_eq!(blocks[0].transactions.len(), 1); - assert_eq!(blocks[0].transactions[0].kind, "blinded"); - assert_eq!( - blocks[0].transactions[0].commitment.as_deref(), - Some(built.transaction.commitment.as_str()) - ); - assert_eq!(blocks[1].transactions.len(), 1); - assert_eq!(blocks[1].transactions[0].kind, "transfer"); - assert!(blocks[1].transactions[0].revealed); - assert_eq!(blocks[1].transactions[0].amount, transfer.amount()); - assert_eq!(blocks[1].transactions[0].to.as_deref(), Some(bob.address())); - assert_eq!(blocks[1].revealed_transactions.len(), 1); - } - - #[test] - fn block_detail_markup_uses_blinded_and_revealed_labels() { - let app_js = include_str!("../../www/assets/iuna-ui.js"); - - assert!(super::INDEX_HTML.contains("txPillLabel(tx)")); - assert!(super::INDEX_HTML.contains("commitCountLabel(block)")); - assert!(super::INDEX_HTML.contains(".pill.blinded")); - assert!(super::INDEX_HTML.contains(".pill.reveal, .pill.revealed")); - assert!(super::INDEX_HTML.contains(":class=\"mempoolItemClass(tx)\"")); - assert!(super::INDEX_HTML.contains(".mempool-item.before-last-block")); - assert!(super::INDEX_HTML.contains(":class=\"row[2]\"")); - assert!(super::INDEX_HTML.contains("New since last block")); - assert!(super::INDEX_HTML.contains("class=\"mempool-top\"")); - assert!(super::INDEX_HTML.contains("mempoolSeenTimeLabel(tx)")); - assert!(super::INDEX_HTML.contains("<details class=\"tx-section\">")); - assert!(super::INDEX_HTML.contains("<summary class=\"tx-section-title\">")); - assert!(super::INDEX_HTML.contains("Commitment")); - assert!(!super::INDEX_HTML.contains("<h3>Revealed</h3>")); - assert!(app_js.contains("tx?.revealed ? \"revealed\"")); - assert!(app_js.contains("transactions.some((tx) => tx?.revealed)")); - assert!(app_js.contains("blockCommitCount(block)")); - assert!(app_js.contains("blockTransactionByteBreakdown(block)")); - assert!(app_js.contains("[label, Number(row.bytes ?? 0), label]")); - } - - #[test] - fn password_policy_rejects_short_or_excessive_passwords() { - let short = validate_password("too-short").unwrap_err(); - assert!(short.to_string().contains("at least 12")); - - let long_password = "x".repeat(1025); - let long = validate_password(&long_password).unwrap_err(); - assert!(long.to_string().contains("too long")); - - validate_password("correct horse battery staple").unwrap(); - } - - #[test] - fn password_hash_round_trips_without_storing_plaintext() { - let password = "correct horse battery staple"; - let encoded = hash_password(password).unwrap(); - - assert!(!encoded.contains(password)); - assert!(verify_password(password, &encoded).unwrap()); - assert!(!verify_password("wrong horse battery staple", &encoded).unwrap()); - } - - #[test] - fn pbkdf2_sha256_matches_known_vectors() { - let one_iteration = pbkdf2_sha256(b"password", b"salt", 1); - assert_eq!( - hex_encode(one_iteration), - "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b" - ); - - let two_iterations = pbkdf2_sha256(b"password", b"salt", 2); - assert_eq!( - hex_encode(two_iterations), - "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43" - ); - } - - #[test] - fn same_origin_check_accepts_forwarded_host_and_rejects_cross_site_origin() { - let mut headers = HeaderMap::new(); - headers.insert(header::HOST, "127.0.0.1:18661".parse().unwrap()); - headers.insert("x-forwarded-host", "iuna.example".parse().unwrap()); - headers.insert(header::ORIGIN, "https://iuna.example".parse().unwrap()); - assert!(same_origin_request(&headers)); - - headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap()); - assert!(!same_origin_request(&headers)); - } - - #[test] - fn auth_client_key_trusts_forwarded_headers_only_from_private_or_local_peers() { - let mut headers = HeaderMap::new(); - headers.insert("x-forwarded-for", "198.51.100.99".parse().unwrap()); - let socket = Some("203.0.113.10:51234".parse().unwrap()); - - assert_eq!(auth_client_key(&headers, socket), "203.0.113.10"); - assert_eq!( - auth_client_key(&headers, Some("127.0.0.1:51234".parse().unwrap())), - "198.51.100.99" - ); - assert_eq!( - auth_client_key(&headers, Some("10.42.1.12:51234".parse().unwrap())), - "198.51.100.99" - ); - assert_eq!( - auth_client_key(&headers, Some("172.20.4.8:51234".parse().unwrap())), - "198.51.100.99" - ); - assert_eq!(auth_client_key(&headers, None), "198.51.100.99"); - } - - #[tokio::test] - async fn protected_endpoints_require_authentication_setup() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state( - dir.path().join("config.json"), - UiConfig { - auth_password_hash: None, - ..UiConfig::default() - }, - ) - .await; - let app = auth_test_app(state); - - let protected = http_request(app.clone(), Method::GET, "/api/protected", None, "").await; - assert_eq!(protected.status, StatusCode::UNAUTHORIZED); - assert!(protected.body.contains("authentication setup is required")); - - let status = http_request(app, Method::GET, "/api/auth/status", None, "").await; - assert_eq!(status.status, StatusCode::OK); - assert!(status.body.contains("\"configured\":false")); - } - - #[tokio::test] - async fn favicon_is_public_before_authentication_setup() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state( - dir.path().join("config.json"), - UiConfig { - auth_password_hash: None, - ..UiConfig::default() - }, - ) - .await; - let app = auth_test_app(state); - - let response = http_request(app, Method::GET, "/favicon.ico", None, "").await; - - assert_eq!(response.status, StatusCode::NO_CONTENT); - } - - #[tokio::test] - async fn protected_endpoints_require_valid_session_after_authentication_setup() { - let dir = tempfile::tempdir().unwrap(); - let password = "correct horse battery staple"; - let state = auth_test_state( - dir.path().join("config.json"), - UiConfig { - auth_password_hash: Some(hash_password(password).unwrap()), - ..UiConfig::default() - }, - ) - .await; - let app = auth_test_app(state); - - let missing_cookie = - http_request(app.clone(), Method::GET, "/api/protected", None, "").await; - assert_eq!(missing_cookie.status, StatusCode::UNAUTHORIZED); - assert!(missing_cookie.body.contains("authentication required")); - - let bad_cookie = http_request( - app.clone(), - Method::GET, - "/api/protected", - Some("iuna_session=bogus"), - "", - ) - .await; - assert_eq!(bad_cookie.status, StatusCode::UNAUTHORIZED); - - let login = http_request( - app.clone(), - Method::POST, - "/api/auth/login", - None, - "password=correct+horse+battery+staple", - ) - .await; - assert_eq!(login.status, StatusCode::OK); - assert!(login.body.contains("\"ok\":true")); - let cookie = set_cookie_pair(&login.headers); - assert!(cookie.starts_with(AUTH_COOKIE_NAME)); - - let protected = http_request(app, Method::GET, "/api/protected", Some(&cookie), "").await; - assert_eq!(protected.status, StatusCode::OK); - assert_eq!(protected.body, "protected"); - } - - #[tokio::test] - async fn auth_posts_require_same_origin_headers() { - let dir = tempfile::tempdir().unwrap(); - let password = "correct horse battery staple"; - let state = auth_test_state( - dir.path().join("config.json"), - UiConfig { - auth_password_hash: Some(hash_password(password).unwrap()), - ..UiConfig::default() - }, - ) - .await; - let app = auth_test_app(state); - - let response = app - .oneshot( - Request::builder() - .method(Method::POST) - .uri("/api/auth/login") - .header(header::HOST, "127.0.0.1:18661") - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .body(Body::from("password=correct+horse+battery+staple")) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .to_vec(), - ) - .unwrap(); - assert!(body.contains("same-origin request required")); - } - - #[tokio::test] - async fn login_authentication_locks_out_after_repeated_failures() { - let dir = tempfile::tempdir().unwrap(); - let password = "correct horse battery staple"; - let state = auth_test_state( - dir.path().join("config.json"), - UiConfig { - auth_password_hash: Some(hash_password(password).unwrap()), - ..UiConfig::default() - }, - ) - .await; - let client_a = "198.51.100.10"; - let client_b = "198.51.100.11"; - - for _ in 0..super::AUTH_MAX_FAILED_ATTEMPTS { - let error = super::login_auth_password(&state, "wrong horse battery staple", client_a) - .await - .unwrap_err(); - assert!(format!("{error:#}").contains("invalid password")); - } - - let locked = super::login_auth_password(&state, password, client_a) - .await - .unwrap_err(); - assert!(format!("{locked:#}").contains("too many failed login attempts")); - - let other_client_cookie = super::login_auth_password(&state, password, client_b) - .await - .unwrap(); - assert!(other_client_cookie.starts_with(AUTH_COOKIE_NAME)); - - state - .auth_backoff - .lock() - .await - .get_mut(client_a) - .unwrap() - .locked_until_ms = Some(crate::app::now_ms().saturating_sub(1)); - let cookie = super::login_auth_password(&state, password, client_a) - .await - .unwrap(); - assert!(cookie.starts_with(AUTH_COOKIE_NAME)); - assert!(!state.auth_backoff.lock().await.contains_key(client_a)); - } - - #[tokio::test] - async fn password_setup_creates_session_for_protected_endpoints() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state(config_path.clone(), UiConfig::default()).await; - let app = auth_test_app(state); - - let setup = http_request( - app.clone(), - Method::POST, - "/api/auth/setup", - None, - "password=correct+horse+battery+staple", - ) - .await; - assert_eq!(setup.status, StatusCode::OK); - assert!(setup.body.contains("\"ok\":true")); - let cookie = set_cookie_pair(&setup.headers); - - let stored = config_store::load_or_create(&config_path).unwrap(); - assert!(stored.auth_password_hash.is_some()); - let protected = http_request(app, Method::GET, "/api/protected", Some(&cookie), "").await; - assert_eq!(protected.status, StatusCode::OK); - assert_eq!(protected.body, "protected"); - } - - #[tokio::test] - async fn password_change_reencrypts_wallet_and_replaces_login_password() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let wallet_path = config_path.with_file_name("wallet.json"); - let old_password = "correct horse battery staple"; - let new_password = "new correct battery staple"; - let state = auth_test_state( - config_path.clone(), - UiConfig { - auth_password_hash: Some(hash_password(old_password).unwrap()), - setup_complete: true, - ..UiConfig::default() - }, - ) - .await; - wallet_store::encrypt_existing_with_password(&wallet_path, old_password).unwrap(); - let app = auth_test_app(state); - - let login = http_request( - app.clone(), - Method::POST, - "/api/auth/login", - None, - "password=correct+horse+battery+staple", - ) - .await; - assert_eq!(login.status, StatusCode::OK); - let cookie = set_cookie_pair(&login.headers); - - let change = http_request( - app.clone(), - Method::POST, - "/api/auth/change-password", - Some(&cookie), - "old_password=correct+horse+battery+staple&new_password=new+correct+battery+staple", - ) - .await; - assert_eq!(change.status, StatusCode::OK); - assert!(change.body.contains("\"ok\":true")); - - let stored = config_store::load_or_create(&config_path).unwrap(); - let stored_hash = stored.auth_password_hash.unwrap(); - assert!(!verify_password(old_password, &stored_hash).unwrap()); - assert!(verify_password(new_password, &stored_hash).unwrap()); - assert!(wallet_store::load_with_password(&wallet_path, old_password).is_err()); - assert!(wallet_store::load_with_password(&wallet_path, new_password).is_ok()); - - let old_login = http_request( - app.clone(), - Method::POST, - "/api/auth/login", - None, - "password=correct+horse+battery+staple", - ) - .await; - assert!(old_login.body.contains("invalid password")); - - let new_login = http_request( - app, - Method::POST, - "/api/auth/login", - None, - "password=new+correct+battery+staple", - ) - .await; - assert_eq!(new_login.status, StatusCode::OK); - } - - #[tokio::test] - async fn peer_management_updates_config_file() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state( - config_path.clone(), - UiConfig { - setup_complete: true, - ..UiConfig::default() - }, - ) - .await; - - super::add_peer(&state, " 127.0.0.1:9445 ".to_string()) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert_eq!(config.peers, vec!["127.0.0.1:9445"]); - assert_eq!(state.peers.lock().await.addresses(), vec!["127.0.0.1:9445"]); - - super::remove_peer(&state, "127.0.0.1:9445".to_string()) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(config.peers.is_empty()); - assert!(state.peers.lock().await.addresses().is_empty()); - } - - #[tokio::test] - async fn address_book_updates_config_file() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state( - config_path.clone(), - UiConfig { - setup_complete: true, - ..UiConfig::default() - }, - ) - .await; - let alice = Wallet::from_seed("address-book-alice"); - let bob = Wallet::from_seed("address-book-bob"); - - super::upsert_address_book_entry( - &state, - format!(" {} ", alice.address().to_ascii_uppercase()), - " Alice ".to_string(), - None, - ) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert_eq!( - config.address_book.get(alice.address()), - Some(&"Alice".to_string()) - ); - - super::upsert_address_book_entry( - &state, - alice.address().to_string(), - "Alice Prime".to_string(), - Some(alice.address().to_string()), - ) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert_eq!( - config.address_book.get(alice.address()), - Some(&"Alice Prime".to_string()) - ); - - let error = super::upsert_address_book_entry( - &state, - alice.address().to_string(), - "Alice Duplicate".to_string(), - None, - ) - .await - .unwrap_err(); - assert!(format!("{error:#}").contains("address is already saved")); - - let carol = Wallet::from_seed("address-book-carol"); - super::upsert_address_book_entry( - &state, - carol.address().to_string(), - "Carol".to_string(), - None, - ) - .await - .unwrap(); - let error = super::upsert_address_book_entry( - &state, - carol.address().to_string(), - "Bob As Carol".to_string(), - Some(alice.address().to_string()), - ) - .await - .unwrap_err(); - assert!(format!("{error:#}").contains("address is already saved")); - - super::upsert_address_book_entry( - &state, - bob.address().to_string(), - "Bob".to_string(), - Some(alice.address().to_string()), - ) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(!config.address_book.contains_key(alice.address())); - assert_eq!( - config.address_book.get(bob.address()), - Some(&"Bob".to_string()) - ); - assert_eq!( - config.address_book.get(carol.address()), - Some(&"Carol".to_string()) - ); - - let error = super::upsert_address_book_entry( - &state, - "iuna-address".to_string(), - "Not Alice".to_string(), - None, - ) - .await - .unwrap_err(); - assert!(format!("{error:#}").contains("invalid address book address")); - - super::remove_address_book_entry(&state, bob.address().to_string()) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(!config.address_book.contains_key(bob.address())); - assert!(config.address_book.contains_key(carol.address())); - } - - #[tokio::test] - async fn p2p_announce_setting_persists_config_and_waits_for_public_node() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state( - config_path.clone(), - UiConfig { - setup_complete: true, - ..UiConfig::default() - }, - ) - .await; - - super::set_p2p_announce_addr(&state, " 203.0.113.10:9444 ".to_string()) - .await - .unwrap(); - - let config = config_store::load_or_create(&config_path).unwrap(); - assert_eq!( - config.p2p_announce_addr.as_deref(), - Some("203.0.113.10:9444") - ); - match state.gossip.peer_exchange().await { - GossipEnvelope::PeerList { peers } => { - assert!(!peers.contains(&"203.0.113.10:9444".to_string())); - } - other => panic!("expected peer list, got {other:?}"), - } - - super::set_p2p_accept_inbound(&state, true, Some(9555)) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(config.p2p_accept_inbound); - assert_eq!(config.p2p_bind_port, 9555); - assert!(!state.gossip.accepts_inbound().await); - match state.gossip.peer_exchange().await { - GossipEnvelope::PeerList { peers } => { - assert!(!peers.contains(&"203.0.113.10:9444".to_string())); - } - other => panic!("expected peer list, got {other:?}"), - } - - super::set_p2p_accept_inbound(&state, false, None) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(!config.p2p_accept_inbound); - assert!(!state.gossip.accepts_inbound().await); - - super::set_p2p_announce_addr(&state, " ".to_string()) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(config.p2p_announce_addr.is_none()); - } - - #[tokio::test] - async fn p2p_announce_setting_rejects_invalid_address() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; - - let error = super::set_p2p_announce_addr(&state, "not-an-address".to_string()) - .await - .unwrap_err(); - - assert!(format!("{error:#}").contains("invalid P2P announce address")); - } - - #[tokio::test] - async fn setup_config_form_can_add_bootstrap_peer() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state(config_path.clone(), UiConfig::default()).await; - - super::apply_config_form( - &state, - super::ConfigForm { - setup_complete: true, - peer: " iuna.jhx.app:9444 ".to_string(), - }, - ) - .await - .unwrap(); - - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(config.setup_complete); - assert_eq!(config.peers, vec!["iuna.jhx.app:9444"]); - assert_eq!( - state.peers.lock().await.addresses(), - vec!["iuna.jhx.app:9444"] - ); - } - - #[tokio::test] - async fn setup_config_form_requires_peer_for_placeholder_chain() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state(config_path.clone(), UiConfig::default()).await; - - let error = super::apply_config_form( - &state, - super::ConfigForm { - setup_complete: true, - peer: " ".to_string(), - }, - ) - .await - .unwrap_err(); - - assert!(format!("{error:#}").contains("add a bootstrap peer")); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(!config.setup_complete); - } - - #[tokio::test] - async fn peer_management_rejects_empty_and_inbound_removal() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; - - assert!(super::add_peer(&state, " ".to_string()).await.is_err()); - state - .peers - .lock() - .await - .record_received("127.0.0.1:9555", 1); - - let result = super::remove_peer(&state, "127.0.0.1:9555".to_string()).await; - assert!(result.is_err()); - assert_eq!(state.peers.lock().await.addresses(), Vec::<String>::new()); - } - - #[tokio::test] - async fn network_health_summarizes_sync_and_peer_errors() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; - let status = state.node.lock().await.status(); - let mempool = super::MempoolCounts { - plain_transactions: 1, - blinded_transactions: 2, - blinded_reveals: 3, - }; - - let isolated = super::network_health(&status, &[], mempool); - assert!(!isolated.ok); - assert_eq!(isolated.state, "isolated"); - assert_eq!(isolated.local_height, 0); - assert_eq!(isolated.best_known_height, 0); - assert_eq!(isolated.pending_plain_transactions, 1); - assert_eq!(isolated.pending_blinded_transactions, 2); - assert_eq!(isolated.pending_blinded_reveals, 3); - - let mut clock_peers = PeerBook::from_addresses(vec![ - "127.0.0.1:9450".to_string(), - "127.0.0.1:9451".to_string(), - ]); - clock_peers.record_status("127.0.0.1:9450", 0, "tip".to_string()); - clock_peers.record_status("127.0.0.1:9451", 0, "tip".to_string()); - clock_peers.record_clock_observation( - "127.0.0.1:9450", - PeerDirection::Outbound, - 10_500, - 10_000, - ); - clock_peers.record_clock_observation( - "127.0.0.1:9451", - PeerDirection::Outbound, - 11 * 60 * 1_000, - 10_000, - ); - let clock_health = super::network_health_at(&status, &clock_peers.list(), mempool, 10_000); - assert_eq!(clock_health.network_time_offset_ms, Some(500)); - assert_eq!(clock_health.bad_clock_peers, 1); - - let syncing = super::network_health( - &status, - &[PeerInfo { - address: "127.0.0.1:9445".to_string(), - direction: PeerDirection::Outbound, - messages_sent: 1, - messages_received: 1, - last_known_height: Some(3), - last_known_tip_hash: Some("remote-tip".to_string()), - last_clock_offset_ms: None, - last_clock_offset_accepted: None, - last_clock_observed_ms: None, - last_error: None, - last_contact_ms: Some(10_000), - last_success_ms: Some(10_000), - last_error_ms: None, - misbehavior_score: 0, - banned_until_ms: None, - ban_reason: None, - }], - mempool, - ); - assert!(!syncing.ok); - assert_eq!(syncing.state, "syncing"); - assert_eq!(syncing.best_known_height, 3); - assert_eq!(syncing.lag_blocks, 3); - - let peer_errors = super::network_health( - &status, - &[PeerInfo { - address: "127.0.0.1:9446".to_string(), - direction: PeerDirection::Outbound, - messages_sent: 0, - messages_received: 0, - last_known_height: None, - last_known_tip_hash: None, - last_clock_offset_ms: None, - last_clock_offset_accepted: None, - last_clock_observed_ms: None, - last_error: Some("connection refused".to_string()), - last_contact_ms: Some(10_000), - last_success_ms: None, - last_error_ms: Some(10_000), - misbehavior_score: 1, - banned_until_ms: None, - ban_reason: Some("connection refused".to_string()), - }], - mempool, - ); - assert!(!peer_errors.ok); - assert_eq!(peer_errors.state, "peer errors"); - assert_eq!( - peer_errors.last_error.as_deref(), - Some("127.0.0.1:9446: connection refused") - ); - - let stale = super::network_health_at( - &status, - &[PeerInfo { - address: "127.0.0.1:9447".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_clock_offset_ms: None, - last_clock_offset_accepted: None, - last_clock_observed_ms: None, - last_error: None, - last_contact_ms: Some(1), - last_success_ms: Some(1), - last_error_ms: None, - misbehavior_score: 0, - banned_until_ms: None, - ban_reason: None, - }], - mempool, - PEER_STALE_AFTER_MS + 2, - ); - assert!(!stale.ok); - assert_eq!(stale.state, "stale"); - assert_eq!(stale.stale_peers, 1); - - let banned = super::network_health_at( - &status, - &[PeerInfo { - address: "127.0.0.1:9448".to_string(), - direction: PeerDirection::Outbound, - messages_sent: 0, - messages_received: 0, - last_known_height: None, - last_known_tip_hash: None, - last_clock_offset_ms: None, - last_clock_offset_accepted: None, - last_clock_observed_ms: None, - last_error: Some("invalid block".to_string()), - last_contact_ms: Some(10), - last_success_ms: None, - last_error_ms: Some(10), - misbehavior_score: 3, - banned_until_ms: Some(1_000), - ban_reason: Some("invalid block".to_string()), - }], - mempool, - 20, - ); - assert!(!banned.ok); - assert_eq!(banned.state, "banned"); - assert_eq!(banned.banned_peers, 1); - } - - #[test] - fn wallet_transactions_include_old_confirmed_transfers_without_burns_or_explorer_pagination() { - let alice = Wallet::from_seed("wallet-history-alice"); - let bob = Wallet::from_seed("wallet-history-bob"); - let carol = Wallet::from_seed("wallet-history-carol"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 100); - allocations.insert(bob.address().to_string(), 100); - allocations.insert(carol.address().to_string(), 100); - let ledger = crate::domain::Ledger::new(allocations.clone(), 1); - let old_received = ledger.build_transfer(&bob, alice.address(), 31, 0).unwrap(); - let pending_burn = ledger.build_burn(&alice, 2, 1).unwrap(); - let carol_transfer = ledger.build_transfer(&carol, bob.address(), 5, 0).unwrap(); - let carol_burn = ledger.build_burn(&carol, 1, 0).unwrap(); - let chain = vec![ - fake_block(30, vec![carol_transfer]), - fake_block(31, vec![old_received.clone()]), - fake_block(32, vec![carol_burn]), - ]; - - let snapshot = fake_snapshot(allocations, chain.clone()); - let outputs = super::known_output_index(&snapshot, std::slice::from_ref(&pending_burn)); - let rows = wallet_transaction_rows( - alice.address(), - vec![pending_burn.clone()], - Vec::new(), - &chain, - &BTreeMap::new(), - &outputs, - WalletTransactionFilters::default(), - ); - - assert_eq!(rows.len(), 1); - assert_ne!(rows[0].signature, pending_burn.signature()); - assert_eq!(rows[0].signature, old_received.signature()); - assert_eq!(rows[0].inputs[0].amount, Some(100)); - assert_eq!(rows[0].status, "confirmed"); - assert_eq!(rows[0].block_height, Some(31)); - assert_eq!(rows[0].timestamp_ms, Some(31)); - assert_eq!(rows[0].block_finalizer.as_deref(), Some("miner")); - assert_eq!(rows[0].direction, "received"); - } - - #[test] - fn wallet_transactions_show_owned_blinded_payloads_as_pending_blind() { - let alice = Wallet::from_seed("wallet-blind-pending-alice"); - let bob = Wallet::from_seed("wallet-blind-pending-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 100); - allocations.insert(bob.address().to_string(), 100); - let ledger = Ledger::new(allocations.clone(), 1); - let pending_blind = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap(); - let snapshot = fake_snapshot(allocations, Vec::new()); - let outputs = super::known_output_index(&snapshot, &[]); - - let rows = wallet_transaction_rows( - alice.address(), - Vec::new(), - vec![pending_blind.clone()], - &[], - &BTreeMap::new(), - &outputs, - WalletTransactionFilters::default(), - ); - - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].kind, "transfer"); - assert_eq!(rows[0].status, "pending"); - assert_eq!(rows[0].timestamp_ms, None); - assert!(rows[0].blinded); - assert_eq!(rows[0].direction, "sent"); - assert_eq!(rows[0].to.as_deref(), Some(bob.address())); - assert_eq!(rows[0].amount, 12); - assert_eq!(rows[0].fee, 3); - assert_eq!(rows[0].signature, pending_blind.signature()); - } - - #[test] - fn wallet_transaction_query_defaults_to_tx_only() { - assert_eq!( - WalletTransactionFilters::from_query(WalletTransactionsQuery::default()), - WalletTransactionFilters::default() - ); - assert_eq!( - WalletTransactionFilters::from_query(WalletTransactionsQuery { - tx: Some(false), - mine: Some(true), - burn: Some(true), - offset: None, - limit: None, - }), - WalletTransactionFilters { - transfer: false, - mine: true, - burn: true, - } - ); - } - - #[test] - fn page_items_returns_bounded_slices_with_next_offset() { - let page = super::page_items( - vec![1, 2, 3, 4, 5], - super::PageQuery { - offset: Some(1), - limit: Some(2), - }, - ); - - assert_eq!(page.items, vec![2, 3]); - assert_eq!(page.offset, 1); - assert_eq!(page.limit, 2); - assert_eq!(page.total, 5); - assert!(page.has_more); - assert_eq!(page.next_offset, Some(3)); - } - - #[test] - fn page_items_clamps_limit_and_empty_tail() { - let page = super::page_items( - vec![1, 2], - super::PageQuery { - offset: Some(20), - limit: Some(0), - }, - ); - - assert!(page.items.is_empty()); - assert_eq!(page.offset, 2); - assert_eq!(page.limit, 1); - assert_eq!(page.total, 2); - assert!(!page.has_more); - assert_eq!(page.next_offset, None); - } - - #[test] - fn mine_transaction_views_include_protocol_finalizer_fee() { - let alice = Wallet::from_seed("wallet-mine-fee-alice"); - let ledger = Ledger::new(BTreeMap::new(), 1); - let mine = ledger.build_mine(alice.address()).unwrap(); - let chain = vec![fake_block(1, vec![mine.clone()])]; - let snapshot = fake_snapshot(BTreeMap::new(), chain.clone()); - let outputs = super::known_output_index(&snapshot, &[]); - - let rows = wallet_transaction_rows( - alice.address(), - Vec::new(), - Vec::new(), - &chain, - &BTreeMap::new(), - &outputs, - WalletTransactionFilters { - transfer: false, - mine: true, - burn: false, - }, - ); - let transaction = super::ui_transaction(&mine, &outputs); - - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].amount, mine.amount()); - assert_eq!(rows[0].fee, MINE_FINALIZER_FEE); - assert_eq!(transaction.amount, mine.amount()); - assert_eq!(transaction.fee, MINE_FINALIZER_FEE); - } - - #[test] - fn wallet_transactions_include_public_mine_actions() { - let alice = Wallet::from_seed("wallet-revealed-mine-alice"); - let ledger = Ledger::new( - BTreeMap::from([(alice.address().to_string(), 2 * MICRO_IUNA)]), - 1, - ); - let mine = ledger.build_mine(alice.address()).unwrap(); - let mut mine_block = fake_block(8, vec![mine.clone()]); - mine_block.reward = mine.fee(); - let chain = vec![mine_block.clone()]; - let snapshot = fake_snapshot(BTreeMap::new(), chain.clone()); - let revealed_by_height = super::revealed_transactions_by_height(&snapshot); - let outputs = super::known_output_index(&snapshot, &[]); - - let rows = wallet_transaction_rows( - alice.address(), - Vec::new(), - Vec::new(), - &chain, - &revealed_by_height, - &outputs, - WalletTransactionFilters { - transfer: false, - mine: true, - burn: false, - }, - ); - - assert_eq!(rows.len(), 1); - assert_eq!(rows[0].kind, "mine"); - assert_eq!(rows[0].status, "confirmed"); - assert_eq!(rows[0].block_height, Some(8)); - assert_eq!(rows[0].timestamp_ms, Some(8)); - assert_eq!(rows[0].block_finalizer.as_deref(), Some("miner")); - assert_eq!(rows[0].direction, "received"); - assert_eq!(rows[0].amount, mine.amount()); - assert_eq!(rows[0].fee, MINE_FINALIZER_FEE); - assert!(!rows[0].blinded); - assert_eq!(rows[0].signature, mine.signature()); - } - - #[test] - fn burn_wallet_transactions_require_burn_filter() { - let alice = Wallet::from_seed("wallet-burn-filter-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10); - let ledger = Ledger::new(allocations.clone(), 1); - let burn = ledger.build_burn(&alice, 3, 1).unwrap(); - let snapshot = fake_snapshot(allocations, Vec::new()); - let outputs = super::known_output_index(&snapshot, std::slice::from_ref(&burn)); - - let default_rows = wallet_transaction_rows( - alice.address(), - vec![burn.clone()], - Vec::new(), - &[], - &BTreeMap::new(), - &outputs, - WalletTransactionFilters::default(), - ); - let burn_rows = wallet_transaction_rows( - alice.address(), - vec![burn.clone()], - Vec::new(), - &[], - &BTreeMap::new(), - &outputs, - WalletTransactionFilters { - transfer: false, - mine: false, - burn: true, - }, - ); - - assert!(default_rows.is_empty()); - assert_eq!(burn_rows.len(), 1); - assert_eq!(burn_rows[0].kind, "burn"); - assert_eq!(burn_rows[0].direction, "burned"); - assert_eq!(burn_rows[0].amount, 3); - assert_eq!(burn_rows[0].fee, 1); - } - - #[test] - fn wallet_utxo_rows_include_pending_spent_outputs_as_disabled() { - let alice = Wallet::from_seed("wallet-utxo-pending-alice"); - let bob = Wallet::from_seed("wallet-utxo-pending-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10); - let mut ledger = Ledger::new(allocations, 1); - let pending = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); - let Transaction::Transfer { inputs, .. } = &pending else { - panic!("expected transfer"); - }; - let spent_outpoint = inputs[0].outpoint.clone(); - - ledger.submit_transaction(pending).unwrap(); - let rows = wallet_utxo_rows(&ledger, alice.address()); - - assert!(rows.iter().any(|row| row.outpoint == spent_outpoint)); - assert!( - rows.iter() - .any(|row| row.outpoint == spent_outpoint && !row.spendable) - ); - } - - #[test] - fn selectable_wallet_utxo_rows_include_only_spendable_outputs() { - let alice = Wallet::from_seed("wallet-utxo-selectable-alice"); - let bob = Wallet::from_seed("wallet-utxo-selectable-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10); - let mut ledger = Ledger::new(allocations, 1); - let pending = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); - let Transaction::Transfer { inputs, .. } = &pending else { - panic!("expected transfer"); - }; - let spent_outpoint = inputs[0].outpoint.clone(); - - ledger.submit_transaction(pending).unwrap(); - let rows = super::selectable_wallet_utxo_rows(&ledger, alice.address()); - - assert!(!rows.iter().any(|row| row.outpoint == spent_outpoint)); - assert!(rows.iter().all(|row| row.spendable)); - } - - #[test] - fn wallet_utxo_rows_treat_owned_blinded_spends_as_pending() { - let alice = Wallet::from_seed("wallet-utxo-blind-pending-alice"); - let bob = Wallet::from_seed("wallet-utxo-blind-pending-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10); - let ledger = Ledger::new(allocations, 1); - let mut node = NodeCore::from_ledger(alice.clone(), ledger, 0); - let pending = node.transfer_with_fee(bob.address(), 3, 0).unwrap(); - let Transaction::Transfer { inputs, .. } = &pending else { - panic!("expected transfer"); - }; - let spent_outpoint = inputs[0].outpoint.clone(); - let wallet_view = node.wallet_view_ledger().unwrap(); - - let rows = wallet_utxo_rows(&wallet_view, alice.address()); - let selectable = super::selectable_wallet_utxo_rows(&wallet_view, alice.address()); - - assert!( - rows.iter() - .any(|row| row.outpoint == spent_outpoint && !row.spendable) - ); - assert!(!selectable.iter().any(|row| row.outpoint == spent_outpoint)); - } - - #[test] - fn wallet_utxo_rows_keep_local_anchor_spends_visible_as_pending() { - let alice = Wallet::from_seed("wallet-utxo-local-anchor-alice"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(alice.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let mut node = - NodeCore::from_ledger_with_burn_fee_and_enabled(alice.clone(), ledger, true, 1, 0); - - let plan = node.prepare_automatic_finalization(1); - assert!(plan.burned.is_some()); - let rows = wallet_utxo_rows(&node.wallet_view_ledger().unwrap(), alice.address()); - - assert!(!rows.is_empty()); - assert!(rows.iter().any(|row| !row.spendable)); - } - - fn fake_block(height: u64, transactions: Vec<Transaction>) -> Block { - Block { - height, - prev_hash: format!("prev-{height}"), - timestamp_ms: height, - miner: "miner".to_string(), - finalizer_mode: crate::domain::FinalizerMode::Ticket, - finalizer_rank: 0, - reward: 100, - vdf_rounds: 0, - vdf_output: "vdf".to_string(), - leader_proof: None, - blinded_transactions: Vec::new(), - reveal_bundle_section: RevealBundleSection::default(), - transactions, - hash: format!("hash-{height}"), - } - } - - fn fake_snapshot( - genesis_allocations: BTreeMap<String, Amount>, - blocks: Vec<Block>, - ) -> ChainSnapshot { - ChainSnapshot { - genesis_allocations, - vdf_rounds: 1, - launch_profile: LaunchProfile::default(), - blocks, - } - } - - fn metric_row( - height: u64, - block_time_ms: Option<u64>, - vdf_rounds: u64, - ) -> crate::adapters::chain_store::BlockMetricRow { - crate::adapters::chain_store::BlockMetricRow { - height, - block_hash: format!("hash-{height}"), - timestamp_ms: height, - block_time_ms, - mine_difficulty_bits: 12, - circulating_supply: 100, - known_wallet_addresses: 1, - transaction_count: 0, - transfer_count: 0, - burn_count: 0, - mine_count: 0, - burned_amount: 0, - total_burned_amount: 0, - fees_amount: 0, - reward_amount: 0, - vdf_rounds, - finalizer_rank: 0, - } - } - - #[test] - fn metrics_response_skips_bootstrap_points_for_block_time_and_vdf_rounds() { - let response = super::metrics_response( - true, - vec![ - metric_row(0, None, 0), - metric_row(1, Some(1_764_000_000_000), 0), - metric_row(2, Some(600_000), 120), - metric_row(3, Some(610_000), 130), - ], - ); - - let block_time = response - .charts - .iter() - .find(|chart| chart.id == "block-time") - .expect("block time chart should exist"); - assert_eq!( - block_time - .points - .iter() - .map(|point| (point.height, point.value)) - .collect::<Vec<_>>(), - vec![(2, 600.0), (3, 610.0)] - ); - - let vdf_rounds = response - .charts - .iter() - .find(|chart| chart.id == "vdf-rounds") - .expect("VDF rounds chart should exist"); - assert_eq!( - vdf_rounds - .points - .iter() - .map(|point| (point.height, point.value)) - .collect::<Vec<_>>(), - vec![(2, 120.0), (3, 130.0)] - ); - - let known_wallet_addresses = response - .charts - .iter() - .find(|chart| chart.id == "known-wallet-addresses") - .expect("known wallet addresses chart should exist"); - assert_eq!( - known_wallet_addresses - .points - .iter() - .map(|point| (point.height, point.value)) - .collect::<Vec<_>>(), - vec![(0, 1.0), (1, 1.0), (2, 1.0), (3, 1.0)] - ); - } - - #[test] - fn metrics_screen_includes_block_range_filter() { - assert!(super::INDEX_HTML.contains("iuna-ui.js?v=97")); - assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\"")); - assert!(super::INDEX_HTML.contains("setMetricsRange(100)")); - assert!(super::INDEX_HTML.contains("setMetricsRange(1000)")); - assert!(super::INDEX_HTML.contains("setMetricsRange('all')")); - assert!(super::INDEX_HTML.contains("Known addresses")); - assert!(super::INDEX_HTML.contains("knownWalletAddresses")); - } - - #[test] - fn reveal_mempool_items_show_unknown_fee_label() { - let app_js = include_str!("../../www/assets/iuna-ui.js"); - assert!(app_js.contains("txFeeLabel(tx)")); - assert!(app_js.contains("!tx?.revealed && tx?.kind === \"reveal\"")); - assert!(app_js.contains("unknown until reveal")); - assert!( - app_js - .contains("!tx?.revealed && (tx?.kind === \"blinded\" || tx?.kind === \"reveal\")") - ); - assert!(app_js.contains("mempoolFirstSeenHeights")); - assert!(app_js.contains("mempoolFirstSeenAt")); - assert!( - app_js.contains( - "this.trackMempoolFirstSeenHeights({ append: options.replace !== true })" - ) - ); - assert!(app_js.contains("return rightSeenAt - leftSeenAt")); - assert!(app_js.contains("syncMempoolBlockMarker")); - assert!(app_js.contains("this.status.chain?.height ?? this.lastBlockMempoolHeight")); - assert!(app_js.contains("mempoolItemClass")); - assert!(app_js.contains("walletTxTimeLabel(tx)")); - assert!(app_js.contains("timestampMs ?? tx?.timestamp_ms")); - assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(tx)\"")); - assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(selectedTransaction?.tx)\"")); - assert!(super::INDEX_HTML.contains("<span class=\"tx-label\">Time</span>")); - assert!(super::INDEX_HTML.contains("x-text=\"walletTxTimeLabel(tx)\"")); - } - - #[test] - fn wallet_screen_includes_address_book_alias_controls() { - let app_js = include_str!("../../www/assets/iuna-ui.js"); - assert!(super::INDEX_HTML.contains("<h3>Address Book</h3>")); - assert!(super::INDEX_HTML.contains("saveAddressBookEntry")); - assert!(super::INDEX_HTML.contains("addressBookEntries()")); - assert!(super::INDEX_HTML.contains("openAddressBookModal()")); - assert!(super::INDEX_HTML.contains("addressBookModalOpen")); - assert!(super::INDEX_HTML.contains("openAddressBookPicker()")); - assert!(super::INDEX_HTML.contains("addressBookPickerOpen")); - assert!(super::INDEX_HTML.contains("selectTransferContact(entry.address)")); - assert!(super::INDEX_HTML.contains("editAddressBookEntry(entry)")); - assert!( - super::INDEX_HTML - .contains("removeAddressBookEntry({ address: addressBookEditingAddress") - ); - assert!(super::INDEX_HTML.contains("aria-label=\"Choose contact\"")); - assert!(super::INDEX_HTML.contains("aria-label=\"Delete contact\"")); - assert!(super::INDEX_HTML.contains("shortAddressLabel(tx.from)")); - assert!(super::INDEX_HTML.contains("addressLabel(input.owner)")); - assert!(app_js.contains("addressBook: {}")); - assert!(app_js.contains("addressBookVersion: 0")); - assert!(app_js.contains("addressBookPickerOpen: false")); - assert!(app_js.contains("this.config.address_book || this.config.addressBook")); - assert!(app_js.contains("options.addressBookVersion >= this.addressBookVersion")); - assert!(app_js.contains("async saveAddressBookEntry()")); - assert!(app_js.contains("Address is already saved")); - assert!(app_js.contains("validAddressBookAddress(address)")); - assert!(app_js.contains("openAddressBookPicker()")); - assert!(app_js.contains("await this.submitForm(\"/api/address-book\"")); - assert!(app_js.contains("\"/api/address-book\"")); - assert!(app_js.contains("addressLabel(address)")); - assert!(app_js.contains("shortAddressLabel(address)")); - } - - #[test] - fn initial_setup_includes_node_mode_choices() { - assert!(super::INDEX_HTML.contains("aria-label=\"Initial node mode\"")); - assert!(super::INDEX_HTML.contains("selectSetupNodeMode('wallet')")); - assert!(super::INDEX_HTML.contains("selectSetupNodeMode('non-listening')")); - assert!(super::INDEX_HTML.contains("selectSetupNodeMode('listening')")); - assert!(super::INDEX_HTML.contains("setupNodeMode === 'listening'")); - assert!(super::INDEX_HTML.contains("x-model.number=\"p2pBindPort\"")); - assert!(super::INDEX_HTML.contains("Change later in Settings")); - } - - #[test] - fn setup_completion_refreshes_chain_data() { - let app_js = include_str!("../../www/assets/iuna-ui.js"); - assert!( - app_js.contains( - "await this.refresh({ force: true });\n this.setupFeedback = null;" - ) - ); - assert!(!app_js.contains( - "await this.refreshConfig();\n await this.resetPagedDataset(\"peer\");" - )); - } - - #[test] - fn setup_completion_applies_selected_node_mode() { - let app_js = include_str!("../../www/assets/iuna-ui.js"); - assert!(app_js.contains("setupNodeMode: \"wallet\"")); - assert!(app_js.contains("async applySetupNodeMode()")); - assert!(app_js.contains("await this.applySetupNodeMode();")); - assert!(app_js.contains("\"/api/settings/p2p-inbound\"")); - assert!(app_js.contains("bind_port: this.p2pBindPortValue()")); - assert!(app_js.contains("this.setUiMode(mode === \"wallet\" ? \"basic\" : \"advanced\")")); - } - - #[test] - fn p2p_bind_port_changes_show_global_restart_notice() { - let app_js = include_str!("../../www/assets/iuna-ui.js"); - assert!(super::INDEX_HTML.contains("Bind port")); - assert!(super::INDEX_HTML.contains("persistent-banner")); - assert!(super::INDEX_HTML.contains("p2pRestartRequired()")); - assert!(app_js.contains("p2pBindPort: 9444")); - assert!(app_js.contains("p2pConfiguredBindAddr()")); - assert!(app_js.contains("p2pRestartMessage()")); - assert!(app_js.contains("Restart iuna to close the public P2P listener.")); - assert!(app_js.contains("0.0.0.0:${port}")); - } - - #[test] - fn polling_refreshes_paged_datasets_without_visible_loaders() { - let app_js = include_str!("../../www/assets/iuna-ui.js"); - assert!(app_js.contains("setInterval(() => this.refresh({ silent: true }), 5000)")); - assert!( - app_js.contains( - "return this.authLoaded && this.auth.configured === true && this.auth.authenticated === true;" - ) - ); - assert!(app_js.contains( - "async refreshNow(options = {}) {\n if (!this.canUseProtectedApi()) return;" - )); - assert!(app_js.contains("refreshPromise: null")); - assert!(app_js.contains("if (this.refreshPromise)")); - assert!(app_js.contains("return this.refreshPromise;")); - assert!(app_js.contains("options.force === true")); - assert!(app_js.contains("this.setTab(this.tabFromHash());")); - assert!( - app_js.contains("const shouldLoadBlocks = tab === \"chain\" || tab === \"mining\";") - ); - assert!(app_js.contains("const shouldLoadP2pMetrics = tab === \"p2p\";")); - assert!(app_js.contains("const shouldLoadMetrics = tab === \"metrics\";")); - assert!( - app_js.contains( - "if (tab === \"wallet\") pagedDatasets.push(\"walletTx\", \"walletUtxo\");" - ) - ); - assert!(app_js.contains("if (tab === \"chain\") pagedDatasets.push(\"mempool\");")); - assert!(app_js.contains("if (tab === \"p2p\") pagedDatasets.push(\"peer\");")); - assert!(app_js.contains("cache: \"no-store\"")); - assert!(app_js.contains("async fetchWithTimeout(path, options = {})")); - assert!(app_js.contains("controller.abort()")); - assert!(app_js.contains("async refreshPagedDataset(kind, options = {}) {\n if (!this.canUseProtectedApi()) return;")); - assert!( - app_js.contains( - "async loadNextPage(kind) {\n if (!this.canUseProtectedApi()) return;" - ) - ); - assert!( - app_js.contains( - "async loadOlderBlocks() {\n if (!this.canUseProtectedApi()) return;" - ) - ); - assert!(app_js.contains("this.stopPolling();\n await this.refreshAuth();")); - assert!(app_js.contains("backgroundLoading")); - assert!(app_js.contains("options.silent === true ? \"backgroundLoading\" : \"loading\"")); - } - - #[test] - fn wallet_pending_blinded_transactions_are_labeled() { - let app_js = include_str!("../../www/assets/iuna-ui.js"); - assert!(app_js.contains("Pending blind")); - assert!(app_js.contains("tx.blinded")); - } - - #[test] - fn mine_screen_shows_fixed_pow_reward_without_burn_slider() { - let app_js = include_str!("../../www/assets/iuna-ui.js"); - assert!(app_js.contains("powMineReward()")); - assert!( - super::INDEX_HTML.contains("Search for PoW actions that mint a fixed IUNA reward.") - ); - assert!(super::INDEX_HTML.contains("amountLabel(powMineReward())")); - assert!(super::INDEX_HTML.contains("aria-label=\"Local mining status\"")); - assert!(super::INDEX_HTML.contains("PoB State")); - assert!(super::INDEX_HTML.contains("PoW State")); - assert!(super::INDEX_HTML.contains("Workers")); - assert!(super::INDEX_HTML.contains("Selected Finalizer")); - assert!(app_js.contains("pobStatusLabel()")); - assert!(app_js.contains("powStatusShortLabel()")); - assert!(app_js.contains("setPowMiningWorkers(workers)")); - assert!(app_js.contains("localMiningMempoolLabel()")); - assert!(app_js.contains("miningEventLog()")); - assert!(app_js.contains("miningEventLimit: 1000")); - assert!(app_js.contains("slice(0, this.miningEventLimit)")); - assert!(super::INDEX_HTML.contains("aria-label=\"Mining event log\"")); - assert!(super::INDEX_HTML.contains("miningEventLog().length === 0")); - assert!(super::INDEX_HTML.contains("mining-event-empty")); - assert!(app_js.contains("Resource budget:")); - assert!(app_js.contains("isPowMineSuccessStatus")); - assert!(app_js.contains("You mined a PoW action")); - assert!(app_js.contains("Waiting for a finalizer to include it in a block.")); - assert!(app_js.contains("Observed block")); - assert!(app_js.contains("Finalized by")); - assert!(app_js.contains("You finalized block")); - assert!(app_js.contains("if (!locallyFinalized)")); - assert!(app_js.contains("last?.title === title")); - assert!(app_js.contains("this.miningEventState.pob = enabled ? \"on\" : \"off\";")); - assert!(app_js.contains("Automatic burn prepared at height")); - assert!(app_js.contains("Eligible for the next block opportunity.")); - assert!(app_js.contains("!Number.isFinite(timestampMs)")); - assert!(!super::INDEX_HTML.contains("Needs burns")); - } - - #[test] - fn block_detail_finalizer_opens_burn_leader_ranks_modal() { - let app_js = include_str!("../../www/assets/iuna-ui.js"); - assert!(super::INDEX_HTML.contains("openBurnLeaderRanksModal(selectedBlock)")); - assert!(super::INDEX_HTML.contains("id=\"burn-ranks-title\"")); - assert!(super::INDEX_HTML.contains("burnLeaderRanks(selectedBurnLeaderBlock)")); - assert!(app_js.contains("selectedBurnLeaderBlock")); - assert!(app_js.contains("burnLeaderRankLabel(rank)")); - assert!(app_js.contains("block?.burn_leader_ranks")); - assert!(super::INDEX_HTML.contains("rank.ticket_id ?? rank.ticketId")); - } - - async fn auth_test_state(config_path: std::path::PathBuf, config: UiConfig) -> HttpState { - config_store::save(&config_path, &config).unwrap(); - let wallet_path = config_path.with_file_name("wallet.json"); - let (wallet, _) = wallet_store::replace_with_generated_seed_phrase(&wallet_path).unwrap(); - let ledger = Ledger::new(BTreeMap::new(), 1); - let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0))); - let peers = Arc::new(Mutex::new(PeerBook::default())); - let gossip = GossipNetwork::new_for_tests(node.clone(), peers.clone()); - let chain_store = SqliteChainStore::open(config_path.with_file_name("chain.sqlite3")) - .expect("test chain store should open"); - HttpState { - node, - peers, - gossip, - ui_config: Arc::new(Mutex::new( - config_store::load_or_create(&config_path).unwrap(), - )), - config_path, - chain_store, - wallet_path, - stratum: StratumStatus { - enabled: false, - listen_addr: None, - }, - auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), - auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), - ui_cache: Arc::new(Mutex::new(super::UiChainCache::default())), - } - } - - fn auth_test_app(state: HttpState) -> Router { - Router::new() - .route("/api/auth/status", get(api_auth_status)) - .route("/api/auth/setup", post(api_auth_setup_form)) - .route("/api/auth/login", post(api_auth_login_form)) - .route( - "/api/auth/change-password", - post(api_auth_change_password_form), - ) - .route("/favicon.ico", get(super::favicon)) - .route("/api/protected", get(protected_auth_test_endpoint)) - .layer(middleware::from_fn_with_state( - state.clone(), - require_auth_middleware, - )) - .with_state(state) - } - - async fn protected_auth_test_endpoint() -> &'static str { - "protected" - } - - struct TestHttpResponse { - status: StatusCode, - headers: HeaderMap, - body: String, - } - - async fn http_request( - app: Router, - method: Method, - path: &str, - cookie: Option<&str>, - body: &str, - ) -> TestHttpResponse { - let mut builder = Request::builder() - .method(method.clone()) - .uri(path) - .header(header::ACCEPT, "application/json") - .header(header::HOST, "127.0.0.1:18661") - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); - if matches!(method, Method::POST | Method::DELETE) { - builder = builder.header(header::ORIGIN, "http://127.0.0.1:18661"); - } - if let Some(cookie) = cookie { - builder = builder.header(header::COOKIE, cookie); - } - let response = app - .oneshot(builder.body(Body::from(body.to_string())).unwrap()) - .await - .unwrap(); - let status = response.status(); - let headers = response.headers().clone(); - let body = String::from_utf8( - to_bytes(response.into_body(), usize::MAX) - .await - .unwrap() - .to_vec(), - ) - .unwrap(); - TestHttpResponse { - status, - headers, - body, - } - } - - fn set_cookie_pair(headers: &HeaderMap) -> String { - let header = headers - .get(header::SET_COOKIE) - .and_then(|value| value.to_str().ok()) - .expect("response should include Set-Cookie header"); - header.split(';').next().unwrap().to_string() - } - - #[tokio::test] - async fn burn_settings_config_persistence_updates_config_file() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let ui_config = Arc::new(Mutex::new(UiConfig { - setup_complete: true, - ..UiConfig::default() - })); - let initial_config = ui_config.lock().await.clone(); - config_store::save(&config_path, &initial_config).expect("initial config should save"); - - persist_burn_settings_config( - &ui_config, - &config_path, - true, - 50 * MICRO_IUNA, - 3 * MICRO_IUNA, - ) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - - assert!(config.mining_enabled); - assert_eq!(config.burn_per_block, 50 * MICRO_IUNA); - assert_eq!(config.burn_fee, 3 * MICRO_IUNA); - } - - #[tokio::test] - async fn enabled_burn_settings_reject_zero_amount() { - let dir = tempfile::tempdir().unwrap(); - let state = auth_test_state( - dir.path().join("config.json"), - UiConfig { - setup_complete: true, - ..UiConfig::default() - }, - ) - .await; - - let error = super::set_burn_settings(&state, true, 0, MICRO_IUNA) - .await - .unwrap_err(); - - assert!(format!("{error:#}").contains("greater than zero")); - } - - #[tokio::test] - async fn metrics_setting_persists_config_and_clears_rows_when_disabled() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let state = auth_test_state( - config_path.clone(), - UiConfig { - setup_complete: true, - ..UiConfig::default() - }, - ) - .await; - let wallet = Wallet::from_seed("metrics-setting-wallet"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 10); - let ledger = Ledger::new_with_genesis_burns( - genesis, - vec![crate::domain::GenesisBurn::new(wallet.address(), 1)], - 1, - ) - .unwrap(); - *state.node.lock().await = NodeCore::from_ledger(wallet, ledger, 0); - - super::set_keep_track_of_metrics(&state, true) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(config.keep_track_of_metrics); - assert!(!state.chain_store.load_metrics().unwrap().is_empty()); - - super::set_keep_track_of_metrics(&state, false) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - assert!(!config.keep_track_of_metrics); - assert!(state.chain_store.load_metrics().unwrap().is_empty()); - } - - #[tokio::test] - async fn pow_mining_config_persistence_updates_config_file() { - let dir = tempfile::tempdir().unwrap(); - let config_path = dir.path().join("config.json"); - let ui_config = Arc::new(Mutex::new(UiConfig { - setup_complete: true, - ..UiConfig::default() - })); - let initial_config = ui_config.lock().await.clone(); - config_store::save(&config_path, &initial_config).expect("initial config should save"); - - persist_pow_mining_config(&ui_config, &config_path, true, 4) - .await - .unwrap(); - let config = config_store::load_or_create(&config_path).unwrap(); - - assert!(config.pow_mining_enabled); - assert_eq!(config.pow_mining_workers, 4); - } - - #[test] - fn transfer_form_requires_recipient_amount_and_fee() { - let error = validate_transfer_form(TransferForm { - to: " ".to_string(), - amount: 1, - fee_per_byte: Some(1), - utxos: String::new(), - }) - .unwrap_err(); - assert!(error.to_string().contains("recipient is required")); - - let error = validate_transfer_form(TransferForm { - to: "abc".to_string(), - amount: 0, - fee_per_byte: Some(1), - utxos: String::new(), - }) - .unwrap_err(); - assert!( - error - .to_string() - .contains("amount must be greater than zero") - ); - - let error = validate_transfer_form(TransferForm { - to: "abc".to_string(), - amount: 1, - fee_per_byte: None, - utxos: String::new(), - }) - .unwrap_err(); - assert!(error.to_string().contains("fee per byte is required")); - } - - #[test] - fn burn_and_mine_forms_require_fee_per_byte() { - let burn = required_fee_per_byte_burn(&super::BurnSettingsForm { - enabled: Some(true), - amount: 1, - fee_per_byte: None, - }) - .unwrap_err(); - assert!(burn.to_string().contains("fee per byte is required")); - } - - #[test] - fn transfer_form_trims_recipient() { - let (to, amount, fee, utxos) = validate_transfer_form(TransferForm { - to: " abc ".to_string(), - amount: 2, - fee_per_byte: Some(3), - utxos: String::new(), - }) - .unwrap(); - - assert_eq!(to, "abc"); - assert_eq!(amount, 2); - assert_eq!(fee, 3); - assert!(utxos.is_empty()); - } - - #[test] - fn transfer_form_parses_selected_utxos() { - let (_, _, _, utxos) = validate_transfer_form(TransferForm { - to: "abc".to_string(), - amount: 2, - fee_per_byte: Some(3), - utxos: "tx-one:0\ntx:with:colons:7,\n".to_string(), - }) - .unwrap(); - - assert_eq!( - utxos, - vec![ - OutPoint { - txid: "tx-one".to_string(), - index: 0 - }, - OutPoint { - txid: "tx:with:colons".to_string(), - index: 7 - } - ] - ); - } -} +mod tests; diff --git a/src/adapters/http/actions.rs b/src/adapters/http/actions.rs @@ -0,0 +1,461 @@ +use std::{net::SocketAddr, path::Path, sync::Arc}; + +use anyhow::{Context, Result, bail}; +use axum::{ + Form, Json, + extract::State, + http::HeaderMap, + response::{IntoResponse, Redirect, Response}, +}; +use tokio::sync::Mutex; + +use super::types::{ + ActionResponse, AddressBookDeleteForm, AddressBookForm, BurnSettingsForm, ConfigForm, + FeeEstimateResponse, MetricsSettingsForm, P2pAnnounceForm, P2pInboundForm, PeerForm, + PowMiningForm, RecoveryVdfSettingsForm, SeedPhraseForm, TransferForm, WalletSetupResponse, +}; +use super::{ + HttpState, action_json, api_error, config_store, estimate_burn_fee, estimate_mine_fee, + estimate_transfer_fee, fee_estimate_json, required_fee_per_byte_burn, transfer, + validate_address, wallet_setup_json, +}; +use crate::{ + adapters::{chain_store::SqliteChainStore, config_store::UiConfig}, + domain::Amount, +}; + +pub(super) async fn apply_config_form(state: &HttpState, form: ConfigForm) -> Result<()> { + let peer = form.peer.trim(); + if !peer.is_empty() { + add_peer(state, peer.to_string()).await?; + } + if form.setup_complete && super::setup_requires_peer(state).await { + let has_peer = !state.peers.lock().await.addresses().is_empty(); + if !has_peer { + bail!("add a bootstrap peer before completing setup"); + } + } + let mut config = state.ui_config.lock().await; + config.setup_complete = form.setup_complete; + config_store::save(&state.config_path, &config) +} + +pub(super) async fn api_wallet_generate_form( + State(state): State<HttpState>, + headers: HeaderMap, +) -> Json<WalletSetupResponse> { + wallet_setup_json(super::replace_setup_wallet_with_generated_seed(&state, &headers).await) +} + +pub(super) async fn api_wallet_import_form( + State(state): State<HttpState>, + headers: HeaderMap, + Form(form): Form<SeedPhraseForm>, +) -> Json<WalletSetupResponse> { + wallet_setup_json(super::import_setup_wallet_seed(&state, &headers, &form.seed_phrase).await) +} + +pub(super) async fn api_transfer_fee_estimate_form( + State(state): State<HttpState>, + Form(form): Form<TransferForm>, +) -> Json<FeeEstimateResponse> { + fee_estimate_json(estimate_transfer_fee(&state, form).await) +} + +pub(super) async fn api_burn_fee_estimate_form( + State(state): State<HttpState>, + Form(form): Form<BurnSettingsForm>, +) -> Json<FeeEstimateResponse> { + fee_estimate_json(estimate_burn_fee(&state, form).await) +} + +pub(super) async fn api_mine_fee_estimate_form( + State(state): State<HttpState>, + Form(_form): Form<std::collections::BTreeMap<String, String>>, +) -> Json<FeeEstimateResponse> { + fee_estimate_json(estimate_mine_fee(&state).await) +} + +pub(super) async fn api_burn_per_block_form( + State(state): State<HttpState>, + Form(form): Form<BurnSettingsForm>, +) -> Json<ActionResponse> { + let enabled = form.enabled.unwrap_or(form.amount > 0); + let result = match required_fee_per_byte_burn(&form) { + Ok(fee_per_byte) => set_burn_settings(&state, enabled, form.amount, fee_per_byte).await, + Err(error) => Err(error), + }; + action_json(result) +} + +pub(super) async fn api_pow_mining_form( + State(state): State<HttpState>, + Form(form): Form<PowMiningForm>, +) -> Json<ActionResponse> { + action_json(set_pow_mining(&state, form.enabled, form.workers).await) +} + +pub(super) async fn api_metrics_settings_form( + State(state): State<HttpState>, + Form(form): Form<MetricsSettingsForm>, +) -> Json<ActionResponse> { + action_json(set_keep_track_of_metrics(&state, form.enabled).await) +} + +pub(super) async fn api_recovery_vdf_settings_form( + State(state): State<HttpState>, + Form(form): Form<RecoveryVdfSettingsForm>, +) -> Json<ActionResponse> { + action_json(set_recovery_vdf_top_rank_percent(&state, form.top_rank_percent).await) +} + +pub(super) async fn api_p2p_announce_form( + State(state): State<HttpState>, + Form(form): Form<P2pAnnounceForm>, +) -> Json<ActionResponse> { + action_json(set_p2p_announce_addr(&state, form.addr).await) +} + +pub(super) async fn api_p2p_inbound_form( + State(state): State<HttpState>, + Form(form): Form<P2pInboundForm>, +) -> Json<ActionResponse> { + action_json(set_p2p_accept_inbound(&state, form.enabled, form.bind_port).await) +} + +pub(super) async fn burn_per_block_form( + State(state): State<HttpState>, + Form(form): Form<BurnSettingsForm>, +) -> Response { + let enabled = form.enabled.unwrap_or(form.amount > 0); + let result = match required_fee_per_byte_burn(&form) { + Ok(fee_per_byte) => set_burn_settings(&state, enabled, form.amount, fee_per_byte).await, + Err(error) => Err(error), + }; + match result { + Ok(_) => Redirect::to("/").into_response(), + Err(error) => api_error(error).into_response(), + } +} + +pub(super) async fn api_transfer_form( + State(state): State<HttpState>, + Form(form): Form<TransferForm>, +) -> Json<ActionResponse> { + let result = transfer(&state, form).await; + action_json(result) +} + +pub(super) async fn transfer_form( + State(state): State<HttpState>, + Form(form): Form<TransferForm>, +) -> Response { + match transfer(&state, form).await { + Ok(_) => Redirect::to("/").into_response(), + Err(error) => api_error(error).into_response(), + } +} + +pub(super) async fn api_peer_form( + State(state): State<HttpState>, + Form(form): Form<PeerForm>, +) -> Json<ActionResponse> { + let result = add_peer(&state, form.peer).await; + action_json(result) +} + +pub(super) async fn api_peer_delete_form( + State(state): State<HttpState>, + Form(form): Form<PeerForm>, +) -> Json<ActionResponse> { + let result = remove_peer(&state, form.peer).await; + action_json(result) +} + +pub(super) async fn api_address_book_form( + State(state): State<HttpState>, + Form(form): Form<AddressBookForm>, +) -> Json<ActionResponse> { + action_json(upsert_address_book_entry(&state, form.address, form.name, form.old_address).await) +} + +pub(super) async fn api_address_book_delete_form( + State(state): State<HttpState>, + Form(form): Form<AddressBookDeleteForm>, +) -> Json<ActionResponse> { + action_json(remove_address_book_entry(&state, form.address).await) +} + +pub(super) async fn peer_form( + State(state): State<HttpState>, + Form(form): Form<PeerForm>, +) -> Response { + match add_peer(&state, form.peer).await { + Ok(()) => Redirect::to("/").into_response(), + Err(error) => api_error(error).into_response(), + } +} + +pub(super) async fn set_burn_settings( + state: &HttpState, + enabled: bool, + amount: Amount, + fee: Amount, +) -> Result<()> { + if enabled && amount == 0 { + bail!("IUNA per block must be greater than zero when finalization burns are on"); + } + let result = { + let mut node = state.node.lock().await; + let result = node.set_automatic_burn_settings(enabled, amount, fee); + let outbox = node.drain_outbox(); + (result, outbox) + }; + + match result.0 { + Ok(_) => { + persist_burn_settings_config( + &state.ui_config, + &state.config_path, + enabled, + amount, + fee, + ) + .await?; + state.gossip.broadcast(result.1).await + } + Err(error) => Err(error), + } +} + +pub(super) async fn persist_burn_settings_config( + ui_config: &Arc<Mutex<UiConfig>>, + config_path: &Path, + enabled: bool, + amount: Amount, + fee: Amount, +) -> Result<()> { + let mut config = ui_config.lock().await; + config.mining_enabled = enabled; + config.burn_per_block = amount; + config.burn_fee = fee; + config_store::save(config_path, &config) +} + +pub(super) async fn set_pow_mining( + state: &HttpState, + enabled: bool, + workers: Option<u8>, +) -> Result<()> { + let workers = match workers { + Some(workers) => workers, + None => state.ui_config.lock().await.pow_mining_workers, + }; + { + let mut node = state.node.lock().await; + node.set_pow_mining_workers(workers); + node.set_pow_mining_enabled(enabled); + } + persist_pow_mining_config(&state.ui_config, &state.config_path, enabled, workers).await +} + +pub(super) async fn persist_pow_mining_config( + ui_config: &Arc<Mutex<UiConfig>>, + config_path: &Path, + enabled: bool, + workers: u8, +) -> Result<()> { + let mut config = ui_config.lock().await; + config.pow_mining_enabled = enabled; + config.pow_mining_workers = config_store::clamp_pow_mining_workers(workers); + config_store::save(config_path, &config) +} + +pub(super) async fn set_recovery_vdf_top_rank_percent( + state: &HttpState, + percent: u8, +) -> Result<()> { + let percent = percent.min(100); + { + let mut node = state.node.lock().await; + node.set_recovery_vdf_top_rank_percent(percent); + } + let mut config = state.ui_config.lock().await; + config.recovery_vdf_top_rank_percent = percent; + config_store::save(&state.config_path, &config) +} + +pub(super) async fn set_keep_track_of_metrics(state: &HttpState, enabled: bool) -> Result<()> { + if enabled { + let snapshot = { + let node = state.node.lock().await; + node.has_real_chain().then(|| node.chain_snapshot()) + }; + if let Some(snapshot) = snapshot { + replace_metrics_for_snapshot(&state.chain_store, snapshot).await?; + } else { + clear_metrics(&state.chain_store).await?; + } + } else { + clear_metrics(&state.chain_store).await?; + } + + let mut config = state.ui_config.lock().await; + config.keep_track_of_metrics = enabled; + config_store::save(&state.config_path, &config) +} + +pub(super) async fn set_p2p_announce_addr(state: &HttpState, addr: String) -> Result<()> { + let trimmed = addr.trim(); + let parsed = if trimmed.is_empty() { + None + } else { + Some( + trimmed + .parse::<SocketAddr>() + .with_context(|| format!("invalid P2P announce address {trimmed}"))?, + ) + }; + + let mut config = state.ui_config.lock().await; + let mut next_config = config.clone(); + next_config.p2p_announce_addr = parsed.map(|addr| addr.to_string()); + config_store::save(&state.config_path, &next_config)?; + *config = next_config; + drop(config); + state.gossip.set_p2p_announce_addr(parsed).await; + Ok(()) +} + +pub(super) async fn set_p2p_accept_inbound( + state: &HttpState, + enabled: bool, + bind_port: Option<u16>, +) -> Result<()> { + let bind_port = bind_port.unwrap_or(config_store::DEFAULT_P2P_BIND_PORT); + if bind_port == 0 { + bail!("P2P bind port must be between 1 and 65535"); + } + let previous = state.gossip.accepts_inbound().await; + if enabled && previous { + state.gossip.set_accept_inbound(true).await?; + } + + let mut config = state.ui_config.lock().await; + let mut next_config = config.clone(); + next_config.p2p_accept_inbound = enabled; + next_config.p2p_bind_port = bind_port; + if let Err(error) = config_store::save(&state.config_path, &next_config) { + let _ = state.gossip.set_accept_inbound(previous).await; + return Err(error); + } + *config = next_config; + drop(config); + + if !enabled { + state.gossip.set_accept_inbound(false).await?; + } + + Ok(()) +} + +async fn replace_metrics_for_snapshot( + store: &SqliteChainStore, + snapshot: crate::domain::ChainSnapshot, +) -> Result<()> { + let store = store.clone(); + tokio::task::spawn_blocking(move || store.replace_metrics_for_snapshot(&snapshot)) + .await + .context("metrics worker failed")??; + Ok(()) +} + +async fn clear_metrics(store: &SqliteChainStore) -> Result<()> { + let store = store.clone(); + tokio::task::spawn_blocking(move || store.clear_metrics()) + .await + .context("metrics cleanup worker failed")??; + Ok(()) +} + +pub(super) async fn add_peer(state: &HttpState, peer: String) -> Result<()> { + let peer = validate_peer_address(peer)?; + let addresses = { + let mut peers = state.peers.lock().await; + peers.add_peer(peer); + peers.addresses() + }; + let mut config = state.ui_config.lock().await; + config.peers = addresses; + config_store::save(&state.config_path, &config) +} + +pub(super) async fn remove_peer(state: &HttpState, peer: String) -> Result<()> { + let peer = validate_peer_address(peer)?; + let addresses = { + let mut peers = state.peers.lock().await; + if !peers.remove_peer(&peer) { + bail!("peer is not configured as an outbound peer"); + } + peers.addresses() + }; + let mut config = state.ui_config.lock().await; + config.peers = addresses; + config_store::save(&state.config_path, &config) +} + +pub(super) async fn upsert_address_book_entry( + state: &HttpState, + address: String, + name: String, + old_address: Option<String>, +) -> Result<()> { + let address = validate_address_book_address(address)?; + let name = validate_address_book_name(name)?; + let old_address = old_address.map(validate_address_book_address).transpose()?; + let mut config = state.ui_config.lock().await; + if let Some(old_address) = old_address.as_deref() { + if old_address != address { + if config.address_book.contains_key(&address) { + bail!("address is already saved"); + } + config.address_book.remove(old_address); + } + } else if config.address_book.contains_key(&address) { + bail!("address is already saved"); + } + config.address_book.insert(address, name); + config_store::save(&state.config_path, &config) +} + +pub(super) async fn remove_address_book_entry(state: &HttpState, address: String) -> Result<()> { + let address = validate_address_book_address(address)?; + let mut config = state.ui_config.lock().await; + config.address_book.remove(&address); + config_store::save(&state.config_path, &config) +} + +fn validate_peer_address(peer: String) -> Result<String> { + let peer = peer.trim().to_string(); + if peer.is_empty() { + bail!("peer address is required"); + } + Ok(peer) +} + +fn validate_address_book_address(address: String) -> Result<String> { + let address = address.trim().to_string(); + if address.is_empty() { + bail!("address is required"); + } + validate_address(&address, "address book")?; + Ok(address.to_ascii_lowercase()) +} + +fn validate_address_book_name(name: String) -> Result<String> { + let name = name.trim().to_string(); + if name.is_empty() { + bail!("name is required"); + } + Ok(name) +} diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -0,0 +1,291 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::Result; +use axum::{ + Json, + extract::{Query, State}, +}; + +use crate::{ + adapters::p2p::P2pMetrics, + app::{NodeStatus, PeerInfo}, + domain::Ledger, +}; + +use super::{ + BlocksQuery, ConfigResponse, MempoolCounts, MetricsResponse, NetworkHealthResponse, Page, + PageQuery, UiBlock, UiTransaction, WalletTransactionFilters, WalletTransactionRow, + WalletTransactionsQuery, WalletUtxoRow, +}; +use super::{ + DATASET_LIMIT, DATASET_PAGE_LIMIT, EXPLORER_LIMIT, EXPLORER_PAGE_LIMIT, HttpState, + add_pending_outputs, cached_chain_view, metrics_response, network_health, ui_blinded_reveal, + ui_blinded_transaction, ui_blocks_from_indexes, ui_pending_revealed_transaction, + ui_transaction, wallet_transaction_rows, +}; + +pub(super) async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> { + let mut status = state.node.lock().await.status(); + status.stratum = state.stratum.clone(); + Json(status) +} + +pub(super) async fn api_blocks( + State(state): State<HttpState>, + Query(query): Query<BlocksQuery>, +) -> Json<Vec<UiBlock>> { + let limit = query + .limit + .unwrap_or(EXPLORER_PAGE_LIMIT) + .min(EXPLORER_LIMIT); + let (snapshot, pending, blocks, burn_leader_ranks) = { + let node = state.node.lock().await; + let snapshot = node.chain_snapshot(); + let pending = node.pending_transactions(); + let blocks = match query.before_height { + Some(before_height) => node.blocks_before(before_height, limit), + None => node.recent_blocks(limit), + }; + let burn_leader_ranks = blocks + .iter() + .map(|block| { + ( + block.hash.clone(), + node.burn_leader_ranks_for_block(block.height) + .unwrap_or_default(), + ) + }) + .collect::<BTreeMap<_, _>>(); + (snapshot, pending, blocks, burn_leader_ranks) + }; + let view = cached_chain_view(&state, &snapshot).await; + let mut outputs = view.outputs; + add_pending_outputs(&mut outputs, &pending); + Json(ui_blocks_from_indexes( + blocks, + &outputs, + &view.revealed_by_height, + &burn_leader_ranks, + )) +} + +pub(super) async fn api_config(State(state): State<HttpState>) -> Json<ConfigResponse> { + Json(ConfigResponse { + config: state.ui_config.lock().await.clone(), + p2p_inbound_runtime_active: state.gossip.accepts_inbound().await, + p2p_runtime_bind_addr: state.gossip.listen_addr().to_string(), + }) +} + +pub(super) async fn api_mempool( + State(state): State<HttpState>, + Query(query): Query<PageQuery>, +) -> Json<Page<UiTransaction>> { + let (snapshot, pending, pending_blinded, pending_reveals, pending_revealed) = { + let node = state.node.lock().await; + let snapshot = node.chain_snapshot(); + let pending = node.pending_transactions(); + let pending_blinded = node.pending_blinded_transactions(); + let pending_reveals = node.pending_blinded_reveals(); + let pending_revealed = node + .pending_revealed_blinded_transactions() + .into_iter() + .map(|revealed| (revealed.commitment.clone(), revealed)) + .collect::<BTreeMap<_, _>>(); + ( + snapshot, + pending, + pending_blinded, + pending_reveals, + pending_revealed, + ) + }; + let view = cached_chain_view(&state, &snapshot).await; + let mut outputs = view.outputs; + add_pending_outputs(&mut outputs, &pending); + let mut items = pending + .iter() + .map(|tx| ui_transaction(tx, &outputs)) + .collect::<Vec<_>>(); + items.extend( + pending_blinded + .iter() + .map(|transaction| ui_blinded_transaction(transaction, &outputs)), + ); + items.extend(pending_reveals.iter().map(|reveal| { + pending_revealed + .get(&reveal.commitment) + .map(|revealed| ui_pending_revealed_transaction(revealed, &outputs)) + .unwrap_or_else(|| ui_blinded_reveal(reveal)) + })); + items.reverse(); + Json(page_items(items, query)) +} + +pub(super) async fn api_wallet_transactions( + State(state): State<HttpState>, + Query(query): Query<WalletTransactionsQuery>, +) -> Json<Page<WalletTransactionRow>> { + let (wallet, snapshot, pending, owned_blinded) = { + let node = state.node.lock().await; + ( + node.wallet_address().to_string(), + node.chain_snapshot(), + node.pending_transactions(), + node.owned_blinded_payloads(), + ) + }; + let view = cached_chain_view(&state, &snapshot).await; + let mut outputs = view.outputs; + add_pending_outputs(&mut outputs, &pending); + let page_query = query.page(); + let filters = WalletTransactionFilters::from_query(query); + Json(page_items( + wallet_transaction_rows( + &wallet, + pending, + owned_blinded, + &snapshot.blocks, + &view.revealed_by_height, + &outputs, + filters, + ), + page_query, + )) +} + +pub(super) async fn api_wallet_utxos( + State(state): State<HttpState>, + Query(query): Query<PageQuery>, +) -> Json<Page<WalletUtxoRow>> { + let (ledger, wallet) = { + let node = state.node.lock().await; + ( + node.wallet_view_ledger() + .unwrap_or_else(|_| node.clone_ledger()), + node.wallet_address().to_string(), + ) + }; + Json(page_items(wallet_utxo_rows(&ledger, &wallet), query)) +} + +pub(super) async fn api_wallet_selectable_utxos( + State(state): State<HttpState>, +) -> Json<Vec<WalletUtxoRow>> { + let (ledger, wallet) = { + let node = state.node.lock().await; + ( + node.wallet_view_ledger() + .unwrap_or_else(|_| node.clone_ledger()), + node.wallet_address().to_string(), + ) + }; + Json(selectable_wallet_utxo_rows(&ledger, &wallet)) +} + +pub(super) fn page_items<T>(items: Vec<T>, query: PageQuery) -> Page<T> { + let total = items.len(); + let offset = query.offset.unwrap_or(0).min(total); + let limit = query + .limit + .unwrap_or(DATASET_PAGE_LIMIT) + .clamp(1, DATASET_LIMIT); + let page_items = items + .into_iter() + .skip(offset) + .take(limit) + .collect::<Vec<_>>(); + let next_offset = offset + page_items.len(); + Page { + items: page_items, + offset, + limit, + total, + has_more: next_offset < total, + next_offset: (next_offset < total).then_some(next_offset), + } +} + +pub(super) fn wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoRow> { + let spendable_outpoints = ledger + .available_utxos_for_address(wallet) + .unwrap_or_default() + .into_iter() + .map(|(outpoint, _)| outpoint) + .collect::<BTreeSet<_>>(); + let mut utxos = ledger + .utxos_for_address(wallet) + .into_iter() + .map(|(outpoint, output)| { + let spendable = spendable_outpoints.contains(&outpoint); + WalletUtxoRow { + outpoint, + address: output.address, + amount: output.amount, + spendable, + } + }) + .collect::<Vec<_>>(); + utxos.sort_by(|left, right| { + right + .amount + .cmp(&left.amount) + .then_with(|| left.outpoint.txid.cmp(&right.outpoint.txid)) + .then_with(|| left.outpoint.index.cmp(&right.outpoint.index)) + }); + utxos +} + +pub(super) fn selectable_wallet_utxo_rows(ledger: &Ledger, wallet: &str) -> Vec<WalletUtxoRow> { + wallet_utxo_rows(ledger, wallet) + .into_iter() + .filter(|utxo| utxo.spendable) + .collect() +} + +pub(super) async fn api_peers( + State(state): State<HttpState>, + Query(query): Query<PageQuery>, +) -> Json<Page<PeerInfo>> { + Json(page_items(state.peers.lock().await.list(), query)) +} + +pub(super) async fn api_p2p_metrics(State(state): State<HttpState>) -> Json<P2pMetrics> { + Json(state.gossip.metrics()) +} + +pub(super) async fn api_metrics(State(state): State<HttpState>) -> Json<MetricsResponse> { + let enabled = state.ui_config.lock().await.keep_track_of_metrics; + if !enabled { + return Json(MetricsResponse { + enabled, + latest: None, + charts: Vec::new(), + }); + } + let store = state.chain_store.clone(); + let rows = tokio::task::spawn_blocking(move || store.load_metrics()) + .await + .ok() + .and_then(Result::ok) + .unwrap_or_default(); + Json(metrics_response(enabled, rows)) +} + +pub(super) async fn api_network_health( + State(state): State<HttpState>, +) -> Json<NetworkHealthResponse> { + let (status, mempool) = { + let node = state.node.lock().await; + ( + node.status(), + MempoolCounts { + plain_transactions: node.pending_transactions().len(), + blinded_transactions: node.pending_blinded_transactions().len(), + blinded_reveals: node.pending_blinded_reveals().len(), + }, + ) + }; + let peers = state.peers.lock().await.list(); + Json(network_health(&status, &peers, mempool)) +} diff --git a/src/adapters/http/auth.rs b/src/adapters/http/auth.rs @@ -0,0 +1,155 @@ +use anyhow::{Context, Result, bail}; +use getrandom::getrandom; +use sha2::{Digest, Sha256}; + +const PASSWORD_KDF_ALGORITHM: &str = "pbkdf2-sha256"; +const PASSWORD_KDF_ITERATIONS: u32 = 120_000; + +pub(super) fn validate_password(password: &str) -> Result<()> { + if password.len() < 12 { + bail!("password must be at least 12 characters"); + } + if password.len() > 1024 { + bail!("password is too long"); + } + Ok(()) +} + +pub(super) fn hash_password(password: &str) -> Result<String> { + let salt = random_bytes::<16>()?; + let hash = pbkdf2_sha256(password.as_bytes(), &salt, PASSWORD_KDF_ITERATIONS); + Ok(format!( + "{PASSWORD_KDF_ALGORITHM}${PASSWORD_KDF_ITERATIONS}${}${}", + hex_encode(salt), + hex_encode(hash) + )) +} + +pub(super) fn verify_password(password: &str, encoded: &str) -> Result<bool> { + let parts = encoded.split('$').collect::<Vec<_>>(); + if parts.len() != 4 || parts[0] != PASSWORD_KDF_ALGORITHM { + bail!("unsupported password hash"); + } + let iterations = parts[1] + .parse::<u32>() + .context("invalid password hash iterations")?; + let salt = decode_hex(parts[2]).context("invalid password hash salt")?; + let expected = decode_hex(parts[3]).context("invalid password hash")?; + let actual = pbkdf2_sha256(password.as_bytes(), &salt, iterations); + Ok(constant_time_eq(&actual, &expected)) +} + +pub(super) fn session_token_hash(token: &str) -> String { + hex_encode(Sha256::digest(format!("iuna-session:{token}").as_bytes())) +} + +pub(super) fn random_hex(bytes: usize) -> Result<String> { + let mut value = vec![0_u8; bytes]; + getrandom(&mut value) + .map_err(|error| anyhow::anyhow!("secure random generation failed: {error}"))?; + Ok(hex_encode(value)) +} + +pub(super) fn pbkdf2_sha256(password: &[u8], salt: &[u8], iterations: u32) -> [u8; 32] { + let mut block_salt = Vec::with_capacity(salt.len() + 4); + block_salt.extend_from_slice(salt); + block_salt.extend_from_slice(&1_u32.to_be_bytes()); + let hmac = HmacSha256Key::new(password); + let mut u = hmac.digest(&block_salt); + let mut output = u; + for _ in 1..iterations { + u = hmac.digest(&u); + for (left, right) in output.iter_mut().zip(u) { + *left ^= right; + } + } + output +} + +struct HmacSha256Key { + outer_key_pad: [u8; 64], + inner_key_pad: [u8; 64], +} + +impl HmacSha256Key { + fn new(key: &[u8]) -> Self { + let mut key_block = [0_u8; 64]; + if key.len() > 64 { + key_block[..32].copy_from_slice(&Sha256::digest(key)); + } else { + key_block[..key.len()].copy_from_slice(key); + } + + let mut outer_key_pad = [0x5c_u8; 64]; + let mut inner_key_pad = [0x36_u8; 64]; + for index in 0..64 { + outer_key_pad[index] ^= key_block[index]; + inner_key_pad[index] ^= key_block[index]; + } + Self { + outer_key_pad, + inner_key_pad, + } + } + + fn digest(&self, message: &[u8]) -> [u8; 32] { + let mut inner = Sha256::new(); + inner.update(self.inner_key_pad); + inner.update(message); + let inner_hash = inner.finalize(); + + let mut outer = Sha256::new(); + outer.update(self.outer_key_pad); + outer.update(inner_hash); + outer.finalize().into() + } +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + left.iter() + .zip(right) + .fold(0_u8, |diff, (left, right)| diff | (left ^ right)) + == 0 +} + +fn random_bytes<const N: usize>() -> Result<[u8; N]> { + let mut bytes = [0_u8; N]; + getrandom(&mut bytes) + .map_err(|error| anyhow::anyhow!("secure random generation failed: {error}"))?; + Ok(bytes) +} + +pub(super) fn hex_encode(bytes: impl AsRef<[u8]>) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.as_ref().len() * 2); + for byte in bytes.as_ref() { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + encoded +} + +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 = decode_hex_nibble(pair[0])?; + let low = decode_hex_nibble(pair[1])?; + bytes.push((high << 4) | low); + } + Ok(bytes) +} + +fn decode_hex_nibble(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"), + } +} diff --git a/src/adapters/http/auth_routes.rs b/src/adapters/http/auth_routes.rs @@ -0,0 +1,144 @@ +use std::net::SocketAddr; + +use axum::{ + Form, Json, + body::Body, + extract::{ConnectInfo, Extension, State}, + http::{HeaderMap, Request, StatusCode, header}, + middleware::Next, + response::{IntoResponse, Response}, +}; + +use super::{ + AUTH_COOKIE_NAME, ActionResponse, AuthClientKey, AuthForm, AuthStatusResponse, + ChangePasswordForm, HttpState, action_json, + auth::session_token_hash, + request_auth::{ + auth_client_key, auth_cookie, auth_exempt_path, change_auth_password, csrf_required, + login_auth_password, request_is_authenticated, same_origin_request, setup_auth_password, + }, +}; + +pub(super) async fn require_auth_middleware( + State(state): State<HttpState>, + headers: HeaderMap, + mut request: Request<Body>, + next: Next, +) -> Response { + let path = request.uri().path().to_string(); + if csrf_required(request.method()) && !same_origin_request(&headers) { + return csrf_error().into_response(); + } + let client_key = auth_client_key( + &headers, + request + .extensions() + .get::<ConnectInfo<SocketAddr>>() + .map(|info| info.0), + ); + request.extensions_mut().insert(AuthClientKey(client_key)); + if auth_exempt_path(&path) { + return next.run(request).await; + } + let configured = state.ui_config.lock().await.auth_password_hash.is_some(); + if !configured { + return auth_error("authentication setup is required").into_response(); + } + if request_is_authenticated(&state, &headers).await { + return next.run(request).await; + } + auth_error("authentication required").into_response() +} + +pub(super) async fn api_auth_status( + State(state): State<HttpState>, + headers: HeaderMap, +) -> Json<AuthStatusResponse> { + let configured = state.ui_config.lock().await.auth_password_hash.is_some(); + let authenticated = configured && request_is_authenticated(&state, &headers).await; + Json(AuthStatusResponse { + configured, + authenticated, + }) +} + +pub(super) async fn api_auth_setup_form( + State(state): State<HttpState>, + Extension(client_key): Extension<AuthClientKey>, + Form(form): Form<AuthForm>, +) -> Response { + match setup_auth_password(&state, &form.password, &client_key.0).await { + Ok(cookie) => ([(header::SET_COOKIE, cookie)], action_json(Ok(()))).into_response(), + Err(error) => action_json(Err(error)).into_response(), + } +} + +pub(super) async fn api_auth_login_form( + State(state): State<HttpState>, + Extension(client_key): Extension<AuthClientKey>, + Form(form): Form<AuthForm>, +) -> Response { + match login_auth_password(&state, &form.password, &client_key.0).await { + Ok(cookie) => ([(header::SET_COOKIE, cookie)], action_json(Ok(()))).into_response(), + Err(error) => action_json(Err(error)).into_response(), + } +} + +pub(super) async fn api_auth_logout_form( + State(state): State<HttpState>, + headers: HeaderMap, +) -> Response { + if let Some(token) = auth_cookie(&headers) { + state + .auth_sessions + .lock() + .await + .remove(&session_token_hash(token)); + } + ( + [( + header::SET_COOKIE, + format!("{AUTH_COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"), + )], + action_json(Ok(())), + ) + .into_response() +} + +pub(super) async fn api_auth_change_password_form( + State(state): State<HttpState>, + Extension(client_key): Extension<AuthClientKey>, + Form(form): Form<ChangePasswordForm>, +) -> Response { + match change_auth_password( + &state, + &form.old_password, + &form.new_password, + &client_key.0, + ) + .await + { + Ok(cookie) => ([(header::SET_COOKIE, cookie)], action_json(Ok(()))).into_response(), + Err(error) => action_json(Err(error)).into_response(), + } +} + +fn auth_error(message: &str) -> (StatusCode, Json<ActionResponse>) { + ( + StatusCode::UNAUTHORIZED, + Json(ActionResponse { + ok: false, + error: Some(message.to_string()), + }), + ) +} + +fn csrf_error() -> (StatusCode, Json<ActionResponse>) { + ( + StatusCode::FORBIDDEN, + Json(ActionResponse { + ok: false, + error: Some("same-origin request required".to_string()), + }), + ) +} diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -0,0 +1,1463 @@ +pub(super) const INDEX_HTML: &str = r#"<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="icon" href="data:,"> + <title>iuna</title> + <style> + [x-cloak] { display: none !important; } + .visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; overflow: hidden !important; clip: rect(0 0 0 0) !important; clip-path: inset(50%) !important; white-space: nowrap !important; } + :root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #0f1012; + color: #e8edf0; + } + * { box-sizing: border-box; } + html { width: 100%; max-width: 100%; overflow-x: hidden; } + body { margin: 0; min-height: 100vh; max-width: 100%; overflow-x: hidden; background: #0f1012; color: #e8edf0; } + .app-shell { width: 100%; max-width: 100%; min-height: 100vh; display: block; overflow-x: hidden; } + .sidebar { position: fixed; z-index: 5; inset: 0 auto 0 0; width: 84px; height: 100vh; display: flex; flex-direction: column; align-items: center; gap: 20px; padding: 16px 10px; background: #15171a; border-right: 1px solid #262b2f; } + .brand-mark { position: relative; width: 38px; height: 38px; display: grid; place-items: center; overflow: hidden; border: 1px solid #e8ff8d; border-radius: 8px; background: linear-gradient(145deg, #ecff8a 0%, #d5f55f 54%, #8de9cd 100%); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .42), 0 10px 24px rgba(213, 245, 95, .16); user-select: none; cursor: default; } + .brand-mark::after { content: ""; position: absolute; inset: -40% -70%; background: linear-gradient(100deg, transparent 42%, rgba(255, 255, 255, .34) 50%, transparent 58%); transform: translateX(-58%) rotate(8deg); opacity: 0; pointer-events: none; } + .brand-mark svg { position: relative; z-index: 1; width: 24px; height: 24px; display: block; } + .brand-mark .mark-loop { fill: none; stroke: #101315; stroke-width: 4.2; stroke-linecap: round; stroke-linejoin: round; } + .brand-mark .mark-dot { fill: #101315; } + .brand-mark:hover::after { animation: mark-sheen .72s ease both; } + @keyframes mark-sheen { from { opacity: 0; transform: translateX(-58%) rotate(8deg); } 32% { opacity: 1; } to { opacity: 0; transform: translateX(58%) rotate(8deg); } } + .side-nav { display: grid; gap: 10px; width: 100%; } + .nav-button { width: 64px; min-height: 58px; display: grid; place-items: center; gap: 4px; border: 1px solid transparent; border-radius: 8px; padding: 7px 4px; background: transparent; color: #9fa8ad; } + .nav-button svg { width: 21px; height: 21px; stroke: currentColor; stroke-width: 2; fill: none; } + .nav-button svg.chain-icon { stroke-width: 1.35; } + .nav-button span { font-size: 11px; font-weight: 800; } + .nav-button:hover, .nav-button.active { background: #202328; border-color: #3b4448; color: #d5f55f; } + .settings-button { margin-top: auto; width: 64px; min-height: 54px; display: grid; place-items: center; border: 1px solid transparent; border-radius: 8px; padding: 7px 4px; color: #9fa8ad; background: transparent; text-align: center; } + .settings-button svg { width: 23px; height: 23px; stroke: currentColor; stroke-width: 1.9; fill: none; } + .settings-button:hover, .settings-button.active { background: #202328; border-color: #3b4448; color: #d5f55f; } + .version-panel { width: 64px; display: grid; gap: 4px; justify-items: center; border: 1px solid transparent; border-radius: 8px; padding: 7px 4px; color: #7f888e; background: transparent; font-size: 10px; font-weight: 850; text-align: center; } + .version-panel.update { border-color: #566d25; color: #d5f55f; background: #1c2516; cursor: pointer; } + .version-panel.checking { color: #a8b2b8; } + .version-panel.failed { color: #ffb1a8; } + .version-dot { width: 6px; height: 6px; border-radius: 999px; background: #3a4248; } + .version-panel.update .version-dot { background: #d5f55f; box-shadow: 0 0 0 3px rgba(213, 245, 95, .12); } + .version-panel.failed .version-dot { background: #ff8f82; } + .version-label { line-height: 1; } + .version-update { color: #d5f55f; font-size: 9px; line-height: 1; text-transform: uppercase; } + .content { width: 100%; min-width: 0; overflow-x: hidden; padding: 22px 24px 48px 108px; } + main { width: 100%; } + main > section { width: 100%; } + header { display: flex; justify-content: space-between; gap: 18px; align-items: flex-start; padding: 0 0 18px; } + .header-actions { display: flex; gap: 10px; align-items: center; } + .basic-status-row { display: inline-flex; gap: 8px; align-items: center; margin-top: 5px; } + .basic-status { display: inline-flex; gap: 7px; align-items: center; border: 1px solid transparent; border-radius: 999px; padding: 3px 7px; color: #9eb3bc; font-size: 12px; font-weight: 800; } + .basic-status::before { content: ""; width: 6px; height: 6px; flex: 0 0 auto; border-radius: 999px; background: #7f888e; } + .basic-status.healthy { color: #d5f55f; } + .basic-status.healthy::before { background: #d5f55f; box-shadow: 0 0 12px rgba(213, 245, 95, .45); } + .basic-status.syncing { color: #ffd070; } + .basic-status.syncing::before { background: #ffd070; } + .basic-status.isolated, .basic-status.stale, .basic-status.banned, .basic-status.error { border-color: #6a332c; background: #261817; color: #ffb8ad; font-weight: 900; } + .basic-status.isolated::before, .basic-status.stale::before, .basic-status.banned::before, .basic-status.error::before { background: #ff7668; box-shadow: 0 0 12px rgba(255, 118, 104, .38); } + .basic-status-detail { padding: 3px 7px; border-color: #3a4248; background: #202328; color: #9fa8ad; font-size: 11px; } + .basic-status-detail:hover { border-color: #d5f55f; color: #d5f55f; } + .lock-button { padding: 5px 8px; border-color: #3a4248; background: #202328; color: #9fa8ad; font-size: 12px; } + .lock-button:hover { border-color: #d5f55f; color: #d5f55f; } + h1 { margin: 0 0 4px; font-size: 28px; } + h2 { margin: 0 0 12px; font-size: 18px; } + h3 { margin: 0 0 10px; font-size: 15px; } + button { border: 1px solid #3a4248; border-radius: 6px; padding: 8px 11px; font: inherit; font-weight: 700; background: #191c20; color: #e8edf0; cursor: pointer; } + button:hover { border-color: #d5f55f; color: #d5f55f; } + button.primary { background: #d5f55f; border-color: #d5f55f; color: #15171a; } + button.primary:hover { background: #e4ff83; color: #15171a; } + button.subtle { background: transparent; } + button:disabled { cursor: default; opacity: .5; } + .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; } + .metric, .panel { background: #181b1f; border: 1px solid #2a3035; border-radius: 8px; padding: 13px; } + .metric .label { color: #8d989f; font-size: 11px; text-transform: uppercase; } + .metric .value { margin-top: 7px; font-weight: 850; overflow-wrap: anywhere; } + .panel { margin-bottom: 12px; } + .split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, .72fr); gap: 12px; } + form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } + label { display: grid; gap: 5px; color: #a8b2b8; font-size: 13px; } + input, textarea { min-width: 180px; border: 1px solid #3a444b; border-radius: 6px; padding: 9px 10px; font: inherit; background: #101215; color: #edf2f5; } + textarea { min-height: 118px; resize: vertical; line-height: 1.45; } + input:focus, textarea:focus { outline: 2px solid #d5f55f; outline-offset: 1px; } + table { width: 100%; border-collapse: collapse; font-size: 13px; } + th, td { text-align: left; border-bottom: 1px solid #2a3035; padding: 8px; vertical-align: top; } + th { color: #8d989f; font-size: 11px; text-transform: uppercase; } + code { overflow-wrap: anywhere; color: #c7f5ea; } + .table-wrap { overflow-x: auto; } + .muted { color: #8d989f; } + .flash { position: fixed; top: 18px; right: 18px; z-index: 80; width: min(420px, calc(100vw - 36px)); border-radius: 6px; padding: 10px 12px; border: 1px solid; font-weight: 700; box-shadow: 0 18px 48px rgba(0, 0, 0, .38); } + .flash.success { color: #d5f55f; background: #1c2516; border-color: #566d25; } + .flash.error { color: #ffb1a8; background: #2a1717; border-color: #713434; } + .persistent-banner { border: 1px solid #566d25; border-radius: 8px; padding: 10px 12px; margin: -4px 0 16px; color: #d5f55f; background: #1c2516; font-weight: 800; } + .ok { color: #d5f55f; } + .page-title { margin-bottom: 16px; } + .setup-overlay { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; padding: 22px; background: rgba(8, 9, 10, .72); backdrop-filter: blur(8px); } + .transaction-overlay { z-index: 40; } + .setup-modal { width: min(980px, 100%); max-height: calc(100vh - 44px); overflow: auto; border: 1px solid #3b4448; border-radius: 8px; padding: 18px; background: #181b1f; box-shadow: 0 24px 80px rgba(0, 0, 0, .42); } + .setup-modal-head { display: grid; gap: 5px; margin-bottom: 16px; } + .setup-modal-head h2 { margin: 0; font-size: 24px; } + .setup-welcome { color: #d5f55f; font-size: 12px; font-weight: 900; text-transform: uppercase; } + .setup-copy { max-width: 620px; color: #a8b2b8; line-height: 1.45; } + .setup-feedback { border: 1px solid; border-radius: 8px; padding: 10px 12px; margin-bottom: 14px; font-weight: 800; } + .setup-feedback.success { color: #d5f55f; background: #1c2516; border-color: #566d25; } + .setup-feedback.error { color: #ffb1a8; background: #2a1717; border-color: #713434; } + .setup-grid { width: 100%; display: grid; grid-template-columns: minmax(0, .9fr) minmax(320px, .7fr); gap: 12px; align-items: start; } + .setup-section { border: 1px solid #2f363c; border-radius: 8px; padding: 13px; background: #111316; } + .setup-node-mode, .setup-network, .setup-wallet-section { grid-column: 1 / -1; } + .segmented.setup-mode-picker { width: 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } + .setup-mode-picker button { min-width: 0; min-height: 38px; white-space: normal; } + .setup-network-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; align-items: end; } + .setup-network-copy { margin-top: 8px; color: #a8b2b8; line-height: 1.45; } + .setup-network-link { color: #d5f55f; font-size: 12px; font-weight: 900; text-decoration: none; } + .setup-network-link:hover { text-decoration: underline; } + .setup-field { display: grid; gap: 6px; } + .setup-field-label { color: #8d989f; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0; } + .setup-address-box { display: flex; justify-content: space-between; gap: 10px; align-items: center; } + .setup-address-box code { min-width: 0; } + .setup-address-box button { flex: 0 0 auto; } + .setup-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; } + .segmented { display: inline-flex; gap: 4px; padding: 4px; border: 1px solid #2f363c; border-radius: 8px; background: #181b1f; } + .segmented button { border-color: transparent; background: transparent; color: #9fa8ad; } + .segmented button.active { background: #d5f55f; color: #15171a; } + .seed-panel { display: grid; gap: 12px; } + .seed-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } + .seed-word { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 7px; align-items: center; border: 1px solid #2f363c; border-radius: 6px; padding: 7px 8px; background: #181b1f; } + .seed-word .index { color: #8d989f; font-size: 11px; font-weight: 800; } + .seed-word .word { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-weight: 800; color: #c7f5ea; } + .verify-grid { display: grid; gap: 8px; } + .setup-status { border: 1px solid #566d25; border-radius: 8px; padding: 10px; background: #1c2516; color: #d5f55f; font-weight: 800; } + .auth-form { width: min(420px, 100%); display: grid; gap: 10px; } + .auth-form form { display: grid; gap: 10px; align-items: stretch; } + .auth-form input { width: 100%; } + .settings-grid { width: min(760px, 100%); display: grid; gap: 12px; } + .settings-mode-row { display: flex; justify-content: space-between; gap: 14px; align-items: center; } + .settings-mode-copy { min-width: 0; display: grid; gap: 4px; } + .settings-mode-title { color: #e8edf0; font-size: 15px; font-weight: 850; } + .settings-form { display: grid; gap: 10px; align-items: stretch; } + .public-p2p-form { margin-top: 14px; } + .settings-form label, .settings-form input { width: 100%; } + .metrics-shell { display: grid; gap: 12px; } + .metrics-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; } + .metrics-head h2 { margin: 0; } + .metrics-range { flex: 0 0 auto; } + .metrics-range button { padding: 5px 9px; font-size: 12px; white-space: nowrap; } + .metrics-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; } + .metrics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 430px), 1fr)); gap: 12px; } + .metric-chart-card { display: grid; gap: 10px; min-width: 0; border: 1px solid #2a3035; border-radius: 8px; padding: 12px; background: #181b1f; } + .metric-chart-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; } + .metric-chart-title { margin: 0; color: #e8edf0; font-size: 14px; font-weight: 850; } + .metric-chart-value { color: #d5f55f; font-size: 13px; font-weight: 850; font-variant-numeric: tabular-nums; } + .metric-chart-frame { display: grid; grid-template-columns: 50px minmax(0, 1fr); grid-template-rows: 156px 18px; column-gap: 6px; row-gap: 4px; min-width: 0; } + .metric-chart-plot { position: relative; min-width: 0; } + .metric-chart-svg { width: 100%; height: 156px; display: block; border: 1px solid #2f363c; border-radius: 8px; background: #111316; } + .metric-chart-gridline { stroke: #3a4248; stroke-width: .8; stroke-dasharray: 3 7; opacity: .58; } + .metric-chart-axis { stroke: #3a4248; stroke-width: 1.2; } + .metric-chart-axis-label { color: #8d989f; font-size: 10px; font-weight: 750; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .metric-chart-y-axis { position: relative; min-width: 0; } + .metric-chart-y-axis .metric-chart-axis-label { position: absolute; right: 0; transform: translateY(-50%); max-width: 100%; } + .metric-chart-x-axis { position: relative; grid-column: 2; min-width: 0; overflow: visible; } + .metric-chart-x-axis .metric-chart-axis-label { position: absolute; top: 0; transform: translateX(-50%); } + .metric-chart-line { fill: none; stroke: #d5f55f; stroke-width: 2.2; stroke-linejoin: round; stroke-linecap: round; } + .metric-chart-points { position: absolute; inset: 0; } + .metric-chart-point-hit { position: absolute; width: 18px; height: 18px; border: 0; border-radius: 50%; padding: 0; background: transparent; cursor: crosshair; transform: translate(-50%, -50%); } + .metric-chart-point-hit::after { content: ""; position: absolute; left: 50%; top: 50%; width: 5px; height: 5px; border-radius: 50%; background: #d5f55f; opacity: .2; transform: translate(-50%, -50%); } + .metric-chart-point-hit:hover::after, .metric-chart-point-hit:focus-visible::after, .metric-chart-point-hit.is-active::after { width: 8px; height: 8px; opacity: 1; } + .metric-chart-tooltip { position: absolute; z-index: 1; max-width: min(180px, 80%); border: 1px solid #566d25; border-radius: 6px; padding: 5px 7px; background: #202615; color: #e8edf0; font-size: 11px; font-weight: 850; font-variant-numeric: tabular-nums; line-height: 1.25; pointer-events: none; box-shadow: 0 8px 20px rgba(0, 0, 0, .28); white-space: nowrap; } + .metrics-empty { border: 1px dashed #3a4248; border-radius: 8px; padding: 14px; color: #8d989f; background: #111316; } + .wallet-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr); gap: 12px; align-items: start; } + .wallet-actions { display: grid; gap: 12px; } + .advanced-toggle { flex-basis: 100%; width: max-content; align-self: flex-start; border-color: #3a4248; padding: 4px 7px; background: #202328; color: #9fa8ad; font-size: 12px; } + .advanced-toggle:hover { border-color: #5a646b; color: #d6dee2; } + .send-utxo-list { display: grid; gap: 8px; max-height: 260px; overflow: auto; border: 1px solid #2f363c; border-radius: 8px; padding: 8px; background: #111316; } + .send-utxo-list-head { display: flex; justify-content: space-between; gap: 8px; align-items: center; color: #8d989f; font-size: 12px; font-weight: 800; } + .send-utxo-actions { display: flex; gap: 6px; align-items: center; } + .utxo-select-button { padding: 3px 7px; border-color: #3a4248; background: #202328; color: #9fa8ad; font-size: 12px; } + .utxo-select-button:hover { border-color: #5a646b; color: #d6dee2; } + .send-utxo-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: start; border: 1px solid #2f363c; border-radius: 8px; padding: 8px; background: #181b1f; } + .send-utxo-option.disabled { border-color: #262c31; background: #14171a; color: #687178; } + .send-utxo-option.disabled code, .send-utxo-option.disabled .utxo-node-amount { color: #687178; } + .send-utxo-option input { min-width: auto; margin-top: 3px; } + .utxo-status { color: #8d989f; font-size: 10px; font-weight: 850; text-transform: uppercase; } + .send-utxo-summary { flex-basis: 100%; width: 100%; display: grid; gap: 5px; color: #9eb3bc; font-size: 13px; } + .wallet-balance-line { display: inline-grid; grid-template-columns: auto auto; gap: 10px; align-items: baseline; padding: 8px 10px; border: 1px solid #2f363c; border-radius: 8px; background: #111316; color: inherit; cursor: pointer; } + .wallet-balance-line:hover, .wallet-balance-line:focus-visible { border-color: #d5f55f; outline: none; } + .wallet-balance-line .tx-value { font-size: 16px; font-weight: 850; } + .mining-grid { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; align-items: start; } + .panel-description { max-width: 760px; margin: -4px 0 12px; color: #9eb3bc; font-size: 13px; line-height: 1.45; } + .mining-form { width: 100%; display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } + .burn-fields { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } + .mine-action-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 12px; align-items: center; } + .mine-settings-form { display: grid; gap: 10px; } + .mine-fee-fields { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } + .fee-preview { flex-basis: 100%; color: #9eb3bc; font-size: 12px; font-weight: 700; } + .mine-stats { display: grid; grid-template-columns: repeat(4, minmax(112px, 1fr)); gap: 8px; min-width: 0; } + .local-mining-stats { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); } + .fee-history { grid-template-columns: repeat(3, minmax(112px, 1fr)); margin-top: 12px; } + .mine-stat { min-width: 0; border: 1px solid #2f363c; border-radius: 8px; padding: 9px 10px; background: #111316; } + .mine-stat-label { display: flex; gap: 5px; align-items: center; color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } + .mine-stat-value { margin-top: 5px; color: #dce4e7; font-size: 14px; font-weight: 850; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } + .mine-stat-value.money { color: #d5f55f; } + .mine-reward-control { display: grid; gap: 7px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } + .mine-reward-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; color: #a8b2b8; font-size: 13px; font-weight: 800; } + .mine-reward-head strong { color: #d5f55f; font-size: 14px; font-variant-numeric: tabular-nums; } + .mine-reward-control input[type="range"] { width: 100%; min-width: 0; padding: 0; accent-color: #d5f55f; } + .mine-slider-hints { display: flex; justify-content: space-between; gap: 10px; color: #879198; font-size: 11px; font-weight: 750; } + .mine-include-status { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 7px; color: #9eb3bc; font-size: 12px; font-weight: 800; } + .mine-include-status.waiting { color: #ffd280; } + .mine-include-status.ready { color: #d5f55f; } + .mine-include-status.muted { color: #879198; } + .mine-save-row { display: flex; justify-content: flex-start; } + .mining-event-log { display: grid; gap: 8px; max-height: 360px; overflow: auto; margin-top: 12px; border: 1px solid #2f363c; border-radius: 8px; padding: 8px; background: #0f1114; } + .mining-event-log-head { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } + .mining-event { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: start; border: 1px solid #30383d; border-radius: 8px; padding: 10px; background: #111316; } + .mining-event-dot { width: 8px; height: 8px; margin-top: 5px; border-radius: 999px; background: #7f888e; } + .mining-event.active .mining-event-dot { background: #d5f55f; box-shadow: 0 0 12px rgba(213, 245, 95, .45); } + .mining-event.warning .mining-event-dot { background: #ffd070; } + .mining-event.info .mining-event-dot { background: #8de9cd; } + .mining-event-title { color: #eef6f8; font-weight: 850; overflow-wrap: anywhere; } + .mining-event-detail { margin-top: 3px; color: #9fa8ad; font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; } + .mining-event-time { color: #7f888e; font-size: 11px; font-weight: 800; white-space: nowrap; } + .mining-event-empty { height: 42px; border: 1px solid #30383d; border-radius: 8px; background: #111316; } + .mining-event-empty .skeleton-line { width: 100%; height: 100%; border-radius: 8px; opacity: .55; } + .panel-separator { border-top: 1px solid #2f363c; margin: 14px 0 12px; } + .stratum-config { display: grid; gap: 10px; } + .stratum-note { max-width: 760px; color: #9eb3bc; font-size: 12px; line-height: 1.45; } + .stratum-note code { color: #dce4e7; } + .stratum-fields { display: grid; gap: 8px; } + .stratum-field { display: grid; grid-template-columns: 86px minmax(0, 1fr); gap: 10px; align-items: baseline; min-width: 0; } + .stratum-label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } + .stratum-value { min-width: 0; color: #dce4e7; font-size: 13px; font-weight: 650; overflow-wrap: anywhere; } + .stratum-value.hash { color: #9eb3bc; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; font-weight: 500; } + .info-button { display: inline-grid; place-items: center; width: 18px; height: 18px; padding: 0; border-radius: 999px; border-color: #3a4248; background: #181b1f; color: #9eb3bc; font-size: 11px; line-height: 1; } + .info-button:hover, .info-button:focus-visible { border-color: #d5f55f; color: #d5f55f; outline: none; } + .info-copy { display: grid; gap: 10px; color: #c3cbd0; line-height: 1.45; } + .info-copy p { margin: 0; } + .info-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; } + .info-fact { border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } + .info-fact .label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } + .info-fact .value { margin-top: 5px; color: #d5f55f; font-weight: 850; } + .mining-head { align-items: center; } + .toggle-switch { display: inline-flex; grid-template-columns: none; align-items: center; gap: 9px; color: #9fa8ad; font-size: 12px; font-weight: 850; cursor: pointer; user-select: none; } + .toggle-switch input { position: absolute; width: 1px; height: 1px; min-width: 0; margin: 0; opacity: 0; pointer-events: none; } + .toggle-track { position: relative; width: 46px; height: 26px; border: 1px solid #3a4248; border-radius: 999px; background: #101215; transition: background .16s ease, border-color .16s ease; } + .toggle-thumb { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 999px; background: #879198; transition: transform .16s ease, background .16s ease; } + .toggle-switch.active { color: #d5f55f; } + .toggle-switch.active .toggle-track { border-color: #d5f55f; background: #263219; } + .toggle-switch.active .toggle-thumb { transform: translateX(20px); background: #d5f55f; } + .toggle-switch:focus-within .toggle-track { outline: 2px solid #d5f55f; outline-offset: 2px; } + .toggle-text { min-width: 22px; text-align: right; } + .compact-number-field { display: inline-flex; align-items: center; gap: 8px; color: #9fa8ad; font-size: 12px; font-weight: 850; } + .compact-number-field input { width: 58px; min-width: 0; border: 1px solid #3a4248; border-radius: 8px; padding: 7px 8px; background: #101215; color: #dce4e7; font: inherit; font-variant-numeric: tabular-nums; } + .compact-number-field input:focus { border-color: #d5f55f; outline: 2px solid rgba(213,245,95,.2); outline-offset: 2px; } + .receive-address { display: grid; gap: 8px; } + .address-box { border: 1px solid #2f363c; border-radius: 8px; padding: 11px; background: #111316; } + .address-book-list { display: grid; gap: 8px; margin-top: 12px; } + .address-book-row { width: 100%; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; text-align: left; border: 1px solid #2f363c; border-radius: 8px; padding: 9px; background: #111316; color: inherit; } + .address-book-row:hover { border-color: #4c565c; background: #15181b; } + .address-book-row > svg { width: 18px; height: 18px; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; color: #9fa8ad; } + .address-book-name { font-weight: 800; color: #eef6f8; overflow-wrap: anywhere; } + .address-book-actions { display: flex; gap: 6px; align-items: center; } + .address-book-modal { width: min(560px, 100%); } + .address-book-modal form { width: 100%; } + .address-book-modal form label { flex: 1 0 100%; } + .address-book-modal-actions { flex: 1 0 100%; width: 100%; display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } + .address-book-picker-list { display: grid; gap: 8px; } + .address-book-picker-row { width: 100%; display: grid; gap: 3px; text-align: left; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; color: inherit; } + .address-book-picker-row:hover { border-color: #4c565c; background: #15181b; } + .recipient-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: end; } + .icon-button { display: inline-grid; place-items: center; width: 38px; height: 38px; padding: 0; line-height: 0; border-radius: 8px; } + .icon-button svg { width: 19px; height: 19px; stroke: currentColor; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; } + .modal-delete-button { color: #ffb4b4; } + input.invalid { border-color: #e36a6a; outline: 2px solid rgba(227,106,106,.16); outline-offset: 2px; } + .panel-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 12px; } + .panel-head h2, .panel-head h3 { margin-bottom: 0; } + .switch { display: inline-flex; grid-template-columns: none; align-items: center; gap: 8px; color: #d6dee2; font-weight: 700; } + .switch input { width: auto; min-width: 0; accent-color: #d5f55f; } + .wallet-tx-panel { min-width: 0; overflow: hidden; } + .wallet-tx-panel .panel-head { flex-wrap: wrap; } + .wallet-tx-filters { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; align-items: center; } + .tx-filter { display: inline-flex; align-items: center; gap: 6px; border: 1px solid #3a4248; border-radius: 999px; padding: 4px 8px; color: #9fa8ad; background: #111316; font-size: 12px; font-weight: 850; cursor: pointer; user-select: none; } + .tx-filter input { position: absolute; width: 1px; height: 1px; min-width: 0; margin: 0; opacity: 0; pointer-events: none; } + .tx-filter.active { border-color: #d5f55f; background: #202616; color: #d5f55f; } + .tx-filter:focus-within { outline: 2px solid #d5f55f; outline-offset: 2px; } + .wallet-tx-list { max-height: min(620px, calc(100vh - 220px)); min-width: 0; display: grid; gap: 8px; overflow-y: auto; overscroll-behavior-y: contain; padding-right: 4px; } + .wallet-tx-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; align-items: start; border: 1px solid #2f363c; border-radius: 8px; padding: 12px; background: #111316; cursor: pointer; text-align: left; } + .wallet-tx-row:hover, .wallet-tx-row:focus-visible, .tx-card:hover, .tx-card:focus-visible, .mempool-item:hover, .mempool-item:focus-visible { border-color: #d5f55f; box-shadow: 0 0 0 1px rgba(213, 245, 95, .22); outline: none; } + .wallet-tx-row.pending { border-color: #3a4147; background: #191c20; box-shadow: inset 3px 0 0 #6f7880; } + .wallet-tx-row .pill { position: absolute; top: 10px; right: 10px; } + .wallet-tx-main { display: grid; gap: 5px; min-width: 0; padding-right: 92px; } + .tx-field { display: grid; grid-template-columns: 74px minmax(0, 1fr); gap: 8px; align-items: baseline; min-width: 0; } + .tx-label { color: #879198; font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: 0; } + .tx-value { min-width: 0; color: #dce4e7; font-size: 13px; font-weight: 600; overflow-wrap: anywhere; } + .tx-value.money { color: #d5f55f; font-variant-numeric: tabular-nums; } + .tx-value.hash { color: #9eb3bc; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; font-weight: 500; } + .tx-value.number { color: #c7d0d5; font-variant-numeric: tabular-nums; } + .tx-value.text { color: #e8edf0; } + .metric-context { display: grid; gap: 5px; margin-top: 12px; } + .peer-toolbar { display: flex; justify-content: space-between; gap: 12px; align-items: start; flex-wrap: wrap; margin-bottom: 12px; } + .peer-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 8px; margin-bottom: 12px; } + .peer-summary-item { min-width: 0; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } + .peer-summary-label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } + .peer-summary-value { margin-top: 5px; color: #dce4e7; font-size: 15px; font-weight: 850; } + .peer-form { display: flex; flex-wrap: wrap; gap: 10px; align-items: end; } + .peer-form label { min-width: min(320px, 100%); } + .peer-form input { width: 100%; } + .peer-status { display: inline-flex; align-items: center; border: 1px solid #3a4248; border-radius: 999px; padding: 3px 8px; color: #a8b2b8; font-size: 11px; font-weight: 850; } + .peer-status.synced, .peer-status.active { border-color: #566d25; color: #d5f55f; background: #1c2516; } + .peer-status.stale { border-color: #5f5125; color: #ffe08a; background: #211d12; } + .peer-status.banned { border-color: #713434; color: #ffb1a8; background: #2a1717; } + .peer-status.error { border-color: #713434; color: #ffb1a8; background: #2a1717; } + .peer-actions { display: flex; gap: 6px; align-items: center; } + .peer-remove { padding: 4px 7px; border-color: #4f3737; background: #221717; color: #ffb1a8; font-size: 12px; } + .peer-remove:hover { border-color: #ffb1a8; color: #ffd4cf; } + .network-health { display: grid; grid-template-columns: minmax(180px, .8fr) minmax(0, 1.2fr); gap: 12px; align-items: stretch; margin-bottom: 12px; } + .network-health-state { display: grid; align-content: center; gap: 5px; border: 1px solid #3a4248; border-radius: 8px; padding: 12px; background: #111316; } + .network-health-state.healthy { border-color: #566d25; background: #182112; } + .network-health-state.syncing, .network-health-state.stale { border-color: #5f5125; background: #211d12; } + .network-health-state.isolated, .network-health-state.error, .network-health-state.banned { border-color: #713434; background: #241716; } + .network-health-label { color: #879198; font-size: 10px; font-weight: 850; text-transform: uppercase; } + .network-health-value { color: #e8edf0; font-size: 20px; font-weight: 900; text-transform: capitalize; } + .network-health-detail { color: #a8b2b8; font-size: 12px; } + .network-health-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(116px, 1fr)); gap: 8px; } + .panel .grid + form { margin-top: 12px; } + .explorer-shell { width: 100%; display: grid; gap: 12px; } + .block-rail-wrap { background: #181b1f; border: 1px solid #2a3035; border-radius: 8px; padding: 12px; overflow: hidden; } + .block-rail-head { display: flex; justify-content: space-between; gap: 10px; align-items: center; margin-bottom: 10px; } + .block-rail { display: flex; gap: 8px; overflow-x: auto; padding: 1px 0 10px; scroll-snap-type: x proximity; } + .block-card { flex: 0 0 122px; min-height: 100px; display: grid; gap: 6px; border: 1px solid #2f363c; border-radius: 8px; padding: 9px; background: #111316; color: #e8edf0; text-align: left; scroll-snap-align: start; } + .block-card:hover { border-color: #d5f55f; color: #d5f55f; } + .block-card.selected { background: #202616; border-color: #d5f55f; box-shadow: inset 0 0 0 1px #d5f55f; } + .block-card.new-block { animation: block-arrive .45s ease both; } + @keyframes block-arrive { from { opacity: .2; transform: translateX(-12px); } to { opacity: 1; transform: translateX(0); } } + .block-height { font-size: 18px; font-weight: 900; } + .block-meta { display: flex; gap: 8px; color: #8d989f; font-size: 12px; } + .block-miner { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; overflow-wrap: anywhere; color: #9eb3bc; } + .skeleton-card { pointer-events: none; position: relative; overflow: hidden; } + .skeleton-card::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(213, 245, 95, .12), transparent); animation: skeleton-sweep 1.15s ease-in-out infinite; } + @keyframes skeleton-sweep { from { transform: translateX(-100%); } to { transform: translateX(100%); } } + .skeleton-line { height: 12px; border-radius: 6px; background: #2b3136; } + .skeleton-line.short { width: 42%; } + .skeleton-line.medium { width: 68%; } + .skeleton-line.long { width: 88%; } + .page-sentinel { min-height: 1px; } + .block-page-sentinel { flex: 0 0 1px; min-height: 100px; } + .dataset-loader { display: grid; gap: 8px; min-width: 0; } + .skeleton-table-cell { height: 12px; width: 100%; border-radius: 6px; background: #2b3136; } + tr.skeleton-card td { padding-top: 11px; padding-bottom: 11px; } + .detail-grid { display: grid; grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: 12px; } + .detail-kv { display: grid; grid-template-columns: 90px minmax(0, 1fr); gap: 8px; font-size: 13px; margin: 7px 0; } + .detail-kv .key { color: #8d989f; } + .detail-link { width: fit-content; max-width: 100%; padding: 0; border: 0; background: transparent; color: #d7f2ff; font: inherit; text-align: left; cursor: pointer; } + .detail-link code { color: inherit; text-decoration: underline; text-underline-offset: 3px; } + .rank-list { display: grid; gap: 8px; } + .rank-row { display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 10px; align-items: start; border: 1px solid #30383d; border-radius: 8px; padding: 10px; background: #15191d; } + .rank-number { color: #d7f2ff; font-weight: 700; } + .rank-details { display: grid; gap: 6px; min-width: 0; } + .tx-list { display: grid; gap: 8px; } + .tx-section { display: grid; gap: 8px; } + .tx-section + .tx-section { margin-top: 10px; padding-top: 10px; border-top: 1px solid #2f363c; } + .tx-section-title { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: #f4f7f8; font-size: 13px; font-weight: 800; } + summary.tx-section-title { cursor: pointer; } + details.tx-section:not([open]) { gap: 0; } + .tx-section-meta { color: #8e979e; font-size: 12px; font-weight: 600; } + .tx-card, .mempool-item { position: relative; display: grid; align-content: start; grid-auto-rows: min-content; gap: 6px; border: 1px solid #2f363c; border-radius: 8px; padding: 12px; background: #111316; cursor: pointer; text-align: left; } + .mempool-item.before-last-block { opacity: .56; } + .mempool-item.new-since-block { background: #151a12; opacity: 1; } + .mempool-item.new-since-block::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; border-radius: 8px 0 0 8px; background: #d5f55f; } + .mempool-item.blinded-hidden { background: linear-gradient(135deg, #141218, #101316); border-color: #353040; } + .mempool-state { color: #d5f55f; font-size: 10px; font-weight: 850; text-transform: uppercase; } + .mempool-time { color: #8d989f; font-size: 11px; font-weight: 700; } + .mempool-top { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; min-width: 0; } + .mempool-top-meta { display: grid; gap: 3px; min-width: 0; } + .tx-card .pill, .mempool-item .pill { position: absolute; top: 10px; right: 10px; } + .mempool-item .pill { position: static; flex: 0 0 auto; } + .pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 8px; font-size: 12px; font-weight: 800; background: #2b3136; color: #d6dee2; } + .pill.burn { background: #332918; color: #ffd070; } + .pill.transfer { background: #17312a; color: #8de9cd; } + .pill.mine { background: #172a34; color: #8bdcff; } + .pill.blinded { background: #272433; color: #c8b8ff; } + .pill.reveal, .pill.revealed { background: #2b2f20; color: #d5f55f; } + .mempool-panel { min-width: 0; overflow: hidden; } + .mempool-strip { width: 100%; min-width: 0; display: flex; gap: 8px; overflow-x: auto; overscroll-behavior-x: contain; padding: 1px 0 10px; scroll-snap-type: x proximity; } + .mempool-item { flex: 0 0 220px; scroll-snap-align: start; align-self: stretch; } + .tx-modal { width: min(940px, 100%); max-height: calc(100vh - 44px); overflow: auto; border: 1px solid #3b4448; border-radius: 8px; padding: 16px; background: #181b1f; box-shadow: 0 24px 80px rgba(0, 0, 0, .46); } + .tx-modal-head { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 14px; } + .tx-modal-title { display: grid; justify-items: start; gap: 6px; min-width: 0; } + .tx-modal-title h2 { margin: 0; } + .tx-modal-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; margin-bottom: 12px; } + .utxo-flow { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 12px; align-items: stretch; } + .utxo-column { display: grid; align-content: start; gap: 8px; min-width: 0; } + .utxo-column h3 { margin: 0; color: #8d989f; font-size: 11px; text-transform: uppercase; } + .utxo-node { display: grid; gap: 5px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; min-width: 0; } + .utxo-node.burned { border-color: #5e4821; background: #1f1a12; } + .utxo-node.fee { border-color: #4b5260; background: #171a20; } + .utxo-node-label { display: flex; justify-content: space-between; gap: 8px; color: #8d989f; font-size: 11px; font-weight: 800; text-transform: uppercase; } + .utxo-node-amount { color: #d5f55f; font-weight: 850; font-variant-numeric: tabular-nums; } + .utxo-node-address, .utxo-node-ref { color: #9eb3bc; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; } + .utxo-arrow { display: grid; place-items: center; color: #d5f55f; font-size: 24px; font-weight: 900; } + .tx-modal-empty { border: 1px dashed #3a4248; border-radius: 8px; padding: 10px; color: #8d989f; } + .utxo-list { display: grid; gap: 8px; } + .wallet-utxo-row { display: grid; gap: 6px; border: 1px solid #2f363c; border-radius: 8px; padding: 10px; background: #111316; } + @media (max-width: 760px) { .utxo-flow, .mine-action-row, .mine-stats { grid-template-columns: 1fr; } .utxo-arrow { min-height: 28px; transform: rotate(90deg); } .tx-modal-head { align-items: stretch; } } + @media (max-width: 920px) { .setup-grid, .wallet-grid, .mining-grid, .detail-grid, .network-health { grid-template-columns: 1fr; } } + @media (max-width: 760px) { + .app-shell { display: grid; grid-template-columns: 1fr; } + .sidebar { position: sticky; inset: auto; width: auto; height: auto; flex-direction: row; justify-content: space-between; padding: 8px; border-right: 0; border-bottom: 1px solid #262b2f; } + .brand-mark { width: 34px; height: 34px; } + .brand-mark svg { width: 22px; height: 22px; } + .side-nav { display: flex; width: auto; gap: 8px; } + .nav-button { width: 52px; min-height: 48px; } + .nav-button span { font-size: 10px; } + .settings-button, .version-panel { margin-top: 0; width: 48px; min-height: 48px; padding: 6px 3px; } + .settings-button svg { width: 21px; height: 21px; } + .content { padding: 16px 12px 36px; } + header, .split, .setup-grid, .wallet-grid, .mining-grid, .detail-grid, .wallet-tx-row { grid-template-columns: 1fr; } + header { display: grid; } + .settings-mode-row { align-items: flex-start; } + .metrics-head { align-items: flex-start; flex-direction: column; } + .metrics-range { width: 100%; } + .metrics-range button { flex: 1 1 0; } + .segmented.setup-mode-picker { grid-template-columns: 1fr; } + .metrics-grid { grid-template-columns: 1fr; } + input { min-width: 0; width: 100%; } + .switch input { width: auto; } + .seed-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .block-card { flex-basis: 108px; } + } + </style> + <script defer src="/assets/iuna-ui.js?v=97"></script> + <script defer src="/assets/alpine.min.js"></script> +</head> +<body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> + <div class="app-shell"> + <aside class="sidebar" aria-label="iuna navigation"> + <div class="brand-mark" title="iuna" aria-label="iuna"><svg viewBox="0 0 32 32" aria-hidden="true" focusable="false"><circle class="mark-dot" cx="9.4" cy="7.6" r="2.8"></circle><path class="mark-loop" d="M9.4 13v7.1c0 3.7 2.9 6.4 6.6 6.4s6.6-2.7 6.6-6.4V13"></path></svg></div> + <nav class="side-nav"> + <button class="nav-button" :class="{ active: tab === 'wallet' }" @click="setTab('wallet')" type="button" title="Wallet" aria-label="Wallet"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7h16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H3z"></path><path d="M3 7V5a2 2 0 0 1 2-2h12"></path><path d="M16 13h3"></path></svg> + <span>Wallet</span> + </button> + <button class="nav-button" x-show="advancedMode()" :class="{ active: tab === 'mining' }" @click="setTab('mining')" type="button" title="Mining" aria-label="Mining"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="M7 15l4-4 3 3 5-7"></path></svg> + <span>Mining</span> + </button> + <button class="nav-button" x-show="advancedMode()" :class="{ active: tab === 'p2p' }" @click="setTab('p2p')" type="button" title="P2P" aria-label="P2P"> + <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="6" cy="12" r="3"></circle><circle cx="18" cy="6" r="3"></circle><circle cx="18" cy="18" r="3"></circle><path d="M8.5 10.5 15.5 7.5"></path><path d="M8.5 13.5 15.5 16.5"></path></svg> + <span>P2P</span> + </button> + <button class="nav-button" :class="{ active: tab === 'chain' }" @click="setTab('chain')" type="button" title="Explorer" aria-label="Explorer"> + <svg class="chain-icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="1.5" y="9" width="5.5" height="5.5"></rect><rect x="9.25" y="9" width="5.5" height="5.5"></rect><rect x="17" y="9" width="5.5" height="5.5"></rect></svg> + <span>Chain</span> + </button> + <button class="nav-button" x-show="config.keep_track_of_metrics" :class="{ active: tab === 'metrics' }" @click="setTab('metrics')" type="button" title="Metrics" aria-label="Metrics"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="M7 15l3-4 3 2 4-7"></path><path d="M7 17h10"></path></svg> + <span>Metrics</span> + </button> + </nav> + <button class="settings-button" :class="{ active: tab === 'settings' }" type="button" @click="setTab('settings')" title="Settings" aria-label="Settings"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9.7 3.2 9.2 5.5a7.2 7.2 0 0 0-1.4.8L5.6 5.6 3.2 9.8l1.7 1.6a7.8 7.8 0 0 0 0 1.6l-1.7 1.6 2.4 4.2 2.2-.7a7.2 7.2 0 0 0 1.4.8l.5 2.3h4.8l.5-2.3a7.2 7.2 0 0 0 1.4-.8l2.2.7 2.4-4.2-1.7-1.6a7.8 7.8 0 0 0 0-1.6L21 9.8l-2.4-4.2-2.2.7a7.2 7.2 0 0 0-1.4-.8l-.5-2.3H9.7Z"></path><circle cx="12" cy="12.2" r="3.1"></circle></svg> + </button> + <button class="version-panel" type="button" :class="{ update: updateAvailable(), checking: releaseCheckState === 'checking', failed: releaseCheckState === 'failed' }" :title="versionPanelTitle()" @click="openLatestRelease"> + <span class="version-dot" aria-hidden="true"></span> + <span class="version-label" x-text="appVersionLabel()"></span> + <span class="version-update" x-show="updateAvailable()">Update</span> + </button> + </aside> + + <main class="content"> + <header> + <div> + <h1 x-text="pageTitle()">iuna</h1> + <div class="basic-status-row" x-show="basicMode()"> + <span class="basic-status" :class="networkHealthClass()" x-text="basicNetworkStatusLabel()"></span> + <button class="basic-status-detail" type="button" x-show="basicNetworkNeedsAttention()" @click="setUiMode('advanced'); setTab('p2p')">Details</button> + </div> + </div> + <div class="header-actions"> + <div class="muted" x-text="lastUpdatedLabel()"></div> + <button class="lock-button" type="button" x-show="auth.authenticated" @click="logout">Lock</button> + </div> + </header> + + <div class="flash" :class="flash?.kind" x-show="flash" x-transition x-text="flash?.message"></div> + <div class="persistent-banner" x-show="p2pRestartRequired()" x-transition x-text="p2pRestartMessage()"></div> + + <section x-show="tab === 'wallet'"> + <div class="page-title"> + <button class="wallet-balance-line" type="button" @click="openWalletUtxosModal" title="Show wallet UTXOs"> + <span class="tx-label">Balance</span> + <span class="tx-value money">IUNA <span x-text="amountLabel(status.wallet_balance)"></span></span> + </button> + </div> + <div class="wallet-grid"> + <div class="wallet-actions"> + <div class="panel"> + <h3>Send</h3> + <form @submit.prevent="sendTransfer"> + <div class="recipient-field"> + <label>Recipient<input x-model="transferTo" @input="scheduleFeeEstimates" autocomplete="off" required></label> + <button class="icon-button" type="button" @click="openAddressBookPicker()" title="Choose contact" aria-label="Choose contact"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v18H6.5A2.5 2.5 0 0 1 4 18.5z"></path><path d="M8 7h8"></path><path d="M8 11h6"></path><path d="M8 15h4"></path></svg> + </button> + </div> + <label>Amount<input x-model="transferAmount" @input="scheduleFeeEstimates" type="number" min="0.000001" step="0.000001" required></label> + <label>Fee / byte<input x-model="transferFee" @input="scheduleFeeEstimates" type="number" min="0" step="0.000001" required></label> + <div class="fee-preview" x-text="feeEstimateLabel('transfer')"></div> + <button class="advanced-toggle" type="button" @click="toggleSendAdvanced" x-text="showSendAdvanced ? 'Hide UTXOs' : 'UTXOs'"></button> + <div class="send-utxo-summary" x-show="showSendAdvanced"> + <div>Selected UTXOs: <span x-text="selectedTransferUtxos.length"></span></div> + <div>Selected total: IUNA <span x-text="amountLabel(selectedTransferUtxoTotal())"></span></div> + <div>Required: IUNA <span x-text="amountLabel(transferRequiredTotal())"></span></div> + <div class="setup-feedback error" x-show="!selectedTransferUtxosCoverTransfer()">Selected UTXOs do not cover amount plus fee</div> + <div class="send-utxo-list"> + <div class="send-utxo-list-head"> + <span>UTXOs</span> + <span class="send-utxo-actions"> + <button class="utxo-select-button" type="button" @click="selectAllTransferUtxos" :disabled="walletUtxoPage.loading && walletUtxos.length === 0">Select all</button> + <button class="utxo-select-button" type="button" @click="clearTransferUtxos" :disabled="selectedTransferUtxos.length === 0">None</button> + </span> + </div> + <template x-for="utxo in walletUtxos" :key="utxoOutpoint(utxo)"> + <label class="send-utxo-option" :class="{ disabled: !utxo.spendable }"> + <input type="checkbox" :value="utxoOutpoint(utxo)" x-model="selectedTransferUtxos" @change="scheduleFeeEstimates" :disabled="!utxo.spendable"> + <span> + <span class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">IUNA <span x-text="amountLabel(utxo.amount)"></span></span></span> + <span class="utxo-status" x-show="!utxo.spendable">Pending</span> + <code class="tx-value hash" x-text="utxoOutpoint(utxo)"></code> + </span> + </label> + </template> + <div class="dataset-loader" x-show="walletUtxoPage.loading" aria-hidden="true"> + <div class="send-utxo-option skeleton-card"><span><span class="skeleton-line medium"></span><span class="skeleton-line long"></span></span></div> + <div class="send-utxo-option skeleton-card"><span><span class="skeleton-line short"></span><span class="skeleton-line long"></span></span></div> + </div> + <div class="page-sentinel" x-show="walletUtxoPage.hasMore" x-init="$nextTick(() => observePageSentinel('walletUtxo', $el))"></div> + <div class="tx-modal-empty" x-show="walletUtxos.length === 0 && !walletUtxoPage.loading">No UTXOs</div> + </div> + </div> + <button class="primary" type="submit">Send</button> + </form> + </div> + <div class="panel"> + <div class="panel-head"> + <h3>Receive</h3> + <button type="button" @click="copyAddress">Copy</button> + </div> + <div class="receive-address"> + <div class="muted">Public key / address</div> + <div class="address-box"><code x-text="status.wallet_address || '-'"></code></div> + </div> + </div> + <div class="panel"> + <div class="panel-head"> + <h3>Address Book</h3> + <div class="address-book-actions"> + <button class="icon-button" type="button" @click="openAddressBookModal()" title="Add contact" aria-label="Add contact"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14"></path><path d="M5 12h14"></path></svg> + </button> + </div> + </div> + <div class="address-book-list"> + <template x-for="entry in addressBookEntries()" :key="entry.address"> + <button class="address-book-row" type="button" @click="editAddressBookEntry(entry)" :title="`Edit ${entry.name}`"> + <div> + <div class="address-book-name" x-text="entry.name"></div> + <code class="tx-value hash" x-text="short(entry.address)"></code> + </div> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18l6-6-6-6"></path></svg> + </button> + </template> + <div class="muted" x-show="addressBookEntries().length === 0">No saved addresses</div> + </div> + </div> + </div> + <div class="panel wallet-tx-panel"> + <div class="panel-head"> + <h3>Transactions</h3> + <div class="wallet-tx-filters" aria-label="Transaction filters"> + <label class="tx-filter" :class="{ active: walletTxFilters.transfer }"> + <input type="checkbox" x-model="walletTxFilters.transfer" @change="refreshWalletTransactions()"> + <span>Tx</span> + </label> + <label class="tx-filter" :class="{ active: walletTxFilters.mine }"> + <input type="checkbox" x-model="walletTxFilters.mine" @change="refreshWalletTransactions()"> + <span>Mine</span> + </label> + <label class="tx-filter" :class="{ active: walletTxFilters.burn }"> + <input type="checkbox" x-model="walletTxFilters.burn" @change="refreshWalletTransactions()"> + <span>Burn</span> + </label> + </div> + </div> + <div class="wallet-tx-list"> + <template x-for="tx in walletTransactions()" :key="tx.status + '-' + tx.signature"> + <div class="wallet-tx-row" :class="{ pending: tx.status === 'pending' }" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Wallet' })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Wallet' })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Wallet' })"> + <span class="pill" :class="tx.kind" x-text="tx.direction"></span> + <div class="wallet-tx-main"> + <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(tx.amount)"></span></span></div> + <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> + <div class="tx-field"><span class="tx-label">Status</span><span class="tx-value text" x-text="txTitle(tx)"></span></div> + <div class="tx-field"><span class="tx-label">Time</span><span class="tx-value text" x-text="walletTxTimeLabel(tx)"></span></div> + <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(tx.from)"></code></div> + <div class="tx-field" x-show="tx.to"><span class="tx-label">To</span><code class="tx-value hash" x-text="shortAddressLabel(tx.to)"></code></div> + <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> + <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="short(txProofHash(tx))"></code></div> + <div class="tx-field"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> + </div> + </div> + </template> + <div class="dataset-loader" x-show="walletTxPage.loading" aria-hidden="true"> + <div class="wallet-tx-row skeleton-card"><div class="wallet-tx-main"><div class="skeleton-line medium"></div><div class="skeleton-line short"></div><div class="skeleton-line long"></div></div></div> + <div class="wallet-tx-row skeleton-card"><div class="wallet-tx-main"><div class="skeleton-line short"></div><div class="skeleton-line medium"></div><div class="skeleton-line long"></div></div></div> + </div> + <div class="page-sentinel" x-show="walletTxPage.hasMore" x-init="$nextTick(() => observePageSentinel('walletTx', $el))"></div> + <div class="muted" x-show="walletTransactions().length === 0 && !walletTxPage.loading">No wallet transactions</div> + </div> + </div> + </div> + </section> + + <section x-show="tab === 'mining'"> + <div class="page-title"> + <div class="muted">PoB/VDF block production with PoW issuance actions</div> + </div> + <div class="mining-grid"> + <div class="panel"> + <h3>Status</h3> + <div class="mine-stats local-mining-stats" aria-label="Local mining status"> + <div class="mine-stat"> + <div class="mine-stat-label">PoB State</div> + <div class="mine-stat-value" x-text="pobStatusLabel()"></div> + </div> + <div class="mine-stat"> + <div class="mine-stat-label">PoW State</div> + <div class="mine-stat-value" x-text="powStatusShortLabel()"></div> + </div> + <div class="mine-stat"> + <div class="mine-stat-label">Selected Finalizer</div> + <code class="mine-stat-value" x-text="currentFinalizerLabel()"></code> + </div> + <div class="mine-stat"> + <div class="mine-stat-label">Mempool</div> + <div class="mine-stat-value" x-text="localMiningMempoolLabel()"></div> + </div> + </div> + <div class="mining-event-log" aria-label="Mining event log"> + <div class="mining-event-log-head"><span>Event log</span><span x-text="`${miningEventLog().length} lines`"></span></div> + <template x-if="miningEventLog().length === 0"> + <div class="mining-event-empty skeleton-card" aria-hidden="true"><div class="skeleton-line"></div></div> + </template> + <template x-for="event in miningEventLog()" :key="event.key"> + <div class="mining-event" :class="event.kind"> + <span class="mining-event-dot" aria-hidden="true"></span> + <div> + <div class="mining-event-title" x-text="event.title"></div> + <div class="mining-event-detail" x-text="event.detail"></div> + </div> + <div class="mining-event-time" x-text="event.time"></div> + </div> + </template> + </div> + </div> + <div class="panel"> + <div class="panel-head mining-head"> + <h3>Burn</h3> + <label class="toggle-switch" :class="{ active: miningEnabled }"> + <input type="checkbox" :checked="miningEnabled" @change="setMiningEnabled($event.target.checked)"> + <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> + <span class="toggle-text" x-text="miningEnabled ? 'On' : 'Off'"></span> + </label> + </div> + <div class="panel-description">Burn IUNA to compete for block finalization. Winning burns finalize PoB/VDF blocks and earn the transaction fees in those blocks.</div> + <form class="mining-form" @submit.prevent="saveBurn"> + <div class="burn-fields"> + <label>IUNA per block<input x-model="burnAmountDraft" @input="burnAmountDirty = true; scheduleFeeEstimates()" type="number" min="0.000001" step="0.000001"></label> + <label>Fee / byte<input x-model="burnFeeDraft" @input="burnAmountDirty = true; scheduleFeeEstimates()" type="number" min="0" step="0.000001" required></label> + <button class="primary" type="submit">Save</button> + </div> + <div class="fee-preview" x-text="feeEstimateLabel('burn')"></div> + </form> + <div class="mine-stats fee-history" aria-label="Recent block fees"> + <div class="mine-stat"> + <div class="mine-stat-label">Last block fees</div> + <div class="mine-stat-value money">IUNA <span x-text="amountLabel(recentBlockFeeAverage(1))"></span></div> + </div> + <div class="mine-stat"> + <div class="mine-stat-label">5 block avg</div> + <div class="mine-stat-value money">IUNA <span x-text="amountLabel(recentBlockFeeAverage(5))"></span></div> + </div> + <div class="mine-stat"> + <div class="mine-stat-label">30 block avg</div> + <div class="mine-stat-value money">IUNA <span x-text="amountLabel(recentBlockFeeAverage(30))"></span></div> + </div> + </div> + </div> + <div class="panel"> + <h3>Mine</h3> + <div class="panel-description">Search for PoW actions that mint a fixed IUNA reward.</div> + <div class="mine-settings-form"> + <div class="mine-action-row"> + <div class="mine-stats" aria-label="PoW issuance settings"> + <div class="mine-stat"> + <div class="mine-stat-label">You receive</div> + <div class="mine-stat-value money">IUNA <span x-text="amountLabel(powMineReward())"></span></div> + </div> + <div class="mine-stat"> + <div class="mine-stat-label">Finalizer earns</div> + <div class="mine-stat-value">IUNA <span x-text="amountLabel(feeEstimates.mine?.fee ?? 0)"></span></div> + </div> + <div class="mine-stat"> + <div class="mine-stat-label">Difficulty <button class="info-button" type="button" @click="openPowDifficultyInfo" title="How difficulty is adjusted" aria-label="How PoW difficulty is adjusted">i</button></div> + <div class="mine-stat-value"><span x-text="powDifficultyLabel()"></span> bits</div> + </div> + </div> + <label class="toggle-switch" :class="{ active: powMiningEnabled }" title="Continuously search for PoW mine actions with a small local work budget"> + <input type="checkbox" :checked="powMiningEnabled" @change="setPowMiningEnabled($event.target.checked)"> + <span class="toggle-track"><span class="toggle-thumb"></span></span> + <span class="toggle-text" x-text="powMiningEnabled ? 'On' : 'Off'"></span> + </label> + <label class="compact-number-field" title="Local PoW worker count"> + <span>Workers</span> + <input type="number" min="1" :max="maxPowMiningWorkers" :value="powMiningWorkers" @change="setPowMiningWorkers($event.target.value)"> + </label> + </div> + <div class="fee-preview" x-text="autoPowStatusLabel()"></div> + </div> + <div class="panel-separator"></div> + <div class="stratum-config"> + <div class="stratum-note">Start the node with <code>--stratum 0.0.0.0:3333</code> to expose a Stratum V1 endpoint for ASIC miners. Use the pool URL below in the miner configuration.</div> + <div class="stratum-fields" aria-label="Stratum settings"> + <div class="stratum-field"> + <div class="stratum-label">Status</div> + <div class="stratum-value" x-text="status.stratum?.enabled ? 'On' : 'Off'"></div> + </div> + <div class="stratum-field"> + <div class="stratum-label">Listener</div> + <code class="stratum-value hash" x-text="stratumListenAddr()"></code> + </div> + <div class="stratum-field"> + <div class="stratum-label">Pool URL</div> + <code class="stratum-value hash" x-text="stratumPoolUrl()"></code> + </div> + </div> + </div> + </div> + </div> + </section> + + <section x-show="tab === 'p2p'"> + <div class="panel"> + <div class="peer-toolbar"> + <div> + <h2>Peers</h2> + <div class="panel-description" x-text="p2pAcceptInbound ? 'Manage outbound peers and inspect inbound or outbound sessions. Public node is accepting inbound P2P connections.' : 'Manage outbound peers and inspect sync health. This node is outbound-only and does not open an inbound P2P port.'"></div> + </div> + <form class="peer-form" @submit.prevent="addPeer"> + <label>Peer address<input x-model="peerAddress" placeholder="seed.example:9444"></label> + <button class="primary" type="submit">Add</button> + </form> + </div> + <div class="network-health"> + <div class="network-health-state" :class="networkHealthClass()"> + <div class="network-health-label">Network Health</div> + <div class="network-health-value" x-text="networkHealth.state || '-'"></div> + <div class="network-health-detail" x-text="networkHealth.last_error || 'No peer errors reported'"></div> + </div> + <div class="network-health-grid"> + <div class="peer-summary-item"><div class="peer-summary-label">Local Height</div><div class="peer-summary-value" x-text="networkHealth.local_height ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Best Known</div><div class="peer-summary-value" x-text="networkHealth.best_known_height ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Lag</div><div class="peer-summary-value" x-text="networkLagLabel()"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Stale</div><div class="peer-summary-value" x-text="networkHealth.stale_peers ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Banned</div><div class="peer-summary-value" x-text="networkHealth.banned_peers ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Mempool</div><div class="peer-summary-value" x-text="networkHealth.pending_transactions ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Plain Tx</div><div class="peer-summary-value" x-text="networkHealth.pending_plain_transactions ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Commits</div><div class="peer-summary-value" x-text="networkHealth.pending_blinded_transactions ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Reveals</div><div class="peer-summary-value" x-text="networkHealth.pending_blinded_reveals ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Time Offset</div><div class="peer-summary-value" x-text="networkTimeOffsetLabel()"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Clock Warnings</div><div class="peer-summary-value" x-text="networkHealth.bad_clock_peers ?? '-'"></div></div> + </div> + </div> + <div class="peer-summary"> + <div class="peer-summary-item"><div class="peer-summary-label">Outbound</div><div class="peer-summary-value" x-text="outboundPeers().length"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Inbound</div><div class="peer-summary-value" x-text="inboundPeers().length"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Healthy</div><div class="peer-summary-value" x-text="healthyPeers().length"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Errors</div><div class="peer-summary-value" x-text="failedPeers().length"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Shared Height</div><div class="peer-summary-value" x-text="sharedHeightLabel()"></div></div> + </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>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> + <td><span class="peer-status" :class="peerStatus(peer)" x-text="peerStatusLabel(peer)"></span></td> + <td><code x-text="peer.address"></code></td> + <td x-text="peer.direction"></td> + <td x-text="peerLastContactLabel(peer)"></td> + <td x-text="peerClockLabel(peer)"></td> + <td x-text="peerBanLabel(peer)"></td> + <td x-text="peer.misbehavior_score ?? 0"></td> + <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.messages_sent"></td> + <td x-text="peer.messages_received"></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="14"><div class="skeleton-table-cell"></div></td> + </tr> + <tr class="skeleton-card" x-show="peerPage.loading" aria-hidden="true"> + <td colspan="14"><div class="skeleton-table-cell"></div></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> + </div> + <div class="panel"> + <h2>Metrics</h2> + <div class="grid"> + <div class="metric"><div class="label">Inbound Sessions</div><div class="value" x-text="p2pMetrics.inbound_sessions_started ?? 0"></div></div> + <div class="metric"><div class="label">Inbound Rejects</div><div class="value" x-text="p2pMetrics.inbound_sessions_rejected ?? 0"></div></div> + <div class="metric"><div class="label">Outbound Attempts</div><div class="value" x-text="p2pMetrics.outbound_connect_attempts ?? 0"></div></div> + <div class="metric"><div class="label">Connect Failures</div><div class="value" x-text="p2pMetrics.outbound_connect_failures ?? 0"></div></div> + <div class="metric"><div class="label">Session Failures</div><div class="value" x-text="p2pMetrics.session_failures ?? 0"></div></div> + <div class="metric"><div class="label">Parse Errors</div><div class="value" x-text="p2pMetrics.parse_errors ?? 0"></div></div> + <div class="metric"><div class="label">Empty Frames</div><div class="value" x-text="p2pMetrics.empty_frames ?? 0"></div></div> + <div class="metric"><div class="label">Self Rejects</div><div class="value" x-text="p2pMetrics.self_peer_rejections ?? 0"></div></div> + <div class="metric"><div class="label">Self Skips</div><div class="value" x-text="p2pMetrics.self_peer_skips ?? 0"></div></div> + <div class="metric"><div class="label">Received</div><div class="value" x-text="p2pMetrics.envelopes_received ?? 0"></div></div> + <div class="metric"><div class="label">Bytes In</div><div class="value" x-text="p2pMetrics.bytes_received ?? 0"></div></div> + <div class="metric"><div class="label">Status Rx</div><div class="value" x-text="p2pMetrics.peer_status_envelopes_received ?? 0"></div></div> + <div class="metric"><div class="label">Hello Rx</div><div class="value" x-text="p2pMetrics.hello_envelopes_received ?? 0"></div></div> + <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">Commit Rx</div><div class="value" x-text="p2pMetrics.blinded_transactions_received ?? 0"></div></div> + <div class="metric"><div class="label">Commit Batches Rx</div><div class="value" x-text="p2pMetrics.blinded_transaction_envelopes_received ?? 0"></div></div> + <div class="metric"><div class="label">Reveal Rx</div><div class="value" x-text="p2pMetrics.blinded_reveals_received ?? 0"></div></div> + <div class="metric"><div class="label">Reveal Batches Rx</div><div class="value" x-text="p2pMetrics.blinded_reveal_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> + <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> + <div class="tx-field"><span class="tx-label">Last Empty</span><span class="tx-value text" x-text="p2pMetrics.last_empty_frame_remote || '-'"></span></div> + <div class="tx-field"><span class="tx-label">Last Parse</span><span class="tx-value text" x-text="p2pMetrics.last_parse_error || '-'"></span></div> + </div> + </div> + </section> + + <section x-show="tab === 'chain'"> + <div class="explorer-shell"> + <div class="block-rail-wrap"> + <div class="block-rail-head"> + <h2>Blocks</h2> + <div class="muted"><span x-text="blocks.length"></span> loaded</div> + </div> + <div class="block-rail" x-ref="blockRail" @scroll.debounce.200ms="maybeLoadOlderBlocks($event)"> + <template x-for="block in blocks" :key="block.hash"> + <button class="block-card" :class="{ selected: selectedBlock?.hash === block.hash, 'new-block': newBlockHashes.has(block.hash) }" @click="selectBlock(block)" type="button"> + <div class="block-height" x-text="block.height"></div> + <div class="block-meta"> + <span x-text="burnCountLabel(block)"></span> + <span x-text="transferCountLabel(block)"></span> + <span x-text="commitCountLabel(block)"></span> + <span x-text="mineCountLabel(block)"></span> + </div> + <div class="block-miner" x-text="blockFinalizerLabel(block)"></div> + </button> + </template> + <template x-if="loadingOlder"> + <div class="block-card skeleton-card" aria-hidden="true"> + <div class="skeleton-line short"></div> + <div class="skeleton-line medium"></div> + <div class="skeleton-line long"></div> + </div> + </template> + <div class="page-sentinel block-page-sentinel" x-show="hasMoreBlocks" x-init="$nextTick(() => observeBlockSentinel($el))"></div> + </div> + </div> + + <section class="panel"> + <h2>Block Detail</h2> + <template x-if="selectedBlock"> + <div class="detail-grid"> + <div> + <div class="detail-kv"><div class="key">Height</div><div x-text="selectedBlock.height"></div></div> + <div class="detail-kv"><div class="key">Time</div><div x-text="blockTimestampLabel(selectedBlock)"></div></div> + <div class="detail-kv"><div class="key">Hash</div><code x-text="selectedBlock.hash"></code></div> + <div class="detail-kv"><div class="key">Previous</div><code x-text="short(selectedBlock.prev_hash)"></code></div> + <div class="detail-kv"> + <div class="key">Finalizer</div> + <button class="detail-link" type="button" @click="openBurnLeaderRanksModal(selectedBlock)" title="Burn leader ranks"> + <code x-text="shortAddressLabel(selectedBlock.miner)"></code> + </button> + </div> + <div class="detail-kv"><div class="key">Mode</div><div x-text="selectedBlock.finalizer_mode === 'recovery' ? 'Recovery' : `Rank ${selectedBlock.finalizer_rank ?? 0}`"></div></div> + <div class="detail-kv"><div class="key">Reward</div><div>IUNA <span x-text="amountLabel(selectedBlock.reward)"></span></div></div> + <div class="detail-kv"><div class="key">Burns</div><div x-text="blockBurnCount(selectedBlock)"></div></div> + <div class="detail-kv"><div class="key">Transfers</div><div x-text="blockTransferCount(selectedBlock)"></div></div> + <div class="detail-kv"><div class="key">Total Burned</div><div>IUNA <span x-text="amountLabel(blockBurned(selectedBlock))"></span></div></div> + <div class="detail-kv"> + <div class="key">Bytes</div> + <button class="detail-link" type="button" @click="openBlockBytesModal(selectedBlock)" title="Block byte breakdown"> + <span x-text="blockTotalBytes(selectedBlock)"></span>B + </button> + </div> + <div class="detail-kv"><div class="key">VDF</div><div><span x-text="selectedBlock.vdf_rounds"></span> rounds</div></div> + </div> + <div class="tx-list"> + <h3>Transactions</h3> + <div class="tx-section"> + <div class="tx-section-title"><span>Envelope</span><span class="tx-section-meta" x-text="shortAddressLabel(selectedBlock.miner)"></span></div> + <template x-for="tx in selectedBlock.transactions" :key="tx.signature"> + <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Envelope', blockHeight: selectedBlock.height, blockFinalizer: selectedBlock.miner })"> + <span class="pill" :class="txPillClass(tx)" x-text="txPillLabel(tx)"></span> + <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> + <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(txFrom(tx))"></code></div> + <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="shortAddressLabel(txTo(tx))"></code></div> + <div class="tx-field" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div> + <div class="tx-field" x-show="tx.encrypted_size || tx.encryptedSize"><span class="tx-label">Bytes</span><span class="tx-value number" x-text="tx.encrypted_size || tx.encryptedSize"></span></div> + <div class="tx-field" x-show="tx.expires_at_height || tx.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="tx.expires_at_height || tx.expiresAtHeight"></span></div> + <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> + <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="short(txProofHash(tx))"></code></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> + </div> + </template> + <div class="muted" x-show="selectedBlock.transactions.length === 0">No envelope transactions</div> + </div> + <template x-for="bundle in selectedBlock.reveal_bundles || selectedBlock.revealBundles || []" :key="bundle.hash"> + <details class="tx-section"> + <summary class="tx-section-title"><span x-text="`Reveal bundle ${bundle.slot}`"></span><span class="tx-section-meta"><span x-text="shortAddressLabel(bundle.member)"></span> · <span x-text="bundle.byte_size || bundle.byteSize || 0"></span>B</span></summary> + <template x-for="tx in bundle.reveals" :key="tx.signature"> + <div class="tx-card" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Reveal bundle', blockHeight: selectedBlock.height, blockFinalizer: bundle.member })"> + <span class="pill" :class="txPillClass(tx)" x-text="txPillLabel(tx)"></span> + <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> + <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(txFrom(tx))"></code></div> + <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="shortAddressLabel(txTo(tx))"></code></div> + <div class="tx-field" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> + </div> + </template> + <div class="muted" x-show="bundle.reveals.length === 0">No reveals in bundle</div> + </details> + </template> + <div class="muted" x-show="selectedBlock.transactions.length === 0 && !(selectedBlock.reveal_bundles || selectedBlock.revealBundles || []).length">No transactions</div> + </div> + </div> + </template> + <div class="muted" x-show="!selectedBlock">Select a block</div> + </section> + + <section class="panel mempool-panel" x-show="mempool.length > 0 || mempoolPage.loading"> + <h2>Mempool</h2> + <div class="mempool-strip"> + <template x-for="tx in mempool" :key="tx.signature"> + <div class="mempool-item" :class="mempoolItemClass(tx)" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Mempool' })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Mempool' })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Mempool' })"> + <div class="mempool-top"> + <div class="mempool-top-meta"> + <div class="mempool-state" x-show="mempoolItemClass(tx).includes('new-since-block')">New since last block</div> + <div class="mempool-time" x-show="mempoolSeenTimeLabel(tx)" x-text="mempoolSeenTimeLabel(tx)"></div> + </div> + <span class="pill" :class="tx.kind" x-text="tx.kind"></span> + </div> + <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> + <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(tx)"></span></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="shortAddressLabel(txFrom(tx))"></code></div> + <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="shortAddressLabel(txTo(tx))"></code></div> + <div class="tx-field" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div> + <div class="tx-field" x-show="tx.encrypted_size || tx.encryptedSize"><span class="tx-label">Bytes</span><span class="tx-value number" x-text="tx.encrypted_size || tx.encryptedSize"></span></div> + <div class="tx-field" x-show="tx.expires_at_height || tx.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="tx.expires_at_height || tx.expiresAtHeight"></span></div> + <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> + <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="short(txProofHash(tx))"></code></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> + </div> + </template> + <template x-if="mempoolPage.loading"> + <div class="mempool-item skeleton-card" aria-hidden="true"> + <div class="skeleton-line short"></div> + <div class="skeleton-line medium"></div> + <div class="skeleton-line long"></div> + </div> + </template> + <div class="page-sentinel" x-show="mempoolPage.hasMore" x-init="$nextTick(() => observePageSentinel('mempool', $el))"></div> + </div> + </section> + </div> + </section> + <section x-show="tab === 'metrics'"> + <div class="metrics-shell"> + <div class="metrics-head"> + <h2>Metrics</h2> + <div class="segmented metrics-range" role="group" aria-label="Metrics block range"> + <button type="button" :class="{ active: metricsRange === 100 }" @click="setMetricsRange(100)">Last 100</button> + <button type="button" :class="{ active: metricsRange === 1000 }" @click="setMetricsRange(1000)">Last 1000</button> + <button type="button" :class="{ active: metricsRange === 'all' }" @click="setMetricsRange('all')">All</button> + </div> + </div> + <div class="metrics-summary"> + <div class="metric"><div class="label">Latest block</div><div class="value" x-text="metricsLatest().height ?? '-'"></div></div> + <div class="metric"><div class="label">Supply</div><div class="value" x-text="metricAmountLabel(metricsLatest().circulatingSupply)"></div></div> + <div class="metric"><div class="label">Known addresses</div><div class="value" x-text="metricsLatest().knownWalletAddresses ?? '-'"></div></div> + <div class="metric"><div class="label">Total burned</div><div class="value" x-text="metricAmountLabel(metricsLatest().totalBurnedAmount)"></div></div> + <div class="metric"><div class="label">Difficulty</div><div class="value" x-text="metricsLatest().mineDifficultyBits ?? '-'"></div></div> + </div> + <div class="metrics-empty" x-show="metricsCharts().length === 0">No metrics collected yet</div> + <div class="metrics-grid"> + <template x-for="chart in metricsCharts()" :key="chart.id"> + <article class="metric-chart-card"> + <div class="metric-chart-head"> + <h3 class="metric-chart-title" x-text="chart.title"></h3> + <div class="metric-chart-value" x-text="metricLatestValueLabel(chart)"></div> + </div> + <div class="metric-chart-frame"> + <div class="metric-chart-y-axis"> + <template x-for="tick in metricYAxisTicks(chart)" :key="`${chart.id}-y-${tick}`"> + <span class="metric-chart-axis-label" :style="metricYAxisLabelStyle(chart, tick)" x-text="metricAxisValueLabel(chart, tick)"></span> + </template> + </div> + <div class="metric-chart-plot" @mousemove="setMetricHoverFromPlot(chart, $event)" @mouseleave="clearMetricHover(chart)"> + <svg class="metric-chart-svg" viewBox="0 0 300 148" preserveAspectRatio="none" role="img" :aria-label="chart.title"> + <path class="metric-chart-gridline" :d="metricGridPath(chart)"></path> + <line class="metric-chart-axis" x1="4" y1="8" x2="4" y2="132"></line> + <line class="metric-chart-axis" x1="4" y1="132" x2="296" y2="132"></line> + <polyline class="metric-chart-line" :points="metricChartPoints(chart)"></polyline> + </svg> + <div class="metric-chart-points"> + <template x-for="marker in metricChartPointMarkers(chart)" :key="`${chart.id}-point-${marker.height}`"> + <button class="metric-chart-point-hit" type="button" :class="{ 'is-active': metricHover?.chartId === chart.id && metricHover?.height === marker.height }" :style="metricPointStyle(marker)" :title="marker.label" @focus="setMetricHover(chart, marker)" @blur="clearMetricHover(chart)" :aria-label="marker.label"></button> + </template> + </div> + <template x-if="metricHover?.chartId === chart.id"> + <div class="metric-chart-tooltip" :style="metricTooltipStyle(chart)" x-text="metricTooltipLabel(chart)"></div> + </template> + </div> + <div class="metric-chart-x-axis"> + <template x-for="tick in metricXAxisTicks(chart)" :key="`${chart.id}-x-${tick}`"> + <span class="metric-chart-axis-label" :style="metricXAxisLabelStyle(chart, tick)" x-text="`#${tick}`"></span> + </template> + </div> + </div> + </article> + </template> + </div> + </div> + </section> + <section x-show="tab === 'settings'"> + <div class="settings-grid"> + <div class="panel"> + <div class="panel-head"> + <h2>Settings</h2> + <span class="pill" x-text="advancedMode() ? 'Node mode' : 'Wallet mode'"></span> + </div> + <div class="settings-mode-row"> + <div class="settings-mode-copy"> + <div class="settings-mode-title">Mode</div> + <div class="muted" x-text="advancedMode() ? 'Node mode shows mining and peer controls.' : 'Wallet mode keeps the interface focused on wallet and chain views.'"></div> + </div> + <label class="toggle-switch" :class="{ active: advancedMode() }"> + <input type="checkbox" :checked="advancedMode()" @change="setUiMode($event.target.checked ? 'advanced' : 'basic')"> + <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> + <span class="toggle-text" x-text="advancedMode() ? 'Node' : 'Wallet'"></span> + </label> + </div> + </div> + <div class="panel"> + <div class="settings-mode-row"> + <div class="settings-mode-copy"> + <div class="settings-mode-title">Keep track of metrics</div> + <div class="muted" x-text="keepTrackOfMetrics ? 'Metrics are stored per block.' : 'Metrics storage is off.'"></div> + </div> + <label class="toggle-switch" :class="{ active: keepTrackOfMetrics }"> + <input type="checkbox" :checked="keepTrackOfMetrics" @change="setKeepTrackOfMetrics($event.target.checked)"> + <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> + <span class="toggle-text" x-text="keepTrackOfMetrics ? 'On' : 'Off'"></span> + </label> + </div> + </div> + <div class="panel" x-show="advancedMode()"> + <div class="settings-mode-row"> + <div class="settings-mode-copy"> + <div class="settings-mode-title">Recovery VDF</div> + <div class="muted">Top <span x-text="recoveryVdfTopRankPercent"></span>% threshold for fallback/recovery work.</div> + </div> + <label>Top ranks + <input type="range" min="0" max="100" step="5" :value="recoveryVdfTopRankPercent" @change="setRecoveryVdfTopRankPercent($event.target.value)"> + </label> + </div> + </div> + <div class="panel" x-show="advancedMode()"> + <h3>Node Networking</h3> + <div class="settings-mode-row"> + <div class="settings-mode-copy"> + <div class="settings-mode-title">Public node</div> + <div class="muted" x-text="p2pAcceptInbound ? 'Accepting inbound P2P connections.' : 'Outbound-only P2P; no inbound port is open.'"></div> + </div> + <label class="toggle-switch" :class="{ active: p2pAcceptInbound }"> + <input type="checkbox" :checked="p2pAcceptInbound" @change="setP2pAcceptInbound($event.target.checked)"> + <span class="toggle-track" aria-hidden="true"><span class="toggle-thumb"></span></span> + <span class="toggle-text" x-text="p2pAcceptInbound ? 'Public' : 'Private'"></span> + </label> + </div> + <form class="settings-form public-p2p-form" x-show="p2pAcceptInbound" x-transition @submit.prevent="saveP2pAnnounce"> + <label>Bind port<input x-model.number="p2pBindPort" @input="p2pBindPortDirty = true" type="number" min="1" max="65535" step="1" required></label> + <label>Public P2P address<input x-model="p2pAnnounceAddr" @input="p2pAnnounceDirty = true" placeholder="203.0.113.10:9444"></label> + <div class="muted">Use this only when TCP port <span x-text="p2pBindPort"></span> is reachable from the internet.</div> + <div class="setup-actions"><button class="primary" type="submit">Save</button></div> + </form> + </div> + <div class="panel"> + <h3>Change Password</h3> + <div class="setup-feedback" :class="settingsFeedback?.kind" x-show="settingsFeedback" x-transition x-text="settingsFeedback?.message"></div> + <form class="settings-form" @submit.prevent="changePassword"> + <input class="visually-hidden" type="text" name="username" value="iuna" autocomplete="username" tabindex="-1" aria-hidden="true"> + <label>Current password<input x-model="settingsOldPassword" type="password" autocomplete="current-password" required></label> + <label>New password<input x-model="settingsNewPassword" type="password" autocomplete="new-password" minlength="12" required></label> + <label>Confirm new password<input x-model="settingsPasswordConfirm" type="password" autocomplete="new-password" minlength="12" required></label> + <div class="setup-actions"><button class="primary" type="submit">Change password</button></div> + </form> + </div> + </div> + </section> + </main> + </div> + <div class="setup-overlay" x-show="showingAuth()" x-transition.opacity role="dialog" aria-modal="true" aria-labelledby="auth-title"> + <section class="setup-modal auth-form"> + <div class="setup-modal-head"> + <div class="setup-welcome">iuna Access</div> + <h2 id="auth-title" x-text="auth.configured ? 'Unlock iuna' : 'Set Password'"></h2> + <div class="setup-copy" x-show="!auth.configured">Choose a local password before wallet setup continues.</div> + <div class="setup-copy" x-show="auth.configured">Enter the local password to unlock this node.</div> + </div> + <div class="setup-feedback" :class="authFeedback?.kind" x-show="authFeedback" x-transition x-text="authFeedback?.message"></div> + <form x-show="!auth.configured" @submit.prevent="setupPassword"> + <input class="visually-hidden" type="text" name="username" value="iuna" autocomplete="username" tabindex="-1" aria-hidden="true"> + <label>Password<input x-model="authPassword" type="password" autocomplete="new-password" minlength="12" required></label> + <label>Confirm password<input x-model="authPasswordConfirm" type="password" autocomplete="new-password" minlength="12" required></label> + <div class="setup-actions"><button class="primary" type="submit">Set password</button></div> + </form> + <form x-show="auth.configured && !auth.authenticated" @submit.prevent="login"> + <input class="visually-hidden" type="text" name="username" value="iuna" autocomplete="username" tabindex="-1" aria-hidden="true"> + <label>Password<input x-model="loginPassword" type="password" autocomplete="current-password" required></label> + <div class="setup-actions"><button class="primary" type="submit">Unlock</button></div> + </form> + </section> + </div> + <div class="setup-overlay transaction-overlay" x-show="showWalletUtxos" x-transition.opacity @click.self="closeWalletUtxosModal()" role="dialog" aria-modal="true" aria-labelledby="wallet-utxos-title"> + <section class="tx-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <h2 id="wallet-utxos-title">Wallet UTXOs</h2> + <div class="tx-field"><span class="tx-label">Total</span><span class="tx-value money">IUNA <span x-text="amountLabel(status.wallet_balance)"></span></span></div> + </div> + <button type="button" @click="closeWalletUtxosModal">Close</button> + </div> + <div class="utxo-list"> + <template x-for="utxo in walletUtxos" :key="`${utxo.outpoint.txid}:${utxo.outpoint.index}`"> + <div class="wallet-utxo-row"> + <div class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">IUNA <span x-text="amountLabel(utxo.amount)"></span></span></div> + <div class="tx-field"><span class="tx-label">Outpoint</span><code class="tx-value hash" x-text="txInputOutpoint({ outpoint: utxo.outpoint })"></code></div> + <div class="tx-field"><span class="tx-label">Address</span><code class="tx-value hash" x-text="addressLabel(utxo.address)"></code></div> + </div> + </template> + <div class="dataset-loader" x-show="walletUtxoPage.loading" aria-hidden="true"> + <div class="wallet-utxo-row skeleton-card"><div class="skeleton-line medium"></div><div class="skeleton-line long"></div></div> + <div class="wallet-utxo-row skeleton-card"><div class="skeleton-line short"></div><div class="skeleton-line long"></div></div> + </div> + <div class="page-sentinel" x-show="walletUtxoPage.hasMore" x-init="$nextTick(() => observePageSentinel('walletUtxo', $el))"></div> + <div class="tx-modal-empty" x-show="walletUtxos.length === 0 && !walletUtxoPage.loading">No wallet UTXOs</div> + </div> + </section> + </div> + <div class="setup-overlay transaction-overlay" x-show="showPowDifficultyInfo" x-transition.opacity @click.self="closePowDifficultyInfo()" role="dialog" aria-modal="true" aria-labelledby="pow-difficulty-title"> + <section class="tx-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <h2 id="pow-difficulty-title">PoW Difficulty</h2> + </div> + <button type="button" @click="closePowDifficultyInfo">Close</button> + </div> + <div class="info-copy"> + <p>Difficulty is adjusted to target about one mine action per block.</p> + <div class="info-facts"> + <div class="info-fact"><div class="label">Window</div><div class="value">10 blocks</div></div> + <div class="info-fact"><div class="label">Target</div><div class="value">10 mine actions</div></div> + <div class="info-fact"><div class="label">Max step</div><div class="value">2 bits</div></div> + </div> + <p>If a window includes more mine actions than the target, difficulty rises. If it includes fewer, difficulty falls. The initial difficulty is 12 bits.</p> + </div> + </section> + </div> + <div class="setup-overlay transaction-overlay" x-show="selectedByteBlock" x-transition.opacity @click.self="closeBlockBytesModal()" role="dialog" aria-modal="true" aria-labelledby="block-bytes-title"> + <section class="tx-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <h2 id="block-bytes-title" x-text="selectedByteBlock ? `Block ${selectedByteBlock.height} Bytes` : 'Block Bytes'"></h2> + <div class="tx-field"><span class="tx-label">Total</span><span class="tx-value number"><span x-text="blockTotalBytes(selectedByteBlock)"></span>B</span></div> + </div> + <button type="button" @click="closeBlockBytesModal">Close</button> + </div> + <div class="rank-list"> + <template x-for="row in blockByteBreakdown(selectedByteBlock)" :key="row[0]"> + <div class="rank-row"> + <div class="rank-number" x-text="`${row[1]}B`"></div> + <div class="rank-details"> + <div class="tx-field"> + <span class="tx-label">Category</span> + <span class="pill" x-show="row[2]" :class="row[2]" x-text="row[0]"></span> + <span class="tx-value text" x-show="!row[2]" x-text="row[0]"></span> + </div> + </div> + </div> + </template> + </div> + </section> + </div> + <div class="setup-overlay transaction-overlay" x-show="selectedBurnLeaderBlock" x-transition.opacity @click.self="closeBurnLeaderRanksModal()" role="dialog" aria-modal="true" aria-labelledby="burn-ranks-title"> + <section class="tx-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <h2 id="burn-ranks-title" x-text="burnLeaderRanksTitle(selectedBurnLeaderBlock)"></h2> + <div class="tx-field"><span class="tx-label">Finalizer</span><code class="tx-value hash" x-text="selectedBurnLeaderBlock ? addressLabel(selectedBurnLeaderBlock.miner) : '-'"></code></div> + </div> + <button type="button" @click="closeBurnLeaderRanksModal">Close</button> + </div> + <div class="rank-list"> + <template x-for="rank in burnLeaderRanks(selectedBurnLeaderBlock)" :key="`${selectedBurnLeaderBlock.hash}-${rank.rank}-${rank.ticket_id ?? rank.ticketId}`"> + <div class="rank-row"> + <div class="rank-number" x-text="burnLeaderRankLabel(rank)"></div> + <div class="rank-details"> + <div class="tx-field"><span class="tx-label">Owner</span><code class="tx-value hash" x-text="addressLabel(rank.owner)"></code></div> + <div class="tx-field"><span class="tx-label">Burn</span><span class="tx-value money">IUNA <span x-text="amountLabel(rank.amount)"></span></span></div> + <div class="tx-field"><span class="tx-label">Ticket</span><code class="tx-value hash" x-text="short(rank.ticket_id ?? rank.ticketId)"></code></div> + <div class="tx-field"><span class="tx-label">Eligible</span><span class="tx-value number" x-text="burnLeaderEligibilityLabel(rank)"></span></div> + </div> + </div> + </template> + <div class="tx-modal-empty" x-show="burnLeaderRanks(selectedBurnLeaderBlock).length === 0">No burn leader ranks</div> + </div> + </section> + </div> + <div class="setup-overlay transaction-overlay" x-show="selectedTransaction" x-transition.opacity @click.self="closeTransactionModal()" role="dialog" aria-modal="true" aria-labelledby="tx-modal-title"> + <section class="tx-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <span class="pill" :class="txPillClass(selectedTransaction?.tx)" x-text="txPillLabel(selectedTransaction?.tx)"></span> + <h2 id="tx-modal-title">Transaction</h2> + <code class="tx-value hash" x-text="selectedTransaction?.tx?.signature || '-'"></code> + </div> + <button type="button" @click="closeTransactionModal">Close</button> + </div> + <div class="tx-modal-summary"> + <div class="tx-field"><span class="tx-label">Source</span><span class="tx-value text" x-text="selectedTransactionLabel()"></span></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(selectedTransaction?.tx || {}))"></span></span></div> + <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money" x-text="txFeeLabel(selectedTransaction?.tx)"></span></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="addressLabel(txFrom(selectedTransaction?.tx || {}))"></code></div> + <div class="tx-field" x-show="txTo(selectedTransaction?.tx || {})"><span class="tx-label">To</span><code class="tx-value hash" x-text="addressLabel(txTo(selectedTransaction?.tx || {}))"></code></div> + <div class="tx-field" x-show="isBlindedMempoolItem(selectedTransaction?.tx) || selectedTransaction?.tx?.commitment"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="selectedTransaction?.tx?.commitment || selectedTransaction?.tx?.signature || '-'"></code></div> + <div class="tx-field" x-show="selectedTransaction?.tx?.encrypted_size || selectedTransaction?.tx?.encryptedSize"><span class="tx-label">Encrypted Bytes</span><span class="tx-value number" x-text="selectedTransaction?.tx?.encrypted_size || selectedTransaction?.tx?.encryptedSize"></span></div> + <div class="tx-field" x-show="selectedTransaction?.tx?.expires_at_height || selectedTransaction?.tx?.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="selectedTransaction?.tx?.expires_at_height || selectedTransaction?.tx?.expiresAtHeight"></span></div> + <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Difficulty</span><span class="tx-value number" x-text="txDifficultyBits(selectedTransaction?.tx) ?? '-'"></span></div> + <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number" x-text="txProofBits(selectedTransaction?.tx) ?? '-'"></span></div> + <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="txProofHash(selectedTransaction?.tx) || '-'"></code></div> + </div> + <div class="utxo-flow"> + <div class="utxo-column"> + <h3>Inputs</h3> + <template x-for="(input, index) in txInputs(selectedTransaction?.tx || {})" :key="txInputKey(input, index)"> + <div class="utxo-node"> + <div class="utxo-node-label"><span>Input <span x-text="index + 1"></span></span><span>spent</span></div> + <div class="utxo-node-ref" x-text="txInputOutpoint(input)"></div> + <div class="tx-field"><span class="tx-label">Value</span><span class="tx-value money" x-text="txInputAmountLabel(input)"></span></div> + <div class="tx-field"><span class="tx-label">Owner</span><code class="tx-value hash" x-text="addressLabel(input.owner)"></code></div> + <div class="tx-field"><span class="tx-label">Sig</span><code class="tx-value hash" x-text="short(input.signature)"></code></div> + </div> + </template> + <div class="tx-modal-empty" x-show="txInputs(selectedTransaction?.tx || {}).length === 0">No inputs</div> + </div> + <div class="utxo-arrow" aria-hidden="true">&rarr;</div> + <div class="utxo-column"> + <h3>Outputs</h3> + <template x-for="(output, index) in txVisualOutputs(selectedTransaction?.tx || {})" :key="txOutputKey(output, index)"> + <div class="utxo-node" :class="{ burned: output.kind === 'burned', fee: output.kind === 'fee' }"> + <div class="utxo-node-label"><span x-text="output.label"></span><span x-text="output.kind"></span></div> + <div class="utxo-node-amount">IUNA <span x-text="amountLabel(output.amount)"></span></div> + <template x-if="output.address"> + <div class="tx-field"><span class="tx-label">To</span><code class="tx-value hash" x-text="addressLabel(output.address)"></code></div> + </template> + <template x-if="output.detail"> + <div class="tx-field"><span class="tx-label" x-text="output.detailLabel"></span><code class="tx-value hash" x-text="output.detail"></code></div> + </template> + </div> + </template> + <div class="tx-modal-empty" x-show="txVisualOutputs(selectedTransaction?.tx || {}).length === 0">No outputs</div> + </div> + </div> + </section> + </div> + <div class="setup-overlay transaction-overlay" x-show="addressBookModalOpen" x-transition.opacity @click.self="closeAddressBookModal()" role="dialog" aria-modal="true" aria-label="Address Book"> + <section class="tx-modal address-book-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <span class="pill">Address Book</span> + </div> + <button class="icon-button" type="button" @click="closeAddressBookModal()" title="Close" aria-label="Close"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg> + </button> + </div> + <form @submit.prevent="saveAddressBookEntry"> + <label>Name<input x-model="addressBookDraftName" autocomplete="off" required></label> + <label>Address<input x-model="addressBookDraftAddress" autocomplete="off" required :class="{ invalid: addressBookDraftAddress && !validAddressBookAddress(addressBookDraftAddress) }"></label> + <div class="setup-feedback error" x-show="addressBookDraftAddress && !validAddressBookAddress(addressBookDraftAddress)">Address must be a 64 character hex public key</div> + <div class="address-book-modal-actions"> + <button class="icon-button modal-delete-button" type="button" x-show="addressBookEditingAddress" @click="removeAddressBookEntry({ address: addressBookEditingAddress, name: addressBookDraftName || addressBookEditingAddress })" title="Delete contact" aria-label="Delete contact"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M6 7l1 14h10l1-14"></path><path d="M9 7V4h6v3"></path></svg> + </button> + <button class="primary icon-button" type="submit" title="Save contact" aria-label="Save contact"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z"></path><path d="M7 3v6h8"></path><path d="M7 21v-8h10v8"></path></svg> + </button> + </div> + </form> + </section> + </div> + <div class="setup-overlay transaction-overlay" x-show="addressBookPickerOpen" x-transition.opacity @click.self="closeAddressBookPicker()" role="dialog" aria-modal="true" aria-labelledby="address-book-picker-title"> + <section class="tx-modal address-book-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <span class="pill">Send</span> + <h2 id="address-book-picker-title">Choose Contact</h2> + </div> + <button class="icon-button" type="button" @click="closeAddressBookPicker()" title="Close" aria-label="Close"> + <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg> + </button> + </div> + <div class="address-book-picker-list"> + <template x-for="entry in addressBookEntries()" :key="entry.address"> + <button class="address-book-picker-row" type="button" @click="selectTransferContact(entry.address)" :title="`Send to ${entry.name}`"> + <span class="address-book-name" x-text="entry.name"></span> + <code class="tx-value hash" x-text="short(entry.address)"></code> + </button> + </template> + </div> + </section> + </div> + <div class="setup-overlay" x-show="showingSetup()" x-transition.opacity role="dialog" aria-modal="true" aria-labelledby="setup-title"> + <section class="setup-modal"> + <div class="setup-modal-head"> + <div class="setup-welcome">Welcome to iuna</div> + <h2 id="setup-title">Initial Setup</h2> + <div class="setup-copy">Connect this node to the network, then set up the local wallet.</div> + </div> + <div class="setup-feedback" :class="setupFeedback?.kind" x-show="setupFeedback" x-transition x-text="setupFeedback?.message"></div> + <div class="setup-grid"> + <div class="setup-section setup-node-mode"> + <div class="panel-head"> + <h3>Mode</h3> + <span class="pill">Change later in Settings</span> + </div> + <div class="segmented setup-mode-picker" role="tablist" aria-label="Initial node mode"> + <button type="button" :class="{ active: setupNodeMode === 'wallet' }" @click="selectSetupNodeMode('wallet')">Wallet</button> + <button type="button" :class="{ active: setupNodeMode === 'non-listening' }" @click="selectSetupNodeMode('non-listening')">Non-listening node</button> + <button type="button" :class="{ active: setupNodeMode === 'listening' }" @click="selectSetupNodeMode('listening')">Listening node</button> + </div> + <div class="setup-network-copy" x-text="setupNodeModeCopy()"></div> + </div> + <div class="setup-section setup-network"> + <div class="panel-head"> + <h3>Network</h3> + <a class="setup-network-link" href="https://github.com/iuna-labs/iuna/blob/main/KNOWN_NODES.txt" target="_blank" rel="noreferrer">Known nodes</a> + </div> + <div class="setup-network-row"> + <label><span x-text="setupRequiresPeer() ? 'Bootstrap peer (required)' : 'Bootstrap peer'"></span><input x-model="setupPeerAddress" placeholder="iuna.jhx.app:9444"></label> + <label x-show="setupNodeMode === 'listening'" x-transition>Bind port<input x-model.number="p2pBindPort" @input="p2pBindPortDirty = true" type="number" min="1" max="65535" step="1" required></label> + </div> + <div class="setup-network-copy" x-text="setupRequiresPeer() ? 'A bootstrap peer is required before this node can join the network. Known nodes help discovery; they do not control your wallet or decide valid blocks.' : 'You can add a bootstrap peer now or later from the P2P screen. Known nodes help discovery; they do not control your wallet or decide valid blocks.'"></div> + </div> + <div class="setup-section setup-wallet-section seed-panel"> + <div class="panel-head"> + <h3>Wallet</h3> + </div> + <div class="segmented" role="tablist" aria-label="Wallet setup mode"> + <button type="button" :class="{ active: setupWalletMode === 'create' }" @click="selectSetupWalletMode('create')">Create</button> + <button type="button" :class="{ active: setupWalletMode === 'import' }" @click="selectSetupWalletMode('import')">Import</button> + </div> + <div x-show="setupWalletMode === 'create'" class="seed-panel"> + <div class="setup-field"> + <div class="setup-field-label">Address</div> + <div class="address-box setup-address-box"> + <code x-text="setupAddress()"></code> + <button type="button" @click="copyAddress">Copy</button> + </div> + </div> + <template x-if="setupSeedWords().length > 0 && setupSeedStep === 'write'"> + <div class="seed-panel"> + <div class="setup-field"> + <div class="setup-field-label">Recovery phrase</div> + <div class="seed-grid"> + <template x-for="(word, index) in setupSeedWords()" :key="index"> + <div class="seed-word"> + <span class="index" x-text="index + 1"></span> + <span class="word" x-text="word"></span> + </div> + </template> + </div> + </div> + <div class="setup-actions"> + <button type="button" class="subtle" @click="generateSetupSeed">Regenerate</button> + <button type="button" class="subtle" x-show="setupWallet.dev_verify_bypass" @click="skipSeedVerificationForDev">Skip verification</button> + <button type="button" class="primary" @click="beginSeedVerification">I wrote it down</button> + </div> + </div> + </template> + <template x-if="setupSeedWords().length === 0"> + <div class="seed-panel"> + <div class="muted">This wallet does not have a recovery phrase yet.</div> + <button type="button" class="primary" @click="generateSetupSeed">Generate recovery phrase</button> + </div> + </template> + <template x-if="setupSeedStep === 'verify'"> + <div class="seed-panel"> + <div class="verify-grid"> + <template x-for="challenge in verifyChallenges" :key="challenge.index"> + <label> + <span>Word <span x-text="challenge.position"></span></span> + <input x-model="verifyAnswers[challenge.index]" autocomplete="off"> + </label> + </template> + </div> + <div class="setup-actions"> + <button type="button" class="subtle" @click="setupSeedStep = 'write'">Back</button> + <button type="button" class="primary" @click="verifyGeneratedSeed">Verify</button> + </div> + </div> + </template> + <template x-if="setupSeedStep === 'verified' && walletVerified"> + <div class="setup-status">Recovery phrase verified</div> + </template> + </div> + <div x-show="setupWalletMode === 'import'" class="seed-panel"> + <form @submit.prevent="importSetupSeed"> + <label>Recovery phrase<textarea x-model="importSeedPhrase" autocomplete="off" spellcheck="false" placeholder="24 words, separated by spaces or new lines"></textarea></label> + <button class="primary" type="submit">Import</button> + </form> + <template x-if="walletVerified"> + <div class="setup-status">Recovery phrase imported</div> + </template> + </div> + </div> + </div> + <div class="setup-actions"> + <button class="primary" type="button" :disabled="!setupCanContinue()" @click="completeSetup">Continue</button> + </div> + </section> + </div> +</body> +</html>"#; diff --git a/src/adapters/http/metrics.rs b/src/adapters/http/metrics.rs @@ -0,0 +1,277 @@ +use crate::{ + adapters::chain_store::BlockMetricRow, + app::{NodeStatus, PeerDirection, PeerInfo}, + domain::Amount, +}; + +use super::{ + PEER_STALE_AFTER_MS, now_ms, + types::{ + MempoolCounts, MetricsChart, MetricsPoint, MetricsResponse, MetricsValueKind, + NetworkHealthResponse, + }, +}; + +pub(super) fn network_health( + status: &NodeStatus, + peers: &[PeerInfo], + mempool: MempoolCounts, +) -> NetworkHealthResponse { + network_health_at(status, peers, mempool, now_ms()) +} + +pub(super) fn metrics_response(enabled: bool, rows: Vec<BlockMetricRow>) -> MetricsResponse { + let latest = rows.last().cloned(); + MetricsResponse { + enabled, + latest, + charts: vec![ + metrics_chart( + "block-time", + "Time per block", + "s", + MetricsValueKind::Seconds, + &rows, + |row| { + row.block_time_ms + .filter(|_| row.height > 1) + .map(|ms| ms as f64 / 1_000.0) + }, + ), + metrics_chart( + "difficulty", + "Difficulty", + "bits", + MetricsValueKind::Number, + &rows, + |row| Some(row.mine_difficulty_bits as f64), + ), + metrics_chart( + "supply", + "IUNA in circulation", + "IUNA", + MetricsValueKind::Iuna, + &rows, + |row| Some(micro_iuna_as_iuna(row.circulating_supply)), + ), + metrics_chart( + "known-wallet-addresses", + "Known wallet addresses", + "addresses", + MetricsValueKind::Number, + &rows, + |row| Some(row.known_wallet_addresses as f64), + ), + metrics_chart( + "transactions", + "Transactions", + "tx", + MetricsValueKind::Number, + &rows, + |row| Some(row.transaction_count as f64), + ), + metrics_chart( + "burn-count", + "Burn transactions", + "burns", + MetricsValueKind::Number, + &rows, + |row| Some(row.burn_count as f64), + ), + metrics_chart( + "burn-amount", + "Burn amount", + "IUNA", + MetricsValueKind::Iuna, + &rows, + |row| Some(micro_iuna_as_iuna(row.burned_amount)), + ), + metrics_chart( + "total-burn", + "Total burn", + "IUNA", + MetricsValueKind::Iuna, + &rows, + |row| Some(micro_iuna_as_iuna(row.total_burned_amount)), + ), + metrics_chart( + "fees", + "Fees", + "IUNA", + MetricsValueKind::Iuna, + &rows, + |row| Some(micro_iuna_as_iuna(row.fees_amount)), + ), + metrics_chart( + "mine-actions", + "Mine actions", + "mine", + MetricsValueKind::Number, + &rows, + |row| Some(row.mine_count as f64), + ), + metrics_chart( + "vdf-rounds", + "VDF rounds", + "rounds", + MetricsValueKind::Number, + &rows, + |row| (row.vdf_rounds > 0).then_some(row.vdf_rounds as f64), + ), + ], + } +} + +fn metrics_chart( + id: &'static str, + title: &'static str, + unit: &'static str, + value_kind: MetricsValueKind, + rows: &[BlockMetricRow], + value: impl Fn(&BlockMetricRow) -> Option<f64>, +) -> MetricsChart { + MetricsChart { + id, + title, + unit, + value_kind, + points: rows + .iter() + .filter_map(|row| { + value(row).map(|value| MetricsPoint { + height: row.height, + value, + }) + }) + .collect(), + } +} + +fn micro_iuna_as_iuna(amount: Amount) -> f64 { + amount as f64 / 1_000_000.0 +} + +pub(super) fn network_health_at( + status: &NodeStatus, + peers: &[PeerInfo], + mempool: MempoolCounts, + now_ms: u64, +) -> NetworkHealthResponse { + let local_height = status.chain.height; + let remote_best_height = peers.iter().filter_map(|peer| peer.last_known_height).max(); + let best_known_height = remote_best_height.unwrap_or(local_height).max(local_height); + let healthy_heights = peers + .iter() + .filter(|peer| peer.last_error.is_none()) + .filter_map(|peer| peer.last_known_height) + .collect::<Vec<_>>(); + let shared_height = healthy_heights + .iter() + .copied() + .min() + .unwrap_or(local_height) + .min(local_height); + let outbound_peers = peers + .iter() + .filter(|peer| peer.direction != PeerDirection::Inbound) + .count(); + let inbound_peers = peers + .iter() + .filter(|peer| peer.direction == PeerDirection::Inbound) + .count(); + let healthy_peers = peers + .iter() + .filter(|peer| peer.last_error.is_none() && peer.last_known_height.is_some()) + .count(); + let failed_peers = peers + .iter() + .filter(|peer| peer.last_error.is_some()) + .count(); + let stale_peers = peers + .iter() + .filter(|peer| { + peer.last_success_ms.is_some_and(|last_success| { + now_ms.saturating_sub(last_success) > PEER_STALE_AFTER_MS + }) + }) + .count(); + let banned_peers = peers + .iter() + .filter(|peer| peer.is_banned_at(now_ms)) + .count(); + let network_time_offset_ms = median_peer_clock_offset(peers, now_ms); + let bad_clock_peers = peers + .iter() + .filter(|peer| { + peer.last_clock_observed_ms.is_some_and(|observed_ms| { + now_ms.saturating_sub(observed_ms) <= PEER_STALE_AFTER_MS + }) + }) + .filter(|peer| peer.last_clock_offset_accepted == Some(false)) + .count(); + let lag_blocks = best_known_height.saturating_sub(local_height); + let last_error = peers.iter().rev().find_map(|peer| { + peer.last_error + .as_ref() + .map(|error| format!("{}: {error}", peer.address)) + }); + + let state = if peers.is_empty() { + "isolated" + } else if banned_peers > 0 && healthy_peers == 0 { + "banned" + } else if lag_blocks > 0 { + "syncing" + } else if failed_peers > 0 && healthy_peers == 0 { + "peer errors" + } else if stale_peers > 0 && healthy_peers == stale_peers { + "stale" + } else if remote_best_height.is_some_and(|height| local_height > height) { + "ahead of peers" + } else { + "healthy" + } + .to_string(); + + NetworkHealthResponse { + ok: !peers.is_empty() && lag_blocks == 0 && healthy_peers > stale_peers, + state, + local_height, + best_known_height, + shared_height, + lag_blocks, + outbound_peers, + inbound_peers, + healthy_peers, + failed_peers, + stale_peers, + banned_peers, + pending_transactions: status.chain.pending_transactions, + pending_plain_transactions: mempool.plain_transactions, + pending_blinded_transactions: mempool.blinded_transactions, + pending_blinded_reveals: mempool.blinded_reveals, + network_time_offset_ms, + bad_clock_peers, + last_error, + } +} + +fn median_peer_clock_offset(peers: &[PeerInfo], now_ms: u64) -> Option<i64> { + let mut offsets = peers + .iter() + .filter(|peer| peer.last_error.is_none()) + .filter(|peer| !peer.is_banned_at(now_ms)) + .filter(|peer| peer.last_clock_offset_accepted == Some(true)) + .filter(|peer| { + peer.last_clock_observed_ms.is_some_and(|observed_ms| { + now_ms.saturating_sub(observed_ms) <= PEER_STALE_AFTER_MS + }) + }) + .filter_map(|peer| peer.last_clock_offset_ms) + .collect::<Vec<_>>(); + if offsets.is_empty() { + return None; + } + offsets.sort_unstable(); + Some(offsets[offsets.len() / 2]) +} diff --git a/src/adapters/http/request_auth.rs b/src/adapters/http/request_auth.rs @@ -0,0 +1,316 @@ +use std::net::SocketAddr; + +use anyhow::{Context, Result, bail}; +use axum::http::{HeaderMap, Method, header}; + +use crate::{ + adapters::{config_store, wallet_store}, + domain::Wallet, +}; + +use super::{ + AUTH_COOKIE_NAME, AUTH_LOCKOUT_MS, AUTH_MAX_FAILED_ATTEMPTS, AUTH_SESSION_TTL_MS, AuthSession, + HttpState, UNKNOWN_CLIENT_KEY, + auth::{hash_password, random_hex, session_token_hash, validate_password, verify_password}, + now_ms, +}; + +pub(super) fn auth_exempt_path(path: &str) -> bool { + path == "/" + || path == "/favicon.ico" + || path == "/assets/alpine.min.js" + || path == "/assets/iuna-ui.js" + || path == "/api/auth/status" + || path == "/api/auth/setup" + || path == "/api/auth/login" +} + +pub(super) fn csrf_required(method: &Method) -> bool { + !matches!(method, &Method::GET | &Method::HEAD | &Method::OPTIONS) +} + +pub(super) fn same_origin_request(headers: &HeaderMap) -> bool { + let Some(request_host) = request_host(headers) else { + return false; + }; + let Some(origin_host) = origin_or_referer_host(headers) else { + return false; + }; + normalize_host(&origin_host) == normalize_host(&request_host) +} + +fn request_host(headers: &HeaderMap) -> Option<String> { + header_string(headers, "x-forwarded-host").or_else(|| header_string(headers, "host")) +} + +fn origin_or_referer_host(headers: &HeaderMap) -> Option<String> { + header_string(headers, "origin") + .and_then(|origin| url_host(&origin)) + .or_else(|| header_string(headers, "referer").and_then(|referer| url_host(&referer))) +} + +fn header_string(headers: &HeaderMap, name: &'static str) -> Option<String> { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn url_host(value: &str) -> Option<String> { + let (_, rest) = value.split_once("://")?; + rest.split(['/', '?', '#']) + .next() + .map(str::trim) + .filter(|authority| !authority.is_empty() && *authority != "null") + .map(|authority| { + authority + .rsplit('@') + .next() + .unwrap_or(authority) + .to_string() + }) +} + +fn normalize_host(host: &str) -> String { + host.trim().trim_end_matches('.').to_ascii_lowercase() +} + +pub(super) async fn request_is_authenticated(state: &HttpState, headers: &HeaderMap) -> bool { + let Some(token) = auth_cookie(headers) else { + return false; + }; + let token_hash = session_token_hash(token); + let now = now_ms(); + let mut sessions = state.auth_sessions.lock().await; + sessions.retain(|_, session| session.expires_at > now); + sessions + .get(&token_hash) + .is_some_and(|session| session.expires_at > now) +} + +pub(super) async fn wallet_password_for_request( + state: &HttpState, + headers: &HeaderMap, +) -> Option<String> { + let token = auth_cookie(headers)?; + let token_hash = session_token_hash(token); + let now = now_ms(); + let mut sessions = state.auth_sessions.lock().await; + sessions.retain(|_, session| session.expires_at > now); + sessions + .get(&token_hash) + .filter(|session| session.expires_at > now) + .map(|session| session.wallet_password.clone()) +} + +pub(super) fn auth_client_key(headers: &HeaderMap, socket_addr: Option<SocketAddr>) -> String { + if let Some(addr) = socket_addr { + if !trusted_forwarding_peer(addr.ip()) { + return addr.ip().to_string(); + } + } + forwarded_for_client(headers) + .or_else(|| header_string(headers, "x-real-ip")) + .or_else(|| forwarded_header_client(headers)) + .or_else(|| socket_addr.map(|addr| addr.ip().to_string())) + .unwrap_or_else(|| UNKNOWN_CLIENT_KEY.to_string()) +} + +fn trusted_forwarding_peer(ip: std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), + std::net::IpAddr::V6(ip) => { + ip.is_loopback() || ipv6_is_unique_local(ip) || ipv6_is_unicast_link_local(ip) + } + } +} + +fn ipv6_is_unique_local(ip: std::net::Ipv6Addr) -> bool { + (ip.segments()[0] & 0xfe00) == 0xfc00 +} + +fn ipv6_is_unicast_link_local(ip: std::net::Ipv6Addr) -> bool { + (ip.segments()[0] & 0xffc0) == 0xfe80 +} + +fn forwarded_for_client(headers: &HeaderMap) -> Option<String> { + header_string(headers, "x-forwarded-for").and_then(|value| { + value + .split(',') + .next() + .map(str::trim) + .filter(|client| !client.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn forwarded_header_client(headers: &HeaderMap) -> Option<String> { + let value = header_string(headers, "forwarded")?; + for item in value.split(';') { + let Some((name, value)) = item.split_once('=') else { + continue; + }; + if name.trim().eq_ignore_ascii_case("for") { + return Some( + value + .trim() + .trim_matches('"') + .trim_matches('[') + .trim_matches(']') + .to_string(), + ) + .filter(|client| !client.is_empty()); + } + } + None +} + +pub(super) async fn setup_auth_password( + state: &HttpState, + password: &str, + client_key: &str, +) -> Result<String> { + check_auth_backoff(state, client_key).await?; + if let Err(error) = validate_password(password) { + record_auth_failure(state, client_key).await; + return Err(error); + } + let mut config = state.ui_config.lock().await; + if config.auth_password_hash.is_some() { + record_auth_failure(state, client_key).await; + bail!("authentication is already configured"); + } + config.auth_password_hash = Some(hash_password(password)?); + config_store::save(&state.config_path, &config)?; + drop(config); + wallet_store::encrypt_existing_with_password(&state.wallet_path, password)?; + let wallet = wallet_store::load_with_password(&state.wallet_path, password)?; + restore_node_wallet_from_store(state, wallet, Some(password)).await?; + clear_auth_backoff(state, client_key).await; + create_session_cookie(state, password).await +} + +pub(super) async fn login_auth_password( + state: &HttpState, + password: &str, + client_key: &str, +) -> Result<String> { + check_auth_backoff(state, client_key).await?; + let hash = state + .ui_config + .lock() + .await + .auth_password_hash + .clone() + .context("authentication setup is required")?; + if !verify_password(password, &hash)? { + record_auth_failure(state, client_key).await; + bail!("invalid password"); + } + wallet_store::encrypt_existing_with_password(&state.wallet_path, password)?; + let wallet = wallet_store::load_with_password(&state.wallet_path, password)?; + restore_node_wallet_from_store(state, wallet, Some(password)).await?; + clear_auth_backoff(state, client_key).await; + create_session_cookie(state, password).await +} + +pub(super) async fn change_auth_password( + state: &HttpState, + old_password: &str, + new_password: &str, + client_key: &str, +) -> Result<String> { + check_auth_backoff(state, client_key).await?; + validate_password(new_password)?; + let current_hash = state + .ui_config + .lock() + .await + .auth_password_hash + .clone() + .context("authentication setup is required")?; + if !verify_password(old_password, &current_hash)? { + record_auth_failure(state, client_key).await; + bail!("invalid current password"); + } + let wallet = + wallet_store::reencrypt_with_password(&state.wallet_path, old_password, new_password)?; + { + let mut config = state.ui_config.lock().await; + config.auth_password_hash = Some(hash_password(new_password)?); + config_store::save(&state.config_path, &config)?; + } + restore_node_wallet_from_store(state, wallet, Some(new_password)).await?; + state.auth_sessions.lock().await.clear(); + clear_auth_backoff(state, client_key).await; + create_session_cookie(state, new_password).await +} + +pub(super) async fn restore_node_wallet_from_store( + state: &HttpState, + wallet: Wallet, + password: Option<&str>, +) -> Result<()> { + let owned_blinded_transactions = + wallet_store::load_owned_blinded_transactions(&state.wallet_path, password)?; + let mut node = state.node.lock().await; + node.replace_wallet(wallet); + node.restore_owned_blinded_transactions(owned_blinded_transactions) +} + +async fn check_auth_backoff(state: &HttpState, client_key: &str) -> Result<()> { + let now = now_ms(); + let mut backoffs = state.auth_backoff.lock().await; + let backoff = backoffs.entry(client_key.to_string()).or_default(); + if backoff + .locked_until_ms + .is_some_and(|locked_until| locked_until > now) + { + bail!("too many failed login attempts; try again later"); + } + if backoff.locked_until_ms.is_some() { + backoff.locked_until_ms = None; + backoff.failed_attempts = 0; + } + Ok(()) +} + +async fn record_auth_failure(state: &HttpState, client_key: &str) { + let mut backoffs = state.auth_backoff.lock().await; + let backoff = backoffs.entry(client_key.to_string()).or_default(); + backoff.failed_attempts = backoff.failed_attempts.saturating_add(1); + if backoff.failed_attempts >= AUTH_MAX_FAILED_ATTEMPTS { + backoff.locked_until_ms = Some(now_ms().saturating_add(AUTH_LOCKOUT_MS)); + } +} + +async fn clear_auth_backoff(state: &HttpState, client_key: &str) { + state.auth_backoff.lock().await.remove(client_key); +} + +async fn create_session_cookie(state: &HttpState, password: &str) -> Result<String> { + let token = random_hex(32)?; + let token_hash = session_token_hash(&token); + let expires_at = now_ms().saturating_add(AUTH_SESSION_TTL_MS); + state.auth_sessions.lock().await.insert( + token_hash, + AuthSession { + expires_at, + wallet_password: password.to_string(), + }, + ); + Ok(format!( + "{AUTH_COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}", + AUTH_SESSION_TTL_MS / 1000 + )) +} + +pub(super) fn auth_cookie(headers: &HeaderMap) -> Option<&str> { + let cookie = headers.get(header::COOKIE)?.to_str().ok()?; + cookie.split(';').find_map(|part| { + let (name, value) = part.trim().split_once('=')?; + (name == AUTH_COOKIE_NAME).then_some(value) + }) +} diff --git a/src/adapters/http/state.rs b/src/adapters/http/state.rs @@ -0,0 +1,60 @@ +use std::{collections::BTreeMap, net::SocketAddr, path::PathBuf, sync::Arc}; + +use tokio::sync::Mutex; + +use crate::{ + adapters::{chain_store::SqliteChainStore, config_store::UiConfig, p2p::GossipNetwork}, + app::{SharedNode, SharedPeerBook, StratumStatus}, + domain::{OutPoint, RevealedBlindedTransaction, TxOutput}, +}; + +#[derive(Clone)] +pub(super) struct HttpState { + pub(super) node: SharedNode, + pub(super) peers: SharedPeerBook, + pub(super) gossip: GossipNetwork, + pub(super) ui_config: Arc<Mutex<UiConfig>>, + pub(super) config_path: PathBuf, + pub(super) chain_store: SqliteChainStore, + pub(super) wallet_path: PathBuf, + pub(super) stratum: StratumStatus, + pub(super) auth_sessions: Arc<Mutex<BTreeMap<String, AuthSession>>>, + pub(super) auth_backoff: Arc<Mutex<BTreeMap<String, AuthBackoff>>>, + pub(super) ui_cache: Arc<Mutex<UiChainCache>>, +} + +#[derive(Clone)] +pub(super) struct AuthSession { + pub(super) expires_at: u64, + pub(super) wallet_password: String, +} + +#[derive(Clone, Debug)] +pub(super) struct AuthClientKey(pub(super) String); + +#[derive(Clone, Debug, Default)] +pub(super) struct AuthBackoff { + pub(super) failed_attempts: u32, + pub(super) locked_until_ms: Option<u64>, +} + +#[derive(Clone, Debug, Default)] +pub(super) struct UiChainCache { + pub(super) tip_hash: Option<String>, + pub(super) outputs: BTreeMap<OutPoint, TxOutput>, + pub(super) revealed_by_height: BTreeMap<u64, Vec<RevealedBlindedTransaction>>, +} + +#[derive(Clone, Debug)] +pub(super) struct UiChainView { + pub(super) outputs: BTreeMap<OutPoint, TxOutput>, + pub(super) revealed_by_height: BTreeMap<u64, Vec<RevealedBlindedTransaction>>, +} + +pub struct ServeOptions { + pub config_path: PathBuf, + pub chain_store: SqliteChainStore, + pub wallet_path: PathBuf, + pub stratum: StratumStatus, + pub addr: SocketAddr, +} diff --git a/src/adapters/http/static_assets.rs b/src/adapters/http/static_assets.rs @@ -0,0 +1,37 @@ +use axum::{ + http::{StatusCode, header}, + response::{Html, IntoResponse}, +}; + +use super::INDEX_HTML; + +pub(super) async fn index() -> Html<&'static str> { + Html(INDEX_HTML) +} + +pub(super) async fn favicon() -> impl IntoResponse { + ( + StatusCode::NO_CONTENT, + [(header::CACHE_CONTROL, "public, max-age=86400")], + ) +} + +pub(super) async fn alpine_js() -> impl IntoResponse { + ( + [( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + )], + include_str!("../../../www/assets/alpine.min.js"), + ) +} + +pub(super) async fn app_js() -> impl IntoResponse { + ( + [( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + )], + include_str!("../../../www/assets/iuna-ui.js"), + ) +} diff --git a/src/adapters/http/tests.rs b/src/adapters/http/tests.rs @@ -0,0 +1,1972 @@ +use std::{collections::BTreeMap, sync::Arc}; + +use axum::{ + Router, + body::{Body, to_bytes}, + http::{HeaderMap, Method, Request, StatusCode, header}, + middleware, + routing::{get, post}, +}; +use tokio::sync::Mutex; +use tower::ServiceExt; + +use crate::{ + adapters::{ + chain_store::SqliteChainStore, config_store, config_store::UiConfig, p2p::GossipNetwork, + wallet_store, + }, + app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus}, + domain::{ + Amount, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, GenesisBurn, + LaunchProfile, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MaskedBlindedReveal, OutPoint, + RevealBundleSection, RevealBundleSignature, Transaction, TxInput, TxOutput, Wallet, + }, +}; + +use super::{ + AUTH_COOKIE_NAME, HttpState, PEER_STALE_AFTER_MS, TransferForm, WalletTransactionFilters, + WalletTransactionsQuery, api_auth_change_password_form, api_auth_login_form, + api_auth_setup_form, api_auth_status, auth_client_key, dev_seed_verify_bypass_allowed, + hash_password, hex_encode, pbkdf2_sha256, persist_burn_settings_config, + persist_pow_mining_config, require_auth_middleware, required_fee_per_byte_burn, + same_origin_request, validate_password, validate_transfer_form, verify_password, + wallet_transaction_rows, wallet_utxo_rows, +}; + +#[test] +fn dev_seed_verify_bypass_requires_env_flag() { + assert!(dev_seed_verify_bypass_allowed(true)); + assert!(!dev_seed_verify_bypass_allowed(false)); +} + +#[test] +fn mempool_ui_items_can_represent_blinded_transactions_and_reveals() { + let commitment = "a".repeat(64); + let owner = "c".repeat(64); + let input_outpoint = OutPoint { + txid: "d".repeat(64), + index: 0, + }; + let blinded = BlindedTransaction { + commitment: commitment.clone(), + inputs: vec![TxInput { + outpoint: input_outpoint.clone(), + owner: owner.clone(), + signature: "e".repeat(128), + }], + fee: 7, + encrypted_size: 123, + expires_at_height: 42, + nonce: "00".repeat(12), + ciphertext: "11".repeat(123), + payload_hash: "b".repeat(64), + }; + let reveal = BlindedReveal { + commitment: commitment.clone(), + key: "22".repeat(32), + }; + let outputs = BTreeMap::from([( + input_outpoint.clone(), + TxOutput { + address: owner.clone(), + amount: 99, + }, + )]); + + let blinded_row = super::ui_blinded_transaction(&blinded, &outputs); + let reveal_row = super::ui_blinded_reveal(&reveal); + let revealed_row = super::ui_pending_revealed_transaction( + &crate::domain::RevealedBlindedTransaction { + height: 2, + commitment: reveal.commitment.clone(), + included_by: owner.clone(), + transaction: Transaction::Burn { + inputs: blinded.inputs.clone(), + change: Vec::new(), + amount: 12, + fee: blinded.fee, + signature: "f".repeat(128), + }, + }, + &outputs, + ); + + assert_eq!(blinded_row.kind, "blinded"); + assert_eq!(blinded_row.from, owner); + assert_eq!(blinded_row.inputs.len(), 1); + assert_eq!(blinded_row.inputs[0].amount, Some(99)); + assert_eq!(blinded_row.signature, commitment); + assert_eq!(blinded_row.encrypted_size, Some(123)); + assert_eq!(blinded_row.expires_at_height, Some(42)); + assert_eq!(reveal_row.kind, "reveal"); + assert_eq!(reveal_row.commitment, blinded_row.commitment); + assert_eq!(revealed_row.kind, "burn"); + assert!(revealed_row.revealed); + assert_eq!(revealed_row.commitment, Some(reveal.commitment)); + assert_eq!(revealed_row.amount, 12); + assert_eq!(revealed_row.fee, 7); + assert_eq!(revealed_row.inputs[0].amount, Some(99)); +} + +#[test] +fn block_detail_transactions_include_blinded_and_revealed_items() { + let alice = Wallet::from_seed("block-detail-blind-alice"); + let bob = Wallet::from_seed("block-detail-blind-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 100); + allocations.insert(bob.address().to_string(), 100); + let ledger = Ledger::new(allocations.clone(), 1); + let transfer = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap(); + let built = ledger + .build_blinded_transaction(&alice, transfer.clone(), 20) + .unwrap(); + let mut block = fake_block(7, Vec::new()); + block.blinded_transactions = vec![built.transaction.clone()]; + + let revealed = crate::domain::RevealedBlindedTransaction { + height: 7, + commitment: built.transaction.commitment.clone(), + included_by: "miner".to_string(), + transaction: transfer, + }; + let ui_block = super::ui_block(block, &BTreeMap::new(), &BTreeMap::new(), &[revealed]); + + assert_eq!(ui_block.transactions.len(), 2); + assert_eq!(ui_block.transactions[0].kind, "blinded"); + assert_eq!( + ui_block.transactions[0].commitment.as_deref(), + Some(built.transaction.commitment.as_str()) + ); + assert!(!ui_block.transactions[0].revealed); + assert_eq!(ui_block.transactions[1].kind, "transfer"); + assert!(ui_block.transactions[1].revealed); + assert_eq!(ui_block.revealed_transactions.len(), 1); + assert!(ui_block.revealed_transactions[0].revealed); + assert!(ui_block.total_bytes > 0); + assert!(ui_block.blinded_transaction_bytes > 0); + assert_eq!(ui_block.reveal_bundle_bytes, 0); + + let burn = ledger.build_burn(&alice, 5, 1).unwrap(); + let mine = Transaction::Mine { + recipient: bob.address().to_string(), + anchor: "a".repeat(64), + salt: 1, + nonce: 2, + difficulty_bits: 12, + proof_header: None, + signature: "b".repeat(64), + }; + let typed_block = super::ui_block( + fake_block( + 8, + vec![ + ledger.build_transfer(&alice, bob.address(), 7, 1).unwrap(), + burn, + mine, + ], + ), + &BTreeMap::new(), + &BTreeMap::new(), + &[], + ); + let typed_bytes = typed_block + .transaction_byte_breakdown + .iter() + .map(|row| (row.label, row.bytes)) + .collect::<BTreeMap<_, _>>(); + assert!(typed_bytes["transfer"] > 0); + assert!(typed_bytes["burn"] > 0); + assert!(typed_bytes["mine"] > 0); +} + +#[test] +fn block_detail_reconstructs_revealed_items_from_snapshot_blocks() { + let alice = Wallet::from_seed("block-detail-reveal-alice"); + let bob = Wallet::from_seed("block-detail-reveal-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 100); + allocations.insert(bob.address().to_string(), 100); + let ledger = Ledger::new(allocations.clone(), 1); + let transfer = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap(); + let built = ledger + .build_blinded_transaction(&alice, transfer.clone(), 20) + .unwrap(); + let mut commit_block = fake_block(7, Vec::new()); + commit_block.blinded_transactions = vec![built.transaction.clone()]; + let mut reveal_block = fake_block(8, Vec::new()); + reveal_block.reveal_bundle_section = RevealBundleSection { + signatures: vec![RevealBundleSignature { + slot: 0, + member: reveal_block.miner.clone(), + signature: "11".repeat(64), + }], + reveals: vec![MaskedBlindedReveal { + reveal: built.reveal.clone(), + bundle_mask: 1, + }], + }; + let snapshot = fake_snapshot( + allocations, + vec![commit_block.clone(), reveal_block.clone()], + ); + + let blocks = super::ui_blocks( + vec![commit_block, reveal_block], + &snapshot, + &[], + &BTreeMap::new(), + ); + + assert_eq!(blocks[0].transactions.len(), 1); + assert_eq!(blocks[0].transactions[0].kind, "blinded"); + assert_eq!( + blocks[0].transactions[0].commitment.as_deref(), + Some(built.transaction.commitment.as_str()) + ); + assert_eq!(blocks[1].transactions.len(), 1); + assert_eq!(blocks[1].transactions[0].kind, "transfer"); + assert!(blocks[1].transactions[0].revealed); + assert_eq!(blocks[1].transactions[0].amount, transfer.amount()); + assert_eq!(blocks[1].transactions[0].to.as_deref(), Some(bob.address())); + assert_eq!(blocks[1].revealed_transactions.len(), 1); +} + +#[test] +fn block_detail_markup_uses_blinded_and_revealed_labels() { + let app_js = include_str!("../../../www/assets/iuna-ui.js"); + + assert!(super::INDEX_HTML.contains("txPillLabel(tx)")); + assert!(super::INDEX_HTML.contains("commitCountLabel(block)")); + assert!(super::INDEX_HTML.contains(".pill.blinded")); + assert!(super::INDEX_HTML.contains(".pill.reveal, .pill.revealed")); + assert!(super::INDEX_HTML.contains(":class=\"mempoolItemClass(tx)\"")); + assert!(super::INDEX_HTML.contains(".mempool-item.before-last-block")); + assert!(super::INDEX_HTML.contains(":class=\"row[2]\"")); + assert!(super::INDEX_HTML.contains("New since last block")); + assert!(super::INDEX_HTML.contains("class=\"mempool-top\"")); + assert!(super::INDEX_HTML.contains("mempoolSeenTimeLabel(tx)")); + assert!(super::INDEX_HTML.contains("<details class=\"tx-section\">")); + assert!(super::INDEX_HTML.contains("<summary class=\"tx-section-title\">")); + assert!(super::INDEX_HTML.contains("Commitment")); + assert!(!super::INDEX_HTML.contains("<h3>Revealed</h3>")); + assert!(app_js.contains("tx?.revealed ? \"revealed\"")); + assert!(app_js.contains("transactions.some((tx) => tx?.revealed)")); + assert!(app_js.contains("blockCommitCount(block)")); + assert!(app_js.contains("blockTransactionByteBreakdown(block)")); + assert!(app_js.contains("[label, Number(row.bytes ?? 0), label]")); +} + +#[test] +fn password_policy_rejects_short_or_excessive_passwords() { + let short = validate_password("too-short").unwrap_err(); + assert!(short.to_string().contains("at least 12")); + + let long_password = "x".repeat(1025); + let long = validate_password(&long_password).unwrap_err(); + assert!(long.to_string().contains("too long")); + + validate_password("correct horse battery staple").unwrap(); +} + +#[test] +fn password_hash_round_trips_without_storing_plaintext() { + let password = "correct horse battery staple"; + let encoded = hash_password(password).unwrap(); + + assert!(!encoded.contains(password)); + assert!(verify_password(password, &encoded).unwrap()); + assert!(!verify_password("wrong horse battery staple", &encoded).unwrap()); +} + +#[test] +fn pbkdf2_sha256_matches_known_vectors() { + let one_iteration = pbkdf2_sha256(b"password", b"salt", 1); + assert_eq!( + hex_encode(one_iteration), + "120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b" + ); + + let two_iterations = pbkdf2_sha256(b"password", b"salt", 2); + assert_eq!( + hex_encode(two_iterations), + "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43" + ); +} + +#[test] +fn same_origin_check_accepts_forwarded_host_and_rejects_cross_site_origin() { + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, "127.0.0.1:18661".parse().unwrap()); + headers.insert("x-forwarded-host", "iuna.example".parse().unwrap()); + headers.insert(header::ORIGIN, "https://iuna.example".parse().unwrap()); + assert!(same_origin_request(&headers)); + + headers.insert(header::ORIGIN, "https://evil.example".parse().unwrap()); + assert!(!same_origin_request(&headers)); +} + +#[test] +fn auth_client_key_trusts_forwarded_headers_only_from_private_or_local_peers() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", "198.51.100.99".parse().unwrap()); + let socket = Some("203.0.113.10:51234".parse().unwrap()); + + assert_eq!(auth_client_key(&headers, socket), "203.0.113.10"); + assert_eq!( + auth_client_key(&headers, Some("127.0.0.1:51234".parse().unwrap())), + "198.51.100.99" + ); + assert_eq!( + auth_client_key(&headers, Some("10.42.1.12:51234".parse().unwrap())), + "198.51.100.99" + ); + assert_eq!( + auth_client_key(&headers, Some("172.20.4.8:51234".parse().unwrap())), + "198.51.100.99" + ); + assert_eq!(auth_client_key(&headers, None), "198.51.100.99"); +} + +#[tokio::test] +async fn protected_endpoints_require_authentication_setup() { + let dir = tempfile::tempdir().unwrap(); + let state = auth_test_state( + dir.path().join("config.json"), + UiConfig { + auth_password_hash: None, + ..UiConfig::default() + }, + ) + .await; + let app = auth_test_app(state); + + let protected = http_request(app.clone(), Method::GET, "/api/protected", None, "").await; + assert_eq!(protected.status, StatusCode::UNAUTHORIZED); + assert!(protected.body.contains("authentication setup is required")); + + let status = http_request(app, Method::GET, "/api/auth/status", None, "").await; + assert_eq!(status.status, StatusCode::OK); + assert!(status.body.contains("\"configured\":false")); +} + +#[tokio::test] +async fn favicon_is_public_before_authentication_setup() { + let dir = tempfile::tempdir().unwrap(); + let state = auth_test_state( + dir.path().join("config.json"), + UiConfig { + auth_password_hash: None, + ..UiConfig::default() + }, + ) + .await; + let app = auth_test_app(state); + + let response = http_request(app, Method::GET, "/favicon.ico", None, "").await; + + assert_eq!(response.status, StatusCode::NO_CONTENT); +} + +#[tokio::test] +async fn protected_endpoints_require_valid_session_after_authentication_setup() { + let dir = tempfile::tempdir().unwrap(); + let password = "correct horse battery staple"; + let state = auth_test_state( + dir.path().join("config.json"), + UiConfig { + auth_password_hash: Some(hash_password(password).unwrap()), + ..UiConfig::default() + }, + ) + .await; + let app = auth_test_app(state); + + let missing_cookie = http_request(app.clone(), Method::GET, "/api/protected", None, "").await; + assert_eq!(missing_cookie.status, StatusCode::UNAUTHORIZED); + assert!(missing_cookie.body.contains("authentication required")); + + let bad_cookie = http_request( + app.clone(), + Method::GET, + "/api/protected", + Some("iuna_session=bogus"), + "", + ) + .await; + assert_eq!(bad_cookie.status, StatusCode::UNAUTHORIZED); + + let login = http_request( + app.clone(), + Method::POST, + "/api/auth/login", + None, + "password=correct+horse+battery+staple", + ) + .await; + assert_eq!(login.status, StatusCode::OK); + assert!(login.body.contains("\"ok\":true")); + let cookie = set_cookie_pair(&login.headers); + assert!(cookie.starts_with(AUTH_COOKIE_NAME)); + + let protected = http_request(app, Method::GET, "/api/protected", Some(&cookie), "").await; + assert_eq!(protected.status, StatusCode::OK); + assert_eq!(protected.body, "protected"); +} + +#[tokio::test] +async fn auth_posts_require_same_origin_headers() { + let dir = tempfile::tempdir().unwrap(); + let password = "correct horse battery staple"; + let state = auth_test_state( + dir.path().join("config.json"), + UiConfig { + auth_password_hash: Some(hash_password(password).unwrap()), + ..UiConfig::default() + }, + ) + .await; + let app = auth_test_app(state); + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/login") + .header(header::HOST, "127.0.0.1:18661") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from("password=correct+horse+battery+staple")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = String::from_utf8( + to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(), + ) + .unwrap(); + assert!(body.contains("same-origin request required")); +} + +#[tokio::test] +async fn login_authentication_locks_out_after_repeated_failures() { + let dir = tempfile::tempdir().unwrap(); + let password = "correct horse battery staple"; + let state = auth_test_state( + dir.path().join("config.json"), + UiConfig { + auth_password_hash: Some(hash_password(password).unwrap()), + ..UiConfig::default() + }, + ) + .await; + let client_a = "198.51.100.10"; + let client_b = "198.51.100.11"; + + for _ in 0..super::AUTH_MAX_FAILED_ATTEMPTS { + let error = super::login_auth_password(&state, "wrong horse battery staple", client_a) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("invalid password")); + } + + let locked = super::login_auth_password(&state, password, client_a) + .await + .unwrap_err(); + assert!(format!("{locked:#}").contains("too many failed login attempts")); + + let other_client_cookie = super::login_auth_password(&state, password, client_b) + .await + .unwrap(); + assert!(other_client_cookie.starts_with(AUTH_COOKIE_NAME)); + + state + .auth_backoff + .lock() + .await + .get_mut(client_a) + .unwrap() + .locked_until_ms = Some(crate::app::now_ms().saturating_sub(1)); + let cookie = super::login_auth_password(&state, password, client_a) + .await + .unwrap(); + assert!(cookie.starts_with(AUTH_COOKIE_NAME)); + assert!(!state.auth_backoff.lock().await.contains_key(client_a)); +} + +#[tokio::test] +async fn password_setup_creates_session_for_protected_endpoints() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let state = auth_test_state(config_path.clone(), UiConfig::default()).await; + let app = auth_test_app(state); + + let setup = http_request( + app.clone(), + Method::POST, + "/api/auth/setup", + None, + "password=correct+horse+battery+staple", + ) + .await; + assert_eq!(setup.status, StatusCode::OK); + assert!(setup.body.contains("\"ok\":true")); + let cookie = set_cookie_pair(&setup.headers); + + let stored = config_store::load_or_create(&config_path).unwrap(); + assert!(stored.auth_password_hash.is_some()); + let protected = http_request(app, Method::GET, "/api/protected", Some(&cookie), "").await; + assert_eq!(protected.status, StatusCode::OK); + assert_eq!(protected.body, "protected"); +} + +#[tokio::test] +async fn password_change_reencrypts_wallet_and_replaces_login_password() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let wallet_path = config_path.with_file_name("wallet.json"); + let old_password = "correct horse battery staple"; + let new_password = "new correct battery staple"; + let state = auth_test_state( + config_path.clone(), + UiConfig { + auth_password_hash: Some(hash_password(old_password).unwrap()), + setup_complete: true, + ..UiConfig::default() + }, + ) + .await; + wallet_store::encrypt_existing_with_password(&wallet_path, old_password).unwrap(); + let app = auth_test_app(state); + + let login = http_request( + app.clone(), + Method::POST, + "/api/auth/login", + None, + "password=correct+horse+battery+staple", + ) + .await; + assert_eq!(login.status, StatusCode::OK); + let cookie = set_cookie_pair(&login.headers); + + let change = http_request( + app.clone(), + Method::POST, + "/api/auth/change-password", + Some(&cookie), + "old_password=correct+horse+battery+staple&new_password=new+correct+battery+staple", + ) + .await; + assert_eq!(change.status, StatusCode::OK); + assert!(change.body.contains("\"ok\":true")); + + let stored = config_store::load_or_create(&config_path).unwrap(); + let stored_hash = stored.auth_password_hash.unwrap(); + assert!(!verify_password(old_password, &stored_hash).unwrap()); + assert!(verify_password(new_password, &stored_hash).unwrap()); + assert!(wallet_store::load_with_password(&wallet_path, old_password).is_err()); + assert!(wallet_store::load_with_password(&wallet_path, new_password).is_ok()); + + let old_login = http_request( + app.clone(), + Method::POST, + "/api/auth/login", + None, + "password=correct+horse+battery+staple", + ) + .await; + assert!(old_login.body.contains("invalid password")); + + let new_login = http_request( + app, + Method::POST, + "/api/auth/login", + None, + "password=new+correct+battery+staple", + ) + .await; + assert_eq!(new_login.status, StatusCode::OK); +} + +#[tokio::test] +async fn peer_management_updates_config_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let state = auth_test_state( + config_path.clone(), + UiConfig { + setup_complete: true, + ..UiConfig::default() + }, + ) + .await; + + super::add_peer(&state, " 127.0.0.1:9445 ".to_string()) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert_eq!(config.peers, vec!["127.0.0.1:9445"]); + assert_eq!(state.peers.lock().await.addresses(), vec!["127.0.0.1:9445"]); + + super::remove_peer(&state, "127.0.0.1:9445".to_string()) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(config.peers.is_empty()); + assert!(state.peers.lock().await.addresses().is_empty()); +} + +#[tokio::test] +async fn address_book_updates_config_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let state = auth_test_state( + config_path.clone(), + UiConfig { + setup_complete: true, + ..UiConfig::default() + }, + ) + .await; + let alice = Wallet::from_seed("address-book-alice"); + let bob = Wallet::from_seed("address-book-bob"); + + super::upsert_address_book_entry( + &state, + format!(" {} ", alice.address().to_ascii_uppercase()), + " Alice ".to_string(), + None, + ) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert_eq!( + config.address_book.get(alice.address()), + Some(&"Alice".to_string()) + ); + + super::upsert_address_book_entry( + &state, + alice.address().to_string(), + "Alice Prime".to_string(), + Some(alice.address().to_string()), + ) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert_eq!( + config.address_book.get(alice.address()), + Some(&"Alice Prime".to_string()) + ); + + let error = super::upsert_address_book_entry( + &state, + alice.address().to_string(), + "Alice Duplicate".to_string(), + None, + ) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("address is already saved")); + + let carol = Wallet::from_seed("address-book-carol"); + super::upsert_address_book_entry( + &state, + carol.address().to_string(), + "Carol".to_string(), + None, + ) + .await + .unwrap(); + let error = super::upsert_address_book_entry( + &state, + carol.address().to_string(), + "Bob As Carol".to_string(), + Some(alice.address().to_string()), + ) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("address is already saved")); + + super::upsert_address_book_entry( + &state, + bob.address().to_string(), + "Bob".to_string(), + Some(alice.address().to_string()), + ) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(!config.address_book.contains_key(alice.address())); + assert_eq!( + config.address_book.get(bob.address()), + Some(&"Bob".to_string()) + ); + assert_eq!( + config.address_book.get(carol.address()), + Some(&"Carol".to_string()) + ); + + let error = super::upsert_address_book_entry( + &state, + "iuna-address".to_string(), + "Not Alice".to_string(), + None, + ) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("invalid address book address")); + + super::remove_address_book_entry(&state, bob.address().to_string()) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(!config.address_book.contains_key(bob.address())); + assert!(config.address_book.contains_key(carol.address())); +} + +#[tokio::test] +async fn p2p_announce_setting_persists_config_and_waits_for_public_node() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let state = auth_test_state( + config_path.clone(), + UiConfig { + setup_complete: true, + ..UiConfig::default() + }, + ) + .await; + + super::set_p2p_announce_addr(&state, " 203.0.113.10:9444 ".to_string()) + .await + .unwrap(); + + let config = config_store::load_or_create(&config_path).unwrap(); + assert_eq!( + config.p2p_announce_addr.as_deref(), + Some("203.0.113.10:9444") + ); + match state.gossip.peer_exchange().await { + GossipEnvelope::PeerList { peers } => { + assert!(!peers.contains(&"203.0.113.10:9444".to_string())); + } + other => panic!("expected peer list, got {other:?}"), + } + + super::set_p2p_accept_inbound(&state, true, Some(9555)) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(config.p2p_accept_inbound); + assert_eq!(config.p2p_bind_port, 9555); + assert!(!state.gossip.accepts_inbound().await); + match state.gossip.peer_exchange().await { + GossipEnvelope::PeerList { peers } => { + assert!(!peers.contains(&"203.0.113.10:9444".to_string())); + } + other => panic!("expected peer list, got {other:?}"), + } + + super::set_p2p_accept_inbound(&state, false, None) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(!config.p2p_accept_inbound); + assert!(!state.gossip.accepts_inbound().await); + + super::set_p2p_announce_addr(&state, " ".to_string()) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(config.p2p_announce_addr.is_none()); +} + +#[tokio::test] +async fn p2p_announce_setting_rejects_invalid_address() { + let dir = tempfile::tempdir().unwrap(); + let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; + + let error = super::set_p2p_announce_addr(&state, "not-an-address".to_string()) + .await + .unwrap_err(); + + assert!(format!("{error:#}").contains("invalid P2P announce address")); +} + +#[tokio::test] +async fn setup_config_form_can_add_bootstrap_peer() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let state = auth_test_state(config_path.clone(), UiConfig::default()).await; + + super::apply_config_form( + &state, + super::ConfigForm { + setup_complete: true, + peer: " iuna.jhx.app:9444 ".to_string(), + }, + ) + .await + .unwrap(); + + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(config.setup_complete); + assert_eq!(config.peers, vec!["iuna.jhx.app:9444"]); + assert_eq!( + state.peers.lock().await.addresses(), + vec!["iuna.jhx.app:9444"] + ); +} + +#[tokio::test] +async fn setup_config_form_requires_peer_for_placeholder_chain() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let state = auth_test_state(config_path.clone(), UiConfig::default()).await; + + let error = super::apply_config_form( + &state, + super::ConfigForm { + setup_complete: true, + peer: " ".to_string(), + }, + ) + .await + .unwrap_err(); + + assert!(format!("{error:#}").contains("add a bootstrap peer")); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(!config.setup_complete); +} + +#[tokio::test] +async fn peer_management_rejects_empty_and_inbound_removal() { + let dir = tempfile::tempdir().unwrap(); + let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; + + assert!(super::add_peer(&state, " ".to_string()).await.is_err()); + state + .peers + .lock() + .await + .record_received("127.0.0.1:9555", 1); + + let result = super::remove_peer(&state, "127.0.0.1:9555".to_string()).await; + assert!(result.is_err()); + assert_eq!(state.peers.lock().await.addresses(), Vec::<String>::new()); +} + +#[tokio::test] +async fn network_health_summarizes_sync_and_peer_errors() { + let dir = tempfile::tempdir().unwrap(); + let state = auth_test_state(dir.path().join("config.json"), UiConfig::default()).await; + let status = state.node.lock().await.status(); + let mempool = super::MempoolCounts { + plain_transactions: 1, + blinded_transactions: 2, + blinded_reveals: 3, + }; + + let isolated = super::network_health(&status, &[], mempool); + assert!(!isolated.ok); + assert_eq!(isolated.state, "isolated"); + assert_eq!(isolated.local_height, 0); + assert_eq!(isolated.best_known_height, 0); + assert_eq!(isolated.pending_plain_transactions, 1); + assert_eq!(isolated.pending_blinded_transactions, 2); + assert_eq!(isolated.pending_blinded_reveals, 3); + + let mut clock_peers = PeerBook::from_addresses(vec![ + "127.0.0.1:9450".to_string(), + "127.0.0.1:9451".to_string(), + ]); + clock_peers.record_status("127.0.0.1:9450", 0, "tip".to_string()); + clock_peers.record_status("127.0.0.1:9451", 0, "tip".to_string()); + clock_peers.record_clock_observation("127.0.0.1:9450", PeerDirection::Outbound, 10_500, 10_000); + clock_peers.record_clock_observation( + "127.0.0.1:9451", + PeerDirection::Outbound, + 11 * 60 * 1_000, + 10_000, + ); + let clock_health = super::network_health_at(&status, &clock_peers.list(), mempool, 10_000); + assert_eq!(clock_health.network_time_offset_ms, Some(500)); + assert_eq!(clock_health.bad_clock_peers, 1); + + let syncing = super::network_health( + &status, + &[PeerInfo { + address: "127.0.0.1:9445".to_string(), + direction: PeerDirection::Outbound, + messages_sent: 1, + messages_received: 1, + last_known_height: Some(3), + last_known_tip_hash: Some("remote-tip".to_string()), + last_clock_offset_ms: None, + last_clock_offset_accepted: None, + last_clock_observed_ms: None, + last_error: None, + last_contact_ms: Some(10_000), + last_success_ms: Some(10_000), + last_error_ms: None, + misbehavior_score: 0, + banned_until_ms: None, + ban_reason: None, + }], + mempool, + ); + assert!(!syncing.ok); + assert_eq!(syncing.state, "syncing"); + assert_eq!(syncing.best_known_height, 3); + assert_eq!(syncing.lag_blocks, 3); + + let peer_errors = super::network_health( + &status, + &[PeerInfo { + address: "127.0.0.1:9446".to_string(), + direction: PeerDirection::Outbound, + messages_sent: 0, + messages_received: 0, + last_known_height: None, + last_known_tip_hash: None, + last_clock_offset_ms: None, + last_clock_offset_accepted: None, + last_clock_observed_ms: None, + last_error: Some("connection refused".to_string()), + last_contact_ms: Some(10_000), + last_success_ms: None, + last_error_ms: Some(10_000), + misbehavior_score: 1, + banned_until_ms: None, + ban_reason: Some("connection refused".to_string()), + }], + mempool, + ); + assert!(!peer_errors.ok); + assert_eq!(peer_errors.state, "peer errors"); + assert_eq!( + peer_errors.last_error.as_deref(), + Some("127.0.0.1:9446: connection refused") + ); + + let stale = super::network_health_at( + &status, + &[PeerInfo { + address: "127.0.0.1:9447".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_clock_offset_ms: None, + last_clock_offset_accepted: None, + last_clock_observed_ms: None, + last_error: None, + last_contact_ms: Some(1), + last_success_ms: Some(1), + last_error_ms: None, + misbehavior_score: 0, + banned_until_ms: None, + ban_reason: None, + }], + mempool, + PEER_STALE_AFTER_MS + 2, + ); + assert!(!stale.ok); + assert_eq!(stale.state, "stale"); + assert_eq!(stale.stale_peers, 1); + + let banned = super::network_health_at( + &status, + &[PeerInfo { + address: "127.0.0.1:9448".to_string(), + direction: PeerDirection::Outbound, + messages_sent: 0, + messages_received: 0, + last_known_height: None, + last_known_tip_hash: None, + last_clock_offset_ms: None, + last_clock_offset_accepted: None, + last_clock_observed_ms: None, + last_error: Some("invalid block".to_string()), + last_contact_ms: Some(10), + last_success_ms: None, + last_error_ms: Some(10), + misbehavior_score: 3, + banned_until_ms: Some(1_000), + ban_reason: Some("invalid block".to_string()), + }], + mempool, + 20, + ); + assert!(!banned.ok); + assert_eq!(banned.state, "banned"); + assert_eq!(banned.banned_peers, 1); +} + +#[test] +fn wallet_transactions_include_old_confirmed_transfers_without_burns_or_explorer_pagination() { + let alice = Wallet::from_seed("wallet-history-alice"); + let bob = Wallet::from_seed("wallet-history-bob"); + let carol = Wallet::from_seed("wallet-history-carol"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 100); + allocations.insert(bob.address().to_string(), 100); + allocations.insert(carol.address().to_string(), 100); + let ledger = crate::domain::Ledger::new(allocations.clone(), 1); + let old_received = ledger.build_transfer(&bob, alice.address(), 31, 0).unwrap(); + let pending_burn = ledger.build_burn(&alice, 2, 1).unwrap(); + let carol_transfer = ledger.build_transfer(&carol, bob.address(), 5, 0).unwrap(); + let carol_burn = ledger.build_burn(&carol, 1, 0).unwrap(); + let chain = vec![ + fake_block(30, vec![carol_transfer]), + fake_block(31, vec![old_received.clone()]), + fake_block(32, vec![carol_burn]), + ]; + + let snapshot = fake_snapshot(allocations, chain.clone()); + let outputs = super::known_output_index(&snapshot, std::slice::from_ref(&pending_burn)); + let rows = wallet_transaction_rows( + alice.address(), + vec![pending_burn.clone()], + Vec::new(), + &chain, + &BTreeMap::new(), + &outputs, + WalletTransactionFilters::default(), + ); + + assert_eq!(rows.len(), 1); + assert_ne!(rows[0].signature, pending_burn.signature()); + assert_eq!(rows[0].signature, old_received.signature()); + assert_eq!(rows[0].inputs[0].amount, Some(100)); + assert_eq!(rows[0].status, "confirmed"); + assert_eq!(rows[0].block_height, Some(31)); + assert_eq!(rows[0].timestamp_ms, Some(31)); + assert_eq!(rows[0].block_finalizer.as_deref(), Some("miner")); + assert_eq!(rows[0].direction, "received"); +} + +#[test] +fn wallet_transactions_show_owned_blinded_payloads_as_pending_blind() { + let alice = Wallet::from_seed("wallet-blind-pending-alice"); + let bob = Wallet::from_seed("wallet-blind-pending-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 100); + allocations.insert(bob.address().to_string(), 100); + let ledger = Ledger::new(allocations.clone(), 1); + let pending_blind = ledger.build_transfer(&alice, bob.address(), 12, 3).unwrap(); + let snapshot = fake_snapshot(allocations, Vec::new()); + let outputs = super::known_output_index(&snapshot, &[]); + + let rows = wallet_transaction_rows( + alice.address(), + Vec::new(), + vec![pending_blind.clone()], + &[], + &BTreeMap::new(), + &outputs, + WalletTransactionFilters::default(), + ); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].kind, "transfer"); + assert_eq!(rows[0].status, "pending"); + assert_eq!(rows[0].timestamp_ms, None); + assert!(rows[0].blinded); + assert_eq!(rows[0].direction, "sent"); + assert_eq!(rows[0].to.as_deref(), Some(bob.address())); + assert_eq!(rows[0].amount, 12); + assert_eq!(rows[0].fee, 3); + assert_eq!(rows[0].signature, pending_blind.signature()); +} + +#[test] +fn wallet_transaction_query_defaults_to_tx_only() { + assert_eq!( + WalletTransactionFilters::from_query(WalletTransactionsQuery::default()), + WalletTransactionFilters::default() + ); + assert_eq!( + WalletTransactionFilters::from_query(WalletTransactionsQuery { + tx: Some(false), + mine: Some(true), + burn: Some(true), + offset: None, + limit: None, + }), + WalletTransactionFilters { + transfer: false, + mine: true, + burn: true, + } + ); +} + +#[test] +fn page_items_returns_bounded_slices_with_next_offset() { + let page = super::page_items( + vec![1, 2, 3, 4, 5], + super::PageQuery { + offset: Some(1), + limit: Some(2), + }, + ); + + assert_eq!(page.items, vec![2, 3]); + assert_eq!(page.offset, 1); + assert_eq!(page.limit, 2); + assert_eq!(page.total, 5); + assert!(page.has_more); + assert_eq!(page.next_offset, Some(3)); +} + +#[test] +fn page_items_clamps_limit_and_empty_tail() { + let page = super::page_items( + vec![1, 2], + super::PageQuery { + offset: Some(20), + limit: Some(0), + }, + ); + + assert!(page.items.is_empty()); + assert_eq!(page.offset, 2); + assert_eq!(page.limit, 1); + assert_eq!(page.total, 2); + assert!(!page.has_more); + assert_eq!(page.next_offset, None); +} + +#[test] +fn mine_transaction_views_include_protocol_finalizer_fee() { + let alice = Wallet::from_seed("wallet-mine-fee-alice"); + let ledger = Ledger::new(BTreeMap::new(), 1); + let mine = ledger.build_mine(alice.address()).unwrap(); + let chain = vec![fake_block(1, vec![mine.clone()])]; + let snapshot = fake_snapshot(BTreeMap::new(), chain.clone()); + let outputs = super::known_output_index(&snapshot, &[]); + + let rows = wallet_transaction_rows( + alice.address(), + Vec::new(), + Vec::new(), + &chain, + &BTreeMap::new(), + &outputs, + WalletTransactionFilters { + transfer: false, + mine: true, + burn: false, + }, + ); + let transaction = super::ui_transaction(&mine, &outputs); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].amount, mine.amount()); + assert_eq!(rows[0].fee, MINE_FINALIZER_FEE); + assert_eq!(transaction.amount, mine.amount()); + assert_eq!(transaction.fee, MINE_FINALIZER_FEE); +} + +#[test] +fn wallet_transactions_include_public_mine_actions() { + let alice = Wallet::from_seed("wallet-revealed-mine-alice"); + let ledger = Ledger::new( + BTreeMap::from([(alice.address().to_string(), 2 * MICRO_IUNA)]), + 1, + ); + let mine = ledger.build_mine(alice.address()).unwrap(); + let mut mine_block = fake_block(8, vec![mine.clone()]); + mine_block.reward = mine.fee(); + let chain = vec![mine_block.clone()]; + let snapshot = fake_snapshot(BTreeMap::new(), chain.clone()); + let revealed_by_height = super::revealed_transactions_by_height(&snapshot); + let outputs = super::known_output_index(&snapshot, &[]); + + let rows = wallet_transaction_rows( + alice.address(), + Vec::new(), + Vec::new(), + &chain, + &revealed_by_height, + &outputs, + WalletTransactionFilters { + transfer: false, + mine: true, + burn: false, + }, + ); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].kind, "mine"); + assert_eq!(rows[0].status, "confirmed"); + assert_eq!(rows[0].block_height, Some(8)); + assert_eq!(rows[0].timestamp_ms, Some(8)); + assert_eq!(rows[0].block_finalizer.as_deref(), Some("miner")); + assert_eq!(rows[0].direction, "received"); + assert_eq!(rows[0].amount, mine.amount()); + assert_eq!(rows[0].fee, MINE_FINALIZER_FEE); + assert!(!rows[0].blinded); + assert_eq!(rows[0].signature, mine.signature()); +} + +#[test] +fn burn_wallet_transactions_require_burn_filter() { + let alice = Wallet::from_seed("wallet-burn-filter-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10); + let ledger = Ledger::new(allocations.clone(), 1); + let burn = ledger.build_burn(&alice, 3, 1).unwrap(); + let snapshot = fake_snapshot(allocations, Vec::new()); + let outputs = super::known_output_index(&snapshot, std::slice::from_ref(&burn)); + + let default_rows = wallet_transaction_rows( + alice.address(), + vec![burn.clone()], + Vec::new(), + &[], + &BTreeMap::new(), + &outputs, + WalletTransactionFilters::default(), + ); + let burn_rows = wallet_transaction_rows( + alice.address(), + vec![burn.clone()], + Vec::new(), + &[], + &BTreeMap::new(), + &outputs, + WalletTransactionFilters { + transfer: false, + mine: false, + burn: true, + }, + ); + + assert!(default_rows.is_empty()); + assert_eq!(burn_rows.len(), 1); + assert_eq!(burn_rows[0].kind, "burn"); + assert_eq!(burn_rows[0].direction, "burned"); + assert_eq!(burn_rows[0].amount, 3); + assert_eq!(burn_rows[0].fee, 1); +} + +#[test] +fn wallet_utxo_rows_include_pending_spent_outputs_as_disabled() { + let alice = Wallet::from_seed("wallet-utxo-pending-alice"); + let bob = Wallet::from_seed("wallet-utxo-pending-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10); + let mut ledger = Ledger::new(allocations, 1); + let pending = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); + let Transaction::Transfer { inputs, .. } = &pending else { + panic!("expected transfer"); + }; + let spent_outpoint = inputs[0].outpoint.clone(); + + ledger.submit_transaction(pending).unwrap(); + let rows = wallet_utxo_rows(&ledger, alice.address()); + + assert!(rows.iter().any(|row| row.outpoint == spent_outpoint)); + assert!( + rows.iter() + .any(|row| row.outpoint == spent_outpoint && !row.spendable) + ); +} + +#[test] +fn selectable_wallet_utxo_rows_include_only_spendable_outputs() { + let alice = Wallet::from_seed("wallet-utxo-selectable-alice"); + let bob = Wallet::from_seed("wallet-utxo-selectable-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10); + let mut ledger = Ledger::new(allocations, 1); + let pending = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); + let Transaction::Transfer { inputs, .. } = &pending else { + panic!("expected transfer"); + }; + let spent_outpoint = inputs[0].outpoint.clone(); + + ledger.submit_transaction(pending).unwrap(); + let rows = super::selectable_wallet_utxo_rows(&ledger, alice.address()); + + assert!(!rows.iter().any(|row| row.outpoint == spent_outpoint)); + assert!(rows.iter().all(|row| row.spendable)); +} + +#[test] +fn wallet_utxo_rows_treat_owned_blinded_spends_as_pending() { + let alice = Wallet::from_seed("wallet-utxo-blind-pending-alice"); + let bob = Wallet::from_seed("wallet-utxo-blind-pending-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10); + let ledger = Ledger::new(allocations, 1); + let mut node = NodeCore::from_ledger(alice.clone(), ledger, 0); + let pending = node.transfer_with_fee(bob.address(), 3, 0).unwrap(); + let Transaction::Transfer { inputs, .. } = &pending else { + panic!("expected transfer"); + }; + let spent_outpoint = inputs[0].outpoint.clone(); + let wallet_view = node.wallet_view_ledger().unwrap(); + + let rows = wallet_utxo_rows(&wallet_view, alice.address()); + let selectable = super::selectable_wallet_utxo_rows(&wallet_view, alice.address()); + + assert!( + rows.iter() + .any(|row| row.outpoint == spent_outpoint && !row.spendable) + ); + assert!(!selectable.iter().any(|row| row.outpoint == spent_outpoint)); +} + +#[test] +fn wallet_utxo_rows_keep_local_anchor_spends_visible_as_pending() { + let alice = Wallet::from_seed("wallet-utxo-local-anchor-alice"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(alice.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + let mut node = + NodeCore::from_ledger_with_burn_fee_and_enabled(alice.clone(), ledger, true, 1, 0); + + let plan = node.prepare_automatic_finalization(1); + assert!(plan.burned.is_some()); + let rows = wallet_utxo_rows(&node.wallet_view_ledger().unwrap(), alice.address()); + + assert!(!rows.is_empty()); + assert!(rows.iter().any(|row| !row.spendable)); +} + +fn fake_block(height: u64, transactions: Vec<Transaction>) -> Block { + Block { + height, + prev_hash: format!("prev-{height}"), + timestamp_ms: height, + miner: "miner".to_string(), + finalizer_mode: crate::domain::FinalizerMode::Ticket, + finalizer_rank: 0, + reward: 100, + vdf_rounds: 0, + vdf_output: "vdf".to_string(), + leader_proof: None, + blinded_transactions: Vec::new(), + reveal_bundle_section: RevealBundleSection::default(), + transactions, + hash: format!("hash-{height}"), + } +} + +fn fake_snapshot( + genesis_allocations: BTreeMap<String, Amount>, + blocks: Vec<Block>, +) -> ChainSnapshot { + ChainSnapshot { + genesis_allocations, + vdf_rounds: 1, + launch_profile: LaunchProfile::default(), + blocks, + } +} + +fn metric_row( + height: u64, + block_time_ms: Option<u64>, + vdf_rounds: u64, +) -> crate::adapters::chain_store::BlockMetricRow { + crate::adapters::chain_store::BlockMetricRow { + height, + block_hash: format!("hash-{height}"), + timestamp_ms: height, + block_time_ms, + mine_difficulty_bits: 12, + circulating_supply: 100, + known_wallet_addresses: 1, + transaction_count: 0, + transfer_count: 0, + burn_count: 0, + mine_count: 0, + burned_amount: 0, + total_burned_amount: 0, + fees_amount: 0, + reward_amount: 0, + vdf_rounds, + finalizer_rank: 0, + } +} + +#[test] +fn metrics_response_skips_bootstrap_points_for_block_time_and_vdf_rounds() { + let response = super::metrics_response( + true, + vec![ + metric_row(0, None, 0), + metric_row(1, Some(1_764_000_000_000), 0), + metric_row(2, Some(600_000), 120), + metric_row(3, Some(610_000), 130), + ], + ); + + let block_time = response + .charts + .iter() + .find(|chart| chart.id == "block-time") + .expect("block time chart should exist"); + assert_eq!( + block_time + .points + .iter() + .map(|point| (point.height, point.value)) + .collect::<Vec<_>>(), + vec![(2, 600.0), (3, 610.0)] + ); + + let vdf_rounds = response + .charts + .iter() + .find(|chart| chart.id == "vdf-rounds") + .expect("VDF rounds chart should exist"); + assert_eq!( + vdf_rounds + .points + .iter() + .map(|point| (point.height, point.value)) + .collect::<Vec<_>>(), + vec![(2, 120.0), (3, 130.0)] + ); + + let known_wallet_addresses = response + .charts + .iter() + .find(|chart| chart.id == "known-wallet-addresses") + .expect("known wallet addresses chart should exist"); + assert_eq!( + known_wallet_addresses + .points + .iter() + .map(|point| (point.height, point.value)) + .collect::<Vec<_>>(), + vec![(0, 1.0), (1, 1.0), (2, 1.0), (3, 1.0)] + ); +} + +#[test] +fn metrics_screen_includes_block_range_filter() { + assert!(super::INDEX_HTML.contains("iuna-ui.js?v=97")); + assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\"")); + assert!(super::INDEX_HTML.contains("setMetricsRange(100)")); + assert!(super::INDEX_HTML.contains("setMetricsRange(1000)")); + assert!(super::INDEX_HTML.contains("setMetricsRange('all')")); + assert!(super::INDEX_HTML.contains("Known addresses")); + assert!(super::INDEX_HTML.contains("knownWalletAddresses")); +} + +#[test] +fn reveal_mempool_items_show_unknown_fee_label() { + let app_js = include_str!("../../../www/assets/iuna-ui.js"); + assert!(app_js.contains("txFeeLabel(tx)")); + assert!(app_js.contains("!tx?.revealed && tx?.kind === \"reveal\"")); + assert!(app_js.contains("unknown until reveal")); + assert!( + app_js.contains("!tx?.revealed && (tx?.kind === \"blinded\" || tx?.kind === \"reveal\")") + ); + assert!(app_js.contains("mempoolFirstSeenHeights")); + assert!(app_js.contains("mempoolFirstSeenAt")); + assert!( + app_js.contains("this.trackMempoolFirstSeenHeights({ append: options.replace !== true })") + ); + assert!(app_js.contains("return rightSeenAt - leftSeenAt")); + assert!(app_js.contains("syncMempoolBlockMarker")); + assert!(app_js.contains("this.status.chain?.height ?? this.lastBlockMempoolHeight")); + assert!(app_js.contains("mempoolItemClass")); + assert!(app_js.contains("walletTxTimeLabel(tx)")); + assert!(app_js.contains("timestampMs ?? tx?.timestamp_ms")); + assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(tx)\"")); + assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(selectedTransaction?.tx)\"")); + assert!(super::INDEX_HTML.contains("<span class=\"tx-label\">Time</span>")); + assert!(super::INDEX_HTML.contains("x-text=\"walletTxTimeLabel(tx)\"")); +} + +#[test] +fn wallet_screen_includes_address_book_alias_controls() { + let app_js = include_str!("../../../www/assets/iuna-ui.js"); + assert!(super::INDEX_HTML.contains("<h3>Address Book</h3>")); + assert!(super::INDEX_HTML.contains("saveAddressBookEntry")); + assert!(super::INDEX_HTML.contains("addressBookEntries()")); + assert!(super::INDEX_HTML.contains("openAddressBookModal()")); + assert!(super::INDEX_HTML.contains("addressBookModalOpen")); + assert!(super::INDEX_HTML.contains("openAddressBookPicker()")); + assert!(super::INDEX_HTML.contains("addressBookPickerOpen")); + assert!(super::INDEX_HTML.contains("selectTransferContact(entry.address)")); + assert!(super::INDEX_HTML.contains("editAddressBookEntry(entry)")); + assert!( + super::INDEX_HTML.contains("removeAddressBookEntry({ address: addressBookEditingAddress") + ); + assert!(super::INDEX_HTML.contains("aria-label=\"Choose contact\"")); + assert!(super::INDEX_HTML.contains("aria-label=\"Delete contact\"")); + assert!(super::INDEX_HTML.contains("shortAddressLabel(tx.from)")); + assert!(super::INDEX_HTML.contains("addressLabel(input.owner)")); + assert!(app_js.contains("addressBook: {}")); + assert!(app_js.contains("addressBookVersion: 0")); + assert!(app_js.contains("addressBookPickerOpen: false")); + assert!(app_js.contains("this.config.address_book || this.config.addressBook")); + assert!(app_js.contains("options.addressBookVersion >= this.addressBookVersion")); + assert!(app_js.contains("async saveAddressBookEntry()")); + assert!(app_js.contains("Address is already saved")); + assert!(app_js.contains("validAddressBookAddress(address)")); + assert!(app_js.contains("openAddressBookPicker()")); + assert!(app_js.contains("await this.submitForm(\"/api/address-book\"")); + assert!(app_js.contains("\"/api/address-book\"")); + assert!(app_js.contains("addressLabel(address)")); + assert!(app_js.contains("shortAddressLabel(address)")); +} + +#[test] +fn initial_setup_includes_node_mode_choices() { + assert!(super::INDEX_HTML.contains("aria-label=\"Initial node mode\"")); + assert!(super::INDEX_HTML.contains("selectSetupNodeMode('wallet')")); + assert!(super::INDEX_HTML.contains("selectSetupNodeMode('non-listening')")); + assert!(super::INDEX_HTML.contains("selectSetupNodeMode('listening')")); + assert!(super::INDEX_HTML.contains("setupNodeMode === 'listening'")); + assert!(super::INDEX_HTML.contains("x-model.number=\"p2pBindPort\"")); + assert!(super::INDEX_HTML.contains("Change later in Settings")); +} + +#[test] +fn setup_completion_refreshes_chain_data() { + let app_js = include_str!("../../../www/assets/iuna-ui.js"); + assert!( + app_js.contains("await this.refresh({ force: true });\n this.setupFeedback = null;") + ); + assert!( + !app_js.contains( + "await this.refreshConfig();\n await this.resetPagedDataset(\"peer\");" + ) + ); +} + +#[test] +fn setup_completion_applies_selected_node_mode() { + let app_js = include_str!("../../../www/assets/iuna-ui.js"); + assert!(app_js.contains("setupNodeMode: \"wallet\"")); + assert!(app_js.contains("async applySetupNodeMode()")); + assert!(app_js.contains("await this.applySetupNodeMode();")); + assert!(app_js.contains("\"/api/settings/p2p-inbound\"")); + assert!(app_js.contains("bind_port: this.p2pBindPortValue()")); + assert!(app_js.contains("this.setUiMode(mode === \"wallet\" ? \"basic\" : \"advanced\")")); +} + +#[test] +fn p2p_bind_port_changes_show_global_restart_notice() { + let app_js = include_str!("../../../www/assets/iuna-ui.js"); + assert!(super::INDEX_HTML.contains("Bind port")); + assert!(super::INDEX_HTML.contains("persistent-banner")); + assert!(super::INDEX_HTML.contains("p2pRestartRequired()")); + assert!(app_js.contains("p2pBindPort: 9444")); + assert!(app_js.contains("p2pConfiguredBindAddr()")); + assert!(app_js.contains("p2pRestartMessage()")); + assert!(app_js.contains("Restart iuna to close the public P2P listener.")); + assert!(app_js.contains("0.0.0.0:${port}")); +} + +#[test] +fn polling_refreshes_paged_datasets_without_visible_loaders() { + let app_js = include_str!("../../../www/assets/iuna-ui.js"); + assert!(app_js.contains("setInterval(() => this.refresh({ silent: true }), 5000)")); + assert!( + app_js.contains( + "return this.authLoaded && this.auth.configured === true && this.auth.authenticated === true;" + ) + ); + assert!(app_js.contains( + "async refreshNow(options = {}) {\n if (!this.canUseProtectedApi()) return;" + )); + assert!(app_js.contains("refreshPromise: null")); + assert!(app_js.contains("if (this.refreshPromise)")); + assert!(app_js.contains("return this.refreshPromise;")); + assert!(app_js.contains("options.force === true")); + assert!(app_js.contains("this.setTab(this.tabFromHash());")); + assert!(app_js.contains("const shouldLoadBlocks = tab === \"chain\" || tab === \"mining\";")); + assert!(app_js.contains("const shouldLoadP2pMetrics = tab === \"p2p\";")); + assert!(app_js.contains("const shouldLoadMetrics = tab === \"metrics\";")); + assert!( + app_js + .contains("if (tab === \"wallet\") pagedDatasets.push(\"walletTx\", \"walletUtxo\");") + ); + assert!(app_js.contains("if (tab === \"chain\") pagedDatasets.push(\"mempool\");")); + assert!(app_js.contains("if (tab === \"p2p\") pagedDatasets.push(\"peer\");")); + assert!(app_js.contains("cache: \"no-store\"")); + assert!(app_js.contains("async fetchWithTimeout(path, options = {})")); + assert!(app_js.contains("controller.abort()")); + assert!(app_js.contains("async refreshPagedDataset(kind, options = {}) {\n if (!this.canUseProtectedApi()) return;")); + assert!( + app_js + .contains("async loadNextPage(kind) {\n if (!this.canUseProtectedApi()) return;") + ); + assert!( + app_js.contains("async loadOlderBlocks() {\n if (!this.canUseProtectedApi()) return;") + ); + assert!(app_js.contains("this.stopPolling();\n await this.refreshAuth();")); + assert!(app_js.contains("backgroundLoading")); + assert!(app_js.contains("options.silent === true ? \"backgroundLoading\" : \"loading\"")); +} + +#[test] +fn wallet_pending_blinded_transactions_are_labeled() { + let app_js = include_str!("../../../www/assets/iuna-ui.js"); + assert!(app_js.contains("Pending blind")); + assert!(app_js.contains("tx.blinded")); +} + +#[test] +fn mine_screen_shows_fixed_pow_reward_without_burn_slider() { + let app_js = include_str!("../../../www/assets/iuna-ui.js"); + assert!(app_js.contains("powMineReward()")); + assert!(super::INDEX_HTML.contains("Search for PoW actions that mint a fixed IUNA reward.")); + assert!(super::INDEX_HTML.contains("amountLabel(powMineReward())")); + assert!(super::INDEX_HTML.contains("aria-label=\"Local mining status\"")); + assert!(super::INDEX_HTML.contains("PoB State")); + assert!(super::INDEX_HTML.contains("PoW State")); + assert!(super::INDEX_HTML.contains("Workers")); + assert!(super::INDEX_HTML.contains("Selected Finalizer")); + assert!(app_js.contains("pobStatusLabel()")); + assert!(app_js.contains("powStatusShortLabel()")); + assert!(app_js.contains("setPowMiningWorkers(workers)")); + assert!(app_js.contains("localMiningMempoolLabel()")); + assert!(app_js.contains("miningEventLog()")); + assert!(app_js.contains("miningEventLimit: 1000")); + assert!(app_js.contains("slice(0, this.miningEventLimit)")); + assert!(super::INDEX_HTML.contains("aria-label=\"Mining event log\"")); + assert!(super::INDEX_HTML.contains("miningEventLog().length === 0")); + assert!(super::INDEX_HTML.contains("mining-event-empty")); + assert!(app_js.contains("Resource budget:")); + assert!(app_js.contains("isPowMineSuccessStatus")); + assert!(app_js.contains("You mined a PoW action")); + assert!(app_js.contains("Waiting for a finalizer to include it in a block.")); + assert!(app_js.contains("Observed block")); + assert!(app_js.contains("Finalized by")); + assert!(app_js.contains("You finalized block")); + assert!(app_js.contains("if (!locallyFinalized)")); + assert!(app_js.contains("last?.title === title")); + assert!(app_js.contains("this.miningEventState.pob = enabled ? \"on\" : \"off\";")); + assert!(app_js.contains("Automatic burn prepared at height")); + assert!(app_js.contains("Eligible for the next block opportunity.")); + assert!(app_js.contains("!Number.isFinite(timestampMs)")); + assert!(!super::INDEX_HTML.contains("Needs burns")); +} + +#[test] +fn block_detail_finalizer_opens_burn_leader_ranks_modal() { + let app_js = include_str!("../../../www/assets/iuna-ui.js"); + assert!(super::INDEX_HTML.contains("openBurnLeaderRanksModal(selectedBlock)")); + assert!(super::INDEX_HTML.contains("id=\"burn-ranks-title\"")); + assert!(super::INDEX_HTML.contains("burnLeaderRanks(selectedBurnLeaderBlock)")); + assert!(app_js.contains("selectedBurnLeaderBlock")); + assert!(app_js.contains("burnLeaderRankLabel(rank)")); + assert!(app_js.contains("block?.burn_leader_ranks")); + assert!(super::INDEX_HTML.contains("rank.ticket_id ?? rank.ticketId")); +} + +async fn auth_test_state(config_path: std::path::PathBuf, config: UiConfig) -> HttpState { + config_store::save(&config_path, &config).unwrap(); + let wallet_path = config_path.with_file_name("wallet.json"); + let (wallet, _) = wallet_store::replace_with_generated_seed_phrase(&wallet_path).unwrap(); + let ledger = Ledger::new(BTreeMap::new(), 1); + let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0))); + let peers = Arc::new(Mutex::new(PeerBook::default())); + let gossip = GossipNetwork::new_for_tests(node.clone(), peers.clone()); + let chain_store = SqliteChainStore::open(config_path.with_file_name("chain.sqlite3")) + .expect("test chain store should open"); + HttpState { + node, + peers, + gossip, + ui_config: Arc::new(Mutex::new( + config_store::load_or_create(&config_path).unwrap(), + )), + config_path, + chain_store, + wallet_path, + stratum: StratumStatus { + enabled: false, + listen_addr: None, + }, + auth_sessions: Arc::new(Mutex::new(BTreeMap::new())), + auth_backoff: Arc::new(Mutex::new(BTreeMap::new())), + ui_cache: Arc::new(Mutex::new(super::UiChainCache::default())), + } +} + +fn auth_test_app(state: HttpState) -> Router { + Router::new() + .route("/api/auth/status", get(api_auth_status)) + .route("/api/auth/setup", post(api_auth_setup_form)) + .route("/api/auth/login", post(api_auth_login_form)) + .route( + "/api/auth/change-password", + post(api_auth_change_password_form), + ) + .route("/favicon.ico", get(super::favicon)) + .route("/api/protected", get(protected_auth_test_endpoint)) + .layer(middleware::from_fn_with_state( + state.clone(), + require_auth_middleware, + )) + .with_state(state) +} + +async fn protected_auth_test_endpoint() -> &'static str { + "protected" +} + +struct TestHttpResponse { + status: StatusCode, + headers: HeaderMap, + body: String, +} + +async fn http_request( + app: Router, + method: Method, + path: &str, + cookie: Option<&str>, + body: &str, +) -> TestHttpResponse { + let mut builder = Request::builder() + .method(method.clone()) + .uri(path) + .header(header::ACCEPT, "application/json") + .header(header::HOST, "127.0.0.1:18661") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); + if matches!(method, Method::POST | Method::DELETE) { + builder = builder.header(header::ORIGIN, "http://127.0.0.1:18661"); + } + if let Some(cookie) = cookie { + builder = builder.header(header::COOKIE, cookie); + } + let response = app + .oneshot(builder.body(Body::from(body.to_string())).unwrap()) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let body = String::from_utf8( + to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(), + ) + .unwrap(); + TestHttpResponse { + status, + headers, + body, + } +} + +fn set_cookie_pair(headers: &HeaderMap) -> String { + let header = headers + .get(header::SET_COOKIE) + .and_then(|value| value.to_str().ok()) + .expect("response should include Set-Cookie header"); + header.split(';').next().unwrap().to_string() +} + +#[tokio::test] +async fn burn_settings_config_persistence_updates_config_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let ui_config = Arc::new(Mutex::new(UiConfig { + setup_complete: true, + ..UiConfig::default() + })); + let initial_config = ui_config.lock().await.clone(); + config_store::save(&config_path, &initial_config).expect("initial config should save"); + + persist_burn_settings_config( + &ui_config, + &config_path, + true, + 50 * MICRO_IUNA, + 3 * MICRO_IUNA, + ) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + + assert!(config.mining_enabled); + assert_eq!(config.burn_per_block, 50 * MICRO_IUNA); + assert_eq!(config.burn_fee, 3 * MICRO_IUNA); +} + +#[tokio::test] +async fn enabled_burn_settings_reject_zero_amount() { + let dir = tempfile::tempdir().unwrap(); + let state = auth_test_state( + dir.path().join("config.json"), + UiConfig { + setup_complete: true, + ..UiConfig::default() + }, + ) + .await; + + let error = super::set_burn_settings(&state, true, 0, MICRO_IUNA) + .await + .unwrap_err(); + + assert!(format!("{error:#}").contains("greater than zero")); +} + +#[tokio::test] +async fn metrics_setting_persists_config_and_clears_rows_when_disabled() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let state = auth_test_state( + config_path.clone(), + UiConfig { + setup_complete: true, + ..UiConfig::default() + }, + ) + .await; + let wallet = Wallet::from_seed("metrics-setting-wallet"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 10); + let ledger = Ledger::new_with_genesis_burns( + genesis, + vec![crate::domain::GenesisBurn::new(wallet.address(), 1)], + 1, + ) + .unwrap(); + *state.node.lock().await = NodeCore::from_ledger(wallet, ledger, 0); + + super::set_keep_track_of_metrics(&state, true) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(config.keep_track_of_metrics); + assert!(!state.chain_store.load_metrics().unwrap().is_empty()); + + super::set_keep_track_of_metrics(&state, false) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + assert!(!config.keep_track_of_metrics); + assert!(state.chain_store.load_metrics().unwrap().is_empty()); +} + +#[tokio::test] +async fn pow_mining_config_persistence_updates_config_file() { + let dir = tempfile::tempdir().unwrap(); + let config_path = dir.path().join("config.json"); + let ui_config = Arc::new(Mutex::new(UiConfig { + setup_complete: true, + ..UiConfig::default() + })); + let initial_config = ui_config.lock().await.clone(); + config_store::save(&config_path, &initial_config).expect("initial config should save"); + + persist_pow_mining_config(&ui_config, &config_path, true, 4) + .await + .unwrap(); + let config = config_store::load_or_create(&config_path).unwrap(); + + assert!(config.pow_mining_enabled); + assert_eq!(config.pow_mining_workers, 4); +} + +#[test] +fn transfer_form_requires_recipient_amount_and_fee() { + let error = validate_transfer_form(TransferForm { + to: " ".to_string(), + amount: 1, + fee_per_byte: Some(1), + utxos: String::new(), + }) + .unwrap_err(); + assert!(error.to_string().contains("recipient is required")); + + let error = validate_transfer_form(TransferForm { + to: "abc".to_string(), + amount: 0, + fee_per_byte: Some(1), + utxos: String::new(), + }) + .unwrap_err(); + assert!( + error + .to_string() + .contains("amount must be greater than zero") + ); + + let error = validate_transfer_form(TransferForm { + to: "abc".to_string(), + amount: 1, + fee_per_byte: None, + utxos: String::new(), + }) + .unwrap_err(); + assert!(error.to_string().contains("fee per byte is required")); +} + +#[test] +fn burn_and_mine_forms_require_fee_per_byte() { + let burn = required_fee_per_byte_burn(&super::BurnSettingsForm { + enabled: Some(true), + amount: 1, + fee_per_byte: None, + }) + .unwrap_err(); + assert!(burn.to_string().contains("fee per byte is required")); +} + +#[test] +fn transfer_form_trims_recipient() { + let (to, amount, fee, utxos) = validate_transfer_form(TransferForm { + to: " abc ".to_string(), + amount: 2, + fee_per_byte: Some(3), + utxos: String::new(), + }) + .unwrap(); + + assert_eq!(to, "abc"); + assert_eq!(amount, 2); + assert_eq!(fee, 3); + assert!(utxos.is_empty()); +} + +#[test] +fn transfer_form_parses_selected_utxos() { + let (_, _, _, utxos) = validate_transfer_form(TransferForm { + to: "abc".to_string(), + amount: 2, + fee_per_byte: Some(3), + utxos: "tx-one:0\ntx:with:colons:7,\n".to_string(), + }) + .unwrap(); + + assert_eq!( + utxos, + vec![ + OutPoint { + txid: "tx-one".to_string(), + index: 0 + }, + OutPoint { + txid: "tx:with:colons".to_string(), + index: 7 + } + ] + ); +} diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -0,0 +1,378 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ + adapters::{chain_store::BlockMetricRow, config_store::UiConfig}, + domain::{Amount, BurnLeaderRank, OutPoint, Transaction, TxOutput}, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct AuthForm { + pub(super) password: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ChangePasswordForm { + pub(super) old_password: String, + pub(super) new_password: String, +} + +#[derive(Debug, Serialize)] +pub(super) struct AuthStatusResponse { + pub(super) configured: bool, + pub(super) authenticated: bool, +} + +#[derive(Debug, Serialize)] +pub(super) struct NetworkHealthResponse { + pub(super) ok: bool, + pub(super) state: String, + pub(super) local_height: u64, + pub(super) best_known_height: u64, + pub(super) shared_height: u64, + pub(super) lag_blocks: u64, + pub(super) outbound_peers: usize, + pub(super) inbound_peers: usize, + pub(super) healthy_peers: usize, + pub(super) failed_peers: usize, + pub(super) stale_peers: usize, + pub(super) banned_peers: usize, + pub(super) pending_transactions: usize, + pub(super) pending_plain_transactions: usize, + pub(super) pending_blinded_transactions: usize, + pub(super) pending_blinded_reveals: usize, + pub(super) network_time_offset_ms: Option<i64>, + pub(super) bad_clock_peers: usize, + pub(super) last_error: Option<String>, +} + +#[derive(Clone, Copy, Debug, Default)] +pub(super) struct MempoolCounts { + pub(super) plain_transactions: usize, + pub(super) blinded_transactions: usize, + pub(super) blinded_reveals: usize, +} + +#[derive(Debug, Deserialize)] +pub(super) struct BurnSettingsForm { + pub(super) enabled: Option<bool>, + pub(super) amount: Amount, + pub(super) fee_per_byte: Option<Amount>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RecoveryVdfSettingsForm { + pub(super) top_rank_percent: u8, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PowMiningForm { + pub(super) enabled: bool, + pub(super) workers: Option<u8>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct MetricsSettingsForm { + pub(super) enabled: bool, +} + +#[derive(Debug, Deserialize)] +pub(super) struct P2pAnnounceForm { + pub(super) addr: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct P2pInboundForm { + pub(super) enabled: bool, + pub(super) bind_port: Option<u16>, +} + +#[derive(Debug, Serialize)] +pub(super) struct ConfigResponse { + #[serde(flatten)] + pub(super) config: UiConfig, + pub(super) p2p_inbound_runtime_active: bool, + pub(super) p2p_runtime_bind_addr: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct TransferForm { + pub(super) to: String, + pub(super) amount: Amount, + pub(super) fee_per_byte: Option<Amount>, + #[serde(default)] + pub(super) utxos: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct PeerForm { + pub(super) peer: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct AddressBookForm { + pub(super) address: String, + pub(super) name: String, + pub(super) old_address: Option<String>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct AddressBookDeleteForm { + pub(super) address: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ConfigForm { + pub(super) setup_complete: bool, + #[serde(default)] + pub(super) peer: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct SeedPhraseForm { + pub(super) seed_phrase: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct BlocksQuery { + pub(super) before_height: Option<u64>, + pub(super) limit: Option<usize>, +} + +#[derive(Debug, Default, Deserialize)] +pub(super) struct PageQuery { + pub(super) offset: Option<usize>, + pub(super) limit: Option<usize>, +} + +#[derive(Debug, Default, Deserialize)] +pub(super) struct WalletTransactionsQuery { + pub(super) tx: Option<bool>, + pub(super) mine: Option<bool>, + pub(super) burn: Option<bool>, + pub(super) offset: Option<usize>, + pub(super) limit: Option<usize>, +} + +impl WalletTransactionsQuery { + pub(super) fn page(&self) -> PageQuery { + PageQuery { + offset: self.offset, + limit: self.limit, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct WalletTransactionFilters { + pub(super) transfer: bool, + pub(super) mine: bool, + pub(super) burn: bool, +} + +impl Default for WalletTransactionFilters { + fn default() -> Self { + Self { + transfer: true, + mine: false, + burn: false, + } + } +} + +impl WalletTransactionFilters { + pub(super) fn from_query(query: WalletTransactionsQuery) -> Self { + Self { + transfer: query.tx.unwrap_or(true), + mine: query.mine.unwrap_or(false), + burn: query.burn.unwrap_or(false), + } + } + + pub(super) fn allows(self, transaction: &Transaction) -> bool { + match transaction { + Transaction::Transfer { .. } => self.transfer, + Transaction::Mine { .. } => self.mine, + Transaction::Burn { .. } => self.burn, + } + } +} + +#[derive(Debug, Serialize)] +pub(super) struct ActionResponse { + pub(super) ok: bool, + pub(super) error: Option<String>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct Page<T> { + pub(super) items: Vec<T>, + pub(super) offset: usize, + pub(super) limit: usize, + pub(super) total: usize, + pub(super) has_more: bool, + pub(super) next_offset: Option<usize>, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct MetricsResponse { + pub(super) enabled: bool, + pub(super) latest: Option<BlockMetricRow>, + pub(super) charts: Vec<MetricsChart>, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct MetricsChart { + pub(super) id: &'static str, + pub(super) title: &'static str, + pub(super) unit: &'static str, + pub(super) value_kind: MetricsValueKind, + pub(super) points: Vec<MetricsPoint>, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct MetricsPoint { + pub(super) height: u64, + pub(super) value: f64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) enum MetricsValueKind { + Number, + Seconds, + Iuna, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct FeeEstimateResponse { + pub(super) ok: bool, + pub(super) error: Option<String>, + pub(super) bytes: Option<usize>, + pub(super) fee: Option<Amount>, +} + +#[derive(Debug, Serialize)] +pub(super) struct WalletSetupResponse { + pub(super) ok: bool, + pub(super) error: Option<String>, + pub(super) address: Option<String>, + pub(super) seed_phrase: Option<String>, + pub(super) dev_verify_bypass: bool, + pub(super) requires_peer: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct WalletTransactionRow { + pub(super) kind: &'static str, + pub(super) from: String, + pub(super) to: Option<String>, + pub(super) amount: Amount, + pub(super) fee: Amount, + pub(super) inputs: Vec<UiTxInput>, + pub(super) outputs: Vec<TxOutput>, + pub(super) change: Vec<TxOutput>, + pub(super) signature: String, + pub(super) status: &'static str, + pub(super) block_height: Option<u64>, + pub(super) timestamp_ms: Option<u64>, + pub(super) block_finalizer: Option<String>, + pub(super) direction: &'static str, + pub(super) blinded: bool, + pub(super) difficulty_bits: Option<u32>, + pub(super) proof_bits: Option<u32>, + pub(super) proof_hash: Option<String>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct WalletUtxoRow { + pub(super) outpoint: OutPoint, + pub(super) address: String, + pub(super) amount: Amount, + pub(super) spendable: bool, +} + +#[derive(Clone, Debug)] +pub(super) struct WalletTransactionContext { + pub(super) status: &'static str, + pub(super) block_height: Option<u64>, + pub(super) timestamp_ms: Option<u64>, + pub(super) block_finalizer: Option<String>, + pub(super) blinded: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(super) struct UiBlock { + pub(super) height: u64, + pub(super) prev_hash: String, + pub(super) timestamp_ms: u64, + pub(super) miner: String, + pub(super) finalizer_mode: crate::domain::FinalizerMode, + pub(super) finalizer_rank: u32, + pub(super) reward: Amount, + pub(super) total_fees: Amount, + pub(super) total_bytes: usize, + pub(super) transaction_bytes: usize, + pub(super) transaction_byte_breakdown: Vec<UiByteBreakdown>, + pub(super) blinded_transaction_bytes: usize, + pub(super) reveal_bundle_bytes: usize, + pub(super) vdf_rounds: u64, + pub(super) vdf_output: String, + pub(super) leader_proof: Option<crate::domain::LeaderProof>, + pub(super) burn_leader_ranks: Vec<BurnLeaderRank>, + pub(super) transactions: Vec<UiTransaction>, + pub(super) revealed_transactions: Vec<UiTransaction>, + pub(super) reveal_bundles: Vec<UiRevealBundle>, + pub(super) hash: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(super) struct UiByteBreakdown { + pub(super) label: &'static str, + pub(super) bytes: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct UiRevealBundle { + pub(super) slot: u8, + pub(super) member: String, + pub(super) hash: String, + pub(super) byte_size: usize, + pub(super) reveals: Vec<UiTransaction>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(super) struct UiTransaction { + pub(super) kind: &'static str, + pub(super) from: String, + pub(super) to: Option<String>, + pub(super) amount: Amount, + pub(super) fee: Amount, + pub(super) inputs: Vec<UiTxInput>, + pub(super) outputs: Vec<TxOutput>, + pub(super) change: Vec<TxOutput>, + pub(super) signature: String, + pub(super) difficulty_bits: Option<u32>, + pub(super) proof_bits: Option<u32>, + pub(super) proof_hash: Option<String>, + pub(super) commitment: Option<String>, + pub(super) encrypted_size: Option<u32>, + pub(super) expires_at_height: Option<u64>, + pub(super) revealed: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub(super) struct UiTxInput { + pub(super) outpoint: OutPoint, + pub(super) owner: String, + pub(super) signature: String, + pub(super) amount: Option<Amount>, + pub(super) address: Option<String>, +} diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -0,0 +1,876 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use crate::domain::{ + Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, + BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, + ChainSnapshot, Ledger, MINE_REWARD, OutPoint, REVEAL_COMMITTEE_SIZE, + RevealedBlindedTransaction, Transaction, TxInput, TxOutput, blinded_reveal_finalizer_fee, + hex_hash, revealed_blinded_transactions, +}; + +use super::{ + HttpState, UiChainView, + types::{ + UiBlock, UiByteBreakdown, UiRevealBundle, UiTransaction, UiTxInput, + WalletTransactionContext, WalletTransactionFilters, WalletTransactionRow, + }, +}; + +pub(super) fn wallet_transaction_rows( + wallet: &str, + pending: Vec<Transaction>, + owned_blinded: Vec<Transaction>, + chain: &[Block], + revealed_by_height: &BTreeMap<u64, Vec<RevealedBlindedTransaction>>, + outputs: &BTreeMap<OutPoint, TxOutput>, + filters: WalletTransactionFilters, +) -> Vec<WalletTransactionRow> { + let mut rows = Vec::new(); + let pending_context = WalletTransactionContext { + status: "pending", + block_height: None, + timestamp_ms: None, + block_finalizer: None, + blinded: false, + }; + + for (index, tx) in pending.iter().enumerate() { + if !filters.allows(tx) { + continue; + } + if let Some(row) = wallet_transaction_row(wallet, tx, outputs, &pending_context) { + rows.push((u128::MAX - index as u128, row)); + } + } + + let pending_blind_context = WalletTransactionContext { + blinded: true, + ..pending_context + }; + for (index, tx) in owned_blinded.iter().enumerate() { + if !filters.allows(tx) { + continue; + } + if let Some(row) = wallet_transaction_row(wallet, tx, outputs, &pending_blind_context) { + rows.push((u128::MAX - 10_000 - index as u128, row)); + } + } + + for block in chain { + for (index, tx) in block.transactions.iter().rev().enumerate() { + if !filters.allows(tx) { + continue; + } + if let Some(row) = wallet_transaction_row( + wallet, + tx, + outputs, + &WalletTransactionContext { + status: "confirmed", + block_height: Some(block.height), + timestamp_ms: Some(block.timestamp_ms), + block_finalizer: Some(block.miner.clone()), + blinded: false, + }, + ) { + rows.push((block.height as u128 * 10_000 + index as u128, row)); + } + } + if let Some(revealed_transactions) = revealed_by_height.get(&block.height) { + for (index, revealed) in revealed_transactions.iter().rev().enumerate() { + let tx = &revealed.transaction; + if !filters.allows(tx) { + continue; + } + if let Some(row) = wallet_transaction_row( + wallet, + tx, + outputs, + &WalletTransactionContext { + status: "confirmed", + block_height: Some(block.height), + timestamp_ms: Some(block.timestamp_ms), + block_finalizer: Some(block.miner.clone()), + blinded: false, + }, + ) { + rows.push((block.height as u128 * 10_000 + 5_000 + index as u128, row)); + } + } + } + } + + rows.sort_by(|left, right| right.0.cmp(&left.0)); + rows.into_iter().map(|(_, row)| row).collect() +} + +fn wallet_transaction_row( + wallet: &str, + tx: &Transaction, + outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, + context: &WalletTransactionContext, +) -> Option<WalletTransactionRow> { + match tx { + Transaction::Transfer { + inputs, + outputs, + fee, + signature, + } if tx.sender() == wallet || tx.to() == Some(wallet) => Some(WalletTransactionRow { + kind: "transfer", + from: tx.sender().to_string(), + to: tx.to().map(str::to_string), + amount: tx.amount(), + fee: *fee, + inputs: ui_inputs(inputs, outputs_by_outpoint), + outputs: outputs.clone(), + change: Vec::new(), + signature: signature.clone(), + status: context.status, + block_height: context.block_height, + timestamp_ms: context.timestamp_ms, + block_finalizer: context.block_finalizer.clone(), + direction: if tx.to() == Some(wallet) { + "received" + } else { + "sent" + }, + blinded: context.blinded, + difficulty_bits: None, + proof_bits: None, + proof_hash: None, + }), + Transaction::Burn { + inputs, + change, + amount, + fee, + signature, + } if tx.sender() == wallet => Some(WalletTransactionRow { + kind: "burn", + from: tx.sender().to_string(), + to: None, + amount: *amount, + fee: *fee, + inputs: ui_inputs(inputs, outputs_by_outpoint), + outputs: Vec::new(), + change: change.clone(), + signature: signature.clone(), + status: context.status, + block_height: context.block_height, + timestamp_ms: context.timestamp_ms, + block_finalizer: context.block_finalizer.clone(), + direction: "burned", + blinded: context.blinded, + difficulty_bits: None, + proof_bits: None, + proof_hash: None, + }), + Transaction::Mine { + recipient, + difficulty_bits, + signature, + .. + } if recipient == wallet => Some(WalletTransactionRow { + kind: "mine", + from: "pow".to_string(), + to: Some(recipient.clone()), + amount: MINE_REWARD, + fee: tx.fee(), + inputs: Vec::new(), + outputs: vec![TxOutput { + address: recipient.clone(), + amount: MINE_REWARD, + }], + change: Vec::new(), + signature: signature.clone(), + status: context.status, + block_height: context.block_height, + timestamp_ms: context.timestamp_ms, + block_finalizer: context.block_finalizer.clone(), + direction: "received", + blinded: context.blinded, + difficulty_bits: Some(*difficulty_bits), + proof_bits: Some(proof_bits(signature)), + proof_hash: Some(signature.clone()), + }), + _ => None, + } +} + +pub(super) fn revealed_transactions_by_height( + snapshot: &ChainSnapshot, +) -> BTreeMap<u64, Vec<RevealedBlindedTransaction>> { + revealed_blinded_transactions(snapshot) + .unwrap_or_default() + .into_iter() + .fold( + BTreeMap::<u64, Vec<RevealedBlindedTransaction>>::new(), + |mut by_height, revealed| { + by_height.entry(revealed.height).or_default().push(revealed); + by_height + }, + ) +} + +#[cfg(test)] +pub(super) fn ui_blocks( + blocks: Vec<Block>, + snapshot: &ChainSnapshot, + pending: &[Transaction], + burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, +) -> Vec<UiBlock> { + let outputs = known_output_index(snapshot, pending); + let revealed = revealed_transactions_by_height(snapshot); + ui_blocks_from_indexes(blocks, &outputs, &revealed, burn_leader_ranks) +} + +pub(super) fn ui_blocks_from_indexes( + blocks: Vec<Block>, + outputs: &BTreeMap<OutPoint, TxOutput>, + revealed: &BTreeMap<u64, Vec<RevealedBlindedTransaction>>, + burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, +) -> Vec<UiBlock> { + blocks + .into_iter() + .map(|block| { + let revealed_transactions = revealed.get(&block.height).cloned().unwrap_or_default(); + ui_block(block, outputs, burn_leader_ranks, &revealed_transactions) + }) + .collect() +} + +pub(super) fn ui_block( + block: Block, + outputs: &BTreeMap<OutPoint, TxOutput>, + burn_leader_ranks: &BTreeMap<String, Vec<BurnLeaderRank>>, + revealed_transactions: &[RevealedBlindedTransaction], +) -> UiBlock { + let ranks = burn_leader_ranks + .get(&block.hash) + .cloned() + .unwrap_or_default(); + let revealed_fees = revealed_transactions.iter().fold(0_u64, |total, revealed| { + total.saturating_add(revealed.transaction.fee()) + }); + let transaction_bytes = block + .transactions + .iter() + .map(|tx| tx.serialized_size_bytes().unwrap_or_default()) + .sum::<usize>(); + let transaction_byte_breakdown = transaction_byte_breakdown(&block.transactions); + let blinded_transaction_bytes = block + .blinded_transactions + .iter() + .map(|tx| tx.serialized_size_bytes().unwrap_or_default()) + .sum::<usize>(); + let mut transactions = block + .transactions + .iter() + .map(|tx| ui_transaction(tx, outputs)) + .collect::<Vec<_>>(); + transactions.extend( + block + .blinded_transactions + .iter() + .map(|transaction| ui_blinded_transaction(transaction, outputs)), + ); + transactions.extend( + revealed_transactions + .iter() + .map(|revealed| ui_revealed_transaction(&revealed.transaction, outputs)), + ); + let revealed_by_commitment = revealed_transactions + .iter() + .map(|revealed| (revealed.commitment.clone(), revealed.transaction.clone())) + .collect::<BTreeMap<_, _>>(); + let reveal_bundles: Vec<UiRevealBundle> = block + .reveal_bundle_section + .expand(block.height, &block.prev_hash) + .into_iter() + .map(|bundle| UiRevealBundle { + slot: bundle.slot, + member: bundle.member.clone(), + hash: bundle.bundle_hash(), + byte_size: bundle.serialized_size_bytes().unwrap_or_default(), + reveals: bundle + .reveals + .iter() + .map(|reveal| { + revealed_by_commitment + .get(&reveal.commitment) + .map(|tx| ui_revealed_transaction(tx, outputs)) + .unwrap_or_else(|| ui_blinded_reveal(reveal)) + }) + .collect(), + }) + .collect(); + let reveal_bundle_bytes = reveal_bundles + .iter() + .map(|bundle: &UiRevealBundle| bundle.byte_size) + .sum::<usize>(); + let total_bytes = block.serialized_size_bytes().unwrap_or_else(|_| { + transaction_bytes + .saturating_add(blinded_transaction_bytes) + .saturating_add(reveal_bundle_bytes) + }); + UiBlock { + height: block.height, + prev_hash: block.prev_hash, + timestamp_ms: block.timestamp_ms, + miner: block.miner, + finalizer_mode: block.finalizer_mode, + finalizer_rank: block.finalizer_rank, + reward: block.reward, + total_fees: block.reward.saturating_add(revealed_fees), + total_bytes, + transaction_bytes, + transaction_byte_breakdown, + blinded_transaction_bytes, + reveal_bundle_bytes, + vdf_rounds: block.vdf_rounds, + vdf_output: block.vdf_output, + leader_proof: block.leader_proof, + burn_leader_ranks: ranks, + transactions, + revealed_transactions: revealed_transactions + .iter() + .map(|revealed| ui_revealed_transaction(&revealed.transaction, outputs)) + .collect(), + reveal_bundles, + hash: block.hash, + } +} + +fn ui_revealed_transaction( + transaction: &Transaction, + outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, +) -> UiTransaction { + let mut row = ui_transaction(transaction, outputs_by_outpoint); + row.revealed = true; + row +} + +fn transaction_byte_breakdown(transactions: &[Transaction]) -> Vec<UiByteBreakdown> { + let mut transfer_bytes = 0_usize; + let mut burn_bytes = 0_usize; + let mut mine_bytes = 0_usize; + for transaction in transactions { + let bytes = transaction.serialized_size_bytes().unwrap_or_default(); + match transaction { + Transaction::Transfer { .. } => transfer_bytes = transfer_bytes.saturating_add(bytes), + Transaction::Burn { .. } => burn_bytes = burn_bytes.saturating_add(bytes), + Transaction::Mine { .. } => mine_bytes = mine_bytes.saturating_add(bytes), + } + } + [ + ("transfer", transfer_bytes), + ("burn", burn_bytes), + ("mine", mine_bytes), + ] + .into_iter() + .filter_map(|(label, bytes)| (bytes > 0).then_some(UiByteBreakdown { label, bytes })) + .collect() +} + +pub(super) fn ui_pending_revealed_transaction( + revealed: &RevealedBlindedTransaction, + outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, +) -> UiTransaction { + let mut row = ui_revealed_transaction(&revealed.transaction, outputs_by_outpoint); + row.commitment = Some(revealed.commitment.clone()); + row +} + +pub(super) fn ui_transaction( + transaction: &Transaction, + outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, +) -> UiTransaction { + match transaction { + Transaction::Transfer { + inputs, + outputs, + fee, + signature, + } => UiTransaction { + kind: "transfer", + from: transaction.sender().to_string(), + to: transaction.to().map(str::to_string), + amount: transaction.amount(), + fee: *fee, + inputs: ui_inputs(inputs, outputs_by_outpoint), + outputs: outputs.clone(), + change: Vec::new(), + signature: signature.clone(), + difficulty_bits: None, + proof_bits: None, + proof_hash: None, + commitment: None, + encrypted_size: None, + expires_at_height: None, + revealed: false, + }, + Transaction::Burn { + inputs, + change, + amount, + fee, + signature, + } => UiTransaction { + kind: "burn", + from: transaction.sender().to_string(), + to: None, + amount: *amount, + fee: *fee, + inputs: ui_inputs(inputs, outputs_by_outpoint), + outputs: Vec::new(), + change: change.clone(), + signature: signature.clone(), + difficulty_bits: None, + proof_bits: None, + proof_hash: None, + commitment: None, + encrypted_size: None, + expires_at_height: None, + revealed: false, + }, + Transaction::Mine { + recipient, + difficulty_bits, + signature, + .. + } => UiTransaction { + kind: "mine", + from: "pow".to_string(), + to: Some(recipient.clone()), + amount: MINE_REWARD, + fee: transaction.fee(), + inputs: Vec::new(), + outputs: vec![TxOutput { + address: recipient.clone(), + amount: MINE_REWARD, + }], + change: Vec::new(), + signature: signature.clone(), + difficulty_bits: Some(*difficulty_bits), + proof_bits: Some(proof_bits(signature)), + proof_hash: Some(signature.clone()), + commitment: None, + encrypted_size: None, + expires_at_height: None, + revealed: false, + }, + } +} + +pub(super) fn ui_blinded_transaction( + transaction: &BlindedTransaction, + outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, +) -> UiTransaction { + UiTransaction { + kind: "blinded", + from: transaction + .inputs + .first() + .map(|input| input.owner.clone()) + .unwrap_or_else(|| "encrypted".to_string()), + to: None, + amount: 0, + fee: transaction.fee, + inputs: ui_inputs(&transaction.inputs, outputs_by_outpoint), + outputs: Vec::new(), + change: Vec::new(), + signature: transaction.commitment.clone(), + difficulty_bits: None, + proof_bits: None, + proof_hash: None, + commitment: Some(transaction.commitment.clone()), + encrypted_size: Some(transaction.encrypted_size), + expires_at_height: Some(transaction.expires_at_height), + revealed: false, + } +} + +pub(super) fn ui_blinded_reveal(reveal: &BlindedReveal) -> UiTransaction { + UiTransaction { + kind: "reveal", + from: "encrypted".to_string(), + to: None, + amount: 0, + fee: 0, + inputs: Vec::new(), + outputs: Vec::new(), + change: Vec::new(), + signature: reveal.commitment.clone(), + difficulty_bits: None, + proof_bits: None, + proof_hash: None, + commitment: Some(reveal.commitment.clone()), + encrypted_size: None, + expires_at_height: None, + revealed: false, + } +} + +fn ui_inputs( + inputs: &[TxInput], + outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, +) -> Vec<UiTxInput> { + inputs + .iter() + .map(|input| { + let spent_output = outputs_by_outpoint.get(&input.outpoint); + UiTxInput { + outpoint: input.outpoint.clone(), + owner: input.owner.clone(), + signature: input.signature.clone(), + amount: spent_output.map(|output| output.amount), + address: spent_output.map(|output| output.address.clone()), + } + }) + .collect() +} + +fn proof_bits(hex_hash: &str) -> u32 { + let mut bits = 0_u32; + for byte in hex_hash.as_bytes() { + let Some(nibble) = hex_nibble(*byte) else { + break; + }; + if nibble == 0 { + bits += 4; + continue; + } + bits += nibble.leading_zeros() - 4; + break; + } + bits +} + +fn hex_nibble(byte: u8) -> Option<u8> { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +pub(super) fn known_output_index( + snapshot: &ChainSnapshot, + pending: &[Transaction], +) -> BTreeMap<OutPoint, TxOutput> { + let mut outputs = known_chain_output_index(snapshot); + add_pending_outputs(&mut outputs, pending); + outputs +} + +pub(super) async fn cached_chain_view(state: &HttpState, snapshot: &ChainSnapshot) -> UiChainView { + let tip_hash = snapshot.blocks.last().map(|block| block.hash.clone()); + { + let cache = state.ui_cache.lock().await; + if cache.tip_hash == tip_hash { + return UiChainView { + outputs: cache.outputs.clone(), + revealed_by_height: cache.revealed_by_height.clone(), + }; + } + } + + let outputs = known_chain_output_index(snapshot); + let revealed_by_height = revealed_transactions_by_height(snapshot); + + let mut cache = state.ui_cache.lock().await; + if cache.tip_hash == tip_hash { + return UiChainView { + outputs: cache.outputs.clone(), + revealed_by_height: cache.revealed_by_height.clone(), + }; + } + + let view = UiChainView { + outputs, + revealed_by_height, + }; + cache.tip_hash = tip_hash; + cache.outputs = view.outputs.clone(); + cache.revealed_by_height = view.revealed_by_height.clone(); + UiChainView { + outputs: view.outputs, + revealed_by_height: view.revealed_by_height, + } +} + +fn known_chain_output_index(snapshot: &ChainSnapshot) -> BTreeMap<OutPoint, TxOutput> { + let mut outputs = BTreeMap::new(); + for (address, amount) in &snapshot.genesis_allocations { + if *amount == 0 { + continue; + } + outputs.insert( + genesis_allocation_outpoint(address), + TxOutput { + address: address.clone(), + amount: *amount, + }, + ); + } + let revealed = revealed_blinded_transactions(snapshot).unwrap_or_default(); + let blocks_by_height = snapshot + .blocks + .iter() + .map(|block| (block.height, block)) + .collect::<BTreeMap<_, _>>(); + let reveal_bundle_slots_by_height = Ledger::from_persisted_snapshot(snapshot.clone()) + .ok() + .map(|ledger| { + snapshot + .blocks + .iter() + .map(|block| { + let slots = ledger + .burn_leader_ranks_for_block(block.height) + .map(|ranks| ranks.len()) + .unwrap_or(REVEAL_COMMITTEE_SIZE); + (block.height, slots) + }) + .collect::<BTreeMap<_, _>>() + }) + .unwrap_or_default(); + let blinded_by_commitment = snapshot + .blocks + .iter() + .flat_map(|block| block.blinded_transactions.iter()) + .map(|transaction| (transaction.commitment.clone(), transaction.clone())) + .collect::<BTreeMap<_, _>>(); + for block in &snapshot.blocks { + for transaction in &block.transactions { + index_transaction_outputs(&mut outputs, transaction); + } + if block.reward > 0 { + outputs.insert( + reward_outpoint(&block.hash), + TxOutput { + address: block.miner.clone(), + amount: block.reward, + }, + ); + } + } + for revealed in revealed { + index_transaction_outputs(&mut outputs, &revealed.transaction); + let fee = revealed.transaction.fee(); + if matches!(revealed.transaction, Transaction::Mine { .. }) { + if let Some(commit) = blinded_by_commitment.get(&revealed.commitment) { + index_blinded_collateral_change(&mut outputs, commit, fee); + } + } + if fee > 0 { + let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); + if committer_fee > 0 { + outputs.insert( + blinded_committer_fee_outpoint(&revealed.commitment), + TxOutput { + address: revealed.included_by, + amount: committer_fee, + }, + ); + } + if let Some(block) = blocks_by_height.get(&revealed.height) { + let reveal_finalizer_fee = blinded_reveal_finalizer_fee( + fee, + block.included_reveal_bundle_count(), + reveal_bundle_slots_by_height + .get(&revealed.height) + .copied() + .unwrap_or(REVEAL_COMMITTEE_SIZE), + ); + if reveal_finalizer_fee > 0 { + outputs.insert( + blinded_executor_fee_outpoint(&revealed.commitment), + TxOutput { + address: block.miner.clone(), + amount: reveal_finalizer_fee, + }, + ); + } + let reveal_bundle_signer_fee = + blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); + if reveal_bundle_signer_fee > 0 { + for signature in &block.reveal_bundle_section.signatures { + outputs.insert( + blinded_reveal_bundle_signer_fee_outpoint( + &revealed.commitment, + signature.slot, + ), + TxOutput { + address: signature.member.clone(), + amount: reveal_bundle_signer_fee, + }, + ); + } + } + } + } + } + index_expired_blinded_outputs(&mut outputs, snapshot); + outputs +} + +pub(super) fn add_pending_outputs( + outputs: &mut BTreeMap<OutPoint, TxOutput>, + pending: &[Transaction], +) { + for transaction in pending { + index_transaction_outputs(outputs, transaction); + } +} + +fn index_blinded_collateral_change( + outputs: &mut BTreeMap<OutPoint, TxOutput>, + transaction: &BlindedTransaction, + fee: Amount, +) { + let Some(first_input) = transaction.inputs.first() else { + return; + }; + let locked_total = transaction.inputs.iter().fold(0_u64, |total, input| { + total.saturating_add( + outputs + .get(&input.outpoint) + .map(|output| output.amount) + .unwrap_or_default(), + ) + }); + if fee >= locked_total { + return; + } + outputs.insert( + blinded_expiry_change_outpoint(&transaction.commitment), + TxOutput { + address: first_input.owner.clone(), + amount: locked_total - fee, + }, + ); +} + +fn index_expired_blinded_outputs( + outputs: &mut BTreeMap<OutPoint, TxOutput>, + snapshot: &ChainSnapshot, +) { + let mut active = BTreeMap::<String, (BlindedTransaction, Amount)>::new(); + for block in &snapshot.blocks { + let revealed = block + .all_blinded_reveals() + .into_iter() + .map(|reveal| reveal.commitment.clone()) + .collect::<BTreeSet<_>>(); + active.retain(|commitment, (transaction, locked_total)| { + if revealed.contains(commitment) { + return false; + } + if block.height >= transaction.expires_at_height { + if let Some(first_input) = transaction.inputs.first() { + if transaction.fee <= *locked_total { + let change = *locked_total - transaction.fee; + if change > 0 { + outputs.insert( + blinded_expiry_change_outpoint(commitment), + TxOutput { + address: first_input.owner.clone(), + amount: change, + }, + ); + } + } + } + return false; + } + true + }); + for transaction in &block.blinded_transactions { + let locked_total = transaction.inputs.iter().fold(0_u64, |total, input| { + total.saturating_add( + outputs + .get(&input.outpoint) + .map(|output| output.amount) + .unwrap_or_default(), + ) + }); + active.insert( + transaction.commitment.clone(), + (transaction.clone(), locked_total), + ); + } + } +} + +fn index_transaction_outputs( + outputs: &mut BTreeMap<OutPoint, TxOutput>, + transaction: &Transaction, +) { + let created_outputs = match transaction { + Transaction::Transfer { outputs, .. } => outputs.clone(), + Transaction::Burn { change, .. } => change.clone(), + Transaction::Mine { recipient, .. } => vec![TxOutput { + address: recipient.clone(), + amount: MINE_REWARD, + }], + }; + for (index, output) in created_outputs.iter().enumerate() { + outputs.insert( + OutPoint { + txid: transaction.signature().to_string(), + index: index as u32, + }, + output.clone(), + ); + } +} + +fn genesis_allocation_outpoint(address: &str) -> OutPoint { + OutPoint { + txid: hex_hash(format!("iuna-genesis-allocation:{address}")), + index: 0, + } +} + +fn reward_outpoint(block_hash: &str) -> OutPoint { + OutPoint { + txid: block_hash.to_string(), + index: u32::MAX, + } +} + +fn blinded_committer_fee_outpoint(commitment: &str) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: u32::MAX - 1, + } +} + +fn blinded_executor_fee_outpoint(commitment: &str) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: u32::MAX - 2, + } +} + +fn blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: u32::MAX - 3 - u32::from(slot), + } +} + +fn blinded_expiry_change_outpoint(commitment: &str) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: 0, + } +} + +fn blinded_fee_share(fee: Amount, bps: u64) -> Amount { + ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount +} diff --git a/src/adapters/http/wallet.rs b/src/adapters/http/wallet.rs @@ -0,0 +1,238 @@ +use anyhow::{Context, Result, bail}; +use axum::{Json, extract::State, http::HeaderMap}; + +use crate::{ + adapters::wallet_store, + app::FeeEstimate, + domain::{Amount, MINE_FINALIZER_FEE, OutPoint}, +}; + +use super::{ + HttpState, + types::{BurnSettingsForm, FeeEstimateResponse, TransferForm, WalletSetupResponse}, + wallet_password_for_request, +}; + +pub(super) async fn api_wallet_setup( + State(state): State<HttpState>, + headers: HeaderMap, +) -> Json<WalletSetupResponse> { + wallet_setup_json(wallet_setup_response(&state, &headers).await) +} + +pub(super) async fn wallet_setup_response( + state: &HttpState, + headers: &HeaderMap, +) -> Result<WalletSetupResponse> { + let setup_complete = state.ui_config.lock().await.setup_complete; + let password = wallet_password_for_request(state, headers).await; + let seed_phrase = if setup_complete { + None + } else { + wallet_store::setup_seed_phrase_with_password(&state.wallet_path, password.as_deref())? + }; + let address = state.node.lock().await.wallet_address().to_string(); + Ok(WalletSetupResponse { + ok: true, + error: None, + address: Some(address), + seed_phrase, + dev_verify_bypass: dev_seed_verify_bypass_enabled(), + requires_peer: setup_requires_peer(state).await, + }) +} + +pub(super) async fn setup_requires_peer(state: &HttpState) -> bool { + !state.node.lock().await.has_real_chain() +} + +pub(super) async fn replace_setup_wallet_with_generated_seed( + state: &HttpState, + headers: &HeaderMap, +) -> Result<WalletSetupResponse> { + ensure_wallet_setup_open(state).await?; + let password = wallet_password_for_request(state, headers) + .await + .context("wallet password session is required")?; + let (wallet, seed_phrase) = + wallet_store::replace_with_generated_seed_phrase_encrypted(&state.wallet_path, &password)?; + let address = wallet.address().to_string(); + state.node.lock().await.replace_wallet(wallet); + Ok(WalletSetupResponse { + ok: true, + error: None, + address: Some(address), + seed_phrase: Some(seed_phrase), + dev_verify_bypass: dev_seed_verify_bypass_enabled(), + requires_peer: setup_requires_peer(state).await, + }) +} + +pub(super) async fn import_setup_wallet_seed( + state: &HttpState, + headers: &HeaderMap, + seed_phrase: &str, +) -> Result<WalletSetupResponse> { + ensure_wallet_setup_open(state).await?; + let password = wallet_password_for_request(state, headers) + .await + .context("wallet password session is required")?; + let wallet = wallet_store::replace_with_imported_seed_phrase_encrypted( + &state.wallet_path, + seed_phrase, + &password, + )?; + let address = wallet.address().to_string(); + state.node.lock().await.replace_wallet(wallet); + Ok(WalletSetupResponse { + ok: true, + error: None, + address: Some(address), + seed_phrase: None, + dev_verify_bypass: dev_seed_verify_bypass_enabled(), + requires_peer: setup_requires_peer(state).await, + }) +} + +async fn ensure_wallet_setup_open(state: &HttpState) -> Result<()> { + let setup_complete = state.ui_config.lock().await.setup_complete; + if setup_complete { + bail!("wallet setup is already complete"); + } + Ok(()) +} + +pub(super) fn wallet_setup_json(result: Result<WalletSetupResponse>) -> Json<WalletSetupResponse> { + match result { + Ok(response) => Json(response), + Err(error) => Json(WalletSetupResponse { + ok: false, + error: Some(format!("{error:#}")), + address: None, + seed_phrase: None, + dev_verify_bypass: dev_seed_verify_bypass_enabled(), + requires_peer: false, + }), + } +} + +fn dev_seed_verify_bypass_enabled() -> bool { + dev_seed_verify_bypass_allowed(std::env::var_os("IUNA_DEV_SKIP_SEED_VERIFY").is_some()) +} + +pub(super) fn dev_seed_verify_bypass_allowed(env_present: bool) -> bool { + env_present +} + +pub(super) async fn transfer(state: &HttpState, form: TransferForm) -> Result<()> { + let (to, amount, fee_per_byte, selected_utxos) = validate_transfer_form(form)?; + + let result = { + let mut node = state.node.lock().await; + let result = node.transfer_with_fee_rate(to, amount, fee_per_byte, &selected_utxos); + let outbox = node.drain_outbox(); + (result, outbox) + }; + + match result.0 { + Ok(_) => state.gossip.broadcast(result.1).await, + Err(error) => Err(error), + } +} + +pub(super) fn validate_transfer_form( + form: TransferForm, +) -> Result<(String, Amount, Amount, Vec<OutPoint>)> { + let to = form.to.trim(); + if to.is_empty() { + bail!("recipient is required"); + } + if form.amount == 0 { + bail!("amount must be greater than zero"); + } + let fee = required_fee_per_byte_transfer(&form)?; + let selected_utxos = form + .utxos + .lines() + .flat_map(|line| line.split(',')) + .map(str::trim) + .filter(|value| !value.trim().is_empty()) + .map(parse_outpoint) + .collect::<Result<Vec<_>>>()?; + Ok((to.to_string(), form.amount, fee, selected_utxos)) +} + +pub(super) async fn estimate_transfer_fee( + state: &HttpState, + form: TransferForm, +) -> Result<FeeEstimate> { + let (to, amount, fee_per_byte, selected_utxos) = validate_transfer_form(form)?; + state + .node + .lock() + .await + .estimate_transfer_fee(to, amount, fee_per_byte, &selected_utxos) +} + +pub(super) async fn estimate_burn_fee( + state: &HttpState, + form: BurnSettingsForm, +) -> Result<FeeEstimate> { + let fee_per_byte = required_fee_per_byte_burn(&form)?; + if form.amount == 0 { + bail!("amount must be greater than zero"); + } + state + .node + .lock() + .await + .estimate_burn_fee(form.amount, fee_per_byte) +} + +pub(super) async fn estimate_mine_fee(state: &HttpState) -> Result<FeeEstimate> { + state + .node + .lock() + .await + .estimate_mine_fee(MINE_FINALIZER_FEE) +} + +fn required_fee_per_byte_transfer(form: &TransferForm) -> Result<Amount> { + form.fee_per_byte.context("fee per byte is required") +} + +pub(super) fn required_fee_per_byte_burn(form: &BurnSettingsForm) -> Result<Amount> { + form.fee_per_byte.context("fee per byte is required") +} + +pub(super) fn fee_estimate_json(result: Result<FeeEstimate>) -> Json<FeeEstimateResponse> { + match result { + Ok(estimate) => Json(FeeEstimateResponse { + ok: true, + error: None, + bytes: Some(estimate.bytes), + fee: Some(estimate.fee), + }), + Err(error) => Json(FeeEstimateResponse { + ok: false, + error: Some(format!("{error:#}")), + bytes: None, + fee: None, + }), + } +} + +fn parse_outpoint(value: &str) -> Result<OutPoint> { + let (txid, index) = value + .rsplit_once(':') + .with_context(|| format!("invalid UTXO reference {value}"))?; + if txid.is_empty() { + bail!("invalid UTXO reference {value}"); + } + Ok(OutPoint { + txid: txid.to_string(), + index: index + .parse::<u32>() + .with_context(|| format!("invalid UTXO reference {value}"))?, + }) +} diff --git a/src/adapters/http/wallet_persistence.rs b/src/adapters/http/wallet_persistence.rs @@ -0,0 +1,52 @@ +use std::time::Duration; + +use tokio::time::sleep; + +use crate::adapters::wallet_store; + +use super::{HttpState, now_ms}; + +pub(super) async fn run_owned_blinded_outbox_persistence(state: HttpState) { + loop { + sleep(Duration::from_millis(500)).await; + let (version, entries) = { + let node = state.node.lock().await; + let version = node.owned_blinded_outbox_version(); + if version == 0 { + continue; + } + (version, node.owned_blinded_transactions()) + }; + let password = wallet_persistence_password(&state).await; + match wallet_store::replace_owned_blinded_transactions( + &state.wallet_path, + password.as_deref(), + entries, + ) { + Ok(()) => { + state + .node + .lock() + .await + .mark_owned_blinded_outbox_persisted(version); + } + Err(error) if format!("{error:#}").contains("wallet is encrypted") => {} + Err(error) => eprintln!("failed to persist owned blinded transactions: {error:#}"), + } + } +} + +async fn wallet_persistence_password(state: &HttpState) -> Option<String> { + let metadata = wallet_store::metadata(&state.wallet_path).ok().flatten()?; + if !metadata.encrypted { + return None; + } + let now = now_ms(); + state + .auth_sessions + .lock() + .await + .values() + .find(|session| session.expires_at > now) + .map(|session| session.wallet_password.clone()) +} diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -1,35 +1,66 @@ use std::{ - collections::{BTreeMap, BTreeSet, VecDeque}, - io::ErrorKind, - net::{IpAddr, SocketAddr}, - sync::{ - Arc, Mutex as StdMutex, OnceLock, - atomic::{AtomicU64, Ordering}, - }, + collections::BTreeMap, + net::SocketAddr, + sync::{Arc, Mutex as StdMutex}, time::Duration, }; -use anyhow::{Context, Result, anyhow}; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; -use serde::Serialize; use tokio::{ - io::{AsyncBufReadExt, AsyncRead, AsyncWriteExt, BufReader}, - net::{ - TcpListener, TcpStream, - tcp::{OwnedReadHalf, OwnedWriteHalf}, - }, sync::{Mutex, mpsc}, task::JoinHandle, - time::{Instant, interval, interval_at, sleep, timeout}, }; -use crate::{ - app::{ - BlockInventory, GossipEnvelope, NETWORK_ID, PROTOCOL_VERSION, PeerDirection, ProtocolHello, - SharedNode, SharedPeerBook, TRANSACTION_BATCH_LIMIT, debug_logging_enabled, now_ms, - }, - domain::{Block, ChainSnapshot, Ledger, verify_vdf}, +use crate::app::{GossipEnvelope, SharedNode, SharedPeerBook}; + +mod fetch; +mod handshake; +mod identity; +mod inbound_limiter; +mod line_codec; +mod metrics; +mod network; +mod peer_addr; +mod peer_status; +mod process; +mod session; +mod sync; +#[cfg(test)] +mod test_support; +mod writer; +pub use fetch::{fetch_peer_height, fetch_snapshot, fetch_snapshot_with_announcement}; +use fetch::{ + network_adjusted_time_ms, validate_blocks_extension, validate_snapshot_extension, + verify_block_vdf, +}; +#[cfg(test)] +use handshake::verify_advertised_peer_node_id; +use handshake::{ + forget_stale_self_peer, process_hello, process_hello_with_verification, record_peer_status, +}; +#[cfg(test)] +use identity::peer_verification_response_for_node_id; +use identity::{new_node_id, peer_verification_response}; +#[cfg(test)] +use identity::{new_verification_nonce, peer_verification_response_is_valid}; +use inbound_limiter::{InboundConnectionLimiter, InboundSessionPermit, InboundSessionRejection}; +use line_codec::{LimitedLineReader, parse_envelope, read_session_envelope}; +pub use metrics::P2pMetrics; +use metrics::P2pMetricsCounters; +use peer_addr::{ + inbound_error_counts_as_misbehavior, is_possible_fork_error, is_quiet_disconnect, + is_self_peer_address_for, next_reconnect_delay as next_reconnect_delay_with_max, + normalize_advertised_peer, }; +use peer_status::PeerStatus; +use process::{process_envelope, respond_to_peer_verification_challenge}; +use session::{accept_loop, outbound_session, outbound_supervisor}; +#[cfg(test)] +use sync::catchup_payload_for_peer; +use sync::{ + apply_peer_list, envelopes_for_peer, maybe_request_catchup, push_catchup_to_peer, + write_peer_exchange, +}; +use writer::{write_envelope, write_payload}; const MAX_BLOCK_BATCH: usize = 128; const MAX_OBJECT_REQUESTS: usize = 128; @@ -52,63 +83,6 @@ const MAX_JOIN_RESPONSE_ENVELOPES: usize = 16; const MAX_PEER_VERIFICATION_ENVELOPES: usize = 8; const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(1); const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30); -static NODE_SIGNING_KEYS: OnceLock<StdMutex<BTreeMap<String, SigningKey>>> = OnceLock::new(); - -#[derive(Clone, Debug, Eq, PartialEq)] -struct PeerStatus { - height: u64, - tip_hash: String, - time_ms: u64, - request_snapshot: bool, - push_snapshot: bool, -} - -impl PeerStatus { - fn new(height: u64, tip_hash: String) -> Self { - Self::with_time(height, tip_hash, now_ms()) - } - - fn with_time(height: u64, tip_hash: String, time_ms: u64) -> Self { - Self { - height, - tip_hash, - time_ms, - request_snapshot: false, - push_snapshot: false, - } - } - - fn from_envelope(height: u64, tip_hash: String, time_ms: u64) -> Self { - Self { - height, - tip_hash, - time_ms, - request_snapshot: false, - push_snapshot: false, - } - } - - fn with_snapshot_request(height: u64, tip_hash: String, time_ms: u64) -> Self { - Self { - height, - tip_hash, - time_ms, - request_snapshot: true, - push_snapshot: false, - } - } - - fn with_snapshot_push(height: u64, tip_hash: String, time_ms: u64) -> Self { - Self { - height, - tip_hash, - time_ms, - request_snapshot: false, - push_snapshot: true, - } - } -} - type OutboundBatch = Vec<GossipEnvelope>; #[derive(Clone)] @@ -128,4437 +102,5 @@ struct GossipNetworkInner { metrics: P2pMetricsCounters, } -#[derive(Default)] -struct InboundConnectionLimiter { - active: usize, - peers: BTreeMap<IpAddr, InboundPeerLimit>, -} - -#[derive(Default)] -struct InboundPeerLimit { - active: usize, - accepted_at_ms: VecDeque<u64>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum InboundSessionRejection { - GlobalActive, - PeerActive, - PeerRate, -} - -impl InboundSessionRejection { - fn label(self) -> &'static str { - match self { - Self::GlobalActive => "global active inbound session limit", - Self::PeerActive => "per-IP active inbound session limit", - Self::PeerRate => "per-IP inbound accept rate limit", - } - } -} - -struct InboundSessionPermit { - limiter: Arc<StdMutex<InboundConnectionLimiter>>, - ip: IpAddr, -} - -struct PeerVerificationSession<'a> { - writer: &'a mut OwnedWriteHalf, - reader: &'a mut LimitedLineReader<OwnedReadHalf>, - connection_label: &'a str, -} - -impl Drop for InboundSessionPermit { - fn drop(&mut self) { - if let Ok(mut limiter) = self.limiter.lock() { - limiter.release(self.ip); - } - } -} - -impl InboundConnectionLimiter { - fn try_acquire( - &mut self, - ip: IpAddr, - now_ms: u64, - ) -> std::result::Result<(), InboundSessionRejection> { - self.prune_stale_accepts(now_ms); - if self.active >= MAX_INBOUND_SESSIONS { - return Err(InboundSessionRejection::GlobalActive); - } - - let peer = self.peers.entry(ip).or_default(); - prune_peer_accepts(peer, now_ms); - if peer.active >= MAX_INBOUND_SESSIONS_PER_IP { - return Err(InboundSessionRejection::PeerActive); - } - if peer.accepted_at_ms.len() >= MAX_INBOUND_ACCEPTS_PER_IP_PER_WINDOW { - return Err(InboundSessionRejection::PeerRate); - } - - peer.active += 1; - peer.accepted_at_ms.push_back(now_ms); - self.active += 1; - Ok(()) - } - - fn release(&mut self, ip: IpAddr) { - if self.active > 0 { - self.active -= 1; - } - if let Some(peer) = self.peers.get_mut(&ip) { - if peer.active > 0 { - peer.active -= 1; - } - } - } - - fn prune_stale_accepts(&mut self, now_ms: u64) { - self.peers.retain(|_, peer| { - prune_peer_accepts(peer, now_ms); - peer.active > 0 || !peer.accepted_at_ms.is_empty() - }); - } -} - -fn prune_peer_accepts(peer: &mut InboundPeerLimit, now_ms: u64) { - while peer.accepted_at_ms.front().is_some_and(|accepted_ms| { - now_ms.saturating_sub(*accepted_ms) >= INBOUND_ACCEPT_RATE_WINDOW_MS - }) { - peer.accepted_at_ms.pop_front(); - } -} - -#[derive(Default)] -struct P2pMetricsCounters { - inbound_sessions_started: AtomicU64, - inbound_sessions_rejected: AtomicU64, - outbound_connect_attempts: AtomicU64, - outbound_connect_successes: AtomicU64, - outbound_connect_failures: AtomicU64, - outbound_sessions_started: AtomicU64, - sessions_closed: AtomicU64, - session_failures: AtomicU64, - quiet_disconnects: AtomicU64, - envelopes_received: AtomicU64, - hello_envelopes_received: AtomicU64, - peer_status_envelopes_received: AtomicU64, - inventory_envelopes_received: AtomicU64, - data_envelopes_received: AtomicU64, - blinded_transaction_envelopes_received: AtomicU64, - blinded_transactions_received: AtomicU64, - blinded_reveal_envelopes_received: AtomicU64, - blinded_reveals_received: AtomicU64, - control_envelopes_received: AtomicU64, - bytes_received: AtomicU64, - parse_errors: AtomicU64, - empty_frames: AtomicU64, - self_peer_rejections: AtomicU64, - self_peer_skips: AtomicU64, - outbound_queue_full: AtomicU64, - outbound_queue_closed: AtomicU64, - last_session_failure: StdMutex<Option<String>>, - last_empty_frame_remote: StdMutex<Option<String>>, - last_parse_error: StdMutex<Option<String>>, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] -pub struct P2pMetrics { - pub inbound_sessions_started: u64, - pub inbound_sessions_rejected: u64, - pub outbound_connect_attempts: u64, - pub outbound_connect_successes: u64, - pub outbound_connect_failures: u64, - pub outbound_sessions_started: u64, - pub sessions_closed: u64, - pub session_failures: u64, - pub quiet_disconnects: u64, - pub envelopes_received: u64, - pub hello_envelopes_received: u64, - pub peer_status_envelopes_received: u64, - pub inventory_envelopes_received: u64, - pub data_envelopes_received: u64, - pub blinded_transaction_envelopes_received: u64, - pub blinded_transactions_received: u64, - pub blinded_reveal_envelopes_received: u64, - pub blinded_reveals_received: u64, - pub control_envelopes_received: u64, - pub bytes_received: u64, - pub parse_errors: u64, - pub empty_frames: u64, - pub self_peer_rejections: u64, - pub self_peer_skips: u64, - pub outbound_queue_full: u64, - pub outbound_queue_closed: u64, - pub last_session_failure: Option<String>, - pub last_empty_frame_remote: Option<String>, - pub last_parse_error: Option<String>, -} - -impl P2pMetricsCounters { - fn inc(counter: &AtomicU64) { - counter.fetch_add(1, Ordering::Relaxed); - } - - fn add(counter: &AtomicU64, amount: u64) { - counter.fetch_add(amount, Ordering::Relaxed); - } - - fn set_last(target: &StdMutex<Option<String>>, value: impl Into<String>) { - if let Ok(mut last) = target.lock() { - *last = Some(value.into()); - } - } - - fn snapshot(&self) -> P2pMetrics { - P2pMetrics { - inbound_sessions_started: self.inbound_sessions_started.load(Ordering::Relaxed), - inbound_sessions_rejected: self.inbound_sessions_rejected.load(Ordering::Relaxed), - outbound_connect_attempts: self.outbound_connect_attempts.load(Ordering::Relaxed), - outbound_connect_successes: self.outbound_connect_successes.load(Ordering::Relaxed), - outbound_connect_failures: self.outbound_connect_failures.load(Ordering::Relaxed), - outbound_sessions_started: self.outbound_sessions_started.load(Ordering::Relaxed), - sessions_closed: self.sessions_closed.load(Ordering::Relaxed), - session_failures: self.session_failures.load(Ordering::Relaxed), - quiet_disconnects: self.quiet_disconnects.load(Ordering::Relaxed), - envelopes_received: self.envelopes_received.load(Ordering::Relaxed), - hello_envelopes_received: self.hello_envelopes_received.load(Ordering::Relaxed), - peer_status_envelopes_received: self - .peer_status_envelopes_received - .load(Ordering::Relaxed), - inventory_envelopes_received: self.inventory_envelopes_received.load(Ordering::Relaxed), - data_envelopes_received: self.data_envelopes_received.load(Ordering::Relaxed), - blinded_transaction_envelopes_received: self - .blinded_transaction_envelopes_received - .load(Ordering::Relaxed), - blinded_transactions_received: self - .blinded_transactions_received - .load(Ordering::Relaxed), - blinded_reveal_envelopes_received: self - .blinded_reveal_envelopes_received - .load(Ordering::Relaxed), - blinded_reveals_received: self.blinded_reveals_received.load(Ordering::Relaxed), - control_envelopes_received: self.control_envelopes_received.load(Ordering::Relaxed), - bytes_received: self.bytes_received.load(Ordering::Relaxed), - parse_errors: self.parse_errors.load(Ordering::Relaxed), - empty_frames: self.empty_frames.load(Ordering::Relaxed), - self_peer_rejections: self.self_peer_rejections.load(Ordering::Relaxed), - 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), - last_session_failure: self - .last_session_failure - .lock() - .ok() - .and_then(|last| last.clone()), - last_empty_frame_remote: self - .last_empty_frame_remote - .lock() - .ok() - .and_then(|last| last.clone()), - last_parse_error: self - .last_parse_error - .lock() - .ok() - .and_then(|last| last.clone()), - } - } -} - -impl GossipNetwork { - #[cfg(test)] - pub(crate) fn new_for_tests(node: SharedNode, peers: SharedPeerBook) -> Self { - Self { - inner: Arc::new(GossipNetworkInner { - node, - peers, - listen_addr: "127.0.0.1:0".parse().unwrap(), - p2p_announce_addr: Mutex::new(None), - node_id: new_node_id(), - accept_task: Mutex::new(None), - sessions: Mutex::new(BTreeMap::new()), - inbound_limiter: Arc::new(StdMutex::new(InboundConnectionLimiter::default())), - metrics: P2pMetricsCounters::default(), - }), - } - } - - pub async fn start( - node: SharedNode, - peers: SharedPeerBook, - addr: SocketAddr, - p2p_announce_addr: Option<SocketAddr>, - accept_inbound: bool, - ) -> Result<Self> { - let network = Self { - inner: Arc::new(GossipNetworkInner { - node, - peers, - listen_addr: addr, - p2p_announce_addr: Mutex::new(p2p_announce_addr), - node_id: new_node_id(), - accept_task: Mutex::new(None), - sessions: Mutex::new(BTreeMap::new()), - inbound_limiter: Arc::new(StdMutex::new(InboundConnectionLimiter::default())), - metrics: P2pMetricsCounters::default(), - }), - }; - - if accept_inbound { - network.set_accept_inbound(true).await?; - } - tokio::spawn(outbound_supervisor(network.clone())); - network.ensure_outbound_sessions().await; - Ok(network) - } - - pub async fn set_accept_inbound(&self, enabled: bool) -> Result<()> { - let mut accept_task = self.inner.accept_task.lock().await; - if enabled { - if accept_task.is_some() { - return Ok(()); - } - let listener = TcpListener::bind(self.inner.listen_addr) - .await - .with_context(|| format!("binding p2p listener on {}", self.inner.listen_addr))?; - *accept_task = Some(tokio::spawn(accept_loop(self.clone(), listener))); - } else if let Some(task) = accept_task.take() { - task.abort(); - } - Ok(()) - } - - pub async fn accepts_inbound(&self) -> bool { - self.inner.accept_task.lock().await.is_some() - } - - pub fn listen_addr(&self) -> SocketAddr { - self.inner.listen_addr - } - - pub async fn set_p2p_announce_addr(&self, addr: Option<SocketAddr>) { - *self.inner.p2p_announce_addr.lock().await = addr; - } - - async fn advertised_addr(&self) -> Option<SocketAddr> { - if !self.accepts_inbound().await { - return None; - } - Some((*self.inner.p2p_announce_addr.lock().await).unwrap_or(self.inner.listen_addr)) - } - - async fn self_filter_addr(&self) -> Option<SocketAddr> { - if let Some(addr) = *self.inner.p2p_announce_addr.lock().await { - return Some(addr); - } - self.accepts_inbound() - .await - .then_some(self.inner.listen_addr) - } - - async fn is_self_peer(&self, address: &str) -> bool { - is_self_peer_address_for( - address, - self.inner.listen_addr, - self.self_filter_addr().await, - ) - } - - pub fn metrics(&self) -> P2pMetrics { - self.inner.metrics.snapshot() - } - - fn try_acquire_inbound_session( - &self, - ip: IpAddr, - ) -> std::result::Result<InboundSessionPermit, InboundSessionRejection> { - self.inner - .inbound_limiter - .lock() - .expect("inbound limiter mutex poisoned") - .try_acquire(ip, now_ms())?; - Ok(InboundSessionPermit { - limiter: Arc::clone(&self.inner.inbound_limiter), - ip, - }) - } - - pub async fn broadcast(&self, envelopes: Vec<GossipEnvelope>) -> Result<()> { - let envelopes = self.prepare_gossip(envelopes).await; - if envelopes.is_empty() { - return Ok(()); - } - - let sessions = self.inner.sessions.lock().await.clone(); - for (peer, sender) in sessions { - if self.inner.peers.lock().await.is_banned(&peer) { - continue; - } - match sender.try_send(envelopes.clone()) { - Ok(()) => {} - Err(mpsc::error::TrySendError::Full(_)) => { - P2pMetricsCounters::inc(&self.inner.metrics.outbound_queue_full); - } - Err(mpsc::error::TrySendError::Closed(_)) => { - P2pMetricsCounters::inc(&self.inner.metrics.outbound_queue_closed); - } - } - } - Ok(()) - } - - async fn prepare_gossip(&self, envelopes: Vec<GossipEnvelope>) -> Vec<GossipEnvelope> { - let mut blocks = Vec::new(); - let mut passthrough = Vec::new(); - - for envelope in envelopes { - match envelope { - GossipEnvelope::Block(block) => blocks.push(BlockInventory { - height: block.height, - hash: block.hash, - }), - GossipEnvelope::Blocks { blocks: batch } => { - blocks.extend(batch.into_iter().map(|block| BlockInventory { - height: block.height, - hash: block.hash, - })); - } - GossipEnvelope::Inventory { blocks: inv_blocks } => blocks.extend(inv_blocks), - other => passthrough.push(other), - } - } - - blocks.sort_by(|left, right| { - left.height - .cmp(&right.height) - .then_with(|| left.hash.cmp(&right.hash)) - }); - blocks.dedup_by(|left, right| left.hash == right.hash); - - if !blocks.is_empty() { - passthrough.push(GossipEnvelope::Inventory { blocks }); - } - passthrough - } - - pub async fn peer_exchange(&self) -> GossipEnvelope { - let advertised_addr = self.advertised_addr().await; - let self_filter_addr = self.self_filter_addr().await; - let self_addr = advertised_addr.map(|addr| addr.to_string()); - let peers = self - .inner - .peers - .lock() - .await - .addresses_except(self_addr.as_deref().unwrap_or("")) - .into_iter() - .filter(|peer| peer.parse::<SocketAddr>().is_ok()) - .filter(|peer| { - !is_self_peer_address_for(peer, self.inner.listen_addr, self_filter_addr) - }) - .collect::<Vec<_>>(); - GossipEnvelope::PeerList { - peers: self_addr.into_iter().chain(peers.into_iter()).collect(), - } - } - - async fn ensure_outbound_sessions(&self) { - self.inner - .peers - .lock() - .await - .prune_stale_inbound_peers_at(crate::app::now_ms(), STALE_INBOUND_PEER_RETENTION_MS); - let addresses = self - .inner - .peers - .lock() - .await - .connectable_addresses_at(crate::app::now_ms()); - let address_set = addresses.iter().cloned().collect::<BTreeSet<_>>(); - let self_filter_addr = self.self_filter_addr().await; - let mut sessions = self.inner.sessions.lock().await; - sessions.retain(|peer, _| { - let keep = address_set.contains(peer) - && !is_self_peer_address_for(peer, self.inner.listen_addr, self_filter_addr); - if !keep { - P2pMetricsCounters::inc(&self.inner.metrics.self_peer_skips); - } - keep - }); - for peer in addresses { - if is_self_peer_address_for(&peer, self.inner.listen_addr, self_filter_addr) { - P2pMetricsCounters::inc(&self.inner.metrics.self_peer_skips); - continue; - } - if sessions.contains_key(&peer) { - continue; - } - - let (sender, receiver) = mpsc::channel(PEER_QUEUE_SIZE); - sessions.insert(peer.clone(), sender); - tokio::spawn(outbound_session(self.clone(), peer, receiver)); - } - } - - async fn forward_outbox(&self) { - let outbox = self.inner.node.lock().await.drain_outbox(); - if let Err(error) = self.broadcast(outbox).await { - if debug_logging_enabled() { - eprintln!("p2p rebroadcast failed: {error:#}"); - } - } - } -} - -async fn accept_loop(network: GossipNetwork, listener: TcpListener) { - loop { - match listener.accept().await { - Ok((stream, remote_addr)) => { - let network = network.clone(); - let permit = match network.try_acquire_inbound_session(remote_addr.ip()) { - Ok(permit) => permit, - Err(rejection) => { - P2pMetricsCounters::inc(&network.inner.metrics.inbound_sessions_rejected); - P2pMetricsCounters::set_last( - &network.inner.metrics.last_session_failure, - format!("{remote_addr}: {}", rejection.label()), - ); - if debug_logging_enabled() { - eprintln!( - "p2p inbound connection from {remote_addr} rejected: {}", - rejection.label() - ); - } - drop(stream); - continue; - } - }; - P2pMetricsCounters::inc(&network.inner.metrics.inbound_sessions_started); - tokio::spawn(async move { - let _permit = permit; - let result = session_loop( - network.clone(), - stream, - remote_addr, - None, - mpsc::channel(1).1, - ) - .await; - match result { - Ok(()) => { - P2pMetricsCounters::inc(&network.inner.metrics.sessions_closed); - } - Err(error) if is_quiet_disconnect(&error) => { - P2pMetricsCounters::inc(&network.inner.metrics.quiet_disconnects); - } - Err(error) => { - P2pMetricsCounters::inc(&network.inner.metrics.session_failures); - P2pMetricsCounters::set_last( - &network.inner.metrics.last_session_failure, - format!("{remote_addr}: {error:#}"), - ); - if debug_logging_enabled() { - eprintln!( - "p2p inbound connection from {remote_addr} failed: {error:#}" - ); - } - } - } - }); - } - Err(error) if debug_logging_enabled() => eprintln!("p2p accept failed: {error:#}"), - Err(_) => {} - } - } -} - -async fn outbound_supervisor(network: GossipNetwork) { - let mut tick = interval(Duration::from_secs(2)); - loop { - tick.tick().await; - network.ensure_outbound_sessions().await; - } -} - -async fn outbound_session( - network: GossipNetwork, - peer: String, - mut receiver: mpsc::Receiver<OutboundBatch>, -) { - let mut reconnect_delay = INITIAL_RECONNECT_DELAY; - loop { - let self_filter_addr = network.self_filter_addr().await; - if !peer_is_connectable(&network, &peer).await - || is_self_peer_address_for(&peer, network.inner.listen_addr, self_filter_addr) - { - network.inner.sessions.lock().await.remove(&peer); - return; - } - if network.inner.peers.lock().await.is_banned(&peer) { - sleep(MAX_RECONNECT_DELAY).await; - continue; - } - P2pMetricsCounters::inc(&network.inner.metrics.outbound_connect_attempts); - let stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(&peer)).await { - Ok(Ok(stream)) => { - P2pMetricsCounters::inc(&network.inner.metrics.outbound_connect_successes); - stream - } - Ok(Err(error)) => { - P2pMetricsCounters::inc(&network.inner.metrics.outbound_connect_failures); - network - .inner - .peers - .lock() - .await - .record_error(&peer, format!("connecting to peer {peer}: {error}")); - sleep(reconnect_delay).await; - reconnect_delay = next_reconnect_delay(reconnect_delay); - continue; - } - Err(_) => { - P2pMetricsCounters::inc(&network.inner.metrics.outbound_connect_failures); - network - .inner - .peers - .lock() - .await - .record_error(&peer, format!("connecting to peer {peer}: timeout")); - sleep(reconnect_delay).await; - reconnect_delay = next_reconnect_delay(reconnect_delay); - continue; - } - }; - - reconnect_delay = INITIAL_RECONNECT_DELAY; - let remote_addr = stream.peer_addr().unwrap_or_else(|_| { - peer.parse() - .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0))) - }); - P2pMetricsCounters::inc(&network.inner.metrics.outbound_sessions_started); - let result = session_loop( - network.clone(), - stream, - remote_addr, - Some(peer.clone()), - receiver, - ) - .await; - match result { - Ok(()) => { - P2pMetricsCounters::inc(&network.inner.metrics.sessions_closed); - } - Err(error) if is_quiet_disconnect(&error) => { - P2pMetricsCounters::inc(&network.inner.metrics.quiet_disconnects); - } - Err(error) => { - P2pMetricsCounters::inc(&network.inner.metrics.session_failures); - let message = format!("{error:#}"); - P2pMetricsCounters::set_last( - &network.inner.metrics.last_session_failure, - format!("{peer}: {message}"), - ); - network - .inner - .peers - .lock() - .await - .record_error(&peer, message.clone()); - if debug_logging_enabled() { - eprintln!("p2p session with {peer} failed: {message}"); - } - } - } - - let (sender, next_receiver) = mpsc::channel(PEER_QUEUE_SIZE); - receiver = next_receiver; - if !peer_is_connectable(&network, &peer).await { - network.inner.sessions.lock().await.remove(&peer); - return; - } - network - .inner - .sessions - .lock() - .await - .insert(peer.clone(), sender); - sleep(reconnect_delay).await; - reconnect_delay = next_reconnect_delay(reconnect_delay); - } -} - -async fn session_loop( - network: GossipNetwork, - stream: TcpStream, - remote_addr: SocketAddr, - stable_peer: Option<String>, - mut outbound: mpsc::Receiver<OutboundBatch>, -) -> Result<()> { - let (reader, mut writer) = stream.into_split(); - let connection_label = stable_peer - .as_ref() - .map(|peer| format!("outbound {peer}")) - .unwrap_or_else(|| format!("inbound {remote_addr}")); - let advertised_addr = network.advertised_addr().await; - let hello = network.inner.node.lock().await.hello( - advertised_addr.map(|addr| addr.to_string()), - Some(network.inner.node_id.clone()), - ); - write_envelope(&mut writer, &hello).await?; - let mut reader = LimitedLineReader::new(reader); - let mut sync_tick = interval_at( - Instant::now() + SESSION_SYNC_INTERVAL, - SESSION_SYNC_INTERVAL, - ); - let mut peer_exchange_tick = interval_at( - Instant::now() + PEER_EXCHANGE_INTERVAL, - PEER_EXCHANGE_INTERVAL, - ); - let mut outbound_closed = false; - let mut peer_status: Option<PeerStatus> = None; - let is_outbound_session = stable_peer.is_some(); - let mut known_peer = stable_peer; - - if known_peer.is_some() { - if let Ok(Ok(Some(envelope))) = timeout( - HANDSHAKE_TIMEOUT, - read_session_envelope(&network, &connection_label, &mut reader), - ) - .await - { - if let GossipEnvelope::Hello(hello) = envelope { - peer_status = Some( - process_hello_with_verification( - &network, - &mut writer, - &mut reader, - &connection_label, - remote_addr, - &mut known_peer, - hello, - ) - .await?, - ); - if is_outbound_session && known_peer.is_none() { - return Ok(()); - } - maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; - write_peer_exchange(&network, &mut writer, &known_peer).await?; - } else if let GossipEnvelope::PeerStatus { - height, - tip_hash, - time_ms, - } = envelope - { - let status = PeerStatus::from_envelope(height, tip_hash, time_ms); - record_peer_status(&network, &known_peer, remote_addr, &status).await; - peer_status = Some(status); - maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; - write_peer_exchange(&network, &mut writer, &known_peer).await?; - } else if respond_to_peer_verification_challenge(&network, &mut writer, &envelope) - .await? - { - if known_peer.is_none() { - return Ok(()); - } - } else { - process_envelope( - &network, - &mut writer, - remote_addr, - &mut known_peer, - envelope, - ) - .await?; - if is_outbound_session && known_peer.is_none() { - return Ok(()); - } - } - } - } - - loop { - tokio::select! { - maybe_batch = outbound.recv(), if !outbound_closed => { - match maybe_batch { - Some(batch) => { - let payload = envelopes_for_peer( - Some(&network.inner.node), - peer_status.clone(), - &batch, - ).await; - write_payload(&mut writer, &payload).await?; - if let Some(peer) = &known_peer { - network.inner.peers.lock().await.record_sent(peer, payload.len() as u64); - } - } - None => outbound_closed = true, - } - } - _ = sync_tick.tick() => { - let status = network.inner.node.lock().await.peer_status(); - write_envelope(&mut writer, &status).await?; - if let Some(status) = peer_status.as_mut() { - if let Some(updated_status) = push_catchup_to_peer(&network, &mut writer, status).await? { - *status = updated_status; - } - } - } - _ = peer_exchange_tick.tick() => { - write_peer_exchange(&network, &mut writer, &known_peer).await?; - } - envelope = read_session_envelope(&network, &connection_label, &mut reader) => { - let Some(envelope) = envelope? else { - return Ok(()); - }; - if let GossipEnvelope::Hello(hello) = envelope { - peer_status = Some( - process_hello_with_verification( - &network, - &mut writer, - &mut reader, - &connection_label, - remote_addr, - &mut known_peer, - hello, - ) - .await?, - ); - if is_outbound_session && known_peer.is_none() { - return Ok(()); - } - maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; - write_peer_exchange(&network, &mut writer, &known_peer).await?; - continue; - } - if let GossipEnvelope::PeerStatus { - height, - tip_hash, - time_ms, - } = &envelope - { - let status = PeerStatus::from_envelope(*height, tip_hash.clone(), *time_ms); - record_peer_status(&network, &known_peer, remote_addr, &status).await; - peer_status = Some(status); - maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; - write_peer_exchange(&network, &mut writer, &known_peer).await?; - continue; - } - - if respond_to_peer_verification_challenge(&network, &mut writer, &envelope).await? { - if known_peer.is_none() { - return Ok(()); - } - continue; - } - process_envelope( - &network, - &mut writer, - remote_addr, - &mut known_peer, - envelope, - ).await?; - if is_outbound_session && known_peer.is_none() { - return Ok(()); - } - } - } - } -} - -async fn respond_to_peer_verification_challenge( - network: &GossipNetwork, - writer: &mut OwnedWriteHalf, - envelope: &GossipEnvelope, -) -> Result<bool> { - let GossipEnvelope::PeerVerificationChallenge { address, nonce } = envelope else { - return Ok(false); - }; - if let Some(response) = peer_verification_response(network, address, nonce) { - write_envelope(writer, &response).await?; - } - Ok(true) -} - -async fn peer_is_connectable(network: &GossipNetwork, peer: &str) -> bool { - network.inner.peers.lock().await.is_connectable_peer(peer) -} - -async fn process_envelope( - network: &GossipNetwork, - writer: &mut OwnedWriteHalf, - remote_addr: SocketAddr, - known_peer: &mut Option<String>, - envelope: GossipEnvelope, -) -> Result<()> { - match envelope { - GossipEnvelope::Hello(hello) => { - let _ = process_hello(network, remote_addr, known_peer, hello).await?; - } - GossipEnvelope::ChainSnapshotRequest => { - let snapshot = network.inner.node.lock().await.chain_snapshot(); - write_envelope(writer, &GossipEnvelope::ChainSnapshot(snapshot)).await?; - } - GossipEnvelope::BlockRangeRequest { from_height, limit } => { - let blocks = network - .inner - .node - .lock() - .await - .blocks_from(from_height, limit.min(MAX_BLOCK_BATCH)); - write_envelope(writer, &GossipEnvelope::Blocks { blocks }).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 { blocks } => { - let requests = network - .inner - .node - .lock() - .await - .missing_inventory_requests(&blocks); - write_payload(writer, &requests).await?; - } - GossipEnvelope::PeerAnnouncement { address, node_id } => { - let peer = normalize_advertised_peer(&address, remote_addr)?; - if network.is_self_peer(&peer).await { - P2pMetricsCounters::inc(&network.inner.metrics.self_peer_rejections); - forget_stale_self_peer(network, known_peer).await; - } else if node_id.is_some() && debug_logging_enabled() { - eprintln!("p2p peer announcement for {peer} ignored until hello verification"); - } - let snapshot = network.inner.node.lock().await.chain_snapshot(); - write_envelope(writer, &GossipEnvelope::ChainSnapshot(snapshot)).await?; - } - GossipEnvelope::PeerVerificationChallenge { address, nonce } => { - if let Some(response) = peer_verification_response(network, &address, &nonce) { - write_envelope(writer, &response).await?; - } - } - GossipEnvelope::PeerVerificationResponse { .. } => {} - GossipEnvelope::PeerList { peers } => { - apply_peer_list(network, remote_addr, peers).await?; - } - GossipEnvelope::BlindedTransaction(tx) => { - process_blinded_transactions(network, remote_addr, known_peer, vec![tx]).await; - } - GossipEnvelope::BlindedTransactions { transactions } => { - process_blinded_transactions(network, remote_addr, known_peer, transactions).await; - } - GossipEnvelope::MineAction(tx) => { - process_mine_actions(network, remote_addr, known_peer, vec![tx]).await; - } - GossipEnvelope::MineActions { transactions } => { - process_mine_actions(network, remote_addr, known_peer, transactions).await; - } - GossipEnvelope::BlindedReveal(reveal) => { - process_blinded_reveals(network, remote_addr, known_peer, vec![reveal]).await; - } - GossipEnvelope::BlindedReveals { reveals } => { - process_blinded_reveals(network, remote_addr, known_peer, reveals).await; - } - GossipEnvelope::RevealBundle(bundle) => { - process_reveal_bundles(network, remote_addr, known_peer, vec![bundle]).await; - } - GossipEnvelope::RevealBundles { bundles } => { - process_reveal_bundles(network, remote_addr, known_peer, bundles).await; - } - GossipEnvelope::Block(block) => { - let adjusted_time_ms = network_adjusted_time_ms(network).await; - let needs_vdf = { - let node = network.inner.node.lock().await; - node.block_requires_vdf_verification_at(&block, adjusted_time_ms) - }; - let result = match needs_vdf { - Ok(false) => Ok(()), - Ok(true) => match verify_block_vdf(block).await { - Ok(block) => network - .inner - .node - .lock() - .await - .receive_preverified_block_at(block, adjusted_time_ms), - Err(error) => Err(error), - }, - Err(error) => Err(error), - }; - record_inbound_result(network, known_peer, remote_addr, result).await; - network.forward_outbox().await; - } - GossipEnvelope::Blocks { blocks } => { - let adjusted_time_ms = network_adjusted_time_ms(network).await; - let local_ledger = network.inner.node.lock().await.clone_ledger(); - let result = - match validate_blocks_extension(local_ledger, blocks, adjusted_time_ms).await { - Ok(ledger) => network - .inner - .node - .lock() - .await - .import_verified_ledger(ledger) - .map(|_| ()), - Err(error) => Err(error), - }; - let request_snapshot = result.as_ref().err().is_some_and(is_possible_fork_error); - record_inbound_result(network, known_peer, remote_addr, result).await; - if request_snapshot { - write_envelope(writer, &GossipEnvelope::ChainSnapshotRequest).await?; - } - network.forward_outbox().await; - } - GossipEnvelope::ChainSnapshot(snapshot) => { - let adjusted_time_ms = network_adjusted_time_ms(network).await; - let local_ledger = network.inner.node.lock().await.clone_ledger(); - let result = - match validate_snapshot_extension(local_ledger, snapshot, adjusted_time_ms).await { - Ok(ledger) => network - .inner - .node - .lock() - .await - .import_verified_ledger(ledger) - .map(|_| ()), - Err(error) => Err(error), - }; - record_inbound_result(network, known_peer, remote_addr, result).await; - network.forward_outbox().await; - } - other => { - let result = network.inner.node.lock().await.receive(other); - record_inbound_result(network, known_peer, remote_addr, result).await; - network.forward_outbox().await; - } - } - Ok(()) -} - -async fn process_blinded_transactions( - network: &GossipNetwork, - remote_addr: SocketAddr, - known_peer: &Option<String>, - transactions: Vec<crate::domain::BlindedTransaction>, -) { - let first_error = { - let mut node = network.inner.node.lock().await; - let mut first_error = None; - for tx in transactions { - if let Err(error) = node.receive_blinded_transaction(tx) { - first_error.get_or_insert(error); - } - } - first_error - }; - record_inbound_result( - network, - known_peer, - remote_addr, - first_error - .map(|error| Err(anyhow!(format!("{error:#}")))) - .unwrap_or(Ok(())), - ) - .await; - network.forward_outbox().await; -} - -async fn process_mine_actions( - network: &GossipNetwork, - remote_addr: SocketAddr, - known_peer: &Option<String>, - transactions: Vec<crate::domain::Transaction>, -) { - let first_error = { - let mut node = network.inner.node.lock().await; - let mut first_error = None; - for tx in transactions { - if let Err(error) = node.receive_mine_action(tx) { - first_error.get_or_insert(error); - } - } - first_error - }; - record_inbound_result( - network, - known_peer, - remote_addr, - first_error - .map(|error| Err(anyhow!(format!("{error:#}")))) - .unwrap_or(Ok(())), - ) - .await; - network.forward_outbox().await; -} - -async fn process_blinded_reveals( - network: &GossipNetwork, - remote_addr: SocketAddr, - known_peer: &Option<String>, - reveals: Vec<crate::domain::BlindedReveal>, -) { - let first_error = { - let mut node = network.inner.node.lock().await; - let mut first_error = None; - for reveal in reveals { - if let Err(error) = node.receive_blinded_reveal(reveal) { - first_error.get_or_insert(error); - } - } - first_error - }; - record_inbound_result( - network, - known_peer, - remote_addr, - first_error - .map(|error| Err(anyhow!(format!("{error:#}")))) - .unwrap_or(Ok(())), - ) - .await; - network.forward_outbox().await; -} - -async fn process_reveal_bundles( - network: &GossipNetwork, - remote_addr: SocketAddr, - known_peer: &Option<String>, - bundles: Vec<crate::domain::RevealBundle>, -) { - let first_error = { - let mut node = network.inner.node.lock().await; - let mut first_error = None; - for bundle in bundles { - if let Err(error) = node.receive_reveal_bundle(bundle) { - first_error.get_or_insert(error); - } - } - first_error - }; - record_inbound_result( - network, - known_peer, - remote_addr, - first_error - .map(|error| Err(anyhow!(format!("{error:#}")))) - .unwrap_or(Ok(())), - ) - .await; - network.forward_outbox().await; -} - -async fn maybe_request_catchup( - network: &GossipNetwork, - writer: &mut OwnedWriteHalf, - peer_status: &PeerStatus, -) -> Result<()> { - let (local_height, local_tip_hash) = { - let node = network.inner.node.lock().await; - let status = node.ledger().status(); - (status.height, status.tip_hash) - }; - if peer_status.request_snapshot { - write_envelope(writer, &GossipEnvelope::ChainSnapshotRequest).await?; - } else if peer_status.height > local_height { - write_envelope( - writer, - &GossipEnvelope::BlockRangeRequest { - from_height: local_height + 1, - limit: MAX_BLOCK_BATCH, - }, - ) - .await?; - } else if peer_status.height == local_height && peer_status.tip_hash != local_tip_hash { - write_envelope(writer, &GossipEnvelope::ChainSnapshotRequest).await?; - } - Ok(()) -} - -async fn push_catchup_to_peer( - network: &GossipNetwork, - writer: &mut OwnedWriteHalf, - peer_status: &PeerStatus, -) -> Result<Option<PeerStatus>> { - let payload = catchup_payload_for_peer(&network.inner.node, peer_status).await; - if payload.is_empty() { - return Ok(None); - } - - let updated_status = payload.iter().find_map(|envelope| match envelope { - GossipEnvelope::Blocks { blocks } => blocks - .last() - .map(|block| PeerStatus::new(block.height, block.hash.clone())), - GossipEnvelope::ChainSnapshot(snapshot) => snapshot - .blocks - .last() - .map(|block| PeerStatus::new(block.height, block.hash.clone())), - _ => None, - }); - write_payload(writer, &payload).await?; - Ok(updated_status) -} - -async fn catchup_payload_for_peer( - node: &SharedNode, - peer_status: &PeerStatus, -) -> Vec<GossipEnvelope> { - let mut node = node.lock().await; - let local_status = node.ledger().status(); - if node.ledger().is_setup_placeholder() { - return Vec::new(); - } - let mempool = node.mempool_gossip(); - if peer_status.push_snapshot { - let mut payload = vec![GossipEnvelope::ChainSnapshot(node.chain_snapshot())]; - payload.extend(mempool); - return payload; - } - if peer_status.height < local_status.height { - let blocks = node.blocks_from(peer_status.height + 1, MAX_BLOCK_BATCH); - if blocks.is_empty() { - mempool - } else { - let mut payload = vec![GossipEnvelope::Blocks { blocks }]; - payload.extend(mempool); - payload - } - } else if peer_status.height == local_status.height - && peer_status.tip_hash != local_status.tip_hash - { - let mut payload = vec![GossipEnvelope::ChainSnapshot(node.chain_snapshot())]; - payload.extend(mempool); - payload - } else { - mempool - } -} - -async fn apply_peer_list( - network: &GossipNetwork, - remote_addr: SocketAddr, - peers: Vec<String>, -) -> Result<()> { - let self_filter_addr = network.self_filter_addr().await; - let mut peerbook = network.inner.peers.lock().await; - for address in peers { - let peer = match normalize_advertised_peer(&address, remote_addr) { - Ok(peer) => peer, - Err(error) => { - if debug_logging_enabled() { - eprintln!("p2p peer-list address {address} ignored: {error:#}"); - } - continue; - } - }; - if is_self_peer_address_for(&peer, network.inner.listen_addr, self_filter_addr) { - P2pMetricsCounters::inc(&network.inner.metrics.self_peer_skips); - } else if peer_list_address_is_discoverable(&peer, remote_addr)? { - peerbook.add_peer(peer); - } else { - P2pMetricsCounters::inc(&network.inner.metrics.self_peer_skips); - } - } - Ok(()) -} - -async fn write_peer_exchange( - network: &GossipNetwork, - writer: &mut OwnedWriteHalf, - known_peer: &Option<String>, -) -> Result<()> { - let envelope = network.peer_exchange().await; - let GossipEnvelope::PeerList { peers } = &envelope else { - return Ok(()); - }; - if peers.is_empty() { - return Ok(()); - } - write_envelope(writer, &envelope).await?; - if let Some(peer) = known_peer { - network.inner.peers.lock().await.record_sent(peer, 1); - } - Ok(()) -} - -async fn write_payload(writer: &mut OwnedWriteHalf, payload: &[GossipEnvelope]) -> Result<()> { - for envelope in payload { - write_envelope(writer, envelope).await?; - } - Ok(()) -} - -async fn write_envelope(writer: &mut OwnedWriteHalf, envelope: &GossipEnvelope) -> Result<()> { - let line = serde_json::to_string(envelope)?; - if line.len() > MAX_GOSSIP_LINE_BYTES { - anyhow::bail!( - "p2p message is {} bytes, exceeding {} byte limit", - line.len(), - MAX_GOSSIP_LINE_BYTES - ); - } - writer.write_all(line.as_bytes()).await?; - writer.write_all(b"\n").await?; - Ok(()) -} - -struct LimitedLineReader<R> { - reader: BufReader<R>, - pending: Vec<u8>, -} - -impl<R: AsyncRead + Unpin> LimitedLineReader<R> { - fn new(reader: R) -> Self { - Self { - reader: BufReader::new(reader), - pending: Vec::new(), - } - } - - async fn read_line(&mut self) -> Result<Option<String>> { - loop { - let available = self.reader.fill_buf().await?; - if available.is_empty() { - if self.pending.is_empty() { - return Ok(None); - } - anyhow::bail!("peer closed before completing a gossip message"); - } - - if let Some(newline) = available.iter().position(|byte| *byte == b'\n') { - if self.pending.len() + newline > MAX_GOSSIP_LINE_BYTES { - anyhow::bail!("p2p message exceeds {} byte limit", MAX_GOSSIP_LINE_BYTES); - } - self.pending.extend_from_slice(&available[..newline]); - self.reader.consume(newline + 1); - if self.pending.ends_with(b"\r") { - self.pending.pop(); - } - let bytes = std::mem::take(&mut self.pending); - return String::from_utf8(bytes) - .context("p2p message is not valid UTF-8") - .map(Some); - } - - if self.pending.len() + available.len() > MAX_GOSSIP_LINE_BYTES { - anyhow::bail!("p2p message exceeds {} byte limit", MAX_GOSSIP_LINE_BYTES); - } - let consumed = available.len(); - self.pending.extend_from_slice(available); - self.reader.consume(consumed); - } - } -} - -async fn read_session_envelope( - network: &GossipNetwork, - connection_label: &str, - reader: &mut LimitedLineReader<OwnedReadHalf>, -) -> Result<Option<GossipEnvelope>> { - let Some(line) = reader.read_line().await? else { - return Ok(None); - }; - P2pMetricsCounters::add(&network.inner.metrics.bytes_received, line.len() as u64 + 1); - if line.trim().is_empty() { - P2pMetricsCounters::inc(&network.inner.metrics.empty_frames); - P2pMetricsCounters::set_last( - &network.inner.metrics.last_empty_frame_remote, - connection_label.to_string(), - ); - anyhow::bail!("empty p2p envelope"); - } - - match parse_envelope(&line) { - Ok(envelope) => { - P2pMetricsCounters::inc(&network.inner.metrics.envelopes_received); - record_received_envelope_kind(&network.inner.metrics, &envelope); - Ok(Some(envelope)) - } - Err(error) => { - P2pMetricsCounters::inc(&network.inner.metrics.parse_errors); - P2pMetricsCounters::set_last( - &network.inner.metrics.last_parse_error, - format!("{connection_label}: {error:#}"), - ); - Err(error) - } - } -} - -fn record_received_envelope_kind(metrics: &P2pMetricsCounters, envelope: &GossipEnvelope) { - match envelope { - GossipEnvelope::Hello(_) => { - P2pMetricsCounters::inc(&metrics.hello_envelopes_received); - } - GossipEnvelope::PeerStatus { .. } => { - P2pMetricsCounters::inc(&metrics.peer_status_envelopes_received); - } - GossipEnvelope::Inventory { .. } => { - P2pMetricsCounters::inc(&metrics.inventory_envelopes_received); - } - GossipEnvelope::BlindedTransaction(_) => { - P2pMetricsCounters::inc(&metrics.data_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_transaction_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_transactions_received); - } - GossipEnvelope::BlindedTransactions { transactions } => { - P2pMetricsCounters::inc(&metrics.data_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_transaction_envelopes_received); - P2pMetricsCounters::add( - &metrics.blinded_transactions_received, - transactions.len() as u64, - ); - } - GossipEnvelope::MineAction(_) => { - P2pMetricsCounters::inc(&metrics.data_envelopes_received); - } - GossipEnvelope::MineActions { .. } => { - P2pMetricsCounters::inc(&metrics.data_envelopes_received); - } - GossipEnvelope::BlindedReveal(_) => { - P2pMetricsCounters::inc(&metrics.data_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_reveal_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_reveals_received); - } - GossipEnvelope::BlindedReveals { reveals } => { - P2pMetricsCounters::inc(&metrics.data_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_reveal_envelopes_received); - P2pMetricsCounters::add(&metrics.blinded_reveals_received, reveals.len() as u64); - } - GossipEnvelope::RevealBundle(bundle) => { - P2pMetricsCounters::inc(&metrics.data_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_reveal_envelopes_received); - P2pMetricsCounters::add( - &metrics.blinded_reveals_received, - bundle.reveals.len() as u64, - ); - } - GossipEnvelope::RevealBundles { bundles } => { - P2pMetricsCounters::inc(&metrics.data_envelopes_received); - P2pMetricsCounters::inc(&metrics.blinded_reveal_envelopes_received); - P2pMetricsCounters::add( - &metrics.blinded_reveals_received, - bundles - .iter() - .map(|bundle| bundle.reveals.len() as u64) - .sum::<u64>(), - ); - } - GossipEnvelope::Block(_) - | GossipEnvelope::Blocks { .. } - | GossipEnvelope::ChainSnapshot(_) => { - P2pMetricsCounters::inc(&metrics.data_envelopes_received); - } - GossipEnvelope::ChainSnapshotRequest - | GossipEnvelope::BlockRangeRequest { .. } - | GossipEnvelope::BlockRequest { .. } - | GossipEnvelope::PeerAnnouncement { .. } - | GossipEnvelope::PeerVerificationChallenge { .. } - | GossipEnvelope::PeerVerificationResponse { .. } - | GossipEnvelope::PeerList { .. } => { - P2pMetricsCounters::inc(&metrics.control_envelopes_received); - } - } -} - -fn parse_envelope(line: &str) -> Result<GossipEnvelope> { - if line.trim().is_empty() { - anyhow::bail!("empty p2p envelope"); - } - let envelope = serde_json::from_str(line).context("invalid p2p envelope JSON")?; - validate_envelope_limits(&envelope)?; - Ok(envelope) -} - -fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> { - match envelope { - GossipEnvelope::BlockRangeRequest { limit, .. } => { - ensure_len("block range request", *limit, MAX_BLOCK_BATCH)?; - } - GossipEnvelope::BlockRequest { hashes } => { - ensure_len("block request", hashes.len(), MAX_OBJECT_REQUESTS)?; - } - GossipEnvelope::Inventory { blocks } => { - ensure_len("block inventory", blocks.len(), MAX_INVENTORY_ITEMS)?; - } - GossipEnvelope::BlindedTransactions { transactions } => { - ensure_len( - "blinded transaction batch", - transactions.len(), - TRANSACTION_BATCH_LIMIT, - )?; - } - GossipEnvelope::MineActions { transactions } => { - ensure_len( - "mine action batch", - transactions.len(), - TRANSACTION_BATCH_LIMIT, - )?; - } - GossipEnvelope::BlindedReveals { reveals } => { - ensure_len( - "blinded reveal batch", - reveals.len(), - TRANSACTION_BATCH_LIMIT, - )?; - } - GossipEnvelope::RevealBundles { bundles } => { - ensure_len( - "reveal bundle batch", - bundles.len(), - TRANSACTION_BATCH_LIMIT, - )?; - } - GossipEnvelope::Blocks { blocks } => { - ensure_len("block batch", blocks.len(), MAX_BLOCK_BATCH)?; - } - GossipEnvelope::ChainSnapshot(snapshot) => { - ensure_len("chain snapshot", snapshot.blocks.len(), MAX_SNAPSHOT_BLOCKS)?; - } - GossipEnvelope::PeerList { peers } => { - ensure_len("peer list", peers.len(), MAX_PEER_LIST)?; - } - GossipEnvelope::Hello(_) - | GossipEnvelope::ChainSnapshotRequest - | GossipEnvelope::PeerStatus { .. } - | GossipEnvelope::BlindedTransaction(_) - | GossipEnvelope::MineAction(_) - | GossipEnvelope::BlindedReveal(_) - | GossipEnvelope::RevealBundle(_) - | GossipEnvelope::Block(_) - | GossipEnvelope::PeerAnnouncement { .. } - | GossipEnvelope::PeerVerificationChallenge { .. } - | GossipEnvelope::PeerVerificationResponse { .. } => {} - } - Ok(()) -} - -fn ensure_len(label: &str, len: usize, max: usize) -> Result<()> { - if len > max { - anyhow::bail!("{label} has {len} items, exceeding limit {max}"); - } - Ok(()) -} - -async fn envelopes_for_peer( - node: Option<&SharedNode>, - peer_status: Option<PeerStatus>, - envelopes: &[GossipEnvelope], -) -> Vec<GossipEnvelope> { - let Some(node) = node else { - return envelopes.to_vec(); - }; - let Some(peer_status) = peer_status else { - return envelopes.to_vec(); - }; - - let node = node.lock().await; - let local_status = node.ledger().status(); - if node.ledger().is_setup_placeholder() { - return envelopes - .iter() - .filter(|envelope| !matches!(envelope, GossipEnvelope::Block(_))) - .cloned() - .collect(); - } - if peer_status.height < local_status.height { - let mut payload = vec![GossipEnvelope::Blocks { - blocks: node.blocks_from(peer_status.height + 1, MAX_BLOCK_BATCH), - }]; - payload.extend( - envelopes - .iter() - .filter(|envelope| !matches!(envelope, GossipEnvelope::Block(_))) - .cloned(), - ); - return payload; - } - - if peer_status.height == local_status.height && peer_status.tip_hash != local_status.tip_hash { - return vec![GossipEnvelope::ChainSnapshot(node.chain_snapshot())]; - } - - if peer_needs_snapshot(peer_status.height, envelopes) { - return vec![GossipEnvelope::ChainSnapshot(node.chain_snapshot())]; - } - - envelopes - .iter() - .filter(|envelope| match envelope { - GossipEnvelope::Block(block) => block.height > peer_status.height, - GossipEnvelope::Inventory { blocks, .. } => { - blocks.iter().any(|block| block.height > peer_status.height) - } - _ => true, - }) - .map(|envelope| match envelope { - GossipEnvelope::Inventory { blocks } => GossipEnvelope::Inventory { - blocks: blocks - .iter() - .filter(|block| block.height > peer_status.height) - .cloned() - .collect(), - }, - other => other.clone(), - }) - .filter(|envelope| match envelope { - GossipEnvelope::Inventory { blocks } => !blocks.is_empty(), - _ => true, - }) - .collect() -} - -pub async fn fetch_snapshot(peer: &str) -> Result<ChainSnapshot> { - fetch_snapshot_with_announcement(peer, None).await -} - -pub async fn fetch_peer_height(peer: &str) -> Result<u64> { - fetch_peer_status(peer).await.map(|status| status.height) -} - -async fn fetch_peer_status(peer: &str) -> Result<PeerStatus> { - let stream = TcpStream::connect(peer) - .await - .with_context(|| format!("connecting to peer {peer}"))?; - let (reader, _writer) = stream.into_split(); - let mut reader = LimitedLineReader::new(reader); - let line = reader - .read_line() - .await? - .with_context(|| format!("peer {peer} closed before sending its peer status"))?; - match parse_envelope(&line)? { - GossipEnvelope::Hello(hello) => { - if hello.protocol_version != PROTOCOL_VERSION { - anyhow::bail!( - "unsupported protocol version {}; expected {}", - hello.protocol_version, - PROTOCOL_VERSION - ); - } - if hello.network_id != NETWORK_ID { - anyhow::bail!( - "wrong network {}; expected {}", - hello.network_id, - NETWORK_ID - ); - } - Ok(PeerStatus::with_time( - hello.height, - hello.tip_hash, - hello.time_ms, - )) - } - GossipEnvelope::PeerStatus { - height, - tip_hash, - time_ms, - } => Ok(PeerStatus::from_envelope(height, tip_hash, time_ms)), - other => anyhow::bail!("peer {peer} sent {other:?} instead of peer status"), - } -} - -pub async fn fetch_snapshot_with_announcement( - peer: &str, - _advertised_addr: Option<SocketAddr>, -) -> Result<ChainSnapshot> { - let stream = TcpStream::connect(peer) - .await - .with_context(|| format!("connecting to join peer {peer}"))?; - let (reader, mut writer) = stream.into_split(); - let mut reader = LimitedLineReader::new(reader); - let line = reader - .read_line() - .await? - .with_context(|| format!("join peer {peer} closed before sending its peer status"))?; - match parse_envelope(&line)? { - GossipEnvelope::Hello(hello) => { - if hello.protocol_version != PROTOCOL_VERSION { - anyhow::bail!( - "unsupported protocol version {}; expected {}", - hello.protocol_version, - PROTOCOL_VERSION - ); - } - if hello.network_id != NETWORK_ID { - anyhow::bail!( - "wrong network {}; expected {}", - hello.network_id, - NETWORK_ID - ); - } - } - GossipEnvelope::PeerStatus { .. } => {} - other => anyhow::bail!("join peer {peer} sent {other:?} instead of peer status"), - } - - let line = serde_json::to_string(&GossipEnvelope::ChainSnapshotRequest)?; - writer.write_all(line.as_bytes()).await?; - writer.write_all(b"\n").await?; - let snapshot = read_join_snapshot_response(peer, &mut reader).await?; - - Ok(snapshot) -} - -async fn read_join_snapshot_response( - peer: &str, - reader: &mut LimitedLineReader<OwnedReadHalf>, -) -> Result<ChainSnapshot> { - for _ in 0..MAX_JOIN_RESPONSE_ENVELOPES { - let line = timeout(JOIN_RESPONSE_TIMEOUT, reader.read_line()) - .await - .with_context(|| format!("join peer {peer} timed out waiting for a chain snapshot"))?? - .with_context(|| format!("join peer {peer} closed before sending a chain snapshot"))?; - match join_snapshot_response(peer, parse_envelope(&line)?)? { - Some(snapshot) => return Ok(snapshot), - None => continue, - } - } - - anyhow::bail!("join peer {peer} sent too many non-snapshot envelopes while joining") -} - -fn join_snapshot_response(peer: &str, envelope: GossipEnvelope) -> Result<Option<ChainSnapshot>> { - match envelope { - GossipEnvelope::ChainSnapshot(snapshot) => Ok(Some(snapshot)), - GossipEnvelope::Hello(_) - | GossipEnvelope::PeerStatus { .. } - | GossipEnvelope::PeerList { .. } - | GossipEnvelope::PeerVerificationChallenge { .. } - | GossipEnvelope::PeerVerificationResponse { .. } - | GossipEnvelope::Inventory { .. } => Ok(None), - other => anyhow::bail!("join peer {peer} sent {other:?} instead of a chain snapshot"), - } -} - -async fn validate_snapshot_extension( - mut ledger: Ledger, - snapshot: ChainSnapshot, - now_ms: u64, -) -> Result<Ledger> { - if ledger.is_setup_placeholder() { - return tokio::task::spawn_blocking(move || Ledger::from_snapshot_at(snapshot, now_ms)) - .await - .context("chain snapshot adoption worker failed")?; - } - let missing_blocks = ledger.missing_snapshot_blocks(&snapshot)?; - verify_blocks_vdf(missing_blocks).await?; - - tokio::task::spawn_blocking(move || { - ledger.extend_from_preverified_snapshot_at(snapshot, now_ms)?; - Ok(ledger) - }) - .await - .context("chain snapshot extension worker failed")? -} - -async fn validate_blocks_extension( - mut ledger: Ledger, - blocks: Vec<Block>, - now_ms: u64, -) -> Result<Ledger> { - if blocks.is_empty() { - return Ok(ledger); - } - verify_blocks_vdf(blocks.clone()).await?; - - tokio::task::spawn_blocking(move || { - for block in blocks { - ledger.apply_preverified_block_at(block, now_ms)?; - } - Ok(ledger) - }) - .await - .context("block batch extension worker failed")? -} - -async fn network_adjusted_time_ms(network: &GossipNetwork) -> u64 { - let local_time_ms = now_ms(); - network - .inner - .peers - .lock() - .await - .adjusted_time_ms_at(local_time_ms) -} - -async fn verify_block_vdf(block: Block) -> Result<Block> { - let seed = block.vdf_seed(); - let rounds = block.vdf_rounds; - let solution = block.vdf_output.clone(); - let valid = tokio::task::spawn_blocking(move || verify_vdf(&seed, rounds, &solution)) - .await - .context("VDF verification worker failed")?; - if !valid { - anyhow::bail!("block VDF output is invalid"); - } - - Ok(block) -} - -async fn verify_blocks_vdf(blocks: Vec<Block>) -> Result<()> { - let mut tasks = tokio::task::JoinSet::new(); - for block in blocks { - tasks.spawn_blocking(move || { - if !verify_vdf(&block.vdf_seed(), block.vdf_rounds, &block.vdf_output) { - anyhow::bail!("block {} VDF output is invalid", block.height); - } - Ok::<(), anyhow::Error>(()) - }); - } - - while let Some(result) = tasks.join_next().await { - result.context("VDF verification worker failed")??; - } - - Ok(()) -} - -async fn record_peer_status( - network: &GossipNetwork, - known_peer: &Option<String>, - remote_addr: SocketAddr, - peer_status: &PeerStatus, -) { - let local_receive_time_ms = now_ms(); - if let Some(peer) = known_peer { - let mut peers = network.inner.peers.lock().await; - peers.record_status(peer, peer_status.height, peer_status.tip_hash.clone()); - peers.record_clock_observation( - peer, - PeerDirection::Outbound, - peer_status.time_ms, - local_receive_time_ms, - ); - } else { - let peer = remote_addr.to_string(); - let mut peers = network.inner.peers.lock().await; - peers.record_clock_observation( - &peer, - PeerDirection::Inbound, - peer_status.time_ms, - local_receive_time_ms, - ); - peers.record_received(&peer, 1); - } -} - -async fn process_hello( - network: &GossipNetwork, - remote_addr: SocketAddr, - known_peer: &mut Option<String>, - hello: ProtocolHello, -) -> Result<PeerStatus> { - process_hello_inner(network, None, remote_addr, known_peer, hello).await -} - -async fn process_hello_with_verification( - network: &GossipNetwork, - writer: &mut OwnedWriteHalf, - reader: &mut LimitedLineReader<OwnedReadHalf>, - connection_label: &str, - remote_addr: SocketAddr, - known_peer: &mut Option<String>, - hello: ProtocolHello, -) -> Result<PeerStatus> { - let mut verification_session = PeerVerificationSession { - writer, - reader, - connection_label, - }; - process_hello_inner( - network, - Some(&mut verification_session), - remote_addr, - known_peer, - hello, - ) - .await -} - -async fn process_hello_inner( - network: &GossipNetwork, - mut verification_session: Option<&mut PeerVerificationSession<'_>>, - remote_addr: SocketAddr, - known_peer: &mut Option<String>, - hello: ProtocolHello, -) -> Result<PeerStatus> { - if hello.protocol_version != PROTOCOL_VERSION { - anyhow::bail!( - "unsupported protocol version {}; expected {}", - hello.protocol_version, - PROTOCOL_VERSION - ); - } - if hello.network_id != NETWORK_ID { - anyhow::bail!( - "wrong network {}; expected {}", - hello.network_id, - NETWORK_ID - ); - } - if hello - .node_id - .as_deref() - .is_some_and(|node_id| node_id == network.inner.node_id) - { - P2pMetricsCounters::inc(&network.inner.metrics.self_peer_rejections); - forget_stale_self_peer(network, known_peer).await; - return Ok(PeerStatus::with_time( - hello.height, - hello.tip_hash, - hello.time_ms, - )); - } - let (local_genesis, local_accepts_remote_genesis) = { - let node = network.inner.node.lock().await; - ( - node.ledger().genesis_hash().to_string(), - node.ledger().is_setup_placeholder(), - ) - }; - let genesis_mismatch = hello.genesis_hash != local_genesis; - let remote_is_setup_placeholder = - hello.height == 0 && hello.genesis_hash == setup_placeholder_genesis_hash(); - let request_snapshot = genesis_mismatch && local_accepts_remote_genesis; - let push_snapshot = genesis_mismatch && remote_is_setup_placeholder; - if genesis_mismatch && !local_accepts_remote_genesis && !remote_is_setup_placeholder { - anyhow::bail!( - "wrong genesis {}; expected {local_genesis}", - hello.genesis_hash - ); - } - - let remote_node_id = hello.node_id.clone(); - if let Some(listen_addr) = &hello.listen_addr { - let peer = normalize_advertised_peer(listen_addr, remote_addr)?; - if network.is_self_peer(&peer).await { - P2pMetricsCounters::inc(&network.inner.metrics.self_peer_rejections); - forget_stale_self_peer(network, known_peer).await; - } else { - let verified = match verification_session.as_mut() { - Some(session) => { - remember_verified_advertised_peer( - network, - session, - remote_addr, - known_peer, - peer.clone(), - remote_node_id.as_deref(), - ) - .await? - } - None => false, - }; - if !verified && debug_logging_enabled() { - eprintln!( - "p2p advertised address {peer} ignored because ownership was not verified" - ); - } - } - } - record_peer_status( - network, - known_peer, - remote_addr, - &PeerStatus::with_time(hello.height, hello.tip_hash.clone(), hello.time_ms), - ) - .await; - if request_snapshot { - Ok(PeerStatus::with_snapshot_request( - hello.height, - hello.tip_hash, - hello.time_ms, - )) - } else if push_snapshot { - Ok(PeerStatus::with_snapshot_push( - hello.height, - hello.tip_hash, - hello.time_ms, - )) - } else { - Ok(PeerStatus::with_time( - hello.height, - hello.tip_hash, - hello.time_ms, - )) - } -} - -fn setup_placeholder_genesis_hash() -> String { - Ledger::new(BTreeMap::new(), 1).genesis_hash().to_string() -} - -fn new_node_id() -> String { - let mut bytes = [0_u8; 32]; - getrandom::getrandom(&mut bytes).expect("secure randomness unavailable for p2p node id"); - let signing_key = SigningKey::from_bytes(&bytes); - let node_id = hex_encode(&signing_key.verifying_key().to_bytes()); - node_signing_keys() - .lock() - .expect("node signing key registry mutex poisoned") - .insert(node_id.clone(), signing_key); - node_id -} - -fn node_signing_keys() -> &'static StdMutex<BTreeMap<String, SigningKey>> { - NODE_SIGNING_KEYS.get_or_init(|| StdMutex::new(BTreeMap::new())) -} - -fn hex_encode(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut encoded = String::with_capacity(bytes.len() * 2); - for byte in bytes { - encoded.push(HEX[(byte >> 4) as usize] as char); - encoded.push(HEX[(byte & 0x0f) as usize] as char); - } - encoded -} - -fn decode_hex_array<const N: usize>(value: &str) -> Result<[u8; N]> { - if value.len() != N * 2 { - anyhow::bail!("hex value has {} chars, expected {}", value.len(), N * 2); - } - let mut bytes = [0_u8; N]; - for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() { - let high = hex_nibble(chunk[0])?; - let low = hex_nibble(chunk[1])?; - bytes[index] = (high << 4) | low; - } - Ok(bytes) -} - -fn hex_nibble(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), - _ => anyhow::bail!("invalid hex digit"), - } -} - -fn new_verification_nonce() -> String { - let mut bytes = [0_u8; 32]; - getrandom::getrandom(&mut bytes) - .expect("secure randomness unavailable for p2p verification nonce"); - hex_encode(&bytes) -} - -fn peer_verification_payload(address: &str, nonce: &str, node_id: &str) -> String { - format!("iuna-peer-verification:v1:{NETWORK_ID}:{node_id}:{address}:{nonce}") -} - -fn peer_verification_response( - network: &GossipNetwork, - address: &str, - nonce: &str, -) -> Option<GossipEnvelope> { - peer_verification_response_for_node_id(&network.inner.node_id, address, nonce) -} - -fn peer_verification_response_for_node_id( - node_id: &str, - address: &str, - nonce: &str, -) -> Option<GossipEnvelope> { - let keys = node_signing_keys() - .lock() - .expect("node signing key registry mutex poisoned"); - let signing_key = keys.get(node_id)?; - let payload = peer_verification_payload(address, nonce, node_id); - let signature: Signature = signing_key.sign(payload.as_bytes()); - Some(GossipEnvelope::PeerVerificationResponse { - address: address.to_string(), - nonce: nonce.to_string(), - node_id: node_id.to_string(), - signature: hex_encode(&signature.to_bytes()), - }) -} - -fn peer_verification_response_is_valid( - response_address: &str, - response_nonce: &str, - response_node_id: &str, - signature: &str, - expected_address: &str, - expected_nonce: &str, - expected_node_id: &str, -) -> bool { - if response_address != expected_address - || response_nonce != expected_nonce - || response_node_id != expected_node_id - { - return false; - } - let public_key = match decode_hex_array::<32>(response_node_id) { - Ok(public_key) => public_key, - Err(_) => return false, - }; - let signature = match decode_hex_array::<64>(signature) { - Ok(signature) => Signature::from_bytes(&signature), - Err(_) => return false, - }; - let verifying_key = match VerifyingKey::from_bytes(&public_key) { - Ok(verifying_key) => verifying_key, - Err(_) => return false, - }; - verifying_key - .verify( - peer_verification_payload(expected_address, expected_nonce, expected_node_id) - .as_bytes(), - &signature, - ) - .is_ok() -} - -async fn remember_verified_advertised_peer( - network: &GossipNetwork, - session: &mut PeerVerificationSession<'_>, - remote_addr: SocketAddr, - known_peer: &mut Option<String>, - peer: String, - expected_node_id: Option<&str>, -) -> Result<bool> { - if !advertised_peer_is_discoverable(&peer, remote_addr)? { - return Ok(false); - } - if known_peer.as_deref() != Some(peer.as_str()) { - let Some(expected_node_id) = expected_node_id else { - return Ok(false); - }; - if !verify_connected_peer_node_id(network, session, &peer, expected_node_id).await? { - return Ok(false); - } - if !verify_advertised_peer_node_id(network, &peer, expected_node_id).await { - return Ok(false); - } - } - remember_discoverable_advertised_peer(network, remote_addr, known_peer, peer).await -} - -async fn verify_connected_peer_node_id( - network: &GossipNetwork, - session: &mut PeerVerificationSession<'_>, - peer: &str, - expected_node_id: &str, -) -> Result<bool> { - let nonce = new_verification_nonce(); - write_envelope( - session.writer, - &GossipEnvelope::PeerVerificationChallenge { - address: peer.to_string(), - nonce: nonce.clone(), - }, - ) - .await?; - - for _ in 0..MAX_PEER_VERIFICATION_ENVELOPES { - let envelope = match timeout( - HANDSHAKE_TIMEOUT, - read_session_envelope(network, session.connection_label, session.reader), - ) - .await - { - Ok(Ok(Some(envelope))) => envelope, - Ok(Ok(None)) | Err(_) => return Ok(false), - Ok(Err(error)) => return Err(error), - }; - match envelope { - GossipEnvelope::PeerVerificationResponse { - address, - nonce: response_nonce, - node_id, - signature, - } => { - return Ok(peer_verification_response_is_valid( - &address, - &response_nonce, - &node_id, - &signature, - peer, - &nonce, - expected_node_id, - )); - } - GossipEnvelope::PeerVerificationChallenge { address, nonce } => { - if let Some(response) = peer_verification_response(network, &address, &nonce) { - write_envelope(session.writer, &response).await?; - } - } - _ => {} - } - } - Ok(false) -} - -async fn verify_advertised_peer_node_id( - network: &GossipNetwork, - peer: &str, - expected_node_id: &str, -) -> bool { - let stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(peer)).await { - Ok(Ok(stream)) => stream, - Ok(Err(error)) => { - if debug_logging_enabled() { - eprintln!("p2p announced address {peer} failed verification: {error}"); - } - return false; - } - Err(_) => { - if debug_logging_enabled() { - eprintln!("p2p announced address {peer} failed verification: timeout"); - } - return false; - } - }; - let (reader, mut writer) = stream.into_split(); - let mut reader = LimitedLineReader::new(reader); - let line = match timeout(HANDSHAKE_TIMEOUT, reader.read_line()).await { - Ok(Ok(Some(line))) => line, - Ok(Ok(None)) => return false, - Ok(Err(error)) => { - if debug_logging_enabled() { - eprintln!( - "p2p announced address {peer} sent invalid verification hello: {error:#}" - ); - } - return false; - } - Err(_) => return false, - }; - let hello = match parse_envelope(&line) { - Ok(GossipEnvelope::Hello(hello)) => hello, - Ok(_) | Err(_) => return false, - }; - - if !advertised_peer_hello_is_compatible(network, &hello).await - || hello.node_id.as_deref() != Some(expected_node_id) - { - return false; - } - - let nonce = new_verification_nonce(); - if write_envelope( - &mut writer, - &GossipEnvelope::PeerVerificationChallenge { - address: peer.to_string(), - nonce: nonce.clone(), - }, - ) - .await - .is_err() - { - return false; - } - for _ in 0..MAX_PEER_VERIFICATION_ENVELOPES { - let line = match timeout(HANDSHAKE_TIMEOUT, reader.read_line()).await { - Ok(Ok(Some(line))) => line, - Ok(Ok(None)) | Ok(Err(_)) | Err(_) => return false, - }; - let envelope = match parse_envelope(&line) { - Ok(envelope) => envelope, - Err(_) => return false, - }; - if let GossipEnvelope::PeerVerificationResponse { - address, - nonce: response_nonce, - node_id, - signature, - } = envelope - { - return peer_verification_response_is_valid( - &address, - &response_nonce, - &node_id, - &signature, - peer, - &nonce, - expected_node_id, - ); - } - } - false -} - -async fn advertised_peer_hello_is_compatible( - network: &GossipNetwork, - hello: &ProtocolHello, -) -> bool { - if hello.protocol_version != PROTOCOL_VERSION || hello.network_id != NETWORK_ID { - return false; - } - let (local_genesis, local_accepts_remote_genesis) = { - let node = network.inner.node.lock().await; - ( - node.ledger().genesis_hash().to_string(), - node.ledger().is_setup_placeholder(), - ) - }; - let remote_is_setup_placeholder = - hello.height == 0 && hello.genesis_hash == setup_placeholder_genesis_hash(); - hello.genesis_hash == local_genesis - || local_accepts_remote_genesis - || remote_is_setup_placeholder -} - -async fn remember_discoverable_advertised_peer( - network: &GossipNetwork, - remote_addr: SocketAddr, - known_peer: &mut Option<String>, - peer: String, -) -> Result<bool> { - if !advertised_peer_is_discoverable(&peer, remote_addr)? { - return Ok(false); - } - if let Some(previous_peer) = known_peer.as_deref() { - network - .inner - .peers - .lock() - .await - .replace_peer_address(previous_peer, peer.clone()); - } else { - network - .inner - .peers - .lock() - .await - .add_discovered_peer(peer.clone()); - } - *known_peer = Some(peer); - Ok(true) -} - -async fn forget_stale_self_peer(network: &GossipNetwork, known_peer: &mut Option<String>) { - if let Some(previous_peer) = known_peer.take() { - network.inner.peers.lock().await.remove_peer(&previous_peer); - } -} - -async fn record_inbound_result( - network: &GossipNetwork, - known_peer: &Option<String>, - remote_addr: SocketAddr, - result: Result<()>, -) { - let peer = known_peer - .clone() - .unwrap_or_else(|| remote_addr.to_string()); - match result { - Ok(()) => { - if known_peer.is_some() { - network.inner.peers.lock().await.record_received(&peer, 1); - } - } - Err(error) => { - let message = format!("{error:#}"); - if known_peer.is_some() { - let mut peers = network.inner.peers.lock().await; - if inbound_error_counts_as_misbehavior(&message) { - peers.record_misbehavior(&peer, message.clone()); - } else { - peers.record_inbound_error(&peer, message.clone()); - } - } - if debug_logging_enabled() { - eprintln!("p2p envelope from {peer} ignored: {message}"); - } - } - } -} - -fn next_reconnect_delay(current: Duration) -> Duration { - (current * 2).min(MAX_RECONNECT_DELAY) -} - -fn peer_needs_snapshot(peer_height: u64, envelopes: &[GossipEnvelope]) -> bool { - envelopes - .iter() - .filter_map(|envelope| match envelope { - GossipEnvelope::Block(block) => Some(block.height), - GossipEnvelope::Inventory { blocks, .. } => { - blocks.iter().map(|block| block.height).min() - } - _ => None, - }) - .min() - .is_some_and(|first_block_height| peer_height + 1 < first_block_height) -} - -fn reachable_advertised_addr(advertised_addr: SocketAddr, remote_addr: SocketAddr) -> SocketAddr { - let mut reachable_addr = advertised_addr; - if reachable_addr.ip().is_unspecified() { - reachable_addr.set_ip(remote_addr.ip()); - } - reachable_addr -} - -fn normalize_advertised_peer(address: &str, remote_addr: SocketAddr) -> Result<String> { - let advertised_addr = address - .parse::<SocketAddr>() - .with_context(|| format!("invalid announced peer address {address}"))?; - Ok(reachable_advertised_addr(advertised_addr, remote_addr).to_string()) -} - -fn peer_list_address_is_discoverable(address: &str, remote_addr: SocketAddr) -> Result<bool> { - let candidate = address - .parse::<SocketAddr>() - .with_context(|| format!("invalid peer-list address {address}"))?; - Ok(socket_addr_is_discoverable(candidate, remote_addr)) -} - -fn advertised_peer_is_discoverable(address: &str, remote_addr: SocketAddr) -> Result<bool> { - let candidate = address - .parse::<SocketAddr>() - .with_context(|| format!("invalid announced peer address {address}"))?; - Ok(socket_addr_is_discoverable(candidate, remote_addr)) -} - -fn socket_addr_is_discoverable(candidate: SocketAddr, remote_addr: SocketAddr) -> bool { - if candidate.ip().is_loopback() { - return remote_addr.ip().is_loopback(); - } - ip_is_publicly_discoverable(candidate.ip()) -} - -fn ip_is_publicly_discoverable(ip: IpAddr) -> bool { - match ip { - IpAddr::V4(ip) => { - let [a, b, c, d] = ip.octets(); - !(a == 0 - || a == 10 - || a == 127 - || (a == 100 && (64..=127).contains(&b)) - || (a == 169 && b == 254) - || (a == 172 && (16..=31).contains(&b)) - || (a == 192 && b == 168) - || (a == 192 && b == 0 && c == 2) - || (a == 198 && b == 51 && c == 100) - || (a == 203 && b == 0 && c == 113) - || a >= 224 - || [a, b, c, d] == [255, 255, 255, 255]) - } - IpAddr::V6(ip) => { - let segments = ip.segments(); - !(ip.is_unspecified() - || ip.is_loopback() - || (segments[0] & 0xfe00) == 0xfc00 - || (segments[0] & 0xffc0) == 0xfe80 - || (segments[0] & 0xff00) == 0xff00) - } - } -} - -fn is_self_peer_address_for( - address: &str, - listen_addr: SocketAddr, - advertised_addr: Option<SocketAddr>, -) -> bool { - address.parse::<SocketAddr>().is_ok_and(|candidate| { - is_self_socket_addr(candidate, listen_addr) - || advertised_addr.is_some_and(|addr| is_self_socket_addr(candidate, addr)) - }) -} - -fn is_self_socket_addr(candidate: SocketAddr, listen_addr: SocketAddr) -> bool { - if candidate == listen_addr { - return true; - } - if candidate.port() != listen_addr.port() { - return false; - } - - let candidate_ip = candidate.ip(); - let listen_ip = listen_addr.ip(); - if listen_ip.is_unspecified() { - return candidate_ip.is_unspecified() || candidate_ip.is_loopback(); - } - if candidate_ip.is_unspecified() { - return listen_ip.is_loopback(); - } - false -} - -fn is_quiet_disconnect(error: &anyhow::Error) -> bool { - error.chain().any(|cause| { - cause.downcast_ref::<std::io::Error>().is_some_and(|error| { - matches!( - error.kind(), - ErrorKind::ConnectionReset - | ErrorKind::BrokenPipe - | ErrorKind::UnexpectedEof - | ErrorKind::ConnectionAborted - ) - }) - }) -} - -fn is_possible_fork_error(error: &anyhow::Error) -> bool { - let message = format!("{error:#}"); - message.contains("does not extend local tip") - || message.contains("conflicts with local chain") - || message.contains("expected block height") -} - -fn inbound_error_counts_as_misbehavior(message: &str) -> bool { - !message.contains("block timestamp is too far in the future") - && !message.contains("block timestamp is before finalizer rank") -} - #[cfg(test)] -mod tests { - use std::{ - collections::BTreeMap, - net::SocketAddr, - sync::{Arc, Mutex as StdMutex}, - }; - - use crate::{ - app::{ - BlockInventory, GossipEnvelope, NETWORK_ID, NodeCore, PROTOCOL_VERSION, PeerBook, - PeerDirection, ProtocolHello, - }, - domain::{ - Amount, BlindedReveal, BlindedTransaction, GenesisBurn, Ledger, Transaction, Wallet, - }, - }; - use tokio::io::AsyncWriteExt; - - use super::{ - INBOUND_ACCEPT_RATE_WINDOW_MS, InboundConnectionLimiter, InboundSessionRejection, - MAX_INBOUND_ACCEPTS_PER_IP_PER_WINDOW, MAX_INBOUND_SESSIONS, MAX_INBOUND_SESSIONS_PER_IP, - MAX_INVENTORY_ITEMS, MAX_OBJECT_REQUESTS, next_reconnect_delay, parse_envelope, - reachable_advertised_addr, validate_envelope_limits, - }; - - #[test] - fn unspecified_announced_ip_uses_remote_ip_with_announced_port() { - let advertised: SocketAddr = "0.0.0.0:9445".parse().unwrap(); - let remote: SocketAddr = "203.0.113.10:52144".parse().unwrap(); - - assert_eq!( - reachable_advertised_addr(advertised, remote).to_string(), - "203.0.113.10:9445" - ); - } - - #[test] - fn explicit_announced_ip_is_kept() { - let advertised: SocketAddr = "127.0.0.1:9445".parse().unwrap(); - let remote: SocketAddr = "127.0.0.1:52144".parse().unwrap(); - - assert_eq!( - reachable_advertised_addr(advertised, remote).to_string(), - "127.0.0.1:9445" - ); - } - - #[test] - fn loopback_peer_on_unspecified_listen_port_is_self() { - let listen_addr: SocketAddr = "0.0.0.0:9545".parse().unwrap(); - - assert!(super::is_self_peer_address_for( - "127.0.0.1:9545", - listen_addr, - Some(listen_addr) - )); - assert!(super::is_self_peer_address_for( - "0.0.0.0:9545", - listen_addr, - Some(listen_addr) - )); - assert!(!super::is_self_peer_address_for( - "127.0.0.1:9546", - listen_addr, - Some(listen_addr) - )); - assert!(!super::is_self_peer_address_for( - "203.0.113.10:9545", - listen_addr, - Some(listen_addr) - )); - } - - #[test] - fn oversized_inventory_is_rejected_before_processing() { - let envelope = GossipEnvelope::Inventory { - 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("block inventory")); - } - - #[test] - fn parser_applies_envelope_limits() { - let line = serde_json::to_string(&GossipEnvelope::BlockRequest { - hashes: vec!["hash".to_string(); MAX_OBJECT_REQUESTS + 1], - }) - .unwrap(); - - let error = parse_envelope(&line).unwrap_err(); - - assert!(error.to_string().contains("block request")); - } - - #[test] - fn parser_rejects_empty_envelope_without_json_eof() { - let error = parse_envelope("").unwrap_err(); - - assert!(error.to_string().contains("empty p2p envelope")); - assert!(!format!("{error:#}").contains("EOF while parsing")); - } - - #[test] - fn parser_accepts_legacy_peer_status_without_mempool_fields() { - let envelope = - parse_envelope(r#"{"type":"peer_status","height":7,"tip_hash":"tip"}"#).unwrap(); - - assert_eq!( - envelope, - GossipEnvelope::PeerStatus { - height: 7, - tip_hash: "tip".to_string(), - time_ms: 0, - } - ); - } - - #[test] - fn received_envelope_metrics_are_categorized() { - let metrics = super::P2pMetricsCounters::default(); - let blinded_tx = BlindedTransaction { - commitment: "commitment".to_string(), - inputs: Vec::new(), - fee: 3, - encrypted_size: 128, - expires_at_height: 20, - nonce: "nonce".to_string(), - ciphertext: "ciphertext".to_string(), - payload_hash: "payload-hash".to_string(), - }; - let blinded_reveal = BlindedReveal { - commitment: "commitment".to_string(), - key: "key".to_string(), - }; - - super::record_received_envelope_kind( - &metrics, - &GossipEnvelope::PeerStatus { - height: 7, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }, - ); - super::record_received_envelope_kind( - &metrics, - &GossipEnvelope::Inventory { blocks: Vec::new() }, - ); - super::record_received_envelope_kind( - &metrics, - &GossipEnvelope::Blocks { blocks: Vec::new() }, - ); - super::record_received_envelope_kind( - &metrics, - &GossipEnvelope::BlindedTransactions { - transactions: vec![blinded_tx.clone(), blinded_tx], - }, - ); - super::record_received_envelope_kind( - &metrics, - &GossipEnvelope::BlindedReveal(blinded_reveal), - ); - 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, 3); - assert_eq!(snapshot.blinded_transaction_envelopes_received, 1); - assert_eq!(snapshot.blinded_transactions_received, 2); - assert_eq!(snapshot.blinded_reveal_envelopes_received, 1); - assert_eq!(snapshot.blinded_reveals_received, 1); - assert_eq!(snapshot.control_envelopes_received, 1); - } - - #[tokio::test] - async fn full_outbound_queue_is_metric_not_peer_error() { - let wallet = Wallet::from_seed("full-outbound-queue"); - let node = Arc::new(tokio::sync::Mutex::new(node( - "full-outbound-queue", - wallet.clone(), - allocations(&[wallet], 1_000), - ))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ - "127.0.0.1:9444".to_string(), - ]))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers: Arc::clone(&peers), - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let (sender, _receiver) = tokio::sync::mpsc::channel(1); - sender - .try_send(vec![GossipEnvelope::PeerStatus { - height: 1, - tip_hash: "queued".to_string(), - time_ms: 1_000, - }]) - .unwrap(); - network - .inner - .sessions - .lock() - .await - .insert("127.0.0.1:9444".to_string(), sender); - - network - .broadcast(vec![GossipEnvelope::PeerStatus { - height: 2, - tip_hash: "new".to_string(), - time_ms: 2_000, - }]) - .await - .unwrap(); - - assert_eq!(network.metrics().outbound_queue_full, 1); - let peer = peers.lock().await.list().pop().unwrap(); - assert_eq!(peer.last_error, None); - assert_eq!(peer.last_error_ms, None); - } - - #[tokio::test] - async fn limited_line_reader_keeps_partial_line_after_cancelled_read() { - let (mut writer, reader) = tokio::io::duplex(1024); - let mut reader = super::LimitedLineReader::new(reader); - let line = serde_json::to_string(&GossipEnvelope::PeerStatus { - height: 7, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }) - .unwrap(); - let split_at = line.len() / 2; - - writer - .write_all(&line.as_bytes()[..split_at]) - .await - .unwrap(); - let cancelled = - tokio::time::timeout(std::time::Duration::from_millis(25), reader.read_line()).await; - - assert!(cancelled.is_err()); - - writer - .write_all(&line.as_bytes()[split_at..]) - .await - .unwrap(); - writer.write_all(b"\n").await.unwrap(); - - assert_eq!( - reader.read_line().await.unwrap().as_deref(), - Some(line.as_str()) - ); - } - - #[test] - fn peer_needs_snapshot_when_block_gossip_skips_a_height() { - let block = crate::domain::Block { - height: 10, - prev_hash: "prev".to_string(), - timestamp_ms: 1, - miner: "miner".to_string(), - finalizer_mode: crate::domain::FinalizerMode::Ticket, - finalizer_rank: 0, - reward: 100, - vdf_rounds: 1, - vdf_output: "vdf".to_string(), - leader_proof: None, - blinded_transactions: Vec::new(), - reveal_bundle_section: crate::domain::RevealBundleSection::default(), - transactions: Vec::new(), - hash: "hash".to_string(), - }; - - assert!(super::peer_needs_snapshot( - 8, - &[GossipEnvelope::Block(block.clone())] - )); - assert!(!super::peer_needs_snapshot( - 9, - &[GossipEnvelope::Block(block)] - )); - assert!(!super::peer_needs_snapshot( - 8, - &[GossipEnvelope::PeerAnnouncement { - address: "127.0.0.1:9444".to_string(), - node_id: Some("peer-node".to_string()), - }] - )); - } - - #[test] - fn inbound_limiter_enforces_per_ip_active_limit() { - let ip = "203.0.113.10".parse().unwrap(); - let mut limiter = InboundConnectionLimiter::default(); - for _ in 0..MAX_INBOUND_SESSIONS_PER_IP { - limiter.try_acquire(ip, 1_000).unwrap(); - } - - assert_eq!( - limiter.try_acquire(ip, 1_000).unwrap_err(), - InboundSessionRejection::PeerActive - ); - - limiter.release(ip); - limiter.try_acquire(ip, 1_000).unwrap(); - } - - #[test] - fn inbound_limiter_enforces_global_active_limit() { - let mut limiter = InboundConnectionLimiter::default(); - for index in 0..MAX_INBOUND_SESSIONS { - let ip = format!("198.51.100.{index}").parse().unwrap(); - limiter.try_acquire(ip, 1_000).unwrap(); - } - - assert_eq!( - limiter - .try_acquire("203.0.113.200".parse().unwrap(), 1_000) - .unwrap_err(), - InboundSessionRejection::GlobalActive - ); - } - - #[test] - fn inbound_limiter_enforces_per_ip_accept_rate() { - let ip = "203.0.113.20".parse().unwrap(); - let mut limiter = InboundConnectionLimiter::default(); - for _ in 0..MAX_INBOUND_ACCEPTS_PER_IP_PER_WINDOW { - limiter.try_acquire(ip, 1_000).unwrap(); - limiter.release(ip); - } - - assert_eq!( - limiter.try_acquire(ip, 1_000).unwrap_err(), - InboundSessionRejection::PeerRate - ); - limiter - .try_acquire(ip, 1_000 + INBOUND_ACCEPT_RATE_WINDOW_MS) - .unwrap(); - } - - #[test] - fn reconnect_backoff_is_capped() { - assert_eq!( - next_reconnect_delay(super::INITIAL_RECONNECT_DELAY), - std::time::Duration::from_secs(2) - ); - assert_eq!( - next_reconnect_delay(super::MAX_RECONNECT_DELAY), - super::MAX_RECONNECT_DELAY - ); - } - - #[tokio::test] - async fn peer_payload_repairs_lagging_peer_without_networking() { - 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.clone(), - allocations, - ))); - let block = { - let mut node = node.lock().await; - queue_plaintext_burn(&mut node, &alice, 1); - node.drain_outbox(); - let block = node.mine_one_at(1).unwrap(); - node.drain_outbox(); - block - }; - - let payload = super::envelopes_for_peer( - Some(&node), - Some(super::PeerStatus::new(0, "genesis".to_string())), - &[GossipEnvelope::Block(block)], - ) - .await; - - assert!(matches!(payload[0], GossipEnvelope::Blocks { .. })); - match &payload[0] { - GossipEnvelope::Blocks { blocks } => { - assert_eq!(blocks.len(), 1); - assert_eq!(blocks[0].height, 1); - } - _ => unreachable!(), - } - } - - #[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.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(&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] - async fn inventory_gap_requests_range_instead_of_orphan_block() { - 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.clone(), allocations.clone()); - let remote = node("remote", bob, allocations); - - let mut latest = None; - for height in 1..=3 { - 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, - }]); - - assert_eq!(requests.len(), 1); - match &requests[0] { - GossipEnvelope::BlockRangeRequest { from_height, limit } => { - assert_eq!(*from_height, 1); - assert_eq!(*limit, crate::app::BLOCK_REQUEST_LIMIT); - } - other => panic!("expected block range request, got {other:?}"), - } - } - - #[tokio::test] - async fn session_catchup_payload_pushes_missing_blocks_to_lagging_peer() { - 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.clone(), - allocations, - ))); - { - let mut node = node.lock().await; - for height in 1..=3 { - queue_plaintext_burn(&mut node, &alice, 1); - node.drain_outbox(); - node.mine_one_at(height).unwrap(); - node.drain_outbox(); - } - } - - let payload = super::catchup_payload_for_peer( - &node, - &super::PeerStatus::new(1, "old-tip".to_string()), - ) - .await; - - assert_eq!(payload.len(), 1); - match &payload[0] { - GossipEnvelope::Blocks { blocks } => { - assert_eq!( - blocks.iter().map(|block| block.height).collect::<Vec<_>>(), - vec![2, 3] - ); - } - other => panic!("expected missing block payload, got {other:?}"), - } - } - - #[tokio::test] - async fn session_catchup_payload_pushes_blinded_mempool_to_synced_peer() { - let alice = Wallet::from_seed("catchup-mempool-alice"); - let bob = Wallet::from_seed("catchup-mempool-bob"); - let allocations = allocations(&[alice.clone(), bob], 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node( - "alice", - alice.clone(), - allocations, - ))); - let expected_commitment = { - let mut node = node.lock().await; - let tx = node.ledger().build_burn(&alice, 1, 0).unwrap(); - let built = node - .ledger() - .build_blinded_transaction(&alice, tx, 20) - .unwrap(); - let commitment = built.transaction.commitment.clone(); - node.receive_blinded_transaction(built.transaction).unwrap(); - node.drain_outbox(); - commitment - }; - let peer_status = { - let node = node.lock().await; - let status = node.ledger().status(); - super::PeerStatus::new(status.height, status.tip_hash) - }; - - let payload = super::catchup_payload_for_peer(&node, &peer_status).await; - - assert_eq!(payload.len(), 1); - match &payload[0] { - GossipEnvelope::BlindedTransactions { transactions } => { - assert_eq!(transactions.len(), 1); - assert_eq!(transactions[0].commitment, expected_commitment); - } - other => panic!("expected blinded mempool payload, got {other:?}"), - } - } - - #[tokio::test] - async fn hello_rejects_wrong_network_or_genesis_without_banning() { - let alice = Wallet::from_seed("hello-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, - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - - let wrong_network = ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: "other-network".to_string(), - genesis_hash: network - .inner - .node - .lock() - .await - .ledger() - .genesis_hash() - .to_string(), - listen_addr: None, - node_id: None, - height: 0, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }; - assert!( - super::process_hello( - &network, - "127.0.0.1:9545".parse().unwrap(), - &mut None, - wrong_network, - ) - .await - .unwrap_err() - .to_string() - .contains("wrong network") - ); - - let wrong_genesis = ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: "not-local-genesis".to_string(), - listen_addr: Some("127.0.0.1:9545".to_string()), - node_id: None, - height: 0, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }; - assert!( - super::process_hello( - &network, - "127.0.0.1:9545".parse().unwrap(), - &mut None, - wrong_genesis, - ) - .await - .unwrap_err() - .to_string() - .contains("wrong genesis") - ); - - let wrong_protocol = ProtocolHello { - protocol_version: PROTOCOL_VERSION + 1, - network_id: NETWORK_ID.to_string(), - genesis_hash: network - .inner - .node - .lock() - .await - .ledger() - .genesis_hash() - .to_string(), - listen_addr: Some("127.0.0.1:9545".to_string()), - node_id: None, - height: 0, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }; - assert!( - super::process_hello( - &network, - "127.0.0.1:9545".parse().unwrap(), - &mut None, - wrong_protocol, - ) - .await - .unwrap_err() - .to_string() - .contains("unsupported protocol version") - ); - - assert!(network.inner.peers.lock().await.list().is_empty()); - } - - #[tokio::test] - async fn hello_records_remote_clock_observation() { - let alice = Wallet::from_seed("hello-clock-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, - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let remote_time_ms = crate::app::now_ms().saturating_add(60_000); - let hello = ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: network - .inner - .node - .lock() - .await - .ledger() - .genesis_hash() - .to_string(), - listen_addr: Some("127.0.0.1:9545".to_string()), - node_id: None, - height: 0, - tip_hash: "tip".to_string(), - time_ms: remote_time_ms, - }; - - let mut known_peer = None; - super::process_hello( - &network, - "127.0.0.1:9545".parse().unwrap(), - &mut known_peer, - hello, - ) - .await - .unwrap(); - - let peers = network.inner.peers.lock().await.list(); - let peer = peers - .iter() - .find(|peer| peer.address == "127.0.0.1:9545") - .unwrap(); - assert!(peer.last_clock_offset_ms.unwrap() > 30_000); - assert_eq!(peer.last_clock_offset_accepted, Some(true)); - } - - #[tokio::test] - async fn hello_remembers_advertised_address_after_signed_session_and_dialback() { - let alice = Wallet::from_seed("hello-dialback-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node: Arc::clone(&node), - peers: Arc::clone(&peers), - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let remote_node_id = super::new_node_id(); - let remote_addr = spawn_hello_server(ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), - listen_addr: None, - node_id: Some(remote_node_id.clone()), - height: 0, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }) - .await; - let original_addr = spawn_verification_responder(remote_node_id.clone()).await; - let stream = tokio::net::TcpStream::connect(original_addr).await.unwrap(); - let remote_socket = stream.peer_addr().unwrap(); - let (reader, mut writer) = stream.into_split(); - let mut reader = super::LimitedLineReader::new(reader); - let hello = ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), - listen_addr: Some(remote_addr.to_string()), - node_id: Some(remote_node_id), - height: 0, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }; - let mut known_peer = None; - - super::process_hello_with_verification( - &network, - &mut writer, - &mut reader, - "test-original-peer", - remote_socket, - &mut known_peer, - hello, - ) - .await - .unwrap(); - - assert_eq!(known_peer, Some(remote_addr.to_string())); - let listed = peers.lock().await.list(); - let peer = listed - .iter() - .find(|peer| peer.address == remote_addr.to_string()) - .unwrap(); - assert_eq!(peer.direction, PeerDirection::Discovered); - assert!( - peers - .lock() - .await - .addresses() - .contains(&remote_addr.to_string()) - ); - } - - #[tokio::test] - async fn hello_ignores_advertised_address_when_connected_peer_cannot_sign_claimed_node_id() { - let alice = Wallet::from_seed("hello-dialback-spoof-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node: Arc::clone(&node), - peers: Arc::clone(&peers), - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let victim_node_id = super::new_node_id(); - let attacker_node_id = super::new_node_id(); - let remote_addr = spawn_hello_server(ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), - listen_addr: None, - node_id: Some(victim_node_id.clone()), - height: 0, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }) - .await; - let original_addr = spawn_verification_responder(attacker_node_id).await; - let stream = tokio::net::TcpStream::connect(original_addr).await.unwrap(); - let remote_socket = stream.peer_addr().unwrap(); - let (reader, mut writer) = stream.into_split(); - let mut reader = super::LimitedLineReader::new(reader); - let hello = ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), - listen_addr: Some(remote_addr.to_string()), - node_id: Some(victim_node_id), - height: 0, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }; - let mut known_peer = None; - - super::process_hello_with_verification( - &network, - &mut writer, - &mut reader, - "test-attacker-peer", - remote_socket, - &mut known_peer, - hello, - ) - .await - .unwrap(); - - assert!(known_peer.is_none()); - assert!( - !peers - .lock() - .await - .addresses() - .contains(&remote_addr.to_string()) - ); - } - - #[tokio::test] - async fn dialback_rejects_address_that_signs_with_different_node_id() { - let alice = Wallet::from_seed("hello-dialback-mismatch-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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let honest_node_id = super::new_node_id(); - let claimed_node_id = super::new_node_id(); - let remote_addr = spawn_hello_server(ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), - listen_addr: None, - node_id: Some(honest_node_id), - height: 0, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }) - .await; - - assert!( - !super::verify_advertised_peer_node_id( - &network, - &remote_addr.to_string(), - &claimed_node_id - ) - .await - ); - } - - #[tokio::test] - async fn inbound_announced_address_replaces_gateway_address_for_ui() { - let alice = Wallet::from_seed("hello-public-announced-inbound-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers: Arc::clone(&peers), - listen_addr: "0.0.0.0:9444".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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let mut known_peer = None; - - let remembered = super::remember_discoverable_advertised_peer( - &network, - "10.42.0.1:51234".parse().unwrap(), - &mut known_peer, - "142.132.164.59:9444".to_string(), - ) - .await - .unwrap(); - super::record_peer_status( - &network, - &known_peer, - "10.42.0.1:51234".parse().unwrap(), - &super::PeerStatus::with_time(7, "tip".to_string(), 1_000), - ) - .await; - - assert!(remembered); - assert_eq!(known_peer.as_deref(), Some("142.132.164.59:9444")); - let listed = peers.lock().await.list(); - assert_eq!(listed.len(), 1); - let peer = &listed[0]; - assert_eq!(peer.address, "142.132.164.59:9444"); - assert_eq!(peer.direction, PeerDirection::Discovered); - assert_eq!(peer.last_known_height, Some(7)); - assert_eq!(peer.messages_received, 0); - - let repeated = super::remember_discoverable_advertised_peer( - &network, - "10.42.0.1:51234".parse().unwrap(), - &mut known_peer, - "142.132.164.59:9444".to_string(), - ) - .await - .unwrap(); - - assert!(repeated); - assert_eq!( - peers.lock().await.list()[0].direction, - PeerDirection::Discovered - ); - } - - #[tokio::test] - async fn inbound_verification_only_session_closes_after_response() { - let alice = Wallet::from_seed("verification-only-close-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let listen_addr = listener.local_addr().unwrap(); - drop(listener); - let network = super::GossipNetwork::start(node, peers, listen_addr, None, true) - .await - .unwrap(); - - let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap(); - let (reader, mut writer) = stream.into_split(); - let mut reader = super::LimitedLineReader::new(reader); - let hello_line = reader.read_line().await.unwrap().unwrap(); - let node_id = match super::parse_envelope(&hello_line).unwrap() { - GossipEnvelope::Hello(hello) => hello.node_id.unwrap(), - other => panic!("expected hello, got {other:?}"), - }; - let nonce = super::new_verification_nonce(); - super::write_envelope( - &mut writer, - &GossipEnvelope::PeerVerificationChallenge { - address: listen_addr.to_string(), - nonce: nonce.clone(), - }, - ) - .await - .unwrap(); - - let response_line = - tokio::time::timeout(std::time::Duration::from_secs(1), reader.read_line()) - .await - .unwrap() - .unwrap() - .unwrap(); - match super::parse_envelope(&response_line).unwrap() { - GossipEnvelope::PeerVerificationResponse { - address, - nonce: response_nonce, - node_id: response_node_id, - signature, - } => assert!(super::peer_verification_response_is_valid( - &address, - &response_nonce, - &response_node_id, - &signature, - &listen_addr.to_string(), - &nonce, - &node_id, - )), - other => panic!("expected verification response, got {other:?}"), - } - - let closed = tokio::time::timeout(std::time::Duration::from_secs(1), reader.read_line()) - .await - .unwrap() - .unwrap(); - assert!(closed.is_none()); - network.set_accept_inbound(false).await.unwrap(); - } - - #[tokio::test] - async fn setup_placeholder_accepts_remote_genesis_and_adopts_snapshot() { - let local_wallet = Wallet::from_seed("setup-placeholder-local"); - let local_ledger = Ledger::new(BTreeMap::new(), 1); - let local_node = Arc::new(tokio::sync::Mutex::new(NodeCore::from_ledger( - local_wallet, - local_ledger.clone(), - 0, - ))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node: local_node, - peers: Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ - "iuna.jhx.app:9444".to_string(), - ]))), - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - - let remote_wallet = Wallet::from_seed("setup-placeholder-remote"); - let remote_snapshot = node( - "remote", - remote_wallet.clone(), - allocations(std::slice::from_ref(&remote_wallet), 1_000), - ) - .chain_snapshot(); - let remote_genesis = remote_snapshot.blocks[0].hash.clone(); - let hello = ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: remote_genesis.clone(), - listen_addr: Some("142.132.164.59:9444".to_string()), - node_id: None, - height: 5, - tip_hash: "remote-tip".to_string(), - time_ms: 1_000, - }; - let mut known_peer = Some("iuna.jhx.app:9444".to_string()); - let peer_status = super::process_hello( - &network, - "142.132.164.59:51234".parse().unwrap(), - &mut known_peer, - hello, - ) - .await - .unwrap(); - - assert!(peer_status.request_snapshot); - assert!(!peer_status.push_snapshot); - assert_eq!(known_peer.as_deref(), Some("iuna.jhx.app:9444")); - let listed = network.inner.peers.lock().await.list(); - assert_eq!(listed.len(), 1); - let peer = listed - .into_iter() - .find(|peer| peer.address == "iuna.jhx.app:9444") - .unwrap(); - assert_eq!(peer.misbehavior_score, 0); - assert!(!peer.is_banned_at(crate::app::now_ms())); - - let adopted = - super::validate_snapshot_extension(local_ledger, remote_snapshot, crate::app::now_ms()) - .await - .unwrap(); - assert_eq!(adopted.genesis_hash(), remote_genesis); - assert!( - network - .inner - .node - .lock() - .await - .import_verified_ledger(adopted) - .unwrap() - ); - assert_eq!( - network.inner.node.lock().await.ledger().genesis_hash(), - remote_genesis - ); - } - - #[tokio::test] - async fn real_node_accepts_setup_placeholder_peer_and_pushes_snapshot() { - let wallet = Wallet::from_seed("setup-placeholder-peer-real-node"); - let node = Arc::new(tokio::sync::Mutex::new(node( - "real", - wallet.clone(), - allocations(std::slice::from_ref(&wallet), 1_000), - ))); - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let setup_ledger = Ledger::new(BTreeMap::new(), 1); - let hello = ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: setup_ledger.genesis_hash().to_string(), - listen_addr: Some("127.0.0.1:9545".to_string()), - node_id: None, - height: 0, - tip_hash: setup_ledger.status().tip_hash, - time_ms: 1_000, - }; - - let peer_status = super::process_hello( - &network, - "127.0.0.1:51234".parse().unwrap(), - &mut None, - hello, - ) - .await - .unwrap(); - - assert!(!peer_status.request_snapshot); - assert!(peer_status.push_snapshot); - let payload = super::catchup_payload_for_peer(&node, &peer_status).await; - assert!(matches!( - payload.as_slice(), - [GossipEnvelope::ChainSnapshot(_)] - )); - } - - #[tokio::test] - async fn hello_ignores_private_advertised_listen_address() { - let alice = Wallet::from_seed("hello-private-listen-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node: Arc::clone(&node), - peers: Arc::clone(&peers), - listen_addr: "0.0.0.0:9444".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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let status = node.lock().await.ledger().status(); - let hello = ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), - listen_addr: Some("10.42.1.1:12138".to_string()), - node_id: None, - height: status.height, - tip_hash: status.tip_hash, - time_ms: 1_000, - }; - - let mut known_peer = None; - super::process_hello( - &network, - "142.132.164.59:51234".parse().unwrap(), - &mut known_peer, - hello, - ) - .await - .unwrap(); - - assert!(known_peer.is_none()); - assert!(peers.lock().await.addresses().is_empty()); - } - - #[tokio::test] - async fn inbound_status_does_not_create_outbound_ephemeral_peer() { - let alice = Wallet::from_seed("inbound-status-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers: Arc::clone(&peers), - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - - super::record_peer_status( - &network, - &None, - "127.0.0.1:51729".parse().unwrap(), - &super::PeerStatus::new(4, "tip".to_string()), - ) - .await; - - let peers = peers.lock().await; - assert!(peers.addresses().is_empty()); - let listed = peers.list(); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].direction, PeerDirection::Inbound); - } - - #[test] - fn join_snapshot_response_ignores_status_noise_before_snapshot() { - assert!( - super::join_snapshot_response( - "127.0.0.1:9544", - GossipEnvelope::PeerStatus { - height: 0, - tip_hash: "tip".to_string(), - time_ms: 1_000, - } - ) - .unwrap() - .is_none() - ); - - let alice = Wallet::from_seed("join-noise-alice"); - let snapshot = node( - "alice", - alice.clone(), - allocations(std::slice::from_ref(&alice), 1_000), - ) - .chain_snapshot(); - let parsed = super::join_snapshot_response( - "127.0.0.1:9544", - GossipEnvelope::ChainSnapshot(snapshot.clone()), - ) - .unwrap(); - - assert_eq!(parsed, Some(snapshot)); - } - - #[tokio::test] - async fn peer_exchange_does_not_advertise_self_when_outbound_only() { - let alice = Wallet::from_seed("px-private-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ - "127.0.0.1:9545".to_string(), - ]))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers, - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - match network.peer_exchange().await { - GossipEnvelope::PeerList { peers } => { - assert!(!peers.contains(&"127.0.0.1:9544".to_string())); - assert!(peers.contains(&"127.0.0.1:9545".to_string())); - } - other => panic!("expected peer list, got {other:?}"), - } - } - - #[tokio::test] - async fn peer_exchange_omits_hostname_bootstrap_peers() { - let alice = Wallet::from_seed("px-hostname-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ - "iuna.jhx.app:9444".to_string(), - "127.0.0.1:9545".to_string(), - ]))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers, - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - match network.peer_exchange().await { - GossipEnvelope::PeerList { peers } => { - assert!(!peers.contains(&"iuna.jhx.app:9444".to_string())); - assert!(peers.contains(&"127.0.0.1:9545".to_string())); - } - other => panic!("expected peer list, got {other:?}"), - } - } - - #[tokio::test] - async fn peer_exchange_advertises_discovered_listening_peers() { - let alice = Wallet::from_seed("px-discovered-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - peers - .lock() - .await - .add_discovered_peer("127.0.0.1:9546".to_string()); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers, - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - - match network.peer_exchange().await { - GossipEnvelope::PeerList { peers } => { - assert!(peers.contains(&"127.0.0.1:9546".to_string())); - } - other => panic!("expected peer list, got {other:?}"), - } - } - - #[tokio::test] - async fn peer_exchange_advertises_stable_listen_and_known_peers() { - let alice = Wallet::from_seed("px-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ - "127.0.0.1:9545".to_string(), - ]))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers, - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - network.set_accept_inbound(true).await.unwrap(); - - match network.peer_exchange().await { - GossipEnvelope::PeerList { peers } => { - assert!(peers.contains(&"127.0.0.1:9544".to_string())); - assert!(peers.contains(&"127.0.0.1:9545".to_string())); - } - other => panic!("expected peer list, got {other:?}"), - } - network.set_accept_inbound(false).await.unwrap(); - } - - #[tokio::test] - async fn peer_exchange_filters_announced_self_from_known_peers() { - let alice = Wallet::from_seed("px-announced-self-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ - "8.8.8.8:9444".to_string(), - "8.8.4.4:9444".to_string(), - ]))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers, - listen_addr: "127.0.0.1:0".parse().unwrap(), - p2p_announce_addr: tokio::sync::Mutex::new(Some("8.8.8.8:9444".parse().unwrap())), - node_id: super::new_node_id(), - accept_task: tokio::sync::Mutex::new(None), - sessions: tokio::sync::Mutex::new(BTreeMap::new()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - network.set_accept_inbound(true).await.unwrap(); - - match network.peer_exchange().await { - GossipEnvelope::PeerList { peers } => { - assert_eq!( - peers.iter().filter(|peer| *peer == "8.8.8.8:9444").count(), - 1 - ); - assert!(peers.contains(&"8.8.4.4:9444".to_string())); - } - other => panic!("expected peer list, got {other:?}"), - } - network.set_accept_inbound(false).await.unwrap(); - } - - #[tokio::test] - async fn peer_list_adds_stable_outbound_peers() { - let alice = Wallet::from_seed("px-recv-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers: Arc::clone(&peers), - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - super::apply_peer_list( - &network, - "127.0.0.1:9545".parse().unwrap(), - vec!["127.0.0.1:9544".to_string(), "127.0.0.1:9546".to_string()], - ) - .await - .unwrap(); - - let addresses = peers.lock().await.addresses(); - assert!(!addresses.contains(&"127.0.0.1:9544".to_string())); - assert!(addresses.contains(&"127.0.0.1:9546".to_string())); - } - - #[tokio::test] - async fn peer_list_ignores_invalid_peer_addresses() { - let alice = Wallet::from_seed("px-list-invalid-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers: Arc::clone(&peers), - 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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - super::apply_peer_list( - &network, - "127.0.0.1:9545".parse().unwrap(), - vec![ - "iuna.jhx.app:9444".to_string(), - "127.0.0.1:9546".to_string(), - ], - ) - .await - .unwrap(); - - let addresses = peers.lock().await.addresses(); - assert!(!addresses.contains(&"iuna.jhx.app:9444".to_string())); - assert!(addresses.contains(&"127.0.0.1:9546".to_string())); - } - - #[tokio::test] - async fn peer_list_ignores_announced_self_address() { - let alice = Wallet::from_seed("px-list-announced-self-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers: Arc::clone(&peers), - listen_addr: "0.0.0.0:9444".parse().unwrap(), - p2p_announce_addr: tokio::sync::Mutex::new(Some("8.8.8.8:9444".parse().unwrap())), - node_id: super::new_node_id(), - accept_task: tokio::sync::Mutex::new(None), - sessions: tokio::sync::Mutex::new(BTreeMap::new()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - - super::apply_peer_list( - &network, - "8.8.4.4:9444".parse().unwrap(), - vec!["8.8.8.8:9444".to_string(), "8.8.4.4:9445".to_string()], - ) - .await - .unwrap(); - - let addresses = peers.lock().await.addresses(); - assert!(!addresses.contains(&"8.8.8.8:9444".to_string())); - assert!(addresses.contains(&"8.8.4.4:9445".to_string())); - network.set_accept_inbound(false).await.unwrap(); - } - - #[tokio::test] - async fn peer_list_ignores_private_ephemeral_addresses() { - let alice = Wallet::from_seed("px-private-ephemeral-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers: Arc::clone(&peers), - listen_addr: "0.0.0.0:9444".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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - - super::apply_peer_list( - &network, - "142.132.164.59:9444".parse().unwrap(), - vec![ - "10.42.1.1:10091".to_string(), - "142.132.164.59:9444".to_string(), - ], - ) - .await - .unwrap(); - - let addresses = peers.lock().await.addresses(); - assert!(!addresses.contains(&"10.42.1.1:10091".to_string())); - assert!(addresses.contains(&"142.132.164.59:9444".to_string())); - } - - #[tokio::test] - async fn peer_announcement_ignores_private_ephemeral_address() { - let alice = Wallet::from_seed("px-private-announcement-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers: Arc::clone(&peers), - listen_addr: "0.0.0.0:9444".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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let mut known_peer = None; - - let remembered = super::remember_discoverable_advertised_peer( - &network, - "142.132.164.59:51234".parse().unwrap(), - &mut known_peer, - "10.42.1.1:10091".to_string(), - ) - .await - .unwrap(); - - assert!(!remembered); - assert!(known_peer.is_none()); - assert!(peers.lock().await.addresses().is_empty()); - } - - #[tokio::test] - async fn peer_announcement_removes_outbound_peer_that_announces_self_address() { - let alice = Wallet::from_seed("px-self-announcement-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ - "10.42.1.1:30508".to_string(), - ]))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers: Arc::clone(&peers), - listen_addr: "0.0.0.0:9444".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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let mut known_peer = Some("10.42.1.1:30508".to_string()); - - super::forget_stale_self_peer(&network, &mut known_peer).await; - - assert_eq!(known_peer, None); - assert!(peers.lock().await.addresses().is_empty()); - } - - #[tokio::test] - async fn peer_list_ignores_loopback_alias_for_unspecified_self() { - let alice = Wallet::from_seed("px-self-alias-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers: Arc::clone(&peers), - listen_addr: "0.0.0.0:9545".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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - - super::apply_peer_list( - &network, - "127.0.0.1:9544".parse().unwrap(), - vec!["127.0.0.1:9545".to_string(), "127.0.0.1:9546".to_string()], - ) - .await - .unwrap(); - - let addresses = peers.lock().await.addresses(); - assert!(!addresses.contains(&"127.0.0.1:9545".to_string())); - assert!(addresses.contains(&"127.0.0.1:9546".to_string())); - assert_eq!(network.metrics().self_peer_skips, 1); - } - - #[tokio::test] - async fn hello_ignores_loopback_alias_for_unspecified_self() { - let alice = Wallet::from_seed("hello-self-alias-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers: Arc::clone(&peers), - listen_addr: "0.0.0.0:9545".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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let hello = ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: network - .inner - .node - .lock() - .await - .ledger() - .genesis_hash() - .to_string(), - listen_addr: Some("127.0.0.1:9545".to_string()), - node_id: None, - height: 0, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }; - - super::process_hello( - &network, - "127.0.0.1:52144".parse().unwrap(), - &mut None, - hello, - ) - .await - .unwrap(); - - assert_eq!(network.metrics().self_peer_rejections, 1); - assert!(peers.lock().await.addresses().is_empty()); - let listed = peers.lock().await.list(); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].direction, PeerDirection::Inbound); - } - - #[tokio::test] - async fn hello_removes_outbound_peer_that_announces_self_address() { - let alice = Wallet::from_seed("hello-self-outbound-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ - "10.42.1.1:16987".to_string(), - ]))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers: Arc::clone(&peers), - listen_addr: "0.0.0.0:9444".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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let hello = ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: network - .inner - .node - .lock() - .await - .ledger() - .genesis_hash() - .to_string(), - listen_addr: Some("127.0.0.1:9444".to_string()), - node_id: None, - height: 0, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }; - let mut known_peer = Some("10.42.1.1:16987".to_string()); - - super::process_hello( - &network, - "10.42.1.1:16987".parse().unwrap(), - &mut known_peer, - hello, - ) - .await - .unwrap(); - - assert_eq!(network.metrics().self_peer_rejections, 1); - assert!(known_peer.is_none()); - assert!(peers.lock().await.addresses().is_empty()); - } - - #[tokio::test] - async fn hello_removes_outbound_peer_with_same_node_id() { - let alice = Wallet::from_seed("hello-self-node-id-alice"); - let allocations = allocations(std::slice::from_ref(&alice), 1_000); - let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); - let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ - "142.132.164.59:9444".to_string(), - ]))); - let network = super::GossipNetwork { - inner: Arc::new(super::GossipNetworkInner { - node, - peers: Arc::clone(&peers), - listen_addr: "0.0.0.0:9444".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()), - inbound_limiter: Arc::new( - StdMutex::new(super::InboundConnectionLimiter::default()), - ), - metrics: super::P2pMetricsCounters::default(), - }), - }; - let hello = ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: network - .inner - .node - .lock() - .await - .ledger() - .genesis_hash() - .to_string(), - listen_addr: Some("0.0.0.0:9444".to_string()), - node_id: Some(network.inner.node_id.clone()), - height: 0, - tip_hash: "tip".to_string(), - time_ms: 1_000, - }; - let mut known_peer = Some("142.132.164.59:9444".to_string()); - - super::process_hello( - &network, - "142.132.164.59:52144".parse().unwrap(), - &mut known_peer, - hello, - ) - .await - .unwrap(); - - assert_eq!(network.metrics().self_peer_rejections, 1); - assert!(known_peer.is_none()); - assert!(peers.lock().await.addresses().is_empty()); - } - - async fn spawn_hello_server(hello: ProtocolHello) -> SocketAddr { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - let Ok((stream, _)) = listener.accept().await else { - return; - }; - let node_id = hello.node_id.clone(); - let (reader, mut writer) = stream.into_split(); - let line = serde_json::to_string(&GossipEnvelope::Hello(hello)).unwrap(); - let _ = writer.write_all(line.as_bytes()).await; - let _ = writer.write_all(b"\n").await; - let Some(node_id) = node_id else { - return; - }; - let mut reader = super::LimitedLineReader::new(reader); - let Ok(Some(line)) = reader.read_line().await else { - return; - }; - let Ok(GossipEnvelope::PeerVerificationChallenge { address, nonce }) = - super::parse_envelope(&line) - else { - return; - }; - let Some(response) = - super::peer_verification_response_for_node_id(&node_id, &address, &nonce) - else { - return; - }; - let line = serde_json::to_string(&response).unwrap(); - let _ = writer.write_all(line.as_bytes()).await; - let _ = writer.write_all(b"\n").await; - }); - addr - } - - async fn spawn_verification_responder(node_id: String) -> SocketAddr { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - let Ok((stream, _)) = listener.accept().await else { - return; - }; - let (reader, mut writer) = stream.into_split(); - let mut reader = super::LimitedLineReader::new(reader); - let Ok(Some(line)) = reader.read_line().await else { - return; - }; - let Ok(GossipEnvelope::PeerVerificationChallenge { address, nonce }) = - super::parse_envelope(&line) - else { - return; - }; - let Some(response) = - super::peer_verification_response_for_node_id(&node_id, &address, &nonce) - else { - return; - }; - let line = serde_json::to_string(&response).unwrap(); - let _ = writer.write_all(line.as_bytes()).await; - let _ = writer.write_all(b"\n").await; - }); - addr - } - - fn node(_network_key: &str, wallet: Wallet, allocations: BTreeMap<String, Amount>) -> NodeCore { - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(wallet.address(), 1)], - 25, - ) - .unwrap(); - 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() - .map(|wallet| (wallet.address().to_string(), amount)) - .collect() - } -} +mod tests; diff --git a/src/adapters/p2p/fetch.rs b/src/adapters/p2p/fetch.rs @@ -0,0 +1,265 @@ +use std::net::SocketAddr; + +use anyhow::{Context, Result}; +use tokio::{ + io::AsyncWriteExt, + net::{TcpStream, tcp::OwnedReadHalf}, + time::timeout, +}; + +use crate::{ + app::{GossipEnvelope, NETWORK_ID, PROTOCOL_VERSION, now_ms}, + domain::{Block, ChainSnapshot, Ledger, verify_vdf}, +}; + +use super::{ + GossipNetwork, JOIN_RESPONSE_TIMEOUT, LimitedLineReader, MAX_JOIN_RESPONSE_ENVELOPES, + PeerStatus, parse_envelope, +}; + +pub async fn fetch_snapshot(peer: &str) -> Result<ChainSnapshot> { + fetch_snapshot_with_announcement(peer, None).await +} + +pub async fn fetch_peer_height(peer: &str) -> Result<u64> { + fetch_peer_status(peer).await.map(|status| status.height) +} + +async fn fetch_peer_status(peer: &str) -> Result<PeerStatus> { + let stream = TcpStream::connect(peer) + .await + .with_context(|| format!("connecting to peer {peer}"))?; + let (reader, _writer) = stream.into_split(); + let mut reader = LimitedLineReader::new(reader); + let line = reader + .read_line() + .await? + .with_context(|| format!("peer {peer} closed before sending its peer status"))?; + match parse_envelope(&line)? { + GossipEnvelope::Hello(hello) => { + if hello.protocol_version != PROTOCOL_VERSION { + anyhow::bail!( + "unsupported protocol version {}; expected {}", + hello.protocol_version, + PROTOCOL_VERSION + ); + } + if hello.network_id != NETWORK_ID { + anyhow::bail!( + "wrong network {}; expected {}", + hello.network_id, + NETWORK_ID + ); + } + Ok(PeerStatus::with_time( + hello.height, + hello.tip_hash, + hello.time_ms, + )) + } + GossipEnvelope::PeerStatus { + height, + tip_hash, + time_ms, + } => Ok(PeerStatus::from_envelope(height, tip_hash, time_ms)), + other => anyhow::bail!("peer {peer} sent {other:?} instead of peer status"), + } +} + +pub async fn fetch_snapshot_with_announcement( + peer: &str, + _advertised_addr: Option<SocketAddr>, +) -> Result<ChainSnapshot> { + let stream = TcpStream::connect(peer) + .await + .with_context(|| format!("connecting to join peer {peer}"))?; + let (reader, mut writer) = stream.into_split(); + let mut reader = LimitedLineReader::new(reader); + let line = reader + .read_line() + .await? + .with_context(|| format!("join peer {peer} closed before sending its peer status"))?; + match parse_envelope(&line)? { + GossipEnvelope::Hello(hello) => { + if hello.protocol_version != PROTOCOL_VERSION { + anyhow::bail!( + "unsupported protocol version {}; expected {}", + hello.protocol_version, + PROTOCOL_VERSION + ); + } + if hello.network_id != NETWORK_ID { + anyhow::bail!( + "wrong network {}; expected {}", + hello.network_id, + NETWORK_ID + ); + } + } + GossipEnvelope::PeerStatus { .. } => {} + other => anyhow::bail!("join peer {peer} sent {other:?} instead of peer status"), + } + + let line = serde_json::to_string(&GossipEnvelope::ChainSnapshotRequest)?; + writer.write_all(line.as_bytes()).await?; + writer.write_all(b"\n").await?; + let snapshot = read_join_snapshot_response(peer, &mut reader).await?; + + Ok(snapshot) +} + +async fn read_join_snapshot_response( + peer: &str, + reader: &mut LimitedLineReader<OwnedReadHalf>, +) -> Result<ChainSnapshot> { + for _ in 0..MAX_JOIN_RESPONSE_ENVELOPES { + let line = timeout(JOIN_RESPONSE_TIMEOUT, reader.read_line()) + .await + .with_context(|| format!("join peer {peer} timed out waiting for a chain snapshot"))?? + .with_context(|| format!("join peer {peer} closed before sending a chain snapshot"))?; + match join_snapshot_response(peer, parse_envelope(&line)?)? { + Some(snapshot) => return Ok(snapshot), + None => continue, + } + } + + anyhow::bail!("join peer {peer} sent too many non-snapshot envelopes while joining") +} + +pub(super) fn join_snapshot_response( + peer: &str, + envelope: GossipEnvelope, +) -> Result<Option<ChainSnapshot>> { + match envelope { + GossipEnvelope::ChainSnapshot(snapshot) => Ok(Some(snapshot)), + GossipEnvelope::Hello(_) + | GossipEnvelope::PeerStatus { .. } + | GossipEnvelope::PeerList { .. } + | GossipEnvelope::PeerVerificationChallenge { .. } + | GossipEnvelope::PeerVerificationResponse { .. } + | GossipEnvelope::Inventory { .. } => Ok(None), + other => anyhow::bail!("join peer {peer} sent {other:?} instead of a chain snapshot"), + } +} + +pub(super) async fn validate_snapshot_extension( + mut ledger: Ledger, + snapshot: ChainSnapshot, + now_ms: u64, +) -> Result<Ledger> { + if ledger.is_setup_placeholder() { + return tokio::task::spawn_blocking(move || Ledger::from_snapshot_at(snapshot, now_ms)) + .await + .context("chain snapshot adoption worker failed")?; + } + let missing_blocks = ledger.missing_snapshot_blocks(&snapshot)?; + verify_blocks_vdf(missing_blocks).await?; + + tokio::task::spawn_blocking(move || { + ledger.extend_from_preverified_snapshot_at(snapshot, now_ms)?; + Ok(ledger) + }) + .await + .context("chain snapshot extension worker failed")? +} + +pub(super) async fn validate_blocks_extension( + mut ledger: Ledger, + blocks: Vec<Block>, + now_ms: u64, +) -> Result<Ledger> { + if blocks.is_empty() { + return Ok(ledger); + } + verify_blocks_vdf(blocks.clone()).await?; + + tokio::task::spawn_blocking(move || { + for block in blocks { + ledger.apply_preverified_block_at(block, now_ms)?; + } + Ok(ledger) + }) + .await + .context("block batch extension worker failed")? +} + +pub(super) async fn network_adjusted_time_ms(network: &GossipNetwork) -> u64 { + let local_time_ms = now_ms(); + network + .inner + .peers + .lock() + .await + .adjusted_time_ms_at(local_time_ms) +} + +pub(super) async fn verify_block_vdf(block: Block) -> Result<Block> { + let seed = block.vdf_seed(); + let rounds = block.vdf_rounds; + let solution = block.vdf_output.clone(); + let valid = tokio::task::spawn_blocking(move || verify_vdf(&seed, rounds, &solution)) + .await + .context("VDF verification worker failed")?; + if !valid { + anyhow::bail!("block VDF output is invalid"); + } + + Ok(block) +} + +async fn verify_blocks_vdf(blocks: Vec<Block>) -> Result<()> { + let mut tasks = tokio::task::JoinSet::new(); + for block in blocks { + tasks.spawn_blocking(move || { + if !verify_vdf(&block.vdf_seed(), block.vdf_rounds, &block.vdf_output) { + anyhow::bail!("block {} VDF output is invalid", block.height); + } + Ok::<(), anyhow::Error>(()) + }); + } + + while let Some(result) = tasks.join_next().await { + result.context("VDF verification worker failed")??; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::{app::GossipEnvelope, domain::Wallet}; + + use super::super::test_support::{allocations, node}; + use super::join_snapshot_response; + + #[test] + fn join_snapshot_response_ignores_status_noise_before_snapshot() { + assert!( + join_snapshot_response( + "127.0.0.1:9544", + GossipEnvelope::PeerStatus { + height: 0, + tip_hash: "tip".to_string(), + time_ms: 1_000, + } + ) + .unwrap() + .is_none() + ); + + let alice = Wallet::from_seed("join-noise-alice"); + let snapshot = node( + "alice", + alice.clone(), + allocations(std::slice::from_ref(&alice), 1_000), + ) + .chain_snapshot(); + let parsed = join_snapshot_response( + "127.0.0.1:9544", + GossipEnvelope::ChainSnapshot(snapshot.clone()), + ) + .unwrap(); + + assert_eq!(parsed, Some(snapshot)); + } +} diff --git a/src/adapters/p2p/handshake.rs b/src/adapters/p2p/handshake.rs @@ -0,0 +1,585 @@ +use std::{collections::BTreeMap, net::SocketAddr}; + +use anyhow::Result; +use tokio::{ + net::{TcpStream, tcp::OwnedWriteHalf}, + time::timeout, +}; + +use super::identity::{ + new_verification_nonce, peer_verification_response, peer_verification_response_is_valid, +}; +use super::line_codec::{LimitedLineReader, parse_envelope, read_session_envelope}; +use super::metrics::P2pMetricsCounters; +use super::peer_addr::{advertised_peer_is_discoverable, normalize_advertised_peer}; +use super::{ + CONNECT_TIMEOUT, GossipNetwork, HANDSHAKE_TIMEOUT, MAX_PEER_VERIFICATION_ENVELOPES, PeerStatus, + write_envelope, +}; +use crate::{ + app::{ + GossipEnvelope, NETWORK_ID, PROTOCOL_VERSION, PeerDirection, ProtocolHello, + debug_logging_enabled, now_ms, + }, + domain::Ledger, +}; + +pub(super) struct PeerVerificationSession<'a> { + pub(super) writer: &'a mut OwnedWriteHalf, + pub(super) reader: &'a mut LimitedLineReader<tokio::net::tcp::OwnedReadHalf>, + pub(super) connection_label: &'a str, +} + +pub(super) async fn record_peer_status( + network: &GossipNetwork, + known_peer: &Option<String>, + remote_addr: SocketAddr, + peer_status: &PeerStatus, +) { + let local_receive_time_ms = now_ms(); + if let Some(peer) = known_peer { + let mut peers = network.inner.peers.lock().await; + peers.record_status(peer, peer_status.height, peer_status.tip_hash.clone()); + peers.record_clock_observation( + peer, + PeerDirection::Outbound, + peer_status.time_ms, + local_receive_time_ms, + ); + } else { + let peer = remote_addr.to_string(); + let mut peers = network.inner.peers.lock().await; + peers.record_clock_observation( + &peer, + PeerDirection::Inbound, + peer_status.time_ms, + local_receive_time_ms, + ); + peers.record_received(&peer, 1); + } +} + +pub(super) async fn process_hello( + network: &GossipNetwork, + remote_addr: SocketAddr, + known_peer: &mut Option<String>, + hello: ProtocolHello, +) -> Result<PeerStatus> { + process_hello_inner(network, None, remote_addr, known_peer, hello).await +} + +pub(super) async fn process_hello_with_verification( + network: &GossipNetwork, + writer: &mut OwnedWriteHalf, + reader: &mut LimitedLineReader<tokio::net::tcp::OwnedReadHalf>, + connection_label: &str, + remote_addr: SocketAddr, + known_peer: &mut Option<String>, + hello: ProtocolHello, +) -> Result<PeerStatus> { + let mut verification_session = PeerVerificationSession { + writer, + reader, + connection_label, + }; + process_hello_inner( + network, + Some(&mut verification_session), + remote_addr, + known_peer, + hello, + ) + .await +} + +async fn process_hello_inner( + network: &GossipNetwork, + mut verification_session: Option<&mut PeerVerificationSession<'_>>, + remote_addr: SocketAddr, + known_peer: &mut Option<String>, + hello: ProtocolHello, +) -> Result<PeerStatus> { + if hello.protocol_version != PROTOCOL_VERSION { + anyhow::bail!( + "unsupported protocol version {}; expected {}", + hello.protocol_version, + PROTOCOL_VERSION + ); + } + if hello.network_id != NETWORK_ID { + anyhow::bail!( + "wrong network {}; expected {}", + hello.network_id, + NETWORK_ID + ); + } + if hello + .node_id + .as_deref() + .is_some_and(|node_id| node_id == network.inner.node_id) + { + P2pMetricsCounters::inc(&network.inner.metrics.self_peer_rejections); + forget_stale_self_peer(network, known_peer).await; + return Ok(PeerStatus::with_time( + hello.height, + hello.tip_hash, + hello.time_ms, + )); + } + let (local_genesis, local_accepts_remote_genesis) = { + let node = network.inner.node.lock().await; + ( + node.ledger().genesis_hash().to_string(), + node.ledger().is_setup_placeholder(), + ) + }; + let genesis_mismatch = hello.genesis_hash != local_genesis; + let remote_is_setup_placeholder = + hello.height == 0 && hello.genesis_hash == setup_placeholder_genesis_hash(); + let request_snapshot = genesis_mismatch && local_accepts_remote_genesis; + let push_snapshot = genesis_mismatch && remote_is_setup_placeholder; + if genesis_mismatch && !local_accepts_remote_genesis && !remote_is_setup_placeholder { + anyhow::bail!( + "wrong genesis {}; expected {local_genesis}", + hello.genesis_hash + ); + } + + let remote_node_id = hello.node_id.clone(); + if let Some(listen_addr) = &hello.listen_addr { + let peer = normalize_advertised_peer(listen_addr, remote_addr)?; + if network.is_self_peer(&peer).await { + P2pMetricsCounters::inc(&network.inner.metrics.self_peer_rejections); + forget_stale_self_peer(network, known_peer).await; + } else { + let verified = match verification_session.as_mut() { + Some(session) => { + remember_verified_advertised_peer( + network, + session, + remote_addr, + known_peer, + peer.clone(), + remote_node_id.as_deref(), + ) + .await? + } + None => false, + }; + if !verified && debug_logging_enabled() { + eprintln!( + "p2p advertised address {peer} ignored because ownership was not verified" + ); + } + } + } + record_peer_status( + network, + known_peer, + remote_addr, + &PeerStatus::with_time(hello.height, hello.tip_hash.clone(), hello.time_ms), + ) + .await; + if request_snapshot { + Ok(PeerStatus::with_snapshot_request( + hello.height, + hello.tip_hash, + hello.time_ms, + )) + } else if push_snapshot { + Ok(PeerStatus::with_snapshot_push( + hello.height, + hello.tip_hash, + hello.time_ms, + )) + } else { + Ok(PeerStatus::with_time( + hello.height, + hello.tip_hash, + hello.time_ms, + )) + } +} + +fn setup_placeholder_genesis_hash() -> String { + Ledger::new(BTreeMap::new(), 1).genesis_hash().to_string() +} + +async fn remember_verified_advertised_peer( + network: &GossipNetwork, + session: &mut PeerVerificationSession<'_>, + remote_addr: SocketAddr, + known_peer: &mut Option<String>, + peer: String, + expected_node_id: Option<&str>, +) -> Result<bool> { + if !advertised_peer_is_discoverable(&peer, remote_addr)? { + return Ok(false); + } + if known_peer.as_deref() != Some(peer.as_str()) { + let Some(expected_node_id) = expected_node_id else { + return Ok(false); + }; + if !verify_connected_peer_node_id(network, session, &peer, expected_node_id).await? { + return Ok(false); + } + if !verify_advertised_peer_node_id(network, &peer, expected_node_id).await { + return Ok(false); + } + } + remember_discoverable_advertised_peer(network, remote_addr, known_peer, peer).await +} + +async fn verify_connected_peer_node_id( + network: &GossipNetwork, + session: &mut PeerVerificationSession<'_>, + peer: &str, + expected_node_id: &str, +) -> Result<bool> { + let nonce = new_verification_nonce(); + write_envelope( + session.writer, + &GossipEnvelope::PeerVerificationChallenge { + address: peer.to_string(), + nonce: nonce.clone(), + }, + ) + .await?; + + for _ in 0..MAX_PEER_VERIFICATION_ENVELOPES { + let envelope = match timeout( + HANDSHAKE_TIMEOUT, + read_session_envelope(network, session.connection_label, session.reader), + ) + .await + { + Ok(Ok(Some(envelope))) => envelope, + Ok(Ok(None)) | Err(_) => return Ok(false), + Ok(Err(error)) => return Err(error), + }; + match envelope { + GossipEnvelope::PeerVerificationResponse { + address, + nonce: response_nonce, + node_id, + signature, + } => { + return Ok(peer_verification_response_is_valid( + &address, + &response_nonce, + &node_id, + &signature, + peer, + &nonce, + expected_node_id, + )); + } + GossipEnvelope::PeerVerificationChallenge { address, nonce } => { + if let Some(response) = peer_verification_response(network, &address, &nonce) { + write_envelope(session.writer, &response).await?; + } + } + _ => {} + } + } + Ok(false) +} + +pub(super) async fn verify_advertised_peer_node_id( + network: &GossipNetwork, + peer: &str, + expected_node_id: &str, +) -> bool { + let stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(peer)).await { + Ok(Ok(stream)) => stream, + Ok(Err(error)) => { + if debug_logging_enabled() { + eprintln!("p2p announced address {peer} failed verification: {error}"); + } + return false; + } + Err(_) => { + if debug_logging_enabled() { + eprintln!("p2p announced address {peer} failed verification: timeout"); + } + return false; + } + }; + let (reader, mut writer) = stream.into_split(); + let mut reader = LimitedLineReader::new(reader); + let line = match timeout(HANDSHAKE_TIMEOUT, reader.read_line()).await { + Ok(Ok(Some(line))) => line, + Ok(Ok(None)) => return false, + Ok(Err(error)) => { + if debug_logging_enabled() { + eprintln!( + "p2p announced address {peer} sent invalid verification hello: {error:#}" + ); + } + return false; + } + Err(_) => return false, + }; + let hello = match parse_envelope(&line) { + Ok(GossipEnvelope::Hello(hello)) => hello, + Ok(_) | Err(_) => return false, + }; + + if !advertised_peer_hello_is_compatible(network, &hello).await + || hello.node_id.as_deref() != Some(expected_node_id) + { + return false; + } + + let nonce = new_verification_nonce(); + if write_envelope( + &mut writer, + &GossipEnvelope::PeerVerificationChallenge { + address: peer.to_string(), + nonce: nonce.clone(), + }, + ) + .await + .is_err() + { + return false; + } + for _ in 0..MAX_PEER_VERIFICATION_ENVELOPES { + let line = match timeout(HANDSHAKE_TIMEOUT, reader.read_line()).await { + Ok(Ok(Some(line))) => line, + Ok(Ok(None)) | Ok(Err(_)) | Err(_) => return false, + }; + let envelope = match parse_envelope(&line) { + Ok(envelope) => envelope, + Err(_) => return false, + }; + if let GossipEnvelope::PeerVerificationResponse { + address, + nonce: response_nonce, + node_id, + signature, + } = envelope + { + return peer_verification_response_is_valid( + &address, + &response_nonce, + &node_id, + &signature, + peer, + &nonce, + expected_node_id, + ); + } + } + false +} + +async fn advertised_peer_hello_is_compatible( + network: &GossipNetwork, + hello: &ProtocolHello, +) -> bool { + if hello.protocol_version != PROTOCOL_VERSION || hello.network_id != NETWORK_ID { + return false; + } + let (local_genesis, local_accepts_remote_genesis) = { + let node = network.inner.node.lock().await; + ( + node.ledger().genesis_hash().to_string(), + node.ledger().is_setup_placeholder(), + ) + }; + let remote_is_setup_placeholder = + hello.height == 0 && hello.genesis_hash == setup_placeholder_genesis_hash(); + hello.genesis_hash == local_genesis + || local_accepts_remote_genesis + || remote_is_setup_placeholder +} + +pub(super) async fn remember_discoverable_advertised_peer( + network: &GossipNetwork, + remote_addr: SocketAddr, + known_peer: &mut Option<String>, + peer: String, +) -> Result<bool> { + if !advertised_peer_is_discoverable(&peer, remote_addr)? { + return Ok(false); + } + if let Some(previous_peer) = known_peer.as_deref() { + network + .inner + .peers + .lock() + .await + .replace_peer_address(previous_peer, peer.clone()); + } else { + network + .inner + .peers + .lock() + .await + .add_discovered_peer(peer.clone()); + } + *known_peer = Some(peer); + Ok(true) +} + +pub(super) async fn forget_stale_self_peer( + network: &GossipNetwork, + known_peer: &mut Option<String>, +) { + if let Some(previous_peer) = known_peer.take() { + network.inner.peers.lock().await.remove_peer(&previous_peer); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::{ + app::{PeerBook, PeerDirection}, + domain::Wallet, + }; + + use super::super::{ + PeerStatus, + test_support::{allocations, gossip_network, node}, + }; + use super::{ + forget_stale_self_peer, record_peer_status, remember_discoverable_advertised_peer, + }; + + #[tokio::test] + async fn inbound_announced_address_replaces_gateway_address_for_ui() { + let alice = Wallet::from_seed("hello-public-announced-inbound-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let network = gossip_network( + node, + Arc::clone(&peers), + "0.0.0.0:9444".parse().unwrap(), + None, + ); + let mut known_peer = None; + + let remembered = remember_discoverable_advertised_peer( + &network, + "10.42.0.1:51234".parse().unwrap(), + &mut known_peer, + "142.132.164.59:9444".to_string(), + ) + .await + .unwrap(); + record_peer_status( + &network, + &known_peer, + "10.42.0.1:51234".parse().unwrap(), + &PeerStatus::with_time(7, "tip".to_string(), 1_000), + ) + .await; + + assert!(remembered); + assert_eq!(known_peer.as_deref(), Some("142.132.164.59:9444")); + let listed = peers.lock().await.list(); + assert_eq!(listed.len(), 1); + let peer = &listed[0]; + assert_eq!(peer.address, "142.132.164.59:9444"); + assert_eq!(peer.direction, PeerDirection::Discovered); + assert_eq!(peer.last_known_height, Some(7)); + assert_eq!(peer.messages_received, 0); + + let repeated = remember_discoverable_advertised_peer( + &network, + "10.42.0.1:51234".parse().unwrap(), + &mut known_peer, + "142.132.164.59:9444".to_string(), + ) + .await + .unwrap(); + + assert!(repeated); + assert_eq!( + peers.lock().await.list()[0].direction, + PeerDirection::Discovered + ); + } + + #[tokio::test] + async fn inbound_status_does_not_create_outbound_ephemeral_peer() { + let alice = Wallet::from_seed("inbound-status-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let network = gossip_network( + node, + Arc::clone(&peers), + "127.0.0.1:9544".parse().unwrap(), + None, + ); + + record_peer_status( + &network, + &None, + "127.0.0.1:51729".parse().unwrap(), + &PeerStatus::new(4, "tip".to_string()), + ) + .await; + + let peers = peers.lock().await; + assert!(peers.addresses().is_empty()); + let listed = peers.list(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].direction, PeerDirection::Inbound); + } + + #[tokio::test] + async fn peer_announcement_ignores_private_ephemeral_address() { + let alice = Wallet::from_seed("px-private-announcement-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let network = gossip_network( + node, + Arc::clone(&peers), + "0.0.0.0:9444".parse().unwrap(), + None, + ); + let mut known_peer = None; + + let remembered = remember_discoverable_advertised_peer( + &network, + "142.132.164.59:51234".parse().unwrap(), + &mut known_peer, + "10.42.1.1:10091".to_string(), + ) + .await + .unwrap(); + + assert!(!remembered); + assert!(known_peer.is_none()); + assert!(peers.lock().await.addresses().is_empty()); + } + + #[tokio::test] + async fn peer_announcement_removes_outbound_peer_that_announces_self_address() { + let alice = Wallet::from_seed("px-self-announcement-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ + "10.42.1.1:30508".to_string(), + ]))); + let network = gossip_network( + node, + Arc::clone(&peers), + "0.0.0.0:9444".parse().unwrap(), + None, + ); + let mut known_peer = Some("10.42.1.1:30508".to_string()); + + forget_stale_self_peer(&network, &mut known_peer).await; + + assert_eq!(known_peer, None); + assert!(peers.lock().await.addresses().is_empty()); + } +} diff --git a/src/adapters/p2p/identity.rs b/src/adapters/p2p/identity.rs @@ -0,0 +1,135 @@ +use std::{ + collections::BTreeMap, + sync::{Mutex as StdMutex, OnceLock}, +}; + +use anyhow::Result; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; + +use crate::app::{GossipEnvelope, NETWORK_ID}; + +use super::GossipNetwork; + +static NODE_SIGNING_KEYS: OnceLock<StdMutex<BTreeMap<String, SigningKey>>> = OnceLock::new(); + +pub(super) fn new_node_id() -> String { + let mut bytes = [0_u8; 32]; + getrandom::getrandom(&mut bytes).expect("secure randomness unavailable for p2p node id"); + let signing_key = SigningKey::from_bytes(&bytes); + let node_id = hex_encode(&signing_key.verifying_key().to_bytes()); + node_signing_keys() + .lock() + .expect("node signing key registry mutex poisoned") + .insert(node_id.clone(), signing_key); + node_id +} + +fn node_signing_keys() -> &'static StdMutex<BTreeMap<String, SigningKey>> { + NODE_SIGNING_KEYS.get_or_init(|| StdMutex::new(BTreeMap::new())) +} + +pub(super) fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + encoded +} + +pub(super) fn decode_hex_array<const N: usize>(value: &str) -> Result<[u8; N]> { + if value.len() != N * 2 { + anyhow::bail!("hex value has {} chars, expected {}", value.len(), N * 2); + } + let mut bytes = [0_u8; N]; + for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() { + let high = hex_nibble(chunk[0])?; + let low = hex_nibble(chunk[1])?; + bytes[index] = (high << 4) | low; + } + Ok(bytes) +} + +pub(super) fn hex_nibble(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), + _ => anyhow::bail!("invalid hex digit"), + } +} + +pub(super) fn new_verification_nonce() -> String { + let mut bytes = [0_u8; 32]; + getrandom::getrandom(&mut bytes) + .expect("secure randomness unavailable for p2p verification nonce"); + hex_encode(&bytes) +} + +pub(super) fn peer_verification_payload(address: &str, nonce: &str, node_id: &str) -> String { + format!("iuna-peer-verification:v1:{NETWORK_ID}:{node_id}:{address}:{nonce}") +} + +pub(super) fn peer_verification_response( + network: &GossipNetwork, + address: &str, + nonce: &str, +) -> Option<GossipEnvelope> { + peer_verification_response_for_node_id(&network.inner.node_id, address, nonce) +} + +pub(super) fn peer_verification_response_for_node_id( + node_id: &str, + address: &str, + nonce: &str, +) -> Option<GossipEnvelope> { + let keys = node_signing_keys() + .lock() + .expect("node signing key registry mutex poisoned"); + let signing_key = keys.get(node_id)?; + let payload = peer_verification_payload(address, nonce, node_id); + let signature: Signature = signing_key.sign(payload.as_bytes()); + Some(GossipEnvelope::PeerVerificationResponse { + address: address.to_string(), + nonce: nonce.to_string(), + node_id: node_id.to_string(), + signature: hex_encode(&signature.to_bytes()), + }) +} + +pub(super) fn peer_verification_response_is_valid( + response_address: &str, + response_nonce: &str, + response_node_id: &str, + signature: &str, + expected_address: &str, + expected_nonce: &str, + expected_node_id: &str, +) -> bool { + if response_address != expected_address + || response_nonce != expected_nonce + || response_node_id != expected_node_id + { + return false; + } + let public_key = match decode_hex_array::<32>(response_node_id) { + Ok(public_key) => public_key, + Err(_) => return false, + }; + let signature = match decode_hex_array::<64>(signature) { + Ok(signature) => Signature::from_bytes(&signature), + Err(_) => return false, + }; + let verifying_key = match VerifyingKey::from_bytes(&public_key) { + Ok(verifying_key) => verifying_key, + Err(_) => return false, + }; + verifying_key + .verify( + peer_verification_payload(expected_address, expected_nonce, expected_node_id) + .as_bytes(), + &signature, + ) + .is_ok() +} diff --git a/src/adapters/p2p/inbound_limiter.rs b/src/adapters/p2p/inbound_limiter.rs @@ -0,0 +1,165 @@ +use std::{ + collections::{BTreeMap, VecDeque}, + net::IpAddr, + sync::{Arc, Mutex as StdMutex}, +}; + +use super::{ + INBOUND_ACCEPT_RATE_WINDOW_MS, MAX_INBOUND_ACCEPTS_PER_IP_PER_WINDOW, MAX_INBOUND_SESSIONS, + MAX_INBOUND_SESSIONS_PER_IP, +}; + +#[derive(Default)] +pub(super) struct InboundConnectionLimiter { + active: usize, + peers: BTreeMap<IpAddr, InboundPeerLimit>, +} + +#[derive(Default)] +struct InboundPeerLimit { + active: usize, + accepted_at_ms: VecDeque<u64>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum InboundSessionRejection { + GlobalActive, + PeerActive, + PeerRate, +} + +impl InboundSessionRejection { + pub(super) fn label(self) -> &'static str { + match self { + Self::GlobalActive => "global active inbound session limit", + Self::PeerActive => "per-IP active inbound session limit", + Self::PeerRate => "per-IP inbound accept rate limit", + } + } +} + +pub(super) struct InboundSessionPermit { + pub(super) limiter: Arc<StdMutex<InboundConnectionLimiter>>, + pub(super) ip: IpAddr, +} + +impl Drop for InboundSessionPermit { + fn drop(&mut self) { + if let Ok(mut limiter) = self.limiter.lock() { + limiter.release(self.ip); + } + } +} + +impl InboundConnectionLimiter { + pub(super) fn try_acquire( + &mut self, + ip: IpAddr, + now_ms: u64, + ) -> std::result::Result<(), InboundSessionRejection> { + self.prune_stale_accepts(now_ms); + if self.active >= MAX_INBOUND_SESSIONS { + return Err(InboundSessionRejection::GlobalActive); + } + + let peer = self.peers.entry(ip).or_default(); + prune_peer_accepts(peer, now_ms); + if peer.active >= MAX_INBOUND_SESSIONS_PER_IP { + return Err(InboundSessionRejection::PeerActive); + } + if peer.accepted_at_ms.len() >= MAX_INBOUND_ACCEPTS_PER_IP_PER_WINDOW { + return Err(InboundSessionRejection::PeerRate); + } + + peer.active += 1; + peer.accepted_at_ms.push_back(now_ms); + self.active += 1; + Ok(()) + } + + pub(super) fn release(&mut self, ip: IpAddr) { + if self.active > 0 { + self.active -= 1; + } + if let Some(peer) = self.peers.get_mut(&ip) { + if peer.active > 0 { + peer.active -= 1; + } + } + } + + fn prune_stale_accepts(&mut self, now_ms: u64) { + self.peers.retain(|_, peer| { + prune_peer_accepts(peer, now_ms); + peer.active > 0 || !peer.accepted_at_ms.is_empty() + }); + } +} + +fn prune_peer_accepts(peer: &mut InboundPeerLimit, now_ms: u64) { + while peer.accepted_at_ms.front().is_some_and(|accepted_ms| { + now_ms.saturating_sub(*accepted_ms) >= INBOUND_ACCEPT_RATE_WINDOW_MS + }) { + peer.accepted_at_ms.pop_front(); + } +} + +#[cfg(test)] +mod tests { + use super::super::{ + INBOUND_ACCEPT_RATE_WINDOW_MS, MAX_INBOUND_ACCEPTS_PER_IP_PER_WINDOW, MAX_INBOUND_SESSIONS, + MAX_INBOUND_SESSIONS_PER_IP, + }; + use super::{InboundConnectionLimiter, InboundSessionRejection}; + + #[test] + fn inbound_limiter_enforces_per_ip_active_limit() { + let ip = "203.0.113.10".parse().unwrap(); + let mut limiter = InboundConnectionLimiter::default(); + for _ in 0..MAX_INBOUND_SESSIONS_PER_IP { + limiter.try_acquire(ip, 1_000).unwrap(); + } + + assert_eq!( + limiter.try_acquire(ip, 1_000).unwrap_err(), + InboundSessionRejection::PeerActive + ); + + limiter.release(ip); + limiter.try_acquire(ip, 1_000).unwrap(); + } + + #[test] + fn inbound_limiter_enforces_global_active_limit() { + let mut limiter = InboundConnectionLimiter::default(); + for index in 0..MAX_INBOUND_SESSIONS { + let ip = format!("198.51.100.{index}").parse().unwrap(); + limiter.try_acquire(ip, 1_000).unwrap(); + } + + assert_eq!( + limiter + .try_acquire("203.0.113.200".parse().unwrap(), 1_000) + .unwrap_err(), + InboundSessionRejection::GlobalActive + ); + } + + #[test] + fn inbound_limiter_enforces_per_ip_accept_rate() { + let ip = "203.0.113.20".parse().unwrap(); + let mut limiter = InboundConnectionLimiter::default(); + for _ in 0..MAX_INBOUND_ACCEPTS_PER_IP_PER_WINDOW { + limiter.try_acquire(ip, 1_000).unwrap(); + limiter.release(ip); + } + + assert_eq!( + limiter.try_acquire(ip, 1_000).unwrap_err(), + InboundSessionRejection::PeerRate + ); + limiter + .try_acquire(ip, 1_000 + INBOUND_ACCEPT_RATE_WINDOW_MS) + .unwrap(); + } +} diff --git a/src/adapters/p2p/line_codec.rs b/src/adapters/p2p/line_codec.rs @@ -0,0 +1,401 @@ +use anyhow::{Context, Result}; +use tokio::{ + io::{AsyncBufReadExt, AsyncRead, BufReader}, + net::tcp::OwnedReadHalf, +}; + +use crate::app::{GossipEnvelope, TRANSACTION_BATCH_LIMIT}; + +use super::{ + GossipNetwork, MAX_BLOCK_BATCH, MAX_GOSSIP_LINE_BYTES, MAX_INVENTORY_ITEMS, + MAX_OBJECT_REQUESTS, MAX_PEER_LIST, MAX_SNAPSHOT_BLOCKS, metrics::P2pMetricsCounters, +}; + +pub(super) struct LimitedLineReader<R> { + reader: BufReader<R>, + pending: Vec<u8>, +} + +impl<R: AsyncRead + Unpin> LimitedLineReader<R> { + pub(super) fn new(reader: R) -> Self { + Self { + reader: BufReader::new(reader), + pending: Vec::new(), + } + } + + pub(super) async fn read_line(&mut self) -> Result<Option<String>> { + loop { + let available = self.reader.fill_buf().await?; + if available.is_empty() { + if self.pending.is_empty() { + return Ok(None); + } + anyhow::bail!("peer closed before completing a gossip message"); + } + + if let Some(newline) = available.iter().position(|byte| *byte == b'\n') { + if self.pending.len() + newline > MAX_GOSSIP_LINE_BYTES { + anyhow::bail!("p2p message exceeds {} byte limit", MAX_GOSSIP_LINE_BYTES); + } + self.pending.extend_from_slice(&available[..newline]); + self.reader.consume(newline + 1); + if self.pending.ends_with(b"\r") { + self.pending.pop(); + } + let bytes = std::mem::take(&mut self.pending); + return String::from_utf8(bytes) + .context("p2p message is not valid UTF-8") + .map(Some); + } + + if self.pending.len() + available.len() > MAX_GOSSIP_LINE_BYTES { + anyhow::bail!("p2p message exceeds {} byte limit", MAX_GOSSIP_LINE_BYTES); + } + let consumed = available.len(); + self.pending.extend_from_slice(available); + self.reader.consume(consumed); + } + } +} + +pub(super) async fn read_session_envelope( + network: &GossipNetwork, + connection_label: &str, + reader: &mut LimitedLineReader<OwnedReadHalf>, +) -> Result<Option<GossipEnvelope>> { + let Some(line) = reader.read_line().await? else { + return Ok(None); + }; + P2pMetricsCounters::add(&network.inner.metrics.bytes_received, line.len() as u64 + 1); + if line.trim().is_empty() { + P2pMetricsCounters::inc(&network.inner.metrics.empty_frames); + P2pMetricsCounters::set_last( + &network.inner.metrics.last_empty_frame_remote, + connection_label.to_string(), + ); + anyhow::bail!("empty p2p envelope"); + } + + match parse_envelope(&line) { + Ok(envelope) => { + P2pMetricsCounters::inc(&network.inner.metrics.envelopes_received); + record_received_envelope_kind(&network.inner.metrics, &envelope); + Ok(Some(envelope)) + } + Err(error) => { + P2pMetricsCounters::inc(&network.inner.metrics.parse_errors); + P2pMetricsCounters::set_last( + &network.inner.metrics.last_parse_error, + format!("{connection_label}: {error:#}"), + ); + Err(error) + } + } +} + +pub(super) fn record_received_envelope_kind( + metrics: &P2pMetricsCounters, + envelope: &GossipEnvelope, +) { + match envelope { + GossipEnvelope::Hello(_) => { + P2pMetricsCounters::inc(&metrics.hello_envelopes_received); + } + GossipEnvelope::PeerStatus { .. } => { + P2pMetricsCounters::inc(&metrics.peer_status_envelopes_received); + } + GossipEnvelope::Inventory { .. } => { + P2pMetricsCounters::inc(&metrics.inventory_envelopes_received); + } + GossipEnvelope::BlindedTransaction(_) => { + P2pMetricsCounters::inc(&metrics.data_envelopes_received); + P2pMetricsCounters::inc(&metrics.blinded_transaction_envelopes_received); + P2pMetricsCounters::inc(&metrics.blinded_transactions_received); + } + GossipEnvelope::BlindedTransactions { transactions } => { + P2pMetricsCounters::inc(&metrics.data_envelopes_received); + P2pMetricsCounters::inc(&metrics.blinded_transaction_envelopes_received); + P2pMetricsCounters::add( + &metrics.blinded_transactions_received, + transactions.len() as u64, + ); + } + GossipEnvelope::MineAction(_) => { + P2pMetricsCounters::inc(&metrics.data_envelopes_received); + } + GossipEnvelope::MineActions { .. } => { + P2pMetricsCounters::inc(&metrics.data_envelopes_received); + } + GossipEnvelope::BlindedReveal(_) => { + P2pMetricsCounters::inc(&metrics.data_envelopes_received); + P2pMetricsCounters::inc(&metrics.blinded_reveal_envelopes_received); + P2pMetricsCounters::inc(&metrics.blinded_reveals_received); + } + GossipEnvelope::BlindedReveals { reveals } => { + P2pMetricsCounters::inc(&metrics.data_envelopes_received); + P2pMetricsCounters::inc(&metrics.blinded_reveal_envelopes_received); + P2pMetricsCounters::add(&metrics.blinded_reveals_received, reveals.len() as u64); + } + GossipEnvelope::RevealBundle(bundle) => { + P2pMetricsCounters::inc(&metrics.data_envelopes_received); + P2pMetricsCounters::inc(&metrics.blinded_reveal_envelopes_received); + P2pMetricsCounters::add( + &metrics.blinded_reveals_received, + bundle.reveals.len() as u64, + ); + } + GossipEnvelope::RevealBundles { bundles } => { + P2pMetricsCounters::inc(&metrics.data_envelopes_received); + P2pMetricsCounters::inc(&metrics.blinded_reveal_envelopes_received); + P2pMetricsCounters::add( + &metrics.blinded_reveals_received, + bundles + .iter() + .map(|bundle| bundle.reveals.len() as u64) + .sum::<u64>(), + ); + } + GossipEnvelope::Block(_) + | GossipEnvelope::Blocks { .. } + | GossipEnvelope::ChainSnapshot(_) => { + P2pMetricsCounters::inc(&metrics.data_envelopes_received); + } + GossipEnvelope::ChainSnapshotRequest + | GossipEnvelope::BlockRangeRequest { .. } + | GossipEnvelope::BlockRequest { .. } + | GossipEnvelope::PeerAnnouncement { .. } + | GossipEnvelope::PeerVerificationChallenge { .. } + | GossipEnvelope::PeerVerificationResponse { .. } + | GossipEnvelope::PeerList { .. } => { + P2pMetricsCounters::inc(&metrics.control_envelopes_received); + } + } +} + +pub(super) fn parse_envelope(line: &str) -> Result<GossipEnvelope> { + if line.trim().is_empty() { + anyhow::bail!("empty p2p envelope"); + } + let envelope = serde_json::from_str(line).context("invalid p2p envelope JSON")?; + validate_envelope_limits(&envelope)?; + Ok(envelope) +} + +pub(super) fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> { + match envelope { + GossipEnvelope::BlockRangeRequest { limit, .. } => { + ensure_len("block range request", *limit, MAX_BLOCK_BATCH)?; + } + GossipEnvelope::BlockRequest { hashes } => { + ensure_len("block request", hashes.len(), MAX_OBJECT_REQUESTS)?; + } + GossipEnvelope::Inventory { blocks } => { + ensure_len("block inventory", blocks.len(), MAX_INVENTORY_ITEMS)?; + } + GossipEnvelope::BlindedTransactions { transactions } => { + ensure_len( + "blinded transaction batch", + transactions.len(), + TRANSACTION_BATCH_LIMIT, + )?; + } + GossipEnvelope::MineActions { transactions } => { + ensure_len( + "mine action batch", + transactions.len(), + TRANSACTION_BATCH_LIMIT, + )?; + } + GossipEnvelope::BlindedReveals { reveals } => { + ensure_len( + "blinded reveal batch", + reveals.len(), + TRANSACTION_BATCH_LIMIT, + )?; + } + GossipEnvelope::RevealBundles { bundles } => { + ensure_len( + "reveal bundle batch", + bundles.len(), + TRANSACTION_BATCH_LIMIT, + )?; + } + GossipEnvelope::Blocks { blocks } => { + ensure_len("block batch", blocks.len(), MAX_BLOCK_BATCH)?; + } + GossipEnvelope::ChainSnapshot(snapshot) => { + ensure_len("chain snapshot", snapshot.blocks.len(), MAX_SNAPSHOT_BLOCKS)?; + } + GossipEnvelope::PeerList { peers } => { + ensure_len("peer list", peers.len(), MAX_PEER_LIST)?; + } + GossipEnvelope::Hello(_) + | GossipEnvelope::ChainSnapshotRequest + | GossipEnvelope::PeerStatus { .. } + | GossipEnvelope::BlindedTransaction(_) + | GossipEnvelope::MineAction(_) + | GossipEnvelope::BlindedReveal(_) + | GossipEnvelope::RevealBundle(_) + | GossipEnvelope::Block(_) + | GossipEnvelope::PeerAnnouncement { .. } + | GossipEnvelope::PeerVerificationChallenge { .. } + | GossipEnvelope::PeerVerificationResponse { .. } => {} + } + Ok(()) +} + +fn ensure_len(label: &str, len: usize, max: usize) -> Result<()> { + if len > max { + anyhow::bail!("{label} has {len} items, exceeding limit {max}"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use tokio::io::AsyncWriteExt; + + use crate::{ + adapters::p2p::{MAX_INVENTORY_ITEMS, MAX_OBJECT_REQUESTS, metrics::P2pMetricsCounters}, + app::{BlockInventory, GossipEnvelope}, + domain::{BlindedReveal, BlindedTransaction}, + }; + + use super::{ + LimitedLineReader, parse_envelope, record_received_envelope_kind, validate_envelope_limits, + }; + + #[test] + fn oversized_inventory_is_rejected_before_processing() { + let envelope = GossipEnvelope::Inventory { + 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("block inventory")); + } + + #[test] + fn parser_applies_envelope_limits() { + let line = serde_json::to_string(&GossipEnvelope::BlockRequest { + hashes: vec!["hash".to_string(); MAX_OBJECT_REQUESTS + 1], + }) + .unwrap(); + + let error = parse_envelope(&line).unwrap_err(); + + assert!(error.to_string().contains("block request")); + } + + #[test] + fn parser_rejects_empty_envelope_without_json_eof() { + let error = parse_envelope("").unwrap_err(); + + assert!(error.to_string().contains("empty p2p envelope")); + assert!(!format!("{error:#}").contains("EOF while parsing")); + } + + #[test] + fn parser_accepts_legacy_peer_status_without_mempool_fields() { + let envelope = + parse_envelope(r#"{"type":"peer_status","height":7,"tip_hash":"tip"}"#).unwrap(); + + assert_eq!( + envelope, + GossipEnvelope::PeerStatus { + height: 7, + tip_hash: "tip".to_string(), + time_ms: 0, + } + ); + } + + #[test] + fn received_envelope_metrics_are_categorized() { + let metrics = P2pMetricsCounters::default(); + let blinded_tx = BlindedTransaction { + commitment: "commitment".to_string(), + inputs: Vec::new(), + fee: 3, + encrypted_size: 128, + expires_at_height: 20, + nonce: "nonce".to_string(), + ciphertext: "ciphertext".to_string(), + payload_hash: "payload-hash".to_string(), + }; + let blinded_reveal = BlindedReveal { + commitment: "commitment".to_string(), + key: "key".to_string(), + }; + + record_received_envelope_kind( + &metrics, + &GossipEnvelope::PeerStatus { + height: 7, + tip_hash: "tip".to_string(), + time_ms: 1_000, + }, + ); + record_received_envelope_kind(&metrics, &GossipEnvelope::Inventory { blocks: Vec::new() }); + record_received_envelope_kind(&metrics, &GossipEnvelope::Blocks { blocks: Vec::new() }); + record_received_envelope_kind( + &metrics, + &GossipEnvelope::BlindedTransactions { + transactions: vec![blinded_tx.clone(), blinded_tx], + }, + ); + record_received_envelope_kind(&metrics, &GossipEnvelope::BlindedReveal(blinded_reveal)); + 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, 3); + assert_eq!(snapshot.blinded_transaction_envelopes_received, 1); + assert_eq!(snapshot.blinded_transactions_received, 2); + assert_eq!(snapshot.blinded_reveal_envelopes_received, 1); + assert_eq!(snapshot.blinded_reveals_received, 1); + assert_eq!(snapshot.control_envelopes_received, 1); + } + + #[tokio::test] + async fn limited_line_reader_keeps_partial_line_after_cancelled_read() { + let (mut writer, reader) = tokio::io::duplex(1024); + let mut reader = LimitedLineReader::new(reader); + let line = serde_json::to_string(&GossipEnvelope::PeerStatus { + height: 7, + tip_hash: "tip".to_string(), + time_ms: 1_000, + }) + .unwrap(); + let split_at = line.len() / 2; + + writer + .write_all(&line.as_bytes()[..split_at]) + .await + .unwrap(); + let cancelled = + tokio::time::timeout(std::time::Duration::from_millis(25), reader.read_line()).await; + + assert!(cancelled.is_err()); + + writer + .write_all(&line.as_bytes()[split_at..]) + .await + .unwrap(); + writer.write_all(b"\n").await.unwrap(); + + assert_eq!( + reader.read_line().await.unwrap().as_deref(), + Some(line.as_str()) + ); + } +} diff --git a/src/adapters/p2p/metrics.rs b/src/adapters/p2p/metrics.rs @@ -0,0 +1,142 @@ +use std::sync::{ + Mutex as StdMutex, + atomic::{AtomicU64, Ordering}, +}; + +use serde::Serialize; + +#[derive(Default)] +pub(super) struct P2pMetricsCounters { + pub(super) inbound_sessions_started: AtomicU64, + pub(super) inbound_sessions_rejected: AtomicU64, + pub(super) outbound_connect_attempts: AtomicU64, + pub(super) outbound_connect_successes: AtomicU64, + pub(super) outbound_connect_failures: AtomicU64, + pub(super) outbound_sessions_started: AtomicU64, + pub(super) sessions_closed: AtomicU64, + pub(super) session_failures: AtomicU64, + pub(super) quiet_disconnects: AtomicU64, + pub(super) envelopes_received: AtomicU64, + pub(super) hello_envelopes_received: AtomicU64, + pub(super) peer_status_envelopes_received: AtomicU64, + pub(super) inventory_envelopes_received: AtomicU64, + pub(super) data_envelopes_received: AtomicU64, + pub(super) blinded_transaction_envelopes_received: AtomicU64, + pub(super) blinded_transactions_received: AtomicU64, + pub(super) blinded_reveal_envelopes_received: AtomicU64, + pub(super) blinded_reveals_received: AtomicU64, + pub(super) control_envelopes_received: AtomicU64, + pub(super) bytes_received: AtomicU64, + pub(super) parse_errors: AtomicU64, + pub(super) empty_frames: AtomicU64, + pub(super) self_peer_rejections: AtomicU64, + pub(super) self_peer_skips: AtomicU64, + pub(super) outbound_queue_full: AtomicU64, + pub(super) outbound_queue_closed: AtomicU64, + pub(super) last_session_failure: StdMutex<Option<String>>, + pub(super) last_empty_frame_remote: StdMutex<Option<String>>, + pub(super) last_parse_error: StdMutex<Option<String>>, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] +pub struct P2pMetrics { + pub inbound_sessions_started: u64, + pub inbound_sessions_rejected: u64, + pub outbound_connect_attempts: u64, + pub outbound_connect_successes: u64, + pub outbound_connect_failures: u64, + pub outbound_sessions_started: u64, + pub sessions_closed: u64, + pub session_failures: u64, + pub quiet_disconnects: u64, + pub envelopes_received: u64, + pub hello_envelopes_received: u64, + pub peer_status_envelopes_received: u64, + pub inventory_envelopes_received: u64, + pub data_envelopes_received: u64, + pub blinded_transaction_envelopes_received: u64, + pub blinded_transactions_received: u64, + pub blinded_reveal_envelopes_received: u64, + pub blinded_reveals_received: u64, + pub control_envelopes_received: u64, + pub bytes_received: u64, + pub parse_errors: u64, + pub empty_frames: u64, + pub self_peer_rejections: u64, + pub self_peer_skips: u64, + pub outbound_queue_full: u64, + pub outbound_queue_closed: u64, + pub last_session_failure: Option<String>, + pub last_empty_frame_remote: Option<String>, + pub last_parse_error: Option<String>, +} + +impl P2pMetricsCounters { + pub(super) fn inc(counter: &AtomicU64) { + counter.fetch_add(1, Ordering::Relaxed); + } + + pub(super) fn add(counter: &AtomicU64, amount: u64) { + counter.fetch_add(amount, Ordering::Relaxed); + } + + pub(super) fn set_last(target: &StdMutex<Option<String>>, value: impl Into<String>) { + if let Ok(mut last) = target.lock() { + *last = Some(value.into()); + } + } + + pub(super) fn snapshot(&self) -> P2pMetrics { + P2pMetrics { + inbound_sessions_started: self.inbound_sessions_started.load(Ordering::Relaxed), + inbound_sessions_rejected: self.inbound_sessions_rejected.load(Ordering::Relaxed), + outbound_connect_attempts: self.outbound_connect_attempts.load(Ordering::Relaxed), + outbound_connect_successes: self.outbound_connect_successes.load(Ordering::Relaxed), + outbound_connect_failures: self.outbound_connect_failures.load(Ordering::Relaxed), + outbound_sessions_started: self.outbound_sessions_started.load(Ordering::Relaxed), + sessions_closed: self.sessions_closed.load(Ordering::Relaxed), + session_failures: self.session_failures.load(Ordering::Relaxed), + quiet_disconnects: self.quiet_disconnects.load(Ordering::Relaxed), + envelopes_received: self.envelopes_received.load(Ordering::Relaxed), + hello_envelopes_received: self.hello_envelopes_received.load(Ordering::Relaxed), + peer_status_envelopes_received: self + .peer_status_envelopes_received + .load(Ordering::Relaxed), + inventory_envelopes_received: self.inventory_envelopes_received.load(Ordering::Relaxed), + data_envelopes_received: self.data_envelopes_received.load(Ordering::Relaxed), + blinded_transaction_envelopes_received: self + .blinded_transaction_envelopes_received + .load(Ordering::Relaxed), + blinded_transactions_received: self + .blinded_transactions_received + .load(Ordering::Relaxed), + blinded_reveal_envelopes_received: self + .blinded_reveal_envelopes_received + .load(Ordering::Relaxed), + blinded_reveals_received: self.blinded_reveals_received.load(Ordering::Relaxed), + control_envelopes_received: self.control_envelopes_received.load(Ordering::Relaxed), + bytes_received: self.bytes_received.load(Ordering::Relaxed), + parse_errors: self.parse_errors.load(Ordering::Relaxed), + empty_frames: self.empty_frames.load(Ordering::Relaxed), + self_peer_rejections: self.self_peer_rejections.load(Ordering::Relaxed), + 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), + last_session_failure: self + .last_session_failure + .lock() + .ok() + .and_then(|last| last.clone()), + last_empty_frame_remote: self + .last_empty_frame_remote + .lock() + .ok() + .and_then(|last| last.clone()), + last_parse_error: self + .last_parse_error + .lock() + .ok() + .and_then(|last| last.clone()), + } + } +} diff --git a/src/adapters/p2p/network.rs b/src/adapters/p2p/network.rs @@ -0,0 +1,388 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + net::{IpAddr, SocketAddr}, + sync::{Arc, Mutex as StdMutex}, +}; + +use anyhow::{Context, Result}; +use tokio::{net::TcpListener, sync::mpsc}; + +use crate::app::{ + BlockInventory, GossipEnvelope, SharedNode, SharedPeerBook, debug_logging_enabled, now_ms, +}; + +use super::{ + GossipNetwork, GossipNetworkInner, InboundConnectionLimiter, InboundSessionPermit, + InboundSessionRejection, OutboundBatch, P2pMetrics, P2pMetricsCounters, PEER_QUEUE_SIZE, + STALE_INBOUND_PEER_RETENTION_MS, accept_loop, is_self_peer_address_for, new_node_id, + outbound_session, outbound_supervisor, +}; + +impl GossipNetwork { + #[cfg(test)] + pub(crate) fn new_for_tests(node: SharedNode, peers: SharedPeerBook) -> Self { + Self { + inner: Arc::new(GossipNetworkInner { + node, + peers, + listen_addr: "127.0.0.1:0".parse().unwrap(), + p2p_announce_addr: tokio::sync::Mutex::new(None), + node_id: new_node_id(), + accept_task: tokio::sync::Mutex::new(None), + sessions: tokio::sync::Mutex::new( + BTreeMap::<String, mpsc::Sender<OutboundBatch>>::new(), + ), + inbound_limiter: Arc::new(StdMutex::new(InboundConnectionLimiter::default())), + metrics: P2pMetricsCounters::default(), + }), + } + } + + pub async fn start( + node: SharedNode, + peers: SharedPeerBook, + addr: SocketAddr, + p2p_announce_addr: Option<SocketAddr>, + accept_inbound: bool, + ) -> Result<Self> { + let network = Self { + inner: Arc::new(GossipNetworkInner { + node, + peers, + listen_addr: addr, + p2p_announce_addr: tokio::sync::Mutex::new(p2p_announce_addr), + node_id: new_node_id(), + accept_task: tokio::sync::Mutex::new(None), + sessions: tokio::sync::Mutex::new( + BTreeMap::<String, mpsc::Sender<OutboundBatch>>::new(), + ), + inbound_limiter: Arc::new(StdMutex::new(InboundConnectionLimiter::default())), + metrics: P2pMetricsCounters::default(), + }), + }; + + if accept_inbound { + network.set_accept_inbound(true).await?; + } + tokio::spawn(outbound_supervisor(network.clone())); + network.ensure_outbound_sessions().await; + Ok(network) + } + + pub async fn set_accept_inbound(&self, enabled: bool) -> Result<()> { + let mut accept_task = self.inner.accept_task.lock().await; + if enabled { + if accept_task.is_some() { + return Ok(()); + } + let listener = TcpListener::bind(self.inner.listen_addr) + .await + .with_context(|| format!("binding p2p listener on {}", self.inner.listen_addr))?; + *accept_task = Some(tokio::spawn(accept_loop(self.clone(), listener))); + } else if let Some(task) = accept_task.take() { + task.abort(); + } + Ok(()) + } + + pub async fn accepts_inbound(&self) -> bool { + self.inner.accept_task.lock().await.is_some() + } + + pub fn listen_addr(&self) -> SocketAddr { + self.inner.listen_addr + } + + pub async fn set_p2p_announce_addr(&self, addr: Option<SocketAddr>) { + *self.inner.p2p_announce_addr.lock().await = addr; + } + + pub(super) async fn advertised_addr(&self) -> Option<SocketAddr> { + if !self.accepts_inbound().await { + return None; + } + Some((*self.inner.p2p_announce_addr.lock().await).unwrap_or(self.inner.listen_addr)) + } + + pub(super) async fn self_filter_addr(&self) -> Option<SocketAddr> { + if let Some(addr) = *self.inner.p2p_announce_addr.lock().await { + return Some(addr); + } + self.accepts_inbound() + .await + .then_some(self.inner.listen_addr) + } + + pub(super) async fn is_self_peer(&self, address: &str) -> bool { + is_self_peer_address_for( + address, + self.inner.listen_addr, + self.self_filter_addr().await, + ) + } + + pub fn metrics(&self) -> P2pMetrics { + self.inner.metrics.snapshot() + } + + pub(super) fn try_acquire_inbound_session( + &self, + ip: IpAddr, + ) -> std::result::Result<InboundSessionPermit, InboundSessionRejection> { + self.inner + .inbound_limiter + .lock() + .expect("inbound limiter mutex poisoned") + .try_acquire(ip, now_ms())?; + Ok(InboundSessionPermit { + limiter: Arc::clone(&self.inner.inbound_limiter), + ip, + }) + } + + pub async fn broadcast(&self, envelopes: Vec<GossipEnvelope>) -> Result<()> { + let envelopes = self.prepare_gossip(envelopes).await; + if envelopes.is_empty() { + return Ok(()); + } + + let sessions = self.inner.sessions.lock().await.clone(); + for (peer, sender) in sessions { + if self.inner.peers.lock().await.is_banned(&peer) { + continue; + } + match sender.try_send(envelopes.clone()) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + P2pMetricsCounters::inc(&self.inner.metrics.outbound_queue_full); + } + Err(mpsc::error::TrySendError::Closed(_)) => { + P2pMetricsCounters::inc(&self.inner.metrics.outbound_queue_closed); + } + } + } + Ok(()) + } + + async fn prepare_gossip(&self, envelopes: Vec<GossipEnvelope>) -> Vec<GossipEnvelope> { + let mut blocks = Vec::new(); + let mut passthrough = Vec::new(); + + for envelope in envelopes { + match envelope { + GossipEnvelope::Block(block) => blocks.push(BlockInventory { + height: block.height, + hash: block.hash, + }), + GossipEnvelope::Blocks { blocks: batch } => { + blocks.extend(batch.into_iter().map(|block| BlockInventory { + height: block.height, + hash: block.hash, + })); + } + GossipEnvelope::Inventory { blocks: inv_blocks } => blocks.extend(inv_blocks), + other => passthrough.push(other), + } + } + + blocks.sort_by(|left, right| { + left.height + .cmp(&right.height) + .then_with(|| left.hash.cmp(&right.hash)) + }); + blocks.dedup_by(|left, right| left.hash == right.hash); + + if !blocks.is_empty() { + passthrough.push(GossipEnvelope::Inventory { blocks }); + } + passthrough + } + + pub async fn peer_exchange(&self) -> GossipEnvelope { + let advertised_addr = self.advertised_addr().await; + let self_filter_addr = self.self_filter_addr().await; + let self_addr = advertised_addr.map(|addr| addr.to_string()); + let peers = self + .inner + .peers + .lock() + .await + .addresses_except(self_addr.as_deref().unwrap_or("")) + .into_iter() + .filter(|peer| peer.parse::<SocketAddr>().is_ok()) + .filter(|peer| { + !is_self_peer_address_for(peer, self.inner.listen_addr, self_filter_addr) + }) + .collect::<Vec<_>>(); + GossipEnvelope::PeerList { + peers: self_addr.into_iter().chain(peers.into_iter()).collect(), + } + } + + pub(super) async fn ensure_outbound_sessions(&self) { + self.inner + .peers + .lock() + .await + .prune_stale_inbound_peers_at(crate::app::now_ms(), STALE_INBOUND_PEER_RETENTION_MS); + let addresses = self + .inner + .peers + .lock() + .await + .connectable_addresses_at(crate::app::now_ms()); + let address_set = addresses.iter().cloned().collect::<BTreeSet<_>>(); + let self_filter_addr = self.self_filter_addr().await; + let mut sessions = self.inner.sessions.lock().await; + sessions.retain(|peer, _| { + let keep = address_set.contains(peer) + && !is_self_peer_address_for(peer, self.inner.listen_addr, self_filter_addr); + if !keep { + P2pMetricsCounters::inc(&self.inner.metrics.self_peer_skips); + } + keep + }); + for peer in addresses { + if is_self_peer_address_for(&peer, self.inner.listen_addr, self_filter_addr) { + P2pMetricsCounters::inc(&self.inner.metrics.self_peer_skips); + continue; + } + if sessions.contains_key(&peer) { + continue; + } + + let (sender, receiver) = mpsc::channel(PEER_QUEUE_SIZE); + sessions.insert(peer.clone(), sender); + tokio::spawn(outbound_session(self.clone(), peer, receiver)); + } + } + + pub(super) async fn forward_outbox(&self) { + let outbox = self.inner.node.lock().await.drain_outbox(); + if let Err(error) = self.broadcast(outbox).await { + if debug_logging_enabled() { + eprintln!("p2p rebroadcast failed: {error:#}"); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::{ + app::{GossipEnvelope, PeerBook}, + domain::Wallet, + }; + + use super::super::test_support::{allocations, gossip_network, node}; + + #[tokio::test] + async fn peer_exchange_does_not_advertise_self_when_outbound_only() { + let alice = Wallet::from_seed("px-private-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ + "127.0.0.1:9545".to_string(), + ]))); + let network = gossip_network(node, peers, "127.0.0.1:9544".parse().unwrap(), None); + match network.peer_exchange().await { + GossipEnvelope::PeerList { peers } => { + assert!(!peers.contains(&"127.0.0.1:9544".to_string())); + assert!(peers.contains(&"127.0.0.1:9545".to_string())); + } + other => panic!("expected peer list, got {other:?}"), + } + } + + #[tokio::test] + async fn peer_exchange_omits_hostname_bootstrap_peers() { + let alice = Wallet::from_seed("px-hostname-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ + "iuna.jhx.app:9444".to_string(), + "127.0.0.1:9545".to_string(), + ]))); + let network = gossip_network(node, peers, "127.0.0.1:9544".parse().unwrap(), None); + match network.peer_exchange().await { + GossipEnvelope::PeerList { peers } => { + assert!(!peers.contains(&"iuna.jhx.app:9444".to_string())); + assert!(peers.contains(&"127.0.0.1:9545".to_string())); + } + other => panic!("expected peer list, got {other:?}"), + } + } + + #[tokio::test] + async fn peer_exchange_advertises_discovered_listening_peers() { + let alice = Wallet::from_seed("px-discovered-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + peers + .lock() + .await + .add_discovered_peer("127.0.0.1:9546".to_string()); + let network = gossip_network(node, peers, "127.0.0.1:9544".parse().unwrap(), None); + + match network.peer_exchange().await { + GossipEnvelope::PeerList { peers } => { + assert!(peers.contains(&"127.0.0.1:9546".to_string())); + } + other => panic!("expected peer list, got {other:?}"), + } + } + + #[tokio::test] + async fn peer_exchange_advertises_stable_listen_and_known_peers() { + let alice = Wallet::from_seed("px-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ + "127.0.0.1:9545".to_string(), + ]))); + let network = gossip_network(node, peers, "127.0.0.1:9544".parse().unwrap(), None); + network.set_accept_inbound(true).await.unwrap(); + + match network.peer_exchange().await { + GossipEnvelope::PeerList { peers } => { + assert!(peers.contains(&"127.0.0.1:9544".to_string())); + assert!(peers.contains(&"127.0.0.1:9545".to_string())); + } + other => panic!("expected peer list, got {other:?}"), + } + network.set_accept_inbound(false).await.unwrap(); + } + + #[tokio::test] + async fn peer_exchange_filters_announced_self_from_known_peers() { + let alice = Wallet::from_seed("px-announced-self-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ + "8.8.8.8:9444".to_string(), + "8.8.4.4:9444".to_string(), + ]))); + let network = gossip_network( + node, + peers, + "127.0.0.1:0".parse().unwrap(), + Some("8.8.8.8:9444".parse().unwrap()), + ); + network.set_accept_inbound(true).await.unwrap(); + + match network.peer_exchange().await { + GossipEnvelope::PeerList { peers } => { + assert_eq!( + peers.iter().filter(|peer| *peer == "8.8.8.8:9444").count(), + 1 + ); + assert!(peers.contains(&"8.8.4.4:9444".to_string())); + } + other => panic!("expected peer list, got {other:?}"), + } + network.set_accept_inbound(false).await.unwrap(); + } +} diff --git a/src/adapters/p2p/peer_addr.rs b/src/adapters/p2p/peer_addr.rs @@ -0,0 +1,249 @@ +use std::{ + io::ErrorKind, + net::{IpAddr, SocketAddr}, + time::Duration, +}; + +use anyhow::{Context, Result}; + +use crate::app::GossipEnvelope; + +pub(super) fn next_reconnect_delay(current: Duration, max_delay: Duration) -> Duration { + (current * 2).min(max_delay) +} + +pub(super) fn peer_needs_snapshot(peer_height: u64, envelopes: &[GossipEnvelope]) -> bool { + envelopes + .iter() + .filter_map(|envelope| match envelope { + GossipEnvelope::Block(block) => Some(block.height), + GossipEnvelope::Inventory { blocks, .. } => { + blocks.iter().map(|block| block.height).min() + } + _ => None, + }) + .min() + .is_some_and(|first_block_height| peer_height + 1 < first_block_height) +} + +pub(super) fn reachable_advertised_addr( + advertised_addr: SocketAddr, + remote_addr: SocketAddr, +) -> SocketAddr { + let mut reachable_addr = advertised_addr; + if reachable_addr.ip().is_unspecified() { + reachable_addr.set_ip(remote_addr.ip()); + } + reachable_addr +} + +pub(super) fn normalize_advertised_peer(address: &str, remote_addr: SocketAddr) -> Result<String> { + let advertised_addr = address + .parse::<SocketAddr>() + .with_context(|| format!("invalid announced peer address {address}"))?; + Ok(reachable_advertised_addr(advertised_addr, remote_addr).to_string()) +} + +pub(super) fn peer_list_address_is_discoverable( + address: &str, + remote_addr: SocketAddr, +) -> Result<bool> { + let candidate = address + .parse::<SocketAddr>() + .with_context(|| format!("invalid peer-list address {address}"))?; + Ok(socket_addr_is_discoverable(candidate, remote_addr)) +} + +pub(super) fn advertised_peer_is_discoverable( + address: &str, + remote_addr: SocketAddr, +) -> Result<bool> { + let candidate = address + .parse::<SocketAddr>() + .with_context(|| format!("invalid announced peer address {address}"))?; + Ok(socket_addr_is_discoverable(candidate, remote_addr)) +} + +fn socket_addr_is_discoverable(candidate: SocketAddr, remote_addr: SocketAddr) -> bool { + if candidate.ip().is_loopback() { + return remote_addr.ip().is_loopback(); + } + ip_is_publicly_discoverable(candidate.ip()) +} + +fn ip_is_publicly_discoverable(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + let [a, b, c, d] = ip.octets(); + !(a == 0 + || a == 10 + || a == 127 + || (a == 100 && (64..=127).contains(&b)) + || (a == 169 && b == 254) + || (a == 172 && (16..=31).contains(&b)) + || (a == 192 && b == 168) + || (a == 192 && b == 0 && c == 2) + || (a == 198 && b == 51 && c == 100) + || (a == 203 && b == 0 && c == 113) + || a >= 224 + || [a, b, c, d] == [255, 255, 255, 255]) + } + IpAddr::V6(ip) => { + let segments = ip.segments(); + !(ip.is_unspecified() + || ip.is_loopback() + || (segments[0] & 0xfe00) == 0xfc00 + || (segments[0] & 0xffc0) == 0xfe80 + || (segments[0] & 0xff00) == 0xff00) + } + } +} + +pub(super) fn is_self_peer_address_for( + address: &str, + listen_addr: SocketAddr, + advertised_addr: Option<SocketAddr>, +) -> bool { + address.parse::<SocketAddr>().is_ok_and(|candidate| { + is_self_socket_addr(candidate, listen_addr) + || advertised_addr.is_some_and(|addr| is_self_socket_addr(candidate, addr)) + }) +} + +fn is_self_socket_addr(candidate: SocketAddr, listen_addr: SocketAddr) -> bool { + if candidate == listen_addr { + return true; + } + if candidate.port() != listen_addr.port() { + return false; + } + + let candidate_ip = candidate.ip(); + let listen_ip = listen_addr.ip(); + if listen_ip.is_unspecified() { + return candidate_ip.is_unspecified() || candidate_ip.is_loopback(); + } + if candidate_ip.is_unspecified() { + return listen_ip.is_loopback(); + } + false +} + +pub(super) fn is_quiet_disconnect(error: &anyhow::Error) -> bool { + error.chain().any(|cause| { + cause.downcast_ref::<std::io::Error>().is_some_and(|error| { + matches!( + error.kind(), + ErrorKind::ConnectionReset + | ErrorKind::BrokenPipe + | ErrorKind::UnexpectedEof + | ErrorKind::ConnectionAborted + ) + }) + }) +} + +pub(super) fn is_possible_fork_error(error: &anyhow::Error) -> bool { + let message = format!("{error:#}"); + message.contains("does not extend local tip") + || message.contains("conflicts with local chain") + || message.contains("expected block height") +} + +pub(super) fn inbound_error_counts_as_misbehavior(message: &str) -> bool { + !message.contains("block timestamp is too far in the future") + && !message.contains("block timestamp is before finalizer rank") +} + +#[cfg(test)] +mod tests { + use std::net::SocketAddr; + + use crate::{ + app::GossipEnvelope, + domain::{Block, FinalizerMode, RevealBundleSection}, + }; + + use super::{is_self_peer_address_for, peer_needs_snapshot, reachable_advertised_addr}; + + #[test] + fn unspecified_announced_ip_uses_remote_ip_with_announced_port() { + let advertised: SocketAddr = "0.0.0.0:9445".parse().unwrap(); + let remote: SocketAddr = "203.0.113.10:52144".parse().unwrap(); + + assert_eq!( + reachable_advertised_addr(advertised, remote).to_string(), + "203.0.113.10:9445" + ); + } + + #[test] + fn explicit_announced_ip_is_kept() { + let advertised: SocketAddr = "127.0.0.1:9445".parse().unwrap(); + let remote: SocketAddr = "127.0.0.1:52144".parse().unwrap(); + + assert_eq!( + reachable_advertised_addr(advertised, remote).to_string(), + "127.0.0.1:9445" + ); + } + + #[test] + fn loopback_peer_on_unspecified_listen_port_is_self() { + let listen_addr: SocketAddr = "0.0.0.0:9545".parse().unwrap(); + + assert!(is_self_peer_address_for( + "127.0.0.1:9545", + listen_addr, + Some(listen_addr) + )); + assert!(is_self_peer_address_for( + "0.0.0.0:9545", + listen_addr, + Some(listen_addr) + )); + assert!(!is_self_peer_address_for( + "127.0.0.1:9546", + listen_addr, + Some(listen_addr) + )); + assert!(!is_self_peer_address_for( + "203.0.113.10:9545", + listen_addr, + Some(listen_addr) + )); + } + + #[test] + fn peer_needs_snapshot_when_block_gossip_skips_a_height() { + let block = Block { + height: 10, + prev_hash: "prev".to_string(), + timestamp_ms: 1, + miner: "miner".to_string(), + finalizer_mode: FinalizerMode::Ticket, + finalizer_rank: 0, + reward: 100, + vdf_rounds: 1, + vdf_output: "vdf".to_string(), + leader_proof: None, + blinded_transactions: Vec::new(), + reveal_bundle_section: RevealBundleSection::default(), + transactions: Vec::new(), + hash: "hash".to_string(), + }; + + assert!(peer_needs_snapshot( + 8, + &[GossipEnvelope::Block(block.clone())] + )); + assert!(!peer_needs_snapshot(9, &[GossipEnvelope::Block(block)])); + assert!(!peer_needs_snapshot( + 8, + &[GossipEnvelope::PeerAnnouncement { + address: "127.0.0.1:9444".to_string(), + node_id: Some("peer-node".to_string()), + }] + )); + } +} diff --git a/src/adapters/p2p/peer_status.rs b/src/adapters/p2p/peer_status.rs @@ -0,0 +1,56 @@ +use crate::app::now_ms; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct PeerStatus { + pub(super) height: u64, + pub(super) tip_hash: String, + pub(super) time_ms: u64, + pub(super) request_snapshot: bool, + pub(super) push_snapshot: bool, +} + +impl PeerStatus { + pub(super) fn new(height: u64, tip_hash: String) -> Self { + Self::with_time(height, tip_hash, now_ms()) + } + + pub(super) fn with_time(height: u64, tip_hash: String, time_ms: u64) -> Self { + Self { + height, + tip_hash, + time_ms, + request_snapshot: false, + push_snapshot: false, + } + } + + pub(super) fn from_envelope(height: u64, tip_hash: String, time_ms: u64) -> Self { + Self { + height, + tip_hash, + time_ms, + request_snapshot: false, + push_snapshot: false, + } + } + + pub(super) fn with_snapshot_request(height: u64, tip_hash: String, time_ms: u64) -> Self { + Self { + height, + tip_hash, + time_ms, + request_snapshot: true, + push_snapshot: false, + } + } + + pub(super) fn with_snapshot_push(height: u64, tip_hash: String, time_ms: u64) -> Self { + Self { + height, + tip_hash, + time_ms, + request_snapshot: false, + push_snapshot: true, + } + } +} diff --git a/src/adapters/p2p/process.rs b/src/adapters/p2p/process.rs @@ -0,0 +1,326 @@ +use std::net::SocketAddr; + +use anyhow::{Result, anyhow}; +use tokio::net::tcp::OwnedWriteHalf; + +use crate::{ + app::{GossipEnvelope, debug_logging_enabled}, + domain::{BlindedReveal, BlindedTransaction, RevealBundle, Transaction}, +}; + +use super::{ + GossipNetwork, MAX_BLOCK_BATCH, P2pMetricsCounters, apply_peer_list, forget_stale_self_peer, + is_possible_fork_error, normalize_advertised_peer, peer_verification_response, process_hello, + validate_blocks_extension, validate_snapshot_extension, verify_block_vdf, write_envelope, + write_payload, +}; + +pub(super) async fn respond_to_peer_verification_challenge( + network: &GossipNetwork, + writer: &mut OwnedWriteHalf, + envelope: &GossipEnvelope, +) -> Result<bool> { + let GossipEnvelope::PeerVerificationChallenge { address, nonce } = envelope else { + return Ok(false); + }; + if let Some(response) = peer_verification_response(network, address, nonce) { + write_envelope(writer, &response).await?; + } + Ok(true) +} + +pub(super) async fn process_envelope( + network: &GossipNetwork, + writer: &mut OwnedWriteHalf, + remote_addr: SocketAddr, + known_peer: &mut Option<String>, + envelope: GossipEnvelope, +) -> Result<()> { + match envelope { + GossipEnvelope::Hello(hello) => { + let _ = process_hello(network, remote_addr, known_peer, hello).await?; + } + GossipEnvelope::ChainSnapshotRequest => { + let snapshot = network.inner.node.lock().await.chain_snapshot(); + write_envelope(writer, &GossipEnvelope::ChainSnapshot(snapshot)).await?; + } + GossipEnvelope::BlockRangeRequest { from_height, limit } => { + let blocks = network + .inner + .node + .lock() + .await + .blocks_from(from_height, limit.min(MAX_BLOCK_BATCH)); + write_envelope(writer, &GossipEnvelope::Blocks { blocks }).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 { blocks } => { + let requests = network + .inner + .node + .lock() + .await + .missing_inventory_requests(&blocks); + write_payload(writer, &requests).await?; + } + GossipEnvelope::PeerAnnouncement { address, node_id } => { + let peer = normalize_advertised_peer(&address, remote_addr)?; + if network.is_self_peer(&peer).await { + P2pMetricsCounters::inc(&network.inner.metrics.self_peer_rejections); + forget_stale_self_peer(network, known_peer).await; + } else if node_id.is_some() && debug_logging_enabled() { + eprintln!("p2p peer announcement for {peer} ignored until hello verification"); + } + let snapshot = network.inner.node.lock().await.chain_snapshot(); + write_envelope(writer, &GossipEnvelope::ChainSnapshot(snapshot)).await?; + } + GossipEnvelope::PeerVerificationChallenge { address, nonce } => { + if let Some(response) = peer_verification_response(network, &address, &nonce) { + write_envelope(writer, &response).await?; + } + } + GossipEnvelope::PeerVerificationResponse { .. } => {} + GossipEnvelope::PeerList { peers } => { + apply_peer_list(network, remote_addr, peers).await?; + } + GossipEnvelope::BlindedTransaction(tx) => { + process_blinded_transactions(network, remote_addr, known_peer, vec![tx]).await; + } + GossipEnvelope::BlindedTransactions { transactions } => { + process_blinded_transactions(network, remote_addr, known_peer, transactions).await; + } + GossipEnvelope::MineAction(tx) => { + process_mine_actions(network, remote_addr, known_peer, vec![tx]).await; + } + GossipEnvelope::MineActions { transactions } => { + process_mine_actions(network, remote_addr, known_peer, transactions).await; + } + GossipEnvelope::BlindedReveal(reveal) => { + process_blinded_reveals(network, remote_addr, known_peer, vec![reveal]).await; + } + GossipEnvelope::BlindedReveals { reveals } => { + process_blinded_reveals(network, remote_addr, known_peer, reveals).await; + } + GossipEnvelope::RevealBundle(bundle) => { + process_reveal_bundles(network, remote_addr, known_peer, vec![bundle]).await; + } + GossipEnvelope::RevealBundles { bundles } => { + process_reveal_bundles(network, remote_addr, known_peer, bundles).await; + } + GossipEnvelope::Block(block) => { + let adjusted_time_ms = super::network_adjusted_time_ms(network).await; + let needs_vdf = { + let node = network.inner.node.lock().await; + node.block_requires_vdf_verification_at(&block, adjusted_time_ms) + }; + let result = match needs_vdf { + Ok(false) => Ok(()), + Ok(true) => match verify_block_vdf(block).await { + Ok(block) => network + .inner + .node + .lock() + .await + .receive_preverified_block_at(block, adjusted_time_ms), + Err(error) => Err(error), + }, + Err(error) => Err(error), + }; + record_inbound_result(network, known_peer, remote_addr, result).await; + network.forward_outbox().await; + } + GossipEnvelope::Blocks { blocks } => { + let adjusted_time_ms = super::network_adjusted_time_ms(network).await; + let local_ledger = network.inner.node.lock().await.clone_ledger(); + let result = + match validate_blocks_extension(local_ledger, blocks, adjusted_time_ms).await { + Ok(ledger) => network + .inner + .node + .lock() + .await + .import_verified_ledger(ledger) + .map(|_| ()), + Err(error) => Err(error), + }; + let request_snapshot = result.as_ref().err().is_some_and(is_possible_fork_error); + record_inbound_result(network, known_peer, remote_addr, result).await; + if request_snapshot { + write_envelope(writer, &GossipEnvelope::ChainSnapshotRequest).await?; + } + network.forward_outbox().await; + } + GossipEnvelope::ChainSnapshot(snapshot) => { + let adjusted_time_ms = super::network_adjusted_time_ms(network).await; + let local_ledger = network.inner.node.lock().await.clone_ledger(); + let result = + match validate_snapshot_extension(local_ledger, snapshot, adjusted_time_ms).await { + Ok(ledger) => network + .inner + .node + .lock() + .await + .import_verified_ledger(ledger) + .map(|_| ()), + Err(error) => Err(error), + }; + record_inbound_result(network, known_peer, remote_addr, result).await; + network.forward_outbox().await; + } + other => { + let result = network.inner.node.lock().await.receive(other); + record_inbound_result(network, known_peer, remote_addr, result).await; + network.forward_outbox().await; + } + } + Ok(()) +} + +async fn process_blinded_transactions( + network: &GossipNetwork, + remote_addr: SocketAddr, + known_peer: &Option<String>, + transactions: Vec<BlindedTransaction>, +) { + let first_error = { + let mut node = network.inner.node.lock().await; + let mut first_error = None; + for tx in transactions { + if let Err(error) = node.receive_blinded_transaction(tx) { + first_error.get_or_insert(error); + } + } + first_error + }; + record_inbound_result( + network, + known_peer, + remote_addr, + first_error + .map(|error| Err(anyhow!(format!("{error:#}")))) + .unwrap_or(Ok(())), + ) + .await; + network.forward_outbox().await; +} + +async fn process_mine_actions( + network: &GossipNetwork, + remote_addr: SocketAddr, + known_peer: &Option<String>, + transactions: Vec<Transaction>, +) { + let first_error = { + let mut node = network.inner.node.lock().await; + let mut first_error = None; + for tx in transactions { + if let Err(error) = node.receive_mine_action(tx) { + first_error.get_or_insert(error); + } + } + first_error + }; + record_inbound_result( + network, + known_peer, + remote_addr, + first_error + .map(|error| Err(anyhow!(format!("{error:#}")))) + .unwrap_or(Ok(())), + ) + .await; + network.forward_outbox().await; +} + +async fn process_blinded_reveals( + network: &GossipNetwork, + remote_addr: SocketAddr, + known_peer: &Option<String>, + reveals: Vec<BlindedReveal>, +) { + let first_error = { + let mut node = network.inner.node.lock().await; + let mut first_error = None; + for reveal in reveals { + if let Err(error) = node.receive_blinded_reveal(reveal) { + first_error.get_or_insert(error); + } + } + first_error + }; + record_inbound_result( + network, + known_peer, + remote_addr, + first_error + .map(|error| Err(anyhow!(format!("{error:#}")))) + .unwrap_or(Ok(())), + ) + .await; + network.forward_outbox().await; +} + +async fn process_reveal_bundles( + network: &GossipNetwork, + remote_addr: SocketAddr, + known_peer: &Option<String>, + bundles: Vec<RevealBundle>, +) { + let first_error = { + let mut node = network.inner.node.lock().await; + let mut first_error = None; + for bundle in bundles { + if let Err(error) = node.receive_reveal_bundle(bundle) { + first_error.get_or_insert(error); + } + } + first_error + }; + record_inbound_result( + network, + known_peer, + remote_addr, + first_error + .map(|error| Err(anyhow!(format!("{error:#}")))) + .unwrap_or(Ok(())), + ) + .await; + network.forward_outbox().await; +} + +async fn record_inbound_result( + network: &GossipNetwork, + known_peer: &Option<String>, + remote_addr: SocketAddr, + result: Result<()>, +) { + let peer = known_peer + .clone() + .unwrap_or_else(|| remote_addr.to_string()); + match result { + Ok(()) => { + if known_peer.is_some() { + network.inner.peers.lock().await.record_received(&peer, 1); + } + } + Err(error) => { + let message = format!("{error:#}"); + if known_peer.is_some() { + let mut peers = network.inner.peers.lock().await; + if super::inbound_error_counts_as_misbehavior(&message) { + peers.record_misbehavior(&peer, message.clone()); + } else { + peers.record_inbound_error(&peer, message.clone()); + } + } + if debug_logging_enabled() { + eprintln!("p2p envelope from {peer} ignored: {message}"); + } + } + } +} diff --git a/src/adapters/p2p/session.rs b/src/adapters/p2p/session.rs @@ -0,0 +1,411 @@ +use std::{net::SocketAddr, time::Duration}; + +use anyhow::Result; +use tokio::{ + net::{TcpListener, TcpStream}, + sync::mpsc, + time::{Instant, interval, interval_at, sleep, timeout}, +}; + +use crate::app::{GossipEnvelope, debug_logging_enabled}; + +use super::{ + CONNECT_TIMEOUT, GossipNetwork, HANDSHAKE_TIMEOUT, INITIAL_RECONNECT_DELAY, + MAX_RECONNECT_DELAY, PEER_EXCHANGE_INTERVAL, PEER_QUEUE_SIZE, PeerStatus, + SESSION_SYNC_INTERVAL, is_self_peer_address_for, next_reconnect_delay_with_max, + process_envelope, process_hello_with_verification, push_catchup_to_peer, read_session_envelope, + record_peer_status, respond_to_peer_verification_challenge, write_envelope, write_payload, + write_peer_exchange, +}; + +type OutboundBatch = Vec<GossipEnvelope>; + +pub(super) async fn accept_loop(network: GossipNetwork, listener: TcpListener) { + loop { + match listener.accept().await { + Ok((stream, remote_addr)) => { + let network = network.clone(); + let permit = match network.try_acquire_inbound_session(remote_addr.ip()) { + Ok(permit) => permit, + Err(rejection) => { + super::P2pMetricsCounters::inc( + &network.inner.metrics.inbound_sessions_rejected, + ); + super::P2pMetricsCounters::set_last( + &network.inner.metrics.last_session_failure, + format!("{remote_addr}: {}", rejection.label()), + ); + if debug_logging_enabled() { + eprintln!( + "p2p inbound connection from {remote_addr} rejected: {}", + rejection.label() + ); + } + drop(stream); + continue; + } + }; + super::P2pMetricsCounters::inc(&network.inner.metrics.inbound_sessions_started); + tokio::spawn(async move { + let _permit = permit; + let result = session_loop( + network.clone(), + stream, + remote_addr, + None, + mpsc::channel(1).1, + ) + .await; + match result { + Ok(()) => { + super::P2pMetricsCounters::inc(&network.inner.metrics.sessions_closed); + } + Err(error) if super::is_quiet_disconnect(&error) => { + super::P2pMetricsCounters::inc( + &network.inner.metrics.quiet_disconnects, + ); + } + Err(error) => { + super::P2pMetricsCounters::inc(&network.inner.metrics.session_failures); + super::P2pMetricsCounters::set_last( + &network.inner.metrics.last_session_failure, + format!("{remote_addr}: {error:#}"), + ); + if debug_logging_enabled() { + eprintln!( + "p2p inbound connection from {remote_addr} failed: {error:#}" + ); + } + } + } + }); + } + Err(error) if debug_logging_enabled() => eprintln!("p2p accept failed: {error:#}"), + Err(_) => {} + } + } +} + +pub(super) async fn outbound_supervisor(network: GossipNetwork) { + let mut tick = interval(Duration::from_secs(2)); + loop { + tick.tick().await; + network.ensure_outbound_sessions().await; + } +} + +pub(super) async fn outbound_session( + network: GossipNetwork, + peer: String, + mut receiver: mpsc::Receiver<OutboundBatch>, +) { + let mut reconnect_delay = INITIAL_RECONNECT_DELAY; + loop { + let self_filter_addr = network.self_filter_addr().await; + if !peer_is_connectable(&network, &peer).await + || is_self_peer_address_for(&peer, network.inner.listen_addr, self_filter_addr) + { + network.inner.sessions.lock().await.remove(&peer); + return; + } + if network.inner.peers.lock().await.is_banned(&peer) { + sleep(MAX_RECONNECT_DELAY).await; + continue; + } + super::P2pMetricsCounters::inc(&network.inner.metrics.outbound_connect_attempts); + let stream = match timeout(CONNECT_TIMEOUT, TcpStream::connect(&peer)).await { + Ok(Ok(stream)) => { + super::P2pMetricsCounters::inc(&network.inner.metrics.outbound_connect_successes); + stream + } + Ok(Err(error)) => { + super::P2pMetricsCounters::inc(&network.inner.metrics.outbound_connect_failures); + network + .inner + .peers + .lock() + .await + .record_error(&peer, format!("connecting to peer {peer}: {error}")); + sleep(reconnect_delay).await; + reconnect_delay = next_reconnect_delay(reconnect_delay); + continue; + } + Err(_) => { + super::P2pMetricsCounters::inc(&network.inner.metrics.outbound_connect_failures); + network + .inner + .peers + .lock() + .await + .record_error(&peer, format!("connecting to peer {peer}: timeout")); + sleep(reconnect_delay).await; + reconnect_delay = next_reconnect_delay(reconnect_delay); + continue; + } + }; + + reconnect_delay = INITIAL_RECONNECT_DELAY; + let remote_addr = stream.peer_addr().unwrap_or_else(|_| { + peer.parse() + .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0))) + }); + super::P2pMetricsCounters::inc(&network.inner.metrics.outbound_sessions_started); + let result = session_loop( + network.clone(), + stream, + remote_addr, + Some(peer.clone()), + receiver, + ) + .await; + match result { + Ok(()) => { + super::P2pMetricsCounters::inc(&network.inner.metrics.sessions_closed); + } + Err(error) if super::is_quiet_disconnect(&error) => { + super::P2pMetricsCounters::inc(&network.inner.metrics.quiet_disconnects); + } + Err(error) => { + super::P2pMetricsCounters::inc(&network.inner.metrics.session_failures); + let message = format!("{error:#}"); + super::P2pMetricsCounters::set_last( + &network.inner.metrics.last_session_failure, + format!("{peer}: {message}"), + ); + network + .inner + .peers + .lock() + .await + .record_error(&peer, message.clone()); + if debug_logging_enabled() { + eprintln!("p2p session with {peer} failed: {message}"); + } + } + } + + let (sender, next_receiver) = mpsc::channel(PEER_QUEUE_SIZE); + receiver = next_receiver; + if !peer_is_connectable(&network, &peer).await { + network.inner.sessions.lock().await.remove(&peer); + return; + } + network + .inner + .sessions + .lock() + .await + .insert(peer.clone(), sender); + sleep(reconnect_delay).await; + reconnect_delay = next_reconnect_delay(reconnect_delay); + } +} + +async fn session_loop( + network: GossipNetwork, + stream: TcpStream, + remote_addr: SocketAddr, + stable_peer: Option<String>, + mut outbound: mpsc::Receiver<OutboundBatch>, +) -> Result<()> { + let (reader, mut writer) = stream.into_split(); + let connection_label = stable_peer + .as_ref() + .map(|peer| format!("outbound {peer}")) + .unwrap_or_else(|| format!("inbound {remote_addr}")); + let advertised_addr = network.advertised_addr().await; + let hello = network.inner.node.lock().await.hello( + advertised_addr.map(|addr| addr.to_string()), + Some(network.inner.node_id.clone()), + ); + write_envelope(&mut writer, &hello).await?; + let mut reader = super::LimitedLineReader::new(reader); + let mut sync_tick = interval_at( + Instant::now() + SESSION_SYNC_INTERVAL, + SESSION_SYNC_INTERVAL, + ); + let mut peer_exchange_tick = interval_at( + Instant::now() + PEER_EXCHANGE_INTERVAL, + PEER_EXCHANGE_INTERVAL, + ); + let mut outbound_closed = false; + let mut peer_status: Option<PeerStatus> = None; + let is_outbound_session = stable_peer.is_some(); + let mut known_peer = stable_peer; + + if known_peer.is_some() { + if let Ok(Ok(Some(envelope))) = timeout( + HANDSHAKE_TIMEOUT, + read_session_envelope(&network, &connection_label, &mut reader), + ) + .await + { + if let GossipEnvelope::Hello(hello) = envelope { + peer_status = Some( + process_hello_with_verification( + &network, + &mut writer, + &mut reader, + &connection_label, + remote_addr, + &mut known_peer, + hello, + ) + .await?, + ); + if is_outbound_session && known_peer.is_none() { + return Ok(()); + } + super::maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()) + .await?; + write_peer_exchange(&network, &mut writer, &known_peer).await?; + } else if let GossipEnvelope::PeerStatus { + height, + tip_hash, + time_ms, + } = envelope + { + let status = PeerStatus::from_envelope(height, tip_hash, time_ms); + record_peer_status(&network, &known_peer, remote_addr, &status).await; + peer_status = Some(status); + super::maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()) + .await?; + write_peer_exchange(&network, &mut writer, &known_peer).await?; + } else if respond_to_peer_verification_challenge(&network, &mut writer, &envelope) + .await? + { + if known_peer.is_none() { + return Ok(()); + } + } else { + process_envelope( + &network, + &mut writer, + remote_addr, + &mut known_peer, + envelope, + ) + .await?; + if is_outbound_session && known_peer.is_none() { + return Ok(()); + } + } + } + } + + loop { + tokio::select! { + maybe_batch = outbound.recv(), if !outbound_closed => { + match maybe_batch { + Some(batch) => { + let payload = super::envelopes_for_peer( + Some(&network.inner.node), + peer_status.clone(), + &batch, + ).await; + write_payload(&mut writer, &payload).await?; + if let Some(peer) = &known_peer { + network.inner.peers.lock().await.record_sent(peer, payload.len() as u64); + } + } + None => outbound_closed = true, + } + } + _ = sync_tick.tick() => { + let status = network.inner.node.lock().await.peer_status(); + write_envelope(&mut writer, &status).await?; + if let Some(status) = peer_status.as_mut() { + if let Some(updated_status) = push_catchup_to_peer(&network, &mut writer, status).await? { + *status = updated_status; + } + } + } + _ = peer_exchange_tick.tick() => { + write_peer_exchange(&network, &mut writer, &known_peer).await?; + } + envelope = read_session_envelope(&network, &connection_label, &mut reader) => { + let Some(envelope) = envelope? else { + return Ok(()); + }; + if let GossipEnvelope::Hello(hello) = envelope { + peer_status = Some( + process_hello_with_verification( + &network, + &mut writer, + &mut reader, + &connection_label, + remote_addr, + &mut known_peer, + hello, + ) + .await?, + ); + if is_outbound_session && known_peer.is_none() { + return Ok(()); + } + super::maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; + write_peer_exchange(&network, &mut writer, &known_peer).await?; + continue; + } + if let GossipEnvelope::PeerStatus { + height, + tip_hash, + time_ms, + } = &envelope + { + let status = PeerStatus::from_envelope(*height, tip_hash.clone(), *time_ms); + record_peer_status(&network, &known_peer, remote_addr, &status).await; + peer_status = Some(status); + super::maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; + write_peer_exchange(&network, &mut writer, &known_peer).await?; + continue; + } + + if respond_to_peer_verification_challenge(&network, &mut writer, &envelope).await? { + if known_peer.is_none() { + return Ok(()); + } + continue; + } + process_envelope( + &network, + &mut writer, + remote_addr, + &mut known_peer, + envelope, + ).await?; + if is_outbound_session && known_peer.is_none() { + return Ok(()); + } + } + } + } +} + +async fn peer_is_connectable(network: &GossipNetwork, peer: &str) -> bool { + network.inner.peers.lock().await.is_connectable_peer(peer) +} + +pub(super) fn next_reconnect_delay(current: Duration) -> Duration { + next_reconnect_delay_with_max(current, MAX_RECONNECT_DELAY) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::super::{INITIAL_RECONNECT_DELAY, MAX_RECONNECT_DELAY}; + use super::next_reconnect_delay; + + #[test] + fn reconnect_backoff_is_capped() { + assert_eq!( + next_reconnect_delay(INITIAL_RECONNECT_DELAY), + Duration::from_secs(2) + ); + assert_eq!( + next_reconnect_delay(MAX_RECONNECT_DELAY), + MAX_RECONNECT_DELAY + ); + } +} diff --git a/src/adapters/p2p/sync.rs b/src/adapters/p2p/sync.rs @@ -0,0 +1,476 @@ +use std::net::SocketAddr; + +use anyhow::Result; +use tokio::net::tcp::OwnedWriteHalf; + +use super::metrics::P2pMetricsCounters; +use super::peer_addr::{ + is_self_peer_address_for, normalize_advertised_peer, peer_list_address_is_discoverable, + peer_needs_snapshot, +}; +use super::{GossipNetwork, MAX_BLOCK_BATCH, PeerStatus, write_envelope, write_payload}; +use crate::app::{GossipEnvelope, SharedNode, debug_logging_enabled}; + +pub(super) async fn maybe_request_catchup( + network: &GossipNetwork, + writer: &mut OwnedWriteHalf, + peer_status: &PeerStatus, +) -> Result<()> { + let (local_height, local_tip_hash) = { + let node = network.inner.node.lock().await; + let status = node.ledger().status(); + (status.height, status.tip_hash) + }; + if peer_status.request_snapshot { + write_envelope(writer, &GossipEnvelope::ChainSnapshotRequest).await?; + } else if peer_status.height > local_height { + write_envelope( + writer, + &GossipEnvelope::BlockRangeRequest { + from_height: local_height + 1, + limit: MAX_BLOCK_BATCH, + }, + ) + .await?; + } else if peer_status.height == local_height && peer_status.tip_hash != local_tip_hash { + write_envelope(writer, &GossipEnvelope::ChainSnapshotRequest).await?; + } + Ok(()) +} + +pub(super) async fn push_catchup_to_peer( + network: &GossipNetwork, + writer: &mut OwnedWriteHalf, + peer_status: &PeerStatus, +) -> Result<Option<PeerStatus>> { + let payload = catchup_payload_for_peer(&network.inner.node, peer_status).await; + if payload.is_empty() { + return Ok(None); + } + + let updated_status = payload.iter().find_map(|envelope| match envelope { + GossipEnvelope::Blocks { blocks } => blocks + .last() + .map(|block| PeerStatus::new(block.height, block.hash.clone())), + GossipEnvelope::ChainSnapshot(snapshot) => snapshot + .blocks + .last() + .map(|block| PeerStatus::new(block.height, block.hash.clone())), + _ => None, + }); + write_payload(writer, &payload).await?; + Ok(updated_status) +} + +pub(super) async fn catchup_payload_for_peer( + node: &SharedNode, + peer_status: &PeerStatus, +) -> Vec<GossipEnvelope> { + let mut node = node.lock().await; + let local_status = node.ledger().status(); + if node.ledger().is_setup_placeholder() { + return Vec::new(); + } + let mempool = node.mempool_gossip(); + if peer_status.push_snapshot { + let mut payload = vec![GossipEnvelope::ChainSnapshot(node.chain_snapshot())]; + payload.extend(mempool); + return payload; + } + if peer_status.height < local_status.height { + let blocks = node.blocks_from(peer_status.height + 1, MAX_BLOCK_BATCH); + if blocks.is_empty() { + mempool + } else { + let mut payload = vec![GossipEnvelope::Blocks { blocks }]; + payload.extend(mempool); + payload + } + } else if peer_status.height == local_status.height + && peer_status.tip_hash != local_status.tip_hash + { + let mut payload = vec![GossipEnvelope::ChainSnapshot(node.chain_snapshot())]; + payload.extend(mempool); + payload + } else { + mempool + } +} + +pub(super) async fn apply_peer_list( + network: &GossipNetwork, + remote_addr: SocketAddr, + peers: Vec<String>, +) -> Result<()> { + let self_filter_addr = network.self_filter_addr().await; + let mut peerbook = network.inner.peers.lock().await; + for address in peers { + let peer = match normalize_advertised_peer(&address, remote_addr) { + Ok(peer) => peer, + Err(error) => { + if debug_logging_enabled() { + eprintln!("p2p peer-list address {address} ignored: {error:#}"); + } + continue; + } + }; + if is_self_peer_address_for(&peer, network.inner.listen_addr, self_filter_addr) { + P2pMetricsCounters::inc(&network.inner.metrics.self_peer_skips); + } else if peer_list_address_is_discoverable(&peer, remote_addr)? { + peerbook.add_peer(peer); + } else { + P2pMetricsCounters::inc(&network.inner.metrics.self_peer_skips); + } + } + Ok(()) +} + +pub(super) async fn write_peer_exchange( + network: &GossipNetwork, + writer: &mut OwnedWriteHalf, + known_peer: &Option<String>, +) -> Result<()> { + let envelope = network.peer_exchange().await; + let GossipEnvelope::PeerList { peers } = &envelope else { + return Ok(()); + }; + if peers.is_empty() { + return Ok(()); + } + write_envelope(writer, &envelope).await?; + if let Some(peer) = known_peer { + network.inner.peers.lock().await.record_sent(peer, 1); + } + Ok(()) +} + +pub(super) async fn envelopes_for_peer( + node: Option<&SharedNode>, + peer_status: Option<PeerStatus>, + envelopes: &[GossipEnvelope], +) -> Vec<GossipEnvelope> { + let Some(node) = node else { + return envelopes.to_vec(); + }; + let Some(peer_status) = peer_status else { + return envelopes.to_vec(); + }; + + let node = node.lock().await; + let local_status = node.ledger().status(); + if node.ledger().is_setup_placeholder() { + return envelopes + .iter() + .filter(|envelope| !matches!(envelope, GossipEnvelope::Block(_))) + .cloned() + .collect(); + } + if peer_status.height < local_status.height { + let mut payload = vec![GossipEnvelope::Blocks { + blocks: node.blocks_from(peer_status.height + 1, MAX_BLOCK_BATCH), + }]; + payload.extend( + envelopes + .iter() + .filter(|envelope| !matches!(envelope, GossipEnvelope::Block(_))) + .cloned(), + ); + return payload; + } + + if peer_status.height == local_status.height && peer_status.tip_hash != local_status.tip_hash { + return vec![GossipEnvelope::ChainSnapshot(node.chain_snapshot())]; + } + + if peer_needs_snapshot(peer_status.height, envelopes) { + return vec![GossipEnvelope::ChainSnapshot(node.chain_snapshot())]; + } + + envelopes + .iter() + .filter(|envelope| match envelope { + GossipEnvelope::Block(block) => block.height > peer_status.height, + GossipEnvelope::Inventory { blocks, .. } => { + blocks.iter().any(|block| block.height > peer_status.height) + } + _ => true, + }) + .map(|envelope| match envelope { + GossipEnvelope::Inventory { blocks } => GossipEnvelope::Inventory { + blocks: blocks + .iter() + .filter(|block| block.height > peer_status.height) + .cloned() + .collect(), + }, + other => other.clone(), + }) + .filter(|envelope| match envelope { + GossipEnvelope::Inventory { blocks } => !blocks.is_empty(), + _ => true, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use crate::{ + app::{GossipEnvelope, PeerBook}, + domain::Wallet, + }; + + use super::super::{ + PeerStatus, + test_support::{allocations, gossip_network, node, queue_plaintext_burn}, + }; + use super::{apply_peer_list, catchup_payload_for_peer, envelopes_for_peer}; + + #[tokio::test] + async fn peer_payload_repairs_lagging_peer_without_networking() { + 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.clone(), + allocations, + ))); + let block = { + let mut node = node.lock().await; + queue_plaintext_burn(&mut node, &alice, 1); + node.drain_outbox(); + let block = node.mine_one_at(1).unwrap(); + node.drain_outbox(); + block + }; + + let payload = envelopes_for_peer( + Some(&node), + Some(PeerStatus::new(0, "genesis".to_string())), + &[GossipEnvelope::Block(block)], + ) + .await; + + assert!(matches!(payload[0], GossipEnvelope::Blocks { .. })); + match &payload[0] { + GossipEnvelope::Blocks { blocks } => { + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].height, 1); + } + _ => unreachable!(), + } + } + + #[tokio::test] + async fn session_catchup_payload_pushes_missing_blocks_to_lagging_peer() { + 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.clone(), + allocations, + ))); + { + let mut node = node.lock().await; + for height in 1..=3 { + queue_plaintext_burn(&mut node, &alice, 1); + node.drain_outbox(); + node.mine_one_at(height).unwrap(); + node.drain_outbox(); + } + } + + let payload = + catchup_payload_for_peer(&node, &PeerStatus::new(1, "old-tip".to_string())).await; + + assert_eq!(payload.len(), 1); + match &payload[0] { + GossipEnvelope::Blocks { blocks } => { + assert_eq!( + blocks.iter().map(|block| block.height).collect::<Vec<_>>(), + vec![2, 3] + ); + } + other => panic!("expected missing block payload, got {other:?}"), + } + } + + #[tokio::test] + async fn session_catchup_payload_pushes_blinded_mempool_to_synced_peer() { + let alice = Wallet::from_seed("catchup-mempool-alice"); + let bob = Wallet::from_seed("catchup-mempool-bob"); + let allocations = allocations(&[alice.clone(), bob], 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node( + "alice", + alice.clone(), + allocations, + ))); + let expected_commitment = { + let mut node = node.lock().await; + let tx = node.ledger().build_burn(&alice, 1, 0).unwrap(); + let built = node + .ledger() + .build_blinded_transaction(&alice, tx, 20) + .unwrap(); + let commitment = built.transaction.commitment.clone(); + node.receive_blinded_transaction(built.transaction).unwrap(); + node.drain_outbox(); + commitment + }; + let peer_status = { + let node = node.lock().await; + let status = node.ledger().status(); + PeerStatus::new(status.height, status.tip_hash) + }; + + let payload = catchup_payload_for_peer(&node, &peer_status).await; + + assert_eq!(payload.len(), 1); + match &payload[0] { + GossipEnvelope::BlindedTransactions { transactions } => { + assert_eq!(transactions.len(), 1); + assert_eq!(transactions[0].commitment, expected_commitment); + } + other => panic!("expected blinded mempool payload, got {other:?}"), + } + } + + #[tokio::test] + async fn peer_list_adds_stable_outbound_peers() { + let alice = Wallet::from_seed("px-recv-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let network = gossip_network( + node, + Arc::clone(&peers), + "127.0.0.1:9544".parse().unwrap(), + None, + ); + apply_peer_list( + &network, + "127.0.0.1:9545".parse().unwrap(), + vec!["127.0.0.1:9544".to_string(), "127.0.0.1:9546".to_string()], + ) + .await + .unwrap(); + + let addresses = peers.lock().await.addresses(); + assert!(!addresses.contains(&"127.0.0.1:9544".to_string())); + assert!(addresses.contains(&"127.0.0.1:9546".to_string())); + } + + #[tokio::test] + async fn peer_list_ignores_invalid_peer_addresses() { + let alice = Wallet::from_seed("px-list-invalid-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let network = gossip_network( + node, + Arc::clone(&peers), + "127.0.0.1:9544".parse().unwrap(), + None, + ); + apply_peer_list( + &network, + "127.0.0.1:9545".parse().unwrap(), + vec![ + "iuna.jhx.app:9444".to_string(), + "127.0.0.1:9546".to_string(), + ], + ) + .await + .unwrap(); + + let addresses = peers.lock().await.addresses(); + assert!(!addresses.contains(&"iuna.jhx.app:9444".to_string())); + assert!(addresses.contains(&"127.0.0.1:9546".to_string())); + } + + #[tokio::test] + async fn peer_list_ignores_announced_self_address() { + let alice = Wallet::from_seed("px-list-announced-self-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let network = gossip_network( + node, + Arc::clone(&peers), + "0.0.0.0:9444".parse().unwrap(), + Some("8.8.8.8:9444".parse().unwrap()), + ); + + apply_peer_list( + &network, + "8.8.4.4:9444".parse().unwrap(), + vec!["8.8.8.8:9444".to_string(), "8.8.4.4:9445".to_string()], + ) + .await + .unwrap(); + + let addresses = peers.lock().await.addresses(); + assert!(!addresses.contains(&"8.8.8.8:9444".to_string())); + assert!(addresses.contains(&"8.8.4.4:9445".to_string())); + network.set_accept_inbound(false).await.unwrap(); + } + + #[tokio::test] + async fn peer_list_ignores_private_ephemeral_addresses() { + let alice = Wallet::from_seed("px-private-ephemeral-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let network = gossip_network( + node, + Arc::clone(&peers), + "0.0.0.0:9444".parse().unwrap(), + None, + ); + + apply_peer_list( + &network, + "142.132.164.59:9444".parse().unwrap(), + vec![ + "10.42.1.1:10091".to_string(), + "142.132.164.59:9444".to_string(), + ], + ) + .await + .unwrap(); + + let addresses = peers.lock().await.addresses(); + assert!(!addresses.contains(&"10.42.1.1:10091".to_string())); + assert!(addresses.contains(&"142.132.164.59:9444".to_string())); + } + + #[tokio::test] + async fn peer_list_ignores_loopback_alias_for_unspecified_self() { + let alice = Wallet::from_seed("px-self-alias-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let network = gossip_network( + node, + Arc::clone(&peers), + "0.0.0.0:9545".parse().unwrap(), + None, + ); + + apply_peer_list( + &network, + "127.0.0.1:9544".parse().unwrap(), + vec!["127.0.0.1:9545".to_string(), "127.0.0.1:9546".to_string()], + ) + .await + .unwrap(); + + let addresses = peers.lock().await.addresses(); + assert!(!addresses.contains(&"127.0.0.1:9545".to_string())); + assert!(addresses.contains(&"127.0.0.1:9546".to_string())); + assert_eq!(network.metrics().self_peer_skips, 1); + } +} diff --git a/src/adapters/p2p/test_support.rs b/src/adapters/p2p/test_support.rs @@ -0,0 +1,60 @@ +use std::{collections::BTreeMap, net::SocketAddr, sync::Arc}; + +use crate::{ + app::{NodeCore, PeerBook}, + domain::{Amount, GenesisBurn, Ledger, Transaction, Wallet}, +}; + +use super::{GossipNetwork, GossipNetworkInner, InboundConnectionLimiter, P2pMetricsCounters}; + +pub(super) fn node( + _network_key: &str, + wallet: Wallet, + allocations: BTreeMap<String, Amount>, +) -> NodeCore { + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(wallet.address(), 1)], + 25, + ) + .unwrap(); + NodeCore::from_ledger(wallet, ledger, 0) +} + +pub(super) 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 +} + +pub(super) fn allocations(wallets: &[Wallet], amount: Amount) -> BTreeMap<String, Amount> { + wallets + .iter() + .map(|wallet| (wallet.address().to_string(), amount)) + .collect() +} + +pub(super) fn gossip_network( + node: Arc<tokio::sync::Mutex<NodeCore>>, + peers: Arc<tokio::sync::Mutex<PeerBook>>, + listen_addr: SocketAddr, + p2p_announce_addr: Option<SocketAddr>, +) -> GossipNetwork { + GossipNetwork { + inner: Arc::new(GossipNetworkInner { + node, + peers, + listen_addr, + p2p_announce_addr: tokio::sync::Mutex::new(p2p_announce_addr), + node_id: super::new_node_id(), + accept_task: tokio::sync::Mutex::new(None), + sessions: tokio::sync::Mutex::new(BTreeMap::new()), + inbound_limiter: Arc::new(std::sync::Mutex::new(InboundConnectionLimiter::default())), + metrics: P2pMetricsCounters::default(), + }), + } +} diff --git a/src/adapters/p2p/tests.rs b/src/adapters/p2p/tests.rs @@ -0,0 +1,890 @@ +use std::{ + collections::BTreeMap, + net::SocketAddr, + sync::{Arc, Mutex as StdMutex}, +}; + +use crate::{ + app::{ + GossipEnvelope, NETWORK_ID, NodeCore, PROTOCOL_VERSION, PeerBook, PeerDirection, + ProtocolHello, + }, + domain::{Ledger, Wallet}, +}; +use tokio::io::AsyncWriteExt; + +use super::test_support::{allocations, node}; + +#[tokio::test] +async fn full_outbound_queue_is_metric_not_peer_error() { + let wallet = Wallet::from_seed("full-outbound-queue"); + let node = Arc::new(tokio::sync::Mutex::new(node( + "full-outbound-queue", + wallet.clone(), + allocations(&[wallet], 1_000), + ))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ + "127.0.0.1:9444".to_string(), + ]))); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node, + peers: Arc::clone(&peers), + 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()), + inbound_limiter: Arc::new(StdMutex::new(super::InboundConnectionLimiter::default())), + metrics: super::P2pMetricsCounters::default(), + }), + }; + let (sender, _receiver) = tokio::sync::mpsc::channel(1); + sender + .try_send(vec![GossipEnvelope::PeerStatus { + height: 1, + tip_hash: "queued".to_string(), + time_ms: 1_000, + }]) + .unwrap(); + network + .inner + .sessions + .lock() + .await + .insert("127.0.0.1:9444".to_string(), sender); + + network + .broadcast(vec![GossipEnvelope::PeerStatus { + height: 2, + tip_hash: "new".to_string(), + time_ms: 2_000, + }]) + .await + .unwrap(); + + assert_eq!(network.metrics().outbound_queue_full, 1); + let peer = peers.lock().await.list().pop().unwrap(); + assert_eq!(peer.last_error, None); + assert_eq!(peer.last_error_ms, None); +} + +#[tokio::test] +async fn hello_rejects_wrong_network_or_genesis_without_banning() { + let alice = Wallet::from_seed("hello-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, + 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()), + inbound_limiter: Arc::new(StdMutex::new(super::InboundConnectionLimiter::default())), + metrics: super::P2pMetricsCounters::default(), + }), + }; + + let wrong_network = ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: "other-network".to_string(), + genesis_hash: network + .inner + .node + .lock() + .await + .ledger() + .genesis_hash() + .to_string(), + listen_addr: None, + node_id: None, + height: 0, + tip_hash: "tip".to_string(), + time_ms: 1_000, + }; + assert!( + super::process_hello( + &network, + "127.0.0.1:9545".parse().unwrap(), + &mut None, + wrong_network, + ) + .await + .unwrap_err() + .to_string() + .contains("wrong network") + ); + + let wrong_genesis = ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: "not-local-genesis".to_string(), + listen_addr: Some("127.0.0.1:9545".to_string()), + node_id: None, + height: 0, + tip_hash: "tip".to_string(), + time_ms: 1_000, + }; + assert!( + super::process_hello( + &network, + "127.0.0.1:9545".parse().unwrap(), + &mut None, + wrong_genesis, + ) + .await + .unwrap_err() + .to_string() + .contains("wrong genesis") + ); + + let wrong_protocol = ProtocolHello { + protocol_version: PROTOCOL_VERSION + 1, + network_id: NETWORK_ID.to_string(), + genesis_hash: network + .inner + .node + .lock() + .await + .ledger() + .genesis_hash() + .to_string(), + listen_addr: Some("127.0.0.1:9545".to_string()), + node_id: None, + height: 0, + tip_hash: "tip".to_string(), + time_ms: 1_000, + }; + assert!( + super::process_hello( + &network, + "127.0.0.1:9545".parse().unwrap(), + &mut None, + wrong_protocol, + ) + .await + .unwrap_err() + .to_string() + .contains("unsupported protocol version") + ); + + assert!(network.inner.peers.lock().await.list().is_empty()); +} + +#[tokio::test] +async fn hello_records_remote_clock_observation() { + let alice = Wallet::from_seed("hello-clock-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, + 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()), + inbound_limiter: Arc::new(StdMutex::new(super::InboundConnectionLimiter::default())), + metrics: super::P2pMetricsCounters::default(), + }), + }; + let remote_time_ms = crate::app::now_ms().saturating_add(60_000); + let hello = ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: network + .inner + .node + .lock() + .await + .ledger() + .genesis_hash() + .to_string(), + listen_addr: Some("127.0.0.1:9545".to_string()), + node_id: None, + height: 0, + tip_hash: "tip".to_string(), + time_ms: remote_time_ms, + }; + + let mut known_peer = None; + super::process_hello( + &network, + "127.0.0.1:9545".parse().unwrap(), + &mut known_peer, + hello, + ) + .await + .unwrap(); + + let peers = network.inner.peers.lock().await.list(); + let peer = peers + .iter() + .find(|peer| peer.address == "127.0.0.1:9545") + .unwrap(); + assert!(peer.last_clock_offset_ms.unwrap() > 30_000); + assert_eq!(peer.last_clock_offset_accepted, Some(true)); +} + +#[tokio::test] +async fn hello_remembers_advertised_address_after_signed_session_and_dialback() { + let alice = Wallet::from_seed("hello-dialback-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node: Arc::clone(&node), + peers: Arc::clone(&peers), + 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()), + inbound_limiter: Arc::new(StdMutex::new(super::InboundConnectionLimiter::default())), + metrics: super::P2pMetricsCounters::default(), + }), + }; + let remote_node_id = super::new_node_id(); + let remote_addr = spawn_hello_server(ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), + listen_addr: None, + node_id: Some(remote_node_id.clone()), + height: 0, + tip_hash: "tip".to_string(), + time_ms: 1_000, + }) + .await; + let original_addr = spawn_verification_responder(remote_node_id.clone()).await; + let stream = tokio::net::TcpStream::connect(original_addr).await.unwrap(); + let remote_socket = stream.peer_addr().unwrap(); + let (reader, mut writer) = stream.into_split(); + let mut reader = super::LimitedLineReader::new(reader); + let hello = ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), + listen_addr: Some(remote_addr.to_string()), + node_id: Some(remote_node_id), + height: 0, + tip_hash: "tip".to_string(), + time_ms: 1_000, + }; + let mut known_peer = None; + + super::process_hello_with_verification( + &network, + &mut writer, + &mut reader, + "test-original-peer", + remote_socket, + &mut known_peer, + hello, + ) + .await + .unwrap(); + + assert_eq!(known_peer, Some(remote_addr.to_string())); + let listed = peers.lock().await.list(); + let peer = listed + .iter() + .find(|peer| peer.address == remote_addr.to_string()) + .unwrap(); + assert_eq!(peer.direction, PeerDirection::Discovered); + assert!( + peers + .lock() + .await + .addresses() + .contains(&remote_addr.to_string()) + ); +} + +#[tokio::test] +async fn hello_ignores_advertised_address_when_connected_peer_cannot_sign_claimed_node_id() { + let alice = Wallet::from_seed("hello-dialback-spoof-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node: Arc::clone(&node), + peers: Arc::clone(&peers), + 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()), + inbound_limiter: Arc::new(StdMutex::new(super::InboundConnectionLimiter::default())), + metrics: super::P2pMetricsCounters::default(), + }), + }; + let victim_node_id = super::new_node_id(); + let attacker_node_id = super::new_node_id(); + let remote_addr = spawn_hello_server(ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), + listen_addr: None, + node_id: Some(victim_node_id.clone()), + height: 0, + tip_hash: "tip".to_string(), + time_ms: 1_000, + }) + .await; + let original_addr = spawn_verification_responder(attacker_node_id).await; + let stream = tokio::net::TcpStream::connect(original_addr).await.unwrap(); + let remote_socket = stream.peer_addr().unwrap(); + let (reader, mut writer) = stream.into_split(); + let mut reader = super::LimitedLineReader::new(reader); + let hello = ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), + listen_addr: Some(remote_addr.to_string()), + node_id: Some(victim_node_id), + height: 0, + tip_hash: "tip".to_string(), + time_ms: 1_000, + }; + let mut known_peer = None; + + super::process_hello_with_verification( + &network, + &mut writer, + &mut reader, + "test-attacker-peer", + remote_socket, + &mut known_peer, + hello, + ) + .await + .unwrap(); + + assert!(known_peer.is_none()); + assert!( + !peers + .lock() + .await + .addresses() + .contains(&remote_addr.to_string()) + ); +} + +#[tokio::test] +async fn dialback_rejects_address_that_signs_with_different_node_id() { + let alice = Wallet::from_seed("hello-dialback-mismatch-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()), + inbound_limiter: Arc::new(StdMutex::new(super::InboundConnectionLimiter::default())), + metrics: super::P2pMetricsCounters::default(), + }), + }; + let honest_node_id = super::new_node_id(); + let claimed_node_id = super::new_node_id(); + let remote_addr = spawn_hello_server(ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), + listen_addr: None, + node_id: Some(honest_node_id), + height: 0, + tip_hash: "tip".to_string(), + time_ms: 1_000, + }) + .await; + + assert!( + !super::verify_advertised_peer_node_id( + &network, + &remote_addr.to_string(), + &claimed_node_id + ) + .await + ); +} + +#[tokio::test] +async fn inbound_verification_only_session_closes_after_response() { + let alice = Wallet::from_seed("verification-only-close-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let listen_addr = listener.local_addr().unwrap(); + drop(listener); + let network = super::GossipNetwork::start(node, peers, listen_addr, None, true) + .await + .unwrap(); + + let stream = tokio::net::TcpStream::connect(listen_addr).await.unwrap(); + let (reader, mut writer) = stream.into_split(); + let mut reader = super::LimitedLineReader::new(reader); + let hello_line = reader.read_line().await.unwrap().unwrap(); + let node_id = match super::parse_envelope(&hello_line).unwrap() { + GossipEnvelope::Hello(hello) => hello.node_id.unwrap(), + other => panic!("expected hello, got {other:?}"), + }; + let nonce = super::new_verification_nonce(); + super::write_envelope( + &mut writer, + &GossipEnvelope::PeerVerificationChallenge { + address: listen_addr.to_string(), + nonce: nonce.clone(), + }, + ) + .await + .unwrap(); + + let response_line = tokio::time::timeout(std::time::Duration::from_secs(1), reader.read_line()) + .await + .unwrap() + .unwrap() + .unwrap(); + match super::parse_envelope(&response_line).unwrap() { + GossipEnvelope::PeerVerificationResponse { + address, + nonce: response_nonce, + node_id: response_node_id, + signature, + } => assert!(super::peer_verification_response_is_valid( + &address, + &response_nonce, + &response_node_id, + &signature, + &listen_addr.to_string(), + &nonce, + &node_id, + )), + other => panic!("expected verification response, got {other:?}"), + } + + let closed = tokio::time::timeout(std::time::Duration::from_secs(1), reader.read_line()) + .await + .unwrap() + .unwrap(); + assert!(closed.is_none()); + network.set_accept_inbound(false).await.unwrap(); +} + +#[tokio::test] +async fn setup_placeholder_accepts_remote_genesis_and_adopts_snapshot() { + let local_wallet = Wallet::from_seed("setup-placeholder-local"); + let local_ledger = Ledger::new(BTreeMap::new(), 1); + let local_node = Arc::new(tokio::sync::Mutex::new(NodeCore::from_ledger( + local_wallet, + local_ledger.clone(), + 0, + ))); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node: local_node, + peers: Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ + "iuna.jhx.app:9444".to_string(), + ]))), + 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()), + inbound_limiter: Arc::new(StdMutex::new(super::InboundConnectionLimiter::default())), + metrics: super::P2pMetricsCounters::default(), + }), + }; + + let remote_wallet = Wallet::from_seed("setup-placeholder-remote"); + let remote_snapshot = node( + "remote", + remote_wallet.clone(), + allocations(std::slice::from_ref(&remote_wallet), 1_000), + ) + .chain_snapshot(); + let remote_genesis = remote_snapshot.blocks[0].hash.clone(); + let hello = ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: remote_genesis.clone(), + listen_addr: Some("142.132.164.59:9444".to_string()), + node_id: None, + height: 5, + tip_hash: "remote-tip".to_string(), + time_ms: 1_000, + }; + let mut known_peer = Some("iuna.jhx.app:9444".to_string()); + let peer_status = super::process_hello( + &network, + "142.132.164.59:51234".parse().unwrap(), + &mut known_peer, + hello, + ) + .await + .unwrap(); + + assert!(peer_status.request_snapshot); + assert!(!peer_status.push_snapshot); + assert_eq!(known_peer.as_deref(), Some("iuna.jhx.app:9444")); + let listed = network.inner.peers.lock().await.list(); + assert_eq!(listed.len(), 1); + let peer = listed + .into_iter() + .find(|peer| peer.address == "iuna.jhx.app:9444") + .unwrap(); + assert_eq!(peer.misbehavior_score, 0); + assert!(!peer.is_banned_at(crate::app::now_ms())); + + let adopted = + super::validate_snapshot_extension(local_ledger, remote_snapshot, crate::app::now_ms()) + .await + .unwrap(); + assert_eq!(adopted.genesis_hash(), remote_genesis); + assert!( + network + .inner + .node + .lock() + .await + .import_verified_ledger(adopted) + .unwrap() + ); + assert_eq!( + network.inner.node.lock().await.ledger().genesis_hash(), + remote_genesis + ); +} + +#[tokio::test] +async fn real_node_accepts_setup_placeholder_peer_and_pushes_snapshot() { + let wallet = Wallet::from_seed("setup-placeholder-peer-real-node"); + let node = Arc::new(tokio::sync::Mutex::new(node( + "real", + wallet.clone(), + allocations(std::slice::from_ref(&wallet), 1_000), + ))); + 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()), + inbound_limiter: Arc::new(StdMutex::new(super::InboundConnectionLimiter::default())), + metrics: super::P2pMetricsCounters::default(), + }), + }; + let setup_ledger = Ledger::new(BTreeMap::new(), 1); + let hello = ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: setup_ledger.genesis_hash().to_string(), + listen_addr: Some("127.0.0.1:9545".to_string()), + node_id: None, + height: 0, + tip_hash: setup_ledger.status().tip_hash, + time_ms: 1_000, + }; + + let peer_status = super::process_hello( + &network, + "127.0.0.1:51234".parse().unwrap(), + &mut None, + hello, + ) + .await + .unwrap(); + + assert!(!peer_status.request_snapshot); + assert!(peer_status.push_snapshot); + let payload = super::catchup_payload_for_peer(&node, &peer_status).await; + assert!(matches!( + payload.as_slice(), + [GossipEnvelope::ChainSnapshot(_)] + )); +} + +#[tokio::test] +async fn hello_ignores_private_advertised_listen_address() { + let alice = Wallet::from_seed("hello-private-listen-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node: Arc::clone(&node), + peers: Arc::clone(&peers), + listen_addr: "0.0.0.0:9444".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()), + inbound_limiter: Arc::new(StdMutex::new(super::InboundConnectionLimiter::default())), + metrics: super::P2pMetricsCounters::default(), + }), + }; + let status = node.lock().await.ledger().status(); + let hello = ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: node.lock().await.ledger().genesis_hash().to_string(), + listen_addr: Some("10.42.1.1:12138".to_string()), + node_id: None, + height: status.height, + tip_hash: status.tip_hash, + time_ms: 1_000, + }; + + let mut known_peer = None; + super::process_hello( + &network, + "142.132.164.59:51234".parse().unwrap(), + &mut known_peer, + hello, + ) + .await + .unwrap(); + + assert!(known_peer.is_none()); + assert!(peers.lock().await.addresses().is_empty()); +} + +#[tokio::test] +async fn hello_ignores_loopback_alias_for_unspecified_self() { + let alice = Wallet::from_seed("hello-self-alias-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::default())); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node, + peers: Arc::clone(&peers), + listen_addr: "0.0.0.0:9545".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()), + inbound_limiter: Arc::new(StdMutex::new(super::InboundConnectionLimiter::default())), + metrics: super::P2pMetricsCounters::default(), + }), + }; + let hello = ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: network + .inner + .node + .lock() + .await + .ledger() + .genesis_hash() + .to_string(), + listen_addr: Some("127.0.0.1:9545".to_string()), + node_id: None, + height: 0, + tip_hash: "tip".to_string(), + time_ms: 1_000, + }; + + super::process_hello( + &network, + "127.0.0.1:52144".parse().unwrap(), + &mut None, + hello, + ) + .await + .unwrap(); + + assert_eq!(network.metrics().self_peer_rejections, 1); + assert!(peers.lock().await.addresses().is_empty()); + let listed = peers.lock().await.list(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].direction, PeerDirection::Inbound); +} + +#[tokio::test] +async fn hello_removes_outbound_peer_that_announces_self_address() { + let alice = Wallet::from_seed("hello-self-outbound-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ + "10.42.1.1:16987".to_string(), + ]))); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node, + peers: Arc::clone(&peers), + listen_addr: "0.0.0.0:9444".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()), + inbound_limiter: Arc::new(StdMutex::new(super::InboundConnectionLimiter::default())), + metrics: super::P2pMetricsCounters::default(), + }), + }; + let hello = ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: network + .inner + .node + .lock() + .await + .ledger() + .genesis_hash() + .to_string(), + listen_addr: Some("127.0.0.1:9444".to_string()), + node_id: None, + height: 0, + tip_hash: "tip".to_string(), + time_ms: 1_000, + }; + let mut known_peer = Some("10.42.1.1:16987".to_string()); + + super::process_hello( + &network, + "10.42.1.1:16987".parse().unwrap(), + &mut known_peer, + hello, + ) + .await + .unwrap(); + + assert_eq!(network.metrics().self_peer_rejections, 1); + assert!(known_peer.is_none()); + assert!(peers.lock().await.addresses().is_empty()); +} + +#[tokio::test] +async fn hello_removes_outbound_peer_with_same_node_id() { + let alice = Wallet::from_seed("hello-self-node-id-alice"); + let allocations = allocations(std::slice::from_ref(&alice), 1_000); + let node = Arc::new(tokio::sync::Mutex::new(node("alice", alice, allocations))); + let peers = Arc::new(tokio::sync::Mutex::new(PeerBook::from_addresses(vec![ + "142.132.164.59:9444".to_string(), + ]))); + let network = super::GossipNetwork { + inner: Arc::new(super::GossipNetworkInner { + node, + peers: Arc::clone(&peers), + listen_addr: "0.0.0.0:9444".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()), + inbound_limiter: Arc::new(StdMutex::new(super::InboundConnectionLimiter::default())), + metrics: super::P2pMetricsCounters::default(), + }), + }; + let hello = ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: network + .inner + .node + .lock() + .await + .ledger() + .genesis_hash() + .to_string(), + listen_addr: Some("0.0.0.0:9444".to_string()), + node_id: Some(network.inner.node_id.clone()), + height: 0, + tip_hash: "tip".to_string(), + time_ms: 1_000, + }; + let mut known_peer = Some("142.132.164.59:9444".to_string()); + + super::process_hello( + &network, + "142.132.164.59:52144".parse().unwrap(), + &mut known_peer, + hello, + ) + .await + .unwrap(); + + assert_eq!(network.metrics().self_peer_rejections, 1); + assert!(known_peer.is_none()); + assert!(peers.lock().await.addresses().is_empty()); +} + +async fn spawn_hello_server(hello: ProtocolHello) -> SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let node_id = hello.node_id.clone(); + let (reader, mut writer) = stream.into_split(); + let line = serde_json::to_string(&GossipEnvelope::Hello(hello)).unwrap(); + let _ = writer.write_all(line.as_bytes()).await; + let _ = writer.write_all(b"\n").await; + let Some(node_id) = node_id else { + return; + }; + let mut reader = super::LimitedLineReader::new(reader); + let Ok(Some(line)) = reader.read_line().await else { + return; + }; + let Ok(GossipEnvelope::PeerVerificationChallenge { address, nonce }) = + super::parse_envelope(&line) + else { + return; + }; + let Some(response) = + super::peer_verification_response_for_node_id(&node_id, &address, &nonce) + else { + return; + }; + let line = serde_json::to_string(&response).unwrap(); + let _ = writer.write_all(line.as_bytes()).await; + let _ = writer.write_all(b"\n").await; + }); + addr +} + +async fn spawn_verification_responder(node_id: String) -> SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let (reader, mut writer) = stream.into_split(); + let mut reader = super::LimitedLineReader::new(reader); + let Ok(Some(line)) = reader.read_line().await else { + return; + }; + let Ok(GossipEnvelope::PeerVerificationChallenge { address, nonce }) = + super::parse_envelope(&line) + else { + return; + }; + let Some(response) = + super::peer_verification_response_for_node_id(&node_id, &address, &nonce) + else { + return; + }; + let line = serde_json::to_string(&response).unwrap(); + let _ = writer.write_all(line.as_bytes()).await; + let _ = writer.write_all(b"\n").await; + }); + addr +} diff --git a/src/adapters/p2p/writer.rs b/src/adapters/p2p/writer.rs @@ -0,0 +1,33 @@ +use anyhow::Result; +use tokio::{io::AsyncWriteExt, net::tcp::OwnedWriteHalf}; + +use crate::app::GossipEnvelope; + +use super::MAX_GOSSIP_LINE_BYTES; + +pub(super) async fn write_payload( + writer: &mut OwnedWriteHalf, + payload: &[GossipEnvelope], +) -> Result<()> { + for envelope in payload { + write_envelope(writer, envelope).await?; + } + Ok(()) +} + +pub(super) async fn write_envelope( + writer: &mut OwnedWriteHalf, + envelope: &GossipEnvelope, +) -> Result<()> { + let line = serde_json::to_string(envelope)?; + if line.len() > MAX_GOSSIP_LINE_BYTES { + anyhow::bail!( + "p2p message is {} bytes, exceeding {} byte limit", + line.len(), + MAX_GOSSIP_LINE_BYTES + ); + } + writer.write_all(line.as_bytes()).await?; + writer.write_all(b"\n").await?; + Ok(()) +} diff --git a/src/app.rs b/src/app.rs @@ -7,22 +7,32 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use anyhow::{Context, Result, bail}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use tokio::sync::Mutex; -use crate::adapters::config_store::{ - DEFAULT_POW_MINING_WORKERS, MAX_POW_MINING_WORKERS, clamp_pow_mining_workers, -}; use crate::domain::{ - Amount, BlindedReveal, BlindedTransaction, Block, BuiltBlindedTransaction, BurnLeaderRank, - ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE, Ledger, - MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MINE_ACTIONS_PER_ANCHOR_LIMIT, MINE_FINALIZER_FEE, - MINE_REWARD, OutPoint, OwnedBlindedTransaction, PreparedBlock, RevealBundle, StratumMineShare, - StratumMineTemplate, Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, - run_vdf, + Amount, BlindedReveal, BlindedTransaction, BuiltBlindedTransaction, Ledger, + MINE_ACTIONS_PER_ANCHOR_LIMIT, PreparedBlock, RevealBundle, Transaction, run_vdf, +}; + +mod automatic_mining; +mod gossip; +mod helpers; +mod in_memory_network; +mod ledger_view; +mod node_lifecycle; +mod owned_blinded; +mod peer_book; +mod receive; +mod status; +mod types; +mod wallet; +pub use in_memory_network::InMemoryNetwork; +pub use peer_book::{PeerBook, PeerDirection, PeerInfo}; +pub use types::{ + AutoMineOutcome, AutoMinePlan, BlockInventory, ExternalMineJob, FeeEstimate, GossipEnvelope, + LaunchProfileStatus, MiningStatus, NodeConfig, NodeStatus, ProtocolHello, StratumStatus, }; +use wallet::NodeWallet; pub type SharedNode = Arc<Mutex<NodeCore>>; pub type SharedPeerBook = Arc<Mutex<PeerBook>>; @@ -52,198 +62,6 @@ pub fn debug_logging_enabled() -> bool { DEBUG_LOGGING.load(Ordering::Relaxed) } -#[derive(Clone, Debug)] -pub struct NodeConfig { - pub wallet: Wallet, - pub genesis_allocations: BTreeMap<String, Amount>, - pub vdf_rounds: u64, - pub burn_per_block: Amount, - pub burn_fee: Amount, - pub pow_mining_workers: u8, - pub recovery_vdf_top_rank_percent: u8, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct FeeEstimate { - pub bytes: usize, - pub fee: Amount, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ExternalMineJob { - pub template: StratumMineTemplate, -} - -#[derive(Clone, Debug)] -enum NodeWallet { - Unlocked(Wallet), - Locked { address: String }, -} - -impl NodeWallet { - fn address(&self) -> &str { - match self { - Self::Unlocked(wallet) => wallet.address(), - Self::Locked { address } => address, - } - } - - fn unlocked(&self) -> Result<&Wallet> { - match self { - Self::Unlocked(wallet) => Ok(wallet), - Self::Locked { .. } => bail!("wallet is locked"), - } - } - - fn is_locked(&self) -> bool { - matches!(self, Self::Locked { .. }) - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -pub enum GossipEnvelope { - Hello(ProtocolHello), - PeerStatus { - height: u64, - tip_hash: String, - #[serde(default)] - time_ms: u64, - }, - ChainSnapshotRequest, - BlockRangeRequest { - from_height: u64, - limit: usize, - }, - BlockRequest { - hashes: Vec<String>, - }, - Inventory { - blocks: Vec<BlockInventory>, - }, - BlindedTransaction(BlindedTransaction), - BlindedTransactions { - transactions: Vec<BlindedTransaction>, - }, - MineAction(Transaction), - MineActions { - transactions: Vec<Transaction>, - }, - BlindedReveal(BlindedReveal), - BlindedReveals { - reveals: Vec<BlindedReveal>, - }, - RevealBundle(RevealBundle), - RevealBundles { - bundles: Vec<RevealBundle>, - }, - Block(Block), - Blocks { - blocks: Vec<Block>, - }, - ChainSnapshot(ChainSnapshot), - PeerAnnouncement { - address: String, - #[serde(default)] - node_id: Option<String>, - }, - PeerVerificationChallenge { - address: String, - nonce: String, - }, - PeerVerificationResponse { - address: String, - nonce: String, - node_id: String, - signature: String, - }, - PeerList { - peers: Vec<String>, - }, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct ProtocolHello { - pub protocol_version: u32, - pub network_id: String, - pub genesis_hash: String, - pub listen_addr: Option<String>, - #[serde(default)] - pub node_id: Option<String>, - pub height: u64, - pub tip_hash: String, - #[serde(default)] - pub time_ms: u64, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct BlockInventory { - pub height: u64, - pub hash: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct NodeStatus { - pub app_version: String, - pub wallet_address: String, - pub wallet_balance: Amount, - pub wallet_locked: bool, - pub launch_profile: LaunchProfileStatus, - pub mining: MiningStatus, - pub stratum: StratumStatus, - pub chain: ChainStatus, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct LaunchProfileStatus { - pub profile_id: String, - pub profile_hash: String, - pub ticket_maturity_delay_heights: u64, - pub ticket_expiry_window_heights: u64, - pub mine_difficulty_bits: u32, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct MiningStatus { - pub automatic: bool, - pub pow_mining_enabled: bool, - pub pow_mining_workers: u8, - pub max_pow_mining_workers: u8, - pub burn_per_block: Amount, - pub automatic_burn_fee: Amount, - pub automatic_pow_mine_fee: Amount, - pub last_auto_pow_mine_anchor: Option<String>, - pub last_auto_pow_mine_status: Option<String>, - pub vdf_rounds: u64, - pub vdf_target_block_ms: u64, - pub current_leader: Option<String>, - pub wallet_is_current_leader: bool, - pub last_auto_burn_height: Option<u64>, - pub recovery_vdf_top_rank_percent: u8, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct StratumStatus { - pub enabled: bool, - pub listen_addr: Option<String>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct AutoMineOutcome { - pub pow_mined: Option<Transaction>, - pub burned: Option<Transaction>, - pub block: Option<Block>, - pub skipped_reason: Option<String>, -} - -#[derive(Clone, Debug)] -pub struct AutoMinePlan { - pub pow_mined: Option<Transaction>, - pub burned: Option<Transaction>, - pub work: Option<PreparedBlock>, - pub skipped_reason: Option<String>, -} - #[derive(Clone, Debug, Eq, PartialEq)] struct AutoPowMineCursor { anchor: String, @@ -277,2969 +95,146 @@ pub struct NodeCore { outbox: Vec<GossipEnvelope>, } -impl NodeCore { - pub fn new(config: NodeConfig) -> Self { - let ledger = Ledger::new(config.genesis_allocations, config.vdf_rounds); - let mut node = Self::from_ledger_with_burn_fee( - config.wallet, - ledger, - config.burn_per_block, - config.burn_fee, - ); - node.set_pow_mining_workers(config.pow_mining_workers); - node.set_recovery_vdf_top_rank_percent(config.recovery_vdf_top_rank_percent); - node - } - - pub fn from_ledger(wallet: Wallet, ledger: Ledger, burn_per_block: Amount) -> Self { - Self::from_ledger_with_burn_fee(wallet, ledger, burn_per_block, DEFAULT_FEE_PER_BYTE) - } - - pub fn from_locked_wallet_address( - address: impl Into<String>, - ledger: Ledger, - automatic_mining_enabled: bool, - burn_per_block: Amount, - burn_fee: Amount, - ) -> Self { - Self::from_node_wallet_with_burn_fee_and_enabled( - NodeWallet::Locked { - address: address.into(), - }, - ledger, - automatic_mining_enabled, - burn_per_block, - burn_fee, - DEFAULT_POW_MINING_WORKERS, - 100, - ) - } - - pub fn from_ledger_with_burn_fee( - wallet: Wallet, - ledger: Ledger, - burn_per_block: Amount, - burn_fee: Amount, - ) -> Self { - Self::from_ledger_with_burn_fee_and_enabled( - wallet, - ledger, - burn_per_block > 0, - burn_per_block, - burn_fee, - ) - } - - pub fn from_ledger_with_burn_fee_and_enabled( - wallet: Wallet, - ledger: Ledger, - automatic_mining_enabled: bool, - burn_per_block: Amount, - burn_fee: Amount, - ) -> Self { - Self::from_node_wallet_with_burn_fee_and_enabled( - NodeWallet::Unlocked(wallet), - ledger, - automatic_mining_enabled, - burn_per_block, - burn_fee, - DEFAULT_POW_MINING_WORKERS, - 100, - ) - } - - fn from_node_wallet_with_burn_fee_and_enabled( - wallet: NodeWallet, - ledger: Ledger, - automatic_mining_enabled: bool, - burn_per_block: Amount, - burn_fee: Amount, - pow_mining_workers: u8, - recovery_vdf_top_rank_percent: u8, - ) -> Self { - Self { - wallet, - ledger, - automatic_mining_enabled, - pow_mining_enabled: false, - pow_mining_workers: clamp_pow_mining_workers(pow_mining_workers), - burn_per_block, - burn_fee, - recovery_vdf_top_rank_percent: recovery_vdf_top_rank_percent.min(100), - last_auto_burn_height: None, - last_auto_anchor_burn_height: None, - last_auto_pow_mine_anchor: None, - last_auto_pow_mine_status: None, - auto_pow_mine_cursor: None, - owned_blinded_transactions: BTreeMap::new(), - owned_blinded_reveals: BTreeMap::new(), - owned_blinded_payloads: BTreeMap::new(), - owned_blinded_outbox_version: 0, - reveal_bundles: BTreeMap::new(), - equivocated_reveal_bundle_slots: BTreeSet::new(), - local_block_anchor_burn: None, - outbox: Vec::new(), - } - } - - pub fn wallet_address(&self) -> &str { - self.wallet.address() - } - - pub fn wallet_is_locked(&self) -> bool { - self.wallet.is_locked() - } - - pub fn replace_wallet(&mut self, wallet: Wallet) { - self.wallet = NodeWallet::Unlocked(wallet); - self.last_auto_burn_height = None; - self.last_auto_anchor_burn_height = None; - self.last_auto_pow_mine_anchor = None; - self.last_auto_pow_mine_status = None; - self.auto_pow_mine_cursor = None; - self.owned_blinded_transactions.clear(); - self.owned_blinded_reveals.clear(); - self.owned_blinded_payloads.clear(); - self.bump_owned_blinded_outbox_version(); - self.reveal_bundles.clear(); - self.equivocated_reveal_bundle_slots.clear(); - self.local_block_anchor_burn = None; - } - - pub fn ledger(&self) -> &Ledger { - &self.ledger - } - - pub(crate) fn clone_ledger(&self) -> Ledger { - self.ledger.clone() - } - - pub fn wallet_view_ledger(&self) -> Result<Ledger> { - let mut ledger = self.ledger.clone(); - self.queue_local_block_anchor(&mut ledger)?; - self.queue_owned_blinded_payloads(&mut ledger)?; - Ok(ledger) - } - - pub fn chain(&self) -> &[Block] { - self.ledger.chain() - } - - pub fn chain_height(&self) -> u64 { - self.ledger.height() - } - - pub fn has_real_chain(&self) -> bool { - !self.ledger.is_setup_placeholder() - } - - pub fn recent_blocks(&self, limit: usize) -> Vec<Block> { - self.ledger.recent_blocks(limit) - } - - pub fn blocks_before(&self, before_height: u64, limit: usize) -> Vec<Block> { - self.ledger.blocks_before(before_height, limit) - } - - pub fn burn_leader_ranks_for_block(&self, height: u64) -> Result<Vec<BurnLeaderRank>> { - self.ledger.burn_leader_ranks_for_block(height) - } - - pub fn pending_transactions(&self) -> Vec<Transaction> { - self.ledger.pending().to_vec() - } +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time is before unix epoch") + .as_millis() as u64 +} - pub fn pending_blinded_transactions(&self) -> Vec<BlindedTransaction> { - self.ledger.pending_blinded_transactions().to_vec() - } +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; - pub fn pending_blinded_reveals(&self) -> Vec<BlindedReveal> { - self.ledger.pending_blinded_reveals().to_vec() - } + use crate::domain::{ + GenesisBurn, Ledger, MICRO_IUNA, OutPoint, Transaction, VDF_TARGET_BLOCK_MS, Wallet, + run_vdf, + }; - pub fn pending_revealed_blinded_transactions( - &self, - ) -> Vec<crate::domain::RevealedBlindedTransaction> { - self.ledger.pending_revealed_blinded_transactions() - } + use super::{InMemoryNetwork, NodeCore, helpers::transaction_input_outpoints}; - pub fn owned_blinded_payloads(&self) -> Vec<Transaction> { - self.owned_blinded_payloads.values().cloned().collect() + fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet { + wallets + .iter() + .find(|wallet| wallet.address() == address) + .unwrap_or_else(|| panic!("missing wallet for address {address}")) } - pub fn owned_blinded_outbox_version(&self) -> u64 { - self.owned_blinded_outbox_version + fn queue_auto_pow_mine_action(node: &mut NodeCore) -> Transaction { + node.set_pow_mining_enabled(true); + (0..10_000) + .find_map(|timestamp| node.prepare_automatic_mining(timestamp).pow_mined) + .expect("test node should find a PoW mine action") } - pub fn owned_blinded_transactions(&self) -> Vec<OwnedBlindedTransaction> { - self.owned_blinded_transactions - .iter() - .filter_map(|(commitment, transaction)| { - let payload = self.owned_blinded_payloads.get(commitment)?; - let reveal = self.owned_blinded_reveals.get(commitment)?; - Some(OwnedBlindedTransaction { - transaction: transaction.clone(), - payload: payload.clone(), - reveal: reveal.clone(), - }) - }) - .collect() + fn assert_block_has_mine_action(block: &crate::domain::Block) { + assert!( + block + .transactions + .iter() + .any(|transaction| matches!(transaction, Transaction::Mine { .. })), + "block {} should include a mine action", + block.height + ); } - pub fn mark_owned_blinded_outbox_persisted(&mut self, version: u64) { - if self.owned_blinded_outbox_version == version { - self.owned_blinded_outbox_version = 0; - } - } + #[test] + fn automatic_finalization_includes_reveals_with_two_nodes_and_one_burner() { + let finalizer = Wallet::from_seed("single-burner-reveal-finalizer"); + let wallet = Wallet::from_seed("single-burner-reveal-wallet"); + let mut allocations = BTreeMap::new(); + allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + let mut network = InMemoryNetwork::default(); + network.insert( + "finalizer", + NodeCore::from_ledger_with_burn_fee_and_enabled( + finalizer.clone(), + ledger.clone(), + true, + MICRO_IUNA / 10, + 1, + ), + ); + network.insert("wallet", NodeCore::from_ledger(wallet.clone(), ledger, 0)); - pub fn restore_owned_blinded_transactions( - &mut self, - transactions: Vec<OwnedBlindedTransaction>, - ) -> Result<()> { - self.owned_blinded_transactions.clear(); - self.owned_blinded_reveals.clear(); - self.owned_blinded_payloads.clear(); - for owned in transactions { - let commitment = owned.transaction.commitment.clone(); - if owned.reveal.commitment != commitment { - continue; - } - if self.ledger.has_blinded_reveal(&commitment) - || !self.ledger.has_unrevealed_blinded_transaction(&commitment) - && owned.transaction.expires_at_height <= self.ledger.height().saturating_add(1) - { - continue; - } - if !self.ledger.has_blinded_transaction(&commitment) { - match self - .ledger - .submit_blinded_transaction(owned.transaction.clone()) - { - Ok(true) => self.outbox.push(GossipEnvelope::BlindedTransaction( - owned.transaction.clone(), - )), - Ok(false) => {} - Err(_) => continue, - } - } - if !self.ledger.has_unrevealed_blinded_transaction(&commitment) { - continue; - } - self.owned_blinded_transactions - .insert(commitment.clone(), owned.transaction); - self.owned_blinded_payloads - .insert(commitment.clone(), owned.payload); - self.owned_blinded_reveals - .insert(commitment.clone(), owned.reveal); - } - self.publish_owned_reveals_for_active_commits()?; - self.bump_owned_blinded_outbox_version(); - Ok(()) - } + let blinded = network + .node_mut("wallet") + .unwrap() + .blinded_burn_with_fee(MICRO_IUNA / 10, 1, 4) + .unwrap(); + network.deliver_until_idle().unwrap(); - pub fn mempool_gossip(&mut self) -> Vec<GossipEnvelope> { - let _ = self.publish_reveal_bundle_for_next_block(); - let mut gossip = Vec::new(); - let mine_actions = self - .ledger - .pending() - .iter() - .filter(|transaction| matches!(transaction, Transaction::Mine { .. })) - .cloned() - .collect::<Vec<_>>(); - gossip.extend(mine_actions.chunks(TRANSACTION_BATCH_LIMIT).map(|chunk| { - GossipEnvelope::MineActions { - transactions: chunk.to_vec(), - } - })); - gossip.extend( - self.ledger - .pending_blinded_transactions() - .chunks(TRANSACTION_BATCH_LIMIT) - .map(|chunk| GossipEnvelope::BlindedTransactions { - transactions: chunk.to_vec(), - }), + let commit_plan = network + .node_mut("finalizer") + .unwrap() + .prepare_automatic_finalization(1); + let commit_work = commit_plan + .work + .expect("finalizer should prepare commit block"); + let commit_vdf = run_vdf(commit_work.vdf_seed(), commit_work.vdf_rounds()); + let commit_block = network + .node_mut("finalizer") + .unwrap() + .complete_prepared_block_at(commit_work, commit_vdf, 1) + .unwrap(); + let wallet_commitment = blinded.commitment.clone(); + assert!( + commit_block + .blinded_transactions + .iter() + .any(|transaction| transaction.commitment == wallet_commitment), + "first block should commit the wallet's blinded burn" ); - gossip.extend( - self.ledger + network.deliver_until_idle().unwrap(); + assert!( + network + .node("finalizer") + .unwrap() + .ledger() .pending_blinded_reveals() - .chunks(TRANSACTION_BATCH_LIMIT) - .map(|chunk| GossipEnvelope::BlindedReveals { - reveals: chunk.to_vec(), - }), + .iter() + .any(|reveal| reveal.commitment == wallet_commitment), + "finalizer should have received the reveal before building the next block" ); - gossip.extend( - self.usable_reveal_bundles() - .chunks(TRANSACTION_BATCH_LIMIT) - .map(|chunk| GossipEnvelope::RevealBundles { - bundles: chunk.to_vec(), - }), + assert!( + network + .node("wallet") + .unwrap() + .ledger() + .pending_blinded_reveals() + .iter() + .any(|reveal| reveal.commitment == wallet_commitment), + "wallet node should also keep the reveal in its mempool" ); - gossip - } - - pub fn chain_snapshot(&self) -> ChainSnapshot { - self.ledger.snapshot() - } - - pub fn hello(&self, listen_addr: Option<String>, node_id: Option<String>) -> GossipEnvelope { - let status = self.ledger.status(); - GossipEnvelope::Hello(ProtocolHello { - protocol_version: PROTOCOL_VERSION, - network_id: NETWORK_ID.to_string(), - genesis_hash: self.ledger.genesis_hash().to_string(), - listen_addr, - node_id, - height: status.height, - tip_hash: status.tip_hash, - time_ms: now_ms(), - }) - } - - pub fn peer_status(&self) -> GossipEnvelope { - let status = self.ledger.status(); - GossipEnvelope::PeerStatus { - height: status.height, - tip_hash: status.tip_hash, - time_ms: now_ms(), - } - } - - pub fn blocks_from(&self, from_height: u64, limit: usize) -> Vec<Block> { - self.ledger.blocks_from(from_height, limit) - } - - pub fn blocks_by_hash(&self, hashes: &[String]) -> Vec<Block> { - hashes - .iter() - .filter_map(|hash| self.ledger.block_by_hash(hash)) - .collect() - } - - pub fn missing_inventory_requests(&self, blocks: &[BlockInventory]) -> Vec<GossipEnvelope> { - let local_height = self.ledger.height(); - let first_height_gap = blocks - .iter() - .filter(|block| !self.ledger.has_block(&block.hash)) - .filter(|block| block.height > local_height + 1) - .map(|block| block.height) - .min(); - let missing_blocks = blocks - .iter() - .filter(|block| !self.ledger.has_block(&block.hash)) - .filter(|block| first_height_gap.is_none_or(|gap| block.height < gap)) - .map(|block| block.hash.clone()) - .collect::<Vec<_>>(); - - let mut requests = Vec::new(); - if !missing_blocks.is_empty() { - requests.push(GossipEnvelope::BlockRequest { - hashes: missing_blocks, - }); - } - if first_height_gap.is_some() { - requests.push(GossipEnvelope::BlockRangeRequest { - from_height: local_height + 1, - limit: BLOCK_REQUEST_LIMIT, - }); - } - requests - } - pub fn status(&self) -> NodeStatus { - let chain = self.ledger.status(); - let launch_profile = self.ledger.launch_profile(); - let current_leader = self.ledger.expected_leader_for_next_block(); - let wallet_is_current_leader = current_leader - .as_deref() - .is_none_or(|leader| leader == self.wallet.address()); + let reveal_plan = network + .node_mut("finalizer") + .unwrap() + .prepare_automatic_finalization(2); + let reveal_work = reveal_plan + .work + .expect("finalizer should prepare reveal block"); + let reveal_vdf = run_vdf(reveal_work.vdf_seed(), reveal_work.vdf_rounds()); + let reveal_block = network + .node_mut("finalizer") + .unwrap() + .complete_prepared_block_at(reveal_work, reveal_vdf, 2) + .unwrap(); - NodeStatus { - app_version: env!("CARGO_PKG_VERSION").to_string(), - wallet_address: self.wallet.address().to_string(), - wallet_balance: self.wallet_projected_balance(), - wallet_locked: self.wallet.is_locked(), - launch_profile: LaunchProfileStatus { - profile_id: launch_profile.profile_id.clone(), - profile_hash: chain.launch_profile_hash.clone(), - ticket_maturity_delay_heights: launch_profile.ticket_maturity_delay_heights, - ticket_expiry_window_heights: launch_profile.ticket_expiry_window_heights, - mine_difficulty_bits: launch_profile.mine_difficulty_bits, - }, - mining: MiningStatus { - automatic: self.automatic_mining_enabled, - pow_mining_enabled: self.pow_mining_enabled, - pow_mining_workers: self.pow_mining_workers, - max_pow_mining_workers: MAX_POW_MINING_WORKERS, - burn_per_block: self.burn_per_block, - automatic_burn_fee: self.burn_fee, - automatic_pow_mine_fee: MINE_FINALIZER_FEE, - last_auto_pow_mine_anchor: self.last_auto_pow_mine_anchor.clone(), - last_auto_pow_mine_status: if self.pow_mining_enabled && !self.has_real_chain() { - Some("waiting for a real chain before PoW mining can start".to_string()) - } else { - self.last_auto_pow_mine_status.clone() - }, - vdf_rounds: self.ledger.vdf_rounds(), - vdf_target_block_ms: VDF_TARGET_BLOCK_MS, - current_leader, - wallet_is_current_leader, - last_auto_burn_height: self.last_auto_burn_height, - recovery_vdf_top_rank_percent: self.recovery_vdf_top_rank_percent, - }, - stratum: StratumStatus { - enabled: false, - listen_addr: None, - }, - chain, - } - } - - fn wallet_projected_balance(&self) -> Amount { - let address = self.wallet.address(); - let mut balance = self.ledger.balance_of(address); - let confirmed_outputs = self - .ledger - .utxos_for_address(address) - .into_iter() - .map(|(outpoint, output)| (outpoint, output.amount)) - .collect::<BTreeMap<_, _>>(); - - for (commitment, payload) in &self.owned_blinded_payloads { - if !self.ledger.has_unrevealed_blinded_transaction(commitment) { - continue; - } - let output_total = transaction_output_total_for_address(payload, address); - if self.ledger.has_active_blinded_transaction(commitment) { - balance = balance.saturating_add(output_total); - } else { - let input_total = - transaction_input_total_from_outputs(payload, address, &confirmed_outputs); - balance = balance - .saturating_sub(input_total) - .saturating_add(output_total); - } - } - - if let Some((height, burn)) = &self.local_block_anchor_burn { - if *height == self.ledger.height() && !self.ledger.has_transaction(burn.signature()) { - let output_total = transaction_output_total_for_address(burn, address); - let input_total = - transaction_input_total_from_outputs(burn, address, &confirmed_outputs); - balance = balance - .saturating_sub(input_total) - .saturating_add(output_total); - } - } - - balance - } - - pub fn set_burn_per_block(&mut self, amount: Amount) -> Result<Option<Transaction>> { - self.set_automatic_burn(amount, self.burn_fee) - } - - pub fn set_automatic_burn( - &mut self, - amount: Amount, - fee: Amount, - ) -> Result<Option<Transaction>> { - self.set_automatic_burn_settings(amount > 0, amount, fee) - } - - pub fn set_automatic_burn_settings( - &mut self, - enabled: bool, - amount: Amount, - fee: Amount, - ) -> Result<Option<Transaction>> { - let was_disabled = !self.automatic_mining_enabled || self.burn_per_block == 0; - self.automatic_mining_enabled = enabled; - self.burn_per_block = amount; - self.burn_fee = fee; - if was_disabled && enabled && amount > 0 { - self.last_auto_burn_height = None; - self.last_auto_anchor_burn_height = None; - } - self.prepare_automatic_burn(now_ms()) - } - - pub fn set_pow_mining_enabled(&mut self, enabled: bool) { - self.pow_mining_enabled = enabled; - self.auto_pow_mine_cursor = None; - if !enabled { - self.last_auto_pow_mine_anchor = None; - self.last_auto_pow_mine_status = None; - } else { - self.last_auto_pow_mine_status = - Some("waiting for next automatic PoW mining tick".to_string()); - } - } - - pub fn pow_mining_enabled(&self) -> bool { - self.pow_mining_enabled - } - - pub fn set_pow_mining_workers(&mut self, workers: u8) { - let workers = clamp_pow_mining_workers(workers); - if self.pow_mining_workers != workers { - self.pow_mining_workers = workers; - self.auto_pow_mine_cursor = None; - if self.pow_mining_enabled { - self.last_auto_pow_mine_status = - Some("waiting for next automatic PoW mining tick".to_string()); - } - } - } - - pub fn pow_mining_workers(&self) -> u8 { - self.pow_mining_workers - } - - pub fn set_recovery_vdf_top_rank_percent(&mut self, percent: u8) { - self.recovery_vdf_top_rank_percent = percent.min(100); - } - - pub fn burn(&mut self, amount: Amount) -> Result<Transaction> { - self.burn_with_fee(amount, 0) - } - - pub fn burn_with_fee(&mut self, amount: Amount, fee: Amount) -> Result<Transaction> { - let tx = self - .wallet_build_ledger()? - .build_burn(self.wallet.unlocked()?, amount, fee)?; - self.submit_transaction_as_owned_blinded(tx) - } - - pub fn burn_with_fee_rate( - &mut self, - amount: Amount, - fee_per_byte: Amount, - ) -> Result<(Transaction, FeeEstimate)> { - 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)) - } - - pub fn blinded_burn_with_fee( - &mut self, - amount: Amount, - fee: Amount, - expires_at_height: u64, - ) -> Result<BlindedTransaction> { - let built = self.wallet_build_ledger()?.build_blinded_burn( - self.wallet.unlocked()?, - amount, - fee, - expires_at_height, - )?; - self.submit_owned_blinded_transaction(built) - } - - pub fn estimate_burn_fee(&self, amount: Amount, fee_per_byte: Amount) -> Result<FeeEstimate> { - self.build_burn_with_fee_rate(amount, fee_per_byte) - .map(|(_, estimate)| estimate) - } - - pub fn transfer(&mut self, to: impl Into<String>, amount: Amount) -> Result<Transaction> { - self.transfer_with_fee(to, amount, DEFAULT_TRANSACTION_FEE) - } - - pub fn transfer_with_fee( - &mut self, - to: impl Into<String>, - amount: Amount, - fee: Amount, - ) -> Result<Transaction> { - 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( - &mut self, - to: impl Into<String>, - amount: Amount, - fee: Amount, - outpoints: &[OutPoint], - ) -> Result<Transaction> { - let tx = self.wallet_build_ledger()?.build_transfer_with_inputs( - self.wallet.unlocked()?, - to, - amount, - fee, - outpoints, - )?; - self.submit_transaction_as_owned_blinded(tx) - } - - pub fn blinded_transfer_with_fee( - &mut self, - to: impl Into<String>, - amount: Amount, - fee: Amount, - expires_at_height: u64, - ) -> Result<BlindedTransaction> { - let built = self.wallet_build_ledger()?.build_blinded_transfer( - self.wallet.unlocked()?, - to, - amount, - fee, - expires_at_height, - )?; - self.submit_owned_blinded_transaction(built) - } - - pub fn transfer_with_fee_rate( - &mut self, - to: impl Into<String>, - amount: Amount, - 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)?; - let tx = built.payload.clone(); - self.submit_owned_blinded_transaction(built)?; - Ok((tx, estimate)) - } - - pub fn estimate_transfer_fee( - &self, - to: impl Into<String>, - amount: Amount, - fee_per_byte: Amount, - outpoints: &[OutPoint], - ) -> Result<FeeEstimate> { - self.build_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints) - .map(|(_, estimate)| estimate) - } - - pub fn mine_pow_reward(&mut self) -> Result<Transaction> { - let (tx, _) = self.build_mine_estimate()?; - self.submit_public_mine_action(tx) - } - - pub fn estimate_mine_fee(&self, _fee_per_byte: Amount) -> Result<FeeEstimate> { - self.build_mine_estimate().map(|(_, estimate)| estimate) - } - - pub fn external_mine_job( - &self, - recipient: impl Into<String>, - salt: u64, - ) -> Result<ExternalMineJob> { - let recipient = recipient.into(); - let tip = self - .chain() - .last() - .context("cannot build mine job without a chain tip")?; - let difficulty_bits = self.ledger.current_mine_difficulty_bits(); - Ok(ExternalMineJob { - template: self.ledger.stratum_mine_template( - recipient, - &tip.hash, - salt, - difficulty_bits, - )?, - }) - } - - pub fn submit_external_mine( - &mut self, - recipient: impl Into<String>, - template: StratumMineTemplate, - share: StratumMineShare, - ) -> Result<Transaction> { - let tx = self.ledger.build_stratum_mine(template, share)?; - let recipient = recipient.into(); - if tx.to() != Some(recipient.as_str()) { - bail!("submitted mine recipient does not match worker"); - } - self.submit_public_mine_action(tx) - } - - pub fn receive_transaction(&mut self, tx: Transaction) -> Result<TransactionSubmitOutcome> { - let outcome = self.ledger.submit_transaction_with_outcome(tx.clone())?; - Ok(outcome) - } - - pub fn receive_mine_action(&mut self, tx: Transaction) -> Result<()> { - if !matches!(tx, Transaction::Mine { .. }) { - bail!("only mine actions may be gossiped as plaintext"); - } - if self - .ledger - .submit_transaction_with_outcome(tx.clone())? - .added() - { - self.outbox.push(GossipEnvelope::MineAction(tx)); - } - Ok(()) - } - - pub fn receive_blinded_transaction(&mut self, tx: BlindedTransaction) -> Result<()> { - if self.blinded_transaction_conflicts_with_local_anchor(&tx) { - return Ok(()); - } - if self.ledger.submit_blinded_transaction(tx.clone())? { - self.outbox.push(GossipEnvelope::BlindedTransaction(tx)); - } - Ok(()) - } - - fn blinded_transaction_conflicts_with_local_anchor(&self, tx: &BlindedTransaction) -> bool { - let Some((height, burn)) = &self.local_block_anchor_burn else { - return false; - }; - if *height != self.ledger.height() || self.ledger.has_transaction(burn.signature()) { - return false; - } - let anchor_inputs = transaction_input_outpoints(burn); - tx.inputs - .iter() - .any(|input| anchor_inputs.contains(&input.outpoint)) - } - - pub fn receive_blinded_reveal(&mut self, reveal: BlindedReveal) -> Result<()> { - self.receive_blinded_reveal_without_bundle_publish(reveal)?; - Ok(()) - } - - fn receive_blinded_reveal_without_bundle_publish( - &mut self, - reveal: BlindedReveal, - ) -> Result<bool> { - if self.ledger.submit_blinded_reveal(reveal.clone())? { - self.outbox.push(GossipEnvelope::BlindedReveal(reveal)); - return Ok(true); - } - Ok(false) - } - - pub fn receive_reveal_bundle(&mut self, bundle: RevealBundle) -> Result<()> { - let next_height = self.ledger.height().saturating_add(1); - if bundle.height <= self.ledger.height() { - return Ok(()); - } - if bundle.height > next_height { - return Ok(()); - } - let key = (bundle.height, bundle.slot); - if self.equivocated_reveal_bundle_slots.contains(&key) { - return Ok(()); - } - if let Some(existing) = self.reveal_bundles.get(&key) { - if existing.canonical() != bundle.canonical() { - self.reveal_bundles.remove(&key); - self.equivocated_reveal_bundle_slots.insert(key); - } - return Ok(()); - } - self.ledger - .validate_next_block_reveal_bundles(vec![bundle.clone()])?; - self.reveal_bundles.insert(key, bundle.clone()); - self.outbox.push(GossipEnvelope::RevealBundle(bundle)); - Ok(()) - } - - fn usable_reveal_bundles(&self) -> Vec<RevealBundle> { - let next_height = self.ledger.height().saturating_add(1); - let mut bundles = self - .reveal_bundles - .iter() - .filter(|((height, slot), _)| { - *height == next_height - && !self - .equivocated_reveal_bundle_slots - .contains(&(*height, *slot)) - }) - .map(|(_, bundle)| bundle.clone()) - .collect::<Vec<_>>(); - bundles.sort_by_key(|bundle| bundle.slot); - bundles - } - - fn prune_reveal_bundles(&mut self) { - let height = self.ledger.height(); - self.reveal_bundles - .retain(|(bundle_height, _), _| *bundle_height > height); - self.equivocated_reveal_bundle_slots - .retain(|(bundle_height, _)| *bundle_height > height); - } - - fn publish_reveal_bundle_for_next_block(&mut self) -> Result<()> { - let wallet = match &self.wallet { - NodeWallet::Unlocked(wallet) => wallet, - NodeWallet::Locked { .. } => return Ok(()), - }; - let Some(bundle) = self.ledger.build_reveal_bundle(wallet)? else { - return Ok(()); - }; - let key = (bundle.height, bundle.slot); - if self.equivocated_reveal_bundle_slots.contains(&key) - || self.reveal_bundles.contains_key(&key) - { - return Ok(()); - } - self.ledger - .validate_next_block_reveal_bundles(vec![bundle.clone()])?; - self.reveal_bundles.insert(key, bundle.clone()); - self.outbox.push(GossipEnvelope::RevealBundle(bundle)); - Ok(()) - } - - fn submit_owned_blinded_transaction( - &mut self, - built: BuiltBlindedTransaction, - ) -> Result<BlindedTransaction> { - let transaction = built.transaction; - self.owned_blinded_transactions - .insert(transaction.commitment.clone(), transaction.clone()); - self.owned_blinded_payloads - .insert(transaction.commitment.clone(), built.payload); - self.owned_blinded_reveals - .insert(transaction.commitment.clone(), built.reveal); - self.bump_owned_blinded_outbox_version(); - if self - .ledger - .submit_blinded_transaction(transaction.clone())? - { - self.outbox - .push(GossipEnvelope::BlindedTransaction(transaction.clone())); - } - Ok(transaction) - } - - fn submit_public_mine_action(&mut self, tx: Transaction) -> Result<Transaction> { - if !matches!(tx, Transaction::Mine { .. }) { - bail!("only mine actions may be submitted as public mempool transactions"); - } - if self - .ledger - .submit_transaction_with_outcome(tx.clone())? - .added() - { - self.outbox.push(GossipEnvelope::MineAction(tx.clone())); - } - Ok(tx) - } - - fn submit_transaction_as_owned_blinded(&mut self, tx: Transaction) -> Result<Transaction> { - let built = self.ledger.build_blinded_transaction( - self.wallet.unlocked()?, - 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.get(&transaction.commitment) else { - continue; - }; - if self.ledger.submit_blinded_reveal(reveal.clone())? { - self.outbox - .push(GossipEnvelope::BlindedReveal(reveal.clone())); - } - } - Ok(()) - } - - fn publish_owned_reveals_for_active_commits(&mut self) -> Result<()> { - let reveals = self - .owned_blinded_reveals - .iter() - .filter(|(commitment, _)| self.ledger.has_active_blinded_transaction(commitment)) - .map(|(_, reveal)| reveal.clone()) - .collect::<Vec<_>>(); - for reveal in reveals { - if self.ledger.submit_blinded_reveal(reveal.clone())? { - self.outbox.push(GossipEnvelope::BlindedReveal(reveal)); - } - } - Ok(()) - } - - fn prune_owned_blinded_payloads_for_block(&mut self, block: &Block) { - let before = self.owned_blinded_transactions.len() - + self.owned_blinded_reveals.len() - + self.owned_blinded_payloads.len(); - for reveal in block.all_blinded_reveals() { - self.owned_blinded_transactions.remove(&reveal.commitment); - self.owned_blinded_reveals.remove(&reveal.commitment); - self.owned_blinded_payloads.remove(&reveal.commitment); - } - let unrevealed = self - .owned_blinded_transactions - .keys() - .filter(|commitment| self.ledger.has_unrevealed_blinded_transaction(commitment)) - .cloned() - .collect::<Vec<_>>(); - self.owned_blinded_transactions - .retain(|commitment, _| unrevealed.contains(commitment)); - self.owned_blinded_reveals - .retain(|commitment, _| unrevealed.contains(commitment)); - self.owned_blinded_payloads - .retain(|commitment, _| unrevealed.contains(commitment)); - let after = self.owned_blinded_transactions.len() - + self.owned_blinded_reveals.len() - + self.owned_blinded_payloads.len(); - if before != after { - self.bump_owned_blinded_outbox_version(); - } - } - - fn bump_owned_blinded_outbox_version(&mut self) { - self.owned_blinded_outbox_version = self.owned_blinded_outbox_version.saturating_add(1); - } - - 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()?; - self.build_blinded_burn_with_fee_rate_on_ledger(&ledger, amount, fee_per_byte) - } - - fn build_blinded_burn_with_fee_rate_on_ledger( - &self, - ledger: &Ledger, - amount: Amount, - fee_per_byte: Amount, - ) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { - let expires_at_height = self.default_blinded_transaction_expiry_height(); - converge_fee_by_byte(fee_per_byte, |fee| { - let tx = ledger.build_burn(self.wallet.unlocked()?, amount, fee)?; - ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height) - }) - } - - fn build_blinded_burn_with_fee_on_ledger( - &self, - ledger: &Ledger, - amount: Amount, - fee: Amount, - ) -> Result<BuiltBlindedTransaction> { - let expires_at_height = self.default_blinded_transaction_expiry_height(); - let tx = ledger.build_burn(self.wallet.unlocked()?, amount, fee)?; - ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height) - } - - fn build_transfer_with_fee_rate( - &self, - to: impl Into<String>, - amount: Amount, - 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| { - let tx = if outpoints.is_empty() { - ledger.build_transfer(self.wallet.unlocked()?, to.clone(), amount, fee) - } else { - ledger.build_transfer_with_inputs( - self.wallet.unlocked()?, - to.clone(), - amount, - fee, - outpoints, - ) - }?; - ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height) - }) - } - - fn build_mine_estimate(&self) -> Result<(Transaction, FeeEstimate)> { - let tx = self.ledger.build_mine(self.wallet.address())?; - Ok(( - tx.clone(), - FeeEstimate { - bytes: tx.economic_size_bytes(), - fee: tx.fee(), - }, - )) - } - - fn wallet_build_ledger(&self) -> Result<Ledger> { - let mut ledger = self.ledger.clone(); - self.reserve_local_block_anchor_inputs(&mut ledger)?; - self.queue_owned_blinded_payloads(&mut ledger)?; - Ok(ledger) - } - - fn wallet_anchor_build_ledger(&self) -> Result<Ledger> { - let mut ledger = self.ledger.clone(); - self.reserve_local_block_anchor_inputs(&mut ledger)?; - ledger.clear_pending_transactions(); - ledger.clear_pending_blinded_transactions(); - Ok(ledger) - } - - fn queue_owned_blinded_payloads(&self, ledger: &mut Ledger) -> Result<()> { - 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(()) - } - - fn queue_local_block_anchor(&self, ledger: &mut Ledger) -> Result<()> { - let Some((height, burn)) = &self.local_block_anchor_burn else { - return Ok(()); - }; - if *height == ledger.height() && !ledger.has_transaction(burn.signature()) { - let _ = ledger.submit_transaction(burn.clone())?; - } - Ok(()) - } - - fn reserve_local_block_anchor_inputs(&self, ledger: &mut Ledger) -> Result<()> { - let Some((height, burn)) = &self.local_block_anchor_burn else { - return Ok(()); - }; - if *height == ledger.height() && !ledger.has_transaction(burn.signature()) { - let _ = ledger.reserve_transaction_inputs(burn); - } - Ok(()) - } - - pub fn mine_one(&mut self) -> Result<Block> { - self.mine_one_at(now_ms()) - } - - pub fn automatic_mine_once(&mut self, timestamp_ms: u64) -> AutoMineOutcome { - let plan = self.prepare_automatic_mining(timestamp_ms); - let mut outcome = AutoMineOutcome { - pow_mined: plan.pow_mined, - burned: plan.burned, - block: None, - skipped_reason: plan.skipped_reason, - }; - - let Some(work) = plan.work else { - return outcome; - }; - let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); - match self.complete_prepared_block_at(work, vdf_output, timestamp_ms) { - Ok(block) => { - outcome.block = Some(block); - outcome.skipped_reason = None; - } - Err(error) => { - outcome.skipped_reason = Some(format!("{error:#}")); - } - } - - outcome - } - - pub fn prepare_automatic_mining(&mut self, timestamp_ms: u64) -> AutoMinePlan { - let mut plan = AutoMinePlan { - pow_mined: None, - burned: None, - work: None, - skipped_reason: None, - }; - - if self.wallet.is_locked() { - if self.pow_mining_enabled { - self.last_auto_pow_mine_status = Some("wallet is locked".to_string()); - } - return AutoMinePlan { - pow_mined: None, - burned: None, - work: None, - skipped_reason: Some("wallet is locked".to_string()), - }; - } - - let pow_error = match self.prepare_automatic_pow_mine() { - Ok(tx) => { - plan.pow_mined = tx; - None - } - Err(error) => { - let message = format!("automatic PoW mining failed: {error:#}"); - self.last_auto_pow_mine_status = Some(message.clone()); - Some(message) - } - }; - - if !self.automatic_mining_enabled { - plan.skipped_reason = - Some(pow_error.unwrap_or_else(|| "automatic mining is off".to_string())); - return plan; - } - - if let Some(error) = pow_error { - plan.skipped_reason = Some(error); - return plan; - } - - match self.prepare_automatic_burn(timestamp_ms) { - Ok(tx) => plan.burned = tx, - Err(error) => { - plan.skipped_reason = Some(format!("automatic burn failed: {error:#}")); - return plan; - } - } - - if let Err(error) = self.publish_reveal_bundle_for_next_block() { - plan.skipped_reason = Some(format!("{error:#}")); - return plan; - } - - let wallet_rank = self - .ledger - .finalizer_rank_for_next_block(self.wallet.address()); - if let Some(rank) = wallet_rank { - if !self.wallet_rank_runs_vdf(rank) { - plan.skipped_reason = Some(format!( - "wallet finalizer rank {rank} is outside the top {}% VDF threshold", - self.recovery_vdf_top_rank_percent - )); - return plan; - } - } else { - if self.should_prepare_recovery_vdf(timestamp_ms) { - match self.prepare_recovery_block_with_local_anchor(timestamp_ms) { - Ok(work) => { - plan.work = Some(work); - } - Err(error) => { - plan.skipped_reason = Some(format!("{error:#}")); - } - } - } else { - let selected_leader = self.ledger.expected_leader_for_next_block(); - plan.skipped_reason = selected_leader.map(|leader| { - format!("wallet is waiting for selected finalizer {leader} to finish the VDF") - }); - } - return plan; - } - - match self.prepare_next_block_with_local_anchor(timestamp_ms) { - Ok(work) => { - plan.work = Some(work); - } - Err(error) => { - plan.skipped_reason = Some(format!("{error:#}")); - } - } - - plan - } - - pub fn prepare_automatic_pow_mining(&mut self) -> Result<Option<Transaction>> { - if self.wallet.is_locked() { - if self.pow_mining_enabled { - self.last_auto_pow_mine_status = Some("wallet is locked".to_string()); - } - return Ok(None); - } - if self.pow_mining_enabled && !self.has_real_chain() { - self.last_auto_pow_mine_status = - Some("waiting for a real chain before PoW mining can start".to_string()); - self.auto_pow_mine_cursor = None; - return Ok(None); - } - - self.prepare_automatic_pow_mine() - } - - pub fn prepare_automatic_finalization(&mut self, timestamp_ms: u64) -> AutoMinePlan { - let mut plan = AutoMinePlan { - pow_mined: None, - burned: None, - work: None, - skipped_reason: None, - }; - - if self.wallet.is_locked() { - plan.skipped_reason = Some("wallet is locked".to_string()); - return plan; - } - - if !self.automatic_mining_enabled { - plan.skipped_reason = Some("automatic mining is off".to_string()); - return plan; - } - - match self.prepare_automatic_burn(timestamp_ms) { - Ok(tx) => plan.burned = tx, - Err(error) => { - plan.skipped_reason = Some(format!("automatic burn failed: {error:#}")); - return plan; - } - } - - if let Err(error) = self.publish_reveal_bundle_for_next_block() { - plan.skipped_reason = Some(format!("{error:#}")); - return plan; - } - - let wallet_rank = self - .ledger - .finalizer_rank_for_next_block(self.wallet.address()); - if let Some(rank) = wallet_rank { - if !self.wallet_rank_runs_vdf(rank) { - plan.skipped_reason = Some(format!( - "wallet finalizer rank {rank} is outside the top {}% VDF threshold", - self.recovery_vdf_top_rank_percent - )); - return plan; - } - } else { - if self.should_prepare_recovery_vdf(timestamp_ms) { - match self.prepare_recovery_block_with_local_anchor(timestamp_ms) { - Ok(work) => { - plan.work = Some(work); - } - Err(error) => { - plan.skipped_reason = Some(format!("{error:#}")); - } - } - } else { - let selected_leader = self.ledger.expected_leader_for_next_block(); - plan.skipped_reason = selected_leader.map(|leader| { - format!("wallet is waiting for selected finalizer {leader} to finish the VDF") - }); - } - return plan; - } - - match self.prepare_next_block_with_local_anchor(timestamp_ms) { - Ok(work) => { - plan.work = Some(work); - } - Err(error) => { - plan.skipped_reason = Some(format!("{error:#}")); - } - } - - plan - } - - pub fn record_automatic_pow_mining_error(&mut self, message: String) { - self.last_auto_pow_mine_status = Some(message); - } - - fn prepare_automatic_pow_mine(&mut self) -> Result<Option<Transaction>> { - if !self.pow_mining_enabled { - self.last_auto_pow_mine_status = None; - self.auto_pow_mine_cursor = None; - return Ok(None); - } - let anchor = self - .ledger - .chain() - .last() - .map(|block| block.hash.clone()) - .context("ledger has no anchor block")?; - if self.ledger.pending_mine_count_for_anchor(&anchor) >= MINE_ACTIONS_PER_ANCHOR_LIMIT { - self.last_auto_pow_mine_anchor = Some(anchor); - self.last_auto_pow_mine_status = - Some("waiting for next chain tip after queued mine actions".to_string()); - self.auto_pow_mine_cursor = None; - return Ok(None); - } - let wallet_address = self.wallet.address().to_string(); - let needs_cursor = self - .auto_pow_mine_cursor - .as_ref() - .is_none_or(|cursor| cursor.anchor != anchor); - if needs_cursor { - self.auto_pow_mine_cursor = Some(AutoPowMineCursor { - salt: auto_pow_salt(&wallet_address, &anchor), - anchor: anchor.clone(), - next_nonce: 0, - searched: 0, - }); - } - let cursor = self - .auto_pow_mine_cursor - .as_ref() - .context("automatic PoW cursor was not initialized")? - .clone(); - let outcome = self.wallet_build_ledger()?.search_mine( - wallet_address, - cursor.salt, - cursor.next_nonce, - AUTO_POW_NONCE_ATTEMPTS_PER_WORKER_TICK - .saturating_mul(u64::from(self.pow_mining_workers)), - )?; - let mut searched = outcome.attempts; - if let Some(cursor) = &mut self.auto_pow_mine_cursor { - if cursor.anchor == anchor { - cursor.next_nonce = outcome.next_nonce; - cursor.searched = cursor.searched.saturating_add(outcome.attempts); - searched = cursor.searched; - } - } - let Some(tx) = outcome.transaction else { - self.last_auto_pow_mine_status = Some(format!( - "searched {searched} PoW nonces for the current tip; no proof yet" - )); - return Ok(None); - }; - self.submit_public_mine_action(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>> { - let current_height = self.ledger.status().height; - if !self.automatic_mining_enabled { - return Ok(None); - } - let anchor_burn = self.prepare_automatic_anchor_burn(timestamp_ms)?; - if self.burn_per_block == 0 { - self.last_auto_burn_height = Some(current_height); - return Ok(anchor_burn); - } - if self.last_auto_burn_height == Some(current_height) { - return Ok(anchor_burn); - } - - let fee_per_byte = self.burn_fee; - let balance = self.ledger.balance_of(self.wallet.address()); - let ledger = self.wallet_build_ledger()?; - let best = self.best_automatic_burn_on_ledger(&ledger, fee_per_byte, balance); - let Some(tx) = best else { - self.last_auto_burn_height = Some(current_height); - return Ok(anchor_burn); - }; - let burn = tx.payload.clone(); - self.submit_owned_blinded_transaction(tx)?; - self.last_auto_burn_height = Some(current_height); - Ok(Some(burn)) - } - - fn prepare_automatic_anchor_burn(&mut self, timestamp_ms: u64) -> Result<Option<Transaction>> { - let current_height = self.ledger.status().height; - if !self.automatic_burn_needs_plaintext_anchor(timestamp_ms) { - return Ok(None); - } - if self - .local_block_anchor_burn - .as_ref() - .is_some_and(|(height, _)| *height == current_height) - { - return Ok(None); - } - if self.last_auto_anchor_burn_height == Some(current_height) { - return Ok(None); - } - - let ledger = self.wallet_anchor_build_ledger()?; - let wallet = self.wallet.unlocked()?; - let required = AUTO_BLOCK_ANCHOR_BURN_AMOUNT - .checked_add(AUTO_BLOCK_ANCHOR_BURN_FEE) - .context("automatic finalizer anchor burn amount plus fee overflows")?; - let outpoint = ledger - .available_utxos_for_address(wallet.address())? - .into_iter() - .filter(|(_, output)| output.amount >= required) - .min_by_key(|(_, output)| output.amount) - .map(|(outpoint, _)| outpoint); - let burn = match outpoint { - Some(outpoint) => ledger.build_burn_with_inputs( - wallet, - AUTO_BLOCK_ANCHOR_BURN_AMOUNT, - AUTO_BLOCK_ANCHOR_BURN_FEE, - &[outpoint], - ), - None => ledger.build_burn( - wallet, - AUTO_BLOCK_ANCHOR_BURN_AMOUNT, - AUTO_BLOCK_ANCHOR_BURN_FEE, - ), - }; - let burn = match burn { - Ok(burn) => burn, - Err(error) => { - self.last_auto_anchor_burn_height = Some(current_height); - return Err(error).context("automatic finalizer anchor burn failed"); - } - }; - self.local_block_anchor_burn = Some((current_height, burn.clone())); - self.last_auto_anchor_burn_height = Some(current_height); - Ok(Some(burn)) - } - - fn best_automatic_burn_on_ledger( - &self, - ledger: &Ledger, - fee_per_byte: Amount, - balance: Amount, - ) -> Option<BuiltBlindedTransaction> { - let target = self.burn_per_block.min(balance); - if target == 0 { - return None; - } - let exact_at_fee_rate = - self.build_blinded_burn_with_fee_rate_on_ledger(ledger, target, fee_per_byte); - if let Ok((built, estimate)) = exact_at_fee_rate { - if target - .checked_add(estimate.fee) - .is_some_and(|required| required <= balance) - { - return Some(built); - } - } - if self.burn_per_block <= balance { - let affordable_fee = balance.saturating_sub(target); - if let Ok(built) = - self.build_blinded_burn_with_fee_on_ledger(ledger, target, affordable_fee) - { - return Some(built); - } - } - - let mut low = 1; - let mut high = target; - let mut best = None; - while low <= high { - let amount = low + (high - low) / 2; - match self.build_blinded_burn_with_fee_rate_on_ledger(ledger, amount, fee_per_byte) { - Ok((built, estimate)) => { - let fits = amount - .checked_add(estimate.fee) - .is_some_and(|required| required <= balance); - if fits { - best = Some(built); - if amount == Amount::MAX { - break; - } - low = amount + 1; - } else { - high = amount.saturating_sub(1); - } - } - Err(_) => { - high = amount.saturating_sub(1); - } - } - } - best - } - - fn automatic_burn_needs_plaintext_anchor(&self, timestamp_ms: u64) -> bool { - self.ledger - .finalizer_rank_for_next_block(self.wallet.address()) - .is_some_and(|rank| self.wallet_rank_runs_vdf(rank)) - || self.should_prepare_recovery_vdf(timestamp_ms) - || timestamp_ms.saturating_add(AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS) - >= self.ledger.recovery_block_min_timestamp() - } - - fn wallet_rank_runs_vdf(&self, rank: u32) -> bool { - let rank_count = self.ledger.finalizer_rank_count_for_next_block(); - let allowed = - allowed_recovery_vdf_rank_count(rank_count, self.recovery_vdf_top_rank_percent); - usize::try_from(rank).is_ok_and(|rank| rank < allowed) - } - - fn should_prepare_recovery_vdf(&self, timestamp_ms: u64) -> bool { - if !self.ledger.recovery_block_available_at(timestamp_ms) { - return false; - } - if self.recovery_vdf_top_rank_percent == 100 { - return true; - } - if self.recovery_vdf_top_rank_percent == 0 { - return false; - } - if self.ledger.finalizer_rank_count_for_next_block() > 0 { - return false; - } - let tip_hash = self.ledger.status().tip_hash; - recovery_vdf_sample_percent(self.wallet.address(), tip_hash.as_str()) - < self.recovery_vdf_top_rank_percent - } - - fn prepare_next_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> { - let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor(); - ledger.prepare_next_block_with_required_burn_and_reveal_bundles( - self.wallet.address(), - timestamp_ms, - self.usable_reveal_bundles(), - required_burn_signature.as_deref(), - ) - } - - fn prepare_recovery_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> { - let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor(); - ledger.prepare_recovery_block_with_required_burn_and_reveal_bundles( - self.wallet.address(), - timestamp_ms, - self.usable_reveal_bundles(), - required_burn_signature.as_deref(), - ) - } - - fn ledger_with_local_block_anchor(&self) -> (Ledger, Option<String>) { - let mut ledger = self.ledger.clone(); - let Some((height, burn)) = &self.local_block_anchor_burn else { - return (ledger, None); - }; - if *height == ledger.height() && !ledger.has_transaction(burn.signature()) { - ledger.drop_pending_blinded_conflicting_with_transaction(burn); - if ledger.submit_transaction(burn.clone()).is_ok() { - return (ledger, Some(burn.signature().to_string())); - } - } - (ledger, None) - } - - 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> { - self.publish_reveal_bundle_for_next_block()?; - let work = self.prepare_next_block_with_local_anchor(timestamp_ms)?; - let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); - self.complete_prepared_block_at(work, vdf_output, timestamp_ms) - } - - pub fn complete_prepared_block( - &mut self, - work: PreparedBlock, - vdf_output: String, - ) -> Result<Block> { - self.complete_prepared_block_at(work, vdf_output, now_ms()) - } - - pub fn complete_prepared_block_at( - &mut self, - work: PreparedBlock, - vdf_output: String, - timestamp_ms: u64, - ) -> 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_reveal_bundles(); - self.prune_owned_blinded_payloads_for_block(&block); - self.outbox.push(GossipEnvelope::Block(block.clone())); - self.publish_owned_reveals_for_block(&block)?; - Ok(block) - } - - pub fn receive(&mut self, envelope: GossipEnvelope) -> Result<()> { - match envelope { - GossipEnvelope::Hello(_) - | GossipEnvelope::PeerStatus { .. } - | GossipEnvelope::ChainSnapshotRequest - | GossipEnvelope::BlockRangeRequest { .. } - | GossipEnvelope::BlockRequest { .. } - | GossipEnvelope::Inventory { .. } => Ok(()), - GossipEnvelope::BlindedTransaction(tx) => self.receive_blinded_transaction(tx), - GossipEnvelope::BlindedTransactions { transactions } => { - for tx in transactions { - self.receive_blinded_transaction(tx)?; - } - Ok(()) - } - GossipEnvelope::MineAction(tx) => self.receive_mine_action(tx), - GossipEnvelope::MineActions { transactions } => { - for tx in transactions { - self.receive_mine_action(tx)?; - } - Ok(()) - } - GossipEnvelope::BlindedReveal(reveal) => self.receive_blinded_reveal(reveal), - GossipEnvelope::BlindedReveals { reveals } => { - let mut added = false; - for reveal in reveals { - added |= self.receive_blinded_reveal_without_bundle_publish(reveal)?; - } - if added { - self.publish_reveal_bundle_for_next_block()?; - } - Ok(()) - } - GossipEnvelope::RevealBundle(bundle) => self.receive_reveal_bundle(bundle), - GossipEnvelope::RevealBundles { bundles } => { - for bundle in bundles { - self.receive_reveal_bundle(bundle)?; - } - Ok(()) - } - GossipEnvelope::Block(block) => { - 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_reveal_bundles(); - self.prune_owned_blinded_payloads_for_block(&block); - self.publish_owned_reveals_for_block(&block)?; - self.outbox.push(GossipEnvelope::Block(block)); - } - Ok(()) - } - GossipEnvelope::Blocks { blocks } => { - let mut imported = Vec::new(); - for block in blocks { - 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_reveal_bundles(); - self.prune_owned_blinded_payloads_for_block(&block); - self.publish_owned_reveals_for_block(&block)?; - imported.push(block); - } - } - for block in imported { - self.outbox.push(GossipEnvelope::Block(block)); - } - Ok(()) - } - GossipEnvelope::ChainSnapshot(snapshot) => self.import_chain_snapshot(snapshot), - GossipEnvelope::PeerAnnouncement { .. } - | GossipEnvelope::PeerVerificationChallenge { .. } - | GossipEnvelope::PeerVerificationResponse { .. } - | GossipEnvelope::PeerList { .. } => Ok(()), - } - } - - pub(crate) fn receive_preverified_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> { - let previous_height = self.ledger.height(); - self.ledger - .apply_preverified_block_at(block.clone(), now_ms)?; - if self.ledger.height() > previous_height { - self.clear_stale_local_block_anchor(); - self.prune_reveal_bundles(); - self.prune_owned_blinded_payloads_for_block(&block); - self.publish_owned_reveals_for_block(&block)?; - self.outbox.push(GossipEnvelope::Block(block)); - } - Ok(()) - } - - pub(crate) fn block_requires_vdf_verification_at( - &self, - block: &Block, - now_ms: u64, - ) -> Result<bool> { - self.ledger - .block_requires_vdf_verification_at(block, now_ms) - } - - pub fn import_chain_snapshot(&mut self, snapshot: ChainSnapshot) -> Result<()> { - let previous_height = self.ledger.height(); - let imported = self.ledger.extend_from_snapshot(snapshot)?; - if imported { - self.last_auto_burn_height = None; - self.last_auto_anchor_burn_height = None; - 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.prune_reveal_bundles(); - self.enqueue_imported_blocks(previous_height)?; - } - Ok(()) - } - - pub(crate) fn import_verified_ledger(&mut self, ledger: Ledger) -> Result<bool> { - let replaces_setup_placeholder = self.ledger.is_setup_placeholder() - && ledger.genesis_hash() != self.ledger.genesis_hash(); - if ledger.genesis_hash() != self.ledger.genesis_hash() && !replaces_setup_placeholder { - anyhow::bail!("chain snapshot genesis does not match local chain"); - } - let previous_height = self.ledger.height(); - if !replaces_setup_placeholder && ledger.height() <= previous_height { - return Ok(false); - } - - self.ledger = ledger; - self.last_auto_burn_height = None; - self.last_auto_anchor_burn_height = None; - 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.prune_reveal_bundles(); - self.enqueue_imported_blocks(previous_height)?; - Ok(true) - } - - pub fn drain_outbox(&mut self) -> Vec<GossipEnvelope> { - std::mem::take(&mut self.outbox) - } - - fn enqueue_imported_blocks(&mut self, previous_height: u64) -> Result<()> { - if self.ledger.height() <= previous_height { - return Ok(()); - } - let blocks = self - .ledger - .blocks_from(previous_height + 1, IMPORT_REBROADCAST_LIMIT); - for block in &blocks { - self.prune_reveal_bundles(); - self.prune_owned_blinded_payloads_for_block(block); - self.publish_owned_reveals_for_block(block)?; - } - if !blocks.is_empty() { - self.outbox.push(GossipEnvelope::Blocks { blocks }); - } - Ok(()) - } -} - -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -pub struct PeerBook { - peers: BTreeMap<String, PeerInfo>, -} - -impl PeerBook { - pub fn from_addresses(addresses: Vec<String>) -> Self { - let mut book = Self::default(); - for address in addresses { - book.add_peer(address); - } - book - } - - pub fn add_peer(&mut self, address: impl Into<String>) { - let address = address.into(); - let peer = self - .peers - .entry(address.clone()) - .or_insert_with(|| PeerInfo::new(address, PeerDirection::Outbound)); - if peer.direction != PeerDirection::Outbound { - peer.direction = PeerDirection::Outbound; - } - } - - pub fn add_discovered_peer(&mut self, address: impl Into<String>) { - let address = address.into(); - let peer = self - .peers - .entry(address.clone()) - .or_insert_with(|| PeerInfo::new(address, PeerDirection::Discovered)); - if peer.direction == PeerDirection::Inbound { - peer.direction = PeerDirection::Discovered; - } - } - - pub fn observe_inbound_peer(&mut self, address: impl Into<String>) { - let address = address.into(); - self.peers - .entry(address.clone()) - .or_insert_with(|| PeerInfo::new(address, PeerDirection::Inbound)); - } - - pub fn replace_peer_address(&mut self, from: &str, to: impl Into<String>) { - let to = to.into(); - if from == to { - if !self.peers.contains_key(from) { - self.add_peer(to); - } - return; - } - - let Some(from_peer) = self.peers.remove(from) else { - self.add_peer(to); - return; - }; - - let to_peer = self - .peers - .entry(to.clone()) - .or_insert_with(|| PeerInfo::new(to, from_peer.direction.clone())); - if from_peer.direction == PeerDirection::Outbound { - to_peer.direction = PeerDirection::Outbound; - } else if from_peer.direction == PeerDirection::Discovered - && to_peer.direction == PeerDirection::Inbound - { - to_peer.direction = PeerDirection::Discovered; - } - to_peer.messages_sent = to_peer - .messages_sent - .saturating_add(from_peer.messages_sent); - to_peer.messages_received = to_peer - .messages_received - .saturating_add(from_peer.messages_received); - to_peer.last_known_height = to_peer.last_known_height.or(from_peer.last_known_height); - to_peer.last_known_tip_hash = to_peer - .last_known_tip_hash - .clone() - .or(from_peer.last_known_tip_hash); - 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; - to_peer.last_clock_observed_ms = from_peer.last_clock_observed_ms; - } - to_peer.last_contact_ms = to_peer.last_contact_ms.max(from_peer.last_contact_ms); - to_peer.last_success_ms = to_peer.last_success_ms.max(from_peer.last_success_ms); - to_peer.last_error_ms = to_peer.last_error_ms.max(from_peer.last_error_ms); - if to_peer.last_error.is_none() { - to_peer.last_error = from_peer.last_error; - } - to_peer.misbehavior_score = to_peer - .misbehavior_score - .saturating_add(from_peer.misbehavior_score); - to_peer.banned_until_ms = to_peer.banned_until_ms.max(from_peer.banned_until_ms); - if to_peer.ban_reason.is_none() { - to_peer.ban_reason = from_peer.ban_reason; - } - } - - pub fn remove_peer(&mut self, address: &str) -> bool { - if self - .peers - .get(address) - .is_some_and(|peer| peer.direction != PeerDirection::Inbound) - { - self.peers.remove(address); - true - } else { - false - } - } - - pub fn is_connectable_peer(&self, address: &str) -> bool { - self.peers - .get(address) - .is_some_and(|peer| peer.direction != PeerDirection::Inbound) - } - - pub fn addresses(&self) -> Vec<String> { - self.peers - .values() - .filter(|peer| peer.direction != PeerDirection::Inbound) - .map(|peer| peer.address.clone()) - .collect() - } - - pub fn connectable_addresses_at(&self, now_ms: u64) -> Vec<String> { - self.peers - .values() - .filter(|peer| peer.direction != PeerDirection::Inbound) - .filter(|peer| !peer.is_banned_at(now_ms)) - .map(|peer| peer.address.clone()) - .collect() - } - - pub fn addresses_except(&self, excluded: &str) -> Vec<String> { - self.connectable_addresses_at(now_ms()) - .into_iter() - .filter(|address| address != excluded) - .collect() - } - - pub fn list(&self) -> Vec<PeerInfo> { - self.peers.values().cloned().collect() - } - - pub fn prune_stale_inbound_peers_at(&mut self, now_ms: u64, max_age_ms: u64) -> usize { - let before = self.peers.len(); - self.peers.retain(|_, peer| { - if peer.direction != PeerDirection::Inbound || peer.is_banned_at(now_ms) { - return true; - } - peer.last_contact_ms - .is_some_and(|last_contact| now_ms.saturating_sub(last_contact) <= max_age_ms) - }); - before.saturating_sub(self.peers.len()) - } - - pub fn record_sent(&mut self, address: &str, count: u64) { - let now = now_ms(); - let peer = self.ensure(address, PeerDirection::Outbound); - peer.messages_sent += count; - 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_status(&mut self, address: &str, height: u64, tip_hash: String) { - let now = now_ms(); - let peer = self.ensure(address, PeerDirection::Outbound); - peer.last_known_height = Some(height); - peer.last_known_tip_hash = Some(tip_hash); - 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_clock_observation( - &mut self, - address: &str, - direction: PeerDirection, - remote_time_ms: u64, - local_receive_time_ms: u64, - ) { - if remote_time_ms == 0 { - return; - } - let offset = remote_time_ms as i128 - local_receive_time_ms as i128; - let offset = offset.clamp(i64::MIN as i128, i64::MAX as i128) as i64; - let accepted = offset.abs() <= PEER_CLOCK_OFFSET_ACCEPTANCE_MS; - let peer = self.ensure(address, direction); - peer.last_clock_offset_ms = Some(offset); - peer.last_clock_offset_accepted = Some(accepted); - peer.last_clock_observed_ms = Some(local_receive_time_ms); - } - - pub fn network_time_offset_ms_at(&self, now_ms: u64) -> Option<i64> { - median_i64( - self.peers - .values() - .filter(|peer| !peer.is_banned_at(now_ms)) - .filter(|peer| peer.last_error.is_none()) - .filter(|peer| peer.last_clock_offset_accepted == Some(true)) - .filter(|peer| { - peer.last_clock_observed_ms.is_some_and(|observed_ms| { - now_ms.saturating_sub(observed_ms) <= PEER_CLOCK_OFFSET_STALE_MS - }) - }) - .filter_map(|peer| peer.last_clock_offset_ms) - .collect(), - ) - } - - pub fn adjusted_time_ms_at(&self, now_ms: u64) -> u64 { - match self.network_time_offset_ms_at(now_ms) { - Some(offset) if offset >= 0 => now_ms.saturating_add(offset as u64), - Some(offset) => now_ms.saturating_sub(offset.unsigned_abs()), - None => now_ms, - } - } - - pub fn bad_clock_peer_count_at(&self, now_ms: u64) -> usize { - self.peers - .values() - .filter(|peer| !peer.is_banned_at(now_ms)) - .filter(|peer| { - peer.last_clock_observed_ms.is_some_and(|observed_ms| { - now_ms.saturating_sub(observed_ms) <= PEER_CLOCK_OFFSET_STALE_MS - }) - }) - .filter(|peer| peer.last_clock_offset_accepted == Some(false)) - .count() - } - - pub fn record_error(&mut self, address: &str, error: impl Into<String>) { - let now = now_ms(); - let peer = self.ensure(address, PeerDirection::Outbound); - peer.last_contact_ms = Some(now); - peer.last_error_ms = Some(now); - peer.last_error = Some(error.into()); - } - - pub fn record_inbound_error(&mut self, address: &str, error: impl Into<String>) { - let now = now_ms(); - let peer = self.ensure(address, PeerDirection::Inbound); - peer.last_contact_ms = Some(now); - peer.last_error_ms = Some(now); - peer.last_error = Some(error.into()); - } - - pub fn record_received(&mut self, address: &str, count: u64) { - let now = now_ms(); - let peer = self.ensure(address, PeerDirection::Inbound); - peer.messages_received += count; - 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_misbehavior(&mut self, address: &str, reason: impl Into<String>) { - self.record_misbehavior_at(address, reason, now_ms()); - } - - pub fn record_misbehavior_at(&mut self, address: &str, reason: impl Into<String>, now_ms: u64) { - self.record_misbehavior_with_direction(address, reason, now_ms, PeerDirection::Outbound); - } - - pub fn record_inbound_misbehavior(&mut self, address: &str, reason: impl Into<String>) { - self.record_misbehavior_with_direction(address, reason, now_ms(), PeerDirection::Inbound); - } - - fn record_misbehavior_with_direction( - &mut self, - address: &str, - reason: impl Into<String>, - now_ms: u64, - direction: PeerDirection, - ) { - let reason = reason.into(); - let peer = self.ensure(address, direction); - peer.last_contact_ms = Some(now_ms); - peer.last_error_ms = Some(now_ms); - peer.last_error = Some(reason.clone()); - peer.misbehavior_score = peer.misbehavior_score.saturating_add(1); - peer.ban_reason = Some(reason); - if peer.misbehavior_score >= PEER_MISBEHAVIOR_BAN_SCORE { - peer.banned_until_ms = Some(now_ms.saturating_add(PEER_MISBEHAVIOR_BAN_MS)); - } - } - - pub fn is_banned(&self, address: &str) -> bool { - self.is_banned_at(address, now_ms()) - } - - pub fn is_banned_at(&self, address: &str, now_ms: u64) -> bool { - self.peers - .get(address) - .is_some_and(|peer| peer.is_banned_at(now_ms)) - } - - fn ensure(&mut self, address: &str, direction: PeerDirection) -> &mut PeerInfo { - self.peers - .entry(address.to_string()) - .or_insert_with(|| PeerInfo::new(address.to_string(), direction)) - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct PeerInfo { - pub address: String, - pub direction: PeerDirection, - pub messages_sent: u64, - pub messages_received: u64, - pub last_known_height: Option<u64>, - pub last_known_tip_hash: Option<String>, - #[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_contact_ms: Option<u64>, - pub last_success_ms: Option<u64>, - pub last_error_ms: Option<u64>, - pub misbehavior_score: u32, - pub banned_until_ms: Option<u64>, - pub ban_reason: Option<String>, -} - -impl PeerInfo { - fn new(address: String, direction: PeerDirection) -> Self { - Self { - address, - direction, - messages_sent: 0, - messages_received: 0, - last_known_height: None, - last_known_tip_hash: None, - last_clock_offset_ms: None, - last_clock_offset_accepted: None, - last_clock_observed_ms: None, - last_error: None, - last_contact_ms: None, - last_success_ms: None, - last_error_ms: None, - misbehavior_score: 0, - banned_until_ms: None, - ban_reason: None, - } - } - - pub fn is_banned_at(&self, now_ms: u64) -> bool { - self.banned_until_ms - .is_some_and(|banned_until| banned_until > now_ms) - } - - fn clear_misbehavior(&mut self) { - self.misbehavior_score = 0; - self.banned_until_ms = None; - self.ban_reason = None; - } -} - -fn median_i64(mut values: Vec<i64>) -> Option<i64> { - if values.is_empty() { - return None; - } - values.sort_unstable(); - Some(values[values.len() / 2]) -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum PeerDirection { - Outbound, - Discovered, - Inbound, -} - -pub fn now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time is before unix epoch") - .as_millis() as u64 -} - -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]; - bytes.copy_from_slice(&digest[..8]); - u64::from_be_bytes(bytes) -} - -fn converge_fee_by_byte( - fee_per_byte: Amount, - mut build: impl FnMut(Amount) -> Result<BuiltBlindedTransaction>, -) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { - let mut fee = 0; - let mut best = None; - for _ in 0..64 { - 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 blinded transaction bytes overflows")?; - if fee == required_fee { - return Ok((built, FeeEstimate { bytes, fee })); - } - if fee > required_fee - && best - .as_ref() - .is_none_or(|(_, estimate): &(BuiltBlindedTransaction, FeeEstimate)| { - fee < estimate.fee - }) - { - best = Some((built, FeeEstimate { bytes, fee })); - } - fee = required_fee; - } - - 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 blinded transaction bytes overflows")?; - if fee >= required_fee { - if best - .as_ref() - .is_none_or(|(_, estimate): &(BuiltBlindedTransaction, FeeEstimate)| fee < estimate.fee) - { - best = Some((built, FeeEstimate { bytes, fee })); - } - if let Some(best) = best { - return Ok(best); - } - } - 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 blinded transaction bytes overflows")?; - if required_fee < final_required_fee { - bail!("fee per byte did not converge"); - } - Ok(( - built, - FeeEstimate { - bytes, - fee: required_fee, - }, - )) -} - -fn transaction_output_total_for_address(transaction: &Transaction, address: &str) -> Amount { - match transaction { - Transaction::Transfer { outputs, .. } => outputs, - Transaction::Burn { change, .. } => change, - Transaction::Mine { recipient, .. } if recipient == address => return MINE_REWARD, - Transaction::Mine { .. } => return 0, - } - .iter() - .filter(|output| output.address == address) - .fold(0_u64, |total, output| total.saturating_add(output.amount)) -} - -fn transaction_input_total_from_outputs( - transaction: &Transaction, - address: &str, - outputs: &BTreeMap<OutPoint, Amount>, -) -> Amount { - let inputs = match transaction { - Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => inputs, - Transaction::Mine { .. } => return 0, - }; - inputs - .iter() - .filter(|input| input.owner == address) - .filter_map(|input| outputs.get(&input.outpoint)) - .fold(0_u64, |total, amount| total.saturating_add(*amount)) -} - -fn transaction_input_outpoints(transaction: &Transaction) -> BTreeSet<OutPoint> { - match transaction { - Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => inputs, - Transaction::Mine { .. } => return BTreeSet::new(), - } - .iter() - .map(|input| input.outpoint.clone()) - .collect() -} - -fn allowed_recovery_vdf_rank_count(rank_count: usize, percent: u8) -> usize { - if rank_count == 0 || percent == 0 { - return 0; - } - rank_count - .saturating_mul(usize::from(percent.min(100))) - .saturating_add(99) - / 100 -} - -fn recovery_vdf_sample_percent(address: &str, tip_hash: &str) -> u8 { - let digest = Sha256::digest(format!("iuna-recovery-vdf-sample:{tip_hash}:{address}")); - digest[0] % 100 -} - -#[cfg(test)] -mod tests { - use std::collections::{BTreeMap, BTreeSet}; - - use crate::domain::{ - FinalizerMode, GenesisBurn, Ledger, MICRO_IUNA, MINE_ACTIONS_PER_ANCHOR_LIMIT, - MINE_FINALIZER_FEE, OutPoint, RECOVERY_BLOCK_DELAY_MS, Transaction, VDF_TARGET_BLOCK_MS, - Wallet, run_vdf, - }; - - use super::{ - DEFAULT_POW_MINING_WORKERS, GossipEnvelope, InMemoryNetwork, MAX_POW_MINING_WORKERS, - NodeConfig, NodeCore, - }; - - fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet { - wallets - .iter() - .find(|wallet| wallet.address() == address) - .unwrap_or_else(|| panic!("missing wallet for address {address}")) - } - - fn queue_auto_pow_mine_action(node: &mut NodeCore) -> Transaction { - node.set_pow_mining_enabled(true); - (0..10_000) - .find_map(|timestamp| node.prepare_automatic_mining(timestamp).pow_mined) - .expect("test node should find a PoW mine action") - } - - fn assert_block_has_mine_action(block: &crate::domain::Block) { - assert!( - block - .transactions - .iter() - .any(|transaction| matches!(transaction, Transaction::Mine { .. })), - "block {} should include a mine action", - block.height - ); - } - - #[test] - 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(), MICRO_IUNA); - let mut node = NodeCore::new(NodeConfig { - wallet: alice, - genesis_allocations: allocations, - vdf_rounds: 10, - burn_per_block: 1, - burn_fee: 1, - pow_mining_workers: 1, - recovery_vdf_top_rank_percent: 100, - }); - - let first = node.prepare_automatic_mining(1); - assert!(first.burned.is_some()); - assert_eq!(node.last_auto_burn_height, Some(0)); - - let same_height_ledger = node.clone_ledger(); - assert!(!node.import_verified_ledger(same_height_ledger).unwrap()); - assert_eq!(node.last_auto_burn_height, Some(0)); - - let second = node.prepare_automatic_mining(2); - assert!(second.burned.is_none()); - } - - #[test] - fn automatic_pow_mining_searches_bounded_nonce_batches_per_tip() { - let wallet = Wallet::from_seed("automatic-pow-mining-wallet"); - 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()); - assert_eq!( - disabled.skipped_reason.as_deref(), - Some("automatic mining is off") - ); - - node.set_pow_mining_enabled(true); - let first = node.prepare_automatic_mining(2); - 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()) - .expect("bounded PoW search should eventually find a proof"); - let first_mine = first.pow_mined.as_ref().expect("PoW should be queued"); - let Transaction::Mine { - anchor, - recipient, - difficulty_bits, - .. - } = first_mine - else { - panic!("expected mine transaction"); - }; - assert_eq!(anchor, &node.chain().last().unwrap().hash); - assert_eq!(recipient, wallet.address()); - assert_eq!( - *difficulty_bits, - node.ledger().current_mine_difficulty_bits() - ); - let first_pending = node.ledger().pending().len(); - assert!(first_pending >= 1); - assert!(node.ledger().pending_blinded_transactions().is_empty()); - assert!(node - .drain_outbox() - .iter() - .any(|envelope| matches!(envelope, GossipEnvelope::MineAction(tx) if tx.signature() == first_mine.signature()))); - assert!( - node.status() - .mining - .last_auto_pow_mine_status - .as_deref() - .unwrap_or_default() - .contains("queued") - ); - - let second = (10_000..20_000) - .map(|timestamp| node.prepare_automatic_mining(timestamp)) - .find(|plan| plan.pow_mined.is_some()) - .expect("automatic PoW should allow a second proof for the same tip"); - let second_mine = second.pow_mined.as_ref().expect("PoW should be queued"); - assert_ne!(second_mine.signature(), first_mine.signature()); - assert_eq!(node.ledger().pending().len(), first_pending + 1); - - for timestamp in 20_000..20_010 { - assert!(node.prepare_automatic_mining(timestamp).pow_mined.is_none()); - } - assert_eq!(node.ledger().pending().len(), first_pending + 1); - assert_eq!( - node.status().mining.last_auto_pow_mine_status.as_deref(), - Some("waiting for next chain tip after queued mine actions") - ); - assert!(node.ledger().pending_blinded_transactions().is_empty()); - } - - #[test] - fn automatic_pow_mining_waits_after_queueing_anchor_limit_for_tip() { - let wallet = Wallet::from_seed("automatic-pow-independent-wallet"); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 1); - let mut node = NodeCore::new(NodeConfig { - wallet, - genesis_allocations: allocations, - vdf_rounds: 10, - burn_per_block: 0, - burn_fee: 0, - pow_mining_workers: 1, - recovery_vdf_top_rank_percent: 100, - }); - - node.set_pow_mining_enabled(true); - let first_mined = (1..10_000) - .find_map(|_| node.prepare_automatic_pow_mining().unwrap()) - .expect("PoW should eventually queue a mine action"); - let anchor = match first_mined { - Transaction::Mine { ref anchor, .. } => anchor.clone(), - _ => panic!("expected mine action"), - }; - assert_eq!(node.ledger().pending_mine_count_for_anchor(&anchor), 1); - - (1..10_000) - .find_map(|_| node.prepare_automatic_pow_mining().unwrap()) - .expect("PoW should allow a second mine action for the same tip"); - assert_eq!( - node.ledger().pending_mine_count_for_anchor(&anchor), - MINE_ACTIONS_PER_ANCHOR_LIMIT - ); - assert!(node.prepare_automatic_pow_mining().unwrap().is_none()); - assert!(node.auto_pow_mine_cursor.is_none()); - } - - #[test] - fn disabling_automatic_pow_mining_clears_local_work() { - let wallet = Wallet::from_seed("automatic-pow-disable-wallet"); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 1); - let mut node = NodeCore::new(NodeConfig { - wallet, - genesis_allocations: allocations, - vdf_rounds: 10, - burn_per_block: 0, - burn_fee: 0, - pow_mining_workers: 1, - recovery_vdf_top_rank_percent: 100, - }); - - node.set_pow_mining_enabled(true); - assert!(node.pow_mining_enabled()); - node.prepare_automatic_pow_mining().unwrap(); - assert!(node.auto_pow_mine_cursor.is_some()); - assert!(node.status().mining.last_auto_pow_mine_status.is_some()); - - node.set_pow_mining_enabled(false); - - assert!(!node.pow_mining_enabled()); - assert!(node.auto_pow_mine_cursor.is_none()); - assert!(node.status().mining.last_auto_pow_mine_status.is_none()); - } - - #[test] - fn automatic_pow_mining_workers_are_clamped_and_reported() { - let wallet = Wallet::from_seed("automatic-pow-workers-wallet"); - let mut node = NodeCore::new(NodeConfig { - wallet, - genesis_allocations: BTreeMap::new(), - vdf_rounds: 10, - burn_per_block: 0, - burn_fee: 0, - pow_mining_workers: 99, - recovery_vdf_top_rank_percent: 100, - }); - - assert_eq!(node.pow_mining_workers(), MAX_POW_MINING_WORKERS); - assert_eq!( - node.status().mining.max_pow_mining_workers, - MAX_POW_MINING_WORKERS - ); - - node.set_pow_mining_workers(0); - - assert_eq!(node.pow_mining_workers(), DEFAULT_POW_MINING_WORKERS); - assert_eq!( - node.status().mining.pow_mining_workers, - DEFAULT_POW_MINING_WORKERS - ); - } - - #[test] - fn automatic_pow_mining_skips_unspendable_owned_blinded_payloads() { - let alice = Wallet::from_seed("automatic-pow-stale-owned-blind-alice"); - let bob = Wallet::from_seed("automatic-pow-stale-owned-blind-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(bob.address(), MICRO_IUNA)], - 10, - ) - .unwrap(); - let mut node = NodeCore::from_ledger(alice.clone(), ledger, 0); - - let blinded = node - .blinded_burn_with_fee(MICRO_IUNA / 10, 7, node.chain_height() + 4) - .unwrap(); - let mut finalizer_ledger = node.ledger().clone(); - let leader_burn = finalizer_ledger.build_burn(&bob, 1, 0).unwrap(); - finalizer_ledger.submit_transaction(leader_burn).unwrap(); - let commit_block = finalizer_ledger.mine_next_block(&bob, 1).unwrap(); - assert!( - commit_block - .blinded_transactions - .iter() - .any(|tx| tx.commitment == blinded.commitment) - ); - finalizer_ledger.apply_block(commit_block).unwrap(); - assert!(node.import_verified_ledger(finalizer_ledger).unwrap()); - assert!( - node.ledger() - .has_unrevealed_blinded_transaction(&blinded.commitment) - ); - - node.set_pow_mining_enabled(true); - - assert!(node.prepare_automatic_pow_mining().is_ok()); - } - - #[test] - fn automatic_pow_mining_skips_stale_local_anchor_reservation() { - let wallet = Wallet::from_seed("automatic-pow-stale-anchor-wallet"); - let mut stale_allocations = BTreeMap::new(); - stale_allocations.insert(wallet.address().to_string(), MICRO_IUNA); - let stale_ledger = Ledger::new(stale_allocations, 10); - let stale_anchor = stale_ledger.build_burn(&wallet, 1, 0).unwrap(); - let live_ledger = Ledger::new(BTreeMap::new(), 10); - let mut node = NodeCore::from_ledger(wallet.clone(), live_ledger, 0); - node.local_block_anchor_burn = Some((node.chain_height(), stale_anchor)); - node.set_pow_mining_enabled(true); - - assert!(node.prepare_automatic_pow_mining().is_ok()); - } - - #[test] - fn automatic_finalization_does_not_tick_pow_mining() { - let wallet = Wallet::from_seed("automatic-pow-separated-finalizer-wallet"); - let mut node = NodeCore::new(NodeConfig { - wallet, - genesis_allocations: BTreeMap::new(), - vdf_rounds: 10, - burn_per_block: 0, - burn_fee: 0, - pow_mining_workers: 1, - recovery_vdf_top_rank_percent: 100, - }); - - node.set_pow_mining_enabled(true); - let _ = node.prepare_automatic_finalization(1); - - assert!(node.auto_pow_mine_cursor.is_none()); - } - - #[test] - fn automatic_finalization_prepares_recovery_after_ticket_timeout() { - let alice = Wallet::from_seed("automatic-recovery-alice"); - let bob = Wallet::from_seed("automatic-recovery-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(alice.address(), 1)], - 10, - ) - .unwrap(); - let mut node = NodeCore::from_ledger(bob, ledger, 1); - - let early = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS - 1); - assert!(early.work.is_none()); - assert!( - early - .skipped_reason - .as_deref() - .unwrap_or_default() - .contains("waiting for selected finalizer") - ); - - let recovery = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS); - let work = recovery.work.expect("recovery work should be prepared"); - let block = work.finish( - node.wallet.unlocked().unwrap(), - "preverified-vdf".to_string(), - ); - - assert_eq!(block.finalizer_mode, FinalizerMode::Recovery); - assert!(block.leader_proof.is_none()); - } - - #[test] - fn automatic_finalization_respects_zero_recovery_vdf_threshold() { - let alice = Wallet::from_seed("automatic-recovery-zero-alice"); - let bob = Wallet::from_seed("automatic-recovery-zero-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(alice.address(), 1)], - 10, - ) - .unwrap(); - let mut node = NodeCore::from_ledger(bob, ledger, 1); - node.set_recovery_vdf_top_rank_percent(0); - - let recovery = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS); - - assert!(recovery.work.is_none()); - } - - #[test] - fn automatic_pow_mining_uses_protocol_finalizer_fee() { - let wallet = Wallet::from_seed("automatic-pow-mining-fee-wallet"); - let mut node = NodeCore::new(NodeConfig { - wallet, - genesis_allocations: BTreeMap::new(), - vdf_rounds: 10, - burn_per_block: 0, - burn_fee: 0, - pow_mining_workers: 1, - recovery_vdf_top_rank_percent: 100, - }); - - node.set_pow_mining_enabled(true); - let plan = (1..10_000) - .map(|timestamp| node.prepare_automatic_mining(timestamp)) - .find(|plan| plan.pow_mined.is_some()) - .expect("bounded PoW search should eventually find a proof"); - let mine = plan.pow_mined.expect("PoW should be queued"); - - assert_eq!(mine.fee(), MINE_FINALIZER_FEE); - assert_eq!(mine.amount(), crate::domain::MINE_REWARD); - assert_eq!( - node.status().mining.automatic_pow_mine_fee, - MINE_FINALIZER_FEE - ); - } - - #[test] - fn status_reports_package_version() { - let wallet = Wallet::from_seed("status-version-wallet"); - let node = NodeCore::new(NodeConfig { - wallet, - genesis_allocations: BTreeMap::new(), - vdf_rounds: 1, - burn_per_block: 0, - burn_fee: 0, - pow_mining_workers: 1, - recovery_vdf_top_rank_percent: 100, - }); - - assert_eq!(node.status().app_version, env!("CARGO_PKG_VERSION")); - } - - #[test] - fn automatic_pow_status_reports_setup_placeholder_wait() { - let wallet = Wallet::from_seed("automatic-pow-setup-placeholder-wallet"); - let ledger = Ledger::new(BTreeMap::new(), 1); - let mut node = NodeCore::from_ledger(wallet, ledger, 0); - - node.set_pow_mining_enabled(true); - - assert_eq!( - node.status().mining.last_auto_pow_mine_status.as_deref(), - Some("waiting for a real chain before PoW mining can start") - ); - } - - #[test] - fn fee_rate_transfer_and_burn_pay_at_least_bytes_times_rate() { - let transfer_sender = Wallet::from_seed("fee-rate-transfer-sender"); - let transfer_recipient = Wallet::from_seed("fee-rate-transfer-recipient"); - let mut transfer_genesis = BTreeMap::new(); - transfer_genesis.insert(transfer_sender.address().to_string(), 10 * MICRO_IUNA); - let transfer_ledger = crate::domain::Ledger::new_with_genesis_burns( - transfer_genesis, - vec![GenesisBurn::new(transfer_sender.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let mut transfer_node = NodeCore::from_ledger(transfer_sender, transfer_ledger, 0); - - let (transfer, transfer_estimate) = transfer_node - .transfer_with_fee_rate(transfer_recipient.address(), MICRO_IUNA, 2, &[]) - .unwrap(); - let transfer_blinded_bytes = - transfer_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!(transfer_node.ledger().pending().is_empty()); - assert_eq!( - transfer_node.ledger().pending_blinded_transactions().len(), - 1 - ); - - let burn_wallet = Wallet::from_seed("fee-rate-burn-wallet"); - let mut burn_genesis = BTreeMap::new(); - burn_genesis.insert(burn_wallet.address().to_string(), 10 * MICRO_IUNA); - let burn_ledger = crate::domain::Ledger::new_with_genesis_burns( - burn_genesis, - vec![GenesisBurn::new(burn_wallet.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let mut burn_node = NodeCore::from_ledger(burn_wallet, burn_ledger, 0); - let (burn, burn_estimate) = burn_node.burn_with_fee_rate(MICRO_IUNA, 3).unwrap(); - let burn_blinded_bytes = - burn_node.ledger().pending_blinded_transactions()[0].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!(burn_node.ledger().pending().is_empty()); - assert_eq!(burn_node.ledger().pending_blinded_transactions().len(), 1); - } - - #[test] - fn mempool_gossip_includes_blinded_transactions() { - let alice = Wallet::from_seed("blinded-gossip-alice"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new(genesis, 1); - let blinded = ledger.build_blinded_burn(&alice, MICRO_IUNA, 7, 3).unwrap(); - let mut sender = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0); - let mut receiver = NodeCore::from_ledger(alice, ledger, 0); - - sender - .receive_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - for envelope in sender.mempool_gossip() { - receiver.receive(envelope).unwrap(); - } - - assert_eq!( - receiver.ledger().pending_blinded_transactions(), - std::slice::from_ref(&blinded.transaction) - ); - } - - #[test] - fn mempool_gossip_includes_public_mine_actions() { - let alice = Wallet::from_seed("mine-gossip-alice"); - let ledger = Ledger::new(BTreeMap::new(), 1); - let mine = ledger.build_mine(alice.address()).unwrap(); - let mut sender = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0); - let mut receiver = NodeCore::from_ledger(alice, ledger, 0); - - sender.submit_public_mine_action(mine.clone()).unwrap(); - for envelope in sender.mempool_gossip() { - receiver.receive(envelope).unwrap(); - } - - assert_eq!(receiver.ledger().pending(), std::slice::from_ref(&mine)); - assert!(receiver.ledger().pending_blinded_transactions().is_empty()); - } - - #[test] - fn receiving_blinded_reveal_batch_publishes_complete_committee_bundle() { - let alice = Wallet::from_seed("immediate-bundle-alice"); - let bob = Wallet::from_seed("immediate-bundle-bob"); - let carol = Wallet::from_seed("immediate-bundle-carol"); - let dave = Wallet::from_seed("immediate-bundle-dave"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(dave.address().to_string(), 10 * MICRO_IUNA); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let first = ledger - .build_blinded_burn(&carol, 3, 100, ledger.height() + 4) - .unwrap(); - let second = ledger - .build_blinded_burn(&dave, 4, 100, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(first.transaction.clone()) - .unwrap(); - ledger - .submit_blinded_transaction(second.transaction.clone()) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = wallet_for_address(&finalizers, &leader); - let burn = ledger.build_burn(leader_wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let commit_block = ledger.mine_next_block(leader_wallet, 1).unwrap(); - ledger.apply_locally_mined_block(commit_block).unwrap(); - - let committee = ledger.reveal_committee_for_next_block(); - let committee_wallet = committee - .iter() - .filter_map(|member| { - finalizers - .iter() - .find(|wallet| wallet.address() == member.owner) - }) - .next() - .expect("test finalizer should be in reveal committee"); - let mut committee_node = NodeCore::from_ledger(committee_wallet.clone(), ledger, 0); - - committee_node - .receive(GossipEnvelope::BlindedReveals { - reveals: vec![first.reveal.clone(), second.reveal.clone()], - }) - .unwrap(); - let outbox = committee_node.drain_outbox(); - - assert!(outbox.iter().any(|envelope| matches!( - envelope, - GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == first.reveal.commitment - ))); - assert!(outbox.iter().any(|envelope| matches!( - envelope, - GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == second.reveal.commitment - ))); - assert!(outbox.iter().any(|envelope| matches!( - envelope, - GossipEnvelope::RevealBundle(bundle) - if bundle.member == committee_wallet.address() - && bundle.reveals.len() == 2 - && bundle.reveals.iter().any(|reveal| reveal.commitment == first.reveal.commitment) - && bundle.reveals.iter().any(|reveal| reveal.commitment == second.reveal.commitment) - ))); - } - - #[test] - fn automatic_finalization_includes_reveals_with_two_nodes_and_one_burner() { - let finalizer = Wallet::from_seed("single-burner-reveal-finalizer"); - let wallet = Wallet::from_seed("single-burner-reveal-wallet"); - let mut allocations = BTreeMap::new(); - allocations.insert(finalizer.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let mut network = InMemoryNetwork::default(); - network.insert( - "finalizer", - NodeCore::from_ledger_with_burn_fee_and_enabled( - finalizer.clone(), - ledger.clone(), - true, - MICRO_IUNA / 10, - 1, - ), - ); - network.insert("wallet", NodeCore::from_ledger(wallet.clone(), ledger, 0)); - - let blinded = network - .node_mut("wallet") - .unwrap() - .blinded_burn_with_fee(MICRO_IUNA / 10, 1, 4) - .unwrap(); - network.deliver_until_idle().unwrap(); - - let commit_plan = network - .node_mut("finalizer") - .unwrap() - .prepare_automatic_finalization(1); - let commit_work = commit_plan - .work - .expect("finalizer should prepare commit block"); - let commit_vdf = run_vdf(commit_work.vdf_seed(), commit_work.vdf_rounds()); - let commit_block = network - .node_mut("finalizer") - .unwrap() - .complete_prepared_block_at(commit_work, commit_vdf, 1) - .unwrap(); - let wallet_commitment = blinded.commitment.clone(); - assert!( - commit_block - .blinded_transactions - .iter() - .any(|transaction| transaction.commitment == wallet_commitment), - "first block should commit the wallet's blinded burn" - ); - network.deliver_until_idle().unwrap(); - assert!( - network - .node("finalizer") - .unwrap() - .ledger() - .pending_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == wallet_commitment), - "finalizer should have received the reveal before building the next block" - ); - assert!( - network - .node("wallet") - .unwrap() - .ledger() - .pending_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == wallet_commitment), - "wallet node should also keep the reveal in its mempool" - ); - - let reveal_plan = network - .node_mut("finalizer") - .unwrap() - .prepare_automatic_finalization(2); - let reveal_work = reveal_plan - .work - .expect("finalizer should prepare reveal block"); - let reveal_vdf = run_vdf(reveal_work.vdf_seed(), reveal_work.vdf_rounds()); - let reveal_block = network - .node_mut("finalizer") - .unwrap() - .complete_prepared_block_at(reveal_work, reveal_vdf, 2) - .unwrap(); - - assert!( - reveal_block - .all_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == wallet_commitment), - "automatic finalization should include the pending reveal without requiring an extra mempool poll" - ); + assert!( + reveal_block + .all_blinded_reveals() + .iter() + .any(|reveal| reveal.commitment == wallet_commitment), + "automatic finalization should include the pending reveal without requiring an extra mempool poll" + ); } #[test] @@ -3288,7 +283,7 @@ mod tests { let block2_plan = node.prepare_automatic_finalization(2); let block2_work = block2_plan.work?; let (_, anchor_burn) = node.local_block_anchor_burn.clone()?; - let anchor_inputs = super::transaction_input_outpoints(&anchor_burn); + let anchor_inputs = transaction_input_outpoints(&anchor_burn); let anchor_total = node .ledger() .utxos_for_address(finalizer.address()) @@ -3453,7 +448,7 @@ mod tests { let block2_plan = node.prepare_automatic_finalization(2); let block2_work = block2_plan.work?; let (_, anchor_burn) = node.local_block_anchor_burn.clone()?; - let anchor_inputs = super::transaction_input_outpoints(&anchor_burn); + let anchor_inputs = transaction_input_outpoints(&anchor_burn); let utxos = node.ledger().utxos_for_address(finalizer.address()); let anchor_total = utxos .iter() @@ -3476,622 +471,51 @@ mod tests { }) .expect("test should find a seed with live-like small-anchor/large-change UTXOs"); - node.transfer_with_fee_spending( - recipient.address(), - transfer_amount, - 1, - &[transfer_outpoint], - ) - .expect("wallet tx created while block 2 VDF is running"); - let blinded = node - .ledger() - .pending_blinded_transactions() - .last() - .cloned() - .expect("wallet tx should be queued as a blinded transaction"); - - let block2_vdf = run_vdf(block2_work.vdf_seed(), block2_work.vdf_rounds()); - let block2 = node - .complete_prepared_block_at(block2_work, block2_vdf, 2) - .unwrap(); - assert!( - block2.blinded_transactions.is_empty(), - "block 2 work was prepared before the blinded tx arrived" - ); - assert!( - node.ledger() - .pending_blinded_transactions() - .iter() - .any(|tx| tx.commitment == blinded.commitment), - "the during-VDF blinded tx should remain pending for block 3" - ); - - let block3_outcome = node.automatic_mine_once(3); - assert!( - block3_outcome.block.is_some(), - "pending own blinded tx must not starve the next anchor burn: {:?}", - block3_outcome.skipped_reason - ); - let block3 = block3_outcome.block.unwrap(); - assert!( - block3.blinded_transactions.is_empty() - || block3 - .blinded_transactions - .iter() - .any(|tx| tx.commitment == blinded.commitment), - "block 3 may include the during-VDF tx, but must not stall when the anchor burn has priority" - ); - } - - #[test] - fn owned_blinded_transaction_reveals_after_commit_block_import() { - let alice = Wallet::from_seed("owned-blinded-reveal-alice"); - let bob = Wallet::from_seed("owned-blinded-reveal-bob"); - let carol = Wallet::from_seed("owned-blinded-reveal-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let mut wallet_node = NodeCore::from_ledger(carol.clone(), ledger.clone(), 0); - let mut finalizer_ledger = ledger; - - let blinded = wallet_node - .blinded_burn_with_fee(3, 7, wallet_node.chain_height() + 4) - .unwrap(); - wallet_node.drain_outbox(); - finalizer_ledger - .submit_blinded_transaction(blinded.clone()) - .unwrap(); - let leader = finalizer_ledger.expected_leader_for_next_block().unwrap(); - let finalizer = finalizers - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap(); - let burn = finalizer_ledger.build_burn(finalizer, 1, 0).unwrap(); - finalizer_ledger.submit_transaction(burn).unwrap(); - let commit_block = finalizer_ledger.mine_next_block(finalizer, 1).unwrap(); - - wallet_node - .receive(GossipEnvelope::Block(commit_block)) - .unwrap(); - let outbox = wallet_node.drain_outbox(); - - assert!( - wallet_node - .ledger() - .pending_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == blinded.commitment) - ); - assert!(outbox.iter().any(|envelope| matches!( - envelope, - GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == blinded.commitment - ))); - } - - #[test] - fn status_wallet_balance_includes_owned_blinded_change_before_and_after_commit() { - let alice = Wallet::from_seed("owned-blinded-balance-alice"); - let bob = Wallet::from_seed("owned-blinded-balance-bob"); - let carol = Wallet::from_seed("owned-blinded-balance-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let starting_balance = 2 * MICRO_IUNA; - let burn_amount = MICRO_IUNA / 10; - let fee = MICRO_IUNA / 10; - let expected_balance = starting_balance - burn_amount - fee; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(carol.address().to_string(), starting_balance); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let mut wallet_node = NodeCore::from_ledger(carol.clone(), ledger.clone(), 0); - let mut finalizer_ledger = ledger; - - let blinded = wallet_node - .blinded_burn_with_fee(burn_amount, fee, wallet_node.chain_height() + 4) - .unwrap(); - - assert_eq!(wallet_node.status().wallet_balance, expected_balance); - - finalizer_ledger - .submit_blinded_transaction(blinded.clone()) - .unwrap(); - let leader = finalizer_ledger.expected_leader_for_next_block().unwrap(); - let finalizer = finalizers - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap(); - let burn = finalizer_ledger.build_burn(finalizer, 1, 0).unwrap(); - finalizer_ledger.submit_transaction(burn).unwrap(); - let commit_block = finalizer_ledger.mine_next_block(finalizer, 1).unwrap(); - - wallet_node - .receive(GossipEnvelope::Block(commit_block)) - .unwrap(); - - assert_eq!(wallet_node.ledger().balance_of(carol.address()), 0); - assert_eq!(wallet_node.status().wallet_balance, expected_balance); - } - - #[test] - fn owned_blinded_transaction_restore_requeues_pending_commit() { - let alice = Wallet::from_seed("owned-blinded-restore-pending-alice"); - let bob = Wallet::from_seed("owned-blinded-restore-pending-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new(allocations, 1); - let mut node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0); - - let blinded = node - .blinded_transfer_with_fee(bob.address(), MICRO_IUNA, 7, node.chain_height() + 4) - .unwrap(); - let owned = node.owned_blinded_transactions(); - let mut restarted = NodeCore::from_ledger(alice, ledger, 0); - - restarted.restore_owned_blinded_transactions(owned).unwrap(); - - assert_eq!( - restarted.ledger().pending_blinded_transactions(), - std::slice::from_ref(&blinded) - ); - let outbox = restarted.drain_outbox(); - assert!(outbox.iter().any(|envelope| matches!( - envelope, - GossipEnvelope::BlindedTransaction(transaction) - if transaction.commitment == blinded.commitment - ))); - assert!(!outbox.iter().any(|envelope| matches!( - envelope, - GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == blinded.commitment - ))); - } - - #[test] - fn owned_blinded_transaction_restore_skips_stale_commit_with_spent_input() { - let alice = Wallet::from_seed("owned-blinded-restore-stale-alice"); - let bob = Wallet::from_seed("owned-blinded-restore-stale-bob"); - let finalizers = [bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), MICRO_IUNA); - allocations.insert(bob.address().to_string(), MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let mut wallet_node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0); - - wallet_node - .blinded_burn_with_fee(MICRO_IUNA / 10, 7, wallet_node.chain_height() + 4) - .unwrap(); - let owned = wallet_node.owned_blinded_transactions(); - let stale_conflict = ledger.build_burn(&alice, MICRO_IUNA / 10, 7).unwrap(); - let mut advanced_ledger = ledger; - advanced_ledger.submit_transaction(stale_conflict).unwrap(); - let leader = advanced_ledger.expected_leader_for_next_block().unwrap(); - assert_eq!(leader, bob.address()); - let anchor_burn = advanced_ledger.build_burn(&bob, 1, 0).unwrap(); - advanced_ledger.submit_transaction(anchor_burn).unwrap(); - let block = advanced_ledger.mine_next_block(&bob, 1).unwrap(); - advanced_ledger.apply_block(block).unwrap(); - let mut restarted = NodeCore::from_ledger(alice, advanced_ledger, 0); - - restarted.restore_owned_blinded_transactions(owned).unwrap(); - - assert!(restarted.owned_blinded_transactions().is_empty()); - assert!(restarted.ledger().pending_blinded_transactions().is_empty()); - assert!(restarted.drain_outbox().is_empty()); - } - - #[test] - fn owned_blinded_transaction_restore_publishes_reveal_after_commit() { - let alice = Wallet::from_seed("owned-blinded-restore-reveal-alice"); - let bob = Wallet::from_seed("owned-blinded-restore-reveal-bob"); - let carol = Wallet::from_seed("owned-blinded-restore-reveal-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let mut wallet_node = NodeCore::from_ledger(carol.clone(), ledger.clone(), 0); - let mut finalizer_ledger = ledger; - - let blinded = wallet_node - .blinded_burn_with_fee(3, 7, wallet_node.chain_height() + 4) - .unwrap(); - let owned = wallet_node.owned_blinded_transactions(); - finalizer_ledger - .submit_blinded_transaction(blinded.clone()) - .unwrap(); - let leader = finalizer_ledger.expected_leader_for_next_block().unwrap(); - let finalizer = finalizers - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap(); - let burn = finalizer_ledger.build_burn(finalizer, 1, 0).unwrap(); - finalizer_ledger.submit_transaction(burn).unwrap(); - let commit_block = finalizer_ledger.mine_next_block(finalizer, 1).unwrap(); - finalizer_ledger.apply_block(commit_block.clone()).unwrap(); - let mut restarted_ledger = NodeCore::from_ledger( - carol, - Ledger::from_snapshot(finalizer_ledger.snapshot()).unwrap(), - 0, - ); - - restarted_ledger - .restore_owned_blinded_transactions(owned) - .unwrap(); - - assert!( - restarted_ledger - .ledger() - .pending_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == blinded.commitment) - ); - assert!( - restarted_ledger - .drain_outbox() - .iter() - .any(|envelope| matches!( - envelope, - GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == blinded.commitment - )) - ); - assert!( - commit_block - .blinded_transactions - .iter() - .any(|transaction| transaction.commitment == blinded.commitment) - ); - } - - #[test] - fn automatic_non_leader_burn_is_queued_as_blinded() { - let alice = Wallet::from_seed("auto-blinded-burn-alice"); - let bob = Wallet::from_seed("auto-blinded-burn-bob"); - let carol = Wallet::from_seed("auto-blinded-burn-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - assert_eq!(ledger.finalizer_rank_for_next_block(carol.address()), None); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - carol, - ledger, - true, - MICRO_IUNA / 10, - 1, - ); - - let plan = node.prepare_automatic_finalization(1); - let outbox = node.drain_outbox(); - - assert!(plan.burned.is_some()); - assert!(node.ledger().pending().is_empty()); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); - assert!( - outbox - .iter() - .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_))) - ); - } - - #[test] - fn automatic_fallback_finalizer_prepares_anchor_and_blinded_burn() { - let alice = Wallet::from_seed("auto-fallback-burn-alice"); - let bob = Wallet::from_seed("auto-fallback-burn-bob"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let fallback = finalizers - .iter() - .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1)) - .unwrap() - .clone(); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - fallback, - ledger, - true, - MICRO_IUNA / 10, - 1, - ); - - let plan = node.prepare_automatic_finalization(1); - let outbox = node.drain_outbox(); - - assert!(plan.burned.is_some()); - assert!( - plan.skipped_reason.is_none(), - "fallback should not be skipped: {:?}", - plan.skipped_reason - ); - assert!(node.ledger().pending().is_empty()); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); - let (_, anchor_burn) = node - .local_block_anchor_burn - .as_ref() - .expect("fallback anchor burn should be held locally"); - let anchor_signature = anchor_burn.signature().to_string(); - assert_eq!(anchor_burn.amount(), super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT); - assert!( - outbox - .iter() - .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_))) - ); - let work = plan.work.expect("fallback work should be prepared"); - let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); - let block = node - .complete_prepared_block_at(work, vdf_output, VDF_TARGET_BLOCK_MS * 2) - .unwrap(); - - assert_eq!(block.finalizer_rank, 1); - assert_eq!(block.finalizer_mode, FinalizerMode::Ticket); - assert!(block.transactions.iter().any(|transaction| { - transaction.is_burn() && transaction.amount() == super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT - })); - assert_eq!( - block - .transactions - .first() - .map(|transaction| transaction.signature()), - Some(anchor_signature.as_str()) - ); - assert!(!block.blinded_transactions.is_empty()); - assert_eq!(node.ledger().height(), 1); - } - - #[test] - fn automatic_leader_prepares_anchor_and_blinded_burn() { - let alice = Wallet::from_seed("auto-plaintext-burn-alice"); - let bob = Wallet::from_seed("auto-plaintext-burn-bob"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), + node.transfer_with_fee_spending( + recipient.address(), + transfer_amount, 1, + &[transfer_outpoint], ) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = finalizers - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap() - .clone(); - for wallet in &finalizers { - let split = ledger - .build_transfer(wallet, wallet.address(), MICRO_IUNA, 0) - .unwrap(); - ledger.submit_transaction(split).unwrap(); - } - let anchor = ledger.build_burn(&leader_wallet, 1, 0).unwrap(); - ledger.submit_transaction(anchor).unwrap(); - let split_block = ledger.mine_next_block(&leader_wallet, 1).unwrap(); - ledger.apply_block(split_block).unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = finalizers - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap() - .clone(); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - leader_wallet, - ledger, - true, - MICRO_IUNA / 10, - 1, - ); - - let plan = node.prepare_automatic_finalization(1); - let outbox = node.drain_outbox(); + .expect("wallet tx created while block 2 VDF is running"); + let blinded = node + .ledger() + .pending_blinded_transactions() + .last() + .cloned() + .expect("wallet tx should be queued as a blinded transaction"); - assert!(plan.burned.is_some()); - assert!(node.ledger().pending().is_empty()); - assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); - let (_, anchor_burn) = node - .local_block_anchor_burn - .as_ref() - .expect("leader anchor burn should be held locally"); - assert_eq!(anchor_burn.amount(), super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT); + let block2_vdf = run_vdf(block2_work.vdf_seed(), block2_work.vdf_rounds()); + let block2 = node + .complete_prepared_block_at(block2_work, block2_vdf, 2) + .unwrap(); assert!( - outbox - .iter() - .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_))) + block2.blinded_transactions.is_empty(), + "block 2 work was prepared before the blinded tx arrived" ); - assert!(node.prepare_automatic_finalization(1).work.is_some()); - } - - #[test] - fn wallet_building_reserves_local_anchor_burn_inputs() { - let alice = Wallet::from_seed("local-anchor-reserve-alice"); - let finalizers = [alice.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = finalizers - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap() - .clone(); - let split = ledger - .build_transfer(&leader_wallet, leader_wallet.address(), MICRO_IUNA, 0) - .unwrap(); - ledger.submit_transaction(split).unwrap(); - let anchor = ledger.build_burn(&leader_wallet, 1, 0).unwrap(); - ledger.submit_transaction(anchor).unwrap(); - let split_block = ledger.mine_next_block(&leader_wallet, 1).unwrap(); - ledger.apply_block(split_block).unwrap(); - let mut node = - NodeCore::from_ledger_with_burn_fee_and_enabled(leader_wallet, ledger, true, 0, 1); - - let plan = node.prepare_automatic_finalization(1); - assert!(plan.burned.is_some()); - assert!(node.status().wallet_balance < node.ledger().balance_of(node.wallet_address())); - let (_, anchor_burn) = node - .local_block_anchor_burn - .clone() - .expect("leader burn should be held as a local block anchor"); - let Transaction::Burn { inputs, .. } = anchor_burn else { - panic!("local block anchor must be a burn"); - }; - let anchor_inputs = inputs - .iter() - .map(|input| input.outpoint.clone()) - .collect::<Vec<_>>(); - - let blinded = node - .blinded_burn_with_fee(MICRO_IUNA / 20, 1, node.chain_height() + 4) - .unwrap(); - assert!( - blinded - .inputs + node.ledger() + .pending_blinded_transactions() .iter() - .all(|input| !anchor_inputs.contains(&input.outpoint)), - "blinded wallet transactions must not spend inputs reserved by the local anchor burn" + .any(|tx| tx.commitment == blinded.commitment), + "the during-VDF blinded tx should remain pending for block 3" ); - let work = node.prepare_next_block_with_local_anchor(2).unwrap(); - let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); - let mut peer_ledger = node.clone_ledger(); - let block = node - .complete_prepared_block_at(work, vdf_output, VDF_TARGET_BLOCK_MS * 2) - .unwrap(); - assert!(block.transactions.iter().any(Transaction::is_burn)); + let block3_outcome = node.automatic_mine_once(3); assert!( - block - .blinded_transactions - .iter() - .any(|transaction| transaction.commitment == blinded.commitment) - ); - peer_ledger.apply_block_at(block, u64::MAX).unwrap(); - assert_eq!( - node.ledger().status().tip_hash, - peer_ledger.status().tip_hash + block3_outcome.block.is_some(), + "pending own blinded tx must not starve the next anchor burn: {:?}", + block3_outcome.skipped_reason ); - } - - #[test] - fn inbound_blinded_transaction_conflicting_with_local_anchor_is_not_queued() { - let alice = Wallet::from_seed("local-anchor-inbound-alice"); - let bob = Wallet::from_seed("local-anchor-inbound-bob"); - let finalizers = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = finalizers - .iter() - .find(|wallet| wallet.address() == leader) - .unwrap() - .clone(); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - leader_wallet.clone(), - ledger, - true, - MICRO_IUNA / 10, - 1, + let block3 = block3_outcome.block.unwrap(); + assert!( + block3.blinded_transactions.is_empty() + || block3 + .blinded_transactions + .iter() + .any(|tx| tx.commitment == blinded.commitment), + "block 3 may include the during-VDF tx, but must not stall when the anchor burn has priority" ); - - let plan = node.prepare_automatic_finalization(1); - assert!(plan.burned.is_some()); - let (_, anchor_burn) = node - .local_block_anchor_burn - .clone() - .expect("leader burn should be held as a local block anchor"); - let anchor_inputs = super::transaction_input_outpoints(&anchor_burn) - .into_iter() - .collect::<Vec<_>>(); - let conflicting_payload = node - .ledger() - .build_transfer_with_inputs(&leader_wallet, bob.address(), 1, 0, &anchor_inputs) - .unwrap(); - let conflicting = node - .ledger() - .build_blinded_transaction(&leader_wallet, conflicting_payload, node.chain_height() + 4) - .unwrap(); - - node.receive_blinded_transaction(conflicting.transaction) - .unwrap(); - - assert!(node.ledger().pending_blinded_transactions().is_empty()); - assert!(node.drain_outbox().is_empty()); - assert!(node.prepare_automatic_finalization(1).work.is_some()); } #[test] @@ -4159,650 +583,4 @@ mod tests { ); } } - - #[derive(Clone, Debug)] - struct ChaosRng { - state: u64, - } - - impl ChaosRng { - fn new(seed: u64) -> Self { - Self { - state: seed ^ 0x517c_c1b7_2722_0a95, - } - } - - fn next_u64(&mut self) -> u64 { - self.state = self - .state - .wrapping_mul(6_364_136_223_846_793_005) - .wrapping_add(1_442_695_040_888_963_407); - self.state - } - - fn index(&mut self, len: usize) -> usize { - assert!(len > 0); - (self.next_u64() as usize) % len - } - } - - #[test] - fn sparse_network_stays_bounded_under_generated_actions() { - const NODES: usize = 10; - const ROUNDS: usize = 36; - const SETTLE_BLOCKS: u64 = super::MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS + 10; - const QUIET_BLOCKS: usize = SETTLE_BLOCKS as usize + 4; - - let mut rng = ChaosRng::new(0x1aba_0100); - let wallets = (0..NODES) - .map(|index| Wallet::from_seed(&format!("sparse-network-chaos-{index}"))) - .collect::<Vec<_>>(); - let allocations = wallets - .iter() - .map(|wallet| (wallet.address().to_string(), 75 * MICRO_IUNA)) - .collect::<BTreeMap<_, _>>(); - let genesis_burns = wallets - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect::<Vec<_>>(); - let ledger = Ledger::new_with_genesis_burns(allocations, genesis_burns, 1) - .expect("chaos genesis is valid"); - let node_ids = (0..NODES) - .map(|index| format!("n{index}")) - .collect::<Vec<_>>(); - let peers = sparse_chaos_peers(NODES, &mut rng); - let mut offline_until = vec![0_usize; NODES]; - let mut pending_since = BTreeMap::new(); - let mut network = InMemoryNetwork::default(); - - for (index, wallet) in wallets.iter().enumerate() { - let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain"); - let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( - wallet.clone(), - joined, - true, - MICRO_IUNA / 10, - 0, - ); - node.set_recovery_vdf_top_rank_percent(100); - network.insert(node_ids[index].clone(), node); - } - - for round in 0..ROUNDS { - let online = online_chaos_nodes(&offline_until, round); - for index in online.iter().copied().collect::<Vec<_>>() { - match rng.index(32) { - 0 => { - let recipient = wallets[rng.index(wallets.len())].address().to_string(); - let expiry_height = network - .node(&node_ids[index]) - .expect("actor node exists") - .chain_height() - + 16; - let _ = network - .node_mut(&node_ids[index]) - .expect("actor node exists") - .blinded_transfer_with_fee(recipient, 1, 0, expiry_height); - } - 1 => { - attempt_bounded_mine_action( - &mut network, - &node_ids[index], - wallets[index].address(), - &mut rng, - ); - } - 2 if online.len() > 1 => { - offline_until[index] = round + 1 + rng.index(4); - } - _ => {} - } - } - - deliver_sparse_chaos_until_idle( - &mut network, - &node_ids, - &peers, - &online, - chaos_timestamp(round), - &mut rng, - ); - mine_one_sparse_chaos_block( - &mut network, - &node_ids, - &wallets, - &online, - chaos_timestamp(round), - ); - deliver_sparse_chaos_until_idle( - &mut network, - &node_ids, - &peers, - &online, - chaos_timestamp(round), - &mut rng, - ); - observe_bounded_pending(&network, &node_ids, &mut pending_since, SETTLE_BLOCKS); - } - - for round in ROUNDS..ROUNDS + QUIET_BLOCKS { - let online = online_chaos_nodes(&offline_until, round); - deliver_sparse_chaos_until_idle( - &mut network, - &node_ids, - &peers, - &online, - chaos_timestamp(round), - &mut rng, - ); - mine_one_sparse_chaos_block( - &mut network, - &node_ids, - &wallets, - &online, - chaos_timestamp(round), - ); - deliver_sparse_chaos_until_idle( - &mut network, - &node_ids, - &peers, - &online, - chaos_timestamp(round), - &mut rng, - ); - observe_bounded_pending(&network, &node_ids, &mut pending_since, SETTLE_BLOCKS); - } - - let all_online = (0..NODES).collect::<BTreeSet<_>>(); - for round in ROUNDS + QUIET_BLOCKS..ROUNDS + QUIET_BLOCKS + SETTLE_BLOCKS as usize * 4 { - deliver_sparse_chaos_until_idle( - &mut network, - &node_ids, - &peers, - &all_online, - chaos_timestamp(round), - &mut rng, - ); - if !sparse_chaos_has_pending(&network, &node_ids) { - break; - } - mine_one_sparse_chaos_block( - &mut network, - &node_ids, - &wallets, - &all_online, - chaos_timestamp(round), - ); - deliver_sparse_chaos_until_idle( - &mut network, - &node_ids, - &peers, - &all_online, - chaos_timestamp(round), - &mut rng, - ); - observe_bounded_pending(&network, &node_ids, &mut pending_since, SETTLE_BLOCKS); - } - assert_sparse_chaos_converged(&network, &node_ids); - assert_sparse_chaos_mempools_empty(&network, &node_ids); - } - - fn sparse_chaos_peers(nodes: usize, rng: &mut ChaosRng) -> Vec<Vec<usize>> { - let mut peers = vec![BTreeSet::new(); nodes]; - for index in 0..nodes { - let next = (index + 1) % nodes; - peers[index].insert(next); - peers[next].insert(index); - } - for index in 0..nodes { - let target_degree = 1 + rng.index(4); - while peers[index].len() < target_degree { - let peer = rng.index(nodes); - if peer != index { - peers[index].insert(peer); - peers[peer].insert(index); - } - } - } - peers - .into_iter() - .map(|set| set.into_iter().take(4).collect()) - .collect() - } - - fn online_chaos_nodes(offline_until: &[usize], round: usize) -> BTreeSet<usize> { - offline_until - .iter() - .enumerate() - .filter_map(|(index, until)| (*until <= round).then_some(index)) - .collect() - } - - fn chaos_timestamp(round: usize) -> u64 { - (round as u64 + 1) * (RECOVERY_BLOCK_DELAY_MS + VDF_TARGET_BLOCK_MS) - } - - fn attempt_bounded_mine_action( - network: &mut InMemoryNetwork, - node_id: &str, - recipient: &str, - rng: &mut ChaosRng, - ) { - let outcome = network - .node(node_id) - .expect("mine actor exists") - .ledger() - .search_mine(recipient, rng.next_u64(), 0, 4) - .expect("bounded mine search is valid"); - let Some(transaction) = outcome.transaction else { - return; - }; - let _ = network - .node_mut(node_id) - .expect("mine actor exists") - .receive_mine_action(transaction); - } - - fn mine_one_sparse_chaos_block( - network: &mut InMemoryNetwork, - node_ids: &[String], - wallets: &[Wallet], - online: &BTreeSet<usize>, - timestamp_ms: u64, - ) { - let Some(reference_index) = highest_online_node(network, node_ids, online) else { - return; - }; - let reference = network - .node(&node_ids[reference_index]) - .expect("reference node exists"); - let leader = reference.ledger().expected_leader_for_next_block(); - let mut candidates = Vec::new(); - if let Some(index) = leader - .as_deref() - .and_then(|leader| { - wallets - .iter() - .position(|wallet| wallet.address() == leader) - .filter(|index| online.contains(index)) - }) - .filter(|index| { - network.node(&node_ids[*index]).unwrap().chain_height() == reference.chain_height() - }) - { - candidates.push(index); - } - candidates.push(reference_index); - candidates.extend(online.iter().copied().filter(|index| { - network.node(&node_ids[*index]).unwrap().chain_height() == reference.chain_height() - })); - let mut seen = BTreeSet::new(); - for producer_index in candidates { - if !seen.insert(producer_index) { - continue; - } - let mut producer = network - .node(&node_ids[producer_index]) - .expect("producer node exists") - .clone(); - let plan = producer.prepare_automatic_finalization(timestamp_ms); - let Some(work) = plan.work else { - continue; - }; - let block = work.finish_at( - &wallets[producer_index], - "preverified-chaos-vdf".to_string(), - timestamp_ms, - ); - network - .node_mut(&node_ids[producer_index]) - .expect("producer node exists") - .receive_preverified_block_at(block, timestamp_ms) - .expect("mock-VDF block applies locally"); - return; - } - } - - fn highest_online_node( - network: &InMemoryNetwork, - node_ids: &[String], - online: &BTreeSet<usize>, - ) -> Option<usize> { - online.iter().copied().max_by_key(|index| { - network - .node(&node_ids[*index]) - .expect("online node exists") - .chain_height() - }) - } - - fn deliver_sparse_chaos_until_idle( - network: &mut InMemoryNetwork, - node_ids: &[String], - peers: &[Vec<usize>], - online: &BTreeSet<usize>, - timestamp_ms: u64, - rng: &mut ChaosRng, - ) { - for _ in 0..512 { - let mut progressed = - sync_sparse_chaos_once(network, node_ids, peers, online, timestamp_ms); - progressed |= - deliver_sparse_chaos_once(network, node_ids, peers, online, timestamp_ms, rng); - if !progressed { - return; - } - } - panic!("sparse chaos network did not become idle"); - } - - fn sync_sparse_chaos_once( - network: &mut InMemoryNetwork, - node_ids: &[String], - peers: &[Vec<usize>], - online: &BTreeSet<usize>, - timestamp_ms: u64, - ) -> bool { - let mut syncs = Vec::new(); - for from in online { - let from_height = network.node(&node_ids[*from]).unwrap().chain_height(); - for to in &peers[*from] { - if !online.contains(to) { - continue; - } - let to_height = network.node(&node_ids[*to]).unwrap().chain_height(); - if from_height > to_height { - let blocks = network - .node(&node_ids[*from]) - .unwrap() - .blocks_from(to_height + 1, 16); - syncs.push((*to, blocks)); - } - } - } - - let mut progressed = false; - for (to, blocks) in syncs { - for block in blocks { - let before = network.node(&node_ids[to]).unwrap().chain_height(); - receive_sparse_chaos_block(network, &node_ids[to], block, timestamp_ms); - progressed |= network.node(&node_ids[to]).unwrap().chain_height() > before; - } - } - progressed - } - - fn deliver_sparse_chaos_once( - network: &mut InMemoryNetwork, - node_ids: &[String], - peers: &[Vec<usize>], - online: &BTreeSet<usize>, - timestamp_ms: u64, - rng: &mut ChaosRng, - ) -> bool { - let mut outbound = Vec::new(); - for from in online { - let node = network - .node_mut(&node_ids[*from]) - .expect("online node exists"); - outbound.extend( - node.drain_outbox() - .into_iter() - .map(|envelope| (*from, envelope)), - ); - } - if outbound.is_empty() { - return false; - } - - while !outbound.is_empty() { - let index = rng.index(outbound.len()); - let (from, envelope) = outbound.swap_remove(index); - for to in &peers[from] { - if online.contains(to) { - receive_sparse_chaos_envelope( - network, - &node_ids[*to], - envelope.clone(), - timestamp_ms, - ); - } - } - } - true - } - - fn receive_sparse_chaos_envelope( - network: &mut InMemoryNetwork, - node_id: &str, - envelope: GossipEnvelope, - timestamp_ms: u64, - ) { - match envelope { - GossipEnvelope::Block(block) => { - receive_sparse_chaos_block(network, node_id, block, timestamp_ms); - } - other => { - if let Err(error) = network - .node_mut(node_id) - .expect("node exists") - .receive(other) - { - let message = error.to_string(); - assert!( - message.contains("mine transaction anchor is not on this chain") - || message.contains("conflicts with an existing pending transaction") - || message.contains("blinded transaction expired"), - "unexpected sparse chaos delivery error: {message}" - ); - } - } - } - } - - fn receive_sparse_chaos_block( - network: &mut InMemoryNetwork, - node_id: &str, - block: crate::domain::Block, - timestamp_ms: u64, - ) { - if let Err(error) = network - .node_mut(node_id) - .expect("node exists") - .receive_preverified_block_at(block, timestamp_ms) - { - let message = error.to_string(); - assert!( - message.contains("expected block height") - || message.contains("same-height fork") - || message.contains("block is already known"), - "unexpected sparse chaos block error: {message}" - ); - } - } - - fn observe_bounded_pending( - network: &InMemoryNetwork, - node_ids: &[String], - pending_since: &mut BTreeMap<String, u64>, - settle_blocks: u64, - ) { - let mut current = BTreeSet::new(); - for node_id in node_ids { - let node = network.node(node_id).expect("node exists"); - for id in pending_item_ids(node) { - current.insert(id); - } - } - let max_height = node_ids - .iter() - .map(|id| network.node(id).unwrap().chain_height()) - .max() - .unwrap_or_default(); - pending_since.retain(|id, _| current.contains(id)); - for id in current { - pending_since.entry(id).or_insert(max_height); - } - for (id, first_height) in pending_since { - assert!( - max_height.saturating_sub(*first_height) <= settle_blocks, - "pending item {id} stayed in mempool for more than {settle_blocks} blocks" - ); - } - } - - fn pending_item_ids(node: &NodeCore) -> Vec<String> { - let mut ids = Vec::new(); - ids.extend( - node.pending_transactions() - .into_iter() - .map(|tx| format!("tx:{}", tx.signature())), - ); - ids.extend( - node.pending_blinded_transactions() - .into_iter() - .map(|tx| format!("commit:{}@{}", tx.commitment, tx.expires_at_height)), - ); - ids.extend( - node.pending_blinded_reveals() - .into_iter() - .map(|reveal| format!("reveal:{}", reveal.commitment)), - ); - ids - } - - fn assert_sparse_chaos_converged(network: &InMemoryNetwork, node_ids: &[String]) { - let first = network.node(&node_ids[0]).expect("first node exists"); - let height = first.chain_height(); - let tip = first.ledger().status().tip_hash.clone(); - for node_id in node_ids.iter().skip(1) { - let node = network.node(node_id).expect("node exists"); - assert_eq!(node.chain_height(), height, "{node_id} height diverged"); - assert_eq!( - node.ledger().status().tip_hash, - tip, - "{node_id} tip diverged" - ); - } - } - - fn sparse_chaos_has_pending(network: &InMemoryNetwork, node_ids: &[String]) -> bool { - node_ids.iter().any(|node_id| { - let node = network.node(node_id).expect("node exists"); - !node.pending_transactions().is_empty() - || !node.pending_blinded_transactions().is_empty() - || !node.pending_blinded_reveals().is_empty() - }) - } - - fn assert_sparse_chaos_mempools_empty(network: &InMemoryNetwork, node_ids: &[String]) { - for node_id in node_ids { - let node = network.node(node_id).expect("node exists"); - let pending = pending_item_ids(node); - assert!( - pending.is_empty(), - "{node_id} at height {} still has pending mempool items: {pending:?}", - node.chain_height() - ); - } - } -} - -#[derive(Debug, Default)] -pub struct InMemoryNetwork { - nodes: BTreeMap<String, NodeCore>, -} - -impl InMemoryNetwork { - pub fn insert(&mut self, id: impl Into<String>, node: NodeCore) { - self.nodes.insert(id.into(), node); - } - - pub fn node(&self, id: &str) -> Option<&NodeCore> { - self.nodes.get(id) - } - - pub fn node_mut(&mut self, id: &str) -> Option<&mut NodeCore> { - self.nodes.get_mut(id) - } - - pub fn deliver_until_idle(&mut self) -> Result<()> { - loop { - let mut outbound = Vec::new(); - for (id, node) in &mut self.nodes { - for envelope in node.drain_outbox() { - outbound.push((id.clone(), envelope)); - } - } - - if outbound.is_empty() { - return Ok(()); - } - - for (from, envelope) in outbound { - for (id, node) in &mut self.nodes { - if *id != from { - receive_in_memory_envelope(node, envelope.clone())?; - } - } - } - } - } - - pub fn gossip_mempools_once(&mut self) -> Result<()> { - let mut outbound = Vec::new(); - for (id, node) in &mut self.nodes { - for envelope in node.mempool_gossip() { - outbound.push((id.clone(), envelope)); - } - } - - for (from, envelope) in outbound { - for (id, node) in &mut self.nodes { - if *id != from { - receive_in_memory_envelope(node, envelope.clone())?; - } - } - } - Ok(()) - } - - pub fn sync_node_from_peer(&mut self, from: &str, to: &str, limit: usize) -> Result<bool> { - let from_height = self - .nodes - .get(to) - .map(|node| node.chain_height() + 1) - .ok_or_else(|| anyhow::anyhow!("missing sync target node {to}"))?; - let blocks = self - .nodes - .get(from) - .map(|node| node.blocks_from(from_height, limit)) - .ok_or_else(|| anyhow::anyhow!("missing sync source node {from}"))?; - if blocks.is_empty() { - return Ok(false); - } - - self.nodes - .get_mut(to) - .expect("sync target exists") - .receive(GossipEnvelope::Blocks { blocks })?; - Ok(true) - } -} - -fn receive_in_memory_envelope(node: &mut NodeCore, envelope: GossipEnvelope) -> Result<()> { - let transaction_like = matches!( - envelope, - GossipEnvelope::BlindedTransaction(_) - | GossipEnvelope::BlindedTransactions { .. } - | GossipEnvelope::MineAction(_) - | GossipEnvelope::MineActions { .. } - | GossipEnvelope::BlindedReveal(_) - | GossipEnvelope::BlindedReveals { .. } - ); - match node.receive(envelope) { - Ok(()) => Ok(()), - Err(_) if transaction_like => Ok(()), - Err(error) => Err(error), - } } diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -0,0 +1,434 @@ +use anyhow::{Context, Result}; + +use super::helpers::{allowed_recovery_vdf_rank_count, recovery_vdf_sample_percent}; +use super::{ + AUTO_BLOCK_ANCHOR_BURN_AMOUNT, AUTO_BLOCK_ANCHOR_BURN_FEE, + AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS, AutoMineOutcome, AutoMinePlan, BuiltBlindedTransaction, + Ledger, NodeCore, PreparedBlock, Transaction, run_vdf, +}; +use crate::domain::Amount; + +mod pow; + +impl NodeCore { + pub fn automatic_mine_once(&mut self, timestamp_ms: u64) -> AutoMineOutcome { + let plan = self.prepare_automatic_mining(timestamp_ms); + let mut outcome = AutoMineOutcome { + pow_mined: plan.pow_mined, + burned: plan.burned, + block: None, + skipped_reason: plan.skipped_reason, + }; + + let Some(work) = plan.work else { + return outcome; + }; + let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); + match self.complete_prepared_block_at(work, vdf_output, timestamp_ms) { + Ok(block) => { + outcome.block = Some(block); + outcome.skipped_reason = None; + } + Err(error) => { + outcome.skipped_reason = Some(format!("{error:#}")); + } + } + + outcome + } + + pub fn prepare_automatic_mining(&mut self, timestamp_ms: u64) -> AutoMinePlan { + let mut plan = AutoMinePlan { + pow_mined: None, + burned: None, + work: None, + skipped_reason: None, + }; + + if self.wallet.is_locked() { + if self.pow_mining_enabled { + self.last_auto_pow_mine_status = Some("wallet is locked".to_string()); + } + return AutoMinePlan { + pow_mined: None, + burned: None, + work: None, + skipped_reason: Some("wallet is locked".to_string()), + }; + } + + let pow_error = match self.prepare_automatic_pow_mine() { + Ok(tx) => { + plan.pow_mined = tx; + None + } + Err(error) => { + let message = format!("automatic PoW mining failed: {error:#}"); + self.last_auto_pow_mine_status = Some(message.clone()); + Some(message) + } + }; + + if !self.automatic_mining_enabled { + plan.skipped_reason = + Some(pow_error.unwrap_or_else(|| "automatic mining is off".to_string())); + return plan; + } + + if let Some(error) = pow_error { + plan.skipped_reason = Some(error); + return plan; + } + + match self.prepare_automatic_burn(timestamp_ms) { + Ok(tx) => plan.burned = tx, + Err(error) => { + plan.skipped_reason = Some(format!("automatic burn failed: {error:#}")); + return plan; + } + } + + if let Err(error) = self.publish_reveal_bundle_for_next_block() { + plan.skipped_reason = Some(format!("{error:#}")); + return plan; + } + + let wallet_rank = self + .ledger + .finalizer_rank_for_next_block(self.wallet.address()); + if let Some(rank) = wallet_rank { + if !self.wallet_rank_runs_vdf(rank) { + plan.skipped_reason = Some(format!( + "wallet finalizer rank {rank} is outside the top {}% VDF threshold", + self.recovery_vdf_top_rank_percent + )); + return plan; + } + } else { + if self.should_prepare_recovery_vdf(timestamp_ms) { + match self.prepare_recovery_block_with_local_anchor(timestamp_ms) { + Ok(work) => { + plan.work = Some(work); + } + Err(error) => { + plan.skipped_reason = Some(format!("{error:#}")); + } + } + } else { + let selected_leader = self.ledger.expected_leader_for_next_block(); + plan.skipped_reason = selected_leader.map(|leader| { + format!("wallet is waiting for selected finalizer {leader} to finish the VDF") + }); + } + return plan; + } + + match self.prepare_next_block_with_local_anchor(timestamp_ms) { + Ok(work) => { + plan.work = Some(work); + } + Err(error) => { + plan.skipped_reason = Some(format!("{error:#}")); + } + } + + plan + } + + pub fn prepare_automatic_finalization(&mut self, timestamp_ms: u64) -> AutoMinePlan { + let mut plan = AutoMinePlan { + pow_mined: None, + burned: None, + work: None, + skipped_reason: None, + }; + + if self.wallet.is_locked() { + plan.skipped_reason = Some("wallet is locked".to_string()); + return plan; + } + + if !self.automatic_mining_enabled { + plan.skipped_reason = Some("automatic mining is off".to_string()); + return plan; + } + + match self.prepare_automatic_burn(timestamp_ms) { + Ok(tx) => plan.burned = tx, + Err(error) => { + plan.skipped_reason = Some(format!("automatic burn failed: {error:#}")); + return plan; + } + } + + if let Err(error) = self.publish_reveal_bundle_for_next_block() { + plan.skipped_reason = Some(format!("{error:#}")); + return plan; + } + + let wallet_rank = self + .ledger + .finalizer_rank_for_next_block(self.wallet.address()); + if let Some(rank) = wallet_rank { + if !self.wallet_rank_runs_vdf(rank) { + plan.skipped_reason = Some(format!( + "wallet finalizer rank {rank} is outside the top {}% VDF threshold", + self.recovery_vdf_top_rank_percent + )); + return plan; + } + } else { + if self.should_prepare_recovery_vdf(timestamp_ms) { + match self.prepare_recovery_block_with_local_anchor(timestamp_ms) { + Ok(work) => { + plan.work = Some(work); + } + Err(error) => { + plan.skipped_reason = Some(format!("{error:#}")); + } + } + } else { + let selected_leader = self.ledger.expected_leader_for_next_block(); + plan.skipped_reason = selected_leader.map(|leader| { + format!("wallet is waiting for selected finalizer {leader} to finish the VDF") + }); + } + return plan; + } + + match self.prepare_next_block_with_local_anchor(timestamp_ms) { + Ok(work) => { + plan.work = Some(work); + } + Err(error) => { + plan.skipped_reason = Some(format!("{error:#}")); + } + } + + plan + } + + pub(super) fn prepare_automatic_burn( + &mut self, + timestamp_ms: u64, + ) -> Result<Option<Transaction>> { + let current_height = self.ledger.status().height; + if !self.automatic_mining_enabled { + return Ok(None); + } + let anchor_burn = self.prepare_automatic_anchor_burn(timestamp_ms)?; + if self.burn_per_block == 0 { + self.last_auto_burn_height = Some(current_height); + return Ok(anchor_burn); + } + if self.last_auto_burn_height == Some(current_height) { + return Ok(anchor_burn); + } + + let fee_per_byte = self.burn_fee; + let balance = self.ledger.balance_of(self.wallet.address()); + let ledger = self.wallet_build_ledger()?; + let best = self.best_automatic_burn_on_ledger(&ledger, fee_per_byte, balance); + let Some(tx) = best else { + self.last_auto_burn_height = Some(current_height); + return Ok(anchor_burn); + }; + let burn = tx.payload.clone(); + self.submit_owned_blinded_transaction(tx)?; + self.last_auto_burn_height = Some(current_height); + Ok(Some(burn)) + } + + fn prepare_automatic_anchor_burn(&mut self, timestamp_ms: u64) -> Result<Option<Transaction>> { + let current_height = self.ledger.status().height; + if !self.automatic_burn_needs_plaintext_anchor(timestamp_ms) { + return Ok(None); + } + if self + .local_block_anchor_burn + .as_ref() + .is_some_and(|(height, _)| *height == current_height) + { + return Ok(None); + } + if self.last_auto_anchor_burn_height == Some(current_height) { + return Ok(None); + } + + let ledger = self.wallet_anchor_build_ledger()?; + let wallet = self.wallet.unlocked()?; + let required = AUTO_BLOCK_ANCHOR_BURN_AMOUNT + .checked_add(AUTO_BLOCK_ANCHOR_BURN_FEE) + .context("automatic finalizer anchor burn amount plus fee overflows")?; + let outpoint = ledger + .available_utxos_for_address(wallet.address())? + .into_iter() + .filter(|(_, output)| output.amount >= required) + .min_by_key(|(_, output)| output.amount) + .map(|(outpoint, _)| outpoint); + let burn = match outpoint { + Some(outpoint) => ledger.build_burn_with_inputs( + wallet, + AUTO_BLOCK_ANCHOR_BURN_AMOUNT, + AUTO_BLOCK_ANCHOR_BURN_FEE, + &[outpoint], + ), + None => ledger.build_burn( + wallet, + AUTO_BLOCK_ANCHOR_BURN_AMOUNT, + AUTO_BLOCK_ANCHOR_BURN_FEE, + ), + }; + let burn = match burn { + Ok(burn) => burn, + Err(error) => { + self.last_auto_anchor_burn_height = Some(current_height); + return Err(error).context("automatic finalizer anchor burn failed"); + } + }; + self.local_block_anchor_burn = Some((current_height, burn.clone())); + self.last_auto_anchor_burn_height = Some(current_height); + Ok(Some(burn)) + } + + fn best_automatic_burn_on_ledger( + &self, + ledger: &Ledger, + fee_per_byte: Amount, + balance: Amount, + ) -> Option<BuiltBlindedTransaction> { + let target = self.burn_per_block.min(balance); + if target == 0 { + return None; + } + let exact_at_fee_rate = + self.build_blinded_burn_with_fee_rate_on_ledger(ledger, target, fee_per_byte); + if let Ok((built, estimate)) = exact_at_fee_rate { + if target + .checked_add(estimate.fee) + .is_some_and(|required| required <= balance) + { + return Some(built); + } + } + if self.burn_per_block <= balance { + let affordable_fee = balance.saturating_sub(target); + if let Ok(built) = + self.build_blinded_burn_with_fee_on_ledger(ledger, target, affordable_fee) + { + return Some(built); + } + } + + let mut low = 1; + let mut high = target; + let mut best = None; + while low <= high { + let amount = low + (high - low) / 2; + match self.build_blinded_burn_with_fee_rate_on_ledger(ledger, amount, fee_per_byte) { + Ok((built, estimate)) => { + let fits = amount + .checked_add(estimate.fee) + .is_some_and(|required| required <= balance); + if fits { + best = Some(built); + if amount == Amount::MAX { + break; + } + low = amount + 1; + } else { + high = amount.saturating_sub(1); + } + } + Err(_) => { + high = amount.saturating_sub(1); + } + } + } + best + } + + fn automatic_burn_needs_plaintext_anchor(&self, timestamp_ms: u64) -> bool { + self.ledger + .finalizer_rank_for_next_block(self.wallet.address()) + .is_some_and(|rank| self.wallet_rank_runs_vdf(rank)) + || self.should_prepare_recovery_vdf(timestamp_ms) + || timestamp_ms.saturating_add(AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS) + >= self.ledger.recovery_block_min_timestamp() + } + + fn wallet_rank_runs_vdf(&self, rank: u32) -> bool { + let rank_count = self.ledger.finalizer_rank_count_for_next_block(); + let allowed = + allowed_recovery_vdf_rank_count(rank_count, self.recovery_vdf_top_rank_percent); + usize::try_from(rank).is_ok_and(|rank| rank < allowed) + } + + fn should_prepare_recovery_vdf(&self, timestamp_ms: u64) -> bool { + if !self.ledger.recovery_block_available_at(timestamp_ms) { + return false; + } + if self.recovery_vdf_top_rank_percent == 100 { + return true; + } + if self.recovery_vdf_top_rank_percent == 0 { + return false; + } + if self.ledger.finalizer_rank_count_for_next_block() > 0 { + return false; + } + let tip_hash = self.ledger.status().tip_hash; + recovery_vdf_sample_percent(self.wallet.address(), tip_hash.as_str()) + < self.recovery_vdf_top_rank_percent + } + + pub(super) fn prepare_next_block_with_local_anchor( + &self, + timestamp_ms: u64, + ) -> Result<PreparedBlock> { + let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor(); + ledger.prepare_next_block_with_required_burn_and_reveal_bundles( + self.wallet.address(), + timestamp_ms, + self.usable_reveal_bundles(), + required_burn_signature.as_deref(), + ) + } + + fn prepare_recovery_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> { + let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor(); + ledger.prepare_recovery_block_with_required_burn_and_reveal_bundles( + self.wallet.address(), + timestamp_ms, + self.usable_reveal_bundles(), + required_burn_signature.as_deref(), + ) + } + + fn ledger_with_local_block_anchor(&self) -> (Ledger, Option<String>) { + let mut ledger = self.ledger.clone(); + let Some((height, burn)) = &self.local_block_anchor_burn else { + return (ledger, None); + }; + if *height == ledger.height() && !ledger.has_transaction(burn.signature()) { + ledger.drop_pending_blinded_conflicting_with_transaction(burn); + if ledger.submit_transaction(burn.clone()).is_ok() { + return (ledger, Some(burn.signature().to_string())); + } + } + (ledger, None) + } + + pub(super) 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; + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/app/automatic_mining/pow.rs b/src/app/automatic_mining/pow.rs @@ -0,0 +1,96 @@ +use anyhow::{Context, Result}; + +use super::super::helpers::auto_pow_salt; +use super::super::{ + AUTO_POW_NONCE_ATTEMPTS_PER_WORKER_TICK, AutoPowMineCursor, MINE_ACTIONS_PER_ANCHOR_LIMIT, + NodeCore, Transaction, +}; + +impl NodeCore { + pub fn prepare_automatic_pow_mining(&mut self) -> Result<Option<Transaction>> { + if self.wallet.is_locked() { + if self.pow_mining_enabled { + self.last_auto_pow_mine_status = Some("wallet is locked".to_string()); + } + return Ok(None); + } + if self.pow_mining_enabled && !self.has_real_chain() { + self.last_auto_pow_mine_status = + Some("waiting for a real chain before PoW mining can start".to_string()); + self.auto_pow_mine_cursor = None; + return Ok(None); + } + + self.prepare_automatic_pow_mine() + } + + pub fn record_automatic_pow_mining_error(&mut self, message: String) { + self.last_auto_pow_mine_status = Some(message); + } + + pub(super) fn prepare_automatic_pow_mine(&mut self) -> Result<Option<Transaction>> { + if !self.pow_mining_enabled { + self.last_auto_pow_mine_status = None; + self.auto_pow_mine_cursor = None; + return Ok(None); + } + let anchor = self + .ledger + .chain() + .last() + .map(|block| block.hash.clone()) + .context("ledger has no anchor block")?; + if self.ledger.pending_mine_count_for_anchor(&anchor) >= MINE_ACTIONS_PER_ANCHOR_LIMIT { + self.last_auto_pow_mine_anchor = Some(anchor); + self.last_auto_pow_mine_status = + Some("waiting for next chain tip after queued mine actions".to_string()); + self.auto_pow_mine_cursor = None; + return Ok(None); + } + let wallet_address = self.wallet.address().to_string(); + let needs_cursor = self + .auto_pow_mine_cursor + .as_ref() + .is_none_or(|cursor| cursor.anchor != anchor); + if needs_cursor { + self.auto_pow_mine_cursor = Some(AutoPowMineCursor { + salt: auto_pow_salt(&wallet_address, &anchor), + anchor: anchor.clone(), + next_nonce: 0, + searched: 0, + }); + } + let cursor = self + .auto_pow_mine_cursor + .as_ref() + .context("automatic PoW cursor was not initialized")? + .clone(); + let outcome = self.wallet_build_ledger()?.search_mine( + wallet_address, + cursor.salt, + cursor.next_nonce, + AUTO_POW_NONCE_ATTEMPTS_PER_WORKER_TICK + .saturating_mul(u64::from(self.pow_mining_workers)), + )?; + let mut searched = outcome.attempts; + if let Some(cursor) = &mut self.auto_pow_mine_cursor { + if cursor.anchor == anchor { + cursor.next_nonce = outcome.next_nonce; + cursor.searched = cursor.searched.saturating_add(outcome.attempts); + searched = cursor.searched; + } + } + let Some(tx) = outcome.transaction else { + self.last_auto_pow_mine_status = Some(format!( + "searched {searched} PoW nonces for the current tip; no proof yet" + )); + return Ok(None); + }; + self.submit_public_mine_action(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)) + } +} diff --git a/src/app/automatic_mining/tests.rs b/src/app/automatic_mining/tests.rs @@ -0,0 +1,529 @@ +use std::collections::BTreeMap; + +use crate::{ + adapters::config_store::{DEFAULT_POW_MINING_WORKERS, MAX_POW_MINING_WORKERS}, + app::{GossipEnvelope, NodeConfig, NodeCore}, + domain::{ + FinalizerMode, GenesisBurn, Ledger, MICRO_IUNA, MINE_ACTIONS_PER_ANCHOR_LIMIT, + MINE_FINALIZER_FEE, RECOVERY_BLOCK_DELAY_MS, Transaction, VDF_TARGET_BLOCK_MS, Wallet, + run_vdf, + }, +}; + +#[test] +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(), MICRO_IUNA); + let mut node = NodeCore::new(NodeConfig { + wallet: alice, + genesis_allocations: allocations, + vdf_rounds: 10, + burn_per_block: 1, + burn_fee: 1, + pow_mining_workers: 1, + recovery_vdf_top_rank_percent: 100, + }); + + let first = node.prepare_automatic_mining(1); + assert!(first.burned.is_some()); + assert_eq!(node.last_auto_burn_height, Some(0)); + + let same_height_ledger = node.clone_ledger(); + assert!(!node.import_verified_ledger(same_height_ledger).unwrap()); + assert_eq!(node.last_auto_burn_height, Some(0)); + + let second = node.prepare_automatic_mining(2); + assert!(second.burned.is_none()); +} + +#[test] +fn automatic_pow_mining_searches_bounded_nonce_batches_per_tip() { + let wallet = Wallet::from_seed("automatic-pow-mining-wallet"); + 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()); + assert_eq!( + disabled.skipped_reason.as_deref(), + Some("automatic mining is off") + ); + + node.set_pow_mining_enabled(true); + let first = node.prepare_automatic_mining(2); + 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()) + .expect("bounded PoW search should eventually find a proof"); + let first_mine = first.pow_mined.as_ref().expect("PoW should be queued"); + let Transaction::Mine { + anchor, + recipient, + difficulty_bits, + .. + } = first_mine + else { + panic!("expected mine transaction"); + }; + assert_eq!(anchor, &node.chain().last().unwrap().hash); + assert_eq!(recipient, wallet.address()); + assert_eq!( + *difficulty_bits, + node.ledger().current_mine_difficulty_bits() + ); + let first_pending = node.ledger().pending().len(); + assert!(first_pending >= 1); + assert!(node.ledger().pending_blinded_transactions().is_empty()); + assert!(node.drain_outbox().iter().any(|envelope| { + matches!(envelope, GossipEnvelope::MineAction(tx) if tx.signature() == first_mine.signature()) + })); + assert!( + node.status() + .mining + .last_auto_pow_mine_status + .as_deref() + .unwrap_or_default() + .contains("queued") + ); + + let second = (10_000..20_000) + .map(|timestamp| node.prepare_automatic_mining(timestamp)) + .find(|plan| plan.pow_mined.is_some()) + .expect("automatic PoW should allow a second proof for the same tip"); + let second_mine = second.pow_mined.as_ref().expect("PoW should be queued"); + assert_ne!(second_mine.signature(), first_mine.signature()); + assert_eq!(node.ledger().pending().len(), first_pending + 1); + + for timestamp in 20_000..20_010 { + assert!(node.prepare_automatic_mining(timestamp).pow_mined.is_none()); + } + assert_eq!(node.ledger().pending().len(), first_pending + 1); + assert_eq!( + node.status().mining.last_auto_pow_mine_status.as_deref(), + Some("waiting for next chain tip after queued mine actions") + ); + assert!(node.ledger().pending_blinded_transactions().is_empty()); +} + +#[test] +fn automatic_pow_mining_waits_after_queueing_anchor_limit_for_tip() { + let wallet = Wallet::from_seed("automatic-pow-independent-wallet"); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 1); + let mut node = NodeCore::new(NodeConfig { + wallet, + genesis_allocations: allocations, + vdf_rounds: 10, + burn_per_block: 0, + burn_fee: 0, + pow_mining_workers: 1, + recovery_vdf_top_rank_percent: 100, + }); + + node.set_pow_mining_enabled(true); + let first_mined = (1..10_000) + .find_map(|_| node.prepare_automatic_pow_mining().unwrap()) + .expect("PoW should eventually queue a mine action"); + let anchor = match first_mined { + Transaction::Mine { ref anchor, .. } => anchor.clone(), + _ => panic!("expected mine action"), + }; + assert_eq!(node.ledger().pending_mine_count_for_anchor(&anchor), 1); + + (1..10_000) + .find_map(|_| node.prepare_automatic_pow_mining().unwrap()) + .expect("PoW should allow a second mine action for the same tip"); + assert_eq!( + node.ledger().pending_mine_count_for_anchor(&anchor), + MINE_ACTIONS_PER_ANCHOR_LIMIT + ); + assert!(node.prepare_automatic_pow_mining().unwrap().is_none()); + assert!(node.auto_pow_mine_cursor.is_none()); +} + +#[test] +fn disabling_automatic_pow_mining_clears_local_work() { + let wallet = Wallet::from_seed("automatic-pow-disable-wallet"); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 1); + let mut node = NodeCore::new(NodeConfig { + wallet, + genesis_allocations: allocations, + vdf_rounds: 10, + burn_per_block: 0, + burn_fee: 0, + pow_mining_workers: 1, + recovery_vdf_top_rank_percent: 100, + }); + + node.set_pow_mining_enabled(true); + assert!(node.pow_mining_enabled()); + node.prepare_automatic_pow_mining().unwrap(); + assert!(node.auto_pow_mine_cursor.is_some()); + assert!(node.status().mining.last_auto_pow_mine_status.is_some()); + + node.set_pow_mining_enabled(false); + + assert!(!node.pow_mining_enabled()); + assert!(node.auto_pow_mine_cursor.is_none()); + assert!(node.status().mining.last_auto_pow_mine_status.is_none()); +} + +#[test] +fn automatic_pow_mining_workers_are_clamped_and_reported() { + let wallet = Wallet::from_seed("automatic-pow-workers-wallet"); + let mut node = NodeCore::new(NodeConfig { + wallet, + genesis_allocations: BTreeMap::new(), + vdf_rounds: 10, + burn_per_block: 0, + burn_fee: 0, + pow_mining_workers: 99, + recovery_vdf_top_rank_percent: 100, + }); + + assert_eq!(node.pow_mining_workers(), MAX_POW_MINING_WORKERS); + assert_eq!( + node.status().mining.max_pow_mining_workers, + MAX_POW_MINING_WORKERS + ); + + node.set_pow_mining_workers(0); + + assert_eq!(node.pow_mining_workers(), DEFAULT_POW_MINING_WORKERS); + assert_eq!( + node.status().mining.pow_mining_workers, + DEFAULT_POW_MINING_WORKERS + ); +} + +#[test] +fn automatic_pow_mining_skips_unspendable_owned_blinded_payloads() { + let alice = Wallet::from_seed("automatic-pow-stale-owned-blind-alice"); + let bob = Wallet::from_seed("automatic-pow-stale-owned-blind-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(bob.address(), MICRO_IUNA)], + 10, + ) + .unwrap(); + let mut node = NodeCore::from_ledger(alice.clone(), ledger, 0); + + let blinded = node + .blinded_burn_with_fee(MICRO_IUNA / 10, 7, node.chain_height() + 4) + .unwrap(); + let mut finalizer_ledger = node.ledger().clone(); + let leader_burn = finalizer_ledger.build_burn(&bob, 1, 0).unwrap(); + finalizer_ledger.submit_transaction(leader_burn).unwrap(); + let commit_block = finalizer_ledger.mine_next_block(&bob, 1).unwrap(); + assert!( + commit_block + .blinded_transactions + .iter() + .any(|tx| tx.commitment == blinded.commitment) + ); + finalizer_ledger.apply_block(commit_block).unwrap(); + assert!(node.import_verified_ledger(finalizer_ledger).unwrap()); + assert!( + node.ledger() + .has_unrevealed_blinded_transaction(&blinded.commitment) + ); + + node.set_pow_mining_enabled(true); + + assert!(node.prepare_automatic_pow_mining().is_ok()); +} + +#[test] +fn automatic_pow_mining_skips_stale_local_anchor_reservation() { + let wallet = Wallet::from_seed("automatic-pow-stale-anchor-wallet"); + let mut stale_allocations = BTreeMap::new(); + stale_allocations.insert(wallet.address().to_string(), MICRO_IUNA); + let stale_ledger = Ledger::new(stale_allocations, 10); + let stale_anchor = stale_ledger.build_burn(&wallet, 1, 0).unwrap(); + let live_ledger = Ledger::new(BTreeMap::new(), 10); + let mut node = NodeCore::from_ledger(wallet.clone(), live_ledger, 0); + node.local_block_anchor_burn = Some((node.chain_height(), stale_anchor)); + node.set_pow_mining_enabled(true); + + assert!(node.prepare_automatic_pow_mining().is_ok()); +} + +#[test] +fn automatic_finalization_does_not_tick_pow_mining() { + let wallet = Wallet::from_seed("automatic-pow-separated-finalizer-wallet"); + let mut node = NodeCore::new(NodeConfig { + wallet, + genesis_allocations: BTreeMap::new(), + vdf_rounds: 10, + burn_per_block: 0, + burn_fee: 0, + pow_mining_workers: 1, + recovery_vdf_top_rank_percent: 100, + }); + + node.set_pow_mining_enabled(true); + let _ = node.prepare_automatic_finalization(1); + + assert!(node.auto_pow_mine_cursor.is_none()); +} + +#[test] +fn automatic_finalization_prepares_recovery_after_ticket_timeout() { + let alice = Wallet::from_seed("automatic-recovery-alice"); + let bob = Wallet::from_seed("automatic-recovery-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + let ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) + .unwrap(); + let mut node = NodeCore::from_ledger(bob, ledger, 1); + + let early = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS - 1); + assert!(early.work.is_none()); + assert!( + early + .skipped_reason + .as_deref() + .unwrap_or_default() + .contains("waiting for selected finalizer") + ); + + let recovery = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS); + let work = recovery.work.expect("recovery work should be prepared"); + let block = work.finish( + node.wallet.unlocked().unwrap(), + "preverified-vdf".to_string(), + ); + + assert_eq!(block.finalizer_mode, FinalizerMode::Recovery); + assert!(block.leader_proof.is_none()); +} + +#[test] +fn automatic_finalization_respects_zero_recovery_vdf_threshold() { + let alice = Wallet::from_seed("automatic-recovery-zero-alice"); + let bob = Wallet::from_seed("automatic-recovery-zero-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + let ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) + .unwrap(); + let mut node = NodeCore::from_ledger(bob, ledger, 1); + node.set_recovery_vdf_top_rank_percent(0); + + let recovery = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS); + + assert!(recovery.work.is_none()); +} + +#[test] +fn automatic_non_leader_burn_is_queued_as_blinded() { + let alice = Wallet::from_seed("auto-blinded-burn-alice"); + let bob = Wallet::from_seed("auto-blinded-burn-bob"); + let carol = Wallet::from_seed("auto-blinded-burn-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + assert_eq!(ledger.finalizer_rank_for_next_block(carol.address()), None); + let mut node = + NodeCore::from_ledger_with_burn_fee_and_enabled(carol, ledger, true, MICRO_IUNA / 10, 1); + + let plan = node.prepare_automatic_finalization(1); + let outbox = node.drain_outbox(); + + assert!(plan.burned.is_some()); + assert!(node.ledger().pending().is_empty()); + assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); + assert!( + outbox + .iter() + .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_))) + ); +} + +#[test] +fn automatic_fallback_finalizer_prepares_anchor_and_blinded_burn() { + let alice = Wallet::from_seed("auto-fallback-burn-alice"); + let bob = Wallet::from_seed("auto-fallback-burn-bob"); + let finalizers = [alice.clone(), bob.clone()]; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + let fallback = finalizers + .iter() + .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1)) + .unwrap() + .clone(); + let mut node = + NodeCore::from_ledger_with_burn_fee_and_enabled(fallback, ledger, true, MICRO_IUNA / 10, 1); + + let plan = node.prepare_automatic_finalization(1); + let outbox = node.drain_outbox(); + + assert!(plan.burned.is_some()); + assert!( + plan.skipped_reason.is_none(), + "fallback should not be skipped: {:?}", + plan.skipped_reason + ); + assert!(node.ledger().pending().is_empty()); + assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); + let (_, anchor_burn) = node + .local_block_anchor_burn + .as_ref() + .expect("fallback anchor burn should be held locally"); + let anchor_signature = anchor_burn.signature().to_string(); + assert_eq!(anchor_burn.amount(), super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT); + assert!( + outbox + .iter() + .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_))) + ); + let work = plan.work.expect("fallback work should be prepared"); + let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); + let block = node + .complete_prepared_block_at(work, vdf_output, VDF_TARGET_BLOCK_MS * 2) + .unwrap(); + + assert_eq!(block.finalizer_rank, 1); + assert_eq!(block.finalizer_mode, FinalizerMode::Ticket); + assert!(block.transactions.iter().any(|transaction| { + transaction.is_burn() && transaction.amount() == super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT + })); + assert_eq!( + block + .transactions + .first() + .map(|transaction| transaction.signature()), + Some(anchor_signature.as_str()) + ); + assert!(!block.blinded_transactions.is_empty()); + assert_eq!(node.ledger().height(), 1); +} + +#[test] +fn automatic_leader_prepares_anchor_and_blinded_burn() { + let alice = Wallet::from_seed("auto-plaintext-burn-alice"); + let bob = Wallet::from_seed("auto-plaintext-burn-bob"); + let finalizers = [alice.clone(), bob.clone()]; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + let mut ledger = Ledger::new_with_genesis_burns( + allocations, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + let leader = ledger.expected_leader_for_next_block().unwrap(); + let leader_wallet = finalizers + .iter() + .find(|wallet| wallet.address() == leader) + .unwrap() + .clone(); + for wallet in &finalizers { + let split = ledger + .build_transfer(wallet, wallet.address(), MICRO_IUNA, 0) + .unwrap(); + ledger.submit_transaction(split).unwrap(); + } + let anchor = ledger.build_burn(&leader_wallet, 1, 0).unwrap(); + ledger.submit_transaction(anchor).unwrap(); + let split_block = ledger.mine_next_block(&leader_wallet, 1).unwrap(); + ledger.apply_block(split_block).unwrap(); + let leader = ledger.expected_leader_for_next_block().unwrap(); + let leader_wallet = finalizers + .iter() + .find(|wallet| wallet.address() == leader) + .unwrap() + .clone(); + let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( + leader_wallet, + ledger, + true, + MICRO_IUNA / 10, + 1, + ); + + let plan = node.prepare_automatic_finalization(1); + let outbox = node.drain_outbox(); + + assert!(plan.burned.is_some()); + assert!(node.ledger().pending().is_empty()); + assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); + let (_, anchor_burn) = node + .local_block_anchor_burn + .as_ref() + .expect("leader anchor burn should be held locally"); + assert_eq!(anchor_burn.amount(), super::AUTO_BLOCK_ANCHOR_BURN_AMOUNT); + assert!( + outbox + .iter() + .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_))) + ); + assert!(node.prepare_automatic_finalization(1).work.is_some()); +} + +#[test] +fn automatic_pow_mining_uses_protocol_finalizer_fee() { + let wallet = Wallet::from_seed("automatic-pow-mining-fee-wallet"); + let mut node = NodeCore::new(NodeConfig { + wallet, + genesis_allocations: BTreeMap::new(), + vdf_rounds: 10, + burn_per_block: 0, + burn_fee: 0, + pow_mining_workers: 1, + recovery_vdf_top_rank_percent: 100, + }); + + node.set_pow_mining_enabled(true); + let plan = (1..10_000) + .map(|timestamp| node.prepare_automatic_mining(timestamp)) + .find(|plan| plan.pow_mined.is_some()) + .expect("bounded PoW search should eventually find a proof"); + let mine = plan.pow_mined.expect("PoW should be queued"); + + assert_eq!(mine.fee(), MINE_FINALIZER_FEE); + assert_eq!(mine.amount(), crate::domain::MINE_REWARD); + assert_eq!( + node.status().mining.automatic_pow_mine_fee, + MINE_FINALIZER_FEE + ); +} diff --git a/src/app/gossip.rs b/src/app/gossip.rs @@ -0,0 +1,242 @@ +use crate::domain::{Block, ChainSnapshot, Transaction}; + +use super::{ + BLOCK_REQUEST_LIMIT, GossipEnvelope, NETWORK_ID, NodeCore, PROTOCOL_VERSION, ProtocolHello, + TRANSACTION_BATCH_LIMIT, now_ms, types::BlockInventory, +}; + +impl NodeCore { + pub fn mempool_gossip(&mut self) -> Vec<GossipEnvelope> { + let _ = self.publish_reveal_bundle_for_next_block(); + let mut gossip = Vec::new(); + let mine_actions = self + .ledger + .pending() + .iter() + .filter(|transaction| matches!(transaction, Transaction::Mine { .. })) + .cloned() + .collect::<Vec<_>>(); + gossip.extend(mine_actions.chunks(TRANSACTION_BATCH_LIMIT).map(|chunk| { + GossipEnvelope::MineActions { + transactions: chunk.to_vec(), + } + })); + gossip.extend( + self.ledger + .pending_blinded_transactions() + .chunks(TRANSACTION_BATCH_LIMIT) + .map(|chunk| GossipEnvelope::BlindedTransactions { + transactions: chunk.to_vec(), + }), + ); + gossip.extend( + self.ledger + .pending_blinded_reveals() + .chunks(TRANSACTION_BATCH_LIMIT) + .map(|chunk| GossipEnvelope::BlindedReveals { + reveals: chunk.to_vec(), + }), + ); + gossip.extend( + self.usable_reveal_bundles() + .chunks(TRANSACTION_BATCH_LIMIT) + .map(|chunk| GossipEnvelope::RevealBundles { + bundles: chunk.to_vec(), + }), + ); + gossip + } + + pub fn chain_snapshot(&self) -> ChainSnapshot { + self.ledger.snapshot() + } + + pub fn hello(&self, listen_addr: Option<String>, node_id: Option<String>) -> GossipEnvelope { + let status = self.ledger.status(); + GossipEnvelope::Hello(ProtocolHello { + protocol_version: PROTOCOL_VERSION, + network_id: NETWORK_ID.to_string(), + genesis_hash: self.ledger.genesis_hash().to_string(), + listen_addr, + node_id, + height: status.height, + tip_hash: status.tip_hash, + time_ms: now_ms(), + }) + } + + pub fn peer_status(&self) -> GossipEnvelope { + let status = self.ledger.status(); + GossipEnvelope::PeerStatus { + height: status.height, + tip_hash: status.tip_hash, + time_ms: now_ms(), + } + } + + pub fn blocks_from(&self, from_height: u64, limit: usize) -> Vec<Block> { + self.ledger.blocks_from(from_height, limit) + } + + pub fn blocks_by_hash(&self, hashes: &[String]) -> Vec<Block> { + hashes + .iter() + .filter_map(|hash| self.ledger.block_by_hash(hash)) + .collect() + } + + pub fn missing_inventory_requests(&self, blocks: &[BlockInventory]) -> Vec<GossipEnvelope> { + let local_height = self.ledger.height(); + let first_height_gap = blocks + .iter() + .filter(|block| !self.ledger.has_block(&block.hash)) + .filter(|block| block.height > local_height + 1) + .map(|block| block.height) + .min(); + let missing_blocks = blocks + .iter() + .filter(|block| !self.ledger.has_block(&block.hash)) + .filter(|block| first_height_gap.is_none_or(|gap| block.height < gap)) + .map(|block| block.hash.clone()) + .collect::<Vec<_>>(); + + let mut requests = Vec::new(); + if !missing_blocks.is_empty() { + requests.push(GossipEnvelope::BlockRequest { + hashes: missing_blocks, + }); + } + if first_height_gap.is_some() { + requests.push(GossipEnvelope::BlockRangeRequest { + from_height: local_height + 1, + limit: BLOCK_REQUEST_LIMIT, + }); + } + requests + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::{ + app::{BLOCK_REQUEST_LIMIT, BlockInventory, GossipEnvelope, NodeCore}, + domain::{Amount, GenesisBurn, Ledger, MICRO_IUNA, Transaction, Wallet}, + }; + + #[test] + fn mempool_gossip_includes_blinded_transactions() { + let alice = Wallet::from_seed("blinded-gossip-alice"); + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new(genesis, 1); + let blinded = ledger.build_blinded_burn(&alice, MICRO_IUNA, 7, 3).unwrap(); + let mut sender = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0); + let mut receiver = NodeCore::from_ledger(alice, ledger, 0); + + sender + .receive_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + for envelope in sender.mempool_gossip() { + receiver.receive(envelope).unwrap(); + } + + assert_eq!( + receiver.ledger().pending_blinded_transactions(), + std::slice::from_ref(&blinded.transaction) + ); + } + + #[test] + fn mempool_gossip_includes_public_mine_actions() { + let alice = Wallet::from_seed("mine-gossip-alice"); + let ledger = Ledger::new(BTreeMap::new(), 1); + let mine = ledger.build_mine(alice.address()).unwrap(); + let mut sender = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0); + let mut receiver = NodeCore::from_ledger(alice, ledger, 0); + + sender.submit_public_mine_action(mine.clone()).unwrap(); + for envelope in sender.mempool_gossip() { + receiver.receive(envelope).unwrap(); + } + + assert_eq!(receiver.ledger().pending(), std::slice::from_ref(&mine)); + assert!(receiver.ledger().pending_blinded_transactions().is_empty()); + } + + #[test] + 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.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(&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()); + } + + #[test] + fn inventory_gap_requests_range_instead_of_orphan_block() { + 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.clone(), allocations.clone()); + let remote = node("remote", bob, allocations); + + let mut latest = None; + for height in 1..=3 { + 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, + }]); + + assert_eq!(requests.len(), 1); + match &requests[0] { + GossipEnvelope::BlockRangeRequest { from_height, limit } => { + assert_eq!(*from_height, 1); + assert_eq!(*limit, BLOCK_REQUEST_LIMIT); + } + other => panic!("expected block range request, got {other:?}"), + } + } + + fn node(_network_key: &str, wallet: Wallet, allocations: BTreeMap<String, Amount>) -> NodeCore { + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(wallet.address(), 1)], + 25, + ) + .unwrap(); + 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() + .map(|wallet| (wallet.address().to_string(), amount)) + .collect() + } +} diff --git a/src/app/helpers.rs b/src/app/helpers.rs @@ -0,0 +1,131 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result, bail}; +use sha2::{Digest, Sha256}; + +use crate::domain::{Amount, BuiltBlindedTransaction, MINE_REWARD, OutPoint, Transaction}; + +use super::FeeEstimate; + +pub(super) 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]; + bytes.copy_from_slice(&digest[..8]); + u64::from_be_bytes(bytes) +} + +pub(super) fn converge_fee_by_byte( + fee_per_byte: Amount, + mut build: impl FnMut(Amount) -> Result<BuiltBlindedTransaction>, +) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { + let mut fee = 0; + let mut best = None; + for _ in 0..64 { + 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 blinded transaction bytes overflows")?; + if fee == required_fee { + return Ok((built, FeeEstimate { bytes, fee })); + } + if fee > required_fee + && best + .as_ref() + .is_none_or(|(_, estimate): &(BuiltBlindedTransaction, FeeEstimate)| { + fee < estimate.fee + }) + { + best = Some((built, FeeEstimate { bytes, fee })); + } + fee = required_fee; + } + + 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 blinded transaction bytes overflows")?; + if fee >= required_fee { + if best + .as_ref() + .is_none_or(|(_, estimate): &(BuiltBlindedTransaction, FeeEstimate)| fee < estimate.fee) + { + best = Some((built, FeeEstimate { bytes, fee })); + } + if let Some(best) = best { + return Ok(best); + } + } + 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 blinded transaction bytes overflows")?; + if required_fee < final_required_fee { + bail!("fee per byte did not converge"); + } + Ok(( + built, + FeeEstimate { + bytes, + fee: required_fee, + }, + )) +} + +pub(super) fn transaction_output_total_for_address( + transaction: &Transaction, + address: &str, +) -> Amount { + match transaction { + Transaction::Transfer { outputs, .. } => outputs, + Transaction::Burn { change, .. } => change, + Transaction::Mine { recipient, .. } if recipient == address => return MINE_REWARD, + Transaction::Mine { .. } => return 0, + } + .iter() + .filter(|output| output.address == address) + .fold(0_u64, |total, output| total.saturating_add(output.amount)) +} + +pub(super) fn transaction_input_total_from_outputs( + transaction: &Transaction, + address: &str, + outputs: &BTreeMap<OutPoint, Amount>, +) -> Amount { + let inputs = match transaction { + Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => inputs, + Transaction::Mine { .. } => return 0, + }; + inputs + .iter() + .filter(|input| input.owner == address) + .filter_map(|input| outputs.get(&input.outpoint)) + .fold(0_u64, |total, amount| total.saturating_add(*amount)) +} + +pub(super) fn transaction_input_outpoints(transaction: &Transaction) -> BTreeSet<OutPoint> { + match transaction { + Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => inputs, + Transaction::Mine { .. } => return BTreeSet::new(), + } + .iter() + .map(|input| input.outpoint.clone()) + .collect() +} + +pub(super) fn allowed_recovery_vdf_rank_count(rank_count: usize, percent: u8) -> usize { + if rank_count == 0 || percent == 0 { + return 0; + } + rank_count + .saturating_mul(usize::from(percent.min(100))) + .saturating_add(99) + / 100 +} + +pub(super) fn recovery_vdf_sample_percent(address: &str, tip_hash: &str) -> u8 { + let digest = Sha256::digest(format!("iuna-recovery-vdf-sample:{tip_hash}:{address}")); + digest[0] % 100 +} diff --git a/src/app/in_memory_network.rs b/src/app/in_memory_network.rs @@ -0,0 +1,664 @@ +use std::collections::BTreeMap; + +use anyhow::Result; + +use super::{GossipEnvelope, NodeCore}; + +#[derive(Debug, Default)] +pub struct InMemoryNetwork { + nodes: BTreeMap<String, NodeCore>, +} + +impl InMemoryNetwork { + pub fn insert(&mut self, id: impl Into<String>, node: NodeCore) { + self.nodes.insert(id.into(), node); + } + + pub fn node(&self, id: &str) -> Option<&NodeCore> { + self.nodes.get(id) + } + + pub fn node_mut(&mut self, id: &str) -> Option<&mut NodeCore> { + self.nodes.get_mut(id) + } + + pub fn deliver_until_idle(&mut self) -> Result<()> { + loop { + let mut outbound = Vec::new(); + for (id, node) in &mut self.nodes { + for envelope in node.drain_outbox() { + outbound.push((id.clone(), envelope)); + } + } + + if outbound.is_empty() { + return Ok(()); + } + + for (from, envelope) in outbound { + for (id, node) in &mut self.nodes { + if *id != from { + receive_in_memory_envelope(node, envelope.clone())?; + } + } + } + } + } + + pub fn gossip_mempools_once(&mut self) -> Result<()> { + let mut outbound = Vec::new(); + for (id, node) in &mut self.nodes { + for envelope in node.mempool_gossip() { + outbound.push((id.clone(), envelope)); + } + } + + for (from, envelope) in outbound { + for (id, node) in &mut self.nodes { + if *id != from { + receive_in_memory_envelope(node, envelope.clone())?; + } + } + } + Ok(()) + } + + pub fn sync_node_from_peer(&mut self, from: &str, to: &str, limit: usize) -> Result<bool> { + let from_height = self + .nodes + .get(to) + .map(|node| node.chain_height() + 1) + .ok_or_else(|| anyhow::anyhow!("missing sync target node {to}"))?; + let blocks = self + .nodes + .get(from) + .map(|node| node.blocks_from(from_height, limit)) + .ok_or_else(|| anyhow::anyhow!("missing sync source node {from}"))?; + if blocks.is_empty() { + return Ok(false); + } + + self.nodes + .get_mut(to) + .expect("sync target exists") + .receive(GossipEnvelope::Blocks { blocks })?; + Ok(true) + } +} + +fn receive_in_memory_envelope(node: &mut NodeCore, envelope: GossipEnvelope) -> Result<()> { + let transaction_like = matches!( + envelope, + GossipEnvelope::BlindedTransaction(_) + | GossipEnvelope::BlindedTransactions { .. } + | GossipEnvelope::MineAction(_) + | GossipEnvelope::MineActions { .. } + | GossipEnvelope::BlindedReveal(_) + | GossipEnvelope::BlindedReveals { .. } + ); + match node.receive(envelope) { + Ok(()) => Ok(()), + Err(_) if transaction_like => Ok(()), + Err(error) => Err(error), + } +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, BTreeSet}; + + use crate::{ + app::{GossipEnvelope, InMemoryNetwork, NodeCore}, + domain::{ + Block, GenesisBurn, Ledger, MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MICRO_IUNA, + RECOVERY_BLOCK_DELAY_MS, VDF_TARGET_BLOCK_MS, Wallet, + }, + }; + + #[derive(Clone, Debug)] + struct ChaosRng { + state: u64, + } + + impl ChaosRng { + fn new(seed: u64) -> Self { + Self { + state: seed ^ 0x517c_c1b7_2722_0a95, + } + } + + fn next_u64(&mut self) -> u64 { + self.state = self + .state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + self.state + } + + fn index(&mut self, len: usize) -> usize { + assert!(len > 0); + (self.next_u64() as usize) % len + } + } + + #[test] + fn sparse_network_stays_bounded_under_generated_actions() { + const NODES: usize = 10; + const ROUNDS: usize = 36; + const SETTLE_BLOCKS: u64 = MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS + 10; + const QUIET_BLOCKS: usize = SETTLE_BLOCKS as usize + 4; + + let mut rng = ChaosRng::new(0x1aba_0100); + let wallets = (0..NODES) + .map(|index| Wallet::from_seed(&format!("sparse-network-chaos-{index}"))) + .collect::<Vec<_>>(); + let allocations = wallets + .iter() + .map(|wallet| (wallet.address().to_string(), 75 * MICRO_IUNA)) + .collect::<BTreeMap<_, _>>(); + let genesis_burns = wallets + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect::<Vec<_>>(); + let ledger = Ledger::new_with_genesis_burns(allocations, genesis_burns, 1) + .expect("chaos genesis is valid"); + let node_ids = (0..NODES) + .map(|index| format!("n{index}")) + .collect::<Vec<_>>(); + let peers = sparse_chaos_peers(NODES, &mut rng); + let mut offline_until = vec![0_usize; NODES]; + let mut pending_since = BTreeMap::new(); + let mut network = InMemoryNetwork::default(); + + for (index, wallet) in wallets.iter().enumerate() { + let joined = Ledger::from_snapshot(ledger.snapshot()).expect("node joins valid chain"); + let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( + wallet.clone(), + joined, + true, + MICRO_IUNA / 10, + 0, + ); + node.set_recovery_vdf_top_rank_percent(100); + network.insert(node_ids[index].clone(), node); + } + + for round in 0..ROUNDS { + let online = online_chaos_nodes(&offline_until, round); + for index in online.iter().copied().collect::<Vec<_>>() { + match rng.index(32) { + 0 => { + let recipient = wallets[rng.index(wallets.len())].address().to_string(); + let expiry_height = network + .node(&node_ids[index]) + .expect("actor node exists") + .chain_height() + + 16; + let _ = network + .node_mut(&node_ids[index]) + .expect("actor node exists") + .blinded_transfer_with_fee(recipient, 1, 0, expiry_height); + } + 1 => { + attempt_bounded_mine_action( + &mut network, + &node_ids[index], + wallets[index].address(), + &mut rng, + ); + } + 2 if online.len() > 1 => { + offline_until[index] = round + 1 + rng.index(4); + } + _ => {} + } + } + + deliver_sparse_chaos_until_idle( + &mut network, + &node_ids, + &peers, + &online, + chaos_timestamp(round), + &mut rng, + ); + mine_one_sparse_chaos_block( + &mut network, + &node_ids, + &wallets, + &online, + chaos_timestamp(round), + ); + deliver_sparse_chaos_until_idle( + &mut network, + &node_ids, + &peers, + &online, + chaos_timestamp(round), + &mut rng, + ); + observe_bounded_pending(&network, &node_ids, &mut pending_since, SETTLE_BLOCKS); + } + + for round in ROUNDS..ROUNDS + QUIET_BLOCKS { + let online = online_chaos_nodes(&offline_until, round); + deliver_sparse_chaos_until_idle( + &mut network, + &node_ids, + &peers, + &online, + chaos_timestamp(round), + &mut rng, + ); + mine_one_sparse_chaos_block( + &mut network, + &node_ids, + &wallets, + &online, + chaos_timestamp(round), + ); + deliver_sparse_chaos_until_idle( + &mut network, + &node_ids, + &peers, + &online, + chaos_timestamp(round), + &mut rng, + ); + observe_bounded_pending(&network, &node_ids, &mut pending_since, SETTLE_BLOCKS); + } + + let all_online = (0..NODES).collect::<BTreeSet<_>>(); + for round in ROUNDS + QUIET_BLOCKS..ROUNDS + QUIET_BLOCKS + SETTLE_BLOCKS as usize * 4 { + deliver_sparse_chaos_until_idle( + &mut network, + &node_ids, + &peers, + &all_online, + chaos_timestamp(round), + &mut rng, + ); + if !sparse_chaos_has_pending(&network, &node_ids) { + break; + } + mine_one_sparse_chaos_block( + &mut network, + &node_ids, + &wallets, + &all_online, + chaos_timestamp(round), + ); + deliver_sparse_chaos_until_idle( + &mut network, + &node_ids, + &peers, + &all_online, + chaos_timestamp(round), + &mut rng, + ); + observe_bounded_pending(&network, &node_ids, &mut pending_since, SETTLE_BLOCKS); + } + assert_sparse_chaos_converged(&network, &node_ids); + assert_sparse_chaos_mempools_empty(&network, &node_ids); + } + + fn sparse_chaos_peers(nodes: usize, rng: &mut ChaosRng) -> Vec<Vec<usize>> { + let mut peers = vec![BTreeSet::new(); nodes]; + for index in 0..nodes { + let next = (index + 1) % nodes; + peers[index].insert(next); + peers[next].insert(index); + } + for index in 0..nodes { + let target_degree = 1 + rng.index(4); + while peers[index].len() < target_degree { + let peer = rng.index(nodes); + if peer != index { + peers[index].insert(peer); + peers[peer].insert(index); + } + } + } + peers + .into_iter() + .map(|set| set.into_iter().take(4).collect()) + .collect() + } + + fn online_chaos_nodes(offline_until: &[usize], round: usize) -> BTreeSet<usize> { + offline_until + .iter() + .enumerate() + .filter_map(|(index, until)| (*until <= round).then_some(index)) + .collect() + } + + fn chaos_timestamp(round: usize) -> u64 { + (round as u64 + 1) * (RECOVERY_BLOCK_DELAY_MS + VDF_TARGET_BLOCK_MS) + } + + fn attempt_bounded_mine_action( + network: &mut InMemoryNetwork, + node_id: &str, + recipient: &str, + rng: &mut ChaosRng, + ) { + let outcome = network + .node(node_id) + .expect("mine actor exists") + .ledger() + .search_mine(recipient, rng.next_u64(), 0, 4) + .expect("bounded mine search is valid"); + let Some(transaction) = outcome.transaction else { + return; + }; + let _ = network + .node_mut(node_id) + .expect("mine actor exists") + .receive_mine_action(transaction); + } + + fn mine_one_sparse_chaos_block( + network: &mut InMemoryNetwork, + node_ids: &[String], + wallets: &[Wallet], + online: &BTreeSet<usize>, + timestamp_ms: u64, + ) { + let Some(reference_index) = highest_online_node(network, node_ids, online) else { + return; + }; + let reference = network + .node(&node_ids[reference_index]) + .expect("reference node exists"); + let leader = reference.ledger().expected_leader_for_next_block(); + let mut candidates = Vec::new(); + if let Some(index) = leader + .as_deref() + .and_then(|leader| { + wallets + .iter() + .position(|wallet| wallet.address() == leader) + .filter(|index| online.contains(index)) + }) + .filter(|index| { + network.node(&node_ids[*index]).unwrap().chain_height() == reference.chain_height() + }) + { + candidates.push(index); + } + candidates.push(reference_index); + candidates.extend(online.iter().copied().filter(|index| { + network.node(&node_ids[*index]).unwrap().chain_height() == reference.chain_height() + })); + let mut seen = BTreeSet::new(); + for producer_index in candidates { + if !seen.insert(producer_index) { + continue; + } + let mut producer = network + .node(&node_ids[producer_index]) + .expect("producer node exists") + .clone(); + let plan = producer.prepare_automatic_finalization(timestamp_ms); + let Some(work) = plan.work else { + continue; + }; + let block = work.finish_at( + &wallets[producer_index], + "preverified-chaos-vdf".to_string(), + timestamp_ms, + ); + network + .node_mut(&node_ids[producer_index]) + .expect("producer node exists") + .receive_preverified_block_at(block, timestamp_ms) + .expect("mock-VDF block applies locally"); + return; + } + } + + fn highest_online_node( + network: &InMemoryNetwork, + node_ids: &[String], + online: &BTreeSet<usize>, + ) -> Option<usize> { + online.iter().copied().max_by_key(|index| { + network + .node(&node_ids[*index]) + .expect("online node exists") + .chain_height() + }) + } + + fn deliver_sparse_chaos_until_idle( + network: &mut InMemoryNetwork, + node_ids: &[String], + peers: &[Vec<usize>], + online: &BTreeSet<usize>, + timestamp_ms: u64, + rng: &mut ChaosRng, + ) { + for _ in 0..512 { + let mut progressed = + sync_sparse_chaos_once(network, node_ids, peers, online, timestamp_ms); + progressed |= + deliver_sparse_chaos_once(network, node_ids, peers, online, timestamp_ms, rng); + if !progressed { + return; + } + } + panic!("sparse chaos network did not become idle"); + } + + fn sync_sparse_chaos_once( + network: &mut InMemoryNetwork, + node_ids: &[String], + peers: &[Vec<usize>], + online: &BTreeSet<usize>, + timestamp_ms: u64, + ) -> bool { + let mut syncs = Vec::new(); + for from in online { + let from_height = network.node(&node_ids[*from]).unwrap().chain_height(); + for to in &peers[*from] { + if !online.contains(to) { + continue; + } + let to_height = network.node(&node_ids[*to]).unwrap().chain_height(); + if from_height > to_height { + let blocks = network + .node(&node_ids[*from]) + .unwrap() + .blocks_from(to_height + 1, 16); + syncs.push((*to, blocks)); + } + } + } + + let mut progressed = false; + for (to, blocks) in syncs { + for block in blocks { + let before = network.node(&node_ids[to]).unwrap().chain_height(); + receive_sparse_chaos_block(network, &node_ids[to], block, timestamp_ms); + progressed |= network.node(&node_ids[to]).unwrap().chain_height() > before; + } + } + progressed + } + + fn deliver_sparse_chaos_once( + network: &mut InMemoryNetwork, + node_ids: &[String], + peers: &[Vec<usize>], + online: &BTreeSet<usize>, + timestamp_ms: u64, + rng: &mut ChaosRng, + ) -> bool { + let mut outbound = Vec::new(); + for from in online { + let node = network + .node_mut(&node_ids[*from]) + .expect("online node exists"); + outbound.extend( + node.drain_outbox() + .into_iter() + .map(|envelope| (*from, envelope)), + ); + } + if outbound.is_empty() { + return false; + } + + while !outbound.is_empty() { + let index = rng.index(outbound.len()); + let (from, envelope) = outbound.swap_remove(index); + for to in &peers[from] { + if online.contains(to) { + receive_sparse_chaos_envelope( + network, + &node_ids[*to], + envelope.clone(), + timestamp_ms, + ); + } + } + } + true + } + + fn receive_sparse_chaos_envelope( + network: &mut InMemoryNetwork, + node_id: &str, + envelope: GossipEnvelope, + timestamp_ms: u64, + ) { + match envelope { + GossipEnvelope::Block(block) => { + receive_sparse_chaos_block(network, node_id, block, timestamp_ms); + } + other => { + if let Err(error) = network + .node_mut(node_id) + .expect("node exists") + .receive(other) + { + let message = error.to_string(); + assert!( + message.contains("mine transaction anchor is not on this chain") + || message.contains("conflicts with an existing pending transaction") + || message.contains("blinded transaction expired"), + "unexpected sparse chaos delivery error: {message}" + ); + } + } + } + } + + fn receive_sparse_chaos_block( + network: &mut InMemoryNetwork, + node_id: &str, + block: Block, + timestamp_ms: u64, + ) { + if let Err(error) = network + .node_mut(node_id) + .expect("node exists") + .receive_preverified_block_at(block, timestamp_ms) + { + let message = error.to_string(); + assert!( + message.contains("expected block height") + || message.contains("same-height fork") + || message.contains("block is already known"), + "unexpected sparse chaos block error: {message}" + ); + } + } + + fn observe_bounded_pending( + network: &InMemoryNetwork, + node_ids: &[String], + pending_since: &mut BTreeMap<String, u64>, + settle_blocks: u64, + ) { + let mut current = BTreeSet::new(); + for node_id in node_ids { + let node = network.node(node_id).expect("node exists"); + for id in pending_item_ids(node) { + current.insert(id); + } + } + let max_height = node_ids + .iter() + .map(|id| network.node(id).unwrap().chain_height()) + .max() + .unwrap_or_default(); + pending_since.retain(|id, _| current.contains(id)); + for id in current { + pending_since.entry(id).or_insert(max_height); + } + for (id, first_height) in pending_since { + assert!( + max_height.saturating_sub(*first_height) <= settle_blocks, + "pending item {id} stayed in mempool for more than {settle_blocks} blocks" + ); + } + } + + fn pending_item_ids(node: &NodeCore) -> Vec<String> { + let mut ids = Vec::new(); + ids.extend( + node.pending_transactions() + .into_iter() + .map(|tx| format!("tx:{}", tx.signature())), + ); + ids.extend( + node.pending_blinded_transactions() + .into_iter() + .map(|tx| format!("commit:{}@{}", tx.commitment, tx.expires_at_height)), + ); + ids.extend( + node.pending_blinded_reveals() + .into_iter() + .map(|reveal| format!("reveal:{}", reveal.commitment)), + ); + ids + } + + fn assert_sparse_chaos_converged(network: &InMemoryNetwork, node_ids: &[String]) { + let first = network.node(&node_ids[0]).expect("first node exists"); + let height = first.chain_height(); + let tip = first.ledger().status().tip_hash.clone(); + for node_id in node_ids.iter().skip(1) { + let node = network.node(node_id).expect("node exists"); + assert_eq!(node.chain_height(), height, "{node_id} height diverged"); + assert_eq!( + node.ledger().status().tip_hash, + tip, + "{node_id} tip diverged" + ); + } + } + + fn sparse_chaos_has_pending(network: &InMemoryNetwork, node_ids: &[String]) -> bool { + node_ids.iter().any(|node_id| { + let node = network.node(node_id).expect("node exists"); + !node.pending_transactions().is_empty() + || !node.pending_blinded_transactions().is_empty() + || !node.pending_blinded_reveals().is_empty() + }) + } + + fn assert_sparse_chaos_mempools_empty(network: &InMemoryNetwork, node_ids: &[String]) { + for node_id in node_ids { + let node = network.node(node_id).expect("node exists"); + let pending = pending_item_ids(node); + assert!( + pending.is_empty(), + "{node_id} at height {} still has pending mempool items: {pending:?}", + node.chain_height() + ); + } + } +} diff --git a/src/app/ledger_view.rs b/src/app/ledger_view.rs @@ -0,0 +1,66 @@ +use anyhow::Result; + +use crate::domain::{ + BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, Ledger, Transaction, +}; + +use super::NodeCore; + +impl NodeCore { + pub fn ledger(&self) -> &Ledger { + &self.ledger + } + + pub(crate) fn clone_ledger(&self) -> Ledger { + self.ledger.clone() + } + + pub fn wallet_view_ledger(&self) -> Result<Ledger> { + let mut ledger = self.ledger.clone(); + self.queue_local_block_anchor(&mut ledger)?; + self.queue_owned_blinded_payloads(&mut ledger)?; + Ok(ledger) + } + + pub fn chain(&self) -> &[Block] { + self.ledger.chain() + } + + pub fn chain_height(&self) -> u64 { + self.ledger.height() + } + + pub fn has_real_chain(&self) -> bool { + !self.ledger.is_setup_placeholder() + } + + pub fn recent_blocks(&self, limit: usize) -> Vec<Block> { + self.ledger.recent_blocks(limit) + } + + pub fn blocks_before(&self, before_height: u64, limit: usize) -> Vec<Block> { + self.ledger.blocks_before(before_height, limit) + } + + pub fn burn_leader_ranks_for_block(&self, height: u64) -> Result<Vec<BurnLeaderRank>> { + self.ledger.burn_leader_ranks_for_block(height) + } + + pub fn pending_transactions(&self) -> Vec<Transaction> { + self.ledger.pending().to_vec() + } + + pub fn pending_blinded_transactions(&self) -> Vec<BlindedTransaction> { + self.ledger.pending_blinded_transactions().to_vec() + } + + pub fn pending_blinded_reveals(&self) -> Vec<BlindedReveal> { + self.ledger.pending_blinded_reveals().to_vec() + } + + pub fn pending_revealed_blinded_transactions( + &self, + ) -> Vec<crate::domain::RevealedBlindedTransaction> { + self.ledger.pending_revealed_blinded_transactions() + } +} diff --git a/src/app/node_lifecycle.rs b/src/app/node_lifecycle.rs @@ -0,0 +1,142 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use crate::{ + adapters::config_store::{DEFAULT_POW_MINING_WORKERS, clamp_pow_mining_workers}, + domain::{Amount, DEFAULT_FEE_PER_BYTE, Ledger, RevealBundle, Wallet}, +}; + +use super::{GossipEnvelope, NodeConfig, NodeCore, NodeWallet}; + +impl NodeCore { + pub fn new(config: NodeConfig) -> Self { + let ledger = Ledger::new(config.genesis_allocations, config.vdf_rounds); + let mut node = Self::from_ledger_with_burn_fee( + config.wallet, + ledger, + config.burn_per_block, + config.burn_fee, + ); + node.set_pow_mining_workers(config.pow_mining_workers); + node.set_recovery_vdf_top_rank_percent(config.recovery_vdf_top_rank_percent); + node + } + + pub fn from_ledger(wallet: Wallet, ledger: Ledger, burn_per_block: Amount) -> Self { + Self::from_ledger_with_burn_fee(wallet, ledger, burn_per_block, DEFAULT_FEE_PER_BYTE) + } + + pub fn from_locked_wallet_address( + address: impl Into<String>, + ledger: Ledger, + automatic_mining_enabled: bool, + burn_per_block: Amount, + burn_fee: Amount, + ) -> Self { + Self::from_node_wallet_with_burn_fee_and_enabled( + NodeWallet::Locked { + address: address.into(), + }, + ledger, + automatic_mining_enabled, + burn_per_block, + burn_fee, + DEFAULT_POW_MINING_WORKERS, + 100, + ) + } + + pub fn from_ledger_with_burn_fee( + wallet: Wallet, + ledger: Ledger, + burn_per_block: Amount, + burn_fee: Amount, + ) -> Self { + Self::from_ledger_with_burn_fee_and_enabled( + wallet, + ledger, + burn_per_block > 0, + burn_per_block, + burn_fee, + ) + } + + pub fn from_ledger_with_burn_fee_and_enabled( + wallet: Wallet, + ledger: Ledger, + automatic_mining_enabled: bool, + burn_per_block: Amount, + burn_fee: Amount, + ) -> Self { + Self::from_node_wallet_with_burn_fee_and_enabled( + NodeWallet::Unlocked(wallet), + ledger, + automatic_mining_enabled, + burn_per_block, + burn_fee, + DEFAULT_POW_MINING_WORKERS, + 100, + ) + } + + fn from_node_wallet_with_burn_fee_and_enabled( + wallet: NodeWallet, + ledger: Ledger, + automatic_mining_enabled: bool, + burn_per_block: Amount, + burn_fee: Amount, + pow_mining_workers: u8, + recovery_vdf_top_rank_percent: u8, + ) -> Self { + Self { + wallet, + ledger, + automatic_mining_enabled, + pow_mining_enabled: false, + pow_mining_workers: clamp_pow_mining_workers(pow_mining_workers), + burn_per_block, + burn_fee, + recovery_vdf_top_rank_percent: recovery_vdf_top_rank_percent.min(100), + last_auto_burn_height: None, + last_auto_anchor_burn_height: None, + last_auto_pow_mine_anchor: None, + last_auto_pow_mine_status: None, + auto_pow_mine_cursor: None, + owned_blinded_transactions: BTreeMap::new(), + owned_blinded_reveals: BTreeMap::new(), + owned_blinded_payloads: BTreeMap::new(), + owned_blinded_outbox_version: 0, + reveal_bundles: BTreeMap::<(u64, u8), RevealBundle>::new(), + equivocated_reveal_bundle_slots: BTreeSet::new(), + local_block_anchor_burn: None, + outbox: Vec::<GossipEnvelope>::new(), + } + } + + pub fn wallet_address(&self) -> &str { + self.wallet.address() + } + + pub fn wallet_is_locked(&self) -> bool { + self.wallet.is_locked() + } + + pub fn replace_wallet(&mut self, wallet: Wallet) { + self.wallet = NodeWallet::Unlocked(wallet); + self.reset_automatic_mining_progress(); + self.owned_blinded_transactions.clear(); + self.owned_blinded_reveals.clear(); + self.owned_blinded_payloads.clear(); + self.bump_owned_blinded_outbox_version(); + self.reveal_bundles.clear(); + self.equivocated_reveal_bundle_slots.clear(); + self.local_block_anchor_burn = None; + } + + pub(super) fn reset_automatic_mining_progress(&mut self) { + self.last_auto_burn_height = None; + self.last_auto_anchor_burn_height = None; + self.last_auto_pow_mine_anchor = None; + self.last_auto_pow_mine_status = None; + self.auto_pow_mine_cursor = None; + } +} diff --git a/src/app/owned_blinded.rs b/src/app/owned_blinded.rs @@ -0,0 +1,461 @@ +use anyhow::Result; + +use crate::domain::{ + BlindedTransaction, Block, BuiltBlindedTransaction, Ledger, + MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, OwnedBlindedTransaction, Transaction, +}; + +use super::{GossipEnvelope, NodeCore}; + +impl NodeCore { + pub fn owned_blinded_payloads(&self) -> Vec<Transaction> { + self.owned_blinded_payloads.values().cloned().collect() + } + + pub fn owned_blinded_outbox_version(&self) -> u64 { + self.owned_blinded_outbox_version + } + + pub fn owned_blinded_transactions(&self) -> Vec<OwnedBlindedTransaction> { + self.owned_blinded_transactions + .iter() + .filter_map(|(commitment, transaction)| { + let payload = self.owned_blinded_payloads.get(commitment)?; + let reveal = self.owned_blinded_reveals.get(commitment)?; + Some(OwnedBlindedTransaction { + transaction: transaction.clone(), + payload: payload.clone(), + reveal: reveal.clone(), + }) + }) + .collect() + } + + pub fn mark_owned_blinded_outbox_persisted(&mut self, version: u64) { + if self.owned_blinded_outbox_version == version { + self.owned_blinded_outbox_version = 0; + } + } + + pub fn restore_owned_blinded_transactions( + &mut self, + transactions: Vec<OwnedBlindedTransaction>, + ) -> Result<()> { + self.owned_blinded_transactions.clear(); + self.owned_blinded_reveals.clear(); + self.owned_blinded_payloads.clear(); + for owned in transactions { + let commitment = owned.transaction.commitment.clone(); + if owned.reveal.commitment != commitment { + continue; + } + if self.ledger.has_blinded_reveal(&commitment) + || !self.ledger.has_unrevealed_blinded_transaction(&commitment) + && owned.transaction.expires_at_height <= self.ledger.height().saturating_add(1) + { + continue; + } + if !self.ledger.has_blinded_transaction(&commitment) { + match self + .ledger + .submit_blinded_transaction(owned.transaction.clone()) + { + Ok(true) => self.outbox.push(GossipEnvelope::BlindedTransaction( + owned.transaction.clone(), + )), + Ok(false) => {} + Err(_) => continue, + } + } + if !self.ledger.has_unrevealed_blinded_transaction(&commitment) { + continue; + } + self.owned_blinded_transactions + .insert(commitment.clone(), owned.transaction); + self.owned_blinded_payloads + .insert(commitment.clone(), owned.payload); + self.owned_blinded_reveals + .insert(commitment.clone(), owned.reveal); + } + self.publish_owned_reveals_for_active_commits()?; + self.bump_owned_blinded_outbox_version(); + Ok(()) + } + + pub(super) fn submit_owned_blinded_transaction( + &mut self, + built: BuiltBlindedTransaction, + ) -> Result<BlindedTransaction> { + let transaction = built.transaction; + self.owned_blinded_transactions + .insert(transaction.commitment.clone(), transaction.clone()); + self.owned_blinded_payloads + .insert(transaction.commitment.clone(), built.payload); + self.owned_blinded_reveals + .insert(transaction.commitment.clone(), built.reveal); + self.bump_owned_blinded_outbox_version(); + if self + .ledger + .submit_blinded_transaction(transaction.clone())? + { + self.outbox + .push(GossipEnvelope::BlindedTransaction(transaction.clone())); + } + Ok(transaction) + } + + pub(super) fn submit_transaction_as_owned_blinded( + &mut self, + tx: Transaction, + ) -> Result<Transaction> { + let built = self.ledger.build_blinded_transaction( + self.wallet.unlocked()?, + tx.clone(), + self.default_blinded_transaction_expiry_height(), + )?; + self.submit_owned_blinded_transaction(built)?; + Ok(tx) + } + + pub(super) fn default_blinded_transaction_expiry_height(&self) -> u64 { + self.ledger + .height() + .saturating_add(MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS) + } + + pub(super) fn publish_owned_reveals_for_block(&mut self, block: &Block) -> Result<()> { + for transaction in &block.blinded_transactions { + let Some(reveal) = self.owned_blinded_reveals.get(&transaction.commitment) else { + continue; + }; + if self.ledger.submit_blinded_reveal(reveal.clone())? { + self.outbox + .push(GossipEnvelope::BlindedReveal(reveal.clone())); + } + } + Ok(()) + } + + pub(super) fn publish_owned_reveals_for_active_commits(&mut self) -> Result<()> { + let reveals = self + .owned_blinded_reveals + .iter() + .filter(|(commitment, _)| self.ledger.has_active_blinded_transaction(commitment)) + .map(|(_, reveal)| reveal.clone()) + .collect::<Vec<_>>(); + for reveal in reveals { + if self.ledger.submit_blinded_reveal(reveal.clone())? { + self.outbox.push(GossipEnvelope::BlindedReveal(reveal)); + } + } + Ok(()) + } + + pub(super) fn prune_owned_blinded_payloads_for_block(&mut self, block: &Block) { + let before = self.owned_blinded_transactions.len() + + self.owned_blinded_reveals.len() + + self.owned_blinded_payloads.len(); + for reveal in block.all_blinded_reveals() { + self.owned_blinded_transactions.remove(&reveal.commitment); + self.owned_blinded_reveals.remove(&reveal.commitment); + self.owned_blinded_payloads.remove(&reveal.commitment); + } + let unrevealed = self + .owned_blinded_transactions + .keys() + .filter(|commitment| self.ledger.has_unrevealed_blinded_transaction(commitment)) + .cloned() + .collect::<Vec<_>>(); + self.owned_blinded_transactions + .retain(|commitment, _| unrevealed.contains(commitment)); + self.owned_blinded_reveals + .retain(|commitment, _| unrevealed.contains(commitment)); + self.owned_blinded_payloads + .retain(|commitment, _| unrevealed.contains(commitment)); + let after = self.owned_blinded_transactions.len() + + self.owned_blinded_reveals.len() + + self.owned_blinded_payloads.len(); + if before != after { + self.bump_owned_blinded_outbox_version(); + } + } + + pub(super) fn queue_owned_blinded_payloads(&self, ledger: &mut Ledger) -> Result<()> { + 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(()) + } + + pub(super) fn bump_owned_blinded_outbox_version(&mut self) { + self.owned_blinded_outbox_version = self.owned_blinded_outbox_version.saturating_add(1); + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::{ + app::{GossipEnvelope, NodeCore}, + domain::{GenesisBurn, Ledger, MICRO_IUNA, Wallet}, + }; + + #[test] + fn owned_blinded_transaction_reveals_after_commit_block_import() { + let alice = Wallet::from_seed("owned-blinded-reveal-alice"); + let bob = Wallet::from_seed("owned-blinded-reveal-bob"); + let carol = Wallet::from_seed("owned-blinded-reveal-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + let mut wallet_node = NodeCore::from_ledger(carol.clone(), ledger.clone(), 0); + let mut finalizer_ledger = ledger; + + let blinded = wallet_node + .blinded_burn_with_fee(3, 7, wallet_node.chain_height() + 4) + .unwrap(); + wallet_node.drain_outbox(); + finalizer_ledger + .submit_blinded_transaction(blinded.clone()) + .unwrap(); + let leader = finalizer_ledger.expected_leader_for_next_block().unwrap(); + let finalizer = finalizers + .iter() + .find(|wallet| wallet.address() == leader) + .unwrap(); + let burn = finalizer_ledger.build_burn(finalizer, 1, 0).unwrap(); + finalizer_ledger.submit_transaction(burn).unwrap(); + let commit_block = finalizer_ledger.mine_next_block(finalizer, 1).unwrap(); + + wallet_node + .receive(GossipEnvelope::Block(commit_block)) + .unwrap(); + let outbox = wallet_node.drain_outbox(); + + assert!( + wallet_node + .ledger() + .pending_blinded_reveals() + .iter() + .any(|reveal| reveal.commitment == blinded.commitment) + ); + assert!(outbox.iter().any(|envelope| matches!( + envelope, + GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == blinded.commitment + ))); + } + + #[test] + fn status_wallet_balance_includes_owned_blinded_change_before_and_after_commit() { + let alice = Wallet::from_seed("owned-blinded-balance-alice"); + let bob = Wallet::from_seed("owned-blinded-balance-bob"); + let carol = Wallet::from_seed("owned-blinded-balance-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let starting_balance = 2 * MICRO_IUNA; + let burn_amount = MICRO_IUNA / 10; + let fee = MICRO_IUNA / 10; + let expected_balance = starting_balance - burn_amount - fee; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(carol.address().to_string(), starting_balance); + let ledger = Ledger::new_with_genesis_burns( + allocations, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + let mut wallet_node = NodeCore::from_ledger(carol.clone(), ledger.clone(), 0); + let mut finalizer_ledger = ledger; + + let blinded = wallet_node + .blinded_burn_with_fee(burn_amount, fee, wallet_node.chain_height() + 4) + .unwrap(); + + assert_eq!(wallet_node.status().wallet_balance, expected_balance); + + finalizer_ledger + .submit_blinded_transaction(blinded.clone()) + .unwrap(); + let leader = finalizer_ledger.expected_leader_for_next_block().unwrap(); + let finalizer = finalizers + .iter() + .find(|wallet| wallet.address() == leader) + .unwrap(); + let burn = finalizer_ledger.build_burn(finalizer, 1, 0).unwrap(); + finalizer_ledger.submit_transaction(burn).unwrap(); + let commit_block = finalizer_ledger.mine_next_block(finalizer, 1).unwrap(); + + wallet_node + .receive(GossipEnvelope::Block(commit_block)) + .unwrap(); + + assert_eq!(wallet_node.ledger().balance_of(carol.address()), 0); + assert_eq!(wallet_node.status().wallet_balance, expected_balance); + } + + #[test] + fn owned_blinded_transaction_restore_requeues_pending_commit() { + let alice = Wallet::from_seed("owned-blinded-restore-pending-alice"); + let bob = Wallet::from_seed("owned-blinded-restore-pending-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new(allocations, 1); + let mut node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0); + + let blinded = node + .blinded_transfer_with_fee(bob.address(), MICRO_IUNA, 7, node.chain_height() + 4) + .unwrap(); + let owned = node.owned_blinded_transactions(); + let mut restarted = NodeCore::from_ledger(alice, ledger, 0); + + restarted.restore_owned_blinded_transactions(owned).unwrap(); + + assert_eq!( + restarted.ledger().pending_blinded_transactions(), + std::slice::from_ref(&blinded) + ); + let outbox = restarted.drain_outbox(); + assert!(outbox.iter().any(|envelope| matches!( + envelope, + GossipEnvelope::BlindedTransaction(transaction) + if transaction.commitment == blinded.commitment + ))); + assert!(!outbox.iter().any(|envelope| matches!( + envelope, + GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == blinded.commitment + ))); + } + + #[test] + fn owned_blinded_transaction_restore_skips_stale_commit_with_spent_input() { + let alice = Wallet::from_seed("owned-blinded-restore-stale-alice"); + let bob = Wallet::from_seed("owned-blinded-restore-stale-bob"); + let finalizers = [bob.clone()]; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), MICRO_IUNA); + allocations.insert(bob.address().to_string(), MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + let mut wallet_node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 0); + + wallet_node + .blinded_burn_with_fee(MICRO_IUNA / 10, 7, wallet_node.chain_height() + 4) + .unwrap(); + let owned = wallet_node.owned_blinded_transactions(); + let stale_conflict = ledger.build_burn(&alice, MICRO_IUNA / 10, 7).unwrap(); + let mut advanced_ledger = ledger; + advanced_ledger.submit_transaction(stale_conflict).unwrap(); + let leader = advanced_ledger.expected_leader_for_next_block().unwrap(); + assert_eq!(leader, bob.address()); + let anchor_burn = advanced_ledger.build_burn(&bob, 1, 0).unwrap(); + advanced_ledger.submit_transaction(anchor_burn).unwrap(); + let block = advanced_ledger.mine_next_block(&bob, 1).unwrap(); + advanced_ledger.apply_block(block).unwrap(); + let mut restarted = NodeCore::from_ledger(alice, advanced_ledger, 0); + + restarted.restore_owned_blinded_transactions(owned).unwrap(); + + assert!(restarted.owned_blinded_transactions().is_empty()); + assert!(restarted.ledger().pending_blinded_transactions().is_empty()); + assert!(restarted.drain_outbox().is_empty()); + } + + #[test] + fn owned_blinded_transaction_restore_publishes_reveal_after_commit() { + let alice = Wallet::from_seed("owned-blinded-restore-reveal-alice"); + let bob = Wallet::from_seed("owned-blinded-restore-reveal-bob"); + let carol = Wallet::from_seed("owned-blinded-restore-reveal-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + let mut wallet_node = NodeCore::from_ledger(carol.clone(), ledger.clone(), 0); + let mut finalizer_ledger = ledger; + + let blinded = wallet_node + .blinded_burn_with_fee(3, 7, wallet_node.chain_height() + 4) + .unwrap(); + let owned = wallet_node.owned_blinded_transactions(); + finalizer_ledger + .submit_blinded_transaction(blinded.clone()) + .unwrap(); + let leader = finalizer_ledger.expected_leader_for_next_block().unwrap(); + let finalizer = finalizers + .iter() + .find(|wallet| wallet.address() == leader) + .unwrap(); + let burn = finalizer_ledger.build_burn(finalizer, 1, 0).unwrap(); + finalizer_ledger.submit_transaction(burn).unwrap(); + let commit_block = finalizer_ledger.mine_next_block(finalizer, 1).unwrap(); + finalizer_ledger.apply_block(commit_block.clone()).unwrap(); + let mut restarted_ledger = NodeCore::from_ledger( + carol, + Ledger::from_snapshot(finalizer_ledger.snapshot()).unwrap(), + 0, + ); + + restarted_ledger + .restore_owned_blinded_transactions(owned) + .unwrap(); + + assert!( + restarted_ledger + .ledger() + .pending_blinded_reveals() + .iter() + .any(|reveal| reveal.commitment == blinded.commitment) + ); + assert!( + restarted_ledger + .drain_outbox() + .iter() + .any(|envelope| matches!( + envelope, + GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == blinded.commitment + )) + ); + assert!( + commit_block + .blinded_transactions + .iter() + .any(|transaction| transaction.commitment == blinded.commitment) + ); + } +} diff --git a/src/app/peer_book.rs b/src/app/peer_book.rs @@ -0,0 +1,397 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::{ + PEER_CLOCK_OFFSET_ACCEPTANCE_MS, PEER_CLOCK_OFFSET_STALE_MS, PEER_MISBEHAVIOR_BAN_MS, + PEER_MISBEHAVIOR_BAN_SCORE, now_ms, +}; + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct PeerBook { + peers: BTreeMap<String, PeerInfo>, +} + +impl PeerBook { + pub fn from_addresses(addresses: Vec<String>) -> Self { + let mut book = Self::default(); + for address in addresses { + book.add_peer(address); + } + book + } + + pub fn add_peer(&mut self, address: impl Into<String>) { + let address = address.into(); + let peer = self + .peers + .entry(address.clone()) + .or_insert_with(|| PeerInfo::new(address, PeerDirection::Outbound)); + if peer.direction != PeerDirection::Outbound { + peer.direction = PeerDirection::Outbound; + } + } + + pub fn add_discovered_peer(&mut self, address: impl Into<String>) { + let address = address.into(); + let peer = self + .peers + .entry(address.clone()) + .or_insert_with(|| PeerInfo::new(address, PeerDirection::Discovered)); + if peer.direction == PeerDirection::Inbound { + peer.direction = PeerDirection::Discovered; + } + } + + pub fn observe_inbound_peer(&mut self, address: impl Into<String>) { + let address = address.into(); + self.peers + .entry(address.clone()) + .or_insert_with(|| PeerInfo::new(address, PeerDirection::Inbound)); + } + + pub fn replace_peer_address(&mut self, from: &str, to: impl Into<String>) { + let to = to.into(); + if from == to { + if !self.peers.contains_key(from) { + self.add_peer(to); + } + return; + } + + let Some(from_peer) = self.peers.remove(from) else { + self.add_peer(to); + return; + }; + + let to_peer = self + .peers + .entry(to.clone()) + .or_insert_with(|| PeerInfo::new(to, from_peer.direction.clone())); + if from_peer.direction == PeerDirection::Outbound { + to_peer.direction = PeerDirection::Outbound; + } else if from_peer.direction == PeerDirection::Discovered + && to_peer.direction == PeerDirection::Inbound + { + to_peer.direction = PeerDirection::Discovered; + } + to_peer.messages_sent = to_peer + .messages_sent + .saturating_add(from_peer.messages_sent); + to_peer.messages_received = to_peer + .messages_received + .saturating_add(from_peer.messages_received); + to_peer.last_known_height = to_peer.last_known_height.or(from_peer.last_known_height); + to_peer.last_known_tip_hash = to_peer + .last_known_tip_hash + .clone() + .or(from_peer.last_known_tip_hash); + 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; + to_peer.last_clock_observed_ms = from_peer.last_clock_observed_ms; + } + to_peer.last_contact_ms = to_peer.last_contact_ms.max(from_peer.last_contact_ms); + to_peer.last_success_ms = to_peer.last_success_ms.max(from_peer.last_success_ms); + to_peer.last_error_ms = to_peer.last_error_ms.max(from_peer.last_error_ms); + if to_peer.last_error.is_none() { + to_peer.last_error = from_peer.last_error; + } + to_peer.misbehavior_score = to_peer + .misbehavior_score + .saturating_add(from_peer.misbehavior_score); + to_peer.banned_until_ms = to_peer.banned_until_ms.max(from_peer.banned_until_ms); + if to_peer.ban_reason.is_none() { + to_peer.ban_reason = from_peer.ban_reason; + } + } + + pub fn remove_peer(&mut self, address: &str) -> bool { + if self + .peers + .get(address) + .is_some_and(|peer| peer.direction != PeerDirection::Inbound) + { + self.peers.remove(address); + true + } else { + false + } + } + + pub fn is_connectable_peer(&self, address: &str) -> bool { + self.peers + .get(address) + .is_some_and(|peer| peer.direction != PeerDirection::Inbound) + } + + pub fn addresses(&self) -> Vec<String> { + self.peers + .values() + .filter(|peer| peer.direction != PeerDirection::Inbound) + .map(|peer| peer.address.clone()) + .collect() + } + + pub fn connectable_addresses_at(&self, now_ms: u64) -> Vec<String> { + self.peers + .values() + .filter(|peer| peer.direction != PeerDirection::Inbound) + .filter(|peer| !peer.is_banned_at(now_ms)) + .map(|peer| peer.address.clone()) + .collect() + } + + pub fn addresses_except(&self, excluded: &str) -> Vec<String> { + self.connectable_addresses_at(now_ms()) + .into_iter() + .filter(|address| address != excluded) + .collect() + } + + pub fn list(&self) -> Vec<PeerInfo> { + self.peers.values().cloned().collect() + } + + pub fn prune_stale_inbound_peers_at(&mut self, now_ms: u64, max_age_ms: u64) -> usize { + let before = self.peers.len(); + self.peers.retain(|_, peer| { + if peer.direction != PeerDirection::Inbound || peer.is_banned_at(now_ms) { + return true; + } + peer.last_contact_ms + .is_some_and(|last_contact| now_ms.saturating_sub(last_contact) <= max_age_ms) + }); + before.saturating_sub(self.peers.len()) + } + + pub fn record_sent(&mut self, address: &str, count: u64) { + let now = now_ms(); + let peer = self.ensure(address, PeerDirection::Outbound); + peer.messages_sent += count; + 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_status(&mut self, address: &str, height: u64, tip_hash: String) { + let now = now_ms(); + let peer = self.ensure(address, PeerDirection::Outbound); + peer.last_known_height = Some(height); + peer.last_known_tip_hash = Some(tip_hash); + 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_clock_observation( + &mut self, + address: &str, + direction: PeerDirection, + remote_time_ms: u64, + local_receive_time_ms: u64, + ) { + if remote_time_ms == 0 { + return; + } + let offset = remote_time_ms as i128 - local_receive_time_ms as i128; + let offset = offset.clamp(i64::MIN as i128, i64::MAX as i128) as i64; + let accepted = offset.abs() <= PEER_CLOCK_OFFSET_ACCEPTANCE_MS; + let peer = self.ensure(address, direction); + peer.last_clock_offset_ms = Some(offset); + peer.last_clock_offset_accepted = Some(accepted); + peer.last_clock_observed_ms = Some(local_receive_time_ms); + } + + pub fn network_time_offset_ms_at(&self, now_ms: u64) -> Option<i64> { + median_i64( + self.peers + .values() + .filter(|peer| !peer.is_banned_at(now_ms)) + .filter(|peer| peer.last_error.is_none()) + .filter(|peer| peer.last_clock_offset_accepted == Some(true)) + .filter(|peer| { + peer.last_clock_observed_ms.is_some_and(|observed_ms| { + now_ms.saturating_sub(observed_ms) <= PEER_CLOCK_OFFSET_STALE_MS + }) + }) + .filter_map(|peer| peer.last_clock_offset_ms) + .collect(), + ) + } + + pub fn adjusted_time_ms_at(&self, now_ms: u64) -> u64 { + match self.network_time_offset_ms_at(now_ms) { + Some(offset) if offset >= 0 => now_ms.saturating_add(offset as u64), + Some(offset) => now_ms.saturating_sub(offset.unsigned_abs()), + None => now_ms, + } + } + + pub fn bad_clock_peer_count_at(&self, now_ms: u64) -> usize { + self.peers + .values() + .filter(|peer| !peer.is_banned_at(now_ms)) + .filter(|peer| { + peer.last_clock_observed_ms.is_some_and(|observed_ms| { + now_ms.saturating_sub(observed_ms) <= PEER_CLOCK_OFFSET_STALE_MS + }) + }) + .filter(|peer| peer.last_clock_offset_accepted == Some(false)) + .count() + } + + pub fn record_error(&mut self, address: &str, error: impl Into<String>) { + let now = now_ms(); + let peer = self.ensure(address, PeerDirection::Outbound); + peer.last_contact_ms = Some(now); + peer.last_error_ms = Some(now); + peer.last_error = Some(error.into()); + } + + pub fn record_inbound_error(&mut self, address: &str, error: impl Into<String>) { + let now = now_ms(); + let peer = self.ensure(address, PeerDirection::Inbound); + peer.last_contact_ms = Some(now); + peer.last_error_ms = Some(now); + peer.last_error = Some(error.into()); + } + + pub fn record_received(&mut self, address: &str, count: u64) { + let now = now_ms(); + let peer = self.ensure(address, PeerDirection::Inbound); + peer.messages_received += count; + 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_misbehavior(&mut self, address: &str, reason: impl Into<String>) { + self.record_misbehavior_at(address, reason, now_ms()); + } + + pub fn record_misbehavior_at(&mut self, address: &str, reason: impl Into<String>, now_ms: u64) { + self.record_misbehavior_with_direction(address, reason, now_ms, PeerDirection::Outbound); + } + + pub fn record_inbound_misbehavior(&mut self, address: &str, reason: impl Into<String>) { + self.record_misbehavior_with_direction(address, reason, now_ms(), PeerDirection::Inbound); + } + + fn record_misbehavior_with_direction( + &mut self, + address: &str, + reason: impl Into<String>, + now_ms: u64, + direction: PeerDirection, + ) { + let reason = reason.into(); + let peer = self.ensure(address, direction); + peer.last_contact_ms = Some(now_ms); + peer.last_error_ms = Some(now_ms); + peer.last_error = Some(reason.clone()); + peer.misbehavior_score = peer.misbehavior_score.saturating_add(1); + peer.ban_reason = Some(reason); + if peer.misbehavior_score >= PEER_MISBEHAVIOR_BAN_SCORE { + peer.banned_until_ms = Some(now_ms.saturating_add(PEER_MISBEHAVIOR_BAN_MS)); + } + } + + pub fn is_banned(&self, address: &str) -> bool { + self.is_banned_at(address, now_ms()) + } + + pub fn is_banned_at(&self, address: &str, now_ms: u64) -> bool { + self.peers + .get(address) + .is_some_and(|peer| peer.is_banned_at(now_ms)) + } + + fn ensure(&mut self, address: &str, direction: PeerDirection) -> &mut PeerInfo { + self.peers + .entry(address.to_string()) + .or_insert_with(|| PeerInfo::new(address.to_string(), direction)) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct PeerInfo { + pub address: String, + pub direction: PeerDirection, + pub messages_sent: u64, + pub messages_received: u64, + pub last_known_height: Option<u64>, + pub last_known_tip_hash: Option<String>, + #[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_contact_ms: Option<u64>, + pub last_success_ms: Option<u64>, + pub last_error_ms: Option<u64>, + pub misbehavior_score: u32, + pub banned_until_ms: Option<u64>, + pub ban_reason: Option<String>, +} + +impl PeerInfo { + fn new(address: String, direction: PeerDirection) -> Self { + Self { + address, + direction, + messages_sent: 0, + messages_received: 0, + last_known_height: None, + last_known_tip_hash: None, + last_clock_offset_ms: None, + last_clock_offset_accepted: None, + last_clock_observed_ms: None, + last_error: None, + last_contact_ms: None, + last_success_ms: None, + last_error_ms: None, + misbehavior_score: 0, + banned_until_ms: None, + ban_reason: None, + } + } + + pub fn is_banned_at(&self, now_ms: u64) -> bool { + self.banned_until_ms + .is_some_and(|banned_until| banned_until > now_ms) + } + + fn clear_misbehavior(&mut self) { + self.misbehavior_score = 0; + self.banned_until_ms = None; + self.ban_reason = None; + } +} + +fn median_i64(mut values: Vec<i64>) -> Option<i64> { + if values.is_empty() { + return None; + } + values.sort_unstable(); + Some(values[values.len() / 2]) +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PeerDirection { + Outbound, + Discovered, + Inbound, +} diff --git a/src/app/receive.rs b/src/app/receive.rs @@ -0,0 +1,401 @@ +use anyhow::{Result, bail}; + +use crate::domain::{ + BlindedReveal, BlindedTransaction, Block, ChainSnapshot, Ledger, RevealBundle, Transaction, + TransactionSubmitOutcome, +}; + +use super::{ + GossipEnvelope, IMPORT_REBROADCAST_LIMIT, NodeCore, helpers::transaction_input_outpoints, +}; + +impl NodeCore { + pub fn receive_transaction(&mut self, tx: Transaction) -> Result<TransactionSubmitOutcome> { + let outcome = self.ledger.submit_transaction_with_outcome(tx.clone())?; + Ok(outcome) + } + + pub fn receive_mine_action(&mut self, tx: Transaction) -> Result<()> { + if !matches!(tx, Transaction::Mine { .. }) { + bail!("only mine actions may be gossiped as plaintext"); + } + if self + .ledger + .submit_transaction_with_outcome(tx.clone())? + .added() + { + self.outbox.push(GossipEnvelope::MineAction(tx)); + } + Ok(()) + } + + pub fn receive_blinded_transaction(&mut self, tx: BlindedTransaction) -> Result<()> { + if self.blinded_transaction_conflicts_with_local_anchor(&tx) { + return Ok(()); + } + if self.ledger.submit_blinded_transaction(tx.clone())? { + self.outbox.push(GossipEnvelope::BlindedTransaction(tx)); + } + Ok(()) + } + + fn blinded_transaction_conflicts_with_local_anchor(&self, tx: &BlindedTransaction) -> bool { + let Some((height, burn)) = &self.local_block_anchor_burn else { + return false; + }; + if *height != self.ledger.height() || self.ledger.has_transaction(burn.signature()) { + return false; + } + let anchor_inputs = transaction_input_outpoints(burn); + tx.inputs + .iter() + .any(|input| anchor_inputs.contains(&input.outpoint)) + } + + pub fn receive_blinded_reveal(&mut self, reveal: BlindedReveal) -> Result<()> { + self.receive_blinded_reveal_without_bundle_publish(reveal)?; + Ok(()) + } + + fn receive_blinded_reveal_without_bundle_publish( + &mut self, + reveal: BlindedReveal, + ) -> Result<bool> { + if self.ledger.submit_blinded_reveal(reveal.clone())? { + self.outbox.push(GossipEnvelope::BlindedReveal(reveal)); + return Ok(true); + } + Ok(false) + } + + pub fn receive_reveal_bundle(&mut self, bundle: RevealBundle) -> Result<()> { + let next_height = self.ledger.height().saturating_add(1); + if bundle.height <= self.ledger.height() { + return Ok(()); + } + if bundle.height > next_height { + return Ok(()); + } + let key = (bundle.height, bundle.slot); + if self.equivocated_reveal_bundle_slots.contains(&key) { + return Ok(()); + } + if let Some(existing) = self.reveal_bundles.get(&key) { + if existing.canonical() != bundle.canonical() { + self.reveal_bundles.remove(&key); + self.equivocated_reveal_bundle_slots.insert(key); + } + return Ok(()); + } + self.ledger + .validate_next_block_reveal_bundles(vec![bundle.clone()])?; + self.reveal_bundles.insert(key, bundle.clone()); + self.outbox.push(GossipEnvelope::RevealBundle(bundle)); + Ok(()) + } + + pub fn receive(&mut self, envelope: GossipEnvelope) -> Result<()> { + match envelope { + GossipEnvelope::Hello(_) + | GossipEnvelope::PeerStatus { .. } + | GossipEnvelope::ChainSnapshotRequest + | GossipEnvelope::BlockRangeRequest { .. } + | GossipEnvelope::BlockRequest { .. } + | GossipEnvelope::Inventory { .. } => Ok(()), + GossipEnvelope::BlindedTransaction(tx) => self.receive_blinded_transaction(tx), + GossipEnvelope::BlindedTransactions { transactions } => { + for tx in transactions { + self.receive_blinded_transaction(tx)?; + } + Ok(()) + } + GossipEnvelope::MineAction(tx) => self.receive_mine_action(tx), + GossipEnvelope::MineActions { transactions } => { + for tx in transactions { + self.receive_mine_action(tx)?; + } + Ok(()) + } + GossipEnvelope::BlindedReveal(reveal) => self.receive_blinded_reveal(reveal), + GossipEnvelope::BlindedReveals { reveals } => { + let mut added = false; + for reveal in reveals { + added |= self.receive_blinded_reveal_without_bundle_publish(reveal)?; + } + if added { + self.publish_reveal_bundle_for_next_block()?; + } + Ok(()) + } + GossipEnvelope::RevealBundle(bundle) => self.receive_reveal_bundle(bundle), + GossipEnvelope::RevealBundles { bundles } => { + for bundle in bundles { + self.receive_reveal_bundle(bundle)?; + } + Ok(()) + } + GossipEnvelope::Block(block) => { + 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_reveal_bundles(); + self.prune_owned_blinded_payloads_for_block(&block); + self.publish_owned_reveals_for_block(&block)?; + self.outbox.push(GossipEnvelope::Block(block)); + } + Ok(()) + } + GossipEnvelope::Blocks { blocks } => { + let mut imported = Vec::new(); + for block in blocks { + 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_reveal_bundles(); + self.prune_owned_blinded_payloads_for_block(&block); + self.publish_owned_reveals_for_block(&block)?; + imported.push(block); + } + } + for block in imported { + self.outbox.push(GossipEnvelope::Block(block)); + } + Ok(()) + } + GossipEnvelope::ChainSnapshot(snapshot) => self.import_chain_snapshot(snapshot), + GossipEnvelope::PeerAnnouncement { .. } + | GossipEnvelope::PeerVerificationChallenge { .. } + | GossipEnvelope::PeerVerificationResponse { .. } + | GossipEnvelope::PeerList { .. } => Ok(()), + } + } + + pub(crate) fn receive_preverified_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> { + let previous_height = self.ledger.height(); + self.ledger + .apply_preverified_block_at(block.clone(), now_ms)?; + if self.ledger.height() > previous_height { + self.clear_stale_local_block_anchor(); + self.prune_reveal_bundles(); + self.prune_owned_blinded_payloads_for_block(&block); + self.publish_owned_reveals_for_block(&block)?; + self.outbox.push(GossipEnvelope::Block(block)); + } + Ok(()) + } + + pub(crate) fn block_requires_vdf_verification_at( + &self, + block: &Block, + now_ms: u64, + ) -> Result<bool> { + self.ledger + .block_requires_vdf_verification_at(block, now_ms) + } + + pub fn import_chain_snapshot(&mut self, snapshot: ChainSnapshot) -> Result<()> { + let previous_height = self.ledger.height(); + let imported = self.ledger.extend_from_snapshot(snapshot)?; + if imported { + self.reset_automatic_mining_progress(); + self.clear_stale_local_block_anchor(); + self.prune_reveal_bundles(); + self.enqueue_imported_blocks(previous_height)?; + } + Ok(()) + } + + pub(crate) fn import_verified_ledger(&mut self, ledger: Ledger) -> Result<bool> { + let replaces_setup_placeholder = self.ledger.is_setup_placeholder() + && ledger.genesis_hash() != self.ledger.genesis_hash(); + if ledger.genesis_hash() != self.ledger.genesis_hash() && !replaces_setup_placeholder { + anyhow::bail!("chain snapshot genesis does not match local chain"); + } + let previous_height = self.ledger.height(); + if !replaces_setup_placeholder && ledger.height() <= previous_height { + return Ok(false); + } + + self.ledger = ledger; + self.reset_automatic_mining_progress(); + self.clear_stale_local_block_anchor(); + self.prune_reveal_bundles(); + self.enqueue_imported_blocks(previous_height)?; + Ok(true) + } + + pub fn drain_outbox(&mut self) -> Vec<GossipEnvelope> { + std::mem::take(&mut self.outbox) + } + + fn enqueue_imported_blocks(&mut self, previous_height: u64) -> Result<()> { + if self.ledger.height() <= previous_height { + return Ok(()); + } + let blocks = self + .ledger + .blocks_from(previous_height + 1, IMPORT_REBROADCAST_LIMIT); + for block in &blocks { + self.prune_reveal_bundles(); + self.prune_owned_blinded_payloads_for_block(block); + self.publish_owned_reveals_for_block(block)?; + } + if !blocks.is_empty() { + self.outbox.push(GossipEnvelope::Blocks { blocks }); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::{ + app::{GossipEnvelope, NodeCore, helpers::transaction_input_outpoints}, + domain::{GenesisBurn, Ledger, MICRO_IUNA, Wallet}, + }; + + fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet { + wallets + .iter() + .find(|wallet| wallet.address() == address) + .unwrap_or_else(|| panic!("missing wallet for address {address}")) + } + + #[test] + fn receiving_blinded_reveal_batch_publishes_complete_committee_bundle() { + let alice = Wallet::from_seed("immediate-bundle-alice"); + let bob = Wallet::from_seed("immediate-bundle-bob"); + let carol = Wallet::from_seed("immediate-bundle-carol"); + let dave = Wallet::from_seed("immediate-bundle-dave"); + let finalizers = [alice.clone(), bob.clone()]; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(carol.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(dave.address().to_string(), 10 * MICRO_IUNA); + let mut ledger = Ledger::new_with_genesis_burns( + allocations, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + let first = ledger + .build_blinded_burn(&carol, 3, 100, ledger.height() + 4) + .unwrap(); + let second = ledger + .build_blinded_burn(&dave, 4, 100, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(first.transaction.clone()) + .unwrap(); + ledger + .submit_blinded_transaction(second.transaction.clone()) + .unwrap(); + let leader = ledger.expected_leader_for_next_block().unwrap(); + let leader_wallet = wallet_for_address(&finalizers, &leader); + let burn = ledger.build_burn(leader_wallet, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let commit_block = ledger.mine_next_block(leader_wallet, 1).unwrap(); + ledger.apply_locally_mined_block(commit_block).unwrap(); + + let committee = ledger.reveal_committee_for_next_block(); + let committee_wallet = committee + .iter() + .filter_map(|member| { + finalizers + .iter() + .find(|wallet| wallet.address() == member.owner) + }) + .next() + .expect("test finalizer should be in reveal committee"); + let mut committee_node = NodeCore::from_ledger(committee_wallet.clone(), ledger, 0); + + committee_node + .receive(GossipEnvelope::BlindedReveals { + reveals: vec![first.reveal.clone(), second.reveal.clone()], + }) + .unwrap(); + let outbox = committee_node.drain_outbox(); + + assert!(outbox.iter().any(|envelope| matches!( + envelope, + GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == first.reveal.commitment + ))); + assert!(outbox.iter().any(|envelope| matches!( + envelope, + GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == second.reveal.commitment + ))); + assert!(outbox.iter().any(|envelope| matches!( + envelope, + GossipEnvelope::RevealBundle(bundle) + if bundle.member == committee_wallet.address() + && bundle.reveals.len() == 2 + && bundle.reveals.iter().any(|reveal| reveal.commitment == first.reveal.commitment) + && bundle.reveals.iter().any(|reveal| reveal.commitment == second.reveal.commitment) + ))); + } + + #[test] + fn inbound_blinded_transaction_conflicting_with_local_anchor_is_not_queued() { + let alice = Wallet::from_seed("local-anchor-inbound-alice"); + let bob = Wallet::from_seed("local-anchor-inbound-bob"); + let finalizers = [alice.clone(), bob.clone()]; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + let leader = ledger.expected_leader_for_next_block().unwrap(); + let leader_wallet = finalizers + .iter() + .find(|wallet| wallet.address() == leader) + .unwrap() + .clone(); + let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( + leader_wallet.clone(), + ledger, + true, + MICRO_IUNA / 10, + 1, + ); + + let plan = node.prepare_automatic_finalization(1); + assert!(plan.burned.is_some()); + let (_, anchor_burn) = node + .local_block_anchor_burn + .clone() + .expect("leader burn should be held as a local block anchor"); + let anchor_inputs = transaction_input_outpoints(&anchor_burn) + .into_iter() + .collect::<Vec<_>>(); + let conflicting_payload = node + .ledger() + .build_transfer_with_inputs(&leader_wallet, bob.address(), 1, 0, &anchor_inputs) + .unwrap(); + let conflicting = node + .ledger() + .build_blinded_transaction(&leader_wallet, conflicting_payload, node.chain_height() + 4) + .unwrap(); + + node.receive_blinded_transaction(conflicting.transaction) + .unwrap(); + + assert!(node.ledger().pending_blinded_transactions().is_empty()); + assert!(node.drain_outbox().is_empty()); + assert!(node.prepare_automatic_finalization(1).work.is_some()); + } +} diff --git a/src/app/status.rs b/src/app/status.rs @@ -0,0 +1,210 @@ +use std::collections::BTreeMap; + +use anyhow::Result; + +use crate::{ + adapters::config_store::{MAX_POW_MINING_WORKERS, clamp_pow_mining_workers}, + domain::{Amount, MINE_FINALIZER_FEE, Transaction, VDF_TARGET_BLOCK_MS}, +}; + +use super::{ + LaunchProfileStatus, MiningStatus, NodeCore, NodeStatus, StratumStatus, + helpers::{transaction_input_total_from_outputs, transaction_output_total_for_address}, + now_ms, +}; + +impl NodeCore { + pub fn status(&self) -> NodeStatus { + let chain = self.ledger.status(); + let launch_profile = self.ledger.launch_profile(); + let current_leader = self.ledger.expected_leader_for_next_block(); + let wallet_is_current_leader = current_leader + .as_deref() + .is_none_or(|leader| leader == self.wallet.address()); + + NodeStatus { + app_version: env!("CARGO_PKG_VERSION").to_string(), + wallet_address: self.wallet.address().to_string(), + wallet_balance: self.wallet_projected_balance(), + wallet_locked: self.wallet.is_locked(), + launch_profile: LaunchProfileStatus { + profile_id: launch_profile.profile_id.clone(), + profile_hash: chain.launch_profile_hash.clone(), + ticket_maturity_delay_heights: launch_profile.ticket_maturity_delay_heights, + ticket_expiry_window_heights: launch_profile.ticket_expiry_window_heights, + mine_difficulty_bits: launch_profile.mine_difficulty_bits, + }, + mining: MiningStatus { + automatic: self.automatic_mining_enabled, + pow_mining_enabled: self.pow_mining_enabled, + pow_mining_workers: self.pow_mining_workers, + max_pow_mining_workers: MAX_POW_MINING_WORKERS, + burn_per_block: self.burn_per_block, + automatic_burn_fee: self.burn_fee, + automatic_pow_mine_fee: MINE_FINALIZER_FEE, + last_auto_pow_mine_anchor: self.last_auto_pow_mine_anchor.clone(), + last_auto_pow_mine_status: if self.pow_mining_enabled && !self.has_real_chain() { + Some("waiting for a real chain before PoW mining can start".to_string()) + } else { + self.last_auto_pow_mine_status.clone() + }, + vdf_rounds: self.ledger.vdf_rounds(), + vdf_target_block_ms: VDF_TARGET_BLOCK_MS, + current_leader, + wallet_is_current_leader, + last_auto_burn_height: self.last_auto_burn_height, + recovery_vdf_top_rank_percent: self.recovery_vdf_top_rank_percent, + }, + stratum: StratumStatus { + enabled: false, + listen_addr: None, + }, + chain, + } + } + + fn wallet_projected_balance(&self) -> Amount { + let address = self.wallet.address(); + let mut balance = self.ledger.balance_of(address); + let confirmed_outputs = self + .ledger + .utxos_for_address(address) + .into_iter() + .map(|(outpoint, output)| (outpoint, output.amount)) + .collect::<BTreeMap<_, _>>(); + + for (commitment, payload) in &self.owned_blinded_payloads { + if !self.ledger.has_unrevealed_blinded_transaction(commitment) { + continue; + } + let output_total = transaction_output_total_for_address(payload, address); + if self.ledger.has_active_blinded_transaction(commitment) { + balance = balance.saturating_add(output_total); + } else { + let input_total = + transaction_input_total_from_outputs(payload, address, &confirmed_outputs); + balance = balance + .saturating_sub(input_total) + .saturating_add(output_total); + } + } + + if let Some((height, burn)) = &self.local_block_anchor_burn { + if *height == self.ledger.height() && !self.ledger.has_transaction(burn.signature()) { + let output_total = transaction_output_total_for_address(burn, address); + let input_total = + transaction_input_total_from_outputs(burn, address, &confirmed_outputs); + balance = balance + .saturating_sub(input_total) + .saturating_add(output_total); + } + } + + balance + } + + pub fn set_burn_per_block(&mut self, amount: Amount) -> Result<Option<Transaction>> { + self.set_automatic_burn(amount, self.burn_fee) + } + + pub fn set_automatic_burn( + &mut self, + amount: Amount, + fee: Amount, + ) -> Result<Option<Transaction>> { + self.set_automatic_burn_settings(amount > 0, amount, fee) + } + + pub fn set_automatic_burn_settings( + &mut self, + enabled: bool, + amount: Amount, + fee: Amount, + ) -> Result<Option<Transaction>> { + let was_disabled = !self.automatic_mining_enabled || self.burn_per_block == 0; + self.automatic_mining_enabled = enabled; + self.burn_per_block = amount; + self.burn_fee = fee; + if was_disabled && enabled && amount > 0 { + self.last_auto_burn_height = None; + self.last_auto_anchor_burn_height = None; + } + self.prepare_automatic_burn(now_ms()) + } + + pub fn set_pow_mining_enabled(&mut self, enabled: bool) { + self.pow_mining_enabled = enabled; + self.auto_pow_mine_cursor = None; + if !enabled { + self.last_auto_pow_mine_anchor = None; + self.last_auto_pow_mine_status = None; + } else { + self.last_auto_pow_mine_status = + Some("waiting for next automatic PoW mining tick".to_string()); + } + } + + pub fn pow_mining_enabled(&self) -> bool { + self.pow_mining_enabled + } + + pub fn set_pow_mining_workers(&mut self, workers: u8) { + let workers = clamp_pow_mining_workers(workers); + if self.pow_mining_workers != workers { + self.pow_mining_workers = workers; + self.auto_pow_mine_cursor = None; + if self.pow_mining_enabled { + self.last_auto_pow_mine_status = + Some("waiting for next automatic PoW mining tick".to_string()); + } + } + } + + pub fn pow_mining_workers(&self) -> u8 { + self.pow_mining_workers + } + + pub fn set_recovery_vdf_top_rank_percent(&mut self, percent: u8) { + self.recovery_vdf_top_rank_percent = percent.min(100); + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::{ + app::{NodeConfig, NodeCore}, + domain::{Ledger, Wallet}, + }; + + #[test] + fn status_reports_package_version() { + let wallet = Wallet::from_seed("status-version-wallet"); + let node = NodeCore::new(NodeConfig { + wallet, + genesis_allocations: BTreeMap::new(), + vdf_rounds: 1, + burn_per_block: 0, + burn_fee: 0, + pow_mining_workers: 1, + recovery_vdf_top_rank_percent: 100, + }); + + assert_eq!(node.status().app_version, env!("CARGO_PKG_VERSION")); + } + + #[test] + fn automatic_pow_status_reports_setup_placeholder_wait() { + let wallet = Wallet::from_seed("automatic-pow-setup-placeholder-wallet"); + let ledger = Ledger::new(BTreeMap::new(), 1); + let mut node = NodeCore::from_ledger(wallet, ledger, 0); + + node.set_pow_mining_enabled(true); + + assert_eq!( + node.status().mining.last_auto_pow_mine_status.as_deref(), + Some("waiting for a real chain before PoW mining can start") + ); + } +} diff --git a/src/app/types.rs b/src/app/types.rs @@ -0,0 +1,174 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::domain::{ + Amount, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, ChainStatus, PreparedBlock, + RevealBundle, StratumMineTemplate, Transaction, Wallet, +}; + +#[derive(Clone, Debug)] +pub struct NodeConfig { + pub wallet: Wallet, + pub genesis_allocations: BTreeMap<String, Amount>, + pub vdf_rounds: u64, + pub burn_per_block: Amount, + pub burn_fee: Amount, + pub pow_mining_workers: u8, + pub recovery_vdf_top_rank_percent: u8, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FeeEstimate { + pub bytes: usize, + pub fee: Amount, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExternalMineJob { + pub template: StratumMineTemplate, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum GossipEnvelope { + Hello(ProtocolHello), + PeerStatus { + height: u64, + tip_hash: String, + #[serde(default)] + time_ms: u64, + }, + ChainSnapshotRequest, + BlockRangeRequest { + from_height: u64, + limit: usize, + }, + BlockRequest { + hashes: Vec<String>, + }, + Inventory { + blocks: Vec<BlockInventory>, + }, + BlindedTransaction(BlindedTransaction), + BlindedTransactions { + transactions: Vec<BlindedTransaction>, + }, + MineAction(Transaction), + MineActions { + transactions: Vec<Transaction>, + }, + BlindedReveal(BlindedReveal), + BlindedReveals { + reveals: Vec<BlindedReveal>, + }, + RevealBundle(RevealBundle), + RevealBundles { + bundles: Vec<RevealBundle>, + }, + Block(Block), + Blocks { + blocks: Vec<Block>, + }, + ChainSnapshot(ChainSnapshot), + PeerAnnouncement { + address: String, + #[serde(default)] + node_id: Option<String>, + }, + PeerVerificationChallenge { + address: String, + nonce: String, + }, + PeerVerificationResponse { + address: String, + nonce: String, + node_id: String, + signature: String, + }, + PeerList { + peers: Vec<String>, + }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ProtocolHello { + pub protocol_version: u32, + pub network_id: String, + pub genesis_hash: String, + pub listen_addr: Option<String>, + #[serde(default)] + pub node_id: Option<String>, + pub height: u64, + pub tip_hash: String, + #[serde(default)] + pub time_ms: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct BlockInventory { + pub height: u64, + pub hash: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct NodeStatus { + pub app_version: String, + pub wallet_address: String, + pub wallet_balance: Amount, + pub wallet_locked: bool, + pub launch_profile: LaunchProfileStatus, + pub mining: MiningStatus, + pub stratum: StratumStatus, + pub chain: ChainStatus, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LaunchProfileStatus { + pub profile_id: String, + pub profile_hash: String, + pub ticket_maturity_delay_heights: u64, + pub ticket_expiry_window_heights: u64, + pub mine_difficulty_bits: u32, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct MiningStatus { + pub automatic: bool, + pub pow_mining_enabled: bool, + pub pow_mining_workers: u8, + pub max_pow_mining_workers: u8, + pub burn_per_block: Amount, + pub automatic_burn_fee: Amount, + pub automatic_pow_mine_fee: Amount, + pub last_auto_pow_mine_anchor: Option<String>, + pub last_auto_pow_mine_status: Option<String>, + pub vdf_rounds: u64, + pub vdf_target_block_ms: u64, + pub current_leader: Option<String>, + pub wallet_is_current_leader: bool, + pub last_auto_burn_height: Option<u64>, + pub recovery_vdf_top_rank_percent: u8, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct StratumStatus { + pub enabled: bool, + pub listen_addr: Option<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct AutoMineOutcome { + pub pow_mined: Option<Transaction>, + pub burned: Option<Transaction>, + pub block: Option<Block>, + pub skipped_reason: Option<String>, +} + +#[derive(Clone, Debug)] +pub struct AutoMinePlan { + pub pow_mined: Option<Transaction>, + pub burned: Option<Transaction>, + pub work: Option<PreparedBlock>, + pub skipped_reason: Option<String>, +} diff --git a/src/app/wallet.rs b/src/app/wallet.rs @@ -0,0 +1,560 @@ +use anyhow::{Context, Result, bail}; + +use crate::domain::{ + Amount, BlindedTransaction, Block, BuiltBlindedTransaction, DEFAULT_TRANSACTION_FEE, Ledger, + OutPoint, PreparedBlock, RevealBundle, StratumMineShare, StratumMineTemplate, Transaction, + Wallet, run_vdf, +}; + +use super::{ + ExternalMineJob, FeeEstimate, GossipEnvelope, NodeCore, helpers::converge_fee_by_byte, now_ms, +}; + +#[derive(Clone, Debug)] +pub(super) enum NodeWallet { + Unlocked(Wallet), + Locked { address: String }, +} + +impl NodeWallet { + pub(super) fn address(&self) -> &str { + match self { + Self::Unlocked(wallet) => wallet.address(), + Self::Locked { address } => address, + } + } + + pub(super) fn unlocked(&self) -> Result<&Wallet> { + match self { + Self::Unlocked(wallet) => Ok(wallet), + Self::Locked { .. } => bail!("wallet is locked"), + } + } + + pub(super) fn is_locked(&self) -> bool { + matches!(self, Self::Locked { .. }) + } +} + +impl NodeCore { + pub fn burn(&mut self, amount: Amount) -> Result<Transaction> { + self.burn_with_fee(amount, 0) + } + + pub fn burn_with_fee(&mut self, amount: Amount, fee: Amount) -> Result<Transaction> { + let tx = self + .wallet_build_ledger()? + .build_burn(self.wallet.unlocked()?, amount, fee)?; + self.submit_transaction_as_owned_blinded(tx) + } + + pub fn burn_with_fee_rate( + &mut self, + amount: Amount, + fee_per_byte: Amount, + ) -> Result<(Transaction, FeeEstimate)> { + 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)) + } + + pub fn blinded_burn_with_fee( + &mut self, + amount: Amount, + fee: Amount, + expires_at_height: u64, + ) -> Result<BlindedTransaction> { + let built = self.wallet_build_ledger()?.build_blinded_burn( + self.wallet.unlocked()?, + amount, + fee, + expires_at_height, + )?; + self.submit_owned_blinded_transaction(built) + } + + pub fn estimate_burn_fee(&self, amount: Amount, fee_per_byte: Amount) -> Result<FeeEstimate> { + self.build_burn_with_fee_rate(amount, fee_per_byte) + .map(|(_, estimate)| estimate) + } + + pub fn transfer(&mut self, to: impl Into<String>, amount: Amount) -> Result<Transaction> { + self.transfer_with_fee(to, amount, DEFAULT_TRANSACTION_FEE) + } + + pub fn transfer_with_fee( + &mut self, + to: impl Into<String>, + amount: Amount, + fee: Amount, + ) -> Result<Transaction> { + 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( + &mut self, + to: impl Into<String>, + amount: Amount, + fee: Amount, + outpoints: &[OutPoint], + ) -> Result<Transaction> { + let tx = self.wallet_build_ledger()?.build_transfer_with_inputs( + self.wallet.unlocked()?, + to, + amount, + fee, + outpoints, + )?; + self.submit_transaction_as_owned_blinded(tx) + } + + pub fn blinded_transfer_with_fee( + &mut self, + to: impl Into<String>, + amount: Amount, + fee: Amount, + expires_at_height: u64, + ) -> Result<BlindedTransaction> { + let built = self.wallet_build_ledger()?.build_blinded_transfer( + self.wallet.unlocked()?, + to, + amount, + fee, + expires_at_height, + )?; + self.submit_owned_blinded_transaction(built) + } + + pub fn transfer_with_fee_rate( + &mut self, + to: impl Into<String>, + amount: Amount, + 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)?; + let tx = built.payload.clone(); + self.submit_owned_blinded_transaction(built)?; + Ok((tx, estimate)) + } + + pub fn estimate_transfer_fee( + &self, + to: impl Into<String>, + amount: Amount, + fee_per_byte: Amount, + outpoints: &[OutPoint], + ) -> Result<FeeEstimate> { + self.build_transfer_with_fee_rate(to, amount, fee_per_byte, outpoints) + .map(|(_, estimate)| estimate) + } + + pub fn mine_pow_reward(&mut self) -> Result<Transaction> { + let (tx, _) = self.build_mine_estimate()?; + self.submit_public_mine_action(tx) + } + + pub fn estimate_mine_fee(&self, _fee_per_byte: Amount) -> Result<FeeEstimate> { + self.build_mine_estimate().map(|(_, estimate)| estimate) + } + + pub fn external_mine_job( + &self, + recipient: impl Into<String>, + salt: u64, + ) -> Result<ExternalMineJob> { + let recipient = recipient.into(); + let tip = self + .chain() + .last() + .context("cannot build mine job without a chain tip")?; + let difficulty_bits = self.ledger.current_mine_difficulty_bits(); + Ok(ExternalMineJob { + template: self.ledger.stratum_mine_template( + recipient, + &tip.hash, + salt, + difficulty_bits, + )?, + }) + } + + pub fn submit_external_mine( + &mut self, + recipient: impl Into<String>, + template: StratumMineTemplate, + share: StratumMineShare, + ) -> Result<Transaction> { + let tx = self.ledger.build_stratum_mine(template, share)?; + let recipient = recipient.into(); + if tx.to() != Some(recipient.as_str()) { + bail!("submitted mine recipient does not match worker"); + } + self.submit_public_mine_action(tx) + } + + pub(super) fn usable_reveal_bundles(&self) -> Vec<RevealBundle> { + let next_height = self.ledger.height().saturating_add(1); + let mut bundles = self + .reveal_bundles + .iter() + .filter(|((height, slot), _)| { + *height == next_height + && !self + .equivocated_reveal_bundle_slots + .contains(&(*height, *slot)) + }) + .map(|(_, bundle)| bundle.clone()) + .collect::<Vec<_>>(); + bundles.sort_by_key(|bundle| bundle.slot); + bundles + } + + pub(super) fn prune_reveal_bundles(&mut self) { + let height = self.ledger.height(); + self.reveal_bundles + .retain(|(bundle_height, _), _| *bundle_height > height); + self.equivocated_reveal_bundle_slots + .retain(|(bundle_height, _)| *bundle_height > height); + } + + pub(super) fn publish_reveal_bundle_for_next_block(&mut self) -> Result<()> { + let wallet = match &self.wallet { + NodeWallet::Unlocked(wallet) => wallet, + NodeWallet::Locked { .. } => return Ok(()), + }; + let Some(bundle) = self.ledger.build_reveal_bundle(wallet)? else { + return Ok(()); + }; + let key = (bundle.height, bundle.slot); + if self.equivocated_reveal_bundle_slots.contains(&key) + || self.reveal_bundles.contains_key(&key) + { + return Ok(()); + } + self.ledger + .validate_next_block_reveal_bundles(vec![bundle.clone()])?; + self.reveal_bundles.insert(key, bundle.clone()); + self.outbox.push(GossipEnvelope::RevealBundle(bundle)); + Ok(()) + } + + pub(super) fn submit_public_mine_action(&mut self, tx: Transaction) -> Result<Transaction> { + if !matches!(tx, Transaction::Mine { .. }) { + bail!("only mine actions may be submitted as public mempool transactions"); + } + if self + .ledger + .submit_transaction_with_outcome(tx.clone())? + .added() + { + self.outbox.push(GossipEnvelope::MineAction(tx.clone())); + } + Ok(tx) + } + + pub(super) 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)) + } + + pub(super) fn build_blinded_burn_with_fee_rate( + &self, + amount: Amount, + fee_per_byte: Amount, + ) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { + let ledger = self.wallet_build_ledger()?; + self.build_blinded_burn_with_fee_rate_on_ledger(&ledger, amount, fee_per_byte) + } + + pub(super) fn build_blinded_burn_with_fee_rate_on_ledger( + &self, + ledger: &Ledger, + amount: Amount, + fee_per_byte: Amount, + ) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { + let expires_at_height = self.default_blinded_transaction_expiry_height(); + converge_fee_by_byte(fee_per_byte, |fee| { + let tx = ledger.build_burn(self.wallet.unlocked()?, amount, fee)?; + ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height) + }) + } + + pub(super) fn build_blinded_burn_with_fee_on_ledger( + &self, + ledger: &Ledger, + amount: Amount, + fee: Amount, + ) -> Result<BuiltBlindedTransaction> { + let expires_at_height = self.default_blinded_transaction_expiry_height(); + let tx = ledger.build_burn(self.wallet.unlocked()?, amount, fee)?; + ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height) + } + + pub(super) fn build_transfer_with_fee_rate( + &self, + to: impl Into<String>, + amount: Amount, + 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)) + } + + pub(super) 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| { + let tx = if outpoints.is_empty() { + ledger.build_transfer(self.wallet.unlocked()?, to.clone(), amount, fee) + } else { + ledger.build_transfer_with_inputs( + self.wallet.unlocked()?, + to.clone(), + amount, + fee, + outpoints, + ) + }?; + ledger.build_blinded_transaction(self.wallet.unlocked()?, tx, expires_at_height) + }) + } + + pub(super) fn build_mine_estimate(&self) -> Result<(Transaction, FeeEstimate)> { + let tx = self.ledger.build_mine(self.wallet.address())?; + Ok(( + tx.clone(), + FeeEstimate { + bytes: tx.economic_size_bytes(), + fee: tx.fee(), + }, + )) + } + + pub(super) fn wallet_build_ledger(&self) -> Result<Ledger> { + let mut ledger = self.ledger.clone(); + self.reserve_local_block_anchor_inputs(&mut ledger)?; + self.queue_owned_blinded_payloads(&mut ledger)?; + Ok(ledger) + } + + pub(super) fn wallet_anchor_build_ledger(&self) -> Result<Ledger> { + let mut ledger = self.ledger.clone(); + self.reserve_local_block_anchor_inputs(&mut ledger)?; + ledger.clear_pending_transactions(); + ledger.clear_pending_blinded_transactions(); + Ok(ledger) + } + + pub(super) fn queue_local_block_anchor(&self, ledger: &mut Ledger) -> Result<()> { + let Some((height, burn)) = &self.local_block_anchor_burn else { + return Ok(()); + }; + if *height == ledger.height() && !ledger.has_transaction(burn.signature()) { + let _ = ledger.submit_transaction(burn.clone())?; + } + Ok(()) + } + + pub(super) fn reserve_local_block_anchor_inputs(&self, ledger: &mut Ledger) -> Result<()> { + let Some((height, burn)) = &self.local_block_anchor_burn else { + return Ok(()); + }; + if *height == ledger.height() && !ledger.has_transaction(burn.signature()) { + let _ = ledger.reserve_transaction_inputs(burn); + } + Ok(()) + } + + pub fn mine_one(&mut self) -> Result<Block> { + self.mine_one_at(now_ms()) + } + + pub fn mine_one_at(&mut self, timestamp_ms: u64) -> Result<Block> { + self.publish_reveal_bundle_for_next_block()?; + let work = self.prepare_next_block_with_local_anchor(timestamp_ms)?; + let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); + self.complete_prepared_block_at(work, vdf_output, timestamp_ms) + } + + pub fn complete_prepared_block( + &mut self, + work: PreparedBlock, + vdf_output: String, + ) -> Result<Block> { + self.complete_prepared_block_at(work, vdf_output, now_ms()) + } + + pub fn complete_prepared_block_at( + &mut self, + work: PreparedBlock, + vdf_output: String, + timestamp_ms: u64, + ) -> 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_reveal_bundles(); + self.prune_owned_blinded_payloads_for_block(&block); + self.outbox.push(GossipEnvelope::Block(block.clone())); + self.publish_owned_reveals_for_block(&block)?; + Ok(block) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::{ + app::NodeCore, + domain::{ + GenesisBurn, Ledger, MICRO_IUNA, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + }, + }; + + #[test] + fn fee_rate_transfer_and_burn_pay_at_least_bytes_times_rate() { + let transfer_sender = Wallet::from_seed("fee-rate-transfer-sender"); + let transfer_recipient = Wallet::from_seed("fee-rate-transfer-recipient"); + let mut transfer_genesis = BTreeMap::new(); + transfer_genesis.insert(transfer_sender.address().to_string(), 10 * MICRO_IUNA); + let transfer_ledger = Ledger::new_with_genesis_burns( + transfer_genesis, + vec![GenesisBurn::new(transfer_sender.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + let mut transfer_node = NodeCore::from_ledger(transfer_sender, transfer_ledger, 0); + + let (transfer, transfer_estimate) = transfer_node + .transfer_with_fee_rate(transfer_recipient.address(), MICRO_IUNA, 2, &[]) + .unwrap(); + let transfer_blinded_bytes = + transfer_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!(transfer_node.ledger().pending().is_empty()); + assert_eq!( + transfer_node.ledger().pending_blinded_transactions().len(), + 1 + ); + + let burn_wallet = Wallet::from_seed("fee-rate-burn-wallet"); + let mut burn_genesis = BTreeMap::new(); + burn_genesis.insert(burn_wallet.address().to_string(), 10 * MICRO_IUNA); + let burn_ledger = Ledger::new_with_genesis_burns( + burn_genesis, + vec![GenesisBurn::new(burn_wallet.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + let mut burn_node = NodeCore::from_ledger(burn_wallet, burn_ledger, 0); + let (burn, burn_estimate) = burn_node.burn_with_fee_rate(MICRO_IUNA, 3).unwrap(); + let burn_blinded_bytes = + burn_node.ledger().pending_blinded_transactions()[0].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!(burn_node.ledger().pending().is_empty()); + assert_eq!(burn_node.ledger().pending_blinded_transactions().len(), 1); + } + + #[test] + fn wallet_building_reserves_local_anchor_burn_inputs() { + let alice = Wallet::from_seed("local-anchor-reserve-alice"); + let finalizers = [alice.clone()]; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + let mut ledger = Ledger::new_with_genesis_burns( + allocations, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + let leader = ledger.expected_leader_for_next_block().unwrap(); + let leader_wallet = finalizers + .iter() + .find(|wallet| wallet.address() == leader) + .unwrap() + .clone(); + let split = ledger + .build_transfer(&leader_wallet, leader_wallet.address(), MICRO_IUNA, 0) + .unwrap(); + ledger.submit_transaction(split).unwrap(); + let anchor = ledger.build_burn(&leader_wallet, 1, 0).unwrap(); + ledger.submit_transaction(anchor).unwrap(); + let split_block = ledger.mine_next_block(&leader_wallet, 1).unwrap(); + ledger.apply_block(split_block).unwrap(); + let mut node = + NodeCore::from_ledger_with_burn_fee_and_enabled(leader_wallet, ledger, true, 0, 1); + + let plan = node.prepare_automatic_finalization(1); + assert!(plan.burned.is_some()); + assert!(node.status().wallet_balance < node.ledger().balance_of(node.wallet_address())); + let (_, anchor_burn) = node + .local_block_anchor_burn + .clone() + .expect("leader burn should be held as a local block anchor"); + let Transaction::Burn { inputs, .. } = anchor_burn else { + panic!("local block anchor must be a burn"); + }; + let anchor_inputs = inputs + .iter() + .map(|input| input.outpoint.clone()) + .collect::<Vec<_>>(); + + let blinded = node + .blinded_burn_with_fee(MICRO_IUNA / 20, 1, node.chain_height() + 4) + .unwrap(); + + assert!( + blinded + .inputs + .iter() + .all(|input| !anchor_inputs.contains(&input.outpoint)), + "blinded wallet transactions must not spend inputs reserved by the local anchor burn" + ); + + let work = node.prepare_next_block_with_local_anchor(2).unwrap(); + let vdf_output = run_vdf(work.vdf_seed(), work.vdf_rounds()); + let mut peer_ledger = node.clone_ledger(); + let block = node + .complete_prepared_block_at(work, vdf_output, VDF_TARGET_BLOCK_MS * 2) + .unwrap(); + assert!(block.transactions.iter().any(Transaction::is_burn)); + assert!( + block + .blinded_transactions + .iter() + .any(|transaction| transaction.commitment == blinded.commitment) + ); + peer_ledger.apply_block_at(block, u64::MAX).unwrap(); + assert_eq!( + node.ledger().status().tip_hash, + peer_ledger.status().tip_hash + ); + } +} diff --git a/src/cli.rs b/src/cli.rs @@ -0,0 +1,269 @@ +use std::{ + net::{Ipv4Addr, SocketAddr}, + path::{Path, PathBuf}, + str::FromStr, +}; + +use anyhow::{Context, Result, bail}; +use iuna::{adapters::config_store, domain::Amount}; + +use super::{GENESIS_INITIAL_BURN_FEE, GENESIS_INITIAL_BURN_PER_BLOCK}; + +pub(crate) fn configured_p2p_announce_addr( + opts: &CliOptions, + ui_config: &config_store::UiConfig, +) -> Result<Option<SocketAddr>> { + if let Some(addr) = opts.p2p_announce_addr { + return Ok(Some(addr)); + } + ui_config + .p2p_announce_addr + .as_deref() + .map(|addr| { + addr.parse() + .with_context(|| format!("invalid configured P2P announce address {addr}")) + }) + .transpose() +} + +pub(crate) fn configured_p2p_bind_addr( + opts: &CliOptions, + ui_config: &config_store::UiConfig, +) -> SocketAddr { + if opts.p2p_addr_configured || ui_config.p2p_accept_inbound { + return SocketAddr::from((Ipv4Addr::UNSPECIFIED, ui_config.p2p_bind_port)); + } + opts.p2p_addr +} + +pub(crate) fn apply_cli_p2p_config_overrides( + opts: &CliOptions, + ui_config: &mut config_store::UiConfig, +) -> bool { + let mut dirty = false; + if opts.p2p_addr_configured { + let bind_port = opts.p2p_addr.port(); + if ui_config.p2p_bind_port != bind_port { + ui_config.p2p_bind_port = bind_port; + dirty = true; + } + } + if let Some(addr) = opts.p2p_announce_addr { + let announce_addr = addr.to_string(); + if !ui_config.p2p_accept_inbound + || ui_config.p2p_announce_addr.as_deref() != Some(&announce_addr) + { + ui_config.p2p_accept_inbound = true; + ui_config.p2p_announce_addr = Some(announce_addr); + dirty = true; + } + } + dirty +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ChainMode { + Setup, + Genesis, + Join, +} + +#[derive(Debug)] +pub(crate) struct CliOptions { + pub(crate) wallet_path: Option<PathBuf>, + pub(crate) chain_db_path: Option<PathBuf>, + pub(crate) http_addr: SocketAddr, + pub(crate) p2p_addr: SocketAddr, + pub(crate) p2p_addr_configured: bool, + pub(crate) p2p_announce_addr: Option<SocketAddr>, + pub(crate) stratum_addr: Option<SocketAddr>, + pub(crate) peers: Vec<String>, + pub(crate) join_peers: Vec<String>, + pub(crate) chain_mode: ChainMode, + pub(crate) data_dir: PathBuf, + pub(crate) debug: bool, +} + +impl CliOptions { + pub(crate) fn parse() -> Result<Option<Self>> { + Self::parse_from(std::env::args().skip(1)) + } + + pub(crate) fn parse_from(args: impl IntoIterator<Item = String>) -> Result<Option<Self>> { + let mut opts = Self { + wallet_path: None, + chain_db_path: None, + http_addr: SocketAddr::from_str("127.0.0.1:18661")?, + p2p_addr: SocketAddr::from_str("127.0.0.1:9444")?, + p2p_addr_configured: false, + p2p_announce_addr: None, + stratum_addr: None, + peers: Vec::new(), + join_peers: Vec::new(), + chain_mode: ChainMode::Setup, + data_dir: default_data_dir(), + debug: false, + }; + + let raw_args = args.into_iter().collect::<Vec<_>>(); + let mut args = raw_args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--genesis" => { + if opts.chain_mode == ChainMode::Join { + bail!("choose either --genesis or --join, not both"); + } + opts.chain_mode = ChainMode::Genesis; + } + "--wallet" => { + opts.wallet_path = Some(PathBuf::from(next_value(&mut args, "--wallet")?)) + } + "--chain-db" => { + opts.chain_db_path = Some(PathBuf::from(next_value(&mut args, "--chain-db")?)) + } + "--wallet-seed" => { + bail!( + "--wallet-seed was removed; wallets are stored in --wallet <path> or ~/.iuna/wallet.json" + ) + } + "--http" => { + opts.http_addr = next_value(&mut args, "--http")? + .parse() + .context("invalid --http address")?; + } + "--p2p" => { + opts.p2p_addr = next_value(&mut args, "--p2p")? + .parse() + .context("invalid --p2p address")?; + opts.p2p_addr_configured = true; + } + "--p2p-announce" => { + opts.p2p_announce_addr = Some( + next_value(&mut args, "--p2p-announce")? + .parse() + .context("invalid --p2p-announce address")?, + ); + } + "--stratum" => { + opts.stratum_addr = Some( + next_value(&mut args, "--stratum")? + .parse() + .context("invalid --stratum address")?, + ); + } + "--join" => { + if opts.chain_mode == ChainMode::Genesis { + bail!("choose either --genesis or --join, not both"); + } + let peer = next_value(&mut args, "--join")?; + opts.chain_mode = ChainMode::Join; + opts.peers.push(peer.clone()); + opts.join_peers.push(peer); + } + "--data-dir" => opts.data_dir = PathBuf::from(next_value(&mut args, "--data-dir")?), + "--debug" => opts.debug = true, + "--help" | "-h" => { + print_help(); + std::process::exit(0); + } + other => bail!("unknown argument {other}; pass --help for usage"), + } + } + + if opts.chain_mode == ChainMode::Genesis && !opts.join_peers.is_empty() { + bail!("choose either --genesis or --join, not both"); + } + + Ok(Some(opts)) + } + + pub(crate) fn wallet_path(&self) -> PathBuf { + self.wallet_path + .clone() + .unwrap_or_else(|| self.data_dir.join("wallet.json")) + } + + pub(crate) fn chain_db_path(&self) -> PathBuf { + self.chain_db_path + .clone() + .unwrap_or_else(|| self.data_dir.join("chain.sqlite3")) + } + + pub(crate) fn config_path(&self) -> PathBuf { + self.data_dir.join("config.json") + } + + pub(crate) fn has_chain(&self) -> bool { + self.chain_mode != ChainMode::Setup + } +} + +fn next_value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> { + args.next() + .with_context(|| format!("missing value after {flag}")) +} + +pub(crate) fn validate_wallet_for_mode( + opts: &CliOptions, + wallet_path: &Path, + wallet_file_exists: bool, +) -> Result<()> { + if opts.chain_mode == ChainMode::Genesis && wallet_file_exists { + bail!( + "--genesis requires a fresh wallet path, but {} already exists; start without --genesis to reuse it or choose an empty --data-dir/--wallet", + wallet_path.display() + ); + } + Ok(()) +} + +pub(crate) fn initial_burn_per_block( + opts: &CliOptions, + ui_config: &config_store::UiConfig, +) -> Amount { + match opts.chain_mode { + ChainMode::Genesis => GENESIS_INITIAL_BURN_PER_BLOCK, + ChainMode::Setup | ChainMode::Join => ui_config.burn_per_block, + } +} + +pub(crate) fn initial_burn_fee(opts: &CliOptions, ui_config: &config_store::UiConfig) -> Amount { + match opts.chain_mode { + ChainMode::Genesis => GENESIS_INITIAL_BURN_FEE, + ChainMode::Setup | ChainMode::Join => ui_config.burn_fee, + } +} + +fn print_help() { + println!("{}", help_text()); +} + +pub(crate) fn help_text() -> &'static str { + "iuna\n\n\ + Usage:\n\ + iuna [options]\n\ + iuna --genesis [options]\n\ + iuna --join <addr:port> [options]\n\n\ + Options:\n\ + --genesis Create a new chain with a fresh setup wallet\n\ + --wallet <path> Wallet file (default <data-dir>/wallet.json)\n\ + --chain-db <path> Chain SQLite database (default <data-dir>/chain.sqlite3)\n\ + --http <addr:port> HTTP management UI address (default 127.0.0.1:18661)\n\ + --p2p <addr:port> Inbound P2P listener address when public node is enabled\n\ + --p2p-announce <addr:port> Public P2P address to gossip; enables inbound P2P\n\ + --stratum <addr:port> Stratum V1 listener for SHA-256 ASIC miners\n\ + --join <addr:port> Fetch chain snapshot from this peer before finalization\n\ + --data-dir <path> Local wallet directory (default ~/.iuna)\n\ + --debug Print verbose runtime logs\n\n\ + Environment:\n\ + IUNA_DEV_SKIP_SEED_VERIFY=1 Show a setup button to skip seed verification\n" +} + +pub(crate) fn default_data_dir() -> PathBuf { + std::env::var_os("HOME") + .filter(|home| !home.is_empty()) + .or_else(|| std::env::var_os("USERPROFILE").filter(|home| !home.is_empty())) + .map(PathBuf::from) + .map(|home| home.join(".iuna")) + .unwrap_or_else(|| PathBuf::from(".iuna")) +} diff --git a/src/domain.rs b/src/domain.rs @@ -1,43 +1,127 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - time::{SystemTime, UNIX_EPOCH}, +#[cfg(test)] +use std::collections::{BTreeMap, BTreeSet}; + +mod blinded; +mod block; +mod fork; +mod genesis; +mod hex; +mod history; +mod ledger_apply; +mod ledger_builders; +mod ledger_chain; +mod ledger_consensus; +mod ledger_mempool; +mod ledger_ops; +mod ledger_pending; +mod ledger_prepare; +mod ledger_queries; +mod ledger_reveal; +mod ledger_state; +mod mine_policy; +mod mining; +mod profile; +mod protocol; +mod reveal; +mod selection; +mod stratum; +mod ticket; +mod transaction; +mod validation; +mod vdf; +mod wallet; +use blinded::{ActiveBlindedTransaction, blinded_fee_share}; +#[cfg(test)] +use blinded::{ + blinded_committer_fee_outpoint, blinded_executor_fee_outpoint, blinded_expiry_change_outpoint, + blinded_reveal_bundle_signer_fee_outpoint, blinded_transaction_commitment, + credit_blinded_fee_outputs, decrypt_blinded_payload, decrypt_blinded_transaction, }; - -use anyhow::{Context, Result, anyhow, bail}; -use chacha20poly1305::{ - ChaCha20Poly1305, Nonce, - aead::{Aead, KeyInit}, +use block::LeaderProofPayload; +pub use block::{ + Block, BurnLeaderRank, ChainSnapshot, ChainStatus, FinalizerMode, LeaderProof, PreparedBlock, }; -use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; -use getrandom::getrandom; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -pub type Amount = u64; -pub const MICRO_IUNA: Amount = 1_000_000; -pub const BLOCK_REWARD: Amount = MICRO_IUNA; -pub const MINE_REWARD: Amount = MICRO_IUNA; -pub const MINE_FINALIZER_FEE: Amount = MICRO_IUNA; -pub const DEFAULT_MINE_FEE: Amount = MINE_FINALIZER_FEE; -pub const DEFAULT_TRANSACTION_FEE: Amount = MICRO_IUNA; -pub const DEFAULT_FEE_PER_BYTE: Amount = 1; -pub const MAX_BLOCK_BYTES: usize = 100_000; -pub const VDF_TARGET_BLOCK_MS: u64 = 5 * 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 MINE_ACTIONS_PER_ANCHOR_LIMIT: usize = 2; -pub const MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT: u64 = 200; -pub const FALLBACK_VDF_RETARGET_ACTIVATION_HEIGHT: u64 = 380; -pub const FALLBACK_VDF_RETARGET_DEACTIVATION_HEIGHT: u64 = 1_000; -pub const AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT: u64 = 795; -pub const MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS: u64 = 20; -pub const REVEAL_COMMITTEE_SIZE: usize = 3; -pub const MAX_REVEAL_BUNDLE_BYTES: usize = 10_000; -pub const BLINDED_FEE_BPS_DENOMINATOR: u64 = 10_000; -pub const BLINDED_COMMITTER_FEE_BPS: u64 = 3_500; -pub const BLINDED_REVEAL_FINALIZER_FEE_BPS: u64 = 3_500; -pub const BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS: u64 = 1_000; +use fork::LeaderScore; +#[cfg(test)] +use genesis::balances_from_utxos; +use genesis::genesis_allocation_outpoint; +pub use hex::hex_hash; +use hex::{decode_hex, decode_hex_array, hex_encode}; +pub use history::revealed_blinded_transactions; +#[cfg(test)] +use ledger_ops::estimated_block_selection_size_bytes; +#[cfg(test)] +use ledger_ops::fee_reward; +#[cfg(test)] +use ledger_ops::reward_outpoint; +use ledger_ops::{ + apply_transaction, credit_reward_output, ensure_block_has_burn, ensure_block_has_burn_from, + ensure_outputs_do_not_overflow, ensure_single_input_owner_for_inputs, + recovery_vdf_seed_for_child, validate_genesis_burn_transaction, vdf_seed_for_child, +}; +pub use ledger_state::Ledger; +use ledger_state::unix_now_ms; +#[cfg(test)] +use mine_policy::MINE_RETARGET_WINDOW_BLOCKS; +#[cfg(test)] +use mine_policy::{ + MINE_MAX_ANCHOR_AGE_BLOCKS, MINE_MAX_RETARGET_STEP_BITS, MINE_MIN_DIFFICULTY_BITS, +}; +use mining::{mine_payload, mine_signature}; +pub use profile::{GenesisBurn, LaunchProfile}; +pub use protocol::{ + AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT, Amount, BLINDED_COMMITTER_FEE_BPS, + BLINDED_FEE_BPS_DENOMINATOR, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, + BLINDED_REVEAL_FINALIZER_FEE_BPS, BLOCK_REWARD, DEFAULT_FEE_PER_BYTE, DEFAULT_MINE_FEE, + DEFAULT_TRANSACTION_FEE, FALLBACK_VDF_RETARGET_ACTIVATION_HEIGHT, + FALLBACK_VDF_RETARGET_DEACTIVATION_HEIGHT, MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, + MAX_BLOCK_BYTES, MAX_PENDING_TRANSACTIONS, MAX_REVEAL_BUNDLE_BYTES, MAX_VDF_ROUNDS, MICRO_IUNA, + MINE_ACTIONS_PER_ANCHOR_LIMIT, MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT, + MINE_DIFFICULTY_BITS, MINE_FINALIZER_FEE, MINE_REWARD, RECOVERY_BLOCK_DELAY_MS, + REVEAL_COMMITTEE_SIZE, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, +}; +use protocol::{ + BLINDED_KEY_BYTES, BLINDED_NONCE_BYTES, BLOCK_MEDIAN_TIME_PAST_WINDOW, + DEFAULT_TICKET_EXPIRY_WINDOW, DEFAULT_TICKET_MATURITY_DELAY, FORK_FINALITY_DEPTH, HASH_BYTES, + MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, MAX_BLOCK_TRANSACTIONS, MAX_ORPHAN_TRANSACTIONS, + PUBLIC_KEY_BYTES, SIGNATURE_BYTES, +}; +use reveal::RevealBundlePayload; +#[cfg(test)] +use reveal::reveal_bundle_hashes; +pub use reveal::{ + MaskedBlindedReveal, RevealBundle, RevealBundleSection, RevealBundleSignature, + RevealCommitteeMember, default_reveal_bundle_hash, +}; +use selection::BlockSelection; +pub use stratum::{ + STRATUM_EXTRANONCE1_HEX, STRATUM_EXTRANONCE2_SIZE, StratumMineShare, StratumMineTemplate, + pack_stratum_nonce, +}; +use stratum::{hash_meets_difficulty, stratum_mine_header_bytes, stratum_mine_signature}; +#[cfg(test)] +use ticket::consume_leader_ticket; +use ticket::{BurnTicket, ticket_block_min_timestamp}; +pub use transaction::{ + BlindedReveal, BlindedTransaction, BuiltBlindedTransaction, MineSearchOutcome, OutPoint, + OwnedBlindedTransaction, RevealedBlindedTransaction, Transaction, TxInput, TxOutput, +}; +#[cfg(test)] +use transaction::{ + UnsignedTxInput, UnsignedUtxoTransaction, signed_blinded_inputs, unsigned_inputs, +}; +pub use validation::validate_address; +use validation::{ + canonical_transaction_size_bytes, validate_hash, validate_protocol_id, validate_signature, +}; +#[cfg(test)] +use vdf::{ + MAX_VDF_RETARGET_OBSERVED_BLOCK_MS, MIN_VDF_RETARGET_OBSERVED_BLOCK_MS, + VDF_RETARGET_DEADBAND_PERCENT, clamped_vdf_retarget_observed_block_ms, retarget_vdf_rounds, + vdf_retarget_observed_block_ms, +}; +pub use vdf::{run_vdf, verify_vdf}; +pub use wallet::Wallet; pub fn reveal_committee_slot_count(eligible_rank_count: usize) -> usize { eligible_rank_count.min(REVEAL_COMMITTEE_SIZE) @@ -58,8995 +142,5 @@ pub fn blinded_reveal_finalizer_fee( as Amount } -const MINE_RETARGET_WINDOW_BLOCKS: u64 = 10; -const MINE_TARGET_ACTIONS_PER_BLOCK: u64 = 1; -const MINE_MAX_RETARGET_STEP_BITS: u32 = 2; -const MINE_MIN_DIFFICULTY_BITS: u32 = 10; -const MINE_MAX_DIFFICULTY_BITS: u32 = 32; -const MINE_MAX_ANCHOR_AGE_BLOCKS: u64 = MINE_RETARGET_WINDOW_BLOCKS; -pub const MAX_PENDING_TRANSACTIONS: usize = 10_000; -const MAX_ORPHAN_TRANSACTIONS: usize = 1_024; -const MAX_BLOCK_TRANSACTIONS: usize = 1_000; -const DEFAULT_TICKET_MATURITY_DELAY: u64 = 3; -const DEFAULT_TICKET_EXPIRY_WINDOW: u64 = 3; -const MIN_VDF_ROUNDS: u64 = 1; -const VDF_RETARGET_WINDOW_BLOCKS: usize = 20; -const MAX_VDF_RETARGET_STEP_PERCENT: u128 = 2; -const VDF_RETARGET_DEADBAND_PERCENT: u128 = 10; -const MIN_VDF_RETARGET_OBSERVED_BLOCK_MS: u64 = VDF_TARGET_BLOCK_MS / 4; -const MAX_VDF_RETARGET_OBSERVED_BLOCK_MS: u64 = VDF_TARGET_BLOCK_MS * 4; -const MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS: u64 = 2 * 60 * 1_000; -const BLOCK_MEDIAN_TIME_PAST_WINDOW: usize = 11; -const FORK_FINALITY_DEPTH: u64 = 6; -const VDF_MODULUS: u128 = 4_611_685_975_477_714_963; -const VDF_CHALLENGE_MIN: u64 = 1_073_741_827; -const WALLET_SEED_DOMAIN: &str = "iuna-wallet-seed"; -const PUBLIC_KEY_BYTES: usize = 32; -const HASH_BYTES: usize = 32; -const SIGNATURE_BYTES: usize = 64; -const BLINDED_KEY_BYTES: usize = 32; -const BLINDED_NONCE_BYTES: usize = 12; -const STRATUM_MINE_HEADER_BYTES: usize = 80; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Wallet { - address: String, - secret: String, -} - -impl Wallet { - pub fn from_seed(seed: &str) -> Self { - let seed_hash = Sha256::digest(format!("{WALLET_SEED_DOMAIN}:{seed}").as_bytes()); - let mut signing_seed = [0_u8; 32]; - signing_seed.copy_from_slice(&seed_hash); - let signing_key = SigningKey::from_bytes(&signing_seed); - let secret = hex_encode(signing_seed); - let address = hex_encode(signing_key.verifying_key().to_bytes()); - Self { address, secret } - } - - pub fn address(&self) -> &str { - &self.address - } - - fn sign_payload(&self, payload: &str) -> String { - let seed = - decode_hex_array::<PUBLIC_KEY_BYTES>(&self.secret).expect("wallet secret is valid hex"); - let signing_key = SigningKey::from_bytes(&seed); - let signature: Signature = signing_key.sign(payload.as_bytes()); - hex_encode(signature.to_bytes()) - } - - fn leader_proof(&self, payload: &LeaderProofPayload) -> LeaderProof { - let signature = self.sign_payload(&payload.canonical()); - LeaderProof { - ticket_id: payload.ticket_id.clone(), - public_key: self.address.clone(), - signature, - } - } - - fn reveal_bundle(&self, payload: RevealBundlePayload) -> RevealBundle { - let signature = self.sign_payload(&payload.canonical()); - RevealBundle { - height: payload.height, - prev_hash: payload.prev_hash, - slot: payload.slot, - member: self.address.clone(), - reveals: payload.reveals, - signature, - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] -pub struct OutPoint { - pub txid: String, - pub index: u32, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct TxInput { - pub outpoint: OutPoint, - pub owner: String, - pub signature: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct TxOutput { - pub address: String, - pub amount: Amount, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum Transaction { - Transfer { - inputs: Vec<TxInput>, - outputs: Vec<TxOutput>, - #[serde(default)] - fee: Amount, - signature: String, - }, - Burn { - inputs: Vec<TxInput>, - change: Vec<TxOutput>, - amount: Amount, - #[serde(default)] - fee: Amount, - signature: String, - }, - Mine { - recipient: String, - anchor: String, - #[serde(default)] - salt: u64, - nonce: u64, - difficulty_bits: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - proof_header: Option<String>, - signature: String, - }, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -enum BlindedTransactionPayload { - Transfer { - outputs: Vec<TxOutput>, - signature: String, - }, - Burn { - change: Vec<TxOutput>, - amount: Amount, - signature: String, - }, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BlindedTransaction { - pub commitment: String, - #[serde(default)] - pub inputs: Vec<TxInput>, - pub fee: Amount, - pub encrypted_size: u32, - pub expires_at_height: u64, - pub nonce: String, - pub ciphertext: String, - pub payload_hash: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BlindedReveal { - pub commitment: String, - pub key: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct BuiltBlindedTransaction { - pub payload: Transaction, - pub transaction: BlindedTransaction, - pub reveal: BlindedReveal, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct OwnedBlindedTransaction { - pub transaction: BlindedTransaction, - pub payload: Transaction, - pub reveal: BlindedReveal, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RevealedBlindedTransaction { - pub height: u64, - pub commitment: String, - pub included_by: String, - pub transaction: Transaction, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct ActiveBlindedTransaction { - transaction: BlindedTransaction, - locked_outputs: Vec<TxOutput>, - included_height: u64, - included_by: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MineSearchOutcome { - pub transaction: Option<Transaction>, - pub next_nonce: u64, - pub attempts: u64, -} - -impl Transaction { - pub fn genesis_burn(from: impl Into<String>, amount: Amount) -> Self { - let from = from.into(); - Self::genesis_burn_with_change(from, amount, Vec::new()) - } - - fn genesis_burn_with_allocation( - from: impl Into<String>, - amount: Amount, - allocation: Amount, - ) -> Result<Self> { - if amount > allocation { - bail!("genesis burn exceeds allocation"); - } - let from = from.into(); - let change_amount = allocation - amount; - let change = if change_amount > 0 { - vec![TxOutput { - address: from.clone(), - amount: change_amount, - }] - } else { - Vec::new() - }; - Ok(Self::genesis_burn_with_change(from, amount, change)) - } - - fn genesis_burn_with_change(from: String, amount: Amount, change: Vec<TxOutput>) -> Self { - let input = TxInput { - outpoint: genesis_allocation_outpoint(&from), - owner: from.clone(), - signature: "genesis".to_string(), - }; - let unsigned = UnsignedUtxoTransaction::Burn { - inputs: vec![input.without_signature()], - change: change.clone(), - amount, - fee: 0, - }; - let signature = hex_hash(format!("iuna-genesis-burn:{}", unsigned.canonical())); - Self::Burn { - inputs: vec![input], - change, - amount, - fee: 0, - signature, - } - } - - pub fn sender(&self) -> &str { - match self { - Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs - .first() - .map(|input| input.owner.as_str()) - .unwrap_or(""), - Self::Mine { recipient, .. } => recipient.as_str(), - } - } - - pub fn to(&self) -> Option<&str> { - match self { - Self::Transfer { outputs, .. } => outputs.first().map(|output| output.address.as_str()), - Self::Burn { .. } => None, - Self::Mine { recipient, .. } => Some(recipient.as_str()), - } - } - - pub fn amount(&self) -> Amount { - match self { - Self::Transfer { outputs, .. } => { - outputs.first().map(|output| output.amount).unwrap_or(0) - } - Self::Burn { amount, .. } => *amount, - Self::Mine { .. } => MINE_REWARD, - } - } - - pub fn fee(&self) -> Amount { - match self { - Self::Transfer { fee, .. } | Self::Burn { fee, .. } => *fee, - Self::Mine { .. } => MINE_FINALIZER_FEE, - } - } - - pub fn total_debit(&self) -> Result<Amount> { - if matches!(self, Self::Mine { .. }) { - return Ok(0); - } - self.amount() - .checked_add(self.fee()) - .context("transaction amount plus fee overflows") - } - - pub fn signature(&self) -> &str { - match self { - Self::Transfer { signature, .. } | Self::Burn { signature, .. } => signature, - Self::Mine { signature, .. } => signature, - } - } - - pub fn is_burn(&self) -> bool { - matches!(self, Self::Burn { .. }) - } - - pub fn canonical(&self) -> String { - format!("{}:{}", self.signing_payload(), self.signature()) - } - - pub fn economic_size_bytes(&self) -> usize { - canonical_transaction_size_bytes(self) - } - - pub fn serialized_size_bytes(&self) -> Result<usize> { - serde_json::to_vec(self) - .map(|bytes| bytes.len()) - .context("failed to serialize transaction for size check") - } - - fn signing_payload(&self) -> String { - match self { - Self::Transfer { - inputs, - outputs, - fee, - .. - } => UnsignedUtxoTransaction::Transfer { - inputs: unsigned_inputs(inputs), - outputs: outputs.clone(), - fee: *fee, - } - .canonical(), - Self::Burn { - inputs, - change, - amount, - fee, - .. - } => UnsignedUtxoTransaction::Burn { - inputs: unsigned_inputs(inputs), - change: change.clone(), - amount: *amount, - fee: *fee, - } - .canonical(), - Self::Mine { - recipient, - anchor, - salt, - nonce, - difficulty_bits, - .. - } => mine_payload(recipient, anchor, *salt, *nonce, *difficulty_bits), - } - } - - fn verify_signature(&self) -> Result<()> { - if let Self::Mine { - recipient, - anchor, - salt, - nonce, - difficulty_bits, - proof_header, - signature, - } = self - { - let expected = if let Some(proof_header) = proof_header { - let header = - stratum_mine_header_bytes(recipient, anchor, *salt, *nonce, *difficulty_bits)?; - let expected_header = hex_encode(header); - if *proof_header != expected_header { - bail!("mine transaction proof header is invalid"); - } - stratum_mine_signature(&header) - } else { - mine_signature(recipient, anchor, *salt, *nonce, *difficulty_bits) - }; - if *signature != expected { - bail!("mine transaction proof hash is invalid"); - } - if !hash_meets_difficulty(signature, *difficulty_bits) { - bail!("mine transaction proof does not meet difficulty"); - } - return Ok(()); - } - if self.signature().starts_with("iuna-genesis-burn:") || self.inputs_are_genesis_signed() { - return Ok(()); - } - if !self - .inputs() - .iter() - .all(|input| input.signature == self.signature()) - { - bail!("transaction input signature does not match transaction signature"); - } - let sender = self.sender(); - let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(sender) - .with_context(|| format!("invalid public key for {sender}"))?; - let signature = decode_hex_array::<SIGNATURE_BYTES>(self.signature()) - .context("invalid signature hex")?; - let verifying_key = - VerifyingKey::from_bytes(&public_key).context("invalid transaction public key")?; - let signature = Signature::from_bytes(&signature); - verifying_key - .verify(self.signing_payload().as_bytes(), &signature) - .context("transaction signature is invalid") - } - - fn inputs(&self) -> &[TxInput] { - match self { - Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs, - Self::Mine { .. } => &[], - } - } - - fn outputs(&self) -> Vec<TxOutput> { - match self { - Self::Transfer { outputs, .. } => outputs.clone(), - Self::Burn { change, .. } => change.clone(), - Self::Mine { recipient, .. } => vec![TxOutput { - address: recipient.clone(), - amount: MINE_REWARD, - }], - } - } - - fn inputs_are_genesis_signed(&self) -> bool { - self.inputs() - .iter() - .all(|input| input.signature == "genesis") - } -} - -impl BlindedTransaction { - pub fn id(&self) -> &str { - &self.commitment - } - - pub fn canonical(&self) -> String { - format!( - "blinded-tx:{}:{}:{}:{}:{}:{}:{}", - canonical_signed_inputs(&self.inputs), - self.fee, - self.encrypted_size, - self.expires_at_height, - self.nonce, - self.ciphertext, - self.payload_hash - ) - } - - pub fn fee_rate_size_bytes(&self) -> usize { - self.serialized_size_bytes() - .unwrap_or(self.encrypted_size as usize) - } - - pub fn serialized_size_bytes(&self) -> Result<usize> { - serde_json::to_vec(self) - .map(|bytes| bytes.len()) - .context("failed to serialize blinded transaction for size check") - } -} - -impl BlindedReveal { - pub fn canonical(&self) -> String { - format!("blinded-reveal:{}:{}", self.commitment, self.key) - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RevealBundle { - pub height: u64, - pub prev_hash: String, - pub slot: u8, - pub member: String, - pub reveals: Vec<BlindedReveal>, - pub signature: String, -} - -impl RevealBundle { - pub fn canonical_payload(&self) -> String { - RevealBundlePayload { - height: self.height, - prev_hash: self.prev_hash.clone(), - slot: self.slot, - member: self.member.clone(), - reveals: self.reveals.clone(), - } - .canonical() - } - - pub fn canonical(&self) -> String { - format!("{}:{}", self.canonical_payload(), self.signature) - } - - pub fn bundle_hash(&self) -> String { - hex_hash(self.canonical()) - } - - pub fn serialized_size_bytes(&self) -> Result<usize> { - serde_json::to_vec(self) - .map(|bytes| bytes.len()) - .context("failed to serialize reveal bundle for size check") - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RevealBundleSignature { - pub slot: u8, - pub member: String, - pub signature: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct MaskedBlindedReveal { - pub reveal: BlindedReveal, - pub bundle_mask: u8, -} - -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RevealBundleSection { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub signatures: Vec<RevealBundleSignature>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub reveals: Vec<MaskedBlindedReveal>, -} - -impl RevealBundleSection { - pub fn is_empty(&self) -> bool { - self.signatures.is_empty() && self.reveals.is_empty() - } - - pub fn all_reveals(&self) -> Vec<&BlindedReveal> { - self.reveals.iter().map(|masked| &masked.reveal).collect() - } - - pub fn included_bundle_count(&self) -> usize { - self.signatures.len() - } - - pub fn expand(&self, height: u64, prev_hash: &str) -> Vec<RevealBundle> { - self.signatures - .iter() - .map(|signature| { - let slot_mask = reveal_bundle_slot_mask(signature.slot).unwrap_or(0); - let reveals = self - .reveals - .iter() - .filter(|masked| masked.bundle_mask & slot_mask != 0) - .map(|masked| masked.reveal.clone()) - .collect(); - RevealBundle { - height, - prev_hash: prev_hash.to_string(), - slot: signature.slot, - member: signature.member.clone(), - reveals, - signature: signature.signature.clone(), - } - }) - .collect() - } - - pub fn reveal_bundle_hashes( - &self, - height: u64, - prev_hash: &str, - ) -> [String; REVEAL_COMMITTEE_SIZE] { - let bundles = self.expand(height, prev_hash); - reveal_bundle_hashes(&bundles) - } - - fn canonical(&self) -> String { - let signatures = self - .signatures - .iter() - .map(|signature| { - format!( - "{}:{}:{}", - signature.slot, signature.member, signature.signature - ) - }) - .collect::<Vec<_>>() - .join("|"); - let reveals = self - .reveals - .iter() - .map(|masked| format!("{}:{}", masked.bundle_mask, masked.reveal.canonical())) - .collect::<Vec<_>>() - .join("|"); - format!("reveal-bundle-section-v1:{signatures}:reveals:{reveals}") - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct RevealBundlePayload { - height: u64, - prev_hash: String, - slot: u8, - member: String, - reveals: Vec<BlindedReveal>, -} - -impl RevealBundlePayload { - fn canonical(&self) -> String { - let reveals = self - .reveals - .iter() - .map(BlindedReveal::canonical) - .collect::<Vec<_>>() - .join("|"); - format!( - "iuna-reveal-bundle-v1:{}:{}:{}:{}:{}", - self.height, self.prev_hash, self.slot, self.member, reveals - ) - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct RevealCommitteeMember { - pub slot: u8, - pub rank: u32, - pub ticket_id: String, - pub owner: String, - pub amount: Amount, -} - -fn canonical_blinded_block_items(blinded: &str, reveal_section: &str) -> String { - format!("blinded-v3:{blinded}:reveal-section:{reveal_section}") -} - -impl TxInput { - fn without_signature(&self) -> UnsignedTxInput { - UnsignedTxInput { - outpoint: self.outpoint.clone(), - owner: self.owner.clone(), - } - } -} - -impl OutPoint { - fn id(&self) -> String { - format!("{}:{}", self.txid, self.index) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct UnsignedTxInput { - outpoint: OutPoint, - owner: String, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -enum UnsignedUtxoTransaction { - Transfer { - inputs: Vec<UnsignedTxInput>, - outputs: Vec<TxOutput>, - fee: Amount, - }, - Burn { - inputs: Vec<UnsignedTxInput>, - change: Vec<TxOutput>, - amount: Amount, - fee: Amount, - }, -} - -impl UnsignedUtxoTransaction { - fn sign(self, wallet: &Wallet) -> Transaction { - let signature = wallet.sign_payload(&self.canonical()); - let signed_inputs = self - .inputs() - .iter() - .map(|input| TxInput { - outpoint: input.outpoint.clone(), - owner: input.owner.clone(), - signature: signature.clone(), - }) - .collect::<Vec<_>>(); - match self { - Self::Transfer { outputs, fee, .. } => Transaction::Transfer { - inputs: signed_inputs, - outputs, - fee, - signature, - }, - Self::Burn { - change, - amount, - fee, - .. - } => Transaction::Burn { - inputs: signed_inputs, - change, - amount, - fee, - signature, - }, - } - } - - fn inputs(&self) -> &[UnsignedTxInput] { - match self { - Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs, - } - } - - fn canonical(&self) -> String { - match self { - Self::Transfer { - inputs, - outputs, - fee, - } => format!( - "utxo-transfer:{}:{}:{fee}", - canonical_inputs(inputs), - canonical_outputs(outputs) - ), - Self::Burn { - inputs, - change, - amount, - fee, - } => format!( - "utxo-burn:{}:{}:{amount}:{fee}", - canonical_inputs(inputs), - canonical_outputs(change) - ), - } - } -} - -fn unsigned_inputs(inputs: &[TxInput]) -> Vec<UnsignedTxInput> { - inputs.iter().map(TxInput::without_signature).collect() -} - -fn signed_blinded_inputs(inputs: &[UnsignedTxInput], signature: &str) -> Vec<TxInput> { - inputs - .iter() - .map(|input| TxInput { - outpoint: input.outpoint.clone(), - owner: input.owner.clone(), - signature: signature.to_string(), - }) - .collect() -} - -fn canonical_inputs(inputs: &[UnsignedTxInput]) -> String { - inputs - .iter() - .map(|input| { - format!( - "{}:{}:{}", - input.outpoint.txid, input.outpoint.index, input.owner - ) - }) - .collect::<Vec<_>>() - .join("|") -} - -fn canonical_signed_inputs(inputs: &[TxInput]) -> String { - inputs - .iter() - .map(|input| { - format!( - "{}:{}:{}:{}", - input.outpoint.txid, input.outpoint.index, input.owner, input.signature - ) - }) - .collect::<Vec<_>>() - .join("|") -} - -fn canonical_outputs(outputs: &[TxOutput]) -> String { - outputs - .iter() - .map(|output| format!("{}:{}", output.address, output.amount)) - .collect::<Vec<_>>() - .join("|") -} - -fn mine_payload( - recipient: &str, - anchor: &str, - salt: u64, - nonce: u64, - difficulty_bits: u32, -) -> String { - format!("iuna-mine:{recipient}:{anchor}:{salt}:{nonce}:{difficulty_bits}") -} - -fn mine_signature( - recipient: &str, - anchor: &str, - salt: u64, - nonce: u64, - difficulty_bits: u32, -) -> String { - hex_hash(mine_payload( - recipient, - anchor, - salt, - nonce, - difficulty_bits, - )) -} - -pub const STRATUM_EXTRANONCE1_HEX: &str = "00000000"; -pub const STRATUM_EXTRANONCE2_SIZE: usize = 4; -const STRATUM_MINE_VERSION: [u8; 4] = [1, 0, 0, 0]; -const STRATUM_MINE_NTIME: [u8; 4] = [0, 0, 0, 0]; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct StratumMineTemplate { - pub recipient: String, - pub anchor: String, - pub salt: u64, - pub difficulty_bits: u32, - pub coinbase_prefix: Vec<u8>, - pub version_hex: String, - pub prev_hash_hex: String, - pub nbits_hex: String, - pub ntime_hex: String, -} - -impl StratumMineTemplate { - pub fn coinb1_hex(&self) -> String { - hex_encode(&self.coinbase_prefix) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct StratumMineShare { - pub extranonce2: [u8; 4], - pub header_nonce: [u8; 4], -} - -pub fn pack_stratum_nonce(extranonce2: [u8; 4], header_nonce: [u8; 4]) -> u64 { - let extra = u32::from_be_bytes(extranonce2) as u64; - let nonce = u32::from_le_bytes(header_nonce) as u64; - (extra << 32) | nonce -} - -fn unpack_stratum_nonce(nonce: u64) -> ([u8; 4], [u8; 4]) { - ( - ((nonce >> 32) as u32).to_be_bytes(), - (nonce as u32).to_le_bytes(), - ) -} - -fn stratum_coinbase_prefix( - recipient: &str, - anchor: &str, - salt: u64, - difficulty_bits: u32, -) -> Vec<u8> { - format!("iuna-stratum-mine:{recipient}:{anchor}:{salt}:{difficulty_bits}:").into_bytes() -} - -fn stratum_coinbase_bytes( - recipient: &str, - anchor: &str, - salt: u64, - nonce: u64, - difficulty_bits: u32, -) -> Vec<u8> { - let (extranonce2, _) = unpack_stratum_nonce(nonce); - let mut coinbase = stratum_coinbase_prefix(recipient, anchor, salt, difficulty_bits); - coinbase.extend_from_slice(&[0, 0, 0, 0]); - coinbase.extend_from_slice(&extranonce2); - coinbase -} - -fn double_sha256(bytes: &[u8]) -> [u8; 32] { - let first = Sha256::digest(bytes); - let second = Sha256::digest(first); - second.into() -} - -fn stratum_mine_header_bytes( - recipient: &str, - anchor: &str, - salt: u64, - nonce: u64, - difficulty_bits: u32, -) -> Result<[u8; 80]> { - let mut header = [0_u8; 80]; - header[0..4].copy_from_slice(&STRATUM_MINE_VERSION); - let anchor_bytes = - decode_hex_array::<HASH_BYTES>(anchor).context("mine transaction anchor is not hex")?; - header[4..36].copy_from_slice(&anchor_bytes); - let merkle_root = double_sha256(&stratum_coinbase_bytes( - recipient, - anchor, - salt, - nonce, - difficulty_bits, - )); - header[36..68].copy_from_slice(&merkle_root); - header[68..72].copy_from_slice(&STRATUM_MINE_NTIME); - header[72..76].copy_from_slice(&difficulty_bits.to_le_bytes()); - let (_, header_nonce) = unpack_stratum_nonce(nonce); - header[76..80].copy_from_slice(&header_nonce); - Ok(header) -} - -fn stratum_mine_signature(header: &[u8; 80]) -> String { - let mut digest = double_sha256(header); - digest.reverse(); - hex_encode(digest) -} - -fn stratum_mine_template( - recipient: impl Into<String>, - anchor: &str, - salt: u64, - difficulty_bits: u32, -) -> Result<StratumMineTemplate> { - let recipient = recipient.into(); - validate_address(&recipient, "mine recipient")?; - validate_hash(anchor, "mine transaction anchor")?; - let anchor_bytes = - decode_hex_array::<HASH_BYTES>(anchor).context("mine transaction anchor is not hex")?; - Ok(StratumMineTemplate { - recipient: recipient.clone(), - anchor: anchor.to_string(), - salt, - difficulty_bits, - coinbase_prefix: stratum_coinbase_prefix(&recipient, anchor, salt, difficulty_bits), - version_hex: hex_encode(STRATUM_MINE_VERSION), - prev_hash_hex: hex_encode(anchor_bytes), - nbits_hex: hex_encode(difficulty_bits.to_le_bytes()), - ntime_hex: hex_encode(STRATUM_MINE_NTIME), - }) -} - -fn hash_meets_difficulty(hash: &str, difficulty_bits: u32) -> bool { - let full_zero_nibbles = (difficulty_bits / 4) as usize; - let remaining_bits = difficulty_bits % 4; - if hash.len() < full_zero_nibbles + usize::from(remaining_bits > 0) { - return false; - } - if !hash.as_bytes()[..full_zero_nibbles] - .iter() - .all(|byte| *byte == b'0') - { - return false; - } - if remaining_bits == 0 { - return true; - } - let Some(next) = hash.as_bytes().get(full_zero_nibbles).copied() else { - return false; - }; - let Some(value) = (next as char).to_digit(16) else { - return false; - }; - value < (1 << (4 - remaining_bits)) -} - -fn pending_spent_outpoints(pending: &[Transaction]) -> BTreeSet<OutPoint> { - pending - .iter() - .flat_map(|tx| tx.inputs().iter().map(|input| input.outpoint.clone())) - .collect() -} - -fn transaction_inputs_spent_by(transaction: &Transaction, pending: &[Transaction]) -> bool { - let spent = pending_spent_outpoints(pending); - transaction - .inputs() - .iter() - .any(|input| spent.contains(&input.outpoint)) -} - -fn transaction_inputs_spent_by_inputs(inputs: &[TxInput], pending: &[Transaction]) -> bool { - let spent = pending_spent_outpoints(pending); - inputs.iter().any(|input| spent.contains(&input.outpoint)) -} - -fn blinded_transaction_inputs_spent_by( - transaction: &BlindedTransaction, - pending: &[BlindedTransaction], -) -> bool { - let spent = pending - .iter() - .flat_map(|transaction| { - transaction - .inputs - .iter() - .map(|input| input.outpoint.clone()) - }) - .collect::<BTreeSet<_>>(); - transaction - .inputs - .iter() - .any(|input| spent.contains(&input.outpoint)) -} - -fn transaction_inputs_available( - transaction: &Transaction, - utxos: &BTreeMap<OutPoint, TxOutput>, -) -> bool { - transaction - .inputs() - .iter() - .all(|input| utxos.contains_key(&input.outpoint)) -} - -fn blinded_transaction_inputs_available( - transaction: &BlindedTransaction, - utxos: &BTreeMap<OutPoint, TxOutput>, -) -> bool { - transaction - .inputs - .iter() - .all(|input| utxos.contains_key(&input.outpoint)) -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct Block { - pub height: u64, - pub prev_hash: String, - pub timestamp_ms: u64, - pub miner: String, - #[serde(default)] - pub finalizer_mode: FinalizerMode, - #[serde(default)] - pub finalizer_rank: u32, - pub reward: Amount, - pub vdf_rounds: u64, - pub vdf_output: String, - pub leader_proof: Option<LeaderProof>, - #[serde(default)] - pub blinded_transactions: Vec<BlindedTransaction>, - #[serde(default)] - pub reveal_bundle_section: RevealBundleSection, - pub transactions: Vec<Transaction>, - pub hash: String, -} - -#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum FinalizerMode { - #[default] - Ticket, - Recovery, -} - -impl Block { - fn new(draft: BlockDraft) -> Self { - let mut block = Self { - height: draft.height, - prev_hash: draft.prev_hash, - timestamp_ms: draft.timestamp_ms, - miner: draft.miner, - finalizer_mode: draft.finalizer_mode, - finalizer_rank: draft.finalizer_rank, - reward: draft.reward, - vdf_rounds: draft.vdf_rounds, - vdf_output: draft.vdf_output, - leader_proof: draft.leader_proof, - blinded_transactions: draft.blinded_transactions, - reveal_bundle_section: draft.reveal_bundle_section, - transactions: draft.transactions, - hash: String::new(), - }; - block.hash = block.compute_hash(); - block - } - - pub fn compute_hash(&self) -> String { - hex_hash(format!( - "block:{}:{}:{}", - self.content_hash(), - self.vdf_seed(), - self.vdf_output, - )) - } - - pub fn vdf_seed(&self) -> String { - let bundle_hashes = self.reveal_bundle_hashes(); - match self.finalizer_mode { - FinalizerMode::Ticket => { - vdf_seed_for_child(&self.prev_hash, self.height, &bundle_hashes) - } - FinalizerMode::Recovery => recovery_vdf_seed_for_child( - &self.prev_hash, - self.height, - self.timestamp_ms, - &bundle_hashes, - ), - } - } - - fn content_hash(&self) -> String { - let txs = self - .transactions - .iter() - .map(Transaction::canonical) - .collect::<Vec<_>>() - .join("|"); - let blinded = self - .blinded_transactions - .iter() - .map(BlindedTransaction::canonical) - .collect::<Vec<_>>() - .join("|"); - let reveal_section = self.reveal_bundle_section.canonical(); - let leader_proof = self - .leader_proof - .as_ref() - .map(|proof| { - format!( - "{}:{}:{}", - proof.ticket_id, proof.public_key, proof.signature - ) - }) - .unwrap_or_default(); - if !self.blinded_transactions.is_empty() || !self.reveal_bundle_section.is_empty() { - return hex_hash(format!( - "block-content-v3:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", - self.height, - self.prev_hash, - self.timestamp_ms, - self.miner, - self.finalizer_rank, - self.reward, - self.vdf_rounds, - leader_proof, - txs, - canonical_blinded_block_items(&blinded, &reveal_section) - )); - } - hex_hash(format!( - "{}:{}", - self.legacy_content_hash_prefix(&leader_proof), - txs - )) - } - - fn legacy_content_hash_prefix(&self, leader_proof: &str) -> String { - if self.finalizer_mode == FinalizerMode::Recovery { - format!( - "block-content-recovery-v1:{}:{}:{}:{}:{}:{}:{}", - self.height, - self.prev_hash, - self.timestamp_ms, - self.miner, - self.reward, - self.vdf_rounds, - leader_proof - ) - } else if self.finalizer_rank == 0 { - format!( - "block-content:{}:{}:{}:{}:{}:{}:{}", - self.height, - self.prev_hash, - self.timestamp_ms, - self.miner, - self.reward, - self.vdf_rounds, - leader_proof - ) - } else { - format!( - "block-content-v2:{}:{}:{}:{}:{}:{}:{}:{}", - self.height, - self.prev_hash, - self.timestamp_ms, - self.miner, - self.finalizer_rank, - self.reward, - self.vdf_rounds, - leader_proof - ) - } - } - - fn leader_score(&self) -> LeaderScore { - LeaderScore { - finalizer_mode_rank: self.finalizer_mode.fork_choice_rank(), - finalizer_rank: self.finalizer_rank, - proof_rank: self - .leader_proof - .as_ref() - .map(LeaderProof::rank) - .unwrap_or_else(|| self.hash.clone()), - } - } - - pub fn serialized_size_bytes(&self) -> Result<usize> { - serde_json::to_vec(self) - .map(|bytes| bytes.len()) - .context("failed to serialize block for size check") - } - - pub fn all_blinded_reveals(&self) -> Vec<&BlindedReveal> { - self.reveal_bundle_section.all_reveals() - } - - pub fn reveal_bundle_hashes(&self) -> [String; REVEAL_COMMITTEE_SIZE] { - self.reveal_bundle_section - .reveal_bundle_hashes(self.height, &self.prev_hash) - } - - pub fn included_reveal_bundle_count(&self) -> usize { - self.reveal_bundle_section.included_bundle_count() - } -} - -impl FinalizerMode { - fn fork_choice_rank(self) -> u8 { - match self { - Self::Ticket => 0, - Self::Recovery => 1, - } - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct LeaderProof { - pub ticket_id: String, - pub public_key: String, - pub signature: String, -} - -impl LeaderProof { - fn rank(&self) -> String { - hex_hash(format!( - "iuna-leader-rank:{}:{}", - self.ticket_id, self.signature - )) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct LeaderProofPayload { - height: u64, - prev_hash: String, - finalizer_rank: u32, - vdf_output: String, - ticket_id: String, - ticket_amount: Amount, - ticket_owner: String, -} - -impl LeaderProofPayload { - fn canonical(&self) -> String { - if self.finalizer_rank == 0 { - format!( - "iuna-leader-proof:{}:{}:{}:{}:{}:{}", - self.height, - self.prev_hash, - self.vdf_output, - self.ticket_id, - self.ticket_amount, - self.ticket_owner - ) - } else { - format!( - "iuna-leader-proof-v2:{}:{}:{}:{}:{}:{}:{}", - self.height, - self.prev_hash, - self.finalizer_rank, - self.vdf_output, - self.ticket_id, - self.ticket_amount, - self.ticket_owner - ) - } - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct BurnTicket { - id: String, - owner: String, - amount: Amount, - eligible_from_height: u64, - eligible_until_height: u64, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BurnLeaderRank { - pub rank: u32, - pub ticket_id: String, - pub owner: String, - pub amount: Amount, - pub eligible_from_height: u64, - pub eligible_until_height: u64, -} - -#[derive(Clone, Debug)] -pub struct PreparedBlock { - height: u64, - prev_hash: String, - timestamp_ms: u64, - miner: String, - finalizer_mode: FinalizerMode, - finalizer_rank: u32, - reward: Amount, - vdf_rounds: u64, - vdf_seed: String, - leader_ticket: Option<BurnTicket>, - blinded_transactions: Vec<BlindedTransaction>, - reveal_bundle_section: RevealBundleSection, - transactions: Vec<Transaction>, -} - -impl PreparedBlock { - pub fn vdf_seed(&self) -> &str { - &self.vdf_seed - } - - pub fn vdf_rounds(&self) -> u64 { - self.vdf_rounds - } - - pub fn height(&self) -> u64 { - self.height - } - - pub fn timestamp_ms(&self) -> u64 { - self.timestamp_ms - } - - pub fn finish(self, wallet: &Wallet, vdf_output: String) -> Block { - let timestamp_ms = self.timestamp_ms; - self.finish_with_timestamp(wallet, vdf_output, timestamp_ms) - } - - pub fn finish_at(self, wallet: &Wallet, vdf_output: String, timestamp_ms: u64) -> Block { - let timestamp_ms = match self.finalizer_mode { - FinalizerMode::Ticket => timestamp_ms.max(self.timestamp_ms), - FinalizerMode::Recovery => self.timestamp_ms, - }; - self.finish_with_timestamp(wallet, vdf_output, timestamp_ms) - } - - fn finish_with_timestamp( - self, - wallet: &Wallet, - vdf_output: String, - timestamp_ms: u64, - ) -> Block { - let leader_proof = self.leader_ticket.as_ref().map(|leader_ticket| { - let proof_payload = LeaderProofPayload { - height: self.height, - prev_hash: self.prev_hash.clone(), - finalizer_rank: self.finalizer_rank, - vdf_output: vdf_output.clone(), - ticket_id: leader_ticket.id.clone(), - ticket_amount: leader_ticket.amount, - ticket_owner: leader_ticket.owner.clone(), - }; - wallet.leader_proof(&proof_payload) - }); - Block::new(BlockDraft { - height: self.height, - prev_hash: self.prev_hash, - timestamp_ms, - miner: self.miner, - finalizer_mode: self.finalizer_mode, - finalizer_rank: self.finalizer_rank, - reward: self.reward, - vdf_rounds: self.vdf_rounds, - vdf_output, - leader_proof, - blinded_transactions: self.blinded_transactions, - reveal_bundle_section: self.reveal_bundle_section, - transactions: self.transactions, - }) - } -} - -#[derive(Clone, Debug)] -struct BlockDraft { - height: u64, - prev_hash: String, - timestamp_ms: u64, - miner: String, - finalizer_mode: FinalizerMode, - finalizer_rank: u32, - reward: Amount, - vdf_rounds: u64, - vdf_output: String, - leader_proof: Option<LeaderProof>, - blinded_transactions: Vec<BlindedTransaction>, - reveal_bundle_section: RevealBundleSection, - transactions: Vec<Transaction>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct ChainStatus { - pub height: u64, - pub tip_hash: String, - pub next_leader: Option<String>, - pub launch_profile_hash: String, - pub mine_reward: Amount, - pub current_mine_difficulty_bits: u32, - pub balances: BTreeMap<String, Amount>, - pub pending_transactions: usize, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct ChainSnapshot { - pub genesis_allocations: BTreeMap<String, Amount>, - pub vdf_rounds: u64, - pub launch_profile: LaunchProfile, - pub blocks: Vec<Block>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct LaunchProfile { - pub profile_id: String, - pub ticket_maturity_delay_heights: u64, - #[serde(default = "default_ticket_expiry_window_heights")] - pub ticket_expiry_window_heights: u64, - #[serde(default = "default_mine_difficulty_bits")] - pub mine_difficulty_bits: u32, - pub max_pending_transactions: usize, - pub max_block_transactions: usize, - #[serde(default = "default_max_block_bytes")] - pub max_block_bytes: usize, -} - -impl Default for LaunchProfile { - fn default() -> Self { - Self { - profile_id: "iuna-devnet-v5".to_string(), - ticket_maturity_delay_heights: DEFAULT_TICKET_MATURITY_DELAY, - ticket_expiry_window_heights: DEFAULT_TICKET_EXPIRY_WINDOW, - mine_difficulty_bits: MINE_DIFFICULTY_BITS, - max_pending_transactions: MAX_PENDING_TRANSACTIONS, - max_block_transactions: MAX_BLOCK_TRANSACTIONS, - max_block_bytes: MAX_BLOCK_BYTES, - } - } -} - -fn default_max_block_bytes() -> usize { - MAX_BLOCK_BYTES -} - -fn default_ticket_expiry_window_heights() -> u64 { - DEFAULT_TICKET_EXPIRY_WINDOW -} - -fn default_mine_difficulty_bits() -> u32 { - MINE_DIFFICULTY_BITS -} - -impl LaunchProfile { - pub fn hash(&self) -> String { - hex_hash(format!( - "iuna-launch-profile:{}:{}:{}:{}:{}:{}:{}", - self.profile_id, - self.ticket_maturity_delay_heights, - self.ticket_expiry_window_heights, - self.mine_difficulty_bits, - self.max_pending_transactions, - self.max_block_transactions, - self.max_block_bytes - )) - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct GenesisBurn { - pub from: String, - pub amount: Amount, -} - -impl GenesisBurn { - pub fn new(from: impl Into<String>, amount: Amount) -> Self { - Self { - from: from.into(), - amount, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct ForkPoint { - common_ancestor_height: u64, -} - -impl ForkPoint { - fn first_diverging_height(self) -> u64 { - self.common_ancestor_height + 1 - } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct LeaderScore { - finalizer_mode_rank: u8, - finalizer_rank: u32, - proof_rank: String, -} - -impl Ord for LeaderScore { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.finalizer_mode_rank - .cmp(&other.finalizer_mode_rank) - .then_with(|| self.finalizer_rank.cmp(&other.finalizer_rank)) - .then_with(|| self.proof_rank.cmp(&other.proof_rank)) - } -} - -impl PartialOrd for LeaderScore { - fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { - Some(self.cmp(other)) - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ForkQuality { - LocalBetter, - RemoteBetter, - Equal, -} - -impl From<std::cmp::Ordering> for ForkQuality { - fn from(ordering: std::cmp::Ordering) -> Self { - match ordering { - std::cmp::Ordering::Less => Self::LocalBetter, - std::cmp::Ordering::Equal => Self::Equal, - std::cmp::Ordering::Greater => Self::RemoteBetter, - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ForkChoice { - KeepLocal, - SwitchToCandidate, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum TransactionKind { - Burn, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq)] -struct BlockSelection { - transactions: Vec<Transaction>, - blinded_transactions: Vec<BlindedTransaction>, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum TransactionSubmitOutcome { - Added, - AlreadyKnown, - ConflictsWithPending, -} - -impl TransactionSubmitOutcome { - pub fn added(self) -> bool { - matches!(self, Self::Added) - } -} - -#[derive(Clone, Debug)] -pub struct Ledger { - chain: Vec<Block>, - genesis_allocations: BTreeMap<String, Amount>, - utxos: BTreeMap<OutPoint, TxOutput>, - tickets: Vec<BurnTicket>, - pending: Vec<Transaction>, - orphans: Vec<Transaction>, - pending_blinded: Vec<BlindedTransaction>, - pending_reveals: Vec<BlindedReveal>, - active_blinded: BTreeMap<String, ActiveBlindedTransaction>, - mine_reward: Amount, - initial_vdf_rounds: u64, - vdf_rounds: u64, - launch_profile: LaunchProfile, -} - -impl Ledger { - pub fn new(genesis_allocations: BTreeMap<String, Amount>, vdf_rounds: u64) -> Self { - Self::new_with_genesis_transactions(genesis_allocations, Vec::new(), vdf_rounds) - .expect("empty genesis transactions are valid") - } - - pub fn new_with_genesis_burns( - genesis_allocations: BTreeMap<String, Amount>, - genesis_burns: Vec<GenesisBurn>, - vdf_rounds: u64, - ) -> Result<Self> { - let transactions = genesis_burns - .into_iter() - .map(|burn| { - let allocation = genesis_allocations - .get(&burn.from) - .copied() - .unwrap_or_default(); - Transaction::genesis_burn_with_allocation(burn.from, burn.amount, allocation) - }) - .collect::<Result<Vec<_>>>()?; - Self::new_with_genesis_transactions(genesis_allocations, transactions, vdf_rounds) - } - - fn new_with_genesis_transactions( - genesis_allocations: BTreeMap<String, Amount>, - genesis_transactions: Vec<Transaction>, - vdf_rounds: u64, - ) -> Result<Self> { - validate_genesis_allocations(&genesis_allocations)?; - let launch_profile = LaunchProfile::default(); - let genesis = build_genesis_block(&genesis_allocations, genesis_transactions); - let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?; - let tickets = genesis_tickets(&genesis_allocations, &genesis, &launch_profile)?; - Ok(Self { - chain: vec![genesis], - genesis_allocations: genesis_allocations.clone(), - utxos, - tickets, - pending: Vec::new(), - orphans: Vec::new(), - pending_blinded: Vec::new(), - pending_reveals: Vec::new(), - active_blinded: BTreeMap::new(), - mine_reward: MINE_REWARD, - initial_vdf_rounds: vdf_rounds, - vdf_rounds, - launch_profile, - }) - } - - pub fn from_snapshot(snapshot: ChainSnapshot) -> Result<Self> { - Self::from_snapshot_at(snapshot, unix_now_ms()) - } - - pub fn from_persisted_snapshot(snapshot: ChainSnapshot) -> Result<Self> { - Self::from_snapshot_at(snapshot, u64::MAX) - } - - pub(crate) fn from_snapshot_at(snapshot: ChainSnapshot, now_ms: u64) -> Result<Self> { - Self::from_snapshot_with_vdf_policy(snapshot, true, now_ms) - } - - fn from_snapshot_with_vdf_policy( - snapshot: ChainSnapshot, - verify_vdf: bool, - now_ms: u64, - ) -> Result<Self> { - let ChainSnapshot { - genesis_allocations, - vdf_rounds, - launch_profile, - blocks, - } = snapshot; - - if blocks.is_empty() { - bail!("chain snapshot is empty"); - } - - validate_genesis_allocations(&genesis_allocations)?; - let genesis = blocks[0].clone(); - validate_genesis_block(&genesis)?; - let expected_genesis = - build_genesis_block(&genesis_allocations, genesis.transactions.clone()); - if genesis != expected_genesis { - bail!("chain snapshot genesis does not match its allocations and transactions"); - } - let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?; - - let mut ledger = Self { - chain: vec![genesis], - genesis_allocations, - utxos, - tickets: Vec::new(), - pending: Vec::new(), - orphans: Vec::new(), - pending_blinded: Vec::new(), - pending_reveals: Vec::new(), - active_blinded: BTreeMap::new(), - mine_reward: MINE_REWARD, - initial_vdf_rounds: vdf_rounds, - vdf_rounds, - launch_profile, - }; - ledger.tickets = genesis_tickets( - &ledger.genesis_allocations, - ledger.tip(), - &ledger.launch_profile, - )?; - - for block in blocks.into_iter().skip(1) { - if verify_vdf { - ledger.apply_block_at(block, now_ms)?; - } else { - ledger.apply_preverified_block_at(block, now_ms)?; - } - } - Ok(ledger) - } - - pub fn extend_from_snapshot(&mut self, snapshot: ChainSnapshot) -> Result<bool> { - self.extend_from_snapshot_with_vdf_policy(snapshot, true, unix_now_ms()) - } - - pub(crate) fn extend_from_preverified_snapshot_at( - &mut self, - snapshot: ChainSnapshot, - now_ms: u64, - ) -> Result<bool> { - self.extend_from_snapshot_with_vdf_policy(snapshot, false, now_ms) - } - - pub(crate) fn missing_snapshot_blocks(&self, snapshot: &ChainSnapshot) -> Result<Vec<Block>> { - let remote_height = self.validate_snapshot_identity(snapshot)?; - if remote_height <= self.height() { - return Ok(Vec::new()); - } - let common_ancestor_height = self.common_ancestor_height(snapshot)?; - - Ok(snapshot - .blocks - .iter() - .skip(common_ancestor_height as usize + 1) - .cloned() - .collect()) - } - - fn extend_from_snapshot_with_vdf_policy( - &mut self, - snapshot: ChainSnapshot, - verify_vdf: bool, - now_ms: u64, - ) -> Result<bool> { - self.validate_snapshot_identity(&snapshot)?; - let candidate = Self::from_snapshot_with_vdf_policy(snapshot, verify_vdf, now_ms)?; - let fork_point = self.fork_point_with_candidate(&candidate)?; - - if self.choose_fork(&candidate, fork_point) == ForkChoice::KeepLocal { - return Ok(false); - } - - self.replace_with_better_chain(candidate, fork_point); - - Ok(true) - } - - fn validate_snapshot_identity(&self, snapshot: &ChainSnapshot) -> Result<u64> { - if snapshot.blocks.is_empty() { - bail!("chain snapshot is empty"); - } - if snapshot.vdf_rounds != self.initial_vdf_rounds { - bail!("chain snapshot initial VDF rounds do not match local chain"); - } - if snapshot.launch_profile != self.launch_profile { - bail!("chain snapshot launch profile does not match local chain"); - } - if snapshot.genesis_allocations != self.genesis_allocations { - bail!("chain snapshot genesis allocations do not match local chain"); - } - if snapshot.blocks[0].hash != self.genesis_hash() { - bail!("chain snapshot genesis does not match local chain"); - } - - let remote_height = snapshot - .blocks - .last() - .map(|block| block.height) - .unwrap_or(0); - - Ok(remote_height) - } - - fn common_ancestor_height(&self, snapshot: &ChainSnapshot) -> Result<u64> { - self.validate_snapshot_identity(snapshot)?; - let max_common_index = self.chain.len().min(snapshot.blocks.len()) - 1; - for index in 0..=max_common_index { - if self.chain[index] != snapshot.blocks[index] { - if index == 0 { - bail!("chain snapshot has no common genesis block"); - } - return Ok(index as u64 - 1); - } - } - Ok(max_common_index as u64) - } - - fn fork_point_with_candidate(&self, candidate: &Ledger) -> Result<ForkPoint> { - if candidate.genesis_hash() != self.genesis_hash() { - bail!("candidate chain has no common genesis block"); - } - let max_common_index = self.chain.len().min(candidate.chain.len()) - 1; - for index in 0..=max_common_index { - if self.chain[index] != candidate.chain[index] { - if index == 0 { - bail!("candidate chain has no common genesis block"); - } - return Ok(ForkPoint { - common_ancestor_height: index as u64 - 1, - }); - } - } - Ok(ForkPoint { - common_ancestor_height: max_common_index as u64, - }) - } - - fn choose_fork(&self, candidate: &Ledger, fork_point: ForkPoint) -> ForkChoice { - let local_height = self.height(); - let remote_height = candidate.height(); - if remote_height == local_height && candidate.tip().hash == self.tip().hash { - return ForkChoice::KeepLocal; - } - - let finalized_floor = local_height.saturating_sub(FORK_FINALITY_DEPTH); - if fork_point.common_ancestor_height < finalized_floor { - return ForkChoice::KeepLocal; - } - - if remote_height > local_height { - return ForkChoice::SwitchToCandidate; - } - if remote_height < local_height { - return ForkChoice::KeepLocal; - } - - match self.fork_quality(candidate, fork_point) { - ForkQuality::RemoteBetter => ForkChoice::SwitchToCandidate, - ForkQuality::LocalBetter | ForkQuality::Equal => ForkChoice::KeepLocal, - } - } - - fn fork_quality(&self, candidate: &Ledger, fork_point: ForkPoint) -> ForkQuality { - let local_fork = self - .chain - .iter() - .skip(fork_point.first_diverging_height() as usize); - let remote_fork = candidate - .chain - .iter() - .skip(fork_point.first_diverging_height() as usize); - for (local, remote) in local_fork.zip(remote_fork) { - match local.leader_score().cmp(&remote.leader_score()) { - std::cmp::Ordering::Equal => continue, - ordering => return ForkQuality::from(ordering), - } - } - ForkQuality::Equal - } - - fn replace_with_better_chain(&mut self, mut candidate: Ledger, fork_point: ForkPoint) { - let mut carry_forward = self.pending.clone(); - carry_forward.extend(self.orphans.clone()); - let mut carry_forward_blinded = self.pending_blinded.clone(); - let mut carry_forward_reveals = self.pending_reveals.clone(); - for block in self - .chain - .iter() - .skip(fork_point.first_diverging_height() as usize) - { - carry_forward.extend(block.transactions.clone()); - carry_forward_blinded.extend(block.blinded_transactions.clone()); - carry_forward_reveals.extend(block.all_blinded_reveals().into_iter().cloned()); - } - - let mined_signatures = candidate - .chain - .iter() - .flat_map(|block| block.transactions.iter()) - .map(|tx| tx.signature().to_string()) - .collect::<BTreeSet<_>>(); - let mined_blinded_commitments = candidate - .chain - .iter() - .flat_map(|block| block.blinded_transactions.iter()) - .map(|transaction| transaction.commitment.clone()) - .collect::<BTreeSet<_>>(); - let mined_reveal_commitments = candidate - .chain - .iter() - .flat_map(|block| block.all_blinded_reveals()) - .map(|reveal| reveal.commitment.clone()) - .collect::<BTreeSet<_>>(); - - for transaction in carry_forward { - if !mined_signatures.contains(transaction.signature()) { - let _ = candidate.submit_transaction(transaction); - } - } - for transaction in carry_forward_blinded { - if !mined_blinded_commitments.contains(&transaction.commitment) { - let _ = candidate.submit_blinded_transaction(transaction); - } - } - for reveal in carry_forward_reveals { - if !mined_reveal_commitments.contains(&reveal.commitment) { - let _ = candidate.submit_blinded_reveal(reveal); - } - } - - *self = candidate; - } - - pub fn snapshot(&self) -> ChainSnapshot { - ChainSnapshot { - genesis_allocations: self.genesis_allocations.clone(), - vdf_rounds: self.initial_vdf_rounds, - launch_profile: self.launch_profile.clone(), - blocks: self.chain.clone(), - } - } - - pub fn status(&self) -> ChainStatus { - ChainStatus { - height: self.tip().height, - tip_hash: self.tip().hash.clone(), - next_leader: self.expected_leader_for_next_block(), - launch_profile_hash: self.launch_profile.hash(), - mine_reward: self.mine_reward, - current_mine_difficulty_bits: self.current_mine_difficulty_bits(), - balances: balances_from_utxos(&self.utxos), - pending_transactions: self.pending.len() - + self.pending_blinded.len() - + self.pending_reveals.len(), - } - } - - pub fn chain(&self) -> &[Block] { - &self.chain - } - - pub fn burn_leader_ranks_for_block(&self, height: u64) -> Result<Vec<BurnLeaderRank>> { - if height == 0 { - return Ok(Vec::new()); - } - let parent_index = height.checked_sub(1).context("block height underflows")? as usize; - let parent = self - .chain - .get(parent_index) - .with_context(|| format!("missing parent block for height {height}"))?; - let mut tickets = genesis_tickets( - &self.genesis_allocations, - &self.chain[0], - &self.launch_profile, - )?; - let mut active_blinded = BTreeMap::<String, ActiveBlindedTransaction>::new(); - for block in self - .chain - .iter() - .skip(1) - .take_while(|block| block.height < height) - { - 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.all_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 matches!(transaction, Transaction::Mine { .. }) { - bail!("mine actions are public and cannot be blinded"); - } - if blinded_envelope_fee_for_transaction(&transaction) != 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(), - locked_outputs: Vec::new(), - included_height: block.height, - included_by: block.miner.clone(), - }, - ); - } - } - Ok(ranked_tickets_for_height(parent, height, &tickets) - .into_iter() - .enumerate() - .map(|(rank, ticket)| BurnLeaderRank { - rank: rank as u32, - ticket_id: ticket.id, - owner: ticket.owner, - amount: ticket.amount, - eligible_from_height: ticket.eligible_from_height, - eligible_until_height: ticket.eligible_until_height, - }) - .collect()) - } - - pub fn reveal_committee_for_next_block(&self) -> Vec<RevealCommitteeMember> { - self.reveal_committee_for_height(self.tip().height + 1) - } - - pub fn reveal_committee_for_height(&self, height: u64) -> Vec<RevealCommitteeMember> { - let ranked = ranked_tickets_for_height(self.tip(), height, &self.tickets); - let mut selected = Vec::new(); - if !ranked.is_empty() { - selected.push(0); - } - for index in (0..ranked.len()).rev() { - if selected.len() >= reveal_committee_slot_count(ranked.len()) { - break; - } - if !selected.contains(&index) { - selected.push(index); - } - } - selected - .into_iter() - .enumerate() - .filter_map(|(slot, rank)| { - let ticket = ranked.get(rank)?.clone(); - Some(RevealCommitteeMember { - slot: u8::try_from(slot).ok()?, - rank: u32::try_from(rank).ok()?, - ticket_id: ticket.id, - owner: ticket.owner, - amount: ticket.amount, - }) - }) - .collect() - } - - pub fn genesis_hash(&self) -> &str { - &self.chain[0].hash - } - - pub fn is_setup_placeholder(&self) -> bool { - self.height() == 0 - && self.genesis_allocations.is_empty() - && self.chain[0].transactions.is_empty() - && self.pending.is_empty() - } - - pub fn height(&self) -> u64 { - self.tip().height - } - - pub fn recent_blocks(&self, limit: usize) -> Vec<Block> { - self.chain.iter().rev().take(limit).cloned().collect() - } - - pub fn blocks_before(&self, before_height: u64, limit: usize) -> Vec<Block> { - self.chain - .iter() - .rev() - .filter(|block| block.height < before_height) - .take(limit) - .cloned() - .collect() - } - - pub fn blocks_from(&self, from_height: u64, limit: usize) -> Vec<Block> { - if limit == 0 { - return Vec::new(); - } - self.chain - .iter() - .filter(|block| block.height >= from_height) - .take(limit) - .cloned() - .collect() - } - - pub fn block_by_hash(&self, hash: &str) -> Option<Block> { - self.chain.iter().find(|block| block.hash == hash).cloned() - } - - pub fn has_block(&self, hash: &str) -> bool { - self.chain.iter().any(|block| block.hash == hash) - } - - pub fn pending(&self) -> &[Transaction] { - &self.pending - } - - pub fn pending_blinded_transactions(&self) -> &[BlindedTransaction] { - &self.pending_blinded - } - - pub fn pending_blinded_reveals(&self) -> &[BlindedReveal] { - &self.pending_reveals - } - - pub fn pending_revealed_blinded_transactions(&self) -> Vec<RevealedBlindedTransaction> { - self.pending_reveals - .iter() - .filter_map(|reveal| { - let active = self.active_blinded.get(&reveal.commitment)?; - let transaction = self.pending_reveal_transaction(reveal).ok()?; - Some(RevealedBlindedTransaction { - height: self.height().saturating_add(1), - commitment: reveal.commitment.clone(), - included_by: active.included_by.clone(), - transaction, - }) - }) - .collect() - } - - pub(crate) fn drop_pending_blinded_conflicting_with_transaction( - &mut self, - transaction: &Transaction, - ) { - let spent = transaction - .inputs() - .iter() - .map(|input| input.outpoint.clone()) - .collect::<BTreeSet<_>>(); - self.pending_blinded.retain(|blinded| { - !blinded - .inputs - .iter() - .any(|input| spent.contains(&input.outpoint)) - }); - } - - pub(crate) fn clear_pending_blinded_transactions(&mut self) { - self.pending_blinded.clear(); - } - - pub(crate) fn clear_pending_transactions(&mut self) { - self.pending.clear(); - } - - pub fn build_reveal_bundle(&self, wallet: &Wallet) -> Result<Option<RevealBundle>> { - let height = self.tip().height + 1; - let prev_hash = self.tip().hash.clone(); - let Some(member) = self - .reveal_committee_for_next_block() - .into_iter() - .find(|member| member.owner == wallet.address()) - else { - return Ok(None); - }; - let mut reveals = self.valid_pending_blinded_reveals(); - reveals.sort_by(|left, right| { - self.reveal_fee_order_key(right) - .cmp(&self.reveal_fee_order_key(left)) - .then_with(|| left.commitment.cmp(&right.commitment)) - }); - - let mut selected = Vec::new(); - for reveal in reveals { - let mut candidate = selected.clone(); - candidate.push(reveal); - let bundle = wallet.reveal_bundle(RevealBundlePayload { - height, - prev_hash: prev_hash.clone(), - slot: member.slot, - member: wallet.address().to_string(), - reveals: candidate.clone(), - }); - if bundle.serialized_size_bytes()? <= MAX_REVEAL_BUNDLE_BYTES { - selected = candidate; - } - } - if selected.is_empty() { - return Ok(None); - } - Ok(Some(wallet.reveal_bundle(RevealBundlePayload { - height, - prev_hash, - slot: member.slot, - member: wallet.address().to_string(), - reveals: selected, - }))) - } - - pub fn validate_next_block_reveal_bundles( - &self, - bundles: Vec<RevealBundle>, - ) -> Result<Vec<RevealBundle>> { - let expected_height = self.tip().height + 1; - let expected_prev_hash = self.tip().hash.clone(); - self.validate_reveal_bundles_for_block(expected_height, &expected_prev_hash, bundles) - } - - fn reveal_bundle_section_from_bundles( - &self, - bundles: Vec<RevealBundle>, - ) -> RevealBundleSection { - let signatures = bundles - .iter() - .map(|bundle| RevealBundleSignature { - slot: bundle.slot, - member: bundle.member.clone(), - signature: bundle.signature.clone(), - }) - .collect::<Vec<_>>(); - let mut by_commitment: BTreeMap<String, MaskedBlindedReveal> = BTreeMap::new(); - for bundle in bundles { - let slot_mask = reveal_bundle_slot_mask(bundle.slot).unwrap_or(0); - for reveal in bundle.reveals { - by_commitment - .entry(reveal.commitment.clone()) - .and_modify(|masked| masked.bundle_mask |= slot_mask) - .or_insert(MaskedBlindedReveal { - reveal, - bundle_mask: slot_mask, - }); - } - } - let mut reveals = by_commitment.into_values().collect::<Vec<_>>(); - reveals.sort_by(|left, right| { - self.reveal_fee_order_key(&right.reveal) - .cmp(&self.reveal_fee_order_key(&left.reveal)) - .then_with(|| left.reveal.commitment.cmp(&right.reveal.commitment)) - }); - RevealBundleSection { - signatures, - reveals, - } - } - - fn validate_reveal_bundle_section_for_block( - &self, - expected_height: u64, - expected_prev_hash: &str, - section: &RevealBundleSection, - ) -> Result<()> { - if section.signatures.len() > REVEAL_COMMITTEE_SIZE { - bail!("block has too many reveal bundle signatures"); - } - if section - .signatures - .windows(2) - .any(|pair| pair[0].slot >= pair[1].slot) - { - bail!("reveal bundle signatures are not in slot order"); - } - let committee = self - .reveal_committee_for_height(expected_height) - .into_iter() - .map(|member| (member.slot, member)) - .collect::<BTreeMap<_, _>>(); - let mut seen_slots = BTreeSet::new(); - let mut seen_members = BTreeSet::new(); - let mut included_mask = 0_u8; - for signature in &section.signatures { - if usize::from(signature.slot) >= REVEAL_COMMITTEE_SIZE { - bail!("reveal bundle slot is invalid"); - } - if !seen_slots.insert(signature.slot) { - bail!("duplicate reveal bundle slot"); - } - if !seen_members.insert(signature.member.clone()) { - bail!("duplicate reveal bundle member"); - } - let member = committee - .get(&signature.slot) - .context("reveal bundle slot is not assigned")?; - if signature.member != member.owner { - bail!("reveal bundle member is not assigned to slot"); - } - included_mask |= reveal_bundle_slot_mask(signature.slot)?; - } - - let mut seen_reveals = BTreeSet::new(); - let mut previous_key: Option<((u128, Amount), String)> = None; - for masked in &section.reveals { - if masked.bundle_mask == 0 { - bail!("masked blinded reveal is not assigned to a reveal bundle"); - } - if masked.bundle_mask & !reveal_committee_mask() != 0 { - bail!("masked blinded reveal references an invalid reveal bundle slot"); - } - if masked.bundle_mask & !included_mask != 0 { - bail!("masked blinded reveal references a missing reveal bundle signature"); - } - if !seen_reveals.insert(masked.reveal.commitment.clone()) { - bail!("duplicate blinded reveal in reveal bundle section"); - } - self.pending_reveal_transaction(&masked.reveal)?; - let key = ( - self.reveal_fee_order_key(&masked.reveal), - masked.reveal.commitment.clone(), - ); - if let Some((previous_fee_key, previous_commitment)) = &previous_key { - if key.0 > *previous_fee_key - || key.0 == *previous_fee_key && key.1 < *previous_commitment - { - bail!("reveal bundle section is not fee ordered"); - } - } - previous_key = Some(key); - } - - for bundle in section.expand(expected_height, expected_prev_hash) { - if bundle.serialized_size_bytes()? > MAX_REVEAL_BUNDLE_BYTES { - bail!("reveal bundle exceeds max size"); - } - verify_address_signature( - &bundle.member, - &bundle.canonical_payload(), - &bundle.signature, - "reveal bundle", - )?; - } - Ok(()) - } - - fn validate_reveal_bundles_for_block( - &self, - expected_height: u64, - expected_prev_hash: &str, - mut bundles: Vec<RevealBundle>, - ) -> Result<Vec<RevealBundle>> { - if bundles.len() > REVEAL_COMMITTEE_SIZE { - bail!("block has too many reveal bundles"); - } - if bundles.windows(2).any(|pair| pair[0].slot >= pair[1].slot) { - bail!("reveal bundles are not in slot order"); - } - bundles.sort_by_key(|bundle| bundle.slot); - let committee = self - .reveal_committee_for_height(expected_height) - .into_iter() - .map(|member| (member.slot, member)) - .collect::<BTreeMap<_, _>>(); - let mut seen_slots = BTreeSet::new(); - let mut seen_members = BTreeSet::new(); - for bundle in &bundles { - if bundle.height != expected_height { - bail!("reveal bundle height is invalid"); - } - if bundle.prev_hash != expected_prev_hash { - bail!("reveal bundle parent hash is invalid"); - } - if usize::from(bundle.slot) >= REVEAL_COMMITTEE_SIZE { - bail!("reveal bundle slot is invalid"); - } - if !seen_slots.insert(bundle.slot) { - bail!("duplicate reveal bundle slot"); - } - if !seen_members.insert(bundle.member.clone()) { - bail!("duplicate reveal bundle member"); - } - let member = committee - .get(&bundle.slot) - .context("reveal bundle slot is not assigned")?; - if bundle.member != member.owner { - bail!("reveal bundle member is not assigned to slot"); - } - if bundle.serialized_size_bytes()? > MAX_REVEAL_BUNDLE_BYTES { - bail!("reveal bundle exceeds max size"); - } - verify_address_signature( - &bundle.member, - &bundle.canonical_payload(), - &bundle.signature, - "reveal bundle", - )?; - let mut seen_bundle_reveals = BTreeSet::new(); - let mut previous_key: Option<((u128, Amount), String)> = None; - for reveal in &bundle.reveals { - if !seen_bundle_reveals.insert(reveal.commitment.clone()) { - bail!("duplicate blinded reveal in reveal bundle"); - } - self.pending_reveal_transaction(reveal)?; - let key = (self.reveal_fee_order_key(reveal), reveal.commitment.clone()); - if let Some((previous_fee_key, previous_commitment)) = &previous_key { - if key.0 > *previous_fee_key - || key.0 == *previous_fee_key && key.1 < *previous_commitment - { - bail!("reveal bundle is not fee ordered"); - } - } - previous_key = Some(key); - } - } - Ok(bundles) - } - - pub fn orphan_transactions(&self) -> &[Transaction] { - &self.orphans - } - - pub fn transaction_by_signature(&self, signature: &str) -> Option<Transaction> { - self.pending - .iter() - .chain(self.orphans.iter()) - .chain( - self.chain - .iter() - .flat_map(|block| block.transactions.iter()), - ) - .find(|tx| tx.signature() == signature) - .cloned() - } - - pub fn has_transaction(&self, signature: &str) -> bool { - self.transaction_by_signature(signature).is_some() - } - - pub fn pending_mine_count_for_anchor(&self, anchor: &str) -> usize { - self.pending - .iter() - .filter(|tx| mine_anchor(tx) == Some(anchor)) - .count() - } - - pub fn has_blinded_transaction(&self, commitment: &str) -> bool { - self.pending_blinded - .iter() - .any(|transaction| transaction.commitment == commitment) - || self.active_blinded.contains_key(commitment) - || self.chain.iter().any(|block| { - block - .blinded_transactions - .iter() - .any(|tx| tx.commitment == commitment) - }) - } - - 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_active_blinded_transaction(&self, commitment: &str) -> bool { - self.active_blinded.contains_key(commitment) - } - - pub fn has_blinded_reveal(&self, commitment: &str) -> bool { - self.pending_reveals - .iter() - .any(|reveal| reveal.commitment == commitment) - || self.chain.iter().any(|block| { - block - .all_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == commitment) - }) - } - - pub fn vdf_rounds(&self) -> u64 { - self.vdf_rounds - } - - pub fn launch_profile(&self) -> &LaunchProfile { - &self.launch_profile - } - - pub fn current_mine_difficulty_bits(&self) -> u32 { - self.mine_difficulty_bits_for_anchor_height(self.tip().height) - } - - pub fn mine_difficulty_bits_at_height(&self, height: u64) -> u32 { - self.mine_difficulty_bits_for_anchor_height(height.min(self.tip().height)) - } - - pub fn balance_of(&self, address: &str) -> Amount { - self.utxos - .values() - .filter(|output| output.address == address) - .map(|output| output.amount) - .sum() - } - - pub fn utxos_for_address(&self, address: &str) -> Vec<(OutPoint, TxOutput)> { - self.utxos - .iter() - .filter(|(_, output)| output.address == address) - .map(|(outpoint, output)| (outpoint.clone(), output.clone())) - .collect() - } - - pub fn available_utxos_for_address(&self, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> { - Ok(self - .utxos_after_spendable_pending()? - .into_iter() - .filter(|(_, output)| output.address == address) - .collect()) - } - - pub fn next_nonce(&self, address: &str) -> u64 { - self.utxos - .keys() - .chain( - self.pending - .iter() - .flat_map(|tx| tx.inputs().iter().map(|input| &input.outpoint)), - ) - .filter(|outpoint| outpoint.txid.contains(address)) - .count() as u64 - + 1 - } - - pub fn build_transfer( - &self, - wallet: &Wallet, - to: impl Into<String>, - amount: Amount, - fee: Amount, - ) -> Result<Transaction> { - let to = to.into(); - validate_address(&to, "transfer recipient")?; - let required = amount - .checked_add(fee) - .context("transfer amount plus fee overflows")?; - let (inputs, input_total) = self.select_inputs(wallet.address(), required)?; - let mut outputs = vec![TxOutput { - address: to, - amount, - }]; - let change = input_total - .checked_sub(required) - .context("selected inputs do not cover transfer")?; - if change > 0 { - outputs.push(TxOutput { - address: wallet.address().to_string(), - amount: change, - }); - } - let transaction = UnsignedUtxoTransaction::Transfer { - inputs, - outputs, - fee, - } - .sign(wallet); - self.validate_new_transaction(&transaction)?; - Ok(transaction) - } - - pub fn build_transfer_with_inputs( - &self, - wallet: &Wallet, - to: impl Into<String>, - amount: Amount, - fee: Amount, - outpoints: &[OutPoint], - ) -> Result<Transaction> { - let to = to.into(); - validate_address(&to, "transfer recipient")?; - let required = amount - .checked_add(fee) - .context("transfer amount plus fee overflows")?; - let (inputs, input_total) = - self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?; - let mut outputs = vec![TxOutput { - address: to, - amount, - }]; - let change = input_total - .checked_sub(required) - .context("selected inputs do not cover transfer")?; - if change > 0 { - outputs.push(TxOutput { - address: wallet.address().to_string(), - amount: change, - }); - } - let transaction = UnsignedUtxoTransaction::Transfer { - inputs, - outputs, - fee, - } - .sign(wallet); - self.validate_new_transaction(&transaction)?; - Ok(transaction) - } - - pub fn build_burn(&self, wallet: &Wallet, amount: Amount, fee: Amount) -> Result<Transaction> { - let required = amount - .checked_add(fee) - .context("burn amount plus fee overflows")?; - let (inputs, input_total) = self.select_inputs(wallet.address(), required)?; - self.build_burn_from_inputs(wallet, amount, fee, inputs, input_total) - } - - pub fn build_burn_with_inputs( - &self, - wallet: &Wallet, - amount: Amount, - fee: Amount, - outpoints: &[OutPoint], - ) -> Result<Transaction> { - let required = amount - .checked_add(fee) - .context("burn amount plus fee overflows")?; - let (inputs, input_total) = - self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?; - self.build_burn_from_inputs(wallet, amount, fee, inputs, input_total) - } - - fn build_burn_from_inputs( - &self, - wallet: &Wallet, - amount: Amount, - fee: Amount, - inputs: Vec<UnsignedTxInput>, - input_total: Amount, - ) -> Result<Transaction> { - let required = amount - .checked_add(fee) - .context("burn amount plus fee overflows")?; - let change_amount = input_total - .checked_sub(required) - .context("selected inputs do not cover burn")?; - let change = if change_amount > 0 { - vec![TxOutput { - address: wallet.address().to_string(), - amount: change_amount, - }] - } else { - Vec::new() - }; - let transaction = UnsignedUtxoTransaction::Burn { - inputs, - change, - amount, - fee, - } - .sign(wallet); - self.validate_new_transaction(&transaction)?; - Ok(transaction) - } - - pub fn build_blinded_burn( - &self, - wallet: &Wallet, - amount: Amount, - fee: Amount, - expires_at_height: u64, - ) -> Result<BuiltBlindedTransaction> { - let transaction = self.build_burn(wallet, amount, fee)?; - self.blind_transaction(wallet, transaction, fee, expires_at_height) - } - - pub fn build_blinded_transfer( - &self, - wallet: &Wallet, - to: impl Into<String>, - amount: Amount, - fee: Amount, - expires_at_height: u64, - ) -> Result<BuiltBlindedTransaction> { - let transaction = self.build_transfer(wallet, to, amount, fee)?; - self.blind_transaction(wallet, transaction, fee, expires_at_height) - } - - pub fn build_blinded_transaction( - &self, - wallet: &Wallet, - transaction: Transaction, - expires_at_height: u64, - ) -> Result<BuiltBlindedTransaction> { - if matches!(transaction, Transaction::Mine { .. }) { - bail!("mine actions are public and cannot be blinded"); - } - let fee = blinded_envelope_fee_for_transaction(&transaction); - self.blind_transaction(wallet, transaction, fee, expires_at_height) - } - - fn blind_transaction( - &self, - wallet: &Wallet, - transaction: Transaction, - fee: Amount, - expires_at_height: u64, - ) -> Result<BuiltBlindedTransaction> { - 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 != blinded_envelope_fee_for_transaction(&transaction) { - bail!("blinded transaction fee must match plaintext transaction fee"); - } - let unsigned_inputs = if transaction.inputs().is_empty() && transaction.fee() > 0 { - self.select_inputs(wallet.address(), transaction.fee())?.0 - } else { - unsigned_inputs(transaction.inputs()) - }; - if unsigned_inputs - .iter() - .any(|input| input.owner != wallet.address()) - { - bail!("blinded transaction inputs must be owned by the signing wallet"); - } - let blinded_payload = blinded_payload_from_transaction(&transaction)?; - let plaintext = serde_json::to_vec(&blinded_payload) - .context("failed to serialize transaction for blinded payload")?; - let payload_hash = hex_hash(&plaintext); - let unsigned_commit_inputs = signed_blinded_inputs(&unsigned_inputs, ""); - let payload = transaction; - let mut key = [0_u8; BLINDED_KEY_BYTES]; - let mut nonce = [0_u8; BLINDED_NONCE_BYTES]; - getrandom(&mut key) - .map_err(|error| anyhow!("failed to generate blinded transaction key: {error}"))?; - getrandom(&mut nonce) - .map_err(|error| anyhow!("failed to generate blinded transaction nonce: {error}"))?; - let ciphertext = encrypt_blinded_payload( - &key, - &nonce, - &unsigned_commit_inputs, - fee, - expires_at_height, - &plaintext, - )?; - let encrypted_size = u32::try_from(ciphertext.len()) - .context("blinded transaction ciphertext is too large")?; - let transaction = BlindedTransaction { - commitment: String::new(), - inputs: unsigned_commit_inputs, - fee, - encrypted_size, - expires_at_height, - nonce: hex_encode(nonce), - ciphertext: hex_encode(&ciphertext), - payload_hash, - }; - let signature = wallet.sign_payload(&blinded_transaction_signing_payload(&transaction)); - let transaction = BlindedTransaction { - inputs: signed_blinded_inputs(&unsigned_inputs, &signature), - ..transaction - }; - let commitment = blinded_transaction_commitment(&transaction)?; - let transaction = BlindedTransaction { - commitment: commitment.clone(), - ..transaction - }; - self.validate_blinded_transaction(&transaction)?; - Ok(BuiltBlindedTransaction { - payload, - transaction, - reveal: BlindedReveal { - commitment, - key: hex_encode(key), - }, - }) - } - - pub fn build_mine(&self, recipient: impl Into<String>) -> Result<Transaction> { - let recipient = recipient.into(); - validate_address(&recipient, "mine recipient")?; - let anchor = self.tip().hash.clone(); - let salt = 1; - let difficulty_bits = self.current_mine_difficulty_bits(); - for nonce in 0..u64::MAX { - let signature = mine_signature(&recipient, &anchor, salt, nonce, difficulty_bits); - if !hash_meets_difficulty(&signature, difficulty_bits) { - continue; - } - let transaction = Transaction::Mine { - recipient: recipient.clone(), - anchor: anchor.clone(), - salt, - nonce, - difficulty_bits, - proof_header: None, - signature, - }; - if self.has_transaction(transaction.signature()) { - continue; - } - self.validate_new_transaction(&transaction)?; - return Ok(transaction); - } - bail!("could not find valid mine proof"); - } - - pub fn search_mine( - &self, - recipient: impl Into<String>, - salt: u64, - start_nonce: u64, - max_attempts: u64, - ) -> Result<MineSearchOutcome> { - let recipient = recipient.into(); - validate_address(&recipient, "mine recipient")?; - let anchor = self.tip().hash.clone(); - let difficulty_bits = self.current_mine_difficulty_bits(); - let mut attempts = 0_u64; - let mut nonce = start_nonce; - while attempts < max_attempts { - let signature = mine_signature(&recipient, &anchor, salt, nonce, difficulty_bits); - attempts = attempts.saturating_add(1); - let next_nonce = nonce.checked_add(1).unwrap_or(0); - if hash_meets_difficulty(&signature, difficulty_bits) { - let transaction = Transaction::Mine { - recipient: recipient.clone(), - anchor: anchor.clone(), - salt, - nonce, - difficulty_bits, - proof_header: None, - signature, - }; - if !self.has_transaction(transaction.signature()) { - self.validate_new_transaction(&transaction)?; - return Ok(MineSearchOutcome { - transaction: Some(transaction), - next_nonce, - attempts, - }); - } - } - nonce = next_nonce; - } - Ok(MineSearchOutcome { - transaction: None, - next_nonce: nonce, - attempts, - }) - } - - pub fn stratum_mine_template( - &self, - recipient: impl Into<String>, - anchor: impl AsRef<str>, - salt: u64, - difficulty_bits: u32, - ) -> Result<StratumMineTemplate> { - stratum_mine_template(recipient, anchor.as_ref(), salt, difficulty_bits) - } - - pub fn build_stratum_mine( - &self, - template: StratumMineTemplate, - share: StratumMineShare, - ) -> Result<Transaction> { - let nonce = pack_stratum_nonce(share.extranonce2, share.header_nonce); - let header = stratum_mine_header_bytes( - &template.recipient, - &template.anchor, - template.salt, - nonce, - template.difficulty_bits, - )?; - let transaction = Transaction::Mine { - recipient: template.recipient, - anchor: template.anchor, - salt: template.salt, - nonce, - difficulty_bits: template.difficulty_bits, - proof_header: Some(hex_encode(header)), - signature: stratum_mine_signature(&header), - }; - self.validate_new_transaction(&transaction)?; - Ok(transaction) - } - - pub fn submit_transaction(&mut self, transaction: Transaction) -> Result<bool> { - Ok(self.submit_transaction_with_outcome(transaction)?.added()) - } - - pub(crate) fn reserve_transaction_inputs(&mut self, transaction: &Transaction) -> Result<()> { - self.validate_new_transaction(transaction)?; - let mut utxos = self.utxos.clone(); - spend_inputs(transaction, &mut utxos)?; - self.utxos = utxos; - Ok(()) - } - - pub fn submit_blinded_transaction(&mut self, transaction: BlindedTransaction) -> Result<bool> { - if self.has_blinded_transaction(&transaction.commitment) { - return Ok(false); - } - self.validate_blinded_transaction(&transaction)?; - if blinded_transaction_inputs_spent_by(&transaction, &self.pending_blinded) - || transaction_inputs_spent_by_inputs(&transaction.inputs, &self.pending) - || transaction_inputs_spent_by_inputs(&transaction.inputs, &self.orphans) - { - bail!("blinded transaction conflicts with pending inputs"); - } - let mut utxos = self.utxos_after_valid_pending_and_blinded()?; - spend_blinded_inputs(&transaction, &mut utxos)?; - if self.pending_blinded.len() >= MAX_PENDING_TRANSACTIONS { - bail!("blinded mempool is full"); - } - self.pending_blinded.push(transaction); - Ok(true) - } - - pub fn submit_blinded_reveal(&mut self, reveal: BlindedReveal) -> Result<bool> { - if self.has_blinded_reveal(&reveal.commitment) { - return Ok(false); - } - self.validate_blinded_reveal_terms(&reveal)?; - if self.pending_reveals.len() >= MAX_PENDING_TRANSACTIONS { - bail!("blinded reveal pool is full"); - } - self.pending_reveals.push(reveal); - Ok(true) - } - - pub fn submit_transaction_with_outcome( - &mut self, - transaction: Transaction, - ) -> Result<TransactionSubmitOutcome> { - if self.has_transaction(transaction.signature()) { - return Ok(TransactionSubmitOutcome::AlreadyKnown); - } - - transaction.verify_signature()?; - self.validate_transaction_terms(&transaction)?; - self.validate_mine_anchor_available(&transaction)?; - - if transaction_inputs_spent_by(&transaction, &self.pending) { - return Ok(TransactionSubmitOutcome::ConflictsWithPending); - } - if transaction_inputs_spent_by(&transaction, &self.orphans) { - return Ok(TransactionSubmitOutcome::ConflictsWithPending); - } - - if self.pending.len() >= MAX_PENDING_TRANSACTIONS { - bail!("mempool is full"); - } - - let mut utxos = self.utxos_after_valid_pending_and_blinded()?; - if transaction_has_missing_inputs(&transaction, &utxos) { - if self.orphans.len() >= MAX_ORPHAN_TRANSACTIONS { - bail!("orphan transaction pool is full"); - } - self.orphans.push(transaction); - return Ok(TransactionSubmitOutcome::Added); - } - apply_transaction(&transaction, &mut utxos)?; - self.pending.push(transaction); - self.promote_orphan_transactions()?; - Ok(TransactionSubmitOutcome::Added) - } - - pub fn mine_next_block(&self, wallet: &Wallet, timestamp_ms: u64) -> Result<Block> { - let prepared = self.prepare_next_block(wallet.address(), timestamp_ms)?; - let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); - Ok(prepared.finish(wallet, vdf_output)) - } - - pub fn mine_recovery_block(&self, wallet: &Wallet, timestamp_ms: u64) -> Result<Block> { - let prepared = self.prepare_recovery_block(wallet.address(), timestamp_ms)?; - let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); - Ok(prepared.finish(wallet, vdf_output)) - } - - pub fn prepare_next_block(&self, miner: &str, timestamp_ms: u64) -> Result<PreparedBlock> { - self.prepare_next_block_with_reveal_bundles(miner, timestamp_ms, Vec::new()) - } - - pub fn prepare_next_block_with_reveal_bundles( - &self, - miner: &str, - timestamp_ms: u64, - reveal_bundles: Vec<RevealBundle>, - ) -> Result<PreparedBlock> { - self.prepare_next_block_with_required_burn_and_reveal_bundles( - miner, - timestamp_ms, - reveal_bundles, - None, - ) - } - - pub(crate) fn prepare_next_block_with_required_burn_and_reveal_bundles( - &self, - miner: &str, - timestamp_ms: u64, - reveal_bundles: Vec<RevealBundle>, - required_burn_signature: Option<&str>, - ) -> Result<PreparedBlock> { - let height = self.tip().height + 1; - let Some((finalizer_rank, leader_ticket)) = self.finalizer_ticket_for_miner(height, miner) - else { - bail!("cannot mine block without a mature burn ticket"); - }; - if self.expected_leader_for_next_block().is_none() { - bail!("no selected leader for block {height}"); - } - - let reveal_bundles = self.validate_next_block_reveal_bundles(reveal_bundles)?; - let reveal_bundle_section = self.reveal_bundle_section_from_bundles(reveal_bundles); - let selection = self.select_block_transactions(required_burn_signature)?; - ensure_block_has_burn(&selection.transactions)?; - - let tip = self.tip(); - let prev_hash = tip.hash.clone(); - let timestamp_ms = timestamp_ms.max(ticket_block_min_timestamp(tip, finalizer_rank)?); - let bundle_hashes = reveal_bundle_section.reveal_bundle_hashes(height, &prev_hash); - let vdf_seed = vdf_seed_for_child(&prev_hash, height, &bundle_hashes); - Ok(PreparedBlock { - height, - prev_hash, - timestamp_ms, - miner: miner.to_string(), - finalizer_mode: FinalizerMode::Ticket, - reward: self - .expected_reward_for_next_block(&selection.transactions, &reveal_bundle_section)?, - vdf_rounds: self.vdf_rounds_for_finalizer_rank(finalizer_rank)?, - vdf_seed, - finalizer_rank, - leader_ticket: Some(leader_ticket), - blinded_transactions: selection.blinded_transactions, - reveal_bundle_section, - transactions: selection.transactions, - }) - } - - pub fn recovery_block_available_at(&self, timestamp_ms: u64) -> bool { - timestamp_ms >= self.recovery_block_min_timestamp() - } - - pub fn recovery_block_min_timestamp(&self) -> u64 { - self.tip() - .timestamp_ms - .saturating_add(RECOVERY_BLOCK_DELAY_MS) - } - - pub fn prepare_recovery_block(&self, miner: &str, timestamp_ms: u64) -> Result<PreparedBlock> { - self.prepare_recovery_block_with_reveal_bundles(miner, timestamp_ms, Vec::new()) - } - - pub fn prepare_recovery_block_with_reveal_bundles( - &self, - miner: &str, - timestamp_ms: u64, - reveal_bundles: Vec<RevealBundle>, - ) -> Result<PreparedBlock> { - self.prepare_recovery_block_with_required_burn_and_reveal_bundles( - miner, - timestamp_ms, - reveal_bundles, - None, - ) - } - - pub(crate) fn prepare_recovery_block_with_required_burn_and_reveal_bundles( - &self, - miner: &str, - timestamp_ms: u64, - reveal_bundles: Vec<RevealBundle>, - required_burn_signature: Option<&str>, - ) -> Result<PreparedBlock> { - let height = self.tip().height + 1; - let min_timestamp = self.recovery_block_min_timestamp(); - if timestamp_ms < min_timestamp { - bail!("recovery block is not available before timestamp {min_timestamp}"); - } - - let reveal_bundles = self.validate_next_block_reveal_bundles(reveal_bundles)?; - let reveal_bundle_section = self.reveal_bundle_section_from_bundles(reveal_bundles); - let selection = self.select_recovery_block_transactions(miner, required_burn_signature)?; - ensure_block_has_burn(&selection.transactions)?; - ensure_block_has_burn_from(&selection.transactions, miner)?; - - let tip = self.tip(); - let prev_hash = tip.hash.clone(); - let timestamp_ms = timestamp_ms.max(tip.timestamp_ms + 1); - let bundle_hashes = reveal_bundle_section.reveal_bundle_hashes(height, &prev_hash); - let vdf_seed = - recovery_vdf_seed_for_child(&prev_hash, height, timestamp_ms, &bundle_hashes); - Ok(PreparedBlock { - height, - prev_hash, - timestamp_ms, - miner: miner.to_string(), - finalizer_mode: FinalizerMode::Recovery, - finalizer_rank: 0, - reward: self - .expected_reward_for_next_block(&selection.transactions, &reveal_bundle_section)?, - vdf_rounds: self.recovery_vdf_rounds()?, - vdf_seed, - leader_ticket: None, - blinded_transactions: selection.blinded_transactions, - reveal_bundle_section, - transactions: selection.transactions, - }) - } - - pub fn apply_block(&mut self, block: Block) -> Result<()> { - self.apply_block_at(block, unix_now_ms()) - } - - pub(crate) fn apply_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> { - self.apply_block_with_vdf_policy(block, true, now_ms) - } - - pub(crate) fn block_requires_vdf_verification_at( - &self, - block: &Block, - now_ms: u64, - ) -> Result<bool> { - self.precheck_block_without_vdf_at(block, now_ms) - } - - pub fn apply_locally_mined_block(&mut self, block: Block) -> Result<()> { - self.apply_self_produced_block_at(block, unix_now_ms()) - } - - pub(crate) fn apply_self_produced_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> { - self.verify_self_produced_block_at(&block, now_ms)?; - self.apply_preverified_block_at(block, now_ms) - } - - pub(crate) fn verify_self_produced_block_at(&self, block: &Block, now_ms: u64) -> Result<()> { - let mut verifier = self.clone(); - verifier.apply_preverified_block_at(block.clone(), now_ms)?; - Ok(()) - } - - pub(crate) fn apply_preverified_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> { - self.apply_block_with_vdf_policy(block, false, now_ms) - } - - fn apply_block_with_vdf_policy( - &mut self, - block: Block, - should_verify_vdf: bool, - now_ms: u64, - ) -> Result<()> { - if !self.precheck_block_without_vdf_at(&block, now_ms)? { - return Ok(()); - } - - if should_verify_vdf && !verify_vdf(&block.vdf_seed(), block.vdf_rounds, &block.vdf_output) - { - bail!("block VDF output is invalid"); - } - - let reveal_bundle_slot_count = self.reveal_committee_for_height(block.height).len(); - let mut utxos = self.utxos.clone(); - let mut signatures = BTreeSet::new(); - let mut revealed_transactions = Vec::new(); - let mut aggregated_reveal_finalizer_fees = 0_u64; - for tx in &block.transactions { - if !signatures.insert(tx.signature()) { - bail!("duplicate transaction in block"); - } - self.validate_transaction_terms(tx)?; - apply_transaction(tx, &mut utxos)?; - } - let mut revealed_commitments = BTreeSet::new(); - for reveal in block.all_blinded_reveals() { - if !revealed_commitments.insert(reveal.commitment.clone()) { - bail!("duplicate blinded reveal in block"); - } - let active = self - .active_blinded - .get(&reveal.commitment) - .context("blinded reveal does not reference an active blinded transaction")? - .clone(); - let tx = self.decrypt_active_blinded(&active, reveal)?; - self.apply_revealed_blinded_transaction(&active, &tx, &mut utxos)?; - credit_blinded_fee_outputs( - &mut utxos, - &active, - &block.miner, - &tx, - &block.reveal_bundle_section.signatures, - reveal_bundle_slot_count, - aggregate_finalizer_fees_active(block.height), - )?; - if aggregate_finalizer_fees_active(block.height) { - aggregated_reveal_finalizer_fees = aggregated_reveal_finalizer_fees - .checked_add(blinded_reveal_finalizer_fee( - tx.fee(), - block.included_reveal_bundle_count(), - reveal_bundle_slot_count, - )) - .context("aggregated reveal finalizer fees overflow")?; - } - revealed_transactions.push(tx); - } - for (commitment, active) in &self.active_blinded { - if !revealed_commitments.contains(commitment) - && block.height >= active.transaction.expires_at_height - { - credit_expired_blinded_outputs(&mut utxos, active)?; - } - } - let expected_reward = block_reward(&block.transactions, aggregated_reveal_finalizer_fees)?; - if block.reward != expected_reward { - bail!("block reward is invalid"); - } - let mined_signatures = block - .transactions - .iter() - .map(|tx| tx.signature().to_string()) - .collect::<BTreeSet<_>>(); - let included_blinded = block - .blinded_transactions - .iter() - .map(|transaction| transaction.commitment.clone()) - .collect::<BTreeSet<_>>(); - let revealed_blinded = block - .all_blinded_reveals() - .into_iter() - .map(|reveal| reveal.commitment.clone()) - .collect::<BTreeSet<_>>(); - let mut new_active_blinded = Vec::new(); - for transaction in &block.blinded_transactions { - let locked_outputs = spend_blinded_inputs(transaction, &mut utxos)?; - new_active_blinded.push(( - transaction.commitment.clone(), - ActiveBlindedTransaction { - transaction: transaction.clone(), - locked_outputs, - included_height: block.height, - included_by: block.miner.clone(), - }, - )); - } - let mut tickets = self.tickets.clone(); - apply_finalizer_ticket_effects(&block, &mut tickets)?; - tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?); - tickets.extend(tickets_created_by_transactions( - block.height, - &revealed_transactions, - &self.launch_profile, - )?); - credit_reward_output(&mut utxos, &block)?; - self.utxos = utxos; - self.tickets = tickets; - self.chain.push(block); - let new_height = self.height(); - self.active_blinded.retain(|commitment, active| { - !revealed_blinded.contains(commitment) - && new_height < active.transaction.expires_at_height - }); - for (commitment, active) in new_active_blinded { - self.active_blinded.insert(commitment, active); - } - let available = self.utxos.clone(); - let pending = std::mem::take(&mut self.pending); - self.pending = pending - .into_iter() - .filter(|tx| { - !mined_signatures.contains(tx.signature()) - && transaction_inputs_available(tx, &available) - && self.validate_transaction_terms(tx).is_ok() - }) - .collect(); - let orphans = std::mem::take(&mut self.orphans); - self.orphans = orphans - .into_iter() - .filter(|tx| { - !mined_signatures.contains(tx.signature()) - && self.validate_transaction_terms(tx).is_ok() - }) - .collect(); - let pending_blinded = std::mem::take(&mut self.pending_blinded); - self.pending_blinded = pending_blinded - .into_iter() - .filter(|transaction| { - !included_blinded.contains(&transaction.commitment) - && new_height < transaction.expires_at_height - && blinded_transaction_inputs_available(transaction, &available) - && self.validate_blinded_transaction(transaction).is_ok() - }) - .collect(); - let pending_reveals = std::mem::take(&mut self.pending_reveals); - self.pending_reveals = pending_reveals - .into_iter() - .filter(|reveal| { - !revealed_blinded.contains(&reveal.commitment) - && self.pending_reveal_transaction(reveal).is_ok() - }) - .collect(); - self.promote_orphan_transactions()?; - self.vdf_rounds = self.next_vdf_rounds_after_tip(); - Ok(()) - } - - fn precheck_block_without_vdf_at(&self, block: &Block, now_ms: u64) -> Result<bool> { - if block.height <= self.tip().height { - let existing = self - .chain - .get(block.height as usize) - .with_context(|| format!("local chain has no block at height {}", block.height))?; - if existing.hash == block.hash { - return Ok(false); - } - bail!( - "block at height {} conflicts with local chain", - block.height - ); - } - - let expected_height = self.tip().height + 1; - if block.height != expected_height { - bail!( - "expected block height {expected_height}, got {}", - block.height - ); - } - if block.prev_hash != self.tip().hash { - bail!("block does not extend local tip"); - } - if block.compute_hash() != block.hash { - bail!("block hash is invalid"); - } - let reveal_bundle_slot_count = self.reveal_committee_for_height(block.height).len(); - if block.reward != self.expected_reward_for_block(block, reveal_bundle_slot_count)? { - bail!("block reward is invalid"); - } - let expected_vdf_rounds = self.expected_vdf_rounds_for_block(block)?; - if block.vdf_rounds != expected_vdf_rounds { - bail!("block VDF rounds are invalid"); - } - if block.timestamp_ms <= self.tip().timestamp_ms { - bail!("block timestamp must increase"); - } - if block.finalizer_mode == FinalizerMode::Ticket { - let min_timestamp = ticket_block_min_timestamp(self.tip(), block.finalizer_rank)?; - if block.timestamp_ms < min_timestamp { - bail!( - "block timestamp is before finalizer rank {} time slot {min_timestamp}", - block.finalizer_rank - ); - } - } - let median_time_past = self.median_time_past(); - if block.timestamp_ms <= median_time_past { - bail!("block timestamp must exceed median time past"); - } - let max_future_timestamp = now_ms.saturating_add(MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS); - if block.timestamp_ms > max_future_timestamp { - bail!("block timestamp is too far in the future"); - } - if block.transactions.len() > self.launch_profile.max_block_transactions { - bail!("block has too many transactions"); - } - let block_item_count = block.transactions.len() - + block.blinded_transactions.len() - + block.all_blinded_reveals().len(); - if block_item_count > self.launch_profile.max_block_transactions { - bail!("block has too many transaction items"); - } - if block.serialized_size_bytes()? > self.launch_profile.max_block_bytes { - bail!("block exceeds max block size"); - } - ensure_mine_anchor_limit(block.height, &block.transactions)?; - ensure_block_has_burn(&block.transactions)?; - self.validate_reveal_bundle_section_for_block( - block.height, - &block.prev_hash, - &block.reveal_bundle_section, - )?; - validate_block_blinded_items(block, self)?; - match block.finalizer_mode { - FinalizerMode::Ticket => { - let selected_ticket = self - .ticket_for_finalizer_rank(block.height, block.finalizer_rank) - .context("no selected ticket for block finalizer rank")?; - if selected_ticket.owner != block.miner { - bail!( - "block finalizer {} is not selected for rank {}", - block.miner, - block.finalizer_rank - ); - } - if block - .leader_proof - .as_ref() - .is_none_or(|proof| proof.ticket_id != selected_ticket.id) - { - bail!("block does not prove the selected leader ticket"); - } - verify_leader_proof(block, &self.tickets)?; - } - FinalizerMode::Recovery => { - ensure_valid_recovery_block(block, self.tip())?; - } - } - - Ok(true) - } - - fn median_time_past(&self) -> u64 { - let mut timestamps = self - .chain - .iter() - .rev() - .take(BLOCK_MEDIAN_TIME_PAST_WINDOW) - .map(|block| block.timestamp_ms) - .collect::<Vec<_>>(); - timestamps.sort_unstable(); - timestamps[timestamps.len() / 2] - } - - fn expected_reward_for_next_block( - &self, - transactions: &[Transaction], - reveal_bundle_section: &RevealBundleSection, - ) -> Result<Amount> { - let height = self.tip().height + 1; - if !aggregate_finalizer_fees_active(height) { - return fee_reward(transactions); - } - let reveal_bundle_slot_count = self.reveal_committee_for_height(height).len(); - let aggregate = self.aggregate_reveal_finalizer_fees( - height, - reveal_bundle_section, - reveal_bundle_slot_count, - )?; - block_reward(transactions, aggregate) - } - - fn expected_reward_for_block( - &self, - block: &Block, - reveal_bundle_slot_count: usize, - ) -> Result<Amount> { - if !aggregate_finalizer_fees_active(block.height) { - return fee_reward(&block.transactions); - } - let aggregate = self.aggregate_reveal_finalizer_fees( - block.height, - &block.reveal_bundle_section, - reveal_bundle_slot_count, - )?; - block_reward(&block.transactions, aggregate) - } - - fn aggregate_reveal_finalizer_fees( - &self, - height: u64, - reveal_bundle_section: &RevealBundleSection, - reveal_bundle_slot_count: usize, - ) -> Result<Amount> { - if !aggregate_finalizer_fees_active(height) { - return Ok(0); - } - reveal_bundle_section - .all_reveals() - .into_iter() - .try_fold(0_u64, |total, reveal| { - let active = self - .active_blinded - .get(&reveal.commitment) - .context("blinded reveal does not reference an active blinded transaction")?; - total - .checked_add(blinded_reveal_finalizer_fee( - active.transaction.fee, - reveal_bundle_section.included_bundle_count(), - reveal_bundle_slot_count, - )) - .context("aggregated reveal finalizer fees overflow") - }) - } - - fn next_vdf_rounds_after_tip(&self) -> u64 { - let Some(tip) = self.chain.last() else { - return self.vdf_rounds; - }; - if tip.height < 2 { - return self.vdf_rounds; - } - - let mut total_observed_ms = 0_u128; - let mut observed_blocks = 0_u128; - for pair in self - .chain - .windows(2) - .rev() - .filter(|pair| pair[0].height > 0) - .take(VDF_RETARGET_WINDOW_BLOCKS) - { - let Some(observed_ms) = vdf_retarget_observed_block_ms(&pair[0], &pair[1]) else { - continue; - }; - total_observed_ms += u128::from(observed_ms); - observed_blocks += 1; - } - if observed_blocks == 0 { - return self.vdf_rounds; - } - - let average_observed_ms = (total_observed_ms / observed_blocks) as u64; - let base_rounds = base_vdf_rounds_for_finalizer_rank(tip.vdf_rounds, tip.finalizer_rank); - retarget_vdf_rounds(base_rounds, average_observed_ms) - } - - pub fn expected_leader_for_next_block(&self) -> Option<String> { - self.selected_ticket_for_height(self.tip().height + 1) - .map(|ticket| ticket.owner) - } - - pub fn finalizer_rank_for_next_block(&self, miner: &str) -> Option<u32> { - self.finalizer_ticket_for_miner(self.tip().height + 1, miner) - .map(|(rank, _)| rank) - } - - pub fn finalizer_rank_count_for_next_block(&self) -> usize { - ranked_tickets_for_height(self.tip(), self.tip().height + 1, &self.tickets).len() - } - - fn valid_pending_transactions(&self) -> Vec<Transaction> { - let mut utxos = self.utxos.clone(); - let mut valid = Vec::new(); - let mut remaining = self.pending.iter().collect::<Vec<_>>(); - let mut selected_mine_anchor_counts = BTreeMap::new(); - - while !remaining.is_empty() { - let mut progressed = false; - let mut still_pending = Vec::new(); - - for tx in remaining { - if let Some(anchor) = mine_anchor(tx) { - let selected = selected_mine_anchor_counts - .get(anchor) - .copied() - .unwrap_or_default(); - if mine_anchor_count_before_height(&self.chain, anchor, self.height()) - .saturating_add(selected) - >= MINE_ACTIONS_PER_ANCHOR_LIMIT - { - continue; - } - } - if self.validate_transaction_terms(tx).is_ok() - && apply_transaction(tx, &mut utxos).is_ok() - { - if let Some(anchor) = mine_anchor(tx) { - *selected_mine_anchor_counts - .entry(anchor.to_string()) - .or_insert(0) += 1; - } - valid.push(tx.clone()); - progressed = true; - } else { - still_pending.push(tx); - } - } - - if !progressed { - break; - } - - remaining = still_pending; - } - - valid - } - - fn select_block_transactions( - &self, - required_burn_signature: Option<&str>, - ) -> Result<BlockSelection> { - self.select_block_transactions_with_required_burn_owner(None, required_burn_signature) - } - - fn select_recovery_block_transactions( - &self, - miner: &str, - required_burn_signature: Option<&str>, - ) -> Result<BlockSelection> { - self.select_block_transactions_with_required_burn_owner( - Some(miner), - required_burn_signature, - ) - } - - fn select_block_transactions_with_required_burn_owner( - &self, - required_burn_owner: Option<&str>, - required_burn_signature: Option<&str>, - ) -> Result<BlockSelection> { - let mut utxos = self.utxos.clone(); - let mut remaining = self.valid_pending_transactions(); - let mut remaining_blinded = self.valid_pending_blinded_transactions(); - let mut selected = Vec::new(); - let mut selected_blinded = Vec::new(); - - if let Some(signature) = required_burn_signature { - let index = remaining - .iter() - .position(|transaction| transaction.signature() == signature) - .with_context(|| format!("required burn {signature} is not pending"))?; - let tx = remaining.remove(index); - if !tx.is_burn() { - bail!("required block anchor must be a burn transaction"); - } - if let Some(owner) = required_burn_owner { - if tx.sender() != owner { - bail!("required block anchor burn must be from the recovery finalizer"); - } - } - let candidate = BlockSelection { - transactions: vec![tx.clone()], - blinded_transactions: selected_blinded.clone(), - }; - if estimated_block_selection_size_bytes(&candidate, required_burn_owner.is_some())? - > self.launch_profile.max_block_bytes - { - bail!("required block anchor burn does not fit in the block"); - } - apply_transaction(&tx, &mut utxos) - .context("required block anchor burn is not spendable")?; - selected.push(tx); - } - - let needs_first_burn = !selected.iter().any(Transaction::is_burn); - let needs_owner_burn = required_burn_owner.is_some_and(|owner| { - !selected - .iter() - .any(|transaction| transaction.is_burn() && transaction.sender() == owner) - }); - if needs_first_burn || needs_owner_burn { - let first_burn_index = if let Some(owner) = required_burn_owner { - best_selectable_burn_from_index(&remaining, &utxos, owner) - } else { - best_selectable_transaction_index(&remaining, &utxos, Some(TransactionKind::Burn)) - }; - if let Some(index) = first_burn_index { - let tx = remaining.remove(index); - let mut candidate = BlockSelection { - transactions: selected.clone(), - blinded_transactions: selected_blinded.clone(), - }; - candidate.transactions.push(tx.clone()); - if estimated_block_selection_size_bytes(&candidate, required_burn_owner.is_some())? - <= self.launch_profile.max_block_bytes - { - apply_transaction(&tx, &mut utxos)?; - selected.push(tx); - } - } - } - - while selected.len() < self.launch_profile.max_block_transactions { - let selected_count = selected.len() + selected_blinded.len(); - if selected_count >= self.launch_profile.max_block_transactions { - break; - } - - let best_plain = best_selectable_transaction_index(&remaining, &utxos, None) - .map(|index| SelectableItem::Plain(index, fee_rate_key(&remaining[index]))); - let best_blinded = - best_selectable_blinded_index(&remaining_blinded, &utxos).map(|index| { - SelectableItem::Blinded(index, blinded_fee_rate_key(&remaining_blinded[index])) - }); - let Some(item) = best_selectable_item(best_plain, best_blinded) else { - break; - }; - - match item { - SelectableItem::Plain(index, _) => { - let tx = remaining.remove(index); - let mut candidate = BlockSelection { - transactions: selected.clone(), - blinded_transactions: selected_blinded.clone(), - }; - candidate.transactions.push(tx.clone()); - if estimated_block_selection_size_bytes( - &candidate, - required_burn_owner.is_some(), - )? <= self.launch_profile.max_block_bytes - { - apply_transaction(&tx, &mut utxos)?; - selected.push(tx); - } - } - SelectableItem::Blinded(index, _) => { - let transaction = remaining_blinded.remove(index); - let mut candidate = BlockSelection { - transactions: selected.clone(), - blinded_transactions: selected_blinded.clone(), - }; - candidate.blinded_transactions.push(transaction.clone()); - if estimated_block_selection_size_bytes( - &candidate, - required_burn_owner.is_some(), - )? <= self.launch_profile.max_block_bytes - { - spend_blinded_inputs(&transaction, &mut utxos)?; - selected_blinded.push(transaction); - } - } - } - } - Ok(BlockSelection { - transactions: selected, - blinded_transactions: selected_blinded, - }) - } - - fn select_inputs( - &self, - address: &str, - amount: Amount, - ) -> Result<(Vec<UnsignedTxInput>, Amount)> { - let utxos = self.utxos_after_spendable_pending()?; - let mut selected = Vec::new(); - let mut total = 0_u64; - for (outpoint, output) in &utxos { - if output.address != address { - continue; - } - selected.push(UnsignedTxInput { - outpoint: outpoint.clone(), - owner: address.to_string(), - }); - total = total - .checked_add(output.amount) - .context("selected input total overflows")?; - if total >= amount { - return Ok((selected, total)); - } - } - bail!("insufficient funds for {address}") - } - - fn select_inputs_by_outpoint( - &self, - address: &str, - amount: Amount, - outpoints: &[OutPoint], - ) -> Result<(Vec<UnsignedTxInput>, Amount)> { - if outpoints.is_empty() { - bail!("at least one UTXO must be selected"); - } - let utxos = self.utxos_after_spendable_pending()?; - let mut seen = BTreeSet::new(); - let mut selected = Vec::new(); - let mut total = 0_u64; - for outpoint in outpoints { - if !seen.insert(outpoint.clone()) { - bail!("selected UTXO {} is duplicated", outpoint.id()); - } - let output = utxos - .get(outpoint) - .with_context(|| format!("selected UTXO {} is not spendable", outpoint.id()))?; - if output.address != address { - bail!("selected UTXO {} is not owned by {address}", outpoint.id()); - } - selected.push(UnsignedTxInput { - outpoint: outpoint.clone(), - owner: address.to_string(), - }); - total = total - .checked_add(output.amount) - .context("selected input total overflows")?; - } - if total < amount { - bail!("selected UTXOs do not cover amount plus fee"); - } - Ok((selected, total)) - } - - fn validate_new_transaction(&self, transaction: &Transaction) -> Result<()> { - self.validate_transaction_terms(transaction)?; - self.validate_mine_anchor_available(transaction)?; - let mut utxos = self.utxos_after_spendable_pending()?; - apply_transaction(transaction, &mut utxos) - } - - fn validate_mine_anchor_available(&self, transaction: &Transaction) -> Result<()> { - if mine_actions_per_anchor_limit_active(self.height().saturating_add(1)) { - if let Some(anchor) = mine_anchor(transaction) { - let known_count = - mine_anchor_count_before_height(&self.chain, anchor, self.height()) - .saturating_add( - self.pending - .iter() - .filter(|tx| mine_anchor(tx) == Some(anchor)) - .count(), - ) - .saturating_add( - self.orphans - .iter() - .filter(|tx| { - mine_anchor(tx) == Some(anchor) - && tx.signature() != transaction.signature() - }) - .count(), - ); - if known_count >= MINE_ACTIONS_PER_ANCHOR_LIMIT { - bail!("mine transaction anchor limit reached"); - } - } - } - Ok(()) - } - - fn promote_orphan_transactions(&mut self) -> Result<()> { - loop { - if self.pending.len() >= MAX_PENDING_TRANSACTIONS { - return Ok(()); - } - let mut promoted_index = None; - let mut utxos = self.utxos_after_valid_pending_and_blinded()?; - for (index, transaction) in self.orphans.iter().enumerate() { - if transaction_inputs_spent_by(transaction, &self.pending) { - continue; - } - if transaction_has_missing_inputs(transaction, &utxos) { - continue; - } - if self.validate_new_transaction(transaction).is_ok() - && apply_transaction(transaction, &mut utxos).is_ok() - { - promoted_index = Some(index); - break; - } - } - - let Some(index) = promoted_index else { - return Ok(()); - }; - self.pending.push(self.orphans.remove(index)); - } - } - - fn validate_transaction_terms(&self, transaction: &Transaction) -> Result<()> { - match transaction { - Transaction::Transfer { - inputs, - outputs, - signature, - .. - } => { - validate_transaction_inputs(inputs)?; - validate_transaction_outputs(outputs)?; - validate_signature(signature, "transaction signature")?; - } - Transaction::Burn { - inputs, - change, - signature, - .. - } => { - validate_transaction_inputs(inputs)?; - validate_transaction_outputs(change)?; - validate_signature(signature, "transaction signature")?; - } - Transaction::Mine { - recipient, - anchor, - difficulty_bits, - proof_header, - signature, - .. - } => { - validate_address(recipient, "mine recipient")?; - validate_hash(anchor, "mine transaction anchor")?; - validate_hash(signature, "mine transaction proof hash")?; - if let Some(proof_header) = proof_header { - validate_stratum_header(proof_header)?; - } - let anchor_block = self - .chain - .iter() - .find(|block| block.hash == *anchor) - .context("mine transaction anchor is not on this chain")?; - let anchor_age = self.tip().height.saturating_sub(anchor_block.height); - if anchor_age > MINE_MAX_ANCHOR_AGE_BLOCKS { - bail!("mine transaction anchor is too old"); - } - let required_difficulty = - self.mine_difficulty_bits_for_anchor_height(anchor_block.height); - if *difficulty_bits != required_difficulty { - bail!("mine transaction difficulty is invalid"); - } - } - } - Ok(()) - } - - fn validate_blinded_transaction(&self, transaction: &BlindedTransaction) -> Result<()> { - validate_hash(&transaction.commitment, "blinded transaction commitment")?; - validate_hash( - &transaction.payload_hash, - "blinded transaction payload hash", - )?; - decode_hex_array::<BLINDED_NONCE_BYTES>(&transaction.nonce) - .context("invalid blinded transaction nonce")?; - let ciphertext = decode_hex(&transaction.ciphertext) - .context("invalid blinded transaction ciphertext")?; - if ciphertext.is_empty() { - bail!("blinded transaction ciphertext is empty"); - } - if ciphertext.len() != transaction.encrypted_size as usize { - bail!("blinded transaction encrypted size is invalid"); - } - 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"); - } - validate_transaction_inputs(&transaction.inputs)?; - if transaction.inputs.is_empty() && transaction.fee > 0 { - bail!("blinded transaction with a fee must lock visible inputs"); - } - if !transaction.inputs.is_empty() { - verify_blinded_input_signatures(transaction)?; - } - let expected = blinded_transaction_commitment(transaction)?; - if transaction.commitment != expected { - bail!("blinded transaction commitment is invalid"); - } - Ok(()) - } - - fn validate_blinded_reveal_terms(&self, reveal: &BlindedReveal) -> Result<()> { - validate_hash(&reveal.commitment, "blinded reveal commitment")?; - decode_hex_array::<BLINDED_KEY_BYTES>(&reveal.key).context("invalid blinded reveal key")?; - Ok(()) - } - - fn valid_pending_blinded_transactions(&self) -> Vec<BlindedTransaction> { - let next_height = self.height().saturating_add(1); - self.pending_blinded - .iter() - .filter(|transaction| { - transaction.expires_at_height > next_height - && self.validate_blinded_transaction(transaction).is_ok() - }) - .cloned() - .collect() - } - - fn valid_pending_blinded_reveals(&self) -> Vec<BlindedReveal> { - self.pending_reveals - .iter() - .filter(|reveal| self.pending_reveal_transaction(reveal).is_ok()) - .cloned() - .collect() - } - - fn reveal_fee_order_key(&self, reveal: &BlindedReveal) -> (u128, Amount) { - let Some(active) = self.active_blinded.get(&reveal.commitment) else { - return (0, 0); - }; - let size = active.transaction.fee_rate_size_bytes(); - let rate = if size == 0 { - 0 - } else { - u128::from(active.transaction.fee) * 1_000_000 / size as u128 - }; - (rate, active.transaction.fee) - } - - fn pending_reveal_transaction(&self, reveal: &BlindedReveal) -> Result<Transaction> { - self.validate_blinded_reveal_terms(reveal)?; - let active = self - .active_blinded - .get(&reveal.commitment) - .context("blinded reveal does not reference an active blinded transaction")?; - self.decrypt_active_blinded(active, reveal) - } - - fn decrypt_active_blinded( - &self, - active: &ActiveBlindedTransaction, - reveal: &BlindedReveal, - ) -> Result<Transaction> { - if self.height() >= active.transaction.expires_at_height { - bail!("blinded transaction reveal is expired"); - } - let transaction = decrypt_blinded_transaction(&active.transaction, reveal)?; - if matches!(transaction, Transaction::Mine { .. }) { - bail!("mine actions are public and cannot be blinded"); - } - if blinded_envelope_fee_for_transaction(&transaction) != active.transaction.fee { - bail!("blinded transaction reveal fee does not match envelope"); - } - if !blinded_reveal_inputs_match(active, &transaction) { - bail!("blinded transaction reveal inputs do not match envelope"); - } - self.validate_transaction_terms(&transaction)?; - Ok(transaction) - } - - fn apply_revealed_blinded_transaction( - &self, - active: &ActiveBlindedTransaction, - transaction: &Transaction, - utxos: &mut BTreeMap<OutPoint, TxOutput>, - ) -> Result<()> { - if matches!(transaction, Transaction::Mine { .. }) { - bail!("mine actions are public and cannot be blinded"); - } - transaction.verify_signature()?; - ensure_single_input_owner(transaction)?; - let input_total = blinded_locked_output_total(active)?; - let outputs = transaction.outputs(); - let output_total = outputs.iter().try_fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .context("transaction outputs overflow") - })?; - let required = output_total - .checked_add(transaction.fee()) - .context("transaction outputs plus fee overflow")? - .checked_add(match transaction { - Transaction::Burn { amount, .. } => *amount, - Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, - }) - .context("transaction outputs plus burn overflow")?; - if input_total != required { - bail!("blinded transaction inputs do not balance outputs, burn, and fee"); - } - ensure_outputs_do_not_overflow(utxos, &outputs)?; - for (index, output) in outputs.iter().enumerate() { - utxos.insert( - OutPoint { - txid: transaction.signature().to_string(), - index: index as u32, - }, - output.clone(), - ); - } - Ok(()) - } - - fn utxos_after_valid_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { - let mut utxos = self.utxos.clone(); - for pending in self.valid_pending_transactions() { - apply_transaction(&pending, &mut utxos)?; - } - Ok(utxos) - } - - fn utxos_after_valid_pending_and_blinded(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { - let mut utxos = self.utxos_after_valid_pending()?; - for pending in self.valid_pending_blinded_transactions() { - spend_blinded_inputs(&pending, &mut utxos)?; - } - Ok(utxos) - } - - fn utxos_after_spendable_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { - let mut utxos = self.utxos.clone(); - for pending in self.valid_pending_transactions() { - if matches!(pending, Transaction::Mine { .. }) { - continue; - } - if apply_spendable_pending_transaction(&pending, &mut utxos).is_err() { - continue; - } - } - for pending in self.valid_pending_blinded_transactions() { - if spend_spendable_blinded_inputs(&pending, &mut utxos).is_err() { - continue; - } - } - Ok(utxos) - } - - fn selected_ticket_for_height(&self, height: u64) -> Option<BurnTicket> { - self.ticket_for_finalizer_rank(height, 0) - } - - fn ticket_for_finalizer_rank(&self, height: u64, rank: u32) -> Option<BurnTicket> { - ranked_tickets_for_height(self.tip(), height, &self.tickets) - .get(rank as usize) - .cloned() - } - - fn finalizer_ticket_for_miner(&self, height: u64, miner: &str) -> Option<(u32, BurnTicket)> { - ranked_tickets_for_height(self.tip(), height, &self.tickets) - .into_iter() - .enumerate() - .find(|(_, ticket)| ticket.owner == miner) - .and_then(|(rank, ticket)| { - let rank = u32::try_from(rank).ok()?; - Some((rank, ticket)) - }) - } - - fn vdf_rounds_for_finalizer_rank(&self, rank: u32) -> Result<u64> { - vdf_rounds_for_finalizer_rank(self.vdf_rounds, rank) - } - - fn recovery_vdf_rounds(&self) -> Result<u64> { - vdf_rounds_for_finalizer_rank(self.vdf_rounds, 0) - } - - fn expected_vdf_rounds_for_block(&self, block: &Block) -> Result<u64> { - match block.finalizer_mode { - FinalizerMode::Ticket => self.vdf_rounds_for_finalizer_rank(block.finalizer_rank), - FinalizerMode::Recovery => self.recovery_vdf_rounds(), - } - } - - fn mine_difficulty_bits_for_anchor_height(&self, anchor_height: u64) -> u32 { - let mut difficulty = self.launch_profile.mine_difficulty_bits; - let mut window_end = MINE_RETARGET_WINDOW_BLOCKS; - while window_end <= anchor_height { - let window_start = window_end + 1 - MINE_RETARGET_WINDOW_BLOCKS; - let mine_actions = self - .chain - .iter() - .filter(|block| window_start <= block.height && block.height <= window_end) - .map(mine_action_count) - .sum::<u64>(); - difficulty = retarget_mine_difficulty_bits(difficulty, mine_actions); - window_end = window_end.saturating_add(MINE_RETARGET_WINDOW_BLOCKS); - } - difficulty - } - - fn tip(&self) -> &Block { - self.chain - .last() - .expect("ledger is always initialized with genesis") - } -} - -fn ranked_tickets_for_height( - parent: &Block, - target_height: u64, - tickets: &[BurnTicket], -) -> Vec<BurnTicket> { - let mut remaining = tickets - .iter() - .filter(|ticket| ticket_is_eligible_for_height(ticket, target_height)) - .cloned() - .collect::<Vec<_>>(); - let mut ranked = Vec::with_capacity(remaining.len()); - - for rank in 0.. { - let Some(selected_index) = - select_weighted_ticket_index(parent, target_height, rank, &remaining) - else { - break; - }; - ranked.push(remaining.remove(selected_index)); - } - - ranked -} - -fn select_weighted_ticket_index( - parent: &Block, - target_height: u64, - rank: u32, - tickets: &[BurnTicket], -) -> Option<usize> { - let total_weight = tickets.iter().try_fold(0_u128, |total, ticket| { - total.checked_add(u128::from(ticket.amount)) - })?; - if total_weight == 0 { - return None; - } - - let draw = weighted_ticket_draw(parent, target_height, rank, total_weight); - let mut cumulative = 0_u128; - for (index, ticket) in tickets.iter().enumerate() { - cumulative = cumulative.checked_add(u128::from(ticket.amount))?; - if draw < cumulative { - return Some(index); - } - } - None -} - -fn weighted_ticket_draw(parent: &Block, target_height: u64, rank: u32, total_weight: u128) -> u128 { - let seed = if rank == 0 { - format!( - "iuna-ticket-draw:{}:{}:{}", - target_height, parent.hash, parent.vdf_output - ) - } else { - format!( - "iuna-ticket-draw-rank:{}:{}:{}:{}", - target_height, rank, parent.hash, parent.vdf_output - ) - }; - let digest = Sha256::digest(seed.as_bytes()); - let mut bytes = [0_u8; 16]; - bytes.copy_from_slice(&digest[..16]); - u128::from_be_bytes(bytes) % total_weight -} - -fn vdf_rounds_for_finalizer_rank(base_rounds: u64, rank: u32) -> Result<u64> { - let rounds = base_rounds - .checked_mul(u64::from( - rank.checked_add(1).context("finalizer rank overflows")?, - )) - .context("finalizer rank VDF rounds overflow")?; - if rounds > MAX_VDF_ROUNDS { - bail!("finalizer rank VDF rounds exceed maximum"); - } - Ok(rounds) -} - -fn finalizer_rank_slot_delay_ms(rank: u32) -> Result<u64> { - VDF_TARGET_BLOCK_MS - .checked_mul(2) - .context("finalizer rank time slot overflow")? - .checked_mul(u64::from(rank)) - .context("finalizer rank time slot overflow") -} - -fn ticket_block_min_timestamp(parent: &Block, rank: u32) -> Result<u64> { - if rank == 0 { - return parent - .timestamp_ms - .checked_add(1) - .context("finalizer rank minimum timestamp overflow"); - } - - parent - .timestamp_ms - .checked_add(finalizer_rank_slot_delay_ms(rank)?) - .context("finalizer rank minimum timestamp overflow") -} - -fn base_vdf_rounds_for_finalizer_rank(vdf_rounds: u64, rank: u32) -> u64 { - vdf_rounds / u64::from(rank.saturating_add(1).max(1)) -} - -fn tickets_created_by_block(block: &Block, profile: &LaunchProfile) -> Result<Vec<BurnTicket>> { - tickets_created_by_transactions(block.height, &block.transactions, profile) -} - -fn tickets_created_by_transactions( - block_height: u64, - transactions: &[Transaction], - profile: &LaunchProfile, -) -> Result<Vec<BurnTicket>> { - if profile.ticket_expiry_window_heights == 0 { - bail!("ticket expiry window must be at least one height"); - } - let mut tickets = Vec::new(); - for tx in transactions { - let Transaction::Burn { - inputs, - amount, - signature, - .. - } = tx - else { - continue; - }; - let Some(owner) = inputs.first().map(|input| input.owner.clone()) else { - continue; - }; - if *amount == 0 { - continue; - } - let target_height = block_height - .checked_add(profile.ticket_maturity_delay_heights) - .with_context(|| format!("ticket target height overflow at block {block_height}"))?; - let eligible_until_height = target_height - .checked_add(profile.ticket_expiry_window_heights - 1) - .with_context(|| format!("ticket expiry height overflow at block {block_height}"))?; - tickets.push(BurnTicket { - id: signature.clone(), - owner, - amount: *amount, - eligible_from_height: target_height, - eligible_until_height, - }); - } - Ok(tickets) -} - -fn genesis_tickets( - genesis_allocations: &BTreeMap<String, Amount>, - genesis: &Block, - profile: &LaunchProfile, -) -> Result<Vec<BurnTicket>> { - if profile.ticket_maturity_delay_heights == 0 { - return tickets_created_by_block(genesis, profile); - } - - let burn_tickets = genesis - .transactions - .iter() - .filter_map(|tx| { - let Transaction::Burn { - inputs, - amount, - signature, - .. - } = tx - else { - return None; - }; - let owner = inputs.first()?.owner.clone(); - (*amount > 0).then(|| (owner, *amount, signature.clone())) - }) - .collect::<Vec<_>>(); - - if !burn_tickets.is_empty() { - return genesis_bootstrap_tickets(burn_tickets, profile, genesis); - } - - let Some((owner, amount)) = genesis_allocations - .iter() - .rev() - .find(|(_, amount)| **amount > 0) - else { - return Ok(Vec::new()); - }; - genesis_bootstrap_tickets( - vec![( - owner.clone(), - 1, - hex_hash(format!( - "iuna-genesis-ticket:{owner}:{amount}:{}", - genesis.hash - )), - )], - profile, - genesis, - ) -} - -fn genesis_bootstrap_tickets( - source_tickets: Vec<(String, Amount, String)>, - profile: &LaunchProfile, - genesis: &Block, -) -> Result<Vec<BurnTicket>> { - let mut tickets = Vec::new(); - for height in 1..=profile.ticket_maturity_delay_heights { - for (owner, amount, source_id) in &source_tickets { - tickets.push(BurnTicket { - id: hex_hash(format!( - "iuna-genesis-bootstrap-ticket:{}:{source_id}:{height}", - genesis.hash - )), - owner: owner.clone(), - amount: *amount, - eligible_from_height: height, - eligible_until_height: height, - }); - } - } - Ok(tickets) -} - -fn apply_finalizer_ticket_effects(block: &Block, tickets: &mut Vec<BurnTicket>) -> Result<()> { - match block.finalizer_mode { - FinalizerMode::Ticket => consume_leader_ticket(block, tickets), - FinalizerMode::Recovery => { - tickets.retain(|ticket| { - !ticket_is_eligible_for_height(ticket, block.height) - && ticket.eligible_until_height > block.height - }); - Ok(()) - } - } -} - -fn consume_leader_ticket(block: &Block, tickets: &mut Vec<BurnTicket>) -> Result<()> { - let Some(proof) = &block.leader_proof else { - bail!("block is missing leader proof"); - }; - let Some(index) = tickets.iter().position(|ticket| { - ticket.id == proof.ticket_id && ticket_is_eligible_for_height(ticket, block.height) - }) else { - bail!("leader ticket is not pending for block {}", block.height); - }; - tickets.remove(index); - tickets.retain(|ticket| ticket.eligible_until_height > block.height); - Ok(()) -} - -fn ticket_is_eligible_for_height(ticket: &BurnTicket, height: u64) -> bool { - ticket.eligible_from_height <= height && height <= ticket.eligible_until_height -} - -fn mine_action_count(block: &Block) -> u64 { - block - .transactions - .iter() - .filter(|transaction| matches!(transaction, Transaction::Mine { .. })) - .count() as u64 -} - -fn retarget_mine_difficulty_bits(current: u32, mine_actions: u64) -> u32 { - let target = MINE_RETARGET_WINDOW_BLOCKS.saturating_mul(MINE_TARGET_ACTIONS_PER_BLOCK); - if target == 0 || mine_actions == target { - return current.clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS); - } - - let step = if mine_actions > target { - floor_log2_ratio(mine_actions, target).min(MINE_MAX_RETARGET_STEP_BITS) - } else if mine_actions == 0 { - MINE_MAX_RETARGET_STEP_BITS - } else { - floor_log2_ratio(target, mine_actions).min(MINE_MAX_RETARGET_STEP_BITS) - }; - - if step == 0 { - return current.clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS); - } - if mine_actions > target { - current - .saturating_add(step) - .clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS) - } else { - current - .saturating_sub(step) - .clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS) - } -} - -fn floor_log2_ratio(numerator: u64, denominator: u64) -> u32 { - if denominator == 0 || numerator <= denominator { - return 0; - } - let mut step = 0_u32; - let mut threshold = denominator; - while threshold <= numerator / 2 { - threshold = threshold.saturating_mul(2); - step = step.saturating_add(1); - } - step -} - -fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> { - if !transactions.iter().any(Transaction::is_burn) { - bail!("block must include at least one burn transaction"); - } - Ok(()) -} - -fn ensure_mine_anchor_limit(height: u64, transactions: &[Transaction]) -> Result<()> { - if !mine_actions_per_anchor_limit_active(height) { - return Ok(()); - } - let mut anchor_counts = BTreeMap::new(); - for transaction in transactions { - let Some(anchor) = mine_anchor(transaction) else { - continue; - }; - let count = anchor_counts.entry(anchor).or_insert(0usize); - *count += 1; - if *count > MINE_ACTIONS_PER_ANCHOR_LIMIT { - bail!("block exceeds mine actions per anchor limit"); - } - } - Ok(()) -} - -fn mine_actions_per_anchor_limit_active(height: u64) -> bool { - height >= MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT -} - -fn mine_anchor(transaction: &Transaction) -> Option<&str> { - match transaction { - Transaction::Mine { anchor, .. } => Some(anchor.as_str()), - _ => None, - } -} - -fn mine_anchor_count_before_height(chain: &[Block], anchor: &str, height: u64) -> usize { - chain - .iter() - .take_while(|block| block.height <= height) - .map(|block| { - block - .transactions - .iter() - .filter(|transaction| mine_anchor(transaction) == Some(anchor)) - .count() - }) - .sum() -} - -fn ensure_block_has_burn_from(transactions: &[Transaction], miner: &str) -> Result<()> { - if !transactions - .iter() - .any(|transaction| transaction.is_burn() && transaction.sender() == miner) - { - bail!("recovery block must include a burn from the finalizer"); - } - Ok(()) -} - -fn ensure_valid_recovery_block(block: &Block, parent: &Block) -> Result<()> { - if block.finalizer_rank != 0 { - bail!("recovery block finalizer rank must be 0"); - } - if block.leader_proof.is_some() { - bail!("recovery block must not carry a leader proof"); - } - let min_timestamp = parent.timestamp_ms.saturating_add(RECOVERY_BLOCK_DELAY_MS); - if block.timestamp_ms < min_timestamp { - bail!("recovery block is not available before timestamp {min_timestamp}"); - } - ensure_block_has_burn_from(&block.transactions, &block.miner) -} - -fn fee_rate_key(transaction: &Transaction) -> u128 { - let size = transaction.economic_size_bytes(); - if size == 0 { - return 0; - } - u128::from(transaction.fee()) * 1_000_000 / size as u128 -} - -fn blinded_fee_rate_key(transaction: &BlindedTransaction) -> u128 { - let size = transaction.fee_rate_size_bytes(); - if size == 0 { - return 0; - } - u128::from(transaction.fee) * 1_000_000 / size as u128 -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SelectableItem { - Plain(usize, u128), - Blinded(usize, u128), -} - -fn best_selectable_item( - plain: Option<SelectableItem>, - blinded: Option<SelectableItem>, -) -> Option<SelectableItem> { - match (plain, blinded) { - ( - Some(SelectableItem::Plain(_, plain_rate)), - Some(SelectableItem::Blinded(_, blind_rate)), - ) => { - if blind_rate > plain_rate { - blinded - } else { - plain - } - } - (Some(item), None) | (None, Some(item)) => Some(item), - (None, None) => None, - _ => None, - } -} - -fn best_selectable_blinded_index( - transactions: &[BlindedTransaction], - utxos: &BTreeMap<OutPoint, TxOutput>, -) -> Option<usize> { - transactions - .iter() - .enumerate() - .filter(|(_, transaction)| { - let mut utxos = utxos.clone(); - spend_blinded_inputs(transaction, &mut utxos).is_ok() - }) - .max_by(|(_, left), (_, right)| { - blinded_fee_rate_key(left) - .cmp(&blinded_fee_rate_key(right)) - .then_with(|| left.fee.cmp(&right.fee)) - .then_with(|| right.commitment.cmp(&left.commitment)) - }) - .map(|(index, _)| index) -} - -fn best_selectable_transaction_index( - transactions: &[Transaction], - utxos: &BTreeMap<OutPoint, TxOutput>, - required_kind: Option<TransactionKind>, -) -> Option<usize> { - transactions - .iter() - .enumerate() - .filter(|(_, tx)| match required_kind { - Some(TransactionKind::Burn) => tx.is_burn(), - None => true, - }) - .filter(|(_, tx)| { - let mut utxos = utxos.clone(); - apply_transaction(tx, &mut utxos).is_ok() - }) - .max_by(|(_, left), (_, right)| { - fee_rate_key(left) - .cmp(&fee_rate_key(right)) - .then_with(|| left.fee().cmp(&right.fee())) - .then_with(|| left.is_burn().cmp(&right.is_burn())) - .then_with(|| right.signature().cmp(left.signature())) - }) - .map(|(index, _)| index) -} - -fn best_selectable_burn_from_index( - transactions: &[Transaction], - utxos: &BTreeMap<OutPoint, TxOutput>, - owner: &str, -) -> Option<usize> { - transactions - .iter() - .enumerate() - .filter(|(_, tx)| tx.is_burn() && tx.sender() == owner) - .filter(|(_, tx)| { - let mut utxos = utxos.clone(); - apply_transaction(tx, &mut utxos).is_ok() - }) - .max_by(|(_, left), (_, right)| { - fee_rate_key(left) - .cmp(&fee_rate_key(right)) - .then_with(|| left.fee().cmp(&right.fee())) - .then_with(|| right.signature().cmp(left.signature())) - }) - .map(|(index, _)| index) -} - -fn validate_genesis_allocations(genesis_allocations: &BTreeMap<String, Amount>) -> Result<()> { - for address in genesis_allocations.keys() { - validate_address(address, "genesis allocation")?; - } - Ok(()) -} - -fn validate_transaction_inputs(inputs: &[TxInput]) -> Result<()> { - for input in inputs { - validate_protocol_id(&input.outpoint.txid, "input outpoint txid")?; - validate_address(&input.owner, "input owner")?; - validate_signature(&input.signature, "input signature")?; - } - Ok(()) -} - -fn validate_transaction_outputs(outputs: &[TxOutput]) -> Result<()> { - for output in outputs { - validate_address(&output.address, "output recipient")?; - } - Ok(()) -} - -fn validate_genesis_burn_transaction(transaction: &Transaction) -> Result<()> { - let Transaction::Burn { - inputs, - change, - fee, - signature, - .. - } = transaction - else { - bail!("genesis only supports burn transactions"); - }; - if *fee != 0 { - bail!("genesis burn fee must be zero"); - } - validate_hash(signature, "genesis burn signature")?; - validate_transaction_outputs(change)?; - for input in inputs { - validate_hash(&input.outpoint.txid, "genesis burn input outpoint txid")?; - validate_address(&input.owner, "genesis burn input owner")?; - if input.signature != "genesis" { - bail!("genesis burn input signature is invalid"); - } - } - Ok(()) -} - -pub fn validate_address(address: &str, label: &str) -> Result<()> { - decode_hex_array::<PUBLIC_KEY_BYTES>(address) - .with_context(|| format!("invalid {label} address"))?; - Ok(()) -} - -fn validate_hash(hash: &str, label: &str) -> Result<()> { - decode_hex_array::<HASH_BYTES>(hash).with_context(|| format!("invalid {label}"))?; - Ok(()) -} - -fn validate_signature(signature: &str, label: &str) -> Result<()> { - decode_hex_array::<SIGNATURE_BYTES>(signature).with_context(|| format!("invalid {label}"))?; - Ok(()) -} - -fn validate_stratum_header(header: &str) -> Result<()> { - decode_hex_array::<STRATUM_MINE_HEADER_BYTES>(header) - .context("invalid mine transaction proof header")?; - Ok(()) -} - -fn validate_protocol_id(value: &str, label: &str) -> Result<()> { - let bytes = decode_hex(value).with_context(|| format!("invalid {label}"))?; - match bytes.len() { - HASH_BYTES | SIGNATURE_BYTES => Ok(()), - length => bail!("invalid {label}: expected 32 or 64 bytes, got {length}"), - } -} - -fn canonical_transaction_size_bytes(transaction: &Transaction) -> usize { - match transaction { - Transaction::Transfer { - inputs, - outputs, - fee, - signature, - } => { - 1 + compact_len(inputs.len() as u128) - + compact_inputs_size_bytes(inputs) - + compact_len(outputs.len() as u128) - + compact_outputs_size_bytes(outputs) - + compact_len(u128::from(*fee)) - + signature_size_bytes(signature) - } - Transaction::Burn { - inputs, - change, - amount, - fee, - signature, - } => { - 1 + compact_len(inputs.len() as u128) - + compact_inputs_size_bytes(inputs) - + compact_len(change.len() as u128) - + compact_outputs_size_bytes(change) - + compact_len(u128::from(*amount)) - + compact_len(u128::from(*fee)) - + signature_size_bytes(signature) - } - Transaction::Mine { - recipient, - anchor, - salt, - nonce, - difficulty_bits, - proof_header, - signature, - } => { - 1 + address_size_bytes(recipient) - + hash_size_bytes(anchor) - + compact_len(u128::from(*salt)) - + compact_len(u128::from(*nonce)) - + compact_len(u128::from(*difficulty_bits)) - + 1 - + proof_header - .as_ref() - .map(|header| stratum_header_size_bytes(header)) - .unwrap_or(0) - + hash_size_bytes(signature) - } - } -} - -fn compact_inputs_size_bytes(inputs: &[TxInput]) -> usize { - inputs - .iter() - .map(|input| { - protocol_id_size_bytes(&input.outpoint.txid) - + compact_len(u128::from(input.outpoint.index)) - + address_size_bytes(&input.owner) - }) - .sum() -} - -fn compact_outputs_size_bytes(outputs: &[TxOutput]) -> usize { - outputs.iter().map(compact_output_size_bytes).sum() -} - -fn compact_output_size_bytes(output: &TxOutput) -> usize { - address_size_bytes(&output.address) + compact_len(u128::from(output.amount)) -} - -fn address_size_bytes(address: &str) -> usize { - debug_assert!(validate_address(address, "debug address").is_ok()); - PUBLIC_KEY_BYTES -} - -fn hash_size_bytes(hash: &str) -> usize { - debug_assert!(validate_hash(hash, "debug hash").is_ok()); - HASH_BYTES -} - -fn signature_size_bytes(signature: &str) -> usize { - debug_assert!(validate_signature(signature, "debug signature").is_ok()); - SIGNATURE_BYTES -} - -fn stratum_header_size_bytes(header: &str) -> usize { - debug_assert!(validate_stratum_header(header).is_ok()); - STRATUM_MINE_HEADER_BYTES -} - -fn protocol_id_size_bytes(value: &str) -> usize { - match decode_hex(value).map(|bytes| bytes.len()) { - Ok(HASH_BYTES) => HASH_BYTES, - Ok(SIGNATURE_BYTES) => SIGNATURE_BYTES, - _ => SIGNATURE_BYTES, - } -} - -fn compact_len(mut value: u128) -> usize { - let mut bytes = 1; - while value >= 0x80 { - value >>= 7; - bytes += 1; - } - bytes -} - -fn estimated_block_selection_size_bytes( - selection: &BlockSelection, - recovery: bool, -) -> Result<usize> { - let block = Block { - height: u64::MAX, - prev_hash: "f".repeat(64), - timestamp_ms: u64::MAX, - miner: "f".repeat(64), - finalizer_mode: if recovery { - FinalizerMode::Recovery - } else { - FinalizerMode::Ticket - }, - finalizer_rank: 0, - reward: u64::MAX, - vdf_rounds: u64::MAX, - vdf_output: "f".repeat(64), - leader_proof: (!recovery).then(|| LeaderProof { - ticket_id: "f".repeat(64), - public_key: "f".repeat(64), - signature: "f".repeat(128), - }), - blinded_transactions: selection.blinded_transactions.clone(), - reveal_bundle_section: RevealBundleSection::default(), - transactions: selection.transactions.clone(), - hash: "f".repeat(64), - }; - block.serialized_size_bytes() -} - -fn verify_leader_proof(block: &Block, tickets: &[BurnTicket]) -> Result<()> { - let Some(proof) = &block.leader_proof else { - bail!("block is missing leader proof"); - }; - if proof.public_key != block.miner { - bail!("leader proof public key does not match block finalizer"); - } - let ticket = tickets - .iter() - .find(|ticket| { - ticket.id == proof.ticket_id && ticket_is_eligible_for_height(ticket, block.height) - }) - .context("leader ticket is not pending for this height")?; - if ticket.owner != block.miner { - bail!("leader ticket owner does not match block finalizer"); - } - if ticket.eligible_from_height > block.height { - bail!("leader ticket is not mature"); - } - - let payload = LeaderProofPayload { - height: block.height, - prev_hash: block.prev_hash.clone(), - finalizer_rank: block.finalizer_rank, - vdf_output: block.vdf_output.clone(), - ticket_id: ticket.id.clone(), - ticket_amount: ticket.amount, - ticket_owner: ticket.owner.clone(), - }; - verify_leader_signature(proof, &payload)?; - Ok(()) -} - -fn verify_leader_signature(proof: &LeaderProof, payload: &LeaderProofPayload) -> Result<()> { - verify_address_signature( - &proof.public_key, - &payload.canonical(), - &proof.signature, - "leader", - ) -} - -fn verify_address_signature( - address: &str, - payload: &str, - signature: &str, - label: &str, -) -> Result<()> { - let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(address) - .with_context(|| format!("invalid {label} public key {address}"))?; - let signature = decode_hex_array::<SIGNATURE_BYTES>(signature) - .with_context(|| format!("invalid {label} signature hex"))?; - let verifying_key = VerifyingKey::from_bytes(&public_key) - .with_context(|| format!("invalid {label} public key"))?; - let signature = Signature::from_bytes(&signature); - verifying_key - .verify(payload.as_bytes(), &signature) - .with_context(|| format!("{label} signature is invalid")) -} - -pub fn default_reveal_bundle_hash(slot: usize) -> String { - hex_hash(format!("iuna-default-reveal-bundle-v1:{slot}")) -} - -fn reveal_bundle_slot_mask(slot: u8) -> Result<u8> { - if usize::from(slot) >= REVEAL_COMMITTEE_SIZE || slot >= 8 { - bail!("reveal bundle slot is invalid"); - } - Ok(1_u8 << slot) -} - -fn reveal_committee_mask() -> u8 { - (0..REVEAL_COMMITTEE_SIZE).fold(0_u8, |mask, slot| mask | (1_u8 << slot)) -} - -fn reveal_bundle_hashes(bundles: &[RevealBundle]) -> [String; REVEAL_COMMITTEE_SIZE] { - std::array::from_fn(|slot| { - bundles - .iter() - .find(|bundle| usize::from(bundle.slot) == slot) - .map(RevealBundle::bundle_hash) - .unwrap_or_else(|| default_reveal_bundle_hash(slot)) - }) -} - -fn canonical_reveal_bundle_hashes(bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE]) -> String { - bundle_hashes.join("|") -} - -fn vdf_seed_for_child( - prev_hash: &str, - height: u64, - bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE], -) -> String { - hex_hash(format!( - "iuna-vdf-child:{prev_hash}:{height}:{}", - canonical_reveal_bundle_hashes(bundle_hashes) - )) -} - -fn recovery_vdf_seed_for_child( - prev_hash: &str, - height: u64, - timestamp_ms: u64, - bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE], -) -> String { - hex_hash(format!( - "iuna-recovery-vdf-child:{prev_hash}:{height}:{timestamp_ms}:{}", - canonical_reveal_bundle_hashes(bundle_hashes) - )) -} - -fn apply_transaction( - transaction: &Transaction, - utxos: &mut BTreeMap<OutPoint, TxOutput>, -) -> Result<()> { - transaction.verify_signature()?; - match transaction { - Transaction::Mine { recipient, .. } => { - let output = TxOutput { - address: recipient.clone(), - amount: MINE_REWARD, - }; - ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; - utxos.insert( - OutPoint { - txid: transaction.signature().to_string(), - index: 0, - }, - output, - ); - return Ok(()); - } - Transaction::Transfer { .. } | Transaction::Burn { .. } => {} - } - ensure_single_input_owner(transaction)?; - let input_total = spend_inputs(transaction, utxos)?; - let outputs = transaction.outputs(); - let output_total = outputs.iter().try_fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .context("transaction outputs overflow") - })?; - let required = output_total - .checked_add(transaction.fee()) - .context("transaction outputs plus fee overflow")? - .checked_add(match transaction { - Transaction::Burn { amount, .. } => *amount, - Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, - }) - .context("transaction outputs plus burn overflow")?; - if input_total != required { - bail!("transaction inputs do not balance outputs, burn, and fee"); - } - ensure_outputs_do_not_overflow(utxos, &outputs)?; - for (index, output) in outputs.iter().enumerate() { - utxos.insert( - OutPoint { - txid: transaction.signature().to_string(), - index: index as u32, - }, - output.clone(), - ); - } - Ok(()) -} - -fn validate_block_blinded_items(block: &Block, ledger: &Ledger) -> Result<()> { - let mut commitments = BTreeSet::new(); - for transaction in &block.blinded_transactions { - if !commitments.insert(transaction.commitment.clone()) { - bail!("duplicate blinded transaction in block"); - } - ledger.validate_blinded_transaction(transaction)?; - if transaction.expires_at_height <= block.height { - bail!("blinded transaction is expired for block height"); - } - if ledger.active_blinded.contains_key(&transaction.commitment) { - bail!("blinded transaction is already active"); - } - if ledger.chain.iter().any(|block| { - block - .blinded_transactions - .iter() - .any(|existing| existing.commitment == transaction.commitment) - }) { - bail!("blinded transaction is already on chain"); - } - } - - let mut reveals = BTreeSet::new(); - for reveal in block.all_blinded_reveals() { - if !reveals.insert(reveal.commitment.clone()) { - bail!("duplicate blinded reveal in block"); - } - if ledger.chain.iter().any(|block| { - block - .all_blinded_reveals() - .iter() - .any(|existing| existing.commitment == reveal.commitment) - }) { - bail!("blinded reveal is already on chain"); - } - ledger.pending_reveal_transaction(reveal)?; - } - Ok(()) -} - -fn decrypt_blinded_transaction( - transaction: &BlindedTransaction, - reveal: &BlindedReveal, -) -> Result<Transaction> { - if reveal.commitment != transaction.commitment { - bail!("blinded reveal commitment does not match transaction"); - } - let key = - decode_hex_array::<BLINDED_KEY_BYTES>(&reveal.key).context("invalid blinded reveal key")?; - let nonce = decode_hex_array::<BLINDED_NONCE_BYTES>(&transaction.nonce) - .context("invalid blinded transaction nonce")?; - let ciphertext = - decode_hex(&transaction.ciphertext).context("invalid blinded transaction ciphertext")?; - let plaintext = decrypt_blinded_payload( - &key, - &nonce, - &signed_blinded_inputs(&unsigned_inputs(&transaction.inputs), ""), - transaction.fee, - transaction.expires_at_height, - &ciphertext, - )?; - if hex_hash(&plaintext) != transaction.payload_hash { - bail!("blinded transaction payload hash is invalid"); - } - let payload = serde_json::from_slice(&plaintext) - .context("failed to decode blinded transaction payload")?; - transaction_from_blinded_payload(payload, &transaction.inputs, transaction.fee) -} - -fn blinded_payload_from_transaction( - transaction: &Transaction, -) -> Result<BlindedTransactionPayload> { - match transaction { - Transaction::Transfer { - outputs, signature, .. - } => Ok(BlindedTransactionPayload::Transfer { - outputs: outputs.clone(), - signature: signature.clone(), - }), - Transaction::Burn { - change, - amount, - signature, - .. - } => Ok(BlindedTransactionPayload::Burn { - change: change.clone(), - amount: *amount, - signature: signature.clone(), - }), - Transaction::Mine { .. } => bail!("mine actions are public and cannot be blinded"), - } -} - -fn transaction_from_blinded_payload( - payload: BlindedTransactionPayload, - envelope_inputs: &[TxInput], - fee: Amount, -) -> Result<Transaction> { - match payload { - BlindedTransactionPayload::Transfer { outputs, signature } => { - let inputs = signed_blinded_inputs(&unsigned_inputs(envelope_inputs), &signature); - Ok(Transaction::Transfer { - inputs, - outputs, - fee, - signature, - }) - } - BlindedTransactionPayload::Burn { - change, - amount, - signature, - } => { - let inputs = signed_blinded_inputs(&unsigned_inputs(envelope_inputs), &signature); - Ok(Transaction::Burn { - inputs, - change, - amount, - fee, - signature, - }) - } - } -} - -fn encrypt_blinded_payload( - key: &[u8; BLINDED_KEY_BYTES], - nonce: &[u8; BLINDED_NONCE_BYTES], - inputs: &[TxInput], - fee: Amount, - expires_at_height: u64, - plaintext: &[u8], -) -> Result<Vec<u8>> { - let cipher = ChaCha20Poly1305::new(key.into()); - cipher - .encrypt( - Nonce::from_slice(nonce), - chacha20poly1305::aead::Payload { - msg: plaintext, - aad: blinded_payload_aad(inputs, fee, expires_at_height).as_bytes(), - }, - ) - .map_err(|_| anyhow!("failed to encrypt blinded transaction payload")) -} - -fn decrypt_blinded_payload( - key: &[u8; BLINDED_KEY_BYTES], - nonce: &[u8; BLINDED_NONCE_BYTES], - inputs: &[TxInput], - fee: Amount, - expires_at_height: u64, - ciphertext: &[u8], -) -> Result<Vec<u8>> { - let cipher = ChaCha20Poly1305::new(key.into()); - cipher - .decrypt( - Nonce::from_slice(nonce), - chacha20poly1305::aead::Payload { - msg: ciphertext, - aad: blinded_payload_aad(inputs, fee, expires_at_height).as_bytes(), - }, - ) - .map_err(|_| anyhow!("failed to decrypt blinded transaction payload")) -} - -fn blinded_payload_aad(inputs: &[TxInput], fee: Amount, expires_at_height: u64) -> String { - format!( - "iuna-blinded-payload-v3:{}:{fee}:{expires_at_height}", - canonical_inputs(&unsigned_inputs(inputs)) - ) -} - -fn blinded_transaction_commitment(transaction: &BlindedTransaction) -> Result<String> { - let mut without_commitment = transaction.clone(); - without_commitment.commitment.clear(); - Ok(hex_hash(without_commitment.canonical())) -} - -fn blinded_transaction_signing_payload(transaction: &BlindedTransaction) -> String { - format!( - "blinded-tx-inputs:{}:{}:{}:{}:{}:{}:{}", - canonical_inputs(&unsigned_inputs(&transaction.inputs)), - transaction.fee, - transaction.encrypted_size, - transaction.expires_at_height, - transaction.nonce, - transaction.ciphertext, - transaction.payload_hash - ) -} - -fn verify_blinded_input_signatures(transaction: &BlindedTransaction) -> Result<()> { - if transaction.inputs.is_empty() { - return Ok(()); - } - ensure_single_input_owner_for_inputs(&transaction.inputs)?; - let signature = transaction.inputs[0].signature.clone(); - if !transaction - .inputs - .iter() - .all(|input| input.signature == signature) - { - bail!("blinded transaction input signature mismatch"); - } - let mut unsigned = transaction.clone(); - for input in &mut unsigned.inputs { - input.signature.clear(); - } - let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(&transaction.inputs[0].owner) - .context("invalid blinded transaction input owner")?; - let signature = decode_hex_array::<SIGNATURE_BYTES>(&signature) - .context("invalid blinded input signature")?; - let verifying_key = - VerifyingKey::from_bytes(&public_key).context("invalid blinded input public key")?; - let signature = Signature::from_bytes(&signature); - verifying_key - .verify( - blinded_transaction_signing_payload(&unsigned).as_bytes(), - &signature, - ) - .context("blinded transaction input signature is invalid") -} - -fn credit_blinded_fee_outputs( - utxos: &mut BTreeMap<OutPoint, TxOutput>, - active: &ActiveBlindedTransaction, - reveal_executor: &str, - transaction: &Transaction, - reveal_bundle_signatures: &[RevealBundleSignature], - available_bundle_slots: usize, - aggregate_finalizer_fee: bool, -) -> Result<()> { - let fee = transaction.fee(); - if fee == 0 { - return Ok(()); - } - let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); - let reveal_finalizer_fee = - blinded_reveal_finalizer_fee(fee, reveal_bundle_signatures.len(), available_bundle_slots); - let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); - let mut outputs = Vec::new(); - if committer_fee > 0 { - outputs.push(( - blinded_committer_fee_outpoint(&active.transaction.commitment), - TxOutput { - address: active.included_by.clone(), - amount: committer_fee, - }, - )); - } - if reveal_finalizer_fee > 0 && !aggregate_finalizer_fee { - outputs.push(( - blinded_executor_fee_outpoint(&active.transaction.commitment), - TxOutput { - address: reveal_executor.to_string(), - amount: reveal_finalizer_fee, - }, - )); - } - for signature in reveal_bundle_signatures { - if reveal_bundle_signer_fee > 0 { - outputs.push(( - blinded_reveal_bundle_signer_fee_outpoint( - &active.transaction.commitment, - signature.slot, - ), - TxOutput { - address: signature.member.clone(), - amount: reveal_bundle_signer_fee, - }, - )); - } - } - let tx_outputs = outputs - .iter() - .map(|(_, output)| output.clone()) - .collect::<Vec<_>>(); - ensure_outputs_do_not_overflow(utxos, &tx_outputs)?; - for (outpoint, output) in outputs { - utxos.insert(outpoint, output); - } - Ok(()) -} - -fn blinded_fee_share(fee: Amount, bps: u64) -> Amount { - ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount -} - -fn blinded_envelope_fee_for_transaction(transaction: &Transaction) -> Amount { - match transaction { - Transaction::Mine { .. } => 0, - Transaction::Transfer { .. } | Transaction::Burn { .. } => transaction.fee(), - } -} - -fn fee_reward(transactions: &[Transaction]) -> Result<Amount> { - transactions.iter().try_fold(0_u64, |total, tx| { - total.checked_add(tx.fee()).context("block fees overflow") - }) -} - -fn block_reward( - transactions: &[Transaction], - aggregated_reveal_finalizer_fees: Amount, -) -> Result<Amount> { - fee_reward(transactions)? - .checked_add(aggregated_reveal_finalizer_fees) - .context("block reward overflow") -} - -fn aggregate_finalizer_fees_active(height: u64) -> bool { - height >= AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT -} - -fn spend_inputs( - transaction: &Transaction, - utxos: &mut BTreeMap<OutPoint, TxOutput>, -) -> Result<Amount> { - let mut seen = BTreeSet::new(); - let mut total = 0_u64; - for input in transaction.inputs() { - if !seen.insert(input.outpoint.clone()) { - bail!("duplicate input in transaction"); - } - let output = utxos.remove(&input.outpoint).with_context(|| { - format!("transaction spends missing output {}", input.outpoint.id()) - })?; - if output.address != input.owner { - bail!("transaction input owner does not match spent output"); - } - total = total - .checked_add(output.amount) - .context("transaction input total overflows")?; - } - Ok(total) -} - -fn apply_spendable_pending_transaction( - transaction: &Transaction, - utxos: &mut BTreeMap<OutPoint, TxOutput>, -) -> Result<()> { - if matches!(transaction, Transaction::Mine { .. }) { - bail!("pending mine outputs are not spendable"); - } - transaction.verify_signature()?; - ensure_single_input_owner(transaction)?; - let input_total = transaction_input_total(transaction, utxos)?; - let outputs = transaction.outputs(); - let output_total = outputs.iter().try_fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .context("transaction outputs overflow") - })?; - let required = output_total - .checked_add(transaction.fee()) - .context("transaction outputs plus fee overflow")? - .checked_add(match transaction { - Transaction::Burn { amount, .. } => *amount, - Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, - }) - .context("transaction outputs plus burn overflow")?; - if input_total != required { - bail!("transaction inputs do not balance outputs, burn, and fee"); - } - ensure_outputs_do_not_overflow(utxos, &outputs)?; - for input in transaction.inputs() { - utxos.remove(&input.outpoint); - } - for (index, output) in outputs.iter().enumerate() { - utxos.insert( - OutPoint { - txid: transaction.signature().to_string(), - index: index as u32, - }, - output.clone(), - ); - } - Ok(()) -} - -fn transaction_input_total( - transaction: &Transaction, - utxos: &BTreeMap<OutPoint, TxOutput>, -) -> Result<Amount> { - let mut seen = BTreeSet::new(); - let mut total = 0_u64; - for input in transaction.inputs() { - if !seen.insert(input.outpoint.clone()) { - bail!("duplicate input in transaction"); - } - let output = utxos.get(&input.outpoint).with_context(|| { - format!("transaction spends missing output {}", input.outpoint.id()) - })?; - if output.address != input.owner { - bail!("transaction input owner does not match spent output"); - } - total = total - .checked_add(output.amount) - .context("transaction input total overflows")?; - } - Ok(total) -} - -fn spend_blinded_inputs( - transaction: &BlindedTransaction, - utxos: &mut BTreeMap<OutPoint, TxOutput>, -) -> Result<Vec<TxOutput>> { - verify_blinded_input_signatures(transaction)?; - if transaction.inputs.is_empty() { - return Ok(Vec::new()); - } - let mut seen = BTreeSet::new(); - let mut locked = Vec::new(); - for input in &transaction.inputs { - if !seen.insert(input.outpoint.clone()) { - bail!("duplicate input in blinded transaction"); - } - let output = utxos.remove(&input.outpoint).with_context(|| { - format!( - "blinded transaction spends missing output {}", - input.outpoint.id() - ) - })?; - if output.address != input.owner { - bail!("blinded transaction input owner does not match spent output"); - } - locked.push(output); - } - let locked_total = locked.iter().try_fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .context("blinded transaction locked input total overflows") - })?; - if transaction.fee > locked_total { - bail!("blinded transaction fee exceeds locked inputs"); - } - Ok(locked) -} - -fn spend_spendable_blinded_inputs( - transaction: &BlindedTransaction, - utxos: &mut BTreeMap<OutPoint, TxOutput>, -) -> Result<Vec<TxOutput>> { - let locked = blinded_input_outputs(transaction, utxos)?; - for input in &transaction.inputs { - utxos.remove(&input.outpoint); - } - Ok(locked) -} - -fn blinded_input_outputs( - transaction: &BlindedTransaction, - utxos: &BTreeMap<OutPoint, TxOutput>, -) -> Result<Vec<TxOutput>> { - verify_blinded_input_signatures(transaction)?; - if transaction.inputs.is_empty() { - return Ok(Vec::new()); - } - let mut seen = BTreeSet::new(); - let mut locked = Vec::new(); - for input in &transaction.inputs { - if !seen.insert(input.outpoint.clone()) { - bail!("duplicate input in blinded transaction"); - } - let output = utxos.get(&input.outpoint).with_context(|| { - format!( - "blinded transaction spends missing output {}", - input.outpoint.id() - ) - })?; - if output.address != input.owner { - bail!("blinded transaction input owner does not match spent output"); - } - locked.push(output.clone()); - } - let locked_total = locked.iter().try_fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .context("blinded transaction locked input total overflows") - })?; - if transaction.fee > locked_total { - bail!("blinded transaction fee exceeds locked inputs"); - } - Ok(locked) -} - -fn blinded_locked_output_total(active: &ActiveBlindedTransaction) -> Result<Amount> { - active - .locked_outputs - .iter() - .try_fold(0_u64, |total, output| { - total - .checked_add(output.amount) - .context("blinded transaction locked input total overflows") - }) -} - -fn blinded_reveal_inputs_match( - active: &ActiveBlindedTransaction, - transaction: &Transaction, -) -> bool { - let visible = active - .transaction - .inputs - .iter() - .map(TxInput::without_signature) - .collect::<Vec<_>>(); - let revealed = transaction - .inputs() - .iter() - .map(TxInput::without_signature) - .collect::<Vec<_>>(); - visible == revealed -} - -fn credit_expired_blinded_outputs( - utxos: &mut BTreeMap<OutPoint, TxOutput>, - active: &ActiveBlindedTransaction, -) -> Result<()> { - let Some(first_input) = active.transaction.inputs.first() else { - return Ok(()); - }; - let input_total = blinded_locked_output_total(active)?; - if active.transaction.fee > input_total { - bail!("blinded transaction fee exceeds locked inputs"); - } - let change = input_total - active.transaction.fee; - let mut outputs = Vec::new(); - if change > 0 { - outputs.push(( - blinded_expiry_change_outpoint(&active.transaction.commitment), - TxOutput { - address: first_input.owner.clone(), - amount: change, - }, - )); - } - let tx_outputs = outputs - .iter() - .map(|(_, output)| output.clone()) - .collect::<Vec<_>>(); - ensure_outputs_do_not_overflow(utxos, &tx_outputs)?; - for (outpoint, output) in outputs { - utxos.insert(outpoint, output); - } - Ok(()) -} - -fn transaction_has_missing_inputs( - transaction: &Transaction, - utxos: &BTreeMap<OutPoint, TxOutput>, -) -> bool { - transaction - .inputs() - .iter() - .any(|input| !utxos.contains_key(&input.outpoint)) -} - -fn ensure_single_input_owner(transaction: &Transaction) -> Result<()> { - if matches!(transaction, Transaction::Mine { .. }) { - return Ok(()); - } - ensure_single_input_owner_for_inputs(transaction.inputs()) -} - -fn ensure_single_input_owner_for_inputs(inputs: &[TxInput]) -> Result<()> { - let Some(first) = inputs.first() else { - bail!("transaction has no inputs"); - }; - if inputs.iter().any(|input| input.owner != first.owner) { - bail!("transaction inputs must have one owner"); - } - Ok(()) -} - -fn credit_reward_output(utxos: &mut BTreeMap<OutPoint, TxOutput>, block: &Block) -> Result<()> { - if block.reward == 0 { - return Ok(()); - } - let output = TxOutput { - address: block.miner.clone(), - amount: block.reward, - }; - ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; - utxos.insert(reward_outpoint(&block.hash), output); - Ok(()) -} - -fn ensure_outputs_do_not_overflow( - utxos: &BTreeMap<OutPoint, TxOutput>, - outputs: &[TxOutput], -) -> Result<()> { - let mut balances = BTreeMap::new(); - for output in utxos.values() { - let balance = balances.entry(output.address.clone()).or_insert(0_u64); - *balance = balance - .checked_add(output.amount) - .with_context(|| format!("balance overflow for {}", output.address))?; - } - for output in outputs { - let balance = balances.entry(output.address.clone()).or_insert(0_u64); - *balance = balance - .checked_add(output.amount) - .with_context(|| format!("balance overflow for {}", output.address))?; - } - Ok(()) -} - -fn build_genesis_block( - genesis_allocations: &BTreeMap<String, Amount>, - transactions: Vec<Transaction>, -) -> Block { - let miner = genesis_miner(genesis_allocations, &transactions); - let reward = genesis_reward(genesis_allocations, &transactions); - let txs = transactions - .iter() - .map(Transaction::canonical) - .collect::<Vec<_>>() - .join("|"); - let vdf_output = hex_hash(format!("iuna-genesis-vdf:{genesis_allocations:?}:{txs}")); - let mut genesis = Block { - height: 0, - prev_hash: "0".repeat(64), - timestamp_ms: 0, - miner, - finalizer_mode: FinalizerMode::Ticket, - finalizer_rank: 0, - reward, - vdf_rounds: 0, - vdf_output, - leader_proof: None, - blinded_transactions: Vec::new(), - reveal_bundle_section: RevealBundleSection::default(), - transactions, - hash: String::new(), - }; - genesis.hash = genesis.compute_hash(); - genesis -} - -fn utxos_after_genesis( - genesis_allocations: &BTreeMap<String, Amount>, - genesis: &Block, -) -> Result<BTreeMap<OutPoint, TxOutput>> { - let mut utxos = genesis_allocation_utxos(genesis_allocations); - for transaction in &genesis.transactions { - match transaction { - Transaction::Burn { .. } => { - validate_genesis_burn_transaction(transaction)?; - apply_transaction(transaction, &mut utxos)?; - } - Transaction::Transfer { .. } | Transaction::Mine { .. } => { - bail!("genesis only supports burn transactions") - } - } - } - credit_reward_output(&mut utxos, genesis)?; - Ok(utxos) -} - -fn genesis_allocation_utxos( - genesis_allocations: &BTreeMap<String, Amount>, -) -> BTreeMap<OutPoint, TxOutput> { - genesis_allocations - .iter() - .filter(|(_, amount)| **amount > 0) - .map(|(address, amount)| { - ( - genesis_allocation_outpoint(address), - TxOutput { - address: address.clone(), - amount: *amount, - }, - ) - }) - .collect() -} - -fn balances_from_utxos(utxos: &BTreeMap<OutPoint, TxOutput>) -> BTreeMap<String, Amount> { - let mut balances = BTreeMap::new(); - for output in utxos.values() { - let balance = balances.entry(output.address.clone()).or_insert(0_u64); - *balance = balance.saturating_add(output.amount); - } - balances -} - -fn genesis_allocation_outpoint(address: &str) -> OutPoint { - OutPoint { - txid: hex_hash(format!("iuna-genesis-allocation:{address}")), - index: 0, - } -} - -fn reward_outpoint(block_hash: &str) -> OutPoint { - OutPoint { - txid: block_hash.to_string(), - index: u32::MAX, - } -} - -fn blinded_committer_fee_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 1, - } -} - -fn blinded_executor_fee_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 2, - } -} - -fn blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: u32::MAX - 3 - u32::from(slot), - } -} - -fn blinded_expiry_change_outpoint(commitment: &str) -> OutPoint { - OutPoint { - txid: commitment.to_string(), - index: 0, - } -} - -fn validate_genesis_block(block: &Block) -> Result<()> { - if block.height != 0 { - bail!("genesis block height must be 0"); - } - if block.prev_hash != "0".repeat(64) { - bail!("genesis block prev_hash must be all zeroes"); - } - if block.timestamp_ms != 0 { - bail!("genesis block timestamp must be 0"); - } - if block.miner == "genesis" && block.reward != 0 { - bail!("genesis placeholder miner must not receive a reward"); - } - if block.miner != "genesis" && block.reward != 0 && block.reward != BLOCK_REWARD { - bail!("genesis block reward is invalid"); - } - if block.vdf_rounds != 0 { - bail!("genesis block VDF rounds must be 0"); - } - if block.leader_proof.is_some() { - bail!("genesis block must not carry a leader proof"); - } - if !block.blinded_transactions.is_empty() || !block.reveal_bundle_section.is_empty() { - bail!("genesis block must not carry blinded transactions"); - } - if block.compute_hash() != block.hash { - bail!("genesis block hash is invalid"); - } - Ok(()) -} - -fn genesis_miner( - genesis_allocations: &BTreeMap<String, Amount>, - transactions: &[Transaction], -) -> String { - transactions - .iter() - .filter_map(|transaction| match transaction { - Transaction::Burn { inputs, .. } => inputs.first().map(|input| input.owner.as_str()), - Transaction::Transfer { .. } | Transaction::Mine { .. } => None, - }) - .find(|from| genesis_allocations.contains_key(*from)) - .or_else(|| genesis_allocations.keys().next().map(String::as_str)) - .unwrap_or("genesis") - .to_string() -} - -fn genesis_reward( - genesis_allocations: &BTreeMap<String, Amount>, - transactions: &[Transaction], -) -> Amount { - if genesis_allocations.is_empty() || transactions.is_empty() { - 0 - } else { - BLOCK_REWARD - } -} - -pub fn run_vdf(seed: &str, rounds: u64) -> String { - let x = vdf_seed_element(seed); - let mut y = x; - for _ in 0..rounds { - y = mul_mod(y, y); - } - - let challenge = vdf_challenge_prime(seed, rounds, y); - let proof = vdf_proof(x, rounds, challenge); - encode_vdf_solution(y, proof) -} - -pub fn verify_vdf(seed: &str, rounds: u64, solution: &str) -> bool { - let Some((y, proof)) = decode_vdf_solution(solution) else { - return false; - }; - if y == 0 || y >= VDF_MODULUS || proof >= VDF_MODULUS { - return false; - } - - let x = vdf_seed_element(seed); - let challenge = vdf_challenge_prime(seed, rounds, y); - let remainder = pow_mod_small(2, rounds, challenge) as u128; - let verified = mul_mod(mod_pow(proof, challenge as u128), mod_pow(x, remainder)); - verified == y -} - -pub fn revealed_blinded_transactions( - snapshot: &ChainSnapshot, -) -> Result<Vec<RevealedBlindedTransaction>> { - let mut active = BTreeMap::<String, ActiveBlindedTransaction>::new(); - let mut revealed = Vec::new(); - for block in &snapshot.blocks { - for reveal in block.all_blinded_reveals() { - let active_transaction = active.get(&reveal.commitment).with_context(|| { - format!( - "block {} reveals unknown blinded transaction {}", - block.height, reveal.commitment - ) - })?; - let transaction = decrypt_blinded_transaction(&active_transaction.transaction, reveal)?; - if matches!(transaction, Transaction::Mine { .. }) { - bail!("block {} blinded reveal is a mine action", block.height); - } - if blinded_envelope_fee_for_transaction(&transaction) - != active_transaction.transaction.fee - { - bail!( - "block {} blinded reveal fee does not match envelope", - block.height - ); - } - revealed.push(RevealedBlindedTransaction { - height: block.height, - commitment: reveal.commitment.clone(), - included_by: active_transaction.included_by.clone(), - transaction, - }); - active.remove(&reveal.commitment); - } - active.retain(|_, active_transaction| { - block.height < active_transaction.transaction.expires_at_height - }); - for transaction in &block.blinded_transactions { - active.insert( - transaction.commitment.clone(), - ActiveBlindedTransaction { - transaction: transaction.clone(), - locked_outputs: Vec::new(), - included_height: block.height, - included_by: block.miner.clone(), - }, - ); - } - } - Ok(revealed) -} - -fn vdf_seed_element(seed: &str) -> u128 { - let digest = Sha256::digest(format!("iuna-vdf-seed:{seed}").as_bytes()); - let mut bytes = [0_u8; 16]; - bytes.copy_from_slice(&digest[..16]); - 2 + (u128::from_be_bytes(bytes) % (VDF_MODULUS - 3)) -} - -fn vdf_challenge_prime(seed: &str, rounds: u64, output: u128) -> u64 { - let digest = Sha256::digest(format!("iuna-vdf-challenge:{seed}:{rounds}:{output:x}")); - let mut bytes = [0_u8; 8]; - bytes.copy_from_slice(&digest[..8]); - let candidate = VDF_CHALLENGE_MIN + (u64::from_be_bytes(bytes) % VDF_CHALLENGE_MIN); - next_odd_prime(candidate | 1) -} - -fn vdf_proof(x: u128, rounds: u64, challenge: u64) -> u128 { - let mut proof = 1_u128; - let mut remainder = 1_u64 % challenge; - for _ in 0..rounds { - let doubled = remainder * 2; - let carry = doubled >= challenge; - proof = mul_mod(proof, proof); - if carry { - proof = mul_mod(proof, x); - } - remainder = doubled % challenge; - } - proof -} - -fn encode_vdf_solution(output: u128, proof: u128) -> String { - format!("{output:032x}:{proof:032x}") -} - -fn decode_vdf_solution(solution: &str) -> Option<(u128, u128)> { - let (output, proof) = solution.split_once(':')?; - if output.len() != 32 || proof.len() != 32 { - return None; - } - Some(( - u128::from_str_radix(output, 16).ok()?, - u128::from_str_radix(proof, 16).ok()?, - )) -} - -fn mul_mod(left: u128, right: u128) -> u128 { - (left * right) % VDF_MODULUS -} - -fn mod_pow(mut base: u128, mut exponent: u128) -> u128 { - let mut result = 1_u128; - while exponent > 0 { - if exponent & 1 == 1 { - result = mul_mod(result, base); - } - base = mul_mod(base, base); - exponent >>= 1; - } - result -} - -fn pow_mod_small(base: u64, exponent: u64, modulus: u64) -> u64 { - let mut result = 1_u128; - let mut base = u128::from(base % modulus); - let mut exponent = exponent; - let modulus = u128::from(modulus); - while exponent > 0 { - if exponent & 1 == 1 { - result = (result * base) % modulus; - } - base = (base * base) % modulus; - exponent >>= 1; - } - result as u64 -} - -fn next_odd_prime(mut candidate: u64) -> u64 { - while !is_odd_prime(candidate) { - candidate = candidate.saturating_add(2); - } - candidate -} - -fn is_odd_prime(candidate: u64) -> bool { - if candidate < 3 || candidate % 2 == 0 { - return false; - } - let mut divisor = 3_u64; - while divisor * divisor <= candidate { - if candidate % divisor == 0 { - return false; - } - divisor += 2; - } - true -} - -fn retarget_vdf_rounds(current_rounds: u64, observed_block_ms: u64) -> u64 { - let current = u128::from(current_rounds); - let observed = u128::from(observed_block_ms.max(1)); - let target = u128::from(VDF_TARGET_BLOCK_MS); - let deadband = target * VDF_RETARGET_DEADBAND_PERCENT / 100; - if observed >= target.saturating_sub(deadband) && observed <= target.saturating_add(deadband) { - return current_rounds; - } - - let raw_adjusted = current * target / observed; - let max_step = (current * MAX_VDF_RETARGET_STEP_PERCENT / 100).max(1); - let min_next = current - .saturating_sub(max_step) - .max(u128::from(MIN_VDF_ROUNDS)); - let max_next = current - .saturating_add(max_step) - .min(u128::from(MAX_VDF_ROUNDS)); - raw_adjusted.clamp(min_next, max_next) as u64 -} - -fn clamped_vdf_retarget_observed_block_ms(observed_block_ms: u64) -> u64 { - observed_block_ms.clamp( - MIN_VDF_RETARGET_OBSERVED_BLOCK_MS, - MAX_VDF_RETARGET_OBSERVED_BLOCK_MS, - ) -} - -fn vdf_retarget_observed_block_ms(parent: &Block, child: &Block) -> Option<u64> { - if child.finalizer_mode != FinalizerMode::Ticket { - return None; - } - if child.finalizer_rank != 0 - && (child.height < FALLBACK_VDF_RETARGET_ACTIVATION_HEIGHT - || child.height >= FALLBACK_VDF_RETARGET_DEACTIVATION_HEIGHT) - { - return None; - } - - Some(clamped_vdf_retarget_observed_block_ms( - child.timestamp_ms - parent.timestamp_ms, - )) -} - -fn unix_now_ms() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .try_into() - .unwrap_or(u64::MAX) -} - -pub fn hex_hash(input: impl AsRef<[u8]>) -> String { - hex_encode(Sha256::digest(input.as_ref())) -} - -fn decode_hex_array<const N: usize>(input: &str) -> Result<[u8; N]> { - let bytes = decode_hex(input)?; - let len = bytes.len(); - bytes - .try_into() - .map_err(|_| anyhow!("expected {} hex bytes, got {len}", N)) -} - -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 { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let bytes = bytes.as_ref(); - let mut encoded = String::with_capacity(bytes.len() * 2); - for byte in bytes { - encoded.push(HEX[(byte >> 4) as usize] as char); - encoded.push(HEX[(byte & 0x0f) as usize] as char); - } - encoded -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn target_block_time_is_five_minutes() { - assert_eq!(VDF_TARGET_BLOCK_MS, 5 * 60 * 1_000); - } - - fn test_utxo_outpoint(index: usize) -> OutPoint { - OutPoint { - txid: format!("{index:064x}"), - index: 0, - } - } - - fn named_test_outpoint(name: &str) -> OutPoint { - OutPoint { - txid: hex_hash(format!("test-utxo:{name}")), - index: 0, - } - } - - fn ledger_with_wallet_utxos(wallet: &Wallet, amounts: &[Amount]) -> Ledger { - let mut ledger = Ledger::new(BTreeMap::new(), 1); - ledger.utxos = amounts - .iter() - .enumerate() - .map(|(index, amount)| { - ( - test_utxo_outpoint(index), - TxOutput { - address: wallet.address().to_string(), - amount: *amount, - }, - ) - }) - .collect(); - ledger - } - - fn pending_balances(ledger: &Ledger) -> BTreeMap<String, Amount> { - balances_from_utxos(&ledger.utxos_after_valid_pending().unwrap()) - } - - fn ledger_with_allocation(wallet: &Wallet, amount: Amount) -> Ledger { - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), amount); - Ledger::new(genesis, 1) - } - - fn mine_burn_block_with_mines(ledger: &mut Ledger, wallet: &Wallet, mine_actions: usize) { - let burn = ledger.build_burn(wallet, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - for _ in 0..mine_actions { - let mine = ledger.build_mine(wallet.address()).unwrap(); - ledger.submit_transaction(mine).unwrap(); - } - let block = ledger.mine_next_block(wallet, ledger.height() + 1).unwrap(); - ledger.apply_block(block).unwrap(); - } - - fn test_mine_with_salt(ledger: &Ledger, recipient: &str, salt: u64) -> Transaction { - let anchor = ledger.tip().hash.clone(); - let difficulty_bits = ledger.current_mine_difficulty_bits(); - for nonce in 0..u64::MAX { - let signature = mine_signature(recipient, &anchor, salt, nonce, difficulty_bits); - if hash_meets_difficulty(&signature, difficulty_bits) { - return Transaction::Mine { - recipient: recipient.to_string(), - anchor, - salt, - nonce, - difficulty_bits, - proof_header: None, - signature, - }; - } - } - panic!("test should find a valid mine action"); - } - - fn advance_to_mine_anchor_limit_activation_parent(ledger: &mut Ledger, wallet: &Wallet) { - while ledger.height().saturating_add(1) < MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT { - let timestamp_ms = ledger - .tip() - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS); - apply_preverified_burn_block_at(ledger, wallet, timestamp_ms); - } - assert_eq!( - ledger.height().saturating_add(1), - MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT - ); - } - - fn apply_preverified_burn_block_at( - ledger: &mut Ledger, - wallet: &Wallet, - timestamp_ms: u64, - ) -> Block { - let burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let work = ledger - .prepare_next_block(wallet.address(), timestamp_ms) - .unwrap(); - let block = work.finish(wallet, "preverified-vdf".to_string()); - ledger - .apply_preverified_block_at(block.clone(), u64::MAX) - .unwrap(); - block - } - - fn apply_preverified_burn_block_with_mines( - ledger: &mut Ledger, - wallet: &Wallet, - mine_actions: usize, - ) { - let burn = ledger.build_burn(wallet, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let timestamp_ms = ledger - .tip() - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS); - let mut block = ledger - .prepare_next_block(wallet.address(), timestamp_ms) - .unwrap() - .finish(wallet, "preverified-vdf".to_string()); - for salt in 0..mine_actions { - block.transactions.push(test_mine_with_salt( - ledger, - wallet.address(), - salt as u64 + 1, - )); - } - block.reward = fee_reward(&block.transactions).unwrap(); - block.hash = block.compute_hash(); - ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); - } - - fn vdf_retarget_sample_block( - timestamp_ms: u64, - finalizer_mode: FinalizerMode, - finalizer_rank: u32, - ) -> Block { - Block { - height: 1, - prev_hash: String::new(), - timestamp_ms, - miner: String::new(), - finalizer_mode, - finalizer_rank, - reward: 0, - vdf_rounds: 1, - vdf_output: String::new(), - leader_proof: None, - blinded_transactions: Vec::new(), - reveal_bundle_section: RevealBundleSection::default(), - transactions: Vec::new(), - hash: String::new(), - } - } - - const TEST_BURN_AMOUNT: Amount = MICRO_IUNA / 10; - - fn unsigned_mine(ledger: &Ledger, recipient: &str) -> Transaction { - let anchor = ledger.tip().hash.clone(); - let difficulty_bits = ledger.current_mine_difficulty_bits(); - for nonce in 0..u64::MAX { - let salt = 1; - let signature = mine_signature(recipient, &anchor, salt, nonce, difficulty_bits); - if hash_meets_difficulty(&signature, difficulty_bits) { - return Transaction::Mine { - recipient: recipient.to_string(), - anchor, - salt, - nonce, - difficulty_bits, - proof_header: None, - signature, - }; - } - } - panic!("expected to find mine proof"); - } - - fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet { - wallets - .iter() - .find(|wallet| wallet.address() == address) - .unwrap_or_else(|| panic!("missing wallet for address {address}")) - } - - fn ledger_with_finalizers( - finalizers: &[Wallet], - extra_allocations: &[(&Wallet, Amount)], - ) -> Ledger { - let mut allocations = BTreeMap::new(); - for wallet in finalizers { - allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); - } - for (wallet, amount) in extra_allocations { - allocations.insert(wallet.address().to_string(), *amount); - } - Ledger::new_with_genesis_burns( - allocations, - finalizers - .iter() - .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) - .collect(), - 1, - ) - .unwrap() - } - - fn mine_preverified_as_next_leader( - ledger: &mut Ledger, - wallets: &[Wallet], - timestamp_ms: u64, - ) -> Block { - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(wallets, &leader); - let prepared = ledger - .prepare_next_block(wallet.address(), timestamp_ms) - .unwrap(); - let block = prepared.finish(wallet, "preverified-vdf".to_string()); - ledger - .apply_preverified_block_at(block.clone(), u64::MAX) - .unwrap(); - block - } - - fn mine_preverified_as_next_leader_with_reveal_bundles( - ledger: &mut Ledger, - wallets: &[Wallet], - timestamp_ms: u64, - ) -> Block { - let block = - prepare_preverified_as_next_leader_with_reveal_bundles(ledger, wallets, timestamp_ms); - ledger - .apply_preverified_block_at(block.clone(), u64::MAX) - .unwrap(); - block - } - - fn prepare_preverified_as_next_leader_with_reveal_bundles( - ledger: &Ledger, - wallets: &[Wallet], - timestamp_ms: u64, - ) -> Block { - let bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - let wallet = wallet_for_address(wallets, &member.owner); - ledger.build_reveal_bundle(wallet).unwrap() - }) - .collect::<Vec<_>>(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(wallets, &leader); - let prepared = ledger - .prepare_next_block_with_reveal_bundles(wallet.address(), timestamp_ms, bundles) - .unwrap(); - prepared.finish(wallet, "preverified-vdf".to_string()) - } - - fn advance_preverified_to_height(ledger: &mut Ledger, wallets: &[Wallet], target_height: u64) { - while ledger.height() < target_height { - queue_next_leader_burn(ledger, wallets); - let timestamp_ms = ledger - .tip() - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS); - mine_preverified_as_next_leader(ledger, wallets, timestamp_ms); - } - } - - fn queue_next_leader_burn(ledger: &mut Ledger, wallets: &[Wallet]) { - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(wallets, &leader); - let burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - } - - fn transfer_with_extra_zero_outputs( - ledger: &Ledger, - wallet: &Wallet, - to: &str, - amount: Amount, - fee: Amount, - extra_outputs: usize, - ) -> Transaction { - let required = amount.checked_add(fee).unwrap(); - let (inputs, input_total) = ledger.select_inputs(wallet.address(), required).unwrap(); - let mut outputs = vec![TxOutput { - address: to.to_string(), - amount, - }]; - outputs.extend((0..extra_outputs).map(|_| TxOutput { - address: to.to_string(), - amount: 0, - })); - let change = input_total - required; - if change > 0 { - outputs.push(TxOutput { - address: wallet.address().to_string(), - amount: change, - }); - } - UnsignedUtxoTransaction::Transfer { - inputs, - outputs, - fee, - } - .sign(wallet) - } - - #[test] - fn wallet_utxos_only_include_outputs_owned_by_address() { - let alice = Wallet::from_seed("wallet-utxos-alice"); - let bob = Wallet::from_seed("wallet-utxos-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3]); - ledger.utxos.insert( - named_test_outpoint("bob"), - TxOutput { - address: bob.address().to_string(), - amount: 5, - }, - ); - - let alice_utxos = ledger.utxos_for_address(alice.address()); - let total = alice_utxos - .iter() - .map(|(_, output)| output.amount) - .sum::<Amount>(); - - assert_eq!(alice_utxos.len(), 2); - assert_eq!(total, ledger.balance_of(alice.address())); - assert!( - alice_utxos - .iter() - .all(|(_, output)| output.address == alice.address()) - ); - } - - #[test] - fn transfer_combines_multiple_small_utxos_to_cover_amount_and_fee() { - let alice = Wallet::from_seed("combine-small-utxos-alice"); - let bob = Wallet::from_seed("combine-small-utxos-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 1]); - - let tx = ledger.build_transfer(&alice, bob.address(), 2, 1).unwrap(); - - let Transaction::Transfer { - inputs, - outputs, - fee, - .. - } = &tx - else { - panic!("expected transfer"); - }; - assert_eq!(inputs.len(), 3); - assert_eq!(*fee, 1); - assert_eq!( - outputs, - &[TxOutput { - address: bob.address().to_string(), - amount: 2 - }] - ); - - ledger.submit_transaction(tx).unwrap(); - let balances = pending_balances(&ledger); - assert_eq!( - balances.get(alice.address()).copied().unwrap_or_default(), - 0 - ); - assert_eq!(balances.get(bob.address()).copied().unwrap_or_default(), 2); - } - - #[test] - fn transfer_returns_change_when_combined_utxos_exceed_payment() { - let alice = Wallet::from_seed("combine-change-alice"); - let bob = Wallet::from_seed("combine-change-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 2]); - - let tx = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); - - let Transaction::Transfer { - inputs, outputs, .. - } = &tx - else { - panic!("expected transfer"); - }; - assert_eq!(inputs.len(), 3); - assert_eq!( - outputs, - &[ - TxOutput { - address: bob.address().to_string(), - amount: 3 - }, - TxOutput { - address: alice.address().to_string(), - amount: 1 - } - ] - ); - - ledger.submit_transaction(tx).unwrap(); - let balances = pending_balances(&ledger); - assert_eq!(balances.get(alice.address()).copied(), Some(1)); - assert_eq!(balances.get(bob.address()).copied(), Some(3)); - } - - #[test] - fn transaction_economic_size_uses_compact_canonical_fields() { - let alice = Wallet::from_seed("economic-size-alice"); - let bob = Wallet::from_seed("economic-size-bob"); - let ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 2]); - let selected = vec![ - test_utxo_outpoint(0), - test_utxo_outpoint(1), - test_utxo_outpoint(2), - ]; - - let tx = ledger - .build_transfer_with_inputs(&alice, bob.address(), 3, 0, &selected) - .unwrap(); - - assert!(tx.economic_size_bytes() < tx.serialized_size_bytes().unwrap()); - assert_eq!( - tx.economic_size_bytes(), - 1 + 1 + (3 * (32 + 1 + 32)) + 1 + (2 * (32 + 1)) + 1 + 64 - ); - } - - #[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 blinded_fee_split_burns_rounding_dust() { - let committer = Wallet::from_seed("blinded-split-committer"); - let executor = Wallet::from_seed("blinded-split-executor"); - let commitment = "01".repeat(32); - let active = ActiveBlindedTransaction { - transaction: BlindedTransaction { - commitment: commitment.clone(), - inputs: Vec::new(), - fee: 1, - encrypted_size: 1, - expires_at_height: 2, - nonce: "02".repeat(BLINDED_NONCE_BYTES), - ciphertext: "03".to_string(), - payload_hash: "04".repeat(32), - }, - locked_outputs: Vec::new(), - included_height: 1, - included_by: committer.address().to_string(), - }; - let transaction = Transaction::Transfer { - inputs: Vec::new(), - outputs: Vec::new(), - fee: 1, - signature: String::new(), - }; - let mut utxos = BTreeMap::new(); - - credit_blinded_fee_outputs( - &mut utxos, - &active, - executor.address(), - &transaction, - &[], - 3, - false, - ) - .unwrap(); - - assert!(!utxos.contains_key(&blinded_committer_fee_outpoint(&commitment))); - assert!(!utxos.contains_key(&blinded_executor_fee_outpoint(&commitment))); - } - - #[test] - fn blinded_fee_split_pays_no_reveal_finalizer_without_signed_reveal_lists() { - let committer = Wallet::from_seed("blinded-no-list-committer"); - let executor = Wallet::from_seed("blinded-no-list-executor"); - let commitment = "06".repeat(32); - let active = ActiveBlindedTransaction { - transaction: BlindedTransaction { - commitment: commitment.clone(), - inputs: Vec::new(), - fee: 100, - encrypted_size: 1, - expires_at_height: 2, - nonce: "02".repeat(BLINDED_NONCE_BYTES), - ciphertext: "03".to_string(), - payload_hash: "04".repeat(32), - }, - locked_outputs: Vec::new(), - included_height: 1, - included_by: committer.address().to_string(), - }; - let transaction = Transaction::Transfer { - inputs: Vec::new(), - outputs: Vec::new(), - fee: 100, - signature: String::new(), - }; - let mut utxos = BTreeMap::new(); - - credit_blinded_fee_outputs( - &mut utxos, - &active, - executor.address(), - &transaction, - &[], - 3, - false, - ) - .unwrap(); - - assert_eq!( - utxos.get(&blinded_committer_fee_outpoint(&commitment)), - Some(&TxOutput { - address: committer.address().to_string(), - amount: 35, - }) - ); - assert!(!utxos.contains_key(&blinded_executor_fee_outpoint(&commitment))); - } - - #[test] - fn blinded_fee_split_pays_committer_executor_and_reveal_bundle_signers() { - let committer = Wallet::from_seed("blinded-scale-committer"); - let executor = Wallet::from_seed("blinded-scale-executor"); - let signer_a = Wallet::from_seed("blinded-scale-signer-a"); - let signer_b = Wallet::from_seed("blinded-scale-signer-b"); - let commitment = "05".repeat(32); - let active = ActiveBlindedTransaction { - transaction: BlindedTransaction { - commitment: commitment.clone(), - inputs: Vec::new(), - fee: 7, - encrypted_size: 1, - expires_at_height: 2, - nonce: "02".repeat(BLINDED_NONCE_BYTES), - ciphertext: "03".to_string(), - payload_hash: "04".repeat(32), - }, - locked_outputs: Vec::new(), - included_height: 1, - included_by: committer.address().to_string(), - }; - let transaction = Transaction::Transfer { - inputs: Vec::new(), - outputs: Vec::new(), - fee: 100, - signature: String::new(), - }; - let mut utxos = BTreeMap::new(); - let signatures = vec![ - RevealBundleSignature { - slot: 0, - member: signer_a.address().to_string(), - signature: "11".repeat(SIGNATURE_BYTES), - }, - RevealBundleSignature { - slot: 2, - member: signer_b.address().to_string(), - signature: "22".repeat(SIGNATURE_BYTES), - }, - ]; - - credit_blinded_fee_outputs( - &mut utxos, - &active, - executor.address(), - &transaction, - &signatures, - 3, - false, - ) - .unwrap(); - - assert_eq!( - utxos.get(&blinded_committer_fee_outpoint(&commitment)), - Some(&TxOutput { - address: committer.address().to_string(), - amount: 35, - }) - ); - assert_eq!( - utxos.get(&blinded_executor_fee_outpoint(&commitment)), - Some(&TxOutput { - address: executor.address().to_string(), - amount: 23, - }) - ); - assert_eq!( - utxos.get(&blinded_reveal_bundle_signer_fee_outpoint(&commitment, 0)), - Some(&TxOutput { - address: signer_a.address().to_string(), - amount: 10, - }) - ); - assert_eq!( - utxos.get(&blinded_reveal_bundle_signer_fee_outpoint(&commitment, 2)), - Some(&TxOutput { - address: signer_b.address().to_string(), - amount: 10, - }) - ); - } - - #[test] - fn blinded_reveal_finalizer_fee_scales_by_available_reveal_bundle_slots() { - let fee = 300_000; - let full_share = blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS); - - assert_eq!(blinded_reveal_finalizer_fee(fee, 0, 3), 0); - assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 3), full_share / 3); - assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 2), full_share / 2); - assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 1), full_share); - assert_eq!(blinded_reveal_finalizer_fee(fee, 2, 3), full_share * 2 / 3); - assert_eq!(blinded_reveal_finalizer_fee(fee, 3, 3), full_share); - assert_eq!(blinded_reveal_finalizer_fee(fee, 4, 3), full_share); - } - - #[test] - fn transfer_rejects_invalid_recipient_address() { - let alice = Wallet::from_seed("invalid-transfer-recipient-alice"); - let ledger = ledger_with_wallet_utxos(&alice, &[10]); - - let error = ledger.build_transfer(&alice, "aa", 1, 0).unwrap_err(); - - assert!(format!("{error:#}").contains("invalid transfer recipient address")); - } - - #[test] - fn mine_rejects_invalid_recipient_address_before_pow() { - let ledger = Ledger::new(BTreeMap::new(), 1); - - let error = ledger.build_mine("aa").unwrap_err(); - - assert!(format!("{error:#}").contains("invalid mine recipient address")); - } - - #[test] - fn mine_search_respects_nonce_attempt_limit() { - let alice = Wallet::from_seed("bounded-mine-search-alice"); - let ledger = Ledger::new(BTreeMap::new(), 1); - - let outcome = ledger.search_mine(alice.address(), 1, 0, 0).unwrap(); - - assert!(outcome.transaction.is_none()); - assert_eq!(outcome.next_nonce, 0); - assert_eq!(outcome.attempts, 0); - } - - #[test] - fn mempool_rejects_invalid_input_outpoint_id() { - let alice = Wallet::from_seed("invalid-outpoint-alice"); - let bob = Wallet::from_seed("invalid-outpoint-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[10]); - let unsigned = UnsignedUtxoTransaction::Transfer { - inputs: vec![UnsignedTxInput { - outpoint: OutPoint { - txid: "aa".to_string(), - index: 0, - }, - owner: alice.address().to_string(), - }], - outputs: vec![TxOutput { - address: bob.address().to_string(), - amount: 1, - }], - fee: 0, - }; - let transaction = unsigned.sign(&alice); - - let error = ledger.submit_transaction(transaction).unwrap_err(); - - assert!(format!("{error:#}").contains("invalid input outpoint txid")); - assert!(ledger.pending().is_empty()); - } - - #[test] - fn missing_input_transaction_goes_to_orphan_pool_not_pending_mempool() { - let alice = Wallet::from_seed("missing-input-orphan-alice"); - let bob = Wallet::from_seed("missing-input-orphan-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[10]); - let transaction = UnsignedUtxoTransaction::Transfer { - inputs: vec![UnsignedTxInput { - outpoint: OutPoint { - txid: hex_hash("missing-input-orphan"), - index: 0, - }, - owner: alice.address().to_string(), - }], - outputs: vec![TxOutput { - address: bob.address().to_string(), - amount: 1, - }], - fee: 0, - } - .sign(&alice); - - let outcome = ledger.submit_transaction_with_outcome(transaction).unwrap(); - - assert_eq!(outcome, TransactionSubmitOutcome::Added); - assert!(ledger.pending().is_empty()); - assert_eq!(ledger.orphan_transactions().len(), 1); - } - - #[test] - fn vdf_retarget_observed_block_time_is_clamped() { - assert_eq!( - clamped_vdf_retarget_observed_block_ms(1), - MIN_VDF_RETARGET_OBSERVED_BLOCK_MS - ); - assert_eq!( - clamped_vdf_retarget_observed_block_ms(VDF_TARGET_BLOCK_MS), - VDF_TARGET_BLOCK_MS - ); - assert_eq!( - clamped_vdf_retarget_observed_block_ms(u64::MAX), - MAX_VDF_RETARGET_OBSERVED_BLOCK_MS - ); - } - - #[test] - fn vdf_retarget_observed_block_time_includes_historical_ticket_fallback_ranks() { - let parent = vdf_retarget_sample_block(0, FinalizerMode::Ticket, 0); - let primary_child = - vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS, FinalizerMode::Ticket, 0); - let mut rank_one_child = - vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS * 2, FinalizerMode::Ticket, 1); - rank_one_child.height = FALLBACK_VDF_RETARGET_ACTIVATION_HEIGHT; - let mut rank_two_child = - vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS * 4, FinalizerMode::Ticket, 2); - rank_two_child.height = FALLBACK_VDF_RETARGET_DEACTIVATION_HEIGHT - 1; - - assert_eq!( - vdf_retarget_observed_block_ms(&parent, &primary_child), - Some(VDF_TARGET_BLOCK_MS) - ); - assert_eq!( - vdf_retarget_observed_block_ms(&parent, &rank_one_child), - Some(VDF_TARGET_BLOCK_MS * 2) - ); - assert_eq!( - vdf_retarget_observed_block_ms(&parent, &rank_two_child), - Some(VDF_TARGET_BLOCK_MS * 4) - ); - } - - #[test] - fn vdf_retarget_observed_block_time_ignores_new_ticket_fallback_ranks() { - let parent = vdf_retarget_sample_block(0, FinalizerMode::Ticket, 0); - let mut fallback_child = - vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS * 2, FinalizerMode::Ticket, 1); - fallback_child.height = FALLBACK_VDF_RETARGET_DEACTIVATION_HEIGHT; - - assert_eq!( - vdf_retarget_observed_block_ms(&parent, &fallback_child), - None - ); - } - - #[test] - fn vdf_retarget_observed_block_time_ignores_recovery_blocks() { - let parent = vdf_retarget_sample_block(0, FinalizerMode::Ticket, 0); - let recovery_child = - vdf_retarget_sample_block(RECOVERY_BLOCK_DELAY_MS, FinalizerMode::Recovery, 0); - - assert_eq!( - vdf_retarget_observed_block_ms(&parent, &recovery_child), - None - ); - } - - #[test] - fn vdf_retarget_keeps_rounds_inside_deadband() { - let current = 1_000; - let low_deadband_edge = - VDF_TARGET_BLOCK_MS - VDF_TARGET_BLOCK_MS * VDF_RETARGET_DEADBAND_PERCENT as u64 / 100; - let high_deadband_edge = - VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS * VDF_RETARGET_DEADBAND_PERCENT as u64 / 100; - - assert_eq!(retarget_vdf_rounds(current, low_deadband_edge), current); - assert_eq!(retarget_vdf_rounds(current, VDF_TARGET_BLOCK_MS), current); - assert_eq!(retarget_vdf_rounds(current, high_deadband_edge), current); - } - - #[test] - fn vdf_retarget_limits_each_step_to_two_percent() { - let current = 1_000; - - assert_eq!( - retarget_vdf_rounds(current, MIN_VDF_RETARGET_OBSERVED_BLOCK_MS), - 1_020 - ); - assert_eq!( - retarget_vdf_rounds(current, MAX_VDF_RETARGET_OBSERVED_BLOCK_MS), - 980 - ); - } - - #[test] - fn vdf_rounds_retarget_below_legacy_u32_limit_after_slow_blocks() { - let wallet = Wallet::from_seed("vdf-rounds-slow-above-u32"); - let initial_rounds = u64::from(u32::MAX); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 1_000); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(wallet.address(), 1)], - initial_rounds, - ) - .unwrap(); - - let block1 = apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS); - assert_eq!(block1.vdf_rounds, initial_rounds); - assert_eq!(ledger.vdf_rounds(), initial_rounds); - - let block2 = apply_preverified_burn_block_at( - &mut ledger, - &wallet, - VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS * 2, - ); - assert_eq!(block2.vdf_rounds, initial_rounds); - - assert!( - ledger.vdf_rounds() < initial_rounds, - "slow blocks should retarget below the legacy u32 VDF rounds ceiling" - ); - } - - #[test] - fn fallback_block_before_activation_is_excluded_from_vdf_retarget_observations() { - let alice = Wallet::from_seed("fallback-retarget-alice"); - let bob = Wallet::from_seed("fallback-retarget-bob"); - let wallets = [&alice, &bob]; - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 1_000); - genesis.insert(bob.address().to_string(), 1_000); - let mut ledger = Ledger::new_with_genesis_burns( - genesis, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 100, - ) - .unwrap(); - - let primary = ledger.expected_leader_for_next_block().unwrap(); - let primary_wallet = wallets - .into_iter() - .find(|wallet| wallet.address() == primary) - .unwrap(); - apply_preverified_burn_block_at(&mut ledger, primary_wallet, VDF_TARGET_BLOCK_MS); - assert_eq!(ledger.vdf_rounds(), 100); - - let primary = ledger.expected_leader_for_next_block().unwrap(); - let fallback = wallets - .into_iter() - .find(|wallet| wallet.address() != primary) - .unwrap(); - let timestamp_ms = ledger.tip().timestamp_ms + 1; - let block = apply_preverified_burn_block_at(&mut ledger, fallback, timestamp_ms); - - assert_eq!(block.finalizer_rank, 1); - assert_eq!(block.vdf_rounds, 200); - assert_eq!(ledger.vdf_rounds(), 100); - } - - #[test] - fn recovery_block_is_excluded_from_vdf_retarget_observations() { - let alice = Wallet::from_seed("recovery-retarget-alice"); - let bob = Wallet::from_seed("recovery-retarget-bob"); - let mut genesis = BTreeMap::new(); - genesis.insert(alice.address().to_string(), 1_000); - genesis.insert(bob.address().to_string(), 1_000); - let mut ledger = Ledger::new_with_genesis_burns( - genesis, - vec![GenesisBurn::new(alice.address(), 1)], - 100, - ) - .unwrap(); - - apply_preverified_burn_block_at(&mut ledger, &alice, VDF_TARGET_BLOCK_MS); - assert_eq!(ledger.vdf_rounds(), 100); - - let burn = ledger.build_burn(&bob, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger - .mine_recovery_block(&bob, VDF_TARGET_BLOCK_MS + RECOVERY_BLOCK_DELAY_MS) - .unwrap(); - assert_eq!(block.finalizer_mode, FinalizerMode::Recovery); - ledger.apply_block(block).unwrap(); - - assert_eq!(ledger.vdf_rounds(), 100); - } - - #[test] - fn generated_vdf_retarget_decreases_after_slow_blocks_above_legacy_limit() { - let legacy_limit = u64::from(u32::MAX); - let slow_observed_ms = [ - VDF_TARGET_BLOCK_MS * 6 / 5, - VDF_TARGET_BLOCK_MS * 2, - VDF_TARGET_BLOCK_MS * 3, - MAX_VDF_RETARGET_OBSERVED_BLOCK_MS, - ]; - - for seed in 0..16_u64 { - let wallet = Wallet::from_seed(&format!("generated-vdf-retarget-{seed}")); - let initial_rounds = legacy_limit + 1 + seed * 1_000_003; - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 1_000); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(wallet.address(), 1)], - initial_rounds, - ) - .unwrap(); - let observed_ms = slow_observed_ms[seed as usize % slow_observed_ms.len()]; - - apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS); - let second = apply_preverified_burn_block_at( - &mut ledger, - &wallet, - VDF_TARGET_BLOCK_MS + observed_ms, - ); - - assert_eq!(second.vdf_rounds, initial_rounds); - assert!( - ledger.vdf_rounds() < initial_rounds, - "seed {seed} with observed {observed_ms}ms should lower VDF rounds from {initial_rounds}, got {}", - ledger.vdf_rounds() - ); - - let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let next_work = ledger - .prepare_next_block(wallet.address(), second.timestamp_ms + VDF_TARGET_BLOCK_MS) - .unwrap(); - assert_eq!(next_work.vdf_rounds(), ledger.vdf_rounds()); - } - } - - #[test] - fn block_timestamp_future_check_uses_supplied_network_time() { - let wallet = Wallet::from_seed("adjusted-time-domain"); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 1_000); - let mut ledger = Ledger::new(allocations, 1); - - let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); - assert!(ledger.submit_transaction(burn).unwrap()); - let block = ledger.mine_next_block(&wallet, 10 * 60 * 1_000).unwrap(); - - let error = ledger.apply_block_at(block, 1_000).unwrap_err(); - - assert!(format!("{error:#}").contains("too far in the future")); - } - - #[test] - fn ticket_block_timestamp_uses_finalizer_rank_time_slot() { - let alice = Wallet::from_seed("rank-slot-alice"); - let bob = Wallet::from_seed("rank-slot-bob"); - let wallets = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - allocations.insert(bob.address().to_string(), 1_000); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 100, - ) - .unwrap(); - - let primary = - wallet_for_address(&wallets, &ledger.expected_leader_for_next_block().unwrap()); - let burn = ledger.build_burn(primary, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let work = ledger.prepare_next_block(primary.address(), 1).unwrap(); - assert_eq!(work.timestamp_ms(), 1); - let block = work.finish(primary, "preverified-vdf".to_string()); - ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); - - let fallback = wallets - .iter() - .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1)) - .expect("expected rank 1 fallback"); - let burn = ledger.build_burn(fallback, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let parent_timestamp = ledger.tip().timestamp_ms; - let work = ledger - .prepare_next_block(fallback.address(), parent_timestamp + 1) - .unwrap(); - - assert_eq!( - work.timestamp_ms(), - parent_timestamp + VDF_TARGET_BLOCK_MS * 2 - ); - assert_eq!(work.vdf_rounds(), ledger.vdf_rounds() * 2); - } - - #[test] - fn required_anchor_burn_is_selected_before_higher_fee_burns_when_block_is_full() { - let wallet = Wallet::from_seed("required-anchor-priority-wallet"); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let high_fee_outpoint = named_test_outpoint("required-anchor-priority-high-fee"); - let anchor_outpoint = named_test_outpoint("required-anchor-priority-anchor"); - ledger.utxos.insert( - high_fee_outpoint.clone(), - TxOutput { - address: wallet.address().to_string(), - amount: 3, - }, - ); - ledger.utxos.insert( - anchor_outpoint.clone(), - TxOutput { - address: wallet.address().to_string(), - amount: 2, - }, - ); - let high_fee_burn = ledger - .build_burn_with_inputs(&wallet, 1, 1, &[high_fee_outpoint]) - .unwrap(); - let anchor_burn = ledger - .build_burn_with_inputs(&wallet, 1, 0, &[anchor_outpoint]) - .unwrap(); - ledger.submit_transaction(high_fee_burn).unwrap(); - ledger.submit_transaction(anchor_burn.clone()).unwrap(); - ledger.launch_profile.max_block_transactions = 1; - - let work = ledger - .prepare_next_block_with_required_burn_and_reveal_bundles( - wallet.address(), - 1, - Vec::new(), - Some(anchor_burn.signature()), - ) - .unwrap(); - let block = work.finish(&wallet, "preverified-vdf".to_string()); - - assert_eq!(block.transactions.len(), 1); - assert_eq!(block.transactions[0].signature(), anchor_burn.signature()); - } - - #[test] - fn late_ticket_vdf_completion_is_visible_to_retarget() { - let wallet = Wallet::from_seed("late-ticket-vdf-wallet"); - let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 1_000); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(wallet.address(), 1)], - 100, - ) - .unwrap(); - - apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS); - assert_eq!(ledger.vdf_rounds(), 100); - - let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let work = ledger - .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) - .unwrap(); - let scheduled_timestamp = work.timestamp_ms(); - let late_timestamp = ledger.tip().timestamp_ms + VDF_TARGET_BLOCK_MS * 3; - assert!(late_timestamp > scheduled_timestamp); - - let block = work.finish_at(&wallet, "preverified-vdf".to_string(), late_timestamp); - assert_eq!(block.timestamp_ms, late_timestamp); - ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); - - assert!( - ledger.vdf_rounds() < 100, - "late VDF completion should lower future VDF rounds" - ); - } - - #[test] - fn block_before_finalizer_rank_time_slot_is_rejected() { - let alice = Wallet::from_seed("rank-slot-reject-alice"); - let bob = Wallet::from_seed("rank-slot-reject-bob"); - let wallets = [alice.clone(), bob.clone()]; - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1_000); - allocations.insert(bob.address().to_string(), 1_000); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![ - GenesisBurn::new(alice.address(), 1), - GenesisBurn::new(bob.address(), 1), - ], - 100, - ) - .unwrap(); - - let primary = - wallet_for_address(&wallets, &ledger.expected_leader_for_next_block().unwrap()); - let burn = ledger.build_burn(primary, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let work = ledger.prepare_next_block(primary.address(), 1).unwrap(); - let block = work.finish(primary, "preverified-vdf".to_string()); - ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); - - let fallback = wallets - .iter() - .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1)) - .expect("expected rank 1 fallback"); - let burn = ledger.build_burn(fallback, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let parent_timestamp = ledger.tip().timestamp_ms; - let work = ledger - .prepare_next_block(fallback.address(), parent_timestamp + 1) - .unwrap(); - let mut block = work.finish(fallback, "preverified-vdf".to_string()); - block.timestamp_ms = parent_timestamp + VDF_TARGET_BLOCK_MS * 2 - 1; - block.hash = block.compute_hash(); - - let error = ledger - .apply_preverified_block_at(block, u64::MAX) - .unwrap_err(); - - assert!(format!("{error:#}").contains("before finalizer rank 1 time slot")); - } - - #[test] - fn miner_skips_oversized_pending_transaction_and_keeps_fitting_fee_transaction() { - let alice = Wallet::from_seed("oversized-select-alice"); - let bob = Wallet::from_seed("oversized-select-bob"); - let carol = Wallet::from_seed("oversized-select-carol"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 1); - allocations.insert(bob.address().to_string(), 300_000); - allocations.insert(carol.address().to_string(), 300_000); - let mut ledger = Ledger::new_with_genesis_burns( - allocations, - vec![GenesisBurn::new(alice.address(), 1)], - 10, - ) - .unwrap(); - let burn = ledger.build_burn(&alice, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let oversized = - transfer_with_extra_zero_outputs(&ledger, &bob, alice.address(), 1, 100_000, 4_000); - let fitting = ledger - .build_transfer(&carol, alice.address(), 1, 5) - .unwrap(); - assert!(oversized.serialized_size_bytes().unwrap() > MAX_BLOCK_BYTES); - ledger.submit_transaction(oversized.clone()).unwrap(); - ledger.submit_transaction(fitting.clone()).unwrap(); - - let block = ledger.mine_next_block(&alice, 1).unwrap(); - let signatures = block - .transactions - .iter() - .map(|tx| tx.signature().to_string()) - .collect::<Vec<_>>(); - - assert!(!signatures.contains(&oversized.signature().to_string())); - assert!(signatures.contains(&fitting.signature().to_string())); - assert!(block.serialized_size_bytes().unwrap() <= MAX_BLOCK_BYTES); - } - - #[test] - fn transfer_can_spend_selected_utxos_when_they_cover_amount_and_fee() { - let alice = Wallet::from_seed("selected-utxos-alice"); - let bob = Wallet::from_seed("selected-utxos-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]); - let selected = vec![test_utxo_outpoint(2)]; - - let tx = ledger - .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected) - .unwrap(); - - let Transaction::Transfer { - inputs, outputs, .. - } = &tx - else { - panic!("expected transfer"); - }; - assert_eq!(inputs.len(), 1); - assert_eq!(inputs[0].outpoint, selected[0]); - assert_eq!( - outputs, - &[ - TxOutput { - address: bob.address().to_string(), - amount: 2 - }, - TxOutput { - address: alice.address().to_string(), - amount: 2 - } - ] - ); - - ledger.submit_transaction(tx).unwrap(); - let balances = pending_balances(&ledger); - assert_eq!(balances.get(bob.address()).copied(), Some(2)); - assert_eq!(balances.get(alice.address()).copied(), Some(7)); - } - - #[test] - fn burn_can_spend_selected_utxos_when_they_cover_amount_and_fee() { - let alice = Wallet::from_seed("selected-burn-utxos-alice"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]); - let selected = vec![test_utxo_outpoint(1)]; - - let tx = ledger - .build_burn_with_inputs(&alice, 1, 1, &selected) - .unwrap(); - - let Transaction::Burn { - inputs, - change, - amount, - fee, - .. - } = &tx - else { - panic!("expected burn"); - }; - assert_eq!(*amount, 1); - assert_eq!(*fee, 1); - assert_eq!(inputs.len(), 1); - assert_eq!(inputs[0].outpoint, selected[0]); - assert_eq!( - change, - &[TxOutput { - address: alice.address().to_string(), - amount: 1 - }] - ); - - ledger.submit_transaction(tx).unwrap(); - let balances = pending_balances(&ledger); - assert_eq!(balances.get(alice.address()).copied(), Some(8)); - } - - #[test] - fn transfer_rejects_selected_utxos_that_do_not_cover_amount_plus_fee() { - let alice = Wallet::from_seed("selected-utxos-insufficient-alice"); - let bob = Wallet::from_seed("selected-utxos-insufficient-bob"); - let ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]); - let selected = vec![test_utxo_outpoint(0)]; - - let error = ledger - .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected) - .unwrap_err(); - - assert!(format!("{error:#}").contains("selected UTXOs do not cover")); - } - - #[test] - fn transfer_rejects_selected_utxos_owned_by_someone_else() { - let alice = Wallet::from_seed("selected-utxos-owner-alice"); - let bob = Wallet::from_seed("selected-utxos-owner-bob"); - let carol = Wallet::from_seed("selected-utxos-owner-carol"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[5]); - ledger.utxos.insert( - named_test_outpoint("carol"), - TxOutput { - address: carol.address().to_string(), - amount: 5, - }, - ); - let selected = vec![named_test_outpoint("carol")]; - - let error = ledger - .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected) - .unwrap_err(); - - assert!(format!("{error:#}").contains("is not owned")); - } - - #[test] - fn transfer_rejects_when_combined_utxos_do_not_cover_amount_plus_fee() { - let alice = Wallet::from_seed("combine-insufficient-alice"); - let bob = Wallet::from_seed("combine-insufficient-bob"); - let ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 1]); - - let error = ledger - .build_transfer(&alice, bob.address(), 3, 1) - .unwrap_err(); - - assert!(format!("{error:#}").contains("insufficient funds")); - } - - #[test] - fn pending_change_from_combined_utxos_can_fund_next_transaction() { - let alice = Wallet::from_seed("combine-pending-change-alice"); - let bob = Wallet::from_seed("combine-pending-change-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 2]); - - let first = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); - let first_signature = first.signature().to_string(); - ledger.submit_transaction(first).unwrap(); - - let second = ledger.build_transfer(&alice, bob.address(), 1, 0).unwrap(); - let Transaction::Transfer { inputs, .. } = &second else { - panic!("expected transfer"); - }; - assert_eq!(inputs.len(), 1); - assert_eq!(inputs[0].outpoint.txid, first_signature); - assert_eq!(inputs[0].outpoint.index, 1); - - ledger.submit_transaction(second).unwrap(); - let balances = pending_balances(&ledger); - assert_eq!( - balances.get(alice.address()).copied().unwrap_or_default(), - 0 - ); - assert_eq!(balances.get(bob.address()).copied(), Some(4)); - } - - #[test] - fn winning_burn_ticket_is_consumed_even_when_window_remains() { - let mut tickets = vec![ - BurnTicket { - id: "high-burn".to_string(), - owner: "alice".to_string(), - amount: 10_000, - eligible_from_height: 4, - eligible_until_height: 6, - }, - BurnTicket { - id: "small-burn".to_string(), - owner: "bob".to_string(), - amount: 1, - eligible_from_height: 5, - eligible_until_height: 7, - }, - ]; - let block = Block::new(BlockDraft { - height: 4, - prev_hash: "0".repeat(64), - timestamp_ms: 1, - miner: "alice".to_string(), - finalizer_mode: FinalizerMode::Ticket, - finalizer_rank: 0, - reward: BLOCK_REWARD, - vdf_rounds: 1, - vdf_output: "vdf".to_string(), - leader_proof: Some(LeaderProof { - ticket_id: "high-burn".to_string(), - public_key: "alice".to_string(), - signature: "signature".to_string(), - }), - blinded_transactions: Vec::new(), - reveal_bundle_section: RevealBundleSection::default(), - transactions: Vec::new(), - }); - - consume_leader_ticket(&block, &mut tickets).unwrap(); - - assert!( - tickets.iter().all(|ticket| ticket.id != "high-burn"), - "a winning burn must not remain eligible for the rest of its window" - ); - assert!( - tickets.iter().any(|ticket| ticket.id == "small-burn"), - "unselected future tickets should remain pending" - ); - } - - #[test] - fn burn_leader_ranks_for_block_reconstructs_historical_ticket_order() { - let alice = Wallet::from_seed("burn-rank-alice"); - let bob = Wallet::from_seed("burn-rank-bob"); - let mut allocations = BTreeMap::new(); - allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); - allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - allocations, - vec![ - GenesisBurn::new(alice.address(), MICRO_IUNA), - GenesisBurn::new(bob.address(), MICRO_IUNA), - ], - 1, - ) - .unwrap(); - - let ranks = ledger.burn_leader_ranks_for_block(1).unwrap(); - let leader = ledger.expected_leader_for_next_block().unwrap(); - - assert_eq!(ranks.len(), 2); - assert_eq!(ranks[0].rank, 0); - assert_eq!(ranks[0].owner, leader); - assert!(ranks.iter().all(|rank| rank.amount == MICRO_IUNA)); - assert_eq!(ledger.burn_leader_ranks_for_block(0).unwrap(), Vec::new()); - } - - #[test] - fn mine_recipient_is_bound_to_proof_hash() { - let alice = Wallet::from_seed("mine-proof-alice"); - let bob = Wallet::from_seed("mine-proof-bob"); - let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - let mut forged = unsigned_mine(&ledger, alice.address()); - if let Transaction::Mine { recipient, .. } = &mut forged { - *recipient = bob.address().to_string(); - } - - let error = ledger.submit_transaction(forged).unwrap_err(); - - assert!(format!("{error:#}").contains("proof hash is invalid")); - } - - #[test] - fn mine_action_uses_fixed_reward_and_fixed_finalizer_fee() { - let alice = Wallet::from_seed("mine-fixed-reward-alice"); - let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - - let mine = ledger.build_mine(alice.address()).unwrap(); - - assert_eq!(mine.amount(), MINE_REWARD); - assert_eq!(mine.fee(), MINE_FINALIZER_FEE); - assert!(ledger.submit_transaction(mine).unwrap()); - } - - #[test] - fn burn_fee_goes_to_block_finalizer() { - let alice = Wallet::from_seed("burn-fee-finalizer-alice"); - let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - - let burn_fee = 12; - let burn = ledger - .build_burn(&alice, TEST_BURN_AMOUNT, burn_fee) - .unwrap(); - ledger.submit_transaction(burn).unwrap(); - let prepared = ledger.prepare_next_block(alice.address(), 1).unwrap(); - - assert_eq!(prepared.reward, burn_fee); - } - - #[test] - fn blinded_burn_commits_ciphertext_and_reveal_executes_later() { - let alice = Wallet::from_seed("blinded-burn-finalizer-alice"); - let bob = Wallet::from_seed("blinded-burn-finalizer-bob"); - let carol = Wallet::from_seed("blinded-burn-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let fee = 100; - let burn_amount = 3; - let before_carol = ledger.balance_of(carol.address()); - - let blinded = ledger - .build_blinded_burn(&carol, burn_amount, fee, ledger.height() + 4) - .unwrap(); - assert!(!blinded.transaction.ciphertext.contains("burn")); - assert!(!blinded.transaction.ciphertext.contains(carol.address())); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - - let commit_block = mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - let inclusion_finalizer = commit_block.miner.clone(); - assert_eq!( - commit_block - .transactions - .iter() - .filter(|transaction| transaction.is_burn()) - .count(), - 1 - ); - assert_eq!(commit_block.blinded_transactions, vec![blinded.transaction]); - assert_eq!(commit_block.reward, 0); - let before_inclusion_finalizer = ledger.balance_of(&inclusion_finalizer); - - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - let reveal_block = - mine_preverified_as_next_leader_with_reveal_bundles(&mut ledger, &finalizers, 2); - let reveal_executor = reveal_block.miner.clone(); - - assert_eq!(reveal_block.all_blinded_reveals().len(), 1); - assert_eq!( - 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() - .filter(|transaction| { - transaction.is_burn() && transaction.sender() == inclusion_finalizer.as_str() - }) - .fold(0_u64, |total, transaction| { - total + transaction.amount() + transaction.fee() - }); - let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); - let reveal_finalizer_fee = blinded_reveal_finalizer_fee( - fee, - reveal_block.included_reveal_bundle_count(), - ledger - .burn_leader_ranks_for_block(reveal_block.height) - .unwrap() - .len(), - ); - let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); - let commitment = &commit_block.blinded_transactions[0].commitment; - assert_eq!( - ledger - .utxos - .get(&blinded_committer_fee_outpoint(commitment)) - .unwrap(), - &TxOutput { - address: inclusion_finalizer.clone(), - amount: committer_fee, - } - ); - assert_eq!( - ledger - .utxos - .get(&blinded_executor_fee_outpoint(commitment)) - .unwrap(), - &TxOutput { - address: reveal_executor.clone(), - amount: reveal_finalizer_fee, - } - ); - for signature in &reveal_block.reveal_bundle_section.signatures { - assert_eq!( - ledger - .utxos - .get(&blinded_reveal_bundle_signer_fee_outpoint( - commitment, - signature.slot - )) - .unwrap(), - &TxOutput { - address: signature.member.clone(), - amount: reveal_bundle_signer_fee, - } - ); - } - let mut inclusion_finalizer_fee = committer_fee; - if inclusion_finalizer == reveal_executor { - inclusion_finalizer_fee += reveal_finalizer_fee; - } - inclusion_finalizer_fee += reveal_block - .reveal_bundle_section - .signatures - .iter() - .filter(|signature| signature.member == inclusion_finalizer) - .count() as u64 - * reveal_bundle_signer_fee; - assert_eq!( - ledger.balance_of(&inclusion_finalizer), - before_inclusion_finalizer + inclusion_finalizer_fee - - reveal_plaintext_burn_spent_by_inclusion_finalizer - ); - } - - #[test] - fn activated_blinded_reveal_finalizer_fees_are_aggregated_into_block_reward() { - let alice = Wallet::from_seed("activated-finalizer-fee-alice"); - let bob = Wallet::from_seed("activated-finalizer-fee-bob"); - let carol = Wallet::from_seed("activated-finalizer-fee-carol"); - let dave = Wallet::from_seed("activated-finalizer-fee-dave"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers( - &finalizers, - &[(&carol, 10 * MICRO_IUNA), (&dave, 10 * MICRO_IUNA)], - ); - advance_preverified_to_height( - &mut ledger, - &finalizers, - AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 2, - ); - let first_fee = 100; - let second_fee = 200; - let first_blinded = ledger - .build_blinded_burn(&carol, 3, first_fee, ledger.height() + 4) - .unwrap(); - let second_blinded = ledger - .build_blinded_burn(&dave, 4, second_fee, ledger.height() + 4) - .unwrap(); - let first_commitment = first_blinded.transaction.commitment.clone(); - let second_commitment = second_blinded.transaction.commitment.clone(); - ledger - .submit_blinded_transaction(first_blinded.transaction) - .unwrap(); - ledger - .submit_blinded_transaction(second_blinded.transaction) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - let commit_timestamp_ms = ledger - .tip() - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS); - let commit_block = - mine_preverified_as_next_leader(&mut ledger, &finalizers, commit_timestamp_ms); - assert_eq!( - commit_block.height, - AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 1 - ); - - ledger.submit_blinded_reveal(first_blinded.reveal).unwrap(); - ledger.submit_blinded_reveal(second_blinded.reveal).unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - let reveal_timestamp_ms = ledger - .tip() - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS); - let reveal_block = prepare_preverified_as_next_leader_with_reveal_bundles( - &ledger, - &finalizers, - reveal_timestamp_ms, - ); - let first_reveal_finalizer_fee = blinded_reveal_finalizer_fee( - first_fee, - reveal_block.included_reveal_bundle_count(), - ledger - .burn_leader_ranks_for_block(reveal_block.height) - .unwrap() - .len(), - ); - let second_reveal_finalizer_fee = blinded_reveal_finalizer_fee( - second_fee, - reveal_block.included_reveal_bundle_count(), - ledger - .burn_leader_ranks_for_block(reveal_block.height) - .unwrap() - .len(), - ); - let aggregate_reveal_finalizer_fee = first_reveal_finalizer_fee - .checked_add(second_reveal_finalizer_fee) - .unwrap(); - - assert_eq!( - reveal_block.height, - AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - ); - assert_eq!(reveal_block.reward, aggregate_reveal_finalizer_fee); - let mut legacy_reward_block = reveal_block.clone(); - legacy_reward_block.reward = fee_reward(&legacy_reward_block.transactions).unwrap(); - legacy_reward_block.hash = legacy_reward_block.compute_hash(); - let error = ledger - .clone() - .apply_preverified_block_at(legacy_reward_block, u64::MAX) - .unwrap_err(); - assert!(format!("{error:#}").contains("block reward is invalid")); - - ledger - .apply_preverified_block_at(reveal_block.clone(), u64::MAX) - .unwrap(); - assert!( - !ledger - .utxos - .contains_key(&blinded_executor_fee_outpoint(&first_commitment)) - ); - assert!( - !ledger - .utxos - .contains_key(&blinded_executor_fee_outpoint(&second_commitment)) - ); - assert_eq!( - ledger.utxos.get(&reward_outpoint(&reveal_block.hash)), - Some(&TxOutput { - address: reveal_block.miner.clone(), - amount: aggregate_reveal_finalizer_fee, - }) - ); - } - - #[test] - fn pre_activation_blinded_reveal_finalizer_fee_stays_as_executor_utxo_at_boundary() { - let alice = Wallet::from_seed("pre-activated-finalizer-fee-alice"); - let bob = Wallet::from_seed("pre-activated-finalizer-fee-bob"); - let carol = Wallet::from_seed("pre-activated-finalizer-fee-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - advance_preverified_to_height( - &mut ledger, - &finalizers, - AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 3, - ); - let fee = 100; - let blinded = ledger - .build_blinded_burn(&carol, 3, fee, ledger.height() + 4) - .unwrap(); - let commitment = blinded.transaction.commitment.clone(); - ledger - .submit_blinded_transaction(blinded.transaction) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - let commit_timestamp_ms = ledger - .tip() - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS); - let commit_block = - mine_preverified_as_next_leader(&mut ledger, &finalizers, commit_timestamp_ms); - assert_eq!( - commit_block.height, - AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 2 - ); - - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - let reveal_timestamp_ms = ledger - .tip() - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS); - let reveal_block = mine_preverified_as_next_leader_with_reveal_bundles( - &mut ledger, - &finalizers, - reveal_timestamp_ms, - ); - let reveal_finalizer_fee = blinded_reveal_finalizer_fee( - fee, - reveal_block.included_reveal_bundle_count(), - ledger - .burn_leader_ranks_for_block(reveal_block.height) - .unwrap() - .len(), - ); - - assert_eq!( - reveal_block.height, - AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 1 - ); - assert_eq!(reveal_block.reward, 0); - assert_eq!( - ledger - .utxos - .get(&blinded_executor_fee_outpoint(&commitment)), - Some(&TxOutput { - address: reveal_block.miner, - amount: reveal_finalizer_fee, - }) - ); - assert!( - !ledger - .utxos - .contains_key(&reward_outpoint(&reveal_block.hash)) - ); - } - - #[test] - fn blinded_utxo_commit_exposes_and_locks_inputs_until_reveal_or_expiry() { - let alice = Wallet::from_seed("blinded-lock-alice"); - let bob = Wallet::from_seed("blinded-lock-bob"); - let mut ledger = ledger_with_wallet_utxos(&alice, &[10]); - let transfer = ledger.build_transfer(&alice, bob.address(), 3, 2).unwrap(); - let visible_inputs = transfer.inputs().to_vec(); - - let blinded = ledger - .build_blinded_transaction(&alice, transfer, ledger.height() + 4) - .unwrap(); - - assert_eq!(blinded.transaction.inputs.len(), visible_inputs.len()); - assert_eq!( - unsigned_inputs(&blinded.transaction.inputs), - unsigned_inputs(&visible_inputs) - ); - ledger - .submit_blinded_transaction(blinded.transaction) - .unwrap(); - let error = ledger - .build_transfer(&alice, bob.address(), 1, 0) - .unwrap_err(); - assert!(format!("{error:#}").contains("insufficient funds")); - } - - #[test] - fn blinded_payload_omits_visible_inputs_and_reconstructs_transaction_on_reveal() { - let alice = Wallet::from_seed("blinded-compact-payload-alice"); - let bob = Wallet::from_seed("blinded-compact-payload-bob"); - let ledger = ledger_with_wallet_utxos(&alice, &[10]); - let transfer = ledger.build_transfer(&alice, bob.address(), 3, 2).unwrap(); - let full_transaction_bytes = serde_json::to_vec(&transfer).unwrap().len(); - let blinded = ledger - .build_blinded_transaction(&alice, transfer.clone(), ledger.height() + 4) - .unwrap(); - let key = decode_hex_array::<BLINDED_KEY_BYTES>(&blinded.reveal.key).unwrap(); - let nonce = decode_hex_array::<BLINDED_NONCE_BYTES>(&blinded.transaction.nonce).unwrap(); - let ciphertext = decode_hex(&blinded.transaction.ciphertext).unwrap(); - - let plaintext = decrypt_blinded_payload( - &key, - &nonce, - &signed_blinded_inputs(&unsigned_inputs(&blinded.transaction.inputs), ""), - blinded.transaction.fee, - blinded.transaction.expires_at_height, - &ciphertext, - ) - .unwrap(); - let payload: serde_json::Value = serde_json::from_slice(&plaintext).unwrap(); - let revealed = decrypt_blinded_transaction(&blinded.transaction, &blinded.reveal).unwrap(); - - assert_eq!( - payload.get("kind").and_then(|kind| kind.as_str()), - Some("transfer") - ); - assert!(payload.get("inputs").is_none()); - assert!(plaintext.len() < full_transaction_bytes); - assert_eq!(revealed, transfer); - } - - #[test] - fn unrevealed_blinded_utxo_commit_burns_fee_and_returns_change() { - let alice = Wallet::from_seed("blinded-expiry-alice"); - let bob = Wallet::from_seed("blinded-expiry-bob"); - let carol = Wallet::from_seed("blinded-expiry-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let carol_balance = 10 * MICRO_IUNA; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, carol_balance)]); - let fee = 100; - let blinded = ledger - .build_blinded_burn(&carol, 3, fee, ledger.height() + 2) - .unwrap(); - let commitment = blinded.transaction.commitment.clone(); - ledger - .submit_blinded_transaction(blinded.transaction) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - - assert_eq!(ledger.balance_of(carol.address()), 0); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 2); - - assert_eq!( - ledger - .utxos - .get(&blinded_committer_fee_outpoint(&commitment)), - None - ); - assert_eq!( - ledger - .utxos - .get(&blinded_expiry_change_outpoint(&commitment)), - Some(&TxOutput { - address: carol.address().to_string(), - amount: carol_balance - fee, - }) - ); - assert_eq!(ledger.balance_of(carol.address()), carol_balance - fee); - } - - #[test] - fn fee_bearing_blinded_commit_without_inputs_is_rejected() { - let alice = Wallet::from_seed("blinded-no-input-fee-alice"); - let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); - let mut blinded = ledger - .build_blinded_burn(&alice, 1, 1, ledger.height() + 4) - .unwrap() - .transaction; - blinded.inputs.clear(); - blinded.commitment = blinded_transaction_commitment(&blinded).unwrap(); - - let error = ledger.submit_blinded_transaction(blinded).unwrap_err(); - - assert!(format!("{error:#}").contains("must lock visible inputs")); - } - - #[test] - fn mine_actions_cannot_be_blinded() { - let alice = Wallet::from_seed("blinded-mine-collateral-alice"); - let ledger = ledger_with_finalizers(&[alice.clone()], &[]); - let mine = ledger.build_mine(alice.address()).unwrap(); - let error = ledger - .build_blinded_transaction(&alice, mine, ledger.height() + 4) - .unwrap_err(); - - assert!(format!("{error:#}").contains("mine actions are public")); - } - - #[test] - fn reveal_bundle_hashes_are_bound_to_next_block_vdf_seed() { - let alice = Wallet::from_seed("bundle-seed-alice"); - let bob = Wallet::from_seed("bundle-seed-bob"); - let carol = Wallet::from_seed("bundle-seed-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = wallet_for_address(&finalizers, &leader); - let bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - let wallet = wallet_for_address(&finalizers, &member.owner); - ledger.build_reveal_bundle(wallet).unwrap() - }) - .collect::<Vec<_>>(); - if bundles.len() > 1 { - let mut reversed = bundles.clone(); - reversed.reverse(); - let error = ledger - .validate_next_block_reveal_bundles(reversed) - .unwrap_err(); - assert!(format!("{error:#}").contains("reveal bundles are not in slot order")); - } - - let without_bundles = ledger - .prepare_next_block(leader_wallet.address(), ledger.tip().timestamp_ms + 1) - .unwrap(); - let with_bundles = ledger - .prepare_next_block_with_reveal_bundles( - leader_wallet.address(), - ledger.tip().timestamp_ms + 1, - bundles, - ) - .unwrap(); - - assert_ne!(without_bundles.vdf_seed(), with_bundles.vdf_seed()); - } - - #[test] - fn reveal_committee_includes_next_block_finalizer_as_slot_zero() { - let alice = Wallet::from_seed("bundle-finalizer-slot-alice"); - let bob = Wallet::from_seed("bundle-finalizer-slot-bob"); - let carol = Wallet::from_seed("bundle-finalizer-slot-carol"); - let dave = Wallet::from_seed("bundle-finalizer-slot-dave"); - let erin = Wallet::from_seed("bundle-finalizer-slot-erin"); - let finalizers = [alice.clone(), bob.clone(), carol.clone(), dave.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&erin, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&erin, 3, 7, ledger.height() + 4) - .unwrap(); - let commitment = blinded.transaction.commitment.clone(); - ledger - .submit_blinded_transaction(blinded.transaction) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - - let leader = ledger.expected_leader_for_next_block().unwrap(); - let committee = ledger.reveal_committee_for_next_block(); - let leader_wallet = wallet_for_address(&finalizers, &leader); - let bundle = ledger.build_reveal_bundle(leader_wallet).unwrap().unwrap(); - - assert_eq!(committee.first().map(|member| member.slot), Some(0)); - assert_eq!(committee.first().map(|member| member.rank), Some(0)); - assert_eq!( - committee.first().map(|member| member.owner.as_str()), - Some(leader.as_str()) - ); - assert_eq!(bundle.slot, 0); - assert_eq!(bundle.member, leader); - assert!( - bundle - .reveals - .iter() - .any(|reveal| reveal.commitment == commitment) - ); - } - - #[test] - fn reveal_bundle_section_deduplicates_reveals_with_slot_mask() { - let alice = Wallet::from_seed("bundle-compact-alice"); - let bob = Wallet::from_seed("bundle-compact-bob"); - let carol = Wallet::from_seed("bundle-compact-carol"); - let dave = Wallet::from_seed("bundle-compact-dave"); - let finalizers = [alice.clone(), bob.clone(), carol.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&dave, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&dave, 3, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - ledger - .submit_blinded_reveal(blinded.reveal.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - - let mut bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - let wallet = wallet_for_address(&finalizers, &member.owner); - ledger.build_reveal_bundle(wallet).unwrap() - }) - .collect::<Vec<_>>(); - assert!(bundles.len() >= 2); - bundles.truncate(2); - let expected_hashes = reveal_bundle_hashes(&bundles); - let expected_mask = bundles - .iter() - .fold(0_u8, |mask, bundle| mask | (1_u8 << bundle.slot)); - - let leader = ledger.expected_leader_for_next_block().unwrap(); - let leader_wallet = wallet_for_address(&finalizers, &leader); - let prepared = ledger - .prepare_next_block_with_reveal_bundles( - leader_wallet.address(), - ledger.tip().timestamp_ms + 1, - bundles.clone(), - ) - .unwrap(); - let block = prepared.finish(leader_wallet, "preverified-vdf".to_string()); - - assert_eq!(block.reveal_bundle_section.signatures.len(), 2); - assert_eq!(block.reveal_bundle_section.reveals.len(), 1); - assert_eq!( - block.reveal_bundle_section.reveals[0].bundle_mask, - expected_mask - ); - assert_eq!(block.all_blinded_reveals(), vec![&blinded.reveal]); - assert_eq!(block.reveal_bundle_hashes(), expected_hashes); - assert_eq!( - block - .reveal_bundle_section - .expand(block.height, &block.prev_hash), - bundles - ); - } - - #[test] - fn reveal_bundle_validation_rejects_wrong_signature_and_slot() { - let alice = Wallet::from_seed("bundle-invalid-alice"); - let bob = Wallet::from_seed("bundle-invalid-bob"); - let carol = Wallet::from_seed("bundle-invalid-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - let member = ledger.reveal_committee_for_next_block()[0].clone(); - let wallet = wallet_for_address(&finalizers, &member.owner); - let bundle = ledger.build_reveal_bundle(wallet).unwrap().unwrap(); - - let mut wrong_signature = bundle.clone(); - wrong_signature.signature = "00".repeat(SIGNATURE_BYTES); - let error = ledger - .validate_next_block_reveal_bundles(vec![wrong_signature]) - .unwrap_err(); - assert!(format!("{error:#}").contains("reveal bundle signature is invalid")); - - let mut wrong_slot = bundle; - wrong_slot.slot = REVEAL_COMMITTEE_SIZE as u8 - 1; - let error = ledger - .validate_next_block_reveal_bundles(vec![wrong_slot]) - .unwrap_err(); - assert!( - format!("{error:#}").contains("reveal bundle slot is not assigned") - || format!("{error:#}").contains("reveal bundle member is not assigned to slot") - ); - } - - #[test] - fn blinded_reveal_with_wrong_key_is_rejected_in_block() { - let alice = Wallet::from_seed("blinded-wrong-key-finalizer-alice"); - let bob = Wallet::from_seed("blinded-wrong-key-finalizer-bob"); - let carol = Wallet::from_seed("blinded-wrong-key-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(&finalizers, &leader); - let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(filler_burn).unwrap(); - let mut prepared = ledger - .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) - .unwrap(); - let committee_member = ledger.reveal_committee_for_next_block()[0].clone(); - let committee_wallet = wallet_for_address(&finalizers, &committee_member.owner); - let wrong_reveal = BlindedReveal { - commitment: blinded.transaction.commitment, - key: "00".repeat(BLINDED_KEY_BYTES), - }; - let wrong_bundle = committee_wallet.reveal_bundle(RevealBundlePayload { - height: prepared.height, - prev_hash: prepared.prev_hash.clone(), - slot: committee_member.slot, - member: committee_wallet.address().to_string(), - reveals: vec![wrong_reveal], - }); - prepared.reveal_bundle_section = - ledger.reveal_bundle_section_from_bundles(vec![wrong_bundle]); - let block = prepared.finish(wallet, "preverified-vdf".to_string()); - - let error = ledger - .apply_preverified_block_at(block, u64::MAX) - .unwrap_err(); - - assert!(format!("{error:#}").contains("failed to decrypt blinded transaction payload")); - } - - #[test] - fn expired_blinded_reveal_is_not_selected() { - let alice = Wallet::from_seed("blinded-expire-finalizer-alice"); - let bob = Wallet::from_seed("blinded-expire-finalizer-bob"); - let carol = Wallet::from_seed("blinded-expire-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 2) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(&finalizers, &leader); - let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(filler_burn).unwrap(); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 2); - - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - assert!(ledger.valid_pending_blinded_reveals().is_empty()); - } - - #[test] - fn recovery_block_includes_pending_blinded_transactions_when_space_allows() { - let alice = Wallet::from_seed("recovery-blinded-commit-alice"); - let bob = Wallet::from_seed("recovery-blinded-commit-bob"); - let carol = Wallet::from_seed("recovery-blinded-commit-carol"); - let mut ledger = ledger_with_finalizers( - &[alice], - &[(&bob, 10 * MICRO_IUNA), (&carol, 10 * MICRO_IUNA)], - ); - let blinded = ledger - .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(recovery_burn).unwrap(); - - let block = ledger - .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) - .unwrap(); - - assert!( - block - .blinded_transactions - .iter() - .any(|transaction| transaction.commitment == blinded.transaction.commitment) - ); - } - - #[test] - fn recovery_block_size_selection_uses_recovery_skeleton() { - let alice = Wallet::from_seed("recovery-size-commit-alice"); - let bob = Wallet::from_seed("recovery-size-commit-bob"); - let carol = Wallet::from_seed("recovery-size-commit-carol"); - let mut ledger = ledger_with_finalizers( - &[alice], - &[(&bob, 10 * MICRO_IUNA), (&carol, 10 * MICRO_IUNA)], - ); - let blinded = ledger - .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(recovery_burn.clone()).unwrap(); - let recovery_selection = BlockSelection { - transactions: vec![recovery_burn], - blinded_transactions: vec![blinded.transaction.clone()], - }; - let recovery_estimate = - estimated_block_selection_size_bytes(&recovery_selection, true).unwrap(); - let ticket_estimate = - estimated_block_selection_size_bytes(&recovery_selection, false).unwrap(); - assert!(recovery_estimate < ticket_estimate); - - ledger.launch_profile.max_block_bytes = recovery_estimate; - let tight_block = ledger - .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) - .unwrap(); - - assert!( - tight_block - .blinded_transactions - .iter() - .any(|transaction| transaction.commitment == blinded.transaction.commitment) - ); - } - - #[test] - fn recovery_block_includes_pending_blinded_reveals_when_space_allows() { - let alice = Wallet::from_seed("recovery-blinded-reveal-alice"); - let bob = Wallet::from_seed("recovery-blinded-reveal-bob"); - let carol = Wallet::from_seed("recovery-blinded-reveal-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - ledger - .submit_blinded_reveal(blinded.reveal.clone()) - .unwrap(); - let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(recovery_burn).unwrap(); - - let bundles = ledger - .reveal_committee_for_next_block() - .into_iter() - .filter_map(|member| { - let wallet = wallet_for_address(&finalizers, &member.owner); - ledger.build_reveal_bundle(wallet).unwrap() - }) - .collect::<Vec<_>>(); - let prepared = ledger - .prepare_recovery_block_with_reveal_bundles( - bob.address(), - ledger.recovery_block_min_timestamp(), - bundles, - ) - .unwrap(); - let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); - let block = prepared.finish(&bob, vdf_output); - - assert!( - block - .all_blinded_reveals() - .iter() - .any(|reveal| reveal.commitment == blinded.transaction.commitment) - ); - } - - #[test] - fn blinded_transaction_expiring_at_next_height_is_not_selected() { - let alice = Wallet::from_seed("blinded-next-expire-finalizer-alice"); - let bob = Wallet::from_seed("blinded-next-expire-finalizer-bob"); - let carol = Wallet::from_seed("blinded-next-expire-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 1) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction) - .unwrap(); - - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(&finalizers, &leader); - let error = ledger.prepare_next_block(wallet.address(), 1).unwrap_err(); - - assert!(format!("{error:#}").contains("block must include at least one burn transaction")); - } - - #[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"); - let carol = Wallet::from_seed("blinded-no-burn-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - 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 = wallet_for_address(&finalizers, &leader); - let error = ledger.prepare_next_block(wallet.address(), 1).unwrap_err(); - - assert!(format!("{error:#}").contains("block must include at least one burn transaction")); - } - - #[test] - fn revealed_blinded_transaction_cannot_be_included_again() { - let alice = Wallet::from_seed("blinded-duplicate-finalizer-alice"); - let bob = Wallet::from_seed("blinded-duplicate-finalizer-bob"); - let carol = Wallet::from_seed("blinded-duplicate-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 6) - .unwrap(); - ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - ledger - .submit_blinded_reveal(blinded.reveal.clone()) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader_with_reveal_bundles(&mut ledger, &finalizers, 2); - - let leader = ledger.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(&finalizers, &leader); - let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(filler_burn).unwrap(); - let mut prepared = ledger - .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) - .unwrap(); - prepared - .blinded_transactions - .push(blinded.transaction.clone()); - let block = prepared.finish(wallet, "preverified-vdf".to_string()); - - let error = ledger - .apply_preverified_block_at(block, u64::MAX) - .unwrap_err(); - - assert!(format!("{error:#}").contains("blinded transaction is already on chain")); - } - - #[test] - fn pending_blinded_reveals_expose_revealed_transaction_data() { - let alice = Wallet::from_seed("pending-reveal-data-finalizer-alice"); - let bob = Wallet::from_seed("pending-reveal-data-finalizer-bob"); - let carol = Wallet::from_seed("pending-reveal-data-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let blinded = ledger - .build_blinded_burn(&carol, 3, 7, ledger.height() + 6) - .unwrap(); - let commitment = blinded.transaction.commitment.clone(); - ledger - .submit_blinded_transaction(blinded.transaction) - .unwrap(); - queue_next_leader_burn(&mut ledger, &finalizers); - mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); - - ledger.submit_blinded_reveal(blinded.reveal).unwrap(); - - let revealed = ledger.pending_revealed_blinded_transactions(); - assert_eq!(revealed.len(), 1); - assert_eq!(revealed[0].commitment, commitment); - assert_eq!(revealed[0].height, ledger.height() + 1); - assert_eq!(revealed[0].transaction.amount(), 3); - assert_eq!(revealed[0].transaction.fee(), 7); - assert!(revealed[0].transaction.is_burn()); - } - - #[test] - fn abandoned_fork_blinded_transactions_return_to_mempool() { - let alice = Wallet::from_seed("blinded-reorg-finalizer-alice"); - let bob = Wallet::from_seed("blinded-reorg-finalizer-bob"); - let carol = Wallet::from_seed("blinded-reorg-carol"); - let finalizers = [alice.clone(), bob.clone()]; - let mut local = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); - let mut remote = local.clone(); - let blinded = local - .build_blinded_burn(&carol, 3, 7, local.height() + 8) - .unwrap(); - local - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - queue_next_leader_burn(&mut local, &finalizers); - mine_preverified_as_next_leader(&mut local, &finalizers, 1); - - for timestamp_ms in [1, 2] { - let leader = remote.expected_leader_for_next_block().unwrap(); - let wallet = wallet_for_address(&finalizers, &leader); - let burn = remote.build_burn(wallet, 1, 0).unwrap(); - remote.submit_transaction(burn).unwrap(); - mine_preverified_as_next_leader(&mut remote, &finalizers, timestamp_ms); - } - - assert!( - local - .extend_from_preverified_snapshot_at(remote.snapshot(), u64::MAX) - .unwrap() - ); - assert!(local.has_blinded_transaction(&blinded.transaction.commitment)); - assert_eq!( - local.pending_blinded_transactions(), - std::slice::from_ref(&blinded.transaction) - ); - } - - #[test] - fn block_selection_includes_mine_action_after_required_block_burn() { - let alice = Wallet::from_seed("mine-fixed-reward-select-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - - let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let mine = ledger.build_mine(alice.address()).unwrap(); - ledger.submit_transaction(mine.clone()).unwrap(); - - let block = ledger.mine_next_block(&alice, 1).unwrap(); - - assert_eq!( - block.transactions.iter().filter(|tx| tx.is_burn()).count(), - 1 - ); - assert!( - block - .transactions - .iter() - .any(|tx| tx.signature() == mine.signature()) - ); - assert_eq!(block.reward, MINE_FINALIZER_FEE); - } - - #[test] - fn block_selection_can_skip_mine_action_when_space_is_limited() { - let alice = Wallet::from_seed("mine-space-limit-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - ledger.launch_profile.max_block_transactions = 2; - - let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let first_mine = ledger.build_mine(alice.address()).unwrap(); - ledger.submit_transaction(first_mine).unwrap(); - let second_mine = ledger.build_mine(alice.address()).unwrap(); - ledger.submit_transaction(second_mine).unwrap(); - - let block = ledger.mine_next_block(&alice, 1).unwrap(); - - assert_eq!(block.transactions.len(), 2); - assert!(block.transactions.iter().any(Transaction::is_burn)); - assert_eq!(block.reward, MINE_FINALIZER_FEE); - } - - #[test] - fn pending_mine_outputs_are_not_spendable_until_confirmed() { - let alice = Wallet::from_seed("pending-mine-spend-alice"); - let bob = Wallet::from_seed("pending-mine-spend-bob"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - - let mine = ledger.build_mine(alice.address()).unwrap(); - let mine_outpoint = OutPoint { - txid: mine.signature().to_string(), - index: 0, - }; - ledger.submit_transaction(mine.clone()).unwrap(); - - assert!( - !ledger - .available_utxos_for_address(alice.address()) - .unwrap() - .iter() - .any(|(outpoint, _)| outpoint == &mine_outpoint) - ); - let pending_error = ledger - .build_transfer_with_inputs( - &alice, - bob.address(), - TEST_BURN_AMOUNT, - 0, - std::slice::from_ref(&mine_outpoint), - ) - .unwrap_err(); - assert!(format!("{pending_error:#}").contains("not spendable")); - - let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert!( - block - .transactions - .iter() - .any(|tx| tx.signature() == mine.signature()) - ); - ledger.apply_locally_mined_block(block).unwrap(); - - assert!( - ledger - .available_utxos_for_address(alice.address()) - .unwrap() - .iter() - .any(|(outpoint, _)| outpoint == &mine_outpoint) - ); - ledger - .build_transfer_with_inputs( - &alice, - bob.address(), - TEST_BURN_AMOUNT, - 0, - std::slice::from_ref(&mine_outpoint), - ) - .unwrap(); - } - - #[test] - fn burns_built_after_pending_mine_do_not_spend_pending_mine_output() { - let alice = Wallet::from_seed("pending-mine-burn-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - - let mine = ledger.build_mine(alice.address()).unwrap(); - let mine_outpoint = OutPoint { - txid: mine.signature().to_string(), - index: 0, - }; - ledger.submit_transaction(mine).unwrap(); - - let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap(); - - let Transaction::Burn { inputs, .. } = &burn else { - panic!("expected burn transaction"); - }; - assert!(!inputs.iter().any(|input| input.outpoint == mine_outpoint)); - } - - #[test] - fn pending_blinded_transactions_with_spent_inputs_are_pruned_after_block_apply() { - let alice = Wallet::from_seed("pending-blind-spent-prune-alice"); - let mut mempool_ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - let mut block_ledger = mempool_ledger.clone(); - let amount = mempool_ledger.balance_of(alice.address()); - let blinded = mempool_ledger - .build_blinded_burn(&alice, amount, 0, mempool_ledger.height() + 4) - .unwrap(); - mempool_ledger - .submit_blinded_transaction(blinded.transaction.clone()) - .unwrap(); - - let burn = block_ledger.build_burn(&alice, amount, 0).unwrap(); - let Transaction::Burn { inputs, .. } = &burn else { - panic!("expected burn transaction"); - }; - assert!(blinded.transaction.inputs.iter().any(|input| { - inputs - .iter() - .any(|burn_input| burn_input.outpoint == input.outpoint) - })); - block_ledger.submit_transaction(burn).unwrap(); - let block = block_ledger.mine_next_block(&alice, 1).unwrap(); - - mempool_ledger.apply_block(block).unwrap(); - - assert!(mempool_ledger.pending_blinded_transactions().is_empty()); - } - - #[test] - fn block_selection_limits_mine_actions_per_anchor() { - let alice = Wallet::from_seed("mine-anchor-limit-selection-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - - let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let first_mine = ledger.build_mine(alice.address()).unwrap(); - ledger.submit_transaction(first_mine.clone()).unwrap(); - let second_mine = ledger.build_mine(alice.address()).unwrap(); - ledger.submit_transaction(second_mine.clone()).unwrap(); - let third_mine = ledger.build_mine(alice.address()).unwrap(); - ledger.submit_transaction(third_mine.clone()).unwrap(); - - assert_eq!(ledger.pending().len(), 4); - let block = ledger.mine_next_block(&alice, 1).unwrap(); - - assert_eq!(block.transactions.len(), 3); - assert!(block.transactions.iter().any(Transaction::is_burn)); - let included_mines = block - .transactions - .iter() - .filter(|transaction| matches!(transaction, Transaction::Mine { .. })) - .count(); - assert_eq!(included_mines, MINE_ACTIONS_PER_ANCHOR_LIMIT); - assert!( - block - .transactions - .iter() - .any(|tx| tx.signature() == first_mine.signature()) - ); - assert!( - block - .transactions - .iter() - .any(|tx| tx.signature() == second_mine.signature()) - ); - assert!( - !block - .transactions - .iter() - .any(|tx| tx.signature() == third_mine.signature()) - ); - assert_ne!(first_mine.signature(), second_mine.signature()); - assert_ne!(second_mine.signature(), third_mine.signature()); - assert_eq!(block.reward, first_mine.fee() + second_mine.fee()); - } - - #[test] - fn pre_activation_block_may_keep_multiple_mine_actions_for_one_anchor() { - let alice = Wallet::from_seed("mine-anchor-limit-pre-activation-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - assert!( - ledger.height().saturating_add(1) < MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT - ); - - let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); - let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); - let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let mut block = ledger - .prepare_next_block(alice.address(), 1) - .unwrap() - .finish(&alice, "preverified-vdf".to_string()); - block.transactions.push(first_mine); - block.transactions.push(second_mine); - block.reward = fee_reward(&block.transactions).unwrap(); - block.hash = block.compute_hash(); - - ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); - } - - #[test] - fn activated_blocks_reject_too_many_mine_actions_for_one_anchor() { - let alice = Wallet::from_seed("mine-anchor-limit-active-block-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - advance_to_mine_anchor_limit_activation_parent(&mut ledger, &alice); - - let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); - let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); - let third_mine = test_mine_with_salt(&ledger, alice.address(), 3); - let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let mut block = ledger - .prepare_next_block( - alice.address(), - ledger - .tip() - .timestamp_ms - .saturating_add(VDF_TARGET_BLOCK_MS), - ) - .unwrap() - .finish(&alice, "preverified-vdf".to_string()); - block.transactions.push(first_mine); - block.transactions.push(second_mine); - block.transactions.push(third_mine); - block.reward = fee_reward(&block.transactions).unwrap(); - block.hash = block.compute_hash(); - - let error = ledger - .apply_preverified_block_at(block, u64::MAX) - .unwrap_err(); - - assert!(format!("{error:#}").contains("mine actions per anchor limit")); - } - - #[test] - fn activated_mempool_rejects_mine_actions_above_anchor_limit() { - let alice = Wallet::from_seed("mine-anchor-limit-active-mempool-alice"); - let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - advance_to_mine_anchor_limit_activation_parent(&mut ledger, &alice); - - let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); - let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); - let third_mine = test_mine_with_salt(&ledger, alice.address(), 3); - ledger.submit_transaction(first_mine).unwrap(); - ledger.submit_transaction(second_mine).unwrap(); - let error = ledger.submit_transaction(third_mine).unwrap_err(); - - assert!(format!("{error:#}").contains("mine transaction anchor limit reached")); - } - - #[test] - fn mine_difficulty_increases_when_issuance_exceeds_target_window() { - let alice = Wallet::from_seed("mine-difficulty-up-alice"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); - - for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { - apply_preverified_burn_block_with_mines(&mut ledger, &alice, 2); - } - - assert_eq!( - ledger.current_mine_difficulty_bits(), - MINE_DIFFICULTY_BITS + 1 - ); - let mine = ledger.build_mine(alice.address()).unwrap(); - let Transaction::Mine { - difficulty_bits, .. - } = mine - else { - panic!("expected mine action"); - }; - assert_eq!(difficulty_bits, MINE_DIFFICULTY_BITS + 1); - } - - #[test] - fn mine_difficulty_decreases_when_issuance_is_below_target_window() { - let alice = Wallet::from_seed("mine-difficulty-down-alice"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); - - for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { - mine_burn_block_with_mines(&mut ledger, &alice, 0); - } - - assert_eq!( - ledger.current_mine_difficulty_bits(), - MINE_DIFFICULTY_BITS - MINE_MAX_RETARGET_STEP_BITS - ); - - for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { - mine_burn_block_with_mines(&mut ledger, &alice, 0); - } - - assert_eq!( - ledger.current_mine_difficulty_bits(), - MINE_MIN_DIFFICULTY_BITS - ); - - for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { - mine_burn_block_with_mines(&mut ledger, &alice, 0); - } - - assert_eq!( - ledger.current_mine_difficulty_bits(), - MINE_MIN_DIFFICULTY_BITS - ); - } - - #[test] - fn mine_actions_expire_when_anchor_is_too_old() { - let alice = Wallet::from_seed("mine-anchor-expiry-alice"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); - let stale_mine = ledger.build_mine(alice.address()).unwrap(); - - for _ in 0..=MINE_MAX_ANCHOR_AGE_BLOCKS { - mine_burn_block_with_mines(&mut ledger, &alice, 0); - } - - let error = ledger.submit_transaction(stale_mine).unwrap_err(); - assert!(format!("{error:#}").contains("mine transaction anchor is too old")); - } - - #[test] - fn pending_mine_actions_are_removed_when_anchor_expires() { - let alice = Wallet::from_seed("pending-mine-anchor-expiry-alice"); - let bob = Wallet::from_seed("pending-mine-anchor-expiry-bob"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); - ledger.launch_profile.max_block_transactions = 1; - let stale_mine = ledger.build_mine(bob.address()).unwrap(); - ledger.submit_transaction(stale_mine.clone()).unwrap(); - - for _ in 0..=MINE_MAX_ANCHOR_AGE_BLOCKS { - mine_burn_block_with_mines(&mut ledger, &alice, 0); - } - - assert!( - ledger - .pending() - .iter() - .all(|tx| tx.signature() != stale_mine.signature()) - ); - } - - #[test] - fn stratum_mine_header_proof_is_validated() { - let alice = Wallet::from_seed("stratum-proof-alice"); - let ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); - let anchor = ledger.tip().hash.clone(); - let difficulty_bits = ledger.current_mine_difficulty_bits(); - let template = ledger - .stratum_mine_template(alice.address(), anchor, 1, difficulty_bits) - .unwrap(); - - let mut accepted = None; - for nonce in 0_u32..50_000 { - let result = ledger.build_stratum_mine( - template.clone(), - StratumMineShare { - extranonce2: [0, 0, 0, 0], - header_nonce: nonce.to_le_bytes(), - }, - ); - if let Ok(tx) = result { - accepted = Some(tx); - break; - } - } - - let tx = accepted.expect("expected Stratum proof within search range"); - let Transaction::Mine { - proof_header, - signature, - .. - } = tx - else { - panic!("expected mine action"); - }; - assert_eq!(proof_header.as_deref().unwrap_or_default().len(), 160); - assert!(hash_meets_difficulty(&signature, difficulty_bits)); - } - - #[test] - fn stratum_mine_salt_allows_multiple_actions_for_same_anchor() { - let alice = Wallet::from_seed("stratum-salt-alice"); - let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); - let anchor = ledger.tip().hash.clone(); - let difficulty_bits = ledger.current_mine_difficulty_bits(); - - for salt in [1, 2] { - let template = ledger - .stratum_mine_template(alice.address(), anchor.clone(), salt, difficulty_bits) - .unwrap(); - let mut accepted = None; - for nonce in 0_u32..50_000 { - let result = ledger.build_stratum_mine( - template.clone(), - StratumMineShare { - extranonce2: [0, 0, 0, 0], - header_nonce: nonce.to_le_bytes(), - }, - ); - if let Ok(tx) = result { - accepted = Some(tx); - break; - } - } - let tx = accepted.expect("expected Stratum proof within search range"); - assert!(ledger.submit_transaction(tx).unwrap()); - } - - assert_eq!(ledger.pending().len(), 2); - let salts = ledger - .pending() - .iter() - .map(|tx| match tx { - Transaction::Mine { salt, .. } => *salt, - _ => panic!("expected mine action"), - }) - .collect::<BTreeSet<_>>(); - assert_eq!(salts, BTreeSet::from([1, 2])); - } -} +#[cfg(test)] +mod tests; diff --git a/src/domain/blinded.rs b/src/domain/blinded.rs @@ -0,0 +1,372 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Result, anyhow, bail}; +use chacha20poly1305::{ + ChaCha20Poly1305, Nonce, + aead::{Aead, KeyInit}, +}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + +use super::transaction::{ + BlindedTransactionPayload, canonical_inputs, signed_blinded_inputs, unsigned_inputs, +}; +use super::{ + Amount, BLINDED_COMMITTER_FEE_BPS, BLINDED_FEE_BPS_DENOMINATOR, BLINDED_KEY_BYTES, + BLINDED_NONCE_BYTES, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS, BlindedReveal, BlindedTransaction, + OutPoint, PUBLIC_KEY_BYTES, RevealBundleSignature, SIGNATURE_BYTES, Transaction, TxInput, + TxOutput, blinded_reveal_finalizer_fee, decode_hex, decode_hex_array, + ensure_outputs_do_not_overflow, ensure_single_input_owner_for_inputs, hex_hash, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct ActiveBlindedTransaction { + pub(super) transaction: BlindedTransaction, + pub(super) locked_outputs: Vec<TxOutput>, + pub(super) included_height: u64, + pub(super) included_by: String, +} + +pub(super) fn decrypt_blinded_transaction( + transaction: &BlindedTransaction, + reveal: &BlindedReveal, +) -> Result<Transaction> { + if reveal.commitment != transaction.commitment { + bail!("blinded reveal commitment does not match transaction"); + } + let key = + decode_hex_array::<BLINDED_KEY_BYTES>(&reveal.key).context("invalid blinded reveal key")?; + let nonce = decode_hex_array::<BLINDED_NONCE_BYTES>(&transaction.nonce) + .context("invalid blinded transaction nonce")?; + let ciphertext = + decode_hex(&transaction.ciphertext).context("invalid blinded transaction ciphertext")?; + let plaintext = decrypt_blinded_payload( + &key, + &nonce, + &signed_blinded_inputs(&unsigned_inputs(&transaction.inputs), ""), + transaction.fee, + transaction.expires_at_height, + &ciphertext, + )?; + if hex_hash(&plaintext) != transaction.payload_hash { + bail!("blinded transaction payload hash is invalid"); + } + let payload = serde_json::from_slice(&plaintext) + .context("failed to decode blinded transaction payload")?; + transaction_from_blinded_payload(payload, &transaction.inputs, transaction.fee) +} + +pub(super) fn blinded_payload_from_transaction( + transaction: &Transaction, +) -> Result<BlindedTransactionPayload> { + match transaction { + Transaction::Transfer { + outputs, signature, .. + } => Ok(BlindedTransactionPayload::Transfer { + outputs: outputs.clone(), + signature: signature.clone(), + }), + Transaction::Burn { + change, + amount, + signature, + .. + } => Ok(BlindedTransactionPayload::Burn { + change: change.clone(), + amount: *amount, + signature: signature.clone(), + }), + Transaction::Mine { .. } => bail!("mine actions are public and cannot be blinded"), + } +} + +pub(super) fn transaction_from_blinded_payload( + payload: BlindedTransactionPayload, + envelope_inputs: &[TxInput], + fee: Amount, +) -> Result<Transaction> { + match payload { + BlindedTransactionPayload::Transfer { outputs, signature } => { + let inputs = signed_blinded_inputs(&unsigned_inputs(envelope_inputs), &signature); + Ok(Transaction::Transfer { + inputs, + outputs, + fee, + signature, + }) + } + BlindedTransactionPayload::Burn { + change, + amount, + signature, + } => { + let inputs = signed_blinded_inputs(&unsigned_inputs(envelope_inputs), &signature); + Ok(Transaction::Burn { + inputs, + change, + amount, + fee, + signature, + }) + } + } +} + +pub(super) fn encrypt_blinded_payload( + key: &[u8; BLINDED_KEY_BYTES], + nonce: &[u8; BLINDED_NONCE_BYTES], + inputs: &[TxInput], + fee: Amount, + expires_at_height: u64, + plaintext: &[u8], +) -> Result<Vec<u8>> { + let cipher = ChaCha20Poly1305::new(key.into()); + cipher + .encrypt( + Nonce::from_slice(nonce), + chacha20poly1305::aead::Payload { + msg: plaintext, + aad: blinded_payload_aad(inputs, fee, expires_at_height).as_bytes(), + }, + ) + .map_err(|_| anyhow!("failed to encrypt blinded transaction payload")) +} + +pub(super) fn decrypt_blinded_payload( + key: &[u8; BLINDED_KEY_BYTES], + nonce: &[u8; BLINDED_NONCE_BYTES], + inputs: &[TxInput], + fee: Amount, + expires_at_height: u64, + ciphertext: &[u8], +) -> Result<Vec<u8>> { + let cipher = ChaCha20Poly1305::new(key.into()); + cipher + .decrypt( + Nonce::from_slice(nonce), + chacha20poly1305::aead::Payload { + msg: ciphertext, + aad: blinded_payload_aad(inputs, fee, expires_at_height).as_bytes(), + }, + ) + .map_err(|_| anyhow!("failed to decrypt blinded transaction payload")) +} + +fn blinded_payload_aad(inputs: &[TxInput], fee: Amount, expires_at_height: u64) -> String { + format!( + "iuna-blinded-payload-v3:{}:{fee}:{expires_at_height}", + canonical_inputs(&unsigned_inputs(inputs)) + ) +} + +pub(super) fn blinded_transaction_commitment(transaction: &BlindedTransaction) -> Result<String> { + let mut without_commitment = transaction.clone(); + without_commitment.commitment.clear(); + Ok(hex_hash(without_commitment.canonical())) +} + +pub(super) fn blinded_transaction_signing_payload(transaction: &BlindedTransaction) -> String { + format!( + "blinded-tx-inputs:{}:{}:{}:{}:{}:{}:{}", + canonical_inputs(&unsigned_inputs(&transaction.inputs)), + transaction.fee, + transaction.encrypted_size, + transaction.expires_at_height, + transaction.nonce, + transaction.ciphertext, + transaction.payload_hash + ) +} + +pub(super) fn verify_blinded_input_signatures(transaction: &BlindedTransaction) -> Result<()> { + if transaction.inputs.is_empty() { + return Ok(()); + } + ensure_single_input_owner_for_inputs(&transaction.inputs)?; + let signature = transaction.inputs[0].signature.clone(); + if !transaction + .inputs + .iter() + .all(|input| input.signature == signature) + { + bail!("blinded transaction input signature mismatch"); + } + let mut unsigned = transaction.clone(); + for input in &mut unsigned.inputs { + input.signature.clear(); + } + let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(&transaction.inputs[0].owner) + .context("invalid blinded transaction input owner")?; + let signature = decode_hex_array::<SIGNATURE_BYTES>(&signature) + .context("invalid blinded input signature")?; + let verifying_key = + VerifyingKey::from_bytes(&public_key).context("invalid blinded input public key")?; + let signature = Signature::from_bytes(&signature); + verifying_key + .verify( + blinded_transaction_signing_payload(&unsigned).as_bytes(), + &signature, + ) + .context("blinded transaction input signature is invalid") +} + +pub(super) fn credit_blinded_fee_outputs( + utxos: &mut BTreeMap<OutPoint, TxOutput>, + active: &ActiveBlindedTransaction, + reveal_executor: &str, + transaction: &Transaction, + reveal_bundle_signatures: &[RevealBundleSignature], + available_bundle_slots: usize, + aggregate_finalizer_fee: bool, +) -> Result<()> { + let fee = transaction.fee(); + if fee == 0 { + return Ok(()); + } + let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); + let reveal_finalizer_fee = + blinded_reveal_finalizer_fee(fee, reveal_bundle_signatures.len(), available_bundle_slots); + let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); + let mut outputs = Vec::new(); + if committer_fee > 0 { + outputs.push(( + blinded_committer_fee_outpoint(&active.transaction.commitment), + TxOutput { + address: active.included_by.clone(), + amount: committer_fee, + }, + )); + } + if reveal_finalizer_fee > 0 && !aggregate_finalizer_fee { + outputs.push(( + blinded_executor_fee_outpoint(&active.transaction.commitment), + TxOutput { + address: reveal_executor.to_string(), + amount: reveal_finalizer_fee, + }, + )); + } + for signature in reveal_bundle_signatures { + if reveal_bundle_signer_fee > 0 { + outputs.push(( + blinded_reveal_bundle_signer_fee_outpoint( + &active.transaction.commitment, + signature.slot, + ), + TxOutput { + address: signature.member.clone(), + amount: reveal_bundle_signer_fee, + }, + )); + } + } + let tx_outputs = outputs + .iter() + .map(|(_, output)| output.clone()) + .collect::<Vec<_>>(); + ensure_outputs_do_not_overflow(utxos, &tx_outputs)?; + for (outpoint, output) in outputs { + utxos.insert(outpoint, output); + } + Ok(()) +} + +pub(super) fn blinded_fee_share(fee: Amount, bps: u64) -> Amount { + ((fee as u128 * bps as u128) / BLINDED_FEE_BPS_DENOMINATOR as u128) as Amount +} + +pub(super) fn blinded_envelope_fee_for_transaction(transaction: &Transaction) -> Amount { + match transaction { + Transaction::Mine { .. } => 0, + Transaction::Transfer { .. } | Transaction::Burn { .. } => transaction.fee(), + } +} + +pub(super) fn blinded_locked_output_total(active: &ActiveBlindedTransaction) -> Result<Amount> { + active + .locked_outputs + .iter() + .try_fold(0_u64, |total, output| { + total + .checked_add(output.amount) + .context("blinded transaction locked input total overflows") + }) +} + +pub(super) fn blinded_reveal_inputs_match( + active: &ActiveBlindedTransaction, + transaction: &Transaction, +) -> bool { + let visible = active + .transaction + .inputs + .iter() + .map(TxInput::without_signature) + .collect::<Vec<_>>(); + let revealed = transaction + .inputs() + .iter() + .map(TxInput::without_signature) + .collect::<Vec<_>>(); + visible == revealed +} + +pub(super) fn credit_expired_blinded_outputs( + utxos: &mut BTreeMap<OutPoint, TxOutput>, + active: &ActiveBlindedTransaction, +) -> Result<()> { + let Some(first_input) = active.transaction.inputs.first() else { + return Ok(()); + }; + let input_total = blinded_locked_output_total(active)?; + if active.transaction.fee > input_total { + bail!("blinded transaction fee exceeds locked inputs"); + } + let change = input_total - active.transaction.fee; + let mut outputs = Vec::new(); + if change > 0 { + outputs.push(( + blinded_expiry_change_outpoint(&active.transaction.commitment), + TxOutput { + address: first_input.owner.clone(), + amount: change, + }, + )); + } + let tx_outputs = outputs + .iter() + .map(|(_, output)| output.clone()) + .collect::<Vec<_>>(); + ensure_outputs_do_not_overflow(utxos, &tx_outputs)?; + for (outpoint, output) in outputs { + utxos.insert(outpoint, output); + } + Ok(()) +} + +pub(super) fn blinded_committer_fee_outpoint(commitment: &str) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: u32::MAX - 1, + } +} + +pub(super) fn blinded_executor_fee_outpoint(commitment: &str) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: u32::MAX - 2, + } +} + +pub(super) fn blinded_reveal_bundle_signer_fee_outpoint(commitment: &str, slot: u8) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: u32::MAX - 3 - u32::from(slot), + } +} + +pub(super) fn blinded_expiry_change_outpoint(commitment: &str) -> OutPoint { + OutPoint { + txid: commitment.to_string(), + index: 0, + } +} diff --git a/src/domain/block.rs b/src/domain/block.rs @@ -0,0 +1,429 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use super::{ + Amount, BlindedReveal, BlindedTransaction, BurnTicket, LaunchProfile, LeaderScore, + REVEAL_COMMITTEE_SIZE, RevealBundleSection, Transaction, Wallet, hex_hash, + recovery_vdf_seed_for_child, vdf_seed_for_child, +}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Block { + pub height: u64, + pub prev_hash: String, + pub timestamp_ms: u64, + pub miner: String, + #[serde(default)] + pub finalizer_mode: FinalizerMode, + #[serde(default)] + pub finalizer_rank: u32, + pub reward: Amount, + pub vdf_rounds: u64, + pub vdf_output: String, + pub leader_proof: Option<LeaderProof>, + #[serde(default)] + pub blinded_transactions: Vec<BlindedTransaction>, + #[serde(default)] + pub reveal_bundle_section: RevealBundleSection, + pub transactions: Vec<Transaction>, + pub hash: String, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FinalizerMode { + #[default] + Ticket, + Recovery, +} + +impl Block { + pub fn compute_hash(&self) -> String { + hex_hash(format!( + "block:{}:{}:{}", + self.content_hash(), + self.vdf_seed(), + self.vdf_output, + )) + } + + pub fn vdf_seed(&self) -> String { + let bundle_hashes = self.reveal_bundle_hashes(); + match self.finalizer_mode { + FinalizerMode::Ticket => { + vdf_seed_for_child(&self.prev_hash, self.height, &bundle_hashes) + } + FinalizerMode::Recovery => recovery_vdf_seed_for_child( + &self.prev_hash, + self.height, + self.timestamp_ms, + &bundle_hashes, + ), + } + } + + fn content_hash(&self) -> String { + let txs = self + .transactions + .iter() + .map(Transaction::canonical) + .collect::<Vec<_>>() + .join("|"); + let blinded = self + .blinded_transactions + .iter() + .map(BlindedTransaction::canonical) + .collect::<Vec<_>>() + .join("|"); + let reveal_section = self.reveal_bundle_section.canonical(); + let leader_proof = self + .leader_proof + .as_ref() + .map(|proof| { + format!( + "{}:{}:{}", + proof.ticket_id, proof.public_key, proof.signature + ) + }) + .unwrap_or_default(); + if !self.blinded_transactions.is_empty() || !self.reveal_bundle_section.is_empty() { + return hex_hash(format!( + "block-content-v3:{}:{}:{}:{}:{}:{}:{}:{}:{}:{}", + self.height, + self.prev_hash, + self.timestamp_ms, + self.miner, + self.finalizer_rank, + self.reward, + self.vdf_rounds, + leader_proof, + txs, + canonical_blinded_block_items(&blinded, &reveal_section) + )); + } + hex_hash(format!( + "{}:{}", + self.legacy_content_hash_prefix(&leader_proof), + txs + )) + } + + fn legacy_content_hash_prefix(&self, leader_proof: &str) -> String { + if self.finalizer_mode == FinalizerMode::Recovery { + format!( + "block-content-recovery-v1:{}:{}:{}:{}:{}:{}:{}", + self.height, + self.prev_hash, + self.timestamp_ms, + self.miner, + self.reward, + self.vdf_rounds, + leader_proof + ) + } else if self.finalizer_rank == 0 { + format!( + "block-content:{}:{}:{}:{}:{}:{}:{}", + self.height, + self.prev_hash, + self.timestamp_ms, + self.miner, + self.reward, + self.vdf_rounds, + leader_proof + ) + } else { + format!( + "block-content-v2:{}:{}:{}:{}:{}:{}:{}:{}", + self.height, + self.prev_hash, + self.timestamp_ms, + self.miner, + self.finalizer_rank, + self.reward, + self.vdf_rounds, + leader_proof + ) + } + } + + pub(super) fn leader_score(&self) -> LeaderScore { + LeaderScore { + finalizer_mode_rank: self.finalizer_mode.fork_choice_rank(), + finalizer_rank: self.finalizer_rank, + proof_rank: self + .leader_proof + .as_ref() + .map(LeaderProof::rank) + .unwrap_or_else(|| self.hash.clone()), + } + } + + pub fn serialized_size_bytes(&self) -> Result<usize> { + serde_json::to_vec(self) + .map(|bytes| bytes.len()) + .context("failed to serialize block for size check") + } + + pub fn all_blinded_reveals(&self) -> Vec<&BlindedReveal> { + self.reveal_bundle_section.all_reveals() + } + + pub fn reveal_bundle_hashes(&self) -> [String; REVEAL_COMMITTEE_SIZE] { + self.reveal_bundle_section + .reveal_bundle_hashes(self.height, &self.prev_hash) + } + + pub fn included_reveal_bundle_count(&self) -> usize { + self.reveal_bundle_section.included_bundle_count() + } +} + +impl FinalizerMode { + fn fork_choice_rank(self) -> u8 { + match self { + Self::Ticket => 0, + Self::Recovery => 1, + } + } +} + +fn canonical_blinded_block_items(blinded: &str, reveal_section: &str) -> String { + format!("blinded-v3:{blinded}:reveal-section:{reveal_section}") +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LeaderProof { + pub ticket_id: String, + pub public_key: String, + pub signature: String, +} + +impl LeaderProof { + fn rank(&self) -> String { + hex_hash(format!( + "iuna-leader-rank:{}:{}", + self.ticket_id, self.signature + )) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct LeaderProofPayload { + pub(super) height: u64, + pub(super) prev_hash: String, + pub(super) finalizer_rank: u32, + pub(super) vdf_output: String, + pub(super) ticket_id: String, + pub(super) ticket_amount: Amount, + pub(super) ticket_owner: String, +} + +impl LeaderProofPayload { + pub(super) fn canonical(&self) -> String { + if self.finalizer_rank == 0 { + format!( + "iuna-leader-proof:{}:{}:{}:{}:{}:{}", + self.height, + self.prev_hash, + self.vdf_output, + self.ticket_id, + self.ticket_amount, + self.ticket_owner + ) + } else { + format!( + "iuna-leader-proof-v2:{}:{}:{}:{}:{}:{}:{}", + self.height, + self.prev_hash, + self.finalizer_rank, + self.vdf_output, + self.ticket_id, + self.ticket_amount, + self.ticket_owner + ) + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BurnLeaderRank { + pub rank: u32, + pub ticket_id: String, + pub owner: String, + pub amount: Amount, + pub eligible_from_height: u64, + pub eligible_until_height: u64, +} + +#[derive(Clone, Debug)] +pub struct PreparedBlock { + pub(super) height: u64, + pub(super) prev_hash: String, + pub(super) timestamp_ms: u64, + pub(super) miner: String, + pub(super) finalizer_mode: FinalizerMode, + pub(super) finalizer_rank: u32, + pub(super) reward: Amount, + pub(super) vdf_rounds: u64, + pub(super) vdf_seed: String, + pub(super) leader_ticket: Option<BurnTicket>, + pub(super) blinded_transactions: Vec<BlindedTransaction>, + pub(super) reveal_bundle_section: RevealBundleSection, + pub(super) transactions: Vec<Transaction>, +} + +impl PreparedBlock { + pub fn vdf_seed(&self) -> &str { + &self.vdf_seed + } + + pub fn vdf_rounds(&self) -> u64 { + self.vdf_rounds + } + + pub fn height(&self) -> u64 { + self.height + } + + pub fn timestamp_ms(&self) -> u64 { + self.timestamp_ms + } + + pub fn finish(self, wallet: &Wallet, vdf_output: String) -> Block { + let timestamp_ms = self.timestamp_ms; + self.finish_with_timestamp(wallet, vdf_output, timestamp_ms) + } + + pub fn finish_at(self, wallet: &Wallet, vdf_output: String, timestamp_ms: u64) -> Block { + let timestamp_ms = match self.finalizer_mode { + FinalizerMode::Ticket => timestamp_ms.max(self.timestamp_ms), + FinalizerMode::Recovery => self.timestamp_ms, + }; + self.finish_with_timestamp(wallet, vdf_output, timestamp_ms) + } + + fn finish_with_timestamp( + self, + wallet: &Wallet, + vdf_output: String, + timestamp_ms: u64, + ) -> Block { + let leader_proof = self.leader_ticket.as_ref().map(|leader_ticket| { + let proof_payload = LeaderProofPayload { + height: self.height, + prev_hash: self.prev_hash.clone(), + finalizer_rank: self.finalizer_rank, + vdf_output: vdf_output.clone(), + ticket_id: leader_ticket.id.clone(), + ticket_amount: leader_ticket.amount, + ticket_owner: leader_ticket.owner.clone(), + }; + wallet.leader_proof(&proof_payload) + }); + let mut block = Block { + height: self.height, + prev_hash: self.prev_hash, + timestamp_ms, + miner: self.miner, + finalizer_mode: self.finalizer_mode, + finalizer_rank: self.finalizer_rank, + reward: self.reward, + vdf_rounds: self.vdf_rounds, + vdf_output, + leader_proof, + blinded_transactions: self.blinded_transactions, + reveal_bundle_section: self.reveal_bundle_section, + transactions: self.transactions, + hash: String::new(), + }; + block.hash = block.compute_hash(); + block + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ChainStatus { + pub height: u64, + pub tip_hash: String, + pub next_leader: Option<String>, + pub launch_profile_hash: String, + pub mine_reward: Amount, + pub current_mine_difficulty_bits: u32, + pub balances: BTreeMap<String, Amount>, + pub pending_transactions: usize, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ChainSnapshot { + pub genesis_allocations: BTreeMap<String, Amount>, + pub vdf_rounds: u64, + pub launch_profile: LaunchProfile, + pub blocks: Vec<Block>, +} + +#[cfg(test)] +mod tests { + use super::{Block, FinalizerMode, LeaderProofPayload, canonical_blinded_block_items}; + use crate::domain::{RevealBundleSection, Transaction}; + + #[test] + fn leader_proof_payload_keeps_legacy_primary_canonical_form() { + let primary = LeaderProofPayload { + height: 1, + prev_hash: "prev".to_string(), + finalizer_rank: 0, + vdf_output: "vdf".to_string(), + ticket_id: "ticket".to_string(), + ticket_amount: 2, + ticket_owner: "owner".to_string(), + }; + let fallback = LeaderProofPayload { + finalizer_rank: 1, + ..primary.clone() + }; + + assert_eq!( + primary.canonical(), + "iuna-leader-proof:1:prev:vdf:ticket:2:owner" + ); + assert_eq!( + fallback.canonical(), + "iuna-leader-proof-v2:1:prev:1:vdf:ticket:2:owner" + ); + } + + #[test] + fn blinded_block_items_keep_canonical_prefix() { + assert_eq!( + canonical_blinded_block_items("blind", "section"), + "blinded-v3:blind:reveal-section:section" + ); + } + + #[test] + fn block_compute_hash_sets_content_and_vdf_seed_contract() { + let mut block = Block { + height: 1, + prev_hash: "0".repeat(64), + timestamp_ms: 1, + miner: "miner".to_string(), + finalizer_mode: FinalizerMode::Ticket, + finalizer_rank: 0, + reward: 0, + vdf_rounds: 1, + vdf_output: "out".to_string(), + leader_proof: None, + blinded_transactions: Vec::new(), + reveal_bundle_section: RevealBundleSection::default(), + transactions: vec![Transaction::genesis_burn("owner", 1)], + hash: String::new(), + }; + + block.hash = block.compute_hash(); + + assert_eq!(block.compute_hash(), block.hash); + } +} diff --git a/src/domain/fork.rs b/src/domain/fork.rs @@ -0,0 +1,118 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct ForkPoint { + pub(super) common_ancestor_height: u64, +} + +impl ForkPoint { + pub(super) fn first_diverging_height(self) -> u64 { + self.common_ancestor_height + 1 + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct LeaderScore { + pub(super) finalizer_mode_rank: u8, + pub(super) finalizer_rank: u32, + pub(super) proof_rank: String, +} + +impl Ord for LeaderScore { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.finalizer_mode_rank + .cmp(&other.finalizer_mode_rank) + .then_with(|| self.finalizer_rank.cmp(&other.finalizer_rank)) + .then_with(|| self.proof_rank.cmp(&other.proof_rank)) + } +} + +impl PartialOrd for LeaderScore { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.cmp(other)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ForkQuality { + LocalBetter, + RemoteBetter, + Equal, +} + +impl From<std::cmp::Ordering> for ForkQuality { + fn from(ordering: std::cmp::Ordering) -> Self { + match ordering { + std::cmp::Ordering::Less => Self::LocalBetter, + std::cmp::Ordering::Equal => Self::Equal, + std::cmp::Ordering::Greater => Self::RemoteBetter, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum ForkChoice { + KeepLocal, + SwitchToCandidate, +} + +#[cfg(test)] +mod tests { + use super::{ForkPoint, ForkQuality, LeaderScore}; + + #[test] + fn fork_point_reports_first_diverging_height() { + assert_eq!( + ForkPoint { + common_ancestor_height: 41 + } + .first_diverging_height(), + 42 + ); + } + + #[test] + fn leader_score_orders_by_mode_rank_then_finalizer_rank_then_proof_rank() { + let base = LeaderScore { + finalizer_mode_rank: 0, + finalizer_rank: 0, + proof_rank: "b".to_string(), + }; + + assert!( + base < LeaderScore { + finalizer_mode_rank: 1, + finalizer_rank: 0, + proof_rank: "a".to_string(), + } + ); + assert!( + base < LeaderScore { + finalizer_mode_rank: 0, + finalizer_rank: 1, + proof_rank: "a".to_string(), + } + ); + assert!( + base > LeaderScore { + finalizer_mode_rank: 0, + finalizer_rank: 0, + proof_rank: "a".to_string(), + } + ); + } + + #[test] + fn fork_quality_maps_ordering_without_inversion() { + assert_eq!( + ForkQuality::from(std::cmp::Ordering::Less), + ForkQuality::LocalBetter + ); + assert_eq!( + ForkQuality::from(std::cmp::Ordering::Equal), + ForkQuality::Equal + ); + assert_eq!( + ForkQuality::from(std::cmp::Ordering::Greater), + ForkQuality::RemoteBetter + ); + } +} diff --git a/src/domain/genesis.rs b/src/domain/genesis.rs @@ -0,0 +1,186 @@ +use std::collections::BTreeMap; + +use anyhow::{Result, bail}; + +use super::{ + Amount, BLOCK_REWARD, Block, FinalizerMode, OutPoint, RevealBundleSection, Transaction, + TxOutput, apply_transaction, credit_reward_output, hex_hash, validate_genesis_burn_transaction, +}; + +pub(super) fn build_genesis_block( + genesis_allocations: &BTreeMap<String, Amount>, + transactions: Vec<Transaction>, +) -> Block { + let miner = genesis_miner(genesis_allocations, &transactions); + let reward = genesis_reward(genesis_allocations, &transactions); + let txs = transactions + .iter() + .map(Transaction::canonical) + .collect::<Vec<_>>() + .join("|"); + let vdf_output = hex_hash(format!("iuna-genesis-vdf:{genesis_allocations:?}:{txs}")); + let mut genesis = Block { + height: 0, + prev_hash: "0".repeat(64), + timestamp_ms: 0, + miner, + finalizer_mode: FinalizerMode::Ticket, + finalizer_rank: 0, + reward, + vdf_rounds: 0, + vdf_output, + leader_proof: None, + blinded_transactions: Vec::new(), + reveal_bundle_section: RevealBundleSection::default(), + transactions, + hash: String::new(), + }; + genesis.hash = genesis.compute_hash(); + genesis +} + +pub(super) fn utxos_after_genesis( + genesis_allocations: &BTreeMap<String, Amount>, + genesis: &Block, +) -> Result<BTreeMap<OutPoint, TxOutput>> { + let mut utxos = genesis_allocation_utxos(genesis_allocations); + for transaction in &genesis.transactions { + match transaction { + Transaction::Burn { .. } => { + validate_genesis_burn_transaction(transaction)?; + apply_transaction(transaction, &mut utxos)?; + } + Transaction::Transfer { .. } | Transaction::Mine { .. } => { + bail!("genesis only supports burn transactions") + } + } + } + credit_reward_output(&mut utxos, genesis)?; + Ok(utxos) +} + +fn genesis_allocation_utxos( + genesis_allocations: &BTreeMap<String, Amount>, +) -> BTreeMap<OutPoint, TxOutput> { + genesis_allocations + .iter() + .filter(|(_, amount)| **amount > 0) + .map(|(address, amount)| { + ( + genesis_allocation_outpoint(address), + TxOutput { + address: address.clone(), + amount: *amount, + }, + ) + }) + .collect() +} + +pub(super) fn balances_from_utxos( + utxos: &BTreeMap<OutPoint, TxOutput>, +) -> BTreeMap<String, Amount> { + let mut balances = BTreeMap::new(); + for output in utxos.values() { + let balance = balances.entry(output.address.clone()).or_insert(0_u64); + *balance = balance.saturating_add(output.amount); + } + balances +} + +pub(super) fn genesis_allocation_outpoint(address: &str) -> OutPoint { + OutPoint { + txid: hex_hash(format!("iuna-genesis-allocation:{address}")), + index: 0, + } +} + +pub(super) fn validate_genesis_block(block: &Block) -> Result<()> { + if block.height != 0 { + bail!("genesis block height must be 0"); + } + if block.prev_hash != "0".repeat(64) { + bail!("genesis block prev_hash must be all zeroes"); + } + if block.timestamp_ms != 0 { + bail!("genesis block timestamp must be 0"); + } + if block.miner == "genesis" && block.reward != 0 { + bail!("genesis placeholder miner must not receive a reward"); + } + if block.miner != "genesis" && block.reward != 0 && block.reward != BLOCK_REWARD { + bail!("genesis block reward is invalid"); + } + if block.vdf_rounds != 0 { + bail!("genesis block VDF rounds must be 0"); + } + if block.leader_proof.is_some() { + bail!("genesis block must not carry a leader proof"); + } + if !block.blinded_transactions.is_empty() || !block.reveal_bundle_section.is_empty() { + bail!("genesis block must not carry blinded transactions"); + } + if block.compute_hash() != block.hash { + bail!("genesis block hash is invalid"); + } + Ok(()) +} + +fn genesis_miner( + genesis_allocations: &BTreeMap<String, Amount>, + transactions: &[Transaction], +) -> String { + transactions + .iter() + .filter_map(|transaction| match transaction { + Transaction::Burn { inputs, .. } => inputs.first().map(|input| input.owner.as_str()), + Transaction::Transfer { .. } | Transaction::Mine { .. } => None, + }) + .find(|from| genesis_allocations.contains_key(*from)) + .or_else(|| genesis_allocations.keys().next().map(String::as_str)) + .unwrap_or("genesis") + .to_string() +} + +fn genesis_reward( + genesis_allocations: &BTreeMap<String, Amount>, + transactions: &[Transaction], +) -> Amount { + if genesis_allocations.is_empty() || transactions.is_empty() { + 0 + } else { + BLOCK_REWARD + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::{build_genesis_block, genesis_allocation_outpoint, validate_genesis_block}; + use crate::domain::{MICRO_IUNA, Transaction, Wallet}; + + #[test] + fn genesis_allocation_outpoint_is_address_bound() { + let alice = Wallet::from_seed("genesis-outpoint-alice"); + let bob = Wallet::from_seed("genesis-outpoint-bob"); + + assert_ne!( + genesis_allocation_outpoint(alice.address()), + genesis_allocation_outpoint(bob.address()) + ); + assert_eq!(genesis_allocation_outpoint(alice.address()).index, 0); + } + + #[test] + fn built_genesis_block_validates() { + let wallet = Wallet::from_seed("genesis-module-validates"); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), MICRO_IUNA); + let burn = Transaction::genesis_burn(wallet.address(), MICRO_IUNA); + + let genesis = build_genesis_block(&allocations, vec![burn]); + + validate_genesis_block(&genesis).unwrap(); + } +} diff --git a/src/domain/hex.rs b/src/domain/hex.rs @@ -0,0 +1,77 @@ +use anyhow::{Result, anyhow, bail}; +use sha2::{Digest, Sha256}; + +pub fn hex_hash(input: impl AsRef<[u8]>) -> String { + hex_encode(Sha256::digest(input.as_ref())) +} + +pub(super) fn decode_hex_array<const N: usize>(input: &str) -> Result<[u8; N]> { + let bytes = decode_hex(input)?; + let len = bytes.len(); + bytes + .try_into() + .map_err(|_| anyhow!("expected {} hex bytes, got {len}", N)) +} + +pub(super) 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"), + } +} + +pub(super) fn hex_encode(bytes: impl AsRef<[u8]>) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let bytes = bytes.as_ref(); + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::{decode_hex, decode_hex_array, hex_encode, hex_hash}; + + #[test] + fn hex_roundtrips_bytes_and_accepts_uppercase() { + let bytes = [0x00, 0x0f, 0x10, 0xab, 0xff]; + + assert_eq!(hex_encode(bytes), "000f10abff"); + assert_eq!(decode_hex("000F10ABff").unwrap(), bytes); + assert_eq!(decode_hex_array::<5>("000f10abff").unwrap(), bytes); + } + + #[test] + fn hex_decoder_rejects_odd_length_invalid_digits_and_wrong_array_size() { + assert!(decode_hex("0").is_err()); + assert!(decode_hex("zz").is_err()); + assert!(decode_hex_array::<2>("00").is_err()); + } + + #[test] + fn hex_hash_is_sha256_hex() { + assert_eq!( + hex_hash("iuna"), + "a66946533b68cc0eb75a82632d7a28256633f5a06ef04e3906c1960d437239aa" + ); + } +} diff --git a/src/domain/history.rs b/src/domain/history.rs @@ -0,0 +1,59 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Result, bail}; + +use super::blinded::{ + ActiveBlindedTransaction, blinded_envelope_fee_for_transaction, decrypt_blinded_transaction, +}; +use super::{ChainSnapshot, RevealedBlindedTransaction, Transaction}; + +pub fn revealed_blinded_transactions( + snapshot: &ChainSnapshot, +) -> Result<Vec<RevealedBlindedTransaction>> { + let mut active = BTreeMap::<String, ActiveBlindedTransaction>::new(); + let mut revealed = Vec::new(); + for block in &snapshot.blocks { + for reveal in block.all_blinded_reveals() { + let active_transaction = active.get(&reveal.commitment).with_context(|| { + format!( + "block {} reveals unknown blinded transaction {}", + block.height, reveal.commitment + ) + })?; + let transaction = decrypt_blinded_transaction(&active_transaction.transaction, reveal)?; + if matches!(transaction, Transaction::Mine { .. }) { + bail!("block {} blinded reveal is a mine action", block.height); + } + if blinded_envelope_fee_for_transaction(&transaction) + != active_transaction.transaction.fee + { + bail!( + "block {} blinded reveal fee does not match envelope", + block.height + ); + } + revealed.push(RevealedBlindedTransaction { + height: block.height, + commitment: reveal.commitment.clone(), + included_by: active_transaction.included_by.clone(), + transaction, + }); + active.remove(&reveal.commitment); + } + active.retain(|_, active_transaction| { + block.height < active_transaction.transaction.expires_at_height + }); + for transaction in &block.blinded_transactions { + active.insert( + transaction.commitment.clone(), + ActiveBlindedTransaction { + transaction: transaction.clone(), + locked_outputs: Vec::new(), + included_height: block.height, + included_by: block.miner.clone(), + }, + ); + } + } + Ok(revealed) +} diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs @@ -0,0 +1,397 @@ +use std::collections::BTreeSet; + +use anyhow::{Context, Result, bail}; + +use super::blinded::{ + ActiveBlindedTransaction, credit_blinded_fee_outputs, credit_expired_blinded_outputs, +}; +use super::ledger_ops::{ + aggregate_finalizer_fees_active, apply_transaction, block_reward, credit_reward_output, + ensure_block_has_burn, ensure_valid_recovery_block, fee_reward, spend_blinded_inputs, + validate_block_blinded_items, verify_leader_proof, +}; +use super::mine_policy::ensure_mine_anchor_limit; +use super::ticket::{ + apply_finalizer_ticket_effects, ticket_block_min_timestamp, tickets_created_by_block, + tickets_created_by_transactions, +}; +use super::transaction::{blinded_transaction_inputs_available, transaction_inputs_available}; +use super::{ + Amount, BLOCK_MEDIAN_TIME_PAST_WINDOW, Block, FinalizerMode, Ledger, + MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS, RevealBundleSection, Transaction, + blinded_reveal_finalizer_fee, unix_now_ms, verify_vdf, +}; + +impl Ledger { + pub fn apply_block(&mut self, block: Block) -> Result<()> { + self.apply_block_at(block, unix_now_ms()) + } + + pub(crate) fn apply_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> { + self.apply_block_with_vdf_policy(block, true, now_ms) + } + + pub(crate) fn block_requires_vdf_verification_at( + &self, + block: &Block, + now_ms: u64, + ) -> Result<bool> { + self.precheck_block_without_vdf_at(block, now_ms) + } + + pub fn apply_locally_mined_block(&mut self, block: Block) -> Result<()> { + self.apply_self_produced_block_at(block, unix_now_ms()) + } + + pub(crate) fn apply_self_produced_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> { + self.verify_self_produced_block_at(&block, now_ms)?; + self.apply_preverified_block_at(block, now_ms) + } + + pub(crate) fn verify_self_produced_block_at(&self, block: &Block, now_ms: u64) -> Result<()> { + let mut verifier = self.clone(); + verifier.apply_preverified_block_at(block.clone(), now_ms)?; + Ok(()) + } + + pub(crate) fn apply_preverified_block_at(&mut self, block: Block, now_ms: u64) -> Result<()> { + self.apply_block_with_vdf_policy(block, false, now_ms) + } + + fn apply_block_with_vdf_policy( + &mut self, + block: Block, + should_verify_vdf: bool, + now_ms: u64, + ) -> Result<()> { + if !self.precheck_block_without_vdf_at(&block, now_ms)? { + return Ok(()); + } + + if should_verify_vdf && !verify_vdf(&block.vdf_seed(), block.vdf_rounds, &block.vdf_output) + { + bail!("block VDF output is invalid"); + } + + let reveal_bundle_slot_count = self.reveal_committee_for_height(block.height).len(); + let mut utxos = self.utxos.clone(); + let mut signatures = BTreeSet::new(); + let mut revealed_transactions = Vec::new(); + let mut aggregated_reveal_finalizer_fees = 0_u64; + for tx in &block.transactions { + if !signatures.insert(tx.signature()) { + bail!("duplicate transaction in block"); + } + self.validate_transaction_terms(tx)?; + apply_transaction(tx, &mut utxos)?; + } + let mut revealed_commitments = BTreeSet::new(); + for reveal in block.all_blinded_reveals() { + if !revealed_commitments.insert(reveal.commitment.clone()) { + bail!("duplicate blinded reveal in block"); + } + let active = self + .active_blinded + .get(&reveal.commitment) + .context("blinded reveal does not reference an active blinded transaction")? + .clone(); + let tx = self.decrypt_active_blinded(&active, reveal)?; + self.apply_revealed_blinded_transaction(&active, &tx, &mut utxos)?; + credit_blinded_fee_outputs( + &mut utxos, + &active, + &block.miner, + &tx, + &block.reveal_bundle_section.signatures, + reveal_bundle_slot_count, + aggregate_finalizer_fees_active(block.height), + )?; + if aggregate_finalizer_fees_active(block.height) { + aggregated_reveal_finalizer_fees = aggregated_reveal_finalizer_fees + .checked_add(blinded_reveal_finalizer_fee( + tx.fee(), + block.included_reveal_bundle_count(), + reveal_bundle_slot_count, + )) + .context("aggregated reveal finalizer fees overflow")?; + } + revealed_transactions.push(tx); + } + for (commitment, active) in &self.active_blinded { + if !revealed_commitments.contains(commitment) + && block.height >= active.transaction.expires_at_height + { + credit_expired_blinded_outputs(&mut utxos, active)?; + } + } + let expected_reward = block_reward(&block.transactions, aggregated_reveal_finalizer_fees)?; + if block.reward != expected_reward { + bail!("block reward is invalid"); + } + let mined_signatures = block + .transactions + .iter() + .map(|tx| tx.signature().to_string()) + .collect::<BTreeSet<_>>(); + let included_blinded = block + .blinded_transactions + .iter() + .map(|transaction| transaction.commitment.clone()) + .collect::<BTreeSet<_>>(); + let revealed_blinded = block + .all_blinded_reveals() + .into_iter() + .map(|reveal| reveal.commitment.clone()) + .collect::<BTreeSet<_>>(); + let mut new_active_blinded = Vec::new(); + for transaction in &block.blinded_transactions { + let locked_outputs = spend_blinded_inputs(transaction, &mut utxos)?; + new_active_blinded.push(( + transaction.commitment.clone(), + ActiveBlindedTransaction { + transaction: transaction.clone(), + locked_outputs, + included_height: block.height, + included_by: block.miner.clone(), + }, + )); + } + let mut tickets = self.tickets.clone(); + apply_finalizer_ticket_effects(&block, &mut tickets)?; + tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?); + tickets.extend(tickets_created_by_transactions( + block.height, + &revealed_transactions, + &self.launch_profile, + )?); + credit_reward_output(&mut utxos, &block)?; + self.utxos = utxos; + self.tickets = tickets; + self.chain.push(block); + let new_height = self.height(); + self.active_blinded.retain(|commitment, active| { + !revealed_blinded.contains(commitment) + && new_height < active.transaction.expires_at_height + }); + for (commitment, active) in new_active_blinded { + self.active_blinded.insert(commitment, active); + } + let available = self.utxos.clone(); + let pending = std::mem::take(&mut self.pending); + self.pending = pending + .into_iter() + .filter(|tx| { + !mined_signatures.contains(tx.signature()) + && transaction_inputs_available(tx, &available) + && self.validate_transaction_terms(tx).is_ok() + }) + .collect(); + let orphans = std::mem::take(&mut self.orphans); + self.orphans = orphans + .into_iter() + .filter(|tx| { + !mined_signatures.contains(tx.signature()) + && self.validate_transaction_terms(tx).is_ok() + }) + .collect(); + let pending_blinded = std::mem::take(&mut self.pending_blinded); + self.pending_blinded = pending_blinded + .into_iter() + .filter(|transaction| { + !included_blinded.contains(&transaction.commitment) + && new_height < transaction.expires_at_height + && blinded_transaction_inputs_available(transaction, &available) + && self.validate_blinded_transaction(transaction).is_ok() + }) + .collect(); + let pending_reveals = std::mem::take(&mut self.pending_reveals); + self.pending_reveals = pending_reveals + .into_iter() + .filter(|reveal| { + !revealed_blinded.contains(&reveal.commitment) + && self.pending_reveal_transaction(reveal).is_ok() + }) + .collect(); + self.promote_orphan_transactions()?; + self.vdf_rounds = self.next_vdf_rounds_after_tip(); + Ok(()) + } + + fn precheck_block_without_vdf_at(&self, block: &Block, now_ms: u64) -> Result<bool> { + if block.height <= self.tip().height { + let existing = self + .chain + .get(block.height as usize) + .with_context(|| format!("local chain has no block at height {}", block.height))?; + if existing.hash == block.hash { + return Ok(false); + } + bail!( + "block at height {} conflicts with local chain", + block.height + ); + } + + let expected_height = self.tip().height + 1; + if block.height != expected_height { + bail!( + "expected block height {expected_height}, got {}", + block.height + ); + } + if block.prev_hash != self.tip().hash { + bail!("block does not extend local tip"); + } + if block.compute_hash() != block.hash { + bail!("block hash is invalid"); + } + let reveal_bundle_slot_count = self.reveal_committee_for_height(block.height).len(); + if block.reward != self.expected_reward_for_block(block, reveal_bundle_slot_count)? { + bail!("block reward is invalid"); + } + let expected_vdf_rounds = self.expected_vdf_rounds_for_block(block)?; + if block.vdf_rounds != expected_vdf_rounds { + bail!("block VDF rounds are invalid"); + } + if block.timestamp_ms <= self.tip().timestamp_ms { + bail!("block timestamp must increase"); + } + if block.finalizer_mode == FinalizerMode::Ticket { + let min_timestamp = ticket_block_min_timestamp(self.tip(), block.finalizer_rank)?; + if block.timestamp_ms < min_timestamp { + bail!( + "block timestamp is before finalizer rank {} time slot {min_timestamp}", + block.finalizer_rank + ); + } + } + let median_time_past = self.median_time_past(); + if block.timestamp_ms <= median_time_past { + bail!("block timestamp must exceed median time past"); + } + let max_future_timestamp = now_ms.saturating_add(MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS); + if block.timestamp_ms > max_future_timestamp { + bail!("block timestamp is too far in the future"); + } + if block.transactions.len() > self.launch_profile.max_block_transactions { + bail!("block has too many transactions"); + } + let block_item_count = block.transactions.len() + + block.blinded_transactions.len() + + block.all_blinded_reveals().len(); + if block_item_count > self.launch_profile.max_block_transactions { + bail!("block has too many transaction items"); + } + if block.serialized_size_bytes()? > self.launch_profile.max_block_bytes { + bail!("block exceeds max block size"); + } + ensure_mine_anchor_limit(block.height, &block.transactions)?; + ensure_block_has_burn(&block.transactions)?; + self.validate_reveal_bundle_section_for_block( + block.height, + &block.prev_hash, + &block.reveal_bundle_section, + )?; + validate_block_blinded_items(block, self)?; + match block.finalizer_mode { + FinalizerMode::Ticket => { + let selected_ticket = self + .ticket_for_finalizer_rank(block.height, block.finalizer_rank) + .context("no selected ticket for block finalizer rank")?; + if selected_ticket.owner != block.miner { + bail!( + "block finalizer {} is not selected for rank {}", + block.miner, + block.finalizer_rank + ); + } + if block + .leader_proof + .as_ref() + .is_none_or(|proof| proof.ticket_id != selected_ticket.id) + { + bail!("block does not prove the selected leader ticket"); + } + verify_leader_proof(block, &self.tickets)?; + } + FinalizerMode::Recovery => { + ensure_valid_recovery_block(block, self.tip())?; + } + } + + Ok(true) + } + + fn median_time_past(&self) -> u64 { + let mut timestamps = self + .chain + .iter() + .rev() + .take(BLOCK_MEDIAN_TIME_PAST_WINDOW) + .map(|block| block.timestamp_ms) + .collect::<Vec<_>>(); + timestamps.sort_unstable(); + timestamps[timestamps.len() / 2] + } + + pub(super) fn expected_reward_for_next_block( + &self, + transactions: &[Transaction], + reveal_bundle_section: &RevealBundleSection, + ) -> Result<Amount> { + let height = self.tip().height + 1; + if !aggregate_finalizer_fees_active(height) { + return fee_reward(transactions); + } + let reveal_bundle_slot_count = self.reveal_committee_for_height(height).len(); + let aggregate = self.aggregate_reveal_finalizer_fees( + height, + reveal_bundle_section, + reveal_bundle_slot_count, + )?; + block_reward(transactions, aggregate) + } + + fn expected_reward_for_block( + &self, + block: &Block, + reveal_bundle_slot_count: usize, + ) -> Result<Amount> { + if !aggregate_finalizer_fees_active(block.height) { + return fee_reward(&block.transactions); + } + let aggregate = self.aggregate_reveal_finalizer_fees( + block.height, + &block.reveal_bundle_section, + reveal_bundle_slot_count, + )?; + block_reward(&block.transactions, aggregate) + } + + fn aggregate_reveal_finalizer_fees( + &self, + height: u64, + reveal_bundle_section: &RevealBundleSection, + reveal_bundle_slot_count: usize, + ) -> Result<Amount> { + if !aggregate_finalizer_fees_active(height) { + return Ok(0); + } + reveal_bundle_section + .all_reveals() + .into_iter() + .try_fold(0_u64, |total, reveal| { + let active = self + .active_blinded + .get(&reveal.commitment) + .context("blinded reveal does not reference an active blinded transaction")?; + total + .checked_add(blinded_reveal_finalizer_fee( + active.transaction.fee, + reveal_bundle_section.included_bundle_count(), + reveal_bundle_slot_count, + )) + .context("aggregated reveal finalizer fees overflow") + }) + } +} diff --git a/src/domain/ledger_builders.rs b/src/domain/ledger_builders.rs @@ -0,0 +1,383 @@ +use anyhow::{Context, Result, anyhow, bail}; +use getrandom::getrandom; + +use super::blinded::{ + blinded_envelope_fee_for_transaction, blinded_payload_from_transaction, + blinded_transaction_commitment, blinded_transaction_signing_payload, encrypt_blinded_payload, +}; +use super::hex::{hex_encode, hex_hash}; +use super::mining::mine_signature; +use super::stratum::{ + hash_meets_difficulty, stratum_mine_header_bytes, stratum_mine_signature, stratum_mine_template, +}; +use super::transaction::{ + UnsignedTxInput, UnsignedUtxoTransaction, signed_blinded_inputs, unsigned_inputs, +}; +use super::validation::validate_address; +use super::{ + Amount, BLINDED_KEY_BYTES, BLINDED_NONCE_BYTES, BlindedReveal, BlindedTransaction, + BuiltBlindedTransaction, Ledger, MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MineSearchOutcome, + OutPoint, StratumMineShare, StratumMineTemplate, Transaction, TxOutput, Wallet, +}; + +impl Ledger { + pub fn build_transfer( + &self, + wallet: &Wallet, + to: impl Into<String>, + amount: Amount, + fee: Amount, + ) -> Result<Transaction> { + let to = to.into(); + validate_address(&to, "transfer recipient")?; + let required = amount + .checked_add(fee) + .context("transfer amount plus fee overflows")?; + let (inputs, input_total) = self.select_inputs(wallet.address(), required)?; + let mut outputs = vec![TxOutput { + address: to, + amount, + }]; + let change = input_total + .checked_sub(required) + .context("selected inputs do not cover transfer")?; + if change > 0 { + outputs.push(TxOutput { + address: wallet.address().to_string(), + amount: change, + }); + } + let transaction = UnsignedUtxoTransaction::Transfer { + inputs, + outputs, + fee, + } + .sign(wallet); + self.validate_new_transaction(&transaction)?; + Ok(transaction) + } + + pub fn build_transfer_with_inputs( + &self, + wallet: &Wallet, + to: impl Into<String>, + amount: Amount, + fee: Amount, + outpoints: &[OutPoint], + ) -> Result<Transaction> { + let to = to.into(); + validate_address(&to, "transfer recipient")?; + let required = amount + .checked_add(fee) + .context("transfer amount plus fee overflows")?; + let (inputs, input_total) = + self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?; + let mut outputs = vec![TxOutput { + address: to, + amount, + }]; + let change = input_total + .checked_sub(required) + .context("selected inputs do not cover transfer")?; + if change > 0 { + outputs.push(TxOutput { + address: wallet.address().to_string(), + amount: change, + }); + } + let transaction = UnsignedUtxoTransaction::Transfer { + inputs, + outputs, + fee, + } + .sign(wallet); + self.validate_new_transaction(&transaction)?; + Ok(transaction) + } + + pub fn build_burn(&self, wallet: &Wallet, amount: Amount, fee: Amount) -> Result<Transaction> { + let required = amount + .checked_add(fee) + .context("burn amount plus fee overflows")?; + let (inputs, input_total) = self.select_inputs(wallet.address(), required)?; + self.build_burn_from_inputs(wallet, amount, fee, inputs, input_total) + } + + pub fn build_burn_with_inputs( + &self, + wallet: &Wallet, + amount: Amount, + fee: Amount, + outpoints: &[OutPoint], + ) -> Result<Transaction> { + let required = amount + .checked_add(fee) + .context("burn amount plus fee overflows")?; + let (inputs, input_total) = + self.select_inputs_by_outpoint(wallet.address(), required, outpoints)?; + self.build_burn_from_inputs(wallet, amount, fee, inputs, input_total) + } + + fn build_burn_from_inputs( + &self, + wallet: &Wallet, + amount: Amount, + fee: Amount, + inputs: Vec<UnsignedTxInput>, + input_total: Amount, + ) -> Result<Transaction> { + let required = amount + .checked_add(fee) + .context("burn amount plus fee overflows")?; + let change_amount = input_total + .checked_sub(required) + .context("selected inputs do not cover burn")?; + let change = if change_amount > 0 { + vec![TxOutput { + address: wallet.address().to_string(), + amount: change_amount, + }] + } else { + Vec::new() + }; + let transaction = UnsignedUtxoTransaction::Burn { + inputs, + change, + amount, + fee, + } + .sign(wallet); + self.validate_new_transaction(&transaction)?; + Ok(transaction) + } + + pub fn build_blinded_burn( + &self, + wallet: &Wallet, + amount: Amount, + fee: Amount, + expires_at_height: u64, + ) -> Result<BuiltBlindedTransaction> { + let transaction = self.build_burn(wallet, amount, fee)?; + self.blind_transaction(wallet, transaction, fee, expires_at_height) + } + + pub fn build_blinded_transfer( + &self, + wallet: &Wallet, + to: impl Into<String>, + amount: Amount, + fee: Amount, + expires_at_height: u64, + ) -> Result<BuiltBlindedTransaction> { + let transaction = self.build_transfer(wallet, to, amount, fee)?; + self.blind_transaction(wallet, transaction, fee, expires_at_height) + } + + pub fn build_blinded_transaction( + &self, + wallet: &Wallet, + transaction: Transaction, + expires_at_height: u64, + ) -> Result<BuiltBlindedTransaction> { + if matches!(transaction, Transaction::Mine { .. }) { + bail!("mine actions are public and cannot be blinded"); + } + let fee = blinded_envelope_fee_for_transaction(&transaction); + self.blind_transaction(wallet, transaction, fee, expires_at_height) + } + + fn blind_transaction( + &self, + wallet: &Wallet, + transaction: Transaction, + fee: Amount, + expires_at_height: u64, + ) -> Result<BuiltBlindedTransaction> { + 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 != blinded_envelope_fee_for_transaction(&transaction) { + bail!("blinded transaction fee must match plaintext transaction fee"); + } + let unsigned_inputs = if transaction.inputs().is_empty() && transaction.fee() > 0 { + self.select_inputs(wallet.address(), transaction.fee())?.0 + } else { + unsigned_inputs(transaction.inputs()) + }; + if unsigned_inputs + .iter() + .any(|input| input.owner != wallet.address()) + { + bail!("blinded transaction inputs must be owned by the signing wallet"); + } + let blinded_payload = blinded_payload_from_transaction(&transaction)?; + let plaintext = serde_json::to_vec(&blinded_payload) + .context("failed to serialize transaction for blinded payload")?; + let payload_hash = hex_hash(&plaintext); + let unsigned_commit_inputs = signed_blinded_inputs(&unsigned_inputs, ""); + let payload = transaction; + let mut key = [0_u8; BLINDED_KEY_BYTES]; + let mut nonce = [0_u8; BLINDED_NONCE_BYTES]; + getrandom(&mut key) + .map_err(|error| anyhow!("failed to generate blinded transaction key: {error}"))?; + getrandom(&mut nonce) + .map_err(|error| anyhow!("failed to generate blinded transaction nonce: {error}"))?; + let ciphertext = encrypt_blinded_payload( + &key, + &nonce, + &unsigned_commit_inputs, + fee, + expires_at_height, + &plaintext, + )?; + let encrypted_size = u32::try_from(ciphertext.len()) + .context("blinded transaction ciphertext is too large")?; + let transaction = BlindedTransaction { + commitment: String::new(), + inputs: unsigned_commit_inputs, + fee, + encrypted_size, + expires_at_height, + nonce: hex_encode(nonce), + ciphertext: hex_encode(&ciphertext), + payload_hash, + }; + let signature = wallet.sign_payload(&blinded_transaction_signing_payload(&transaction)); + let transaction = BlindedTransaction { + inputs: signed_blinded_inputs(&unsigned_inputs, &signature), + ..transaction + }; + let commitment = blinded_transaction_commitment(&transaction)?; + let transaction = BlindedTransaction { + commitment: commitment.clone(), + ..transaction + }; + self.validate_blinded_transaction(&transaction)?; + Ok(BuiltBlindedTransaction { + payload, + transaction, + reveal: BlindedReveal { + commitment, + key: hex_encode(key), + }, + }) + } + + pub fn build_mine(&self, recipient: impl Into<String>) -> Result<Transaction> { + let recipient = recipient.into(); + validate_address(&recipient, "mine recipient")?; + let anchor = self.tip().hash.clone(); + let salt = 1; + let difficulty_bits = self.current_mine_difficulty_bits(); + for nonce in 0..u64::MAX { + let signature = mine_signature(&recipient, &anchor, salt, nonce, difficulty_bits); + if !hash_meets_difficulty(&signature, difficulty_bits) { + continue; + } + let transaction = Transaction::Mine { + recipient: recipient.clone(), + anchor: anchor.clone(), + salt, + nonce, + difficulty_bits, + proof_header: None, + signature, + }; + if self.has_transaction(transaction.signature()) { + continue; + } + self.validate_new_transaction(&transaction)?; + return Ok(transaction); + } + bail!("could not find valid mine proof"); + } + + pub fn search_mine( + &self, + recipient: impl Into<String>, + salt: u64, + start_nonce: u64, + max_attempts: u64, + ) -> Result<MineSearchOutcome> { + let recipient = recipient.into(); + validate_address(&recipient, "mine recipient")?; + let anchor = self.tip().hash.clone(); + let difficulty_bits = self.current_mine_difficulty_bits(); + let mut attempts = 0_u64; + let mut nonce = start_nonce; + while attempts < max_attempts { + let signature = mine_signature(&recipient, &anchor, salt, nonce, difficulty_bits); + attempts = attempts.saturating_add(1); + let next_nonce = nonce.checked_add(1).unwrap_or(0); + if hash_meets_difficulty(&signature, difficulty_bits) { + let transaction = Transaction::Mine { + recipient: recipient.clone(), + anchor: anchor.clone(), + salt, + nonce, + difficulty_bits, + proof_header: None, + signature, + }; + if !self.has_transaction(transaction.signature()) { + self.validate_new_transaction(&transaction)?; + return Ok(MineSearchOutcome { + transaction: Some(transaction), + next_nonce, + attempts, + }); + } + } + nonce = next_nonce; + } + Ok(MineSearchOutcome { + transaction: None, + next_nonce: nonce, + attempts, + }) + } + + pub fn stratum_mine_template( + &self, + recipient: impl Into<String>, + anchor: impl AsRef<str>, + salt: u64, + difficulty_bits: u32, + ) -> Result<StratumMineTemplate> { + stratum_mine_template(recipient, anchor.as_ref(), salt, difficulty_bits) + } + + pub fn build_stratum_mine( + &self, + template: StratumMineTemplate, + share: StratumMineShare, + ) -> Result<Transaction> { + let nonce = super::stratum::pack_stratum_nonce(share.extranonce2, share.header_nonce); + let header = stratum_mine_header_bytes( + &template.recipient, + &template.anchor, + template.salt, + nonce, + template.difficulty_bits, + )?; + let transaction = Transaction::Mine { + recipient: template.recipient, + anchor: template.anchor, + salt: template.salt, + nonce, + difficulty_bits: template.difficulty_bits, + proof_header: Some(hex_encode(header)), + signature: stratum_mine_signature(&header), + }; + self.validate_new_transaction(&transaction)?; + Ok(transaction) + } +} diff --git a/src/domain/ledger_chain.rs b/src/domain/ledger_chain.rs @@ -0,0 +1,335 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Result, bail}; + +use super::fork::{ForkChoice, ForkPoint, ForkQuality}; +use super::genesis::{build_genesis_block, utxos_after_genesis, validate_genesis_block}; +use super::ledger_ops::validate_genesis_allocations; +use super::ticket::genesis_tickets; +use super::{ + Amount, Block, ChainSnapshot, GenesisBurn, LaunchProfile, Ledger, MINE_REWARD, Transaction, + unix_now_ms, +}; + +impl Ledger { + pub fn new(genesis_allocations: BTreeMap<String, Amount>, vdf_rounds: u64) -> Self { + Self::new_with_genesis_transactions(genesis_allocations, Vec::new(), vdf_rounds) + .expect("empty genesis transactions are valid") + } + + pub fn new_with_genesis_burns( + genesis_allocations: BTreeMap<String, Amount>, + genesis_burns: Vec<GenesisBurn>, + vdf_rounds: u64, + ) -> Result<Self> { + let transactions = genesis_burns + .into_iter() + .map(|burn| { + let allocation = genesis_allocations + .get(&burn.from) + .copied() + .unwrap_or_default(); + Transaction::genesis_burn_with_allocation(burn.from, burn.amount, allocation) + }) + .collect::<Result<Vec<_>>>()?; + Self::new_with_genesis_transactions(genesis_allocations, transactions, vdf_rounds) + } + + fn new_with_genesis_transactions( + genesis_allocations: BTreeMap<String, Amount>, + genesis_transactions: Vec<Transaction>, + vdf_rounds: u64, + ) -> Result<Self> { + validate_genesis_allocations(&genesis_allocations)?; + let launch_profile = LaunchProfile::default(); + let genesis = build_genesis_block(&genesis_allocations, genesis_transactions); + let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?; + let tickets = genesis_tickets(&genesis_allocations, &genesis, &launch_profile)?; + Ok(Self { + chain: vec![genesis], + genesis_allocations: genesis_allocations.clone(), + utxos, + tickets, + pending: Vec::new(), + orphans: Vec::new(), + pending_blinded: Vec::new(), + pending_reveals: Vec::new(), + active_blinded: BTreeMap::new(), + mine_reward: MINE_REWARD, + initial_vdf_rounds: vdf_rounds, + vdf_rounds, + launch_profile, + }) + } + + pub fn from_snapshot(snapshot: ChainSnapshot) -> Result<Self> { + Self::from_snapshot_at(snapshot, unix_now_ms()) + } + + pub fn from_persisted_snapshot(snapshot: ChainSnapshot) -> Result<Self> { + Self::from_snapshot_at(snapshot, u64::MAX) + } + + pub(crate) fn from_snapshot_at(snapshot: ChainSnapshot, now_ms: u64) -> Result<Self> { + Self::from_snapshot_with_vdf_policy(snapshot, true, now_ms) + } + + fn from_snapshot_with_vdf_policy( + snapshot: ChainSnapshot, + verify_vdf: bool, + now_ms: u64, + ) -> Result<Self> { + let ChainSnapshot { + genesis_allocations, + vdf_rounds, + launch_profile, + blocks, + } = snapshot; + + if blocks.is_empty() { + bail!("chain snapshot is empty"); + } + + validate_genesis_allocations(&genesis_allocations)?; + let genesis = blocks[0].clone(); + validate_genesis_block(&genesis)?; + let expected_genesis = + build_genesis_block(&genesis_allocations, genesis.transactions.clone()); + if genesis != expected_genesis { + bail!("chain snapshot genesis does not match its allocations and transactions"); + } + let utxos = utxos_after_genesis(&genesis_allocations, &genesis)?; + + let mut ledger = Self { + chain: vec![genesis], + genesis_allocations, + utxos, + tickets: Vec::new(), + pending: Vec::new(), + orphans: Vec::new(), + pending_blinded: Vec::new(), + pending_reveals: Vec::new(), + active_blinded: BTreeMap::new(), + mine_reward: MINE_REWARD, + initial_vdf_rounds: vdf_rounds, + vdf_rounds, + launch_profile, + }; + ledger.tickets = genesis_tickets( + &ledger.genesis_allocations, + ledger.tip(), + &ledger.launch_profile, + )?; + + for block in blocks.into_iter().skip(1) { + if verify_vdf { + ledger.apply_block_at(block, now_ms)?; + } else { + ledger.apply_preverified_block_at(block, now_ms)?; + } + } + Ok(ledger) + } + + pub fn extend_from_snapshot(&mut self, snapshot: ChainSnapshot) -> Result<bool> { + self.extend_from_snapshot_with_vdf_policy(snapshot, true, unix_now_ms()) + } + + pub(crate) fn extend_from_preverified_snapshot_at( + &mut self, + snapshot: ChainSnapshot, + now_ms: u64, + ) -> Result<bool> { + self.extend_from_snapshot_with_vdf_policy(snapshot, false, now_ms) + } + + pub(crate) fn missing_snapshot_blocks(&self, snapshot: &ChainSnapshot) -> Result<Vec<Block>> { + let remote_height = self.validate_snapshot_identity(snapshot)?; + if remote_height <= self.height() { + return Ok(Vec::new()); + } + let common_ancestor_height = self.common_ancestor_height(snapshot)?; + + Ok(snapshot + .blocks + .iter() + .skip(common_ancestor_height as usize + 1) + .cloned() + .collect()) + } + + fn extend_from_snapshot_with_vdf_policy( + &mut self, + snapshot: ChainSnapshot, + verify_vdf: bool, + now_ms: u64, + ) -> Result<bool> { + self.validate_snapshot_identity(&snapshot)?; + let candidate = Self::from_snapshot_with_vdf_policy(snapshot, verify_vdf, now_ms)?; + let fork_point = self.fork_point_with_candidate(&candidate)?; + + if self.choose_fork(&candidate, fork_point) == ForkChoice::KeepLocal { + return Ok(false); + } + + self.replace_with_better_chain(candidate, fork_point); + + Ok(true) + } + + fn validate_snapshot_identity(&self, snapshot: &ChainSnapshot) -> Result<u64> { + if snapshot.blocks.is_empty() { + bail!("chain snapshot is empty"); + } + if snapshot.vdf_rounds != self.initial_vdf_rounds { + bail!("chain snapshot initial VDF rounds do not match local chain"); + } + if snapshot.launch_profile != self.launch_profile { + bail!("chain snapshot launch profile does not match local chain"); + } + if snapshot.genesis_allocations != self.genesis_allocations { + bail!("chain snapshot genesis allocations do not match local chain"); + } + if snapshot.blocks[0].hash != self.genesis_hash() { + bail!("chain snapshot genesis does not match local chain"); + } + + let remote_height = snapshot + .blocks + .last() + .map(|block| block.height) + .unwrap_or(0); + + Ok(remote_height) + } + + fn common_ancestor_height(&self, snapshot: &ChainSnapshot) -> Result<u64> { + self.validate_snapshot_identity(snapshot)?; + let max_common_index = self.chain.len().min(snapshot.blocks.len()) - 1; + for index in 0..=max_common_index { + if self.chain[index] != snapshot.blocks[index] { + if index == 0 { + bail!("chain snapshot has no common genesis block"); + } + return Ok(index as u64 - 1); + } + } + Ok(max_common_index as u64) + } + + fn fork_point_with_candidate(&self, candidate: &Ledger) -> Result<ForkPoint> { + if candidate.genesis_hash() != self.genesis_hash() { + bail!("candidate chain has no common genesis block"); + } + let max_common_index = self.chain.len().min(candidate.chain.len()) - 1; + for index in 0..=max_common_index { + if self.chain[index] != candidate.chain[index] { + if index == 0 { + bail!("candidate chain has no common genesis block"); + } + return Ok(ForkPoint { + common_ancestor_height: index as u64 - 1, + }); + } + } + Ok(ForkPoint { + common_ancestor_height: max_common_index as u64, + }) + } + + fn choose_fork(&self, candidate: &Ledger, fork_point: ForkPoint) -> ForkChoice { + let local_height = self.height(); + let remote_height = candidate.height(); + if remote_height == local_height && candidate.tip().hash == self.tip().hash { + return ForkChoice::KeepLocal; + } + + let finalized_floor = local_height.saturating_sub(super::FORK_FINALITY_DEPTH); + if fork_point.common_ancestor_height < finalized_floor { + return ForkChoice::KeepLocal; + } + + if remote_height > local_height { + return ForkChoice::SwitchToCandidate; + } + if remote_height < local_height { + return ForkChoice::KeepLocal; + } + + match self.fork_quality(candidate, fork_point) { + ForkQuality::RemoteBetter => ForkChoice::SwitchToCandidate, + ForkQuality::LocalBetter | ForkQuality::Equal => ForkChoice::KeepLocal, + } + } + + fn fork_quality(&self, candidate: &Ledger, fork_point: ForkPoint) -> ForkQuality { + let local_fork = self + .chain + .iter() + .skip(fork_point.first_diverging_height() as usize); + let remote_fork = candidate + .chain + .iter() + .skip(fork_point.first_diverging_height() as usize); + for (local, remote) in local_fork.zip(remote_fork) { + match local.leader_score().cmp(&remote.leader_score()) { + std::cmp::Ordering::Equal => continue, + ordering => return ForkQuality::from(ordering), + } + } + ForkQuality::Equal + } + + fn replace_with_better_chain(&mut self, mut candidate: Ledger, fork_point: ForkPoint) { + let mut carry_forward = self.pending.clone(); + carry_forward.extend(self.orphans.clone()); + let mut carry_forward_blinded = self.pending_blinded.clone(); + let mut carry_forward_reveals = self.pending_reveals.clone(); + for block in self + .chain + .iter() + .skip(fork_point.first_diverging_height() as usize) + { + carry_forward.extend(block.transactions.clone()); + carry_forward_blinded.extend(block.blinded_transactions.clone()); + carry_forward_reveals.extend(block.all_blinded_reveals().into_iter().cloned()); + } + + let mined_signatures = candidate + .chain + .iter() + .flat_map(|block| block.transactions.iter()) + .map(|tx| tx.signature().to_string()) + .collect::<BTreeSet<_>>(); + let mined_blinded_commitments = candidate + .chain + .iter() + .flat_map(|block| block.blinded_transactions.iter()) + .map(|transaction| transaction.commitment.clone()) + .collect::<BTreeSet<_>>(); + let mined_reveal_commitments = candidate + .chain + .iter() + .flat_map(|block| block.all_blinded_reveals()) + .map(|reveal| reveal.commitment.clone()) + .collect::<BTreeSet<_>>(); + + for transaction in carry_forward { + if !mined_signatures.contains(transaction.signature()) { + let _ = candidate.submit_transaction(transaction); + } + } + for transaction in carry_forward_blinded { + if !mined_blinded_commitments.contains(&transaction.commitment) { + let _ = candidate.submit_blinded_transaction(transaction); + } + } + for reveal in carry_forward_reveals { + if !mined_reveal_commitments.contains(&reveal.commitment) { + let _ = candidate.submit_blinded_reveal(reveal); + } + } + + *self = candidate; + } +} diff --git a/src/domain/ledger_consensus.rs b/src/domain/ledger_consensus.rs @@ -0,0 +1,120 @@ +use anyhow::Result; + +use super::mine_policy::{MINE_RETARGET_WINDOW_BLOCKS, retarget_mine_difficulty_bits}; +use super::ticket::{ + BurnTicket, base_vdf_rounds_for_finalizer_rank, mine_action_count, ranked_tickets_for_height, + vdf_rounds_for_finalizer_rank, +}; +use super::vdf::{VDF_RETARGET_WINDOW_BLOCKS, retarget_vdf_rounds, vdf_retarget_observed_block_ms}; +use super::{Block, FinalizerMode, Ledger}; + +impl Ledger { + pub(super) fn next_vdf_rounds_after_tip(&self) -> u64 { + let Some(tip) = self.chain.last() else { + return self.vdf_rounds; + }; + if tip.height < 2 { + return self.vdf_rounds; + } + + let mut total_observed_ms = 0_u128; + let mut observed_blocks = 0_u128; + for pair in self + .chain + .windows(2) + .rev() + .filter(|pair| pair[0].height > 0) + .take(VDF_RETARGET_WINDOW_BLOCKS) + { + let Some(observed_ms) = vdf_retarget_observed_block_ms(&pair[0], &pair[1]) else { + continue; + }; + total_observed_ms += u128::from(observed_ms); + observed_blocks += 1; + } + if observed_blocks == 0 { + return self.vdf_rounds; + } + + let average_observed_ms = (total_observed_ms / observed_blocks) as u64; + let base_rounds = base_vdf_rounds_for_finalizer_rank(tip.vdf_rounds, tip.finalizer_rank); + retarget_vdf_rounds(base_rounds, average_observed_ms) + } + + pub fn expected_leader_for_next_block(&self) -> Option<String> { + self.selected_ticket_for_height(self.tip().height + 1) + .map(|ticket| ticket.owner) + } + + pub fn finalizer_rank_for_next_block(&self, miner: &str) -> Option<u32> { + self.finalizer_ticket_for_miner(self.tip().height + 1, miner) + .map(|(rank, _)| rank) + } + + pub fn finalizer_rank_count_for_next_block(&self) -> usize { + ranked_tickets_for_height(self.tip(), self.tip().height + 1, &self.tickets).len() + } + + pub(super) fn selected_ticket_for_height(&self, height: u64) -> Option<BurnTicket> { + self.ticket_for_finalizer_rank(height, 0) + } + + pub(super) fn ticket_for_finalizer_rank(&self, height: u64, rank: u32) -> Option<BurnTicket> { + ranked_tickets_for_height(self.tip(), height, &self.tickets) + .get(rank as usize) + .cloned() + } + + pub(super) fn finalizer_ticket_for_miner( + &self, + height: u64, + miner: &str, + ) -> Option<(u32, BurnTicket)> { + ranked_tickets_for_height(self.tip(), height, &self.tickets) + .into_iter() + .enumerate() + .find(|(_, ticket)| ticket.owner == miner) + .and_then(|(rank, ticket)| { + let rank = u32::try_from(rank).ok()?; + Some((rank, ticket)) + }) + } + + pub(super) fn vdf_rounds_for_finalizer_rank(&self, rank: u32) -> Result<u64> { + vdf_rounds_for_finalizer_rank(self.vdf_rounds, rank) + } + + pub(super) fn recovery_vdf_rounds(&self) -> Result<u64> { + vdf_rounds_for_finalizer_rank(self.vdf_rounds, 0) + } + + pub(super) fn expected_vdf_rounds_for_block(&self, block: &Block) -> Result<u64> { + match block.finalizer_mode { + FinalizerMode::Ticket => self.vdf_rounds_for_finalizer_rank(block.finalizer_rank), + FinalizerMode::Recovery => self.recovery_vdf_rounds(), + } + } + + pub(super) fn mine_difficulty_bits_for_anchor_height(&self, anchor_height: u64) -> u32 { + let mut difficulty = self.launch_profile.mine_difficulty_bits; + let mut window_end = MINE_RETARGET_WINDOW_BLOCKS; + while window_end <= anchor_height { + let window_start = window_end + 1 - MINE_RETARGET_WINDOW_BLOCKS; + let mine_actions = self + .chain + .iter() + .filter(|block| window_start <= block.height && block.height <= window_end) + .map(mine_action_count) + .sum::<u64>(); + difficulty = retarget_mine_difficulty_bits(difficulty, mine_actions); + window_end = window_end.saturating_add(MINE_RETARGET_WINDOW_BLOCKS); + } + difficulty + } + + pub(super) fn tip(&self) -> &Block { + self.chain + .last() + .expect("ledger is always initialized with genesis") + } +} diff --git a/src/domain/ledger_mempool.rs b/src/domain/ledger_mempool.rs @@ -0,0 +1,93 @@ +use anyhow::{Result, bail}; + +use super::ledger_ops::{ + apply_transaction, spend_blinded_inputs, spend_inputs, transaction_has_missing_inputs, +}; +use super::transaction::{ + BlindedReveal, BlindedTransaction, Transaction, blinded_transaction_inputs_spent_by, + transaction_inputs_spent_by, transaction_inputs_spent_by_inputs, +}; +use super::{Ledger, MAX_ORPHAN_TRANSACTIONS, MAX_PENDING_TRANSACTIONS, TransactionSubmitOutcome}; + +impl Ledger { + pub fn submit_transaction(&mut self, transaction: Transaction) -> Result<bool> { + Ok(self.submit_transaction_with_outcome(transaction)?.added()) + } + + pub(crate) fn reserve_transaction_inputs(&mut self, transaction: &Transaction) -> Result<()> { + self.validate_new_transaction(transaction)?; + let mut utxos = self.utxos.clone(); + spend_inputs(transaction, &mut utxos)?; + self.utxos = utxos; + Ok(()) + } + + pub fn submit_blinded_transaction(&mut self, transaction: BlindedTransaction) -> Result<bool> { + if self.has_blinded_transaction(&transaction.commitment) { + return Ok(false); + } + self.validate_blinded_transaction(&transaction)?; + if blinded_transaction_inputs_spent_by(&transaction, &self.pending_blinded) + || transaction_inputs_spent_by_inputs(&transaction.inputs, &self.pending) + || transaction_inputs_spent_by_inputs(&transaction.inputs, &self.orphans) + { + bail!("blinded transaction conflicts with pending inputs"); + } + let mut utxos = self.utxos_after_valid_pending_and_blinded()?; + spend_blinded_inputs(&transaction, &mut utxos)?; + if self.pending_blinded.len() >= MAX_PENDING_TRANSACTIONS { + bail!("blinded mempool is full"); + } + self.pending_blinded.push(transaction); + Ok(true) + } + + pub fn submit_blinded_reveal(&mut self, reveal: BlindedReveal) -> Result<bool> { + if self.has_blinded_reveal(&reveal.commitment) { + return Ok(false); + } + self.validate_blinded_reveal_terms(&reveal)?; + if self.pending_reveals.len() >= MAX_PENDING_TRANSACTIONS { + bail!("blinded reveal pool is full"); + } + self.pending_reveals.push(reveal); + Ok(true) + } + + pub fn submit_transaction_with_outcome( + &mut self, + transaction: Transaction, + ) -> Result<TransactionSubmitOutcome> { + if self.has_transaction(transaction.signature()) { + return Ok(TransactionSubmitOutcome::AlreadyKnown); + } + + transaction.verify_signature()?; + self.validate_transaction_terms(&transaction)?; + self.validate_mine_anchor_available(&transaction)?; + + if transaction_inputs_spent_by(&transaction, &self.pending) { + return Ok(TransactionSubmitOutcome::ConflictsWithPending); + } + if transaction_inputs_spent_by(&transaction, &self.orphans) { + return Ok(TransactionSubmitOutcome::ConflictsWithPending); + } + + if self.pending.len() >= MAX_PENDING_TRANSACTIONS { + bail!("mempool is full"); + } + + let mut utxos = self.utxos_after_valid_pending_and_blinded()?; + if transaction_has_missing_inputs(&transaction, &utxos) { + if self.orphans.len() >= MAX_ORPHAN_TRANSACTIONS { + bail!("orphan transaction pool is full"); + } + self.orphans.push(transaction); + return Ok(TransactionSubmitOutcome::Added); + } + apply_transaction(&transaction, &mut utxos)?; + self.pending.push(transaction); + self.promote_orphan_transactions()?; + Ok(TransactionSubmitOutcome::Added) + } +} diff --git a/src/domain/ledger_ops.rs b/src/domain/ledger_ops.rs @@ -0,0 +1,642 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result, bail}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + +use super::blinded::verify_blinded_input_signatures; +use super::hex::hex_hash; +use super::reveal::{RevealBundleSection, canonical_reveal_bundle_hashes}; +use super::selection::{TransactionKind, blinded_fee_rate_key, fee_rate_key}; +use super::ticket::ticket_is_eligible_for_height; +use super::transaction::{BlindedTransaction, Transaction}; +use super::{ + AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT, Amount, Block, BlockSelection, BurnTicket, + FinalizerMode, LeaderProof, LeaderProofPayload, Ledger, MINE_REWARD, OutPoint, + PUBLIC_KEY_BYTES, RECOVERY_BLOCK_DELAY_MS, REVEAL_COMMITTEE_SIZE, SIGNATURE_BYTES, TxInput, + TxOutput, decode_hex_array, validate_address, validate_hash, validate_protocol_id, + validate_signature, +}; + +pub(super) fn validate_genesis_allocations( + genesis_allocations: &BTreeMap<String, Amount>, +) -> Result<()> { + for address in genesis_allocations.keys() { + validate_address(address, "genesis allocation")?; + } + Ok(()) +} + +pub(super) fn validate_transaction_inputs(inputs: &[TxInput]) -> Result<()> { + for input in inputs { + validate_protocol_id(&input.outpoint.txid, "input outpoint txid")?; + validate_address(&input.owner, "input owner")?; + validate_signature(&input.signature, "input signature")?; + } + Ok(()) +} + +pub(super) fn validate_transaction_outputs(outputs: &[TxOutput]) -> Result<()> { + for output in outputs { + validate_address(&output.address, "output recipient")?; + } + Ok(()) +} + +pub(super) fn validate_genesis_burn_transaction(transaction: &Transaction) -> Result<()> { + let Transaction::Burn { + inputs, + change, + fee, + signature, + .. + } = transaction + else { + bail!("genesis only supports burn transactions"); + }; + if *fee != 0 { + bail!("genesis burn fee must be zero"); + } + validate_hash(signature, "genesis burn signature")?; + validate_transaction_outputs(change)?; + for input in inputs { + validate_hash(&input.outpoint.txid, "genesis burn input outpoint txid")?; + validate_address(&input.owner, "genesis burn input owner")?; + if input.signature != "genesis" { + bail!("genesis burn input signature is invalid"); + } + } + Ok(()) +} + +pub(super) fn estimated_block_selection_size_bytes( + selection: &BlockSelection, + recovery: bool, +) -> Result<usize> { + let block = Block { + height: u64::MAX, + prev_hash: "f".repeat(64), + timestamp_ms: u64::MAX, + miner: "f".repeat(64), + finalizer_mode: if recovery { + FinalizerMode::Recovery + } else { + FinalizerMode::Ticket + }, + finalizer_rank: 0, + reward: u64::MAX, + vdf_rounds: u64::MAX, + vdf_output: "f".repeat(64), + leader_proof: (!recovery).then(|| LeaderProof { + ticket_id: "f".repeat(64), + public_key: "f".repeat(64), + signature: "f".repeat(128), + }), + blinded_transactions: selection.blinded_transactions.clone(), + reveal_bundle_section: RevealBundleSection::default(), + transactions: selection.transactions.clone(), + hash: "f".repeat(64), + }; + block.serialized_size_bytes() +} + +pub(super) fn verify_leader_proof(block: &Block, tickets: &[BurnTicket]) -> Result<()> { + let Some(proof) = &block.leader_proof else { + bail!("block is missing leader proof"); + }; + if proof.public_key != block.miner { + bail!("leader proof public key does not match block finalizer"); + } + let ticket = tickets + .iter() + .find(|ticket| { + ticket.id == proof.ticket_id && ticket_is_eligible_for_height(ticket, block.height) + }) + .context("leader ticket is not pending for this height")?; + if ticket.owner != block.miner { + bail!("leader ticket owner does not match block finalizer"); + } + if ticket.eligible_from_height > block.height { + bail!("leader ticket is not mature"); + } + + let payload = LeaderProofPayload { + height: block.height, + prev_hash: block.prev_hash.clone(), + finalizer_rank: block.finalizer_rank, + vdf_output: block.vdf_output.clone(), + ticket_id: ticket.id.clone(), + ticket_amount: ticket.amount, + ticket_owner: ticket.owner.clone(), + }; + verify_leader_signature(proof, &payload)?; + Ok(()) +} + +pub(super) fn verify_leader_signature( + proof: &LeaderProof, + payload: &LeaderProofPayload, +) -> Result<()> { + verify_address_signature( + &proof.public_key, + &payload.canonical(), + &proof.signature, + "leader", + ) +} + +pub(super) fn verify_address_signature( + address: &str, + payload: &str, + signature: &str, + label: &str, +) -> Result<()> { + let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(address) + .with_context(|| format!("invalid {label} public key {address}"))?; + let signature = decode_hex_array::<SIGNATURE_BYTES>(signature) + .with_context(|| format!("invalid {label} signature hex"))?; + let verifying_key = VerifyingKey::from_bytes(&public_key) + .with_context(|| format!("invalid {label} public key"))?; + let signature = Signature::from_bytes(&signature); + verifying_key + .verify(payload.as_bytes(), &signature) + .with_context(|| format!("{label} signature is invalid")) +} + +pub(super) fn vdf_seed_for_child( + prev_hash: &str, + height: u64, + bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE], +) -> String { + hex_hash(format!( + "iuna-vdf-child:{prev_hash}:{height}:{}", + canonical_reveal_bundle_hashes(bundle_hashes) + )) +} + +pub(super) fn recovery_vdf_seed_for_child( + prev_hash: &str, + height: u64, + timestamp_ms: u64, + bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE], +) -> String { + hex_hash(format!( + "iuna-recovery-vdf-child:{prev_hash}:{height}:{timestamp_ms}:{}", + canonical_reveal_bundle_hashes(bundle_hashes) + )) +} + +pub(super) fn apply_transaction( + transaction: &Transaction, + utxos: &mut BTreeMap<OutPoint, TxOutput>, +) -> Result<()> { + transaction.verify_signature()?; + match transaction { + Transaction::Mine { recipient, .. } => { + let output = TxOutput { + address: recipient.clone(), + amount: MINE_REWARD, + }; + ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; + utxos.insert( + OutPoint { + txid: transaction.signature().to_string(), + index: 0, + }, + output, + ); + return Ok(()); + } + Transaction::Transfer { .. } | Transaction::Burn { .. } => {} + } + ensure_single_input_owner(transaction)?; + let input_total = spend_inputs(transaction, utxos)?; + let outputs = transaction.outputs(); + let output_total = outputs.iter().try_fold(0_u64, |total, output| { + total + .checked_add(output.amount) + .context("transaction outputs overflow") + })?; + let required = output_total + .checked_add(transaction.fee()) + .context("transaction outputs plus fee overflow")? + .checked_add(match transaction { + Transaction::Burn { amount, .. } => *amount, + Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, + }) + .context("transaction outputs plus burn overflow")?; + if input_total != required { + bail!("transaction inputs do not balance outputs, burn, and fee"); + } + ensure_outputs_do_not_overflow(utxos, &outputs)?; + for (index, output) in outputs.iter().enumerate() { + utxos.insert( + OutPoint { + txid: transaction.signature().to_string(), + index: index as u32, + }, + output.clone(), + ); + } + Ok(()) +} + +pub(super) fn validate_block_blinded_items(block: &Block, ledger: &Ledger) -> Result<()> { + let mut commitments = BTreeSet::new(); + for transaction in &block.blinded_transactions { + if !commitments.insert(transaction.commitment.clone()) { + bail!("duplicate blinded transaction in block"); + } + ledger.validate_blinded_transaction(transaction)?; + if transaction.expires_at_height <= block.height { + bail!("blinded transaction is expired for block height"); + } + if ledger.active_blinded.contains_key(&transaction.commitment) { + bail!("blinded transaction is already active"); + } + if ledger.chain.iter().any(|block| { + block + .blinded_transactions + .iter() + .any(|existing| existing.commitment == transaction.commitment) + }) { + bail!("blinded transaction is already on chain"); + } + } + + let mut reveals = BTreeSet::new(); + for reveal in block.all_blinded_reveals() { + if !reveals.insert(reveal.commitment.clone()) { + bail!("duplicate blinded reveal in block"); + } + if ledger.chain.iter().any(|block| { + block + .all_blinded_reveals() + .iter() + .any(|existing| existing.commitment == reveal.commitment) + }) { + bail!("blinded reveal is already on chain"); + } + ledger.pending_reveal_transaction(reveal)?; + } + Ok(()) +} + +pub(super) fn fee_reward(transactions: &[Transaction]) -> Result<Amount> { + transactions.iter().try_fold(0_u64, |total, tx| { + total.checked_add(tx.fee()).context("block fees overflow") + }) +} + +pub(super) fn block_reward( + transactions: &[Transaction], + aggregated_reveal_finalizer_fees: Amount, +) -> Result<Amount> { + fee_reward(transactions)? + .checked_add(aggregated_reveal_finalizer_fees) + .context("block reward overflow") +} + +pub(super) fn aggregate_finalizer_fees_active(height: u64) -> bool { + height >= AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT +} + +pub(super) fn spend_inputs( + transaction: &Transaction, + utxos: &mut BTreeMap<OutPoint, TxOutput>, +) -> Result<Amount> { + let mut seen = BTreeSet::new(); + let mut total = 0_u64; + for input in transaction.inputs() { + if !seen.insert(input.outpoint.clone()) { + bail!("duplicate input in transaction"); + } + let output = utxos.remove(&input.outpoint).with_context(|| { + format!("transaction spends missing output {}", input.outpoint.id()) + })?; + if output.address != input.owner { + bail!("transaction input owner does not match spent output"); + } + total = total + .checked_add(output.amount) + .context("transaction input total overflows")?; + } + Ok(total) +} + +pub(super) fn apply_spendable_pending_transaction( + transaction: &Transaction, + utxos: &mut BTreeMap<OutPoint, TxOutput>, +) -> Result<()> { + if matches!(transaction, Transaction::Mine { .. }) { + bail!("pending mine outputs are not spendable"); + } + transaction.verify_signature()?; + ensure_single_input_owner(transaction)?; + let input_total = transaction_input_total(transaction, utxos)?; + let outputs = transaction.outputs(); + let output_total = outputs.iter().try_fold(0_u64, |total, output| { + total + .checked_add(output.amount) + .context("transaction outputs overflow") + })?; + let required = output_total + .checked_add(transaction.fee()) + .context("transaction outputs plus fee overflow")? + .checked_add(match transaction { + Transaction::Burn { amount, .. } => *amount, + Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, + }) + .context("transaction outputs plus burn overflow")?; + if input_total != required { + bail!("transaction inputs do not balance outputs, burn, and fee"); + } + ensure_outputs_do_not_overflow(utxos, &outputs)?; + for input in transaction.inputs() { + utxos.remove(&input.outpoint); + } + for (index, output) in outputs.iter().enumerate() { + utxos.insert( + OutPoint { + txid: transaction.signature().to_string(), + index: index as u32, + }, + output.clone(), + ); + } + Ok(()) +} + +pub(super) fn transaction_input_total( + transaction: &Transaction, + utxos: &BTreeMap<OutPoint, TxOutput>, +) -> Result<Amount> { + let mut seen = BTreeSet::new(); + let mut total = 0_u64; + for input in transaction.inputs() { + if !seen.insert(input.outpoint.clone()) { + bail!("duplicate input in transaction"); + } + let output = utxos.get(&input.outpoint).with_context(|| { + format!("transaction spends missing output {}", input.outpoint.id()) + })?; + if output.address != input.owner { + bail!("transaction input owner does not match spent output"); + } + total = total + .checked_add(output.amount) + .context("transaction input total overflows")?; + } + Ok(total) +} + +pub(super) fn spend_blinded_inputs( + transaction: &BlindedTransaction, + utxos: &mut BTreeMap<OutPoint, TxOutput>, +) -> Result<Vec<TxOutput>> { + verify_blinded_input_signatures(transaction)?; + if transaction.inputs.is_empty() { + return Ok(Vec::new()); + } + let mut seen = BTreeSet::new(); + let mut locked = Vec::new(); + for input in &transaction.inputs { + if !seen.insert(input.outpoint.clone()) { + bail!("duplicate input in blinded transaction"); + } + let output = utxos.remove(&input.outpoint).with_context(|| { + format!( + "blinded transaction spends missing output {}", + input.outpoint.id() + ) + })?; + if output.address != input.owner { + bail!("blinded transaction input owner does not match spent output"); + } + locked.push(output); + } + let locked_total = locked.iter().try_fold(0_u64, |total, output| { + total + .checked_add(output.amount) + .context("blinded transaction locked input total overflows") + })?; + if transaction.fee > locked_total { + bail!("blinded transaction fee exceeds locked inputs"); + } + Ok(locked) +} + +pub(super) fn spend_spendable_blinded_inputs( + transaction: &BlindedTransaction, + utxos: &mut BTreeMap<OutPoint, TxOutput>, +) -> Result<Vec<TxOutput>> { + let locked = blinded_input_outputs(transaction, utxos)?; + for input in &transaction.inputs { + utxos.remove(&input.outpoint); + } + Ok(locked) +} + +pub(super) fn blinded_input_outputs( + transaction: &BlindedTransaction, + utxos: &BTreeMap<OutPoint, TxOutput>, +) -> Result<Vec<TxOutput>> { + verify_blinded_input_signatures(transaction)?; + if transaction.inputs.is_empty() { + return Ok(Vec::new()); + } + let mut seen = BTreeSet::new(); + let mut locked = Vec::new(); + for input in &transaction.inputs { + if !seen.insert(input.outpoint.clone()) { + bail!("duplicate input in blinded transaction"); + } + let output = utxos.get(&input.outpoint).with_context(|| { + format!( + "blinded transaction spends missing output {}", + input.outpoint.id() + ) + })?; + if output.address != input.owner { + bail!("blinded transaction input owner does not match spent output"); + } + locked.push(output.clone()); + } + let locked_total = locked.iter().try_fold(0_u64, |total, output| { + total + .checked_add(output.amount) + .context("blinded transaction locked input total overflows") + })?; + if transaction.fee > locked_total { + bail!("blinded transaction fee exceeds locked inputs"); + } + Ok(locked) +} + +pub(super) fn transaction_has_missing_inputs( + transaction: &Transaction, + utxos: &BTreeMap<OutPoint, TxOutput>, +) -> bool { + transaction + .inputs() + .iter() + .any(|input| !utxos.contains_key(&input.outpoint)) +} + +pub(super) fn ensure_single_input_owner(transaction: &Transaction) -> Result<()> { + if matches!(transaction, Transaction::Mine { .. }) { + return Ok(()); + } + ensure_single_input_owner_for_inputs(transaction.inputs()) +} + +pub(super) fn ensure_single_input_owner_for_inputs(inputs: &[TxInput]) -> Result<()> { + let Some(first) = inputs.first() else { + bail!("transaction has no inputs"); + }; + if inputs.iter().any(|input| input.owner != first.owner) { + bail!("transaction inputs must have one owner"); + } + Ok(()) +} + +pub(super) fn credit_reward_output( + utxos: &mut BTreeMap<OutPoint, TxOutput>, + block: &Block, +) -> Result<()> { + if block.reward == 0 { + return Ok(()); + } + let output = TxOutput { + address: block.miner.clone(), + amount: block.reward, + }; + ensure_outputs_do_not_overflow(utxos, std::slice::from_ref(&output))?; + utxos.insert(reward_outpoint(&block.hash), output); + Ok(()) +} + +pub(super) fn ensure_outputs_do_not_overflow( + utxos: &BTreeMap<OutPoint, TxOutput>, + outputs: &[TxOutput], +) -> Result<()> { + let mut balances = BTreeMap::new(); + for output in utxos.values() { + let balance = balances.entry(output.address.clone()).or_insert(0_u64); + *balance = balance + .checked_add(output.amount) + .with_context(|| format!("balance overflow for {}", output.address))?; + } + for output in outputs { + let balance = balances.entry(output.address.clone()).or_insert(0_u64); + *balance = balance + .checked_add(output.amount) + .with_context(|| format!("balance overflow for {}", output.address))?; + } + Ok(()) +} + +pub(super) fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> { + if !transactions.iter().any(Transaction::is_burn) { + bail!("block must include at least one burn transaction"); + } + Ok(()) +} + +pub(super) fn ensure_block_has_burn_from(transactions: &[Transaction], miner: &str) -> Result<()> { + if !transactions + .iter() + .any(|transaction| transaction.is_burn() && transaction.sender() == miner) + { + bail!("recovery block must include a burn from the finalizer"); + } + Ok(()) +} + +pub(super) fn ensure_valid_recovery_block(block: &Block, parent: &Block) -> Result<()> { + if block.finalizer_rank != 0 { + bail!("recovery block finalizer rank must be 0"); + } + if block.leader_proof.is_some() { + bail!("recovery block must not carry a leader proof"); + } + let min_timestamp = parent.timestamp_ms.saturating_add(RECOVERY_BLOCK_DELAY_MS); + if block.timestamp_ms < min_timestamp { + bail!("recovery block is not available before timestamp {min_timestamp}"); + } + ensure_block_has_burn_from(&block.transactions, &block.miner) +} + +pub(super) fn best_selectable_blinded_index( + transactions: &[BlindedTransaction], + utxos: &BTreeMap<OutPoint, TxOutput>, +) -> Option<usize> { + transactions + .iter() + .enumerate() + .filter(|(_, transaction)| { + let mut utxos = utxos.clone(); + spend_blinded_inputs(transaction, &mut utxos).is_ok() + }) + .max_by(|(_, left), (_, right)| { + blinded_fee_rate_key(left) + .cmp(&blinded_fee_rate_key(right)) + .then_with(|| left.fee.cmp(&right.fee)) + .then_with(|| right.commitment.cmp(&left.commitment)) + }) + .map(|(index, _)| index) +} + +pub(super) fn best_selectable_transaction_index( + transactions: &[Transaction], + utxos: &BTreeMap<OutPoint, TxOutput>, + required_kind: Option<TransactionKind>, +) -> Option<usize> { + transactions + .iter() + .enumerate() + .filter(|(_, tx)| match required_kind { + Some(TransactionKind::Burn) => tx.is_burn(), + None => true, + }) + .filter(|(_, tx)| { + let mut utxos = utxos.clone(); + apply_transaction(tx, &mut utxos).is_ok() + }) + .max_by(|(_, left), (_, right)| { + fee_rate_key(left) + .cmp(&fee_rate_key(right)) + .then_with(|| left.fee().cmp(&right.fee())) + .then_with(|| left.is_burn().cmp(&right.is_burn())) + .then_with(|| right.signature().cmp(left.signature())) + }) + .map(|(index, _)| index) +} + +pub(super) fn best_selectable_burn_from_index( + transactions: &[Transaction], + utxos: &BTreeMap<OutPoint, TxOutput>, + owner: &str, +) -> Option<usize> { + transactions + .iter() + .enumerate() + .filter(|(_, tx)| tx.is_burn() && tx.sender() == owner) + .filter(|(_, tx)| { + let mut utxos = utxos.clone(); + apply_transaction(tx, &mut utxos).is_ok() + }) + .max_by(|(_, left), (_, right)| { + fee_rate_key(left) + .cmp(&fee_rate_key(right)) + .then_with(|| left.fee().cmp(&right.fee())) + .then_with(|| right.signature().cmp(left.signature())) + }) + .map(|(index, _)| index) +} + +pub(super) fn reward_outpoint(block_hash: &str) -> OutPoint { + OutPoint { + txid: block_hash.to_string(), + index: u32::MAX, + } +} diff --git a/src/domain/ledger_pending.rs b/src/domain/ledger_pending.rs @@ -0,0 +1,602 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result, bail}; + +use super::blinded::{ + ActiveBlindedTransaction, blinded_envelope_fee_for_transaction, blinded_locked_output_total, + blinded_reveal_inputs_match, blinded_transaction_commitment, decrypt_blinded_transaction, + verify_blinded_input_signatures, +}; +use super::ledger_ops::{ + apply_spendable_pending_transaction, apply_transaction, best_selectable_blinded_index, + best_selectable_burn_from_index, best_selectable_transaction_index, + ensure_outputs_do_not_overflow, ensure_single_input_owner, + estimated_block_selection_size_bytes, spend_blinded_inputs, spend_spendable_blinded_inputs, + transaction_has_missing_inputs, validate_transaction_inputs, validate_transaction_outputs, +}; +use super::mine_policy::{ + MINE_MAX_ANCHOR_AGE_BLOCKS, mine_actions_per_anchor_limit_active, mine_anchor, + mine_anchor_count_before_height, +}; +use super::selection::{ + BlockSelection, SelectableItem, TransactionKind, best_selectable_item, blinded_fee_rate_key, + fee_rate_key, +}; +use super::transaction::{ + UnsignedTxInput, transaction_inputs_available, transaction_inputs_spent_by, +}; +use super::validation::{ + validate_address, validate_hash, validate_signature, validate_stratum_header, +}; +use super::{ + Amount, BLINDED_KEY_BYTES, BLINDED_NONCE_BYTES, BlindedReveal, BlindedTransaction, Ledger, + MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MAX_PENDING_TRANSACTIONS, + MINE_ACTIONS_PER_ANCHOR_LIMIT, OutPoint, Transaction, TxOutput, decode_hex, decode_hex_array, +}; + +impl Ledger { + pub(super) fn valid_pending_transactions(&self) -> Vec<Transaction> { + let mut utxos = self.utxos.clone(); + let mut valid = Vec::new(); + let mut remaining = self.pending.iter().collect::<Vec<_>>(); + let mut selected_mine_anchor_counts = BTreeMap::new(); + + while !remaining.is_empty() { + let mut progressed = false; + let mut still_pending = Vec::new(); + + for tx in remaining { + if let Some(anchor) = mine_anchor(tx) { + let selected = selected_mine_anchor_counts + .get(anchor) + .copied() + .unwrap_or_default(); + if mine_anchor_count_before_height(&self.chain, anchor, self.height()) + .saturating_add(selected) + >= MINE_ACTIONS_PER_ANCHOR_LIMIT + { + continue; + } + } + if transaction_inputs_available(tx, &utxos) + && self.validate_transaction_terms(tx).is_ok() + && apply_transaction(tx, &mut utxos).is_ok() + { + if let Some(anchor) = mine_anchor(tx) { + selected_mine_anchor_counts + .entry(anchor) + .and_modify(|count| *count += 1) + .or_insert(1); + } + valid.push(tx.clone()); + progressed = true; + } else { + still_pending.push(tx); + } + } + + if !progressed { + break; + } + + remaining = still_pending; + } + + valid + } + + pub(super) fn select_block_transactions( + &self, + required_burn_signature: Option<&str>, + ) -> Result<BlockSelection> { + self.select_block_transactions_with_required_burn_owner(None, required_burn_signature) + } + + pub(super) fn select_recovery_block_transactions( + &self, + miner: &str, + required_burn_signature: Option<&str>, + ) -> Result<BlockSelection> { + self.select_block_transactions_with_required_burn_owner( + Some(miner), + required_burn_signature, + ) + } + + pub(super) fn select_block_transactions_with_required_burn_owner( + &self, + required_burn_owner: Option<&str>, + required_burn_signature: Option<&str>, + ) -> Result<BlockSelection> { + let mut utxos = self.utxos.clone(); + let mut remaining = self.valid_pending_transactions(); + let mut remaining_blinded = self.valid_pending_blinded_transactions(); + let mut selected = Vec::new(); + let mut selected_blinded = Vec::new(); + + if let Some(signature) = required_burn_signature { + let index = remaining + .iter() + .position(|transaction| transaction.signature() == signature) + .with_context(|| format!("required burn {signature} is not pending"))?; + let tx = remaining.remove(index); + if !tx.is_burn() { + bail!("required block anchor must be a burn transaction"); + } + if let Some(owner) = required_burn_owner { + if tx.sender() != owner { + bail!("required block anchor burn must be from the recovery finalizer"); + } + } + let candidate = BlockSelection { + transactions: vec![tx.clone()], + blinded_transactions: selected_blinded.clone(), + }; + if estimated_block_selection_size_bytes(&candidate, required_burn_owner.is_some())? + > self.launch_profile.max_block_bytes + { + bail!("required block anchor burn does not fit in the block"); + } + apply_transaction(&tx, &mut utxos) + .context("required block anchor burn is not spendable")?; + selected.push(tx); + } + + let needs_first_burn = !selected.iter().any(Transaction::is_burn); + let needs_owner_burn = required_burn_owner.is_some_and(|owner| { + !selected + .iter() + .any(|transaction| transaction.is_burn() && transaction.sender() == owner) + }); + if needs_first_burn || needs_owner_burn { + let first_burn_index = if let Some(owner) = required_burn_owner { + best_selectable_burn_from_index(&remaining, &utxos, owner) + } else { + best_selectable_transaction_index(&remaining, &utxos, Some(TransactionKind::Burn)) + }; + if let Some(index) = first_burn_index { + let tx = remaining.remove(index); + let mut candidate = BlockSelection { + transactions: selected.clone(), + blinded_transactions: selected_blinded.clone(), + }; + candidate.transactions.push(tx.clone()); + if estimated_block_selection_size_bytes(&candidate, required_burn_owner.is_some())? + <= self.launch_profile.max_block_bytes + { + apply_transaction(&tx, &mut utxos)?; + selected.push(tx); + } + } + } + + while selected.len() < self.launch_profile.max_block_transactions { + let selected_count = selected.len() + selected_blinded.len(); + if selected_count >= self.launch_profile.max_block_transactions { + break; + } + + let best_plain = best_selectable_transaction_index(&remaining, &utxos, None) + .map(|index| SelectableItem::Plain(index, fee_rate_key(&remaining[index]))); + let best_blinded = + best_selectable_blinded_index(&remaining_blinded, &utxos).map(|index| { + SelectableItem::Blinded(index, blinded_fee_rate_key(&remaining_blinded[index])) + }); + let Some(item) = best_selectable_item(best_plain, best_blinded) else { + break; + }; + + match item { + SelectableItem::Plain(index, _) => { + let tx = remaining.remove(index); + let mut candidate = BlockSelection { + transactions: selected.clone(), + blinded_transactions: selected_blinded.clone(), + }; + candidate.transactions.push(tx.clone()); + if estimated_block_selection_size_bytes( + &candidate, + required_burn_owner.is_some(), + )? <= self.launch_profile.max_block_bytes + { + apply_transaction(&tx, &mut utxos)?; + selected.push(tx); + } + } + SelectableItem::Blinded(index, _) => { + let transaction = remaining_blinded.remove(index); + let mut candidate = BlockSelection { + transactions: selected.clone(), + blinded_transactions: selected_blinded.clone(), + }; + candidate.blinded_transactions.push(transaction.clone()); + if estimated_block_selection_size_bytes( + &candidate, + required_burn_owner.is_some(), + )? <= self.launch_profile.max_block_bytes + { + spend_blinded_inputs(&transaction, &mut utxos)?; + selected_blinded.push(transaction); + } + } + } + } + Ok(BlockSelection { + transactions: selected, + blinded_transactions: selected_blinded, + }) + } + + pub(super) fn select_inputs( + &self, + address: &str, + amount: Amount, + ) -> Result<(Vec<UnsignedTxInput>, Amount)> { + let utxos = self.utxos_after_spendable_pending()?; + let mut selected = Vec::new(); + let mut total = 0_u64; + for (outpoint, output) in &utxos { + if output.address != address { + continue; + } + selected.push(UnsignedTxInput { + outpoint: outpoint.clone(), + owner: address.to_string(), + }); + total = total + .checked_add(output.amount) + .context("selected input total overflows")?; + if total >= amount { + return Ok((selected, total)); + } + } + bail!("insufficient funds for {address}") + } + + pub(super) fn select_inputs_by_outpoint( + &self, + address: &str, + amount: Amount, + outpoints: &[OutPoint], + ) -> Result<(Vec<UnsignedTxInput>, Amount)> { + if outpoints.is_empty() { + bail!("at least one UTXO must be selected"); + } + let utxos = self.utxos_after_spendable_pending()?; + let mut seen = BTreeSet::new(); + let mut selected = Vec::new(); + let mut total = 0_u64; + for outpoint in outpoints { + if !seen.insert(outpoint.clone()) { + bail!("selected UTXO {} is duplicated", outpoint.id()); + } + let output = utxos + .get(outpoint) + .with_context(|| format!("selected UTXO {} is not spendable", outpoint.id()))?; + if output.address != address { + bail!("selected UTXO {} is not owned by {address}", outpoint.id()); + } + selected.push(UnsignedTxInput { + outpoint: outpoint.clone(), + owner: address.to_string(), + }); + total = total + .checked_add(output.amount) + .context("selected input total overflows")?; + } + if total < amount { + bail!("selected UTXOs do not cover amount plus fee"); + } + Ok((selected, total)) + } + + pub(super) fn validate_new_transaction(&self, transaction: &Transaction) -> Result<()> { + self.validate_transaction_terms(transaction)?; + self.validate_mine_anchor_available(transaction)?; + let mut utxos = self.utxos_after_spendable_pending()?; + apply_transaction(transaction, &mut utxos) + } + + pub(super) fn validate_mine_anchor_available(&self, transaction: &Transaction) -> Result<()> { + if mine_actions_per_anchor_limit_active(self.height().saturating_add(1)) { + if let Some(anchor) = mine_anchor(transaction) { + let known_count = + mine_anchor_count_before_height(&self.chain, anchor, self.height()) + .saturating_add( + self.pending + .iter() + .filter(|tx| mine_anchor(tx) == Some(anchor)) + .count(), + ) + .saturating_add( + self.orphans + .iter() + .filter(|tx| { + mine_anchor(tx) == Some(anchor) + && tx.signature() != transaction.signature() + }) + .count(), + ); + if known_count >= MINE_ACTIONS_PER_ANCHOR_LIMIT { + bail!("mine transaction anchor limit reached"); + } + } + } + Ok(()) + } + + pub(super) fn promote_orphan_transactions(&mut self) -> Result<()> { + loop { + if self.pending.len() >= MAX_PENDING_TRANSACTIONS { + return Ok(()); + } + let mut promoted_index = None; + let mut utxos = self.utxos_after_valid_pending_and_blinded()?; + for (index, transaction) in self.orphans.iter().enumerate() { + if transaction_inputs_spent_by(transaction, &self.pending) { + continue; + } + if transaction_has_missing_inputs(transaction, &utxos) { + continue; + } + if self.validate_new_transaction(transaction).is_ok() + && apply_transaction(transaction, &mut utxos).is_ok() + { + promoted_index = Some(index); + break; + } + } + + let Some(index) = promoted_index else { + return Ok(()); + }; + self.pending.push(self.orphans.remove(index)); + } + } + + pub(super) fn validate_transaction_terms(&self, transaction: &Transaction) -> Result<()> { + match transaction { + Transaction::Transfer { + inputs, + outputs, + signature, + .. + } => { + validate_transaction_inputs(inputs)?; + validate_transaction_outputs(outputs)?; + validate_signature(signature, "transaction signature")?; + } + Transaction::Burn { + inputs, + change, + signature, + .. + } => { + validate_transaction_inputs(inputs)?; + validate_transaction_outputs(change)?; + validate_signature(signature, "transaction signature")?; + } + Transaction::Mine { + recipient, + anchor, + difficulty_bits, + proof_header, + signature, + .. + } => { + validate_address(recipient, "mine recipient")?; + validate_hash(anchor, "mine transaction anchor")?; + validate_hash(signature, "mine transaction proof hash")?; + if let Some(proof_header) = proof_header { + validate_stratum_header(proof_header)?; + } + let anchor_block = self + .chain + .iter() + .find(|block| block.hash == *anchor) + .context("mine transaction anchor is not on this chain")?; + let anchor_age = self.tip().height.saturating_sub(anchor_block.height); + if anchor_age > MINE_MAX_ANCHOR_AGE_BLOCKS { + bail!("mine transaction anchor is too old"); + } + let required_difficulty = + self.mine_difficulty_bits_for_anchor_height(anchor_block.height); + if *difficulty_bits != required_difficulty { + bail!("mine transaction difficulty is invalid"); + } + } + } + Ok(()) + } + + pub(super) fn validate_blinded_transaction( + &self, + transaction: &BlindedTransaction, + ) -> Result<()> { + validate_hash(&transaction.commitment, "blinded transaction commitment")?; + validate_hash( + &transaction.payload_hash, + "blinded transaction payload hash", + )?; + decode_hex_array::<BLINDED_NONCE_BYTES>(&transaction.nonce) + .context("invalid blinded transaction nonce")?; + let ciphertext = decode_hex(&transaction.ciphertext) + .context("invalid blinded transaction ciphertext")?; + if ciphertext.is_empty() { + bail!("blinded transaction ciphertext is empty"); + } + if ciphertext.len() != transaction.encrypted_size as usize { + bail!("blinded transaction encrypted size is invalid"); + } + 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"); + } + validate_transaction_inputs(&transaction.inputs)?; + if transaction.inputs.is_empty() && transaction.fee > 0 { + bail!("blinded transaction with a fee must lock visible inputs"); + } + if !transaction.inputs.is_empty() { + verify_blinded_input_signatures(transaction)?; + } + let expected = blinded_transaction_commitment(transaction)?; + if transaction.commitment != expected { + bail!("blinded transaction commitment is invalid"); + } + Ok(()) + } + + pub(super) fn validate_blinded_reveal_terms(&self, reveal: &BlindedReveal) -> Result<()> { + validate_hash(&reveal.commitment, "blinded reveal commitment")?; + decode_hex_array::<BLINDED_KEY_BYTES>(&reveal.key).context("invalid blinded reveal key")?; + Ok(()) + } + + pub(super) fn valid_pending_blinded_transactions(&self) -> Vec<BlindedTransaction> { + let next_height = self.height().saturating_add(1); + self.pending_blinded + .iter() + .filter(|transaction| { + transaction.expires_at_height > next_height + && self.validate_blinded_transaction(transaction).is_ok() + }) + .cloned() + .collect() + } + + pub(super) fn valid_pending_blinded_reveals(&self) -> Vec<BlindedReveal> { + self.pending_reveals + .iter() + .filter(|reveal| self.pending_reveal_transaction(reveal).is_ok()) + .cloned() + .collect() + } + + pub(super) fn reveal_fee_order_key(&self, reveal: &BlindedReveal) -> (u128, Amount) { + let Some(active) = self.active_blinded.get(&reveal.commitment) else { + return (0, 0); + }; + let size = active.transaction.fee_rate_size_bytes(); + let rate = if size == 0 { + 0 + } else { + u128::from(active.transaction.fee) * 1_000_000 / size as u128 + }; + (rate, active.transaction.fee) + } + + pub(super) fn pending_reveal_transaction(&self, reveal: &BlindedReveal) -> Result<Transaction> { + self.validate_blinded_reveal_terms(reveal)?; + let active = self + .active_blinded + .get(&reveal.commitment) + .context("blinded reveal does not reference an active blinded transaction")?; + self.decrypt_active_blinded(active, reveal) + } + + pub(super) fn decrypt_active_blinded( + &self, + active: &ActiveBlindedTransaction, + reveal: &BlindedReveal, + ) -> Result<Transaction> { + if self.height() >= active.transaction.expires_at_height { + bail!("blinded transaction reveal is expired"); + } + let transaction = decrypt_blinded_transaction(&active.transaction, reveal)?; + if matches!(transaction, Transaction::Mine { .. }) { + bail!("mine actions are public and cannot be blinded"); + } + if blinded_envelope_fee_for_transaction(&transaction) != active.transaction.fee { + bail!("blinded transaction reveal fee does not match envelope"); + } + if !blinded_reveal_inputs_match(active, &transaction) { + bail!("blinded transaction reveal inputs do not match envelope"); + } + self.validate_transaction_terms(&transaction)?; + Ok(transaction) + } + + pub(super) fn apply_revealed_blinded_transaction( + &self, + active: &ActiveBlindedTransaction, + transaction: &Transaction, + utxos: &mut BTreeMap<OutPoint, TxOutput>, + ) -> Result<()> { + if matches!(transaction, Transaction::Mine { .. }) { + bail!("mine actions are public and cannot be blinded"); + } + transaction.verify_signature()?; + ensure_single_input_owner(transaction)?; + let input_total = blinded_locked_output_total(active)?; + let outputs = transaction.outputs(); + let output_total = outputs.iter().try_fold(0_u64, |total, output| { + total + .checked_add(output.amount) + .context("transaction outputs overflow") + })?; + let required = output_total + .checked_add(transaction.fee()) + .context("transaction outputs plus fee overflow")? + .checked_add(match transaction { + Transaction::Burn { amount, .. } => *amount, + Transaction::Transfer { .. } | Transaction::Mine { .. } => 0, + }) + .context("transaction outputs plus burn overflow")?; + if input_total != required { + bail!("blinded transaction inputs do not balance outputs, burn, and fee"); + } + ensure_outputs_do_not_overflow(utxos, &outputs)?; + for (index, output) in outputs.iter().enumerate() { + utxos.insert( + OutPoint { + txid: transaction.signature().to_string(), + index: index as u32, + }, + output.clone(), + ); + } + Ok(()) + } + + pub(super) fn utxos_after_valid_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { + let mut utxos = self.utxos.clone(); + for pending in self.valid_pending_transactions() { + apply_transaction(&pending, &mut utxos)?; + } + Ok(utxos) + } + + pub(super) fn utxos_after_valid_pending_and_blinded( + &self, + ) -> Result<BTreeMap<OutPoint, TxOutput>> { + let mut utxos = self.utxos_after_valid_pending()?; + for pending in self.valid_pending_blinded_transactions() { + spend_blinded_inputs(&pending, &mut utxos)?; + } + Ok(utxos) + } + + pub(super) fn utxos_after_spendable_pending(&self) -> Result<BTreeMap<OutPoint, TxOutput>> { + let mut utxos = self.utxos.clone(); + for pending in self.valid_pending_transactions() { + if matches!(pending, Transaction::Mine { .. }) { + continue; + } + if apply_spendable_pending_transaction(&pending, &mut utxos).is_err() { + continue; + } + } + for pending in self.valid_pending_blinded_transactions() { + if spend_spendable_blinded_inputs(&pending, &mut utxos).is_err() { + continue; + } + } + Ok(utxos) + } +} diff --git a/src/domain/ledger_prepare.rs b/src/domain/ledger_prepare.rs @@ -0,0 +1,154 @@ +use anyhow::{Result, bail}; + +use super::{ + FinalizerMode, Ledger, PreparedBlock, RECOVERY_BLOCK_DELAY_MS, RevealBundle, Wallet, + ensure_block_has_burn, ensure_block_has_burn_from, recovery_vdf_seed_for_child, run_vdf, + ticket_block_min_timestamp, vdf_seed_for_child, +}; + +impl Ledger { + pub fn mine_next_block(&self, wallet: &Wallet, timestamp_ms: u64) -> Result<super::Block> { + let prepared = self.prepare_next_block(wallet.address(), timestamp_ms)?; + let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); + Ok(prepared.finish(wallet, vdf_output)) + } + + pub fn mine_recovery_block(&self, wallet: &Wallet, timestamp_ms: u64) -> Result<super::Block> { + let prepared = self.prepare_recovery_block(wallet.address(), timestamp_ms)?; + let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); + Ok(prepared.finish(wallet, vdf_output)) + } + + pub fn prepare_next_block(&self, miner: &str, timestamp_ms: u64) -> Result<PreparedBlock> { + self.prepare_next_block_with_reveal_bundles(miner, timestamp_ms, Vec::new()) + } + + pub fn prepare_next_block_with_reveal_bundles( + &self, + miner: &str, + timestamp_ms: u64, + reveal_bundles: Vec<RevealBundle>, + ) -> Result<PreparedBlock> { + self.prepare_next_block_with_required_burn_and_reveal_bundles( + miner, + timestamp_ms, + reveal_bundles, + None, + ) + } + + pub(crate) fn prepare_next_block_with_required_burn_and_reveal_bundles( + &self, + miner: &str, + timestamp_ms: u64, + reveal_bundles: Vec<RevealBundle>, + required_burn_signature: Option<&str>, + ) -> Result<PreparedBlock> { + let height = self.tip().height + 1; + let Some((finalizer_rank, leader_ticket)) = self.finalizer_ticket_for_miner(height, miner) + else { + bail!("cannot mine block without a mature burn ticket"); + }; + if self.expected_leader_for_next_block().is_none() { + bail!("no selected leader for block {height}"); + } + + let reveal_bundles = self.validate_next_block_reveal_bundles(reveal_bundles)?; + let reveal_bundle_section = self.reveal_bundle_section_from_bundles(reveal_bundles); + let selection = self.select_block_transactions(required_burn_signature)?; + ensure_block_has_burn(&selection.transactions)?; + + let tip = self.tip(); + let prev_hash = tip.hash.clone(); + let timestamp_ms = timestamp_ms.max(ticket_block_min_timestamp(tip, finalizer_rank)?); + let bundle_hashes = reveal_bundle_section.reveal_bundle_hashes(height, &prev_hash); + let vdf_seed = vdf_seed_for_child(&prev_hash, height, &bundle_hashes); + Ok(PreparedBlock { + height, + prev_hash, + timestamp_ms, + miner: miner.to_string(), + finalizer_mode: FinalizerMode::Ticket, + reward: self + .expected_reward_for_next_block(&selection.transactions, &reveal_bundle_section)?, + vdf_rounds: self.vdf_rounds_for_finalizer_rank(finalizer_rank)?, + vdf_seed, + finalizer_rank, + leader_ticket: Some(leader_ticket), + blinded_transactions: selection.blinded_transactions, + reveal_bundle_section, + transactions: selection.transactions, + }) + } + + pub fn recovery_block_available_at(&self, timestamp_ms: u64) -> bool { + timestamp_ms >= self.recovery_block_min_timestamp() + } + + pub fn recovery_block_min_timestamp(&self) -> u64 { + self.tip() + .timestamp_ms + .saturating_add(RECOVERY_BLOCK_DELAY_MS) + } + + pub fn prepare_recovery_block(&self, miner: &str, timestamp_ms: u64) -> Result<PreparedBlock> { + self.prepare_recovery_block_with_reveal_bundles(miner, timestamp_ms, Vec::new()) + } + + pub fn prepare_recovery_block_with_reveal_bundles( + &self, + miner: &str, + timestamp_ms: u64, + reveal_bundles: Vec<RevealBundle>, + ) -> Result<PreparedBlock> { + self.prepare_recovery_block_with_required_burn_and_reveal_bundles( + miner, + timestamp_ms, + reveal_bundles, + None, + ) + } + + pub(crate) fn prepare_recovery_block_with_required_burn_and_reveal_bundles( + &self, + miner: &str, + timestamp_ms: u64, + reveal_bundles: Vec<RevealBundle>, + required_burn_signature: Option<&str>, + ) -> Result<PreparedBlock> { + let height = self.tip().height + 1; + let min_timestamp = self.recovery_block_min_timestamp(); + if timestamp_ms < min_timestamp { + bail!("recovery block is not available before timestamp {min_timestamp}"); + } + + let reveal_bundles = self.validate_next_block_reveal_bundles(reveal_bundles)?; + let reveal_bundle_section = self.reveal_bundle_section_from_bundles(reveal_bundles); + let selection = self.select_recovery_block_transactions(miner, required_burn_signature)?; + ensure_block_has_burn(&selection.transactions)?; + ensure_block_has_burn_from(&selection.transactions, miner)?; + + let tip = self.tip(); + let prev_hash = tip.hash.clone(); + let timestamp_ms = timestamp_ms.max(tip.timestamp_ms + 1); + let bundle_hashes = reveal_bundle_section.reveal_bundle_hashes(height, &prev_hash); + let vdf_seed = + recovery_vdf_seed_for_child(&prev_hash, height, timestamp_ms, &bundle_hashes); + Ok(PreparedBlock { + height, + prev_hash, + timestamp_ms, + miner: miner.to_string(), + finalizer_mode: FinalizerMode::Recovery, + finalizer_rank: 0, + reward: self + .expected_reward_for_next_block(&selection.transactions, &reveal_bundle_section)?, + vdf_rounds: self.recovery_vdf_rounds()?, + vdf_seed, + leader_ticket: None, + blinded_transactions: selection.blinded_transactions, + reveal_bundle_section, + transactions: selection.transactions, + }) + } +} diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -0,0 +1,377 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result, bail}; + +use super::blinded::{ + ActiveBlindedTransaction, blinded_envelope_fee_for_transaction, decrypt_blinded_transaction, +}; +use super::genesis::balances_from_utxos; +use super::mine_policy::mine_anchor; +use super::ticket::{ + apply_finalizer_ticket_effects, genesis_tickets, ranked_tickets_for_height, + tickets_created_by_block, tickets_created_by_transactions, +}; +use super::{ + Amount, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, ChainStatus, + LaunchProfile, Ledger, OutPoint, RevealCommitteeMember, RevealedBlindedTransaction, + Transaction, TxOutput, reveal_committee_slot_count, +}; + +impl Ledger { + pub fn snapshot(&self) -> ChainSnapshot { + ChainSnapshot { + genesis_allocations: self.genesis_allocations.clone(), + vdf_rounds: self.initial_vdf_rounds, + launch_profile: self.launch_profile.clone(), + blocks: self.chain.clone(), + } + } + + pub fn status(&self) -> ChainStatus { + ChainStatus { + height: self.tip().height, + tip_hash: self.tip().hash.clone(), + next_leader: self.expected_leader_for_next_block(), + launch_profile_hash: self.launch_profile.hash(), + mine_reward: self.mine_reward, + current_mine_difficulty_bits: self.current_mine_difficulty_bits(), + balances: balances_from_utxos(&self.utxos), + pending_transactions: self.pending.len() + + self.pending_blinded.len() + + self.pending_reveals.len(), + } + } + + pub fn chain(&self) -> &[Block] { + &self.chain + } + + pub fn burn_leader_ranks_for_block(&self, height: u64) -> Result<Vec<BurnLeaderRank>> { + if height == 0 { + return Ok(Vec::new()); + } + let parent_index = height.checked_sub(1).context("block height underflows")? as usize; + let parent = self + .chain + .get(parent_index) + .with_context(|| format!("missing parent block for height {height}"))?; + let mut tickets = genesis_tickets( + &self.genesis_allocations, + &self.chain[0], + &self.launch_profile, + )?; + let mut active_blinded = BTreeMap::<String, ActiveBlindedTransaction>::new(); + for block in self + .chain + .iter() + .skip(1) + .take_while(|block| block.height < height) + { + 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.all_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 matches!(transaction, Transaction::Mine { .. }) { + bail!("mine actions are public and cannot be blinded"); + } + if blinded_envelope_fee_for_transaction(&transaction) != 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(), + locked_outputs: Vec::new(), + included_height: block.height, + included_by: block.miner.clone(), + }, + ); + } + } + Ok(ranked_tickets_for_height(parent, height, &tickets) + .into_iter() + .enumerate() + .map(|(rank, ticket)| BurnLeaderRank { + rank: rank as u32, + ticket_id: ticket.id, + owner: ticket.owner, + amount: ticket.amount, + eligible_from_height: ticket.eligible_from_height, + eligible_until_height: ticket.eligible_until_height, + }) + .collect()) + } + + pub fn reveal_committee_for_next_block(&self) -> Vec<RevealCommitteeMember> { + self.reveal_committee_for_height(self.tip().height + 1) + } + + pub fn reveal_committee_for_height(&self, height: u64) -> Vec<RevealCommitteeMember> { + let ranked = ranked_tickets_for_height(self.tip(), height, &self.tickets); + let mut selected = Vec::new(); + if !ranked.is_empty() { + selected.push(0); + } + for index in (0..ranked.len()).rev() { + if selected.len() >= reveal_committee_slot_count(ranked.len()) { + break; + } + if !selected.contains(&index) { + selected.push(index); + } + } + selected + .into_iter() + .enumerate() + .filter_map(|(slot, rank)| { + let ticket = ranked.get(rank)?.clone(); + Some(RevealCommitteeMember { + slot: u8::try_from(slot).ok()?, + rank: u32::try_from(rank).ok()?, + ticket_id: ticket.id, + owner: ticket.owner, + amount: ticket.amount, + }) + }) + .collect() + } + + pub fn genesis_hash(&self) -> &str { + &self.chain[0].hash + } + + pub fn is_setup_placeholder(&self) -> bool { + self.height() == 0 + && self.genesis_allocations.is_empty() + && self.chain[0].transactions.is_empty() + && self.pending.is_empty() + } + + pub fn height(&self) -> u64 { + self.tip().height + } + + pub fn recent_blocks(&self, limit: usize) -> Vec<Block> { + self.chain.iter().rev().take(limit).cloned().collect() + } + + pub fn blocks_before(&self, before_height: u64, limit: usize) -> Vec<Block> { + self.chain + .iter() + .rev() + .filter(|block| block.height < before_height) + .take(limit) + .cloned() + .collect() + } + + pub fn blocks_from(&self, from_height: u64, limit: usize) -> Vec<Block> { + if limit == 0 { + return Vec::new(); + } + self.chain + .iter() + .filter(|block| block.height >= from_height) + .take(limit) + .cloned() + .collect() + } + + pub fn block_by_hash(&self, hash: &str) -> Option<Block> { + self.chain.iter().find(|block| block.hash == hash).cloned() + } + + pub fn has_block(&self, hash: &str) -> bool { + self.chain.iter().any(|block| block.hash == hash) + } + + pub fn pending(&self) -> &[Transaction] { + &self.pending + } + + pub fn pending_blinded_transactions(&self) -> &[BlindedTransaction] { + &self.pending_blinded + } + + pub fn pending_blinded_reveals(&self) -> &[BlindedReveal] { + &self.pending_reveals + } + + pub fn pending_revealed_blinded_transactions(&self) -> Vec<RevealedBlindedTransaction> { + self.pending_reveals + .iter() + .filter_map(|reveal| { + let active = self.active_blinded.get(&reveal.commitment)?; + let transaction = self.pending_reveal_transaction(reveal).ok()?; + Some(RevealedBlindedTransaction { + height: self.height().saturating_add(1), + commitment: reveal.commitment.clone(), + included_by: active.included_by.clone(), + transaction, + }) + }) + .collect() + } + + pub(crate) fn drop_pending_blinded_conflicting_with_transaction( + &mut self, + transaction: &Transaction, + ) { + let spent = transaction + .inputs() + .iter() + .map(|input| input.outpoint.clone()) + .collect::<BTreeSet<_>>(); + self.pending_blinded.retain(|blinded| { + !blinded + .inputs + .iter() + .any(|input| spent.contains(&input.outpoint)) + }); + } + + pub(crate) fn clear_pending_blinded_transactions(&mut self) { + self.pending_blinded.clear(); + } + + pub(crate) fn clear_pending_transactions(&mut self) { + self.pending.clear(); + } + + pub fn orphan_transactions(&self) -> &[Transaction] { + &self.orphans + } + + pub fn transaction_by_signature(&self, signature: &str) -> Option<Transaction> { + self.pending + .iter() + .chain(self.orphans.iter()) + .chain( + self.chain + .iter() + .flat_map(|block| block.transactions.iter()), + ) + .find(|tx| tx.signature() == signature) + .cloned() + } + + pub fn has_transaction(&self, signature: &str) -> bool { + self.transaction_by_signature(signature).is_some() + } + + pub fn pending_mine_count_for_anchor(&self, anchor: &str) -> usize { + self.pending + .iter() + .filter(|tx| mine_anchor(tx) == Some(anchor)) + .count() + } + + pub fn has_blinded_transaction(&self, commitment: &str) -> bool { + self.pending_blinded + .iter() + .any(|transaction| transaction.commitment == commitment) + || self.active_blinded.contains_key(commitment) + || self.chain.iter().any(|block| { + block + .blinded_transactions + .iter() + .any(|tx| tx.commitment == commitment) + }) + } + + 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_active_blinded_transaction(&self, commitment: &str) -> bool { + self.active_blinded.contains_key(commitment) + } + + pub fn has_blinded_reveal(&self, commitment: &str) -> bool { + self.pending_reveals + .iter() + .any(|reveal| reveal.commitment == commitment) + || self.chain.iter().any(|block| { + block + .all_blinded_reveals() + .iter() + .any(|reveal| reveal.commitment == commitment) + }) + } + + pub fn vdf_rounds(&self) -> u64 { + self.vdf_rounds + } + + pub fn launch_profile(&self) -> &LaunchProfile { + &self.launch_profile + } + + pub fn current_mine_difficulty_bits(&self) -> u32 { + self.mine_difficulty_bits_for_anchor_height(self.tip().height) + } + + pub fn mine_difficulty_bits_at_height(&self, height: u64) -> u32 { + self.mine_difficulty_bits_for_anchor_height(height.min(self.tip().height)) + } + + pub fn balance_of(&self, address: &str) -> Amount { + self.utxos + .values() + .filter(|output| output.address == address) + .map(|output| output.amount) + .sum() + } + + pub fn utxos_for_address(&self, address: &str) -> Vec<(OutPoint, TxOutput)> { + self.utxos + .iter() + .filter(|(_, output)| output.address == address) + .map(|(outpoint, output)| (outpoint.clone(), output.clone())) + .collect() + } + + pub fn available_utxos_for_address(&self, address: &str) -> Result<Vec<(OutPoint, TxOutput)>> { + Ok(self + .utxos_after_spendable_pending()? + .into_iter() + .filter(|(_, output)| output.address == address) + .collect()) + } + + pub fn next_nonce(&self, address: &str) -> u64 { + self.utxos + .keys() + .chain( + self.pending + .iter() + .flat_map(|tx| tx.inputs().iter().map(|input| &input.outpoint)), + ) + .filter(|outpoint| outpoint.txid.contains(address)) + .count() as u64 + + 1 + } +} diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs @@ -0,0 +1,261 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result, bail}; + +use super::ledger_ops::verify_address_signature; +use super::reveal::{reveal_bundle_slot_mask, reveal_committee_mask}; +use super::{ + Amount, Ledger, MAX_REVEAL_BUNDLE_BYTES, MaskedBlindedReveal, REVEAL_COMMITTEE_SIZE, + RevealBundle, RevealBundlePayload, RevealBundleSection, RevealBundleSignature, Wallet, +}; + +impl Ledger { + pub fn build_reveal_bundle(&self, wallet: &Wallet) -> Result<Option<RevealBundle>> { + let height = self.tip().height + 1; + let prev_hash = self.tip().hash.clone(); + let Some(member) = self + .reveal_committee_for_next_block() + .into_iter() + .find(|member| member.owner == wallet.address()) + else { + return Ok(None); + }; + let mut reveals = self.valid_pending_blinded_reveals(); + reveals.sort_by(|left, right| { + self.reveal_fee_order_key(right) + .cmp(&self.reveal_fee_order_key(left)) + .then_with(|| left.commitment.cmp(&right.commitment)) + }); + + let mut selected = Vec::new(); + for reveal in reveals { + let mut candidate = selected.clone(); + candidate.push(reveal); + let bundle = wallet.reveal_bundle(RevealBundlePayload { + height, + prev_hash: prev_hash.clone(), + slot: member.slot, + member: wallet.address().to_string(), + reveals: candidate.clone(), + }); + if bundle.serialized_size_bytes()? <= MAX_REVEAL_BUNDLE_BYTES { + selected = candidate; + } + } + if selected.is_empty() { + return Ok(None); + } + Ok(Some(wallet.reveal_bundle(RevealBundlePayload { + height, + prev_hash, + slot: member.slot, + member: wallet.address().to_string(), + reveals: selected, + }))) + } + + pub fn validate_next_block_reveal_bundles( + &self, + bundles: Vec<RevealBundle>, + ) -> Result<Vec<RevealBundle>> { + let expected_height = self.tip().height + 1; + let expected_prev_hash = self.tip().hash.clone(); + self.validate_reveal_bundles_for_block(expected_height, &expected_prev_hash, bundles) + } + + pub(super) fn reveal_bundle_section_from_bundles( + &self, + bundles: Vec<RevealBundle>, + ) -> RevealBundleSection { + let signatures = bundles + .iter() + .map(|bundle| RevealBundleSignature { + slot: bundle.slot, + member: bundle.member.clone(), + signature: bundle.signature.clone(), + }) + .collect::<Vec<_>>(); + let mut by_commitment: BTreeMap<String, MaskedBlindedReveal> = BTreeMap::new(); + for bundle in bundles { + let slot_mask = reveal_bundle_slot_mask(bundle.slot).unwrap_or(0); + for reveal in bundle.reveals { + by_commitment + .entry(reveal.commitment.clone()) + .and_modify(|masked| masked.bundle_mask |= slot_mask) + .or_insert(MaskedBlindedReveal { + reveal, + bundle_mask: slot_mask, + }); + } + } + let mut reveals = by_commitment.into_values().collect::<Vec<_>>(); + reveals.sort_by(|left, right| { + self.reveal_fee_order_key(&right.reveal) + .cmp(&self.reveal_fee_order_key(&left.reveal)) + .then_with(|| left.reveal.commitment.cmp(&right.reveal.commitment)) + }); + RevealBundleSection { + signatures, + reveals, + } + } + + pub(super) fn validate_reveal_bundle_section_for_block( + &self, + expected_height: u64, + expected_prev_hash: &str, + section: &RevealBundleSection, + ) -> Result<()> { + if section.signatures.len() > REVEAL_COMMITTEE_SIZE { + bail!("block has too many reveal bundle signatures"); + } + if section + .signatures + .windows(2) + .any(|pair| pair[0].slot >= pair[1].slot) + { + bail!("reveal bundle signatures are not in slot order"); + } + let committee = self + .reveal_committee_for_height(expected_height) + .into_iter() + .map(|member| (member.slot, member)) + .collect::<BTreeMap<_, _>>(); + let mut seen_slots = BTreeSet::new(); + let mut seen_members = BTreeSet::new(); + let mut included_mask = 0_u8; + for signature in &section.signatures { + if usize::from(signature.slot) >= REVEAL_COMMITTEE_SIZE { + bail!("reveal bundle slot is invalid"); + } + if !seen_slots.insert(signature.slot) { + bail!("duplicate reveal bundle slot"); + } + if !seen_members.insert(signature.member.clone()) { + bail!("duplicate reveal bundle member"); + } + let member = committee + .get(&signature.slot) + .context("reveal bundle slot is not assigned")?; + if signature.member != member.owner { + bail!("reveal bundle member is not assigned to slot"); + } + included_mask |= reveal_bundle_slot_mask(signature.slot)?; + } + + let mut seen_reveals = BTreeSet::new(); + let mut previous_key: Option<((u128, Amount), String)> = None; + for masked in &section.reveals { + if masked.bundle_mask == 0 { + bail!("masked blinded reveal is not assigned to a reveal bundle"); + } + if masked.bundle_mask & !reveal_committee_mask() != 0 { + bail!("masked blinded reveal references an invalid reveal bundle slot"); + } + if masked.bundle_mask & !included_mask != 0 { + bail!("masked blinded reveal references a missing reveal bundle signature"); + } + if !seen_reveals.insert(masked.reveal.commitment.clone()) { + bail!("duplicate blinded reveal in reveal bundle section"); + } + self.pending_reveal_transaction(&masked.reveal)?; + let key = ( + self.reveal_fee_order_key(&masked.reveal), + masked.reveal.commitment.clone(), + ); + if let Some((previous_fee_key, previous_commitment)) = &previous_key { + if key.0 > *previous_fee_key + || key.0 == *previous_fee_key && key.1 < *previous_commitment + { + bail!("reveal bundle section is not fee ordered"); + } + } + previous_key = Some(key); + } + + for bundle in section.expand(expected_height, expected_prev_hash) { + if bundle.serialized_size_bytes()? > MAX_REVEAL_BUNDLE_BYTES { + bail!("reveal bundle exceeds max size"); + } + verify_address_signature( + &bundle.member, + &bundle.canonical_payload(), + &bundle.signature, + "reveal bundle", + )?; + } + Ok(()) + } + + fn validate_reveal_bundles_for_block( + &self, + expected_height: u64, + expected_prev_hash: &str, + mut bundles: Vec<RevealBundle>, + ) -> Result<Vec<RevealBundle>> { + if bundles.len() > REVEAL_COMMITTEE_SIZE { + bail!("block has too many reveal bundles"); + } + if bundles.windows(2).any(|pair| pair[0].slot >= pair[1].slot) { + bail!("reveal bundles are not in slot order"); + } + bundles.sort_by_key(|bundle| bundle.slot); + let committee = self + .reveal_committee_for_height(expected_height) + .into_iter() + .map(|member| (member.slot, member)) + .collect::<BTreeMap<_, _>>(); + let mut seen_slots = BTreeSet::new(); + let mut seen_members = BTreeSet::new(); + for bundle in &bundles { + if bundle.height != expected_height { + bail!("reveal bundle height is invalid"); + } + if bundle.prev_hash != expected_prev_hash { + bail!("reveal bundle parent hash is invalid"); + } + if usize::from(bundle.slot) >= REVEAL_COMMITTEE_SIZE { + bail!("reveal bundle slot is invalid"); + } + if !seen_slots.insert(bundle.slot) { + bail!("duplicate reveal bundle slot"); + } + if !seen_members.insert(bundle.member.clone()) { + bail!("duplicate reveal bundle member"); + } + let member = committee + .get(&bundle.slot) + .context("reveal bundle slot is not assigned")?; + if bundle.member != member.owner { + bail!("reveal bundle member is not assigned to slot"); + } + if bundle.serialized_size_bytes()? > MAX_REVEAL_BUNDLE_BYTES { + bail!("reveal bundle exceeds max size"); + } + verify_address_signature( + &bundle.member, + &bundle.canonical_payload(), + &bundle.signature, + "reveal bundle", + )?; + let mut seen_bundle_reveals = BTreeSet::new(); + let mut previous_key: Option<((u128, Amount), String)> = None; + for reveal in &bundle.reveals { + if !seen_bundle_reveals.insert(reveal.commitment.clone()) { + bail!("duplicate blinded reveal in reveal bundle"); + } + self.pending_reveal_transaction(reveal)?; + let key = (self.reveal_fee_order_key(reveal), reveal.commitment.clone()); + if let Some((previous_fee_key, previous_commitment)) = &previous_key { + if key.0 > *previous_fee_key + || key.0 == *previous_fee_key && key.1 < *previous_commitment + { + bail!("reveal bundle is not fee ordered"); + } + } + previous_key = Some(key); + } + } + Ok(bundles) + } +} diff --git a/src/domain/ledger_state.rs b/src/domain/ledger_state.rs @@ -0,0 +1,35 @@ +use std::{ + collections::BTreeMap, + time::{SystemTime, UNIX_EPOCH}, +}; + +use super::{ + ActiveBlindedTransaction, Amount, BlindedReveal, BlindedTransaction, Block, BurnTicket, + LaunchProfile, OutPoint, Transaction, TxOutput, +}; + +#[derive(Clone, Debug)] +pub struct Ledger { + pub(super) chain: Vec<Block>, + pub(super) genesis_allocations: BTreeMap<String, Amount>, + pub(super) utxos: BTreeMap<OutPoint, TxOutput>, + pub(super) tickets: Vec<BurnTicket>, + pub(super) pending: Vec<Transaction>, + pub(super) orphans: Vec<Transaction>, + pub(super) pending_blinded: Vec<BlindedTransaction>, + pub(super) pending_reveals: Vec<BlindedReveal>, + pub(super) active_blinded: BTreeMap<String, ActiveBlindedTransaction>, + pub(super) mine_reward: Amount, + pub(super) initial_vdf_rounds: u64, + pub(super) vdf_rounds: u64, + pub(super) launch_profile: LaunchProfile, +} + +pub(super) fn unix_now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} diff --git a/src/domain/mine_policy.rs b/src/domain/mine_policy.rs @@ -0,0 +1,118 @@ +use std::collections::BTreeMap; + +use anyhow::{Result, bail}; + +use super::{ + Block, MINE_ACTIONS_PER_ANCHOR_LIMIT, MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT, + Transaction, +}; + +pub(super) const MINE_RETARGET_WINDOW_BLOCKS: u64 = 10; +pub(super) const MINE_MAX_RETARGET_STEP_BITS: u32 = 2; +pub(super) const MINE_MIN_DIFFICULTY_BITS: u32 = 10; +pub(super) const MINE_MAX_ANCHOR_AGE_BLOCKS: u64 = MINE_RETARGET_WINDOW_BLOCKS; + +const MINE_TARGET_ACTIONS_PER_BLOCK: u64 = 1; +const MINE_MAX_DIFFICULTY_BITS: u32 = 32; + +pub(super) fn retarget_mine_difficulty_bits(current: u32, mine_actions: u64) -> u32 { + let target = MINE_RETARGET_WINDOW_BLOCKS.saturating_mul(MINE_TARGET_ACTIONS_PER_BLOCK); + if target == 0 || mine_actions == target { + return current.clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS); + } + + let step = if mine_actions > target { + floor_log2_ratio(mine_actions, target).min(MINE_MAX_RETARGET_STEP_BITS) + } else if mine_actions == 0 { + MINE_MAX_RETARGET_STEP_BITS + } else { + floor_log2_ratio(target, mine_actions).min(MINE_MAX_RETARGET_STEP_BITS) + }; + + if step == 0 { + return current.clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS); + } + if mine_actions > target { + current + .saturating_add(step) + .clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS) + } else { + current + .saturating_sub(step) + .clamp(MINE_MIN_DIFFICULTY_BITS, MINE_MAX_DIFFICULTY_BITS) + } +} + +fn floor_log2_ratio(numerator: u64, denominator: u64) -> u32 { + if denominator == 0 || numerator <= denominator { + return 0; + } + let mut step = 0_u32; + let mut threshold = denominator; + while threshold <= numerator / 2 { + threshold = threshold.saturating_mul(2); + step = step.saturating_add(1); + } + step +} + +pub(super) fn ensure_mine_anchor_limit(height: u64, transactions: &[Transaction]) -> Result<()> { + if !mine_actions_per_anchor_limit_active(height) { + return Ok(()); + } + let mut anchor_counts = BTreeMap::new(); + for transaction in transactions { + let Some(anchor) = mine_anchor(transaction) else { + continue; + }; + let count = anchor_counts.entry(anchor).or_insert(0usize); + *count += 1; + if *count > MINE_ACTIONS_PER_ANCHOR_LIMIT { + bail!("block exceeds mine actions per anchor limit"); + } + } + Ok(()) +} + +pub(super) fn mine_actions_per_anchor_limit_active(height: u64) -> bool { + height >= MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT +} + +pub(super) fn mine_anchor(transaction: &Transaction) -> Option<&str> { + match transaction { + Transaction::Mine { anchor, .. } => Some(anchor.as_str()), + _ => None, + } +} + +pub(super) fn mine_anchor_count_before_height(chain: &[Block], anchor: &str, height: u64) -> usize { + chain + .iter() + .take_while(|block| block.height <= height) + .map(|block| { + block + .transactions + .iter() + .filter(|transaction| mine_anchor(transaction) == Some(anchor)) + .count() + }) + .sum() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::MINE_DIFFICULTY_BITS; + + #[test] + fn retarget_bounds_match_protocol_constants() { + assert_eq!( + retarget_mine_difficulty_bits(MINE_DIFFICULTY_BITS, 0), + MINE_DIFFICULTY_BITS - MINE_MAX_RETARGET_STEP_BITS + ); + assert_eq!( + retarget_mine_difficulty_bits(1, 0), + MINE_MIN_DIFFICULTY_BITS + ); + } +} diff --git a/src/domain/mining.rs b/src/domain/mining.rs @@ -0,0 +1,43 @@ +use super::hex_hash; + +pub(super) fn mine_payload( + recipient: &str, + anchor: &str, + salt: u64, + nonce: u64, + difficulty_bits: u32, +) -> String { + format!("iuna-mine:{recipient}:{anchor}:{salt}:{nonce}:{difficulty_bits}") +} + +pub(super) fn mine_signature( + recipient: &str, + anchor: &str, + salt: u64, + nonce: u64, + difficulty_bits: u32, +) -> String { + hex_hash(mine_payload( + recipient, + anchor, + salt, + nonce, + difficulty_bits, + )) +} + +#[cfg(test)] +mod tests { + use super::{mine_payload, mine_signature}; + + #[test] + fn mine_signature_hashes_canonical_mine_payload() { + let payload = mine_payload("recipient", "anchor", 1, 2, 12); + + assert_eq!(payload, "iuna-mine:recipient:anchor:1:2:12"); + assert_eq!( + mine_signature("recipient", "anchor", 1, 2, 12), + "47f9ad353685fdb9b4932cefa9dd1d27f8af70e27eaedf15c4f9ffbbb64300a3" + ); + } +} diff --git a/src/domain/profile.rs b/src/domain/profile.rs @@ -0,0 +1,112 @@ +use serde::{Deserialize, Serialize}; + +use super::{ + DEFAULT_TICKET_EXPIRY_WINDOW, DEFAULT_TICKET_MATURITY_DELAY, MAX_BLOCK_BYTES, + MAX_BLOCK_TRANSACTIONS, MAX_PENDING_TRANSACTIONS, MINE_DIFFICULTY_BITS, hex_hash, +}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LaunchProfile { + pub profile_id: String, + pub ticket_maturity_delay_heights: u64, + #[serde(default = "default_ticket_expiry_window_heights")] + pub ticket_expiry_window_heights: u64, + #[serde(default = "default_mine_difficulty_bits")] + pub mine_difficulty_bits: u32, + pub max_pending_transactions: usize, + pub max_block_transactions: usize, + #[serde(default = "default_max_block_bytes")] + pub max_block_bytes: usize, +} + +impl Default for LaunchProfile { + fn default() -> Self { + Self { + profile_id: "iuna-devnet-v5".to_string(), + ticket_maturity_delay_heights: DEFAULT_TICKET_MATURITY_DELAY, + ticket_expiry_window_heights: DEFAULT_TICKET_EXPIRY_WINDOW, + mine_difficulty_bits: MINE_DIFFICULTY_BITS, + max_pending_transactions: MAX_PENDING_TRANSACTIONS, + max_block_transactions: MAX_BLOCK_TRANSACTIONS, + max_block_bytes: MAX_BLOCK_BYTES, + } + } +} + +fn default_max_block_bytes() -> usize { + MAX_BLOCK_BYTES +} + +fn default_ticket_expiry_window_heights() -> u64 { + DEFAULT_TICKET_EXPIRY_WINDOW +} + +fn default_mine_difficulty_bits() -> u32 { + MINE_DIFFICULTY_BITS +} + +impl LaunchProfile { + pub fn hash(&self) -> String { + hex_hash(format!( + "iuna-launch-profile:{}:{}:{}:{}:{}:{}:{}", + self.profile_id, + self.ticket_maturity_delay_heights, + self.ticket_expiry_window_heights, + self.mine_difficulty_bits, + self.max_pending_transactions, + self.max_block_transactions, + self.max_block_bytes + )) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GenesisBurn { + pub from: String, + pub amount: u64, +} + +impl GenesisBurn { + pub fn new(from: impl Into<String>, amount: u64) -> Self { + Self { + from: from.into(), + amount, + } + } +} + +#[cfg(test)] +mod tests { + use super::{GenesisBurn, LaunchProfile}; + use crate::domain::{ + DEFAULT_TICKET_EXPIRY_WINDOW, DEFAULT_TICKET_MATURITY_DELAY, MAX_BLOCK_BYTES, + MAX_BLOCK_TRANSACTIONS, MAX_PENDING_TRANSACTIONS, MINE_DIFFICULTY_BITS, + }; + + #[test] + fn default_launch_profile_matches_protocol_defaults() { + let profile = LaunchProfile::default(); + + assert_eq!(profile.profile_id, "iuna-devnet-v5"); + assert_eq!( + profile.ticket_maturity_delay_heights, + DEFAULT_TICKET_MATURITY_DELAY + ); + assert_eq!( + profile.ticket_expiry_window_heights, + DEFAULT_TICKET_EXPIRY_WINDOW + ); + assert_eq!(profile.mine_difficulty_bits, MINE_DIFFICULTY_BITS); + assert_eq!(profile.max_pending_transactions, MAX_PENDING_TRANSACTIONS); + assert_eq!(profile.max_block_transactions, MAX_BLOCK_TRANSACTIONS); + assert_eq!(profile.max_block_bytes, MAX_BLOCK_BYTES); + } + + #[test] + fn genesis_burn_constructor_preserves_fields() { + let burn = GenesisBurn::new("alice", 42); + + assert_eq!(burn.from, "alice"); + assert_eq!(burn.amount, 42); + } +} diff --git a/src/domain/protocol.rs b/src/domain/protocol.rs @@ -0,0 +1,53 @@ +pub type Amount = u64; + +pub const MICRO_IUNA: Amount = 1_000_000; +pub const BLOCK_REWARD: Amount = MICRO_IUNA; +pub const MINE_REWARD: Amount = MICRO_IUNA; +pub const MINE_FINALIZER_FEE: Amount = MICRO_IUNA; +pub const DEFAULT_MINE_FEE: Amount = MINE_FINALIZER_FEE; +pub const DEFAULT_TRANSACTION_FEE: Amount = MICRO_IUNA; +pub const DEFAULT_FEE_PER_BYTE: Amount = 1; +pub const MAX_BLOCK_BYTES: usize = 100_000; +pub const VDF_TARGET_BLOCK_MS: u64 = 5 * 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 MINE_ACTIONS_PER_ANCHOR_LIMIT: usize = 2; +pub const MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT: u64 = 200; +pub const FALLBACK_VDF_RETARGET_ACTIVATION_HEIGHT: u64 = 380; +pub const FALLBACK_VDF_RETARGET_DEACTIVATION_HEIGHT: u64 = 1_000; +pub const AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT: u64 = 795; +pub const MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS: u64 = 20; +pub const REVEAL_COMMITTEE_SIZE: usize = 3; +pub const MAX_REVEAL_BUNDLE_BYTES: usize = 10_000; +pub const BLINDED_FEE_BPS_DENOMINATOR: u64 = 10_000; +pub const BLINDED_COMMITTER_FEE_BPS: u64 = 3_500; +pub const BLINDED_REVEAL_FINALIZER_FEE_BPS: u64 = 3_500; +pub const BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS: u64 = 1_000; + +pub const MAX_PENDING_TRANSACTIONS: usize = 10_000; +pub(super) const MAX_ORPHAN_TRANSACTIONS: usize = 1_024; +pub(super) const MAX_BLOCK_TRANSACTIONS: usize = 1_000; +pub(super) const DEFAULT_TICKET_MATURITY_DELAY: u64 = 3; +pub(super) const DEFAULT_TICKET_EXPIRY_WINDOW: u64 = 3; +pub(super) const MAX_BLOCK_TIMESTAMP_FUTURE_DRIFT_MS: u64 = 2 * 60 * 1_000; +pub(super) const BLOCK_MEDIAN_TIME_PAST_WINDOW: usize = 11; +pub(super) const FORK_FINALITY_DEPTH: u64 = 6; +pub(super) const PUBLIC_KEY_BYTES: usize = 32; +pub(super) const HASH_BYTES: usize = 32; +pub(super) const SIGNATURE_BYTES: usize = 64; +pub(super) const BLINDED_KEY_BYTES: usize = 32; +pub(super) const BLINDED_NONCE_BYTES: usize = 12; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TransactionSubmitOutcome { + Added, + AlreadyKnown, + ConflictsWithPending, +} + +impl TransactionSubmitOutcome { + pub fn added(self) -> bool { + matches!(self, Self::Added) + } +} diff --git a/src/domain/reveal.rs b/src/domain/reveal.rs @@ -0,0 +1,276 @@ +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +use super::{Amount, BlindedReveal, REVEAL_COMMITTEE_SIZE, hex_hash}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RevealBundle { + pub height: u64, + pub prev_hash: String, + pub slot: u8, + pub member: String, + pub reveals: Vec<BlindedReveal>, + pub signature: String, +} + +impl RevealBundle { + pub fn canonical_payload(&self) -> String { + RevealBundlePayload { + height: self.height, + prev_hash: self.prev_hash.clone(), + slot: self.slot, + member: self.member.clone(), + reveals: self.reveals.clone(), + } + .canonical() + } + + pub fn canonical(&self) -> String { + format!("{}:{}", self.canonical_payload(), self.signature) + } + + pub fn bundle_hash(&self) -> String { + hex_hash(self.canonical()) + } + + pub fn serialized_size_bytes(&self) -> Result<usize> { + serde_json::to_vec(self) + .map(|bytes| bytes.len()) + .context("failed to serialize reveal bundle for size check") + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RevealBundleSignature { + pub slot: u8, + pub member: String, + pub signature: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MaskedBlindedReveal { + pub reveal: BlindedReveal, + pub bundle_mask: u8, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RevealBundleSection { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub signatures: Vec<RevealBundleSignature>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reveals: Vec<MaskedBlindedReveal>, +} + +impl RevealBundleSection { + pub fn is_empty(&self) -> bool { + self.signatures.is_empty() && self.reveals.is_empty() + } + + pub fn all_reveals(&self) -> Vec<&BlindedReveal> { + self.reveals.iter().map(|masked| &masked.reveal).collect() + } + + pub fn included_bundle_count(&self) -> usize { + self.signatures.len() + } + + pub fn expand(&self, height: u64, prev_hash: &str) -> Vec<RevealBundle> { + self.signatures + .iter() + .map(|signature| { + let slot_mask = reveal_bundle_slot_mask(signature.slot).unwrap_or(0); + let reveals = self + .reveals + .iter() + .filter(|masked| masked.bundle_mask & slot_mask != 0) + .map(|masked| masked.reveal.clone()) + .collect(); + RevealBundle { + height, + prev_hash: prev_hash.to_string(), + slot: signature.slot, + member: signature.member.clone(), + reveals, + signature: signature.signature.clone(), + } + }) + .collect() + } + + pub fn reveal_bundle_hashes( + &self, + height: u64, + prev_hash: &str, + ) -> [String; REVEAL_COMMITTEE_SIZE] { + let bundles = self.expand(height, prev_hash); + reveal_bundle_hashes(&bundles) + } + + pub(super) fn canonical(&self) -> String { + let signatures = self + .signatures + .iter() + .map(|signature| { + format!( + "{}:{}:{}", + signature.slot, signature.member, signature.signature + ) + }) + .collect::<Vec<_>>() + .join("|"); + let reveals = self + .reveals + .iter() + .map(|masked| format!("{}:{}", masked.bundle_mask, masked.reveal.canonical())) + .collect::<Vec<_>>() + .join("|"); + format!("reveal-bundle-section-v1:{signatures}:reveals:{reveals}") + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct RevealBundlePayload { + pub(super) height: u64, + pub(super) prev_hash: String, + pub(super) slot: u8, + pub(super) member: String, + pub(super) reveals: Vec<BlindedReveal>, +} + +impl RevealBundlePayload { + pub(super) fn canonical(&self) -> String { + let reveals = self + .reveals + .iter() + .map(BlindedReveal::canonical) + .collect::<Vec<_>>() + .join("|"); + format!( + "iuna-reveal-bundle-v1:{}:{}:{}:{}:{}", + self.height, self.prev_hash, self.slot, self.member, reveals + ) + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RevealCommitteeMember { + pub slot: u8, + pub rank: u32, + pub ticket_id: String, + pub owner: String, + pub amount: Amount, +} + +pub fn default_reveal_bundle_hash(slot: usize) -> String { + hex_hash(format!("iuna-default-reveal-bundle-v1:{slot}")) +} + +pub(super) fn reveal_bundle_slot_mask(slot: u8) -> Result<u8> { + if usize::from(slot) >= REVEAL_COMMITTEE_SIZE || slot >= 8 { + bail!("reveal bundle slot is invalid"); + } + Ok(1_u8 << slot) +} + +pub(super) fn reveal_committee_mask() -> u8 { + (0..REVEAL_COMMITTEE_SIZE).fold(0_u8, |mask, slot| mask | (1_u8 << slot)) +} + +pub(super) fn reveal_bundle_hashes(bundles: &[RevealBundle]) -> [String; REVEAL_COMMITTEE_SIZE] { + std::array::from_fn(|slot| { + bundles + .iter() + .find(|bundle| usize::from(bundle.slot) == slot) + .map(RevealBundle::bundle_hash) + .unwrap_or_else(|| default_reveal_bundle_hash(slot)) + }) +} + +pub(super) fn canonical_reveal_bundle_hashes( + bundle_hashes: &[String; REVEAL_COMMITTEE_SIZE], +) -> String { + bundle_hashes.join("|") +} + +#[cfg(test)] +mod tests { + use super::{ + MaskedBlindedReveal, RevealBundle, RevealBundlePayload, RevealBundleSection, + RevealBundleSignature, default_reveal_bundle_hash, reveal_bundle_hashes, + reveal_bundle_slot_mask, + }; + use crate::domain::BlindedReveal; + + #[test] + fn reveal_bundle_payload_is_canonical() { + let payload = RevealBundlePayload { + height: 7, + prev_hash: "prev".to_string(), + slot: 1, + member: "member".to_string(), + reveals: vec![BlindedReveal { + commitment: "commitment".to_string(), + key: "key".to_string(), + }], + }; + + assert_eq!( + payload.canonical(), + "iuna-reveal-bundle-v1:7:prev:1:member:blinded-reveal:commitment:key" + ); + } + + #[test] + fn reveal_bundle_hashes_fill_missing_slots_with_defaults() { + let bundle = RevealBundle { + height: 1, + prev_hash: "prev".to_string(), + slot: 1, + member: "member".to_string(), + reveals: Vec::new(), + signature: "sig".to_string(), + }; + + let hashes = reveal_bundle_hashes(&[bundle.clone()]); + + assert_eq!(hashes[0], default_reveal_bundle_hash(0)); + assert_eq!(hashes[1], bundle.bundle_hash()); + assert_eq!(hashes[2], default_reveal_bundle_hash(2)); + } + + #[test] + fn reveal_bundle_section_expands_masked_reveals_by_slot() { + let reveal = BlindedReveal { + commitment: "commitment".to_string(), + key: "key".to_string(), + }; + let section = RevealBundleSection { + signatures: vec![ + RevealBundleSignature { + slot: 0, + member: "a".to_string(), + signature: "sig-a".to_string(), + }, + RevealBundleSignature { + slot: 1, + member: "b".to_string(), + signature: "sig-b".to_string(), + }, + ], + reveals: vec![MaskedBlindedReveal { + reveal: reveal.clone(), + bundle_mask: reveal_bundle_slot_mask(1).unwrap(), + }], + }; + + let expanded = section.expand(3, "prev"); + + assert!(expanded[0].reveals.is_empty()); + assert_eq!(expanded[1].reveals, vec![reveal]); + } +} diff --git a/src/domain/selection.rs b/src/domain/selection.rs @@ -0,0 +1,78 @@ +use super::{BlindedTransaction, Transaction}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum TransactionKind { + Burn, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(super) struct BlockSelection { + pub(super) transactions: Vec<Transaction>, + pub(super) blinded_transactions: Vec<BlindedTransaction>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum SelectableItem { + Plain(usize, u128), + Blinded(usize, u128), +} + +pub(super) fn fee_rate_key(transaction: &Transaction) -> u128 { + let size = transaction.economic_size_bytes(); + if size == 0 { + return 0; + } + u128::from(transaction.fee()) * 1_000_000 / size as u128 +} + +pub(super) fn blinded_fee_rate_key(transaction: &BlindedTransaction) -> u128 { + let size = transaction.fee_rate_size_bytes(); + if size == 0 { + return 0; + } + u128::from(transaction.fee) * 1_000_000 / size as u128 +} + +pub(super) fn best_selectable_item( + plain: Option<SelectableItem>, + blinded: Option<SelectableItem>, +) -> Option<SelectableItem> { + match (plain, blinded) { + ( + Some(SelectableItem::Plain(_, plain_rate)), + Some(SelectableItem::Blinded(_, blind_rate)), + ) => { + if blind_rate > plain_rate { + blinded + } else { + plain + } + } + (Some(item), None) | (None, Some(item)) => Some(item), + (None, None) => None, + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::{SelectableItem, best_selectable_item}; + + #[test] + fn selectable_item_prefers_blinded_only_when_fee_rate_is_higher() { + assert_eq!( + best_selectable_item( + Some(SelectableItem::Plain(1, 10)), + Some(SelectableItem::Blinded(2, 11)) + ), + Some(SelectableItem::Blinded(2, 11)) + ); + assert_eq!( + best_selectable_item( + Some(SelectableItem::Plain(1, 10)), + Some(SelectableItem::Blinded(2, 10)) + ), + Some(SelectableItem::Plain(1, 10)) + ); + } +} diff --git a/src/domain/stratum.rs b/src/domain/stratum.rs @@ -0,0 +1,202 @@ +use anyhow::{Context, Result}; +use sha2::{Digest, Sha256}; + +use super::{ + HASH_BYTES, decode_hex_array, hex_encode, + validation::{validate_address, validate_hash}, +}; + +pub const STRATUM_EXTRANONCE1_HEX: &str = "00000000"; +pub const STRATUM_EXTRANONCE2_SIZE: usize = 4; +pub(super) const STRATUM_MINE_HEADER_BYTES: usize = 80; + +const STRATUM_MINE_VERSION: [u8; 4] = [1, 0, 0, 0]; +const STRATUM_MINE_NTIME: [u8; 4] = [0, 0, 0, 0]; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StratumMineTemplate { + pub recipient: String, + pub anchor: String, + pub salt: u64, + pub difficulty_bits: u32, + pub coinbase_prefix: Vec<u8>, + pub version_hex: String, + pub prev_hash_hex: String, + pub nbits_hex: String, + pub ntime_hex: String, +} + +impl StratumMineTemplate { + pub fn coinb1_hex(&self) -> String { + hex_encode(&self.coinbase_prefix) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StratumMineShare { + pub extranonce2: [u8; 4], + pub header_nonce: [u8; 4], +} + +pub fn pack_stratum_nonce(extranonce2: [u8; 4], header_nonce: [u8; 4]) -> u64 { + let extra = u32::from_be_bytes(extranonce2) as u64; + let nonce = u32::from_le_bytes(header_nonce) as u64; + (extra << 32) | nonce +} + +fn unpack_stratum_nonce(nonce: u64) -> ([u8; 4], [u8; 4]) { + ( + ((nonce >> 32) as u32).to_be_bytes(), + (nonce as u32).to_le_bytes(), + ) +} + +fn stratum_coinbase_prefix( + recipient: &str, + anchor: &str, + salt: u64, + difficulty_bits: u32, +) -> Vec<u8> { + format!("iuna-stratum-mine:{recipient}:{anchor}:{salt}:{difficulty_bits}:").into_bytes() +} + +fn stratum_coinbase_bytes( + recipient: &str, + anchor: &str, + salt: u64, + nonce: u64, + difficulty_bits: u32, +) -> Vec<u8> { + let (extranonce2, _) = unpack_stratum_nonce(nonce); + let mut coinbase = stratum_coinbase_prefix(recipient, anchor, salt, difficulty_bits); + coinbase.extend_from_slice(&[0, 0, 0, 0]); + coinbase.extend_from_slice(&extranonce2); + coinbase +} + +fn double_sha256(bytes: &[u8]) -> [u8; 32] { + let first = Sha256::digest(bytes); + let second = Sha256::digest(first); + second.into() +} + +pub(super) fn stratum_mine_header_bytes( + recipient: &str, + anchor: &str, + salt: u64, + nonce: u64, + difficulty_bits: u32, +) -> Result<[u8; 80]> { + let mut header = [0_u8; STRATUM_MINE_HEADER_BYTES]; + header[0..4].copy_from_slice(&STRATUM_MINE_VERSION); + let anchor_bytes = + decode_hex_array::<HASH_BYTES>(anchor).context("mine transaction anchor is not hex")?; + header[4..36].copy_from_slice(&anchor_bytes); + let merkle_root = double_sha256(&stratum_coinbase_bytes( + recipient, + anchor, + salt, + nonce, + difficulty_bits, + )); + header[36..68].copy_from_slice(&merkle_root); + header[68..72].copy_from_slice(&STRATUM_MINE_NTIME); + header[72..76].copy_from_slice(&difficulty_bits.to_le_bytes()); + let (_, header_nonce) = unpack_stratum_nonce(nonce); + header[76..80].copy_from_slice(&header_nonce); + Ok(header) +} + +pub(super) fn stratum_mine_signature(header: &[u8; 80]) -> String { + let mut digest = double_sha256(header); + digest.reverse(); + hex_encode(digest) +} + +pub(super) fn stratum_mine_template( + recipient: impl Into<String>, + anchor: &str, + salt: u64, + difficulty_bits: u32, +) -> Result<StratumMineTemplate> { + let recipient = recipient.into(); + validate_address(&recipient, "mine recipient")?; + validate_hash(anchor, "mine transaction anchor")?; + let anchor_bytes = + decode_hex_array::<HASH_BYTES>(anchor).context("mine transaction anchor is not hex")?; + Ok(StratumMineTemplate { + recipient: recipient.clone(), + anchor: anchor.to_string(), + salt, + difficulty_bits, + coinbase_prefix: stratum_coinbase_prefix(&recipient, anchor, salt, difficulty_bits), + version_hex: hex_encode(STRATUM_MINE_VERSION), + prev_hash_hex: hex_encode(anchor_bytes), + nbits_hex: hex_encode(difficulty_bits.to_le_bytes()), + ntime_hex: hex_encode(STRATUM_MINE_NTIME), + }) +} + +pub(super) fn hash_meets_difficulty(hash: &str, difficulty_bits: u32) -> bool { + let full_zero_nibbles = (difficulty_bits / 4) as usize; + let remaining_bits = difficulty_bits % 4; + if hash.len() < full_zero_nibbles + usize::from(remaining_bits > 0) { + return false; + } + if !hash.as_bytes()[..full_zero_nibbles] + .iter() + .all(|byte| *byte == b'0') + { + return false; + } + if remaining_bits == 0 { + return true; + } + let Some(next) = hash.as_bytes().get(full_zero_nibbles).copied() else { + return false; + }; + let Some(value) = (next as char).to_digit(16) else { + return false; + }; + value < (1 << (4 - remaining_bits)) +} + +#[cfg(test)] +mod tests { + use super::{ + STRATUM_EXTRANONCE1_HEX, STRATUM_EXTRANONCE2_SIZE, hash_meets_difficulty, + pack_stratum_nonce, stratum_mine_header_bytes, + }; + + #[test] + fn stratum_nonce_packs_extranonce_big_endian_and_header_nonce_little_endian() { + let nonce = pack_stratum_nonce([0x01, 0x02, 0x03, 0x04], [0x08, 0x07, 0x06, 0x05]); + + assert_eq!(nonce, 0x0102_0304_0506_0708); + } + + #[test] + fn stratum_public_extranonce_contract_is_stable() { + assert_eq!(STRATUM_EXTRANONCE1_HEX, "00000000"); + assert_eq!(STRATUM_EXTRANONCE2_SIZE, 4); + } + + #[test] + fn stratum_header_rejects_non_hex_anchor() { + let error = stratum_mine_header_bytes("recipient", "not-hex", 0, 0, 12).unwrap_err(); + + assert!( + error + .to_string() + .contains("mine transaction anchor is not hex") + ); + } + + #[test] + fn difficulty_check_handles_nibble_and_partial_nibble_targets() { + assert!(hash_meets_difficulty("00f", 8)); + assert!(!hash_meets_difficulty("010", 8)); + assert!(hash_meets_difficulty("1ff", 3)); + assert!(!hash_meets_difficulty("2ff", 3)); + } +} diff --git a/src/domain/tests.rs b/src/domain/tests.rs @@ -0,0 +1,2898 @@ +use super::*; + +#[test] +fn target_block_time_is_five_minutes() { + assert_eq!(VDF_TARGET_BLOCK_MS, 5 * 60 * 1_000); +} + +fn test_utxo_outpoint(index: usize) -> OutPoint { + OutPoint { + txid: format!("{index:064x}"), + index: 0, + } +} + +fn named_test_outpoint(name: &str) -> OutPoint { + OutPoint { + txid: hex_hash(format!("test-utxo:{name}")), + index: 0, + } +} + +fn ledger_with_wallet_utxos(wallet: &Wallet, amounts: &[Amount]) -> Ledger { + let mut ledger = Ledger::new(BTreeMap::new(), 1); + ledger.utxos = amounts + .iter() + .enumerate() + .map(|(index, amount)| { + ( + test_utxo_outpoint(index), + TxOutput { + address: wallet.address().to_string(), + amount: *amount, + }, + ) + }) + .collect(); + ledger +} + +fn pending_balances(ledger: &Ledger) -> BTreeMap<String, Amount> { + balances_from_utxos(&ledger.utxos_after_valid_pending().unwrap()) +} + +fn ledger_with_allocation(wallet: &Wallet, amount: Amount) -> Ledger { + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), amount); + Ledger::new(genesis, 1) +} + +fn mine_burn_block_with_mines(ledger: &mut Ledger, wallet: &Wallet, mine_actions: usize) { + let burn = ledger.build_burn(wallet, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + for _ in 0..mine_actions { + let mine = ledger.build_mine(wallet.address()).unwrap(); + ledger.submit_transaction(mine).unwrap(); + } + let block = ledger.mine_next_block(wallet, ledger.height() + 1).unwrap(); + ledger.apply_block(block).unwrap(); +} + +fn test_mine_with_salt(ledger: &Ledger, recipient: &str, salt: u64) -> Transaction { + let anchor = ledger.tip().hash.clone(); + let difficulty_bits = ledger.current_mine_difficulty_bits(); + for nonce in 0..u64::MAX { + let signature = mine_signature(recipient, &anchor, salt, nonce, difficulty_bits); + if hash_meets_difficulty(&signature, difficulty_bits) { + return Transaction::Mine { + recipient: recipient.to_string(), + anchor, + salt, + nonce, + difficulty_bits, + proof_header: None, + signature, + }; + } + } + panic!("test should find a valid mine action"); +} + +fn advance_to_mine_anchor_limit_activation_parent(ledger: &mut Ledger, wallet: &Wallet) { + while ledger.height().saturating_add(1) < MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT { + let timestamp_ms = ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS); + apply_preverified_burn_block_at(ledger, wallet, timestamp_ms); + } + assert_eq!( + ledger.height().saturating_add(1), + MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT + ); +} + +fn apply_preverified_burn_block_at( + ledger: &mut Ledger, + wallet: &Wallet, + timestamp_ms: u64, +) -> Block { + let burn = ledger.build_burn(wallet, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let work = ledger + .prepare_next_block(wallet.address(), timestamp_ms) + .unwrap(); + let block = work.finish(wallet, "preverified-vdf".to_string()); + ledger + .apply_preverified_block_at(block.clone(), u64::MAX) + .unwrap(); + block +} + +fn apply_preverified_burn_block_with_mines( + ledger: &mut Ledger, + wallet: &Wallet, + mine_actions: usize, +) { + let burn = ledger.build_burn(wallet, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let timestamp_ms = ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS); + let mut block = ledger + .prepare_next_block(wallet.address(), timestamp_ms) + .unwrap() + .finish(wallet, "preverified-vdf".to_string()); + for salt in 0..mine_actions { + block.transactions.push(test_mine_with_salt( + ledger, + wallet.address(), + salt as u64 + 1, + )); + } + block.reward = fee_reward(&block.transactions).unwrap(); + block.hash = block.compute_hash(); + ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); +} + +fn vdf_retarget_sample_block( + timestamp_ms: u64, + finalizer_mode: FinalizerMode, + finalizer_rank: u32, +) -> Block { + Block { + height: 1, + prev_hash: String::new(), + timestamp_ms, + miner: String::new(), + finalizer_mode, + finalizer_rank, + reward: 0, + vdf_rounds: 1, + vdf_output: String::new(), + leader_proof: None, + blinded_transactions: Vec::new(), + reveal_bundle_section: RevealBundleSection::default(), + transactions: Vec::new(), + hash: String::new(), + } +} + +const TEST_BURN_AMOUNT: Amount = MICRO_IUNA / 10; + +fn unsigned_mine(ledger: &Ledger, recipient: &str) -> Transaction { + let anchor = ledger.tip().hash.clone(); + let difficulty_bits = ledger.current_mine_difficulty_bits(); + for nonce in 0..u64::MAX { + let salt = 1; + let signature = mine_signature(recipient, &anchor, salt, nonce, difficulty_bits); + if hash_meets_difficulty(&signature, difficulty_bits) { + return Transaction::Mine { + recipient: recipient.to_string(), + anchor, + salt, + nonce, + difficulty_bits, + proof_header: None, + signature, + }; + } + } + panic!("expected to find mine proof"); +} + +fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet { + wallets + .iter() + .find(|wallet| wallet.address() == address) + .unwrap_or_else(|| panic!("missing wallet for address {address}")) +} + +fn ledger_with_finalizers( + finalizers: &[Wallet], + extra_allocations: &[(&Wallet, Amount)], +) -> Ledger { + let mut allocations = BTreeMap::new(); + for wallet in finalizers { + allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); + } + for (wallet, amount) in extra_allocations { + allocations.insert(wallet.address().to_string(), *amount); + } + Ledger::new_with_genesis_burns( + allocations, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap() +} + +fn mine_preverified_as_next_leader( + ledger: &mut Ledger, + wallets: &[Wallet], + timestamp_ms: u64, +) -> Block { + let leader = ledger.expected_leader_for_next_block().unwrap(); + let wallet = wallet_for_address(wallets, &leader); + let prepared = ledger + .prepare_next_block(wallet.address(), timestamp_ms) + .unwrap(); + let block = prepared.finish(wallet, "preverified-vdf".to_string()); + ledger + .apply_preverified_block_at(block.clone(), u64::MAX) + .unwrap(); + block +} + +fn mine_preverified_as_next_leader_with_reveal_bundles( + ledger: &mut Ledger, + wallets: &[Wallet], + timestamp_ms: u64, +) -> Block { + let block = + prepare_preverified_as_next_leader_with_reveal_bundles(ledger, wallets, timestamp_ms); + ledger + .apply_preverified_block_at(block.clone(), u64::MAX) + .unwrap(); + block +} + +fn prepare_preverified_as_next_leader_with_reveal_bundles( + ledger: &Ledger, + wallets: &[Wallet], + timestamp_ms: u64, +) -> Block { + let bundles = ledger + .reveal_committee_for_next_block() + .into_iter() + .filter_map(|member| { + let wallet = wallet_for_address(wallets, &member.owner); + ledger.build_reveal_bundle(wallet).unwrap() + }) + .collect::<Vec<_>>(); + let leader = ledger.expected_leader_for_next_block().unwrap(); + let wallet = wallet_for_address(wallets, &leader); + let prepared = ledger + .prepare_next_block_with_reveal_bundles(wallet.address(), timestamp_ms, bundles) + .unwrap(); + prepared.finish(wallet, "preverified-vdf".to_string()) +} + +fn advance_preverified_to_height(ledger: &mut Ledger, wallets: &[Wallet], target_height: u64) { + while ledger.height() < target_height { + queue_next_leader_burn(ledger, wallets); + let timestamp_ms = ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS); + mine_preverified_as_next_leader(ledger, wallets, timestamp_ms); + } +} + +fn queue_next_leader_burn(ledger: &mut Ledger, wallets: &[Wallet]) { + let leader = ledger.expected_leader_for_next_block().unwrap(); + let wallet = wallet_for_address(wallets, &leader); + let burn = ledger.build_burn(wallet, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); +} + +fn transfer_with_extra_zero_outputs( + ledger: &Ledger, + wallet: &Wallet, + to: &str, + amount: Amount, + fee: Amount, + extra_outputs: usize, +) -> Transaction { + let required = amount.checked_add(fee).unwrap(); + let (inputs, input_total) = ledger.select_inputs(wallet.address(), required).unwrap(); + let mut outputs = vec![TxOutput { + address: to.to_string(), + amount, + }]; + outputs.extend((0..extra_outputs).map(|_| TxOutput { + address: to.to_string(), + amount: 0, + })); + let change = input_total - required; + if change > 0 { + outputs.push(TxOutput { + address: wallet.address().to_string(), + amount: change, + }); + } + UnsignedUtxoTransaction::Transfer { + inputs, + outputs, + fee, + } + .sign(wallet) +} + +#[test] +fn wallet_utxos_only_include_outputs_owned_by_address() { + let alice = Wallet::from_seed("wallet-utxos-alice"); + let bob = Wallet::from_seed("wallet-utxos-bob"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3]); + ledger.utxos.insert( + named_test_outpoint("bob"), + TxOutput { + address: bob.address().to_string(), + amount: 5, + }, + ); + + let alice_utxos = ledger.utxos_for_address(alice.address()); + let total = alice_utxos + .iter() + .map(|(_, output)| output.amount) + .sum::<Amount>(); + + assert_eq!(alice_utxos.len(), 2); + assert_eq!(total, ledger.balance_of(alice.address())); + assert!( + alice_utxos + .iter() + .all(|(_, output)| output.address == alice.address()) + ); +} + +#[test] +fn transfer_combines_multiple_small_utxos_to_cover_amount_and_fee() { + let alice = Wallet::from_seed("combine-small-utxos-alice"); + let bob = Wallet::from_seed("combine-small-utxos-bob"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 1]); + + let tx = ledger.build_transfer(&alice, bob.address(), 2, 1).unwrap(); + + let Transaction::Transfer { + inputs, + outputs, + fee, + .. + } = &tx + else { + panic!("expected transfer"); + }; + assert_eq!(inputs.len(), 3); + assert_eq!(*fee, 1); + assert_eq!( + outputs, + &[TxOutput { + address: bob.address().to_string(), + amount: 2 + }] + ); + + ledger.submit_transaction(tx).unwrap(); + let balances = pending_balances(&ledger); + assert_eq!( + balances.get(alice.address()).copied().unwrap_or_default(), + 0 + ); + assert_eq!(balances.get(bob.address()).copied().unwrap_or_default(), 2); +} + +#[test] +fn transfer_returns_change_when_combined_utxos_exceed_payment() { + let alice = Wallet::from_seed("combine-change-alice"); + let bob = Wallet::from_seed("combine-change-bob"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 2]); + + let tx = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); + + let Transaction::Transfer { + inputs, outputs, .. + } = &tx + else { + panic!("expected transfer"); + }; + assert_eq!(inputs.len(), 3); + assert_eq!( + outputs, + &[ + TxOutput { + address: bob.address().to_string(), + amount: 3 + }, + TxOutput { + address: alice.address().to_string(), + amount: 1 + } + ] + ); + + ledger.submit_transaction(tx).unwrap(); + let balances = pending_balances(&ledger); + assert_eq!(balances.get(alice.address()).copied(), Some(1)); + assert_eq!(balances.get(bob.address()).copied(), Some(3)); +} + +#[test] +fn transaction_economic_size_uses_compact_canonical_fields() { + let alice = Wallet::from_seed("economic-size-alice"); + let bob = Wallet::from_seed("economic-size-bob"); + let ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 2]); + let selected = vec![ + test_utxo_outpoint(0), + test_utxo_outpoint(1), + test_utxo_outpoint(2), + ]; + + let tx = ledger + .build_transfer_with_inputs(&alice, bob.address(), 3, 0, &selected) + .unwrap(); + + assert!(tx.economic_size_bytes() < tx.serialized_size_bytes().unwrap()); + assert_eq!( + tx.economic_size_bytes(), + 1 + 1 + (3 * (32 + 1 + 32)) + 1 + (2 * (32 + 1)) + 1 + 64 + ); +} + +#[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 blinded_fee_split_burns_rounding_dust() { + let committer = Wallet::from_seed("blinded-split-committer"); + let executor = Wallet::from_seed("blinded-split-executor"); + let commitment = "01".repeat(32); + let active = ActiveBlindedTransaction { + transaction: BlindedTransaction { + commitment: commitment.clone(), + inputs: Vec::new(), + fee: 1, + encrypted_size: 1, + expires_at_height: 2, + nonce: "02".repeat(BLINDED_NONCE_BYTES), + ciphertext: "03".to_string(), + payload_hash: "04".repeat(32), + }, + locked_outputs: Vec::new(), + included_height: 1, + included_by: committer.address().to_string(), + }; + let transaction = Transaction::Transfer { + inputs: Vec::new(), + outputs: Vec::new(), + fee: 1, + signature: String::new(), + }; + let mut utxos = BTreeMap::new(); + + credit_blinded_fee_outputs( + &mut utxos, + &active, + executor.address(), + &transaction, + &[], + 3, + false, + ) + .unwrap(); + + assert!(!utxos.contains_key(&blinded_committer_fee_outpoint(&commitment))); + assert!(!utxos.contains_key(&blinded_executor_fee_outpoint(&commitment))); +} + +#[test] +fn blinded_fee_split_pays_no_reveal_finalizer_without_signed_reveal_lists() { + let committer = Wallet::from_seed("blinded-no-list-committer"); + let executor = Wallet::from_seed("blinded-no-list-executor"); + let commitment = "06".repeat(32); + let active = ActiveBlindedTransaction { + transaction: BlindedTransaction { + commitment: commitment.clone(), + inputs: Vec::new(), + fee: 100, + encrypted_size: 1, + expires_at_height: 2, + nonce: "02".repeat(BLINDED_NONCE_BYTES), + ciphertext: "03".to_string(), + payload_hash: "04".repeat(32), + }, + locked_outputs: Vec::new(), + included_height: 1, + included_by: committer.address().to_string(), + }; + let transaction = Transaction::Transfer { + inputs: Vec::new(), + outputs: Vec::new(), + fee: 100, + signature: String::new(), + }; + let mut utxos = BTreeMap::new(); + + credit_blinded_fee_outputs( + &mut utxos, + &active, + executor.address(), + &transaction, + &[], + 3, + false, + ) + .unwrap(); + + assert_eq!( + utxos.get(&blinded_committer_fee_outpoint(&commitment)), + Some(&TxOutput { + address: committer.address().to_string(), + amount: 35, + }) + ); + assert!(!utxos.contains_key(&blinded_executor_fee_outpoint(&commitment))); +} + +#[test] +fn blinded_fee_split_pays_committer_executor_and_reveal_bundle_signers() { + let committer = Wallet::from_seed("blinded-scale-committer"); + let executor = Wallet::from_seed("blinded-scale-executor"); + let signer_a = Wallet::from_seed("blinded-scale-signer-a"); + let signer_b = Wallet::from_seed("blinded-scale-signer-b"); + let commitment = "05".repeat(32); + let active = ActiveBlindedTransaction { + transaction: BlindedTransaction { + commitment: commitment.clone(), + inputs: Vec::new(), + fee: 7, + encrypted_size: 1, + expires_at_height: 2, + nonce: "02".repeat(BLINDED_NONCE_BYTES), + ciphertext: "03".to_string(), + payload_hash: "04".repeat(32), + }, + locked_outputs: Vec::new(), + included_height: 1, + included_by: committer.address().to_string(), + }; + let transaction = Transaction::Transfer { + inputs: Vec::new(), + outputs: Vec::new(), + fee: 100, + signature: String::new(), + }; + let mut utxos = BTreeMap::new(); + let signatures = vec![ + RevealBundleSignature { + slot: 0, + member: signer_a.address().to_string(), + signature: "11".repeat(SIGNATURE_BYTES), + }, + RevealBundleSignature { + slot: 2, + member: signer_b.address().to_string(), + signature: "22".repeat(SIGNATURE_BYTES), + }, + ]; + + credit_blinded_fee_outputs( + &mut utxos, + &active, + executor.address(), + &transaction, + &signatures, + 3, + false, + ) + .unwrap(); + + assert_eq!( + utxos.get(&blinded_committer_fee_outpoint(&commitment)), + Some(&TxOutput { + address: committer.address().to_string(), + amount: 35, + }) + ); + assert_eq!( + utxos.get(&blinded_executor_fee_outpoint(&commitment)), + Some(&TxOutput { + address: executor.address().to_string(), + amount: 23, + }) + ); + assert_eq!( + utxos.get(&blinded_reveal_bundle_signer_fee_outpoint(&commitment, 0)), + Some(&TxOutput { + address: signer_a.address().to_string(), + amount: 10, + }) + ); + assert_eq!( + utxos.get(&blinded_reveal_bundle_signer_fee_outpoint(&commitment, 2)), + Some(&TxOutput { + address: signer_b.address().to_string(), + amount: 10, + }) + ); +} + +#[test] +fn blinded_reveal_finalizer_fee_scales_by_available_reveal_bundle_slots() { + let fee = 300_000; + let full_share = blinded_fee_share(fee, BLINDED_REVEAL_FINALIZER_FEE_BPS); + + assert_eq!(blinded_reveal_finalizer_fee(fee, 0, 3), 0); + assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 3), full_share / 3); + assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 2), full_share / 2); + assert_eq!(blinded_reveal_finalizer_fee(fee, 1, 1), full_share); + assert_eq!(blinded_reveal_finalizer_fee(fee, 2, 3), full_share * 2 / 3); + assert_eq!(blinded_reveal_finalizer_fee(fee, 3, 3), full_share); + assert_eq!(blinded_reveal_finalizer_fee(fee, 4, 3), full_share); +} + +#[test] +fn transfer_rejects_invalid_recipient_address() { + let alice = Wallet::from_seed("invalid-transfer-recipient-alice"); + let ledger = ledger_with_wallet_utxos(&alice, &[10]); + + let error = ledger.build_transfer(&alice, "aa", 1, 0).unwrap_err(); + + assert!(format!("{error:#}").contains("invalid transfer recipient address")); +} + +#[test] +fn mine_rejects_invalid_recipient_address_before_pow() { + let ledger = Ledger::new(BTreeMap::new(), 1); + + let error = ledger.build_mine("aa").unwrap_err(); + + assert!(format!("{error:#}").contains("invalid mine recipient address")); +} + +#[test] +fn mine_search_respects_nonce_attempt_limit() { + let alice = Wallet::from_seed("bounded-mine-search-alice"); + let ledger = Ledger::new(BTreeMap::new(), 1); + + let outcome = ledger.search_mine(alice.address(), 1, 0, 0).unwrap(); + + assert!(outcome.transaction.is_none()); + assert_eq!(outcome.next_nonce, 0); + assert_eq!(outcome.attempts, 0); +} + +#[test] +fn mempool_rejects_invalid_input_outpoint_id() { + let alice = Wallet::from_seed("invalid-outpoint-alice"); + let bob = Wallet::from_seed("invalid-outpoint-bob"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[10]); + let unsigned = UnsignedUtxoTransaction::Transfer { + inputs: vec![UnsignedTxInput { + outpoint: OutPoint { + txid: "aa".to_string(), + index: 0, + }, + owner: alice.address().to_string(), + }], + outputs: vec![TxOutput { + address: bob.address().to_string(), + amount: 1, + }], + fee: 0, + }; + let transaction = unsigned.sign(&alice); + + let error = ledger.submit_transaction(transaction).unwrap_err(); + + assert!(format!("{error:#}").contains("invalid input outpoint txid")); + assert!(ledger.pending().is_empty()); +} + +#[test] +fn missing_input_transaction_goes_to_orphan_pool_not_pending_mempool() { + let alice = Wallet::from_seed("missing-input-orphan-alice"); + let bob = Wallet::from_seed("missing-input-orphan-bob"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[10]); + let transaction = UnsignedUtxoTransaction::Transfer { + inputs: vec![UnsignedTxInput { + outpoint: OutPoint { + txid: hex_hash("missing-input-orphan"), + index: 0, + }, + owner: alice.address().to_string(), + }], + outputs: vec![TxOutput { + address: bob.address().to_string(), + amount: 1, + }], + fee: 0, + } + .sign(&alice); + + let outcome = ledger.submit_transaction_with_outcome(transaction).unwrap(); + + assert_eq!(outcome, TransactionSubmitOutcome::Added); + assert!(ledger.pending().is_empty()); + assert_eq!(ledger.orphan_transactions().len(), 1); +} + +#[test] +fn vdf_retarget_observed_block_time_is_clamped() { + assert_eq!( + clamped_vdf_retarget_observed_block_ms(1), + MIN_VDF_RETARGET_OBSERVED_BLOCK_MS + ); + assert_eq!( + clamped_vdf_retarget_observed_block_ms(VDF_TARGET_BLOCK_MS), + VDF_TARGET_BLOCK_MS + ); + assert_eq!( + clamped_vdf_retarget_observed_block_ms(u64::MAX), + MAX_VDF_RETARGET_OBSERVED_BLOCK_MS + ); +} + +#[test] +fn vdf_retarget_observed_block_time_includes_historical_ticket_fallback_ranks() { + let parent = vdf_retarget_sample_block(0, FinalizerMode::Ticket, 0); + let primary_child = vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS, FinalizerMode::Ticket, 0); + let mut rank_one_child = + vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS * 2, FinalizerMode::Ticket, 1); + rank_one_child.height = FALLBACK_VDF_RETARGET_ACTIVATION_HEIGHT; + let mut rank_two_child = + vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS * 4, FinalizerMode::Ticket, 2); + rank_two_child.height = FALLBACK_VDF_RETARGET_DEACTIVATION_HEIGHT - 1; + + assert_eq!( + vdf_retarget_observed_block_ms(&parent, &primary_child), + Some(VDF_TARGET_BLOCK_MS) + ); + assert_eq!( + vdf_retarget_observed_block_ms(&parent, &rank_one_child), + Some(VDF_TARGET_BLOCK_MS * 2) + ); + assert_eq!( + vdf_retarget_observed_block_ms(&parent, &rank_two_child), + Some(VDF_TARGET_BLOCK_MS * 4) + ); +} + +#[test] +fn vdf_retarget_observed_block_time_ignores_new_ticket_fallback_ranks() { + let parent = vdf_retarget_sample_block(0, FinalizerMode::Ticket, 0); + let mut fallback_child = + vdf_retarget_sample_block(VDF_TARGET_BLOCK_MS * 2, FinalizerMode::Ticket, 1); + fallback_child.height = FALLBACK_VDF_RETARGET_DEACTIVATION_HEIGHT; + + assert_eq!( + vdf_retarget_observed_block_ms(&parent, &fallback_child), + None + ); +} + +#[test] +fn vdf_retarget_observed_block_time_ignores_recovery_blocks() { + let parent = vdf_retarget_sample_block(0, FinalizerMode::Ticket, 0); + let recovery_child = + vdf_retarget_sample_block(RECOVERY_BLOCK_DELAY_MS, FinalizerMode::Recovery, 0); + + assert_eq!( + vdf_retarget_observed_block_ms(&parent, &recovery_child), + None + ); +} + +#[test] +fn vdf_retarget_keeps_rounds_inside_deadband() { + let current = 1_000; + let low_deadband_edge = + VDF_TARGET_BLOCK_MS - VDF_TARGET_BLOCK_MS * VDF_RETARGET_DEADBAND_PERCENT as u64 / 100; + let high_deadband_edge = + VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS * VDF_RETARGET_DEADBAND_PERCENT as u64 / 100; + + assert_eq!(retarget_vdf_rounds(current, low_deadband_edge), current); + assert_eq!(retarget_vdf_rounds(current, VDF_TARGET_BLOCK_MS), current); + assert_eq!(retarget_vdf_rounds(current, high_deadband_edge), current); +} + +#[test] +fn vdf_retarget_limits_each_step_to_two_percent() { + let current = 1_000; + + assert_eq!( + retarget_vdf_rounds(current, MIN_VDF_RETARGET_OBSERVED_BLOCK_MS), + 1_020 + ); + assert_eq!( + retarget_vdf_rounds(current, MAX_VDF_RETARGET_OBSERVED_BLOCK_MS), + 980 + ); +} + +#[test] +fn vdf_rounds_retarget_below_legacy_u32_limit_after_slow_blocks() { + let wallet = Wallet::from_seed("vdf-rounds-slow-above-u32"); + let initial_rounds = u64::from(u32::MAX); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 1_000); + let mut ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(wallet.address(), 1)], + initial_rounds, + ) + .unwrap(); + + let block1 = apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS); + assert_eq!(block1.vdf_rounds, initial_rounds); + assert_eq!(ledger.vdf_rounds(), initial_rounds); + + let block2 = apply_preverified_burn_block_at( + &mut ledger, + &wallet, + VDF_TARGET_BLOCK_MS + VDF_TARGET_BLOCK_MS * 2, + ); + assert_eq!(block2.vdf_rounds, initial_rounds); + + assert!( + ledger.vdf_rounds() < initial_rounds, + "slow blocks should retarget below the legacy u32 VDF rounds ceiling" + ); +} + +#[test] +fn fallback_block_before_activation_is_excluded_from_vdf_retarget_observations() { + let alice = Wallet::from_seed("fallback-retarget-alice"); + let bob = Wallet::from_seed("fallback-retarget-bob"); + let wallets = [&alice, &bob]; + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), 1_000); + genesis.insert(bob.address().to_string(), 1_000); + let mut ledger = Ledger::new_with_genesis_burns( + genesis, + vec![ + GenesisBurn::new(alice.address(), 1), + GenesisBurn::new(bob.address(), 1), + ], + 100, + ) + .unwrap(); + + let primary = ledger.expected_leader_for_next_block().unwrap(); + let primary_wallet = wallets + .into_iter() + .find(|wallet| wallet.address() == primary) + .unwrap(); + apply_preverified_burn_block_at(&mut ledger, primary_wallet, VDF_TARGET_BLOCK_MS); + assert_eq!(ledger.vdf_rounds(), 100); + + let primary = ledger.expected_leader_for_next_block().unwrap(); + let fallback = wallets + .into_iter() + .find(|wallet| wallet.address() != primary) + .unwrap(); + let timestamp_ms = ledger.tip().timestamp_ms + 1; + let block = apply_preverified_burn_block_at(&mut ledger, fallback, timestamp_ms); + + assert_eq!(block.finalizer_rank, 1); + assert_eq!(block.vdf_rounds, 200); + assert_eq!(ledger.vdf_rounds(), 100); +} + +#[test] +fn recovery_block_is_excluded_from_vdf_retarget_observations() { + let alice = Wallet::from_seed("recovery-retarget-alice"); + let bob = Wallet::from_seed("recovery-retarget-bob"); + let mut genesis = BTreeMap::new(); + genesis.insert(alice.address().to_string(), 1_000); + genesis.insert(bob.address().to_string(), 1_000); + let mut ledger = + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 100) + .unwrap(); + + apply_preverified_burn_block_at(&mut ledger, &alice, VDF_TARGET_BLOCK_MS); + assert_eq!(ledger.vdf_rounds(), 100); + + let burn = ledger.build_burn(&bob, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger + .mine_recovery_block(&bob, VDF_TARGET_BLOCK_MS + RECOVERY_BLOCK_DELAY_MS) + .unwrap(); + assert_eq!(block.finalizer_mode, FinalizerMode::Recovery); + ledger.apply_block(block).unwrap(); + + assert_eq!(ledger.vdf_rounds(), 100); +} + +#[test] +fn generated_vdf_retarget_decreases_after_slow_blocks_above_legacy_limit() { + let legacy_limit = u64::from(u32::MAX); + let slow_observed_ms = [ + VDF_TARGET_BLOCK_MS * 6 / 5, + VDF_TARGET_BLOCK_MS * 2, + VDF_TARGET_BLOCK_MS * 3, + MAX_VDF_RETARGET_OBSERVED_BLOCK_MS, + ]; + + for seed in 0..16_u64 { + let wallet = Wallet::from_seed(&format!("generated-vdf-retarget-{seed}")); + let initial_rounds = legacy_limit + 1 + seed * 1_000_003; + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 1_000); + let mut ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(wallet.address(), 1)], + initial_rounds, + ) + .unwrap(); + let observed_ms = slow_observed_ms[seed as usize % slow_observed_ms.len()]; + + apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS); + let second = apply_preverified_burn_block_at( + &mut ledger, + &wallet, + VDF_TARGET_BLOCK_MS + observed_ms, + ); + + assert_eq!(second.vdf_rounds, initial_rounds); + assert!( + ledger.vdf_rounds() < initial_rounds, + "seed {seed} with observed {observed_ms}ms should lower VDF rounds from {initial_rounds}, got {}", + ledger.vdf_rounds() + ); + + let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let next_work = ledger + .prepare_next_block(wallet.address(), second.timestamp_ms + VDF_TARGET_BLOCK_MS) + .unwrap(); + assert_eq!(next_work.vdf_rounds(), ledger.vdf_rounds()); + } +} + +#[test] +fn block_timestamp_future_check_uses_supplied_network_time() { + let wallet = Wallet::from_seed("adjusted-time-domain"); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 1_000); + let mut ledger = Ledger::new(allocations, 1); + + let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); + assert!(ledger.submit_transaction(burn).unwrap()); + let block = ledger.mine_next_block(&wallet, 10 * 60 * 1_000).unwrap(); + + let error = ledger.apply_block_at(block, 1_000).unwrap_err(); + + assert!(format!("{error:#}").contains("too far in the future")); +} + +#[test] +fn ticket_block_timestamp_uses_finalizer_rank_time_slot() { + let alice = Wallet::from_seed("rank-slot-alice"); + let bob = Wallet::from_seed("rank-slot-bob"); + let wallets = [alice.clone(), bob.clone()]; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(bob.address().to_string(), 1_000); + let mut ledger = Ledger::new_with_genesis_burns( + allocations, + vec![ + GenesisBurn::new(alice.address(), 1), + GenesisBurn::new(bob.address(), 1), + ], + 100, + ) + .unwrap(); + + let primary = wallet_for_address(&wallets, &ledger.expected_leader_for_next_block().unwrap()); + let burn = ledger.build_burn(primary, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let work = ledger.prepare_next_block(primary.address(), 1).unwrap(); + assert_eq!(work.timestamp_ms(), 1); + let block = work.finish(primary, "preverified-vdf".to_string()); + ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); + + let fallback = wallets + .iter() + .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1)) + .expect("expected rank 1 fallback"); + let burn = ledger.build_burn(fallback, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let parent_timestamp = ledger.tip().timestamp_ms; + let work = ledger + .prepare_next_block(fallback.address(), parent_timestamp + 1) + .unwrap(); + + assert_eq!( + work.timestamp_ms(), + parent_timestamp + VDF_TARGET_BLOCK_MS * 2 + ); + assert_eq!(work.vdf_rounds(), ledger.vdf_rounds() * 2); +} + +#[test] +fn required_anchor_burn_is_selected_before_higher_fee_burns_when_block_is_full() { + let wallet = Wallet::from_seed("required-anchor-priority-wallet"); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 10 * MICRO_IUNA); + let mut ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + let high_fee_outpoint = named_test_outpoint("required-anchor-priority-high-fee"); + let anchor_outpoint = named_test_outpoint("required-anchor-priority-anchor"); + ledger.utxos.insert( + high_fee_outpoint.clone(), + TxOutput { + address: wallet.address().to_string(), + amount: 3, + }, + ); + ledger.utxos.insert( + anchor_outpoint.clone(), + TxOutput { + address: wallet.address().to_string(), + amount: 2, + }, + ); + let high_fee_burn = ledger + .build_burn_with_inputs(&wallet, 1, 1, &[high_fee_outpoint]) + .unwrap(); + let anchor_burn = ledger + .build_burn_with_inputs(&wallet, 1, 0, &[anchor_outpoint]) + .unwrap(); + ledger.submit_transaction(high_fee_burn).unwrap(); + ledger.submit_transaction(anchor_burn.clone()).unwrap(); + ledger.launch_profile.max_block_transactions = 1; + + let work = ledger + .prepare_next_block_with_required_burn_and_reveal_bundles( + wallet.address(), + 1, + Vec::new(), + Some(anchor_burn.signature()), + ) + .unwrap(); + let block = work.finish(&wallet, "preverified-vdf".to_string()); + + assert_eq!(block.transactions.len(), 1); + assert_eq!(block.transactions[0].signature(), anchor_burn.signature()); +} + +#[test] +fn late_ticket_vdf_completion_is_visible_to_retarget() { + let wallet = Wallet::from_seed("late-ticket-vdf-wallet"); + let mut allocations = BTreeMap::new(); + allocations.insert(wallet.address().to_string(), 1_000); + let mut ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(wallet.address(), 1)], + 100, + ) + .unwrap(); + + apply_preverified_burn_block_at(&mut ledger, &wallet, VDF_TARGET_BLOCK_MS); + assert_eq!(ledger.vdf_rounds(), 100); + + let burn = ledger.build_burn(&wallet, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let work = ledger + .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) + .unwrap(); + let scheduled_timestamp = work.timestamp_ms(); + let late_timestamp = ledger.tip().timestamp_ms + VDF_TARGET_BLOCK_MS * 3; + assert!(late_timestamp > scheduled_timestamp); + + let block = work.finish_at(&wallet, "preverified-vdf".to_string(), late_timestamp); + assert_eq!(block.timestamp_ms, late_timestamp); + ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); + + assert!( + ledger.vdf_rounds() < 100, + "late VDF completion should lower future VDF rounds" + ); +} + +#[test] +fn block_before_finalizer_rank_time_slot_is_rejected() { + let alice = Wallet::from_seed("rank-slot-reject-alice"); + let bob = Wallet::from_seed("rank-slot-reject-bob"); + let wallets = [alice.clone(), bob.clone()]; + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1_000); + allocations.insert(bob.address().to_string(), 1_000); + let mut ledger = Ledger::new_with_genesis_burns( + allocations, + vec![ + GenesisBurn::new(alice.address(), 1), + GenesisBurn::new(bob.address(), 1), + ], + 100, + ) + .unwrap(); + + let primary = wallet_for_address(&wallets, &ledger.expected_leader_for_next_block().unwrap()); + let burn = ledger.build_burn(primary, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let work = ledger.prepare_next_block(primary.address(), 1).unwrap(); + let block = work.finish(primary, "preverified-vdf".to_string()); + ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); + + let fallback = wallets + .iter() + .find(|wallet| ledger.finalizer_rank_for_next_block(wallet.address()) == Some(1)) + .expect("expected rank 1 fallback"); + let burn = ledger.build_burn(fallback, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let parent_timestamp = ledger.tip().timestamp_ms; + let work = ledger + .prepare_next_block(fallback.address(), parent_timestamp + 1) + .unwrap(); + let mut block = work.finish(fallback, "preverified-vdf".to_string()); + block.timestamp_ms = parent_timestamp + VDF_TARGET_BLOCK_MS * 2 - 1; + block.hash = block.compute_hash(); + + let error = ledger + .apply_preverified_block_at(block, u64::MAX) + .unwrap_err(); + + assert!(format!("{error:#}").contains("before finalizer rank 1 time slot")); +} + +#[test] +fn miner_skips_oversized_pending_transaction_and_keeps_fitting_fee_transaction() { + let alice = Wallet::from_seed("oversized-select-alice"); + let bob = Wallet::from_seed("oversized-select-bob"); + let carol = Wallet::from_seed("oversized-select-carol"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 1); + allocations.insert(bob.address().to_string(), 300_000); + allocations.insert(carol.address().to_string(), 300_000); + let mut ledger = + Ledger::new_with_genesis_burns(allocations, vec![GenesisBurn::new(alice.address(), 1)], 10) + .unwrap(); + let burn = ledger.build_burn(&alice, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let oversized = + transfer_with_extra_zero_outputs(&ledger, &bob, alice.address(), 1, 100_000, 4_000); + let fitting = ledger + .build_transfer(&carol, alice.address(), 1, 5) + .unwrap(); + assert!(oversized.serialized_size_bytes().unwrap() > MAX_BLOCK_BYTES); + ledger.submit_transaction(oversized.clone()).unwrap(); + ledger.submit_transaction(fitting.clone()).unwrap(); + + let block = ledger.mine_next_block(&alice, 1).unwrap(); + let signatures = block + .transactions + .iter() + .map(|tx| tx.signature().to_string()) + .collect::<Vec<_>>(); + + assert!(!signatures.contains(&oversized.signature().to_string())); + assert!(signatures.contains(&fitting.signature().to_string())); + assert!(block.serialized_size_bytes().unwrap() <= MAX_BLOCK_BYTES); +} + +#[test] +fn transfer_can_spend_selected_utxos_when_they_cover_amount_and_fee() { + let alice = Wallet::from_seed("selected-utxos-alice"); + let bob = Wallet::from_seed("selected-utxos-bob"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]); + let selected = vec![test_utxo_outpoint(2)]; + + let tx = ledger + .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected) + .unwrap(); + + let Transaction::Transfer { + inputs, outputs, .. + } = &tx + else { + panic!("expected transfer"); + }; + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].outpoint, selected[0]); + assert_eq!( + outputs, + &[ + TxOutput { + address: bob.address().to_string(), + amount: 2 + }, + TxOutput { + address: alice.address().to_string(), + amount: 2 + } + ] + ); + + ledger.submit_transaction(tx).unwrap(); + let balances = pending_balances(&ledger); + assert_eq!(balances.get(bob.address()).copied(), Some(2)); + assert_eq!(balances.get(alice.address()).copied(), Some(7)); +} + +#[test] +fn burn_can_spend_selected_utxos_when_they_cover_amount_and_fee() { + let alice = Wallet::from_seed("selected-burn-utxos-alice"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]); + let selected = vec![test_utxo_outpoint(1)]; + + let tx = ledger + .build_burn_with_inputs(&alice, 1, 1, &selected) + .unwrap(); + + let Transaction::Burn { + inputs, + change, + amount, + fee, + .. + } = &tx + else { + panic!("expected burn"); + }; + assert_eq!(*amount, 1); + assert_eq!(*fee, 1); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].outpoint, selected[0]); + assert_eq!( + change, + &[TxOutput { + address: alice.address().to_string(), + amount: 1 + }] + ); + + ledger.submit_transaction(tx).unwrap(); + let balances = pending_balances(&ledger); + assert_eq!(balances.get(alice.address()).copied(), Some(8)); +} + +#[test] +fn transfer_rejects_selected_utxos_that_do_not_cover_amount_plus_fee() { + let alice = Wallet::from_seed("selected-utxos-insufficient-alice"); + let bob = Wallet::from_seed("selected-utxos-insufficient-bob"); + let ledger = ledger_with_wallet_utxos(&alice, &[2, 3, 5]); + let selected = vec![test_utxo_outpoint(0)]; + + let error = ledger + .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected) + .unwrap_err(); + + assert!(format!("{error:#}").contains("selected UTXOs do not cover")); +} + +#[test] +fn transfer_rejects_selected_utxos_owned_by_someone_else() { + let alice = Wallet::from_seed("selected-utxos-owner-alice"); + let bob = Wallet::from_seed("selected-utxos-owner-bob"); + let carol = Wallet::from_seed("selected-utxos-owner-carol"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[5]); + ledger.utxos.insert( + named_test_outpoint("carol"), + TxOutput { + address: carol.address().to_string(), + amount: 5, + }, + ); + let selected = vec![named_test_outpoint("carol")]; + + let error = ledger + .build_transfer_with_inputs(&alice, bob.address(), 2, 1, &selected) + .unwrap_err(); + + assert!(format!("{error:#}").contains("is not owned")); +} + +#[test] +fn transfer_rejects_when_combined_utxos_do_not_cover_amount_plus_fee() { + let alice = Wallet::from_seed("combine-insufficient-alice"); + let bob = Wallet::from_seed("combine-insufficient-bob"); + let ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 1]); + + let error = ledger + .build_transfer(&alice, bob.address(), 3, 1) + .unwrap_err(); + + assert!(format!("{error:#}").contains("insufficient funds")); +} + +#[test] +fn pending_change_from_combined_utxos_can_fund_next_transaction() { + let alice = Wallet::from_seed("combine-pending-change-alice"); + let bob = Wallet::from_seed("combine-pending-change-bob"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[1, 1, 2]); + + let first = ledger.build_transfer(&alice, bob.address(), 3, 0).unwrap(); + let first_signature = first.signature().to_string(); + ledger.submit_transaction(first).unwrap(); + + let second = ledger.build_transfer(&alice, bob.address(), 1, 0).unwrap(); + let Transaction::Transfer { inputs, .. } = &second else { + panic!("expected transfer"); + }; + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].outpoint.txid, first_signature); + assert_eq!(inputs[0].outpoint.index, 1); + + ledger.submit_transaction(second).unwrap(); + let balances = pending_balances(&ledger); + assert_eq!( + balances.get(alice.address()).copied().unwrap_or_default(), + 0 + ); + assert_eq!(balances.get(bob.address()).copied(), Some(4)); +} + +#[test] +fn winning_burn_ticket_is_consumed_even_when_window_remains() { + let mut tickets = vec![ + BurnTicket { + id: "high-burn".to_string(), + owner: "alice".to_string(), + amount: 10_000, + eligible_from_height: 4, + eligible_until_height: 6, + }, + BurnTicket { + id: "small-burn".to_string(), + owner: "bob".to_string(), + amount: 1, + eligible_from_height: 5, + eligible_until_height: 7, + }, + ]; + let mut block = Block { + height: 4, + prev_hash: "0".repeat(64), + timestamp_ms: 1, + miner: "alice".to_string(), + finalizer_mode: FinalizerMode::Ticket, + finalizer_rank: 0, + reward: BLOCK_REWARD, + vdf_rounds: 1, + vdf_output: "vdf".to_string(), + leader_proof: Some(LeaderProof { + ticket_id: "high-burn".to_string(), + public_key: "alice".to_string(), + signature: "signature".to_string(), + }), + blinded_transactions: Vec::new(), + reveal_bundle_section: RevealBundleSection::default(), + transactions: Vec::new(), + hash: String::new(), + }; + block.hash = block.compute_hash(); + + consume_leader_ticket(&block, &mut tickets).unwrap(); + + assert!( + tickets.iter().all(|ticket| ticket.id != "high-burn"), + "a winning burn must not remain eligible for the rest of its window" + ); + assert!( + tickets.iter().any(|ticket| ticket.id == "small-burn"), + "unselected future tickets should remain pending" + ); +} + +#[test] +fn burn_leader_ranks_for_block_reconstructs_historical_ticket_order() { + let alice = Wallet::from_seed("burn-rank-alice"); + let bob = Wallet::from_seed("burn-rank-bob"); + let mut allocations = BTreeMap::new(); + allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA); + allocations.insert(bob.address().to_string(), 10 * MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![ + GenesisBurn::new(alice.address(), MICRO_IUNA), + GenesisBurn::new(bob.address(), MICRO_IUNA), + ], + 1, + ) + .unwrap(); + + let ranks = ledger.burn_leader_ranks_for_block(1).unwrap(); + let leader = ledger.expected_leader_for_next_block().unwrap(); + + assert_eq!(ranks.len(), 2); + assert_eq!(ranks[0].rank, 0); + assert_eq!(ranks[0].owner, leader); + assert!(ranks.iter().all(|rank| rank.amount == MICRO_IUNA)); + assert_eq!(ledger.burn_leader_ranks_for_block(0).unwrap(), Vec::new()); +} + +#[test] +fn mine_recipient_is_bound_to_proof_hash() { + let alice = Wallet::from_seed("mine-proof-alice"); + let bob = Wallet::from_seed("mine-proof-bob"); + let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); + let mut forged = unsigned_mine(&ledger, alice.address()); + if let Transaction::Mine { recipient, .. } = &mut forged { + *recipient = bob.address().to_string(); + } + + let error = ledger.submit_transaction(forged).unwrap_err(); + + assert!(format!("{error:#}").contains("proof hash is invalid")); +} + +#[test] +fn mine_action_uses_fixed_reward_and_fixed_finalizer_fee() { + let alice = Wallet::from_seed("mine-fixed-reward-alice"); + let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); + + let mine = ledger.build_mine(alice.address()).unwrap(); + + assert_eq!(mine.amount(), MINE_REWARD); + assert_eq!(mine.fee(), MINE_FINALIZER_FEE); + assert!(ledger.submit_transaction(mine).unwrap()); +} + +#[test] +fn burn_fee_goes_to_block_finalizer() { + let alice = Wallet::from_seed("burn-fee-finalizer-alice"); + let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); + + let burn_fee = 12; + let burn = ledger + .build_burn(&alice, TEST_BURN_AMOUNT, burn_fee) + .unwrap(); + ledger.submit_transaction(burn).unwrap(); + let prepared = ledger.prepare_next_block(alice.address(), 1).unwrap(); + + assert_eq!(prepared.reward, burn_fee); +} + +#[test] +fn blinded_burn_commits_ciphertext_and_reveal_executes_later() { + let alice = Wallet::from_seed("blinded-burn-finalizer-alice"); + let bob = Wallet::from_seed("blinded-burn-finalizer-bob"); + let carol = Wallet::from_seed("blinded-burn-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + let fee = 100; + let burn_amount = 3; + let before_carol = ledger.balance_of(carol.address()); + + let blinded = ledger + .build_blinded_burn(&carol, burn_amount, fee, ledger.height() + 4) + .unwrap(); + assert!(!blinded.transaction.ciphertext.contains("burn")); + assert!(!blinded.transaction.ciphertext.contains(carol.address())); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + + let commit_block = mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + let inclusion_finalizer = commit_block.miner.clone(); + assert_eq!( + commit_block + .transactions + .iter() + .filter(|transaction| transaction.is_burn()) + .count(), + 1 + ); + assert_eq!(commit_block.blinded_transactions, vec![blinded.transaction]); + assert_eq!(commit_block.reward, 0); + let before_inclusion_finalizer = ledger.balance_of(&inclusion_finalizer); + + ledger.submit_blinded_reveal(blinded.reveal).unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + let reveal_block = + mine_preverified_as_next_leader_with_reveal_bundles(&mut ledger, &finalizers, 2); + let reveal_executor = reveal_block.miner.clone(); + + assert_eq!(reveal_block.all_blinded_reveals().len(), 1); + assert_eq!( + 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() + .filter(|transaction| { + transaction.is_burn() && transaction.sender() == inclusion_finalizer.as_str() + }) + .fold(0_u64, |total, transaction| { + total + transaction.amount() + transaction.fee() + }); + let committer_fee = blinded_fee_share(fee, BLINDED_COMMITTER_FEE_BPS); + let reveal_finalizer_fee = blinded_reveal_finalizer_fee( + fee, + reveal_block.included_reveal_bundle_count(), + ledger + .burn_leader_ranks_for_block(reveal_block.height) + .unwrap() + .len(), + ); + let reveal_bundle_signer_fee = blinded_fee_share(fee, BLINDED_REVEAL_BUNDLE_SIGNER_FEE_BPS); + let commitment = &commit_block.blinded_transactions[0].commitment; + assert_eq!( + ledger + .utxos + .get(&blinded_committer_fee_outpoint(commitment)) + .unwrap(), + &TxOutput { + address: inclusion_finalizer.clone(), + amount: committer_fee, + } + ); + assert_eq!( + ledger + .utxos + .get(&blinded_executor_fee_outpoint(commitment)) + .unwrap(), + &TxOutput { + address: reveal_executor.clone(), + amount: reveal_finalizer_fee, + } + ); + for signature in &reveal_block.reveal_bundle_section.signatures { + assert_eq!( + ledger + .utxos + .get(&blinded_reveal_bundle_signer_fee_outpoint( + commitment, + signature.slot + )) + .unwrap(), + &TxOutput { + address: signature.member.clone(), + amount: reveal_bundle_signer_fee, + } + ); + } + let mut inclusion_finalizer_fee = committer_fee; + if inclusion_finalizer == reveal_executor { + inclusion_finalizer_fee += reveal_finalizer_fee; + } + inclusion_finalizer_fee += reveal_block + .reveal_bundle_section + .signatures + .iter() + .filter(|signature| signature.member == inclusion_finalizer) + .count() as u64 + * reveal_bundle_signer_fee; + assert_eq!( + ledger.balance_of(&inclusion_finalizer), + before_inclusion_finalizer + inclusion_finalizer_fee + - reveal_plaintext_burn_spent_by_inclusion_finalizer + ); +} + +#[test] +fn activated_blinded_reveal_finalizer_fees_are_aggregated_into_block_reward() { + let alice = Wallet::from_seed("activated-finalizer-fee-alice"); + let bob = Wallet::from_seed("activated-finalizer-fee-bob"); + let carol = Wallet::from_seed("activated-finalizer-fee-carol"); + let dave = Wallet::from_seed("activated-finalizer-fee-dave"); + let finalizers = [alice.clone(), bob.clone()]; + let mut ledger = ledger_with_finalizers( + &finalizers, + &[(&carol, 10 * MICRO_IUNA), (&dave, 10 * MICRO_IUNA)], + ); + advance_preverified_to_height( + &mut ledger, + &finalizers, + AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 2, + ); + let first_fee = 100; + let second_fee = 200; + let first_blinded = ledger + .build_blinded_burn(&carol, 3, first_fee, ledger.height() + 4) + .unwrap(); + let second_blinded = ledger + .build_blinded_burn(&dave, 4, second_fee, ledger.height() + 4) + .unwrap(); + let first_commitment = first_blinded.transaction.commitment.clone(); + let second_commitment = second_blinded.transaction.commitment.clone(); + ledger + .submit_blinded_transaction(first_blinded.transaction) + .unwrap(); + ledger + .submit_blinded_transaction(second_blinded.transaction) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + let commit_timestamp_ms = ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS); + let commit_block = + mine_preverified_as_next_leader(&mut ledger, &finalizers, commit_timestamp_ms); + assert_eq!( + commit_block.height, + AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 1 + ); + + ledger.submit_blinded_reveal(first_blinded.reveal).unwrap(); + ledger.submit_blinded_reveal(second_blinded.reveal).unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + let reveal_timestamp_ms = ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS); + let reveal_block = prepare_preverified_as_next_leader_with_reveal_bundles( + &ledger, + &finalizers, + reveal_timestamp_ms, + ); + let first_reveal_finalizer_fee = blinded_reveal_finalizer_fee( + first_fee, + reveal_block.included_reveal_bundle_count(), + ledger + .burn_leader_ranks_for_block(reveal_block.height) + .unwrap() + .len(), + ); + let second_reveal_finalizer_fee = blinded_reveal_finalizer_fee( + second_fee, + reveal_block.included_reveal_bundle_count(), + ledger + .burn_leader_ranks_for_block(reveal_block.height) + .unwrap() + .len(), + ); + let aggregate_reveal_finalizer_fee = first_reveal_finalizer_fee + .checked_add(second_reveal_finalizer_fee) + .unwrap(); + + assert_eq!( + reveal_block.height, + AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT + ); + assert_eq!(reveal_block.reward, aggregate_reveal_finalizer_fee); + let mut legacy_reward_block = reveal_block.clone(); + legacy_reward_block.reward = fee_reward(&legacy_reward_block.transactions).unwrap(); + legacy_reward_block.hash = legacy_reward_block.compute_hash(); + let error = ledger + .clone() + .apply_preverified_block_at(legacy_reward_block, u64::MAX) + .unwrap_err(); + assert!(format!("{error:#}").contains("block reward is invalid")); + + ledger + .apply_preverified_block_at(reveal_block.clone(), u64::MAX) + .unwrap(); + assert!( + !ledger + .utxos + .contains_key(&blinded_executor_fee_outpoint(&first_commitment)) + ); + assert!( + !ledger + .utxos + .contains_key(&blinded_executor_fee_outpoint(&second_commitment)) + ); + assert_eq!( + ledger.utxos.get(&reward_outpoint(&reveal_block.hash)), + Some(&TxOutput { + address: reveal_block.miner.clone(), + amount: aggregate_reveal_finalizer_fee, + }) + ); +} + +#[test] +fn pre_activation_blinded_reveal_finalizer_fee_stays_as_executor_utxo_at_boundary() { + let alice = Wallet::from_seed("pre-activated-finalizer-fee-alice"); + let bob = Wallet::from_seed("pre-activated-finalizer-fee-bob"); + let carol = Wallet::from_seed("pre-activated-finalizer-fee-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + advance_preverified_to_height( + &mut ledger, + &finalizers, + AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 3, + ); + let fee = 100; + let blinded = ledger + .build_blinded_burn(&carol, 3, fee, ledger.height() + 4) + .unwrap(); + let commitment = blinded.transaction.commitment.clone(); + ledger + .submit_blinded_transaction(blinded.transaction) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + let commit_timestamp_ms = ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS); + let commit_block = + mine_preverified_as_next_leader(&mut ledger, &finalizers, commit_timestamp_ms); + assert_eq!( + commit_block.height, + AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 2 + ); + + ledger.submit_blinded_reveal(blinded.reveal).unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + let reveal_timestamp_ms = ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS); + let reveal_block = mine_preverified_as_next_leader_with_reveal_bundles( + &mut ledger, + &finalizers, + reveal_timestamp_ms, + ); + let reveal_finalizer_fee = blinded_reveal_finalizer_fee( + fee, + reveal_block.included_reveal_bundle_count(), + ledger + .burn_leader_ranks_for_block(reveal_block.height) + .unwrap() + .len(), + ); + + assert_eq!( + reveal_block.height, + AGGREGATE_FINALIZER_FEE_ACTIVATION_HEIGHT - 1 + ); + assert_eq!(reveal_block.reward, 0); + assert_eq!( + ledger + .utxos + .get(&blinded_executor_fee_outpoint(&commitment)), + Some(&TxOutput { + address: reveal_block.miner, + amount: reveal_finalizer_fee, + }) + ); + assert!( + !ledger + .utxos + .contains_key(&reward_outpoint(&reveal_block.hash)) + ); +} + +#[test] +fn blinded_utxo_commit_exposes_and_locks_inputs_until_reveal_or_expiry() { + let alice = Wallet::from_seed("blinded-lock-alice"); + let bob = Wallet::from_seed("blinded-lock-bob"); + let mut ledger = ledger_with_wallet_utxos(&alice, &[10]); + let transfer = ledger.build_transfer(&alice, bob.address(), 3, 2).unwrap(); + let visible_inputs = transfer.inputs().to_vec(); + + let blinded = ledger + .build_blinded_transaction(&alice, transfer, ledger.height() + 4) + .unwrap(); + + assert_eq!(blinded.transaction.inputs.len(), visible_inputs.len()); + assert_eq!( + unsigned_inputs(&blinded.transaction.inputs), + unsigned_inputs(&visible_inputs) + ); + ledger + .submit_blinded_transaction(blinded.transaction) + .unwrap(); + let error = ledger + .build_transfer(&alice, bob.address(), 1, 0) + .unwrap_err(); + assert!(format!("{error:#}").contains("insufficient funds")); +} + +#[test] +fn blinded_payload_omits_visible_inputs_and_reconstructs_transaction_on_reveal() { + let alice = Wallet::from_seed("blinded-compact-payload-alice"); + let bob = Wallet::from_seed("blinded-compact-payload-bob"); + let ledger = ledger_with_wallet_utxos(&alice, &[10]); + let transfer = ledger.build_transfer(&alice, bob.address(), 3, 2).unwrap(); + let full_transaction_bytes = serde_json::to_vec(&transfer).unwrap().len(); + let blinded = ledger + .build_blinded_transaction(&alice, transfer.clone(), ledger.height() + 4) + .unwrap(); + let key = decode_hex_array::<BLINDED_KEY_BYTES>(&blinded.reveal.key).unwrap(); + let nonce = decode_hex_array::<BLINDED_NONCE_BYTES>(&blinded.transaction.nonce).unwrap(); + let ciphertext = decode_hex(&blinded.transaction.ciphertext).unwrap(); + + let plaintext = decrypt_blinded_payload( + &key, + &nonce, + &signed_blinded_inputs(&unsigned_inputs(&blinded.transaction.inputs), ""), + blinded.transaction.fee, + blinded.transaction.expires_at_height, + &ciphertext, + ) + .unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&plaintext).unwrap(); + let revealed = decrypt_blinded_transaction(&blinded.transaction, &blinded.reveal).unwrap(); + + assert_eq!( + payload.get("kind").and_then(|kind| kind.as_str()), + Some("transfer") + ); + assert!(payload.get("inputs").is_none()); + assert!(plaintext.len() < full_transaction_bytes); + assert_eq!(revealed, transfer); +} + +#[test] +fn unrevealed_blinded_utxo_commit_burns_fee_and_returns_change() { + let alice = Wallet::from_seed("blinded-expiry-alice"); + let bob = Wallet::from_seed("blinded-expiry-bob"); + let carol = Wallet::from_seed("blinded-expiry-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let carol_balance = 10 * MICRO_IUNA; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, carol_balance)]); + let fee = 100; + let blinded = ledger + .build_blinded_burn(&carol, 3, fee, ledger.height() + 2) + .unwrap(); + let commitment = blinded.transaction.commitment.clone(); + ledger + .submit_blinded_transaction(blinded.transaction) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + + assert_eq!(ledger.balance_of(carol.address()), 0); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 2); + + assert_eq!( + ledger + .utxos + .get(&blinded_committer_fee_outpoint(&commitment)), + None + ); + assert_eq!( + ledger + .utxos + .get(&blinded_expiry_change_outpoint(&commitment)), + Some(&TxOutput { + address: carol.address().to_string(), + amount: carol_balance - fee, + }) + ); + assert_eq!(ledger.balance_of(carol.address()), carol_balance - fee); +} + +#[test] +fn fee_bearing_blinded_commit_without_inputs_is_rejected() { + let alice = Wallet::from_seed("blinded-no-input-fee-alice"); + let mut ledger = ledger_with_allocation(&alice, MICRO_IUNA); + let mut blinded = ledger + .build_blinded_burn(&alice, 1, 1, ledger.height() + 4) + .unwrap() + .transaction; + blinded.inputs.clear(); + blinded.commitment = blinded_transaction_commitment(&blinded).unwrap(); + + let error = ledger.submit_blinded_transaction(blinded).unwrap_err(); + + assert!(format!("{error:#}").contains("must lock visible inputs")); +} + +#[test] +fn mine_actions_cannot_be_blinded() { + let alice = Wallet::from_seed("blinded-mine-collateral-alice"); + let ledger = ledger_with_finalizers(&[alice.clone()], &[]); + let mine = ledger.build_mine(alice.address()).unwrap(); + let error = ledger + .build_blinded_transaction(&alice, mine, ledger.height() + 4) + .unwrap_err(); + + assert!(format!("{error:#}").contains("mine actions are public")); +} + +#[test] +fn reveal_bundle_hashes_are_bound_to_next_block_vdf_seed() { + let alice = Wallet::from_seed("bundle-seed-alice"); + let bob = Wallet::from_seed("bundle-seed-bob"); + let carol = Wallet::from_seed("bundle-seed-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + let blinded = ledger + .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + ledger.submit_blinded_reveal(blinded.reveal).unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + let leader = ledger.expected_leader_for_next_block().unwrap(); + let leader_wallet = wallet_for_address(&finalizers, &leader); + let bundles = ledger + .reveal_committee_for_next_block() + .into_iter() + .filter_map(|member| { + let wallet = wallet_for_address(&finalizers, &member.owner); + ledger.build_reveal_bundle(wallet).unwrap() + }) + .collect::<Vec<_>>(); + if bundles.len() > 1 { + let mut reversed = bundles.clone(); + reversed.reverse(); + let error = ledger + .validate_next_block_reveal_bundles(reversed) + .unwrap_err(); + assert!(format!("{error:#}").contains("reveal bundles are not in slot order")); + } + + let without_bundles = ledger + .prepare_next_block(leader_wallet.address(), ledger.tip().timestamp_ms + 1) + .unwrap(); + let with_bundles = ledger + .prepare_next_block_with_reveal_bundles( + leader_wallet.address(), + ledger.tip().timestamp_ms + 1, + bundles, + ) + .unwrap(); + + assert_ne!(without_bundles.vdf_seed(), with_bundles.vdf_seed()); +} + +#[test] +fn reveal_committee_includes_next_block_finalizer_as_slot_zero() { + let alice = Wallet::from_seed("bundle-finalizer-slot-alice"); + let bob = Wallet::from_seed("bundle-finalizer-slot-bob"); + let carol = Wallet::from_seed("bundle-finalizer-slot-carol"); + let dave = Wallet::from_seed("bundle-finalizer-slot-dave"); + let erin = Wallet::from_seed("bundle-finalizer-slot-erin"); + let finalizers = [alice.clone(), bob.clone(), carol.clone(), dave.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&erin, 10 * MICRO_IUNA)]); + let blinded = ledger + .build_blinded_burn(&erin, 3, 7, ledger.height() + 4) + .unwrap(); + let commitment = blinded.transaction.commitment.clone(); + ledger + .submit_blinded_transaction(blinded.transaction) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + ledger.submit_blinded_reveal(blinded.reveal).unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + + let leader = ledger.expected_leader_for_next_block().unwrap(); + let committee = ledger.reveal_committee_for_next_block(); + let leader_wallet = wallet_for_address(&finalizers, &leader); + let bundle = ledger.build_reveal_bundle(leader_wallet).unwrap().unwrap(); + + assert_eq!(committee.first().map(|member| member.slot), Some(0)); + assert_eq!(committee.first().map(|member| member.rank), Some(0)); + assert_eq!( + committee.first().map(|member| member.owner.as_str()), + Some(leader.as_str()) + ); + assert_eq!(bundle.slot, 0); + assert_eq!(bundle.member, leader); + assert!( + bundle + .reveals + .iter() + .any(|reveal| reveal.commitment == commitment) + ); +} + +#[test] +fn reveal_bundle_section_deduplicates_reveals_with_slot_mask() { + let alice = Wallet::from_seed("bundle-compact-alice"); + let bob = Wallet::from_seed("bundle-compact-bob"); + let carol = Wallet::from_seed("bundle-compact-carol"); + let dave = Wallet::from_seed("bundle-compact-dave"); + let finalizers = [alice.clone(), bob.clone(), carol.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&dave, 10 * MICRO_IUNA)]); + let blinded = ledger + .build_blinded_burn(&dave, 3, 7, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + ledger + .submit_blinded_reveal(blinded.reveal.clone()) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + + let mut bundles = ledger + .reveal_committee_for_next_block() + .into_iter() + .filter_map(|member| { + let wallet = wallet_for_address(&finalizers, &member.owner); + ledger.build_reveal_bundle(wallet).unwrap() + }) + .collect::<Vec<_>>(); + assert!(bundles.len() >= 2); + bundles.truncate(2); + let expected_hashes = reveal_bundle_hashes(&bundles); + let expected_mask = bundles + .iter() + .fold(0_u8, |mask, bundle| mask | (1_u8 << bundle.slot)); + + let leader = ledger.expected_leader_for_next_block().unwrap(); + let leader_wallet = wallet_for_address(&finalizers, &leader); + let prepared = ledger + .prepare_next_block_with_reveal_bundles( + leader_wallet.address(), + ledger.tip().timestamp_ms + 1, + bundles.clone(), + ) + .unwrap(); + let block = prepared.finish(leader_wallet, "preverified-vdf".to_string()); + + assert_eq!(block.reveal_bundle_section.signatures.len(), 2); + assert_eq!(block.reveal_bundle_section.reveals.len(), 1); + assert_eq!( + block.reveal_bundle_section.reveals[0].bundle_mask, + expected_mask + ); + assert_eq!(block.all_blinded_reveals(), vec![&blinded.reveal]); + assert_eq!(block.reveal_bundle_hashes(), expected_hashes); + assert_eq!( + block + .reveal_bundle_section + .expand(block.height, &block.prev_hash), + bundles + ); +} + +#[test] +fn reveal_bundle_validation_rejects_wrong_signature_and_slot() { + let alice = Wallet::from_seed("bundle-invalid-alice"); + let bob = Wallet::from_seed("bundle-invalid-bob"); + let carol = Wallet::from_seed("bundle-invalid-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + let blinded = ledger + .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + ledger.submit_blinded_reveal(blinded.reveal).unwrap(); + let member = ledger.reveal_committee_for_next_block()[0].clone(); + let wallet = wallet_for_address(&finalizers, &member.owner); + let bundle = ledger.build_reveal_bundle(wallet).unwrap().unwrap(); + + let mut wrong_signature = bundle.clone(); + wrong_signature.signature = "00".repeat(SIGNATURE_BYTES); + let error = ledger + .validate_next_block_reveal_bundles(vec![wrong_signature]) + .unwrap_err(); + assert!(format!("{error:#}").contains("reveal bundle signature is invalid")); + + let mut wrong_slot = bundle; + wrong_slot.slot = REVEAL_COMMITTEE_SIZE as u8 - 1; + let error = ledger + .validate_next_block_reveal_bundles(vec![wrong_slot]) + .unwrap_err(); + assert!( + format!("{error:#}").contains("reveal bundle slot is not assigned") + || format!("{error:#}").contains("reveal bundle member is not assigned to slot") + ); +} + +#[test] +fn blinded_reveal_with_wrong_key_is_rejected_in_block() { + let alice = Wallet::from_seed("blinded-wrong-key-finalizer-alice"); + let bob = Wallet::from_seed("blinded-wrong-key-finalizer-bob"); + let carol = Wallet::from_seed("blinded-wrong-key-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + let blinded = ledger + .build_blinded_burn(&carol, 3, 7, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + + let leader = ledger.expected_leader_for_next_block().unwrap(); + let wallet = wallet_for_address(&finalizers, &leader); + let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap(); + ledger.submit_transaction(filler_burn).unwrap(); + let mut prepared = ledger + .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) + .unwrap(); + let committee_member = ledger.reveal_committee_for_next_block()[0].clone(); + let committee_wallet = wallet_for_address(&finalizers, &committee_member.owner); + let wrong_reveal = BlindedReveal { + commitment: blinded.transaction.commitment, + key: "00".repeat(BLINDED_KEY_BYTES), + }; + let wrong_bundle = committee_wallet.reveal_bundle(RevealBundlePayload { + height: prepared.height, + prev_hash: prepared.prev_hash.clone(), + slot: committee_member.slot, + member: committee_wallet.address().to_string(), + reveals: vec![wrong_reveal], + }); + prepared.reveal_bundle_section = ledger.reveal_bundle_section_from_bundles(vec![wrong_bundle]); + let block = prepared.finish(wallet, "preverified-vdf".to_string()); + + let error = ledger + .apply_preverified_block_at(block, u64::MAX) + .unwrap_err(); + + assert!(format!("{error:#}").contains("failed to decrypt blinded transaction payload")); +} + +#[test] +fn expired_blinded_reveal_is_not_selected() { + let alice = Wallet::from_seed("blinded-expire-finalizer-alice"); + let bob = Wallet::from_seed("blinded-expire-finalizer-bob"); + let carol = Wallet::from_seed("blinded-expire-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + let blinded = ledger + .build_blinded_burn(&carol, 3, 7, ledger.height() + 2) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + + let leader = ledger.expected_leader_for_next_block().unwrap(); + let wallet = wallet_for_address(&finalizers, &leader); + let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap(); + ledger.submit_transaction(filler_burn).unwrap(); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 2); + + ledger.submit_blinded_reveal(blinded.reveal).unwrap(); + assert!(ledger.valid_pending_blinded_reveals().is_empty()); +} + +#[test] +fn recovery_block_includes_pending_blinded_transactions_when_space_allows() { + let alice = Wallet::from_seed("recovery-blinded-commit-alice"); + let bob = Wallet::from_seed("recovery-blinded-commit-bob"); + let carol = Wallet::from_seed("recovery-blinded-commit-carol"); + let mut ledger = ledger_with_finalizers( + &[alice], + &[(&bob, 10 * MICRO_IUNA), (&carol, 10 * MICRO_IUNA)], + ); + let blinded = ledger + .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(recovery_burn).unwrap(); + + let block = ledger + .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) + .unwrap(); + + assert!( + block + .blinded_transactions + .iter() + .any(|transaction| transaction.commitment == blinded.transaction.commitment) + ); +} + +#[test] +fn recovery_block_size_selection_uses_recovery_skeleton() { + let alice = Wallet::from_seed("recovery-size-commit-alice"); + let bob = Wallet::from_seed("recovery-size-commit-bob"); + let carol = Wallet::from_seed("recovery-size-commit-carol"); + let mut ledger = ledger_with_finalizers( + &[alice], + &[(&bob, 10 * MICRO_IUNA), (&carol, 10 * MICRO_IUNA)], + ); + let blinded = ledger + .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(recovery_burn.clone()).unwrap(); + let recovery_selection = BlockSelection { + transactions: vec![recovery_burn], + blinded_transactions: vec![blinded.transaction.clone()], + }; + let recovery_estimate = + estimated_block_selection_size_bytes(&recovery_selection, true).unwrap(); + let ticket_estimate = estimated_block_selection_size_bytes(&recovery_selection, false).unwrap(); + assert!(recovery_estimate < ticket_estimate); + + ledger.launch_profile.max_block_bytes = recovery_estimate; + let tight_block = ledger + .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS) + .unwrap(); + + assert!( + tight_block + .blinded_transactions + .iter() + .any(|transaction| transaction.commitment == blinded.transaction.commitment) + ); +} + +#[test] +fn recovery_block_includes_pending_blinded_reveals_when_space_allows() { + let alice = Wallet::from_seed("recovery-blinded-reveal-alice"); + let bob = Wallet::from_seed("recovery-blinded-reveal-bob"); + let carol = Wallet::from_seed("recovery-blinded-reveal-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + let blinded = ledger + .build_blinded_burn(&carol, MICRO_IUNA, 7, ledger.height() + 4) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + ledger + .submit_blinded_reveal(blinded.reveal.clone()) + .unwrap(); + let recovery_burn = ledger.build_burn(&bob, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(recovery_burn).unwrap(); + + let bundles = ledger + .reveal_committee_for_next_block() + .into_iter() + .filter_map(|member| { + let wallet = wallet_for_address(&finalizers, &member.owner); + ledger.build_reveal_bundle(wallet).unwrap() + }) + .collect::<Vec<_>>(); + let prepared = ledger + .prepare_recovery_block_with_reveal_bundles( + bob.address(), + ledger.recovery_block_min_timestamp(), + bundles, + ) + .unwrap(); + let vdf_output = run_vdf(prepared.vdf_seed(), prepared.vdf_rounds()); + let block = prepared.finish(&bob, vdf_output); + + assert!( + block + .all_blinded_reveals() + .iter() + .any(|reveal| reveal.commitment == blinded.transaction.commitment) + ); +} + +#[test] +fn blinded_transaction_expiring_at_next_height_is_not_selected() { + let alice = Wallet::from_seed("blinded-next-expire-finalizer-alice"); + let bob = Wallet::from_seed("blinded-next-expire-finalizer-bob"); + let carol = Wallet::from_seed("blinded-next-expire-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + let blinded = ledger + .build_blinded_burn(&carol, 3, 7, ledger.height() + 1) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction) + .unwrap(); + + let leader = ledger.expected_leader_for_next_block().unwrap(); + let wallet = wallet_for_address(&finalizers, &leader); + let error = ledger.prepare_next_block(wallet.address(), 1).unwrap_err(); + + assert!(format!("{error:#}").contains("block must include at least one burn transaction")); +} + +#[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"); + let carol = Wallet::from_seed("blinded-no-burn-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + 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 = wallet_for_address(&finalizers, &leader); + let error = ledger.prepare_next_block(wallet.address(), 1).unwrap_err(); + + assert!(format!("{error:#}").contains("block must include at least one burn transaction")); +} + +#[test] +fn revealed_blinded_transaction_cannot_be_included_again() { + let alice = Wallet::from_seed("blinded-duplicate-finalizer-alice"); + let bob = Wallet::from_seed("blinded-duplicate-finalizer-bob"); + let carol = Wallet::from_seed("blinded-duplicate-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + let blinded = ledger + .build_blinded_burn(&carol, 3, 7, ledger.height() + 6) + .unwrap(); + ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + ledger + .submit_blinded_reveal(blinded.reveal.clone()) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader_with_reveal_bundles(&mut ledger, &finalizers, 2); + + let leader = ledger.expected_leader_for_next_block().unwrap(); + let wallet = wallet_for_address(&finalizers, &leader); + let filler_burn = ledger.build_burn(wallet, 1, 0).unwrap(); + ledger.submit_transaction(filler_burn).unwrap(); + let mut prepared = ledger + .prepare_next_block(wallet.address(), ledger.tip().timestamp_ms + 1) + .unwrap(); + prepared + .blinded_transactions + .push(blinded.transaction.clone()); + let block = prepared.finish(wallet, "preverified-vdf".to_string()); + + let error = ledger + .apply_preverified_block_at(block, u64::MAX) + .unwrap_err(); + + assert!(format!("{error:#}").contains("blinded transaction is already on chain")); +} + +#[test] +fn pending_blinded_reveals_expose_revealed_transaction_data() { + let alice = Wallet::from_seed("pending-reveal-data-finalizer-alice"); + let bob = Wallet::from_seed("pending-reveal-data-finalizer-bob"); + let carol = Wallet::from_seed("pending-reveal-data-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut ledger = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + let blinded = ledger + .build_blinded_burn(&carol, 3, 7, ledger.height() + 6) + .unwrap(); + let commitment = blinded.transaction.commitment.clone(); + ledger + .submit_blinded_transaction(blinded.transaction) + .unwrap(); + queue_next_leader_burn(&mut ledger, &finalizers); + mine_preverified_as_next_leader(&mut ledger, &finalizers, 1); + + ledger.submit_blinded_reveal(blinded.reveal).unwrap(); + + let revealed = ledger.pending_revealed_blinded_transactions(); + assert_eq!(revealed.len(), 1); + assert_eq!(revealed[0].commitment, commitment); + assert_eq!(revealed[0].height, ledger.height() + 1); + assert_eq!(revealed[0].transaction.amount(), 3); + assert_eq!(revealed[0].transaction.fee(), 7); + assert!(revealed[0].transaction.is_burn()); +} + +#[test] +fn abandoned_fork_blinded_transactions_return_to_mempool() { + let alice = Wallet::from_seed("blinded-reorg-finalizer-alice"); + let bob = Wallet::from_seed("blinded-reorg-finalizer-bob"); + let carol = Wallet::from_seed("blinded-reorg-carol"); + let finalizers = [alice.clone(), bob.clone()]; + let mut local = ledger_with_finalizers(&finalizers, &[(&carol, 10 * MICRO_IUNA)]); + let mut remote = local.clone(); + let blinded = local + .build_blinded_burn(&carol, 3, 7, local.height() + 8) + .unwrap(); + local + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + queue_next_leader_burn(&mut local, &finalizers); + mine_preverified_as_next_leader(&mut local, &finalizers, 1); + + for timestamp_ms in [1, 2] { + let leader = remote.expected_leader_for_next_block().unwrap(); + let wallet = wallet_for_address(&finalizers, &leader); + let burn = remote.build_burn(wallet, 1, 0).unwrap(); + remote.submit_transaction(burn).unwrap(); + mine_preverified_as_next_leader(&mut remote, &finalizers, timestamp_ms); + } + + assert!( + local + .extend_from_preverified_snapshot_at(remote.snapshot(), u64::MAX) + .unwrap() + ); + assert!(local.has_blinded_transaction(&blinded.transaction.commitment)); + assert_eq!( + local.pending_blinded_transactions(), + std::slice::from_ref(&blinded.transaction) + ); +} + +#[test] +fn block_selection_includes_mine_action_after_required_block_burn() { + let alice = Wallet::from_seed("mine-fixed-reward-select-alice"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + + let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let mine = ledger.build_mine(alice.address()).unwrap(); + ledger.submit_transaction(mine.clone()).unwrap(); + + let block = ledger.mine_next_block(&alice, 1).unwrap(); + + assert_eq!( + block.transactions.iter().filter(|tx| tx.is_burn()).count(), + 1 + ); + assert!( + block + .transactions + .iter() + .any(|tx| tx.signature() == mine.signature()) + ); + assert_eq!(block.reward, MINE_FINALIZER_FEE); +} + +#[test] +fn block_selection_can_skip_mine_action_when_space_is_limited() { + let alice = Wallet::from_seed("mine-space-limit-alice"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + ledger.launch_profile.max_block_transactions = 2; + + let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let first_mine = ledger.build_mine(alice.address()).unwrap(); + ledger.submit_transaction(first_mine).unwrap(); + let second_mine = ledger.build_mine(alice.address()).unwrap(); + ledger.submit_transaction(second_mine).unwrap(); + + let block = ledger.mine_next_block(&alice, 1).unwrap(); + + assert_eq!(block.transactions.len(), 2); + assert!(block.transactions.iter().any(Transaction::is_burn)); + assert_eq!(block.reward, MINE_FINALIZER_FEE); +} + +#[test] +fn pending_mine_outputs_are_not_spendable_until_confirmed() { + let alice = Wallet::from_seed("pending-mine-spend-alice"); + let bob = Wallet::from_seed("pending-mine-spend-bob"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + + let mine = ledger.build_mine(alice.address()).unwrap(); + let mine_outpoint = OutPoint { + txid: mine.signature().to_string(), + index: 0, + }; + ledger.submit_transaction(mine.clone()).unwrap(); + + assert!( + !ledger + .available_utxos_for_address(alice.address()) + .unwrap() + .iter() + .any(|(outpoint, _)| outpoint == &mine_outpoint) + ); + let pending_error = ledger + .build_transfer_with_inputs( + &alice, + bob.address(), + TEST_BURN_AMOUNT, + 0, + std::slice::from_ref(&mine_outpoint), + ) + .unwrap_err(); + assert!(format!("{pending_error:#}").contains("not spendable")); + + let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(&alice, 1).unwrap(); + assert!( + block + .transactions + .iter() + .any(|tx| tx.signature() == mine.signature()) + ); + ledger.apply_locally_mined_block(block).unwrap(); + + assert!( + ledger + .available_utxos_for_address(alice.address()) + .unwrap() + .iter() + .any(|(outpoint, _)| outpoint == &mine_outpoint) + ); + ledger + .build_transfer_with_inputs( + &alice, + bob.address(), + TEST_BURN_AMOUNT, + 0, + std::slice::from_ref(&mine_outpoint), + ) + .unwrap(); +} + +#[test] +fn burns_built_after_pending_mine_do_not_spend_pending_mine_output() { + let alice = Wallet::from_seed("pending-mine-burn-alice"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + + let mine = ledger.build_mine(alice.address()).unwrap(); + let mine_outpoint = OutPoint { + txid: mine.signature().to_string(), + index: 0, + }; + ledger.submit_transaction(mine).unwrap(); + + let burn = ledger.build_burn(&alice, TEST_BURN_AMOUNT, 0).unwrap(); + + let Transaction::Burn { inputs, .. } = &burn else { + panic!("expected burn transaction"); + }; + assert!(!inputs.iter().any(|input| input.outpoint == mine_outpoint)); +} + +#[test] +fn pending_blinded_transactions_with_spent_inputs_are_pruned_after_block_apply() { + let alice = Wallet::from_seed("pending-blind-spent-prune-alice"); + let mut mempool_ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + let mut block_ledger = mempool_ledger.clone(); + let amount = mempool_ledger.balance_of(alice.address()); + let blinded = mempool_ledger + .build_blinded_burn(&alice, amount, 0, mempool_ledger.height() + 4) + .unwrap(); + mempool_ledger + .submit_blinded_transaction(blinded.transaction.clone()) + .unwrap(); + + let burn = block_ledger.build_burn(&alice, amount, 0).unwrap(); + let Transaction::Burn { inputs, .. } = &burn else { + panic!("expected burn transaction"); + }; + assert!(blinded.transaction.inputs.iter().any(|input| { + inputs + .iter() + .any(|burn_input| burn_input.outpoint == input.outpoint) + })); + block_ledger.submit_transaction(burn).unwrap(); + let block = block_ledger.mine_next_block(&alice, 1).unwrap(); + + mempool_ledger.apply_block(block).unwrap(); + + assert!(mempool_ledger.pending_blinded_transactions().is_empty()); +} + +#[test] +fn block_selection_limits_mine_actions_per_anchor() { + let alice = Wallet::from_seed("mine-anchor-limit-selection-alice"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + + let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let first_mine = ledger.build_mine(alice.address()).unwrap(); + ledger.submit_transaction(first_mine.clone()).unwrap(); + let second_mine = ledger.build_mine(alice.address()).unwrap(); + ledger.submit_transaction(second_mine.clone()).unwrap(); + let third_mine = ledger.build_mine(alice.address()).unwrap(); + ledger.submit_transaction(third_mine.clone()).unwrap(); + + assert_eq!(ledger.pending().len(), 4); + let block = ledger.mine_next_block(&alice, 1).unwrap(); + + assert_eq!(block.transactions.len(), 3); + assert!(block.transactions.iter().any(Transaction::is_burn)); + let included_mines = block + .transactions + .iter() + .filter(|transaction| matches!(transaction, Transaction::Mine { .. })) + .count(); + assert_eq!(included_mines, MINE_ACTIONS_PER_ANCHOR_LIMIT); + assert!( + block + .transactions + .iter() + .any(|tx| tx.signature() == first_mine.signature()) + ); + assert!( + block + .transactions + .iter() + .any(|tx| tx.signature() == second_mine.signature()) + ); + assert!( + !block + .transactions + .iter() + .any(|tx| tx.signature() == third_mine.signature()) + ); + assert_ne!(first_mine.signature(), second_mine.signature()); + assert_ne!(second_mine.signature(), third_mine.signature()); + assert_eq!(block.reward, first_mine.fee() + second_mine.fee()); +} + +#[test] +fn pre_activation_block_may_keep_multiple_mine_actions_for_one_anchor() { + let alice = Wallet::from_seed("mine-anchor-limit-pre-activation-alice"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + assert!(ledger.height().saturating_add(1) < MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT); + + let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); + let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); + let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let mut block = ledger + .prepare_next_block(alice.address(), 1) + .unwrap() + .finish(&alice, "preverified-vdf".to_string()); + block.transactions.push(first_mine); + block.transactions.push(second_mine); + block.reward = fee_reward(&block.transactions).unwrap(); + block.hash = block.compute_hash(); + + ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); +} + +#[test] +fn activated_blocks_reject_too_many_mine_actions_for_one_anchor() { + let alice = Wallet::from_seed("mine-anchor-limit-active-block-alice"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + advance_to_mine_anchor_limit_activation_parent(&mut ledger, &alice); + + let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); + let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); + let third_mine = test_mine_with_salt(&ledger, alice.address(), 3); + let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let mut block = ledger + .prepare_next_block( + alice.address(), + ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS), + ) + .unwrap() + .finish(&alice, "preverified-vdf".to_string()); + block.transactions.push(first_mine); + block.transactions.push(second_mine); + block.transactions.push(third_mine); + block.reward = fee_reward(&block.transactions).unwrap(); + block.hash = block.compute_hash(); + + let error = ledger + .apply_preverified_block_at(block, u64::MAX) + .unwrap_err(); + + assert!(format!("{error:#}").contains("mine actions per anchor limit")); +} + +#[test] +fn activated_mempool_rejects_mine_actions_above_anchor_limit() { + let alice = Wallet::from_seed("mine-anchor-limit-active-mempool-alice"); + let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); + advance_to_mine_anchor_limit_activation_parent(&mut ledger, &alice); + + let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); + let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); + let third_mine = test_mine_with_salt(&ledger, alice.address(), 3); + ledger.submit_transaction(first_mine).unwrap(); + ledger.submit_transaction(second_mine).unwrap(); + let error = ledger.submit_transaction(third_mine).unwrap_err(); + + assert!(format!("{error:#}").contains("mine transaction anchor limit reached")); +} + +#[test] +fn mine_difficulty_increases_when_issuance_exceeds_target_window() { + let alice = Wallet::from_seed("mine-difficulty-up-alice"); + let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); + + for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { + apply_preverified_burn_block_with_mines(&mut ledger, &alice, 2); + } + + assert_eq!( + ledger.current_mine_difficulty_bits(), + MINE_DIFFICULTY_BITS + 1 + ); + let mine = ledger.build_mine(alice.address()).unwrap(); + let Transaction::Mine { + difficulty_bits, .. + } = mine + else { + panic!("expected mine action"); + }; + assert_eq!(difficulty_bits, MINE_DIFFICULTY_BITS + 1); +} + +#[test] +fn mine_difficulty_decreases_when_issuance_is_below_target_window() { + let alice = Wallet::from_seed("mine-difficulty-down-alice"); + let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); + + for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { + mine_burn_block_with_mines(&mut ledger, &alice, 0); + } + + assert_eq!( + ledger.current_mine_difficulty_bits(), + MINE_DIFFICULTY_BITS - MINE_MAX_RETARGET_STEP_BITS + ); + + for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { + mine_burn_block_with_mines(&mut ledger, &alice, 0); + } + + assert_eq!( + ledger.current_mine_difficulty_bits(), + MINE_MIN_DIFFICULTY_BITS + ); + + for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { + mine_burn_block_with_mines(&mut ledger, &alice, 0); + } + + assert_eq!( + ledger.current_mine_difficulty_bits(), + MINE_MIN_DIFFICULTY_BITS + ); +} + +#[test] +fn mine_actions_expire_when_anchor_is_too_old() { + let alice = Wallet::from_seed("mine-anchor-expiry-alice"); + let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); + let stale_mine = ledger.build_mine(alice.address()).unwrap(); + + for _ in 0..=MINE_MAX_ANCHOR_AGE_BLOCKS { + mine_burn_block_with_mines(&mut ledger, &alice, 0); + } + + let error = ledger.submit_transaction(stale_mine).unwrap_err(); + assert!(format!("{error:#}").contains("mine transaction anchor is too old")); +} + +#[test] +fn pending_mine_actions_are_removed_when_anchor_expires() { + let alice = Wallet::from_seed("pending-mine-anchor-expiry-alice"); + let bob = Wallet::from_seed("pending-mine-anchor-expiry-bob"); + let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); + ledger.launch_profile.max_block_transactions = 1; + let stale_mine = ledger.build_mine(bob.address()).unwrap(); + ledger.submit_transaction(stale_mine.clone()).unwrap(); + + for _ in 0..=MINE_MAX_ANCHOR_AGE_BLOCKS { + mine_burn_block_with_mines(&mut ledger, &alice, 0); + } + + assert!( + ledger + .pending() + .iter() + .all(|tx| tx.signature() != stale_mine.signature()) + ); +} + +#[test] +fn stratum_mine_header_proof_is_validated() { + let alice = Wallet::from_seed("stratum-proof-alice"); + let ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); + let anchor = ledger.tip().hash.clone(); + let difficulty_bits = ledger.current_mine_difficulty_bits(); + let template = ledger + .stratum_mine_template(alice.address(), anchor, 1, difficulty_bits) + .unwrap(); + + let mut accepted = None; + for nonce in 0_u32..50_000 { + let result = ledger.build_stratum_mine( + template.clone(), + StratumMineShare { + extranonce2: [0, 0, 0, 0], + header_nonce: nonce.to_le_bytes(), + }, + ); + if let Ok(tx) = result { + accepted = Some(tx); + break; + } + } + + let tx = accepted.expect("expected Stratum proof within search range"); + let Transaction::Mine { + proof_header, + signature, + .. + } = tx + else { + panic!("expected mine action"); + }; + assert_eq!(proof_header.as_deref().unwrap_or_default().len(), 160); + assert!(hash_meets_difficulty(&signature, difficulty_bits)); +} + +#[test] +fn stratum_mine_salt_allows_multiple_actions_for_same_anchor() { + let alice = Wallet::from_seed("stratum-salt-alice"); + let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); + let anchor = ledger.tip().hash.clone(); + let difficulty_bits = ledger.current_mine_difficulty_bits(); + + for salt in [1, 2] { + let template = ledger + .stratum_mine_template(alice.address(), anchor.clone(), salt, difficulty_bits) + .unwrap(); + let mut accepted = None; + for nonce in 0_u32..50_000 { + let result = ledger.build_stratum_mine( + template.clone(), + StratumMineShare { + extranonce2: [0, 0, 0, 0], + header_nonce: nonce.to_le_bytes(), + }, + ); + if let Ok(tx) = result { + accepted = Some(tx); + break; + } + } + let tx = accepted.expect("expected Stratum proof within search range"); + assert!(ledger.submit_transaction(tx).unwrap()); + } + + assert_eq!(ledger.pending().len(), 2); + let salts = ledger + .pending() + .iter() + .map(|tx| match tx { + Transaction::Mine { salt, .. } => *salt, + _ => panic!("expected mine action"), + }) + .collect::<BTreeSet<_>>(); + assert_eq!(salts, BTreeSet::from([1, 2])); +} diff --git a/src/domain/ticket.rs b/src/domain/ticket.rs @@ -0,0 +1,288 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Result, bail}; +use sha2::{Digest, Sha256}; + +use super::{ + Amount, Block, FinalizerMode, LaunchProfile, MAX_VDF_ROUNDS, Transaction, VDF_TARGET_BLOCK_MS, + hex_hash, +}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct BurnTicket { + pub(super) id: String, + pub(super) owner: String, + pub(super) amount: Amount, + pub(super) eligible_from_height: u64, + pub(super) eligible_until_height: u64, +} + +pub(super) fn ranked_tickets_for_height( + parent: &Block, + target_height: u64, + tickets: &[BurnTicket], +) -> Vec<BurnTicket> { + let mut remaining = tickets + .iter() + .filter(|ticket| ticket_is_eligible_for_height(ticket, target_height)) + .cloned() + .collect::<Vec<_>>(); + let mut ranked = Vec::with_capacity(remaining.len()); + + for rank in 0.. { + let Some(selected_index) = + select_weighted_ticket_index(parent, target_height, rank, &remaining) + else { + break; + }; + ranked.push(remaining.remove(selected_index)); + } + + ranked +} + +fn select_weighted_ticket_index( + parent: &Block, + target_height: u64, + rank: u32, + tickets: &[BurnTicket], +) -> Option<usize> { + let total_weight = tickets.iter().try_fold(0_u128, |total, ticket| { + total.checked_add(u128::from(ticket.amount)) + })?; + if total_weight == 0 { + return None; + } + + let draw = weighted_ticket_draw(parent, target_height, rank, total_weight); + let mut cumulative = 0_u128; + for (index, ticket) in tickets.iter().enumerate() { + cumulative = cumulative.checked_add(u128::from(ticket.amount))?; + if draw < cumulative { + return Some(index); + } + } + None +} + +fn weighted_ticket_draw(parent: &Block, target_height: u64, rank: u32, total_weight: u128) -> u128 { + let seed = if rank == 0 { + format!( + "iuna-ticket-draw:{}:{}:{}", + target_height, parent.hash, parent.vdf_output + ) + } else { + format!( + "iuna-ticket-draw-rank:{}:{}:{}:{}", + target_height, rank, parent.hash, parent.vdf_output + ) + }; + let digest = Sha256::digest(seed.as_bytes()); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + u128::from_be_bytes(bytes) % total_weight +} + +pub(super) fn vdf_rounds_for_finalizer_rank(base_rounds: u64, rank: u32) -> Result<u64> { + let rounds = base_rounds + .checked_mul(u64::from( + rank.checked_add(1).context("finalizer rank overflows")?, + )) + .context("finalizer rank VDF rounds overflow")?; + if rounds > MAX_VDF_ROUNDS { + bail!("finalizer rank VDF rounds exceed maximum"); + } + Ok(rounds) +} + +fn finalizer_rank_slot_delay_ms(rank: u32) -> Result<u64> { + VDF_TARGET_BLOCK_MS + .checked_mul(2) + .context("finalizer rank time slot overflow")? + .checked_mul(u64::from(rank)) + .context("finalizer rank time slot overflow") +} + +pub(super) fn ticket_block_min_timestamp(parent: &Block, rank: u32) -> Result<u64> { + if rank == 0 { + return parent + .timestamp_ms + .checked_add(1) + .context("finalizer rank minimum timestamp overflow"); + } + + parent + .timestamp_ms + .checked_add(finalizer_rank_slot_delay_ms(rank)?) + .context("finalizer rank minimum timestamp overflow") +} + +pub(super) fn base_vdf_rounds_for_finalizer_rank(vdf_rounds: u64, rank: u32) -> u64 { + vdf_rounds / u64::from(rank.saturating_add(1).max(1)) +} + +pub(super) fn tickets_created_by_block( + block: &Block, + profile: &LaunchProfile, +) -> Result<Vec<BurnTicket>> { + tickets_created_by_transactions(block.height, &block.transactions, profile) +} + +pub(super) fn tickets_created_by_transactions( + block_height: u64, + transactions: &[Transaction], + profile: &LaunchProfile, +) -> Result<Vec<BurnTicket>> { + if profile.ticket_expiry_window_heights == 0 { + bail!("ticket expiry window must be at least one height"); + } + let mut tickets = Vec::new(); + for tx in transactions { + let Transaction::Burn { + inputs, + amount, + signature, + .. + } = tx + else { + continue; + }; + let Some(owner) = inputs.first().map(|input| input.owner.clone()) else { + continue; + }; + if *amount == 0 { + continue; + } + let target_height = block_height + .checked_add(profile.ticket_maturity_delay_heights) + .with_context(|| format!("ticket target height overflow at block {block_height}"))?; + let eligible_until_height = target_height + .checked_add(profile.ticket_expiry_window_heights - 1) + .with_context(|| format!("ticket expiry height overflow at block {block_height}"))?; + tickets.push(BurnTicket { + id: signature.clone(), + owner, + amount: *amount, + eligible_from_height: target_height, + eligible_until_height, + }); + } + Ok(tickets) +} + +pub(super) fn genesis_tickets( + genesis_allocations: &BTreeMap<String, Amount>, + genesis: &Block, + profile: &LaunchProfile, +) -> Result<Vec<BurnTicket>> { + if profile.ticket_maturity_delay_heights == 0 { + return tickets_created_by_block(genesis, profile); + } + + let burn_tickets = genesis + .transactions + .iter() + .filter_map(|tx| { + let Transaction::Burn { + inputs, + amount, + signature, + .. + } = tx + else { + return None; + }; + let owner = inputs.first()?.owner.clone(); + (*amount > 0).then(|| (owner, *amount, signature.clone())) + }) + .collect::<Vec<_>>(); + + if !burn_tickets.is_empty() { + return genesis_bootstrap_tickets(burn_tickets, profile, genesis); + } + + let Some((owner, amount)) = genesis_allocations + .iter() + .rev() + .find(|(_, amount)| **amount > 0) + else { + return Ok(Vec::new()); + }; + genesis_bootstrap_tickets( + vec![( + owner.clone(), + 1, + hex_hash(format!( + "iuna-genesis-ticket:{owner}:{amount}:{}", + genesis.hash + )), + )], + profile, + genesis, + ) +} + +fn genesis_bootstrap_tickets( + source_tickets: Vec<(String, Amount, String)>, + profile: &LaunchProfile, + genesis: &Block, +) -> Result<Vec<BurnTicket>> { + let mut tickets = Vec::new(); + for height in 1..=profile.ticket_maturity_delay_heights { + for (owner, amount, source_id) in &source_tickets { + tickets.push(BurnTicket { + id: hex_hash(format!( + "iuna-genesis-bootstrap-ticket:{}:{source_id}:{height}", + genesis.hash + )), + owner: owner.clone(), + amount: *amount, + eligible_from_height: height, + eligible_until_height: height, + }); + } + } + Ok(tickets) +} + +pub(super) fn apply_finalizer_ticket_effects( + block: &Block, + tickets: &mut Vec<BurnTicket>, +) -> Result<()> { + match block.finalizer_mode { + FinalizerMode::Ticket => consume_leader_ticket(block, tickets), + FinalizerMode::Recovery => { + tickets.retain(|ticket| { + !ticket_is_eligible_for_height(ticket, block.height) + && ticket.eligible_until_height > block.height + }); + Ok(()) + } + } +} + +pub(super) fn consume_leader_ticket(block: &Block, tickets: &mut Vec<BurnTicket>) -> Result<()> { + let Some(proof) = &block.leader_proof else { + bail!("block is missing leader proof"); + }; + let Some(index) = tickets.iter().position(|ticket| { + ticket.id == proof.ticket_id && ticket_is_eligible_for_height(ticket, block.height) + }) else { + bail!("leader ticket is not pending for block {}", block.height); + }; + tickets.remove(index); + tickets.retain(|ticket| ticket.eligible_until_height > block.height); + Ok(()) +} + +pub(super) fn ticket_is_eligible_for_height(ticket: &BurnTicket, height: u64) -> bool { + ticket.eligible_from_height <= height && height <= ticket.eligible_until_height +} + +pub(super) fn mine_action_count(block: &Block) -> u64 { + block + .transactions + .iter() + .filter(|transaction| matches!(transaction, Transaction::Mine { .. })) + .count() as u64 +} diff --git a/src/domain/transaction.rs b/src/domain/transaction.rs @@ -0,0 +1,611 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result, bail}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +use super::{ + Amount, MINE_FINALIZER_FEE, MINE_REWARD, PUBLIC_KEY_BYTES, SIGNATURE_BYTES, Wallet, + canonical_transaction_size_bytes, decode_hex_array, genesis_allocation_outpoint, + hash_meets_difficulty, hex_encode, hex_hash, mine_payload, mine_signature, + stratum_mine_header_bytes, stratum_mine_signature, +}; + +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct OutPoint { + pub txid: String, + pub index: u32, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TxInput { + pub outpoint: OutPoint, + pub owner: String, + pub signature: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct TxOutput { + pub address: String, + pub amount: Amount, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Transaction { + Transfer { + inputs: Vec<TxInput>, + outputs: Vec<TxOutput>, + #[serde(default)] + fee: Amount, + signature: String, + }, + Burn { + inputs: Vec<TxInput>, + change: Vec<TxOutput>, + amount: Amount, + #[serde(default)] + fee: Amount, + signature: String, + }, + Mine { + recipient: String, + anchor: String, + #[serde(default)] + salt: u64, + nonce: u64, + difficulty_bits: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + proof_header: Option<String>, + signature: String, + }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(super) enum BlindedTransactionPayload { + Transfer { + outputs: Vec<TxOutput>, + signature: String, + }, + Burn { + change: Vec<TxOutput>, + amount: Amount, + signature: String, + }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BlindedTransaction { + pub commitment: String, + #[serde(default)] + pub inputs: Vec<TxInput>, + pub fee: Amount, + pub encrypted_size: u32, + pub expires_at_height: u64, + pub nonce: String, + pub ciphertext: String, + pub payload_hash: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BlindedReveal { + pub commitment: String, + pub key: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BuiltBlindedTransaction { + pub payload: Transaction, + pub transaction: BlindedTransaction, + pub reveal: BlindedReveal, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OwnedBlindedTransaction { + pub transaction: BlindedTransaction, + pub payload: Transaction, + pub reveal: BlindedReveal, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RevealedBlindedTransaction { + pub height: u64, + pub commitment: String, + pub included_by: String, + pub transaction: Transaction, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MineSearchOutcome { + pub transaction: Option<Transaction>, + pub next_nonce: u64, + pub attempts: u64, +} + +impl Transaction { + pub fn genesis_burn(from: impl Into<String>, amount: Amount) -> Self { + let from = from.into(); + Self::genesis_burn_with_change(from, amount, Vec::new()) + } + + pub(super) fn genesis_burn_with_allocation( + from: impl Into<String>, + amount: Amount, + allocation: Amount, + ) -> Result<Self> { + if amount > allocation { + bail!("genesis burn exceeds allocation"); + } + let from = from.into(); + let change_amount = allocation - amount; + let change = if change_amount > 0 { + vec![TxOutput { + address: from.clone(), + amount: change_amount, + }] + } else { + Vec::new() + }; + Ok(Self::genesis_burn_with_change(from, amount, change)) + } + + fn genesis_burn_with_change(from: String, amount: Amount, change: Vec<TxOutput>) -> Self { + let input = TxInput { + outpoint: genesis_allocation_outpoint(&from), + owner: from.clone(), + signature: "genesis".to_string(), + }; + let unsigned = UnsignedUtxoTransaction::Burn { + inputs: vec![input.without_signature()], + change: change.clone(), + amount, + fee: 0, + }; + let signature = hex_hash(format!("iuna-genesis-burn:{}", unsigned.canonical())); + Self::Burn { + inputs: vec![input], + change, + amount, + fee: 0, + signature, + } + } + + pub fn sender(&self) -> &str { + match self { + Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs + .first() + .map(|input| input.owner.as_str()) + .unwrap_or(""), + Self::Mine { recipient, .. } => recipient.as_str(), + } + } + + pub fn to(&self) -> Option<&str> { + match self { + Self::Transfer { outputs, .. } => outputs.first().map(|output| output.address.as_str()), + Self::Burn { .. } => None, + Self::Mine { recipient, .. } => Some(recipient.as_str()), + } + } + + pub fn amount(&self) -> Amount { + match self { + Self::Transfer { outputs, .. } => { + outputs.first().map(|output| output.amount).unwrap_or(0) + } + Self::Burn { amount, .. } => *amount, + Self::Mine { .. } => MINE_REWARD, + } + } + + pub fn fee(&self) -> Amount { + match self { + Self::Transfer { fee, .. } | Self::Burn { fee, .. } => *fee, + Self::Mine { .. } => MINE_FINALIZER_FEE, + } + } + + pub fn total_debit(&self) -> Result<Amount> { + if matches!(self, Self::Mine { .. }) { + return Ok(0); + } + self.amount() + .checked_add(self.fee()) + .context("transaction amount plus fee overflows") + } + + pub fn signature(&self) -> &str { + match self { + Self::Transfer { signature, .. } | Self::Burn { signature, .. } => signature, + Self::Mine { signature, .. } => signature, + } + } + + pub fn is_burn(&self) -> bool { + matches!(self, Self::Burn { .. }) + } + + pub fn canonical(&self) -> String { + format!("{}:{}", self.signing_payload(), self.signature()) + } + + pub fn economic_size_bytes(&self) -> usize { + canonical_transaction_size_bytes(self) + } + + pub fn serialized_size_bytes(&self) -> Result<usize> { + serde_json::to_vec(self) + .map(|bytes| bytes.len()) + .context("failed to serialize transaction for size check") + } + + fn signing_payload(&self) -> String { + match self { + Self::Transfer { + inputs, + outputs, + fee, + .. + } => UnsignedUtxoTransaction::Transfer { + inputs: unsigned_inputs(inputs), + outputs: outputs.clone(), + fee: *fee, + } + .canonical(), + Self::Burn { + inputs, + change, + amount, + fee, + .. + } => UnsignedUtxoTransaction::Burn { + inputs: unsigned_inputs(inputs), + change: change.clone(), + amount: *amount, + fee: *fee, + } + .canonical(), + Self::Mine { + recipient, + anchor, + salt, + nonce, + difficulty_bits, + .. + } => mine_payload(recipient, anchor, *salt, *nonce, *difficulty_bits), + } + } + + pub(super) fn verify_signature(&self) -> Result<()> { + if let Self::Mine { + recipient, + anchor, + salt, + nonce, + difficulty_bits, + proof_header, + signature, + } = self + { + let expected = if let Some(proof_header) = proof_header { + let header = + stratum_mine_header_bytes(recipient, anchor, *salt, *nonce, *difficulty_bits)?; + let expected_header = hex_encode(header); + if *proof_header != expected_header { + bail!("mine transaction proof header is invalid"); + } + stratum_mine_signature(&header) + } else { + mine_signature(recipient, anchor, *salt, *nonce, *difficulty_bits) + }; + if *signature != expected { + bail!("mine transaction proof hash is invalid"); + } + if !hash_meets_difficulty(signature, *difficulty_bits) { + bail!("mine transaction proof does not meet difficulty"); + } + return Ok(()); + } + if self.signature().starts_with("iuna-genesis-burn:") || self.inputs_are_genesis_signed() { + return Ok(()); + } + if !self + .inputs() + .iter() + .all(|input| input.signature == self.signature()) + { + bail!("transaction input signature does not match transaction signature"); + } + let sender = self.sender(); + let public_key = decode_hex_array::<PUBLIC_KEY_BYTES>(sender) + .with_context(|| format!("invalid public key for {sender}"))?; + let signature = decode_hex_array::<SIGNATURE_BYTES>(self.signature()) + .context("invalid signature hex")?; + let verifying_key = + VerifyingKey::from_bytes(&public_key).context("invalid transaction public key")?; + let signature = Signature::from_bytes(&signature); + verifying_key + .verify(self.signing_payload().as_bytes(), &signature) + .context("transaction signature is invalid") + } + + pub(super) fn inputs(&self) -> &[TxInput] { + match self { + Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs, + Self::Mine { .. } => &[], + } + } + + pub(super) fn outputs(&self) -> Vec<TxOutput> { + match self { + Self::Transfer { outputs, .. } => outputs.clone(), + Self::Burn { change, .. } => change.clone(), + Self::Mine { recipient, .. } => vec![TxOutput { + address: recipient.clone(), + amount: MINE_REWARD, + }], + } + } + + fn inputs_are_genesis_signed(&self) -> bool { + self.inputs() + .iter() + .all(|input| input.signature == "genesis") + } +} + +impl BlindedTransaction { + pub fn id(&self) -> &str { + &self.commitment + } + + pub fn canonical(&self) -> String { + format!( + "blinded-tx:{}:{}:{}:{}:{}:{}:{}", + canonical_signed_inputs(&self.inputs), + self.fee, + self.encrypted_size, + self.expires_at_height, + self.nonce, + self.ciphertext, + self.payload_hash + ) + } + + pub fn fee_rate_size_bytes(&self) -> usize { + self.serialized_size_bytes() + .unwrap_or(self.encrypted_size as usize) + } + + pub fn serialized_size_bytes(&self) -> Result<usize> { + serde_json::to_vec(self) + .map(|bytes| bytes.len()) + .context("failed to serialize blinded transaction for size check") + } +} + +impl BlindedReveal { + pub fn canonical(&self) -> String { + format!("blinded-reveal:{}:{}", self.commitment, self.key) + } +} + +impl TxInput { + pub(super) fn without_signature(&self) -> UnsignedTxInput { + UnsignedTxInput { + outpoint: self.outpoint.clone(), + owner: self.owner.clone(), + } + } +} + +impl OutPoint { + pub(super) fn id(&self) -> String { + format!("{}:{}", self.txid, self.index) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct UnsignedTxInput { + pub(super) outpoint: OutPoint, + pub(super) owner: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum UnsignedUtxoTransaction { + Transfer { + inputs: Vec<UnsignedTxInput>, + outputs: Vec<TxOutput>, + fee: Amount, + }, + Burn { + inputs: Vec<UnsignedTxInput>, + change: Vec<TxOutput>, + amount: Amount, + fee: Amount, + }, +} + +impl UnsignedUtxoTransaction { + pub(super) fn sign(self, wallet: &Wallet) -> Transaction { + let signature = wallet.sign_payload(&self.canonical()); + let signed_inputs = self + .inputs() + .iter() + .map(|input| TxInput { + outpoint: input.outpoint.clone(), + owner: input.owner.clone(), + signature: signature.clone(), + }) + .collect::<Vec<_>>(); + match self { + Self::Transfer { outputs, fee, .. } => Transaction::Transfer { + inputs: signed_inputs, + outputs, + fee, + signature, + }, + Self::Burn { + change, + amount, + fee, + .. + } => Transaction::Burn { + inputs: signed_inputs, + change, + amount, + fee, + signature, + }, + } + } + + fn inputs(&self) -> &[UnsignedTxInput] { + match self { + Self::Transfer { inputs, .. } | Self::Burn { inputs, .. } => inputs, + } + } + + pub(super) fn canonical(&self) -> String { + match self { + Self::Transfer { + inputs, + outputs, + fee, + } => format!( + "utxo-transfer:{}:{}:{fee}", + canonical_inputs(inputs), + canonical_outputs(outputs) + ), + Self::Burn { + inputs, + change, + amount, + fee, + } => format!( + "utxo-burn:{}:{}:{amount}:{fee}", + canonical_inputs(inputs), + canonical_outputs(change) + ), + } + } +} + +pub(super) fn unsigned_inputs(inputs: &[TxInput]) -> Vec<UnsignedTxInput> { + inputs.iter().map(TxInput::without_signature).collect() +} + +pub(super) fn signed_blinded_inputs(inputs: &[UnsignedTxInput], signature: &str) -> Vec<TxInput> { + inputs + .iter() + .map(|input| TxInput { + outpoint: input.outpoint.clone(), + owner: input.owner.clone(), + signature: signature.to_string(), + }) + .collect() +} + +pub(super) fn canonical_inputs(inputs: &[UnsignedTxInput]) -> String { + inputs + .iter() + .map(|input| { + format!( + "{}:{}:{}", + input.outpoint.txid, input.outpoint.index, input.owner + ) + }) + .collect::<Vec<_>>() + .join("|") +} + +pub(super) fn canonical_signed_inputs(inputs: &[TxInput]) -> String { + inputs + .iter() + .map(|input| { + format!( + "{}:{}:{}:{}", + input.outpoint.txid, input.outpoint.index, input.owner, input.signature + ) + }) + .collect::<Vec<_>>() + .join("|") +} + +fn canonical_outputs(outputs: &[TxOutput]) -> String { + outputs + .iter() + .map(|output| format!("{}:{}", output.address, output.amount)) + .collect::<Vec<_>>() + .join("|") +} + +fn pending_spent_outpoints(pending: &[Transaction]) -> BTreeSet<OutPoint> { + pending + .iter() + .flat_map(|tx| tx.inputs().iter().map(|input| input.outpoint.clone())) + .collect() +} + +pub(super) fn transaction_inputs_spent_by( + transaction: &Transaction, + pending: &[Transaction], +) -> bool { + let spent = pending_spent_outpoints(pending); + transaction + .inputs() + .iter() + .any(|input| spent.contains(&input.outpoint)) +} + +pub(super) fn transaction_inputs_spent_by_inputs( + inputs: &[TxInput], + pending: &[Transaction], +) -> bool { + let spent = pending_spent_outpoints(pending); + inputs.iter().any(|input| spent.contains(&input.outpoint)) +} + +pub(super) fn blinded_transaction_inputs_spent_by( + transaction: &BlindedTransaction, + pending: &[BlindedTransaction], +) -> bool { + let spent = pending + .iter() + .flat_map(|transaction| { + transaction + .inputs + .iter() + .map(|input| input.outpoint.clone()) + }) + .collect::<BTreeSet<_>>(); + transaction + .inputs + .iter() + .any(|input| spent.contains(&input.outpoint)) +} + +pub(super) fn transaction_inputs_available( + transaction: &Transaction, + utxos: &BTreeMap<OutPoint, TxOutput>, +) -> bool { + transaction + .inputs() + .iter() + .all(|input| utxos.contains_key(&input.outpoint)) +} + +pub(super) fn blinded_transaction_inputs_available( + transaction: &BlindedTransaction, + utxos: &BTreeMap<OutPoint, TxOutput>, +) -> bool { + transaction + .inputs + .iter() + .all(|input| utxos.contains_key(&input.outpoint)) +} diff --git a/src/domain/validation.rs b/src/domain/validation.rs @@ -0,0 +1,182 @@ +use anyhow::{Context, Result, bail}; + +use super::{ + HASH_BYTES, PUBLIC_KEY_BYTES, SIGNATURE_BYTES, Transaction, TxInput, TxOutput, decode_hex, + decode_hex_array, stratum::STRATUM_MINE_HEADER_BYTES, +}; + +pub fn validate_address(address: &str, label: &str) -> Result<()> { + decode_hex_array::<PUBLIC_KEY_BYTES>(address) + .with_context(|| format!("invalid {label} address"))?; + Ok(()) +} + +pub(super) fn validate_hash(hash: &str, label: &str) -> Result<()> { + decode_hex_array::<HASH_BYTES>(hash).with_context(|| format!("invalid {label}"))?; + Ok(()) +} + +pub(super) fn validate_signature(signature: &str, label: &str) -> Result<()> { + decode_hex_array::<SIGNATURE_BYTES>(signature).with_context(|| format!("invalid {label}"))?; + Ok(()) +} + +pub(super) fn validate_stratum_header(header: &str) -> Result<()> { + decode_hex_array::<STRATUM_MINE_HEADER_BYTES>(header) + .context("invalid mine transaction proof header")?; + Ok(()) +} + +pub(super) fn validate_protocol_id(value: &str, label: &str) -> Result<()> { + let bytes = decode_hex(value).with_context(|| format!("invalid {label}"))?; + match bytes.len() { + HASH_BYTES | SIGNATURE_BYTES => Ok(()), + length => bail!("invalid {label}: expected 32 or 64 bytes, got {length}"), + } +} + +pub(super) fn canonical_transaction_size_bytes(transaction: &Transaction) -> usize { + match transaction { + Transaction::Transfer { + inputs, + outputs, + fee, + signature, + } => { + 1 + compact_len(inputs.len() as u128) + + compact_inputs_size_bytes(inputs) + + compact_len(outputs.len() as u128) + + compact_outputs_size_bytes(outputs) + + compact_len(u128::from(*fee)) + + signature_size_bytes(signature) + } + Transaction::Burn { + inputs, + change, + amount, + fee, + signature, + } => { + 1 + compact_len(inputs.len() as u128) + + compact_inputs_size_bytes(inputs) + + compact_len(change.len() as u128) + + compact_outputs_size_bytes(change) + + compact_len(u128::from(*amount)) + + compact_len(u128::from(*fee)) + + signature_size_bytes(signature) + } + Transaction::Mine { + recipient, + anchor, + salt, + nonce, + difficulty_bits, + proof_header, + signature, + } => { + 1 + address_size_bytes(recipient) + + hash_size_bytes(anchor) + + compact_len(u128::from(*salt)) + + compact_len(u128::from(*nonce)) + + compact_len(u128::from(*difficulty_bits)) + + 1 + + proof_header + .as_ref() + .map(|header| stratum_header_size_bytes(header)) + .unwrap_or(0) + + hash_size_bytes(signature) + } + } +} + +fn compact_inputs_size_bytes(inputs: &[TxInput]) -> usize { + inputs + .iter() + .map(|input| { + protocol_id_size_bytes(&input.outpoint.txid) + + compact_len(u128::from(input.outpoint.index)) + + address_size_bytes(&input.owner) + }) + .sum() +} + +fn compact_outputs_size_bytes(outputs: &[TxOutput]) -> usize { + outputs.iter().map(compact_output_size_bytes).sum() +} + +fn compact_output_size_bytes(output: &TxOutput) -> usize { + address_size_bytes(&output.address) + compact_len(u128::from(output.amount)) +} + +fn address_size_bytes(address: &str) -> usize { + debug_assert!(validate_address(address, "debug address").is_ok()); + PUBLIC_KEY_BYTES +} + +fn hash_size_bytes(hash: &str) -> usize { + debug_assert!(validate_hash(hash, "debug hash").is_ok()); + HASH_BYTES +} + +fn signature_size_bytes(signature: &str) -> usize { + debug_assert!(validate_signature(signature, "debug signature").is_ok()); + SIGNATURE_BYTES +} + +fn stratum_header_size_bytes(header: &str) -> usize { + debug_assert!(validate_stratum_header(header).is_ok()); + STRATUM_MINE_HEADER_BYTES +} + +fn protocol_id_size_bytes(value: &str) -> usize { + match decode_hex(value).map(|bytes| bytes.len()) { + Ok(HASH_BYTES) => HASH_BYTES, + Ok(SIGNATURE_BYTES) => SIGNATURE_BYTES, + _ => SIGNATURE_BYTES, + } +} + +pub(super) fn compact_len(mut value: u128) -> usize { + let mut bytes = 1; + while value >= 0x80 { + value >>= 7; + bytes += 1; + } + bytes +} + +#[cfg(test)] +mod tests { + use super::{ + compact_len, validate_address, validate_hash, validate_protocol_id, validate_signature, + validate_stratum_header, + }; + + #[test] + fn validators_accept_expected_protocol_lengths() { + assert!(validate_address(&"0".repeat(64), "test").is_ok()); + assert!(validate_hash(&"0".repeat(64), "test").is_ok()); + assert!(validate_signature(&"0".repeat(128), "test").is_ok()); + assert!(validate_stratum_header(&"0".repeat(160)).is_ok()); + assert!(validate_protocol_id(&"0".repeat(64), "test").is_ok()); + assert!(validate_protocol_id(&"0".repeat(128), "test").is_ok()); + } + + #[test] + fn validators_reject_wrong_lengths_and_bad_hex() { + assert!(validate_address(&"0".repeat(62), "test").is_err()); + assert!(validate_hash(&"0".repeat(66), "test").is_err()); + assert!(validate_signature("zz", "test").is_err()); + assert!(validate_stratum_header(&"0".repeat(158)).is_err()); + assert!(validate_protocol_id(&"0".repeat(96), "test").is_err()); + } + + #[test] + fn compact_len_uses_base_128_varint_width() { + assert_eq!(compact_len(0), 1); + assert_eq!(compact_len(127), 1); + assert_eq!(compact_len(128), 2); + assert_eq!(compact_len(16_383), 2); + assert_eq!(compact_len(16_384), 3); + } +} diff --git a/src/domain/vdf.rs b/src/domain/vdf.rs @@ -0,0 +1,197 @@ +use sha2::{Digest, Sha256}; + +use super::{ + Block, FALLBACK_VDF_RETARGET_ACTIVATION_HEIGHT, FALLBACK_VDF_RETARGET_DEACTIVATION_HEIGHT, + FinalizerMode, MAX_VDF_ROUNDS, VDF_TARGET_BLOCK_MS, +}; + +const VDF_MODULUS: u128 = 4_611_685_975_477_714_963; +const VDF_CHALLENGE_MIN: u64 = 1_073_741_827; +const MIN_VDF_ROUNDS: u64 = 1; +pub(super) const VDF_RETARGET_WINDOW_BLOCKS: usize = 20; +pub(super) const MAX_VDF_RETARGET_STEP_PERCENT: u128 = 2; +pub(super) const VDF_RETARGET_DEADBAND_PERCENT: u128 = 10; +pub(super) const MIN_VDF_RETARGET_OBSERVED_BLOCK_MS: u64 = VDF_TARGET_BLOCK_MS / 4; +pub(super) const MAX_VDF_RETARGET_OBSERVED_BLOCK_MS: u64 = VDF_TARGET_BLOCK_MS * 4; + +pub fn run_vdf(seed: &str, rounds: u64) -> String { + let x = vdf_seed_element(seed); + let mut y = x; + for _ in 0..rounds { + y = mul_mod(y, y); + } + + let challenge = vdf_challenge_prime(seed, rounds, y); + let proof = vdf_proof(x, rounds, challenge); + encode_vdf_solution(y, proof) +} + +pub fn verify_vdf(seed: &str, rounds: u64, solution: &str) -> bool { + let Some((y, proof)) = decode_vdf_solution(solution) else { + return false; + }; + if y == 0 || y >= VDF_MODULUS || proof >= VDF_MODULUS { + return false; + } + + let x = vdf_seed_element(seed); + let challenge = vdf_challenge_prime(seed, rounds, y); + let remainder = pow_mod_small(2, rounds, challenge) as u128; + let verified = mul_mod(mod_pow(proof, challenge as u128), mod_pow(x, remainder)); + verified == y +} + +pub(super) fn retarget_vdf_rounds(current_rounds: u64, observed_block_ms: u64) -> u64 { + let current = u128::from(current_rounds); + let observed = u128::from(observed_block_ms.max(1)); + let target = u128::from(VDF_TARGET_BLOCK_MS); + let deadband = target * VDF_RETARGET_DEADBAND_PERCENT / 100; + if observed >= target.saturating_sub(deadband) && observed <= target.saturating_add(deadband) { + return current_rounds; + } + + let raw_adjusted = current * target / observed; + let max_step = (current * MAX_VDF_RETARGET_STEP_PERCENT / 100).max(1); + let min_next = current + .saturating_sub(max_step) + .max(u128::from(MIN_VDF_ROUNDS)); + let max_next = current + .saturating_add(max_step) + .min(u128::from(MAX_VDF_ROUNDS)); + raw_adjusted.clamp(min_next, max_next) as u64 +} + +pub(super) fn clamped_vdf_retarget_observed_block_ms(observed_block_ms: u64) -> u64 { + observed_block_ms.clamp( + MIN_VDF_RETARGET_OBSERVED_BLOCK_MS, + MAX_VDF_RETARGET_OBSERVED_BLOCK_MS, + ) +} + +pub(super) fn vdf_retarget_observed_block_ms(parent: &Block, child: &Block) -> Option<u64> { + if child.finalizer_mode != FinalizerMode::Ticket { + return None; + } + if child.finalizer_rank != 0 + && (child.height < FALLBACK_VDF_RETARGET_ACTIVATION_HEIGHT + || child.height >= FALLBACK_VDF_RETARGET_DEACTIVATION_HEIGHT) + { + return None; + } + + Some(clamped_vdf_retarget_observed_block_ms( + child.timestamp_ms - parent.timestamp_ms, + )) +} + +fn vdf_seed_element(seed: &str) -> u128 { + let digest = Sha256::digest(format!("iuna-vdf-seed:{seed}").as_bytes()); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + 2 + (u128::from_be_bytes(bytes) % (VDF_MODULUS - 3)) +} + +fn vdf_challenge_prime(seed: &str, rounds: u64, output: u128) -> u64 { + let digest = Sha256::digest(format!("iuna-vdf-challenge:{seed}:{rounds}:{output:x}")); + let mut bytes = [0_u8; 8]; + bytes.copy_from_slice(&digest[..8]); + let candidate = VDF_CHALLENGE_MIN + (u64::from_be_bytes(bytes) % VDF_CHALLENGE_MIN); + next_odd_prime(candidate | 1) +} + +fn vdf_proof(x: u128, rounds: u64, challenge: u64) -> u128 { + let mut proof = 1_u128; + let mut remainder = 1_u64 % challenge; + for _ in 0..rounds { + let doubled = remainder * 2; + let carry = doubled >= challenge; + proof = mul_mod(proof, proof); + if carry { + proof = mul_mod(proof, x); + } + remainder = doubled % challenge; + } + proof +} + +fn encode_vdf_solution(output: u128, proof: u128) -> String { + format!("{output:032x}:{proof:032x}") +} + +fn decode_vdf_solution(solution: &str) -> Option<(u128, u128)> { + let (output, proof) = solution.split_once(':')?; + if output.len() != 32 || proof.len() != 32 { + return None; + } + Some(( + u128::from_str_radix(output, 16).ok()?, + u128::from_str_radix(proof, 16).ok()?, + )) +} + +fn mul_mod(left: u128, right: u128) -> u128 { + (left * right) % VDF_MODULUS +} + +fn mod_pow(mut base: u128, mut exponent: u128) -> u128 { + let mut result = 1_u128; + while exponent > 0 { + if exponent & 1 == 1 { + result = mul_mod(result, base); + } + base = mul_mod(base, base); + exponent >>= 1; + } + result +} + +fn pow_mod_small(base: u64, exponent: u64, modulus: u64) -> u64 { + let mut result = 1_u128; + let mut base = u128::from(base % modulus); + let mut exponent = exponent; + let modulus = u128::from(modulus); + while exponent > 0 { + if exponent & 1 == 1 { + result = (result * base) % modulus; + } + base = (base * base) % modulus; + exponent >>= 1; + } + result as u64 +} + +fn next_odd_prime(mut candidate: u64) -> u64 { + while !is_odd_prime(candidate) { + candidate = candidate.saturating_add(2); + } + candidate +} + +fn is_odd_prime(candidate: u64) -> bool { + if candidate < 3 || candidate % 2 == 0 { + return false; + } + let mut divisor = 3_u64; + while divisor * divisor <= candidate { + if candidate % divisor == 0 { + return false; + } + divisor += 2; + } + true +} + +#[cfg(test)] +mod tests { + use super::{run_vdf, verify_vdf}; + + #[test] + fn vdf_solution_verifies_and_is_bound_to_seed_and_rounds() { + let solution = run_vdf("test-seed", 128); + + assert!(verify_vdf("test-seed", 128, &solution)); + assert!(!verify_vdf("other-seed", 128, &solution)); + assert!(!verify_vdf("test-seed", 129, &solution)); + assert!(!verify_vdf("test-seed", 128, "not-a-vdf-solution")); + } +} diff --git a/src/domain/wallet.rs b/src/domain/wallet.rs @@ -0,0 +1,60 @@ +use ed25519_dalek::{Signature, Signer, SigningKey}; +use sha2::{Digest, Sha256}; + +use super::block::LeaderProofPayload; +use super::{ + LeaderProof, PUBLIC_KEY_BYTES, RevealBundle, RevealBundlePayload, decode_hex_array, hex_encode, +}; + +const WALLET_SEED_DOMAIN: &str = "iuna-wallet-seed"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Wallet { + address: String, + secret: String, +} + +impl Wallet { + pub fn from_seed(seed: &str) -> Self { + let seed_hash = Sha256::digest(format!("{WALLET_SEED_DOMAIN}:{seed}").as_bytes()); + let mut signing_seed = [0_u8; 32]; + signing_seed.copy_from_slice(&seed_hash); + let signing_key = SigningKey::from_bytes(&signing_seed); + let secret = hex_encode(signing_seed); + let address = hex_encode(signing_key.verifying_key().to_bytes()); + Self { address, secret } + } + + pub fn address(&self) -> &str { + &self.address + } + + pub(super) fn sign_payload(&self, payload: &str) -> String { + let seed = + decode_hex_array::<PUBLIC_KEY_BYTES>(&self.secret).expect("wallet secret is valid hex"); + let signing_key = SigningKey::from_bytes(&seed); + let signature: Signature = signing_key.sign(payload.as_bytes()); + hex_encode(signature.to_bytes()) + } + + pub(super) fn leader_proof(&self, payload: &LeaderProofPayload) -> LeaderProof { + let signature = self.sign_payload(&payload.canonical()); + LeaderProof { + ticket_id: payload.ticket_id.clone(), + public_key: self.address.clone(), + signature, + } + } + + pub(super) fn reveal_bundle(&self, payload: RevealBundlePayload) -> RevealBundle { + let signature = self.sign_payload(&payload.canonical()); + RevealBundle { + height: payload.height, + prev_hash: payload.prev_hash, + slot: payload.slot, + member: self.address.clone(), + reveals: payload.reveals, + signature, + } + } +} diff --git a/src/main.rs b/src/main.rs @@ -1,8 +1,7 @@ use std::{ collections::BTreeMap, - net::{Ipv4Addr, SocketAddr}, - path::{Path, PathBuf}, - str::FromStr, + net::SocketAddr, + path::Path, sync::Arc, time::{Duration, Instant}, }; @@ -21,6 +20,14 @@ use iuna::{ }; use tokio::sync::Mutex; +mod cli; +use cli::{ + ChainMode, CliOptions, apply_cli_p2p_config_overrides, configured_p2p_announce_addr, + configured_p2p_bind_addr, initial_burn_fee, initial_burn_per_block, validate_wallet_for_mode, +}; +#[cfg(test)] +use cli::{default_data_dir, help_text}; + const GENESIS_BOOTSTRAP_BURN_AMOUNT: Amount = MICRO_IUNA; const GENESIS_INITIAL_BURN_PER_BLOCK: Amount = config_store::DEFAULT_BURN_AMOUNT; const GENESIS_INITIAL_BURN_FEE: Amount = config_store::DEFAULT_BURN_FEE; @@ -295,259 +302,6 @@ async fn initialize_ledger( } } -fn configured_p2p_announce_addr( - opts: &CliOptions, - ui_config: &config_store::UiConfig, -) -> Result<Option<SocketAddr>> { - if let Some(addr) = opts.p2p_announce_addr { - return Ok(Some(addr)); - } - ui_config - .p2p_announce_addr - .as_deref() - .map(|addr| { - addr.parse() - .with_context(|| format!("invalid configured P2P announce address {addr}")) - }) - .transpose() -} - -fn configured_p2p_bind_addr(opts: &CliOptions, ui_config: &config_store::UiConfig) -> SocketAddr { - if opts.p2p_addr_configured || ui_config.p2p_accept_inbound { - return SocketAddr::from((Ipv4Addr::UNSPECIFIED, ui_config.p2p_bind_port)); - } - opts.p2p_addr -} - -fn apply_cli_p2p_config_overrides( - opts: &CliOptions, - ui_config: &mut config_store::UiConfig, -) -> bool { - let mut dirty = false; - if opts.p2p_addr_configured { - let bind_port = opts.p2p_addr.port(); - if ui_config.p2p_bind_port != bind_port { - ui_config.p2p_bind_port = bind_port; - dirty = true; - } - } - if let Some(addr) = opts.p2p_announce_addr { - let announce_addr = addr.to_string(); - if !ui_config.p2p_accept_inbound - || ui_config.p2p_announce_addr.as_deref() != Some(&announce_addr) - { - ui_config.p2p_accept_inbound = true; - ui_config.p2p_announce_addr = Some(announce_addr); - dirty = true; - } - } - dirty -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum ChainMode { - Setup, - Genesis, - Join, -} - -#[derive(Debug)] -struct CliOptions { - wallet_path: Option<PathBuf>, - chain_db_path: Option<PathBuf>, - http_addr: SocketAddr, - p2p_addr: SocketAddr, - p2p_addr_configured: bool, - p2p_announce_addr: Option<SocketAddr>, - stratum_addr: Option<SocketAddr>, - peers: Vec<String>, - join_peers: Vec<String>, - chain_mode: ChainMode, - data_dir: PathBuf, - debug: bool, -} - -impl CliOptions { - fn parse() -> Result<Option<Self>> { - Self::parse_from(std::env::args().skip(1)) - } - - fn parse_from(args: impl IntoIterator<Item = String>) -> Result<Option<Self>> { - let mut opts = Self { - wallet_path: None, - chain_db_path: None, - http_addr: SocketAddr::from_str("127.0.0.1:18661")?, - p2p_addr: SocketAddr::from_str("127.0.0.1:9444")?, - p2p_addr_configured: false, - p2p_announce_addr: None, - stratum_addr: None, - peers: Vec::new(), - join_peers: Vec::new(), - chain_mode: ChainMode::Setup, - data_dir: default_data_dir(), - debug: false, - }; - - let raw_args = args.into_iter().collect::<Vec<_>>(); - let mut args = raw_args.into_iter(); - while let Some(arg) = args.next() { - match arg.as_str() { - "--genesis" => { - if opts.chain_mode == ChainMode::Join { - bail!("choose either --genesis or --join, not both"); - } - opts.chain_mode = ChainMode::Genesis; - } - "--wallet" => { - opts.wallet_path = Some(PathBuf::from(next_value(&mut args, "--wallet")?)) - } - "--chain-db" => { - opts.chain_db_path = Some(PathBuf::from(next_value(&mut args, "--chain-db")?)) - } - "--wallet-seed" => { - bail!( - "--wallet-seed was removed; wallets are stored in --wallet <path> or ~/.iuna/wallet.json" - ) - } - "--http" => { - opts.http_addr = next_value(&mut args, "--http")? - .parse() - .context("invalid --http address")?; - } - "--p2p" => { - opts.p2p_addr = next_value(&mut args, "--p2p")? - .parse() - .context("invalid --p2p address")?; - opts.p2p_addr_configured = true; - } - "--p2p-announce" => { - opts.p2p_announce_addr = Some( - next_value(&mut args, "--p2p-announce")? - .parse() - .context("invalid --p2p-announce address")?, - ); - } - "--stratum" => { - opts.stratum_addr = Some( - next_value(&mut args, "--stratum")? - .parse() - .context("invalid --stratum address")?, - ); - } - "--join" => { - if opts.chain_mode == ChainMode::Genesis { - bail!("choose either --genesis or --join, not both"); - } - let peer = next_value(&mut args, "--join")?; - opts.chain_mode = ChainMode::Join; - opts.peers.push(peer.clone()); - opts.join_peers.push(peer); - } - "--data-dir" => opts.data_dir = PathBuf::from(next_value(&mut args, "--data-dir")?), - "--debug" => opts.debug = true, - "--help" | "-h" => { - print_help(); - std::process::exit(0); - } - other => bail!("unknown argument {other}; pass --help for usage"), - } - } - - if opts.chain_mode == ChainMode::Genesis && !opts.join_peers.is_empty() { - bail!("choose either --genesis or --join, not both"); - } - - Ok(Some(opts)) - } - - fn wallet_path(&self) -> PathBuf { - self.wallet_path - .clone() - .unwrap_or_else(|| self.data_dir.join("wallet.json")) - } - - fn chain_db_path(&self) -> PathBuf { - self.chain_db_path - .clone() - .unwrap_or_else(|| self.data_dir.join("chain.sqlite3")) - } - - fn config_path(&self) -> PathBuf { - self.data_dir.join("config.json") - } - - fn has_chain(&self) -> bool { - self.chain_mode != ChainMode::Setup - } -} - -fn next_value(args: &mut impl Iterator<Item = String>, flag: &str) -> Result<String> { - args.next() - .with_context(|| format!("missing value after {flag}")) -} - -fn validate_wallet_for_mode( - opts: &CliOptions, - wallet_path: &Path, - wallet_file_exists: bool, -) -> Result<()> { - if opts.chain_mode == ChainMode::Genesis && wallet_file_exists { - bail!( - "--genesis requires a fresh wallet path, but {} already exists; start without --genesis to reuse it or choose an empty --data-dir/--wallet", - wallet_path.display() - ); - } - Ok(()) -} - -fn initial_burn_per_block(opts: &CliOptions, ui_config: &config_store::UiConfig) -> Amount { - match opts.chain_mode { - ChainMode::Genesis => GENESIS_INITIAL_BURN_PER_BLOCK, - ChainMode::Setup | ChainMode::Join => ui_config.burn_per_block, - } -} - -fn initial_burn_fee(opts: &CliOptions, ui_config: &config_store::UiConfig) -> Amount { - match opts.chain_mode { - ChainMode::Genesis => GENESIS_INITIAL_BURN_FEE, - ChainMode::Setup | ChainMode::Join => ui_config.burn_fee, - } -} - -fn print_help() { - println!("{}", help_text()); -} - -fn help_text() -> &'static str { - "iuna\n\n\ - Usage:\n\ - iuna [options]\n\ - iuna --genesis [options]\n\ - iuna --join <addr:port> [options]\n\n\ - Options:\n\ - --genesis Create a new chain with a fresh setup wallet\n\ - --wallet <path> Wallet file (default <data-dir>/wallet.json)\n\ - --chain-db <path> Chain SQLite database (default <data-dir>/chain.sqlite3)\n\ - --http <addr:port> HTTP management UI address (default 127.0.0.1:18661)\n\ - --p2p <addr:port> Inbound P2P listener address when public node is enabled\n\ - --p2p-announce <addr:port> Public P2P address to gossip; enables inbound P2P\n\ - --stratum <addr:port> Stratum V1 listener for SHA-256 ASIC miners\n\ - --join <addr:port> Fetch chain snapshot from this peer before finalization\n\ - --data-dir <path> Local wallet directory (default ~/.iuna)\n\ - --debug Print verbose runtime logs\n\n\ - Environment:\n\ - IUNA_DEV_SKIP_SEED_VERIFY=1 Show a setup button to skip seed verification\n" -} - -fn default_data_dir() -> PathBuf { - std::env::var_os("HOME") - .filter(|home| !home.is_empty()) - .or_else(|| std::env::var_os("USERPROFILE").filter(|home| !home.is_empty())) - .map(PathBuf::from) - .map(|home| home.join(".iuna")) - .unwrap_or_else(|| PathBuf::from(".iuna")) -} - fn snapshot_height(snapshot: &ChainSnapshot) -> u64 { snapshot .blocks @@ -865,654 +619,5 @@ async fn persist_chain_snapshot( } #[cfg(test)] -mod tests { - use std::{collections::BTreeMap, sync::Arc, time::Duration}; - - use iuna::{ - adapters::{chain_store::SqliteChainStore, config_store::UiConfig, wallet_store}, - app::{DEFAULT_BURN_PER_BLOCK, NodeCore}, - domain::{BLOCK_REWARD, GenesisBurn, Ledger, MICRO_IUNA, VDF_TARGET_BLOCK_MS, Wallet}, - }; - use rusqlite::Connection; - use tempfile::tempdir; - use tokio::sync::Mutex; - - use super::{ - ChainMode, CliOptions, GENESIS_INITIAL_BURN_FEE, GENESIS_INITIAL_BURN_PER_BLOCK, - StartupWallet, apply_cli_p2p_config_overrides, configured_p2p_announce_addr, - configured_p2p_bind_addr, extrapolate_vdf_rounds, help_text, initial_burn_fee, - initial_burn_per_block, initialize_ledger, load_startup_wallet, measure_vdf_rounds, - persist_chain_snapshot, run_chain_persistence_with_interval, validate_wallet_for_mode, - }; - - fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> { - CliOptions::parse_from(args.iter().map(|arg| arg.to_string())) - } - - #[test] - fn help_mentions_dev_seed_verify_bypass_env() { - assert!(help_text().contains("IUNA_DEV_SKIP_SEED_VERIFY=1")); - assert!(help_text().contains("skip seed verification")); - assert!(help_text().contains("--stratum <addr:port>")); - assert!(help_text().contains("--debug")); - } - - fn ledger_with_one_spendable_iuna(wallet: &Wallet) -> Ledger { - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), 2); - Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1) - .unwrap() - } - - fn ledger_with_one_mined_block(wallet: &Wallet) -> Ledger { - let mut ledger = ledger_with_one_spendable_iuna(wallet); - let burn = ledger.build_burn(wallet, 1, 0).unwrap(); - ledger.submit_transaction(burn).unwrap(); - let block = ledger.mine_next_block(wallet, 1_000).unwrap(); - ledger.apply_locally_mined_block(block).unwrap(); - ledger - } - - #[test] - fn encrypted_startup_wallet_loads_as_locked_metadata() { - let dir = tempdir().unwrap(); - let path = dir.path().join("wallet.json"); - let (wallet, _) = - wallet_store::replace_with_generated_seed_phrase_encrypted(&path, "password-123456") - .unwrap(); - - let startup = load_startup_wallet(&path).unwrap(); - - match startup { - StartupWallet::Locked { address } => assert_eq!(address, wallet.address()), - StartupWallet::Unlocked { .. } => panic!("encrypted wallet should start locked"), - } - } - - #[test] - fn no_args_starts_setup_mode() { - let opts = parse(&[]).unwrap().unwrap(); - assert_eq!(opts.chain_mode, ChainMode::Setup); - assert!(opts.join_peers.is_empty()); - } - - #[test] - fn stratum_port_can_be_configured() { - let opts = parse(&["--stratum", "127.0.0.1:3333"]).unwrap().unwrap(); - assert_eq!(opts.stratum_addr, Some("127.0.0.1:3333".parse().unwrap())); - } - - #[test] - fn debug_logging_can_be_enabled() { - assert!(!parse(&[]).unwrap().unwrap().debug); - assert!(parse(&["--debug"]).unwrap().unwrap().debug); - } - - #[test] - fn removed_wallet_seed_is_rejected() { - let error = parse(&["--wallet-seed", "alice", "--genesis"]).unwrap_err(); - assert!(error.to_string().contains("--wallet-seed was removed")); - } - - #[test] - fn runtime_configuration_flags_are_rejected() { - for flag in [ - "--start", - "--name", - "--burn-per-block", - "--peer", - "--genesis-amount", - "--vdf-rounds", - ] { - let error = parse(&["--genesis", flag, "value"]).unwrap_err(); - assert!( - error.to_string().contains("unknown argument"), - "{flag} should not be accepted" - ); - } - } - - #[test] - fn genesis_mode_is_explicit() { - let opts = parse(&["--genesis"]).unwrap().unwrap(); - assert_eq!(opts.chain_mode, ChainMode::Genesis); - assert!(opts.join_peers.is_empty()); - } - - #[test] - fn join_mode_does_not_start_new_chain() { - let opts = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap(); - assert_eq!(opts.chain_mode, ChainMode::Join); - assert_eq!(opts.join_peers, vec!["127.0.0.1:9444"]); - } - - #[test] - fn genesis_mode_starts_with_default_burn_rate_and_fee() { - let genesis = parse(&["--genesis"]).unwrap().unwrap(); - let configured = UiConfig { - burn_per_block: 50, - burn_fee: 3, - ..UiConfig::default() - }; - assert_eq!( - initial_burn_per_block(&genesis, &configured), - UiConfig::default().burn_per_block - ); - assert_eq!( - initial_burn_fee(&genesis, &configured), - UiConfig::default().burn_fee - ); - } - - #[test] - fn genesis_default_auto_mining_keeps_burning_after_first_block() { - let wallet = Wallet::from_seed("genesis-auto-burn-wallet"); - let mut genesis = BTreeMap::new(); - genesis.insert(wallet.address().to_string(), MICRO_IUNA); - let ledger = Ledger::new_with_genesis_burns( - genesis, - vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], - 1, - ) - .unwrap(); - let mut node = NodeCore::from_ledger_with_burn_fee( - wallet.clone(), - ledger, - GENESIS_INITIAL_BURN_PER_BLOCK, - GENESIS_INITIAL_BURN_FEE, - ); - - let first = node.automatic_mine_once(1_000); - let second = node.automatic_mine_once(2_000); - let third = node.automatic_mine_once(3_000); - - assert_eq!(first.burned.as_ref().map(|tx| tx.amount()), Some(1)); - assert!(first.block.is_some(), "{first:?}"); - assert_eq!(second.burned.as_ref().map(|tx| tx.amount()), Some(1)); - assert!(second.block.is_some(), "{second:?}"); - assert_eq!(third.burned.as_ref().map(|tx| tx.amount()), Some(1)); - assert!(third.block.is_some(), "{third:?}"); - assert!( - second.skipped_reason.as_deref().is_none_or(|reason| { - !reason.contains("block must include at least one burn transaction") - }), - "{second:?}" - ); - assert!( - node.ledger().balance_of(wallet.address()) - >= BLOCK_REWARD - 3 * (GENESIS_INITIAL_BURN_PER_BLOCK + GENESIS_INITIAL_BURN_FEE) - ); - } - - #[test] - fn non_genesis_modes_start_with_configured_burn_rate_and_fee() { - let configured = UiConfig { - burn_per_block: 50, - burn_fee: 3, - ..UiConfig::default() - }; - - let setup = parse(&[]).unwrap().unwrap(); - assert_eq!(initial_burn_per_block(&setup, &configured), 50); - assert_eq!(initial_burn_fee(&setup, &configured), 3); - - let join = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap(); - assert_eq!(initial_burn_per_block(&join, &configured), 50); - assert_eq!(initial_burn_fee(&join, &configured), 3); - - assert_eq!( - initial_burn_per_block(&setup, &UiConfig::default()), - UiConfig::default().burn_per_block - ); - assert_eq!( - initial_burn_fee(&setup, &UiConfig::default()), - UiConfig::default().burn_fee - ); - } - - #[test] - fn genesis_and_join_are_exclusive() { - let error = parse(&["--genesis", "--join", "127.0.0.1:9444"]).unwrap_err(); - assert!( - error - .to_string() - .contains("choose either --genesis or --join") - ); - - let error = parse(&["--join", "127.0.0.1:9444", "--genesis"]).unwrap_err(); - assert!( - error - .to_string() - .contains("choose either --genesis or --join") - ); - } - - #[test] - fn http_management_port_can_be_configured() { - let opts = parse(&["--genesis", "--http", "127.0.0.1:18443"]) - .unwrap() - .unwrap(); - assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18443"); - } - - #[test] - fn p2p_announce_port_can_be_configured() { - let opts = parse(&["--p2p-announce", "203.0.113.10:9444"]) - .unwrap() - .unwrap(); - - assert_eq!( - opts.p2p_announce_addr, - Some("203.0.113.10:9444".parse().unwrap()) - ); - } - - #[test] - fn configured_p2p_announce_addr_uses_cli_before_config() { - let opts = parse(&["--p2p-announce", "203.0.113.20:9444"]) - .unwrap() - .unwrap(); - let config = UiConfig { - p2p_announce_addr: Some("203.0.113.10:9444".to_string()), - ..UiConfig::default() - }; - - assert_eq!( - configured_p2p_announce_addr(&opts, &config).unwrap(), - Some("203.0.113.20:9444".parse().unwrap()) - ); - } - - #[test] - fn configured_p2p_announce_addr_reads_config_without_cli() { - let opts = parse(&[]).unwrap().unwrap(); - let config = UiConfig { - p2p_announce_addr: Some("203.0.113.10:9444".to_string()), - ..UiConfig::default() - }; - - assert_eq!( - configured_p2p_announce_addr(&opts, &config).unwrap(), - Some("203.0.113.10:9444".parse().unwrap()) - ); - } - - #[test] - fn configured_p2p_announce_addr_rejects_invalid_config() { - let opts = parse(&[]).unwrap().unwrap(); - let config = UiConfig { - p2p_announce_addr: Some("not-an-address".to_string()), - ..UiConfig::default() - }; - - let error = configured_p2p_announce_addr(&opts, &config).unwrap_err(); - assert!( - error - .to_string() - .contains("invalid configured P2P announce address") - ); - } - - #[test] - fn configured_p2p_bind_addr_uses_configured_public_port() { - let opts = parse(&[]).unwrap().unwrap(); - let config = UiConfig { - p2p_accept_inbound: true, - p2p_bind_port: 9555, - ..UiConfig::default() - }; - - assert_eq!( - configured_p2p_bind_addr(&opts, &config).to_string(), - "0.0.0.0:9555" - ); - } - - #[test] - fn configured_p2p_bind_addr_uses_cli_port_after_config_override() { - let opts = parse(&["--p2p", "127.0.0.1:9555"]).unwrap().unwrap(); - let config = UiConfig { - p2p_accept_inbound: true, - p2p_bind_port: 9555, - ..UiConfig::default() - }; - - assert_eq!( - configured_p2p_bind_addr(&opts, &config).to_string(), - "0.0.0.0:9555" - ); - } - - #[test] - fn cli_p2p_port_overrides_config_bind_port() { - let opts = parse(&["--p2p", "127.0.0.1:9555"]).unwrap().unwrap(); - let mut config = UiConfig { - p2p_accept_inbound: true, - p2p_bind_port: 9444, - ..UiConfig::default() - }; - - assert!(apply_cli_p2p_config_overrides(&opts, &mut config)); - assert_eq!(config.p2p_bind_port, 9555); - } - - #[test] - fn http_management_port_defaults_to_iuna_port() { - let opts = parse(&[]).unwrap().unwrap(); - assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18661"); - } - - #[test] - fn data_dir_defaults_under_home() { - let opts = parse(&[]).unwrap().unwrap(); - assert_eq!(opts.data_dir, super::default_data_dir()); - assert!(opts.data_dir.ends_with(".iuna")); - } - - #[test] - fn wallet_defaults_under_data_dir() { - let opts = parse(&["--genesis", "--data-dir", "tmp-node"]) - .unwrap() - .unwrap(); - assert_eq!( - opts.wallet_path(), - std::path::PathBuf::from("tmp-node/wallet.json") - ); - } - - #[test] - fn chain_db_defaults_under_data_dir() { - let opts = parse(&["--genesis", "--data-dir", "tmp-node"]) - .unwrap() - .unwrap(); - assert_eq!( - opts.chain_db_path(), - std::path::PathBuf::from("tmp-node/chain.sqlite3") - ); - } - - #[test] - fn config_defaults_under_data_dir() { - let opts = parse(&["--genesis", "--data-dir", "tmp-node"]) - .unwrap() - .unwrap(); - assert_eq!( - opts.config_path(), - std::path::PathBuf::from("tmp-node/config.json") - ); - } - - #[test] - fn wallet_path_can_be_explicit() { - let opts = parse(&["--genesis", "--wallet", "alice-wallet.json"]) - .unwrap() - .unwrap(); - assert_eq!( - opts.wallet_path(), - std::path::PathBuf::from("alice-wallet.json") - ); - } - - #[test] - fn chain_db_path_can_be_explicit() { - let opts = parse(&["--genesis", "--chain-db", "alice-chain.sqlite3"]) - .unwrap() - .unwrap(); - assert_eq!( - opts.chain_db_path(), - std::path::PathBuf::from("alice-chain.sqlite3") - ); - } - - #[test] - fn genesis_requires_fresh_wallet_path() { - let opts = parse(&["--genesis"]).unwrap().unwrap(); - let wallet_path = std::path::Path::new("wallet.json"); - - validate_wallet_for_mode(&opts, wallet_path, false).unwrap(); - let error = validate_wallet_for_mode(&opts, wallet_path, true).unwrap_err(); - assert!(error.to_string().contains("requires a fresh wallet path")); - - let setup = parse(&[]).unwrap().unwrap(); - validate_wallet_for_mode(&setup, wallet_path, true).unwrap(); - } - - #[test] - fn vdf_measurement_extrapolates_to_target() { - assert_eq!( - extrapolate_vdf_rounds( - 10_000, - Duration::from_secs(1), - Duration::from_millis(VDF_TARGET_BLOCK_MS), - ), - 3_000_000 - ); - assert_eq!( - extrapolate_vdf_rounds( - 10_000, - Duration::from_secs(0), - Duration::from_millis(VDF_TARGET_BLOCK_MS), - ), - 3_000_000_000_000_000 - ); - } - - #[test] - fn vdf_measurement_keeps_sampling_until_elapsed_is_useful() { - let min_elapsed = Duration::from_millis(1); - let max_rounds = 1_000_000; - let (rounds, elapsed) = - measure_vdf_rounds("iuna-test-vdf-calibration", 1, min_elapsed, max_rounds); - - assert!(rounds >= 1); - assert!(elapsed >= min_elapsed || rounds >= max_rounds); - assert!(elapsed > Duration::ZERO); - } - - #[tokio::test] - async fn genesis_refuses_to_start_when_chain_database_exists() { - let dir = tempdir().unwrap(); - let chain_path = dir.path().join("chain.sqlite3"); - let store = SqliteChainStore::open(&chain_path).unwrap(); - let persisted_wallet = Wallet::from_seed("persisted-chain-owner"); - let persisted = ledger_with_one_mined_block(&persisted_wallet); - store.save(&persisted.snapshot()).unwrap(); - let fresh_wallet = Wallet::from_seed("fresh-start-wallet"); - let opts = parse(&["--genesis", "--chain-db", chain_path.to_str().unwrap()]) - .unwrap() - .unwrap(); - - let error = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) - .await - .unwrap_err(); - - assert!( - error.to_string().contains("already contains a blockchain"), - "{error:#}" - ); - } - - #[tokio::test] - async fn startup_resumes_persisted_chain_without_genesis_flag() { - let dir = tempdir().unwrap(); - let chain_path = dir.path().join("chain.sqlite3"); - let store = SqliteChainStore::open(&chain_path).unwrap(); - let persisted_wallet = Wallet::from_seed("persisted-chain-owner"); - let persisted = ledger_with_one_mined_block(&persisted_wallet); - store.save(&persisted.snapshot()).unwrap(); - let fresh_wallet = Wallet::from_seed("fresh-start-wallet"); - let opts = parse(&["--chain-db", chain_path.to_str().unwrap()]) - .unwrap() - .unwrap(); - - let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) - .await - .unwrap(); - - assert_eq!(resumed.status().height, 1); - assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash); - assert_eq!(resumed.genesis_hash(), persisted.genesis_hash()); - assert_eq!(resumed.balance_of(fresh_wallet.address()), 0); - } - - #[tokio::test] - async fn startup_resumes_persisted_chain_with_network_accepted_future_tip() { - let dir = tempdir().unwrap(); - let chain_path = dir.path().join("chain.sqlite3"); - let store = SqliteChainStore::open(&chain_path).unwrap(); - let persisted_wallet = Wallet::from_seed("persisted-future-chain-owner"); - let mut persisted = ledger_with_one_spendable_iuna(&persisted_wallet); - let burn = persisted.build_burn(&persisted_wallet, 1, 0).unwrap(); - persisted.submit_transaction(burn).unwrap(); - let future_tip_ms = iuna::app::now_ms().saturating_add(VDF_TARGET_BLOCK_MS); - let future_block = persisted - .mine_next_block(&persisted_wallet, future_tip_ms) - .unwrap(); - let mut snapshot = persisted.snapshot(); - snapshot.blocks.push(future_block); - assert!( - Ledger::from_snapshot(snapshot.clone()) - .unwrap_err() - .to_string() - .contains("too far in the future") - ); - store.save(&snapshot).unwrap(); - let fresh_wallet = Wallet::from_seed("fresh-start-wallet"); - let opts = parse(&["--chain-db", chain_path.to_str().unwrap()]) - .unwrap() - .unwrap(); - - let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) - .await - .unwrap(); - - assert_eq!(resumed.status().height, 1); - assert_eq!( - resumed.status().tip_hash, - snapshot.blocks.last().unwrap().hash - ); - } - - #[tokio::test] - async fn persisted_chain_satisfies_join_mode_without_contacting_peer() { - let dir = tempdir().unwrap(); - let chain_path = dir.path().join("chain.sqlite3"); - let store = SqliteChainStore::open(&chain_path).unwrap(); - let alice = Wallet::from_seed("offline-join-alice"); - let persisted = ledger_with_one_mined_block(&alice); - store.save(&persisted.snapshot()).unwrap(); - let bob = Wallet::from_seed("offline-join-bob"); - let opts = parse(&[ - "--join", - "127.0.0.1:1", - "--chain-db", - chain_path.to_str().unwrap(), - ]) - .unwrap() - .unwrap(); - - let resumed = initialize_ledger(&opts, bob.address(), &store, opts.p2p_addr) - .await - .unwrap(); - - assert_eq!(resumed.status().height, 1); - assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash); - } - - #[tokio::test] - async fn invalid_persisted_chain_is_reported_and_never_replaced() { - let dir = tempdir().unwrap(); - let chain_path = dir.path().join("chain.sqlite3"); - let store = SqliteChainStore::open(&chain_path).unwrap(); - let connection = Connection::open(&chain_path).unwrap(); - connection - .execute( - r#" -INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_blob, updated_at_ms) -VALUES (1, 4, 'bad-tip', x'00010203', 0) -"#, - [], - ) - .unwrap(); - let wallet = Wallet::from_seed("bad-db-wallet"); - let opts = parse(&["--genesis", "--chain-db", chain_path.to_str().unwrap()]) - .unwrap() - .unwrap(); - - let error = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr) - .await - .unwrap_err(); - - assert!( - format!("{error:#}").contains("failed to parse compact chain snapshot from database"), - "{error:#}" - ); - } - - #[tokio::test] - async fn persistence_loop_saves_new_tip_after_node_changes() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("background-persistence"); - let ledger = ledger_with_one_spendable_iuna(&wallet); - let node = Arc::new(Mutex::new(NodeCore::from_ledger( - wallet.clone(), - ledger, - DEFAULT_BURN_PER_BLOCK, - ))); - let initial_snapshot = { node.lock().await.chain_snapshot() }; - persist_chain_snapshot(&store, initial_snapshot, false) - .await - .unwrap(); - let ui_config = Arc::new(Mutex::new(UiConfig::default())); - - let persistence_task = tokio::spawn(run_chain_persistence_with_interval( - Arc::clone(&node), - store.clone(), - ui_config, - Duration::from_millis(10), - )); - { - let mut node = node.lock().await; - let burn = node.ledger().build_burn(&wallet, 1, 0).unwrap(); - node.receive_transaction(burn).unwrap(); - node.mine_one_at(1_000).unwrap(); - } - - let expected_tip = node.lock().await.ledger().status().tip_hash; - let mut restored_tip = None; - for _ in 0..50 { - if let Some(snapshot) = store.load().unwrap() { - restored_tip = snapshot.blocks.last().map(|block| block.hash.clone()); - if restored_tip.as_deref() == Some(expected_tip.as_str()) { - break; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - persistence_task.abort(); - - assert_eq!(restored_tip.as_deref(), Some(expected_tip.as_str())); - } - - #[tokio::test] - async fn persistence_loop_skips_setup_placeholder_chain() { - let dir = tempdir().unwrap(); - let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); - let wallet = Wallet::from_seed("background-persistence-setup"); - let ledger = Ledger::new(BTreeMap::new(), 1); - let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0))); - let ui_config = Arc::new(Mutex::new(UiConfig::default())); - - let persistence_task = tokio::spawn(run_chain_persistence_with_interval( - Arc::clone(&node), - store.clone(), - ui_config, - Duration::from_millis(10), - )); - tokio::time::sleep(Duration::from_millis(50)).await; - persistence_task.abort(); - - assert!(store.load().unwrap().is_none()); - } -} +#[path = "main_tests.rs"] +mod tests; diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -0,0 +1,648 @@ +use std::{collections::BTreeMap, sync::Arc, time::Duration}; + +use iuna::{ + adapters::{chain_store::SqliteChainStore, config_store::UiConfig, wallet_store}, + app::{DEFAULT_BURN_PER_BLOCK, NodeCore}, + domain::{BLOCK_REWARD, GenesisBurn, Ledger, MICRO_IUNA, VDF_TARGET_BLOCK_MS, Wallet}, +}; +use rusqlite::Connection; +use tempfile::tempdir; +use tokio::sync::Mutex; + +use super::{ + ChainMode, CliOptions, GENESIS_INITIAL_BURN_FEE, GENESIS_INITIAL_BURN_PER_BLOCK, StartupWallet, + apply_cli_p2p_config_overrides, configured_p2p_announce_addr, configured_p2p_bind_addr, + extrapolate_vdf_rounds, help_text, initial_burn_fee, initial_burn_per_block, initialize_ledger, + load_startup_wallet, measure_vdf_rounds, persist_chain_snapshot, + run_chain_persistence_with_interval, validate_wallet_for_mode, +}; + +fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> { + CliOptions::parse_from(args.iter().map(|arg| arg.to_string())) +} + +#[test] +fn help_mentions_dev_seed_verify_bypass_env() { + assert!(help_text().contains("IUNA_DEV_SKIP_SEED_VERIFY=1")); + assert!(help_text().contains("skip seed verification")); + assert!(help_text().contains("--stratum <addr:port>")); + assert!(help_text().contains("--debug")); +} + +fn ledger_with_one_spendable_iuna(wallet: &Wallet) -> Ledger { + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), 2); + Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(wallet.address(), 1)], 1).unwrap() +} + +fn ledger_with_one_mined_block(wallet: &Wallet) -> Ledger { + let mut ledger = ledger_with_one_spendable_iuna(wallet); + let burn = ledger.build_burn(wallet, 1, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let block = ledger.mine_next_block(wallet, 1_000).unwrap(); + ledger.apply_locally_mined_block(block).unwrap(); + ledger +} + +#[test] +fn encrypted_startup_wallet_loads_as_locked_metadata() { + let dir = tempdir().unwrap(); + let path = dir.path().join("wallet.json"); + let (wallet, _) = + wallet_store::replace_with_generated_seed_phrase_encrypted(&path, "password-123456") + .unwrap(); + + let startup = load_startup_wallet(&path).unwrap(); + + match startup { + StartupWallet::Locked { address } => assert_eq!(address, wallet.address()), + StartupWallet::Unlocked { .. } => panic!("encrypted wallet should start locked"), + } +} + +#[test] +fn no_args_starts_setup_mode() { + let opts = parse(&[]).unwrap().unwrap(); + assert_eq!(opts.chain_mode, ChainMode::Setup); + assert!(opts.join_peers.is_empty()); +} + +#[test] +fn stratum_port_can_be_configured() { + let opts = parse(&["--stratum", "127.0.0.1:3333"]).unwrap().unwrap(); + assert_eq!(opts.stratum_addr, Some("127.0.0.1:3333".parse().unwrap())); +} + +#[test] +fn debug_logging_can_be_enabled() { + assert!(!parse(&[]).unwrap().unwrap().debug); + assert!(parse(&["--debug"]).unwrap().unwrap().debug); +} + +#[test] +fn removed_wallet_seed_is_rejected() { + let error = parse(&["--wallet-seed", "alice", "--genesis"]).unwrap_err(); + assert!(error.to_string().contains("--wallet-seed was removed")); +} + +#[test] +fn runtime_configuration_flags_are_rejected() { + for flag in [ + "--start", + "--name", + "--burn-per-block", + "--peer", + "--genesis-amount", + "--vdf-rounds", + ] { + let error = parse(&["--genesis", flag, "value"]).unwrap_err(); + assert!( + error.to_string().contains("unknown argument"), + "{flag} should not be accepted" + ); + } +} + +#[test] +fn genesis_mode_is_explicit() { + let opts = parse(&["--genesis"]).unwrap().unwrap(); + assert_eq!(opts.chain_mode, ChainMode::Genesis); + assert!(opts.join_peers.is_empty()); +} + +#[test] +fn join_mode_does_not_start_new_chain() { + let opts = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap(); + assert_eq!(opts.chain_mode, ChainMode::Join); + assert_eq!(opts.join_peers, vec!["127.0.0.1:9444"]); +} + +#[test] +fn genesis_mode_starts_with_default_burn_rate_and_fee() { + let genesis = parse(&["--genesis"]).unwrap().unwrap(); + let configured = UiConfig { + burn_per_block: 50, + burn_fee: 3, + ..UiConfig::default() + }; + assert_eq!( + initial_burn_per_block(&genesis, &configured), + UiConfig::default().burn_per_block + ); + assert_eq!( + initial_burn_fee(&genesis, &configured), + UiConfig::default().burn_fee + ); +} + +#[test] +fn genesis_default_auto_mining_keeps_burning_after_first_block() { + let wallet = Wallet::from_seed("genesis-auto-burn-wallet"); + let mut genesis = BTreeMap::new(); + genesis.insert(wallet.address().to_string(), MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + genesis, + vec![GenesisBurn::new(wallet.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + let mut node = NodeCore::from_ledger_with_burn_fee( + wallet.clone(), + ledger, + GENESIS_INITIAL_BURN_PER_BLOCK, + GENESIS_INITIAL_BURN_FEE, + ); + + let first = node.automatic_mine_once(1_000); + let second = node.automatic_mine_once(2_000); + let third = node.automatic_mine_once(3_000); + + assert_eq!(first.burned.as_ref().map(|tx| tx.amount()), Some(1)); + assert!(first.block.is_some(), "{first:?}"); + assert_eq!(second.burned.as_ref().map(|tx| tx.amount()), Some(1)); + assert!(second.block.is_some(), "{second:?}"); + assert_eq!(third.burned.as_ref().map(|tx| tx.amount()), Some(1)); + assert!(third.block.is_some(), "{third:?}"); + assert!( + second.skipped_reason.as_deref().is_none_or(|reason| { + !reason.contains("block must include at least one burn transaction") + }), + "{second:?}" + ); + assert!( + node.ledger().balance_of(wallet.address()) + >= BLOCK_REWARD - 3 * (GENESIS_INITIAL_BURN_PER_BLOCK + GENESIS_INITIAL_BURN_FEE) + ); +} + +#[test] +fn non_genesis_modes_start_with_configured_burn_rate_and_fee() { + let configured = UiConfig { + burn_per_block: 50, + burn_fee: 3, + ..UiConfig::default() + }; + + let setup = parse(&[]).unwrap().unwrap(); + assert_eq!(initial_burn_per_block(&setup, &configured), 50); + assert_eq!(initial_burn_fee(&setup, &configured), 3); + + let join = parse(&["--join", "127.0.0.1:9444"]).unwrap().unwrap(); + assert_eq!(initial_burn_per_block(&join, &configured), 50); + assert_eq!(initial_burn_fee(&join, &configured), 3); + + assert_eq!( + initial_burn_per_block(&setup, &UiConfig::default()), + UiConfig::default().burn_per_block + ); + assert_eq!( + initial_burn_fee(&setup, &UiConfig::default()), + UiConfig::default().burn_fee + ); +} + +#[test] +fn genesis_and_join_are_exclusive() { + let error = parse(&["--genesis", "--join", "127.0.0.1:9444"]).unwrap_err(); + assert!( + error + .to_string() + .contains("choose either --genesis or --join") + ); + + let error = parse(&["--join", "127.0.0.1:9444", "--genesis"]).unwrap_err(); + assert!( + error + .to_string() + .contains("choose either --genesis or --join") + ); +} + +#[test] +fn http_management_port_can_be_configured() { + let opts = parse(&["--genesis", "--http", "127.0.0.1:18443"]) + .unwrap() + .unwrap(); + assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18443"); +} + +#[test] +fn p2p_announce_port_can_be_configured() { + let opts = parse(&["--p2p-announce", "203.0.113.10:9444"]) + .unwrap() + .unwrap(); + + assert_eq!( + opts.p2p_announce_addr, + Some("203.0.113.10:9444".parse().unwrap()) + ); +} + +#[test] +fn configured_p2p_announce_addr_uses_cli_before_config() { + let opts = parse(&["--p2p-announce", "203.0.113.20:9444"]) + .unwrap() + .unwrap(); + let config = UiConfig { + p2p_announce_addr: Some("203.0.113.10:9444".to_string()), + ..UiConfig::default() + }; + + assert_eq!( + configured_p2p_announce_addr(&opts, &config).unwrap(), + Some("203.0.113.20:9444".parse().unwrap()) + ); +} + +#[test] +fn configured_p2p_announce_addr_reads_config_without_cli() { + let opts = parse(&[]).unwrap().unwrap(); + let config = UiConfig { + p2p_announce_addr: Some("203.0.113.10:9444".to_string()), + ..UiConfig::default() + }; + + assert_eq!( + configured_p2p_announce_addr(&opts, &config).unwrap(), + Some("203.0.113.10:9444".parse().unwrap()) + ); +} + +#[test] +fn configured_p2p_announce_addr_rejects_invalid_config() { + let opts = parse(&[]).unwrap().unwrap(); + let config = UiConfig { + p2p_announce_addr: Some("not-an-address".to_string()), + ..UiConfig::default() + }; + + let error = configured_p2p_announce_addr(&opts, &config).unwrap_err(); + assert!( + error + .to_string() + .contains("invalid configured P2P announce address") + ); +} + +#[test] +fn configured_p2p_bind_addr_uses_configured_public_port() { + let opts = parse(&[]).unwrap().unwrap(); + let config = UiConfig { + p2p_accept_inbound: true, + p2p_bind_port: 9555, + ..UiConfig::default() + }; + + assert_eq!( + configured_p2p_bind_addr(&opts, &config).to_string(), + "0.0.0.0:9555" + ); +} + +#[test] +fn configured_p2p_bind_addr_uses_cli_port_after_config_override() { + let opts = parse(&["--p2p", "127.0.0.1:9555"]).unwrap().unwrap(); + let config = UiConfig { + p2p_accept_inbound: true, + p2p_bind_port: 9555, + ..UiConfig::default() + }; + + assert_eq!( + configured_p2p_bind_addr(&opts, &config).to_string(), + "0.0.0.0:9555" + ); +} + +#[test] +fn cli_p2p_port_overrides_config_bind_port() { + let opts = parse(&["--p2p", "127.0.0.1:9555"]).unwrap().unwrap(); + let mut config = UiConfig { + p2p_accept_inbound: true, + p2p_bind_port: 9444, + ..UiConfig::default() + }; + + assert!(apply_cli_p2p_config_overrides(&opts, &mut config)); + assert_eq!(config.p2p_bind_port, 9555); +} + +#[test] +fn http_management_port_defaults_to_iuna_port() { + let opts = parse(&[]).unwrap().unwrap(); + assert_eq!(opts.http_addr.to_string(), "127.0.0.1:18661"); +} + +#[test] +fn data_dir_defaults_under_home() { + let opts = parse(&[]).unwrap().unwrap(); + assert_eq!(opts.data_dir, super::default_data_dir()); + assert!(opts.data_dir.ends_with(".iuna")); +} + +#[test] +fn wallet_defaults_under_data_dir() { + let opts = parse(&["--genesis", "--data-dir", "tmp-node"]) + .unwrap() + .unwrap(); + assert_eq!( + opts.wallet_path(), + std::path::PathBuf::from("tmp-node/wallet.json") + ); +} + +#[test] +fn chain_db_defaults_under_data_dir() { + let opts = parse(&["--genesis", "--data-dir", "tmp-node"]) + .unwrap() + .unwrap(); + assert_eq!( + opts.chain_db_path(), + std::path::PathBuf::from("tmp-node/chain.sqlite3") + ); +} + +#[test] +fn config_defaults_under_data_dir() { + let opts = parse(&["--genesis", "--data-dir", "tmp-node"]) + .unwrap() + .unwrap(); + assert_eq!( + opts.config_path(), + std::path::PathBuf::from("tmp-node/config.json") + ); +} + +#[test] +fn wallet_path_can_be_explicit() { + let opts = parse(&["--genesis", "--wallet", "alice-wallet.json"]) + .unwrap() + .unwrap(); + assert_eq!( + opts.wallet_path(), + std::path::PathBuf::from("alice-wallet.json") + ); +} + +#[test] +fn chain_db_path_can_be_explicit() { + let opts = parse(&["--genesis", "--chain-db", "alice-chain.sqlite3"]) + .unwrap() + .unwrap(); + assert_eq!( + opts.chain_db_path(), + std::path::PathBuf::from("alice-chain.sqlite3") + ); +} + +#[test] +fn genesis_requires_fresh_wallet_path() { + let opts = parse(&["--genesis"]).unwrap().unwrap(); + let wallet_path = std::path::Path::new("wallet.json"); + + validate_wallet_for_mode(&opts, wallet_path, false).unwrap(); + let error = validate_wallet_for_mode(&opts, wallet_path, true).unwrap_err(); + assert!(error.to_string().contains("requires a fresh wallet path")); + + let setup = parse(&[]).unwrap().unwrap(); + validate_wallet_for_mode(&setup, wallet_path, true).unwrap(); +} + +#[test] +fn vdf_measurement_extrapolates_to_target() { + assert_eq!( + extrapolate_vdf_rounds( + 10_000, + Duration::from_secs(1), + Duration::from_millis(VDF_TARGET_BLOCK_MS), + ), + 3_000_000 + ); + assert_eq!( + extrapolate_vdf_rounds( + 10_000, + Duration::from_secs(0), + Duration::from_millis(VDF_TARGET_BLOCK_MS), + ), + 3_000_000_000_000_000 + ); +} + +#[test] +fn vdf_measurement_keeps_sampling_until_elapsed_is_useful() { + let min_elapsed = Duration::from_millis(1); + let max_rounds = 1_000_000; + let (rounds, elapsed) = + measure_vdf_rounds("iuna-test-vdf-calibration", 1, min_elapsed, max_rounds); + + assert!(rounds >= 1); + assert!(elapsed >= min_elapsed || rounds >= max_rounds); + assert!(elapsed > Duration::ZERO); +} + +#[tokio::test] +async fn genesis_refuses_to_start_when_chain_database_exists() { + let dir = tempdir().unwrap(); + let chain_path = dir.path().join("chain.sqlite3"); + let store = SqliteChainStore::open(&chain_path).unwrap(); + let persisted_wallet = Wallet::from_seed("persisted-chain-owner"); + let persisted = ledger_with_one_mined_block(&persisted_wallet); + store.save(&persisted.snapshot()).unwrap(); + let fresh_wallet = Wallet::from_seed("fresh-start-wallet"); + let opts = parse(&["--genesis", "--chain-db", chain_path.to_str().unwrap()]) + .unwrap() + .unwrap(); + + let error = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) + .await + .unwrap_err(); + + assert!( + error.to_string().contains("already contains a blockchain"), + "{error:#}" + ); +} + +#[tokio::test] +async fn startup_resumes_persisted_chain_without_genesis_flag() { + let dir = tempdir().unwrap(); + let chain_path = dir.path().join("chain.sqlite3"); + let store = SqliteChainStore::open(&chain_path).unwrap(); + let persisted_wallet = Wallet::from_seed("persisted-chain-owner"); + let persisted = ledger_with_one_mined_block(&persisted_wallet); + store.save(&persisted.snapshot()).unwrap(); + let fresh_wallet = Wallet::from_seed("fresh-start-wallet"); + let opts = parse(&["--chain-db", chain_path.to_str().unwrap()]) + .unwrap() + .unwrap(); + + let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) + .await + .unwrap(); + + assert_eq!(resumed.status().height, 1); + assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash); + assert_eq!(resumed.genesis_hash(), persisted.genesis_hash()); + assert_eq!(resumed.balance_of(fresh_wallet.address()), 0); +} + +#[tokio::test] +async fn startup_resumes_persisted_chain_with_network_accepted_future_tip() { + let dir = tempdir().unwrap(); + let chain_path = dir.path().join("chain.sqlite3"); + let store = SqliteChainStore::open(&chain_path).unwrap(); + let persisted_wallet = Wallet::from_seed("persisted-future-chain-owner"); + let mut persisted = ledger_with_one_spendable_iuna(&persisted_wallet); + let burn = persisted.build_burn(&persisted_wallet, 1, 0).unwrap(); + persisted.submit_transaction(burn).unwrap(); + let future_tip_ms = iuna::app::now_ms().saturating_add(VDF_TARGET_BLOCK_MS); + let future_block = persisted + .mine_next_block(&persisted_wallet, future_tip_ms) + .unwrap(); + let mut snapshot = persisted.snapshot(); + snapshot.blocks.push(future_block); + assert!( + Ledger::from_snapshot(snapshot.clone()) + .unwrap_err() + .to_string() + .contains("too far in the future") + ); + store.save(&snapshot).unwrap(); + let fresh_wallet = Wallet::from_seed("fresh-start-wallet"); + let opts = parse(&["--chain-db", chain_path.to_str().unwrap()]) + .unwrap() + .unwrap(); + + let resumed = initialize_ledger(&opts, fresh_wallet.address(), &store, opts.p2p_addr) + .await + .unwrap(); + + assert_eq!(resumed.status().height, 1); + assert_eq!( + resumed.status().tip_hash, + snapshot.blocks.last().unwrap().hash + ); +} + +#[tokio::test] +async fn persisted_chain_satisfies_join_mode_without_contacting_peer() { + let dir = tempdir().unwrap(); + let chain_path = dir.path().join("chain.sqlite3"); + let store = SqliteChainStore::open(&chain_path).unwrap(); + let alice = Wallet::from_seed("offline-join-alice"); + let persisted = ledger_with_one_mined_block(&alice); + store.save(&persisted.snapshot()).unwrap(); + let bob = Wallet::from_seed("offline-join-bob"); + let opts = parse(&[ + "--join", + "127.0.0.1:1", + "--chain-db", + chain_path.to_str().unwrap(), + ]) + .unwrap() + .unwrap(); + + let resumed = initialize_ledger(&opts, bob.address(), &store, opts.p2p_addr) + .await + .unwrap(); + + assert_eq!(resumed.status().height, 1); + assert_eq!(resumed.status().tip_hash, persisted.status().tip_hash); +} + +#[tokio::test] +async fn invalid_persisted_chain_is_reported_and_never_replaced() { + let dir = tempdir().unwrap(); + let chain_path = dir.path().join("chain.sqlite3"); + let store = SqliteChainStore::open(&chain_path).unwrap(); + let connection = Connection::open(&chain_path).unwrap(); + connection + .execute( + r#" +INSERT INTO chain_snapshots (id, height, tip_hash, snapshot_blob, updated_at_ms) +VALUES (1, 4, 'bad-tip', x'00010203', 0) +"#, + [], + ) + .unwrap(); + let wallet = Wallet::from_seed("bad-db-wallet"); + let opts = parse(&["--genesis", "--chain-db", chain_path.to_str().unwrap()]) + .unwrap() + .unwrap(); + + let error = initialize_ledger(&opts, wallet.address(), &store, opts.p2p_addr) + .await + .unwrap_err(); + + assert!( + format!("{error:#}").contains("failed to parse compact chain snapshot from database"), + "{error:#}" + ); +} + +#[tokio::test] +async fn persistence_loop_saves_new_tip_after_node_changes() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("background-persistence"); + let ledger = ledger_with_one_spendable_iuna(&wallet); + let node = Arc::new(Mutex::new(NodeCore::from_ledger( + wallet.clone(), + ledger, + DEFAULT_BURN_PER_BLOCK, + ))); + let initial_snapshot = { node.lock().await.chain_snapshot() }; + persist_chain_snapshot(&store, initial_snapshot, false) + .await + .unwrap(); + let ui_config = Arc::new(Mutex::new(UiConfig::default())); + + let persistence_task = tokio::spawn(run_chain_persistence_with_interval( + Arc::clone(&node), + store.clone(), + ui_config, + Duration::from_millis(10), + )); + { + let mut node = node.lock().await; + let burn = node.ledger().build_burn(&wallet, 1, 0).unwrap(); + node.receive_transaction(burn).unwrap(); + node.mine_one_at(1_000).unwrap(); + } + + let expected_tip = node.lock().await.ledger().status().tip_hash; + let mut restored_tip = None; + for _ in 0..50 { + if let Some(snapshot) = store.load().unwrap() { + restored_tip = snapshot.blocks.last().map(|block| block.hash.clone()); + if restored_tip.as_deref() == Some(expected_tip.as_str()) { + break; + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + persistence_task.abort(); + + assert_eq!(restored_tip.as_deref(), Some(expected_tip.as_str())); +} + +#[tokio::test] +async fn persistence_loop_skips_setup_placeholder_chain() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("background-persistence-setup"); + let ledger = Ledger::new(BTreeMap::new(), 1); + let node = Arc::new(Mutex::new(NodeCore::from_ledger(wallet, ledger, 0))); + let ui_config = Arc::new(Mutex::new(UiConfig::default())); + + let persistence_task = tokio::spawn(run_chain_persistence_with_interval( + Arc::clone(&node), + store.clone(), + ui_config, + Duration::from_millis(10), + )); + tokio::time::sleep(Duration::from_millis(50)).await; + persistence_task.abort(); + + assert!(store.load().unwrap().is_none()); +}