iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 989323a99a190463f6ca7b7deee67b0f9021922b
parent 673391570890bb9e2bed53c126bae299d198d9ff
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri, 21 Aug 2026 15:49:52 +0200

Request missing burn bundles over gossip

Diffstat:
Mdocs/operator-playbooks.md | 2+-
Mdocs/protocol.md | 9++++++++-
Msrc/adapters/p2p/line_codec.rs | 29++++++++++++++++++++++++++---
Msrc/adapters/p2p/process.rs | 15+++++++++++++++
Msrc/app.rs | 4++--
Msrc/app/automatic_mining.rs | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/app/receive.rs | 1+
Msrc/app/types.rs | 5+++++
Msrc/app/wallet.rs | 13+++++++++++++
9 files changed, 228 insertions(+), 10 deletions(-)

diff --git a/docs/operator-playbooks.md b/docs/operator-playbooks.md @@ -128,7 +128,7 @@ Symptoms: Checks: 1. Check `/api/mempool` for fee-paying burn transactions. -2. Check `/api/peers` for enough healthy peers. Burn bundles are gossip, so isolated nodes see fewer attestations. +2. Check `/api/peers` for enough healthy peers. Burn bundles are gossip; finalizers request missing burn-bundle slots during collection, but isolated nodes still cannot receive responses. 3. Check whether committee members are online and unlocked if they are expected to sign bundles. 4. Inspect recent blocks: rank `0` needs the strictest burn-list attestation threshold; fallback ranks relax it for liveness. diff --git a/docs/protocol.md b/docs/protocol.md @@ -17,7 +17,7 @@ This is still experimental. The rules below describe the current devnet and main The current mainnet-candidate parameter set is intentionally close to Bitcoin where that is useful for operator expectations: - P2P network ID: `iuna-mainnet-candidate-v1`; -- protocol version: `1`; +- protocol version: `2`; - launch profile ID: `iuna-mainnet-candidate-v1`; - launch profile hash: `aef51531eaa3a5c5d3ea8a2524ffba029dcb106e4b0a432b57d5ac1f4f8963de`; - target block time: `10 minutes`; @@ -285,10 +285,17 @@ The P2P mempool gossips: - burns; - mine actions; - signed burn bundles; +- burn-bundle repair requests for a specific next-block height, parent hash, and committee slots; - block inventory and blocks. Anchor burns are prepared locally by the finalizer and are not normal wallet traffic. +When a ticket finalizer is collecting burn-bundle attestations and has fewer signatures than the +rank requires, it may gossip a bounded burn-bundle request for the missing committee slots. Peers +answer from their local cache with matching signed burn bundles for the requested height and parent +hash. The request/response path repairs missed gossip only; it does not change the block format, +attestation signatures, or VDF seed rules. + Nodes only keep transactions in their local mempool when they are valid, fee-paying, and unexpired. Pending transaction, burn-bundle, and orphan pools are bounded by both item count and serialized byte size. ## Block Selection diff --git a/src/adapters/p2p/line_codec.rs b/src/adapters/p2p/line_codec.rs @@ -4,7 +4,10 @@ use tokio::{ net::tcp::OwnedReadHalf, }; -use crate::app::{GossipEnvelope, TRANSACTION_BATCH_LIMIT}; +use crate::{ + app::{GossipEnvelope, TRANSACTION_BATCH_LIMIT}, + domain::BURN_COMMITTEE_SIZE, +}; use super::{ GossipNetwork, MAX_BLOCK_BATCH, MAX_GOSSIP_LINE_BYTES, MAX_INVENTORY_ITEMS, @@ -136,6 +139,7 @@ pub(super) fn record_received_envelope_kind( GossipEnvelope::ChainSnapshotRequest | GossipEnvelope::BlockRangeRequest { .. } | GossipEnvelope::BlockRequest { .. } + | GossipEnvelope::BurnBundleRequest { .. } | GossipEnvelope::PeerAnnouncement { .. } | GossipEnvelope::PeerVerificationChallenge { .. } | GossipEnvelope::PeerVerificationResponse { .. } @@ -175,6 +179,9 @@ pub(super) fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()> GossipEnvelope::BurnBundles { bundles } => { ensure_len("burn bundle batch", bundles.len(), TRANSACTION_BATCH_LIMIT)?; } + GossipEnvelope::BurnBundleRequest { slots, .. } => { + ensure_len("burn bundle request", slots.len(), BURN_COMMITTEE_SIZE)?; + } GossipEnvelope::Blocks { blocks } => { ensure_len("block batch", blocks.len(), MAX_BLOCK_BATCH)?; } @@ -212,8 +219,8 @@ mod tests { adapters::p2p::metrics::P2pMetricsCounters, app::{BlockInventory, GossipEnvelope, TRANSACTION_BATCH_LIMIT}, domain::{ - Block, BurnBundle, BurnBundleSection, ChainSnapshot, FinalizerMode, LaunchProfile, - OutPoint, Transaction, TxInput, TxOutput, + BURN_COMMITTEE_SIZE, Block, BurnBundle, BurnBundleSection, ChainSnapshot, + FinalizerMode, LaunchProfile, OutPoint, Transaction, TxInput, TxOutput, }, }; @@ -397,6 +404,22 @@ mod tests { .is_err() ); assert!( + validate_envelope_limits(&GossipEnvelope::BurnBundleRequest { + height: 1, + prev_hash: "0".repeat(64), + slots: vec![1; BURN_COMMITTEE_SIZE] + }) + .is_ok() + ); + assert!( + validate_envelope_limits(&GossipEnvelope::BurnBundleRequest { + height: 1, + prev_hash: "0".repeat(64), + slots: vec![1; BURN_COMMITTEE_SIZE + 1] + }) + .is_err() + ); + assert!( validate_envelope_limits(&GossipEnvelope::Blocks { blocks: vec![dummy_block(1); MAX_BLOCK_BATCH] }) diff --git a/src/adapters/p2p/process.rs b/src/adapters/p2p/process.rs @@ -100,6 +100,21 @@ pub(super) async fn process_envelope( GossipEnvelope::BurnBundles { bundles } => { process_burn_bundles(network, remote_addr, known_peer, bundles).await; } + GossipEnvelope::BurnBundleRequest { + height, + prev_hash, + slots, + } => { + let bundles = network + .inner + .node + .lock() + .await + .burn_bundles_for_request(height, &prev_hash, &slots); + if !bundles.is_empty() { + write_envelope(writer, &GossipEnvelope::BurnBundles { bundles }).await?; + } + } GossipEnvelope::Block(block) => { let adjusted_time_ms = super::network_adjusted_time_ms(network).await; let needs_vdf = { diff --git a/src/app.rs b/src/app.rs @@ -38,7 +38,7 @@ pub type SharedPeerBook = Arc<Mutex<PeerBook>>; pub const DEFAULT_BURN_PER_BLOCK: Amount = 0; pub const DEFAULT_VDF_ROUNDS: u32 = 67_000_000; -pub const PROTOCOL_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 2; pub const MAINNET_CANDIDATE_NETWORK_ID: &str = "iuna-mainnet-candidate-v1"; pub const MAINNET_NETWORK_ID: &str = "iuna-mainnet-v1"; pub const NETWORK_ID: &str = MAINNET_CANDIDATE_NETWORK_ID; @@ -66,7 +66,7 @@ mod tests { #[test] fn mainnet_candidate_network_parameters_are_frozen() { assert_eq!(DEFAULT_VDF_ROUNDS, 67_000_000); - assert_eq!(PROTOCOL_VERSION, 1); + assert_eq!(PROTOCOL_VERSION, 2); assert_eq!(MAINNET_CANDIDATE_NETWORK_ID, "iuna-mainnet-candidate-v1"); assert_eq!(MAINNET_NETWORK_ID, "iuna-mainnet-v1"); assert_ne!(MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID); diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -4,9 +4,10 @@ use super::helpers::{allowed_recovery_vdf_rank_count, recovery_vdf_sample_percen use super::{ AUTO_BLOCK_ANCHOR_BURN_AMOUNT, AUTO_BLOCK_ANCHOR_BURN_FEE, AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS, AutoMineOutcome, AutoMinePlan, - BURN_BUNDLE_COLLECTION_MS, Ledger, NodeCore, PreparedBlock, Transaction, run_vdf, + BURN_BUNDLE_COLLECTION_MS, GossipEnvelope, Ledger, NodeCore, PreparedBlock, Transaction, + run_vdf, }; -use crate::domain::{Amount, FinalizerMode}; +use crate::domain::{Amount, BurnCommitteeMember, FinalizerMode}; mod pow; @@ -98,6 +99,7 @@ impl NodeCore { timestamp_ms, will_run_ticket_vdf || will_run_recovery_vdf, ) { + self.request_missing_burn_bundles_for_next_block(timestamp_ms); plan.skipped_reason = Some(format!( "collecting burns for next block ({:.1}s remaining)", wait_ms as f64 / 1000.0 @@ -188,6 +190,7 @@ impl NodeCore { timestamp_ms, will_run_ticket_vdf || will_run_recovery_vdf, ) { + self.request_missing_burn_bundles_for_next_block(timestamp_ms); plan.skipped_reason = Some(format!( "collecting burns for next block ({:.1}s remaining)", wait_ms as f64 / 1000.0 @@ -479,6 +482,73 @@ impl NodeCore { .then(|| BURN_BUNDLE_COLLECTION_MS.saturating_sub(elapsed)) } + fn request_missing_burn_bundles_for_next_block(&mut self, timestamp_ms: u64) { + if self.should_prepare_recovery_vdf(timestamp_ms) { + return; + } + let (attestation_ledger, _) = self.ledger_with_local_block_anchor(); + let Some(finalizer_rank) = + attestation_ledger.finalizer_rank_for_next_block(self.wallet.address()) + else { + return; + }; + if !self.wallet_rank_runs_vdf(finalizer_rank) { + return; + } + let required = attestation_ledger.explicit_burn_bundle_signatures_required_for_next_block( + FinalizerMode::Ticket, + finalizer_rank, + self.wallet.address(), + ); + if required == 0 { + return; + } + + let present_slots = self + .usable_burn_bundles_for_finalizer_rank(finalizer_rank) + .into_iter() + .map(|bundle| bundle.slot) + .collect::<std::collections::BTreeSet<_>>(); + if present_slots.len() >= required { + return; + } + + self.enqueue_missing_burn_bundle_request( + self.ledger.height().saturating_add(1), + self.ledger.tip_hash().to_string(), + required, + attestation_ledger.burn_committee_for_next_ticket_block(finalizer_rank), + &present_slots, + ); + } + + fn enqueue_missing_burn_bundle_request( + &mut self, + height: u64, + prev_hash: String, + required: usize, + committee: Vec<BurnCommitteeMember>, + present_slots: &std::collections::BTreeSet<u8>, + ) { + if present_slots.len() >= required { + return; + } + let slots = committee + .into_iter() + .map(|member| member.slot) + .filter(|slot| *slot != 0 && !present_slots.contains(slot)) + .collect::<Vec<_>>(); + if slots.is_empty() { + return; + } + + self.outbox.push(GossipEnvelope::BurnBundleRequest { + height, + prev_hash, + slots, + }); + } + pub(super) fn prepare_next_block_with_local_anchor( &self, timestamp_ms: u64, @@ -547,7 +617,9 @@ mod tests { use crate::{ adapters::chain_store::SqliteChainStore, app::{GossipEnvelope, InMemoryNetwork}, - domain::{BurnBundle, GenesisBurn, Ledger, MICRO_IUNA, Wallet, run_vdf}, + domain::{ + BurnBundle, BurnCommitteeMember, GenesisBurn, Ledger, MICRO_IUNA, Wallet, run_vdf, + }, }; use tempfile::tempdir; @@ -876,6 +948,88 @@ mod tests { } #[test] + fn finalizer_requests_missing_burn_bundles_while_collecting() { + let wallet = Wallet::from_seed("missing-bundle-request-wallet"); + let ledger = funded_ledger(std::slice::from_ref(&wallet)); + let mut node = NodeCore::from_ledger(wallet, ledger, 0); + let present_slots = std::collections::BTreeSet::from([1]); + + node.enqueue_missing_burn_bundle_request( + 42, + "parent-hash".to_string(), + 2, + vec![ + BurnCommitteeMember { + slot: 0, + root: "root-0".to_string(), + owner: "finalizer".to_string(), + weight: 1, + }, + BurnCommitteeMember { + slot: 1, + root: "root-1".to_string(), + owner: "present".to_string(), + weight: 1, + }, + BurnCommitteeMember { + slot: 2, + root: "root-2".to_string(), + owner: "missing".to_string(), + weight: 1, + }, + ], + &present_slots, + ); + let request = node + .drain_outbox() + .into_iter() + .find_map(|envelope| match envelope { + GossipEnvelope::BurnBundleRequest { + height, + prev_hash, + slots, + } => Some((height, prev_hash, slots)), + _ => None, + }) + .expect("collecting finalizer should request missing burn bundles"); + + assert_eq!(request.0, 42); + assert_eq!(request.1, "parent-hash"); + assert_eq!(request.2, vec![2]); + } + + #[test] + fn burn_bundle_request_response_only_returns_matching_slots() { + let alice = Wallet::from_seed("bundle-request-response-alice"); + let bob = Wallet::from_seed("bundle-request-response-bob"); + let wallets = [alice.clone(), bob.clone()]; + let ledger = funded_ledger(&wallets); + let signer = selected_finalizer(&ledger, &wallets); + let mut node = NodeCore::from_ledger(signer.clone(), ledger.clone(), 0); + + node.publish_burn_bundle_for_next_block().unwrap(); + let bundle = node + .usable_burn_bundles() + .into_iter() + .find(|bundle| bundle.member == signer.address()) + .expect("signer should have a local burn bundle"); + + let matching = + node.burn_bundles_for_request(bundle.height, &bundle.prev_hash, &[bundle.slot]); + let wrong_parent = node.burn_bundles_for_request(bundle.height, "wrong-parent", &[]); + let wrong_slot = node.burn_bundles_for_request( + bundle.height, + &bundle.prev_hash, + &[bundle.slot.saturating_add(1)], + ); + + assert_eq!(matching.len(), 1); + assert_eq!(matching[0].canonical(), bundle.canonical()); + assert!(wrong_parent.is_empty()); + assert!(wrong_slot.is_empty()); + } + + #[test] fn burn_bundle_gossip_reaches_peer_with_matching_mempool() { let alice = Wallet::from_seed("bundle-gossip-alice"); let bob = Wallet::from_seed("bundle-gossip-bob"); diff --git a/src/app/receive.rs b/src/app/receive.rs @@ -75,6 +75,7 @@ impl NodeCore { } Ok(()) } + GossipEnvelope::BurnBundleRequest { .. } => Ok(()), GossipEnvelope::Block(block) => { let previous_height = self.ledger.height(); self.ledger.apply_block(block.clone())?; diff --git a/src/app/types.rs b/src/app/types.rs @@ -58,6 +58,11 @@ pub enum GossipEnvelope { BurnBundles { bundles: Vec<BurnBundle>, }, + BurnBundleRequest { + height: u64, + prev_hash: String, + slots: Vec<u8>, + }, Block(Block), Blocks { blocks: Vec<Block>, diff --git a/src/app/wallet.rs b/src/app/wallet.rs @@ -216,6 +216,19 @@ impl NodeCore { .collect() } + pub(crate) fn burn_bundles_for_request( + &self, + height: u64, + prev_hash: &str, + slots: &[u8], + ) -> Vec<BurnBundle> { + self.usable_burn_bundles() + .into_iter() + .filter(|bundle| bundle.height == height && bundle.prev_hash == prev_hash) + .filter(|bundle| slots.is_empty() || slots.contains(&bundle.slot)) + .collect() + } + pub(super) fn prune_burn_bundles(&mut self) { let height = self.ledger.height(); self.burn_bundles