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