iuna

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

commit 0d483943a77c4b63275dd1a501ead04d085f247c
parent 55c740310bb240d8d9d437441d4b078c57d06bc8
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Mon,  3 Aug 2026 23:52:21 +0200

Queue automatic burns as blinded mempool items

Diffstat:
MCargo.lock | 2+-
MCargo.toml | 2+-
Msrc/adapters/http.rs | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Msrc/app.rs | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtests/iuna.rs | 13++++---------
Mwww/assets/iuna-ui.js | 4++++
6 files changed, 239 insertions(+), 35 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "iuna" -version = "0.2.12" +version = "0.2.13" dependencies = [ "anyhow", "axum", diff --git a/Cargo.toml b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iuna" -version = "0.2.12" +version = "0.2.13" edition = "2024" license = "Apache-2.0" diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -33,8 +33,9 @@ use crate::{ FeeEstimate, NodeStatus, PeerDirection, PeerInfo, SharedNode, SharedPeerBook, StratumStatus, }, domain::{ - Amount, Block, BurnLeaderRank, ChainSnapshot, Ledger, MINE_FINALIZER_FEE, MINE_REWARD, - OutPoint, Transaction, TxInput, TxOutput, hex_hash, revealed_blinded_transactions, + Amount, BlindedReveal, BlindedTransaction, Block, BurnLeaderRank, ChainSnapshot, Ledger, + MINE_FINALIZER_FEE, MINE_REWARD, OutPoint, Transaction, TxInput, TxOutput, hex_hash, + revealed_blinded_transactions, }, }; @@ -378,6 +379,9 @@ struct UiTransaction { difficulty_bits: Option<u32>, proof_bits: Option<u32>, proof_hash: Option<String>, + commitment: Option<String>, + encrypted_size: Option<u32>, + expires_at_height: Option<u64>, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -750,14 +754,16 @@ async fn api_mempool( let node = state.node.lock().await; let snapshot = node.chain_snapshot(); let pending = node.pending_transactions(); + let pending_blinded = node.pending_blinded_transactions(); + let pending_reveals = node.pending_blinded_reveals(); let outputs = known_output_index(&snapshot, &pending); - Json(page_items( - pending - .iter() - .map(|tx| ui_transaction(tx, &outputs)) - .collect(), - query, - )) + let mut items = pending + .iter() + .map(|tx| ui_transaction(tx, &outputs)) + .collect::<Vec<_>>(); + items.extend(pending_blinded.iter().map(ui_blinded_transaction)); + items.extend(pending_reveals.iter().map(ui_blinded_reveal)); + Json(page_items(items, query)) } async fn api_wallet_transactions( @@ -1746,6 +1752,9 @@ fn ui_transaction( difficulty_bits: None, proof_bits: None, proof_hash: None, + commitment: None, + encrypted_size: None, + expires_at_height: None, }, Transaction::Burn { inputs, @@ -1766,6 +1775,9 @@ fn ui_transaction( difficulty_bits: None, proof_bits: None, proof_hash: None, + commitment: None, + encrypted_size: None, + expires_at_height: None, }, Transaction::Mine { recipient, @@ -1788,10 +1800,53 @@ fn ui_transaction( difficulty_bits: Some(*difficulty_bits), proof_bits: Some(proof_bits(signature)), proof_hash: Some(signature.clone()), + commitment: None, + encrypted_size: None, + expires_at_height: None, }, } } +fn ui_blinded_transaction(transaction: &BlindedTransaction) -> UiTransaction { + UiTransaction { + kind: "blinded", + from: "encrypted".to_string(), + to: None, + amount: 0, + fee: transaction.fee, + inputs: Vec::new(), + outputs: Vec::new(), + change: Vec::new(), + signature: transaction.commitment.clone(), + difficulty_bits: None, + proof_bits: None, + proof_hash: None, + commitment: Some(transaction.commitment.clone()), + encrypted_size: Some(transaction.encrypted_size), + expires_at_height: Some(transaction.expires_at_height), + } +} + +fn ui_blinded_reveal(reveal: &BlindedReveal) -> UiTransaction { + UiTransaction { + kind: "reveal", + from: "encrypted".to_string(), + to: None, + amount: 0, + fee: 0, + inputs: Vec::new(), + outputs: Vec::new(), + change: Vec::new(), + signature: reveal.commitment.clone(), + difficulty_bits: None, + proof_bits: None, + proof_hash: None, + commitment: Some(reveal.commitment.clone()), + encrypted_size: None, + expires_at_height: None, + } +} + fn ui_inputs( inputs: &[TxInput], outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, @@ -2885,7 +2940,7 @@ const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/iuna-ui.js?v=79"></script> + <script defer src="/assets/iuna-ui.js?v=80"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -3355,13 +3410,16 @@ const INDEX_HTML: &str = r#"<!doctype html> <template x-for="tx in mempool" :key="tx.signature"> <div class="mempool-item" role="button" tabindex="0" @click="openTransactionModal(tx, { source: 'Mempool' })" @keydown.enter.prevent="openTransactionModal(tx, { source: 'Mempool' })" @keydown.space.prevent="openTransactionModal(tx, { source: 'Mempool' })"> <span class="pill" :class="tx.kind" x-text="tx.kind"></span> - <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(tx))"></span></span></div> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">IUNA <span x-text="amountLabel(tx.fee ?? 0)"></span></span></div> - <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="short(txFrom(tx))"></code></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="short(txFrom(tx))"></code></div> <div class="tx-field" x-show="txTo(tx)"><span class="tx-label">To</span><code class="tx-value hash" x-text="short(txTo(tx))"></code></div> + <div class="tx-field" x-show="isBlindedMempoolItem(tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="short(tx.commitment || tx.signature)"></code></div> + <div class="tx-field" x-show="tx.encrypted_size || tx.encryptedSize"><span class="tx-label">Bytes</span><span class="tx-value number" x-text="tx.encrypted_size || tx.encryptedSize"></span></div> + <div class="tx-field" x-show="tx.expires_at_height || tx.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="tx.expires_at_height || tx.expiresAtHeight"></span></div> <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number"><span x-text="txProofBits(tx) ?? '-'"></span> / <span x-text="txDifficultyBits(tx) ?? '-'"></span></span></div> <div class="tx-field" x-show="isMineTx(tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="short(txProofHash(tx))"></code></div> - <div class="tx-field"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(tx)"><span class="tx-label">Signature</span><code class="tx-value hash" x-text="short(tx.signature)"></code></div> </div> </template> <template x-if="mempoolPage.loading"> @@ -3603,10 +3661,13 @@ const INDEX_HTML: &str = r#"<!doctype html> </div> <div class="tx-modal-summary"> <div class="tx-field"><span class="tx-label">Source</span><span class="tx-value text" x-text="selectedTransactionLabel()"></span></div> - <div class="tx-field"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(selectedTransaction?.tx || {}))"></span></span></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">Amount</span><span class="tx-value money">IUNA <span x-text="amountLabel(txAmount(selectedTransaction?.tx || {}))"></span></span></div> <div class="tx-field"><span class="tx-label">Fee</span><span class="tx-value money">IUNA <span x-text="amountLabel(selectedTransaction?.tx?.fee ?? 0)"></span></span></div> - <div class="tx-field"><span class="tx-label">From</span><code class="tx-value hash" x-text="txFrom(selectedTransaction?.tx || {})"></code></div> + <div class="tx-field" x-show="!isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">From</span><code class="tx-value hash" x-text="txFrom(selectedTransaction?.tx || {})"></code></div> <div class="tx-field" x-show="txTo(selectedTransaction?.tx || {})"><span class="tx-label">To</span><code class="tx-value hash" x-text="txTo(selectedTransaction?.tx || {})"></code></div> + <div class="tx-field" x-show="isBlindedMempoolItem(selectedTransaction?.tx)"><span class="tx-label">Commitment</span><code class="tx-value hash" x-text="selectedTransaction?.tx?.commitment || selectedTransaction?.tx?.signature || '-'"></code></div> + <div class="tx-field" x-show="selectedTransaction?.tx?.encrypted_size || selectedTransaction?.tx?.encryptedSize"><span class="tx-label">Encrypted Bytes</span><span class="tx-value number" x-text="selectedTransaction?.tx?.encrypted_size || selectedTransaction?.tx?.encryptedSize"></span></div> + <div class="tx-field" x-show="selectedTransaction?.tx?.expires_at_height || selectedTransaction?.tx?.expiresAtHeight"><span class="tx-label">Expires</span><span class="tx-value number" x-text="selectedTransaction?.tx?.expires_at_height || selectedTransaction?.tx?.expiresAtHeight"></span></div> <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Difficulty</span><span class="tx-value number" x-text="txDifficultyBits(selectedTransaction?.tx) ?? '-'"></span></div> <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Proof Bits</span><span class="tx-value number" x-text="txProofBits(selectedTransaction?.tx) ?? '-'"></span></div> <div class="tx-field" x-show="isMineTx(selectedTransaction?.tx)"><span class="tx-label">Proof Hash</span><code class="tx-value hash" x-text="txProofHash(selectedTransaction?.tx) || '-'"></code></div> @@ -3778,8 +3839,8 @@ mod tests { }, app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus}, domain::{ - Amount, Block, ChainSnapshot, LaunchProfile, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, - OutPoint, Transaction, Wallet, + Amount, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, LaunchProfile, Ledger, + MICRO_IUNA, MINE_FINALIZER_FEE, OutPoint, Transaction, Wallet, }, }; @@ -3800,6 +3861,34 @@ mod tests { } #[test] + fn mempool_ui_items_can_represent_blinded_transactions_and_reveals() { + let commitment = "a".repeat(64); + let blinded = BlindedTransaction { + commitment: commitment.clone(), + fee: 7, + encrypted_size: 123, + expires_at_height: 42, + nonce: "00".repeat(12), + ciphertext: "11".repeat(123), + payload_hash: "b".repeat(64), + }; + let reveal = BlindedReveal { + commitment: commitment.clone(), + key: "22".repeat(32), + }; + + let blinded_row = super::ui_blinded_transaction(&blinded); + let reveal_row = super::ui_blinded_reveal(&reveal); + + assert_eq!(blinded_row.kind, "blinded"); + assert_eq!(blinded_row.signature, commitment); + assert_eq!(blinded_row.encrypted_size, Some(123)); + assert_eq!(blinded_row.expires_at_height, Some(42)); + assert_eq!(reveal_row.kind, "reveal"); + assert_eq!(reveal_row.commitment, blinded_row.commitment); + } + + #[test] fn password_policy_rejects_short_or_excessive_passwords() { let short = validate_password("too-short").unwrap_err(); assert!(short.to_string().contains("at least 12")); @@ -4810,7 +4899,7 @@ mod tests { #[test] fn metrics_screen_includes_block_range_filter() { - assert!(super::INDEX_HTML.contains("iuna-ui.js?v=79")); + assert!(super::INDEX_HTML.contains("iuna-ui.js?v=80")); 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/app.rs b/src/app.rs @@ -36,6 +36,8 @@ pub const PEER_MISBEHAVIOR_BAN_MS: u64 = 10 * 60 * 1_000; pub const PEER_CLOCK_OFFSET_ACCEPTANCE_MS: i64 = 10 * 60 * 1_000; const PEER_CLOCK_OFFSET_STALE_MS: u64 = 20 * 60 * 1_000; const AUTO_POW_NONCE_ATTEMPTS_PER_TICK: u64 = 8; +const AUTO_BLINDED_BURN_EXPIRY_HEIGHTS: u64 = 20; +const AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS: u64 = 60_000; static DEBUG_LOGGING: AtomicBool = AtomicBool::new(false); pub fn set_debug_logging(enabled: bool) { @@ -413,6 +415,14 @@ impl NodeCore { self.ledger.pending().to_vec() } + pub fn pending_blinded_transactions(&self) -> Vec<BlindedTransaction> { + self.ledger.pending_blinded_transactions().to_vec() + } + + pub fn pending_blinded_reveals(&self) -> Vec<BlindedReveal> { + self.ledger.pending_blinded_reveals().to_vec() + } + pub fn mempool_inventory(&self, limit: usize) -> Vec<String> { let mut signatures = self .ledger @@ -634,7 +644,7 @@ impl NodeCore { if was_disabled && enabled && amount > 0 { self.last_auto_burn_height = None; } - self.prepare_automatic_burn() + self.prepare_automatic_burn(now_ms()) } pub fn set_pow_mining_enabled(&mut self, enabled: bool) { @@ -996,7 +1006,7 @@ impl NodeCore { return plan; } - match self.prepare_automatic_burn() { + match self.prepare_automatic_burn(timestamp_ms) { Ok(tx) => plan.burned = tx, Err(error) => { plan.skipped_reason = Some(format!("automatic burn failed: {error:#}")); @@ -1079,7 +1089,7 @@ impl NodeCore { return plan; } - match self.prepare_automatic_burn() { + match self.prepare_automatic_burn(timestamp_ms) { Ok(tx) => plan.burned = tx, Err(error) => { plan.skipped_reason = Some(format!("automatic burn failed: {error:#}")); @@ -1194,7 +1204,7 @@ impl NodeCore { Ok(None) } - fn prepare_automatic_burn(&mut self) -> Result<Option<Transaction>> { + fn prepare_automatic_burn(&mut self, timestamp_ms: u64) -> Result<Option<Transaction>> { let current_height = self.ledger.status().height; if !self.automatic_mining_enabled { return Ok(None); @@ -1238,13 +1248,32 @@ impl NodeCore { self.last_auto_burn_height = Some(current_height); return Ok(None); }; - if self.ledger.submit_transaction(tx.clone())? { - self.outbox.push(GossipEnvelope::Transaction(tx.clone())); + if self.automatic_burn_needs_plaintext_anchor(timestamp_ms) { + if self.ledger.submit_transaction(tx.clone())? { + self.outbox.push(GossipEnvelope::Transaction(tx.clone())); + } + } else { + let built = self.ledger.build_blinded_burn( + self.wallet.unlocked()?, + tx.amount(), + tx.fee(), + current_height.saturating_add(AUTO_BLINDED_BURN_EXPIRY_HEIGHTS), + )?; + self.submit_owned_blinded_transaction(built)?; } self.last_auto_burn_height = Some(current_height); Ok(Some(tx)) } + fn automatic_burn_needs_plaintext_anchor(&self, timestamp_ms: u64) -> bool { + self.ledger + .expected_leader_for_next_block() + .is_none_or(|leader| leader == self.wallet.address()) + || self.ledger.recovery_block_available_at(timestamp_ms) + || timestamp_ms.saturating_add(AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS) + >= self.ledger.recovery_block_min_timestamp() + } + pub fn mine_one_at(&mut self, timestamp_ms: u64) -> Result<Block> { let block = self .ledger @@ -2311,6 +2340,93 @@ mod tests { GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == blinded.commitment ))); } + + #[test] + fn automatic_non_leader_burn_is_queued_as_blinded() { + let alice = Wallet::from_seed("auto-blinded-burn-alice"); + let bob = Wallet::from_seed("auto-blinded-burn-bob"); + let finalizers = [alice.clone(), bob.clone()]; + 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, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + let leader = ledger.expected_leader_for_next_block().unwrap(); + let non_leader = finalizers + .iter() + .find(|wallet| wallet.address() != leader) + .unwrap() + .clone(); + let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( + non_leader, + ledger, + true, + MICRO_IUNA / 10, + 1, + ); + + let plan = node.prepare_automatic_finalization(1); + let outbox = node.drain_outbox(); + + assert!(plan.burned.is_some()); + assert!(node.ledger().pending().is_empty()); + assert_eq!(node.ledger().pending_blinded_transactions().len(), 1); + assert!( + outbox + .iter() + .any(|envelope| matches!(envelope, GossipEnvelope::BlindedTransaction(_))) + ); + } + + #[test] + fn automatic_leader_burn_stays_plaintext_for_block_anchor() { + let alice = Wallet::from_seed("auto-plaintext-burn-alice"); + let bob = Wallet::from_seed("auto-plaintext-burn-bob"); + let finalizers = [alice.clone(), bob.clone()]; + 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, + finalizers + .iter() + .map(|wallet| GenesisBurn::new(wallet.address(), MICRO_IUNA)) + .collect(), + 1, + ) + .unwrap(); + let leader = ledger.expected_leader_for_next_block().unwrap(); + let leader_wallet = finalizers + .iter() + .find(|wallet| wallet.address() == leader) + .unwrap() + .clone(); + let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled( + leader_wallet, + ledger, + true, + MICRO_IUNA / 10, + 1, + ); + + let plan = node.prepare_automatic_finalization(1); + let outbox = node.drain_outbox(); + + assert!(plan.burned.is_some()); + assert_eq!(node.ledger().pending().len(), 1); + assert!(node.ledger().pending_blinded_transactions().is_empty()); + assert!(outbox.iter().any(|envelope| matches!( + envelope, + GossipEnvelope::Transaction(transaction) if transaction.is_burn() + ))); + } } #[derive(Debug, Default)] diff --git a/tests/iuna.rs b/tests/iuna.rs @@ -777,13 +777,8 @@ fn waiting_wallet_gossips_pending_burn_to_selected_leader() { Some(bob.address()) ); let mut alice_node = NodeCore::from_ledger(alice.clone(), ledger.clone(), 1); - let mut bob_node = NodeCore::from_ledger_with_burn_fee_and_enabled( - bob.clone(), - ledger, - true, - DEFAULT_BURN_PER_BLOCK, - MICRO_IUNA, - ); + let mut bob_node = + NodeCore::from_ledger_with_burn_fee_and_enabled(bob.clone(), ledger, true, 1, MICRO_IUNA); let alice_outcome = alice_node.automatic_mine_once(1); assert!(alice_outcome.burned.is_some()); @@ -800,8 +795,8 @@ fn waiting_wallet_gossips_pending_burn_to_selected_leader() { bob_node.receive(envelope).unwrap(); } - assert_eq!(bob_node.ledger().pending().len(), 1); - assert_eq!(bob_node.ledger().pending()[0].sender(), alice.address()); + assert!(bob_node.ledger().pending().is_empty()); + assert_eq!(bob_node.ledger().pending_blinded_transactions().len(), 1); let bob_outcome = bob_node.automatic_mine_once(1); assert!(bob_outcome.skipped_reason.is_none()); assert!(bob_outcome.block.is_some()); diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -1548,6 +1548,10 @@ window.iunaApp = function iunaApp() { return tx?.kind === "mine"; }, + isBlindedMempoolItem(tx) { + return tx?.kind === "blinded" || tx?.kind === "reveal"; + }, + txDifficultyBits(tx) { return tx?.difficulty_bits ?? tx?.difficultyBits ?? null; },