commit 0f7ca5b8286a2eeccbdfb2adf7ca92708e471d0c
parent d8b9e7ed6d05fff124b86858b364bb03b7807320
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Tue, 22 Sep 2026 10:18:44 +0200
feat(wallet): enable quantum migration flow
Diffstat:
22 files changed, 965 insertions(+), 52 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
@@ -5,6 +5,18 @@ from the Git history and Conventional Commit titles by `deployment.sh`.
## [Unreleased]
+### Added
+
+- relay transaction-v2 mempool envelopes
+- preview quantum-resistant wallet migration
+- expose legacy and hybrid wallet balances
+- submit block-bounded wallet migration batches
+- send hybrid transaction-v2 transfers
+
+### Documentation
+
+- update the quantum audit scope after height 3000
+
## [0.4.35] - 2026-09-21
### Performance
diff --git a/docs/quantum-audit-scope.md b/docs/quantum-audit-scope.md
@@ -3,14 +3,14 @@
## Purpose and decision boundary
This document defines the review package for Iuna's post-quantum migration. The current code
-reserves versioned addresses and transaction encodings, can verify hybrid Ed25519 + ML-DSA-44
-authorizations, and records height 3000 as the candidate activation target. Live consensus,
-blocks, and gossip do not yet accept or produce v2 transactions.
+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
+migration telemetry, reviewed block-bounded migration submission, and hybrid transfers.
-An audit of this scope may approve shipping dormant code for continued testing. It must not be
-interpreted as approval to activate transaction v2. Activation requires a separate review of the
-final integration commit and the height-3000 migration rehearsal described in
-`quantum-migration.md`.
+An audit of this scope must review the active consensus code and the enabled wallet migration
+flow. It must cover the final integration commit and the
+post-height-3000 migration rehearsal described in `quantum-migration.md`.
## Security claims
@@ -135,12 +135,14 @@ crash artifacts with their report.
## Activation blockers
-Transaction v2 must remain dormant until all of the following are resolved:
+The active transaction-v2 and wallet-migration rules must not be described as production-ready
+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, and no-address-reuse behavior are implemented and
- reviewed; deterministic hybrid key generation already exists but is not yet exposed in the UI;
+- 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;
- 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
@@ -120,17 +120,19 @@ to a version-1 output. Ordinary v2 transactions use 32-byte hash IDs; every late
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 before activation. No transaction-v2 gossip capability is
-advertised yet.
+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
+wallet can submit reviewed migration batches and ordinary hybrid transfers; address rotation and
+broader recovery rehearsal remain release blockers.
The verification tests include a small audit corpus pinned to exact NIST ACVP-Server and C2SP
Wycheproof commits and file hashes. It covers a valid NIST signature, Wycheproof's repeated-hint
regression, and a valid signature at the ML-DSA-44 norm boundary. A dedicated fuzz target exercises
both the transaction-v2 decoder and arbitrary ML-DSA-44 verification inputs; it remains part of the
-release's time-bounded, coverage-guided `cargo fuzz` gate while transaction v2 is dormant. Seed
+release's time-bounded, coverage-guided `cargo fuzz` gate while wallet submission is gated. Seed
corpora, newly discovered coverage inputs, and crash artifacts are retained as release evidence.
-### Dormant hybrid wallet keys
+### Hybrid wallet keys
The existing wallet seed phrase now deterministically derives a separate ML-DSA-44 seed using the
fixed `iuna-wallet-ml-dsa44-seed-v1` domain. The original Ed25519 derivation is unchanged, so
@@ -148,8 +150,11 @@ legacy/v2 double-spends, and dependent v2 transactions. At and after height 3000
can include those transactions; their exact envelopes are committed by the VDF seed and block
hash, counted against the shared transaction and byte limits, applied with UTXO lineage, persisted
in compact snapshot v8, and carried forward after reorgs. Snapshot v7 remains readable. Blocks
-therefore propagate through the existing block P2P path, but standalone v2 mempool gossip and a
-public wallet/API submission route are still absent.
+therefore propagate through the existing block P2P path. Standalone v2 mempool gossip now accepts,
+validates, canonicalizes, rebroadcasts, and periodically re-announces v2 envelopes. The management
+wallet reports legacy and hybrid balances, exposes an authenticated migration preview, submits one
+reviewed block-bounded migration batch at a time, and can spend confirmed hybrid value to another
+address-v1 recipient. Automatic address rotation and full recovery rehearsal remain incomplete.
## Other trust boundaries
diff --git a/src/adapters/http.rs b/src/adapters/http.rs
@@ -31,6 +31,7 @@ mod auth_routes;
mod consolidation;
mod index_html;
mod metrics;
+mod quantum_migration;
mod request_auth;
mod state;
mod static_assets;
@@ -197,6 +198,14 @@ pub async fn serve(
.route("/api/transfer", post(api_transfer_form))
.route("/api/wallet/optimize/preview", post(consolidation::preview))
.route("/api/wallet/optimize/submit", post(consolidation::submit))
+ .route(
+ "/api/wallet/quantum-migration/preview",
+ post(quantum_migration::preview),
+ )
+ .route(
+ "/api/wallet/quantum-migration/submit",
+ post(quantum_migration::submit),
+ )
.route("/settings/burn-per-block", post(burn_per_block_form))
.route("/transfer", post(transfer_form))
.route("/peers", post(peer_form))
diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs
@@ -717,13 +717,40 @@ pub(super) const INDEX_HTML: &str = concat!(
<button type="button" @click="openOptimizeWallet">Review optimization</button>
<button type="button" @click="dismissOptimizeSuggestion">Later</button>
</div>
+ <div class="panel" x-show="status.quantum_migration?.active && (status.quantum_migration?.legacy_balance > 0 || status.quantum_migration?.hybrid_balance > 0)">
+ <h3>Quantum-resistant wallet migration</h3>
+ <p class="panel-description">Transaction v2 is active. Review how your legacy Ed25519 balance can move to the hybrid Ed25519 + ML-DSA wallet.</p>
+ <div class="detail-grid">
+ <div>
+ <div class="detail-kv"><div class="key">Legacy balance</div><div>IUNA <span x-text="amountLabel(status.quantum_migration?.legacy_balance || 0)"></span></div></div>
+ <div class="detail-kv"><div class="key">Hybrid balance</div><div>IUNA <span x-text="amountLabel(status.quantum_migration?.hybrid_balance || 0)"></span></div></div>
+ <div class="detail-kv"><div class="key">Legacy UTXOs</div><div x-text="status.quantum_migration?.legacy_utxos || 0"></div></div>
+ <div class="detail-kv"><div class="key">Hybrid address</div><code x-text="status.quantum_migration?.hybrid_address || 'Unlock wallet to derive'"></code></div>
+ </div>
+ <form @submit.prevent="previewQuantumMigration" x-show="status.quantum_migration?.legacy_balance > 0">
+ <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>
+ </form>
+ </div>
+ <div class="info-copy" x-show="quantumMigrationPreview">
+ <p>Review this batch carefully. Migrated value can only be sent to hybrid address-v1 recipients.</p>
+ <div class="detail-kv"><div class="key">Inputs</div><div x-text="quantumMigrationPreview?.input_count || 0"></div></div>
+ <div class="detail-kv"><div class="key">Transaction size</div><div><span x-text="quantumMigrationPreview?.bytes || 0"></span> bytes</div></div>
+ <div class="detail-kv"><div class="key">Network fee</div><div>IUNA <span x-text="amountLabel(quantumMigrationPreview?.fee || 0)"></span></div></div>
+ <div class="detail-kv"><div class="key">Amount protected</div><div>IUNA <span x-text="amountLabel(quantumMigrationPreview?.amount || 0)"></span></div></div>
+ <div class="detail-kv"><div class="key">Legacy UTXOs after batch</div><div x-text="quantumMigrationPreview?.remaining_legacy_utxos || 0"></div></div>
+ <button class="primary" type="button" @click="submitQuantumMigration" :disabled="quantumMigrationSubmitting" x-text="quantumMigrationSubmitting ? 'Submitting…' : 'Confirm migration batch'"></button>
+ </div>
+ </div>
<div class="wallet-grid">
<div class="wallet-actions">
<div class="panel">
<h3>Send</h3>
<form @submit.prevent="sendTransfer">
<div class="recipient-field">
- <label>Recipient<input x-model="transferTo" @input="scheduleFeeEstimates" autocomplete="off" required></label>
+ <label>Recipient<input x-model="transferTo" @input="transferRecipientChanged" autocomplete="off" required></label>
<button class="icon-button" type="button" @click="openAddressBookPicker()" title="Choose contact" aria-label="Choose contact">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3H20v18H6.5A2.5 2.5 0 0 1 4 18.5z"></path><path d="M8 7h8"></path><path d="M8 11h6"></path><path d="M8 15h4"></path></svg>
</button>
@@ -735,8 +762,8 @@ pub(super) const INDEX_HTML: &str = concat!(
<label>Fee / byte<input x-model="transferFee" @input="scheduleFeeEstimates" type="number" min="0" step="0.000001" required></label>
<div class="fee-preview" :class="{ error: feeEstimateError('transfer') }" x-text="feeEstimateLabel('transfer')" role="status" aria-live="polite"></div>
<div class="fee-warning" x-show="feeExceedsAmount('transfer')" x-text="feeExceedsAmountLabel('transfer')" role="status" aria-live="polite"></div>
- <button class="advanced-toggle" type="button" @click="toggleSendAdvanced" x-text="showSendAdvanced ? 'Hide UTXOs' : 'UTXOs'"></button>
- <div class="send-utxo-summary" x-show="showSendAdvanced">
+ <button class="advanced-toggle" type="button" x-show="!hybridTransferRecipient()" @click="toggleSendAdvanced" x-text="showSendAdvanced ? 'Hide UTXOs' : 'UTXOs'"></button>
+ <div class="send-utxo-summary" x-show="showSendAdvanced && !hybridTransferRecipient()">
<div>Selected UTXOs: <span x-text="selectedTransferUtxos.length"></span></div>
<div>Selected total: IUNA <span x-text="amountLabel(selectedTransferUtxoTotal())"></span></div>
<div>Required: IUNA <span x-text="amountLabel(transferRequiredTotal())"></span></div>
@@ -776,9 +803,17 @@ pub(super) const INDEX_HTML: &str = concat!(
<button type="button" @click="copyAddress">Copy</button>
</div>
<div class="receive-address">
- <div class="muted">Public key / address</div>
+ <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>
</div>
<div class="panel">
<div class="panel-head">
diff --git a/src/adapters/http/quantum_migration.rs b/src/adapters/http/quantum_migration.rs
@@ -0,0 +1,53 @@
+use axum::{Form, Json, extract::State};
+use serde::Deserialize;
+use serde_json::{Value, json};
+
+use super::HttpState;
+use crate::domain::Amount;
+
+#[derive(Deserialize)]
+pub(super) struct PreviewForm {
+ fee_per_byte: Amount,
+}
+
+#[derive(Deserialize)]
+pub(super) struct SubmitForm {
+ fee_per_byte: Amount,
+ max_fee: Amount,
+ transaction_id: String,
+}
+
+pub(super) async fn preview(
+ State(state): State<HttpState>,
+ Form(form): Form<PreviewForm>,
+) -> Json<Value> {
+ let node = state.node.lock().await;
+ match node.preview_quantum_migration(form.fee_per_byte) {
+ Ok(preview) => Json(json!({ "ok": true, "preview": preview })),
+ Err(error) => Json(json!({ "ok": false, "error": error.to_string() })),
+ }
+}
+
+pub(super) async fn submit(
+ State(state): State<HttpState>,
+ Form(form): Form<SubmitForm>,
+) -> Json<Value> {
+ let (result, outbox) = {
+ let mut node = state.node.lock().await;
+ let result =
+ node.submit_quantum_migration(form.fee_per_byte, form.max_fee, &form.transaction_id);
+ (result, node.drain_outbox())
+ };
+ match result {
+ Ok(preview) => {
+ let broadcast = state.gossip.broadcast(outbox).await;
+ Json(json!({
+ "ok": true,
+ "transaction_id": preview.transaction_id,
+ "remaining_legacy_utxos": preview.remaining_legacy_utxos,
+ "broadcast_error": broadcast.err().map(|error| error.to_string()),
+ }))
+ }
+ Err(error) => Json(json!({ "ok": false, "error": error.to_string() })),
+ }
+}
diff --git a/src/adapters/http/wallet.rs b/src/adapters/http/wallet.rs
@@ -5,7 +5,7 @@ use secrecy::ExposeSecret;
use crate::{
adapters::wallet_store,
app::FeeEstimate,
- domain::{Amount, MINE_FINALIZER_FEE, OutPoint},
+ domain::{AddressVersion, Amount, MINE_FINALIZER_FEE, OutPoint},
};
use super::{
@@ -137,14 +137,27 @@ pub(super) async fn transfer(state: &HttpState, form: TransferForm) -> Result<()
let result = {
let mut node = state.node.lock().await;
- let to = node.normalize_user_address(&to)?;
- let result = node.transfer_with_fee_rate(to, amount, fee_per_byte, &selected_utxos);
+ let recipient = node.decode_user_address(&to)?;
+ let result = match recipient.version {
+ AddressVersion::Ed25519PublicKey => {
+ let to = node.normalize_user_address(&to)?;
+ node.transfer_with_fee_rate(to, amount, fee_per_byte, &selected_utxos)
+ .map(|_| ())
+ }
+ AddressVersion::HybridKeyCommitment => {
+ if !selected_utxos.is_empty() {
+ bail!("manual UTXO selection is not available for hybrid transfers");
+ }
+ node.transfer_hybrid_with_fee_rate(recipient, amount, fee_per_byte)
+ .map(|_| ())
+ }
+ };
let outbox = node.drain_outbox();
(result, outbox)
};
match result.0 {
- Ok(_) => state.gossip.broadcast(result.1).await,
+ Ok(()) => state.gossip.broadcast(result.1).await,
Err(error) => Err(error),
}
}
@@ -177,8 +190,19 @@ pub(super) async fn estimate_transfer_fee(
) -> Result<FeeEstimate> {
let (to, amount, fee_per_byte, selected_utxos) = validate_transfer_form(form)?;
let node = state.node.lock().await;
- let to = node.normalize_user_address(&to)?;
- node.estimate_transfer_fee(to, amount, fee_per_byte, &selected_utxos)
+ let recipient = node.decode_user_address(&to)?;
+ match recipient.version {
+ AddressVersion::Ed25519PublicKey => {
+ let to = node.normalize_user_address(&to)?;
+ node.estimate_transfer_fee(to, amount, fee_per_byte, &selected_utxos)
+ }
+ AddressVersion::HybridKeyCommitment => {
+ if !selected_utxos.is_empty() {
+ bail!("manual UTXO selection is not available for hybrid transfers");
+ }
+ node.estimate_hybrid_transfer_fee(recipient, amount, fee_per_byte)
+ }
+ }
}
pub(super) async fn estimate_burn_fee(
diff --git a/src/adapters/p2p/line_codec.rs b/src/adapters/p2p/line_codec.rs
@@ -121,6 +121,16 @@ pub(super) fn record_received_envelope_kind(
P2pMetricsCounters::inc(&metrics.transaction_envelopes_received);
P2pMetricsCounters::add(&metrics.transactions_received, transactions.len() as u64);
}
+ GossipEnvelope::TransactionV2 { .. } => {
+ P2pMetricsCounters::inc(&metrics.data_envelopes_received);
+ P2pMetricsCounters::inc(&metrics.transaction_envelopes_received);
+ P2pMetricsCounters::inc(&metrics.transactions_received);
+ }
+ GossipEnvelope::TransactionsV2 { envelopes } => {
+ P2pMetricsCounters::inc(&metrics.data_envelopes_received);
+ P2pMetricsCounters::inc(&metrics.transaction_envelopes_received);
+ P2pMetricsCounters::add(&metrics.transactions_received, envelopes.len() as u64);
+ }
GossipEnvelope::BurnBundle(_) => {
P2pMetricsCounters::inc(&metrics.data_envelopes_received);
P2pMetricsCounters::inc(&metrics.burn_bundle_envelopes_received);
@@ -181,6 +191,13 @@ pub(super) fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()>
TRANSACTION_BATCH_LIMIT,
)?;
}
+ GossipEnvelope::TransactionsV2 { envelopes } => {
+ ensure_len(
+ "transaction v2 batch",
+ envelopes.len(),
+ TRANSACTION_BATCH_LIMIT,
+ )?;
+ }
GossipEnvelope::BurnBundles { bundles } => {
ensure_len("burn bundle batch", bundles.len(), TRANSACTION_BATCH_LIMIT)?;
}
@@ -198,6 +215,7 @@ pub(super) fn validate_envelope_limits(envelope: &GossipEnvelope) -> Result<()>
| GossipEnvelope::ChainBootstrap(_)
| GossipEnvelope::PeerStatus { .. }
| GossipEnvelope::Transaction(_)
+ | GossipEnvelope::TransactionV2 { .. }
| GossipEnvelope::BurnBundle(_)
| GossipEnvelope::Block(_)
| GossipEnvelope::PeerAnnouncement { .. }
@@ -301,12 +319,24 @@ mod tests {
},
);
record_received_envelope_kind(&metrics, &GossipEnvelope::Transaction(burn("e")));
+ record_received_envelope_kind(
+ &metrics,
+ &GossipEnvelope::TransactionsV2 {
+ envelopes: vec!["00".to_string(), "01".to_string()],
+ },
+ );
+ record_received_envelope_kind(
+ &metrics,
+ &GossipEnvelope::TransactionV2 {
+ envelope: "02".to_string(),
+ },
+ );
record_received_envelope_kind(&metrics, &GossipEnvelope::BurnBundle(burn_bundle(1, "f")));
let snapshot = metrics.snapshot();
- assert_eq!(snapshot.data_envelopes_received, 4);
- assert_eq!(snapshot.transaction_envelopes_received, 2);
- assert_eq!(snapshot.transactions_received, 3);
+ assert_eq!(snapshot.data_envelopes_received, 6);
+ assert_eq!(snapshot.transaction_envelopes_received, 4);
+ assert_eq!(snapshot.transactions_received, 6);
assert_eq!(snapshot.burn_bundle_envelopes_received, 2);
assert_eq!(snapshot.burn_bundles_received, 3);
}
@@ -319,6 +349,12 @@ mod tests {
let line = serde_json::to_string(&envelope).unwrap();
assert_eq!(parse_envelope(&line).unwrap(), envelope);
+
+ let envelope = GossipEnvelope::TransactionV2 {
+ envelope: "000102ff".to_string(),
+ };
+ let line = serde_json::to_string(&envelope).unwrap();
+ assert_eq!(parse_envelope(&line).unwrap(), envelope);
}
#[test]
@@ -386,6 +422,18 @@ mod tests {
.is_err()
);
assert!(
+ validate_envelope_limits(&GossipEnvelope::TransactionsV2 {
+ envelopes: vec!["00".to_string(); TRANSACTION_BATCH_LIMIT]
+ })
+ .is_ok()
+ );
+ assert!(
+ validate_envelope_limits(&GossipEnvelope::TransactionsV2 {
+ envelopes: vec!["00".to_string(); TRANSACTION_BATCH_LIMIT + 1]
+ })
+ .is_err()
+ );
+ assert!(
validate_envelope_limits(&GossipEnvelope::BurnBundles {
bundles: vec![burn_bundle(1, "a"); TRANSACTION_BATCH_LIMIT]
})
diff --git a/src/adapters/p2p/process.rs b/src/adapters/p2p/process.rs
@@ -98,6 +98,12 @@ pub(super) async fn process_envelope(
GossipEnvelope::Transactions { transactions } => {
process_transactions(network, remote_addr, known_peer, transactions).await;
}
+ GossipEnvelope::TransactionV2 { envelope } => {
+ process_transactions_v2(network, remote_addr, known_peer, vec![envelope]).await;
+ }
+ GossipEnvelope::TransactionsV2 { envelopes } => {
+ process_transactions_v2(network, remote_addr, known_peer, envelopes).await;
+ }
GossipEnvelope::BurnBundle(bundle) => {
process_burn_bundles(network, remote_addr, known_peer, vec![bundle]).await;
}
@@ -366,6 +372,32 @@ async fn process_transactions(
network.forward_outbox().await;
}
+async fn process_transactions_v2(
+ network: &GossipNetwork,
+ remote_addr: SocketAddr,
+ known_peer: &Option<String>,
+ envelopes: Vec<String>,
+) {
+ let first_error = {
+ let mut node = network.inner.node.lock().await;
+ let mut first_error = None;
+ for envelope in envelopes {
+ if let Err(error) = node.receive_gossiped_transaction_v2(envelope) {
+ first_error.get_or_insert(error);
+ }
+ }
+ first_error
+ };
+ record_inbound_result(
+ network,
+ known_peer,
+ remote_addr,
+ first_error.map(Err).unwrap_or(Ok(())),
+ )
+ .await;
+ network.forward_outbox().await;
+}
+
async fn process_burn_bundles(
network: &GossipNetwork,
remote_addr: SocketAddr,
diff --git a/src/app.rs b/src/app.rs
@@ -31,7 +31,7 @@ pub use peer_book::{PeerBook, PeerDirection, PeerInfo};
pub use types::{
AutoMineOutcome, AutoMinePlan, BlockInventory, ChainBootstrap, ExternalMineJob, FeeEstimate,
GossipEnvelope, LaunchProfileStatus, MiningStatus, NetworkMigrationStatus, NodeConfig,
- NodeStatus, ProtocolHello, StratumStatus,
+ NodeStatus, ProtocolHello, QuantumMigrationPreview, QuantumMigrationStatus, StratumStatus,
};
use wallet::NodeWallet;
diff --git a/src/app/gossip.rs b/src/app/gossip.rs
@@ -1,4 +1,4 @@
-use crate::domain::{Block, ChainSnapshot};
+use crate::domain::{Block, ChainSnapshot, hex_encode};
use super::{
BLOCK_REQUEST_LIMIT, ChainBootstrap, GossipEnvelope, NETWORK_ID, NodeCore, PROTOCOL_VERSION,
@@ -16,6 +16,24 @@ impl NodeCore {
transactions: chunk.to_vec(),
}),
);
+ let domain = self.ledger.transaction_v2_domain().ok();
+ if let Some(domain) = domain {
+ let envelopes = self
+ .ledger
+ .pending_v2()
+ .iter()
+ .filter_map(|transaction| transaction.encode(&domain).ok())
+ .map(hex_encode)
+ .collect::<Vec<_>>();
+ // A single v2 transaction may approach the block byte limit after hex encoding.
+ // Keep each envelope independently wire-bounded instead of building an oversized
+ // JSON batch from several otherwise valid transactions.
+ gossip.extend(
+ envelopes
+ .into_iter()
+ .map(|envelope| GossipEnvelope::TransactionV2 { envelope }),
+ );
+ }
gossip.extend(
self.usable_burn_bundles()
.chunks(TRANSACTION_BATCH_LIMIT)
diff --git a/src/app/in_memory_network.rs b/src/app/in_memory_network.rs
@@ -103,7 +103,10 @@ impl InMemoryNetwork {
fn receive_in_memory_envelope(node: &mut NodeCore, envelope: GossipEnvelope) -> Result<()> {
let transaction_like = matches!(
envelope,
- GossipEnvelope::Transaction(_) | GossipEnvelope::Transactions { .. }
+ GossipEnvelope::Transaction(_)
+ | GossipEnvelope::Transactions { .. }
+ | GossipEnvelope::TransactionV2 { .. }
+ | GossipEnvelope::TransactionsV2 { .. }
);
match node.receive(envelope) {
Ok(()) => Ok(()),
diff --git a/src/app/node_lifecycle.rs b/src/app/node_lifecycle.rs
@@ -5,8 +5,8 @@ use anyhow::Result;
use crate::{
adapters::config_store::{DEFAULT_POW_MINING_WORKERS, clamp_pow_mining_workers},
domain::{
- AddressNetwork, Amount, BurnBundle, DEFAULT_FEE_PER_BYTE, Ledger, Wallet, decode_address,
- encode_address,
+ AddressNetwork, Amount, BurnBundle, DEFAULT_FEE_PER_BYTE, Ledger, VersionedAddress, Wallet,
+ decode_address, decode_versioned_address, encode_address,
},
};
@@ -140,6 +140,10 @@ impl NodeCore {
decode_address(address, self.address_network())
}
+ pub fn decode_user_address(&self, address: &str) -> Result<VersionedAddress> {
+ decode_versioned_address(address, self.address_network())
+ }
+
fn address_network(&self) -> AddressNetwork {
AddressNetwork::from_profile_id(&self.ledger.launch_profile().profile_id)
}
diff --git a/src/app/receive.rs b/src/app/receive.rs
@@ -1,8 +1,8 @@
use anyhow::Result;
use crate::domain::{
- Block, BurnBundle, ChainSnapshot, Ledger, Transaction, TransactionSubmitOutcome,
- ValidationError, error_has_validation,
+ Block, BurnBundle, ChainSnapshot, Ledger, Transaction, TransactionSubmitOutcome, TransactionV2,
+ ValidationError, decode_hex, error_has_validation, hex_encode,
};
use super::{GossipEnvelope, IMPORT_REBROADCAST_LIMIT, NodeCore};
@@ -32,6 +32,34 @@ impl NodeCore {
Ok(())
}
+ pub fn receive_gossiped_transaction_v2(&mut self, envelope: String) -> Result<()> {
+ let encoded = decode_hex(&envelope)?;
+ let transaction = self.ledger.decode_transaction_v2(&encoded)?;
+ let outcome = self.ledger.submit_transaction_v2(transaction)?;
+ if outcome.added() {
+ self.outbox.push(GossipEnvelope::TransactionV2 {
+ envelope: hex_encode(encoded),
+ });
+ }
+ Ok(())
+ }
+
+ pub(super) fn submit_public_transaction_v2(
+ &mut self,
+ transaction: TransactionV2,
+ ) -> Result<TransactionV2> {
+ let domain = self.ledger.transaction_v2_domain()?;
+ let envelope = hex_encode(transaction.encode(&domain)?);
+ if self
+ .ledger
+ .submit_transaction_v2(transaction.clone())?
+ .added()
+ {
+ self.outbox.push(GossipEnvelope::TransactionV2 { envelope });
+ }
+ Ok(transaction)
+ }
+
pub fn receive_burn_bundle(&mut self, bundle: BurnBundle) -> Result<()> {
let next_height = self.ledger.height().saturating_add(1);
if bundle.height <= self.ledger.height() {
@@ -79,6 +107,15 @@ impl NodeCore {
}
Ok(())
}
+ GossipEnvelope::TransactionV2 { envelope } => {
+ self.receive_gossiped_transaction_v2(envelope)
+ }
+ GossipEnvelope::TransactionsV2 { envelopes } => {
+ for envelope in envelopes {
+ self.receive_gossiped_transaction_v2(envelope)?;
+ }
+ Ok(())
+ }
GossipEnvelope::BurnBundle(bundle) => self.receive_burn_bundle(bundle),
GossipEnvelope::BurnBundles { bundles } => {
for bundle in bundles {
diff --git a/src/app/status.rs b/src/app/status.rs
@@ -2,12 +2,15 @@ use anyhow::Result;
use crate::{
adapters::config_store::{MAX_POW_MINING_WORKERS, clamp_pow_mining_workers},
- domain::{Amount, MINE_FINALIZER_FEE, Transaction, VDF_TARGET_BLOCK_MS},
+ domain::{
+ AddressNetwork, Amount, MINE_FINALIZER_FEE, Transaction, TransactionV2,
+ VDF_TARGET_BLOCK_MS, transaction_v2_is_active,
+ },
};
use super::{
LaunchProfileStatus, MiningStatus, NETWORK_ID, NetworkMigrationStatus, NodeCore, NodeStatus,
- StratumStatus,
+ QuantumMigrationStatus, StratumStatus,
helpers::{transaction_input_total_from_outputs, transaction_output_total_for_address},
now_ms,
};
@@ -20,6 +23,32 @@ impl NodeCore {
let wallet_is_current_leader = current_leader
.as_deref()
.is_none_or(|leader| leader == self.wallet.address());
+ let legacy_address = self.wallet.address();
+ let legacy_balance = self.ledger.balance_of(legacy_address);
+ let legacy_utxos = self.ledger.utxos_for_address(legacy_address).len();
+ let hybrid_address = self.wallet.unlocked().ok().map(|wallet| {
+ wallet.hybrid_address(AddressNetwork::from_profile_id(
+ &self.ledger.launch_profile().profile_id,
+ ))
+ });
+ let hybrid_balance = hybrid_address
+ .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)
+ }))
+ })
+ });
NodeStatus {
app_version: env!("CARGO_PKG_VERSION").to_string(),
@@ -27,6 +56,14 @@ impl NodeCore {
wallet_receive_address: self.wallet_receive_address().unwrap_or_default(),
wallet_balance: self.wallet_projected_balance(),
wallet_locked: self.wallet.is_locked(),
+ quantum_migration: QuantumMigrationStatus {
+ active: transaction_v2_is_active(self.ledger.height()),
+ hybrid_address,
+ legacy_balance,
+ hybrid_balance,
+ legacy_utxos,
+ migration_pending,
+ },
launch_profile: LaunchProfileStatus {
profile_id: launch_profile.profile_id.clone(),
profile_hash: chain.launch_profile_hash.clone(),
@@ -73,6 +110,12 @@ impl NodeCore {
fn wallet_projected_balance(&self) -> Amount {
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));
+ }
let confirmed_outputs = self
.ledger
.utxos_for_address(address)
diff --git a/src/app/types.rs b/src/app/types.rs
@@ -24,6 +24,17 @@ pub struct FeeEstimate {
pub fee: Amount,
}
+#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+pub struct QuantumMigrationPreview {
+ pub address: String,
+ pub transaction_id: String,
+ pub input_count: usize,
+ pub remaining_legacy_utxos: usize,
+ pub bytes: usize,
+ pub fee: Amount,
+ pub amount: Amount,
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExternalMineJob {
pub template: StratumMineTemplate,
@@ -59,6 +70,12 @@ pub enum GossipEnvelope {
Transactions {
transactions: Vec<Transaction>,
},
+ TransactionV2 {
+ envelope: String,
+ },
+ TransactionsV2 {
+ envelopes: Vec<String>,
+ },
BurnBundle(BurnBundle),
BurnBundles {
bundles: Vec<BurnBundle>,
@@ -177,6 +194,7 @@ pub struct NodeStatus {
pub wallet_receive_address: String,
pub wallet_balance: Amount,
pub wallet_locked: bool,
+ pub quantum_migration: QuantumMigrationStatus,
pub launch_profile: LaunchProfileStatus,
pub mining: MiningStatus,
pub stratum: StratumStatus,
@@ -185,6 +203,16 @@ pub struct NodeStatus {
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct QuantumMigrationStatus {
+ pub active: bool,
+ pub hybrid_address: Option<String>,
+ pub legacy_balance: Amount,
+ pub hybrid_balance: Amount,
+ pub legacy_utxos: usize,
+ pub migration_pending: bool,
+}
+
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct NetworkMigrationStatus {
pub required: bool,
pub from_network: Option<String>,
diff --git a/src/app/wallet.rs b/src/app/wallet.rs
@@ -3,12 +3,14 @@ use std::collections::BTreeMap;
use anyhow::{Context, Result, bail};
use crate::domain::{
- Amount, Block, BurnBundle, DEFAULT_TRANSACTION_FEE, Ledger, OutPoint, PreparedBlock,
- StratumMineShare, StratumMineTemplate, Transaction, TransactionSubmitOutcome, Wallet, run_vdf,
+ AddressNetwork, Amount, Block, BurnBundle, DEFAULT_TRANSACTION_FEE, Ledger, OutPoint,
+ PreparedBlock, StratumMineShare, StratumMineTemplate, Transaction, TransactionSubmitOutcome,
+ TransactionV2, VersionedAddress, Wallet, hex_encode, run_vdf,
};
use super::{
- ExternalMineJob, FeeEstimate, GossipEnvelope, NodeCore, helpers::converge_fee_by_byte, now_ms,
+ ExternalMineJob, FeeEstimate, GossipEnvelope, NodeCore, QuantumMigrationPreview,
+ helpers::converge_fee_by_byte, now_ms,
};
#[derive(Clone, Debug)]
@@ -38,6 +40,121 @@ impl NodeWallet {
}
impl NodeCore {
+ pub fn preview_quantum_migration(
+ &self,
+ fee_per_byte: Amount,
+ ) -> Result<QuantumMigrationPreview> {
+ if fee_per_byte == 0 {
+ bail!("migration fee per byte must be greater than zero");
+ }
+ let ledger = self.wallet_build_ledger()?;
+ let wallet = self.wallet.unlocked()?;
+ let domain = ledger.transaction_v2_domain()?;
+ let initial = ledger.build_v2_migration_batch(wallet, 1)?;
+ let bytes = initial.encoded_size_bytes(&domain)?;
+ let fee = fee_per_byte
+ .checked_mul(bytes as u64)
+ .context("migration fee overflows")?;
+ let transaction = ledger.build_v2_migration_batch(wallet, fee)?;
+ let bytes = transaction.encoded_size_bytes(&domain)?;
+ let transaction_id = hex_encode(transaction.transaction_id(&domain)?);
+ let (input_count, amount) = match &transaction {
+ TransactionV2::Migration {
+ inputs, outputs, ..
+ } => (
+ inputs.len(),
+ outputs.iter().try_fold(0_u64, |total, output| {
+ total
+ .checked_add(output.amount)
+ .context("migration amount overflows")
+ })?,
+ ),
+ _ => unreachable!("migration builder returned another transaction kind"),
+ };
+ Ok(QuantumMigrationPreview {
+ address: wallet.hybrid_address(AddressNetwork::from_profile_id(
+ &ledger.launch_profile().profile_id,
+ )),
+ transaction_id,
+ input_count,
+ remaining_legacy_utxos: ledger
+ .available_utxos_for_address(wallet.address())?
+ .len()
+ .saturating_sub(input_count),
+ bytes,
+ fee,
+ amount,
+ })
+ }
+
+ pub fn submit_quantum_migration(
+ &mut self,
+ fee_per_byte: Amount,
+ max_fee: Amount,
+ expected_transaction_id: &str,
+ ) -> Result<QuantumMigrationPreview> {
+ let preview = self.preview_quantum_migration(fee_per_byte)?;
+ if preview.fee > max_fee || preview.transaction_id != expected_transaction_id {
+ bail!("wallet outputs or migration fee changed; request a new preview");
+ }
+ let ledger = self.wallet_build_ledger()?;
+ let transaction = ledger.build_v2_migration_batch(self.wallet.unlocked()?, preview.fee)?;
+ self.submit_public_transaction_v2(transaction)?;
+ Ok(preview)
+ }
+
+ pub fn estimate_hybrid_transfer_fee(
+ &self,
+ recipient: VersionedAddress,
+ amount: Amount,
+ fee_per_byte: Amount,
+ ) -> Result<FeeEstimate> {
+ self.build_hybrid_transfer_with_fee_rate(recipient, amount, fee_per_byte)
+ .map(|(_, estimate)| estimate)
+ }
+
+ pub fn transfer_hybrid_with_fee_rate(
+ &mut self,
+ recipient: VersionedAddress,
+ amount: Amount,
+ fee_per_byte: Amount,
+ ) -> Result<String> {
+ let (transaction, _) =
+ self.build_hybrid_transfer_with_fee_rate(recipient, amount, fee_per_byte)?;
+ let domain = self.ledger.transaction_v2_domain()?;
+ let transaction_id = hex_encode(transaction.transaction_id(&domain)?);
+ self.submit_public_transaction_v2(transaction)?;
+ Ok(transaction_id)
+ }
+
+ fn build_hybrid_transfer_with_fee_rate(
+ &self,
+ recipient: VersionedAddress,
+ amount: Amount,
+ fee_per_byte: Amount,
+ ) -> Result<(TransactionV2, FeeEstimate)> {
+ if fee_per_byte == 0 {
+ bail!("fee per byte must be greater than zero");
+ }
+ let ledger = self.wallet_build_ledger()?;
+ let domain = ledger.transaction_v2_domain()?;
+ let wallet = self.wallet.unlocked()?;
+ let mut fee = 1;
+ for _ in 0..64 {
+ let transaction = ledger.build_v2_transfer(wallet, recipient, amount, fee)?;
+ let bytes = transaction.encoded_size_bytes(&domain)?;
+ let required_fee = fee_per_byte
+ .checked_mul(bytes as Amount)
+ .context("fee per byte times transaction bytes overflows")?
+ .max(1);
+ if fee >= required_fee {
+ return Ok((transaction, FeeEstimate { bytes, fee }));
+ }
+ fee = required_fee;
+ }
+ bail!("hybrid transfer fee did not converge")
+ }
+
/// Accept a transaction signed by an external/lightweight wallet.
/// Mining actions are deliberately excluded from the public wallet API.
pub fn submit_external_wallet_transaction(
@@ -440,3 +557,32 @@ impl NodeCore {
Ok(())
}
}
+
+#[cfg(test)]
+mod quantum_migration_tests {
+ use std::collections::BTreeMap;
+
+ use crate::{
+ app::NodeCore,
+ domain::{Ledger, Wallet},
+ };
+
+ #[test]
+ fn migration_preview_reports_the_canonical_hybrid_transaction() {
+ let wallet = Wallet::from_seed("migration-preview-wallet");
+ let ledger = Ledger::new(
+ BTreeMap::from([(wallet.address().to_string(), 1_000_000)]),
+ 1,
+ );
+ let node = NodeCore::from_ledger(wallet, ledger, 0);
+
+ let preview = node.preview_quantum_migration(2).unwrap();
+
+ assert_eq!(preview.input_count, 1);
+ assert_eq!(preview.remaining_legacy_utxos, 0);
+ assert_eq!(preview.fee, preview.bytes as u64 * 2);
+ assert_eq!(preview.amount + preview.fee, 1_000_000);
+ assert_eq!(preview.transaction_id.len(), 64);
+ assert!(preview.address.starts_with("iuna1p"));
+ }
+}
diff --git a/src/domain.rs b/src/domain.rs
@@ -49,7 +49,7 @@ pub(crate) use error::{ValidationError, error_has_validation};
use fork::{FinalityCheckpoint, LeaderScore};
pub(crate) use genesis::genesis_allocation_outpoint;
pub use hex::hex_hash;
-use hex::{decode_hex, decode_hex_array, hex_encode};
+pub(crate) use hex::{decode_hex, decode_hex_array, hex_encode};
use ledger_lineage::{
LineageOwnerValues, UtxoLineageRoot, attach_existing_output_lineage,
insert_output_with_lineage, newest_lineage_root, output_lineage_root_for_transaction,
diff --git a/src/domain/hex.rs b/src/domain/hex.rs
@@ -5,7 +5,7 @@ pub fn hex_hash(input: impl AsRef<[u8]>) -> String {
hex_encode(Sha256::digest(input.as_ref()))
}
-pub(super) fn decode_hex_array<const N: usize>(input: &str) -> Result<[u8; N]> {
+pub(crate) fn decode_hex_array<const N: usize>(input: &str) -> Result<[u8; N]> {
let bytes = decode_hex(input)?;
let len = bytes.len();
bytes
@@ -13,7 +13,7 @@ pub(super) fn decode_hex_array<const N: usize>(input: &str) -> Result<[u8; N]> {
.map_err(|_| anyhow!("expected {N} hex bytes, got {len}"))
}
-pub(super) fn decode_hex(input: &str) -> Result<Vec<u8>> {
+pub(crate) fn decode_hex(input: &str) -> Result<Vec<u8>> {
if input.len() % 2 != 0 {
bail!("hex string has odd length");
}
@@ -36,7 +36,7 @@ fn hex_value(byte: u8) -> Result<u8> {
}
}
-pub(super) fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
+pub(crate) fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let bytes = bytes.as_ref();
let mut encoded = String::with_capacity(bytes.len() * 2);
diff --git a/src/domain/ledger_builders.rs b/src/domain/ledger_builders.rs
@@ -1,4 +1,5 @@
use super::hex::{decode_hex, decode_hex_array, hex_encode};
+use super::ledger_ops::{compact_block_context, ensure_transaction_v2_fits_empty_block};
use super::mining::mine_signature;
use super::stratum::{
hash_meets_difficulty, stratum_mine_header_bytes, stratum_mine_signature, stratum_mine_template,
@@ -6,9 +7,10 @@ use super::stratum::{
use super::transaction::{UnsignedTxInput, UnsignedUtxoTransaction};
use super::validation::validate_address;
use super::{
- Amount, Ledger, LegacyTransactionId, MineSearchOutcome, OutPoint, StratumMineShare,
- StratumMineTemplate, Transaction, TransactionV2, TransactionV2Domain, TransactionV2LegacyInput,
- TransactionV2Output, TxOutput, Wallet,
+ AddressNetwork, Amount, Ledger, LegacyTransactionId, MineSearchOutcome, OutPoint,
+ StratumMineShare, StratumMineTemplate, Transaction, TransactionV2, TransactionV2Domain,
+ TransactionV2Input, TransactionV2LegacyInput, TransactionV2Output, TxOutput, VersionedAddress,
+ Wallet,
};
use anyhow::{Context, Result, bail};
@@ -20,7 +22,54 @@ impl Ledger {
if available.is_empty() {
bail!("no legacy outputs are available for migration");
}
+ self.build_v2_migration_from_available(wallet, fee, &available, true)
+ }
+
+ /// Builds the largest deterministic prefix of legacy outputs that fits one block.
+ pub fn build_v2_migration_batch(&self, wallet: &Wallet, fee: Amount) -> Result<TransactionV2> {
+ let available = self.available_utxos_for_address(wallet.address())?;
+ if available.is_empty() {
+ bail!("no legacy outputs are available for migration");
+ }
+ let mut input_count = available.len().min(1_000);
+ loop {
+ let transaction = self.build_v2_migration_from_available(
+ wallet,
+ fee,
+ &available[..input_count],
+ false,
+ )?;
+ let bytes = transaction.encoded_size_bytes(&self.transaction_v2_domain()?)?;
+ if ensure_v2_transaction_within_block_budget(
+ self,
+ &transaction,
+ &self.transaction_v2_domain()?,
+ self.launch_profile.max_block_bytes,
+ )
+ .is_ok()
+ {
+ return Ok(transaction);
+ }
+ if input_count == 1 {
+ bail!(
+ "one-input migration requires {bytes} bytes and exceeds the {}-byte block budget",
+ self.launch_profile.max_block_bytes
+ );
+ }
+ let proportional = ((input_count as u128)
+ .saturating_mul(self.launch_profile.max_block_bytes as u128)
+ / bytes as u128) as usize;
+ input_count = proportional.clamp(1, input_count - 1);
+ }
+ }
+ fn build_v2_migration_from_available(
+ &self,
+ wallet: &Wallet,
+ fee: Amount,
+ available: &[(OutPoint, TxOutput)],
+ enforce_block_budget: bool,
+ ) -> Result<TransactionV2> {
let mut total = 0_u64;
let mut inputs = Vec::with_capacity(available.len());
for (outpoint, output) in available {
@@ -66,7 +115,96 @@ impl Ledger {
authorizations.resize(inputs.len(), authorization);
}
transaction.verify_authorizations(&domain)?;
+ if enforce_block_budget {
+ ensure_v2_transaction_within_block_budget(
+ self,
+ &transaction,
+ &domain,
+ self.launch_profile.max_block_bytes,
+ )?;
+ }
+ Ok(transaction)
+ }
+
+ pub fn build_v2_transfer(
+ &self,
+ wallet: &Wallet,
+ recipient: VersionedAddress,
+ amount: Amount,
+ fee: Amount,
+ ) -> Result<TransactionV2> {
+ if recipient.version != super::AddressVersion::HybridKeyCommitment {
+ bail!("transaction v2 recipient must use address v1");
+ }
+ if amount == 0 {
+ bail!("transfer amount must be greater than zero");
+ }
+ 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)| {
+ right
+ .amount
+ .cmp(&left.amount)
+ .then_with(|| left_point.cmp(right_point))
+ });
+
+ let mut total = 0_u64;
+ let mut inputs = Vec::new();
+ for (outpoint, output) in available {
+ total = total
+ .checked_add(output.amount)
+ .context("transaction v2 input total overflows")?;
+ inputs.push(TransactionV2Input {
+ outpoint_txid: decode_hex_array::<32>(&outpoint.txid)
+ .context("transaction v2 outpoint ID must be a 32-byte hash")?,
+ outpoint_index: outpoint.index,
+ owner,
+ });
+ if total >= required {
+ break;
+ }
+ }
+ if total < required {
+ bail!("insufficient hybrid funds");
+ }
+
+ let mut outputs = vec![TransactionV2Output {
+ address: recipient,
+ amount,
+ }];
+ let change = total - required;
+ if change > 0 {
+ outputs.push(TransactionV2Output {
+ address: owner,
+ amount: change,
+ });
+ }
+ let domain = self.transaction_v2_domain()?;
+ let mut transaction = TransactionV2::Transfer {
+ inputs,
+ outputs,
+ fee,
+ 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);
+ }
+ transaction.verify_authorizations(&domain)?;
ensure_v2_transaction_within_block_budget(
+ self,
&transaction,
&domain,
self.launch_profile.max_block_bytes,
@@ -444,6 +582,7 @@ fn legacy_transaction_id(txid: &str) -> Result<LegacyTransactionId> {
}
fn ensure_v2_transaction_within_block_budget(
+ ledger: &Ledger,
transaction: &TransactionV2,
domain: &TransactionV2Domain,
max_block_bytes: usize,
@@ -454,6 +593,11 @@ fn ensure_v2_transaction_within_block_budget(
"transaction v2 requires {transaction_bytes} bytes and exceeds the {max_block_bytes}-byte block budget"
);
}
+ ensure_transaction_v2_fits_empty_block(
+ compact_block_context(ledger),
+ &hex_encode(transaction.encode(domain)?),
+ max_block_bytes,
+ )?;
Ok(())
}
@@ -524,4 +668,76 @@ mod v2_migration_tests {
.contains("exceeds the 1-byte block budget")
);
}
+
+ #[test]
+ fn hybrid_transfer_builder_spends_migrated_value_and_returns_hybrid_change() {
+ let wallet = Wallet::from_seed("v2-transfer-builder-wallet");
+ let recipient = Wallet::from_seed("v2-transfer-builder-recipient");
+ let ledger = Ledger::new(BTreeMap::from([(wallet.address().to_string(), 100)]), 1);
+ let migration = ledger.build_v2_migration(&wallet, 3).unwrap();
+ let mut migrated = ledger.clone();
+ migrated.utxos = ledger
+ .validated_v2_utxos_at_height(&migration, 3_000)
+ .unwrap();
+
+ let transfer = migrated
+ .build_v2_transfer(&wallet, recipient.hybrid_versioned_address(), 40, 2)
+ .unwrap();
+ let TransactionV2::Transfer {
+ inputs,
+ outputs,
+ fee,
+ authorizations,
+ } = &transfer
+ else {
+ panic!("builder returned a non-transfer transaction");
+ };
+
+ assert_eq!(inputs.len(), 1);
+ assert_eq!(*fee, 2);
+ assert_eq!(outputs.len(), 2);
+ assert_eq!(outputs[0].address, recipient.hybrid_versioned_address());
+ assert_eq!(outputs[0].amount, 40);
+ assert_eq!(outputs[1].address, wallet.hybrid_versioned_address());
+ assert_eq!(outputs[1].amount, 55);
+ assert_eq!(authorizations.len(), inputs.len());
+ migrated
+ .validate_transaction_v2_at_height(&transfer, 3_001)
+ .unwrap();
+ }
+
+ #[test]
+ fn migration_batch_fits_the_real_empty_block_budget() {
+ let wallet = Wallet::from_seed("v2-migration-batch-wallet");
+ let profile = crate::domain::LaunchProfile {
+ max_block_bytes: 2_000,
+ ..crate::domain::LaunchProfile::default()
+ };
+ let mut ledger =
+ Ledger::new_with_genesis_burns_and_profile(BTreeMap::new(), Vec::new(), 1, profile)
+ .unwrap();
+ ledger.utxos = (1_u64..=50)
+ .map(|index| {
+ (
+ OutPoint {
+ txid: format!("{index:064x}"),
+ index: 0,
+ },
+ TxOutput {
+ address: wallet.address().to_string(),
+ amount: 10_000,
+ },
+ )
+ })
+ .collect();
+ ledger.chain.last_mut().unwrap().height = 2_999;
+
+ let transaction = ledger.build_v2_migration_batch(&wallet, 1).unwrap();
+ let TransactionV2::Migration { inputs, .. } = &transaction else {
+ panic!("builder returned a non-migration transaction");
+ };
+ assert!(!inputs.is_empty());
+ assert!(inputs.len() < 50);
+ ledger.submit_transaction_v2(transaction).unwrap();
+ }
}
diff --git a/tests/quantum-migration.test.cjs b/tests/quantum-migration.test.cjs
@@ -0,0 +1,100 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { readFileSync } = require('node:fs');
+const vm = require('node:vm');
+
+function app() {
+ const context = {
+ window: {},
+ localStorage: { getItem: () => null },
+ setTimeout,
+ clearTimeout,
+ URLSearchParams,
+ };
+ vm.runInNewContext(readFileSync(require.resolve('../www/assets/iuna-ui.js'), 'utf8'), context);
+ const ui = context.window.iunaApp();
+ ui.status = {
+ wallet_locked: false,
+ quantum_migration: { active: true, legacy_balance: 1_000_000, hybrid_balance: 0 },
+ };
+ ui.refresh = async () => {};
+ ui.showFlash = () => {};
+ return ui;
+}
+
+test('migration preview does not submit funds', async () => {
+ const ui = app();
+ const paths = [];
+ ui.submitForm = async (path, fields) => {
+ paths.push(path);
+ assert.equal(fields.fee_per_byte, 1);
+ return {
+ preview: {
+ transaction_id: 'ab'.repeat(32),
+ input_count: 2,
+ remaining_legacy_utxos: 3,
+ bytes: 500,
+ fee: 500,
+ amount: 999_500,
+ },
+ };
+ };
+
+ await ui.previewQuantumMigration();
+
+ assert.deepEqual(paths, ['/api/wallet/quantum-migration/preview']);
+ assert.equal(ui.quantumMigrationPreview.remaining_legacy_utxos, 3);
+});
+
+test('confirmed migration submits the exact preview once', async () => {
+ const ui = app();
+ ui.quantumMigrationPreview = {
+ transaction_id: 'cd'.repeat(32),
+ rate: 2,
+ fee: 1_000,
+ remaining_legacy_utxos: 4,
+ };
+ let submissions = 0;
+ ui.submitForm = async (path, fields) => {
+ submissions += 1;
+ assert.equal(path, '/api/wallet/quantum-migration/submit');
+ assert.equal(fields.transaction_id, 'cd'.repeat(32));
+ assert.equal(fields.max_fee, 1_000);
+ return { transaction_id: fields.transaction_id, remaining_legacy_utxos: 4 };
+ };
+
+ await ui.submitQuantumMigration();
+ await ui.submitQuantumMigration();
+
+ assert.equal(submissions, 1);
+ assert.equal(ui.quantumMigrationPreview, null);
+});
+
+test('uncertain migration submission requires a fresh preview', async () => {
+ const ui = app();
+ ui.quantumMigrationPreview = {
+ transaction_id: 'ef'.repeat(32),
+ rate: 1,
+ fee: 500,
+ };
+ ui.submitForm = async () => { throw new Error('timeout'); };
+
+ await ui.submitQuantumMigration();
+
+ assert.equal(ui.quantumMigrationPreview, null);
+ assert.match(ui.quantumMigrationError, /Check wallet activity/);
+});
+
+test('hybrid recipients never reuse selected legacy UTXOs', () => {
+ const ui = app();
+ ui.transferTo = `iuna1p${'q'.repeat(58)}`;
+ ui.selectedTransferUtxos = ['legacy:0'];
+ ui.selectedTransferUtxoAmounts = { 'legacy:0': 10 };
+ ui.showSendAdvanced = true;
+
+ ui.transferRecipientChanged();
+
+ assert.equal(ui.hybridTransferRecipient(), true);
+ assert.equal(ui.selectedTransferUtxos.length, 0);
+ assert.equal(ui.showSendAdvanced, false);
+});
diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js
@@ -125,6 +125,11 @@ window.iunaApp = function iunaApp() {
optimizeMessage: "",
optimizeError: "",
optimizeDismissed: false,
+ quantumMigrationFee: "0.000001",
+ quantumMigrationBusy: false,
+ quantumMigrationSubmitting: false,
+ quantumMigrationPreview: null,
+ quantumMigrationError: "",
selectedTransferUtxos: [],
selectedTransferUtxoAmounts: {},
lastSelectedTransferUtxo: null,
@@ -2604,6 +2609,70 @@ window.iunaApp = function iunaApp() {
finally { this.optimizeBusy = false; }
},
+ async previewQuantumMigration() {
+ if (this.quantumMigrationBusy) return;
+ this.quantumMigrationBusy = true;
+ this.quantumMigrationPreview = null;
+ this.quantumMigrationError = "";
+ try {
+ const rate = this.parseiunaAmountRequired(
+ this.quantumMigrationFee,
+ "Enter a migration fee per byte"
+ );
+ if (!Number.isSafeInteger(rate) || rate < 1) {
+ throw new Error("Fee per byte must be at least 0.000001 IUNA");
+ }
+ const result = await this.submitForm("/api/wallet/quantum-migration/preview", {
+ fee_per_byte: rate,
+ });
+ const preview = result.preview;
+ if (![preview.fee, preview.amount, preview.bytes, preview.input_count, preview.remaining_legacy_utxos].every(Number.isSafeInteger)) {
+ throw new Error("Migration values exceed the safe range for this interface");
+ }
+ this.quantumMigrationPreview = { ...preview, rate };
+ } catch (error) {
+ this.quantumMigrationError = error.message;
+ } finally {
+ this.quantumMigrationBusy = false;
+ }
+ },
+
+ async submitQuantumMigration() {
+ const preview = this.quantumMigrationPreview;
+ if (!preview || this.quantumMigrationSubmitting || this.quantumMigrationBusy) return;
+ this.quantumMigrationSubmitting = true;
+ this.quantumMigrationError = "";
+ try {
+ let result;
+ try {
+ result = await this.submitForm("/api/wallet/quantum-migration/submit", {
+ fee_per_byte: preview.rate,
+ max_fee: preview.fee,
+ transaction_id: preview.transaction_id,
+ });
+ } catch (error) {
+ this.quantumMigrationPreview = null;
+ throw new Error(`${error.message}. Check wallet activity before requesting a new preview.`);
+ }
+ this.quantumMigrationPreview = null;
+ if (result.broadcast_error) {
+ throw new Error("Migration queued locally, but broadcasting failed. Check connectivity before continuing.");
+ }
+ const remainder = Number(result.remaining_legacy_utxos || 0);
+ this.showFlash(
+ remainder > 0
+ ? `Migration batch queued. ${remainder} legacy UTXOs remain after confirmation.`
+ : "Wallet migration queued.",
+ "success"
+ );
+ await this.refresh({ force: true });
+ } catch (error) {
+ this.quantumMigrationError = error.message;
+ } finally {
+ this.quantumMigrationSubmitting = false;
+ }
+ },
+
async runOptimization() {
const plan = this.optimizePlan;
if (!plan || this.optimizeRunning || this.optimizeBusy) return;
@@ -2662,7 +2731,7 @@ window.iunaApp = function iunaApp() {
feePerByte: fee,
bytes: Number(estimate.bytes),
fee: this.microiunaAmount(estimate.fee),
- utxos: this.selectedTransferUtxos.join("\n"),
+ utxos: this.hybridTransferRecipient() ? "" : this.selectedTransferUtxos.join("\n"),
};
this.sendConfirmModalOpen = true;
} catch (error) {
@@ -2710,13 +2779,31 @@ window.iunaApp = function iunaApp() {
},
transferMaxDisabled() {
+ if (this.hybridTransferRecipient()) {
+ return Number(this.status.quantum_migration?.hybrid_balance || 0) <= 0;
+ }
return this.selectedTransferUtxoTotal() <= 0 && Number(this.status.wallet_balance || 0) <= 0;
},
+ hybridTransferRecipient() {
+ return /^(?:iuna|tiuna)1p/i.test(this.transferTo.trim());
+ },
+
+ transferRecipientChanged() {
+ if (this.hybridTransferRecipient()) {
+ this.showSendAdvanced = false;
+ this.selectedTransferUtxos = [];
+ this.selectedTransferUtxoAmounts = {};
+ }
+ this.scheduleFeeEstimates();
+ },
+
async setMaxTransferAmount() {
try {
- let selectedTotal = this.selectedTransferUtxoTotal();
- if (this.selectedTransferUtxos.length === 0) {
+ let selectedTotal = this.hybridTransferRecipient()
+ ? Number(this.status.quantum_migration?.hybrid_balance || 0)
+ : this.selectedTransferUtxoTotal();
+ if (!this.hybridTransferRecipient() && this.selectedTransferUtxos.length === 0) {
const utxos = await this.fetchJson("/api/wallet/utxos/selectable");
this.rememberUtxoAmounts(utxos);
this.selectedTransferUtxos = utxos.map((utxo) => this.utxoOutpoint(utxo));
@@ -2778,7 +2865,7 @@ window.iunaApp = function iunaApp() {
to: recipient,
amount,
fee_per_byte: this.parseiunaAmount(this.transferFee),
- utxos: this.selectedTransferUtxos.join("\n"),
+ utxos: this.hybridTransferRecipient() ? "" : this.selectedTransferUtxos.join("\n"),
});
if (
estimate?.error
@@ -3015,6 +3102,17 @@ window.iunaApp = function iunaApp() {
}
},
+ async copyHybridAddress() {
+ 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");
+ } catch (error) {
+ this.showFlash("Could not copy hybrid address", "error");
+ }
+ },
+
showFlash(message, kind) {
this.flash = { message, kind };
if (this.flashTimer) {