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:
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;
},