commit bead6125d79e0c4d7a4d8f5b18bf4c39d1c801af
parent 326ef64ba79e47bed4ecb94e23b4017296cda3f1
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Fri, 7 Aug 2026 22:34:20 +0200
Address testnet feedback UI and recovery settings
Diffstat:
9 files changed, 347 insertions(+), 22 deletions(-)
diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json
@@ -7,6 +7,7 @@
],
"permissions": [
"core:default",
+ "shell:allow-open",
{
"identifier": "shell:allow-execute",
"allow": [
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "iuna",
- "version": "0.2.30",
+ "version": "0.2.31",
"identifier": "labs.iuna.desktop",
"build": {
"beforeDevCommand": "",
@@ -20,7 +20,7 @@
}
],
"security": {
- "csp": "default-src 'self'; connect-src http://127.0.0.1:18661; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
+ "csp": "default-src 'self'; connect-src http://127.0.0.1:18661 https://api.github.com; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
}
},
"bundle": {
diff --git a/src/adapters/config_store.rs b/src/adapters/config_store.rs
@@ -16,6 +16,7 @@ const AMOUNT_UNIT_MICROIUNA: &str = "microiuna";
const LEGACY_AMOUNT_UNIT_PRE_RENAME: &str = concat!("micro", "l", "uun");
pub const DEFAULT_BURN_AMOUNT: Amount = MICRO_IUNA / 10_000;
pub const DEFAULT_BURN_FEE: Amount = DEFAULT_BURN_AMOUNT;
+pub const DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT: u8 = 50;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct UiConfig {
@@ -26,6 +27,7 @@ pub struct UiConfig {
pub pow_mining_enabled: bool,
pub burn_per_block: Amount,
pub burn_fee: Amount,
+ pub recovery_vdf_top_rank_percent: u8,
pub keep_track_of_metrics: bool,
pub p2p_accept_inbound: bool,
pub p2p_announce_addr: Option<String>,
@@ -41,6 +43,7 @@ impl Default for UiConfig {
pow_mining_enabled: false,
burn_per_block: DEFAULT_BURN_AMOUNT,
burn_fee: DEFAULT_BURN_FEE,
+ recovery_vdf_top_rank_percent: DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT,
keep_track_of_metrics: false,
p2p_accept_inbound: false,
p2p_announce_addr: None,
@@ -66,6 +69,8 @@ struct ConfigFile {
#[serde(default)]
burn_fee: Option<Amount>,
#[serde(default)]
+ recovery_vdf_top_rank_percent: Option<u8>,
+ #[serde(default)]
keep_track_of_metrics: bool,
#[serde(default)]
p2p_accept_inbound: Option<bool>,
@@ -100,6 +105,7 @@ pub fn save(path: &Path, config: &UiConfig) -> Result<()> {
pow_mining_enabled: config.pow_mining_enabled,
burn_per_block: config.burn_per_block,
burn_fee: Some(config.burn_fee),
+ recovery_vdf_top_rank_percent: Some(config.recovery_vdf_top_rank_percent),
keep_track_of_metrics: config.keep_track_of_metrics,
p2p_accept_inbound: Some(config.p2p_accept_inbound),
p2p_announce_addr: config.p2p_announce_addr.clone(),
@@ -151,6 +157,10 @@ fn load(path: &Path) -> Result<UiConfig> {
.burn_fee
.map(|fee| fee.saturating_mul(scale))
.unwrap_or(DEFAULT_BURN_FEE),
+ recovery_vdf_top_rank_percent: stored
+ .recovery_vdf_top_rank_percent
+ .unwrap_or(DEFAULT_RECOVERY_VDF_TOP_RANK_PERCENT)
+ .min(100),
keep_track_of_metrics: stored.keep_track_of_metrics,
p2p_accept_inbound,
p2p_announce_addr: stored.p2p_announce_addr,
@@ -221,6 +231,7 @@ mod tests {
p2p_accept_inbound: true,
p2p_announce_addr: Some("203.0.113.10:9444".to_string()),
peers: vec!["127.0.0.1:9444".to_string()],
+ ..UiConfig::default()
},
)
.unwrap();
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -146,6 +146,11 @@ struct BurnSettingsForm {
}
#[derive(Debug, Deserialize)]
+struct RecoveryVdfSettingsForm {
+ top_rank_percent: u8,
+}
+
+#[derive(Debug, Deserialize)]
struct PowMiningForm {
enabled: bool,
}
@@ -366,6 +371,10 @@ struct UiBlock {
finalizer_rank: u32,
reward: Amount,
total_fees: Amount,
+ total_bytes: usize,
+ transaction_bytes: usize,
+ blinded_transaction_bytes: usize,
+ reveal_bundle_bytes: usize,
vdf_rounds: u64,
vdf_output: String,
leader_proof: Option<crate::domain::LeaderProof>,
@@ -483,6 +492,10 @@ pub async fn serve(
)
.route("/api/settings/pow-mining", post(api_pow_mining_form))
.route("/api/settings/metrics", post(api_metrics_settings_form))
+ .route(
+ "/api/settings/recovery-vdf",
+ post(api_recovery_vdf_settings_form),
+ )
.route("/api/settings/p2p-inbound", post(api_p2p_inbound_form))
.route("/api/settings/p2p-announce", post(api_p2p_announce_form))
.route("/api/transfer", post(api_transfer_form))
@@ -845,6 +858,7 @@ async fn api_mempool(
.map(|revealed| ui_pending_revealed_transaction(revealed, &outputs))
.unwrap_or_else(|| ui_blinded_reveal(reveal))
}));
+ items.reverse();
Json(page_items(items, query))
}
@@ -1086,6 +1100,13 @@ async fn api_metrics_settings_form(
action_json(set_keep_track_of_metrics(&state, form.enabled).await)
}
+async fn api_recovery_vdf_settings_form(
+ State(state): State<HttpState>,
+ Form(form): Form<RecoveryVdfSettingsForm>,
+) -> Json<ActionResponse> {
+ action_json(set_recovery_vdf_top_rank_percent(&state, form.top_rank_percent).await)
+}
+
async fn api_p2p_announce_form(
State(state): State<HttpState>,
Form(form): Form<P2pAnnounceForm>,
@@ -1217,6 +1238,17 @@ async fn persist_pow_mining_config(
config_store::save(config_path, &config)
}
+async fn set_recovery_vdf_top_rank_percent(state: &HttpState, percent: u8) -> Result<()> {
+ let percent = percent.min(100);
+ {
+ let mut node = state.node.lock().await;
+ node.set_recovery_vdf_top_rank_percent(percent);
+ }
+ let mut config = state.ui_config.lock().await;
+ config.recovery_vdf_top_rank_percent = percent;
+ config_store::save(&state.config_path, &config)
+}
+
async fn set_keep_track_of_metrics(state: &HttpState, enabled: bool) -> Result<()> {
if enabled {
let snapshot = {
@@ -1837,6 +1869,16 @@ fn ui_block(
let revealed_fees = revealed_transactions.iter().fold(0_u64, |total, revealed| {
total.saturating_add(revealed.transaction.fee())
});
+ let transaction_bytes = block
+ .transactions
+ .iter()
+ .map(|tx| tx.serialized_size_bytes().unwrap_or_default())
+ .sum::<usize>();
+ let blinded_transaction_bytes = block
+ .blinded_transactions
+ .iter()
+ .map(|tx| tx.serialized_size_bytes().unwrap_or_default())
+ .sum::<usize>();
let mut transactions = block
.transactions
.iter()
@@ -1857,7 +1899,7 @@ fn ui_block(
.iter()
.map(|revealed| (revealed.commitment.clone(), revealed.transaction.clone()))
.collect::<BTreeMap<_, _>>();
- let reveal_bundles = block
+ let reveal_bundles: Vec<UiRevealBundle> = block
.reveal_bundle_section
.expand(block.height, &block.prev_hash)
.into_iter()
@@ -1878,6 +1920,15 @@ fn ui_block(
.collect(),
})
.collect();
+ let reveal_bundle_bytes = reveal_bundles
+ .iter()
+ .map(|bundle: &UiRevealBundle| bundle.byte_size)
+ .sum::<usize>();
+ let total_bytes = block.serialized_size_bytes().unwrap_or_else(|_| {
+ transaction_bytes
+ .saturating_add(blinded_transaction_bytes)
+ .saturating_add(reveal_bundle_bytes)
+ });
UiBlock {
height: block.height,
prev_hash: block.prev_hash,
@@ -1887,6 +1938,10 @@ fn ui_block(
finalizer_rank: block.finalizer_rank,
reward: block.reward,
total_fees: block.reward.saturating_add(revealed_fees),
+ total_bytes,
+ transaction_bytes,
+ blinded_transaction_bytes,
+ reveal_bundle_bytes,
vdf_rounds: block.vdf_rounds,
vdf_output: block.vdf_output,
leader_proof: block.leader_proof,
@@ -3282,11 +3337,17 @@ const INDEX_HTML: &str = r#"<!doctype html>
summary.tx-section-title { cursor: pointer; }
details.tx-section:not([open]) { gap: 0; }
.tx-section-meta { color: #8e979e; font-size: 12px; font-weight: 600; }
- .tx-card, .mempool-item { position: relative; display: grid; gap: 6px; border: 1px solid #2f363c; border-radius: 8px; padding: 12px; background: #111316; cursor: pointer; text-align: left; }
+ .tx-card, .mempool-item { position: relative; display: grid; align-content: start; grid-auto-rows: min-content; gap: 6px; border: 1px solid #2f363c; border-radius: 8px; padding: 12px; background: #111316; cursor: pointer; text-align: left; }
.mempool-item.before-last-block { opacity: .56; }
- .mempool-item.new-since-block { border-color: #49543b; background: #151a12; opacity: 1; }
+ .mempool-item.new-since-block { background: #151a12; opacity: 1; }
.mempool-item.new-since-block::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 3px; border-radius: 8px 0 0 8px; background: #d5f55f; }
+ .mempool-item.blinded-hidden { background: linear-gradient(135deg, #141218, #101316); border-color: #353040; }
+ .mempool-state { color: #d5f55f; font-size: 10px; font-weight: 850; text-transform: uppercase; }
+ .mempool-time { color: #8d989f; font-size: 11px; font-weight: 700; }
+ .mempool-top { display: flex; justify-content: space-between; gap: 8px; align-items: flex-start; min-width: 0; }
+ .mempool-top-meta { display: grid; gap: 3px; min-width: 0; }
.tx-card .pill, .mempool-item .pill { position: absolute; top: 10px; right: 10px; }
+ .mempool-item .pill { position: static; flex: 0 0 auto; }
.pill { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 8px; font-size: 12px; font-weight: 800; background: #2b3136; color: #d6dee2; }
.pill.burn { background: #332918; color: #ffd070; }
.pill.transfer { background: #17312a; color: #8de9cd; }
@@ -3295,7 +3356,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
.pill.reveal, .pill.revealed { background: #2b2f20; color: #d5f55f; }
.mempool-panel { min-width: 0; overflow: hidden; }
.mempool-strip { width: 100%; min-width: 0; display: flex; gap: 8px; overflow-x: auto; overscroll-behavior-x: contain; padding: 1px 0 10px; scroll-snap-type: x proximity; }
- .mempool-item { flex: 0 0 220px; scroll-snap-align: start; }
+ .mempool-item { flex: 0 0 220px; scroll-snap-align: start; align-self: stretch; }
.tx-modal { width: min(940px, 100%); max-height: calc(100vh - 44px); overflow: auto; border: 1px solid #3b4448; border-radius: 8px; padding: 16px; background: #181b1f; box-shadow: 0 24px 80px rgba(0, 0, 0, .46); }
.tx-modal-head { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 14px; }
.tx-modal-title { display: grid; justify-items: start; gap: 6px; min-width: 0; }
@@ -3742,6 +3803,7 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="detail-grid">
<div>
<div class="detail-kv"><div class="key">Height</div><div x-text="selectedBlock.height"></div></div>
+ <div class="detail-kv"><div class="key">Time</div><div x-text="blockTimestampLabel(selectedBlock)"></div></div>
<div class="detail-kv"><div class="key">Hash</div><code x-text="selectedBlock.hash"></code></div>
<div class="detail-kv"><div class="key">Previous</div><code x-text="short(selectedBlock.prev_hash)"></code></div>
<div class="detail-kv">
@@ -3755,6 +3817,12 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="detail-kv"><div class="key">Burns</div><div x-text="blockBurnCount(selectedBlock)"></div></div>
<div class="detail-kv"><div class="key">Transfers</div><div x-text="blockTransferCount(selectedBlock)"></div></div>
<div class="detail-kv"><div class="key">Total Burned</div><div>IUNA <span x-text="amountLabel(blockBurned(selectedBlock))"></span></div></div>
+ <div class="detail-kv">
+ <div class="key">Bytes</div>
+ <button class="detail-link" type="button" @click="openBlockBytesModal(selectedBlock)" title="Block byte breakdown">
+ <span x-text="blockTotalBytes(selectedBlock)"></span>B
+ </button>
+ </div>
<div class="detail-kv"><div class="key">VDF</div><div><span x-text="selectedBlock.vdf_rounds"></span> rounds</div></div>
</div>
<div class="tx-list">
@@ -3807,8 +3875,13 @@ const INDEX_HTML: &str = r#"<!doctype html>
<div class="mempool-strip">
<template x-for="tx in mempool" :key="tx.signature">
<div class="mempool-item" :class="mempoolItemClass(tx)" 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" x-show="mempoolItemClass(tx) === 'new-since-block'"><span class="tx-label">Seen</span><span class="tx-value text">after last block</span></div>
+ <div class="mempool-top">
+ <div class="mempool-top-meta">
+ <div class="mempool-state" x-show="mempoolItemClass(tx).includes('new-since-block')">New since last block</div>
+ <div class="mempool-time" x-show="mempoolSeenTimeLabel(tx)" x-text="mempoolSeenTimeLabel(tx)"></div>
+ </div>
+ <span class="pill" :class="tx.kind" x-text="tx.kind"></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" x-text="txFeeLabel(tx)"></span></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>
@@ -3924,6 +3997,17 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
</div>
<div class="panel" x-show="advancedMode()">
+ <div class="settings-mode-row">
+ <div class="settings-mode-copy">
+ <div class="settings-mode-title">Recovery VDF</div>
+ <div class="muted">Top <span x-text="recoveryVdfTopRankPercent"></span>% threshold for fallback/recovery work.</div>
+ </div>
+ <label>Top ranks
+ <input type="range" min="0" max="100" step="5" :value="recoveryVdfTopRankPercent" @change="setRecoveryVdfTopRankPercent($event.target.value)">
+ </label>
+ </div>
+ </div>
+ <div class="panel" x-show="advancedMode()">
<h3>Node Networking</h3>
<div class="settings-mode-row">
<div class="settings-mode-copy">
@@ -4024,6 +4108,27 @@ const INDEX_HTML: &str = r#"<!doctype html>
</div>
</section>
</div>
+ <div class="setup-overlay transaction-overlay" x-show="selectedByteBlock" x-transition.opacity @click.self="closeBlockBytesModal()" role="dialog" aria-modal="true" aria-labelledby="block-bytes-title">
+ <section class="tx-modal">
+ <div class="tx-modal-head">
+ <div class="tx-modal-title">
+ <h2 id="block-bytes-title" x-text="selectedByteBlock ? `Block ${selectedByteBlock.height} Bytes` : 'Block Bytes'"></h2>
+ <div class="tx-field"><span class="tx-label">Total</span><span class="tx-value number"><span x-text="blockTotalBytes(selectedByteBlock)"></span>B</span></div>
+ </div>
+ <button type="button" @click="closeBlockBytesModal">Close</button>
+ </div>
+ <div class="rank-list">
+ <template x-for="row in blockByteBreakdown(selectedByteBlock)" :key="row[0]">
+ <div class="rank-row">
+ <div class="rank-number" x-text="`${row[1]}B`"></div>
+ <div class="rank-details">
+ <div class="tx-field"><span class="tx-label">Category</span><span class="tx-value text" x-text="row[0]"></span></div>
+ </div>
+ </div>
+ </template>
+ </div>
+ </section>
+ </div>
<div class="setup-overlay transaction-overlay" x-show="selectedBurnLeaderBlock" x-transition.opacity @click.self="closeBurnLeaderRanksModal()" role="dialog" aria-modal="true" aria-labelledby="burn-ranks-title">
<section class="tx-modal">
<div class="tx-modal-head">
@@ -4364,6 +4469,9 @@ mod tests {
assert!(ui_block.transactions[1].revealed);
assert_eq!(ui_block.revealed_transactions.len(), 1);
assert!(ui_block.revealed_transactions[0].revealed);
+ assert!(ui_block.total_bytes > 0);
+ assert!(ui_block.blinded_transaction_bytes > 0);
+ assert_eq!(ui_block.reveal_bundle_bytes, 0);
}
#[test]
@@ -4428,7 +4536,9 @@ mod tests {
assert!(super::INDEX_HTML.contains(".pill.reveal, .pill.revealed"));
assert!(super::INDEX_HTML.contains(":class=\"mempoolItemClass(tx)\""));
assert!(super::INDEX_HTML.contains(".mempool-item.before-last-block"));
- assert!(super::INDEX_HTML.contains("after last block"));
+ assert!(super::INDEX_HTML.contains("New since last block"));
+ assert!(super::INDEX_HTML.contains("class=\"mempool-top\""));
+ assert!(super::INDEX_HTML.contains("mempoolSeenTimeLabel(tx)"));
assert!(super::INDEX_HTML.contains("<details class=\"tx-section\">"));
assert!(super::INDEX_HTML.contains("<summary class=\"tx-section-title\">"));
assert!(super::INDEX_HTML.contains("Commitment"));
@@ -5552,7 +5662,9 @@ mod tests {
.contains("!tx?.revealed && (tx?.kind === \"blinded\" || tx?.kind === \"reveal\")")
);
assert!(app_js.contains("mempoolFirstSeenHeights"));
+ assert!(app_js.contains("mempoolFirstSeenAt"));
assert!(app_js.contains("syncMempoolBlockMarker"));
+ assert!(app_js.contains("this.status.chain?.height ?? this.lastBlockMempoolHeight"));
assert!(app_js.contains("mempoolItemClass"));
assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(tx)\""));
assert!(super::INDEX_HTML.contains("x-text=\"txFeeLabel(selectedTransaction?.tx)\""));
diff --git a/src/app.rs b/src/app.rs
@@ -53,6 +53,7 @@ pub struct NodeConfig {
pub vdf_rounds: u64,
pub burn_per_block: Amount,
pub burn_fee: Amount,
+ pub recovery_vdf_top_rank_percent: u8,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -209,6 +210,7 @@ pub struct MiningStatus {
pub current_leader: Option<String>,
pub wallet_is_current_leader: bool,
pub last_auto_burn_height: Option<u64>,
+ pub recovery_vdf_top_rank_percent: u8,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
@@ -249,6 +251,7 @@ pub struct NodeCore {
pow_mining_enabled: bool,
burn_per_block: Amount,
burn_fee: Amount,
+ recovery_vdf_top_rank_percent: u8,
last_auto_burn_height: Option<u64>,
last_auto_pow_mine_anchor: Option<String>,
last_auto_pow_mine_status: Option<String>,
@@ -266,12 +269,14 @@ pub struct NodeCore {
impl NodeCore {
pub fn new(config: NodeConfig) -> Self {
let ledger = Ledger::new(config.genesis_allocations, config.vdf_rounds);
- Self::from_ledger_with_burn_fee(
+ let mut node = Self::from_ledger_with_burn_fee(
config.wallet,
ledger,
config.burn_per_block,
config.burn_fee,
- )
+ );
+ node.set_recovery_vdf_top_rank_percent(config.recovery_vdf_top_rank_percent);
+ node
}
pub fn from_ledger(wallet: Wallet, ledger: Ledger, burn_per_block: Amount) -> Self {
@@ -293,6 +298,7 @@ impl NodeCore {
automatic_mining_enabled,
burn_per_block,
burn_fee,
+ 100,
)
}
@@ -324,6 +330,7 @@ impl NodeCore {
automatic_mining_enabled,
burn_per_block,
burn_fee,
+ 100,
)
}
@@ -333,6 +340,7 @@ impl NodeCore {
automatic_mining_enabled: bool,
burn_per_block: Amount,
burn_fee: Amount,
+ recovery_vdf_top_rank_percent: u8,
) -> Self {
Self {
wallet,
@@ -341,6 +349,7 @@ impl NodeCore {
pow_mining_enabled: false,
burn_per_block,
burn_fee,
+ recovery_vdf_top_rank_percent: recovery_vdf_top_rank_percent.min(100),
last_auto_burn_height: None,
last_auto_pow_mine_anchor: None,
last_auto_pow_mine_status: None,
@@ -656,6 +665,7 @@ impl NodeCore {
current_leader,
wallet_is_current_leader,
last_auto_burn_height: self.last_auto_burn_height,
+ recovery_vdf_top_rank_percent: self.recovery_vdf_top_rank_percent,
},
stratum: StratumStatus {
enabled: false,
@@ -745,6 +755,10 @@ impl NodeCore {
}
}
+ pub fn set_recovery_vdf_top_rank_percent(&mut self, percent: u8) {
+ self.recovery_vdf_top_rank_percent = percent.min(100);
+ }
+
pub fn burn(&mut self, amount: Amount) -> Result<Transaction> {
self.burn_with_fee(amount, 0)
}
@@ -1374,8 +1388,16 @@ impl NodeCore {
let wallet_rank = self
.ledger
.finalizer_rank_for_next_block(self.wallet.address());
- if wallet_rank.is_none() {
- if self.ledger.recovery_block_available_at(timestamp_ms) {
+ if let Some(rank) = wallet_rank {
+ if !self.wallet_rank_runs_vdf(rank) {
+ plan.skipped_reason = Some(format!(
+ "wallet finalizer rank {rank} is outside the top {}% VDF threshold",
+ self.recovery_vdf_top_rank_percent
+ ));
+ return plan;
+ }
+ } else {
+ if self.should_prepare_recovery_vdf(timestamp_ms) {
match self.prepare_recovery_block_with_local_anchor(timestamp_ms) {
Ok(work) => {
plan.work = Some(work);
@@ -1456,8 +1478,16 @@ impl NodeCore {
let wallet_rank = self
.ledger
.finalizer_rank_for_next_block(self.wallet.address());
- if wallet_rank.is_none() {
- if self.ledger.recovery_block_available_at(timestamp_ms) {
+ if let Some(rank) = wallet_rank {
+ if !self.wallet_rank_runs_vdf(rank) {
+ plan.skipped_reason = Some(format!(
+ "wallet finalizer rank {rank} is outside the top {}% VDF threshold",
+ self.recovery_vdf_top_rank_percent
+ ));
+ return plan;
+ }
+ } else {
+ if self.should_prepare_recovery_vdf(timestamp_ms) {
match self.prepare_recovery_block_with_local_anchor(timestamp_ms) {
Ok(work) => {
plan.work = Some(work);
@@ -1653,12 +1683,37 @@ impl NodeCore {
fn automatic_burn_needs_plaintext_anchor(&self, timestamp_ms: u64) -> bool {
self.ledger
.finalizer_rank_for_next_block(self.wallet.address())
- .is_some()
- || self.ledger.recovery_block_available_at(timestamp_ms)
+ .is_some_and(|rank| self.wallet_rank_runs_vdf(rank))
+ || self.should_prepare_recovery_vdf(timestamp_ms)
|| timestamp_ms.saturating_add(AUTO_PLAINTEXT_BURN_BEFORE_RECOVERY_MS)
>= self.ledger.recovery_block_min_timestamp()
}
+ fn wallet_rank_runs_vdf(&self, rank: u32) -> bool {
+ let rank_count = self.ledger.finalizer_rank_count_for_next_block();
+ let allowed =
+ allowed_recovery_vdf_rank_count(rank_count, self.recovery_vdf_top_rank_percent);
+ usize::try_from(rank).is_ok_and(|rank| rank < allowed)
+ }
+
+ fn should_prepare_recovery_vdf(&self, timestamp_ms: u64) -> bool {
+ if !self.ledger.recovery_block_available_at(timestamp_ms) {
+ return false;
+ }
+ if self.recovery_vdf_top_rank_percent == 100 {
+ return true;
+ }
+ if self.recovery_vdf_top_rank_percent == 0 {
+ return false;
+ }
+ if self.ledger.finalizer_rank_count_for_next_block() > 0 {
+ return false;
+ }
+ let tip_hash = self.ledger.status().tip_hash;
+ recovery_vdf_sample_percent(self.wallet.address(), tip_hash.as_str())
+ < self.recovery_vdf_top_rank_percent
+ }
+
fn prepare_next_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> {
self.ledger_with_local_block_anchor()?
.prepare_next_block_with_reveal_bundles(
@@ -2364,6 +2419,21 @@ fn transaction_input_outpoints(transaction: &Transaction) -> BTreeSet<OutPoint>
.collect()
}
+fn allowed_recovery_vdf_rank_count(rank_count: usize, percent: u8) -> usize {
+ if rank_count == 0 || percent == 0 {
+ return 0;
+ }
+ rank_count
+ .saturating_mul(usize::from(percent.min(100)))
+ .saturating_add(99)
+ / 100
+}
+
+fn recovery_vdf_sample_percent(address: &str, tip_hash: &str) -> u8 {
+ let digest = Sha256::digest(format!("iuna-recovery-vdf-sample:{tip_hash}:{address}"));
+ digest[0] % 100
+}
+
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
@@ -2411,6 +2481,7 @@ mod tests {
vdf_rounds: 10,
burn_per_block: 1,
burn_fee: 1,
+ recovery_vdf_top_rank_percent: 100,
});
let first = node.prepare_automatic_mining(1);
@@ -2505,6 +2576,7 @@ mod tests {
vdf_rounds: 10,
burn_per_block: 0,
burn_fee: 0,
+ recovery_vdf_top_rank_percent: 100,
});
node.set_pow_mining_enabled(true);
@@ -2534,6 +2606,7 @@ mod tests {
vdf_rounds: 10,
burn_per_block: 0,
burn_fee: 0,
+ recovery_vdf_top_rank_percent: 100,
});
node.set_pow_mining_enabled(true);
@@ -2579,6 +2652,27 @@ mod tests {
}
#[test]
+ fn automatic_finalization_respects_zero_recovery_vdf_threshold() {
+ let alice = Wallet::from_seed("automatic-recovery-zero-alice");
+ let bob = Wallet::from_seed("automatic-recovery-zero-bob");
+ 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,
+ vec![GenesisBurn::new(alice.address(), 1)],
+ 10,
+ )
+ .unwrap();
+ let mut node = NodeCore::from_ledger(bob, ledger, 1);
+ node.set_recovery_vdf_top_rank_percent(0);
+
+ let recovery = node.prepare_automatic_finalization(RECOVERY_BLOCK_DELAY_MS);
+
+ assert!(recovery.work.is_none());
+ }
+
+ #[test]
fn automatic_pow_mining_uses_protocol_finalizer_fee() {
let wallet = Wallet::from_seed("automatic-pow-mining-fee-wallet");
let mut node = NodeCore::new(NodeConfig {
@@ -2587,6 +2681,7 @@ mod tests {
vdf_rounds: 10,
burn_per_block: 0,
burn_fee: 0,
+ recovery_vdf_top_rank_percent: 100,
});
node.set_pow_mining_enabled(true);
@@ -2613,6 +2708,7 @@ mod tests {
vdf_rounds: 1,
burn_per_block: 0,
burn_fee: 0,
+ recovery_vdf_top_rank_percent: 100,
});
assert_eq!(node.status().app_version, env!("CARGO_PKG_VERSION"));
diff --git a/src/domain.rs b/src/domain.rs
@@ -3423,6 +3423,10 @@ impl Ledger {
.map(|(rank, _)| rank)
}
+ pub fn finalizer_rank_count_for_next_block(&self) -> usize {
+ ranked_tickets_for_height(self.tip(), self.tip().height + 1, &self.tickets).len()
+ }
+
fn valid_pending_transactions(&self) -> Vec<Transaction> {
let mut utxos = self.utxos.clone();
let mut valid = Vec::new();
diff --git a/src/main.rs b/src/main.rs
@@ -95,6 +95,7 @@ async fn main() -> Result<()> {
),
};
node_core.set_pow_mining_enabled(ui_config.pow_mining_enabled);
+ node_core.set_recovery_vdf_top_rank_percent(ui_config.recovery_vdf_top_rank_percent);
let node: SharedNode = Arc::new(Mutex::new(node_core));
let ui_config = Arc::new(Mutex::new(ui_config));
let mut peers = ui_config.lock().await.peers.clone();
diff --git a/tests/iuna.rs b/tests/iuna.rs
@@ -29,6 +29,7 @@ fn node(_network_key: &str, wallet: Wallet, allocations: BTreeMap<String, Amount
vdf_rounds: 25,
burn_per_block: DEFAULT_BURN_PER_BLOCK,
burn_fee: 1,
+ recovery_vdf_top_rank_percent: 100,
})
}
@@ -618,6 +619,7 @@ fn automatic_mining_burns_configured_amount_once_per_height() {
vdf_rounds: 10,
burn_per_block: iuna(25),
burn_fee: DEFAULT_FEE_PER_BYTE,
+ recovery_vdf_top_rank_percent: 100,
});
let first = node.automatic_mine_once(1);
@@ -712,6 +714,7 @@ fn automatic_mining_caps_burn_to_spendable_balance_after_fee() {
vdf_rounds: 10,
burn_per_block: BLOCK_REWARD + iuna(50),
burn_fee: DEFAULT_FEE_PER_BYTE,
+ recovery_vdf_top_rank_percent: 100,
});
let mut node = NodeCore::new(NodeConfig {
wallet: alice.clone(),
@@ -719,6 +722,7 @@ fn automatic_mining_caps_burn_to_spendable_balance_after_fee() {
vdf_rounds: 10,
burn_per_block: BLOCK_REWARD + iuna(50),
burn_fee: DEFAULT_FEE_PER_BYTE,
+ recovery_vdf_top_rank_percent: 100,
});
let outcome = node.automatic_mine_once(1);
@@ -748,6 +752,7 @@ fn automatic_mining_preserves_configured_burn_when_only_fee_is_short() {
vdf_rounds: 10,
burn_per_block: MICRO_IUNA,
burn_fee: DEFAULT_FEE_PER_BYTE,
+ recovery_vdf_top_rank_percent: 100,
});
let outcome = node.automatic_mine_once(1);
@@ -1785,6 +1790,7 @@ fn mined_block_gossip_does_not_include_full_chain_snapshot() {
vdf_rounds: 10,
burn_per_block: 1,
burn_fee: 1,
+ recovery_vdf_top_rank_percent: 100,
});
let plan = alice_node.prepare_automatic_mining(1);
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -4,6 +4,7 @@ window.iunaApp = function iunaApp() {
status: {},
blocks: [],
selectedBlock: null,
+ selectedByteBlock: null,
selectedTransaction: null,
selectedBurnLeaderBlock: null,
loadingOlder: false,
@@ -67,6 +68,7 @@ window.iunaApp = function iunaApp() {
burnFeeDraft: "0.0001",
miningEnabled: false,
powMiningEnabled: false,
+ recoveryVdfTopRankPercent: 50,
burnAmountDirty: false,
transferTo: "",
transferAmount: null,
@@ -90,6 +92,7 @@ window.iunaApp = function iunaApp() {
newBlockTimer: null,
lastBlockMempoolHeight: null,
mempoolFirstSeenHeights: {},
+ mempoolFirstSeenAt: {},
mempoolSeenInitialized: false,
blockPageSize: 20,
datasetPageSize: 25,
@@ -215,8 +218,15 @@ window.iunaApp = function iunaApp() {
return "iuna is up to date";
},
- openLatestRelease() {
+ async openLatestRelease() {
const url = this.latestRelease?.url || "https://github.com/iuna-labs/iuna/releases";
+ try {
+ const tauriOpen = window.__TAURI__?.shell?.open;
+ if (typeof tauriOpen === "function") {
+ await tauriOpen(url);
+ return;
+ }
+ } catch {}
window.open(url, "_blank", "noopener,noreferrer");
},
@@ -364,6 +374,11 @@ window.iunaApp = function iunaApp() {
syncConfigState() {
this.keepTrackOfMetrics = this.config.keep_track_of_metrics === true;
+ this.recoveryVdfTopRankPercent = Number(
+ this.config.recovery_vdf_top_rank_percent ??
+ this.config.recoveryVdfTopRankPercent ??
+ this.recoveryVdfTopRankPercent
+ );
this.p2pAcceptInbound = this.config.p2p_accept_inbound === true;
if (!this.p2pAnnounceDirty) {
this.p2pAnnounceAddr = this.config.p2p_announce_addr || "";
@@ -701,6 +716,7 @@ window.iunaApp = function iunaApp() {
: this.mergeDatasetItems(this[config.items], normalized.items, config.key);
if (kind === "mempool") {
this.trackMempoolFirstSeenHeights();
+ this.sortMempoolNewestFirst();
}
page.offset = normalized.nextOffset ?? this[config.items].length;
page.total = normalized.total;
@@ -773,17 +789,22 @@ window.iunaApp = function iunaApp() {
const active = new Set();
const firstBatch = !this.mempoolSeenInitialized;
const seenHeight = firstBatch ? height - 1 : height;
+ const seenAt = Date.now();
for (const tx of this.mempool) {
const key = this.mempoolKey(tx);
if (!key) continue;
active.add(key);
if (this.mempoolFirstSeenHeights[key] === undefined) {
this.mempoolFirstSeenHeights[key] = seenHeight;
+ this.mempoolFirstSeenAt[key] = seenAt;
}
}
this.mempoolSeenInitialized = true;
for (const key of Object.keys(this.mempoolFirstSeenHeights)) {
- if (!active.has(key)) delete this.mempoolFirstSeenHeights[key];
+ if (!active.has(key)) {
+ delete this.mempoolFirstSeenHeights[key];
+ delete this.mempoolFirstSeenAt[key];
+ }
}
},
@@ -794,9 +815,30 @@ window.iunaApp = function iunaApp() {
mempoolItemClass(tx) {
const key = this.mempoolKey(tx);
const firstSeenHeight = Number(this.mempoolFirstSeenHeights[key]);
- const markerHeight = Number(this.lastBlockMempoolHeight);
- if (!key || !Number.isFinite(firstSeenHeight) || !Number.isFinite(markerHeight)) return "";
- return firstSeenHeight >= markerHeight ? "new-since-block" : "before-last-block";
+ const markerHeight = Number(this.status.chain?.height ?? this.lastBlockMempoolHeight);
+ const classes = [];
+ if (isBlindedMempoolItem(tx)) classes.push("blinded-hidden");
+ if (key && Number.isFinite(firstSeenHeight) && Number.isFinite(markerHeight)) {
+ classes.push(firstSeenHeight >= markerHeight ? "new-since-block" : "before-last-block");
+ }
+ return classes.join(" ");
+ },
+
+ mempoolSeenTimeLabel(tx) {
+ const seenAt = Number(this.mempoolFirstSeenAt[this.mempoolKey(tx)]);
+ if (!Number.isFinite(seenAt)) return "";
+ return `Seen ${new Date(seenAt).toLocaleTimeString()}`;
+ },
+
+ sortMempoolNewestFirst() {
+ this.mempool = [...this.mempool].sort((left, right) => {
+ const leftSeen = Number(this.mempoolFirstSeenHeights[this.mempoolKey(left)]);
+ const rightSeen = Number(this.mempoolFirstSeenHeights[this.mempoolKey(right)]);
+ if (Number.isFinite(leftSeen) && Number.isFinite(rightSeen) && leftSeen !== rightSeen) {
+ return rightSeen - leftSeen;
+ }
+ return this.mempoolKey(right).localeCompare(this.mempoolKey(left));
+ });
},
observePageSentinel(kind, element) {
@@ -928,6 +970,14 @@ window.iunaApp = function iunaApp() {
this.selectedBurnLeaderBlock = null;
},
+ openBlockBytesModal(block) {
+ this.selectedByteBlock = block;
+ },
+
+ closeBlockBytesModal() {
+ this.selectedByteBlock = null;
+ },
+
openTransactionModal(tx, context = {}) {
this.selectedTransaction = { tx, context };
},
@@ -1188,6 +1238,23 @@ window.iunaApp = function iunaApp() {
}
},
+ async setRecoveryVdfTopRankPercent(percent) {
+ const previous = this.recoveryVdfTopRankPercent;
+ const normalized = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
+ try {
+ this.recoveryVdfTopRankPercent = normalized;
+ await this.postForm(
+ "/api/settings/recovery-vdf",
+ { top_rank_percent: String(normalized) },
+ `Recovery VDF threshold set to top ${normalized}%`
+ );
+ await this.refreshConfig();
+ } catch (error) {
+ this.recoveryVdfTopRankPercent = previous;
+ this.showFlash(error.message, "error");
+ }
+ },
+
async setP2pAcceptInbound(enabled) {
const previous = this.p2pAcceptInbound;
try {
@@ -1793,6 +1860,33 @@ window.iunaApp = function iunaApp() {
return this.blockTransactions(block).reduce((sum, tx) => sum + Number(tx.fee || 0), 0);
},
+ blockTimestampLabel(block) {
+ const timestamp = Number(block?.timestamp_ms ?? block?.timestampMs);
+ if (!Number.isFinite(timestamp)) return "-";
+ return new Date(timestamp).toLocaleString();
+ },
+
+ blockTotalBytes(block) {
+ return Number(block?.totalBytes ?? block?.total_bytes ?? 0);
+ },
+
+ blockPayloadBytes(block) {
+ return (
+ Number(block?.transactionBytes ?? block?.transaction_bytes ?? 0) +
+ Number(block?.blindedTransactionBytes ?? block?.blinded_transaction_bytes ?? 0) +
+ Number(block?.revealBundleBytes ?? block?.reveal_bundle_bytes ?? 0)
+ );
+ },
+
+ blockByteBreakdown(block) {
+ return [
+ ["Header and proof", Math.max(0, this.blockTotalBytes(block) - this.blockPayloadBytes(block))],
+ ["Transactions", Number(block?.transactionBytes ?? block?.transaction_bytes ?? 0)],
+ ["Blinded commits", Number(block?.blindedTransactionBytes ?? block?.blinded_transaction_bytes ?? 0)],
+ ["Reveal bundles", Number(block?.revealBundleBytes ?? block?.reveal_bundle_bytes ?? 0)],
+ ];
+ },
+
recentBlockFeeAverage(count) {
const sample = this.blocks.filter((block) => block.height > 0).slice(0, count);
if (sample.length === 0) return 0;