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:
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(