iuna

iuna

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

commit b42254852ec60be7e9280b5622e6b0105c702c7a
parent 0f7ca5b8286a2eeccbdfb2adf7ca92708e471d0c
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 22 Sep 2026 11:23:24 +0200

feat(wallet): expose compatible v2 mempool

Diffstat:
MCHANGELOG.md | 2++
Mdocs/quantum-audit-scope.md | 3++-
Mdocs/quantum-migration.md | 4+++-
Msrc/adapters/http.rs | 5+++--
Msrc/adapters/http/api.rs | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/adapters/http/index_html.rs | 6+++++-
Msrc/adapters/http/metrics.rs | 2++
Msrc/adapters/http/types.rs | 14++++++++++++--
Msrc/adapters/http/ui.rs | 396+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/adapters/p2p/handshake.rs | 3+++
Msrc/adapters/p2p/peer_status.rs | 16++++++++++++++++
Msrc/adapters/p2p/session.rs | 16++++++++++++++--
Msrc/adapters/p2p/sync.rs | 20++++++++++++++++++--
Msrc/adapters/p2p/tests.rs | 37+++++++++++++++++++++++++++++++++++++
Msrc/app.rs | 12++++++++----
Msrc/app/ledger_view.rs | 6+++++-
Msrc/app/status.rs | 33++++++++++++++++++++-------------
Msrc/app/types.rs | 1+
Msrc/domain/ledger_queries.rs | 4++++
Mtests/quantum-migration.test.cjs | 8++++++++
20 files changed, 622 insertions(+), 44 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -8,10 +8,12 @@ from the Git history and Conventional Commit titles by `deployment.sh`. ### Added - relay transaction-v2 mempool envelopes +- negotiate transaction-v2 mempool relay separately for mixed-version networks - preview quantum-resistant wallet migration - expose legacy and hybrid wallet balances - submit block-bounded wallet migration batches - send hybrid transaction-v2 transfers +- merge pending transaction-v2 entries into the wallet and chain mempool views ### Documentation diff --git a/docs/quantum-audit-scope.md b/docs/quantum-audit-scope.md @@ -5,7 +5,8 @@ This document defines the review package for Iuna's post-quantum migration. The current code reserves versioned addresses and transaction encodings, verifies hybrid Ed25519 + ML-DSA-44 authorizations, and activated transaction v2 at height 3000. Live consensus and blocks accept v2 -transactions, and nodes relay canonical v2 mempool envelopes. The management wallet exposes +transactions, and nodes relay canonical v2 mempool envelopes only across sessions that negotiated +the `transaction-v2-mempool` capability. The management wallet exposes migration telemetry, reviewed block-bounded migration submission, and hybrid transfers. An audit of this scope must review the active consensus code and the enabled wallet migration diff --git a/docs/quantum-migration.md b/docs/quantum-migration.md @@ -121,7 +121,9 @@ version-1 output requires Ed25519 + ML-DSA-44. Verification uses the exact-pinned RustCrypto `ml-dsa` 0.1.1 implementation. That implementation has not been independently audited, so an independent review and an explicit backend acceptance decision remain prerequisites for treating the active rules as production-ready. Nodes advertise -the transaction-v2 block capability and relay canonical transaction-v2 envelopes. The management +the transaction-v2 block capability and relay canonical transaction-v2 envelopes only to peers +that advertise the separate `transaction-v2-mempool` capability. This keeps 0.4.35 block-validating +peers connected during a gradual relay upgrade. The management wallet can submit reviewed migration batches and ordinary hybrid transfers; address rotation and broader recovery rehearsal remain release blockers. diff --git a/src/adapters/http.rs b/src/adapters/http.rs @@ -61,8 +61,9 @@ pub use state::ServeOptions; use state::{AuthClientKey, AuthSession, HttpState}; use static_assets::{alpine_js, app_js, favicon, index}; use ui::{ - add_pending_outputs, populate_wallet_reward_flow, ui_blocks_from_indexes, ui_transaction, - wallet_transaction_row, wallet_transaction_rows, + add_pending_outputs, add_pending_v2_outputs, populate_wallet_reward_flow, + transaction_v2_input_outpoints, ui_blocks_from_indexes, ui_transaction, ui_transaction_v2, + wallet_transaction_row, wallet_transaction_rows, wallet_transaction_v2_rows, }; use wallet::{ api_wallet_setup, estimate_burn_fee, estimate_mine_fee, estimate_transfer_fee, diff --git a/src/adapters/http/api.rs b/src/adapters/http/api.rs @@ -10,7 +10,7 @@ use axum::{ use crate::{ adapters::p2p::P2pMetrics, app::{NodeStatus, PeerInfo}, - domain::{OutPoint, Transaction, TxOutput}, + domain::{AddressNetwork, OutPoint, Transaction, TxOutput}, ip_geolocation::IpGeolocation, }; @@ -23,9 +23,10 @@ use super::{ }; use super::{ DATASET_LIMIT, DATASET_PAGE_LIMIT, EXPLORER_LIMIT, EXPLORER_PAGE_LIMIT, HttpState, - add_pending_outputs, metrics_response, network_health, populate_wallet_reward_flow, - top_mine_proofs, ui_blocks_from_indexes, ui_transaction, wallet_transaction_row, - wallet_transaction_rows, + add_pending_outputs, add_pending_v2_outputs, metrics_response, network_health, + populate_wallet_reward_flow, top_mine_proofs, transaction_v2_input_outpoints, + ui_blocks_from_indexes, ui_transaction, ui_transaction_v2, wallet_transaction_row, + wallet_transaction_rows, wallet_transaction_v2_rows, }; pub(super) async fn api_status(State(state): State<HttpState>) -> Json<NodeStatus> { @@ -84,9 +85,25 @@ pub(super) async fn api_mempool( State(state): State<HttpState>, Query(query): Query<PageQuery>, ) -> Json<Page<UiTransaction>> { - let pending = { + let (pending, pending_v2, domain, network, confirmed_outputs) = { let node = state.node.lock().await; - node.pending_transactions() + let pending_v2 = node.pending_transactions_v2(); + let confirmed_outputs = pending_v2 + .iter() + .flat_map(transaction_v2_input_outpoints) + .filter_map(|outpoint| { + node.ledger() + .output_for_outpoint(&outpoint) + .map(|output| (outpoint, output)) + }) + .collect::<BTreeMap<_, _>>(); + ( + node.pending_transactions(), + pending_v2, + node.ledger().transaction_v2_domain().ok(), + AddressNetwork::from_profile_id(&node.ledger().launch_profile().profile_id), + confirmed_outputs, + ) }; let mut required_outputs = BTreeSet::new(); collect_transaction_input_outpoints(pending.iter(), &mut required_outputs); @@ -98,6 +115,13 @@ pub(super) async fn api_mempool( .iter() .map(|tx| ui_transaction(tx, &outputs)) .collect::<Vec<_>>(); + let mut v2_outputs = confirmed_outputs; + if let Some(domain) = domain.as_ref() { + let _ = add_pending_v2_outputs(&mut v2_outputs, &pending_v2, domain, network); + items.extend(pending_v2.iter().filter_map(|transaction| { + ui_transaction_v2(transaction, &v2_outputs, domain, network).ok() + })); + } items.reverse(); Json(page_items(items, query)) } @@ -113,11 +137,32 @@ pub(super) async fn api_wallet_transactions( .unwrap_or(DATASET_PAGE_LIMIT) .clamp(1, DATASET_LIMIT); let filters = WalletTransactionFilters::from_query(query); - let (wallet, pending) = { + let (wallet, 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 pending_v2 = node.pending_transactions_v2(); + let v2_outputs = pending_v2 + .iter() + .flat_map(transaction_v2_input_outpoints) + .filter_map(|outpoint| { + node.ledger() + .output_for_outpoint(&outpoint) + .map(|output| (outpoint, output)) + }) + .collect::<BTreeMap<_, _>>(); ( - node.wallet_address().to_string(), + wallet, + wallet_addresses, node.pending_transactions(), + pending_v2, + node.ledger().transaction_v2_domain().ok(), + AddressNetwork::from_profile_id(&node.ledger().launch_profile().profile_id), + v2_outputs, ) }; let mut pending_required_outputs = BTreeSet::new(); @@ -126,8 +171,22 @@ pub(super) async fn api_wallet_transactions( .await .unwrap_or_default(); add_pending_outputs(&mut pending_outputs, &pending); - let pending_rows = + let mut pending_rows = wallet_transaction_rows(&wallet, pending.clone(), &[], &pending_outputs, filters); + if let Some(domain) = domain.as_ref() { + let mut v2_outputs = v2_outputs; + let _ = add_pending_v2_outputs(&mut v2_outputs, &pending_v2, domain, network); + let mut v2_rows = wallet_transaction_v2_rows( + &wallet_addresses, + &pending_v2, + &v2_outputs, + filters, + domain, + network, + ); + v2_rows.append(&mut pending_rows); + pending_rows = v2_rows; + } let pending_total = pending_rows.len(); let mut items = pending_rows .into_iter() @@ -487,6 +546,7 @@ pub(super) async fn api_network_health( .map(|block| block.timestamp_ms); let mempool = MempoolCounts { plain_transactions: node.pending_transactions().len(), + v2_transactions: node.pending_transactions_v2().len(), }; ( NetworkHealthLocalState { diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -731,7 +731,10 @@ pub(super) const INDEX_HTML: &str = concat!( <label>Fee / byte<input x-model="quantumMigrationFee" type="number" min="0.000001" step="0.000001" required></label> <button type="submit" :disabled="quantumMigrationBusy || quantumMigrationSubmitting || status.wallet_locked || status.quantum_migration?.migration_pending" x-text="quantumMigrationBusy ? 'Calculating…' : 'Preview migration'"></button> <div class="fee-warning" role="alert" x-show="quantumMigrationError" x-text="quantumMigrationError"></div> - <div class="muted" x-show="status.quantum_migration?.migration_pending">A migration batch is pending confirmation.</div> + <div class="muted" x-show="status.quantum_migration?.migration_pending"> + A migration batch is still in this node's transaction-v2 mempool and is pending confirmation. + <code x-show="status.quantum_migration?.pending_transaction_id" x-text="status.quantum_migration?.pending_transaction_id || ''"></code> + </div> </form> </div> <div class="info-copy" x-show="quantumMigrationPreview"> @@ -1051,6 +1054,7 @@ pub(super) const INDEX_HTML: &str = concat!( <div class="peer-summary-item"><div class="peer-summary-label">Banned</div><div class="peer-summary-value" x-text="networkHealth.banned_peers ?? '-'"></div></div> <div class="peer-summary-item"><div class="peer-summary-label">Mempool</div><div class="peer-summary-value" x-text="networkHealth.pending_transactions ?? '-'"></div></div> <div class="peer-summary-item"><div class="peer-summary-label">Plain Tx</div><div class="peer-summary-value" x-text="networkHealth.pending_plain_transactions ?? '-'"></div></div> + <div class="peer-summary-item"><div class="peer-summary-label">V2 Tx</div><div class="peer-summary-value" x-text="networkHealth.pending_v2_transactions ?? '-'"></div></div> <div class="peer-summary-item"><div class="peer-summary-label">Next Finalizer</div><code class="peer-summary-value" :class="{ 'wallet-address-link': hasWalletAddress(networkHealth.current_leader) }" role="button" :tabindex="hasWalletAddress(networkHealth.current_leader) ? 0 : -1" x-text="networkFinalizerLabel()" @click="openAddressContact(networkHealth.current_leader)" @keydown.enter.prevent="openAddressContact(networkHealth.current_leader)" @keydown.space.prevent="openAddressContact(networkHealth.current_leader)" :title="hasWalletAddress(networkHealth.current_leader) ? 'Add or edit contact' : null"></code></div> <div class="peer-summary-item"><div class="peer-summary-label">Last Mode</div><div class="peer-summary-value" x-text="networkFinalizerModeLabel()"></div></div> <div class="peer-summary-item"><div class="peer-summary-label">VDF</div><div class="peer-summary-value" x-text="networkVdfLabel()"></div></div> diff --git a/src/adapters/http/metrics.rs b/src/adapters/http/metrics.rs @@ -331,6 +331,7 @@ pub(super) fn network_health_at( banned_peers, pending_transactions: local.pending_transactions, pending_plain_transactions: mempool.plain_transactions, + pending_v2_transactions: mempool.v2_transactions, last_finalizer_mode: local.last_finalizer_mode, last_finalizer_rank: local.last_finalizer_rank, last_block_finalizer: local.last_block_finalizer, @@ -517,6 +518,7 @@ mod tests { &[], MempoolCounts { plain_transactions: 3, + v2_transactions: 0, }, 2_500, ); diff --git a/src/adapters/http/types.rs b/src/adapters/http/types.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize, Serializer}; use crate::{ adapters::{config_store::UiConfig, ui_data_store::BlockMetricRow}, app::PeerInfo, - domain::{Amount, BurnLeaderRank, OutPoint, Transaction, TxOutput}, + domain::{Amount, BurnLeaderRank, OutPoint, Transaction, TransactionV2, TxOutput}, ip_geolocation::CountryCode, }; @@ -48,6 +48,7 @@ pub(super) struct NetworkHealthResponse { pub(super) banned_peers: usize, pub(super) pending_transactions: usize, pub(super) pending_plain_transactions: usize, + pub(super) pending_v2_transactions: usize, pub(super) last_finalizer_mode: Option<String>, pub(super) last_finalizer_rank: Option<u32>, pub(super) last_block_finalizer: Option<String>, @@ -77,11 +78,12 @@ pub(super) struct PeerPresentation { #[derive(Clone, Copy, Debug, Default)] pub(super) struct MempoolCounts { pub(super) plain_transactions: usize, + pub(super) v2_transactions: usize, } impl MempoolCounts { pub(super) fn total(&self) -> usize { - self.plain_transactions + self.plain_transactions.saturating_add(self.v2_transactions) } } @@ -283,6 +285,14 @@ impl WalletTransactionFilters { Transaction::Burn { .. } => self.burn, } } + + pub(super) fn allows_v2(self, transaction: &TransactionV2) -> bool { + match transaction { + TransactionV2::Migration { .. } | TransactionV2::Transfer { .. } => self.transfer, + TransactionV2::Mine { .. } => self.mine, + TransactionV2::Burn { .. } => self.burn, + } + } } #[derive(Debug, Serialize)] diff --git a/src/adapters/http/ui.rs b/src/adapters/http/ui.rs @@ -1,9 +1,12 @@ use std::collections::BTreeMap; +use anyhow::Result; + use crate::compact::CompactBlockSizeBreakdown; use crate::domain::{ - Amount, Block, BurnLeaderRank, FinalizerMode, MINE_REWARD, OutPoint, Transaction, TxInput, - TxOutput, + AddressNetwork, Amount, Block, BurnLeaderRank, FinalizerMode, LegacyTransactionId, MINE_REWARD, + OutPoint, Transaction, TransactionV2, TransactionV2Domain, TxInput, TxOutput, + encode_versioned_address, hex_encode, }; use super::types::{ @@ -61,6 +64,91 @@ pub(super) fn wallet_transaction_rows( rows.into_iter().map(|(_, row)| row).collect() } +pub(super) fn wallet_transaction_v2_rows( + wallet_addresses: &[String], + pending: &[TransactionV2], + outputs: &BTreeMap<OutPoint, TxOutput>, + filters: WalletTransactionFilters, + domain: &TransactionV2Domain, + network: AddressNetwork, +) -> Vec<WalletTransactionRow> { + pending + .iter() + .rev() + .filter(|transaction| filters.allows_v2(transaction)) + .filter_map(|transaction| { + wallet_transaction_v2_row(wallet_addresses, transaction, outputs, domain, network) + .ok() + .flatten() + }) + .collect() +} + +fn wallet_transaction_v2_row( + wallet_addresses: &[String], + transaction: &TransactionV2, + outputs: &BTreeMap<OutPoint, TxOutput>, + domain: &TransactionV2Domain, + network: AddressNetwork, +) -> Result<Option<WalletTransactionRow>> { + let presented = ui_transaction_v2(transaction, outputs, domain, network)?; + let is_wallet_address = + |candidate: &str| wallet_addresses.iter().any(|address| address == candidate); + let sent = is_wallet_address(&presented.from); + let received = presented + .outputs + .iter() + .chain(presented.change.iter()) + .any(|output| is_wallet_address(&output.address)); + if !sent && !received { + return Ok(None); + } + let amount = if matches!(transaction, TransactionV2::Migration { .. }) { + presented.amount + } else if sent { + presented + .outputs + .iter() + .filter(|output| !is_wallet_address(&output.address)) + .fold(0_u64, |total, output| total.saturating_add(output.amount)) + } else { + presented + .outputs + .iter() + .chain(presented.change.iter()) + .filter(|output| is_wallet_address(&output.address)) + .fold(0_u64, |total, output| total.saturating_add(output.amount)) + }; + let direction = match transaction { + TransactionV2::Migration { .. } => "migrated", + TransactionV2::Burn { .. } => "burned", + _ if sent => "sent", + _ => "received", + }; + Ok(Some(WalletTransactionRow { + kind: presented.kind, + from: presented.from, + to: presented.to, + amount, + fee: presented.fee, + inputs: presented.inputs, + outputs: presented.outputs, + change: presented.change, + signature: presented.signature, + status: "pending", + block_height: None, + timestamp_ms: None, + block_finalizer: None, + direction, + difficulty_bits: presented.difficulty_bits, + proof_bits: presented.proof_bits, + proof_hash: presented.proof_hash, + reward_total: None, + reward_fee_inputs: Vec::new(), + reward_outputs: Vec::new(), + })) +} + pub(super) fn wallet_transaction_row( wallet: &str, tx: &Transaction, @@ -476,6 +564,263 @@ pub(super) fn ui_transaction( } } +pub(super) fn ui_transaction_v2( + transaction: &TransactionV2, + outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, + domain: &TransactionV2Domain, + network: AddressNetwork, +) -> Result<UiTransaction> { + let transaction_id = hex_encode(transaction.transaction_id(domain)?); + match transaction { + TransactionV2::Migration { + inputs, + outputs, + fee, + authorizations, + } => Ok(UiTransaction { + kind: "migration", + from: inputs + .first() + .map(|input| encode_versioned_address(input.owner, network)) + .transpose()? + .unwrap_or_default(), + to: first_v2_output_address(outputs, network)?, + amount: outputs + .iter() + .fold(0_u64, |total, output| total.saturating_add(output.amount)), + fee: *fee, + inputs: inputs + .iter() + .enumerate() + .map(|(index, input)| { + let txid = match &input.outpoint_id { + LegacyTransactionId::Hash(value) => hex_encode(value), + LegacyTransactionId::Signature(value) => hex_encode(value), + }; + v2_ui_input( + OutPoint { + txid, + index: input.outpoint_index, + }, + input.owner, + authorizations.get(index), + outputs_by_outpoint, + network, + ) + }) + .collect::<Result<Vec<_>>>()?, + outputs: v2_ui_outputs(outputs, network)?, + change: Vec::new(), + signature: transaction_id, + difficulty_bits: None, + proof_bits: None, + proof_hash: None, + }), + TransactionV2::Transfer { + inputs, + outputs, + fee, + authorizations, + } => Ok(UiTransaction { + kind: "transfer", + from: inputs + .first() + .map(|input| encode_versioned_address(input.owner, network)) + .transpose()? + .unwrap_or_default(), + to: first_v2_output_address(outputs, network)?, + amount: outputs.first().map(|output| output.amount).unwrap_or(0), + fee: *fee, + inputs: inputs + .iter() + .enumerate() + .map(|(index, input)| { + v2_ui_input( + OutPoint { + txid: hex_encode(input.outpoint_txid), + index: input.outpoint_index, + }, + input.owner, + authorizations.get(index), + outputs_by_outpoint, + network, + ) + }) + .collect::<Result<Vec<_>>>()?, + outputs: v2_ui_outputs(outputs, network)?, + change: Vec::new(), + signature: transaction_id, + difficulty_bits: None, + proof_bits: None, + proof_hash: None, + }), + TransactionV2::Burn { + inputs, + change, + amount, + fee, + authorizations, + .. + } => Ok(UiTransaction { + kind: "burn", + from: inputs + .first() + .map(|input| encode_versioned_address(input.owner, network)) + .transpose()? + .unwrap_or_default(), + to: None, + amount: *amount, + fee: *fee, + inputs: inputs + .iter() + .enumerate() + .map(|(index, input)| { + v2_ui_input( + OutPoint { + txid: hex_encode(input.outpoint_txid), + index: input.outpoint_index, + }, + input.owner, + authorizations.get(index), + outputs_by_outpoint, + network, + ) + }) + .collect::<Result<Vec<_>>>()?, + outputs: Vec::new(), + change: v2_ui_outputs(change, network)?, + signature: transaction_id, + difficulty_bits: None, + proof_bits: None, + proof_hash: None, + }), + TransactionV2::Mine { + recipient, + difficulty_bits, + proof_hash, + .. + } => { + let recipient = encode_versioned_address(*recipient, network)?; + let proof_hash = hex_encode(proof_hash); + Ok(UiTransaction { + kind: "mine", + from: "pow".to_string(), + to: Some(recipient.clone()), + amount: MINE_REWARD, + fee: 0, + inputs: Vec::new(), + outputs: vec![TxOutput { + address: recipient, + amount: MINE_REWARD, + }], + change: Vec::new(), + signature: transaction_id, + difficulty_bits: Some(*difficulty_bits), + proof_bits: Some(proof_bits(&proof_hash)), + proof_hash: Some(proof_hash), + }) + } + } +} + +pub(super) fn transaction_v2_input_outpoints(transaction: &TransactionV2) -> Vec<OutPoint> { + match transaction { + TransactionV2::Migration { inputs, .. } => 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, + }) + .collect(), + TransactionV2::Transfer { inputs, .. } | TransactionV2::Burn { inputs, .. } => inputs + .iter() + .map(|input| OutPoint { + txid: hex_encode(input.outpoint_txid), + index: input.outpoint_index, + }) + .collect(), + TransactionV2::Mine { .. } => Vec::new(), + } +} + +pub(super) fn add_pending_v2_outputs( + outputs: &mut BTreeMap<OutPoint, TxOutput>, + pending: &[TransactionV2], + domain: &TransactionV2Domain, + network: AddressNetwork, +) -> Result<()> { + for transaction in pending { + let txid = hex_encode(transaction.transaction_id(domain)?); + let transaction_outputs = match transaction { + TransactionV2::Migration { outputs, .. } | TransactionV2::Transfer { outputs, .. } => { + outputs.as_slice() + } + TransactionV2::Burn { change, .. } => change.as_slice(), + TransactionV2::Mine { .. } => &[], + }; + for (index, output) in v2_ui_outputs(transaction_outputs, network)? + .into_iter() + .enumerate() + { + outputs.insert( + OutPoint { + txid: txid.clone(), + index: u32::try_from(index)?, + }, + output, + ); + } + } + Ok(()) +} + +fn first_v2_output_address( + outputs: &[crate::domain::TransactionV2Output], + network: AddressNetwork, +) -> Result<Option<String>> { + outputs + .first() + .map(|output| encode_versioned_address(output.address, network)) + .transpose() +} + +fn v2_ui_input( + outpoint: OutPoint, + owner: crate::domain::VersionedAddress, + authorization: Option<&crate::domain::V2SpendingAuthorization>, + outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, + network: AddressNetwork, +) -> Result<UiTxInput> { + let spent_output = outputs_by_outpoint.get(&outpoint); + Ok(UiTxInput { + outpoint, + owner: encode_versioned_address(owner, network)?, + signature: authorization + .map(|authorization| hex_encode(authorization.signature().as_bytes())) + .unwrap_or_default(), + amount: spent_output.map(|output| output.amount), + address: spent_output.map(|output| output.address.clone()), + }) +} + +fn v2_ui_outputs( + outputs: &[crate::domain::TransactionV2Output], + network: AddressNetwork, +) -> Result<Vec<TxOutput>> { + outputs + .iter() + .map(|output| { + Ok(TxOutput { + address: encode_versioned_address(output.address, network)?, + amount: output.amount, + }) + }) + .collect() +} + fn ui_inputs( inputs: &[TxInput], outputs_by_outpoint: &BTreeMap<OutPoint, TxOutput>, @@ -558,12 +903,16 @@ mod tests { use crate::compact::CompactBlockSizeBreakdown; use crate::domain::{ - Amount, Block, BurnBundleSection, BurnBundleSignature, BurnLeaderRank, FinalizerMode, - MaskedBurn, OutPoint, Transaction, TxInput, TxOutput, + AddressNetwork, Amount, Block, BurnBundleSection, BurnBundleSignature, BurnLeaderRank, + FinalizerMode, Ledger, MaskedBurn, OutPoint, Transaction, TxInput, TxOutput, Wallet, + encode_versioned_address, }; - use super::{block_lost_iuna, populate_wallet_reward_flow, ui_block, wallet_transaction_row}; - use crate::adapters::http::types::WalletTransactionContext; + use super::{ + block_lost_iuna, populate_wallet_reward_flow, ui_block, ui_transaction_v2, + wallet_transaction_row, wallet_transaction_v2_rows, + }; + use crate::adapters::http::types::{WalletTransactionContext, WalletTransactionFilters}; fn burn(signature: &str) -> Transaction { Transaction::Burn { @@ -606,6 +955,41 @@ mod tests { } #[test] + fn pending_v2_migration_is_presented_in_chain_and_wallet_views() { + let wallet = Wallet::from_seed("pending-v2-ui-wallet"); + let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100_000)]), 1); + let transaction = ledger.build_v2_migration_batch(&wallet, 100).unwrap(); + let domain = ledger.transaction_v2_domain().unwrap(); + let outputs = ledger.all_utxos().into_iter().collect::<BTreeMap<_, _>>(); + let network = AddressNetwork::Mainnet; + + let chain_row = ui_transaction_v2(&transaction, &outputs, &domain, network).unwrap(); + assert_eq!(chain_row.kind, "migration"); + assert_eq!(chain_row.signature.len(), 64); + assert_eq!(chain_row.inputs.len(), 1); + assert_eq!(chain_row.outputs.len(), 1); + + let wallet_addresses = vec![ + wallet.address().to_string(), + encode_versioned_address(wallet.legacy_versioned_address(), network).unwrap(), + wallet.hybrid_address(network), + ]; + let rows = wallet_transaction_v2_rows( + &wallet_addresses, + &[transaction], + &outputs, + WalletTransactionFilters::default(), + &domain, + network, + ); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].kind, "migration"); + assert_eq!(rows[0].direction, "migrated"); + assert_eq!(rows[0].status, "pending"); + assert_eq!(rows[0].amount, 99_900); + } + + #[test] fn wallet_reward_flow_contains_every_fee_and_payout() { let reward_projection = Transaction::Transfer { inputs: Vec::new(), diff --git a/src/adapters/p2p/handshake.rs b/src/adapters/p2p/handshake.rs @@ -155,6 +155,7 @@ async fn process_hello_inner( ) }; validate_transaction_v2_peer_capability(&hello.capabilities, local_height, hello.height)?; + let peer_capabilities = hello.capabilities.clone(); let genesis_mismatch = hello.genesis_hash != local_genesis; let remote_is_setup_placeholder = hello.height == 0 && hello.genesis_hash == setup_placeholder_genesis_hash(); @@ -206,8 +207,10 @@ async fn process_hello_inner( record_peer_hello(network, known_peer, remote_addr, hello.clone()).await; let mut status = if request_bootstrap { PeerStatus::with_bootstrap_request(hello.height, hello.tip_hash, hello.time_ms) + .with_capabilities(peer_capabilities) } else { PeerStatus::with_time(hello.height, hello.tip_hash, hello.time_ms) + .with_capabilities(peer_capabilities) }; status.reject_session = reject_session; Ok(status) diff --git a/src/adapters/p2p/peer_status.rs b/src/adapters/p2p/peer_status.rs @@ -8,6 +8,7 @@ pub(super) struct PeerStatus { pub(super) time_ms: u64, pub(super) request_bootstrap: bool, pub(super) reject_session: bool, + pub(super) capabilities: Vec<String>, } impl PeerStatus { @@ -23,6 +24,7 @@ impl PeerStatus { time_ms, request_bootstrap: false, reject_session: false, + capabilities: Vec::new(), } } @@ -33,6 +35,7 @@ impl PeerStatus { time_ms, request_bootstrap: false, reject_session: false, + capabilities: Vec::new(), } } @@ -43,6 +46,7 @@ impl PeerStatus { time_ms, request_bootstrap: true, reject_session: false, + capabilities: Vec::new(), } } @@ -53,6 +57,18 @@ impl PeerStatus { time_ms, request_bootstrap: false, reject_session: true, + capabilities: Vec::new(), } } + + pub(super) fn with_capabilities(mut self, capabilities: Vec<String>) -> Self { + self.capabilities = capabilities; + self + } + + pub(super) fn supports(&self, capability: &str) -> bool { + self.capabilities + .binary_search_by(|candidate| candidate.as_str().cmp(capability)) + .is_ok() + } } diff --git a/src/adapters/p2p/session.rs b/src/adapters/p2p/session.rs @@ -366,7 +366,13 @@ async fn session_loop( time_ms, } = envelope { - let status = PeerStatus::from_envelope(height, tip_hash, time_ms); + let status = PeerStatus::from_envelope(height, tip_hash, time_ms) + .with_capabilities( + peer_status + .as_ref() + .map(|status| status.capabilities.clone()) + .unwrap_or_default(), + ); record_peer_status(&network, &known_peer, remote_addr, &status).await; peer_status = Some(status); handshake_complete = true; @@ -490,7 +496,13 @@ async fn session_loop( time_ms, } = &envelope { - let status = PeerStatus::from_envelope(*height, tip_hash.clone(), *time_ms); + let status = PeerStatus::from_envelope(*height, tip_hash.clone(), *time_ms) + .with_capabilities( + peer_status + .as_ref() + .map(|status| status.capabilities.clone()) + .unwrap_or_default(), + ); record_peer_status(&network, &known_peer, remote_addr, &status).await; peer_status = Some(status); maybe_start_catchup( diff --git a/src/adapters/p2p/sync.rs b/src/adapters/p2p/sync.rs @@ -8,7 +8,9 @@ use super::peer_addr::{ is_self_peer_address_for, normalize_advertised_peer, peer_list_address_is_discoverable, }; use super::{GossipNetwork, MAX_BLOCK_BATCH, PeerStatus, write_envelope}; -use crate::app::{GossipEnvelope, SharedNode, debug_logging_enabled}; +use crate::app::{ + CAPABILITY_TRANSACTION_V2_MEMPOOL, GossipEnvelope, SharedNode, debug_logging_enabled, +}; pub(super) async fn maybe_request_catchup( network: &GossipNetwork, @@ -103,8 +105,22 @@ pub(super) async fn envelopes_for_peer( let Some(node) = node else { return envelopes.to_vec(); }; + let supports_v2_mempool = peer_status + .as_ref() + .is_some_and(|status| status.supports(CAPABILITY_TRANSACTION_V2_MEMPOOL)); + let envelopes = envelopes + .iter() + .filter(|envelope| { + supports_v2_mempool + || !matches!( + envelope, + GossipEnvelope::TransactionV2 { .. } | GossipEnvelope::TransactionsV2 { .. } + ) + }) + .cloned() + .collect::<Vec<_>>(); let Some(peer_status) = peer_status else { - return envelopes.to_vec(); + return envelopes; }; let node = node.lock().await; diff --git a/src/adapters/p2p/tests.rs b/src/adapters/p2p/tests.rs @@ -1561,6 +1561,43 @@ async fn periodic_broadcast_does_not_push_duplicate_block_pages_to_lagging_peer( } #[tokio::test] +async fn transaction_v2_mempool_gossip_requires_explicit_peer_capability() { + let wallet = Wallet::from_seed("v2-mempool-capability-filter"); + let node = Arc::new(tokio::sync::Mutex::new(node( + "v2-mempool-capability-filter", + wallet.clone(), + allocations(std::slice::from_ref(&wallet), 1_000), + ))); + let envelopes = vec![ + GossipEnvelope::TransactionV2 { + envelope: "00".to_string(), + }, + GossipEnvelope::PeerStatus { + height: 1, + tip_hash: "tip".to_string(), + time_ms: 1, + }, + ]; + + let legacy = super::PeerStatus::new(1, "tip".to_string()).with_capabilities(vec![ + crate::app::CAPABILITY_ADDRESS_V1_READ.to_string(), + crate::app::CAPABILITY_SIGNATURE_SCHEMES_V1.to_string(), + crate::app::CAPABILITY_TRANSACTION_V2_BLOCKS.to_string(), + ]); + let legacy_payload = super::envelopes_for_peer(Some(&node), Some(legacy), &envelopes).await; + assert!(matches!( + legacy_payload.as_slice(), + [GossipEnvelope::PeerStatus { .. }] + )); + + let upgraded = super::PeerStatus::new(1, "tip".to_string()).with_capabilities(vec![ + crate::app::CAPABILITY_TRANSACTION_V2_MEMPOOL.to_string(), + ]); + let upgraded_payload = super::envelopes_for_peer(Some(&node), Some(upgraded), &envelopes).await; + assert_eq!(upgraded_payload, envelopes); +} + +#[tokio::test] async fn hello_ignores_private_advertised_listen_address() { let alice = Wallet::from_seed("hello-private-listen-alice"); let allocations = allocations(std::slice::from_ref(&alice), 1_000); diff --git a/src/app.rs b/src/app.rs @@ -46,6 +46,7 @@ pub const MAX_PROTOCOL_CAPABILITY_BYTES: usize = 64; pub const CAPABILITY_ADDRESS_V1_READ: &str = "address-v1-read"; pub const CAPABILITY_SIGNATURE_SCHEMES_V1: &str = "signature-schemes-v1"; pub const CAPABILITY_TRANSACTION_V2_BLOCKS: &str = "transaction-v2-blocks"; +pub const CAPABILITY_TRANSACTION_V2_MEMPOOL: &str = "transaction-v2-mempool"; pub const MAINNET_CANDIDATE_NETWORK_ID: &str = "iuna-mainnet-candidate"; pub const MAINNET_CANDIDATE_GENESIS_HASH: &str = "3d677cd7ced1c04d3a276cbee7ea38076e34ac65f18a2c9b8286a4872d986a9a"; @@ -69,6 +70,7 @@ pub fn protocol_capabilities() -> Vec<String> { CAPABILITY_ADDRESS_V1_READ.to_string(), CAPABILITY_SIGNATURE_SCHEMES_V1.to_string(), CAPABILITY_TRANSACTION_V2_BLOCKS.to_string(), + CAPABILITY_TRANSACTION_V2_MEMPOOL.to_string(), ] } @@ -115,10 +117,11 @@ pub fn validate_transaction_v2_peer_capability( mod tests { use super::{ BLOCK_REQUEST_LIMIT, CAPABILITY_ADDRESS_V1_READ, CAPABILITY_SIGNATURE_SCHEMES_V1, - CAPABILITY_TRANSACTION_V2_BLOCKS, DEFAULT_VDF_ROUNDS, MAINNET_CANDIDATE_GENESIS_HASH, - MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID, MAX_PROTOCOL_CAPABILITIES, NETWORK_ID, - PROTOCOL_VERSION, TRANSACTION_BATCH_LIMIT, protocol_capabilities, validate_network_genesis, - validate_protocol_capabilities, validate_transaction_v2_peer_capability, + CAPABILITY_TRANSACTION_V2_BLOCKS, CAPABILITY_TRANSACTION_V2_MEMPOOL, DEFAULT_VDF_ROUNDS, + MAINNET_CANDIDATE_GENESIS_HASH, MAINNET_CANDIDATE_NETWORK_ID, MAINNET_NETWORK_ID, + MAX_PROTOCOL_CAPABILITIES, NETWORK_ID, PROTOCOL_VERSION, TRANSACTION_BATCH_LIMIT, + protocol_capabilities, validate_network_genesis, validate_protocol_capabilities, + validate_transaction_v2_peer_capability, }; #[test] @@ -153,6 +156,7 @@ mod tests { CAPABILITY_ADDRESS_V1_READ, CAPABILITY_SIGNATURE_SCHEMES_V1, CAPABILITY_TRANSACTION_V2_BLOCKS, + CAPABILITY_TRANSACTION_V2_MEMPOOL, ] ); validate_protocol_capabilities(&capabilities).unwrap(); diff --git a/src/app/ledger_view.rs b/src/app/ledger_view.rs @@ -3,7 +3,7 @@ use anyhow::Result; use std::collections::BTreeSet; use crate::compact::CompactBlockSizeBreakdown; -use crate::domain::{Block, BurnLeaderRank, Ledger, OutPoint, Transaction}; +use crate::domain::{Block, BurnLeaderRank, Ledger, OutPoint, Transaction, TransactionV2}; use std::collections::BTreeMap; use super::{NodeCore, helpers::transaction_input_outpoints}; @@ -80,4 +80,8 @@ impl NodeCore { pub fn pending_transactions(&self) -> Vec<Transaction> { self.ledger.pending().to_vec() } + + pub fn pending_transactions_v2(&self) -> Vec<TransactionV2> { + self.ledger.pending_v2().to_vec() + } } diff --git a/src/app/status.rs b/src/app/status.rs @@ -35,18 +35,24 @@ impl NodeCore { .as_deref() .map(|address| self.ledger.balance_of(address)) .unwrap_or(0); - let migration_pending = hybrid_address.as_deref().is_some_and(|address| { - self.ledger.pending_v2().iter().any(|transaction| { - matches!(transaction, TransactionV2::Migration { outputs, .. } - if outputs.iter().any(|output| { - crate::domain::encode_versioned_address( - output.address, - AddressNetwork::from_profile_id( - &self.ledger.launch_profile().profile_id, - ), - ) - .is_ok_and(|output_address| output_address == address) - })) + let address_network = + AddressNetwork::from_profile_id(&self.ledger.launch_profile().profile_id); + let transaction_v2_domain = self.ledger.transaction_v2_domain().ok(); + let pending_transaction_id = hybrid_address.as_deref().and_then(|address| { + self.ledger.pending_v2().iter().find_map(|transaction| { + let TransactionV2::Migration { outputs, .. } = transaction else { + return None; + }; + let belongs_to_wallet = outputs.iter().any(|output| { + crate::domain::encode_versioned_address(output.address, address_network) + .is_ok_and(|output_address| output_address == address) + }); + belongs_to_wallet.then(|| { + transaction_v2_domain + .as_ref() + .and_then(|domain| transaction.transaction_id(domain).ok()) + .map(crate::domain::hex_encode) + })? }) }); @@ -62,7 +68,8 @@ impl NodeCore { legacy_balance, hybrid_balance, legacy_utxos, - migration_pending, + migration_pending: pending_transaction_id.is_some(), + pending_transaction_id, }, launch_profile: LaunchProfileStatus { profile_id: launch_profile.profile_id.clone(), diff --git a/src/app/types.rs b/src/app/types.rs @@ -210,6 +210,7 @@ pub struct QuantumMigrationStatus { pub hybrid_balance: Amount, pub legacy_utxos: usize, pub migration_pending: bool, + pub pending_transaction_id: Option<String>, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] diff --git a/src/domain/ledger_queries.rs b/src/domain/ledger_queries.rs @@ -609,6 +609,10 @@ impl Ledger { .collect() } + pub fn output_for_outpoint(&self, outpoint: &OutPoint) -> Option<TxOutput> { + self.utxos.get(outpoint).cloned() + } + /// Consolidation keeps mining roots separate unless explicitly requested. pub(crate) fn consolidation_root(&self, outpoint: &OutPoint) -> Option<OutPoint> { self.utxo_lineage diff --git a/tests/quantum-migration.test.cjs b/tests/quantum-migration.test.cjs @@ -98,3 +98,11 @@ test('hybrid recipients never reuse selected legacy UTXOs', () => { assert.equal(ui.selectedTransferUtxos.length, 0); assert.equal(ui.showSendAdvanced, false); }); + +test('migration status can identify the exact pending v2 transaction', () => { + const ui = app(); + ui.status.quantum_migration.migration_pending = true; + ui.status.quantum_migration.pending_transaction_id = '12'.repeat(32); + + assert.equal(ui.status.quantum_migration.pending_transaction_id, '12'.repeat(32)); +});