iuna

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

commit e4c2bc87a6d97d0b1e631a221635a0d290fce512
parent b79848a6dc620fd7642a9495d5a17ca1c0b96bd4
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 12 Aug 2026 20:30:29 +0200

Invalidate missed fallback tickets

Diffstat:
Mdocs/protocol.md | 2++
Msrc/domain.rs | 7+++++--
Msrc/domain/ledger_apply.rs | 2+-
Msrc/domain/ledger_queries.rs | 8+++++++-
Msrc/domain/tests.rs | 191++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/domain/ticket.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++------
6 files changed, 237 insertions(+), 27 deletions(-)

diff --git a/docs/protocol.md b/docs/protocol.md @@ -46,6 +46,8 @@ For each block height, eligible tickets are ranked: The selected finalizer must prove ownership of the selected ticket, respect its rank time slot, and run the required VDF work. A block is valid only if the finalizer matches its ranked ticket, carries the correct leader proof, has a valid timestamp for its rank, includes a valid VDF output, and follows the transaction selection rules. +Starting at height `300`, fallback finalization invalidates missed ticket opportunities. If a ticket block is finalized by rank `1` or higher, nodes invalidate all tickets ranked from `0` through the finalizing rank for that height. They also invalidate any other currently eligible tickets owned by those same addresses. Future tickets from those addresses that are not yet eligible remain pending. Rank `0` ticket blocks continue to consume only the winning ticket. + Every normal block must include at least one plaintext burn. A blinded transaction envelope does not satisfy that rule, because the finalizer and validators cannot know whether the encrypted payload is a burn until reveal. A node that may finalize prepares a local plaintext anchor burn for the next block from the finalizer wallet. This anchor burn is not gossiped as normal wallet traffic. This mandatory anchor burn is a liveness rule for the ticket pool, not a fairness rule for ticket distribution. It guarantees that normal block production keeps creating future tickets. Fairness against self-serving finalizers comes from blinded third-party burns. diff --git a/src/domain.rs b/src/domain.rs @@ -97,9 +97,12 @@ pub use stratum::{ 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}; +#[cfg(test)] +use ticket::{ + MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT, consume_leader_ticket, ranked_tickets_for_height, + ticket_is_eligible_for_height, +}; pub use transaction::{ BlindedReveal, BlindedTransaction, BuiltBlindedTransaction, MineSearchOutcome, OutPoint, OwnedBlindedTransaction, RevealedBlindedTransaction, Transaction, TxInput, TxOutput, diff --git a/src/domain/ledger_apply.rs b/src/domain/ledger_apply.rs @@ -155,7 +155,7 @@ impl Ledger { )); } let mut tickets = self.tickets.clone(); - apply_finalizer_ticket_effects(&block, &mut tickets)?; + apply_finalizer_ticket_effects(self.tip(), &block, &mut tickets)?; tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?); tickets.extend(tickets_created_by_transactions( block.height, diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -18,12 +18,13 @@ use super::{ }; fn apply_historical_ticket_block( + parent: &Block, block: &Block, launch_profile: &LaunchProfile, tickets: &mut Vec<BurnTicket>, active_blinded: &mut BTreeMap<String, ActiveBlindedTransaction>, ) -> Result<()> { - apply_finalizer_ticket_effects(block, tickets)?; + apply_finalizer_ticket_effects(parent, block, tickets)?; tickets.extend(tickets_created_by_block(block, launch_profile)?); let mut revealed_transactions = Vec::new(); for reveal in block.all_blinded_reveals() { @@ -150,7 +151,12 @@ impl Ledger { if block.height >= height { break; } + let block_parent = self + .chain + .get(next_block_index - 1) + .with_context(|| format!("missing parent block for height {}", block.height))?; apply_historical_ticket_block( + block_parent, block, &self.launch_profile, &mut tickets, diff --git a/src/domain/tests.rs b/src/domain/tests.rs @@ -1286,6 +1286,7 @@ fn pending_change_from_combined_utxos_can_fund_next_transaction() { #[test] fn winning_burn_ticket_is_consumed_even_when_window_remains() { + let parent = ticket_test_block(3, FinalizerMode::Ticket, 0, "parent"); let mut tickets = vec![ BurnTicket { id: "high-burn".to_string(), @@ -1302,37 +1303,193 @@ fn winning_burn_ticket_is_consumed_even_when_window_remains() { eligible_until_height: 7, }, ]; + let block = ticket_test_child(&parent, 4, "alice", 0, "high-burn"); + + consume_leader_ticket(&parent, &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" + ); +} + +fn ticket_test_block( + height: u64, + finalizer_mode: FinalizerMode, + finalizer_rank: u32, + seed: &str, +) -> Block { 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, + height, + prev_hash: hex_hash(format!("ticket-test-prev:{seed}:{height}")), + timestamp_ms: height, + miner: seed.to_string(), + finalizer_mode, + finalizer_rank, 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(), - }), + vdf_output: hex_hash(format!("ticket-test-vdf:{seed}:{height}")), + leader_proof: None, blinded_transactions: Vec::new(), reveal_bundle_section: RevealBundleSection::default(), transactions: Vec::new(), hash: String::new(), }; block.hash = block.compute_hash(); + block +} + +fn ticket_test_child( + parent: &Block, + height: u64, + miner: &str, + finalizer_rank: u32, + ticket_id: &str, +) -> Block { + let mut block = ticket_test_block(height, FinalizerMode::Ticket, finalizer_rank, miner); + block.prev_hash = parent.hash.clone(); + block.leader_proof = Some(LeaderProof { + ticket_id: ticket_id.to_string(), + public_key: miner.to_string(), + signature: "signature".to_string(), + }); + block.hash = block.compute_hash(); + block +} + +fn fallback_invalidation_tickets(height: u64) -> Vec<BurnTicket> { + vec![ + BurnTicket { + id: "alice-main".to_string(), + owner: "alice".to_string(), + amount: 10, + eligible_from_height: height, + eligible_until_height: height + 2, + }, + BurnTicket { + id: "alice-other-eligible".to_string(), + owner: "alice".to_string(), + amount: 7, + eligible_from_height: height, + eligible_until_height: height + 2, + }, + BurnTicket { + id: "bob-main".to_string(), + owner: "bob".to_string(), + amount: 9, + eligible_from_height: height, + eligible_until_height: height + 2, + }, + BurnTicket { + id: "bob-other-eligible".to_string(), + owner: "bob".to_string(), + amount: 6, + eligible_from_height: height, + eligible_until_height: height + 2, + }, + BurnTicket { + id: "carol-main".to_string(), + owner: "carol".to_string(), + amount: 8, + eligible_from_height: height, + eligible_until_height: height + 2, + }, + BurnTicket { + id: "future-same-owner".to_string(), + owner: "alice".to_string(), + amount: 5, + eligible_from_height: height + 1, + eligible_until_height: height + 3, + }, + ] +} - consume_leader_ticket(&block, &mut tickets).unwrap(); +#[test] +fn primary_ticket_at_activation_height_only_consumes_winning_ticket() { + let height = MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT; + let parent = ticket_test_block(height - 1, FinalizerMode::Ticket, 0, "primary-parent"); + let mut tickets = fallback_invalidation_tickets(height); + let ranked = ranked_tickets_for_height(&parent, height, &tickets); + let leader = &ranked[0]; + let leader_id = leader.id.clone(); + let leader_owner = leader.owner.clone(); + let eligible_before = tickets + .iter() + .filter(|ticket| ticket_is_eligible_for_height(ticket, height)) + .count(); + let block = ticket_test_child(&parent, height, &leader_owner, 0, &leader_id); + + consume_leader_ticket(&parent, &block, &mut tickets).unwrap(); + + assert!(tickets.iter().all(|ticket| ticket.id != leader_id)); + let eligible_after = tickets + .iter() + .filter(|ticket| ticket_is_eligible_for_height(ticket, height)) + .count(); + assert_eq!(eligible_after, eligible_before - 1); +} + +#[test] +fn fallback_ticket_before_activation_height_only_consumes_winning_ticket() { + let height = MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT - 1; + let parent = ticket_test_block( + height - 1, + FinalizerMode::Ticket, + 0, + "legacy-fallback-parent", + ); + let mut tickets = fallback_invalidation_tickets(height); + let ranked = ranked_tickets_for_height(&parent, height, &tickets); + let fallback = &ranked[1]; + let fallback_id = fallback.id.clone(); + let fallback_owner = fallback.owner.clone(); + let eligible_before = tickets + .iter() + .filter(|ticket| ticket_is_eligible_for_height(ticket, height)) + .count(); + let block = ticket_test_child(&parent, height, &fallback_owner, 1, &fallback_id); + + consume_leader_ticket(&parent, &block, &mut tickets).unwrap(); + + assert!(tickets.iter().all(|ticket| ticket.id != fallback_id)); + let eligible_after = tickets + .iter() + .filter(|ticket| ticket_is_eligible_for_height(ticket, height)) + .count(); + assert_eq!(eligible_after, eligible_before - 1); +} + +#[test] +fn fallback_ticket_invalidates_missed_ranks_and_current_sibling_tickets_from_height_300() { + let height = MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT; + let parent = ticket_test_block(height - 1, FinalizerMode::Ticket, 0, "fallback-parent"); + let mut tickets = fallback_invalidation_tickets(height); + let ranked = ranked_tickets_for_height(&parent, height, &tickets); + let prefix_owners = ranked + .iter() + .take(2) + .map(|ticket| ticket.owner.clone()) + .collect::<BTreeSet<_>>(); + let fallback = &ranked[1]; + let block = ticket_test_child(&parent, height, &fallback.owner, 1, &fallback.id); + + consume_leader_ticket(&parent, &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" + tickets.iter().all(|ticket| { + !ticket_is_eligible_for_height(ticket, height) || !prefix_owners.contains(&ticket.owner) + }), + "eligible tickets from missed ranks and the fallback finalizer should be invalidated" ); assert!( - tickets.iter().any(|ticket| ticket.id == "small-burn"), - "unselected future tickets should remain pending" + tickets + .iter() + .any(|ticket| ticket.id == "future-same-owner"), + "future tickets from invalidated owners should stay pending" ); } diff --git a/src/domain/ticket.rs b/src/domain/ticket.rs @@ -8,6 +8,8 @@ use super::{ hex_hash, }; +pub(super) const MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT: u64 = 300; + #[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct BurnTicket { pub(super) id: String, @@ -246,11 +248,12 @@ fn genesis_bootstrap_tickets( } pub(super) fn apply_finalizer_ticket_effects( + parent: &Block, block: &Block, tickets: &mut Vec<BurnTicket>, ) -> Result<()> { match block.finalizer_mode { - FinalizerMode::Ticket => consume_leader_ticket(block, tickets), + FinalizerMode::Ticket => consume_leader_ticket(parent, block, tickets), FinalizerMode::Recovery => { tickets.retain(|ticket| { !ticket_is_eligible_for_height(ticket, block.height) @@ -261,20 +264,59 @@ pub(super) fn apply_finalizer_ticket_effects( } } -pub(super) fn consume_leader_ticket(block: &Block, tickets: &mut Vec<BurnTicket>) -> Result<()> { +pub(super) fn consume_leader_ticket( + parent: &Block, + 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| { + if !tickets.iter().any(|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); + let invalidated = invalidated_ticket_ids(parent, block, tickets, &proof.ticket_id); + tickets.retain(|ticket| { + !invalidated.contains(&ticket.id) && ticket.eligible_until_height > block.height + }); Ok(()) } +fn invalidated_ticket_ids( + parent: &Block, + block: &Block, + tickets: &[BurnTicket], + leader_ticket_id: &str, +) -> std::collections::BTreeSet<String> { + let ranked_tickets = ranked_tickets_for_height(parent, block.height, tickets); + let Some(finalizer_index) = ranked_tickets + .iter() + .position(|ticket| ticket.id == leader_ticket_id) + else { + return [leader_ticket_id.to_string()].into(); + }; + if block.height < MISSED_FALLBACK_TICKET_INVALIDATION_HEIGHT || finalizer_index == 0 { + return [leader_ticket_id.to_string()].into(); + } + + let missed_and_finalizer_owners = ranked_tickets + .iter() + .take(finalizer_index + 1) + .map(|ticket| ticket.owner.clone()) + .collect::<std::collections::BTreeSet<_>>(); + + tickets + .iter() + .filter(|ticket| { + ticket_is_eligible_for_height(ticket, block.height) + && missed_and_finalizer_owners.contains(&ticket.owner) + }) + .map(|ticket| ticket.id.clone()) + .collect() +} + pub(super) fn ticket_is_eligible_for_height(ticket: &BurnTicket, height: u64) -> bool { ticket.eligible_from_height <= height && height <= ticket.eligible_until_height }