fix(lite-backend): don't panic when a reorg lands during a send
After broadcasting, the send path looked up the spent notes' and UTXOs' transactions with unwrap. A reorg while the transaction was being proved could remove one of them, so the backend panicked and poisoned the wallet lock although the transaction was already broadcast. The lookups are now `if let` with a warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+56
@@ -2337,3 +2337,59 @@ fn oracle_r3_monitor_entry_mined_then_reorg() {
|
||||
fn oracle_r3_monitor_entry_at_height_zero() {
|
||||
r3_case(0x3A1, |_| 0);
|
||||
}
|
||||
|
||||
/// U1 (regression test). A reorg that lands while a send is proved can remove the tx that holds a selected
|
||||
/// note. send_to_address marks its inputs pending after the broadcast and unwrapped those lookups, so it
|
||||
/// panicked with the tx already broadcast and poisoned `txs`.
|
||||
#[test]
|
||||
fn oracle_u1_reorg_during_send() {
|
||||
let v = Verifier::new();
|
||||
let mut sim = Sim::new(0x51A);
|
||||
sim.force_pay = 12;
|
||||
for _ in 0..12 {
|
||||
sim.mine(vec![], true);
|
||||
}
|
||||
let (tip, off) = sim.anchor(sim.cfg.anchor_offset);
|
||||
let fee = u64::from(DEFAULT_FEE);
|
||||
let cands: Vec<(u64, [u8; 32])> = sim.notes.iter()
|
||||
.filter(|n| n.live() && n.has_sk && n.len >= off as usize + 1).map(|n| (n.value, n.nf)).collect();
|
||||
// The newest eligible note that the selection rule spends alone for amount = its value - fee.
|
||||
let (q, vq, at) = sim.notes.iter()
|
||||
.filter(|n| n.live() && n.has_sk && n.len >= off as usize + 1 && n.value > fee)
|
||||
.filter(|n| expected_selection(&cands, n.value) == Some(vec![n.nf]))
|
||||
.max_by_key(|n| n.recv).map(|n| (n.nf, n.value, n.recv)).expect("setup: a note spent alone");
|
||||
let k = (tip - at + 1) as usize;
|
||||
assert!(k < sim.win.len());
|
||||
let anchor = Fr::from(sim.block_at(tip - off).root);
|
||||
let eligible: HashSet<[u8; 32]> = [q].iter().cloned().collect();
|
||||
let to = encode_payment_address(sim.cfg.hrp_sapling_address(), &sim.foreign[0].1);
|
||||
let (ss, so) = (SaplingParams::get("sapling-spend.params").unwrap(),
|
||||
SaplingParams::get("sapling-output.params").unwrap());
|
||||
let popped = Cell::new(0u64);
|
||||
let r = {
|
||||
let w = &sim.w;
|
||||
panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
w.send_to_address(BRANCH_ID, &ss, &so, false, vec![(to.as_str(), vq - fee, None)], &fee, |_raw| {
|
||||
popped.set(w.invalidate_block(at)); // the reorg lands between proving and the bookkeeping
|
||||
Ok(NOT_BROADCAST.to_string())
|
||||
})
|
||||
}))
|
||||
};
|
||||
assert!(r.is_ok() && !sim.w.txs.is_poisoned(),
|
||||
"U1 (regression): a reorg removed the tx of a selected note while the send was proved; \
|
||||
send_to_address panicked after the broadcast and poisoned the wallet's txs lock");
|
||||
let (_, raw) = r.unwrap().expect("the broadcast send returns Ok");
|
||||
assert_eq!(popped.get(), k as u64);
|
||||
let tx = verify_tx(&v, &raw, anchor, &eligible, fee as i64, (tip + TX_EXPIRY_DELTA) as u32, "u1");
|
||||
assert!(tx.shielded_spends.len() == 1 && tx.shielded_spends[0].nullifier == q);
|
||||
|
||||
// Reference: the reorg removed q (the send can never be mined); the wallet still records the send.
|
||||
sim.ref_invalidate(at, k);
|
||||
sim.requeue.clear();
|
||||
sim.mempool.insert(tx.txid(), tip);
|
||||
assert!(sim.w.txs.read().unwrap().values().flat_map(|w| w.notes.iter()).all(|nd| nd.unconfirmed_spent.is_none()));
|
||||
sim.check(true);
|
||||
for _ in 0..3 {
|
||||
sim.mine(vec![], true);
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -955,6 +955,41 @@ fn test_r3_mempool_seen_send_takes_mined_height() {
|
||||
assert_eq!(wallet.zbalance(None), AMOUNT1); // guard dropped first: zbalance takes txs.read itself
|
||||
}
|
||||
|
||||
/// U1 (regression test). A reorg that lands while a send is proved can remove the tx that holds a selected
|
||||
/// utxo. send_to_address marks its inputs pending after the broadcast and unwrapped that lookup, so it
|
||||
/// panicked with the tx already broadcast and poisoned the wallet's `txs` lock.
|
||||
#[test]
|
||||
fn test_u1_reorg_during_send_removes_utxo_tx() {
|
||||
let mut rng = OsRng;
|
||||
let secp = Secp256k1::new();
|
||||
let (wallet, _txid0, _hash) = get_test_wallet(0); // zero-value note at block 0: never selected
|
||||
let pk = PublicKey::from_secret_key(&secp, &wallet.tkeys.read().unwrap()[0].tkey.unwrap());
|
||||
let mut tx = FakeTransaction::new(&mut rng);
|
||||
tx.add_t_output(&pk, 50000);
|
||||
let txid1 = tx.get_tx().txid();
|
||||
wallet.scan_full_tx(&tx.get_tx(), 1, 0); // utxo tx at block 1
|
||||
assert_eq!(wallet.txs.read().unwrap()[&txid1].utxos.len(), 1);
|
||||
|
||||
let zaddr = wallet.add_zaddr();
|
||||
let branch_id = u32::from_str_radix("2bb40e60", 16).unwrap();
|
||||
let (ss, so) = get_sapling_params().unwrap();
|
||||
let popped = std::cell::Cell::new(0u64);
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
wallet.send_to_address(branch_id, &ss, &so, false, vec![(&zaddr, 20, None)], &default_fee(), |_| {
|
||||
popped.set(wallet.invalidate_block(1)); // lands between proving and the bookkeeping
|
||||
Ok(' '.to_string())
|
||||
})
|
||||
}));
|
||||
assert!(r.is_ok() && !wallet.txs.is_poisoned(),
|
||||
"U1 (regression): a reorg removed the tx of a selected utxo while the send was proved; \
|
||||
send_to_address panicked after the broadcast and poisoned the wallet's txs lock");
|
||||
let (_, raw_tx) = r.unwrap().expect("the broadcast send returns Ok");
|
||||
let sent = Transaction::read(&raw_tx[..]).unwrap();
|
||||
assert_eq!(popped.get(), 1);
|
||||
assert!(wallet.txs.read().unwrap().get(&txid1).is_none());
|
||||
assert!(wallet.mempool_txs.read().unwrap().contains_key(&sent.txid()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_self_txns_ztoz() {
|
||||
const AMOUNT1: u64 = 50000;
|
||||
|
||||
Reference in New Issue
Block a user