iuna

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

commit 1d5c0bb2d85228eba9efcc69f9ce190bb4dcaf3f
parent c1cdd4d481b432cae99412f048c339872ecd5d15
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 28 Jul 2026 20:07:08 +0200

Sync mempools through peer status

Diffstat:
Msrc/adapters/http.rs | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/adapters/p2p.rs | 285++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/app.rs | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/iuna.rs | 49++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 525 insertions(+), 29 deletions(-)

diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -95,6 +95,9 @@ struct NetworkHealthResponse { stale_peers: usize, banned_peers: usize, pending_transactions: usize, + mempool_known_peers: usize, + mempool_divergent_peers: usize, + mempool_missing_transactions: usize, last_error: Option<String>, } @@ -814,6 +817,18 @@ fn network_health_at( .iter() .filter(|peer| peer.is_banned_at(now_ms)) .count(); + let mempool_known_peers = peers + .iter() + .filter(|peer| peer.last_known_mempool_count.is_some()) + .count(); + let mempool_divergent_peers = peers + .iter() + .filter(|peer| peer.last_known_mempool_missing.unwrap_or(0) > 0) + .count(); + let mempool_missing_transactions = peers + .iter() + .map(|peer| peer.last_known_mempool_missing.unwrap_or(0)) + .sum(); let lag_blocks = best_known_height.saturating_sub(local_height); let last_error = peers.iter().rev().find_map(|peer| { peer.last_error @@ -828,6 +843,8 @@ fn network_health_at( "banned" } else if lag_blocks > 0 { "syncing" + } else if mempool_missing_transactions > 0 { + "mempool syncing" } else if failed_peers > 0 && healthy_peers == 0 { "peer errors" } else if stale_peers > 0 && healthy_peers == stale_peers { @@ -840,7 +857,10 @@ fn network_health_at( .to_string(); NetworkHealthResponse { - ok: !peers.is_empty() && lag_blocks == 0 && healthy_peers > stale_peers, + ok: !peers.is_empty() + && lag_blocks == 0 + && mempool_missing_transactions == 0 + && healthy_peers > stale_peers, state, local_height, best_known_height, @@ -853,6 +873,9 @@ fn network_health_at( stale_peers, banned_peers, pending_transactions: status.chain.pending_transactions, + mempool_known_peers, + mempool_divergent_peers, + mempool_missing_transactions, last_error, } } @@ -2148,6 +2171,9 @@ const INDEX_HTML: &str = r#"<!doctype html> <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">Peer Mempools</div><div class="peer-summary-value" x-text="networkHealth.mempool_known_peers ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Divergent</div><div class="peer-summary-value" x-text="networkHealth.mempool_divergent_peers ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">Missing Tx</div><div class="peer-summary-value" x-text="networkHealth.mempool_missing_transactions ?? '-'"></div></div> </div> </div> <div class="peer-summary"> @@ -2159,7 +2185,7 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> <div class="table-wrap"> <table> - <thead><tr><th>Status</th><th>Address</th><th>Direction</th><th>Last Contact</th><th>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> + <thead><tr><th>Status</th><th>Address</th><th>Direction</th><th>Last Contact</th><th>Ban</th><th>Score</th><th>Height</th><th>Delta</th><th>Tip</th><th>Mempool</th><th>Shared</th><th>Missing</th><th>Root</th><th>Sent</th><th>Received</th><th>Last Error</th><th>Actions</th></tr></thead> <tbody> <template x-for="peer in peers" :key="peer.address"> <tr> @@ -2172,13 +2198,17 @@ const INDEX_HTML: &str = r#"<!doctype html> <td x-text="peer.last_known_height ?? '-'"></td> <td x-text="peerHeightDelta(peer)"></td> <td><code x-text="short(peer.last_known_tip_hash)"></code></td> + <td x-text="peer.last_known_mempool_count ?? '-'"></td> + <td x-text="peer.last_known_mempool_shared ?? '-'"></td> + <td x-text="peer.last_known_mempool_missing ?? '-'"></td> + <td><code x-text="short(peer.last_known_mempool_root)"></code></td> <td x-text="peer.messages_sent"></td> <td x-text="peer.messages_received"></td> <td x-text="peer.last_error || peer.last_transaction_rejection || ''"></td> <td><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 x-show="peers.length === 0"><td colspan="13">No peers</td></tr> + <tr x-show="peers.length === 0"><td colspan="17">No peers</td></tr> </tbody> </table> </div> @@ -2209,6 +2239,11 @@ const INDEX_HTML: &str = r#"<!doctype html> <div class="metric"><div class="label">Tx Rejected Rx</div><div class="value" x-text="p2pMetrics.transactions_rejected_received ?? 0"></div></div> <div class="metric"><div class="label">Tx Retries</div><div class="value" x-text="p2pMetrics.transaction_retries_sent ?? 0"></div></div> <div class="metric"><div class="label">Tx Ack Pending</div><div class="value" x-text="p2pMetrics.transaction_ack_pending ?? 0"></div></div> + <div class="metric"><div class="label">Mempool Status Rx</div><div class="value" x-text="p2pMetrics.mempool_statuses_received ?? 0"></div></div> + <div class="metric"><div class="label">Mempool Tx Seen</div><div class="value" x-text="p2pMetrics.mempool_status_transactions_received ?? 0"></div></div> + <div class="metric"><div class="label">Mempool Mismatch</div><div class="value" x-text="p2pMetrics.mempool_status_mismatches ?? 0"></div></div> + <div class="metric"><div class="label">Mempool Requests</div><div class="value" x-text="p2pMetrics.mempool_transaction_requests_sent ?? 0"></div></div> + <div class="metric"><div class="label">Mempool Requested Tx</div><div class="value" x-text="p2pMetrics.mempool_transaction_request_signatures_sent ?? 0"></div></div> </div> <div class="metric-context"> <div class="tx-field"><span class="tx-label">Last Failure</span><span class="tx-value text" x-text="p2pMetrics.last_session_failure || '-'"></span></div> @@ -2770,6 +2805,16 @@ mod tests { assert_eq!(isolated.local_height, 0); assert_eq!(isolated.best_known_height, 0); + let mut mempool_peers = PeerBook::from_addresses(vec!["127.0.0.1:9444".to_string()]); + mempool_peers.record_status("127.0.0.1:9444", 0, "tip".to_string()); + mempool_peers.record_mempool_status("127.0.0.1:9444", 2, "remote-root".to_string(), 1, 1); + let mempool_syncing = super::network_health(&status, &mempool_peers.list()); + assert!(!mempool_syncing.ok); + assert_eq!(mempool_syncing.state, "mempool syncing"); + assert_eq!(mempool_syncing.mempool_known_peers, 1); + assert_eq!(mempool_syncing.mempool_divergent_peers, 1); + assert_eq!(mempool_syncing.mempool_missing_transactions, 1); + let syncing = super::network_health( &status, &[PeerInfo { @@ -2779,6 +2824,11 @@ mod tests { messages_received: 1, last_known_height: Some(3), last_known_tip_hash: Some("remote-tip".to_string()), + last_known_mempool_count: None, + last_known_mempool_root: None, + last_known_mempool_shared: None, + last_known_mempool_missing: None, + last_mempool_status_ms: None, last_error: None, last_transaction_rejection: None, last_contact_ms: Some(10_000), @@ -2804,6 +2854,11 @@ mod tests { messages_received: 0, last_known_height: None, last_known_tip_hash: None, + last_known_mempool_count: None, + last_known_mempool_root: None, + last_known_mempool_shared: None, + last_known_mempool_missing: None, + last_mempool_status_ms: None, last_error: Some("connection refused".to_string()), last_transaction_rejection: None, last_contact_ms: Some(10_000), @@ -2831,6 +2886,11 @@ mod tests { messages_received: 1, last_known_height: Some(0), last_known_tip_hash: Some("tip".to_string()), + last_known_mempool_count: None, + last_known_mempool_root: None, + last_known_mempool_shared: None, + last_known_mempool_missing: None, + last_mempool_status_ms: None, last_error: None, last_transaction_rejection: Some( "peer rejected transaction abc: conflict".to_string(), @@ -2858,6 +2918,11 @@ mod tests { messages_received: 1, last_known_height: Some(0), last_known_tip_hash: Some("tip".to_string()), + last_known_mempool_count: None, + last_known_mempool_root: None, + last_known_mempool_shared: None, + last_known_mempool_missing: None, + last_mempool_status_ms: None, last_error: None, last_transaction_rejection: None, last_contact_ms: Some(1), @@ -2883,6 +2948,11 @@ mod tests { messages_received: 0, last_known_height: None, last_known_tip_hash: None, + last_known_mempool_count: None, + last_known_mempool_root: None, + last_known_mempool_shared: None, + last_known_mempool_missing: None, + last_mempool_status_ms: None, last_error: Some("invalid block".to_string()), last_transaction_rejection: None, last_contact_ms: Some(10), diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs @@ -23,8 +23,8 @@ use tokio::{ use crate::{ app::{ - BlockInventory, GossipEnvelope, NETWORK_ID, NodeCore, PROTOCOL_VERSION, ProtocolHello, - SharedNode, SharedPeerBook, TransactionRejection, + BlockInventory, GossipEnvelope, MEMPOOL_STATUS_LIMIT, NETWORK_ID, NodeCore, + PROTOCOL_VERSION, ProtocolHello, SharedNode, SharedPeerBook, TransactionRejection, }, domain::{Block, ChainSnapshot, Ledger, Transaction, TransactionSubmitOutcome, verify_vdf}, }; @@ -50,6 +50,9 @@ static NEXT_NODE_ID: AtomicU64 = AtomicU64::new(1); struct PeerStatus { height: u64, tip_hash: String, + mempool_count: usize, + mempool_root: String, + mempool_txs: Vec<String>, request_snapshot: bool, push_snapshot: bool, } @@ -59,6 +62,27 @@ impl PeerStatus { Self { height, tip_hash, + mempool_count: 0, + mempool_root: String::new(), + mempool_txs: Vec::new(), + request_snapshot: false, + push_snapshot: false, + } + } + + fn from_envelope( + height: u64, + tip_hash: String, + mempool_count: usize, + mempool_root: String, + mempool_txs: Vec<String>, + ) -> Self { + Self { + height, + tip_hash, + mempool_count, + mempool_root, + mempool_txs, request_snapshot: false, push_snapshot: false, } @@ -68,6 +92,9 @@ impl PeerStatus { Self { height, tip_hash, + mempool_count: 0, + mempool_root: String::new(), + mempool_txs: Vec::new(), request_snapshot: true, push_snapshot: false, } @@ -77,6 +104,9 @@ impl PeerStatus { Self { height, tip_hash, + mempool_count: 0, + mempool_root: String::new(), + mempool_txs: Vec::new(), request_snapshot: false, push_snapshot: true, } @@ -137,6 +167,11 @@ struct P2pMetricsCounters { transactions_rejected_sent: AtomicU64, transactions_rejected_received: AtomicU64, transaction_retries_sent: AtomicU64, + mempool_statuses_received: AtomicU64, + mempool_status_transactions_received: AtomicU64, + mempool_status_mismatches: AtomicU64, + mempool_transaction_requests_sent: AtomicU64, + mempool_transaction_request_signatures_sent: AtomicU64, last_session_failure: StdMutex<Option<String>>, last_empty_frame_remote: StdMutex<Option<String>>, last_parse_error: StdMutex<Option<String>>, @@ -172,6 +207,11 @@ pub struct P2pMetrics { pub transactions_rejected_sent: u64, pub transactions_rejected_received: u64, pub transaction_retries_sent: u64, + pub mempool_statuses_received: u64, + pub mempool_status_transactions_received: u64, + pub mempool_status_mismatches: u64, + pub mempool_transaction_requests_sent: u64, + pub mempool_transaction_request_signatures_sent: u64, pub transaction_ack_pending: u64, pub last_session_failure: Option<String>, pub last_empty_frame_remote: Option<String>, @@ -233,6 +273,17 @@ impl P2pMetricsCounters { .transactions_rejected_received .load(Ordering::Relaxed), transaction_retries_sent: self.transaction_retries_sent.load(Ordering::Relaxed), + mempool_statuses_received: self.mempool_statuses_received.load(Ordering::Relaxed), + mempool_status_transactions_received: self + .mempool_status_transactions_received + .load(Ordering::Relaxed), + mempool_status_mismatches: self.mempool_status_mismatches.load(Ordering::Relaxed), + mempool_transaction_requests_sent: self + .mempool_transaction_requests_sent + .load(Ordering::Relaxed), + mempool_transaction_request_signatures_sent: self + .mempool_transaction_request_signatures_sent + .load(Ordering::Relaxed), transaction_ack_pending: 0, last_session_failure: self .last_session_failure @@ -745,9 +796,31 @@ async fn session_loop( return Ok(()); } maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; - } else if let GossipEnvelope::PeerStatus { height, tip_hash } = envelope { - peer_status = Some(PeerStatus::new(height, tip_hash.clone())); - record_peer_status(&network, &known_peer, remote_addr, height, tip_hash).await; + } else if let GossipEnvelope::PeerStatus { + height, + tip_hash, + mempool_count, + mempool_root, + mempool_txs, + } = envelope + { + let status = PeerStatus::from_envelope( + height, + tip_hash, + mempool_count, + mempool_root, + mempool_txs, + ); + record_peer_status(&network, &known_peer, remote_addr, &status).await; + maybe_request_mempool_catchup( + &network, + &mut writer, + &known_peer, + remote_addr, + &status, + ) + .await?; + peer_status = Some(status); maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; } else { process_envelope( @@ -814,9 +887,25 @@ async fn session_loop( maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; continue; } - if let GossipEnvelope::PeerStatus { height, tip_hash } = &envelope { - peer_status = Some(PeerStatus::new(*height, tip_hash.clone())); - record_peer_status(&network, &known_peer, remote_addr, *height, tip_hash.clone()).await; + if let GossipEnvelope::PeerStatus { + height, + tip_hash, + mempool_count, + mempool_root, + mempool_txs, + } = &envelope + { + let status = PeerStatus::from_envelope( + *height, + tip_hash.clone(), + *mempool_count, + mempool_root.clone(), + mempool_txs.clone(), + ); + record_peer_status(&network, &known_peer, remote_addr, &status).await; + maybe_request_mempool_catchup(&network, &mut writer, &known_peer, remote_addr, &status) + .await?; + peer_status = Some(status); maybe_request_catchup(&network, &mut writer, peer_status.as_ref().unwrap()).await?; continue; } @@ -1109,6 +1198,77 @@ async fn maybe_request_catchup( Ok(()) } +async fn maybe_request_mempool_catchup( + network: &GossipNetwork, + writer: &mut OwnedWriteHalf, + known_peer: &Option<String>, + remote_addr: SocketAddr, + peer_status: &PeerStatus, +) -> Result<()> { + P2pMetricsCounters::inc(&network.inner.metrics.mempool_statuses_received); + P2pMetricsCounters::add( + &network.inner.metrics.mempool_status_transactions_received, + peer_status.mempool_txs.len() as u64, + ); + + let (local_root, local_inventory, requests) = { + let node = network.inner.node.lock().await; + ( + node.mempool_root(), + node.mempool_inventory(MEMPOOL_STATUS_LIMIT), + node.missing_inventory_requests(&peer_status.mempool_txs, &[]), + ) + }; + let local_txs = local_inventory.into_iter().collect::<BTreeSet<_>>(); + let shared = peer_status + .mempool_txs + .iter() + .filter(|signature| local_txs.contains(*signature)) + .count(); + let missing = peer_status.mempool_txs.len().saturating_sub(shared); + + if peer_status.mempool_root != local_root { + P2pMetricsCounters::inc(&network.inner.metrics.mempool_status_mismatches); + } + record_peer_mempool_status( + network, + known_peer, + remote_addr, + peer_status, + shared, + missing, + ) + .await; + + let requested_signatures = requests + .iter() + .map(|envelope| match envelope { + GossipEnvelope::TransactionRequest { signatures } => signatures.len(), + _ => 0, + }) + .sum::<usize>(); + if requested_signatures > 0 { + write_payload(writer, &requests).await?; + P2pMetricsCounters::inc(&network.inner.metrics.mempool_transaction_requests_sent); + P2pMetricsCounters::add( + &network + .inner + .metrics + .mempool_transaction_request_signatures_sent, + requested_signatures as u64, + ); + if let Some(peer) = known_peer { + network + .inner + .peers + .lock() + .await + .record_sent(peer, requests.len() as u64); + } + } + Ok(()) +} + async fn push_catchup_to_peer( network: &GossipNetwork, writer: &mut OwnedWriteHalf, @@ -1429,8 +1589,10 @@ fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> { GossipEnvelope::PeerList { peers } => { ensure_len("peer list", peers.len(), MAX_PEER_LIST)?; } + GossipEnvelope::PeerStatus { mempool_txs, .. } => { + ensure_len("mempool status", mempool_txs.len(), MAX_INVENTORY_ITEMS)?; + } GossipEnvelope::Hello(_) - | GossipEnvelope::PeerStatus { .. } | GossipEnvelope::ChainSnapshotRequest | GossipEnvelope::Transaction(_) | GossipEnvelope::Block(_) @@ -1551,7 +1713,19 @@ async fn fetch_peer_status(peer: &str) -> Result<PeerStatus> { } Ok(PeerStatus::new(hello.height, hello.tip_hash)) } - GossipEnvelope::PeerStatus { height, tip_hash } => Ok(PeerStatus::new(height, tip_hash)), + GossipEnvelope::PeerStatus { + height, + tip_hash, + mempool_count, + mempool_root, + mempool_txs, + } => Ok(PeerStatus::from_envelope( + height, + tip_hash, + mempool_count, + mempool_root, + mempool_txs, + )), other => anyhow::bail!("peer {peer} sent {other:?} instead of peer status"), } } @@ -1714,16 +1888,14 @@ async fn record_peer_status( network: &GossipNetwork, known_peer: &Option<String>, remote_addr: SocketAddr, - height: u64, - tip_hash: String, + peer_status: &PeerStatus, ) { if let Some(peer) = known_peer { - network - .inner - .peers - .lock() - .await - .record_status(peer, height, tip_hash); + network.inner.peers.lock().await.record_status( + peer, + peer_status.height, + peer_status.tip_hash.clone(), + ); } else { network .inner @@ -1734,6 +1906,34 @@ async fn record_peer_status( } } +async fn record_peer_mempool_status( + network: &GossipNetwork, + known_peer: &Option<String>, + remote_addr: SocketAddr, + peer_status: &PeerStatus, + shared: usize, + missing: usize, +) { + let mut peers = network.inner.peers.lock().await; + if let Some(peer) = known_peer { + peers.record_mempool_status( + peer, + peer_status.mempool_count, + peer_status.mempool_root.clone(), + shared, + missing, + ); + } else { + peers.record_inbound_mempool_status( + &remote_addr.to_string(), + peer_status.mempool_count, + peer_status.mempool_root.clone(), + shared, + missing, + ); + } +} + async fn process_hello( network: &GossipNetwork, remote_addr: SocketAddr, @@ -1795,8 +1995,7 @@ async fn process_hello( network, known_peer, remote_addr, - hello.height, - hello.tip_hash.clone(), + &PeerStatus::new(hello.height, hello.tip_hash.clone()), ) .await; if request_snapshot { @@ -2132,6 +2331,38 @@ mod tests { } #[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(), + mempool_count: 0, + mempool_root: String::new(), + mempool_txs: Vec::new(), + } + ); + } + + #[test] + fn oversized_mempool_status_is_rejected_before_processing() { + let envelope = GossipEnvelope::PeerStatus { + height: 7, + tip_hash: "tip".to_string(), + mempool_count: MAX_INVENTORY_ITEMS + 1, + mempool_root: "root".to_string(), + mempool_txs: vec!["sig".to_string(); MAX_INVENTORY_ITEMS + 1], + }; + + let error = validate_envelope_limits(&envelope).unwrap_err(); + + assert!(error.to_string().contains("mempool status")); + } + + #[test] fn received_envelope_metrics_are_categorized() { let metrics = super::P2pMetricsCounters::default(); @@ -2140,6 +2371,9 @@ mod tests { &GossipEnvelope::PeerStatus { height: 7, tip_hash: "tip".to_string(), + mempool_count: 0, + mempool_root: String::new(), + mempool_txs: Vec::new(), }, ); super::record_received_envelope_kind( @@ -2176,6 +2410,9 @@ mod tests { let line = serde_json::to_string(&GossipEnvelope::PeerStatus { height: 7, tip_hash: "tip".to_string(), + mempool_count: 0, + mempool_root: String::new(), + mempool_txs: Vec::new(), }) .unwrap(); let split_at = line.len() / 2; @@ -2883,8 +3120,7 @@ mod tests { &network, &None, "127.0.0.1:51729".parse().unwrap(), - 4, - "tip".to_string(), + &super::PeerStatus::new(4, "tip".to_string()), ) .await; @@ -2902,7 +3138,10 @@ mod tests { "127.0.0.1:9544", GossipEnvelope::PeerStatus { height: 0, - tip_hash: "tip".to_string() + tip_hash: "tip".to_string(), + mempool_count: 0, + mempool_root: String::new(), + mempool_txs: Vec::new(), } ) .unwrap() diff --git a/src/app.rs b/src/app.rs @@ -1,5 +1,5 @@ use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; @@ -12,7 +12,7 @@ use tokio::sync::Mutex; use crate::domain::{ Amount, Block, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE, Ledger, MINE_FINALIZER_FEE, OutPoint, PreparedBlock, StratumMineShare, StratumMineTemplate, - Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, hex_hash, run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -23,6 +23,7 @@ pub const DEFAULT_VDF_ROUNDS: u32 = 67_000_000; pub const PROTOCOL_VERSION: u32 = 1; pub const NETWORK_ID: &str = "iuna-devnet-v2"; pub const BLOCK_REQUEST_LIMIT: usize = 128; +pub const MEMPOOL_STATUS_LIMIT: usize = 512; const IMPORT_REBROADCAST_LIMIT: usize = 128; pub const PEER_MISBEHAVIOR_BAN_SCORE: u32 = 3; pub const PEER_MISBEHAVIOR_BAN_MS: u64 = 10 * 60 * 1_000; @@ -81,6 +82,12 @@ pub enum GossipEnvelope { PeerStatus { height: u64, tip_hash: String, + #[serde(default)] + mempool_count: usize, + #[serde(default)] + mempool_root: String, + #[serde(default)] + mempool_txs: Vec<String>, }, ChainSnapshotRequest, BlockRangeRequest { @@ -360,6 +367,36 @@ impl NodeCore { self.ledger.pending().to_vec() } + pub fn mempool_inventory(&self, limit: usize) -> Vec<String> { + let mut signatures = self + .ledger + .pending() + .iter() + .map(|tx| tx.signature().to_string()) + .collect::<BTreeSet<_>>() + .into_iter() + .collect::<Vec<_>>(); + signatures.truncate(limit); + signatures + } + + pub fn mempool_count(&self) -> usize { + self.ledger.pending().len() + } + + pub fn mempool_root(&self) -> String { + mempool_root_for_signatures( + &self + .ledger + .pending() + .iter() + .map(|tx| tx.signature().to_string()) + .collect::<BTreeSet<_>>() + .into_iter() + .collect::<Vec<_>>(), + ) + } + pub fn mempool_gossip(&self) -> Vec<GossipEnvelope> { let transactions = self.ledger.pending().to_vec(); if transactions.is_empty() { @@ -388,9 +425,13 @@ impl NodeCore { pub fn peer_status(&self) -> GossipEnvelope { let status = self.ledger.status(); + let mempool_txs = self.mempool_inventory(MEMPOOL_STATUS_LIMIT); GossipEnvelope::PeerStatus { height: status.height, tip_hash: status.tip_hash, + mempool_count: self.mempool_count(), + mempool_root: self.mempool_root(), + mempool_txs, } } @@ -1198,6 +1239,22 @@ impl PeerBook { .last_known_tip_hash .clone() .or(from_peer.last_known_tip_hash); + to_peer.last_known_mempool_count = to_peer + .last_known_mempool_count + .or(from_peer.last_known_mempool_count); + to_peer.last_known_mempool_root = to_peer + .last_known_mempool_root + .clone() + .or(from_peer.last_known_mempool_root); + to_peer.last_known_mempool_shared = to_peer + .last_known_mempool_shared + .or(from_peer.last_known_mempool_shared); + to_peer.last_known_mempool_missing = to_peer + .last_known_mempool_missing + .or(from_peer.last_known_mempool_missing); + to_peer.last_mempool_status_ms = to_peer + .last_mempool_status_ms + .max(from_peer.last_mempool_status_ms); 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); @@ -1291,6 +1348,66 @@ impl PeerBook { } } + pub fn record_mempool_status( + &mut self, + address: &str, + mempool_count: usize, + mempool_root: String, + mempool_shared: usize, + mempool_missing: usize, + ) { + self.record_mempool_status_with_direction( + address, + PeerDirection::Outbound, + mempool_count, + mempool_root, + mempool_shared, + mempool_missing, + ); + } + + pub fn record_inbound_mempool_status( + &mut self, + address: &str, + mempool_count: usize, + mempool_root: String, + mempool_shared: usize, + mempool_missing: usize, + ) { + self.record_mempool_status_with_direction( + address, + PeerDirection::Inbound, + mempool_count, + mempool_root, + mempool_shared, + mempool_missing, + ); + } + + fn record_mempool_status_with_direction( + &mut self, + address: &str, + direction: PeerDirection, + mempool_count: usize, + mempool_root: String, + mempool_shared: usize, + mempool_missing: usize, + ) { + let now = now_ms(); + let peer = self.ensure(address, direction); + peer.last_known_mempool_count = Some(mempool_count); + peer.last_known_mempool_root = Some(mempool_root); + peer.last_known_mempool_shared = Some(mempool_shared); + peer.last_known_mempool_missing = Some(mempool_missing); + peer.last_mempool_status_ms = Some(now); + peer.last_contact_ms = Some(now); + peer.last_success_ms = Some(now); + if !peer.is_banned_at(now) { + peer.last_error = None; + peer.clear_misbehavior(); + } + } + pub fn record_error(&mut self, address: &str, error: impl Into<String>) { let now = now_ms(); let peer = self.ensure(address, PeerDirection::Outbound); @@ -1395,6 +1512,16 @@ pub struct PeerInfo { pub messages_received: u64, pub last_known_height: Option<u64>, pub last_known_tip_hash: Option<String>, + #[serde(default)] + pub last_known_mempool_count: Option<usize>, + #[serde(default)] + pub last_known_mempool_root: Option<String>, + #[serde(default)] + pub last_known_mempool_shared: Option<usize>, + #[serde(default)] + pub last_known_mempool_missing: Option<usize>, + #[serde(default)] + pub last_mempool_status_ms: Option<u64>, pub last_error: Option<String>, pub last_transaction_rejection: Option<String>, pub last_contact_ms: Option<u64>, @@ -1415,6 +1542,11 @@ impl PeerInfo { messages_received: 0, last_known_height: None, last_known_tip_hash: None, + last_known_mempool_count: None, + last_known_mempool_root: None, + last_known_mempool_shared: None, + last_known_mempool_missing: None, + last_mempool_status_ms: None, last_error: None, last_transaction_rejection: None, last_contact_ms: None, @@ -1453,6 +1585,14 @@ pub fn now_ms() -> u64 { .as_millis() as u64 } +pub fn mempool_root_for_signatures(signatures: &[String]) -> String { + if signatures.is_empty() { + String::new() + } else { + hex_hash(format!("iuna-mempool-root:{}", signatures.join("|"))) + } +} + fn auto_pow_salt(wallet_address: &str, anchor: &str) -> u64 { let digest = Sha256::digest(format!("iuna-auto-pow:{wallet_address}:{anchor}").as_bytes()); let mut bytes = [0_u8; 8]; diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -2,7 +2,10 @@ use std::collections::BTreeMap; use iuna::{ adapters::chain_store::SqliteChainStore, - app::{DEFAULT_BURN_PER_BLOCK, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, PeerDirection}, + app::{ + DEFAULT_BURN_PER_BLOCK, GossipEnvelope, InMemoryNetwork, NodeConfig, NodeCore, PeerBook, + PeerDirection, + }, domain::{ Amount, BLOCK_REWARD, DEFAULT_FEE_PER_BYTE, GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_IUNA, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf, @@ -1663,6 +1666,41 @@ fn mempool_gossip_repairs_future_nonce_gap_without_networking() { } #[test] +fn peer_status_advertises_mempool_and_drives_missing_transaction_request() { + let alice = Wallet::from_seed("mempool-status-alice"); + let bob = Wallet::from_seed("mempool-status-bob"); + let wallets = vec![alice.clone(), bob.clone()]; + let allocations = allocations(&wallets, 1_000); + let mut alice_node = node("alice", alice, allocations.clone()); + let bob_node = node("bob", bob, allocations); + + let tx = alice_node.burn(1).unwrap(); + let signature = tx.signature().to_string(); + + let GossipEnvelope::PeerStatus { + mempool_count, + mempool_root, + mempool_txs, + .. + } = alice_node.peer_status() + else { + panic!("expected peer status"); + }; + + assert_eq!(mempool_count, 1); + assert!(!mempool_root.is_empty()); + assert_eq!(mempool_txs, vec![signature.clone()]); + + let requests = bob_node.missing_inventory_requests(&mempool_txs, &[]); + assert_eq!( + requests, + vec![GossipEnvelope::TransactionRequest { + signatures: vec![signature] + }] + ); +} + +#[test] fn received_block_is_rebroadcast_to_other_peers_without_networking() { let names = ["alice", "bob", "carol"]; let wallets = wallets(&names); @@ -1776,6 +1814,7 @@ fn peer_book_tracks_multiple_peers_without_networking() { peers.record_sent("127.0.0.1:9444", 2); peers.record_status("127.0.0.1:9444", 12, "tip-hash".to_string()); + peers.record_mempool_status("127.0.0.1:9444", 3, "mempool-root".to_string(), 2, 1); peers.record_error("127.0.0.1:9445", "connection refused"); peers.record_received("127.0.0.1:9555", 1); peers.record_inbound_error("127.0.0.1:56666", "invalid nonce"); @@ -1797,6 +1836,14 @@ fn peer_book_tracks_multiple_peers_without_networking() { assert_eq!(sent_peer.messages_sent, 2); assert_eq!(sent_peer.last_known_height, Some(12)); assert_eq!(sent_peer.last_known_tip_hash.as_deref(), Some("tip-hash")); + assert_eq!(sent_peer.last_known_mempool_count, Some(3)); + assert_eq!( + sent_peer.last_known_mempool_root.as_deref(), + Some("mempool-root") + ); + assert_eq!(sent_peer.last_known_mempool_shared, Some(2)); + assert_eq!(sent_peer.last_known_mempool_missing, Some(1)); + assert!(sent_peer.last_mempool_status_ms.is_some()); assert_eq!(sent_peer.last_error, None); assert!(sent_peer.last_contact_ms.is_some()); assert!(sent_peer.last_success_ms.is_some());