iuna

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

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

Limit mine actions per anchor

Diffstat:
MCargo.lock | 2+-
MCargo.toml | 2+-
Mdocs/protocol.md | 6++++--
Msrc/app.rs | 91++++++++++++++++++++++++++++++++-----------------------------------------------
Msrc/domain.rs | 236++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
5 files changed, 272 insertions(+), 65 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "iuna" -version = "0.2.36" +version = "0.2.37" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iuna" -version = "0.2.36" +version = "0.2.37" edition = "2024" license = "Apache-2.0" diff --git a/docs/protocol.md b/docs/protocol.md @@ -110,11 +110,13 @@ Difficulty targets about one mine action per block: - The retarget window is `10` blocks. - The target is `10` mine actions per window. - Difficulty can move by at most `2` bits per window. -- Difficulty is clamped between `1` and `32` bits in the devnet profile. +- Difficulty is clamped between `10` and `32` bits in the devnet profile. - Mine actions expire when their anchor is too old. 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. + ## Fair Burn Inclusion The central censorship risk is simple: what if a finalizer only includes its own burns and ignores everyone else's burns? @@ -181,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. +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. 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 @@ -1574,6 +1574,13 @@ impl NodeCore { .last() .map(|block| block.hash.clone()) .context("ledger has no anchor block")?; + if self.ledger.has_pending_mine_for_anchor(&anchor) { + 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()); + self.auto_pow_mine_cursor = None; + return Ok(None); + } let wallet_address = self.wallet.address().to_string(); let needs_cursor = self .auto_pow_mine_cursor @@ -1592,49 +1599,31 @@ impl NodeCore { .as_ref() .context("automatic PoW cursor was not initialized")? .clone(); - let budget = AUTO_POW_NONCE_ATTEMPTS_PER_WORKER_TICK - .saturating_mul(u64::from(self.pow_mining_workers)); - let mut remaining = budget; - let mut next_nonce = cursor.next_nonce; - let mut tick_attempts = 0_u64; - let mut queued = 0_u64; - let mut first_tx = None; - while remaining > 0 { - let outcome = self.wallet_build_ledger()?.search_mine( - wallet_address.clone(), - cursor.salt, - next_nonce, - remaining, - )?; - remaining = remaining.saturating_sub(outcome.attempts); - tick_attempts = tick_attempts.saturating_add(outcome.attempts); - next_nonce = outcome.next_nonce; - let Some(tx) = outcome.transaction else { - break; - }; - self.submit_public_mine_action(tx.clone())?; - queued = queued.saturating_add(1); - first_tx.get_or_insert(tx); - } - - let mut searched = tick_attempts; + let outcome = self.wallet_build_ledger()?.search_mine( + wallet_address, + cursor.salt, + cursor.next_nonce, + AUTO_POW_NONCE_ATTEMPTS_PER_WORKER_TICK + .saturating_mul(u64::from(self.pow_mining_workers)), + )?; + let mut searched = outcome.attempts; if let Some(cursor) = &mut self.auto_pow_mine_cursor { if cursor.anchor == anchor { - cursor.next_nonce = next_nonce; - cursor.searched = cursor.searched.saturating_add(tick_attempts); + cursor.next_nonce = outcome.next_nonce; + cursor.searched = cursor.searched.saturating_add(outcome.attempts); searched = cursor.searched; } } - let Some(tx) = first_tx else { + let Some(tx) = outcome.transaction else { self.last_auto_pow_mine_status = Some(format!( "searched {searched} PoW nonces for the current tip; no proof yet" )); return Ok(None); }; + self.submit_public_mine_action(tx.clone())?; self.last_auto_pow_mine_anchor = Some(anchor); self.last_auto_pow_mine_status = Some(format!( - "queued {queued} mine action{} after {searched} PoW nonce attempts for the current tip", - if queued == 1 { "" } else { "s" } + "queued mine action after {searched} PoW nonce attempts for the current tip" )); Ok(Some(tx)) } @@ -2659,20 +2648,19 @@ mod tests { .contains("queued") ); - let second = (10_000..20_000) - .map(|timestamp| node.prepare_automatic_mining(timestamp)) - .find(|plan| plan.pow_mined.is_some()) - .expect("automatic PoW should keep searching the same tip after one proof"); - assert_ne!( - second.pow_mined.as_ref().unwrap().signature(), - first_mine.signature() + for timestamp in 10_000..10_010 { + assert!(node.prepare_automatic_mining(timestamp).pow_mined.is_none()); + } + assert_eq!(node.ledger().pending().len(), first_pending); + assert_eq!( + node.status().mining.last_auto_pow_mine_status.as_deref(), + Some("waiting for next chain tip after queued mine action") ); - assert!(node.ledger().pending().len() > first_pending); assert!(node.ledger().pending_blinded_transactions().is_empty()); } #[test] - fn automatic_pow_mining_can_tick_without_finalization() { + fn automatic_pow_mining_waits_after_queueing_for_tip() { let wallet = Wallet::from_seed("automatic-pow-independent-wallet"); let mut allocations = BTreeMap::new(); allocations.insert(wallet.address().to_string(), 1); @@ -2687,21 +2675,16 @@ mod tests { }); node.set_pow_mining_enabled(true); - node.prepare_automatic_pow_mining().unwrap(); - let first_searched = node - .auto_pow_mine_cursor - .as_ref() - .expect("PoW cursor should be initialized") - .searched; + let mined = (1..10_000) + .find_map(|_| node.prepare_automatic_pow_mining().unwrap()) + .expect("PoW should eventually queue a mine action"); - node.prepare_automatic_pow_mining().unwrap(); - let second_searched = node - .auto_pow_mine_cursor - .as_ref() - .expect("PoW cursor should keep tracking the current tip") - .searched; - - assert!(second_searched > first_searched); + assert!(node.ledger().has_pending_mine_for_anchor(match mined { + Transaction::Mine { ref anchor, .. } => anchor, + _ => panic!("expected mine action"), + })); + assert!(node.prepare_automatic_pow_mining().unwrap().is_none()); + assert!(node.auto_pow_mine_cursor.is_none()); } #[test] diff --git a/src/domain.rs b/src/domain.rs @@ -26,6 +26,7 @@ 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 MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS: u64 = 20; pub const REVEAL_COMMITTEE_SIZE: usize = 3; pub const MAX_REVEAL_BUNDLE_BYTES: usize = 10_000; @@ -2475,6 +2476,12 @@ impl Ledger { self.transaction_by_signature(signature).is_some() } + pub fn has_pending_mine_for_anchor(&self, anchor: &str) -> bool { + self.pending + .iter() + .any(|tx| mine_anchor(tx) == Some(anchor)) + } + pub fn has_blinded_transaction(&self, commitment: &str) -> bool { self.pending_blinded .iter() @@ -2978,6 +2985,7 @@ impl Ledger { transaction.verify_signature()?; self.validate_transaction_terms(&transaction)?; + self.validate_mine_anchor_available(&transaction)?; if transaction_inputs_spent_by(&transaction, &self.pending) { return Ok(TransactionSubmitOutcome::ConflictsWithPending); @@ -3396,6 +3404,7 @@ impl Ledger { if block.serialized_size_bytes()? > self.launch_profile.max_block_bytes { bail!("block exceeds max block size"); } + ensure_mine_anchor_limit(block.height, &block.transactions)?; ensure_block_has_burn(&block.transactions)?; self.validate_reveal_bundle_section_for_block( block.height, @@ -3494,15 +3503,28 @@ 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; 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 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()); + } valid.push(tx.clone()); progressed = true; } else { @@ -3727,10 +3749,36 @@ impl Ledger { fn validate_new_transaction(&self, transaction: &Transaction) -> Result<()> { self.validate_transaction_terms(transaction)?; + self.validate_mine_anchor_available(transaction)?; let mut utxos = self.utxos_after_spendable_pending()?; apply_transaction(transaction, &mut utxos) } + fn validate_mine_anchor_available(&self, transaction: &Transaction) -> Result<()> { + if single_mine_per_anchor_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"); + } + } + } + Ok(()) + } + fn promote_orphan_transactions(&mut self) -> Result<()> { loop { if self.pending.len() >= MAX_PENDING_TRANSACTIONS { @@ -3745,7 +3793,7 @@ impl Ledger { if transaction_has_missing_inputs(transaction, &utxos) { continue; } - if self.validate_transaction_terms(transaction).is_ok() + if self.validate_new_transaction(transaction).is_ok() && apply_transaction(transaction, &mut utxos).is_ok() { promoted_index = Some(index); @@ -4375,6 +4423,45 @@ fn ensure_block_has_burn(transactions: &[Transaction]) -> Result<()> { Ok(()) } +fn ensure_mine_anchor_limit(height: u64, transactions: &[Transaction]) -> Result<()> { + if !single_mine_per_anchor_active(height) { + return Ok(()); + } + let mut anchors = BTreeSet::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"); + } + } + Ok(()) +} + +fn single_mine_per_anchor_active(height: u64) -> bool { + height >= SINGLE_MINE_PER_ANCHOR_ACTIVATION_HEIGHT +} + +fn mine_anchor(transaction: &Transaction) -> Option<&str> { + match transaction { + Transaction::Mine { anchor, .. } => Some(anchor.as_str()), + _ => None, + } +} + +fn mine_anchor_was_used_before_height(chain: &[Block], anchor: &str, height: u64) -> bool { + chain + .iter() + .take_while(|block| block.height <= height) + .any(|block| { + block + .transactions + .iter() + .any(|transaction| mine_anchor(transaction) == Some(anchor)) + }) +} + fn ensure_block_has_burn_from(transactions: &[Transaction], miner: &str) -> Result<()> { if !transactions .iter() @@ -5887,6 +5974,40 @@ mod tests { ledger.apply_block(block).unwrap(); } + fn test_mine_with_salt(ledger: &Ledger, recipient: &str, salt: u64) -> Transaction { + let anchor = ledger.tip().hash.clone(); + let difficulty_bits = ledger.current_mine_difficulty_bits(); + for nonce in 0..u64::MAX { + let signature = mine_signature(recipient, &anchor, salt, nonce, difficulty_bits); + if hash_meets_difficulty(&signature, difficulty_bits) { + return Transaction::Mine { + recipient: recipient.to_string(), + anchor, + salt, + nonce, + difficulty_bits, + proof_header: None, + signature, + }; + } + } + 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 { + let timestamp_ms = ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS); + apply_preverified_burn_block_at(ledger, wallet, timestamp_ms); + } + assert_eq!( + ledger.height().saturating_add(1), + SINGLE_MINE_PER_ANCHOR_ACTIVATION_HEIGHT + ); + } + fn apply_preverified_burn_block_at( ledger: &mut Ledger, wallet: &Wallet, @@ -5904,6 +6025,33 @@ mod tests { block } + fn apply_preverified_burn_block_with_mines( + ledger: &mut Ledger, + wallet: &Wallet, + mine_actions: usize, + ) { + let burn = ledger.build_burn(wallet, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let timestamp_ms = ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS); + let mut block = ledger + .prepare_next_block(wallet.address(), timestamp_ms) + .unwrap() + .finish(wallet, "preverified-vdf".to_string()); + for salt in 0..mine_actions { + block.transactions.push(test_mine_with_salt( + ledger, + wallet.address(), + salt as u64 + 1, + )); + } + block.reward = fee_reward(&block.transactions).unwrap(); + block.hash = block.compute_hash(); + ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); + } + fn vdf_retarget_sample_block( timestamp_ms: u64, finalizer_mode: FinalizerMode, @@ -8164,8 +8312,8 @@ mod tests { } #[test] - fn block_selection_can_include_multiple_mine_actions() { - let alice = Wallet::from_seed("mine-multiple-actions-alice"); + fn block_selection_limits_mine_actions_to_one_per_anchor() { + let alice = Wallet::from_seed("mine-single-anchor-selection-alice"); let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA); let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); @@ -8178,8 +8326,14 @@ mod tests { assert_eq!(ledger.pending().len(), 3); let block = ledger.mine_next_block(&alice, 1).unwrap(); - assert_eq!(block.transactions.len(), 3); + assert_eq!(block.transactions.len(), 2); 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!( block .transactions @@ -8187,13 +8341,81 @@ mod tests { .any(|tx| tx.signature() == first_mine.signature()) ); assert!( - block + !block .transactions .iter() .any(|tx| tx.signature() == second_mine.signature()) ); assert_ne!(first_mine.signature(), second_mine.signature()); - assert_eq!(block.reward, first_mine.fee() + second_mine.fee()); + assert_eq!(block.reward, first_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); + + let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); + let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); + let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let mut block = ledger + .prepare_next_block(alice.address(), 1) + .unwrap() + .finish(&alice, "preverified-vdf".to_string()); + block.transactions.push(first_mine); + block.transactions.push(second_mine); + block.reward = fee_reward(&block.transactions).unwrap(); + block.hash = block.compute_hash(); + + ledger.apply_preverified_block_at(block, u64::MAX).unwrap(); + } + + #[test] + fn activated_blocks_reject_multiple_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); + + let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); + let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); + let burn = ledger.build_burn(&alice, MICRO_IUNA, 0).unwrap(); + ledger.submit_transaction(burn).unwrap(); + let mut block = ledger + .prepare_next_block( + alice.address(), + ledger + .tip() + .timestamp_ms + .saturating_add(VDF_TARGET_BLOCK_MS), + ) + .unwrap() + .finish(&alice, "preverified-vdf".to_string()); + block.transactions.push(first_mine); + block.transactions.push(second_mine); + block.reward = fee_reward(&block.transactions).unwrap(); + block.hash = block.compute_hash(); + + let error = ledger + .apply_preverified_block_at(block, u64::MAX) + .unwrap_err(); + + assert!(format!("{error:#}").contains("multiple mine actions for one anchor")); + } + + #[test] + fn activated_mempool_rejects_second_mine_action_for_one_anchor() { + 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); + + let first_mine = test_mine_with_salt(&ledger, alice.address(), 1); + let second_mine = test_mine_with_salt(&ledger, alice.address(), 2); + ledger.submit_transaction(first_mine).unwrap(); + let error = ledger.submit_transaction(second_mine).unwrap_err(); + + assert!(format!("{error:#}").contains("pending mine transaction anchor already exists")); } #[test] @@ -8202,7 +8424,7 @@ mod tests { let mut ledger = ledger_with_allocation(&alice, 100 * MICRO_IUNA); for _ in 0..MINE_RETARGET_WINDOW_BLOCKS { - mine_burn_block_with_mines(&mut ledger, &alice, 2); + apply_preverified_burn_block_with_mines(&mut ledger, &alice, 2); } assert_eq!(