commit 6b8bdc1bc5accc636238c7609326eb53631780f7
parent cef24ab982068d129428a00c46d102b597852421
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Sat, 1 Aug 2026 23:13:37 +0200
Add recovery finalization path
Diffstat:
6 files changed, 484 insertions(+), 53 deletions(-)
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -352,6 +352,7 @@ struct UiBlock {
prev_hash: String,
timestamp_ms: u64,
miner: String,
+ finalizer_mode: crate::domain::FinalizerMode,
finalizer_rank: u32,
reward: Amount,
total_fees: Amount,
@@ -1684,6 +1685,7 @@ fn ui_block(block: Block, outputs: &BTreeMap<OutPoint, TxOutput>) -> UiBlock {
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,
@@ -2838,7 +2840,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.block-card { flex-basis: 108px; }
}
</style>
- <script defer src="/assets/iuna-ui.js?v=69"></script>
+ <script defer src="/assets/iuna-ui.js?v=70"></script>
<script defer src="/assets/alpine.min.js"></script>
</head>
<body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak>
@@ -3270,7 +3272,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<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><code x-text="short(selectedBlock.miner)"></code></div>
- <div class="detail-kv"><div class="key">Rank</div><div x-text="selectedBlock.finalizer_rank ?? 0"></div></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>
@@ -4624,6 +4626,7 @@ mod tests {
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,
@@ -4702,7 +4705,7 @@ mod tests {
#[test]
fn metrics_screen_includes_block_range_filter() {
- assert!(super::INDEX_HTML.contains("iuna-ui.js?v=69"));
+ assert!(super::INDEX_HTML.contains("iuna-ui.js?v=70"));
assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\""));
assert!(super::INDEX_HTML.contains("setMetricsRange(100)"));
assert!(super::INDEX_HTML.contains("setMetricsRange(1000)"));
diff --git a/src/adapters/p2p.rs b/src/adapters/p2p.rs
@@ -3180,6 +3180,7 @@ mod tests {
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,
diff --git a/src/app.rs b/src/app.rs
@@ -958,10 +958,24 @@ impl NodeCore {
.ledger
.finalizer_rank_for_next_block(self.wallet.address());
if wallet_rank.is_none() {
- 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")
- });
+ if self.ledger.recovery_block_available_at(timestamp_ms) {
+ match self
+ .ledger
+ .prepare_recovery_block(self.wallet.address(), 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;
}
@@ -1027,10 +1041,24 @@ impl NodeCore {
.ledger
.finalizer_rank_for_next_block(self.wallet.address());
if wallet_rank.is_none() {
- 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")
- });
+ if self.ledger.recovery_block_available_at(timestamp_ms) {
+ match self
+ .ledger
+ .prepare_recovery_block(self.wallet.address(), 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;
}
@@ -1891,8 +1919,8 @@ mod tests {
use std::collections::BTreeMap;
use crate::domain::{
- DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE,
- Transaction, Wallet,
+ DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS, FinalizerMode, GenesisBurn, Ledger, MICRO_IUNA,
+ MINE_FINALIZER_FEE, RECOVERY_BLOCK_DELAY_MS, Transaction, Wallet,
};
use super::{NodeConfig, NodeCore};
@@ -2035,6 +2063,42 @@ mod tests {
}
#[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_pow_mining_uses_protocol_finalizer_fee() {
let wallet = Wallet::from_seed("automatic-pow-mining-fee-wallet");
let mut node = NodeCore::new(NodeConfig {
diff --git a/src/domain.rs b/src/domain.rs
@@ -20,6 +20,7 @@ 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 = 10 * 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;
const MINE_RETARGET_WINDOW_BLOCKS: u64 = 10;
@@ -783,6 +784,8 @@ pub struct Block {
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,
@@ -792,6 +795,14 @@ pub struct Block {
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 {
@@ -799,6 +810,7 @@ impl Block {
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,
@@ -821,7 +833,12 @@ impl Block {
}
pub fn vdf_seed(&self) -> String {
- vdf_seed_for_child(&self.prev_hash, self.height)
+ match self.finalizer_mode {
+ FinalizerMode::Ticket => vdf_seed_for_child(&self.prev_hash, self.height),
+ FinalizerMode::Recovery => {
+ recovery_vdf_seed_for_child(&self.prev_hash, self.height, self.timestamp_ms)
+ }
+ }
}
fn content_hash(&self) -> String {
@@ -849,7 +866,18 @@ impl Block {
}
fn legacy_content_hash_prefix(&self, leader_proof: &str) -> String {
- if self.finalizer_rank == 0 {
+ 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,
@@ -877,6 +905,7 @@ impl Block {
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
@@ -893,6 +922,15 @@ impl Block {
}
}
+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,
@@ -962,11 +1000,12 @@ pub struct PreparedBlock {
prev_hash: String,
timestamp_ms: u64,
miner: String,
+ finalizer_mode: FinalizerMode,
finalizer_rank: u32,
reward: Amount,
vdf_rounds: u64,
vdf_seed: String,
- leader_ticket: BurnTicket,
+ leader_ticket: Option<BurnTicket>,
transactions: Vec<Transaction>,
}
@@ -984,25 +1023,29 @@ impl PreparedBlock {
}
pub fn finish(self, wallet: &Wallet, vdf_output: String) -> Block {
- 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: self.leader_ticket.id.clone(),
- ticket_amount: self.leader_ticket.amount,
- ticket_owner: self.leader_ticket.owner.clone(),
- };
+ 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: self.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: Some(wallet.leader_proof(&proof_payload)),
+ leader_proof,
transactions: self.transactions,
})
}
@@ -1014,6 +1057,7 @@ struct BlockDraft {
prev_hash: String,
timestamp_ms: u64,
miner: String,
+ finalizer_mode: FinalizerMode,
finalizer_rank: u32,
reward: Amount,
vdf_rounds: u64,
@@ -1125,14 +1169,16 @@ impl ForkPoint {
#[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_rank
- .cmp(&other.finalizer_rank)
+ 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))
}
}
@@ -1962,6 +2008,12 @@ impl Ledger {
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> {
let height = self.tip().height + 1;
let Some((finalizer_rank, leader_ticket)) = self.finalizer_ticket_for_miner(height, miner)
@@ -1985,11 +2037,53 @@ impl Ledger {
prev_hash,
timestamp_ms,
miner: miner.to_string(),
+ finalizer_mode: FinalizerMode::Ticket,
reward: fee_reward(&transactions)?,
vdf_rounds: self.vdf_rounds_for_finalizer_rank(finalizer_rank)?,
vdf_seed,
finalizer_rank,
- leader_ticket,
+ leader_ticket: Some(leader_ticket),
+ 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> {
+ 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 transactions = self.select_recovery_block_transactions(miner)?;
+ ensure_block_has_burn(&transactions)?;
+ ensure_block_has_burn_from(&transactions, miner)?;
+ ensure_mine_actions_have_required_burns(&transactions)?;
+
+ let tip = self.tip();
+ let prev_hash = tip.hash.clone();
+ let timestamp_ms = timestamp_ms.max(tip.timestamp_ms + 1);
+ let vdf_seed = recovery_vdf_seed_for_child(&prev_hash, height, timestamp_ms);
+ Ok(PreparedBlock {
+ height,
+ prev_hash,
+ timestamp_ms,
+ miner: miner.to_string(),
+ finalizer_mode: FinalizerMode::Recovery,
+ finalizer_rank: 0,
+ reward: fee_reward(&transactions)?,
+ vdf_rounds: self.recovery_vdf_rounds()?,
+ vdf_seed,
+ leader_ticket: None,
transactions,
})
}
@@ -2050,7 +2144,7 @@ impl Ledger {
bail!("block reward is invalid");
}
let mut tickets = self.tickets.clone();
- consume_leader_ticket(&block, &mut tickets)?;
+ apply_finalizer_ticket_effects(&block, &mut tickets)?;
credit_reward_output(&mut utxos, &block)?;
tickets.extend(tickets_created_by_block(&block, &self.launch_profile)?);
@@ -2116,7 +2210,7 @@ impl Ledger {
if block.reward != fee_reward(&block.transactions)? {
bail!("block reward is invalid");
}
- let expected_vdf_rounds = self.vdf_rounds_for_finalizer_rank(block.finalizer_rank)?;
+ let expected_vdf_rounds = self.expected_vdf_rounds_for_block(block)?;
if block.vdf_rounds != expected_vdf_rounds {
bail!("block VDF rounds are invalid");
}
@@ -2139,24 +2233,31 @@ impl Ledger {
}
ensure_block_has_burn(&block.transactions)?;
ensure_mine_actions_have_required_burns(&block.transactions)?;
- 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");
+ 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())?;
+ }
}
- verify_leader_proof(block, &self.tickets)?;
Ok(true)
}
@@ -2245,14 +2346,28 @@ impl Ledger {
}
fn select_block_transactions(&self) -> Result<Vec<Transaction>> {
+ self.select_block_transactions_with_required_burn_owner(None)
+ }
+
+ fn select_recovery_block_transactions(&self, miner: &str) -> Result<Vec<Transaction>> {
+ self.select_block_transactions_with_required_burn_owner(Some(miner))
+ }
+
+ fn select_block_transactions_with_required_burn_owner(
+ &self,
+ required_burn_owner: Option<&str>,
+ ) -> Result<Vec<Transaction>> {
let mut utxos = self.utxos.clone();
let mut remaining = self.valid_pending_transactions();
let mut selected = Vec::new();
let mut selected_burn_amount = 0_u64;
- if let Some(index) =
+ 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 = selected.clone();
candidate.push(tx.clone());
@@ -2490,6 +2605,17 @@ impl Ledger {
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;
@@ -2710,6 +2836,19 @@ fn genesis_bootstrap_tickets(
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");
@@ -2784,6 +2923,16 @@ fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> {
Ok(())
}
+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 block_burn_amount(block: &Block) -> Amount {
transactions_burn_amount(&block.transactions).unwrap_or(Amount::MAX)
}
@@ -2816,6 +2965,20 @@ fn ensure_mine_actions_have_required_burns(transactions: &[Transaction]) -> Resu
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 {
@@ -2837,6 +3000,28 @@ fn best_selectable_transaction_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 best_selectable_transaction_index_for_burn_amount(
transactions: &[Transaction],
utxos: &BTreeMap<OutPoint, TxOutput>,
@@ -3081,6 +3266,7 @@ fn estimated_block_size_bytes(transactions: &[Transaction]) -> Result<usize> {
prev_hash: "f".repeat(64),
timestamp_ms: u64::MAX,
miner: "f".repeat(64),
+ finalizer_mode: FinalizerMode::Ticket,
finalizer_rank: 0,
reward: u64::MAX,
vdf_rounds: u64::MAX,
@@ -3146,6 +3332,12 @@ fn vdf_seed_for_child(prev_hash: &str, height: u64) -> String {
hex_hash(format!("iuna-vdf-child:{prev_hash}:{height}"))
}
+fn recovery_vdf_seed_for_child(prev_hash: &str, height: u64, timestamp_ms: u64) -> String {
+ hex_hash(format!(
+ "iuna-recovery-vdf-child:{prev_hash}:{height}:{timestamp_ms}"
+ ))
+}
+
fn apply_transaction(
transaction: &Transaction,
utxos: &mut BTreeMap<OutPoint, TxOutput>,
@@ -3309,6 +3501,7 @@ fn build_genesis_block(
prev_hash: "0".repeat(64),
timestamp_ms: 0,
miner,
+ finalizer_mode: FinalizerMode::Ticket,
finalizer_rank: 0,
reward,
vdf_rounds: 0,
@@ -4300,6 +4493,7 @@ mod tests {
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,
diff --git a/tests/iuna.rs b/tests/iuna.rs
@@ -11,8 +11,9 @@ use iuna::{
},
domain::{
Amount, BLOCK_REWARD, DEFAULT_FEE_PER_BYTE, DEFAULT_MINE_REQUIRED_BURN_MULTIPLIER_BPS,
- GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_IUNA, MIN_MINE_REQUIRED_BURN,
- TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, verify_vdf,
+ FinalizerMode, GenesisBurn, Ledger, MAX_BLOCK_BYTES, MICRO_IUNA, MIN_MINE_REQUIRED_BURN,
+ RECOVERY_BLOCK_DELAY_MS, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf,
+ verify_vdf,
},
};
use tempfile::tempdir;
@@ -998,6 +999,173 @@ fn fallback_finalizer_unblocks_network_when_primary_does_not_publish() {
}
#[test]
+fn recovery_block_is_rejected_before_timeout() {
+ let alice = Wallet::from_seed("recovery-before-timeout-alice");
+ let bob = Wallet::from_seed("recovery-before-timeout-bob");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), iuna(10));
+ genesis.insert(bob.address().to_string(), iuna(10));
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25)
+ .unwrap();
+
+ submit_burn(&mut ledger, &bob, 1);
+ let error = ledger
+ .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS - 1)
+ .unwrap_err();
+
+ assert!(format!("{error:#}").contains("recovery block is not available"));
+}
+
+#[test]
+fn recovery_block_unblocks_chain_when_only_ticket_holder_stops() {
+ let alice = Wallet::from_seed("recovery-unblocks-alice");
+ let bob = Wallet::from_seed("recovery-unblocks-bob");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), iuna(10));
+ genesis.insert(bob.address().to_string(), iuna(10));
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25)
+ .unwrap();
+
+ assert_eq!(
+ ledger.expected_leader_for_next_block().as_deref(),
+ Some(alice.address())
+ );
+ submit_burn(&mut ledger, &bob, 1);
+ assert!(ledger.mine_next_block(&bob, 1).is_err());
+
+ let block = ledger
+ .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS)
+ .unwrap();
+
+ assert_eq!(block.miner, bob.address());
+ assert_eq!(block.finalizer_mode, FinalizerMode::Recovery);
+ assert_eq!(block.finalizer_rank, 0);
+ assert_eq!(block.vdf_rounds, 25);
+ assert!(block.leader_proof.is_none());
+ ledger.apply_block(block).unwrap();
+ assert_eq!(ledger.status().height, 1);
+}
+
+#[test]
+fn recovery_block_requires_finalizer_own_burn() {
+ let alice = Wallet::from_seed("recovery-own-burn-alice");
+ let bob = Wallet::from_seed("recovery-own-burn-bob");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), iuna(10));
+ genesis.insert(bob.address().to_string(), iuna(10));
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25)
+ .unwrap();
+
+ submit_burn(&mut ledger, &alice, 1);
+ let error = ledger
+ .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS)
+ .unwrap_err();
+
+ assert!(format!("{error:#}").contains("burn from the finalizer"));
+}
+
+#[test]
+fn recovery_block_prioritizes_finalizer_own_burn() {
+ let alice = Wallet::from_seed("recovery-prioritizes-alice");
+ let bob = Wallet::from_seed("recovery-prioritizes-bob");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), iuna(10));
+ genesis.insert(bob.address().to_string(), iuna(10));
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25)
+ .unwrap();
+
+ let alice_burn = ledger.build_burn(&alice, 1, 1).unwrap();
+ ledger.submit_transaction(alice_burn).unwrap();
+ let bob_burn = ledger.build_burn(&bob, 1, 0).unwrap();
+ let bob_burn_signature = bob_burn.signature().to_string();
+ ledger.submit_transaction(bob_burn).unwrap();
+
+ let block = ledger
+ .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS)
+ .unwrap();
+
+ assert!(
+ block
+ .transactions
+ .iter()
+ .any(|tx| tx.signature() == bob_burn_signature)
+ );
+}
+
+#[test]
+fn recovery_vdf_seed_is_bound_to_timestamp() {
+ let alice = Wallet::from_seed("recovery-vdf-seed-alice");
+ let bob = Wallet::from_seed("recovery-vdf-seed-bob");
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), iuna(10));
+ genesis.insert(bob.address().to_string(), iuna(10));
+ let mut ledger =
+ Ledger::new_with_genesis_burns(genesis, vec![GenesisBurn::new(alice.address(), 1)], 25)
+ .unwrap();
+
+ submit_burn(&mut ledger, &bob, 1);
+ let mut block = ledger
+ .mine_recovery_block(&bob, RECOVERY_BLOCK_DELAY_MS)
+ .unwrap();
+ block.timestamp_ms += 1;
+ block.hash = block.compute_hash();
+
+ let error = ledger.apply_block(block).unwrap_err();
+ assert!(format!("{error:#}").contains("block VDF output is invalid"));
+}
+
+#[test]
+fn fork_choice_prefers_ticket_block_over_recovery_block_at_same_height() {
+ let alice = Wallet::from_seed("recovery-fork-alice");
+ let bob = Wallet::from_seed("recovery-fork-bob");
+ let wallets = [&alice, &bob];
+ let mut genesis = BTreeMap::new();
+ genesis.insert(alice.address().to_string(), iuna(10));
+ genesis.insert(bob.address().to_string(), iuna(10));
+ let common = Ledger::new_with_genesis_burns(
+ genesis,
+ vec![
+ GenesisBurn::new(alice.address(), 1),
+ GenesisBurn::new(bob.address(), 1),
+ ],
+ 25,
+ )
+ .unwrap();
+ let leader_address = common.expected_leader_for_next_block().unwrap();
+ let leader = wallets
+ .iter()
+ .copied()
+ .find(|wallet| wallet.address() == leader_address)
+ .unwrap();
+ let recovery = wallets
+ .into_iter()
+ .find(|wallet| wallet.address() != leader_address)
+ .unwrap();
+
+ let mut local = common.clone();
+ submit_burn(&mut local, recovery, 1);
+ let recovery_block = local
+ .mine_recovery_block(recovery, RECOVERY_BLOCK_DELAY_MS)
+ .unwrap();
+ assert_eq!(recovery_block.finalizer_mode, FinalizerMode::Recovery);
+ local.apply_block(recovery_block).unwrap();
+
+ let mut remote = common;
+ submit_burn(&mut remote, leader, 1);
+ let leader_block = remote.mine_next_block(leader, 1).unwrap();
+ assert_eq!(leader_block.finalizer_mode, FinalizerMode::Ticket);
+ let leader_hash = leader_block.hash.clone();
+ remote.apply_block(leader_block).unwrap();
+
+ assert!(local.extend_from_snapshot(remote.snapshot()).unwrap());
+ assert_eq!(local.status().tip_hash, leader_hash);
+}
+
+#[test]
fn fork_choice_prefers_primary_finalizer_over_fallback_rank() {
let alice = Wallet::from_seed("fallback-fork-alice");
let bob = Wallet::from_seed("fallback-fork-bob");
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -1773,7 +1773,8 @@ window.iunaApp = function iunaApp() {
blockFinalizerLabel(block) {
const finalizer = this.short(block.miner);
- return block.miner === this.status.wallet_address ? `${finalizer} (me)` : finalizer;
+ const owner = block.miner === this.status.wallet_address ? `${finalizer} (me)` : finalizer;
+ return block.finalizer_mode === "recovery" ? `${owner} ยท Recovery` : owner;
},
walletTransactions() {