From 1c11476378ed919a5234816b0273d2fc440c3d7e Mon Sep 17 00:00:00 2001 From: Thomas Zarebczan Date: Tue, 20 Oct 2026 13:40:45 -0400 Subject: [PATCH 1/3] Keep a saved send mined when its transaction is in the scanned block. Expiry was releasing that send when no note decrypted, so a mined payment showed as expired and its inputs became spendable again. --- crates/z-engine/src/web/mod.rs | 87 ++++++++++++++++++++++++++++++++- crates/z-engine/src/web/scan.rs | 36 +++++++++----- 2 files changed, 109 insertions(+), 14 deletions(-) diff --git a/crates/z-engine/src/web/mod.rs b/crates/z-engine/src/web/mod.rs index 59d96a0..3801ec4 100644 --- a/crates/z-engine/src/web/mod.rs +++ b/crates/z-engine/src/web/mod.rs @@ -131,11 +131,12 @@ pub(crate) fn scan_address_fixture_structure(w: &mut WebWallet) { #[cfg(test)] mod tests { + use super::store::{TrackedNote, TxAgg}; use super::*; use crate::keys::{account_from_mnemonic, REGTEST_FAUCET_MNEMONIC}; use crate::Network; use prost::Message; - use zcash_client_backend::proto::compact_formats::{ChainMetadata, CompactBlock}; + use zcash_client_backend::proto::compact_formats::{ChainMetadata, CompactBlock, CompactTx}; fn empty_block(height: u64, prev_hash: Vec) -> CompactBlock { let mut hash = vec![0u8; 32]; @@ -518,6 +519,90 @@ mod tests { assert_eq!(h[0].fee_zat, Some(1)); } + /// A saved send whose id is in the scanned block is mined. Expiry must not + /// release its note when that block is the expiry height and no nullifier matched. + #[test] + fn saved_send_in_a_scanned_block_stays_mined_at_expiry() { + for blob in [false, true] { + let acct = account_from_mnemonic(REGTEST_FAUCET_MNEMONIC, Network::Regtest, 0).unwrap(); + let mut w = WebWallet::from_account(acct, 1).unwrap(); + let b1 = empty_block(1, vec![0u8; 32]); + let b2 = empty_block(2, b1.hash.clone()); + w.apply_compact_block(&b1.encode_to_vec()).unwrap(); + w.apply_compact_block(&b2.encode_to_vec()).unwrap(); + let included = "ab".repeat(32); + let missed = "cd".repeat(32); + for txid in [&included, &missed] { + w.txs_mut().insert( + txid.clone(), + TxAgg { + spent_zat: 50_000, + spent_notes: 1, + fee_zat: Some(10_000), + expiry_height: Some(3), + ..TxAgg::default() + }, + ); + w.notes_mut().push(TrackedNote { + pool: "ironwood".into(), + txid: "11".repeat(32), + output_index: if txid == &included { 0 } else { 1 }, + value_zat: 50_000, + nf: txid.clone(), + is_change: false, + spent: true, + spent_in: Some(txid.clone()), + spent_height: None, + mined_height: 1, + }); + } + let mut block = empty_block(3, b2.hash.clone()); + block.time = 1_700_000_003; + block.vtx.push(CompactTx { + index: 1, + txid: from_hex(&included).unwrap(), + ..CompactTx::default() + }); + if blob { + w.apply_compact_blocks_blob(&encode_delimited([block])) + .unwrap(); + } else { + w.apply_compact_block(&block.encode_to_vec()).unwrap(); + } + + let shown = WebWallet::display_txid(&included); + let row = w + .history(10) + .into_iter() + .find(|entry| entry.txid == shown) + .expect("included send"); + assert_eq!(row.mined_height, Some(3), "blob={blob}"); + assert_eq!(row.block_time, Some(1_700_000_003), "blob={blob}"); + assert!(!row.expired_unmined, "blob={blob}"); + assert_eq!(row.status(), "mined", "blob={blob}"); + let reserved = w + .notes() + .iter() + .find(|note| note.spent_in.as_deref() == Some(included.as_str())) + .expect("mined send keeps its note"); + assert!(reserved.spent, "blob={blob}"); + + let expired = w + .history(10) + .into_iter() + .find(|entry| entry.txid == WebWallet::display_txid(&missed)) + .expect("unmined send"); + assert!(expired.expired_unmined, "blob={blob}"); + assert_eq!(expired.status(), "expired", "blob={blob}"); + assert!( + w.notes() + .iter() + .all(|note| note.spent_in.as_deref() != Some(missed.as_str())), + "blob={blob}" + ); + } + } + #[test] fn history_includes_transparent_receive_and_pool_fields() { let acct = account_from_mnemonic(REGTEST_FAUCET_MNEMONIC, Network::Regtest, 0).unwrap(); diff --git a/crates/z-engine/src/web/scan.rs b/crates/z-engine/src/web/scan.rs index 0b011fc..ad2e37a 100644 --- a/crates/z-engine/src/web/scan.rs +++ b/crates/z-engine/src/web/scan.rs @@ -320,11 +320,12 @@ impl WebWallet { self.append_sapling(height, &[])?; self.append_orchard(height, &[])?; self.append_ironwood(height, &[])?; + // Inclusion has to land before set_scanned releases expired spends. + self.note_included(height, block_time, &block_txids); self.set_scanned( height, Self::make_prior(height, hash32(&block.hash), sap, orch, iron), )?; - self.stamp_block_time(block_time, &block_txids); return Ok(ScanDelta { height, notes_found: 0, @@ -356,11 +357,14 @@ impl WebWallet { let orchard_sz = scanned.orchard().final_tree_size(); let ironwood_sz = scanned.ironwood().final_tree_size(); let hash = scanned.block_hash(); + // A saved send is mined when its id is in this block, even if no note + // of ours decrypted. Expiry is only 40 blocks later; missing the link + // shows that mined send as expired once the chain passes it. + self.note_included(height, block_time, &block_txids); self.set_scanned( height, Self::make_prior(height, hash, sapling_sz, orchard_sz, ironwood_sz), )?; - self.stamp_block_time(block_time, &block_txids); self.retain_enhance_candidates(enhance); Ok(ScanDelta { @@ -370,15 +374,21 @@ impl WebWallet { }) } - fn stamp_block_time(&mut self, time: u32, txids: &[String]) { - if time == 0 { - return; - } + /// Record that a transaction we already track was in this block. + /// + /// Nullifier matching and trial decryption also set `mined_height`, but + /// either can miss a send we built. `set_scanned` then treats it as + /// expired once `expiry_height` is reached and releases its inputs. + fn note_included(&mut self, height: u32, time: u32, txids: &[String]) { for txid in txids { - if let Some(agg) = self.txs_mut().get_mut(txid) { - if agg.block_time.is_none() { - agg.block_time = Some(time); - } + let Some(agg) = self.txs_mut().get_mut(txid) else { + continue; + }; + if agg.mined_height.is_none() { + agg.mined_height = Some(height); + } + if time != 0 && agg.block_time.is_none() { + agg.block_time = Some(time); } } } @@ -483,7 +493,7 @@ impl WebWallet { orch, iron, shielded: false, - txids: Vec::new(), + txids: block.vtx.iter().map(|t| to_hex(&t.txid)).collect(), spends: Vec::new(), enhance: Vec::new(), }); @@ -509,6 +519,7 @@ impl WebWallet { for (txid, nf) in &head.spends { spends_found += self.mark_spent(nf, txid, head.height); } + self.note_included(head.height, head.time, &head.txids); match sc { DecryptOut::Empty { hash, @@ -524,7 +535,6 @@ impl WebWallet { ironwood_sz, true, )?; - self.stamp_block_time(head.time, &head.txids); self.retain_enhance_candidates(head.enhance.clone()); out.push(ScanDelta { height: head.height, @@ -564,7 +574,6 @@ impl WebWallet { sc.ironwood().final_tree_size(), ), )?; - self.stamp_block_time(head.time, &head.txids); self.retain_enhance_candidates(head.enhance.clone()); out.push(ScanDelta { height: head.height, @@ -591,6 +600,7 @@ impl WebWallet { let is_last = i + 1 == run.len(); let checkpoint = is_last || h.height.saturating_sub(last_cp) >= crate::scan::CHECKPOINT_EVERY; + self.note_included(h.height, h.time, &h.txids); self.finish_empty(h.height, &h.hash, h.sap, h.orch, h.iron, checkpoint)?; if checkpoint { last_cp = h.height; From 6858bfa4c444ff01af699b24848d88ec2103f7a8 Mon Sep 17 00:00:00 2001 From: Thomas Zarebczan Date: Tue, 20 Oct 2026 13:40:45 -0400 Subject: [PATCH 2/3] Remove an expired unmined payment from wallet history. Confirmed and still-pending payments stay. A payment whose output another saved payment spends stays too. Hardware rollback still uses the broader unmined abandon. --- crates/z-engine/src/native/bridge.rs | 20 ++ crates/z-engine/src/native/wallet.rs | 236 ++++++++++++++++++++++++ crates/z-engine/src/web/store.rs | 60 ++++++ crates/z-engine/src/web/store_tests.rs | 69 +++++++ crates/z-wasm/src/lib.rs | 6 + docs/SDK.md | 1 + packages/sdk/src/engine.ts | 25 +++ packages/sdk/src/scan-host.ts | 12 ++ packages/sdk/src/scan.worker.ts | 8 + packages/sdk/src/wasm-client-runtime.ts | 1 + packages/sdk/src/wasm-client.ts | 15 ++ packages/sdk/src/wasm-shim.d.ts | 2 + 12 files changed, 455 insertions(+) diff --git a/crates/z-engine/src/native/bridge.rs b/crates/z-engine/src/native/bridge.rs index d76b2ab..f296757 100644 --- a/crates/z-engine/src/native/bridge.rs +++ b/crates/z-engine/src/native/bridge.rs @@ -40,6 +40,11 @@ struct ShieldBody { threshold: Option, } +#[derive(Debug, Deserialize, Default)] +struct TxidBody { + txid: Option, +} + #[derive(Debug, Deserialize, Default)] struct MaxSendBody { to: Option, @@ -562,6 +567,21 @@ impl Bridge { }) .await } + ("POST", "/history/remove") => { + let body: TxidBody = serde_json::from_slice(if req.body.is_empty() { + b"{}" + } else { + &req.body + }) + .unwrap_or_default(); + let txid = body.txid.unwrap_or_default(); + let dir = self.wallet.clone(); + self.locked(async move { + let w = NativeWallet::open(&dir)?; + Ok(json!({ "removed": w.abandon_expired(&txid)? })) + }) + .await + } ("GET", "/tip") => { let dir = self.wallet.clone(); self.locked(async move { diff --git a/crates/z-engine/src/native/wallet.rs b/crates/z-engine/src/native/wallet.rs index 3169890..0ec39d7 100644 --- a/crates/z-engine/src/native/wallet.rs +++ b/crates/z-engine/src/native/wallet.rs @@ -3107,6 +3107,92 @@ impl NativeWallet { Ok(self.query_history(1, None, Some(txid))?.into_iter().next()) } + /// Delete one expired, unmined history row. + /// + /// Confirmed and still-pending payments stay. A missing id returns + /// `Ok(false)`. An output of this payment that another saved transaction + /// spends is left in place. + pub fn abandon_expired(&self, txid: &str) -> Result { + // Public ids are display order. Convert once; looking the display + // string up as the stored blob would select a different transaction. + let id = TxId::from_hex(txid.trim()) + .ok_or_else(|| EngineError::Message("invalid_txid".into()))?; + let _lease = database_lease::shared(&self.paths.data_db)?; + let Some(entry) = self.transaction(&id.to_string())? else { + return Ok(false); + }; + if entry.mined_height.is_some() || !entry.expired_unmined { + return Err(EngineError::Message("history_remove_refused".into())); + } + let mut conn = rusqlite::Connection::open(&self.paths.data_db) + .map_err(|e| EngineError::WalletDb(format!("history remove open: {e}")))?; + // Cascade must be enabled before BEGIN. It drops this payment's spend + // rows and its unconfirmed change, and leaves the notes it spent. + conn.pragma_update(None, "foreign_keys", true) + .map_err(|e| EngineError::WalletDb(format!("history remove foreign keys: {e}")))?; + let tx = conn + .transaction() + .map_err(|e| EngineError::WalletDb(format!("history remove begin: {e}")))?; + // Same predicate as `v_transactions.expired_unmined`: chain tip is + // MAX(blocks.height), not the filled scan island. A row that mined + // between the read above and this write stays. + let id_tx = match tx.query_row( + "SELECT id_tx FROM transactions + WHERE txid = ?1 + AND mined_height IS NULL + AND expiry_height BETWEEN 1 AND (SELECT COALESCE(MAX(height), 0) FROM blocks)", + [id.as_ref()], + |row| row.get::<_, i64>(0), + ) { + Ok(id_tx) => id_tx, + Err(rusqlite::Error::QueryReturnedNoRows) => { + return Err(EngineError::Message("history_remove_refused".into())); + } + Err(e) => { + return Err(EngineError::WalletDb(format!("history remove lookup: {e}"))); + } + }; + let spent_elsewhere: i64 = tx + .query_row( + "SELECT ( + SELECT COUNT(*) FROM sapling_received_notes n + JOIN sapling_received_note_spends s ON s.sapling_received_note_id = n.id + WHERE n.transaction_id = ?1 AND s.transaction_id != ?1 + ) + ( + SELECT COUNT(*) FROM orchard_received_notes n + JOIN orchard_received_note_spends s ON s.orchard_received_note_id = n.id + WHERE n.transaction_id = ?1 AND s.transaction_id != ?1 + ) + ( + SELECT COUNT(*) FROM ironwood_received_notes n + JOIN ironwood_received_note_spends s ON s.ironwood_received_note_id = n.id + WHERE n.transaction_id = ?1 AND s.transaction_id != ?1 + ) + ( + SELECT COUNT(*) FROM transparent_received_outputs n + JOIN transparent_received_output_spends s + ON s.transparent_received_output_id = n.id + WHERE n.transaction_id = ?1 AND s.transaction_id != ?1 + )", + [id_tx], + |row| row.get(0), + ) + .map_err(|e| EngineError::WalletDb(format!("history remove spends: {e}")))?; + if spent_elsewhere != 0 { + return Err(EngineError::Message("history_remove_spent".into())); + } + let removed = tx + .execute( + "DELETE FROM transactions WHERE id_tx = ?1 AND mined_height IS NULL", + [id_tx], + ) + .map_err(|e| EngineError::WalletDb(format!("history remove delete: {e}")))?; + if removed != 1 { + return Err(EngineError::Message("history_remove_refused".into())); + } + tx.commit() + .map_err(|e| EngineError::WalletDb(format!("history remove commit: {e}")))?; + Ok(true) + } + /// `status` filters before `LIMIT`, so a pending row is not pushed out by mined history. pub fn query_history( &self, @@ -5984,6 +6070,156 @@ mod tests { assert_eq!(pending[0].1, vec![1u8; 4]); } + #[test] + fn abandon_expired_removes_only_an_expired_unmined_send() { + let dir = tempfile::tempdir().unwrap(); + let wallet = fixture_wallet(dir.path()); + let (ufvk, birthday) = fixture_account(); + wallet.replace_scan_db(&ufvk, &birthday).unwrap(); + let conn = rusqlite::Connection::open(&wallet.paths.data_db).unwrap(); + conn.execute_batch( + "INSERT INTO blocks (height, hash, time, sapling_tree) VALUES (100, zeroblob(32), 0, X'000000')", + ) + .unwrap(); + let parent = [1u8; 32]; + let expired = [2u8; 32]; + let pending = [3u8; 32]; + let mined = [4u8; 32]; + let expired_spent = [6u8; 32]; + let later = [5u8; 32]; + let note_a = [0x11u8; 32]; + let note_b = [0x12u8; 32]; + let note_c = [0x13u8; 32]; + let change = [0x21u8; 32]; + let spent_change = [0x31u8; 32]; + let mined_note = [0x41u8; 32]; + let insert_tx = |id: &[u8; 32], mined_height: Option, expiry: u32| { + assert_eq!( + conn.execute( + "INSERT INTO transactions (txid, mined_height, expiry_height, min_observed_height) + VALUES (?1, ?2, ?3, 1)", + rusqlite::params![id.as_slice(), mined_height, expiry], + ) + .unwrap(), + 1 + ); + }; + let insert_note = |txid: &[u8; 32], + output: i64, + value: i64, + nf: &[u8; 32], + is_change: i64| { + assert_eq!( + conn.execute( + "INSERT INTO sapling_received_notes + (transaction_id, output_index, account_id, diversifier, value, rcm, nf, is_change) + SELECT id_tx, ?2, (SELECT id FROM accounts LIMIT 1), zeroblob(11), ?3, zeroblob(32), ?4, ?5 + FROM transactions WHERE txid = ?1", + rusqlite::params![txid.as_slice(), output, value, nf.as_slice(), is_change], + ) + .unwrap(), + 1 + ); + }; + let spend = |nf: &[u8; 32], spender: &[u8; 32]| { + assert_eq!( + conn.execute( + "INSERT INTO sapling_received_note_spends (sapling_received_note_id, transaction_id) + SELECT n.id, t.id_tx + FROM sapling_received_notes n, transactions t + WHERE n.nf = ?1 AND t.txid = ?2", + rusqlite::params![nf.as_slice(), spender.as_slice()], + ) + .unwrap(), + 1 + ); + }; + let show = |id: &[u8; 32]| TxId::from_bytes(*id).to_string(); + let notes = |nf: &[u8; 32]| -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM sapling_received_notes WHERE nf = ?1", + [nf.as_slice()], + |row| row.get(0), + ) + .unwrap() + }; + let spends = |nf: &[u8; 32]| -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM sapling_received_note_spends s + JOIN sapling_received_notes n ON n.id = s.sapling_received_note_id + WHERE n.nf = ?1", + [nf.as_slice()], + |row| row.get(0), + ) + .unwrap() + }; + + insert_tx(&parent, Some(50), 90); + insert_tx(&expired, None, 80); + insert_tx(&pending, None, 200); + insert_tx(&mined, Some(60), 40); + insert_tx(&expired_spent, None, 70); + insert_tx(&later, None, 180); + insert_note(&parent, 0, 50_000, ¬e_a, 0); + insert_note(&parent, 1, 40_000, ¬e_b, 0); + insert_note(&parent, 2, 30_000, ¬e_c, 0); + insert_note(&expired, 0, 1_000, &change, 1); + insert_note(&expired_spent, 0, 500, &spent_change, 1); + insert_note(&mined, 0, 2_000, &mined_note, 0); + spend(¬e_a, &expired); + spend(¬e_b, &pending); + spend(¬e_c, &expired_spent); + spend(&spent_change, &later); + + let expired_id = show(&expired); + assert_eq!( + wallet.transaction(&expired_id).unwrap().unwrap().status(), + "expired" + ); + assert_eq!( + wallet + .transaction(&show(&pending)) + .unwrap() + .unwrap() + .status(), + "pending" + ); + assert_eq!( + wallet.transaction(&show(&mined)).unwrap().unwrap().status(), + "mined" + ); + + assert!(wallet.abandon_expired(&expired_id).unwrap()); + assert!(wallet.transaction(&expired_id).unwrap().is_none()); + assert_eq!(notes(¬e_a), 1); + assert_eq!(spends(¬e_a), 0); + assert_eq!(notes(&change), 0); + assert!(wallet.transaction(&show(&pending)).unwrap().is_some()); + assert!(!wallet.abandon_expired(&expired_id).unwrap()); + + let pending_err = wallet.abandon_expired(&show(&pending)).unwrap_err(); + assert_eq!(pending_err.to_string(), "history_remove_refused"); + assert!(wallet.transaction(&show(&pending)).unwrap().is_some()); + let mined_err = wallet.abandon_expired(&show(&mined)).unwrap_err(); + assert_eq!(mined_err.to_string(), "history_remove_refused"); + assert!(wallet.transaction(&show(&mined)).unwrap().is_some()); + assert_eq!( + wallet + .abandon_expired("not-a-txid") + .unwrap_err() + .to_string(), + "invalid_txid" + ); + assert!(!wallet.abandon_expired(&show(&[9u8; 32])).unwrap()); + + let spent_err = wallet.abandon_expired(&show(&expired_spent)).unwrap_err(); + assert_eq!(spent_err.to_string(), "history_remove_spent"); + assert!(wallet.transaction(&show(&expired_spent)).unwrap().is_some()); + assert!(wallet.transaction(&show(&later)).unwrap().is_some()); + assert_eq!(notes(&spent_change), 1); + assert_eq!(notes(¬e_c), 1); + } + #[test] fn a_node_that_already_has_the_transaction_counts_as_delivered() { for message in [ diff --git a/crates/z-engine/src/web/store.rs b/crates/z-engine/src/web/store.rs index 5413df3..2c7347e 100644 --- a/crates/z-engine/src/web/store.rs +++ b/crates/z-engine/src/web/store.rs @@ -3152,6 +3152,66 @@ impl WebWallet { Ok(true) } + /// Delete one expired, unmined activity row. + /// + /// `abandon_unmined` remains the hardware-rollback primitive and can drop a + /// still-pending send. This path is the activity control: confirmed and + /// pending rows stay, and so does a payment whose output another saved + /// payment spends. + pub fn abandon_expired(&mut self, txid: &str) -> Result { + let id = TxId::from_hex(txid.trim()) + .ok_or_else(|| EngineError::Message("invalid_txid".into()))?; + let wire = to_hex(id.as_ref()); + let Some(agg) = self.txs.get(&wire) else { + return Ok(false); + }; + if agg.mined_height.is_some() + || !self.expired_unmined(agg) + || self.recorded_as_mined_spender(&wire) + { + return Err(EngineError::Message("history_remove_refused".into())); + } + if self.payment_output_spent_elsewhere(&wire) { + return Err(EngineError::Message("history_remove_spent".into())); + } + self.abandon_unmined(&id.to_string()) + } + + fn recorded_as_mined_spender(&self, txid: &str) -> bool { + self.txs.values().any(|agg| { + agg.accounted_transparent_receipts.values().any(|receipt| { + receipt.spent_in.as_ref().is_some_and(|spent| { + spent.txid == txid && spent.height.is_some_and(|height| height > 0) + }) + }) + }) + } + + fn payment_output_spent_elsewhere(&self, txid: &str) -> bool { + let spent_by_other = |created: &str, spender: Option<&str>| { + created == txid && spender.is_some_and(|spender| spender != txid) + }; + self.notes + .iter() + .any(|note| spent_by_other(¬e.txid, note.spent_in.as_deref())) + || self + .spend_notes + .iter() + .any(|note| spent_by_other(¬e.txid, note.spent_in.as_deref())) + || self + .utxos + .iter() + .any(|utxo| spent_by_other(&utxo.txid, utxo.spent_in.as_deref())) + || self.txs.get(txid).is_some_and(|agg| { + agg.accounted_transparent_receipts.values().any(|receipt| { + receipt + .spent_in + .as_ref() + .is_some_and(|spent| spent.txid != txid) + }) + }) + } + pub(crate) fn prior_metadata(&self) -> Result> { let Some(p) = &self.prior else { return Ok(None); diff --git a/crates/z-engine/src/web/store_tests.rs b/crates/z-engine/src/web/store_tests.rs index 0e3ee38..aebf5bc 100644 --- a/crates/z-engine/src/web/store_tests.rs +++ b/crates/z-engine/src/web/store_tests.rs @@ -512,6 +512,75 @@ fn abandon_resolves_canonical_ids_once_without_reverse_alias_fallback() { assert!(w.abandon_unmined("invalid transaction id").is_err()); } +#[test] +fn abandon_expired_keeps_pending_mined_and_spent_outputs() { + let id = TxId::from_bytes([7u8; 32]); + let public_id = id.to_string(); + let wire = to_hex(id.as_ref()); + let mut w = wallet(); + w.scanned_height = 10; + w.txs.insert( + wire.clone(), + TxAgg { + expiry_height: Some(10), + ..TxAgg::default() + }, + ); + w.notes.push(TrackedNote { + pool: "orchard".into(), + txid: wire.clone(), + output_index: 0, + value_zat: 1, + nf: "aa".repeat(32), + is_change: true, + spent: true, + spent_in: Some("ab".repeat(32)), + spent_height: None, + mined_height: 1, + }); + assert_eq!( + w.abandon_expired(&public_id).unwrap_err().to_string(), + "history_remove_spent" + ); + assert!(w.txs.contains_key(&wire)); + assert!(w.notes[0].spent); + + w.notes.clear(); + assert!(w.abandon_expired(&public_id).unwrap()); + assert!(!w.txs.contains_key(&wire)); + + w.txs.insert( + wire.clone(), + TxAgg { + expiry_height: Some(11), + ..TxAgg::default() + }, + ); + assert_eq!( + w.abandon_expired(&public_id).unwrap_err().to_string(), + "history_remove_refused" + ); + assert!(w.txs.contains_key(&wire)); + + w.txs.insert( + wire.clone(), + TxAgg { + mined_height: Some(4), + expiry_height: Some(1), + ..TxAgg::default() + }, + ); + assert_eq!( + w.abandon_expired(&public_id).unwrap_err().to_string(), + "history_remove_refused" + ); + assert!(!w.abandon_expired(&"12".repeat(32)).unwrap()); + assert_eq!( + w.abandon_expired("nope").unwrap_err().to_string(), + "invalid_txid" + ); +} + /// Real Sapling note encryption, with inert proof/signature bytes: enhancement /// parses/decrypts transactions and deliberately does not validate consensus. fn legacy_sapling_memo(w: &WebWallet, mined: u32, memo: &str) -> Vec { diff --git a/crates/z-wasm/src/lib.rs b/crates/z-wasm/src/lib.rs index 7f5c34c..4cf73bb 100644 --- a/crates/z-wasm/src/lib.rs +++ b/crates/z-wasm/src/lib.rs @@ -749,6 +749,12 @@ impl WasmWallet { self.inner.abandon_unmined(txid).map_err(js_err) } + /// Activity removal. Still-pending sends stay; hardware rollback uses `abandon`. + #[wasm_bindgen(js_name = abandonExpired)] + pub fn abandon_expired(&mut self, txid: &str) -> Result { + self.inner.abandon_expired(txid).map_err(js_err) + } + /// Apply a proved raw tx as the next compact block (pending → mined). #[wasm_bindgen(js_name = applyMinedTx)] pub fn apply_mined_tx(&mut self, hex: &str, time: u32) -> Result { diff --git a/docs/SDK.md b/docs/SDK.md index 83a01a1..1a081b8 100644 --- a/docs/SDK.md +++ b/docs/SDK.md @@ -105,6 +105,7 @@ they can be cached forever. | `wallet.sync()` | Catches up to the chain tip. Progress arrives through `on("sync")`, and `onProgress` gives scan detail. | | `wallet.getWallet()` | Returns the snapshot: addresses, per-pool balances, scanned height and recent history. | | `wallet.history(limit?)` | Returns transactions, newest first. `classifyHistory(entry)` gives the action (sent, received, shielding, …) and the display amount. Classify at read time; do not persist the result (`type`), since the engine refines it as it learns more. | +| `wallet.abandonExpired(txid)` | Removes one expired, unmined payment from activity and persists that change. Confirmed payments, still-pending payments, and a payment whose output another saved payment spends stay. Returns false when the wallet has no such transaction. | | `wallet.maxSend(to?)` / `wallet.estimateFee(to, amount?)` | Gives the ZIP-317 fee from the engine's current proposal. Ordinary send builds a fresh proposal; this estimate is not a binding fee cap. | | `wallet.send(to, amountZec, memo?)` | Sends to a unified address or a `zcash:` ZIP-321 URI (multi-recipient). Returns `{ …snapshot, txid }`. | | `wallet.supportsTransparentSend()` | Probes the loaded browser engine for explicit transparent swap-output support; it does not prove ownership, funds or signing readiness. Hardware wallets return false; the native client does not expose this method. | diff --git a/packages/sdk/src/engine.ts b/packages/sdk/src/engine.ts index 31b7194..96b7df2 100644 --- a/packages/sdk/src/engine.ts +++ b/packages/sdk/src/engine.ts @@ -226,6 +226,12 @@ export type EngineClient = { history: (limit?: number, query?: HistoryQuery) => Promise; /** One row, or null when the wallet has no such txid. */ transaction: (txid: string) => Promise; + /** + * Remove one expired, unmined payment from activity. + * Confirmed and still-pending payments stay. + * Returns false when the wallet has no such transaction. + */ + abandonExpired: (txid: string) => Promise; /** Unmined rows that have not expired (`status=pending`). */ pending: (limit?: number) => Promise; tip: () => Promise; @@ -476,6 +482,25 @@ export function createEngineClient(baseUrl: string, opts?: EngineClientOpts): En throw err; } }, + abandonExpired: async (txid) => { + const id = txid.trim(); + if (!/^[0-9a-fA-F]{64}$/.test(id)) { + throw new WalletError("unknown", "That transaction id is not valid."); + } + const listed = await call<{ transactions: HistoryEntry[] }>( + `/history?${new URLSearchParams({ limit: "1", txid: id })}`, + ); + const row = listed.transactions?.[0]; + if (!row) return false; + if (row.status !== "expired") { + throw new WalletError("unknown", "Only an expired payment can be removed."); + } + const removed = await call<{ removed: boolean }>("/history/remove", { + method: "POST", + body: JSON.stringify({ txid: id }), + }); + return removed.removed === true; + }, pending: (limit = 50) => { const q = new URLSearchParams({ limit: String(limit), status: "pending" }); return call<{ transactions: HistoryEntry[] }>(`/history?${q}`).then((r) => r.transactions ?? []); diff --git a/packages/sdk/src/scan-host.ts b/packages/sdk/src/scan-host.ts index efdefdb..998ae88 100644 --- a/packages/sdk/src/scan-host.ts +++ b/packages/sdk/src/scan-host.ts @@ -81,6 +81,8 @@ export type ScanSession = PrivateEnhanceHost & PrivateTransparentHost & { persistenceRevision?: () => number | undefined; history: (limit: number) => Promise; historyQuery: (limit: number, status: string, txid: string) => Promise; + /** Delete one history row. Callers must already know it is expired. */ + abandon: (txid: string) => Promise; /** Raw hex of unmined, unexpired sends, for an idempotent rebroadcast. */ pendingRawTxs: () => Promise; pendingSubmissions: () => Promise; @@ -144,6 +146,7 @@ type WalletHandle = HardwareWalletHandle & { toSnapshot: () => Uint8Array; history: (limit: number) => string; historyQuery?: (limit: number, status: string, txid: string) => string; + abandonExpired?: (txid: string) => boolean; pendingRawTxs?: () => string; pendingSubmissions?: () => string; scannedHeight: () => number; @@ -367,6 +370,11 @@ export function localScanSession( if (typeof cur.historyQuery !== "function") throw new Error("this wasm build cannot query complete transaction history"); return cur.historyQuery(limit, status, txid); }, + abandon: async (txid) => { + const cur = w(); + if (typeof cur.abandonExpired !== "function") throw new Error("this wasm build cannot remove an expired payment"); + return cur.abandonExpired(txid); + }, pendingRawTxs: async () => parseRawTxs(w().pendingRawTxs?.()), pendingSubmissions: async () => parsePendingSubmissions(w().pendingSubmissions?.()), scannedHeight: async () => w().scannedHeight(), @@ -942,6 +950,10 @@ export function workerScanSession(): ScanSession | null { const data = await sessionRpc("historyQuery", { limit, status, txid }); return String(data.json ?? "[]"); }, + abandon: async (txid) => { + const data = await sessionRpc("abandon", { txid }); + return data.removed === true; + }, scannedHeight: async () => { const data = await sessionRpc("meta"); return Number(data.scanned) || 0; diff --git a/packages/sdk/src/scan.worker.ts b/packages/sdk/src/scan.worker.ts index 68ca92e..1d9ba20 100644 --- a/packages/sdk/src/scan.worker.ts +++ b/packages/sdk/src/scan.worker.ts @@ -67,6 +67,7 @@ type Wallet = HardwareWalletHandle & { toSnapshot: () => Uint8Array; history: (limit: number) => string; historyQuery?: (limit: number, status: string, txid: string) => string; + abandonExpired?: (txid: string) => boolean; pendingRawTxs?: () => string; pendingSubmissions?: () => string; scannedHeight: () => number; @@ -505,6 +506,13 @@ async function handle(msg: Req): Promise { post({ id: msg.id, json: needWallet().history(msg.limit ?? 50) }); return; } + case "abandon": { + const w = needWallet(); + // Hardware rollback still calls `abandon` for a pending send. + if (typeof w.abandonExpired !== "function") throw new Error("this wasm build cannot remove an expired payment"); + post({ id: msg.id, removed: w.abandonExpired(msg.txid ?? "") }); + return; + } case "meta": { const w = needWallet(); post({ diff --git a/packages/sdk/src/wasm-client-runtime.ts b/packages/sdk/src/wasm-client-runtime.ts index 894c7c3..41594ab 100644 --- a/packages/sdk/src/wasm-client-runtime.ts +++ b/packages/sdk/src/wasm-client-runtime.ts @@ -224,6 +224,7 @@ export type WasmWalletHandle = { rewindTo?: (height: number) => number; rewindToChecked?: (height: number) => number; abandon?: (txid: string) => boolean; + abandonExpired?: (txid: string) => boolean; applyMinedTx?: (hex: string, time: number) => string; attachSeed?: (mnemonic: string) => void; recomputePools?: () => void; diff --git a/packages/sdk/src/wasm-client.ts b/packages/sdk/src/wasm-client.ts index 8fd6e72..bd89e22 100644 --- a/packages/sdk/src/wasm-client.ts +++ b/packages/sdk/src/wasm-client.ts @@ -1032,6 +1032,21 @@ export function createWasmClient( const rows = await loadHistory(1, { txid }); return rows[0] ?? null; }, + abandonExpired: async (txid) => { + const id = txid.trim(); + if (!/^[0-9a-fA-F]{64}$/.test(id)) { + throw new WalletError("unknown", "That transaction id is not valid."); + } + if (!currentSession() && !(await loadIfNeeded())) return false; + const row = (await loadHistory(1, { txid: id }))[0]; + if (!row) return false; + if (row.status !== "expired") { + throw new WalletError("unknown", "Only an expired payment can be removed."); + } + const removed = await session!.abandon(id); + if (removed) await persist(); + return removed; + }, pending: (limit = 50) => loadHistory(limit, { status: "pending" }), tip: async () => { const operation = captureWalletOperation(); diff --git a/packages/sdk/src/wasm-shim.d.ts b/packages/sdk/src/wasm-shim.d.ts index 3603855..3843752 100644 --- a/packages/sdk/src/wasm-shim.d.ts +++ b/packages/sdk/src/wasm-shim.d.ts @@ -66,6 +66,7 @@ declare module "./generated/z_wasm.js" { rewindTo(height: number): number; rewindToChecked(height: number): number; abandon(txid: string): boolean; + abandonExpired(txid: string): boolean; applyMinedTx(hex: string, time: number): string; toSnapshot(): Uint8Array; applyCompactBlock(bytes: Uint8Array): string; @@ -143,6 +144,7 @@ declare module "./generated-mt/z_wasm.js" { rewindTo(height: number): number; rewindToChecked(height: number): number; abandon(txid: string): boolean; + abandonExpired(txid: string): boolean; applyMinedTx(hex: string, time: number): string; toSnapshot(): Uint8Array; applyCompactBlock(bytes: Uint8Array): string; From af19f11c2a0528e8684cf50861aa31d1b319025d Mon Sep 17 00:00:00 2001 From: Thomas Zarebczan Date: Tue, 20 Oct 2026 13:52:17 -0400 Subject: [PATCH 3/3] Stamp block time when a batch scan first sees a receipt. Batch sync recorded the mined height before that receipt existed, so its history date stayed empty. Stamp inclusion after ingest and before expiry can release a send the wallet already tracked. --- crates/z-engine/src/web/mod.rs | 30 ++++++++++++++++++++++++++++++ crates/z-engine/src/web/scan.rs | 9 ++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/z-engine/src/web/mod.rs b/crates/z-engine/src/web/mod.rs index 3801ec4..dedd66e 100644 --- a/crates/z-engine/src/web/mod.rs +++ b/crates/z-engine/src/web/mod.rs @@ -435,6 +435,36 @@ mod tests { ); let pending_orchard = pending.orchard_pending; + let batch_block = w + .mined_raw_tx_block(&raw, 1_700_000_000) + .unwrap_or_else(|e| panic!("compact block for shield: {e}")); + let mut discovered = WebWallet::from_account( + account_from_mnemonic(REGTEST_FAUCET_MNEMONIC, Network::Regtest, 0).unwrap(), + 1, + ) + .unwrap(); + let d1 = empty_block(1, vec![0u8; 32]); + let d2 = empty_block(2, d1.hash.clone()); + discovered.apply_compact_block(&d1.encode_to_vec()).unwrap(); + discovered.apply_compact_block(&d2.encode_to_vec()).unwrap(); + let found = discovered + .apply_compact_blocks_blob(&encode_delimited([batch_block])) + .unwrap_or_else(|e| panic!("batch-scan discovered shield: {e}")); + assert!( + found.iter().any(|delta| delta.notes_found > 0), + "batch scan should decrypt the new receipt" + ); + let discovered_row = discovered + .history(5) + .into_iter() + .find(|entry| entry.mined_height == Some(3)) + .expect("batch-scanned receipt"); + assert_eq!( + discovered_row.block_time, + Some(1_700_000_000), + "a receipt first seen in a batch scan keeps the block time" + ); + let mined = w .apply_mined_raw_tx(&raw, 1_700_000_000) .unwrap_or_else(|e| panic!("mine pending shield: {e}")); diff --git a/crates/z-engine/src/web/scan.rs b/crates/z-engine/src/web/scan.rs index ad2e37a..fdc2113 100644 --- a/crates/z-engine/src/web/scan.rs +++ b/crates/z-engine/src/web/scan.rs @@ -519,7 +519,6 @@ impl WebWallet { for (txid, nf) in &head.spends { spends_found += self.mark_spent(nf, txid, head.height); } - self.note_included(head.height, head.time, &head.txids); match sc { DecryptOut::Empty { hash, @@ -527,6 +526,9 @@ impl WebWallet { orchard_sz, ironwood_sz, } => { + // No new receipt is created here. Stamp a send we already + // track before finish_empty can release it as expired. + self.note_included(head.height, head.time, &head.txids); self.finish_empty( head.height, &hash, @@ -564,6 +566,11 @@ impl WebWallet { self.append_sapling(head.height, sc.sapling().commitments())?; self.append_orchard(head.height, sc.orchard().commitments())?; self.append_ironwood(head.height, sc.ironwood().commitments())?; + // Ingest creates a receipt with a mined height and no + // block time. Stamp after that, and before set_scanned, + // so a send we already tracked is mined before expiry + // release and a newly decrypted receipt keeps its date. + self.note_included(head.height, head.time, &head.txids); self.set_scanned( head.height, Self::make_prior(