iuna

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

commit ffd05f802b88146b73bbca37f3c178dc002112b9
parent 86041b1cd9746a7e0d7bad73c5f44c3633118db8
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Thu,  6 Aug 2026 13:45:27 +0200

Prepare v0.2.29

Diffstat:
MCargo.lock | 2+-
MCargo.toml | 2+-
Msrc/app.rs | 338++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/domain.rs | 21+++++++++++++++++++++
4 files changed, 348 insertions(+), 15 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "iuna" -version = "0.2.28" +version = "0.2.29" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iuna" -version = "0.2.28" +version = "0.2.29" edition = "2024" license = "Apache-2.0" diff --git a/src/app.rs b/src/app.rs @@ -1158,6 +1158,15 @@ impl NodeCore { fee_per_byte: Amount, ) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { let ledger = self.wallet_build_ledger()?; + self.build_blinded_burn_with_fee_rate_on_ledger(&ledger, amount, fee_per_byte) + } + + fn build_blinded_burn_with_fee_rate_on_ledger( + &self, + ledger: &Ledger, + amount: Amount, + fee_per_byte: Amount, + ) -> Result<(BuiltBlindedTransaction, FeeEstimate)> { let expires_at_height = self.default_blinded_transaction_expiry_height(); converge_fee_by_byte(fee_per_byte, |fee| { let tx = ledger.build_burn(self.wallet.unlocked()?, amount, fee)?; @@ -1221,6 +1230,13 @@ impl NodeCore { Ok(ledger) } + fn wallet_anchor_build_ledger(&self) -> Result<Ledger> { + let mut ledger = self.ledger.clone(); + self.reserve_local_block_anchor_inputs(&mut ledger)?; + ledger.clear_pending_blinded_transactions(); + Ok(ledger) + } + fn queue_owned_blinded_payloads(&self, ledger: &mut Ledger) -> Result<()> { for (commitment, payload) in &self.owned_blinded_payloads { if self.ledger.has_unrevealed_blinded_transaction(commitment) @@ -1531,12 +1547,47 @@ impl NodeCore { let fee_per_byte = self.burn_fee; let balance = self.ledger.balance_of(self.wallet.address()); + let needs_plaintext_anchor = self.automatic_burn_needs_plaintext_anchor(timestamp_ms); + let spendable_best = match self.wallet_build_ledger() { + Ok(ledger) => self.best_automatic_burn_on_ledger(&ledger, fee_per_byte, balance), + Err(error) if needs_plaintext_anchor => { + let _ = error; + None + } + Err(error) => return Err(error), + }; + let best = if spendable_best.is_none() && needs_plaintext_anchor { + let anchor_ledger = self.wallet_anchor_build_ledger()?; + self.best_automatic_burn_on_ledger(&anchor_ledger, fee_per_byte, balance) + } else { + spendable_best + }; + let Some(tx) = best else { + self.last_auto_burn_height = Some(current_height); + return Ok(None); + }; + let burn = tx.payload.clone(); + if needs_plaintext_anchor { + self.local_block_anchor_burn = Some((current_height, burn.clone())); + } else { + self.submit_owned_blinded_transaction(tx)?; + } + self.last_auto_burn_height = Some(current_height); + Ok(Some(burn)) + } + + fn best_automatic_burn_on_ledger( + &self, + ledger: &Ledger, + fee_per_byte: Amount, + balance: Amount, + ) -> Option<BuiltBlindedTransaction> { let mut low = 1; let mut high = self.burn_per_block.min(balance); let mut best = None; while low <= high { let amount = low + (high - low) / 2; - match self.build_blinded_burn_with_fee_rate(amount, fee_per_byte) { + match self.build_blinded_burn_with_fee_rate_on_ledger(ledger, amount, fee_per_byte) { Ok((built, estimate)) => { let fits = amount .checked_add(estimate.fee) @@ -1556,18 +1607,7 @@ impl NodeCore { } } } - let Some(tx) = best else { - self.last_auto_burn_height = Some(current_height); - return Ok(None); - }; - let burn = tx.payload.clone(); - if self.automatic_burn_needs_plaintext_anchor(timestamp_ms) { - self.local_block_anchor_burn = Some((current_height, burn.clone())); - } else { - self.submit_owned_blinded_transaction(tx)?; - } - self.last_auto_burn_height = Some(current_height); - Ok(Some(burn)) + best } fn automatic_burn_needs_plaintext_anchor(&self, timestamp_ms: u64) -> bool { @@ -1603,6 +1643,7 @@ impl NodeCore { return Ok(ledger); }; if *height == ledger.height() && !ledger.has_transaction(burn.signature()) { + ledger.drop_pending_blinded_conflicting_with_transaction(burn); let _ = ledger.submit_transaction(burn.clone())?; } Ok(ledger) @@ -2301,6 +2342,24 @@ mod tests { .unwrap_or_else(|| panic!("missing wallet for address {address}")) } + fn queue_auto_pow_mine_action(node: &mut NodeCore) -> Transaction { + node.set_pow_mining_enabled(true); + (0..10_000) + .find_map(|timestamp| node.prepare_automatic_mining(timestamp).pow_mined) + .expect("test node should find a PoW mine action") + } + + fn assert_block_has_mine_action(block: &crate::domain::Block) { + assert!( + block + .transactions + .iter() + .any(|transaction| matches!(transaction, Transaction::Mine { .. })), + "block {} should include a mine action", + block.height + ); + } + #[test] fn same_height_verified_import_does_not_reset_auto_burn_guard() { let alice = Wallet::from_seed("same-height-import-alice"); @@ -2780,6 +2839,259 @@ mod tests { } #[test] + fn genesis_transfer_arriving_during_vdf_with_peer_mines_does_not_stall_following_blocks() { + let miner = Wallet::from_seed("during-vdf-miner"); + let (_finalizer, finalizer_node, block2_work) = (0..1_000) + .find_map(|seed_index| { + let finalizer = Wallet::from_seed(&format!("during-vdf-finalizer-{seed_index}")); + let mut allocations = BTreeMap::new(); + allocations.insert(finalizer.address().to_string(), MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( + finalizer.clone(), + ledger, + true, + 100, + 100, + ); + let block1 = node.automatic_mine_once(1).block?; + assert!(block1.blinded_transactions.is_empty()); + assert!( + !block1 + .transactions + .iter() + .any(|transaction| matches!(transaction, Transaction::Mine { .. })), + "node B joins after block 1, so block 1 should not include B's mine action" + ); + let block2_plan = node.prepare_automatic_finalization(2); + let block2_work = block2_plan.work?; + let (_, anchor_burn) = node.local_block_anchor_burn.clone()?; + let anchor_inputs = super::transaction_input_outpoints(&anchor_burn); + let anchor_total = node + .ledger() + .utxos_for_address(finalizer.address()) + .iter() + .filter(|(outpoint, _)| anchor_inputs.contains(outpoint)) + .map(|(_, output)| output.amount) + .sum::<u64>(); + if anchor_total >= 200_000 { + return None; + } + Some((finalizer, node, block2_work)) + }) + .expect("test should find a seed where block 2 anchor uses the small reward UTXO"); + let mut network = InMemoryNetwork::default(); + network.insert("finalizer", finalizer_node); + + let miner_ledger = + Ledger::from_snapshot(network.node("finalizer").unwrap().chain_snapshot()).unwrap(); + network.insert( + "miner", + NodeCore::from_ledger(miner.clone(), miner_ledger, 0), + ); + + queue_auto_pow_mine_action(network.node_mut("miner").unwrap()); + network.deliver_until_idle().unwrap(); + network + .node_mut("finalizer") + .unwrap() + .transfer_with_fee_rate(miner.address(), MICRO_IUNA / 10, 100, &[]) + .unwrap(); + let blinded = network + .node("finalizer") + .unwrap() + .ledger() + .pending_blinded_transactions() + .last() + .cloned() + .expect("A should queue the A -> B blinded transfer while block 2 VDF is running"); + network.deliver_until_idle().unwrap(); + assert!( + network + .node("finalizer") + .unwrap() + .ledger() + .pending_blinded_transactions() + .iter() + .any(|tx| tx.commitment == blinded.commitment), + "the finalizer should receive the blinded tx while block 2 VDF is running" + ); + + let block2_vdf = run_vdf(block2_work.vdf_seed(), block2_work.vdf_rounds()); + let block2 = network + .node_mut("finalizer") + .unwrap() + .complete_prepared_block_at(block2_work, block2_vdf, 2) + .unwrap(); + assert!( + block2.blinded_transactions.is_empty(), + "block 2 work was prepared before the blinded tx arrived" + ); + assert!( + !block2 + .transactions + .iter() + .any(|transaction| matches!(transaction, Transaction::Mine { .. })), + "block 2 work was prepared before B's mine action arrived" + ); + network.deliver_until_idle().unwrap(); + + let block3_outcome = network + .node_mut("finalizer") + .unwrap() + .automatic_mine_once(3); + assert!( + block3_outcome.block.is_some(), + "finalizer should keep producing after the during-VDF blinded tx: {:?}", + block3_outcome.skipped_reason + ); + let block3 = block3_outcome.block.unwrap(); + assert!( + !block3 + .blinded_transactions + .iter() + .any(|tx| tx.commitment == blinded.commitment), + "the mandatory anchor burn should win when the during-VDF blinded tx would starve it" + ); + assert_block_has_mine_action(&block3); + network.deliver_until_idle().unwrap(); + assert!( + network + .node("finalizer") + .unwrap() + .ledger() + .pending_blinded_transactions() + .is_empty(), + "the conflicting own blinded tx should be pruned after the anchor burn spends its input" + ); + assert!( + network + .node("finalizer") + .unwrap() + .owned_blinded_transactions() + .is_empty(), + "owned blinded state should not keep rebroadcasting the pruned tx" + ); + + queue_auto_pow_mine_action(network.node_mut("miner").unwrap()); + network.deliver_until_idle().unwrap(); + let block4 = network + .node_mut("finalizer") + .unwrap() + .automatic_mine_once(4) + .block + .expect("finalizer should keep producing the next block"); + assert!( + block4.all_blinded_reveals().is_empty(), + "the pruned blinded tx was never committed, so there should be no reveal" + ); + assert_block_has_mine_action(&block4); + } + + #[test] + fn own_blinded_transaction_arriving_during_vdf_does_not_starve_next_anchor_burn() { + let recipient = Wallet::from_seed("during-vdf-own-recipient"); + let (mut node, block2_work, transfer_outpoint, transfer_amount) = (0..1_000) + .find_map(|seed_index| { + let finalizer = + Wallet::from_seed(&format!("during-vdf-own-finalizer-{seed_index}")); + let mut allocations = BTreeMap::new(); + allocations.insert(finalizer.address().to_string(), MICRO_IUNA); + let ledger = Ledger::new_with_genesis_burns( + allocations, + vec![GenesisBurn::new(finalizer.address(), MICRO_IUNA)], + 1, + ) + .unwrap(); + let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( + finalizer.clone(), + ledger, + true, + 100, + 1_000, + ); + node.set_pow_mining_enabled(true); + let block1 = node.automatic_mine_once(1).block?; + assert!(block1.blinded_transactions.is_empty()); + + let block2_plan = node.prepare_automatic_finalization(2); + let block2_work = block2_plan.work?; + let (_, anchor_burn) = node.local_block_anchor_burn.clone()?; + let anchor_inputs = super::transaction_input_outpoints(&anchor_burn); + let utxos = node.ledger().utxos_for_address(finalizer.address()); + let anchor_total = utxos + .iter() + .filter(|(outpoint, _)| anchor_inputs.contains(outpoint)) + .map(|(_, output)| output.amount) + .sum::<u64>(); + let (transfer_outpoint, transfer_output) = + utxos.into_iter().find(|(outpoint, output)| { + !anchor_inputs.contains(outpoint) && output.amount > MICRO_IUNA + })?; + if anchor_total >= 2_000_000 { + return None; + } + Some(( + node, + block2_work, + transfer_outpoint, + transfer_output.amount / 2, + )) + }) + .expect("test should find a seed with live-like small-anchor/large-change UTXOs"); + + node.transfer_with_fee_spending( + recipient.address(), + transfer_amount, + 1, + &[transfer_outpoint], + ) + .expect("wallet tx created while block 2 VDF is running"); + let blinded = node + .ledger() + .pending_blinded_transactions() + .last() + .cloned() + .expect("wallet tx should be queued as a blinded transaction"); + + let block2_vdf = run_vdf(block2_work.vdf_seed(), block2_work.vdf_rounds()); + let block2 = node + .complete_prepared_block_at(block2_work, block2_vdf, 2) + .unwrap(); + assert!( + block2.blinded_transactions.is_empty(), + "block 2 work was prepared before the blinded tx arrived" + ); + assert!( + node.ledger() + .pending_blinded_transactions() + .iter() + .any(|tx| tx.commitment == blinded.commitment), + "the during-VDF blinded tx should remain pending for block 3" + ); + + let block3_outcome = node.automatic_mine_once(3); + assert!( + block3_outcome.block.is_some(), + "pending own blinded tx must not starve the next anchor burn: {:?}", + block3_outcome.skipped_reason + ); + let block3 = block3_outcome.block.unwrap(); + assert!( + block3 + .blinded_transactions + .iter() + .any(|tx| tx.commitment == blinded.commitment), + "block 3 should include the blinded tx that arrived during block 2 VDF" + ); + } + + #[test] fn owned_blinded_transaction_reveals_after_commit_block_import() { let alice = Wallet::from_seed("owned-blinded-reveal-alice"); let bob = Wallet::from_seed("owned-blinded-reveal-bob"); diff --git a/src/domain.rs b/src/domain.rs @@ -2164,6 +2164,27 @@ impl Ledger { &self.pending_reveals } + pub(crate) fn drop_pending_blinded_conflicting_with_transaction( + &mut self, + transaction: &Transaction, + ) { + let spent = transaction + .inputs() + .iter() + .map(|input| input.outpoint.clone()) + .collect::<BTreeSet<_>>(); + self.pending_blinded.retain(|blinded| { + !blinded + .inputs + .iter() + .any(|input| spent.contains(&input.outpoint)) + }); + } + + pub(crate) fn clear_pending_blinded_transactions(&mut self) { + self.pending_blinded.clear(); + } + pub fn build_reveal_bundle(&self, wallet: &Wallet) -> Result<Option<RevealBundle>> { let height = self.tip().height + 1; let prev_hash = self.tip().hash.clone();