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:
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");
}
},