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