iuna

iuna

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

commit 1a2b4c3c2b5ce2be17bf10fa24a61e8b5e22f885
parent e4dea7209a80305d4bc470e6882fc463d286ef14
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Tue, 22 Sep 2026 12:03:20 +0200

fix(wallet): recover pending v2 transactions

Diffstat:
MCHANGELOG.md | 1+
Mdocs/quantum-migration.md | 6++++++
Msrc/adapters/chain_store.rs | 113+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/adapters/http/index_html.rs | 2+-
Msrc/adapters/http/quantum_migration.rs | 26++++++++++++++++++++++++--
Msrc/app/gossip.rs | 29+++++++++++++++++++----------
Msrc/main.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/main_tests.rs | 30+++++++++++++++++++++++++++---
Mtests/quantum-migration.test.cjs | 37+++++++++++++++++++++++++++++++++++++
Mwww/assets/iuna-ui.js | 5++++-
10 files changed, 292 insertions(+), 17 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -19,6 +19,7 @@ from the Git history and Conventional Commit titles by `deployment.sh`. ### Fixed - rebuild confirmed transaction-v2 wallet history after chain reorganizations +- persist, restore, revalidate, and rebroadcast pending transaction-v2 envelopes ### Documentation diff --git a/docs/quantum-migration.md b/docs/quantum-migration.md @@ -130,6 +130,12 @@ broader recovery rehearsal remain release blockers. Pending and confirmed transaction-v2 entries are included in the management wallet history and chain views. Confirmed history is materialized from the canonical chain snapshot, so a chain reorganization atomically replaces entries from the abandoned branch. +Pending transaction-v2 envelopes are journaled in the chain database before a wallet migration +reports durable success; a storage failure is surfaced separately from broadcast failure. On +restart they are decoded and validated against the restored +canonical chain before re-entering the mempool and normal periodic rebroadcast path. Mining, +reorganization, invalidation, and an explicit chain reset reconcile or clear the journal instead +of blindly replaying stale spends. 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 diff --git a/src/adapters/chain_store.rs b/src/adapters/chain_store.rs @@ -31,6 +31,12 @@ CREATE TABLE IF NOT EXISTS chain_verification ( verifier_version TEXT NOT NULL, verified_at_ms INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS pending_transactions_v2 ( + transaction_id TEXT PRIMARY KEY, + envelope TEXT NOT NULL, + position INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); "#; // This identifies the consensus rules, not the application release. UI, packaging, and @@ -119,6 +125,79 @@ impl SqliteChainStore { }) } + pub fn load_pending_transactions_v2(&self) -> Result<Vec<(String, String)>> { + self.with_connection(|connection| { + let mut statement = connection + .prepare( + r#" +SELECT transaction_id, envelope +FROM pending_transactions_v2 +ORDER BY position ASC +"#, + ) + .context("failed to prepare pending transaction v2 query")?; + let rows = statement + .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) + .context("failed to load pending transactions v2")?; + rows.collect::<std::result::Result<Vec<_>, _>>() + .context("failed to read pending transaction v2 rows") + }) + } + + pub fn save_pending_transaction_v2(&self, transaction_id: &str, envelope: &str) -> Result<()> { + self.with_connection_mut(|connection| { + connection + .execute( + r#" +INSERT INTO pending_transactions_v2 (transaction_id, envelope, position, updated_at_ms) +VALUES ( + ?1, + ?2, + COALESCE((SELECT MAX(position) + 1 FROM pending_transactions_v2), 0), + ?3 +) +ON CONFLICT(transaction_id) DO UPDATE SET + envelope = excluded.envelope, + updated_at_ms = excluded.updated_at_ms +"#, + params![transaction_id, envelope, unix_ms()], + ) + .context("failed to persist pending transaction v2")?; + Ok(()) + }) + } + + pub fn replace_pending_transactions_v2(&self, rows: &[(String, String)]) -> Result<()> { + let updated_at_ms = unix_ms(); + self.with_connection_mut(|connection| { + let transaction = connection + .transaction() + .context("failed to start pending transaction v2 persistence transaction")?; + transaction + .execute("DELETE FROM pending_transactions_v2", []) + .context("failed to clear pending transactions v2")?; + for (position, (transaction_id, envelope)) in rows.iter().enumerate() { + transaction + .execute( + r#" +INSERT INTO pending_transactions_v2 ( + transaction_id, envelope, position, updated_at_ms +) +VALUES (?1, ?2, ?3, ?4) +"#, + params![transaction_id, envelope, position, updated_at_ms], + ) + .with_context(|| { + format!("failed to persist pending transaction v2 {transaction_id}") + })?; + } + transaction + .commit() + .context("failed to commit pending transaction v2 persistence transaction")?; + Ok(()) + }) + } + pub fn load_with_verification_status(&self) -> Result<Option<LoadedChainSnapshot>> { self.with_connection(|connection| { let stored = connection @@ -246,6 +325,9 @@ ON CONFLICT(id) DO UPDATE SET .execute("DELETE FROM chain_verification", []) .context("failed to delete chain verification status")?; transaction + .execute("DELETE FROM pending_transactions_v2", []) + .context("failed to delete pending transactions v2")?; + transaction .commit() .context("failed to commit chain reset transaction")?; Ok(()) @@ -566,6 +648,37 @@ VALUES (1, 0, 'bad-tip', ?1, 0) } #[test] + fn pending_transaction_v2_journal_preserves_order_and_clears_with_chain() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + store + .save_pending_transaction_v2("tx-b", "envelope-b") + .unwrap(); + store + .save_pending_transaction_v2("tx-a", "envelope-a") + .unwrap(); + + assert_eq!( + store.load_pending_transactions_v2().unwrap(), + vec![ + ("tx-b".to_string(), "envelope-b".to_string()), + ("tx-a".to_string(), "envelope-a".to_string()), + ] + ); + + store + .replace_pending_transactions_v2(&[("tx-a".to_string(), "replacement-a".to_string())]) + .unwrap(); + assert_eq!( + store.load_pending_transactions_v2().unwrap(), + vec![("tx-a".to_string(), "replacement-a".to_string())] + ); + + store.clear_chain().unwrap(); + assert!(store.load_pending_transactions_v2().unwrap().is_empty()); + } + + #[test] fn load_rejects_snapshot_that_does_not_match_stored_tip_metadata() { let dir = tempdir().unwrap(); let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); diff --git a/src/adapters/http/index_html.rs b/src/adapters/http/index_html.rs @@ -732,7 +732,7 @@ pub(super) const INDEX_HTML: &str = concat!( <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 still in this node's transaction-v2 mempool and is pending confirmation. + A migration batch is saved in this node's transaction-v2 mempool and will be rebroadcast until confirmation. <code x-show="status.quantum_migration?.pending_transaction_id" x-text="status.quantum_migration?.pending_transaction_id || ''"></code> </div> </form> diff --git a/src/adapters/http/quantum_migration.rs b/src/adapters/http/quantum_migration.rs @@ -32,19 +32,41 @@ pub(super) async fn submit( State(state): State<HttpState>, Form(form): Form<SubmitForm>, ) -> Json<Value> { - let (result, outbox) = { + let (result, outbox, pending_envelope) = { 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()) + let pending_envelope = result.as_ref().ok().and_then(|preview| { + node.pending_transaction_v2_envelopes() + .ok()? + .into_iter() + .find(|(transaction_id, _)| transaction_id == &preview.transaction_id) + }); + (result, node.drain_outbox(), pending_envelope) }; match result { Ok(preview) => { + let persistence_error = if let Some((transaction_id, envelope)) = pending_envelope { + let store = state.chain_store.clone(); + tokio::task::spawn_blocking(move || { + store.save_pending_transaction_v2(&transaction_id, &envelope) + }) + .await + .map_err(|error| anyhow::anyhow!("migration persistence worker failed: {error}")) + .and_then(|result| result) + .err() + .map(|error| error.to_string()) + } else { + Some( + "queued migration is missing from the local transaction-v2 mempool".to_string(), + ) + }; let broadcast = state.gossip.broadcast(outbox).await; Json(json!({ "ok": true, "transaction_id": preview.transaction_id, "remaining_legacy_utxos": preview.remaining_legacy_utxos, + "persistence_error": persistence_error, "broadcast_error": broadcast.err().map(|error| error.to_string()), })) } diff --git a/src/app/gossip.rs b/src/app/gossip.rs @@ -1,3 +1,5 @@ +use anyhow::Result; + use crate::domain::{Block, ChainSnapshot, hex_encode}; use super::{ @@ -6,6 +8,20 @@ use super::{ }; impl NodeCore { + pub fn pending_transaction_v2_envelopes(&self) -> Result<Vec<(String, String)>> { + let domain = self.ledger.transaction_v2_domain()?; + self.ledger + .pending_v2() + .iter() + .map(|transaction| { + Ok(( + hex_encode(transaction.transaction_id(&domain)?), + hex_encode(transaction.encode(&domain)?), + )) + }) + .collect() + } + pub fn mempool_gossip(&mut self) -> Vec<GossipEnvelope> { let mut gossip = Vec::new(); gossip.extend( @@ -16,21 +32,14 @@ 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<_>>(); + if let Ok(pending_v2) = self.pending_transaction_v2_envelopes() { // 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 + pending_v2 .into_iter() + .map(|(_, envelope)| envelope) .map(|envelope| GossipEnvelope::TransactionV2 { envelope }), ); } diff --git a/src/main.rs b/src/main.rs @@ -214,6 +214,12 @@ async fn main() -> Result<()> { ); node_core.require_network_migration(from_network); } + if !migration_required { + let restored = restore_pending_transactions_v2(&mut node_core, &chain_store)?; + if restored > 0 { + println!("restored {restored} pending transaction-v2 envelope(s)"); + } + } let node: SharedNode = Arc::new(Mutex::new(node_core)); let ui_config = Arc::new(Mutex::new(ui_config)); let mut peers = ui_config.lock().await.peers.clone(); @@ -674,6 +680,24 @@ struct InitializedLedger { migration_from: Option<String>, } +fn restore_pending_transactions_v2( + node: &mut NodeCore, + chain_store: &SqliteChainStore, +) -> Result<usize> { + let mut restored = 0_usize; + for (transaction_id, envelope) in chain_store.load_pending_transactions_v2()? { + match node.receive_gossiped_transaction_v2(envelope) { + Ok(()) => restored = restored.saturating_add(1), + Err(error) if debug_logging_enabled() => { + eprintln!("dropping stale pending transaction v2 {transaction_id}: {error:#}"); + } + Err(_) => {} + } + } + chain_store.replace_pending_transactions_v2(&node.pending_transaction_v2_envelopes()?)?; + Ok(restored) +} + impl std::ops::Deref for InitializedLedger { type Target = Ledger; @@ -1283,8 +1307,33 @@ async fn run_chain_persistence_loop( let mut last_projected_tip = initial_state.projected_tip; let mut last_projected_keep_metrics = initial_state.projected_keep_metrics; let mut last_chain_checkpoint = Instant::now(); + let mut last_saved_pending_v2_ids = None::<Vec<String>>; loop { tokio::time::sleep(interval).await; + { + let node = node.lock().await; + match node.pending_transaction_v2_envelopes() { + Ok(pending_v2) => { + let pending_v2_ids = pending_v2 + .iter() + .map(|(transaction_id, _)| transaction_id.clone()) + .collect::<Vec<_>>(); + if last_saved_pending_v2_ids.as_ref() != Some(&pending_v2_ids) { + match persist_pending_transactions_v2(&store, pending_v2).await { + Ok(()) => last_saved_pending_v2_ids = Some(pending_v2_ids), + Err(error) if debug_logging_enabled() => { + eprintln!("pending transaction-v2 persistence failed: {error:#}"); + } + Err(_) => {} + } + } + } + Err(error) if debug_logging_enabled() => { + eprintln!("pending transaction-v2 encoding failed: {error:#}"); + } + Err(_) => {} + } + } let syncing = gossip .as_ref() .is_some_and(|network| network.chain_sync_active_or_recent(Duration::from_secs(5))); @@ -1370,6 +1419,17 @@ async fn persist_chain_snapshot(store: &SqliteChainStore, snapshot: ChainSnapsho Ok(()) } +async fn persist_pending_transactions_v2( + store: &SqliteChainStore, + pending: Vec<(String, String)>, +) -> Result<()> { + let store = store.clone(); + tokio::task::spawn_blocking(move || store.replace_pending_transactions_v2(&pending)) + .await + .context("pending transaction-v2 persistence worker failed")??; + Ok(()) +} + async fn warm_ui_data_store( store: &SqliteUiDataStore, snapshot: ChainSnapshot, diff --git a/src/main_tests.rs b/src/main_tests.rs @@ -24,9 +24,9 @@ use super::{ configured_stratum_addr, extrapolate_vdf_rounds, help_text, initial_burn_fee, initial_burn_per_block, initialize_ledger, load_startup_wallet, measure_vdf_rounds, parse_startup_bool_env_value, parse_startup_pow_mining_workers_env_value, - persist_chain_snapshot, project_ui_data_store, run_chain_persistence_with_interval, - setup_ledger, should_defer_sync_checkpoint, should_log_automatic_finalization_skip, - start_genesis_ledger, validate_wallet_for_mode, + persist_chain_snapshot, project_ui_data_store, restore_pending_transactions_v2, + run_chain_persistence_with_interval, setup_ledger, should_defer_sync_checkpoint, + should_log_automatic_finalization_skip, start_genesis_ledger, validate_wallet_for_mode, }; fn parse(args: &[&str]) -> anyhow::Result<Option<CliOptions>> { @@ -1461,6 +1461,30 @@ VALUES (1, 4, 'bad-tip', x'00010203', 0) ); } +#[test] +fn startup_prunes_a_persisted_v2_transaction_that_no_longer_validates() { + let dir = tempdir().unwrap(); + let store = SqliteChainStore::open(dir.path().join("chain.sqlite3")).unwrap(); + let wallet = Wallet::from_seed("stale-persisted-v2"); + let ledger = ledger_with_one_spendable_iuna(&wallet); + let domain = ledger.transaction_v2_domain().unwrap(); + let transaction = ledger.build_v2_migration_batch(&wallet, 1).unwrap(); + let to_hex = + |bytes: &[u8]| -> String { bytes.iter().map(|byte| format!("{byte:02x}")).collect() }; + let transaction_id = to_hex(&transaction.transaction_id(&domain).unwrap()); + let envelope = to_hex(&transaction.encode(&domain).unwrap()); + store + .save_pending_transaction_v2(&transaction_id, &envelope) + .unwrap(); + let mut node = NodeCore::from_ledger(wallet, ledger, DEFAULT_BURN_PER_BLOCK); + + let restored = restore_pending_transactions_v2(&mut node, &store).unwrap(); + + assert_eq!(restored, 0); + assert!(node.pending_transaction_v2_envelopes().unwrap().is_empty()); + assert!(store.load_pending_transactions_v2().unwrap().is_empty()); +} + #[tokio::test] async fn persistence_loop_saves_new_tip_after_node_changes() { let dir = tempdir().unwrap(); diff --git a/tests/quantum-migration.test.cjs b/tests/quantum-migration.test.cjs @@ -85,6 +85,43 @@ test('uncertain migration submission requires a fresh preview', async () => { assert.match(ui.quantumMigrationError, /Check wallet activity/); }); +test('durability failure warns without inviting a duplicate migration', async () => { + const ui = app(); + ui.quantumMigrationPreview = { + transaction_id: '34'.repeat(32), + rate: 1, + fee: 500, + }; + ui.submitForm = async () => ({ + transaction_id: '34'.repeat(32), + remaining_legacy_utxos: 0, + persistence_error: 'disk full', + }); + + await ui.submitQuantumMigration(); + + assert.equal(ui.quantumMigrationPreview, null); + assert.match(ui.quantumMigrationError, /durable recovery failed/); +}); + +test('broadcast failure explains that the saved migration will retry', async () => { + const ui = app(); + ui.quantumMigrationPreview = { + transaction_id: '56'.repeat(32), + rate: 1, + fee: 500, + }; + ui.submitForm = async () => ({ + transaction_id: '56'.repeat(32), + remaining_legacy_utxos: 0, + broadcast_error: 'no peers', + }); + + await ui.submitQuantumMigration(); + + assert.match(ui.quantumMigrationError, /rebroadcast automatically/); +}); + test('hybrid recipients never reuse selected legacy UTXOs', () => { const ui = app(); ui.transferTo = `iuna1p${'q'.repeat(58)}`; diff --git a/www/assets/iuna-ui.js b/www/assets/iuna-ui.js @@ -2655,8 +2655,11 @@ window.iunaApp = function iunaApp() { throw new Error(`${error.message}. Check wallet activity before requesting a new preview.`); } this.quantumMigrationPreview = null; + if (result.persistence_error) { + throw new Error("Migration queued locally, but durable recovery failed. Keep this node running and check its storage before continuing."); + } if (result.broadcast_error) { - throw new Error("Migration queued locally, but broadcasting failed. Check connectivity before continuing."); + throw new Error("Migration saved locally, but broadcasting failed. It will be rebroadcast automatically; check connectivity before continuing."); } const remainder = Number(result.remaining_legacy_utxos || 0); this.showFlash(