iuna

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

commit df91f307f56dad5a3088db261264c316942eaa6b
parent 1771ea96b4f8a287cd46dfd443c22ba3c6b68568
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 11 Aug 2026 11:24:29 +0200

Improve reveal bundle timing and UTXO selection

Diffstat:
Mdocs/protocol.md | 2++
Msrc/adapters/http/index_html.rs | 6+++---
Msrc/adapters/http/tests.rs | 28+++++++++++++++++++++++++++-
Msrc/app.rs | 34+++++++++++++++++++++++++++++++---
Msrc/app/automatic_mining.rs | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/app/gossip.rs | 1-
Msrc/app/in_memory_network.rs | 18++++++++++++++----
Msrc/app/node_lifecycle.rs | 4++++
Msrc/app/receive.rs | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mtests/properties.rs | 35+++++++++++++++++++++++++++++++----
Mwww/assets/iuna-ui.js | 47+++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 293 insertions(+), 32 deletions(-)

diff --git a/docs/protocol.md b/docs/protocol.md @@ -138,6 +138,8 @@ Reveal is a later step. A `BlindedReveal` carries only the commitment and decryp For each next block height, nodes compute a reveal committee from the burn leader ranking. Slot `0` is assigned to the rank `0` block finalizer, so the selected finalizer can always sign a reveal list for its own block. The remaining slots are assigned to the two lowest-ranked eligible tickets. A committee member can sign one bundle for its slot, height, and parent hash. A bundle is at most `10,000` bytes and lists valid pending reveals ordered by visible fee rate. Empty bundles are not gossiped. +Automatic nodes wait about `30 seconds` after seeing pending reveals for the next height before signing a reveal bundle or starting the reveal-bound VDF. This gives reveal gossip time to settle and avoids locking in an underfilled bundle from the first partial batch a node received. + A block has an envelope section and one compact reveal-bundle section. The envelope section contains the finalizer's plaintext anchor burn, public mine actions, and blinded transaction envelopes. The compact reveal-bundle section stores: diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -442,7 +442,7 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> .block-card { flex-basis: 108px; } } </style> - <script defer src="/assets/iuna-ui.js?v=100"></script> + <script defer src="/assets/iuna-ui.js?v=101"></script> <script defer src="/assets/alpine.min.js"></script> </head> <body x-data="iunaApp()" x-init="init()" @keydown.window.escape="closeModals()" x-cloak> @@ -535,8 +535,8 @@ pub(super) const INDEX_HTML: &str = r#"<!doctype html> </span> </div> <template x-for="utxo in walletUtxos" :key="utxoOutpoint(utxo)"> - <label class="send-utxo-option" :class="{ disabled: !utxo.spendable }"> - <input type="checkbox" :value="utxoOutpoint(utxo)" x-model="selectedTransferUtxos" @change="scheduleFeeEstimates" :disabled="!utxo.spendable"> + <label class="send-utxo-option" :class="{ disabled: !utxo.spendable }" @click.prevent="toggleTransferUtxoSelection($event, utxo)"> + <input type="checkbox" :value="utxoOutpoint(utxo)" :checked="selectedTransferUtxos.includes(utxoOutpoint(utxo))" :disabled="!utxo.spendable"> <span> <span class="utxo-node-label"><span>UTXO</span><span class="utxo-node-amount">IUNA <span x-text="amountLabel(utxo.amount)"></span></span></span> <span class="utxo-status" x-show="!utxo.spendable">Pending</span> diff --git a/src/adapters/http/tests.rs b/src/adapters/http/tests.rs @@ -1465,7 +1465,7 @@ fn metrics_response_skips_bootstrap_points_for_block_time_and_vdf_rounds() { #[test] fn metrics_screen_includes_block_range_filter() { - assert!(super::INDEX_HTML.contains("iuna-ui.js?v=100")); + assert!(super::INDEX_HTML.contains("iuna-ui.js?v=101")); assert!(super::INDEX_HTML.contains("aria-label=\"Metrics block range\"")); assert!(super::INDEX_HTML.contains("setMetricsRange(100)")); assert!(super::INDEX_HTML.contains("setMetricsRange(1000)")); @@ -1475,6 +1475,32 @@ fn metrics_screen_includes_block_range_filter() { } #[test] +fn transfer_utxo_selection_supports_shift_click_ranges() { + let app_js = include_str!("../../../www/assets/iuna-ui.js"); + assert!( + super::INDEX_HTML + .contains(":checked=\"selectedTransferUtxos.includes(utxoOutpoint(utxo))\"") + ); + assert!( + !super::INDEX_HTML + .contains("x-model=\"selectedTransferUtxos\" @click=\"toggleTransferUtxoSelection") + ); + assert!(!super::INDEX_HTML.contains("@change=\"toggleTransferUtxoSelection($event, utxo)\"")); + assert!( + super::INDEX_HTML.contains("@click.prevent=\"toggleTransferUtxoSelection($event, utxo)\"") + ); + assert!( + !super::INDEX_HTML + .contains(":checked=\"selectedTransferUtxos.includes(utxoOutpoint(utxo))\" @click=") + ); + assert!(app_js.contains("lastSelectedTransferUtxo: null")); + assert!(app_js.contains("toggleTransferUtxoSelection(event, utxo)")); + assert!(app_js.contains("const checked = !selected.has(outpoint);")); + assert!(app_js.contains("event?.shiftKey && anchorIndex >= 0 && currentIndex >= 0")); + assert!(app_js.contains("const [from, to] = [anchorIndex, currentIndex].sort")); +} + +#[test] fn reveal_mempool_items_show_unknown_fee_label() { let app_js = include_str!("../../../www/assets/iuna-ui.js"); assert!(app_js.contains("txFeeLabel(tx)")); diff --git a/src/app.rs b/src/app.rs @@ -50,6 +50,7 @@ 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_WORKER_TICK: u64 = 100_000; const AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS: u64 = 60_000; +const REVEAL_BUNDLE_COLLECTION_MS: u64 = 30_000; const AUTO_BLOCK_ANCHOR_BURN_AMOUNT: Amount = 1; const AUTO_BLOCK_ANCHOR_BURN_FEE: Amount = 0; static DEBUG_LOGGING: AtomicBool = AtomicBool::new(false); @@ -91,6 +92,7 @@ pub struct NodeCore { owned_blinded_outbox_version: u64, reveal_bundles: BTreeMap<(u64, u8), RevealBundle>, equivocated_reveal_bundle_slots: BTreeSet<(u64, u8)>, + reveal_bundle_collection_started: Option<(u64, u64)>, local_block_anchor_burn: Option<(u64, Transaction)>, outbox: Vec<GossipEnvelope>, } @@ -111,7 +113,10 @@ mod tests { run_vdf, }; - use super::{InMemoryNetwork, NodeCore, helpers::transaction_input_outpoints}; + use super::{ + InMemoryNetwork, NodeCore, REVEAL_BUNDLE_COLLECTION_MS, + helpers::transaction_input_outpoints, + }; fn wallet_for_address<'a>(wallets: &'a [Wallet], address: &str) -> &'a Wallet { wallets @@ -218,6 +223,18 @@ mod tests { .node_mut("finalizer") .unwrap() .prepare_automatic_finalization(2); + assert!(reveal_plan.work.is_none()); + assert!( + reveal_plan + .skipped_reason + .as_deref() + .unwrap_or_default() + .contains("collecting blinded reveals") + ); + let reveal_plan = network + .node_mut("finalizer") + .unwrap() + .prepare_automatic_finalization(REVEAL_BUNDLE_COLLECTION_MS + 2); let reveal_work = reveal_plan .work .expect("finalizer should prepare reveal block"); @@ -379,10 +396,21 @@ mod tests { queue_auto_pow_mine_action(network.node_mut("miner").unwrap()); network.deliver_until_idle().unwrap(); - let block4 = network + let block4_started_at = block3.timestamp_ms.saturating_add(1); + let mut block4_outcome = network .node_mut("finalizer") .unwrap() - .automatic_mine_once(4) + .automatic_mine_once(block4_started_at); + if block4_outcome + .skipped_reason + .as_deref() + .is_some_and(|reason| reason.contains("collecting blinded reveals")) + { + block4_outcome = network.node_mut("finalizer").unwrap().automatic_mine_once( + block4_started_at.saturating_add(REVEAL_BUNDLE_COLLECTION_MS + 1), + ); + } + let block4 = block4_outcome .block .expect("finalizer should keep producing the next block"); if committed_blinded { diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -4,7 +4,7 @@ use super::helpers::{allowed_recovery_vdf_rank_count, recovery_vdf_sample_percen use super::{ AUTO_BLOCK_ANCHOR_BURN_AMOUNT, AUTO_BLOCK_ANCHOR_BURN_FEE, AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS, AutoMineOutcome, AutoMinePlan, BuiltBlindedTransaction, - Ledger, NodeCore, PreparedBlock, Transaction, run_vdf, + Ledger, NodeCore, PreparedBlock, REVEAL_BUNDLE_COLLECTION_MS, Transaction, run_vdf, }; use crate::domain::Amount; @@ -88,14 +88,27 @@ impl NodeCore { } } + let wallet_rank = self + .ledger + .finalizer_rank_for_next_block(self.wallet.address()); + let will_run_ticket_vdf = wallet_rank.is_some_and(|rank| self.wallet_rank_runs_vdf(rank)); + let will_run_recovery_vdf = + wallet_rank.is_none() && self.should_prepare_recovery_vdf(timestamp_ms); + if let Some(wait_ms) = self.reveal_bundle_collection_wait_ms( + timestamp_ms, + will_run_ticket_vdf || will_run_recovery_vdf, + ) { + plan.skipped_reason = Some(format!( + "collecting blinded reveals for next block ({:.1}s remaining)", + wait_ms as f64 / 1000.0 + )); + return plan; + } if let Err(error) = self.publish_reveal_bundle_for_next_block() { plan.skipped_reason = Some(format!("{error:#}")); return plan; } - let wallet_rank = self - .ledger - .finalizer_rank_for_next_block(self.wallet.address()); if let Some(rank) = wallet_rank { if !self.wallet_rank_runs_vdf(rank) { plan.skipped_reason = Some(format!( @@ -161,14 +174,27 @@ impl NodeCore { } } + let wallet_rank = self + .ledger + .finalizer_rank_for_next_block(self.wallet.address()); + let will_run_ticket_vdf = wallet_rank.is_some_and(|rank| self.wallet_rank_runs_vdf(rank)); + let will_run_recovery_vdf = + wallet_rank.is_none() && self.should_prepare_recovery_vdf(timestamp_ms); + if let Some(wait_ms) = self.reveal_bundle_collection_wait_ms( + timestamp_ms, + will_run_ticket_vdf || will_run_recovery_vdf, + ) { + plan.skipped_reason = Some(format!( + "collecting blinded reveals for next block ({:.1}s remaining)", + wait_ms as f64 / 1000.0 + )); + return plan; + } if let Err(error) = self.publish_reveal_bundle_for_next_block() { plan.skipped_reason = Some(format!("{error:#}")); return plan; } - let wallet_rank = self - .ledger - .finalizer_rank_for_next_block(self.wallet.address()); if let Some(rank) = wallet_rank { if !self.wallet_rank_runs_vdf(rank) { plan.skipped_reason = Some(format!( @@ -381,6 +407,37 @@ impl NodeCore { < self.recovery_vdf_top_rank_percent } + fn reveal_bundle_collection_wait_ms( + &mut self, + timestamp_ms: u64, + will_run_vdf: bool, + ) -> Option<u64> { + let next_height = self.ledger.height().saturating_add(1); + let has_pending_reveals = !self.ledger.pending_blinded_reveals().is_empty(); + let wallet_is_committee_member = self + .ledger + .reveal_committee_for_next_block() + .iter() + .any(|member| member.owner == self.wallet.address()); + if !has_pending_reveals || (!wallet_is_committee_member && !will_run_vdf) { + if !has_pending_reveals { + self.reveal_bundle_collection_started = None; + } + return None; + } + + let started_at = match self.reveal_bundle_collection_started { + Some((height, started_at)) if height == next_height => started_at, + _ => { + self.reveal_bundle_collection_started = Some((next_height, timestamp_ms)); + timestamp_ms + } + }; + let elapsed = timestamp_ms.saturating_sub(started_at); + (elapsed < REVEAL_BUNDLE_COLLECTION_MS) + .then(|| REVEAL_BUNDLE_COLLECTION_MS.saturating_sub(elapsed)) + } + pub(super) fn prepare_next_block_with_local_anchor( &self, timestamp_ms: u64, @@ -427,6 +484,19 @@ impl NodeCore { self.local_block_anchor_burn = None; } } + + pub(super) fn clear_stale_reveal_bundle_collection(&mut self) { + let current_next_height = self.ledger.height().saturating_add(1); + if self + .reveal_bundle_collection_started + .is_some_and(|(height, _)| height != current_next_height) + { + self.reveal_bundle_collection_started = None; + } + if self.ledger.pending_blinded_reveals().is_empty() { + self.reveal_bundle_collection_started = None; + } + } } #[cfg(test)] diff --git a/src/app/gossip.rs b/src/app/gossip.rs @@ -7,7 +7,6 @@ use super::{ impl NodeCore { pub fn mempool_gossip(&mut self) -> Vec<GossipEnvelope> { - let _ = self.publish_reveal_bundle_for_next_block(); let mut gossip = Vec::new(); let mine_actions = self .ledger diff --git a/src/app/in_memory_network.rs b/src/app/in_memory_network.rs @@ -108,7 +108,7 @@ mod tests { use std::collections::{BTreeMap, BTreeSet}; use crate::{ - app::{GossipEnvelope, InMemoryNetwork, NodeCore}, + app::{GossipEnvelope, InMemoryNetwork, NodeCore, REVEAL_BUNDLE_COLLECTION_MS}, domain::{ Block, GenesisBurn, Ledger, MAX_BLINDED_TRANSACTION_EXPIRY_HEIGHTS, MICRO_IUNA, RECOVERY_BLOCK_DELAY_MS, VDF_TARGET_BLOCK_MS, Wallet, @@ -400,19 +400,29 @@ mod tests { .node(&node_ids[producer_index]) .expect("producer node exists") .clone(); - let plan = producer.prepare_automatic_finalization(timestamp_ms); + let mut publish_timestamp_ms = timestamp_ms; + let mut plan = producer.prepare_automatic_finalization(timestamp_ms); + if plan.work.is_none() + && plan + .skipped_reason + .as_deref() + .is_some_and(|reason| reason.contains("collecting blinded reveals")) + { + publish_timestamp_ms = timestamp_ms.saturating_add(REVEAL_BUNDLE_COLLECTION_MS + 1); + plan = producer.prepare_automatic_finalization(publish_timestamp_ms); + } let Some(work) = plan.work else { continue; }; let block = work.finish_at( &wallets[producer_index], "preverified-chaos-vdf".to_string(), - timestamp_ms, + publish_timestamp_ms, ); network .node_mut(&node_ids[producer_index]) .expect("producer node exists") - .receive_preverified_block_at(block, timestamp_ms) + .receive_preverified_block_at(block, publish_timestamp_ms) .expect("mock-VDF block applies locally"); return; } diff --git a/src/app/node_lifecycle.rs b/src/app/node_lifecycle.rs @@ -107,6 +107,7 @@ impl NodeCore { owned_blinded_outbox_version: 0, reveal_bundles: BTreeMap::<(u64, u8), RevealBundle>::new(), equivocated_reveal_bundle_slots: BTreeSet::new(), + reveal_bundle_collection_started: None, local_block_anchor_burn: None, outbox: Vec::<GossipEnvelope>::new(), } @@ -129,6 +130,7 @@ impl NodeCore { self.bump_owned_blinded_outbox_version(); self.reveal_bundles.clear(); self.equivocated_reveal_bundle_slots.clear(); + self.reveal_bundle_collection_started = None; self.local_block_anchor_burn = None; } @@ -141,6 +143,7 @@ impl NodeCore { self.bump_owned_blinded_outbox_version(); self.reveal_bundles.clear(); self.equivocated_reveal_bundle_slots.clear(); + self.reveal_bundle_collection_started = None; self.local_block_anchor_burn = None; self.outbox.clear(); } @@ -151,5 +154,6 @@ impl NodeCore { self.last_auto_pow_mine_anchor = None; self.last_auto_pow_mine_status = None; self.auto_pow_mine_cursor = None; + self.reveal_bundle_collection_started = None; } } diff --git a/src/app/receive.rs b/src/app/receive.rs @@ -118,12 +118,8 @@ impl NodeCore { } GossipEnvelope::BlindedReveal(reveal) => self.receive_blinded_reveal(reveal), GossipEnvelope::BlindedReveals { reveals } => { - let mut added = false; for reveal in reveals { - added |= self.receive_blinded_reveal_without_bundle_publish(reveal)?; - } - if added { - self.publish_reveal_bundle_for_next_block()?; + self.receive_blinded_reveal_without_bundle_publish(reveal)?; } Ok(()) } @@ -139,6 +135,7 @@ impl NodeCore { self.ledger.apply_block(block.clone())?; if self.ledger.height() > previous_height { self.clear_stale_local_block_anchor(); + self.clear_stale_reveal_bundle_collection(); self.prune_reveal_bundles(); self.prune_owned_blinded_payloads_for_block(&block); self.publish_owned_reveals_for_block(&block)?; @@ -153,6 +150,7 @@ impl NodeCore { self.ledger.apply_block(block.clone())?; if self.ledger.height() > previous_height { self.clear_stale_local_block_anchor(); + self.clear_stale_reveal_bundle_collection(); self.prune_reveal_bundles(); self.prune_owned_blinded_payloads_for_block(&block); self.publish_owned_reveals_for_block(&block)?; @@ -178,6 +176,7 @@ impl NodeCore { .apply_preverified_block_at(block.clone(), now_ms)?; if self.ledger.height() > previous_height { self.clear_stale_local_block_anchor(); + self.clear_stale_reveal_bundle_collection(); self.prune_reveal_bundles(); self.prune_owned_blinded_payloads_for_block(&block); self.publish_owned_reveals_for_block(&block)?; @@ -201,6 +200,7 @@ impl NodeCore { if imported { self.reset_automatic_mining_progress(); self.clear_stale_local_block_anchor(); + self.clear_stale_reveal_bundle_collection(); self.prune_reveal_bundles(); self.enqueue_imported_blocks(previous_height)?; } @@ -221,6 +221,7 @@ impl NodeCore { self.ledger = ledger; self.reset_automatic_mining_progress(); self.clear_stale_local_block_anchor(); + self.clear_stale_reveal_bundle_collection(); self.prune_reveal_bundles(); self.enqueue_imported_blocks(previous_height)?; Ok(true) @@ -239,6 +240,7 @@ impl NodeCore { .blocks_from(previous_height + 1, IMPORT_REBROADCAST_LIMIT); for block in &blocks { self.prune_reveal_bundles(); + self.clear_stale_reveal_bundle_collection(); self.prune_owned_blinded_payloads_for_block(block); self.publish_owned_reveals_for_block(block)?; } @@ -254,7 +256,10 @@ mod tests { use std::collections::BTreeMap; use crate::{ - app::{GossipEnvelope, NodeCore, helpers::transaction_input_outpoints}, + app::{ + GossipEnvelope, NodeCore, REVEAL_BUNDLE_COLLECTION_MS, + helpers::transaction_input_outpoints, + }, domain::{GenesisBurn, Ledger, MICRO_IUNA, Wallet}, }; @@ -266,7 +271,7 @@ mod tests { } #[test] - fn receiving_blinded_reveal_batch_publishes_complete_committee_bundle() { + fn receiving_blinded_reveal_batch_waits_before_signing_committee_bundle() { let alice = Wallet::from_seed("immediate-bundle-alice"); let bob = Wallet::from_seed("immediate-bundle-bob"); let carol = Wallet::from_seed("immediate-bundle-carol"); @@ -315,11 +320,17 @@ mod tests { }) .next() .expect("test finalizer should be in reveal committee"); - let mut committee_node = NodeCore::from_ledger(committee_wallet.clone(), ledger, 0); + let mut committee_node = NodeCore::from_ledger_with_burn_fee_and_enabled( + committee_wallet.clone(), + ledger, + true, + 0, + 0, + ); committee_node .receive(GossipEnvelope::BlindedReveals { - reveals: vec![first.reveal.clone(), second.reveal.clone()], + reveals: vec![first.reveal.clone()], }) .unwrap(); let outbox = committee_node.drain_outbox(); @@ -328,10 +339,47 @@ mod tests { envelope, GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == first.reveal.commitment ))); + assert!( + !outbox + .iter() + .any(|envelope| matches!(envelope, GossipEnvelope::RevealBundle(_))) + ); + + let early = committee_node.prepare_automatic_finalization(2); + assert!(early.work.is_none()); + assert!( + early + .skipped_reason + .as_deref() + .unwrap_or_default() + .contains("collecting blinded reveals") + ); + assert!( + !committee_node + .drain_outbox() + .iter() + .any(|envelope| matches!(envelope, GossipEnvelope::RevealBundle(_))) + ); + + committee_node + .receive(GossipEnvelope::BlindedReveals { + reveals: vec![second.reveal.clone()], + }) + .unwrap(); + let outbox = committee_node.drain_outbox(); assert!(outbox.iter().any(|envelope| matches!( envelope, GossipEnvelope::BlindedReveal(reveal) if reveal.commitment == second.reveal.commitment ))); + assert!( + !outbox + .iter() + .any(|envelope| matches!(envelope, GossipEnvelope::RevealBundle(_))) + ); + + let ready = committee_node.prepare_automatic_finalization(REVEAL_BUNDLE_COLLECTION_MS + 3); + let _ = ready; + let outbox = committee_node.drain_outbox(); assert!(outbox.iter().any(|envelope| matches!( envelope, GossipEnvelope::RevealBundle(bundle) diff --git a/tests/properties.rs b/tests/properties.rs @@ -20,6 +20,7 @@ const NETWORK_CHAOS_ROUNDS: usize = 10; const VDF_STABILITY_SEEDS: std::ops::Range<u64> = 500..516; const VDF_STABILITY_BLOCKS: usize = 128; const VDF_STABILITY_INITIAL_ROUNDS: u64 = 1_000_000; +const TEST_REVEAL_BUNDLE_COLLECTION_MS: u64 = 30_000; #[derive(Clone, Debug)] struct TestRng { @@ -697,10 +698,23 @@ fn in_memory_network_converges_under_generated_node_actions() { .enumerate() .find(|(_, wallet)| wallet.address() == leader) { - let outcome = network + let timestamp_ms = (round + 1) as u64; + let mut outcome = network .node_mut(&format!("n{leader_index}")) .expect("leader node exists") - .automatic_mine_once((round + 1) as u64); + .automatic_mine_once(timestamp_ms); + if outcome + .skipped_reason + .as_deref() + .is_some_and(|reason| reason.contains("collecting blinded reveals")) + { + outcome = network + .node_mut(&format!("n{leader_index}")) + .expect("leader node exists") + .automatic_mine_once( + timestamp_ms.saturating_add(TEST_REVEAL_BUNDLE_COLLECTION_MS + 1), + ); + } if let Some(reason) = outcome.skipped_reason { assert!( reason.contains("at least one burn") @@ -873,10 +887,23 @@ fn in_memory_network_converges_after_generated_offline_and_reordered_delivery() } } - let outcome = network + let timestamp_ms = (round + 1) as u64; + let mut outcome = network .node_mut("n0") .expect("finalizer node exists") - .automatic_mine_once((round + 1) as u64); + .automatic_mine_once(timestamp_ms); + if outcome + .skipped_reason + .as_deref() + .is_some_and(|reason| reason.contains("collecting blinded reveals")) + { + outcome = network + .node_mut("n0") + .expect("finalizer node exists") + .automatic_mine_once( + timestamp_ms.saturating_add(TEST_REVEAL_BUNDLE_COLLECTION_MS + 1), + ); + } if let Some(reason) = outcome.skipped_reason { assert!( reason.contains("at least one burn") diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -95,6 +95,7 @@ window.iunaApp = function iunaApp() { showSendAdvanced: false, selectedTransferUtxos: [], selectedTransferUtxoAmounts: {}, + lastSelectedTransferUtxo: null, walletTxFilters: { transfer: true, mine: false, burn: false }, setupPeerAddress: "iuna.jhx.app:9444", peerAddress: "", @@ -2326,6 +2327,49 @@ window.iunaApp = function iunaApp() { const utxo = visible.get(outpoint); return !utxo || utxo.spendable !== false; }); + if (this.lastSelectedTransferUtxo && !this.selectedTransferUtxos.includes(this.lastSelectedTransferUtxo)) { + this.lastSelectedTransferUtxo = null; + } + }, + + toggleTransferUtxoSelection(event, utxo) { + const outpoint = this.utxoOutpoint(utxo); + if (!utxo || utxo.spendable === false || !outpoint) { + this.scheduleFeeEstimates(); + return; + } + + this.rememberUtxoAmounts([utxo]); + const spendable = this.spendableWalletUtxos(); + const outpoints = spendable.map((item) => this.utxoOutpoint(item)); + const currentIndex = outpoints.indexOf(outpoint); + const anchorIndex = this.lastSelectedTransferUtxo + ? outpoints.indexOf(this.lastSelectedTransferUtxo) + : -1; + + const selected = new Set(this.selectedTransferUtxos); + const checked = !selected.has(outpoint); + if (event?.shiftKey && anchorIndex >= 0 && currentIndex >= 0) { + const [from, to] = [anchorIndex, currentIndex].sort((left, right) => left - right); + const range = spendable.slice(from, to + 1); + this.rememberUtxoAmounts(range); + for (const item of range) { + const itemOutpoint = this.utxoOutpoint(item); + if (checked) { + selected.add(itemOutpoint); + } else { + selected.delete(itemOutpoint); + } + } + } else if (checked) { + selected.add(outpoint); + } else { + selected.delete(outpoint); + } + this.selectedTransferUtxos = Array.from(selected); + + this.lastSelectedTransferUtxo = outpoint; + this.scheduleFeeEstimates(); }, async selectAllTransferUtxos() { @@ -2333,6 +2377,8 @@ window.iunaApp = function iunaApp() { const utxos = await this.fetchJson("/api/wallet/utxos/selectable"); this.rememberUtxoAmounts(utxos); this.selectedTransferUtxos = utxos.map((utxo) => this.utxoOutpoint(utxo)); + this.lastSelectedTransferUtxo = + this.selectedTransferUtxos[this.selectedTransferUtxos.length - 1] || null; this.scheduleFeeEstimates(); if (this.selectedTransferUtxos.length === 0) { this.showFlash("No spendable UTXOs", "error"); @@ -2345,6 +2391,7 @@ window.iunaApp = function iunaApp() { clearTransferUtxos() { this.selectedTransferUtxos = []; this.selectedTransferUtxoAmounts = {}; + this.lastSelectedTransferUtxo = null; this.scheduleFeeEstimates(); },