diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs index 78f8332..a51a2d2 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs @@ -2023,9 +2023,19 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64 } // unconfirmed_spent is left as it is: a pending send can still be mined on the - // new branch. Only a block that spends the note (scan_block) or a reload clears - // it, the same as for a send that expires unmined. - }) + // new branch. Only a block that spends the note (scan_block), the send expiring + // unmined (cleanup_mempool) or a reload clears it. + }); + + // Same for transparent utxos: one whose spender was reorged out is unspent again + // (else it stays spent=Some(removed txid) and drops out of the balance until a rescan). + wtx.utxos.iter_mut() + .for_each(|utxo| { + if utxo.spent.is_some() && txids_to_remove.contains(&utxo.spent.unwrap()) { + utxo.spent = None; + } + // unconfirmed_spent is left as it is, for the same reason as on the notes. + }); }) } @@ -3007,16 +3017,47 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64 // 2. The Tx has been added to the wallet via a mined block pub fn cleanup_mempool(&self) { const DEFAULT_TX_EXPIRY_DELTA: i32 = 20; + // A send is built with Builder::new(tip) (wtx.block = tip), so its expiry_height is tip + 20 and it can + // still be mined through block tip+20 (consensus rejects it only once the height exceeds the expiry). It + // is provably dead once the chain reaches tip+21, so the pending lock on its inputs must be held until + // then — one block longer than the old drop point (tip+20), which would release the notes while the tx + // could still be mined. The point is that the lock IS released now; previously unconfirmed_spent never was. + const MEMPOOL_KEEP: i32 = DEFAULT_TX_EXPIRY_DELTA + 1; let current_height = self.blocks.read().unwrap().last().map(|b| b.height).unwrap_or(0); + let mut expired: HashSet = HashSet::new(); { - // Remove all expired Txns - self.mempool_txs.write().unwrap().retain( | _, wtx| { - current_height < (wtx.block + DEFAULT_TX_EXPIRY_DELTA) + // Remove all expired Txns, remembering which ones so their inputs can be released below. + self.mempool_txs.write().unwrap().retain( | txid, wtx| { + let keep = current_height < (wtx.block + MEMPOOL_KEEP); + if !keep { + expired.insert(txid.clone()); + } + keep }); } + if !expired.is_empty() { + // An expired send can never be mined: release the notes/utxos it locked (unconfirmed_spent). The + // mempool_txs lock above is released first, so mempool_txs and txs are never held together. + let mut txs = self.txs.write().unwrap(); + // A txid already in txs was mined; scan_block/scan_full_tx settled its inputs, so skip it. + expired.retain(|txid| !txs.contains_key(txid)); + for wtx in txs.values_mut() { + for nd in wtx.notes.iter_mut() { + if nd.unconfirmed_spent.map_or(false, |t| expired.contains(&t)) { + nd.unconfirmed_spent = None; + } + } + for utxo in wtx.utxos.iter_mut() { + if utxo.unconfirmed_spent.map_or(false, |t| expired.contains(&t)) { + utxo.unconfirmed_spent = None; + } + } + } + } + { // Remove all txns where the txid is added to the wallet directly self.mempool_txs.write().unwrap().retain ( |txid, _| { diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/data.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/data.rs index b5c64a2..3b3ab12 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/data.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/data.rs @@ -94,7 +94,6 @@ pub struct SaplingNoteData { pub unconfirmed_spent: Option, // If this note was spent in a send, but has not yet been confirmed. pub memo: Option, pub is_change: bool, - // TODO: We need to remove the unconfirmed_spent (i.e., set it to None) if the Tx has expired } diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/oracle_tests.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/oracle_tests.rs index 3d572e7..5cd845a 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/oracle_tests.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet/oracle_tests.rs @@ -987,7 +987,28 @@ impl Sim { let ids: HashSet = txs.iter().map(|g| g.txid()).collect(); // cleanup_mempool also drops a send that `txs` holds (the mempool monitor may have recorded it). let rel = &self.rel; - self.mempool.retain(|t, b| !ids.contains(t) && !rel.contains_key(t) && h < *b + TX_EXPIRY_DELTA); + // Mirror cleanup_mempool: it keeps a send for MEMPOOL_KEEP (= TX_EXPIRY_DELTA + 1, i.e. one block past the + // tx's expiry height, when it is provably dead) past the sending tip, and for a send dropped purely by + // expiry (never mined, i.e. not in `ids`/`rel`/the wallet's `txs`) it now RELEASES the pending lock on the + // inputs. So clear `pending` for those, exactly as the wallet clears `unconfirmed_spent`. The real tx + // expiry stays TX_EXPIRY_DELTA (used at verify_tx below). + const MEMPOOL_KEEP: i32 = TX_EXPIRY_DELTA + 1; + let mut expired: HashSet = HashSet::new(); + self.mempool.retain(|t, b| { + let mined = ids.contains(t) || rel.contains_key(t); + let keep = !mined && h < *b + MEMPOOL_KEEP; + if !keep && !mined { + expired.insert(*t); + } + keep + }); + if !expired.is_empty() { + for n in self.notes.iter_mut() { + if n.pending.map_or(false, |p| expired.contains(&p)) { + n.pending = None; + } + } + } let rel_txs: Vec = txs.iter().filter(|g| self.rel.get(&g.txid()).map_or(false, |r| r.0 == h)) .cloned().collect();