iuna

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

commit 4867cad30a1b95f5c4daa21ca6e133a5b61d7520
parent cbc29096e84812b351beb07bbf71ddb4cc99f728
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sun,  9 Aug 2026 00:49:16 +0200

Allow two mine actions per anchor

Diffstat:
MCargo.lock | 2+-
MCargo.toml | 2+-
Mdocs/protocol.md | 4++--
Msrc/app.rs | 50++++++++++++++++++++++++++++++++++----------------
Msrc/domain.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
5 files changed, 124 insertions(+), 77 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "iuna" -version = "0.2.37" +version = "0.2.38" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iuna" -version = "0.2.37" +version = "0.2.38" edition = "2024" license = "Apache-2.0" diff --git a/docs/protocol.md b/docs/protocol.md @@ -115,7 +115,7 @@ Difficulty targets about one mine action per block: This keeps issuance separate from finalization. PoW miners compete to create mine actions; burn-ticket finalizers decide blocks. -Starting at height `500`, a block may contain at most one mine action for the same anchor. Upgraded nodes already produce and select at most one pending mine action per anchor before that activation height, so duplicate-anchor floods drain without invalidating older blocks during rollout. +Starting at height `200`, a block may contain at most `2` mine actions for the same anchor. Upgraded nodes already produce and select at most `2` pending mine actions per anchor before that activation height, so duplicate-anchor floods drain without invalidating older blocks during rollout. This leaves room for the difficulty retarget to move upward when PoW regularly fills both slots, while still bounding issuance from any single anchor. ## Fair Burn Inclusion @@ -183,7 +183,7 @@ When a node builds a block, it selects transactions in this order: 1. Collect valid signed reveal bundles for the next height. 2. Reserve the local plaintext anchor burn as the first plaintext block item. 3. For recovery blocks, ensure at least one plaintext anchor burn is from the recovery finalizer. -4. Fill remaining envelope space with valid public mine actions and blinded transaction envelopes ordered by fee rate. Public mine actions are limited to one action per anchor. +4. Fill remaining envelope space with valid public mine actions and blinded transaction envelopes ordered by fee rate. Public mine actions are limited to `2` actions per anchor. 5. Bind the VDF seed to the three reveal-bundle slot hashes, using default hashes for missing slots. Blocks are bounded by transaction count and serialized byte size. The devnet maximum block size is `100,000` bytes. diff --git a/src/app.rs b/src/app.rs @@ -18,9 +18,10 @@ use crate::adapters::config_store::{ use crate::domain::{ Amount, BlindedReveal, BlindedTransaction, Block, BuiltBlindedTransaction, BurnLeaderRank, ChainSnapshot, ChainStatus, DEFAULT_FEE_PER_BYTE, DEFAULT_TRANSACTION_FEE, Ledger, - MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, - OwnedBlindedTransaction, PreparedBlock, RevealBundle, StratumMineShare, StratumMineTemplate, - Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MINE_ACTIONS_PER_ANCHOR_LIMIT, MINE_FINALIZER_FEE, + MINE_REWARD, OutPoint, OwnedBlindedTransaction, PreparedBlock, RevealBundle, StratumMineShare, + StratumMineTemplate, Transaction, TransactionSubmitOutcome, VDF_TARGET_BLOCK_MS, Wallet, + run_vdf, }; pub type SharedNode = Arc<Mutex<NodeCore>>; @@ -1574,10 +1575,10 @@ impl NodeCore { .last() .map(|block| block.hash.clone()) .context("ledger has no anchor block")?; - if self.ledger.has_pending_mine_for_anchor(&anchor) { + if self.ledger.pending_mine_count_for_anchor(&anchor) >= MINE_ACTIONS_PER_ANCHOR_LIMIT { self.last_auto_pow_mine_anchor = Some(anchor); self.last_auto_pow_mine_status = - Some("waiting for next chain tip after queued mine action".to_string()); + Some("waiting for next chain tip after queued mine actions".to_string()); self.auto_pow_mine_cursor = None; return Ok(None); } @@ -2530,8 +2531,9 @@ mod tests { use std::collections::{BTreeMap, BTreeSet}; use crate::domain::{ - FinalizerMode, GenesisBurn, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, OutPoint, - RECOVERY_BLOCK_DELAY_MS, Transaction, VDF_TARGET_BLOCK_MS, Wallet, run_vdf, + FinalizerMode, GenesisBurn, Ledger, MICRO_IUNA, MINE_ACTIONS_PER_ANCHOR_LIMIT, + MINE_FINALIZER_FEE, OutPoint, RECOVERY_BLOCK_DELAY_MS, Transaction, VDF_TARGET_BLOCK_MS, + Wallet, run_vdf, }; use super::{ @@ -2648,19 +2650,27 @@ mod tests { .contains("queued") ); - for timestamp in 10_000..10_010 { + let second = (10_000..20_000) + .map(|timestamp| node.prepare_automatic_mining(timestamp)) + .find(|plan| plan.pow_mined.is_some()) + .expect("automatic PoW should allow a second proof for the same tip"); + let second_mine = second.pow_mined.as_ref().expect("PoW should be queued"); + assert_ne!(second_mine.signature(), first_mine.signature()); + assert_eq!(node.ledger().pending().len(), first_pending + 1); + + for timestamp in 20_000..20_010 { assert!(node.prepare_automatic_mining(timestamp).pow_mined.is_none()); } - assert_eq!(node.ledger().pending().len(), first_pending); + assert_eq!(node.ledger().pending().len(), first_pending + 1); assert_eq!( node.status().mining.last_auto_pow_mine_status.as_deref(), - Some("waiting for next chain tip after queued mine action") + Some("waiting for next chain tip after queued mine actions") ); assert!(node.ledger().pending_blinded_transactions().is_empty()); } #[test] - fn automatic_pow_mining_waits_after_queueing_for_tip() { + fn automatic_pow_mining_waits_after_queueing_anchor_limit_for_tip() { let wallet = Wallet::from_seed("automatic-pow-independent-wallet"); let mut allocations = BTreeMap::new(); allocations.insert(wallet.address().to_string(), 1); @@ -2675,14 +2685,22 @@ mod tests { }); node.set_pow_mining_enabled(true); - let mined = (1..10_000) + let first_mined = (1..10_000) .find_map(|_| node.prepare_automatic_pow_mining().unwrap()) .expect("PoW should eventually queue a mine action"); - - assert!(node.ledger().has_pending_mine_for_anchor(match mined { - Transaction::Mine { ref anchor, .. } => anchor, + let anchor = match first_mined { + Transaction::Mine { ref anchor, .. } => anchor.clone(), _ => panic!("expected mine action"), - })); + }; + assert_eq!(node.ledger().pending_mine_count_for_anchor(&anchor), 1); + + (1..10_000) + .find_map(|_| node.prepare_automatic_pow_mining().unwrap()) + .expect("PoW should allow a second mine action for the same tip"); + assert_eq!( + node.ledger().pending_mine_count_for_anchor(&anchor), + MINE_ACTIONS_PER_ANCHOR_LIMIT + ); assert!(node.prepare_automatic_pow_mining().unwrap().is_none()); assert!(node.auto_pow_mine_cursor.is_none()); } diff --git a/src/domain.rs b/src/domain.rs @@ -26,7 +26,8 @@ pub const VDF_TARGET_BLOCK_MS: u64 = 5 * 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; -pub const SINGLE_MINE_PER_ANCHOR_ACTIVATION_HEIGHT: u64 = 500; +pub const MINE_ACTIONS_PER_ANCHOR_LIMIT: usize = 2; +pub const MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT: u64 = 200; pub const MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS: u64 = 20; pub const REVEAL_COMMITTEE_SIZE: usize = 3; pub const MAX_REVEAL_BUNDLE_BYTES: usize = 10_000; @@ -2476,10 +2477,11 @@ impl Ledger { self.transaction_by_signature(signature).is_some() } - pub fn has_pending_mine_for_anchor(&self, anchor: &str) -> bool { + pub fn pending_mine_count_for_anchor(&self, anchor: &str) -> usize { self.pending .iter() - .any(|tx| mine_anchor(tx) == Some(anchor)) + .filter(|tx| mine_anchor(tx) == Some(anchor)) + .count() } pub fn has_blinded_transaction(&self, commitment: &str) -> bool { @@ -3503,27 +3505,32 @@ impl Ledger { let mut utxos = self.utxos.clone(); let mut valid = Vec::new(); let mut remaining = self.pending.iter().collect::<Vec<_>>(); - let mut selected_mine_anchors = BTreeSet::new(); - let enforce_single_mine_per_anchor = true; + let mut selected_mine_anchor_counts = BTreeMap::new(); while !remaining.is_empty() { let mut progressed = false; let mut still_pending = Vec::new(); for tx in remaining { - if enforce_single_mine_per_anchor - && mine_anchor(tx).is_some_and(|anchor| { - mine_anchor_was_used_before_height(&self.chain, anchor, self.height()) - || selected_mine_anchors.contains(anchor) - }) - { - continue; + if let Some(anchor) = mine_anchor(tx) { + let selected = selected_mine_anchor_counts + .get(anchor) + .copied() + .unwrap_or_default(); + if mine_anchor_count_before_height(&self.chain, anchor, self.height()) + .saturating_add(selected) + >= MINE_ACTIONS_PER_ANCHOR_LIMIT + { + continue; + } } if self.validate_transaction_terms(tx).is_ok() && apply_transaction(tx, &mut utxos).is_ok() { if let Some(anchor) = mine_anchor(tx) { - selected_mine_anchors.insert(anchor.to_string()); + *selected_mine_anchor_counts + .entry(anchor.to_string()) + .or_insert(0) += 1; } valid.push(tx.clone()); progressed = true; @@ -3755,24 +3762,27 @@ impl Ledger { } fn validate_mine_anchor_available(&self, transaction: &Transaction) -> Result<()> { - if single_mine_per_anchor_active(self.height().saturating_add(1)) { + if mine_actions_per_anchor_limit_active(self.height().saturating_add(1)) { if let Some(anchor) = mine_anchor(transaction) { - if mine_anchor_was_used_before_height(&self.chain, anchor, self.height()) { - bail!("mine transaction anchor already has a mined action"); - } - if self - .pending - .iter() - .any(|tx| mine_anchor(tx) == Some(anchor)) - { - bail!("pending mine transaction anchor already exists"); - } - if self - .orphans - .iter() - .any(|tx| mine_anchor(tx) == Some(anchor)) - { - bail!("orphan mine transaction anchor already exists"); + let known_count = + mine_anchor_count_before_height(&self.chain, anchor, self.height()) + .saturating_add( + self.pending + .iter() + .filter(|tx| mine_anchor(tx) == Some(anchor)) + .count(), + ) + .saturating_add( + self.orphans + .iter() + .filter(|tx| { + mine_anchor(tx) == Some(anchor) + && tx.signature() != transaction.signature() + }) + .count(), + ); + if known_count >= MINE_ACTIONS_PER_ANCHOR_LIMIT { + bail!("mine transaction anchor limit reached"); } } } @@ -4424,23 +4434,25 @@ fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> { } fn ensure_mine_anchor_limit(height: u64, transactions: &[Transaction]) -> Result<()> { - if !single_mine_per_anchor_active(height) { + if !mine_actions_per_anchor_limit_active(height) { return Ok(()); } - let mut anchors = BTreeSet::new(); + let mut anchor_counts = BTreeMap::new(); for transaction in transactions { let Some(anchor) = mine_anchor(transaction) else { continue; }; - if !anchors.insert(anchor) { - bail!("block contains multiple mine actions for one anchor"); + let count = anchor_counts.entry(anchor).or_insert(0usize); + *count += 1; + if *count > MINE_ACTIONS_PER_ANCHOR_LIMIT { + bail!("block exceeds mine actions per anchor limit"); } } Ok(()) } -fn single_mine_per_anchor_active(height: u64) -> bool { - height >= SINGLE_MINE_PER_ANCHOR_ACTIVATION_HEIGHT +fn mine_actions_per_anchor_limit_active(height: u64) -> bool { + height >= MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT } fn mine_anchor(transaction: &Transaction) -> Option<&str> { @@ -4450,16 +4462,18 @@ fn mine_anchor(transaction: &Transaction) -> Option<&str> { } } -fn mine_anchor_was_used_before_height(chain: &[Block], anchor: &str, height: u64) -> bool { +fn mine_anchor_count_before_height(chain: &[Block], anchor: &str, height: u64) -> usize { chain .iter() .take_while(|block| block.height <= height) - .any(|block| { + .map(|block| { block .transactions .iter() - .any(|transaction| mine_anchor(transaction) == Some(anchor)) + .filter(|transaction| mine_anchor(transaction) == Some(anchor)) + .count() }) + .sum() } fn ensure_block_has_burn_from(transactions: &[Transaction], miner: &str) -> Result<()> { @@ -5994,8 +6008,8 @@ mod tests { panic!("test should find a valid mine action"); } - fn advance_to_single_mine_activation_parent(ledger: &mut Ledger, wallet: &Wallet) { - while ledger.height().saturating_add(1) < SINGLE_MINE_PER_ANCHOR_ACTIVATION_HEIGHT { + fn advance_to_mine_anchor_limit_activation_parent(ledger: &mut Ledger, wallet: &Wallet) { + while ledger.height().saturating_add(1) < MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT { let timestamp_ms = ledger .tip() .timestamp_ms @@ -6004,7 +6018,7 @@ mod tests { } assert_eq!( ledger.height().saturating_add(1), - SINGLE_MINE_PER_ANCHOR_ACTIVATION_HEIGHT + MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT ); } @@ -8312,8 +8326,8 @@ mod tests { } #[test] - fn block_selection_limits_mine_actions_to_one_per_anchor() { - let alice = Wallet::from_seed("mine-single-anchor-selection-alice"); + fn block_selection_limits_mine_actions_per_anchor() { + let alice = Wallet::from_seed("mine-anchor-limit-selection-alice"); let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); @@ -8322,18 +8336,20 @@ mod tests { ledger.submit_transaction(first_mine.clone()).unwrap(); let second_mine = ledger.build_mine(alice.address()).unwrap(); ledger.submit_transaction(second_mine.clone()).unwrap(); + let third_mine = ledger.build_mine(alice.address()).unwrap(); + ledger.submit_transaction(third_mine.clone()).unwrap(); - assert_eq!(ledger.pending().len(), 3); + assert_eq!(ledger.pending().len(), 4); let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert_eq!(block.transactions.len(), 2); + assert_eq!(block.transactions.len(), 3); assert!(block.transactions.iter().any(Transaction::is_burn)); let included_mines = block .transactions .iter() .filter(|transaction| matches!(transaction, Transaction::Mine { .. })) .count(); - assert_eq!(included_mines, 1); + assert_eq!(included_mines, MINE_ACTIONS_PER_ANCHOR_LIMIT); assert!( block .transactions @@ -8341,20 +8357,29 @@ mod tests { .any(|tx| tx.signature() == first_mine.signature()) ); assert!( - !block + block .transactions .iter() .any(|tx| tx.signature() == second_mine.signature()) ); + assert!( + !block + .transactions + .iter() + .any(|tx| tx.signature() == third_mine.signature()) + ); assert_ne!(first_mine.signature(), second_mine.signature()); - assert_eq!(block.reward, first_mine.fee()); + assert_ne!(second_mine.signature(), third_mine.signature()); + assert_eq!(block.reward, first_mine.fee() + second_mine.fee()); } #[test] fn pre_activation_block_may_keep_multiple_mine_actions_for_one_anchor() { let alice = Wallet::from_seed("mine-anchor-limit-pre-activation-alice"); let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - assert!(ledger.height().saturating_add(1) < SINGLE_MINE_PER_ANCHOR_ACTIVATION_HEIGHT); + assert!( + ledger.height().saturating_add(1) < MINE_ACTIONS_PER_ANCHOR_LIMIT_ACTIVATION_HEIGHT + ); let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); @@ -8373,13 +8398,14 @@ mod tests { } #[test] - fn activated_blocks_reject_multiple_mine_actions_for_one_anchor() { + fn activated_blocks_reject_too_many_mine_actions_for_one_anchor() { let alice = Wallet::from_seed("mine-anchor-limit-active-block-alice"); let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - advance_to_single_mine_activation_parent(&mut ledger, &alice); + advance_to_mine_anchor_limit_activation_parent(&mut ledger, &alice); let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); + let third_mine = test_mine_with_salt(&ledger, alice.address(), 3); let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); ledger.submit_transaction(burn).unwrap(); let mut block = ledger @@ -8394,6 +8420,7 @@ mod tests { .finish(&alice, "preverified-vdf".to_string()); block.transactions.push(first_mine); block.transactions.push(second_mine); + block.transactions.push(third_mine); block.reward = fee_reward(&block.transactions).unwrap(); block.hash = block.compute_hash(); @@ -8401,21 +8428,23 @@ mod tests { .apply_preverified_block_at(block, u64::MAX) .unwrap_err(); - assert!(format!("{error:#}").contains("multiple mine actions for one anchor")); + assert!(format!("{error:#}").contains("mine actions per anchor limit")); } #[test] - fn activated_mempool_rejects_second_mine_action_for_one_anchor() { + fn activated_mempool_rejects_mine_actions_above_anchor_limit() { let alice = Wallet::from_seed("mine-anchor-limit-active-mempool-alice"); let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); - advance_to_single_mine_activation_parent(&mut ledger, &alice); + advance_to_mine_anchor_limit_activation_parent(&mut ledger, &alice); let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); + let third_mine = test_mine_with_salt(&ledger, alice.address(), 3); ledger.submit_transaction(first_mine).unwrap(); - let error = ledger.submit_transaction(second_mine).unwrap_err(); + ledger.submit_transaction(second_mine).unwrap(); + let error = ledger.submit_transaction(third_mine).unwrap_err(); - assert!(format!("{error:#}").contains("pending mine transaction anchor already exists")); + assert!(format!("{error:#}").contains("mine transaction anchor limit reached")); } #[test]