iuna

iuna

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

commit 55efc328af7e9e8239c42b6684e6c142e31534ce
parent 8148bd14fb72d986db6009cfe4849132a09d946b
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Thu, 24 Sep 2026 15:20:16 +0200

feat: rotate hybrid wallet addresses

Diffstat:
Mdocs/quantum-audit-scope.md | 4++--
Mdocs/quantum-migration.md | 14+++++++++++---
Msrc/adapters/http/api.rs | 38++++++++++++++++++++++++++------------
Msrc/adapters/http/index_html.rs | 68+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/app.rs | 5+++--
Msrc/app/automatic_mining.rs | 31+++++++++++++------------------
Msrc/app/ledger_view.rs | 25++++++++++++++++++++++++-
Msrc/app/node_lifecycle.rs | 17++++++++++++++++-
Msrc/app/status.rs | 72+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/app/types.rs | 11+++++++++++
Msrc/app/wallet.rs | 10++++------
Msrc/domain.rs | 2+-
Msrc/domain/ledger_builders.rs | 462++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Msrc/domain/ledger_prepare.rs | 20++++++--------------
Msrc/domain/ledger_reveal.rs | 45+++++++++++++++++++--------------------------
Msrc/domain/wallet.rs | 216++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/main_tests.rs | 19+++++++++++++++++++
Mwww/assets/iuna-ui.js | 55+++++++++++++++++++++++++++++++++++++++++++++++++------
18 files changed, 972 insertions(+), 142 deletions(-)

diff --git a/docs/quantum-audit-scope.md b/docs/quantum-audit-scope.md @@ -142,8 +142,8 @@ until all of the following are resolved: - the cryptographic backend and Iuna integration are independently reviewed; - the final consensus call sites and byte-based fee accounting receive independent review; - wallet backup compatibility, migration batching, hybrid spending, recovery, and no-address-reuse - behavior are reviewed; deterministic hybrid keys, block-bounded migration batches, and hybrid - transfers exist, but address rotation remains incomplete; + behavior are reviewed; deterministic external/change rotation and spend-triggered reward + rotation now exist, but their recovery and no-reuse behavior still require independent review; - migration progress is observable without exposing wallet secrets; - advertised `transaction-v2-blocks` behavior (including already-open sessions), restored snapshot-v7 behavior, and activation-boundary recovery are rehearsed on the mainnet-candidate diff --git a/docs/quantum-migration.md b/docs/quantum-migration.md @@ -130,7 +130,9 @@ height-3000 consensus path, including ticket creation and committee burn bundles require the additional `transaction-v2-burns` capability once transaction v2 is active; validators that lack it must be upgraded together rather than remaining connected through a gradual relay rollout. The management wallet can submit reviewed migration batches, ordinary hybrid transfers, -and anchored hybrid burns. Address rotation and broader recovery rehearsal remain release blockers. +and anchored hybrid burns. It deterministically rotates hybrid receive/change addresses and uses a +separate reward branch that rotates after a spend reveals its current key. Broader recovery +rehearsal and independent review of the rotation behavior remain release blockers. ### Hybrid reward activation target @@ -192,8 +194,14 @@ wallet reports legacy and hybrid balances, exposes an authenticated migration pr reviewed block-bounded migration batch at a time, can spend confirmed hybrid value to another address-v1 recipient, and can destroy confirmed hybrid value through the same one-block burn queue used by legacy burns. Hybrid burns create ordinary lottery tickets linked to the Ed25519 component -of the hybrid wallet and participate in the same anti-censorship bundle rules. Automatic address -rotation and full recovery rehearsal remain incomplete. +of the hybrid wallet and participate in the same anti-censorship bundle rules. The original hybrid +address remains derivation index zero for backup compatibility. Once an external address appears +in the chain or local mempool, the wallet exposes the next deterministic address and sends v2 +change there. Reward destinations use a separately domain-separated branch and rotate as soon as +the current reward key is revealed by a confirmed or pending spend. Seed recovery scans both +branches with a 20-address gap limit. Child addresses retain the wallet's Ed25519 identity for +ticket/finalizer compatibility while using distinct ML-DSA-44 keys. Full recovery rehearsal +remains incomplete. ## Other trust boundaries diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -148,11 +148,12 @@ pub(super) async fn api_wallet_transactions( let (wallet_addresses, pending, pending_v2, domain, network, v2_outputs) = { let node = state.node.lock().await; let status = node.status(); - let wallet = node.wallet_address().to_string(); - let mut wallet_addresses = vec![wallet.clone(), status.wallet_receive_address]; - if let Some(address) = status.quantum_migration.hybrid_address { - wallet_addresses.push(address); - } + let mut wallet_addresses = node + .wallet_owned_addresses() + .unwrap_or_else(|_| vec![node.wallet_address().to_string()]); + wallet_addresses.push(status.wallet_receive_address); + wallet_addresses.sort(); + wallet_addresses.dedup(); let pending_v2 = node.pending_transactions_v2(); let v2_outputs = pending_v2 .iter() @@ -373,19 +374,32 @@ pub(super) async fn api_wallet_utxos( State(state): State<HttpState>, Query(query): Query<PageQuery>, ) -> Json<Page<WalletUtxoRow>> { - let (wallet, pending_spent) = { + let (wallet_addresses, pending_spent) = { let node = state.node.lock().await; ( - node.wallet_address().to_string(), + node.wallet_owned_addresses() + .unwrap_or_else(|_| vec![node.wallet_address().to_string()]), node.wallet_pending_spent_outpoints(), ) }; let store = state.ui_data_store.clone(); - let utxos = tokio::task::spawn_blocking(move || store.load_wallet_utxos(&wallet)) - .await - .ok() - .and_then(Result::ok) - .unwrap_or_default(); + let utxos = tokio::task::spawn_blocking(move || -> Result<Vec<_>> { + let mut utxos = Vec::new(); + for address in wallet_addresses { + utxos.extend(store.load_wallet_utxos(&address)?); + } + utxos.sort_by(|(left_point, left), (right_point, right)| { + right + .amount + .cmp(&left.amount) + .then_with(|| left_point.cmp(right_point)) + }); + Ok(utxos) + }) + .await + .ok() + .and_then(Result::ok) + .unwrap_or_default(); Json(page_items( wallet_utxo_rows_from_ui_data(utxos, &pending_spent), query, diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -323,6 +323,14 @@ pub(super) const INDEX_HTML: &str = concat!( .compact-number-field input:focus { border-color: #d5f55f; outline: 2px solid rgba(213,245,95,.2); outline-offset: 2px; } .receive-address { display: grid; gap: 8px; } .address-box { border: 1px solid #2f363c; border-radius: 8px; padding: 11px; background: #111316; } + .wallet-address-summary { display: flex; justify-content: space-between; gap: 12px; align-items: center; width: 100%; padding: 9px 10px; text-align: left; color: #b9c2c7; background: #111316; border-color: #2f363c; } + .wallet-address-summary:hover, .wallet-address-summary:focus-visible { border-color: #59656c; color: #eef6f8; outline: none; } + .wallet-address-summary-action { flex: 0 0 auto; color: #d5f55f; font-weight: 800; } + .wallet-address-list { display: grid; gap: 8px; } + .wallet-address-row { display: grid; gap: 8px; border: 1px solid #2f363c; border-radius: 8px; padding: 11px; background: #111316; } + .wallet-address-row-head { display: flex; justify-content: space-between; gap: 10px; align-items: center; } + .wallet-address-state { color: #9eb3bc; font-size: 11px; font-weight: 850; text-transform: uppercase; } + .wallet-address-state.pending { color: #efca75; } .wallet-address-link { cursor: pointer; text-decoration: underline; text-decoration-style: dotted; text-decoration-color: #59656c; text-underline-offset: 3px; } .wallet-address-link:hover, .wallet-address-link:focus-visible { color: #d5f55f; text-decoration-color: #d5f55f; outline: none; } .address-book-list { display: grid; gap: 8px; margin-top: 12px; } @@ -803,19 +811,16 @@ pub(super) const INDEX_HTML: &str = concat!( <div class="panel"> <div class="panel-head"> <h3>Receive</h3> - <button type="button" @click="copyAddress">Copy</button> + <button type="button" @click="copyReceiveAddress">Copy</button> </div> <div class="receive-address"> - <div class="muted">Legacy Ed25519 address</div> - <div class="address-box"><code class="wallet-address-link" role="button" tabindex="0" x-text="setupAddress()" @click="openAddressContact(setupAddress())" @keydown.enter.prevent="openAddressContact(setupAddress())" @keydown.space.prevent="openAddressContact(setupAddress())" title="Add or edit contact"></code></div> - </div> - <div class="receive-address" x-show="status.quantum_migration?.active && status.quantum_migration?.hybrid_address"> - <div class="panel-head"> - <div class="muted">Hybrid Ed25519 + ML-DSA address</div> - <button type="button" @click="copyHybridAddress">Copy</button> - </div> - <div class="address-box"><code x-text="status.quantum_migration?.hybrid_address || '-'"></code></div> - <div class="muted">Do not reuse this address after its key has been revealed by a spend. Address rotation is the next wallet upgrade.</div> + <div class="muted" x-text="status.quantum_migration?.active ? 'Current hybrid Ed25519 + ML-DSA receive address' : 'Legacy Ed25519 address'"></div> + <div class="address-box"><code class="wallet-address-link" role="button" tabindex="0" x-text="receiveAddress()" @click="openAddressContact(receiveAddress())" @keydown.enter.prevent="openAddressContact(receiveAddress())" @keydown.space.prevent="openAddressContact(receiveAddress())" title="Add or edit contact"></code></div> + <div class="muted" x-show="status.quantum_migration?.active">A new receive address is selected after funds are received. Previous addresses remain monitored by this wallet.</div> + <button class="wallet-address-summary" type="button" x-show="fundedWalletAddresses().length > 0" @click="openWalletAddressesModal"> + <span x-text="walletAddressSummary()"></span> + <span class="wallet-address-summary-action">View details</span> + </button> </div> </div> <div class="panel"> @@ -1825,6 +1830,30 @@ pub(super) const INDEX_HTML: &str = concat!( </div> </section> </div> + <div class="setup-overlay transaction-overlay" x-show="showWalletAddresses" x-transition.opacity @click.self="closeWalletAddressesModal()" @keydown.escape.stop="closeWalletAddressesModal()" role="dialog" aria-modal="true" aria-labelledby="wallet-addresses-title"> + <section class="tx-modal"> + <div class="tx-modal-head"> + <div class="tx-modal-title"> + <h2 id="wallet-addresses-title">Funded wallet addresses</h2> + <div class="muted">Previous receive and reward addresses remain monitored automatically.</div> + </div> + <button type="button" @click="closeWalletAddressesModal">Close</button> + </div> + <div class="wallet-address-list"> + <template x-for="entry in fundedWalletAddresses()" :key="entry.address"> + <div class="wallet-address-row"> + <div class="wallet-address-row-head"> + <span class="wallet-address-state" :class="{ pending: walletAddressHasPendingSpend(entry) }" x-text="walletAddressState(entry)"></span> + <span class="utxo-node-amount">IUNA <span x-text="amountLabel(entry.balance)"></span></span> + </div> + <code class="tx-value hash" x-text="entry.address"></code> + <div class="muted" x-text="walletAddressUtxoLabel(entry)"></div> + </div> + </template> + <div class="tx-modal-empty" x-show="fundedWalletAddresses().length === 0">No funded wallet addresses</div> + </div> + </section> + </div> <div class="setup-overlay transaction-overlay" x-show="showPowDifficultyInfo" x-transition.opacity @click.self="closePowDifficultyInfo()" role="dialog" aria-modal="true" aria-labelledby="pow-difficulty-title"> <section class="tx-modal"> <div class="tx-modal-head"> @@ -2180,4 +2209,21 @@ mod tests { r#"status.quantum_migration?.legacy_balance > 0 || status.quantum_migration?.hybrid_balance > 0"# )); } + + #[test] + fn receive_panel_presents_the_current_rotating_address() { + assert!(INDEX_HTML.contains(r#"@click="copyReceiveAddress""#)); + assert!(INDEX_HTML.contains(r#"x-text="receiveAddress()""#)); + assert!(INDEX_HTML.contains( + "A new receive address is selected after funds are received. Previous addresses remain monitored by this wallet." + )); + assert!(INDEX_HTML.contains(r#"x-text="walletAddressSummary()""#)); + assert!(INDEX_HTML.contains(r#"x-show="showWalletAddresses""#)); + assert!(INDEX_HTML.contains("Funded wallet addresses")); + assert!(!INDEX_HTML.contains("copyWalletAddress")); + assert!( + !INDEX_HTML + .contains("Do not reuse this address after its key has been revealed by a spend.") + ); + } } diff --git a/src/app.rs b/src/app.rs @@ -30,8 +30,9 @@ pub use in_memory_network::InMemoryNetwork; pub use peer_book::{PeerBook, PeerDirection, PeerInfo}; pub use types::{ AutoMineOutcome, AutoMinePlan, BlockInventory, ChainBootstrap, ExternalMineJob, FeeEstimate, - GossipEnvelope, LaunchProfileStatus, MiningStatus, NetworkMigrationStatus, NodeConfig, - NodeStatus, ProtocolHello, QuantumMigrationPreview, QuantumMigrationStatus, StratumStatus, + FundedWalletAddressStatus, GossipEnvelope, LaunchProfileStatus, MiningStatus, + NetworkMigrationStatus, NodeConfig, NodeStatus, ProtocolHello, QuantumMigrationPreview, + QuantumMigrationStatus, StratumStatus, }; use wallet::NodeWallet; diff --git a/src/app/automatic_mining.rs b/src/app/automatic_mining.rs @@ -334,12 +334,13 @@ impl NodeCore { let ledger = self.wallet_build_ledger()?; 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, - ), - ); - let hybrid_balance = self.ledger.balance_of(&hybrid_address); + let hybrid_balance = self + .ledger + .wallet_owned_hybrid_encoded_addresses(self.wallet.unlocked()?)? + .iter() + .fold(0_u64, |total, address| { + total.saturating_add(self.ledger.balance_of(address)) + }); if let Some((transaction, _)) = self.best_automatic_v2_burn_on_ledger(&ledger, fee_per_byte, hybrid_balance, false) { @@ -751,12 +752,9 @@ impl NodeCore { let finalizer_rank = ledger .finalizer_rank_for_next_block(self.wallet.address()) .context("cannot prepare ticket block without a mature burn ticket")?; - let reward_address = if ledger.height().saturating_add(1) - >= crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT - { - Some(self.wallet.unlocked()?.hybrid_address( - crate::domain::AddressNetwork::from_profile_id(&ledger.launch_profile().profile_id), - )) + let next_height = ledger.height().saturating_add(1); + let reward_address = if next_height >= crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT { + Some(ledger.wallet_reward_address(self.wallet.unlocked()?, next_height)) } else { None }; @@ -771,12 +769,9 @@ impl NodeCore { fn prepare_recovery_block_with_local_anchor(&self, timestamp_ms: u64) -> Result<PreparedBlock> { let (ledger, required_burn_signature) = self.ledger_with_local_block_anchor(); - let reward_address = if ledger.height().saturating_add(1) - >= crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT - { - Some(self.wallet.unlocked()?.hybrid_address( - crate::domain::AddressNetwork::from_profile_id(&ledger.launch_profile().profile_id), - )) + let next_height = ledger.height().saturating_add(1); + let reward_address = if next_height >= crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT { + Some(ledger.wallet_reward_address(self.wallet.unlocked()?, next_height)) } else { None }; diff --git a/src/app/ledger_view.rs b/src/app/ledger_view.rs @@ -3,7 +3,10 @@ use anyhow::Result; use std::collections::BTreeSet; use crate::compact::CompactBlockSizeBreakdown; -use crate::domain::{Block, BurnLeaderRank, Ledger, OutPoint, Transaction, TransactionV2}; +use crate::domain::{ + Block, BurnLeaderRank, Ledger, LegacyTransactionId, OutPoint, Transaction, TransactionV2, + hex_encode, +}; use std::collections::BTreeMap; use super::{NodeCore, helpers::transaction_input_outpoints}; @@ -35,6 +38,26 @@ impl NodeCore { spent.extend(transaction_input_outpoints(burn)); } } + for transaction in self.ledger.pending_v2() { + match transaction { + TransactionV2::Migration { inputs, .. } => { + spent.extend(inputs.iter().map(|input| OutPoint { + txid: match &input.outpoint_id { + LegacyTransactionId::Hash(value) => hex_encode(value), + LegacyTransactionId::Signature(value) => hex_encode(value), + }, + index: input.outpoint_index, + })); + } + TransactionV2::Transfer { inputs, .. } | TransactionV2::Burn { inputs, .. } => { + spent.extend(inputs.iter().map(|input| OutPoint { + txid: hex_encode(input.outpoint_txid), + index: input.outpoint_index, + })); + } + TransactionV2::Mine { .. } => {} + } + } spent } diff --git a/src/app/node_lifecycle.rs b/src/app/node_lifecycle.rs @@ -6,7 +6,7 @@ use crate::{ adapters::config_store::{DEFAULT_POW_MINING_WORKERS, clamp_pow_mining_workers}, domain::{ AddressNetwork, Amount, BurnBundle, DEFAULT_FEE_PER_BYTE, Ledger, VersionedAddress, Wallet, - decode_address, decode_versioned_address, encode_address, + decode_address, decode_versioned_address, encode_address, transaction_v2_is_active, }, }; @@ -133,9 +133,24 @@ impl NodeCore { } pub fn wallet_receive_address(&self) -> Result<String> { + if transaction_v2_is_active(self.ledger.height()) { + if let Ok(wallet) = self.wallet.unlocked() { + return self.ledger.wallet_receive_address(wallet); + } + } encode_address(self.wallet.address(), self.address_network()) } + pub fn wallet_owned_addresses(&self) -> Result<Vec<String>> { + let mut addresses = vec![self.wallet.address().to_string()]; + if let Ok(wallet) = self.wallet.unlocked() { + addresses.extend(self.ledger.wallet_owned_hybrid_encoded_addresses(wallet)?); + } + addresses.sort(); + addresses.dedup(); + Ok(addresses) + } + pub fn normalize_user_address(&self, address: &str) -> Result<String> { decode_address(address, self.address_network()) } diff --git a/src/app/status.rs b/src/app/status.rs @@ -9,8 +9,8 @@ use crate::{ }; use super::{ - LaunchProfileStatus, MiningStatus, NETWORK_ID, NetworkMigrationStatus, NodeCore, NodeStatus, - QuantumMigrationStatus, StratumStatus, + FundedWalletAddressStatus, LaunchProfileStatus, MiningStatus, NETWORK_ID, + NetworkMigrationStatus, NodeCore, NodeStatus, QuantumMigrationStatus, StratumStatus, helpers::{transaction_input_total_from_outputs, transaction_output_total_for_address}, now_ms, }; @@ -31,10 +31,51 @@ impl NodeCore { &self.ledger.launch_profile().profile_id, )) }); - let hybrid_balance = hybrid_address - .as_deref() - .map(|address| self.ledger.balance_of(address)) - .unwrap_or(0); + let hybrid_addresses = self + .wallet + .unlocked() + .ok() + .and_then(|wallet| { + self.ledger + .wallet_owned_hybrid_encoded_addresses(wallet) + .ok() + }) + .unwrap_or_default(); + let hybrid_balance = hybrid_addresses.iter().fold(0_u64, |total, address| { + total.saturating_add(self.ledger.balance_of(address)) + }); + let pending_spent = self.wallet_pending_spent_outpoints(); + let mut owned_addresses = vec![legacy_address.to_string()]; + owned_addresses.extend(hybrid_addresses); + owned_addresses.sort(); + owned_addresses.dedup(); + let mut funded_wallet_addresses = owned_addresses + .into_iter() + .filter_map(|address| { + let utxos = self.ledger.utxos_for_address(&address); + if utxos.is_empty() { + return None; + } + Some(FundedWalletAddressStatus { + legacy: address == legacy_address, + address, + balance: utxos.iter().fold(0_u64, |total, (_, output)| { + total.saturating_add(output.amount) + }), + utxos: utxos.len(), + spendable_utxos: utxos + .iter() + .filter(|(outpoint, _)| !pending_spent.contains(outpoint)) + .count(), + }) + }) + .collect::<Vec<_>>(); + funded_wallet_addresses.sort_by(|left, right| { + right + .balance + .cmp(&left.balance) + .then_with(|| left.address.cmp(&right.address)) + }); let address_network = AddressNetwork::from_profile_id(&self.ledger.launch_profile().profile_id); let transaction_v2_domain = self.ledger.transaction_v2_domain().ok(); @@ -60,6 +101,7 @@ impl NodeCore { app_version: env!("CARGO_PKG_VERSION").to_string(), wallet_address: self.wallet.address().to_string(), wallet_receive_address: self.wallet_receive_address().unwrap_or_default(), + funded_wallet_addresses, wallet_balance: self.wallet_projected_balance(), wallet_locked: self.wallet.is_locked(), quantum_migration: QuantumMigrationStatus { @@ -118,10 +160,11 @@ impl NodeCore { let address = self.wallet.address(); let mut balance = self.ledger.balance_of(address); if let Ok(wallet) = self.wallet.unlocked() { - let hybrid_address = wallet.hybrid_address(AddressNetwork::from_profile_id( - &self.ledger.launch_profile().profile_id, - )); - balance = balance.saturating_add(self.ledger.balance_of(&hybrid_address)); + if let Ok(addresses) = self.ledger.wallet_owned_hybrid_encoded_addresses(wallet) { + for hybrid_address in addresses { + balance = balance.saturating_add(self.ledger.balance_of(&hybrid_address)); + } + } } let confirmed_outputs = self .ledger @@ -293,8 +336,9 @@ mod tests { #[test] fn status_omits_full_balance_map_for_ui_polling() { let wallet = Wallet::from_seed("status-light-wallet"); + let wallet_address = wallet.address().to_string(); let mut allocations = BTreeMap::new(); - allocations.insert(wallet.address().to_string(), 10); + allocations.insert(wallet_address.clone(), 10); allocations.insert( Wallet::from_seed("status-light-peer").address().to_string(), 5, @@ -305,6 +349,12 @@ mod tests { let status = node.status(); assert_eq!(status.wallet_balance, 10); + assert_eq!(status.funded_wallet_addresses.len(), 1); + assert_eq!(status.funded_wallet_addresses[0].address, wallet_address); + assert_eq!(status.funded_wallet_addresses[0].balance, 10); + assert_eq!(status.funded_wallet_addresses[0].utxos, 1); + assert_eq!(status.funded_wallet_addresses[0].spendable_utxos, 1); + assert!(status.funded_wallet_addresses[0].legacy); assert!(status.chain.balances.is_empty()); } diff --git a/src/app/types.rs b/src/app/types.rs @@ -192,6 +192,8 @@ pub struct NodeStatus { pub app_version: String, pub wallet_address: String, pub wallet_receive_address: String, + #[serde(default)] + pub funded_wallet_addresses: Vec<FundedWalletAddressStatus>, pub wallet_balance: Amount, pub wallet_locked: bool, pub quantum_migration: QuantumMigrationStatus, @@ -203,6 +205,15 @@ pub struct NodeStatus { } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct FundedWalletAddressStatus { + pub address: String, + pub balance: Amount, + pub utxos: usize, + pub spendable_utxos: usize, + pub legacy: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct QuantumMigrationStatus { pub active: bool, pub hybrid_address: Option<String>, diff --git a/src/app/wallet.rs b/src/app/wallet.rs @@ -530,12 +530,10 @@ impl NodeCore { if self.ledger.height().saturating_add(1) < crate::domain::HYBRID_REWARD_ACTIVATION_HEIGHT { return Ok(self.wallet.address().to_string()); } - Ok(self - .wallet - .unlocked()? - .hybrid_address(crate::domain::AddressNetwork::from_profile_id( - &self.ledger.launch_profile().profile_id, - ))) + Ok(self.ledger.wallet_reward_address( + self.wallet.unlocked()?, + self.ledger.height().saturating_add(1), + )) } pub(super) fn wallet_anchor_build_ledger(&self) -> Result<Ledger> { diff --git a/src/domain.rs b/src/domain.rs @@ -130,7 +130,7 @@ pub use vdf::{ VdfProgress, VdfProgressPhase, run_vdf, run_vdf_cancellable_with_progress, run_vdf_with_progress, verify_vdf, }; -pub use wallet::Wallet; +pub use wallet::{HybridAddressBranch, Wallet}; pub fn burn_committee_slot_count(eligible_rank_count: usize) -> usize { eligible_rank_count.min(BURN_COMMITTEE_SIZE) diff --git a/src/domain/ledger_builders.rs b/src/domain/ledger_builders.rs @@ -7,14 +7,220 @@ use super::stratum::{ use super::transaction::{UnsignedTxInput, UnsignedUtxoTransaction}; use super::validation::validate_address; use super::{ - AddressNetwork, Amount, Ledger, LegacyTransactionId, MineSearchOutcome, OutPoint, - StratumMineShare, StratumMineTemplate, Transaction, TransactionV2, TransactionV2Domain, - TransactionV2Input, TransactionV2LegacyInput, TransactionV2Output, TxOutput, VersionedAddress, - Wallet, + 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, }; use anyhow::{Context, Result, bail}; +pub const HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT: u32 = 20; +const MAX_DISCOVERED_EXTERNAL_ADDRESSES: u32 = 10_000; + impl Ledger { + pub fn wallet_receive_address(&self, wallet: &Wallet) -> Result<String> { + let (_, address) = self + .wallet_external_addresses(wallet)? + .last() + .copied() + .context( + "wallet external address discovery did not return a current receive address", + )?; + encode_versioned_address(address, self.address_network()) + } + + pub fn wallet_reward_address(&self, wallet: &Wallet, _height: u64) -> String { + let (_, address) = self + .wallet_reward_addresses(wallet) + .expect("valid chain has discoverable wallet reward addresses") + .last() + .copied() + .expect("wallet reward discovery always returns a current address"); + encode_versioned_address(address, self.address_network()) + .expect("wallet reward address has a valid fixed-size commitment") + } + + pub fn wallet_owned_hybrid_addresses(&self, wallet: &Wallet) -> Result<Vec<VersionedAddress>> { + let mut addresses = self + .wallet_external_addresses(wallet)? + .into_iter() + .map(|(_, address)| address) + .collect::<Vec<_>>(); + if self.height() >= HYBRID_REWARD_ACTIVATION_HEIGHT { + for (_, address) in self.wallet_reward_addresses(wallet)? { + if !addresses.contains(&address) { + addresses.push(address); + } + } + } + Ok(addresses.into_iter().collect()) + } + + pub fn wallet_owned_hybrid_encoded_addresses(&self, wallet: &Wallet) -> Result<Vec<String>> { + self.wallet_owned_hybrid_addresses(wallet)? + .into_iter() + .map(|address| encode_versioned_address(address, self.address_network())) + .collect() + } + + fn address_network(&self) -> AddressNetwork { + AddressNetwork::from_profile_id(&self.launch_profile.profile_id) + } + + fn wallet_external_addresses(&self, wallet: &Wallet) -> Result<Vec<(u32, VersionedAddress)>> { + let tip_changed = !wallet.external_discovery_tip_matches(self.tip_hash()); + let used = if tip_changed { + self.used_hybrid_addresses()? + } else { + self.pending_hybrid_addresses() + }; + let start = wallet.external_address_cursor(); + let mut index = start; + let mut highest_used = None; + let mut unused_run = 0_u32; + while index < MAX_DISCOVERED_EXTERNAL_ADDRESSES { + let address = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, index); + if used.contains(&address) { + highest_used = Some(index); + unused_run = 0; + } else { + unused_run = unused_run.saturating_add(1); + if unused_run >= HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT { + let current = highest_used + .and_then(|used| used.checked_add(1)) + .unwrap_or(start); + wallet.advance_external_address_cursor(current); + if tip_changed { + wallet.mark_external_discovery_tip(self.tip_hash()); + } + return Ok((0..=current) + .map(|index| { + ( + index, + wallet.hybrid_versioned_address_at( + HybridAddressBranch::External, + index, + ), + ) + }) + .collect()); + } + } + index = index.saturating_add(1); + } + bail!( + "wallet external address discovery exceeded {MAX_DISCOVERED_EXTERNAL_ADDRESSES} addresses" + ) + } + + fn wallet_reward_addresses(&self, wallet: &Wallet) -> Result<Vec<(u32, VersionedAddress)>> { + let tip_changed = !wallet.reward_discovery_tip_matches(self.tip_hash()); + let spent = if tip_changed { + self.spent_hybrid_addresses()? + } else { + self.pending_spent_hybrid_addresses() + }; + let start = wallet.reward_address_cursor(); + let mut index = start; + let mut highest_spent = None; + let mut unspent_run = 0_u32; + while index < MAX_DISCOVERED_EXTERNAL_ADDRESSES { + let address = wallet.hybrid_versioned_address_at(HybridAddressBranch::Reward, index); + if spent.contains(&address) { + highest_spent = Some(index); + unspent_run = 0; + } else { + unspent_run = unspent_run.saturating_add(1); + if unspent_run >= HYBRID_EXTERNAL_ADDRESS_GAP_LIMIT { + let current = highest_spent + .and_then(|spent| spent.checked_add(1)) + .unwrap_or(start); + wallet.advance_reward_address_cursor(current); + if tip_changed { + wallet.mark_reward_discovery_tip(self.tip_hash()); + } + return Ok((0..=current) + .map(|index| { + ( + index, + wallet.hybrid_versioned_address_at( + HybridAddressBranch::Reward, + index, + ), + ) + }) + .collect()); + } + } + index = index.saturating_add(1); + } + bail!( + "wallet reward address discovery exceeded {MAX_DISCOVERED_EXTERNAL_ADDRESSES} addresses" + ) + } + + fn used_hybrid_addresses(&self) -> Result<Vec<VersionedAddress>> { + let mut used = self.pending_hybrid_addresses(); + let network = self.address_network(); + let domain = self.transaction_v2_domain()?; + for block in self.chain() { + if let Some(address) = block.reward_address.as_deref() { + if let Ok(address) = decode_versioned_address(address, network) { + if address.version == super::AddressVersion::HybridKeyCommitment { + push_unique_address(&mut used, address); + } + } + } + for transaction in &block.transactions { + collect_legacy_hybrid_outputs(transaction, network, &mut used); + } + 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 { + collect_v2_output_addresses(&transaction, &mut used); + } + } + } + Ok(used) + } + + fn pending_hybrid_addresses(&self) -> Vec<VersionedAddress> { + let mut used = Vec::new(); + let network = self.address_network(); + for transaction in &self.pending { + collect_legacy_hybrid_outputs(transaction, network, &mut used); + } + for transaction in &self.pending_v2 { + collect_v2_output_addresses(transaction, &mut used); + } + used + } + + fn spent_hybrid_addresses(&self) -> Result<Vec<VersionedAddress>> { + let mut spent = self.pending_spent_hybrid_addresses(); + let domain = self.transaction_v2_domain()?; + 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 { + collect_v2_input_addresses(&transaction, &mut spent); + } + } + } + Ok(spent) + } + + fn pending_spent_hybrid_addresses(&self) -> Vec<VersionedAddress> { + let mut spent = Vec::new(); + for transaction in &self.pending_v2 { + collect_v2_input_addresses(transaction, &mut spent); + } + spent + } + /// Builds one consolidation transaction from every currently spendable legacy wallet output /// into the wallet's hybrid address. Submission remains subject to the height-3000 gate. pub fn build_v2_migration(&self, wallet: &Wallet, fee: Amount) -> Result<TransactionV2> { @@ -142,12 +348,16 @@ impl Ledger { let required = amount .checked_add(fee) .context("transfer amount plus fee overflows")?; - let owner = wallet.hybrid_versioned_address(); - let owner_address = wallet.hybrid_address(AddressNetwork::from_profile_id( - &self.launch_profile.profile_id, - )); - let mut available = self.available_utxos_for_address(&owner_address)?; - available.sort_by(|(left_point, left), (right_point, right)| { + let mut available = Vec::new(); + for owner in self.wallet_owned_hybrid_addresses(wallet)? { + let owner_address = encode_versioned_address(owner, self.address_network())?; + available.extend( + self.available_utxos_for_address(&owner_address)? + .into_iter() + .map(|(outpoint, output)| (outpoint, output, owner)), + ); + } + available.sort_by(|(left_point, left, _), (right_point, right, _)| { right .amount .cmp(&left.amount) @@ -156,7 +366,7 @@ impl Ledger { let mut total = 0_u64; let mut inputs = Vec::new(); - for (outpoint, output) in available { + for (outpoint, output, owner) in available { total = total .checked_add(output.amount) .context("transaction v2 input total overflows")?; @@ -180,8 +390,13 @@ impl Ledger { }]; let change = total - required; if change > 0 { + let change_address = self + .wallet_external_addresses(wallet)? + .last() + .map(|(_, address)| *address) + .context("wallet change address is unavailable")?; outputs.push(TransactionV2Output { - address: owner, + address: change_address, amount: change, }); } @@ -193,14 +408,16 @@ impl Ledger { authorizations: Vec::new(), }; let payload = transaction.signing_bytes(&domain)?; - let authorization = wallet.sign_v2_authorization(owner, &payload)?; if let TransactionV2::Transfer { inputs, authorizations, .. } = &mut transaction { - authorizations.resize(inputs.len(), authorization); + *authorizations = inputs + .iter() + .map(|input| wallet.sign_v2_authorization(input.owner, &payload)) + .collect::<Result<Vec<_>>>()?; } transaction.verify_authorizations(&domain)?; ensure_v2_transaction_within_block_budget( @@ -243,17 +460,29 @@ impl Ledger { let required = amount .checked_add(fee) .context("burn amount plus fee overflows")?; - let owner = wallet.hybrid_versioned_address(); - let owner_address = wallet.hybrid_address(AddressNetwork::from_profile_id( - &self.launch_profile.profile_id, - )); - let mut available = self.available_utxos_for_address(&owner_address)?; - available.sort_by(|(left_point, left), (right_point, right)| { - right - .amount - .cmp(&left.amount) - .then_with(|| left_point.cmp(right_point)) - }); + let mut selected = None; + for owner in self.wallet_owned_hybrid_addresses(wallet)? { + let owner_address = encode_versioned_address(owner, self.address_network())?; + let mut available = self.available_utxos_for_address(&owner_address)?; + available.sort_by(|(left_point, left), (right_point, right)| { + right + .amount + .cmp(&left.amount) + .then_with(|| left_point.cmp(right_point)) + }); + let total = available.iter().try_fold(0_u64, |total, (_, output)| { + total + .checked_add(output.amount) + .context("transaction v2 input total overflows") + })?; + if total >= required { + selected = Some((owner, available)); + break; + } + } + let Some((owner, available)) = selected else { + bail!("insufficient hybrid funds in one address for burn"); + }; let mut total = 0_u64; let mut inputs = Vec::new(); for (outpoint, output) in available { @@ -274,9 +503,14 @@ impl Ledger { bail!("insufficient hybrid funds"); } let change_amount = total - required; + let change_address = self + .wallet_external_addresses(wallet)? + .last() + .map(|(_, address)| *address) + .context("wallet change address is unavailable")?; let change = (change_amount > 0) .then_some(TransactionV2Output { - address: owner, + address: change_address, amount: change_amount, }) .into_iter() @@ -685,6 +919,54 @@ impl Ledger { } } +fn collect_legacy_hybrid_outputs( + transaction: &Transaction, + network: AddressNetwork, + used: &mut Vec<VersionedAddress>, +) { + for output in transaction.outputs() { + if let Ok(address) = decode_versioned_address(&output.address, network) { + if address.version == super::AddressVersion::HybridKeyCommitment { + push_unique_address(used, address); + } + } + } +} + +fn collect_v2_output_addresses(transaction: &TransactionV2, used: &mut Vec<VersionedAddress>) { + let outputs = match transaction { + TransactionV2::Migration { outputs, .. } | TransactionV2::Transfer { outputs, .. } => { + outputs.as_slice() + } + TransactionV2::Burn { change, .. } => change.as_slice(), + TransactionV2::Mine { recipient, .. } => { + push_unique_address(used, *recipient); + return; + } + }; + for output in outputs { + push_unique_address(used, output.address); + } +} + +fn collect_v2_input_addresses(transaction: &TransactionV2, spent: &mut Vec<VersionedAddress>) { + let inputs = match transaction { + TransactionV2::Transfer { inputs, .. } | TransactionV2::Burn { inputs, .. } => { + inputs.as_slice() + } + TransactionV2::Migration { .. } | TransactionV2::Mine { .. } => return, + }; + for input in inputs { + push_unique_address(spent, input.owner); + } +} + +fn push_unique_address(addresses: &mut Vec<VersionedAddress>, address: VersionedAddress) { + if !addresses.contains(&address) { + addresses.push(address); + } +} + fn legacy_transaction_id(txid: &str) -> Result<LegacyTransactionId> { let bytes = decode_hex(txid).context("legacy outpoint ID is not hexadecimal")?; match bytes.len() { @@ -749,6 +1031,134 @@ mod v2_migration_tests { } #[test] + fn receive_and_change_advance_after_the_current_external_address_is_used() { + let wallet = Wallet::from_seed("rotating-external-wallet"); + let recipient = Wallet::from_seed("rotating-external-recipient"); + let mut ledger = Ledger::new(BTreeMap::new(), 1); + let owner = wallet.hybrid_versioned_address(); + let initial_receive = ledger.wallet_receive_address(&wallet).unwrap(); + assert_eq!( + initial_receive, + wallet.hybrid_address(AddressNetwork::Mainnet) + ); + + ledger.pending_v2.push(TransactionV2::Migration { + inputs: Vec::new(), + outputs: vec![TransactionV2Output { + address: owner, + amount: 100, + }], + fee: 0, + authorizations: Vec::new(), + }); + let rotated_receive = ledger.wallet_receive_address(&wallet).unwrap(); + assert_eq!( + rotated_receive, + wallet.hybrid_address_at(HybridAddressBranch::External, 1, AddressNetwork::Mainnet) + ); + let restored = Wallet::from_seed("rotating-external-wallet"); + assert_eq!( + ledger.wallet_receive_address(&restored).unwrap(), + rotated_receive + ); + + ledger.utxos.insert( + OutPoint { + txid: "42".repeat(32), + index: 0, + }, + TxOutput { + address: initial_receive, + amount: 100, + }, + ); + let transaction = ledger + .build_v2_transfer(&wallet, recipient.hybrid_versioned_address(), 40, 2) + .unwrap(); + let TransactionV2::Transfer { + outputs, + authorizations, + .. + } = transaction + else { + panic!("builder returned a non-transfer transaction"); + }; + assert_eq!( + outputs[1].address, + wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 1,) + ); + assert_eq!(authorizations[0].committed_address().unwrap(), owner); + } + + #[test] + fn reward_address_rotates_after_its_hybrid_key_is_revealed_by_a_spend() { + let wallet = Wallet::from_seed("rotating-reward-wallet"); + let mut ledger = Ledger::new(BTreeMap::new(), 1); + let activation = super::super::HYBRID_REWARD_ACTIVATION_HEIGHT; + + let first = ledger.wallet_reward_address(&wallet, activation); + assert_eq!( + first, + ledger.wallet_reward_address(&wallet, activation + 1_000) + ); + let first_owner = wallet.hybrid_versioned_address_at(HybridAddressBranch::Reward, 0); + ledger.pending_v2.push(TransactionV2::Burn { + inputs: vec![TransactionV2Input { + outpoint_txid: [0x42; 32], + outpoint_index: 0, + owner: first_owner, + }], + change: Vec::new(), + amount: 1, + fee: 1, + anchor: Some([0x24; 32]), + authorizations: Vec::new(), + }); + let second = ledger.wallet_reward_address(&wallet, activation + 1); + assert_ne!(first, second); + let restored = Wallet::from_seed("rotating-reward-wallet"); + assert_eq!( + ledger.wallet_reward_address(&restored, activation + 1), + second + ); + + ledger.chain.last_mut().unwrap().height = activation; + let owned = ledger + .wallet_owned_hybrid_encoded_addresses(&wallet) + .unwrap(); + assert!(owned.contains(&first)); + assert!(owned.contains(&second)); + } + + #[test] + fn seed_recovery_discovers_used_external_addresses_across_a_gap() { + let wallet = Wallet::from_seed("external-gap-recovery-wallet"); + let mut ledger = Ledger::new(BTreeMap::new(), 1); + let used_after_gap = wallet.hybrid_versioned_address_at(HybridAddressBranch::External, 2); + ledger.pending_v2.push(TransactionV2::Transfer { + inputs: Vec::new(), + outputs: vec![TransactionV2Output { + address: used_after_gap, + amount: 10, + }], + fee: 0, + authorizations: Vec::new(), + }); + + let restored = Wallet::from_seed("external-gap-recovery-wallet"); + assert_eq!( + ledger.wallet_receive_address(&restored).unwrap(), + restored.hybrid_address_at(HybridAddressBranch::External, 3, AddressNetwork::Mainnet,) + ); + assert!( + ledger + .wallet_owned_hybrid_addresses(&restored) + .unwrap() + .contains(&used_after_gap) + ); + } + + #[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/ledger_prepare.rs b/src/domain/ledger_prepare.rs @@ -8,13 +8,9 @@ use super::{ impl Ledger { pub fn mine_next_block(&self, wallet: &Wallet, timestamp_ms: u64) -> Result<super::Block> { - let reward_address = (self.height().saturating_add(1) - >= super::HYBRID_REWARD_ACTIVATION_HEIGHT) - .then(|| { - wallet.hybrid_address(super::AddressNetwork::from_profile_id( - &self.launch_profile.profile_id, - )) - }); + let next_height = self.height().saturating_add(1); + let reward_address = (next_height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT) + .then(|| self.wallet_reward_address(wallet, next_height)); let prepared = self.prepare_next_block_with_required_burn_and_burn_bundles( wallet.address(), reward_address.as_deref(), @@ -27,13 +23,9 @@ impl Ledger { } pub fn mine_recovery_block(&self, wallet: &Wallet, timestamp_ms: u64) -> Result<super::Block> { - let reward_address = (self.height().saturating_add(1) - >= super::HYBRID_REWARD_ACTIVATION_HEIGHT) - .then(|| { - wallet.hybrid_address(super::AddressNetwork::from_profile_id( - &self.launch_profile.profile_id, - )) - }); + let next_height = self.height().saturating_add(1); + let reward_address = (next_height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT) + .then(|| self.wallet_reward_address(wallet, next_height)); let prepared = self.prepare_recovery_block_with_required_burn_and_burn_bundles( wallet.address(), reward_address.as_deref(), diff --git a/src/domain/ledger_reveal.rs b/src/domain/ledger_reveal.rs @@ -5,10 +5,10 @@ use anyhow::{Context, Result, bail}; use super::ledger_ops::verify_address_signature; use super::reveal::{burn_bundle_slot_mask, burn_committee_mask}; use super::{ - AddressNetwork, Amount, BURN_COMMITTEE_SIZE, Block, BurnBundle, BurnBundlePayload, - BurnBundleSection, BurnBundleSignature, BurnCommitteeMember, FinalizerMode, Ledger, - MAX_BURN_BUNDLE_BYTES, MaskedBurn, MaskedBurnV2, OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, - Transaction, Wallet, hex_encode, + Amount, BURN_COMMITTEE_SIZE, Block, BurnBundle, BurnBundlePayload, BurnBundleSection, + BurnBundleSignature, BurnCommitteeMember, FinalizerMode, Ledger, MAX_BURN_BUNDLE_BYTES, + MaskedBurn, MaskedBurnV2, OBJECTIVE_FINALITY_ACTIVATION_HEIGHT, Transaction, Wallet, + hex_encode, }; impl Ledger { @@ -102,11 +102,8 @@ impl Ledger { prev_hash: prev_hash.clone(), slot: member.slot, member: wallet.address().to_string(), - reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT).then(|| { - wallet.hybrid_address(AddressNetwork::from_profile_id( - &self.launch_profile.profile_id, - )) - }), + reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT) + .then(|| self.wallet_reward_address(wallet, height)), burns: candidate.clone(), burns_v2: candidate_v2.clone(), }); @@ -115,19 +112,18 @@ impl Ledger { selected_v2 = candidate_v2; } } - bundles.push(wallet.burn_bundle(BurnBundlePayload { - height, - prev_hash: prev_hash.clone(), - slot: member.slot, - member: wallet.address().to_string(), - reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT).then(|| { - wallet.hybrid_address(AddressNetwork::from_profile_id( - &self.launch_profile.profile_id, - )) + bundles.push( + wallet.burn_bundle(BurnBundlePayload { + height, + prev_hash: prev_hash.clone(), + slot: member.slot, + member: wallet.address().to_string(), + reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT) + .then(|| self.wallet_reward_address(wallet, height)), + burns: selected, + burns_v2: selected_v2, }), - burns: selected, - burns_v2: selected_v2, - })); + ); } Ok(bundles) } @@ -222,11 +218,8 @@ impl Ledger { prev_hash, slot: member.slot, member: wallet.address().to_string(), - reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT).then(|| { - wallet.hybrid_address(AddressNetwork::from_profile_id( - &self.launch_profile.profile_id, - )) - }), + reward_address: (height >= super::HYBRID_REWARD_ACTIVATION_HEIGHT) + .then(|| self.wallet_reward_address(wallet, height)), burns, burns_v2: Vec::new(), }) diff --git a/src/domain/wallet.rs b/src/domain/wallet.rs @@ -1,6 +1,10 @@ use std::{ + collections::BTreeMap, fmt, - sync::{Arc, OnceLock}, + sync::{ + Arc, OnceLock, RwLock, + atomic::{AtomicU32, Ordering}, + }, }; use secrecy::{ExposeSecret, SecretBox, zeroize::Zeroize}; @@ -16,6 +20,30 @@ use super::{ const WALLET_SEED_DOMAIN: &str = "iuna-wallet-seed"; const WALLET_ML_DSA44_SEED_DOMAIN: &str = "iuna-wallet-ml-dsa44-seed-v1"; +const WALLET_ML_DSA44_CHILD_SEED_DOMAIN: &str = "iuna-wallet-ml-dsa44-child-seed-v1"; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum HybridAddressBranch { + External, + Reward, +} + +impl HybridAddressBranch { + fn domain_label(self) -> &'static str { + match self { + Self::External => "external", + Self::Reward => "reward", + } + } +} + +struct HybridChildKey { + signing_seed: SecretBox<[u8; 32]>, + public_key: ProtocolPublicKey, + address: VersionedAddress, +} + +type HybridChildKeyCache = BTreeMap<(HybridAddressBranch, u32), Arc<HybridChildKey>>; #[derive(Clone)] pub struct Wallet { @@ -23,6 +51,11 @@ pub struct Wallet { signing_seed: Arc<SecretBox<[u8; 32]>>, ml_dsa44_signing_seed: Arc<SecretBox<[u8; 32]>>, hybrid_public_key: Arc<OnceLock<ProtocolPublicKey>>, + hybrid_child_keys: Arc<RwLock<HybridChildKeyCache>>, + external_address_cursor: Arc<AtomicU32>, + external_discovery_tip: Arc<RwLock<Option<String>>>, + reward_address_cursor: Arc<AtomicU32>, + reward_discovery_tip: Arc<RwLock<Option<String>>>, } impl Wallet { @@ -35,6 +68,11 @@ impl Wallet { signing_seed: Arc::new(signing_seed), ml_dsa44_signing_seed: Arc::new(ml_dsa44_signing_seed), hybrid_public_key: Arc::new(OnceLock::new()), + hybrid_child_keys: Arc::new(RwLock::new(BTreeMap::new())), + external_address_cursor: Arc::new(AtomicU32::new(0)), + external_discovery_tip: Arc::new(RwLock::new(None)), + reward_address_cursor: Arc::new(AtomicU32::new(0)), + reward_discovery_tip: Arc::new(RwLock::new(None)), } } @@ -61,6 +99,27 @@ impl Wallet { .expect("wallet hybrid address has a valid fixed-size commitment") } + pub fn hybrid_versioned_address_at( + &self, + branch: HybridAddressBranch, + index: u32, + ) -> VersionedAddress { + if branch == HybridAddressBranch::External && index == 0 { + return self.hybrid_versioned_address(); + } + self.hybrid_child_key(branch, index).address + } + + pub fn hybrid_address_at( + &self, + branch: HybridAddressBranch, + index: u32, + network: AddressNetwork, + ) -> String { + encode_versioned_address(self.hybrid_versioned_address_at(branch, index), network) + .expect("wallet hybrid address has a valid fixed-size commitment") + } + pub fn hybrid_public_key(&self) -> &ProtocolPublicKey { self.hybrid_public_key.get_or_init(|| { let mut public_key = @@ -74,6 +133,54 @@ impl Wallet { }) } + pub(crate) fn external_address_cursor(&self) -> u32 { + self.external_address_cursor.load(Ordering::Relaxed) + } + + pub(crate) fn advance_external_address_cursor(&self, index: u32) { + self.external_address_cursor + .fetch_max(index, Ordering::Relaxed); + } + + pub(crate) fn external_discovery_tip_matches(&self, tip: &str) -> bool { + self.external_discovery_tip + .read() + .expect("wallet external discovery lock is not poisoned") + .as_deref() + == Some(tip) + } + + pub(crate) fn mark_external_discovery_tip(&self, tip: &str) { + *self + .external_discovery_tip + .write() + .expect("wallet external discovery lock is not poisoned") = Some(tip.to_string()); + } + + pub(crate) fn reward_address_cursor(&self) -> u32 { + self.reward_address_cursor.load(Ordering::Relaxed) + } + + pub(crate) fn advance_reward_address_cursor(&self, index: u32) { + self.reward_address_cursor + .fetch_max(index, Ordering::Relaxed); + } + + pub(crate) fn reward_discovery_tip_matches(&self, tip: &str) -> bool { + self.reward_discovery_tip + .read() + .expect("wallet reward discovery lock is not poisoned") + .as_deref() + == Some(tip) + } + + pub(crate) fn mark_reward_discovery_tip(&self, tip: &str) { + *self + .reward_discovery_tip + .write() + .expect("wallet reward discovery lock is not poisoned") = Some(tip.to_string()); + } + /// Creates both signatures over the same canonical transaction-v2 payload. pub fn sign_hybrid_authorization( &self, @@ -111,9 +218,56 @@ impl Wallet { if owner == self.hybrid_versioned_address() { return self.sign_hybrid_authorization(payload); } + let child = self + .hybrid_child_keys + .read() + .expect("wallet hybrid child-key cache lock is not poisoned") + .values() + .find(|child| child.address == owner) + .cloned(); + if let Some(child) = child { + return sign_hybrid_authorization_with_key( + self.signing_seed.expose_secret(), + child.signing_seed.expose_secret(), + &child.public_key, + payload, + ); + } anyhow::bail!("transaction v2 input is not owned by this wallet") } + fn hybrid_child_key(&self, branch: HybridAddressBranch, index: u32) -> Arc<HybridChildKey> { + let descriptor = (branch, index); + if let Some(key) = self + .hybrid_child_keys + .read() + .expect("wallet hybrid child-key cache lock is not poisoned") + .get(&descriptor) + { + return Arc::clone(key); + } + let signing_seed = + derive_child_signing_seed(self.ml_dsa44_signing_seed.expose_secret(), branch, index); + let mut bytes = + Vec::with_capacity(SignatureScheme::HybridEd25519MlDsa44.public_key_bytes()); + bytes.extend_from_slice(&ed25519_public_key(self.signing_seed.expose_secret())); + bytes.extend_from_slice(&ml_dsa44_public_key(signing_seed.expose_secret())); + let public_key = ProtocolPublicKey::new(SignatureScheme::HybridEd25519MlDsa44, bytes) + .expect("wallet hybrid child public key has the scheme-defined length"); + let address = hybrid_key_commitment_address(&public_key) + .expect("wallet always constructs a valid hybrid child public key"); + let key = Arc::new(HybridChildKey { + signing_seed, + public_key, + address, + }); + self.hybrid_child_keys + .write() + .expect("wallet hybrid child-key cache lock is not poisoned") + .insert(descriptor, Arc::clone(&key)); + key + } + pub(super) fn sign_payload(&self, payload: &str) -> String { self.sign_bytes(payload.as_bytes()) } @@ -178,11 +332,47 @@ fn derive_signing_seed(domain: &str, seed: &str) -> SecretBox<[u8; 32]> { signing_seed } +fn derive_child_signing_seed( + parent: &[u8; 32], + branch: HybridAddressBranch, + index: u32, +) -> SecretBox<[u8; 32]> { + let mut hasher = Sha256::new(); + hasher.update(WALLET_ML_DSA44_CHILD_SEED_DOMAIN.as_bytes()); + hasher.update(b":"); + hasher.update(branch.domain_label().as_bytes()); + hasher.update(b":"); + hasher.update(index.to_be_bytes()); + hasher.update(b":"); + hasher.update(parent); + let mut seed_hash = hasher.finalize(); + let signing_seed = SecretBox::init_with_mut(|signing_seed: &mut [u8; 32]| { + signing_seed.copy_from_slice(&seed_hash); + }); + seed_hash.zeroize(); + signing_seed +} + +fn sign_hybrid_authorization_with_key( + ed25519_signing_seed: &[u8; 32], + ml_dsa44_signing_seed: &[u8; 32], + public_key: &ProtocolPublicKey, + payload: &[u8], +) -> anyhow::Result<V2SpendingAuthorization> { + let mut signature = Vec::with_capacity(SignatureScheme::HybridEd25519MlDsa44.signature_bytes()); + signature.extend_from_slice(&sign_ed25519(ed25519_signing_seed, payload)); + signature.extend_from_slice(&sign_ml_dsa44(ml_dsa44_signing_seed, payload)?); + V2SpendingAuthorization::new( + public_key.clone(), + ProtocolSignature::new(SignatureScheme::HybridEd25519MlDsa44, signature)?, + ) +} + #[cfg(test)] mod tests { use secrecy::ExposeSecret; - use super::Wallet; + use super::{HybridAddressBranch, Wallet}; use crate::domain::{ AddressNetwork, SignatureScheme, hex_encode, verify_ed25519, verify_ml_dsa44, }; @@ -299,4 +489,26 @@ mod tests { .is_err() ); } + + #[test] + fn child_hybrid_addresses_are_deterministic_separated_and_spendable() { + let first = Wallet::from_seed("rotating-hybrid-wallet-seed"); + let restored = Wallet::from_seed("rotating-hybrid-wallet-seed"); + let external_zero = first.hybrid_versioned_address_at(HybridAddressBranch::External, 0); + let external_one = first.hybrid_versioned_address_at(HybridAddressBranch::External, 1); + let reward_zero = first.hybrid_versioned_address_at(HybridAddressBranch::Reward, 0); + + assert_eq!(external_zero, first.hybrid_versioned_address()); + assert_eq!( + external_one, + restored.hybrid_versioned_address_at(HybridAddressBranch::External, 1) + ); + assert_ne!(external_one, external_zero); + assert_ne!(reward_zero, external_one); + + let authorization = first + .sign_v2_authorization(external_one, b"rotated child spend") + .unwrap(); + assert_eq!(authorization.committed_address().unwrap(), external_one); + } } diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -179,6 +179,25 @@ fn management_ui_opens_contact_editor_from_wallet_addresses() { } #[test] +fn management_ui_prefers_the_live_rotating_receive_address() { + let javascript = include_str!("../www/assets/iuna-ui.js"); + let receive_address = javascript + .split_once("receiveAddress()") + .and_then(|(_, rest)| rest.split_once("\n },")) + .map(|(body, _)| body) + .expect("receive address helper"); + + assert!(receive_address.contains( + "return this.status.wallet_receive_address || this.setupWallet.address || \"-\";" + )); + assert!(javascript.contains("await navigator.clipboard.writeText(this.receiveAddress())")); + assert!(javascript.contains("fundedWalletAddresses()")); + assert!(javascript.contains("Funds are held across ${count} wallet address")); + assert!(javascript.contains("openWalletAddressesModal()")); + assert!(!javascript.contains("copyHybridAddress")); +} + +#[test] fn management_ui_only_offers_wallet_optimization_when_relevant_or_requested() { let html = include_str!("adapters/http/index_html.rs"); let javascript = include_str!("../www/assets/iuna-ui.js"); diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -142,6 +142,7 @@ window.iunaApp = function iunaApp() { chainResetConfirm: "", chainResetBusy: false, showWalletUtxos: false, + showWalletAddresses: false, showPowDifficultyInfo: false, lastUpdated: null, pollHandle: null, @@ -588,6 +589,41 @@ window.iunaApp = function iunaApp() { return this.setupWallet.address || this.status.wallet_receive_address || "-"; }, + receiveAddress() { + return this.status.wallet_receive_address || this.setupWallet.address || "-"; + }, + + fundedWalletAddresses() { + return Array.isArray(this.status.funded_wallet_addresses) + ? this.status.funded_wallet_addresses + : []; + }, + + walletAddressSummary() { + const count = this.fundedWalletAddresses().length; + return `Funds are held across ${count} wallet address${count === 1 ? "" : "es"}`; + }, + + walletAddressHasPendingSpend(entry) { + return Number(entry?.spendable_utxos || 0) < Number(entry?.utxos || 0); + }, + + walletAddressState(entry) { + if (this.walletAddressHasPendingSpend(entry)) return "Pending spend"; + if (entry?.address === this.receiveAddress()) return "Current"; + if (entry?.legacy === true) return "Legacy funds"; + return "Funded"; + }, + + walletAddressUtxoLabel(entry) { + const total = Number(entry?.utxos || 0); + const available = Number(entry?.spendable_utxos || 0); + const pending = Math.max(0, total - available); + const parts = [`${total} UTXO${total === 1 ? "" : "s"}`]; + if (pending > 0) parts.push(`${available} available`, `${pending} pending`); + return parts.join(" ยท "); + }, + selectSetupWalletMode(mode) { this.setupWalletMode = mode; this.walletVerified = mode === "import" ? this.walletVerified && !this.generatedSeedPhrase : false; @@ -1356,6 +1392,14 @@ window.iunaApp = function iunaApp() { this.showWalletUtxos = false; }, + openWalletAddressesModal() { + this.showWalletAddresses = true; + }, + + closeWalletAddressesModal() { + this.showWalletAddresses = false; + }, + openPowDifficultyInfo() { this.showPowDifficultyInfo = true; }, @@ -1417,6 +1461,7 @@ window.iunaApp = function iunaApp() { this.closeSendConfirmModal(); this.closeTransactionModal(); this.closeWalletUtxosModal(); + this.closeWalletAddressesModal(); this.closePowDifficultyInfo(); this.closeBurnLeaderRanksModal(); this.closeBurnBundleModal(); @@ -3105,14 +3150,12 @@ window.iunaApp = function iunaApp() { } }, - async copyHybridAddress() { + async copyReceiveAddress() { try { - const address = this.status.quantum_migration?.hybrid_address; - if (!address) throw new Error("Hybrid address unavailable"); - await navigator.clipboard.writeText(address); - this.showFlash("Hybrid address copied", "success"); + await navigator.clipboard.writeText(this.receiveAddress()); + this.showFlash("Address copied", "success"); } catch (error) { - this.showFlash("Could not copy hybrid address", "error"); + this.showFlash("Could not copy address", "error"); } },