iuna

iuna

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

commit 65ee5a60393e75ec5ad74f4e70e052e9af2cff2b
parent 6e4994f88bb8729cd7b47790fedcc4f9142290b9
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Fri,  4 Sep 2026 01:06:10 +0200

Avoid finalizer anchor conflicts with pending transfers

Diffstat:
Msrc/app/automatic_mining.rs | 160++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Msrc/domain/ledger_builders.rs | 1+
2 files changed, 156 insertions(+), 5 deletions(-)

diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use super::helpers::{ allowed_recovery_vdf_rank_count, converge_fee_by_byte, recovery_vdf_sample_percent, @@ -318,6 +318,19 @@ impl NodeCore { return Ok(None); } + // Pending wallet burns can themselves satisfy the block-anchor requirement. + // Pending transfers cannot, so keep their confirmed inputs reserved. + let pending_transfer_spent_outpoints = self + .ledger + .pending() + .iter() + .filter_map(|transaction| match transaction { + Transaction::Transfer { inputs, .. } => Some(inputs), + Transaction::Burn { .. } | Transaction::Mine { .. } => None, + }) + .flatten() + .map(|input| input.outpoint.clone()) + .collect::<std::collections::BTreeSet<_>>(); let ledger = self.wallet_anchor_build_ledger()?; let wallet = self.wallet.unlocked()?; let available_utxos = ledger.available_utxos_for_address(wallet.address())?; @@ -331,6 +344,7 @@ impl NodeCore { .context("automatic finalizer anchor burn amount plus fee overflows")?; let outpoint = available_utxos .iter() + .filter(|(outpoint, _)| !pending_transfer_spent_outpoints.contains(outpoint)) .filter(|(_, output)| output.amount >= required) .min_by_key(|(_, output)| output.amount) .map(|(outpoint, _)| outpoint); @@ -341,7 +355,34 @@ impl NodeCore { fee, std::slice::from_ref(outpoint), ), - None => ledger.build_burn_for_next_block(wallet, anchor_burn_amount, fee), + None => { + let mut total = 0_u64; + let outpoints = available_utxos + .iter() + .filter(|(outpoint, _)| { + !pending_transfer_spent_outpoints.contains(outpoint) + }) + .take_while(|(_, output)| { + if total >= required { + return false; + } + total = total.saturating_add(output.amount); + true + }) + .map(|(outpoint, _)| outpoint.clone()) + .collect::<Vec<_>>(); + if total < required { + bail!( + "automatic finalizer anchor burn has insufficient confirmed funds outside pending transfers" + ); + } + ledger.build_burn_for_next_block_with_inputs( + wallet, + anchor_burn_amount, + fee, + &outpoints, + ) + } } }) { Ok((burn, _)) => burn, @@ -1065,7 +1106,7 @@ mod tests { } #[test] - fn local_anchor_displaces_conflicting_pending_transfer_in_block_candidate() { + fn local_anchor_preserves_pending_transfer_when_another_utxo_is_available() { 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)); @@ -1095,12 +1136,121 @@ mod tests { .any(|transaction| transaction.signature() == local_anchor_signature) ); assert!( - !candidate + candidate + .pending() + .iter() + .any(|transaction| transaction.signature() == pending_transfer.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_transfer.signature()) + ); + } + + #[test] + fn local_anchor_uses_unreserved_utxo_instead_of_displacing_pending_transfer() { + let wallet = Wallet::from_seed("non-conflicting-local-anchor-wallet"); + let recipient = Wallet::from_seed("non-conflicting-local-anchor-recipient"); + let ledger = funded_ledger(std::slice::from_ref(&wallet)); + let timestamp_ms = ledger.recovery_block_min_timestamp(); + let mut node = + NodeCore::from_ledger_with_burn_fee_and_enabled(wallet.clone(), ledger, true, 1, 1); + + let first = node.automatic_mine_once(timestamp_ms); + assert!( + first.block.is_some(), + "fixture should create multiple wallet UTXOs" + ); + let available = node + .ledger() + .available_utxos_for_address(wallet.address()) + .unwrap(); + assert!(available.len() >= 2); + let transfer_outpoint = available + .iter() + .filter(|(_, output)| output.amount >= 2) + .min_by_key(|(_, output)| output.amount) + .map(|(outpoint, _)| outpoint.clone()) + .expect("fixture should have a transfer input"); + let pending_transfer = node + .ledger() + .build_transfer_with_inputs( + &wallet, + recipient.address(), + 1, + 1, + std::slice::from_ref(&transfer_outpoint), + ) + .unwrap(); + node.receive_transaction(pending_transfer.clone()).unwrap(); + + node.prepare_automatic_burn(timestamp_ms + 1).unwrap(); + let anchor = node + .local_block_anchor_burn + .as_ref() + .map(|(_, burn)| burn) + .expect("selected finalizer should prepare a local anchor"); + let Transaction::Burn { inputs, .. } = anchor else { + panic!("local anchor should be a burn transaction"); + }; + assert!( + inputs + .iter() + .all(|input| input.outpoint != transfer_outpoint) + ); + let anchor_signature = anchor.signature().to_string(); + + let prepared = node + .prepare_next_block_with_local_anchor(timestamp_ms + 1) + .unwrap(); + let block = prepared.finish(&wallet, "test-vdf-output".to_string()); + assert!( + block + .transactions + .iter() + .any(|transaction| { transaction.signature() == pending_transfer.signature() }) + ); + assert!( + block + .transactions + .iter() + .any(|transaction| transaction.signature() == anchor_signature) + ); + } + + #[test] + fn local_anchor_waits_when_all_confirmed_utxos_are_reserved_by_pending_transfer() { + let wallet = Wallet::from_seed("reserved-local-anchor-wallet"); + let recipient = Wallet::from_seed("reserved-local-anchor-recipient"); + let ledger = funded_ledger(std::slice::from_ref(&wallet)); + let outpoints = ledger + .available_utxos_for_address(wallet.address()) + .unwrap() + .into_iter() + .map(|(outpoint, _)| outpoint) + .collect::<Vec<_>>(); + let pending_transfer = ledger + .build_transfer_with_inputs(&wallet, recipient.address(), 1, 1, &outpoints) + .unwrap(); + let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(wallet, ledger, true, 1, 1); + node.receive_transaction(pending_transfer.clone()).unwrap(); + + let error = node.prepare_automatic_burn(1).unwrap_err(); + + assert!(format!("{error:#}").contains( + "automatic finalizer anchor burn has insufficient confirmed funds outside pending transfers" + )); + assert!(node.local_block_anchor_burn.is_none()); + assert!( + node.ledger() .pending() .iter() .any(|transaction| transaction.signature() == pending_transfer.signature()) ); - assert!(node.prepare_next_block_with_local_anchor(1).is_ok()); } #[test] diff --git a/src/domain/ledger_builders.rs b/src/domain/ledger_builders.rs @@ -123,6 +123,7 @@ impl Ledger { ) } + #[cfg(test)] pub(crate) fn build_burn_for_next_block( &self, wallet: &Wallet,