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