commit c2d08cb3ab0629002aae90639eb48e74ad4bb527
parent aecdcb03e5f04bb500306f7ceb75705bbcedfd0d
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sun, 30 Aug 2026 06:28:53 +0200
Fix finalization stalls and committee grinding
Diffstat:
8 files changed, 249 insertions(+), 26 deletions(-)
diff --git a/docs/operator-playbooks.md b/docs/operator-playbooks.md
@@ -173,7 +173,9 @@ Avoid:
Height `1000` is a coordinated consensus activation. At that height, VDF seeds
start committing to block content and ticket draws stop using the final block
-hash. Transaction signatures and native and Stratum mine proofs also switch to
+hash. Burn-committee lineage draws make the same switch, closing the timestamp/
+block-hash grinding path for both leader and committee selection. Transaction
+signatures and native and Stratum mine proofs also switch to
chain-bound binary format v1. Rank `0` blocks also start requiring a strict
two-thirds committee quorum so their next rank `0` child can objectively certify
them. This preserves blocks, snapshots, and UTXOs below
diff --git a/docs/protocol.md b/docs/protocol.md
@@ -125,7 +125,7 @@ In the mainnet-candidate profile, tickets mature after `3` blocks and remain eli
The lottery draw for the next height is deterministic. Nodes rank all eligible burn tickets using the parent block hash, the parent VDF output, the target height, and the ticket amounts. More burned IUNA means more weight, but the winner is still drawn by the protocol.
-From height `1000`, leader selection uses the parent's VDF seed and VDF output instead of the parent's final block hash. Blocks at height `1000` and later commit the finalizer identity, mode, rank, reward, VDF rounds, leader ticket, transactions, and burn-bundle section into their VDF seed. Together these rules prevent a finalizer from completing one VDF and then cheaply varying transaction selection or the publication timestamp to grind the next leader. Ticket-block timestamps remain adjustable to the actual completion time, but they no longer influence the next lottery draw. Earlier candidate history retains the original parent-hash lottery rule.
+From height `1000`, leader and burn-committee selection use the parent's VDF seed and VDF output instead of the parent's final block hash. Blocks at height `1000` and later commit the finalizer identity, mode, rank, reward, VDF rounds, leader ticket, transactions, and burn-bundle section into their VDF seed. Together these rules prevent a finalizer from completing one VDF and then cheaply varying transaction selection or the publication timestamp to grind either the next leader or its committee. Ticket-block timestamps remain adjustable to the actual completion time, but they no longer influence either draw. Earlier candidate history retains the original parent-hash draw rules.
## Finalizing Blocks
@@ -311,14 +311,31 @@ The available committee size is the finalizer plus the selected non-finalizer co
Recovery blocks do not require burn-list signatures. They are the last liveness escape hatch after the ticket path has failed, so committee failure must not be able to stop the chain forever. Recovery is weaker for fairness and is not meant to be the normal block path.
-The ticket-block VDF seed is bound to the burn-list attestation hashes:
+Before height `1000`, burn-committee lineage draws use the parent block hash and
+VDF output. From height `1000`, they use the same ungrindable parent randomness
+as the leader lottery: the parent VDF seed and VDF output. The target height and
+committee slot are domain-separated inputs to each draw. This prevents a
+completed ticket VDF from being reused with different publication timestamps to
+search for a favorable next committee.
+
+Before height `1000`, the ticket-block VDF seed is bound to the burn-list
+attestation hashes:
`seed = hash(parent hash || height || attestation_hash[0] || ... || attestation_hash[4])`
-Recovery blocks additionally bind the block timestamp into the VDF seed:
+From height `1000`, ticket blocks additionally bind a content commitment that
+covers the finalizer, mode, rank, reward, VDF rounds, selected ticket,
+transactions, and burn-bundle section:
+
+`seed = hash(parent hash || height || content commitment || attestation_hash[0] || ... || attestation_hash[4])`
+
+Recovery blocks bind the block timestamp into the VDF seed both before and after
+activation, and add the same content commitment from height `1000`:
`seed = hash(parent hash || height || timestamp_ms || attestation_hash[0] || ... || attestation_hash[4])`
+`activated seed = hash(parent hash || height || timestamp_ms || content commitment || attestation_hash[0] || ... || attestation_hash[4])`
+
The burn-list attestation hashes are part of the VDF seed. This forces the finalizer to choose the included burn-attestation set before doing the delay work. After the VDF is computed, changing that attestation set changes the seed and invalidates the work.
Slot `0` uses a synthetic finalizer attestation hash derived from the parent, height, finalizer address, and the block's canonical deduplicated required burn list. Slots `1` through `4` use the signed burn-bundle hash or the fixed default hash when absent.
diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs
@@ -589,11 +589,25 @@ impl NodeCore {
let Some((height, burn)) = &self.local_block_anchor_burn else {
return (ledger, None);
};
- if *height == ledger.height()
- && !ledger.has_transaction(burn.signature())
- && ledger.submit_transaction(burn.clone()).is_ok()
- {
- return (ledger, Some(burn.signature().to_string()));
+ if *height == ledger.height() && !ledger.has_transaction(burn.signature()) {
+ // `submit_transaction` returns `Ok(false)` when another pending
+ // transaction already spends the anchor input. Prefer an existing
+ // pending burn from this wallet. Otherwise rebuild this cloned
+ // block-building mempool with the liveness anchor first.
+ if ledger.submit_transaction(burn.clone()).unwrap_or(false) {
+ return (ledger, Some(burn.signature().to_string()));
+ }
+ if ledger.pending().iter().any(|transaction| {
+ transaction.is_burn() && transaction.sender() == self.wallet.address()
+ }) {
+ return (ledger, None);
+ }
+ if ledger
+ .prioritize_transaction_for_block_building(burn.clone())
+ .unwrap_or(false)
+ {
+ return (ledger, Some(burn.signature().to_string()));
+ }
}
(ledger, None)
}
@@ -952,6 +966,81 @@ mod tests {
}
#[test]
+ fn conflicting_local_anchor_falls_back_to_pending_wallet_burn() {
+ let wallet = Wallet::from_seed("conflicting-local-anchor-wallet");
+ let ledger = funded_ledger(std::slice::from_ref(&wallet));
+ let pending_burn = ledger.build_burn(&wallet, 2, 1).unwrap();
+ let mut node =
+ NodeCore::from_ledger_with_burn_fee_and_enabled(wallet.clone(), ledger, true, 1, 1);
+ node.receive_transaction(pending_burn.clone()).unwrap();
+
+ node.prepare_automatic_burn(1).unwrap();
+ let local_anchor = node
+ .local_block_anchor_burn
+ .as_ref()
+ .map(|(_, burn)| burn)
+ .expect("selected finalizer should prepare a local anchor");
+ assert_ne!(local_anchor.signature(), pending_burn.signature());
+
+ let (candidate, required_burn_signature) = node.ledger_with_local_block_anchor();
+ assert_eq!(required_burn_signature, None);
+ assert!(
+ candidate
+ .pending()
+ .iter()
+ .any(|transaction| transaction.signature() == pending_burn.signature())
+ );
+
+ let prepared = node.prepare_next_block_with_local_anchor(1).unwrap();
+ let block = prepared.finish(&wallet, "test-vdf-output".to_string());
+ assert!(
+ block
+ .transactions
+ .iter()
+ .any(|transaction| transaction.signature() == pending_burn.signature())
+ );
+ }
+
+ #[test]
+ fn local_anchor_displaces_conflicting_pending_transfer_in_block_candidate() {
+ let wallet = Wallet::from_seed("priority-local-anchor-wallet");
+ let recipient = Wallet::from_seed("priority-local-anchor-recipient");
+ let ledger = funded_ledger(std::slice::from_ref(&wallet));
+ let pending_transfer = ledger
+ .build_transfer(&wallet, recipient.address(), 2, 1)
+ .unwrap();
+ let mut node =
+ NodeCore::from_ledger_with_burn_fee_and_enabled(wallet.clone(), ledger, true, 1, 1);
+ node.receive_transaction(pending_transfer.clone()).unwrap();
+
+ node.prepare_automatic_burn(1).unwrap();
+ let local_anchor_signature = node
+ .local_block_anchor_burn
+ .as_ref()
+ .map(|(_, burn)| burn.signature().to_string())
+ .expect("selected finalizer should prepare a local anchor");
+ let (candidate, required_burn_signature) = node.ledger_with_local_block_anchor();
+
+ assert_eq!(
+ required_burn_signature,
+ Some(local_anchor_signature.clone())
+ );
+ assert!(
+ candidate
+ .pending()
+ .iter()
+ .any(|transaction| transaction.signature() == local_anchor_signature)
+ );
+ assert!(
+ !candidate
+ .pending()
+ .iter()
+ .any(|transaction| transaction.signature() == pending_transfer.signature())
+ );
+ assert!(node.prepare_next_block_with_local_anchor(1).is_ok());
+ }
+
+ #[test]
fn automatic_finalization_disabled_node_still_publishes_committee_bundle() {
let wallet = Wallet::from_seed("disabled-finalizer-committee-wallet");
let mut allocations = BTreeMap::new();
diff --git a/src/domain/adversarial_tests.rs b/src/domain/adversarial_tests.rs
@@ -1824,10 +1824,12 @@ fn mini_select_weighted_lineage_index(
if total_weight == 0 {
return None;
}
- let seed = format!(
- "iuna-burn-lineage-draw-v1:{target_height}:{}:{}:{slot}",
- parent.hash, parent.vdf_output
- );
+ let parent_randomness = if target_height >= GRINDING_RESISTANCE_ACTIVATION_HEIGHT {
+ format!("{}:{}", parent.vdf_seed(), parent.vdf_output)
+ } else {
+ format!("{}:{}", parent.hash, parent.vdf_output)
+ };
+ let seed = format!("iuna-burn-lineage-draw-v1:{target_height}:{parent_randomness}:{slot}");
let digest = Sha256::digest(seed.as_bytes());
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest[..16]);
diff --git a/src/domain/ledger_mempool.rs b/src/domain/ledger_mempool.rs
@@ -16,6 +16,28 @@ impl Ledger {
Ok(self.submit_transaction_with_outcome(transaction)?.added())
}
+ /// Inserts a locally required block transaction ahead of the current
+ /// mempool, then rebuilds the pool around it. This is only used on a cloned
+ /// ledger while assembling a block; the node's public mempool is unchanged.
+ pub(crate) fn prioritize_transaction_for_block_building(
+ &mut self,
+ transaction: Transaction,
+ ) -> Result<bool> {
+ let mut displaced = std::mem::take(&mut self.pending);
+ displaced.append(&mut self.orphans);
+ self.pending_bytes = 0;
+ self.orphan_bytes = 0;
+
+ let added = self.submit_transaction(transaction)?;
+ if !added {
+ return Ok(false);
+ }
+ for candidate in displaced {
+ let _ = self.submit_transaction(candidate);
+ }
+ Ok(true)
+ }
+
pub(crate) fn reserve_transaction_inputs(&mut self, transaction: &Transaction) -> Result<()> {
self.validate_new_transaction(transaction)?;
let mut utxos = self.utxos.clone();
diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs
@@ -6,8 +6,8 @@ use sha2::{Digest, Sha256};
use super::genesis::balances_from_utxos;
use super::mine_policy::mine_anchor;
use super::ticket::{
- BurnTicket, apply_finalizer_ticket_effects, genesis_tickets, ranked_tickets_for_height,
- tickets_created_by_block,
+ BurnTicket, apply_finalizer_ticket_effects, draw_parent_randomness, genesis_tickets,
+ ranked_tickets_for_height, tickets_created_by_block,
};
use super::{
Amount, Block, BurnCommitteeMember, BurnLeaderRank, ChainSnapshot, ChainStatus, LaunchProfile,
@@ -50,10 +50,7 @@ pub(super) fn select_weighted_lineage_index(
if total_weight == 0 {
return None;
}
- let seed = format!(
- "iuna-burn-lineage-draw-v1:{target_height}:{}:{}:{slot}",
- parent.hash, parent.vdf_output
- );
+ let seed = lineage_committee_draw_seed(parent, target_height, slot);
let digest = Sha256::digest(seed.as_bytes());
let mut bytes = [0_u8; 16];
bytes.copy_from_slice(&digest[..16]);
@@ -68,6 +65,11 @@ pub(super) fn select_weighted_lineage_index(
None
}
+fn lineage_committee_draw_seed(parent: &Block, target_height: u64, slot: u8) -> String {
+ let parent_randomness = draw_parent_randomness(parent, target_height);
+ format!("iuna-burn-lineage-draw-v1:{target_height}:{parent_randomness}:{slot}")
+}
+
impl Ledger {
pub fn snapshot(&self) -> ChainSnapshot {
ChainSnapshot {
@@ -619,7 +621,9 @@ impl Ledger {
#[cfg(test)]
mod tests {
use super::*;
- use crate::domain::{BURN_COMMITTEE_SIZE, GenesisBurn, MICRO_IUNA, Wallet};
+ use crate::domain::{
+ BURN_COMMITTEE_SIZE, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, GenesisBurn, MICRO_IUNA, Wallet,
+ };
use proptest::prelude::*;
use proptest::test_runner::Config;
@@ -802,7 +806,7 @@ mod tests {
}
#[test]
- fn committee_draw_seed_has_a_fixed_parent_vdf_height_and_slot_vector() {
+ fn committee_draw_seed_has_a_fixed_legacy_parent_hash_height_and_slot_vector() {
let mut parent = Ledger::new(BTreeMap::new(), 1).tip().clone();
parent.hash = "a".repeat(64);
parent.vdf_output = "parent-vdf".to_string();
@@ -829,6 +833,40 @@ mod tests {
);
}
+ #[test]
+ fn committee_draw_stops_using_grindable_parent_hash_at_height_1000() {
+ let mut parent = Ledger::new(BTreeMap::new(), 1).tip().clone();
+ parent.height = GRINDING_RESISTANCE_ACTIVATION_HEIGHT - 1;
+ parent.hash = "1".repeat(64);
+ parent.vdf_output = "parent-vdf".to_string();
+ let mut alternate_hash = parent.clone();
+ alternate_hash.hash = "2".repeat(64);
+
+ assert_ne!(
+ lineage_committee_draw_seed(&parent, GRINDING_RESISTANCE_ACTIVATION_HEIGHT - 1, 1,),
+ lineage_committee_draw_seed(
+ &alternate_hash,
+ GRINDING_RESISTANCE_ACTIVATION_HEIGHT - 1,
+ 1,
+ )
+ );
+ assert_eq!(
+ lineage_committee_draw_seed(&parent, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 1),
+ lineage_committee_draw_seed(&alternate_hash, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 1,)
+ );
+
+ let mut alternate_output = parent;
+ alternate_output.vdf_output = "different-vdf".to_string();
+ assert_ne!(
+ lineage_committee_draw_seed(
+ &alternate_output,
+ GRINDING_RESISTANCE_ACTIVATION_HEIGHT,
+ 1,
+ ),
+ lineage_committee_draw_seed(&alternate_hash, GRINDING_RESISTANCE_ACTIVATION_HEIGHT, 1,)
+ );
+ }
+
proptest! {
#![proptest_config(Config { cases: 128, .. Config::default() })]
diff --git a/src/domain/ticket.rs b/src/domain/ticket.rs
@@ -75,12 +75,16 @@ fn weighted_ticket_draw(parent: &Block, target_height: u64, rank: u32, total_wei
u128::from_be_bytes(bytes) % total_weight
}
-fn ticket_draw_seed(parent: &Block, target_height: u64, rank: u32) -> String {
- let parent_randomness = if target_height >= GRINDING_RESISTANCE_ACTIVATION_HEIGHT {
+pub(super) fn draw_parent_randomness(parent: &Block, target_height: u64) -> String {
+ if target_height >= GRINDING_RESISTANCE_ACTIVATION_HEIGHT {
format!("{}:{}", parent.vdf_seed(), parent.vdf_output)
} else {
format!("{}:{}", parent.hash, parent.vdf_output)
- };
+ }
+}
+
+fn ticket_draw_seed(parent: &Block, target_height: u64, rank: u32) -> String {
+ let parent_randomness = draw_parent_randomness(parent, target_height);
if rank == 0 {
format!("iuna-ticket-draw:{}:{}", target_height, parent_randomness)
} else {
diff --git a/tests/properties.rs b/tests/properties.rs
@@ -76,7 +76,13 @@ async fn release_soak_auto_finalization_p2p_stratum_and_restarts() -> Result<()>
stratum_addr,
)
.await?;
- assert_stratum_serves_work(stratum_addr, wallets[1].address()).await?;
+ let stratum_worker = nodes[1]
+ .node
+ .lock()
+ .await
+ .wallet_receive_address()
+ .context("release-soak wallet must have a checksummed receive address")?;
+ assert_stratum_serves_work(stratum_addr, &stratum_worker).await?;
sleep(Duration::from_secs(2)).await;
for target_height in 1..=SOAK_BLOCKS {
@@ -190,12 +196,55 @@ async fn finalize_one_block(nodes: &[SoakNode], target_height: u64) -> Result<()
}
}
if tokio::time::Instant::now() >= deadline {
- bail!("no node finalized block {target_height}");
+ bail!(
+ "no node finalized block {target_height}:\n{}",
+ soak_diagnostics(nodes, target_height).await
+ );
}
sleep(Duration::from_millis(100)).await;
}
}
+async fn soak_diagnostics(nodes: &[SoakNode], target_height: u64) -> String {
+ let mut lines = Vec::new();
+ for (index, node) in nodes.iter().enumerate() {
+ let core = node.node.lock().await;
+ let status = core.status();
+ let rank = core
+ .burn_leader_ranks_for_block(target_height)
+ .ok()
+ .and_then(|ranks| {
+ ranks
+ .into_iter()
+ .find(|rank| rank.owner == core.wallet_address())
+ .map(|rank| rank.rank)
+ });
+ let pending = core.pending_transactions();
+ let pending_burns = pending.iter().filter(|tx| tx.is_burn()).count();
+ let metrics = node.network.metrics();
+ lines.push(format!(
+ "node {index}: height={}, tip={}, wallet_rank={rank:?}, leader={:?}, \
+ last_finalization={:?}, pending={} (burns={pending_burns}), \
+ burn_bundles_received={}, control_received={}, rejected_blocks={}, \
+ session_failures={}, last_session_failure={:?}, last_chain_error={:?}, \
+ sync_progress={:?}",
+ status.chain.height,
+ status.chain.tip_hash,
+ status.mining.current_leader,
+ status.mining.last_auto_finalization_status,
+ pending.len(),
+ metrics.burn_bundles_received,
+ metrics.control_envelopes_received,
+ metrics.rejected_blocks,
+ metrics.session_failures,
+ metrics.last_session_failure,
+ metrics.last_chain_payload_error,
+ node.network.sync_progress(),
+ ));
+ }
+ lines.join("\n")
+}
+
async fn prepare_and_broadcast(nodes: &[SoakNode], timestamp_ms: u64) -> Result<()> {
for node in nodes {
{