iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 87d8c4b88f9e72964f252d4b4934d3175ef49edb
parent 63f7a47ca195f4e1e9c538784ebb41515e640de8
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 23 Sep 2026 15:17:21 +0200

fix(wallet): finalize hybrid migration flows

Diffstat:
Msrc/adapters/http/actions.rs | 42+++++++++++++++++++++++++++++++++---------
Msrc/adapters/http/index_html.rs | 12+++++++++++-
Msrc/adapters/http/ui.rs | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/app/automatic_mining.rs | 204++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/domain/ledger_queries.rs | 8++++++++
5 files changed, 295 insertions(+), 46 deletions(-)

diff --git a/src/adapters/http/actions.rs b/src/adapters/http/actions.rs @@ -25,7 +25,7 @@ use crate::{ adapters::{ chain_store::SqliteChainStore, config_store::UiConfig, ui_data_store::SqliteUiDataStore, }, - app::GossipEnvelope, + app::{GossipEnvelope, NodeCore}, domain::Amount, }; @@ -546,15 +546,16 @@ fn validate_peer_address(peer: String) -> Result<String> { } async fn validate_address_book_address(state: &HttpState, address: String) -> Result<String> { + let node = state.node.lock().await; + normalize_address_book_address(&node, address) +} + +fn normalize_address_book_address(node: &NodeCore, address: String) -> Result<String> { let address = address.trim().to_string(); if address.is_empty() { bail!("address is required"); } - state - .node - .lock() - .await - .normalize_user_address(&address) + node.decode_user_address(&address) .context("invalid address book address")?; Ok(address.to_ascii_lowercase()) } @@ -571,7 +572,7 @@ async fn validate_existing_address_book_address( .node .lock() .await - .normalize_user_address(&address) + .decode_user_address(&address) .is_err() { crate::domain::validate_address(&address, "legacy address book") @@ -605,12 +606,13 @@ mod tests { p2p::GossipNetwork, ui_data_store::SqliteUiDataStore, wallet_store, }, app::{NodeCore, PeerBook, StratumStatus}, - domain::{GenesisBurn, Ledger, MICRO_IUNA}, + domain::{AddressNetwork, GenesisBurn, Ledger, MICRO_IUNA, Wallet, encode_address}, }; use super::super::{AuthSession, HttpState, state::AuthBackoff}; use super::{ - CHAIN_RESET_CONFIRMATION, reset_local_chain, validate_wallet_endpoint_public_node, + CHAIN_RESET_CONFIRMATION, normalize_address_book_address, reset_local_chain, + validate_wallet_endpoint_public_node, }; fn socket() -> SocketAddr { @@ -628,6 +630,28 @@ mod tests { validate_wallet_endpoint_public_node(&UiConfig::default(), false).unwrap(); } + #[test] + fn address_book_accepts_legacy_and_hybrid_addresses_for_the_active_network() { + let wallet = Wallet::from_seed("hybrid-address-book-wallet"); + let ledger = Ledger::new(BTreeMap::new(), 1); + let node = NodeCore::from_ledger(wallet.clone(), ledger, 0); + let legacy = encode_address(wallet.address(), AddressNetwork::Mainnet).unwrap(); + let hybrid = wallet.hybrid_address(AddressNetwork::Mainnet); + + assert_eq!( + normalize_address_book_address(&node, legacy.to_uppercase()).unwrap(), + legacy + ); + assert_eq!( + normalize_address_book_address(&node, hybrid.to_uppercase()).unwrap(), + hybrid + ); + assert!( + normalize_address_book_address(&node, wallet.hybrid_address(AddressNetwork::Testnet)) + .is_err() + ); + } + #[tokio::test] async fn local_chain_reset_clears_chain_and_ui_without_touching_wallet_or_config() { let dir = tempdir().unwrap(); diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -717,7 +717,7 @@ pub(super) const INDEX_HTML: &str = concat!( <button type="button" @click="openOptimizeWallet">Review optimization</button> <button type="button" @click="dismissOptimizeSuggestion">Later</button> </div> - <div class="panel" x-show="status.quantum_migration?.active && (status.quantum_migration?.legacy_balance > 0 || status.quantum_migration?.hybrid_balance > 0)"> + <div class="panel" x-show="status.quantum_migration?.active && status.quantum_migration?.legacy_utxos > 0"> <h3>Quantum-resistant wallet migration</h3> <p class="panel-description">Transaction v2 is active. Review how your legacy Ed25519 balance can move to the hybrid Ed25519 + ML-DSA wallet.</p> <div class="detail-grid"> @@ -2170,4 +2170,14 @@ mod tests { assert!(!INDEX_HTML.contains(r#"x-text="walletEndpointRestartMessage()""#)); assert!(!INDEX_HTML.contains(r#"x-text="stratumRestartMessage()""#)); } + + #[test] + fn migration_panel_is_only_visible_while_legacy_utxos_remain() { + assert!(INDEX_HTML.contains( + r#"x-show="status.quantum_migration?.active && status.quantum_migration?.legacy_utxos > 0""# + )); + assert!(!INDEX_HTML.contains( + r#"status.quantum_migration?.legacy_balance > 0 || status.quantum_migration?.hybrid_balance > 0"# + )); + } } diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -119,21 +119,19 @@ pub(super) fn wallet_transaction_v2_row( if !sent && !received { return Ok(None); } - let amount = if matches!(transaction, TransactionV2::Migration { .. }) { - presented.amount - } else if sent { - presented + let amount = match transaction { + TransactionV2::Migration { .. } | TransactionV2::Burn { .. } => presented.amount, + _ if sent => presented .outputs .iter() .filter(|output| !is_wallet_address(&output.address)) - .fold(0_u64, |total, output| total.saturating_add(output.amount)) - } else { - presented + .fold(0_u64, |total, output| total.saturating_add(output.amount)), + _ => presented .outputs .iter() .chain(presented.change.iter()) .filter(|output| is_wallet_address(&output.address)) - .fold(0_u64, |total, output| total.saturating_add(output.amount)) + .fold(0_u64, |total, output| total.saturating_add(output.amount)), }; let direction = match transaction { TransactionV2::Migration { .. } => "migrated", @@ -1048,7 +1046,8 @@ mod tests { use crate::compact::CompactBlockSizeBreakdown; use crate::domain::{ AddressNetwork, Amount, Block, BurnBundleSection, BurnBundleSignature, BurnLeaderRank, - FinalizerMode, Ledger, MaskedBurn, OutPoint, Transaction, TxInput, TxOutput, Wallet, + FinalizerMode, Ledger, MaskedBurn, OutPoint, Transaction, TransactionV2, + TransactionV2Input, TransactionV2Output, TxInput, TxOutput, Wallet, encode_versioned_address, hex_encode, }; @@ -1134,6 +1133,64 @@ mod tests { } #[test] + fn pending_v2_burn_shows_the_burned_amount_in_the_wallet_view() { + let wallet = Wallet::from_seed("pending-v2-burn-ui-wallet"); + let ledger = Ledger::new(BTreeMap::new(), 1); + let domain = ledger.transaction_v2_domain().unwrap(); + let network = AddressNetwork::Mainnet; + let owner = wallet.hybrid_versioned_address(); + let input_id = [0x42; 32]; + let mut transaction = TransactionV2::Burn { + inputs: vec![TransactionV2Input { + outpoint_txid: input_id, + outpoint_index: 0, + owner, + }], + change: vec![TransactionV2Output { + address: owner, + amount: 2, + }], + amount: 7, + fee: 1, + anchor: Some([0x24; 32]), + authorizations: Vec::new(), + }; + let payload = transaction.signing_bytes(&domain).unwrap(); + let authorization = wallet.sign_v2_authorization(owner, &payload).unwrap(); + let TransactionV2::Burn { authorizations, .. } = &mut transaction else { + unreachable!(); + }; + authorizations.push(authorization); + let outputs = BTreeMap::from([( + OutPoint { + txid: hex_encode(input_id), + index: 0, + }, + TxOutput { + address: wallet.hybrid_address(network), + amount: 10, + }, + )]); + let wallet_addresses = vec![wallet.hybrid_address(network)]; + + let rows = wallet_transaction_v2_rows( + &wallet_addresses, + &[transaction], + &outputs, + WalletTransactionFilters::default(), + &domain, + network, + ); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].kind, "burn"); + assert_eq!(rows[0].direction, "burned"); + assert_eq!(rows[0].status, "pending"); + assert_eq!(rows[0].amount, 7); + assert_eq!(rows[0].fee, 1); + } + + #[test] fn confirmed_v2_migration_is_presented_in_block_transactions() { let wallet = Wallet::from_seed("confirmed-v2-chain-ui-wallet"); let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100_000)]), 1); diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -6,7 +6,10 @@ use super::{ BURN_BUNDLE_COLLECTION_MS, GossipEnvelope, Ledger, MIN_AUTO_BLOCK_ANCHOR_BURN_AMOUNT, NodeCore, PreparedBlock, Transaction, run_vdf, }; -use crate::domain::{Amount, BurnCommitteeMember, FinalizerMode}; +use crate::domain::{ + Amount, BurnCommitteeMember, FinalizerMode, HYBRID_REWARD_ACTIVATION_HEIGHT, + transaction_v2_is_active, +}; mod pow; @@ -328,10 +331,9 @@ impl NodeCore { } let fee_per_byte = self.burn_fee; - let balance = self.ledger.balance_of(self.wallet.address()); let ledger = self.wallet_build_ledger()?; - let best = self.best_automatic_burn_on_ledger(&ledger, fee_per_byte, balance); - let Some(tx) = best else { + let next_height = current_height.saturating_add(1); + if transaction_v2_is_active(next_height) { let hybrid_address = self.wallet.unlocked()?.hybrid_address( crate::domain::AddressNetwork::from_profile_id( &self.ledger.launch_profile().profile_id, @@ -342,7 +344,17 @@ impl NodeCore { self.best_automatic_v2_burn_on_ledger(&ledger, fee_per_byte, hybrid_balance, false) { self.submit_public_transaction_v2(transaction)?; + self.last_auto_burn_height = Some(current_height); + return Ok(anchor_burn); + } + if next_height >= HYBRID_REWARD_ACTIVATION_HEIGHT { + bail!("automatic hybrid burn has insufficient confirmed funds"); } + } + + let balance = self.ledger.balance_of(self.wallet.address()); + let best = self.best_automatic_burn_on_ledger(&ledger, fee_per_byte, balance); + let Some(tx) = best else { self.last_auto_burn_height = Some(current_height); return Ok(anchor_burn); }; @@ -382,6 +394,29 @@ impl NodeCore { return Ok(None); } + let anchor_burn_amount = self.burn_per_block.max(MIN_AUTO_BLOCK_ANCHOR_BURN_AMOUNT); + let next_height = current_height.saturating_add(1); + let mut hybrid_error = None; + if transaction_v2_is_active(next_height) { + let ledger = self.wallet_anchor_build_ledger()?; + match self.build_v2_burn_with_fee_rate_on_ledger( + &ledger, + anchor_burn_amount, + self.burn_fee, + true, + ) { + Ok((transaction, _)) => { + self.submit_public_transaction_v2(transaction)?; + self.last_auto_anchor_burn_height = Some(current_height); + return Ok(None); + } + Err(error) if next_height >= HYBRID_REWARD_ACTIVATION_HEIGHT => { + return Err(error).context("automatic hybrid finalizer anchor burn failed"); + } + Err(error) => hybrid_error = Some(error), + } + } + // Pending wallet burns can themselves satisfy the block-anchor requirement. // Pending transfers cannot, so keep their confirmed inputs reserved. let pending_transfer_spent_outpoints = self @@ -401,7 +436,6 @@ impl NodeCore { // The plaintext anchor is the block's automatic burn when one is configured. // Keep a one-micro-IUNA anchor when automatic finalization is enabled with a // zero target, because the finalizer still needs a local burn to anchor. - let anchor_burn_amount = self.burn_per_block.max(MIN_AUTO_BLOCK_ANCHOR_BURN_AMOUNT); let burn = match converge_fee_by_byte(self.burn_fee, |fee| { let required = anchor_burn_amount .checked_add(fee) @@ -450,27 +484,15 @@ impl NodeCore { } }) { Ok((burn, _)) => burn, - Err(legacy_error) => { - match self.build_v2_burn_with_fee_rate_on_ledger( - &ledger, - anchor_burn_amount, - self.burn_fee, - true, - ) { - Ok((transaction, _)) => { - self.submit_public_transaction_v2(transaction)?; - self.last_auto_anchor_burn_height = Some(current_height); - return Ok(None); - } - Err(hybrid_error) => { - self.last_auto_anchor_burn_height = Some(current_height); - return Err(hybrid_error).with_context(|| { - format!( - "automatic finalizer anchor burn failed; legacy path: {legacy_error:#}" - ) - }); - } + Err(error) => { + if let Some(hybrid_error) = hybrid_error { + return Err(error).with_context(|| { + format!( + "automatic finalizer anchor burn failed; hybrid path: {hybrid_error:#}" + ) + }); } + return Err(error).context("automatic legacy finalizer anchor burn failed"); } }; self.local_block_anchor_burn = Some((current_height, burn.clone())); @@ -835,8 +857,9 @@ mod tests { adapters::chain_store::SqliteChainStore, app::{GossipEnvelope, InMemoryNetwork}, domain::{ - BurnBundle, BurnCommitteeMember, FinalizerMode, GenesisBurn, Ledger, MICRO_IUNA, - Transaction, Wallet, run_vdf, + BurnBundle, BurnCommitteeMember, FinalizerMode, GenesisBurn, + HYBRID_REWARD_ACTIVATION_HEIGHT, LaunchProfile, Ledger, MICRO_IUNA, Transaction, + TransactionV2, Wallet, run_vdf, }, }; use tempfile::tempdir; @@ -976,6 +999,133 @@ mod tests { } #[test] + fn failed_anchor_burn_is_retried_at_the_same_height() { + let wallet = Wallet::from_seed("retry-failed-anchor-burn"); + let ledger = Ledger::new(BTreeMap::new(), 1); + let timestamp_ms = ledger.recovery_block_min_timestamp(); + let mut node = + NodeCore::from_ledger_with_burn_fee_and_enabled(wallet, ledger, true, 100, 1); + + assert!(node.prepare_automatic_anchor_burn(timestamp_ms).is_err()); + assert_eq!(node.last_auto_anchor_burn_height, None); + assert!(node.prepare_automatic_anchor_burn(timestamp_ms).is_err()); + assert_eq!(node.last_auto_anchor_burn_height, None); + } + + #[test] + fn first_migration_block_can_bootstrap_with_a_legacy_anchor() { + let wallet = Wallet::from_seed("migration-bootstrap-anchor-wallet"); + let mut ledger = Ledger::new( + BTreeMap::from([(wallet.address().to_string(), 10 * MICRO_IUNA)]), + 1, + ); + ledger.set_tip_height_for_test(2_999); + let timestamp_ms = ledger.recovery_block_min_timestamp(); + let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(wallet, ledger, true, 1, 1); + + let anchor = node + .prepare_automatic_anchor_burn(timestamp_ms) + .unwrap() + .expect("the first migration block may still use a legacy bootstrap anchor"); + + assert!(anchor.is_burn()); + assert!(node.local_block_anchor_burn.is_some()); + assert!(node.ledger().pending_v2().is_empty()); + } + + #[test] + fn height_3750_rejects_legacy_anchor_fallback() { + let wallet = Wallet::from_seed("hybrid-only-anchor-wallet"); + let mut ledger = Ledger::new( + BTreeMap::from([(wallet.address().to_string(), 10 * MICRO_IUNA)]), + 1, + ); + ledger.set_tip_height_for_test(HYBRID_REWARD_ACTIVATION_HEIGHT - 1); + let timestamp_ms = ledger.recovery_block_min_timestamp(); + let mut node = NodeCore::from_ledger_with_burn_fee_and_enabled(wallet, ledger, true, 1, 1); + + let error = node + .prepare_automatic_anchor_burn(timestamp_ms) + .unwrap_err(); + + assert!(format!("{error:#}").contains("automatic hybrid finalizer anchor burn failed")); + assert!(node.local_block_anchor_burn.is_none()); + assert_eq!(node.last_auto_anchor_burn_height, None); + } + + #[test] + fn post_activation_recovery_uses_hybrid_anchor_alongside_pending_migration() { + let wallet = Wallet::from_seed("hybrid-recovery-anchor-wallet"); + let bootstrap = Wallet::from_seed("hybrid-recovery-bootstrap-wallet"); + let migrator = Wallet::from_seed("hybrid-recovery-pending-migrator"); + let allocations = [&wallet, &bootstrap, &migrator] + .into_iter() + .map(|wallet| (wallet.address().to_string(), 10 * MICRO_IUNA)) + .collect::<BTreeMap<_, _>>(); + let mut profile = LaunchProfile::local_testnet(); + profile.ticket_maturity_delay_heights = 0; + profile.ticket_expiry_window_heights = 4_000; + let mut ledger = Ledger::new_with_genesis_burns_and_profile( + allocations, + vec![GenesisBurn::new(bootstrap.address(), MICRO_IUNA)], + 1, + profile, + ) + .unwrap(); + ledger.set_tip_height_for_test(2_999); + + let bootstrap_anchor = ledger.build_burn_for_next_block(&bootstrap, 1, 1).unwrap(); + ledger.submit_transaction(bootstrap_anchor).unwrap(); + let wallet_migration = ledger.build_v2_migration(&wallet, 1).unwrap(); + ledger.submit_transaction_v2(wallet_migration).unwrap(); + let migration_block = ledger + .mine_recovery_block(&bootstrap, ledger.recovery_block_min_timestamp()) + .unwrap(); + ledger + .apply_preverified_block_at(migration_block, u64::MAX) + .unwrap(); + + let pending_migration = ledger.build_v2_migration(&migrator, 1).unwrap(); + ledger.submit_transaction_v2(pending_migration).unwrap(); + let timestamp_ms = ledger.recovery_block_min_timestamp(); + let mut node = + NodeCore::from_ledger_with_burn_fee_and_enabled(wallet.clone(), ledger, true, 1, 1); + + assert_eq!( + node.prepare_automatic_anchor_burn(timestamp_ms).unwrap(), + None + ); + assert!(node.local_block_anchor_burn.is_none()); + assert_eq!( + node.ledger() + .pending_v2() + .iter() + .filter(|transaction| transaction.is_burn()) + .count(), + 1 + ); + assert!( + node.ledger() + .pending_v2() + .iter() + .any(|transaction| { matches!(transaction, TransactionV2::Migration { .. }) }) + ); + + let prepared = node + .prepare_recovery_block_with_local_anchor(timestamp_ms) + .unwrap(); + let block = prepared.finish(&wallet, "test-vdf-output".to_string()); + assert!(!block.transactions.iter().any(Transaction::is_burn)); + let hybrid_owner = wallet.hybrid_versioned_address(); + assert!(block.transactions_v2.iter().any(|envelope| { + let bytes = crate::domain::decode_hex(envelope).unwrap(); + let (_, transaction) = TransactionV2::decode(&bytes).unwrap(); + matches!(transaction, TransactionV2::Burn { ref inputs, .. } + if inputs.iter().all(|input| input.owner == hybrid_owner)) + })); + } + + #[test] fn configured_top_percentage_limits_fallback_ticket_vdfs() { let wallets = (0..4) .map(|index| Wallet::from_seed(&format!("fallback-percent-wallet-{index}"))) diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -71,6 +71,14 @@ fn lineage_committee_draw_seed(parent: &Block, target_height: u64, slot: u8) -> } impl Ledger { + #[cfg(test)] + pub(crate) fn set_tip_height_for_test(&mut self, height: u64) { + self.chain + .last_mut() + .expect("ledger is always initialized with genesis") + .height = height; + } + pub fn snapshot(&self) -> ChainSnapshot { ChainSnapshot { genesis_allocations: self.genesis_allocations.clone(),