fix(lite-backend): record the mined height of mempool-first transactions

When the mempool monitor saw a transaction first, its wallet entry kept
the monitor's height (often 0) after the transaction was mined, because
scanning the block reused the entry without updating its block. A later
reorg between the two heights then left a phantom spend of our own
send and a change note with an empty witness list until a rescan.

Scanning a block now sets the entry's block to the block height. This
applies to every monitor-first transaction, including payments received
from others: their reported block height, the verified/spendable timing
that depends on it, and the wallet birthday now use the real mined
height instead of the monitor's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 06:05:06 -05:00
co-authored by Claude Opus 5.5
parent 24a90ae532
commit 7c0afd06a8
3 changed files with 231 additions and 12 deletions
@@ -917,6 +917,44 @@ fn test_self_txns_ttoz_nomemo() {
}
}
/// R3 (regression test). The mempool monitor (scan_full_mempool_tx) records our send in `txs` at the height
/// the server reports. The block that mines it must become its height: a reorg of that block removes the
/// send, un-spends its note and drops its change. Before the fix the entry kept the monitor's height.
#[test]
fn test_r3_mempool_seen_send_takes_mined_height() {
const AMOUNT1: u64 = 50000;
let (wallet, txid1, block_hash) = get_test_wallet(AMOUNT1); // note at block 0, tip 1
let fvk = ExtendedFullViewingKey::from(&ExtendedSpendingKey::master(&[1u8; 32]));
let ext_address = encode_payment_address(wallet.config.hrp_sapling_address(), &fvk.default_address().unwrap().1);
let branch_id = u32::from_str_radix("76b809bb", 16).unwrap();
let (ss, so) = get_sapling_params().unwrap();
let (_, raw_tx) = wallet.send_to_address(branch_id, &ss, &so, false,
vec![(&ext_address, 20, None)], &default_fee(), |_| Ok(' '.to_string())).unwrap();
let sent_tx = Transaction::read(&raw_tx[..]).unwrap();
let sent_txid = sent_tx.txid();
wallet.scan_full_mempool_tx(&sent_tx, 1, 0, true); // decrypts the change: entry at the server height 1
assert_eq!(wallet.txs.read().unwrap().get(&sent_txid).map(|t| t.block), Some(1), "setup: monitor entry");
let cb2 = FakeCompactBlock::new(2, block_hash); // not in block 2
wallet.scan_block(&cb2.as_bytes()).unwrap();
let mut cb3 = FakeCompactBlock::new(3, cb2.hash()); // mined in block 3
cb3.add_tx(&sent_tx);
wallet.scan_block(&cb3.as_bytes()).unwrap();
assert_eq!(wallet.txs.read().unwrap()[&sent_txid].block, 3,
"R3 (regression): mined at 3, but the WalletTx keeps the mempool monitor's height 1");
assert_eq!(wallet.invalidate_block(3), 1);
{
let txs = wallet.txs.read().unwrap();
assert!(txs.get(&sent_txid).is_none(), "R3 (regression): a reorg of its mining block kept the send");
assert_eq!(txs[&txid1].notes[0].spent, None, "R3 (regression): phantom spend");
assert_eq!(txs[&txid1].notes[0].unconfirmed_spent, None);
assert!(!txs[&txid1].notes[0].witnesses.is_empty());
}
assert_eq!(wallet.zbalance(None), AMOUNT1); // guard dropped first: zbalance takes txs.read itself
}
#[test]
fn test_self_txns_ztoz() {
const AMOUNT1: u64 = 50000;