iuna

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

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:
Msrc-tauri/capabilities/default.json | 1+
Msrc-tauri/tauri.conf.json | 4++--
Msrc/adapters/config_store.rs | 11+++++++++++
Msrc/adapters/http.rs | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/app.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Msrc/domain.rs | 4++++
Msrc/main.rs | 1+
Mtests/iuna.rs | 6++++++
Mwww/assets/iuna-ui.js | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
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;