commit 71325b1bbad0f2ead41b859cf0b3172e71a7e8b4
parent 43044bbba40e6075b9eb5ace2e3545a211de1ce7
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Tue, 11 Aug 2026 06:12:29 +0200
Split large modules
Diffstat:
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 §ion.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 §ion.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 §ion.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 §ion.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, ¤t_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">→</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">→</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, ¤t_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 §ion.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 §ion.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 §ion.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 §ion.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());
+}