iuna

iuna

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

commit d5ea9a9e8df57bf74558bfbc89d4825d27198abb
parent 3c14ab571412395ebd9a838d3055a7d3b52c32b4
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Sun, 27 Sep 2026 08:23:54 +0200

fix(wallet): recover hybrid addresses across pending gaps

Diffstat:
Msrc/domain/ledger_builders.rs | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/domain/wallet.rs | 14+++++++++++++-
2 files changed, 201 insertions(+), 5 deletions(-)

diff --git a/src/domain/ledger_builders.rs b/src/domain/ledger_builders.rs @@ -8,10 +8,10 @@ use super::transaction::{UnsignedTxInput, UnsignedUtxoTransaction}; use super::validation::validate_address; use super::{ AddressNetwork, Amount, HYBRID_REWARD_ACTIVATION_HEIGHT, HybridAddressBranch, Ledger, - LegacyTransactionId, MineSearchOutcome, OutPoint, StratumMineShare, StratumMineTemplate, - Transaction, TransactionV2, TransactionV2Domain, TransactionV2Input, TransactionV2LegacyInput, - TransactionV2Output, TxOutput, VersionedAddress, Wallet, decode_versioned_address, - encode_versioned_address, + LegacyTransactionId, MineSearchOutcome, OutPoint, SignatureScheme, StratumMineShare, + StratumMineTemplate, Transaction, TransactionV2, TransactionV2Domain, TransactionV2Input, + TransactionV2LegacyInput, TransactionV2Output, TxOutput, V2SpendingAuthorization, + VersionedAddress, Wallet, decode_versioned_address, encode_versioned_address, }; use anyhow::{Context, Result, bail}; @@ -69,6 +69,7 @@ impl Ledger { } fn wallet_external_addresses(&self, wallet: &Wallet) -> Result<Vec<(u32, VersionedAddress)>> { + self.recover_historical_hybrid_address_cursors(wallet)?; let tip_changed = !wallet.external_discovery_tip_matches(self.tip_hash()); let used = if tip_changed { self.used_hybrid_addresses()? @@ -115,6 +116,7 @@ impl Ledger { } fn wallet_reward_addresses(&self, wallet: &Wallet) -> Result<Vec<(u32, VersionedAddress)>> { + self.recover_historical_hybrid_address_cursors(wallet)?; let tip_changed = !wallet.reward_discovery_tip_matches(self.tip_hash()); let spent = if tip_changed { self.spent_hybrid_addresses()? @@ -160,6 +162,114 @@ impl Ledger { ) } + /// Restores cursors across gaps left by addresses issued to pending transactions that never + /// confirmed. Hybrid authorizations retain the wallet's Ed25519 public key, so confirmed + /// wallet-authored transactions provide an unambiguous recovery target without persisting + /// secret child-key material. + fn recover_historical_hybrid_address_cursors(&self, wallet: &Wallet) -> Result<()> { + if wallet.historical_address_recovery_complete() { + return Ok(()); + } + + let mut targets = self.wallet_authored_hybrid_addresses(wallet)?; + let mut highest_external = None; + let mut highest_reward = None; + for index in 0..MAX_DISCOVERED_EXTERNAL_ADDRESSES { + let external = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, index); + if targets.contains(&external) { + targets.retain(|target| *target != external); + highest_external = Some(index); + } + let reward = wallet.hybrid_versioned_address_at(HybridAddressBranch::Reward, index); + if targets.contains(&reward) { + targets.retain(|target| *target != reward); + highest_reward = Some(index); + } + if targets.is_empty() { + break; + } + } + + if let Some(index) = highest_external.and_then(|index| index.checked_add(1)) { + wallet.advance_external_address_cursor(index); + } + if let Some(index) = highest_reward.and_then(|index| index.checked_add(1)) { + wallet.advance_reward_address_cursor(index); + } + wallet.mark_historical_address_recovery_complete(); + Ok(()) + } + + fn wallet_authored_hybrid_addresses(&self, wallet: &Wallet) -> Result<Vec<VersionedAddress>> { + let legacy_public_key = wallet.legacy_versioned_address().payload; + let domain = self.transaction_v2_domain()?; + let mut owned = Vec::new(); + for block in self.chain() { + for envelope in &block.transactions_v2 { + let bytes = decode_hex(envelope).context("invalid confirmed transaction-v2 hex")?; + let (decoded_domain, transaction) = TransactionV2::decode(&bytes)?; + if decoded_domain != domain { + continue; + } + match &transaction { + TransactionV2::Migration { + outputs, + authorizations, + .. + } => { + if authorizations.iter().any(|authorization| { + authorization.scheme() == SignatureScheme::Ed25519 + && authorization.public_key().as_bytes() == legacy_public_key + }) { + for output in outputs { + push_unique_address(&mut owned, output.address); + } + } + } + TransactionV2::Transfer { + inputs, + outputs, + authorizations, + .. + } => { + let authored = collect_wallet_hybrid_input_addresses( + inputs, + authorizations, + &legacy_public_key, + &mut owned, + ); + if authored { + // Native and browser builders place the external recipient first and + // deterministic wallet change, when present, in subsequent outputs. + for output in outputs.iter().skip(1) { + push_unique_address(&mut owned, output.address); + } + } + } + TransactionV2::Burn { + inputs, + change, + authorizations, + .. + } => { + if collect_wallet_hybrid_input_addresses( + inputs, + authorizations, + &legacy_public_key, + &mut owned, + ) { + for output in change { + push_unique_address(&mut owned, output.address); + } + } + } + TransactionV2::Mine { .. } => {} + } + } + } + Ok(owned) + } + fn used_hybrid_addresses(&self) -> Result<Vec<VersionedAddress>> { let mut used = self.pending_hybrid_addresses(); let network = self.address_network(); @@ -1064,6 +1174,25 @@ fn collect_v2_input_addresses(transaction: &TransactionV2, spent: &mut Vec<Versi } } +fn collect_wallet_hybrid_input_addresses( + inputs: &[TransactionV2Input], + authorizations: &[V2SpendingAuthorization], + legacy_public_key: &[u8; 32], + owned: &mut Vec<VersionedAddress>, +) -> bool { + let mut authored = false; + for (input, authorization) in inputs.iter().zip(authorizations) { + let public_key = authorization.public_key().as_bytes(); + if authorization.scheme() == SignatureScheme::HybridEd25519MlDsa44 + && public_key.get(..legacy_public_key.len()) == Some(legacy_public_key) + { + push_unique_address(owned, input.owner); + authored = true; + } + } + authored +} + fn push_unique_address(addresses: &mut Vec<VersionedAddress>, address: VersionedAddress) { if !addresses.contains(&address) { addresses.push(address); @@ -1262,6 +1391,61 @@ mod v2_migration_tests { } #[test] + fn seed_recovery_crosses_abandoned_pending_address_gap_after_restart() { + let seed = "abandoned-pending-gap-recovery-wallet"; + let signing_wallet = Wallet::from_seed(seed); + let mut ledger = Ledger::new(BTreeMap::new(), 1); + let source_index = HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT + 7; + let source = + signing_wallet.hybrid_versioned_address_at(HybridAddressBranch::External, source_index); + let change = signing_wallet + .hybrid_versioned_address_at(HybridAddressBranch::External, source_index + 1); + let mut burn = TransactionV2::Burn { + inputs: vec![TransactionV2Input { + outpoint_txid: [0x42; 32], + outpoint_index: 0, + owner: source, + }], + change: vec![TransactionV2Output { + address: change, + amount: 98, + }], + amount: 1, + fee: 1, + anchor: None, + authorizations: Vec::new(), + }; + let domain = ledger.transaction_v2_domain().unwrap(); + let payload = burn.signing_bytes(&domain).unwrap(); + let authorization = signing_wallet + .sign_v2_authorization(source, &payload) + .unwrap(); + let TransactionV2::Burn { authorizations, .. } = &mut burn else { + unreachable!(); + }; + authorizations.push(authorization); + ledger + .chain + .last_mut() + .unwrap() + .transactions_v2 + .push(hex_encode(burn.encode(&domain).unwrap())); + + let restored = Wallet::from_seed(seed); + let owned = ledger.wallet_owned_hybrid_addresses(&restored).unwrap(); + assert!(owned.contains(&source)); + assert!(owned.contains(&change)); + assert_eq!( + ledger.wallet_receive_address(&restored).unwrap(), + restored.hybrid_address_at( + HybridAddressBranch::External, + source_index + 2, + AddressNetwork::Mainnet, + ) + ); + } + + #[test] fn migration_builder_consolidates_legacy_value_into_one_hybrid_output() { let wallet = Wallet::from_seed("v2-migration-builder-wallet"); let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1); diff --git a/src/domain/wallet.rs b/src/domain/wallet.rs @@ -3,7 +3,7 @@ use std::{ fmt, sync::{ Arc, OnceLock, RwLock, - atomic::{AtomicU32, Ordering}, + atomic::{AtomicBool, AtomicU32, Ordering}, }, }; @@ -56,6 +56,7 @@ pub struct Wallet { external_discovery_tip: Arc<RwLock<Option<String>>>, reward_address_cursor: Arc<AtomicU32>, reward_discovery_tip: Arc<RwLock<Option<String>>>, + historical_address_recovery_complete: Arc<AtomicBool>, } impl Wallet { @@ -73,6 +74,7 @@ impl Wallet { external_discovery_tip: Arc::new(RwLock::new(None)), reward_address_cursor: Arc::new(AtomicU32::new(0)), reward_discovery_tip: Arc::new(RwLock::new(None)), + historical_address_recovery_complete: Arc::new(AtomicBool::new(false)), } } @@ -181,6 +183,16 @@ impl Wallet { .expect("wallet reward discovery lock is not poisoned") = Some(tip.to_string()); } + pub(crate) fn historical_address_recovery_complete(&self) -> bool { + self.historical_address_recovery_complete + .load(Ordering::Relaxed) + } + + pub(crate) fn mark_historical_address_recovery_complete(&self) { + self.historical_address_recovery_complete + .store(true, Ordering::Relaxed); + } + /// Creates both signatures over the same canonical transaction-v2 payload. pub fn sign_hybrid_authorization( &self,