commit 86041b1cd9746a7e0d7bad73c5f44c3633118db8
parent 94cb4e3a98893e05c380cdcb0a74e82bdc6d89fb
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Thu, 6 Aug 2026 12:12:23 +0200
Prepare v0.2.28
Diffstat:
5 files changed, 183 insertions(+), 7 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -537,7 +537,7 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "iuna"
-version = "0.2.27"
+version = "0.2.28"
dependencies = [
"anyhow",
"axum",
diff --git a/Cargo.toml b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "iuna"
-version = "0.2.27"
+version = "0.2.28"
edition = "2024"
license = "Apache-2.0"
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -4207,9 +4207,9 @@ mod tests {
},
app::{GossipEnvelope, NodeCore, PeerBook, PeerDirection, PeerInfo, StratumStatus},
domain::{
- Amount, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, LaunchProfile, Ledger,
- MICRO_IUNA, MINE_FINALIZER_FEE, MaskedBlindedReveal, OutPoint, RevealBundleSection,
- RevealBundleSignature, Transaction, TxInput, TxOutput, Wallet,
+ Amount, BlindedReveal, BlindedTransaction, Block, ChainSnapshot, GenesisBurn,
+ LaunchProfile, Ledger, MICRO_IUNA, MINE_FINALIZER_FEE, MaskedBlindedReveal, OutPoint,
+ RevealBundleSection, RevealBundleSignature, Transaction, TxInput, TxOutput, Wallet,
},
};
@@ -5340,6 +5340,28 @@ mod tests {
assert!(!selectable.iter().any(|row| row.outpoint == spent_outpoint));
}
+ #[test]
+ fn wallet_utxo_rows_keep_local_anchor_spends_visible_as_pending() {
+ let alice = Wallet::from_seed("wallet-utxo-local-anchor-alice");
+ let mut allocations = BTreeMap::new();
+ allocations.insert(alice.address().to_string(), 10 * MICRO_IUNA);
+ let ledger = Ledger::new_with_genesis_burns(
+ allocations,
+ vec![GenesisBurn::new(alice.address(), MICRO_IUNA)],
+ 1,
+ )
+ .unwrap();
+ let mut node =
+ NodeCore::from_ledger_with_burn_fee_and_enabled(alice.clone(), ledger, true, 1, 0);
+
+ let plan = node.prepare_automatic_finalization(1);
+ assert!(plan.burned.is_some());
+ let rows = wallet_utxo_rows(&node.wallet_view_ledger().unwrap(), alice.address());
+
+ assert!(!rows.is_empty());
+ assert!(rows.iter().any(|row| !row.spendable));
+ }
+
fn fake_block(height: u64, transactions: Vec<Transaction>) -> Block {
Block {
height,
diff --git a/src/app.rs b/src/app.rs
@@ -388,7 +388,10 @@ impl NodeCore {
}
pub fn wallet_view_ledger(&self) -> Result<Ledger> {
- self.wallet_build_ledger()
+ let mut ledger = self.ledger.clone();
+ self.queue_local_block_anchor(&mut ledger)?;
+ self.queue_owned_blinded_payloads(&mut ledger)?;
+ Ok(ledger)
}
pub fn chain(&self) -> &[Block] {
@@ -682,6 +685,17 @@ impl NodeCore {
}
}
+ if let Some((height, burn)) = &self.local_block_anchor_burn {
+ if *height == self.ledger.height() && !self.ledger.has_transaction(burn.signature()) {
+ let output_total = transaction_output_total_for_address(burn, address);
+ let input_total =
+ transaction_input_total_from_outputs(burn, address, &confirmed_outputs);
+ balance = balance
+ .saturating_sub(input_total)
+ .saturating_add(output_total);
+ }
+ }
+
balance
}
@@ -906,12 +920,28 @@ impl NodeCore {
}
pub fn receive_blinded_transaction(&mut self, tx: BlindedTransaction) -> Result<()> {
+ if self.blinded_transaction_conflicts_with_local_anchor(&tx) {
+ return Ok(());
+ }
if self.ledger.submit_blinded_transaction(tx.clone())? {
self.outbox.push(GossipEnvelope::BlindedTransaction(tx));
}
Ok(())
}
+ fn blinded_transaction_conflicts_with_local_anchor(&self, tx: &BlindedTransaction) -> bool {
+ let Some((height, burn)) = &self.local_block_anchor_burn else {
+ return false;
+ };
+ if *height != self.ledger.height() || self.ledger.has_transaction(burn.signature()) {
+ return false;
+ }
+ let anchor_inputs = transaction_input_outpoints(burn);
+ tx.inputs
+ .iter()
+ .any(|input| anchor_inputs.contains(&input.outpoint))
+ }
+
pub fn receive_blinded_reveal(&mut self, reveal: BlindedReveal) -> Result<()> {
self.receive_blinded_reveal_without_bundle_publish(reveal)?;
Ok(())
@@ -1187,6 +1217,11 @@ impl NodeCore {
fn wallet_build_ledger(&self) -> Result<Ledger> {
let mut ledger = self.ledger.clone();
self.reserve_local_block_anchor_inputs(&mut ledger)?;
+ self.queue_owned_blinded_payloads(&mut ledger)?;
+ 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)
&& !ledger.has_transaction(payload.signature())
@@ -1194,7 +1229,17 @@ impl NodeCore {
let _ = ledger.submit_transaction(payload.clone())?;
}
}
- Ok(ledger)
+ Ok(())
+ }
+
+ fn queue_local_block_anchor(&self, ledger: &mut Ledger) -> Result<()> {
+ let Some((height, burn)) = &self.local_block_anchor_burn else {
+ return Ok(());
+ };
+ if *height == ledger.height() && !ledger.has_transaction(burn.signature()) {
+ let _ = ledger.submit_transaction(burn.clone())?;
+ }
+ Ok(())
}
fn reserve_local_block_anchor_inputs(&self, ledger: &mut Ledger) -> Result<()> {
@@ -2228,6 +2273,16 @@ fn transaction_input_total_from_outputs(
.fold(0_u64, |total, amount| total.saturating_add(*amount))
}
+fn transaction_input_outpoints(transaction: &Transaction) -> BTreeSet<OutPoint> {
+ match transaction {
+ Transaction::Transfer { inputs, .. } | Transaction::Burn { inputs, .. } => inputs,
+ Transaction::Mine { .. } => return BTreeSet::new(),
+ }
+ .iter()
+ .map(|input| input.outpoint.clone())
+ .collect()
+}
+
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
@@ -3148,6 +3203,7 @@ mod tests {
let plan = node.prepare_automatic_finalization(1);
assert!(plan.burned.is_some());
+ assert!(node.status().wallet_balance < node.ledger().balance_of(node.wallet_address()));
let (_, anchor_burn) = node
.local_block_anchor_burn
.clone()
@@ -3193,6 +3249,63 @@ mod tests {
}
#[test]
+ fn inbound_blinded_transaction_conflicting_with_local_anchor_is_not_queued() {
+ let alice = Wallet::from_seed("local-anchor-inbound-alice");
+ let bob = Wallet::from_seed("local-anchor-inbound-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.clone(),
+ ledger,
+ true,
+ MICRO_IUNA / 10,
+ 1,
+ );
+
+ let plan = node.prepare_automatic_finalization(1);
+ assert!(plan.burned.is_some());
+ let (_, anchor_burn) = node
+ .local_block_anchor_burn
+ .clone()
+ .expect("leader burn should be held as a local block anchor");
+ let anchor_inputs = super::transaction_input_outpoints(&anchor_burn)
+ .into_iter()
+ .collect::<Vec<_>>();
+ let conflicting_payload = node
+ .ledger()
+ .build_transfer_with_inputs(&leader_wallet, bob.address(), 1, 0, &anchor_inputs)
+ .unwrap();
+ let conflicting = node
+ .ledger()
+ .build_blinded_transaction(&leader_wallet, conflicting_payload, node.chain_height() + 4)
+ .unwrap();
+
+ node.receive_blinded_transaction(conflicting.transaction)
+ .unwrap();
+
+ assert!(node.ledger().pending_blinded_transactions().is_empty());
+ assert!(node.drain_outbox().is_empty());
+ assert!(node.prepare_automatic_finalization(1).work.is_some());
+ }
+
+ #[test]
fn locally_produced_blocks_import_on_independent_peer_ledger() {
let alice = Wallet::from_seed("producer-parity-alice");
let bob = Wallet::from_seed("producer-parity-bob");
diff --git a/src/domain.rs b/src/domain.rs
@@ -1071,6 +1071,16 @@ fn transaction_inputs_available(
.all(|input| utxos.contains_key(&input.outpoint))
}
+fn blinded_transaction_inputs_available(
+ transaction: &BlindedTransaction,
+ utxos: &BTreeMap<OutPoint, TxOutput>,
+) -> bool {
+ transaction
+ .inputs
+ .iter()
+ .all(|input| utxos.contains_key(&input.outpoint))
+}
+
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct Block {
pub height: u64,
@@ -3202,6 +3212,7 @@ impl Ledger {
.filter(|transaction| {
!included_blinded.contains(&transaction.commitment)
&& new_height < transaction.expires_at_height
+ && blinded_transaction_inputs_available(transaction, &available)
&& self.validate_blinded_transaction(transaction).is_ok()
})
.collect();
@@ -7859,6 +7870,36 @@ mod tests {
}
#[test]
+ fn pending_blinded_transactions_with_spent_inputs_are_pruned_after_block_apply() {
+ let alice = Wallet::from_seed("pending-blind-spent-prune-alice");
+ let mut mempool_ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);
+ let mut block_ledger = mempool_ledger.clone();
+ let amount = mempool_ledger.balance_of(alice.address());
+ let blinded = mempool_ledger
+ .build_blinded_burn(&alice, amount, 0, mempool_ledger.height() + 4)
+ .unwrap();
+ mempool_ledger
+ .submit_blinded_transaction(blinded.transaction.clone())
+ .unwrap();
+
+ let burn = block_ledger.build_burn(&alice, amount, 0).unwrap();
+ let Transaction::Burn { inputs, .. } = &burn else {
+ panic!("expected burn transaction");
+ };
+ assert!(blinded.transaction.inputs.iter().any(|input| {
+ inputs
+ .iter()
+ .any(|burn_input| burn_input.outpoint == input.outpoint)
+ }));
+ block_ledger.submit_transaction(burn).unwrap();
+ let block = block_ledger.mine_next_block(&alice, 1).unwrap();
+
+ mempool_ledger.apply_block(block).unwrap();
+
+ assert!(mempool_ledger.pending_blinded_transactions().is_empty());
+ }
+
+ #[test]
fn block_selection_can_include_multiple_mine_actions() {
let alice = Wallet::from_seed("mine-multiple-actions-alice");
let mut ledger = ledger_with_allocation(&alice, 10 * MICRO_IUNA);