From db8d5359a4b8eb422b22efea0248c053d875fcda Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 11 Oct 2026 06:05:06 -0500 Subject: [PATCH] fix(lite-backend): don't panic on a reorg while a send is pending invalidate_block unwrapped a note's `spent` field for notes that only had `unconfirmed_spent` set, so any reorg while one of our sends was still in the mempool panicked the backend (and poisoned the wallet lock). The branch could only panic or do nothing; it is gone, and a reorg now leaves `unconfirmed_spent` as it is: the send can still be mined on the new branch, because the chain checks the anchor's root, not its height. The oracle no longer steers reorgs around pending sends: it now reorgs above, across and below the sent tip, including popping the anchor block, and the reproducer runs by default. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/lightwallet.rs | 6 +- .../lib/src/lightwallet/oracle_tests.rs | 437 ++++++++++++++---- 2 files changed, 345 insertions(+), 98 deletions(-) diff --git a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs index 50bb3c5..3c86230 100644 --- a/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs +++ b/third_party/silentdragonxlite/silentdragonxlite-cli/lib/src/lightwallet.rs @@ -1867,9 +1867,9 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64 nd.spent = None; } - if nd.unconfirmed_spent.is_some() && txids_to_remove.contains(&nd.spent.unwrap()) { - nd.unconfirmed_spent = None; - } + // 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. }) }) } 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 d089b71..b2fbab2 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 @@ -33,16 +33,19 @@ //! signature. The first send of a run also checks that the verifier rejects a wrong anchor //! and a wrong nullifier. //! -//! Pre-existing bugs the green runs avoid, each with an `#[ignore]` reproducer below: -//! - F5a: `invalidate_block` panics while a send is pending (`nd.spent.unwrap()` on a note that -//! only has `unconfirmed_spent`). So no reorg happens while a send is pending. -//! - F5b: a send that never mines leaves its notes with a stale witness (pending notes are not -//! advanced); a reload drops `unconfirmed_spent` and exposes it as spendable. So no reload or -//! throwaway copy happens while a send is pending, and a run that leaves a send unmined only -//! mines blocks afterwards. -//! - F5b via a reorg (same root cause): once a send is mined its notes are spent, but a reorg -//! that undoes the mining block back to a height >= sent tip + 2 revives them with the stale -//! entries of the pending blocks. So reorg depths are adjusted to stay out of that window. +//! Pending sends: reorgs of any depth happen while a send is pending, above its sent tip T, +//! across it (A < at <= T, A = its anchor height) or popping its anchor block (at <= A), in one +//! call or block by block. The wallet keeps `unconfirmed_spent` until a block spends the note or +//! a reload drops it (F5a, `oracle_f5a_*`). The chain checks an anchor root, not a height, so a +//! send whose anchor block was popped may still be mined: the reference orphans it (never mined, +//! its notes stay pending) when the reorg removed one of its notes, else with probability 1/2. +//! The fake chain does not validate anchors. A run that leaves a send unmined orphans it at once. +//! +//! Pre-existing bug the green runs avoid, with `#[ignore]` reproducers below: +//! - F5b: pending notes are not advanced, so their witnesses go stale. A reload drops +//! `unconfirmed_spent` and exposes such a note as spendable; so does a reorg that undoes the +//! block that mined the send back into the stale blocks. So no reload or throwaway copy happens +//! while a note is pending, and reorg depths are adjusted to stay out of that window. //! //! Sizes: `oracle_default_*` (4 x 5 seeds x 300 blocks, three proving sends per seed); //! `#[ignore]` `oracle_full_*` (8 x 125 seeds x 400 blocks, one proving send per seed) and @@ -339,7 +342,9 @@ impl RefNote { struct Pending { gtx: GenTx, txid: TxId, - sent_tip: i32, + /// The wallet's entries above this height may be stale (F5b): the sent tip, lowered to + /// at - 1 by a reorg at `at` while pending. + stale_from: i32, } #[derive(Clone, Copy, PartialEq)] @@ -366,6 +371,13 @@ struct Stats { requeued: usize, remined: usize, full_checks: usize, + /// Reorgs while a note is pending, by `at`: above the sent tip, across it (anchor kept), + /// popping the anchor block. + pending_reorgs: [usize; 3], + /// Pending sends that will never be mined (anchor popped, or a run that leaves it unmined). + orphaned: usize, + /// Pending sends whose anchor block was popped but that are still mined. + anchor_root_reuse: usize, t_gen: f64, t_scan: f64, t_ref: f64, @@ -397,10 +409,12 @@ struct Sim { /// Our sends in the wallet's mempool: txid -> tip when sent. mempool: HashMap, pending: Option, - /// A send was left unmined: from here on only blocks are mined (F5a/F5b). - frozen: bool, - /// (tip when sent, block that first mined it) of our mined sends: a reorg to a height in - /// (sent + 1, mined] would revive their notes with stale witnesses (F5b via a reorg). + /// (txid, tip when sent, anchor height) of the last real send. + last_send: Option<(TxId, i32, i32)>, + /// Overrides the coin flip that orphans a pending send whose anchor block was popped. + force_orphan: Option, + /// (stale_from, block that first mined it) of our mined sends: a reorg to a height in + /// (stale_from + 1, mined] would revive their notes with stale witnesses (F5b via a reorg). pending_windows: Vec<(i32, i32)>, /// Blocks still to be paid to us (setup helper). force_pay: usize, @@ -482,7 +496,8 @@ impl Sim { requeue: vec![], mempool: HashMap::new(), pending: None, - frozen: false, + last_send: None, + force_orphan: None, pending_windows: vec![], force_pay: 0, force_output: false, @@ -911,10 +926,15 @@ impl Sim { /// Reorg: pop the newest k blocks (one invalidate_block call, or one call per block like /// the sync loop does), then let the new branch be generated. fn reorg(&mut self, k: usize, stepwise: bool) { - assert!(self.pending.is_none() && !self.frozen, "{} reorg with a pending send (F5a)", self.ctx()); assert!(k >= 1 && k < self.win.len(), "{} bad reorg depth {}", self.ctx(), k); self.op = "reorg"; let at = self.tip() - k as i32 + 1; + if let Some((_, t, a)) = self.last_send { + if self.any_pending() { + let class = if at > t { 0 } else if at > a { 1 } else { 2 }; + self.stats.pending_reorgs[class] += 1; + } + } let n = if stepwise { (0..k).map(|_| self.w.invalidate_block(self.w.last_scanned_height())).sum::() } else { @@ -940,6 +960,29 @@ impl Sim { assert_eq!(popped, k, "{} window pop count", self.ctx()); assert!(!self.win.is_empty()); + // The wallet leaves pending notes pending (n.pending is kept). A pending send whose anchor + // block was popped can still be mined if the new branch has the same anchor root, so it is + // orphaned when one of its notes was removed, else by a coin flip. + let anchor_popped = self.pending.as_ref() + .map_or(false, |p| p.gtx.anchor_height.map_or(false, |a| a >= at)); + if anchor_popped { + let nfs = self.pending.as_ref().unwrap().gtx.nfs(); + let removed = self.notes.iter().any(|n| n.recv >= at && nfs.contains(&n.nf)); + let orphan = removed || match self.force_orphan { + Some(o) => o, + None => self.rng.gen_bool(0.5), + }; + if orphan { + self.pending = None; + self.stats.orphaned += 1; + } else { + self.stats.anchor_root_reuse += 1; + } + } + if let Some(p) = self.pending.as_mut() { + p.stale_from = cmp::min(p.stale_from, at - 1); + } + self.notes.retain(|n| n.recv < at); let ctx = self.ctx(); for n in self.notes.iter_mut() { @@ -1007,8 +1050,55 @@ impl Sim { None } + /// Some note is pending: a send is in flight, or an orphaned one locked its notes until reload. + fn any_pending(&self) -> bool { + self.notes.iter().any(|n| n.pending.is_some()) + } + + /// A reorg depth for a reorg while a send is pending. The class of `at` (above the sent tip T, + /// across it with the anchor A kept, or popping A; every range capped at the current tip, which + /// an earlier reorg may have lowered) is drawn from the non-empty ones, with weights 1, 2 and 3 + /// (random reorgs mostly hit the first). Half of the popping ones stay within 3 blocks of the + /// top of their range, so they rarely remove one of the send's notes and the send may still be + /// mined (anchor-root reuse). + fn pending_reorg_depth(&mut self) -> Option { + let (_, t, a) = self.last_send?; + let tip = self.tip(); + let lo = *self.win.front().unwrap() + 1; + let classes: Vec<(u32, i32, i32)> = [ + (1, cmp::max(t + 1, lo), tip), + (2, cmp::max(a + 1, lo), cmp::min(t, tip)), + (3, lo, cmp::min(a, tip)), + ].iter().cloned().filter(|(_, l, h)| l <= h).collect(); + if classes.is_empty() { + return None; + } + let mut r = self.rng.gen_range(0, classes.iter().map(|c| c.0).sum::()); + let mut pick = classes[0]; + for c in classes.iter() { + if r < c.0 { + pick = *c; + break; + } + r -= c.0; + } + let (w, mut l, h) = pick; + if w == 3 && self.rng.gen_bool(0.5) { + l = cmp::max(l, h - 3); + } + let at = self.rng.gen_range(l, h + 1); + Some((tip - at + 1) as usize) + } + + /// Orphan the pending send: it is never mined, and its notes stay pending until a reload. + fn orphan_pending(&mut self) { + if self.pending.take().is_some() { + self.stats.orphaned += 1; + } + } + fn reload(&mut self) { - assert!(self.pending.is_none() && !self.frozen, "{} reload with a pending send (F5b)", self.ctx()); + assert!(!self.any_pending(), "{} reload with a pending note (F5b)", self.ctx()); self.op = "reload"; self.reload_raw(); self.stats.reloads += 1; @@ -1030,6 +1120,7 @@ impl Sim { w3.write(&mut b3).unwrap(); assert!(b2 == b3, "{} write(read(x)) is not idempotent", self.ctx()); self.w = w3; + self.mempool.clear(); // mempool_txs is not saved (an orphaned send may still be in it) if self.win.len() > MAX_REORG { self.clear_bound = cmp::max(self.clear_bound, *self.win.front().unwrap()); } @@ -1039,7 +1130,14 @@ impl Sim { /// wallet itself, which leaves it pending. Returns whether a proving send was made (false /// when there were not enough eligible funds; the error path is checked instead). fn send(&mut self, v: &Verifier, kind: SendKind) -> bool { - assert!(self.pending.is_none() && !self.frozen, "{} send with a pending send", self.ctx()); + self.send_with(v, kind, None) + } + + /// `send` with an optional fixed amount (one recipient). With a fixed amount the spent notes + /// must be exactly the ones the wallet's selection rule picks from the eligible notes; it + /// returns false without sending when they do not cover it or need more than 10 inputs. + fn send_with(&mut self, v: &Verifier, kind: SendKind, fixed: Option) -> bool { + assert!(kind != SendKind::Real || self.pending.is_none(), "{} send with a pending send", self.ctx()); self.op = match kind { SendKind::Probe(_) => "probe-send", SendKind::Real => "send" }; let offset_cfg = match kind { SendKind::Probe(o) => o, SendKind::Real => self.cfg.anchor_offset }; let (tip, off) = self.anchor(offset_cfg); @@ -1071,7 +1169,18 @@ impl Sim { None }; - if covering.is_empty() { + let expected: Option> = match fixed { + None => None, + Some(a) => { + let cands: Vec<(u64, [u8; 32])> = elig.iter() + .map(|&i| (self.notes[i].value, self.notes[i].nf)).collect(); + match expected_selection(&cands, a + fee) { + Some(s) if s.len() <= 10 => Some(s.into_iter().collect()), + _ => return false, + } + } + }; + if covering.is_empty() && fixed.is_none() { // Not enough: check the error path instead. self.insufficient_check(); return false; @@ -1081,19 +1190,19 @@ impl Sim { // Proving cost is per input, so keep the worst case over all selection orders bounded. let values: Vec = elig.iter().map(|&i| self.notes[i].value).collect(); - let max1 = *covering.last().unwrap(); - let multi = if covering.len() >= 2 && self.rng.gen_bool(0.5) { + let multi = if fixed.is_none() && covering.len() >= 2 && self.rng.gen_bool(0.5) { // More than the largest note: needs at least two inputs. + let max1 = *covering.last().unwrap(); let amount = self.rint(max1 - fee + 1, cmp::min(total, max1 + 100_000) - fee); if worst_case_inputs(&values, amount + fee) <= 10 { Some(amount) } else { None } } else { None }; - let amount = match multi { - Some(a) => a, - None => self.rint(1, covering[0] - fee), + let amount = match (fixed, multi) { + (Some(a), _) | (None, Some(a)) => a, + (None, None) => self.rint(1, covering[0] - fee), }; - let tos: Vec<(&str, u64, Option)> = if amount >= 2 && self.rng.gen_bool(0.15) { + let tos: Vec<(&str, u64, Option)> = if fixed.is_none() && amount >= 2 && self.rng.gen_bool(0.15) { let a1 = self.rint(1, amount - 1); vec![(to.as_str(), a1, memo.clone()), (to.as_str(), amount - a1, None)] } else { @@ -1132,6 +1241,9 @@ impl Sim { let tx_nfs: HashSet<[u8; 32]> = tx.shielded_spends.iter().map(|s| s.nullifier).collect(); let selected: u64 = self.notes.iter().filter(|n| tx_nfs.contains(&n.nf)).map(|n| n.value).sum(); assert!(selected >= amount + fee, "{} selected {} < {} + fee", ctx, selected, amount); + if let Some(e) = &expected { + assert!(tx_nfs == *e, "{} spent notes are not the ones the selection rule picks", ctx); + } if !self.controls_done { // The verifier is not vacuous: a wrong anchor or nullifier must be rejected. @@ -1185,7 +1297,8 @@ impl Sim { raw: Some(raw), anchor_height: Some(tip - off), }; - self.pending = Some(Pending { gtx, txid, sent_tip: tip }); + self.pending = Some(Pending { gtx, txid, stale_from: tip }); + self.last_send = Some((txid, tip, tip - off)); self.stats.sends += 1; self.check(false); } @@ -1365,14 +1478,65 @@ impl Sim { format!("seed {:#x}: {} blocks, {} outputs ({} ours, {} notes), {} compact spends, {} reorgs (max {}), \ {} reloads, {} probes, {} sends ({} mined), {} insufficient, {} requeued/{} re-mined, \ {} full checks, max {} cleared lists, {} keys; time gen {:.1}s scan {:.1}s ref {:.1}s \ - check {:.1}s send {:.1}s reorg {:.1}s reload {:.1}s periodic {:.1}s", + check {:.1}s send {:.1}s reorg {:.1}s reload {:.1}s periodic {:.1}s; {}", self.seed, s.blocks, s.outputs, s.ours, self.notes.len(), s.spends, s.reorgs, s.max_depth, s.reloads, s.probes, s.sends, s.mined_sends, s.insufficient, s.requeued, s.remined, s.full_checks, self.cleared_seen.get(), self.w.zkeys.read().unwrap().len(), - s.t_gen, s.t_scan, s.t_ref, s.t_check, s.t_send, s.t_reorg, s.t_reload, s.t_full) + s.t_gen, s.t_scan, s.t_ref, s.t_check, s.t_send, s.t_reorg, s.t_reload, s.t_full, + s.pending_summary()) } } +impl Stats { + fn pending_summary(&self) -> String { + format!("pending: reorgs above/across/popped {}/{}/{}, orphaned {}, anchor-root reuse {}", + self.pending_reorgs[0], self.pending_reorgs[1], self.pending_reorgs[2], self.orphaned, + self.anchor_root_reuse) + } + + /// Add the pending-state counters of one seed. + fn add_pending(&mut self, o: &Stats) { + for i in 0..3 { + self.pending_reorgs[i] += o.pending_reorgs[i]; + } + self.orphaned += o.orphaned; + self.anchor_root_reuse += o.anchor_root_reuse; + } + + /// Every pending-state class was hit (asserted by the full runs). + fn assert_pending_cover(&self) { + let all = [self.pending_reorgs[0], self.pending_reorgs[1], self.pending_reorgs[2], self.orphaned, + self.anchor_root_reuse]; + assert!(all.iter().all(|&c| c > 0), "a pending-state class was never hit: {}", self.pending_summary()); + } +} + +/// The notes the wallet's selection rule spends for `needed` (amount + fee; no transparent funds +/// here), from (value, nullifier) of the eligible notes: the smallest non-zero note that covers +/// it, else the largest notes first (the shortest covering prefix) with the last one replaced by +/// the smallest remaining note that still covers. Ties go to the smaller nullifier. None if the +/// notes do not cover it. +fn expected_selection(cands: &[(u64, [u8; 32])], needed: u64) -> Option> { + let mut c: Vec<(u64, [u8; 32])> = cands.iter().cloned().filter(|x| x.0 > 0).collect(); + if c.iter().map(|x| x.0).sum::() < needed { + return None; + } + if let Some(best) = c.iter().filter(|x| x.0 >= needed).min() { + return Some(vec![best.1]); + } + c.sort_by(|x, y| y.0.cmp(&x.0).then(x.1.cmp(&y.1))); + let (mut sum, mut k) = (0u64, 0usize); + while sum < needed { + sum += c[k].0; + k += 1; + } + let remaining = needed - c[..k - 1].iter().map(|x| x.0).sum::(); + let last = *c[k - 1..].iter().filter(|x| x.0 >= remaining).min().unwrap_or(&c[k - 1]); + let mut out: Vec<[u8; 32]> = c[..k - 1].iter().map(|x| x.1).collect(); + out.push(last.1); + Some(out) +} + /// The most inputs any selection order can take for `target`: notes are taken while the running /// total is below the target, and ascending order reaches it last. fn worst_case_inputs(values: &[u64], target: u64) -> usize { @@ -1437,7 +1601,7 @@ enum Proofs { One, } -fn run_seed(seed: u64, n_blocks: usize, proofs: Proofs, v: &Verifier) { +fn run_seed(seed: u64, n_blocks: usize, proofs: Proofs, v: &Verifier) -> Stats { assert!(n_blocks >= 20, "T6_BLOCKS must be >= 20"); let t0 = Instant::now(); let mut sim = Sim::new(seed); @@ -1459,64 +1623,76 @@ fn run_seed(seed: u64, n_blocks: usize, proofs: Proofs, v: &Verifier) { _ => { probe0 = None; probe4 = None; } }, } + // Rate of a reorg inside the pending window of the real send. + let p_pending_reorg = match proofs { Proofs::Three => 0.4, Proofs::One => 0.9 }; let mut insufficient_at = Some(sim.rng.gen_range(0, n)); let mut deep_reorg_at = if n > 150 && sim.rng.gen_bool(0.4) { Some(sim.rng.gen_range(120, n)) } else { None }; let mut mine_at: Option = None; + let mut pending_reorg_at: Option = None; for step in 0..n { sim.step = step; - let calm = sim.pending.is_none() && !sim.frozen; - if calm { - let want = if deep_reorg_at.map_or(false, |s| step >= s) && sim.win.len() > 91 { - deep_reorg_at = None; - Some(cmp::min(sim.rint(90, 100) as usize, sim.win.len() - 1)) - } else if sim.rng.gen_range(0, 1000) < 30 && sim.win.len() > 1 { - Some(sim.pick_depth()) - } else { - None - }; - if let Some(k) = want.and_then(|k| sim.safe_depth(k)) { - let stepwise = sim.rng.gen_bool(0.3); - let t = Instant::now(); - sim.reorg(k, stepwise); - sim.stats.t_reorg += t.elapsed().as_secs_f64(); - } - if sim.rng.gen_range(0, 1000) < 15 { - let t = Instant::now(); - sim.reload(); - sim.stats.t_reload += t.elapsed().as_secs_f64(); - } - if probe0.map_or(false, |s| step >= s) { - probe0 = None; - sim.send(v, SendKind::Probe(0)); - } - if probe4.map_or(false, |s| step >= s) { - probe4 = None; - sim.send(v, SendKind::Probe(4)); - } - if send_at.map_or(false, |s| step >= s) { - send_at = None; - if sim.send(v, SendKind::Real) { - if unmined { - sim.frozen = true; - } else { - mine_at = Some(step + sim.rint(1, 12) as usize); - } + let want = if pending_reorg_at == Some(step) { + pending_reorg_at = None; + sim.pending_reorg_depth() + } else if deep_reorg_at.map_or(false, |s| step >= s) && sim.win.len() > 91 { + deep_reorg_at = None; + Some(cmp::min(sim.rint(90, 100) as usize, sim.win.len() - 1)) + } else if sim.rng.gen_range(0, 1000) < 30 && sim.win.len() > 1 { + Some(sim.pick_depth()) + } else { + None + }; + if let Some(k) = want.and_then(|k| sim.safe_depth(k)) { + let stepwise = sim.rng.gen_bool(0.3); + let t = Instant::now(); + sim.reorg(k, stepwise); + sim.stats.t_reorg += t.elapsed().as_secs_f64(); + } + // F5b: no reload or throwaway copy while a note is pending. + if sim.rng.gen_range(0, 1000) < 15 && !sim.any_pending() { + let t = Instant::now(); + sim.reload(); + sim.stats.t_reload += t.elapsed().as_secs_f64(); + } + if probe0.map_or(false, |s| step >= s) && !sim.any_pending() { + probe0 = None; + sim.send(v, SendKind::Probe(0)); + } + if probe4.map_or(false, |s| step >= s) && !sim.any_pending() { + probe4 = None; + sim.send(v, SendKind::Probe(4)); + } + if send_at.map_or(false, |s| step >= s) && sim.pending.is_none() { + send_at = None; + if sim.send(v, SendKind::Real) { + let end = step + sim.rint(3, 12) as usize; + if unmined { + sim.orphan_pending(); + } else { + mine_at = Some(end); + } + if sim.rng.gen_bool(p_pending_reorg) { + pending_reorg_at = Some(step + sim.rint(1, (end - step) as u64) as usize); } } } - if insufficient_at.map_or(false, |s| step >= s) && sim.pending.is_none() { + if insufficient_at.map_or(false, |s| step >= s) { insufficient_at = None; sim.insufficient_check(); } if mine_at.map_or(false, |s| step >= s) { mine_at = None; - let p = sim.pending.take().unwrap(); - sim.mine(vec![p.gtx], true); - let mined_at = sim.tip(); - sim.pending_windows.push((p.sent_tip, mined_at)); + // A reorg may have orphaned it. + if let Some(p) = sim.pending.take() { + sim.mine(vec![p.gtx], true); + let mined_at = sim.tip(); + sim.pending_windows.push((p.stale_from, mined_at)); + } else { + sim.mine(vec![], true); + } } else { sim.mine(vec![], true); } @@ -1528,17 +1704,11 @@ fn run_seed(seed: u64, n_blocks: usize, proofs: Proofs, v: &Verifier) { sim.stats.t_full += t.elapsed().as_secs_f64(); } } - if let Some(p) = &sim.pending { - // An unmined send: by now it has expired from the mempool. - if sim.tip() >= *sim.mempool.get(&p.txid).unwrap_or(&i32::MIN) + TX_EXPIRY_DELTA { - assert!(sim.w.mempool_txs.read().unwrap().is_empty(), "{} expired send still in the mempool", - sim.ctx()); - } - } sim.op = "end"; sim.check(true); sim.check_addresses(&sim.w); println!("oracle {} ({:.1}s)", sim.summary(), t0.elapsed().as_secs_f64()); + std::mem::take(&mut sim.stats) } impl Sim { @@ -1578,7 +1748,8 @@ fn env_u64(name: &str) -> Option { }) } -fn run_seeds(first: u64, count: u64, n_blocks: usize, proofs: Proofs) { +/// Runs the seeds and returns their summed pending-state counters. +fn run_seeds(first: u64, count: u64, n_blocks: usize, proofs: Proofs) -> Stats { let v = Verifier::new(); let n_blocks = env_u64("T6_BLOCKS").map_or(n_blocks, |b| b as usize); let seeds: Vec = match env_u64("T6_SEED") { @@ -1586,10 +1757,22 @@ fn run_seeds(first: u64, count: u64, n_blocks: usize, proofs: Proofs) { None => (first..first + count).collect(), }; let t0 = Instant::now(); + let mut sum = Stats::default(); for s in seeds.iter() { - run_seed(*s, n_blocks, proofs, &v); + sum.add_pending(&run_seed(*s, n_blocks, proofs, &v)); + } + println!("oracle: {} seeds from {:#x} in {:.1}s; {}", seeds.len(), seeds[0], t0.elapsed().as_secs_f64(), + sum.pending_summary()); + sum +} + +/// One eighth of the full run (125 seeds x 400 blocks, one proving send per seed). Every +/// pending-state class must be hit (not checked for a single T6_SEED). +fn run_full(first: u64) { + let sum = run_seeds(first, 125, 400, Proofs::One); + if env_u64("T6_SEED").is_none() { + sum.assert_pending_cover(); } - println!("oracle: {} seeds from {:#x} in {:.1}s", seeds.len(), seeds[0], t0.elapsed().as_secs_f64()); } #[test] @@ -1615,49 +1798,49 @@ fn oracle_default_d() { #[test] #[ignore] fn oracle_full_0() { - run_seeds(0x1_0000, 125, 400, Proofs::One); + run_full(0x1_0000); } #[test] #[ignore] fn oracle_full_1() { - run_seeds(0x1_1000, 125, 400, Proofs::One); + run_full(0x1_1000); } #[test] #[ignore] fn oracle_full_2() { - run_seeds(0x1_2000, 125, 400, Proofs::One); + run_full(0x1_2000); } #[test] #[ignore] fn oracle_full_3() { - run_seeds(0x1_3000, 125, 400, Proofs::One); + run_full(0x1_3000); } #[test] #[ignore] fn oracle_full_4() { - run_seeds(0x1_4000, 125, 400, Proofs::One); + run_full(0x1_4000); } #[test] #[ignore] fn oracle_full_5() { - run_seeds(0x1_5000, 125, 400, Proofs::One); + run_full(0x1_5000); } #[test] #[ignore] fn oracle_full_6() { - run_seeds(0x1_6000, 125, 400, Proofs::One); + run_full(0x1_6000); } #[test] #[ignore] fn oracle_full_7() { - run_seeds(0x1_7000, 125, 400, Proofs::One); + run_full(0x1_7000); } #[test] @@ -1680,11 +1863,10 @@ fn oracle_env_seed() { run_seeds(seed, 1, 300, Proofs::Three); } -/// F5a reproducer (pre-existing bug, out of Tier 6). A reorg while a send is pending panics in -/// invalidate_block: `nd.unconfirmed_spent.is_some() && txids_to_remove.contains(&nd.spent.unwrap())` -/// unwraps `spent`, which is None for a pending note. Fails on BASE; passes once fixed. +/// F5a (regression test). A reorg while a send is pending panicked in invalidate_block: +/// `nd.unconfirmed_spent.is_some() && txids_to_remove.contains(&nd.spent.unwrap())` unwrapped +/// `spent`, which is None for a pending note. The notes stay pending across the reorg. #[test] -#[ignore] fn oracle_f5a_reorg_while_send_pending() { let v = Verifier::new(); let mut sim = Sim::new(0xF5A); @@ -1699,7 +1881,7 @@ fn oracle_f5a_reorg_while_send_pending() { let tip = sim.tip(); let w = &sim.w; let r = panic::catch_unwind(AssertUnwindSafe(|| w.invalidate_block(tip))); - assert!(r.is_ok(), "F5a (BASE bug): invalidate_block panicked while a send was pending \ + assert!(r.is_ok(), "F5a (regression): invalidate_block panicked while a send was pending \ (it unwraps nd.spent of a note that only has unconfirmed_spent)"); assert_eq!(r.unwrap(), 1); sim.ref_invalidate(tip, 1); @@ -1711,6 +1893,71 @@ fn oracle_f5a_reorg_while_send_pending() { sim.check(true); } +/// The first seed >= `from` whose Sim draws anchor offset `off` (the first draw of Sim::new). +fn seed_with_offset(from: u64, off: u32) -> u64 { + (from..).find(|&s| { + let mut r = StdRng::seed_from_u64(s); + (if r.gen_bool(0.5) { 0 } else { 4 }) == off + }).unwrap() +} + +/// F5a: the mempool monitor (`scan_full_mempool_tx`) records our own pending send in `txs` at +/// the height the server reports, when it can decrypt one of its outputs (here the change). A +/// reorg that removes that entry must leave the notes pending: the send is still valid and is +/// mined on the new branch. Releasing them there would offer notes that are still in flight. +#[test] +fn oracle_f5a_monitor_entry_reorg_keeps_pending() { + let v = Verifier::new(); + let mut sim = Sim::new(seed_with_offset(0xF5D0, 4)); + assert_eq!(sim.cfg.anchor_offset, 4, "setup: an offset-4 seed"); + sim.force_pay = 12; + for _ in 0..12 { + sim.mine(vec![], true); + } + // The smallest eligible note above fee + 1, sent less 1: one input and a change output of 1. + let (_, off) = sim.anchor(sim.cfg.anchor_offset); + let fee = u64::from(DEFAULT_FEE); + let vq = sim.notes.iter().filter(|n| n.live() && n.has_sk && n.len >= off as usize + 1 && n.value > fee + 1) + .map(|n| n.value).min().expect("setup: a note to spend"); + assert!(sim.send_with(&v, SendKind::Real, Some(vq - fee - 1)), "setup: the send must be built"); + let t = sim.tip(); + let (txid, a) = { + let p = sim.pending.as_ref().unwrap(); + (p.txid, p.gtx.anchor_height.unwrap()) + }; + let spent: HashSet<[u8; 32]> = sim.pending.as_ref().unwrap().gtx.nfs().into_iter().collect(); + let tx = Transaction::read(&sim.pending.as_ref().unwrap().gtx.raw.as_ref().unwrap()[..]).unwrap(); + sim.w.scan_full_mempool_tx(&tx, t, 0, true); + assert!(sim.w.txs.read().unwrap().get(&txid).map_or(false, |w| w.block == t), + "setup: the monitor recorded our send in txs at the tip"); + sim.rel.insert(txid, (t, 0)); + sim.mempool.remove(&txid); // the next block's cleanup drops it: txs has the txid + sim.mine(vec![], true); + + // A 2-block reorg removes the monitor's entry (block t). + sim.force_orphan = Some(true); + sim.reorg(2, false); + { + let txs = sim.w.txs.read().unwrap(); + assert!(txs.get(&txid).is_none(), "the reorg removed the monitor's entry"); + let still: HashSet<[u8; 32]> = txs.values().flat_map(|w| w.notes.iter()) + .filter(|nd| nd.unconfirmed_spent == Some(txid)).map(|nd| nd.nullifier).collect(); + assert!(still == spent, "F5a (regression): a reorg that removed the monitor's entry for our pending \ + send released its notes ({} of {} still pending)", still.len(), spent.len()); + } + if a < t { + // The anchor block was kept: mine the send on the new branch. + let g = sim.pending.take().expect("anchor kept: still pending").gtx; + sim.mine(vec![g], true); + assert_eq!(sim.w.txs.read().unwrap().get(&txid).map(|w| w.block), Some(sim.tip()), "mined send's block"); + assert!(sim.notes.iter().filter(|n| spent.contains(&n.nf)).all(|n| n.spent.map(|s| s.0) == Some(txid))); + sim.mine(vec![], true); + sim.check(true); + } else { + assert!(sim.pending.is_none(), "anchor popped: orphaned"); + } +} + /// F5b via a reorg (pre-existing bug, out of Tier 6; the F5b root cause). While a send is /// pending, its notes' witness lists get a copy per block but are not advanced. Once the send is /// mined the notes are spent and that does not matter, but a reorg that undoes the mining block