Skip to main content

bsv_overlay_engine/
engine.rs

1//! Overlay Services Engine — the core orchestrator.
2//!
3//! Receives transactions (submit), answers queries (lookup), manages advertisements,
4//! and coordinates GASP sync. All storage, topic management, and lookup service
5//! operations go through trait interfaces.
6//!
7//! Ported from `~/bsv/overlay-services/src/Engine.ts` (1,337 lines).
8
9use crate::beef_limits;
10
11use bsv_rs::transaction::{Beef, Transaction};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use tracing::{error, info, warn};
15
16use crate::advertiser::{Advertiser, AdvertiserError};
17use crate::broadcaster::{ArcBroadcaster, Broadcaster};
18use crate::lookup_service::{LookupService, LookupServiceError};
19use crate::storage::{Storage, StorageError};
20use crate::topic_manager::{TopicManager, TopicManagerError};
21use crate::types::*;
22
23/// Controls how much spend history to include in lookup responses.
24///
25/// Maps to the TS `historySelector` parameter which can be a number
26/// (depth limit) or an async function (per-output decider).
27#[derive(Debug, Clone)]
28pub enum HistorySelector {
29    /// Include up to N levels of ancestor spend history.
30    Depth(u32),
31}
32
33/// Configuration for constructing the Engine.
34pub struct EngineConfig {
35    /// URL where this engine is hosted. Required for advertisement sync.
36    pub hosting_url: Option<String>,
37    /// Known SHIP tracker URLs for bootstrapping.
38    pub ship_trackers: Vec<String>,
39    /// Known SLAP tracker URLs for bootstrapping.
40    pub slap_trackers: Vec<String>,
41    /// Configuration for GASP topic synchronization.
42    pub sync_configuration: SyncConfiguration,
43    /// Whether to suppress default SHIP/SLAP sync advertisements.
44    pub suppress_default_sync_advertisements: bool,
45}
46
47impl Default for EngineConfig {
48    fn default() -> Self {
49        Self {
50            hosting_url: None,
51            ship_trackers: Vec::new(),
52            slap_trackers: Vec::new(),
53            sync_configuration: HashMap::new(),
54            suppress_default_sync_advertisements: true,
55        }
56    }
57}
58
59/// The Overlay Services Engine.
60///
61/// Orchestrates topic managers, lookup services, storage, and advertisements.
62/// Does NOT include an HTTP server — that's the job of overlay-cloudflare.
63pub struct Engine {
64    managers: HashMap<String, Box<dyn TopicManager>>,
65    lookup_services: HashMap<String, Box<dyn LookupService>>,
66    storage: Box<dyn Storage>,
67    advertiser: Option<Box<dyn Advertiser>>,
68    broadcaster: Option<Box<dyn Broadcaster>>,
69    arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
70    chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
71    gasp_remote_factory: Option<Box<dyn crate::gasp::GASPRemoteFactory>>,
72    ship_lookup_client: Option<Box<dyn crate::ship_lookup::ShipLookupClient>>,
73    ship_bootstrap_peers: HashMap<String, Vec<String>>,
74    /// OPT-IN / OFF BY DEFAULT chain-backed ancestor fetcher for GASP ingest.
75    /// When `None` (default / production), GASP ingest is byte-identical to
76    /// today. A platform crate may set `Some` (one-time-migration only) to let
77    /// ingest self-heal missing ancestry by fetching it from chain.
78    ancestor_fetcher: Option<std::rc::Rc<dyn crate::gasp::AncestorFetcher>>,
79    gasp_ancestor_fetcher: Option<std::rc::Rc<dyn crate::gasp::AncestorFetcher>>,
80    /// bsv-low#302: per-peer GASP sync wall-clock budget —
81    /// `(sleep factory, budget ms)`. `None` (default) = unbounded per-peer
82    /// sync, byte-identical to the pre-#302 behavior. When set, each peer's
83    /// `GASPSync::sync` is raced against `sleep(budget_ms)`; a peer that
84    /// exceeds the budget is DROPPED (loud log, failure recorded, cursor NOT
85    /// advanced) and the loop continues with the next peer.
86    peer_sync_budget: Option<(SleepFactory, u64)>,
87    /// The per-GRAPH GASP budget (bsv-low #555): the sleep, the calls, the
88    /// ms. `None`: no graph is deferred.
89    graph_budget: Option<(SleepFactory, u32, u64)>,
90    /// The per-graph budget's bytes and nodes limbs (bsv-low #586,
91    /// [`Engine::set_graph_budget_limbs`]): in force once a graph budget is
92    /// set.
93    graph_budget_limbs: (u64, u32),
94    /// The bound of one transaction's finalize submit (bsv-low #559), see
95    /// [`Engine::set_finalize_submit_budget`].
96    finalize_submit_budget: Option<(SleepFactory, u64)>,
97    /// Open around each GASP finalize submit, so no deadline that races the
98    /// sync drops a transaction between its writes (bsv-low #552, the lens
99    /// fold's HIGH-1). See [`Engine::finalize_submit_gate`].
100    finalize_gate: crate::gasp::SubmitGate,
101    /// (txid, topic) pairs whose Phase-3 writes FAULTED in this engine and
102    /// have not landed since (bsv-low #559): a successor that finds no coin
103    /// while its predecessor is in here is not recorded as applied. One
104    /// invocation's memory; across invocations the same question is put to
105    /// the store ([`Engine::unlanded_predecessor`]).
106    not_landed: std::cell::RefCell<HashSet<(String, String)>>,
107    /// Asked before the door lands a carried predecessor (bsv-low #575, the
108    /// E1D delta-2 fold, L2). See [`Engine::set_landing_guard`].
109    landing_guard: Option<LandingGuard>,
110    /// Reference-parity spend verification on submit (2026-09-08). `true`
111    /// (DEFAULT): every submit outside `HistoricalTxNoSpv` runs the
112    /// reference's `Transaction.verify` walk: merkle paths against the chain
113    /// tracker AND every input's unlocking script EXECUTED, recursively over
114    /// the unproven ancestry in the BEEF; with no chain tracker the walk is
115    /// the ts-sdk's `'scripts only'` (roots accepted unchecked, scripts still
116    /// run). `false` is an ESCAPE HATCH, not a mode: it restores the
117    /// pre-2026-09-08 structural check (`Beef::verify_valid` + every root
118    /// against the tracker; NOTHING without a tracker), which admitted an
119    /// invalid spend on structure alone. See
120    /// [`Engine::set_script_verification`].
121    verify_scripts: bool,
122    /// The two limbs of the submit's own script walk (bsv-low #592): the
123    /// door's [`DoorBudget`], [`DoorBudget::DEFAULT`] unless set. See
124    /// [`Engine::set_walk_budget`].
125    walk_budget: DoorBudget,
126    config: EngineConfig,
127}
128
129/// Boxed platform sleep future (bsv-low#302). Not `Send` — the engine runs
130/// single-threaded on wasm (Workers) and under `#[tokio::test]` locally.
131pub type SleepFuture = std::pin::Pin<Box<dyn std::future::Future<Output = ()>>>;
132
133/// Platform sleep factory (bsv-low#302): milliseconds → a future that
134/// resolves after that delay. The Cloudflare crate passes its `sleep_ms`;
135/// tests pass `ready(())` (instant deadline) or `pending()` (no deadline)
136/// to drive the race deterministically.
137pub type SleepFactory = std::rc::Rc<dyn Fn(u64) -> SleepFuture>;
138
139/// The answer of a [`LandingGuard`]: `Ok` lets the body land, `Err` says why
140/// it may not (the topic waits).
141pub type LandingGuardFuture =
142    std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
143
144/// The caller's admission predicate over a carried predecessor the door is
145/// about to land: `(predecessor txid, topic)`. See
146/// [`Engine::set_landing_guard`].
147pub type LandingGuard = std::rc::Rc<dyn Fn(&str, &str) -> LandingGuardFuture>;
148
149/// The bound of ONE GASP finalize submit's storage calls and hooks (bsv-low
150/// #559, the lens fold of 2026-10-07, F2). The submit itself is never
151/// dropped: it is the write section no deadline drops, and c9921ee, which
152/// raced the whole submit against its budget inside the section, dropped it
153/// between two writes all the same. Each storage call, lookup hook and topic
154/// manager call inside it is raced against the section's ONE deadline
155/// instead, and a call that has not answered when the deadline falls due is
156/// dropped ALONE and reported as that call's fault: the submit runs on to
157/// its end as for any storage fault. Once the deadline is due no further
158/// call is started (each answers "no answer" at once), so a transaction
159/// costs at most its budget, and once more for the undo
160/// ([`CallBound::renew`]).
161struct CallBound {
162    sleep: SleepFactory,
163    budget_ms: u64,
164    deadline: std::cell::RefCell<SleepFuture>,
165    due: std::cell::Cell<bool>,
166}
167
168impl CallBound {
169    fn new(sleep: &SleepFactory, budget_ms: u64) -> Self {
170        Self {
171            deadline: std::cell::RefCell::new(sleep(budget_ms)),
172            sleep: sleep.clone(),
173            budget_ms,
174            due: std::cell::Cell::new(false),
175        }
176    }
177
178    /// A fresh allowance of the same length, for the undo of a faulted
179    /// submit: the calls that put the store back as it was found must not be
180    /// refused because the deadline the fault answered to has passed.
181    fn renew(&self) {
182        *self.deadline.borrow_mut() = (self.sleep)(self.budget_ms);
183        self.due.set(false);
184    }
185
186    /// Whether a call under this bound went unanswered (or was not started
187    /// because one had) since the last [`CallBound::renew`].
188    fn is_due(&self) -> bool {
189        self.due.get()
190    }
191
192    /// `call`, or `None` when the deadline fell due first.
193    async fn call<T>(&self, call: impl std::future::Future<Output = T>) -> Option<T> {
194        if self.due.get() {
195            return None;
196        }
197        let mut call = std::pin::pin!(call);
198        std::future::poll_fn(|cx| {
199            if let std::task::Poll::Ready(v) = call.as_mut().poll(cx) {
200                return std::task::Poll::Ready(Some(v));
201            }
202            if self.deadline.borrow_mut().as_mut().poll(cx).is_ready() {
203                self.due.set(true);
204                return std::task::Poll::Ready(None);
205            }
206            std::task::Poll::Pending
207        })
208        .await
209    }
210}
211
212/// `call` under `bound` (or unbounded with none); `Err` is "no answer".
213async fn bounded<T>(
214    bound: Option<&CallBound>,
215    call: impl std::future::Future<Output = T>,
216) -> Result<T, String> {
217    match bound {
218        Some(bound) => bound
219            .call(call)
220            .await
221            .ok_or_else(|| format!("no answer within {} ms", bound.budget_ms)),
222        None => Ok(call.await),
223    }
224}
225
226/// A storage call under `bound`: no answer in time is that call's fault.
227async fn stored<T>(
228    bound: Option<&CallBound>,
229    call: impl std::future::Future<Output = Result<T, StorageError>>,
230) -> Result<T, StorageError> {
231    match bounded(bound, call).await {
232        Ok(answer) => answer,
233        Err(no_answer) => Err(StorageError::Database(no_answer)),
234    }
235}
236
237/// A lookup hook under `bound`, its error as the report carries it.
238async fn hooked(
239    bound: Option<&CallBound>,
240    call: impl std::future::Future<Output = Result<(), LookupServiceError>>,
241) -> Result<(), String> {
242    bounded(bound, call).await?.map_err(|e| e.to_string())
243}
244
245/// The most store reads [`Engine::unlanded_predecessor`] makes for one
246/// topic of one submit (bsv-low #559, F3): the question is asked of every
247/// transaction that admits nothing and found no coin, so it is kept cheap.
248/// Counted are the reads spent on a body the BEEF does not prove and the
249/// store does not hold as landed (the second delta fold of 2026-10-07, M2);
250/// the 17th of those is never made: out of reads is "not now" (the delta
251/// fold of 2026-10-07, M2). An unproven body still needs room under it for
252/// the reads that find it landed (one for an applied row, two for a held
253/// output): each is tested before it is made.
254const PREDECESSOR_READS: usize = 16;
255
256/// The most store reads [`Engine::unlanded_predecessor`] makes for one
257/// SUBMIT (the third delta fold of 2026-10-07, M1): every read of the
258/// question, counted against [`PREDECESSOR_READS`] or not (those of proven
259/// and landed bodies included), in every topic of the submit together. The
260/// 257th is never made: past the allowance the answer is "not now", never
261/// "landed". Without it the question read as far as the BEEF reached (one
262/// read per 41 bytes: 66,305 reads measured from one 2.7 MB BEEF), on a
263/// public route. Per submit and not per topic because what it protects, the
264/// invocation's D1 allowance, is per invocation; 256 is the door's own unit
265/// ([`DoorBudget`]'s inputs per transaction), no derived figure.
266const PREDECESSOR_READS_PER_SUBMIT: usize = 256;
267
268/// Why a successor must wait ([`Engine::unlanded_predecessor`]), and the
269/// unlanded predecessor whose body the BEEF carries, when the answer names
270/// one: the door lands it first ([`Engine::land_carried`]).
271/// A submit's STEAK and report, and the carried predecessor that blocks each
272/// topic of a carried predecessor's own submit ([`Engine::submit_counted`]).
273type Counted = (Steak, MutationReport, HashMap<String, String>);
274
275/// What the landings of carried predecessors take from their successor's
276/// submit (the E592 lens fold, M1): its breach policy, and whether its own
277/// walk RAN (only then are the bodies it reached trusted).
278#[derive(Debug, Clone, Copy)]
279struct Landing {
280    policy: WalkBreachPolicy,
281    subject_walk_ran: bool,
282}
283
284struct NotNow {
285    why: String,
286    carried: Option<String>,
287}
288
289/// Summary of an [`Engine::complete_missing_proofs`] pass.
290///
291/// All counts are over the single bounded page scanned this tick. A no-fetcher
292/// (production) build returns the all-zero default.
293#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
294pub struct ProofCompletionSummary {
295    /// Young walk-admitted rows selected without waiting for a callback.
296    pub early_gasp: usize,
297    /// Stored transactions inspected this tick (the bounded page size).
298    pub scanned: usize,
299    /// Of `scanned`, how many still lacked a merkle proof for their own tx.
300    pub proofless: usize,
301    /// Proofless txs whose BUMP was fetched + stitched into the stored BEEF.
302    pub completed: usize,
303    /// Proofless txs the fetcher could serve but that are not yet mined
304    /// (no BUMP yet) — retried on a later tick.
305    pub still_unconfirmed: usize,
306    /// Proofless txs the fetcher errored on (e.g. budget exhausted, 429) —
307    /// retried on a later tick.
308    pub fetch_failed: usize,
309    /// Proofless txs whose proof came back but failed to stitch into storage.
310    pub stitch_failed: usize,
311    /// Scanned rows that ALREADY carried a valid proof (stale `has_proof = 0`
312    /// flag) and were marked proven this tick so they drop out of the candidate
313    /// window — clearing the window-clog (#130).
314    pub already_proven: usize,
315}
316
317/// Outcome of one [`Engine::sync_advertisements`] run (bsv-low #320 defect
318/// 3a).
319///
320/// The engine used to swallow create/submit failures into `error!` and
321/// return `Ok(())`, so the admin route reported `success` while zero
322/// advertisements were admitted locally; a caller could not tell a
323/// converged no-op (`to_create == 0`) from a silent failure. Every field
324/// here is observational — the sync's behavior is unchanged, its outcome is
325/// no longer hidden.
326#[derive(Debug, Default, Clone, Serialize)]
327#[serde(rename_all = "camelCase")]
328pub struct SyncAdvertisementsReport {
329    /// Advertisements the diff found missing (attempted this run). A
330    /// converged node reports 0 here.
331    pub to_create: usize,
332    /// Stale advertisements the diff said to revoke.
333    pub to_revoke: usize,
334    /// `advertiser.create_advertisements` failure, verbatim.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub create_error: Option<String>,
337    /// Local `Engine::submit` failure for the created ads, verbatim — the
338    /// path that silently hid the self-admission failure live (our own
339    /// ls_ship/ls_slap never gained our ads, so every cycle re-created all
340    /// of them).
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub submit_error: Option<String>,
343    /// Outputs admitted per topic by the local submit (from the STEAK).
344    /// A successful submit that admitted NOTHING (topic-manager refusal)
345    /// shows up here as explicit zeros.
346    pub admitted: std::collections::BTreeMap<String, usize>,
347    /// `advertiser.find_all_advertisements` failure, verbatim (#320 M2).
348    /// When set, creation was REFUSED this run: a blind read must never
349    /// become "current ads = none" and re-create (re-pay) the entire set.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub lookup_error: Option<String>,
352    /// Stale ads the diff wanted revoked but the advertiser declined to
353    /// build a revocation for (the CF advertiser's documented v1 no-op —
354    /// #320 L2). Distinguishes "revoke happened" from "revoke skipped".
355    pub revoke_skipped: usize,
356    /// `advertiser.revoke_advertisements` failure, verbatim.
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub revoke_error: Option<String>,
359    /// Local `Engine::submit` failure for the revocation, verbatim.
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub revoke_submit_error: Option<String>,
362}
363
364impl SyncAdvertisementsReport {
365    /// True when nothing errored. NOTE: a submit that admitted zero outputs
366    /// is not an error at this layer — that refusal is [`Self::effective`]'s
367    /// job.
368    #[must_use]
369    pub fn ok(&self) -> bool {
370        self.create_error.is_none()
371            && self.submit_error.is_none()
372            && self.lookup_error.is_none()
373            && self.revoke_error.is_none()
374            && self.revoke_submit_error.is_none()
375    }
376
377    /// Total outputs admitted by the local submit across topics.
378    #[must_use]
379    pub fn admitted_total(&self) -> usize {
380        self.admitted.values().sum()
381    }
382
383    /// True when the run errored nowhere AND the local submit admitted
384    /// EVERY created ad (#320 M1 + delta D1): each advertisement is exactly
385    /// one output and change is never a SHIP/SLAP token, so full admission
386    /// means `admitted_total() >= to_create` (defensive `>=`). A weaker
387    /// `> 0` bar let a PARTIAL admit pass — the live set is 9 SHIP + 8 SLAP
388    /// in ONE tx with independent per-topic admission, so tm_ship admitting
389    /// its 9 while tm_slap refused all 8 read as success while the diff
390    /// re-created (re-paid for) the refused 8 every cycle. Subsumes the
391    /// converged no-op (`0 >= 0`).
392    #[must_use]
393    pub fn effective(&self) -> bool {
394        self.ok() && self.admitted_total() >= self.to_create
395    }
396}
397
398/// Internal result from Phase 1 + 2 validation.
399///
400/// Carries per-topic admittance decisions, previous coins, and the parsed
401/// transaction so that Phase 3 (mutations) can proceed without re-parsing.
402struct TopicValidation {
403    topic: String,
404    is_dupe: bool,
405    /// Input indices that spend previously-admitted outputs from this topic.
406    previous_coins: Vec<u32>,
407    /// The previous outputs found in storage (parallel to previous_coins).
408    previous_outputs: Vec<Output>,
409    admittance: AdmittanceInstructions,
410    failed: bool,
411    /// The FIRST storage read fault hit while scanning this topic's
412    /// previous outputs (S2, bsv-low 2026-08-29). A faulted read makes a
413    /// spend look like it consumes nothing — the settle's spend pointer is
414    /// then never written — so it is carried into the [`MutationReport`]
415    /// and blocks the `applied_transactions` record for this topic: a
416    /// replay re-reads instead of being deduplicated away. With the read
417    /// that faulted: `find_output` (a previous coin) or
418    /// `does_applied_transaction_exist` (the dedup read, the delta fold of
419    /// 2026-10-07, H3).
420    read_fault: Option<(&'static str, String)>,
421}
422
423/// One Phase-3 write — or the validation read it depends on — that FAILED
424/// during a submit (S2 queue-durable admission, bsv-low 2026-08-29).
425///
426/// Before this existed every such failure was `error!`-logged and swallowed
427/// while `submit` returned `Ok(steak)`: under a D1-overload storm the
428/// overlay ACKED admissions whose writes never landed (the 2026-08-26
429/// phantom class — `c4d2ed06…`/`62c2a9b3…` absent from the engine store,
430/// beta D1 queried directly). A fault is now a VALUE the caller can act on
431/// (re-queue for an idempotent replay) instead of a log line nobody reads.
432#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct MutationFault {
434    /// The topic whose mutation faulted.
435    pub topic: String,
436    /// The write (or read) that failed: `insert_output`,
437    /// `mark_utxo_as_spent`, `update_consumed_by`, `delete_utxo_deep`,
438    /// `insert_applied_transaction`, `find_output` (validation read),
439    /// `does_applied_transaction_exist` (the dedup read),
440    /// `lookup_service.output_spent`, `lookup_service.output_admitted_by_topic`,
441    /// `undo_insert_output` (the undo of a faulted submit's inserts),
442    /// `record_spent_coin_applied` (the applied row a spender writes for the
443    /// transaction of a coin it deletes), `predecessor_not_landed`.
444    pub site: &'static str,
445    /// The backend's own error text.
446    pub error: String,
447}
448
449/// What Phase 3 actually landed for a submit — the durability report.
450///
451/// `applied_topics` are the topics whose EVERY write (and lookup-service
452/// notification) succeeded and were therefore recorded in
453/// `applied_transactions`. A topic with any fault is deliberately NOT
454/// recorded: the dedup in Phase 1 would otherwise turn a replay into a
455/// no-op and make the loss permanent (the ordering was already
456/// write-then-record; the record is now conditional on the writes).
457#[derive(Debug, Default, Clone, PartialEq, Eq)]
458pub struct MutationReport {
459    pub faults: Vec<MutationFault>,
460    pub applied_topics: Vec<String>,
461    /// Topics this (txid, topic) pair had ALREADY been applied under — the
462    /// Phase-1 dedup skipped them, so their STEAK entry is an empty default
463    /// that means "nothing new", never "judged another tx" (loop-3 client
464    /// belt re-presents read the empty STEAK as index-pending).
465    pub deduped_topics: Vec<String>,
466    /// The predecessors the door LANDED first from this submit's BEEF, as
467    /// `(txid, topic)`, ancestors first ([`Engine::land_carried`]; the E1D
468    /// delta fold, L2). Each is an admission write of its own that no
469    /// caller asked for: a caller that guards its admission writes (the
470    /// worker's eviction ledger, bsv-low #513) guards these too.
471    pub landed_predecessors: Vec<(String, String)>,
472    /// The submit's own script walk could not run within the engine's
473    /// budget (bsv-low #592) and the submit went on as under
474    /// `HistoricalTxNoSpv` ([`WalkBreachPolicy::NetworkAccepted`] only; under
475    /// `NotNow` a breach is [`EngineError::WalkCouldNotRun`]). `None` when it
476    /// ran (or the mode does not walk). A caller counts it; it never makes a
477    /// report undurable.
478    pub walk_could_not_run: Option<WalkCouldNotRun>,
479    /// The walks of the carried predecessors this submit tried to land
480    /// ([`Engine::land_carried`]) that could not run within the budget (the
481    /// E592 lens fold, L3): under `NotNow` each one's landing did not happen
482    /// (the successor waits), under `NetworkAccepted` each went on. Counted
483    /// by the caller beside `walk_could_not_run`.
484    pub landed_walks_could_not_run: Vec<WalkCouldNotRun>,
485}
486
487/// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): what [`Engine::renotify_admitted`] did.
488#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
489pub struct RenotifyReport {
490    /// stored outputs of (txid, topic) the engine had ALREADY admitted
491    pub outputs: u32,
492    /// lookup-service notifications that returned Ok (outputs × services)
493    pub notified: u32,
494    /// notifications that failed — `<service>: <error>`; the others still ran
495    pub faults: Vec<String>,
496    /// the output indexes re-notified (the caller's spend discovery keys on them)
497    pub vouts: Vec<u32>,
498}
499
500impl MutationReport {
501    fn fault(&mut self, topic: &str, site: &'static str, error: String) {
502        self.faults.push(MutationFault {
503            topic: topic.to_string(),
504            site,
505            error,
506        });
507    }
508
509    /// True when every mutation this submit needed landed (nothing to
510    /// replay). A dupe-only submit is durable (there was nothing to write).
511    #[must_use]
512    pub fn is_durable(&self) -> bool {
513        self.faults.is_empty()
514    }
515
516    /// One log line naming every fault (`topic/site: error`), each error
517    /// clipped so a D1 stack dump cannot flood the log stream.
518    #[must_use]
519    pub fn summary(&self) -> String {
520        self.faults
521            .iter()
522            .map(|f| {
523                let err: String = f.error.chars().take(120).collect();
524                format!("{}/{}: {}", f.topic, f.site, err)
525            })
526            .collect::<Vec<_>>()
527            .join("; ")
528    }
529}
530
531/// The DOOR's static work bound (bsv-low W-A gate MED-1, 2026-09-09). Bitcoin
532/// script has no loops: every opcode runs at most once, so an input's work is
533/// bounded BEFORE execution by its opcode census times the interpreter's
534/// element limit (the stack memory limit bounds every element, and every
535/// CAT / NUM2BIN / SPLIT result with it) plus its signature checks times the
536/// transaction's own size or a floor, whichever is more (a BIP-143 preimage
537/// hashes the prevouts and the outputs; the EC verification costs the same
538/// whatever the transaction weighs). The estimate is computed from the bytes;
539/// nothing executes past the budget, and a budget breach is the DOOR's
540/// verdict (inconclusive: the network judges), never the interpreter's.
541///
542/// THE BODY IS NOT COUNTED (bsv-low #585, door 1). Until #585 the budget also
543/// counted the BODY: 64 unproven transactions, 256 inputs per transaction,
544/// 512 KB per transaction, and the parse's own size and counts. A valid BEEF
545/// is never left unjudged for its size or its counts, so those are gone: the
546/// walk reads the body through the streaming reader ([`crate::script_door`]).
547///
548/// TWO LIMBS, both a budget whose breach is "the network judges" and never a
549/// refusal: the WORK the interpreter will do, and the MEMORY the door will
550/// hold beside the caller's body. Each is estimated from the bytes before it
551/// is spent.
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553pub struct DoorBudget {
554    /// The interpreter's stack memory limit: bounds every element, and is the
555    /// unit of the work estimate (a hash-class opcode is charged one element).
556    /// Not a bound on the BEEF: a script that trips it is "the network
557    /// judges", as a breach of the work is.
558    pub memory_limit: usize,
559    /// The whole walk's estimated work, in bytes hashed or pushed.
560    pub max_work_bytes: u64,
561    /// The least a signature check is charged (the doors lens E585-D12-M1).
562    /// A check is charged `max(its transaction's bytes, this)`: the digest
563    /// hashes about the transaction, and the EC verification behind it costs
564    /// the same whatever the transaction weighs (about 95 us native). Charged
565    /// the transaction's bytes alone, a 106 byte `OP_CHECKSIG OP_NOT`
566    /// transaction bought a verification for 106 bytes of budget: 90,000 of
567    /// them walked at a fifth of the budget in 9.6 s. With the floor the
568    /// budget bounds the EC verifications of one walk at
569    /// `max_work_bytes / sig_check_floor`.
570    pub sig_check_floor: u64,
571    /// The whole walk's estimated memory beside the body, in bytes (the doors
572    /// lens E585-D12-L3): what the index, the stream's element in hand and
573    /// the walk's layout of a transaction hold, estimated from the frame's
574    /// lengths and counts by [`crate::stream_sizing::estimate`] BEFORE the
575    /// stream is opened; and, per input, what its two scripts hold once
576    /// parsed and run (the delta lens E585-D12-DELTA-M1: bsv-rs's chunk
577    /// records, their clones, a signature's subscript, the empty stack
578    /// entries the interpreter's memory limit does not count), charged from
579    /// the scripts' chunk count and bytes BEFORE either is parsed
580    /// (`script_door::script_parse_charge`, 512 bytes a chunk and 8 a byte).
581    ///
582    /// How the two compose: the frame's estimate is the most the walk holds
583    /// at any moment apart from the input being run (what is kept, and the
584    /// heaviest transaction in hand, counted once with its raw bytes and the
585    /// interpreter's raw copies of the unlocking script); the scripts' charge
586    /// is the EXPANSION of one input's scripts past those bytes, held while
587    /// that input runs and released after it. An input is run only while the
588    /// frame's estimate plus its own charge is within this limb.
589    pub max_memory_bytes: u64,
590}
591
592impl DoorBudget {
593    /// Sized for LOW's real shapes with a wide margin. What the static census
594    /// BOUNDS: hashing (hash-class ops × the element limit) and signature
595    /// checks (× the transaction's size or the floor, whichever is more; a
596    /// CHECKMULTISIG weighted by its static key count when the count is a
597    /// small-int push, else by the most keys the element limit admits).
598    ///
599    /// THE FLOOR, 64 KiB (half the hash charge), chosen against LOW's shapes
600    /// as the pin `door_low_shapes_sit_under_a_quarter_of_the_work_budget`
601    /// measures them (work, of the 64 MiB; memory, the frame's and the
602    /// largest input's scripts, of the 48 MiB limb; work before the floor in
603    /// brackets):
604    ///
605    /// | shape | inputs | work | memory |
606    /// |---|---|---|---|
607    /// | the real covenant settle (5 hash opcodes, 4 checks: a CHECKSIG and the stated `OP_3` CHECKMULTISIG) | 1 | 924,112, 1.4 % (676,284) | 1,282,178, 2.55 % |
608    /// | the real covenant refund | 1 | 924,112, 1.4 % (676,284) | 1,282,178, 2.55 % |
609    /// | LOW's JOIN: two seats (bsv-low `template_tx_sizes.rs:76`), the real pot lock, each coin 40 unproven P2PKH hops deep (the deepest the fleet's record names) | 82 | 16,132,638, 24.0 % | 67,026, 0.13 % |
610    /// | STRESS, not LOW's shape: nine seats, three hops each | 36 | 7,082,619, 10.6 % (4,740,838) | 36,420, 0.07 % |
611    /// | a coin 30 P2PKH hops deep | 30 | 5,902,188, 8.8 % (3,941,856) | 23,720, 0.05 % |
612    ///
613    /// A P2PKH input is charged 196,739 bytes (a hash opcode at
614    /// the element limit, a check at the floor, its 131 script bytes): 341 of
615    /// them are the budget, 85 a quarter of it. The bound on EC verifications
616    /// per walk is 64 MiB / 64 KiB = 1,024 (1,023 with their script bytes):
617    /// measured natively, 1,000 of them walk in 87 ms in the release profile
618    /// (1.4 to 1.8 s in a debug build), and the lens's 17,000 stop at the
619    /// 1,024th in 108 ms. wasm32 was not measured.
620    ///
621    /// THE MEMORY, 48 MiB: three eighths of a 128 MB isolate. Beside it stand,
622    /// for a 10 MB request (the figure the arithmetic is made at: since NL-6
623    /// the route caps no body, so a heavier one is the isolate's), the
624    /// request's body, the completed BEEF the route hands the door (a second
625    /// copy of up to as much) and the EF batch (the route's 2 MB bound, twice
626    /// while it is serialized): 24 MB, which leaves 56 MB for the module, the
627    /// runtime, the allocator's fragmentation and what a native estimate does
628    /// not see of wasm32. The limb bounds the walk (the door's, and since
629    /// bsv-low #592 the submit's own and the GASP anchor's under
630    /// [`Engine::set_walk_budget`]) and nothing else: the route's own
631    /// hydrated parses before it are bounded by the isolate only (the land
632    /// lens E585-LAND-M1: 1.43 GB natively for a 9.9 MB body of 900,000
633    /// minimal transactions, before the route's 429). What
634    /// it lets through, by the door's charges (`script_door::DOOR_CHARGES`,
635    /// each an upper bound: the pins measure 1.3 to 5.3 times the heap the
636    /// walk reaches): a BUMP of up to 72,944 leaves (690 bytes a level-0
637    /// leaf); a single transaction of up to about 12 MB (4 bytes a byte)
638    /// whose walked inputs' scripts are few chunks (a script is charged 520
639    /// bytes a one-byte opcode and 8 a pushed byte: an input whose two
640    /// scripts are about 96,000 opcodes, or a 1 MB `OP_NOP` lock, is past
641    /// the limb before it is parsed, the network judges; LOW's covenant leg,
642    /// 6,608 script bytes, is charged 1.3 MB); a 10 MB body of one-input,
643    /// one-output P2PKH transactions (191 bytes, charged 570: 30 MB). What it
644    /// stops: 111,848 transactions of no input and no output, whatever
645    /// follows them.
646    ///
647    /// THE OPEN RESIDUAL (the W-A gate's delta-verify, 2026-09-09, measured):
648    /// the interpreter itself meters nothing, so two classes stay charged
649    /// only by their script bytes — bignum arithmetic (`OP_MUL`/`OP_DIV`/
650    /// `OP_MOD` on operands up to the element limit: ~1.3 ms per 30 KB×30 KB
651    /// round, linear in rounds) and `OP_NUM2BIN`, which
652    /// allocates its size operand BEFORE the memory limit is consulted (up to
653    /// bsv-rs's 1 GB element size: an isolate kill, not a refusal). None of
654    /// them can refuse a spend (a breach and a trip are the door's bound and
655    /// the request proceeds); all of them cost the beta overlay CPU until
656    /// bsv-rs meters work dynamically (a `work_limit` charged per op with the
657    /// real operand sizes, and a pre-allocation check in NUM2BIN) — OWED
658    /// before any PROD flip of `SCRIPT_VERIFY_NETWORK_GATED`. Since #585 the
659    /// count of such rounds is bounded by the body alone (the script bytes
660    /// are charged, the transactions and inputs are not counted). A third,
661    /// as before #585 (the doors lens N7): an opcode that copies an element
662    /// (`OP_DUP`, `OP_CAT`, `OP_PICK`) is charged its one script byte and can
663    /// copy up to the element limit. The class the floor CLOSED: a
664    /// CHECKMULTISIG whose key count is a computed value was charged the most
665    /// keys the element limit admits (3,971) at its transaction's bytes and
666    /// run (~80 µs per key tried); at the floor those are 260 MB, past the
667    /// budget, so such an input is never run at the door (the network
668    /// judges). LOW's covenant states its count (`OP_3`).
669    pub const DEFAULT: DoorBudget = DoorBudget {
670        memory_limit: 128 * 1024,
671        max_work_bytes: 64 * 1024 * 1024,
672        sig_check_floor: 64 * 1024,
673        max_memory_bytes: 48 * 1024 * 1024,
674    };
675}
676
677/// Which limb of the [`DoorBudget`] a walk went past.
678#[derive(Debug, Clone, Copy, PartialEq, Eq)]
679pub enum DoorLimb {
680    /// The static work estimate, or the interpreter's own memory limit
681    /// tripping inside one input.
682    Work,
683    /// The estimate of what the door holds beside the body.
684    Memory,
685}
686
687/// What [`Engine::run_validation`] answers: the topics' validations, the
688/// preliminary STEAK, the subject and its txid, and whether the walk could
689/// not run within the budget (bsv-low #592).
690type Validated = (
691    Vec<TopicValidation>,
692    Steak,
693    Transaction,
694    String,
695    Option<WalkCouldNotRun>,
696);
697
698/// Which limb of the [`DoorBudget`] a submit's own walk went past (bsv-low
699/// #592): the [`DoorLimb`] of the door's error, with the interpreter's own
700/// memory limit told apart from the static work estimate.
701#[derive(Debug, Clone, Copy, PartialEq, Eq)]
702pub enum WalkLimb {
703    /// The static work estimate.
704    OverWork,
705    /// The estimate of what the walk holds beside the body.
706    OverMemory,
707    /// The interpreter's own memory limit (the budget's element limit)
708    /// tripping inside one input.
709    InterpreterMemory,
710}
711
712impl WalkLimb {
713    /// The door's word for the same limb ([`DoorLimb`]: the interpreter's
714    /// trip is `Work` there).
715    #[must_use]
716    pub fn door_limb(self) -> DoorLimb {
717        match self {
718            WalkLimb::OverWork | WalkLimb::InterpreterMemory => DoorLimb::Work,
719            WalkLimb::OverMemory => DoorLimb::Memory,
720        }
721    }
722
723    /// The limb's name in a log line and a counter's reason.
724    #[must_use]
725    pub fn as_str(self) -> &'static str {
726        match self {
727            WalkLimb::OverWork => "over_work",
728            WalkLimb::OverMemory => "over_memory",
729            WalkLimb::InterpreterMemory => "interpreter_memory",
730        }
731    }
732}
733
734/// What a submit does when its own script walk could not run within the
735/// engine's [`DoorBudget`] (bsv-low #592; the E592 lens fold, H1, the owner's
736/// ruling 3a read with the lens): a breach is NEVER an admission without the
737/// network's word on record.
738///
739/// The work limb is a static estimate from the bytes, and a LOCK can be made
740/// to pass it cheaply (a bare `OP_CHECKMULTISIG` in a fabricated parent's
741/// lock is charged 3,971 keys). So a breach says nothing about whether the
742/// spend is valid, and on a path where nothing else judges it an admission
743/// would admit a spend nobody checked: the lens's forged spend of a held coin.
744#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
745pub enum WalkBreachPolicy {
746    /// "Not now" ([`EngineError::WalkCouldNotRun`]): nothing judged, nothing
747    /// written, never a final refusal. Every door but one: the ungated
748    /// modes, `/admin/readmit`, the peer crawler, a library consumer's
749    /// submit; the GASP anchor HOLDS its UTXO the same way.
750    #[default]
751    NotNow,
752    /// The network's ACCEPT of these bytes is on record (the queue's replay
753    /// of a gated submission, whose producer broadcast them and marked the
754    /// message): the submit goes on exactly as under `HistoricalTxNoSpv`,
755    /// reported on [`MutationReport::walk_could_not_run`]. The roots the walk
756    /// reached are still checked, and a bad one is still the `SpvError` it is.
757    NetworkAccepted,
758}
759
760/// A submit whose own script walk COULD NOT RUN within the engine's
761/// [`DoorBudget`] (bsv-low #592, the owner's ruling 3a of 2026-10-10). Shaped
762/// like [`EngineError::ScriptWalkOverBudget`]. Under
763/// [`WalkBreachPolicy::NotNow`] it is carried by
764/// [`EngineError::WalkCouldNotRun`] (not now); under
765/// [`WalkBreachPolicy::NetworkAccepted`] the submit went on as under
766/// `HistoricalTxNoSpv` and this says so on its [`MutationReport`]. The
767/// reference (`Engine.submit`, `tx.verify`) has no budget; this is the
768/// platform's addition, on the door's lineage (D8).
769#[derive(Debug, Clone, PartialEq, Eq)]
770pub struct WalkCouldNotRun {
771    /// The submitted transaction.
772    pub subject_txid: String,
773    /// The transaction the walk stopped at.
774    pub at_txid: String,
775    /// Whether the subject's own inputs had all executed before the stop.
776    pub subject_judged: bool,
777    /// The limb it went past.
778    pub limb: WalkLimb,
779    /// The estimate against the limb, in the door's words.
780    pub what: String,
781}
782
783/// What the walk did (the door's cost instrument, since `Date.now()` is
784/// frozen during synchronous work on Workers and cannot time it).
785#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
786pub struct WalkStats {
787    /// Unproven transactions whose inputs were executed.
788    pub unproven_txs: usize,
789    /// Inputs executed (scripts run).
790    pub inputs_executed: usize,
791    /// Unlocking + locking script bytes executed.
792    pub script_bytes: usize,
793    /// Hash-class opcodes seen (SHA1/SHA256/HASH160/HASH256/RIPEMD160).
794    pub hash_ops: usize,
795    /// Signature checks seen (CHECKSIG counts 1, CHECKMULTISIG counts its
796    /// consensus maximum of 20).
797    pub sig_ops: usize,
798    /// The static work estimate charged against the budget.
799    pub work_bytes: u64,
800    /// The memory estimate charged against the budget: the frame's, made
801    /// before the stream was opened, plus the largest charge of one walked
802    /// input's scripts, made before they were parsed.
803    pub memory_bytes: u64,
804    /// Whether the SUBJECT's own inputs were all executed (an ancestor may
805    /// still have faulted afterwards).
806    pub subject_judged: bool,
807}
808
809/// Static opcode census of one input's scripts: (bytes, hash ops, sig ops).
810///
811/// A CHECKMULTISIG is weighted by its KEY COUNT: the small-int push that
812/// precedes it when the script states the count (the Poc5 covenant's
813/// `OP_3`), else the most 33-byte keys the element limit admits (the count is
814/// a computed value the census cannot see; bsv-rs allows up to `i32::MAX`).
815pub(crate) fn script_census(
816    unlocking: &[u8],
817    locking: &[u8],
818    memory_limit: usize,
819) -> Result<(usize, usize, usize), String> {
820    use bsv_rs::script::op::*;
821    let most_keys = (memory_limit / 33).max(1);
822    let mut hash_ops = 0usize;
823    let mut sig_ops = 0usize;
824    for (label, bytes) in [("unlocking", unlocking), ("locking", locking)] {
825        let script = bsv_rs::script::Script::from_binary(bytes)
826            .map_err(|e| format!("{label} script does not parse: {e}"))?;
827        let chunks = script.chunks();
828        for (i, chunk) in chunks.iter().enumerate() {
829            match chunk.op {
830                OP_RIPEMD160 | OP_SHA1 | OP_SHA256 | OP_HASH160 | OP_HASH256 => hash_ops += 1,
831                OP_CHECKSIG | OP_CHECKSIGVERIFY => sig_ops += 1,
832                OP_CHECKMULTISIG | OP_CHECKMULTISIGVERIFY => {
833                    let stated = i
834                        .checked_sub(1)
835                        .map(|j| chunks[j].op)
836                        .and_then(|op| match op {
837                            OP_1..=OP_16 => Some((op - OP_1 + 1) as usize),
838                            _ => None,
839                        });
840                    sig_ops += stated.unwrap_or(most_keys);
841                }
842                _ => {}
843            }
844        }
845    }
846    Ok((unlocking.len() + locking.len(), hash_ops, sig_ops))
847}
848
849impl Engine {
850    /// Page size used by `/requestSyncResponse` when the (public) caller
851    /// omits `limit`. A bounded page is not lossy for a conforming
852    /// initiator: ours ([`crate::gasp::GASPSync`]) keeps requesting pages
853    /// for as long as the `since` score cursor advances (gate finding M1 —
854    /// it must NOT require a "full" page to continue, because this clamp
855    /// makes a full-by-the-initiator's-limit page impossible).
856    pub const SYNC_RESPONSE_DEFAULT_LIMIT: u64 = 500;
857    /// Hard cap on a `/requestSyncResponse` page. Before bsv-low #291 an
858    /// absent/huge caller-supplied limit meant an unbounded scan of the
859    /// outputs table on a public route.
860    pub const SYNC_RESPONSE_MAX_LIMIT: u64 = 1000;
861
862    /// Clamp the public `/requestSyncResponse` page size: absent → the
863    /// default page; anything larger than the cap → the cap. The result is
864    /// ALWAYS `Some`-worthy — a sync response is never unbounded (bsv-low
865    /// #291). Completeness relies on the INITIATOR paging while its `since`
866    /// cursor advances (see `SYNC_RESPONSE_DEFAULT_LIMIT`'s doc) — the
867    /// responder alone cannot page for it.
868    pub fn clamp_sync_limit(requested: Option<u64>) -> u64 {
869        requested
870            .unwrap_or(Self::SYNC_RESPONSE_DEFAULT_LIMIT)
871            .min(Self::SYNC_RESPONSE_MAX_LIMIT)
872    }
873
874    /// Create a new Overlay Services Engine.
875    pub fn new(
876        managers: HashMap<String, Box<dyn TopicManager>>,
877        lookup_services: HashMap<String, Box<dyn LookupService>>,
878        storage: Box<dyn Storage>,
879        advertiser: Option<Box<dyn Advertiser>>,
880        config: EngineConfig,
881    ) -> Self {
882        Self::with_chain_tracker(
883            managers,
884            lookup_services,
885            storage,
886            advertiser,
887            None,
888            None,
889            config,
890        )
891    }
892
893    /// Create a new Engine with an optional ChainTracker for SPV verification.
894    pub fn with_chain_tracker(
895        managers: HashMap<String, Box<dyn TopicManager>>,
896        lookup_services: HashMap<String, Box<dyn LookupService>>,
897        storage: Box<dyn Storage>,
898        advertiser: Option<Box<dyn Advertiser>>,
899        broadcaster: Option<Box<dyn Broadcaster>>,
900        chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
901        config: EngineConfig,
902    ) -> Self {
903        Self::with_all(
904            managers,
905            lookup_services,
906            storage,
907            advertiser,
908            broadcaster,
909            None,
910            chain_tracker,
911            config,
912        )
913    }
914
915    /// Create a new Engine with all optional components.
916    #[allow(clippy::too_many_arguments)]
917    pub fn with_all(
918        managers: HashMap<String, Box<dyn TopicManager>>,
919        lookup_services: HashMap<String, Box<dyn LookupService>>,
920        storage: Box<dyn Storage>,
921        advertiser: Option<Box<dyn Advertiser>>,
922        broadcaster: Option<Box<dyn Broadcaster>>,
923        arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
924        chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
925        mut config: EngineConfig,
926    ) -> Self {
927        // Build default sync configuration: SHIP for all topics except tm_ship/tm_slap
928        // which get their trackers merged with provided shipTrackers/slapTrackers.
929        for manager_name in managers.keys() {
930            if manager_name == "tm_ship" {
931                if matches!(
932                    config.sync_configuration.get(manager_name),
933                    Some(SyncTarget::Disabled)
934                ) {
935                    continue;
936                }
937                let mut combined: HashSet<String> = HashSet::new();
938                if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
939                {
940                    combined.extend(peers.iter().cloned());
941                }
942                combined.extend(config.ship_trackers.iter().cloned());
943                if !combined.is_empty() {
944                    config.sync_configuration.insert(
945                        manager_name.clone(),
946                        SyncTarget::Peers(combined.into_iter().collect()),
947                    );
948                }
949            } else if manager_name == "tm_slap" {
950                if matches!(
951                    config.sync_configuration.get(manager_name),
952                    Some(SyncTarget::Disabled)
953                ) {
954                    continue;
955                }
956                let mut combined: HashSet<String> = HashSet::new();
957                if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
958                {
959                    combined.extend(peers.iter().cloned());
960                }
961                combined.extend(config.slap_trackers.iter().cloned());
962                if !combined.is_empty() {
963                    config.sync_configuration.insert(
964                        manager_name.clone(),
965                        SyncTarget::Peers(combined.into_iter().collect()),
966                    );
967                }
968            } else if !config.sync_configuration.contains_key(manager_name) {
969                config
970                    .sync_configuration
971                    .insert(manager_name.clone(), SyncTarget::Ship);
972            }
973        }
974
975        Self {
976            managers,
977            lookup_services,
978            storage: Box::new(crate::load_storage::MeteredStorage(storage)),
979            advertiser,
980            broadcaster,
981            arc_broadcaster,
982            chain_tracker,
983            gasp_remote_factory: None,
984            ship_lookup_client: None,
985            ship_bootstrap_peers: HashMap::new(),
986            ancestor_fetcher: None,
987            gasp_ancestor_fetcher: None,
988            peer_sync_budget: None,
989            graph_budget: None,
990            graph_budget_limbs: (
991                crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES,
992                crate::gasp::DEFAULT_GRAPH_BUDGET_NODES,
993            ),
994            finalize_submit_budget: None,
995            finalize_gate: crate::gasp::SubmitGate::default(),
996            not_landed: std::cell::RefCell::new(HashSet::new()),
997            landing_guard: None,
998            verify_scripts: true,
999            walk_budget: DoorBudget::DEFAULT,
1000            config,
1001        }
1002    }
1003
1004    /// Set the ARC broadcaster for network broadcast to miners.
1005    ///
1006    /// When set, the Engine broadcasts transactions to ARC during Phase 2
1007    /// of `submit()` for `CurrentTx` submissions.
1008    pub fn set_arc_broadcaster(&mut self, arc: Box<dyn ArcBroadcaster>) {
1009        self.arc_broadcaster = Some(arc);
1010    }
1011
1012    /// Set the GASP remote factory for peer communication during sync.
1013    ///
1014    /// Platform-specific crates (like overlay-cloudflare) provide an implementation
1015    /// that creates HTTP-based remotes using their native fetch API.
1016    pub fn set_gasp_remote_factory(&mut self, factory: Box<dyn crate::gasp::GASPRemoteFactory>) {
1017        self.gasp_remote_factory = Some(factory);
1018    }
1019
1020    /// Set the platform lookup transport for network SHIP discovery.
1021    /// Trackers come from [`EngineConfig::slap_trackers`].
1022    pub fn set_ship_lookup_client(
1023        &mut self,
1024        client: Box<dyn crate::ship_lookup::ShipLookupClient>,
1025    ) {
1026        self.ship_lookup_client = Some(client);
1027    }
1028
1029    /// Keep these peers in a topic's SHIP union even when trackers fail.
1030    /// This leaves the existing [`SyncTarget`] serialization unchanged.
1031    /// Such topics run ahead of tracker imports so discovery and sync can
1032    /// proceed without spending the tick on importing discovery records.
1033    pub fn set_ship_bootstrap_peers(&mut self, topic: impl Into<String>, peers: Vec<String>) {
1034        self.ship_bootstrap_peers.insert(topic.into(), peers);
1035    }
1036
1037    /// Set the OPT-IN chain-backed ancestor fetcher for GASP ingest.
1038    ///
1039    /// **OFF BY DEFAULT.** When set, a peer's failure to serve a needed
1040    /// ancestor during GASP ingest falls back to fetching that ancestor's raw
1041    /// tx from chain (via the supplied fetcher), self-healing the graph and
1042    /// also enabling strict-BEEF finalize so missing ancestry fails loud.
1043    ///
1044    /// This is a deliberate one-time-migration escape hatch. Production must
1045    /// NOT call this — when unset, GASP ingest behavior is unchanged.
1046    pub fn set_ancestor_fetcher(&mut self, fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>) {
1047        self.ancestor_fetcher = Some(fetcher);
1048    }
1049
1050    /// Supply a courier reserved for sync. Completion retains its own budget.
1051    pub fn set_gasp_ancestor_fetcher(
1052        &mut self,
1053        fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>,
1054    ) {
1055        self.gasp_ancestor_fetcher = Some(fetcher);
1056    }
1057
1058    /// Set the admission predicate asked BEFORE the door lands a carried
1059    /// predecessor (bsv-low #575, the E1D delta-2 fold, L2;
1060    /// [`Engine::land_carried`]). It is asked once per body the landing
1061    /// would submit, before that body's submit (a dry one included) and
1062    /// before its reads are charged; an `Err` (the caller's refusal, or its
1063    /// own read fault) ends the landing with nothing of that body written
1064    /// and the successor's topic answers "not now", as on `683dffd`. The
1065    /// worker installs its eviction ledger here: an OPEN eviction of the
1066    /// body refuses it. Its read is not one of the submit's 256 (it is the
1067    /// caller's store, at most one per landed body). Unset (default): every
1068    /// carried predecessor may land. What the predicate cannot see (a row
1069    /// opened while the landing writes) stays the caller's to guard after
1070    /// the write ([`MutationReport::landed_predecessors`]).
1071    pub fn set_landing_guard(&mut self, guard: LandingGuard) {
1072        self.landing_guard = Some(guard);
1073    }
1074
1075    /// Set the per-peer GASP sync budget (bsv-low#302).
1076    ///
1077    /// `sleep` is the platform sleep factory (ms → future); `budget_ms` is
1078    /// the wall-clock slice ONE peer's sync may consume. A peer exceeding it
1079    /// is dropped (loudly logged, listed in the topic's `errors`) and the
1080    /// loop moves on. Unset (default) = unbounded, the pre-#302 behavior.
1081    ///
1082    /// **Progress survives the deadline (bsv-low #552).** With a budget set,
1083    /// every graph is submitted AS IT FINALIZES, inside the raced future (the
1084    /// reference submits inside `finalizeGraph` too), so what was finalized
1085    /// before the deadline stays admitted. At the deadline the cursor is
1086    /// advanced to `GASPSync::completed_cursor`: past the UTXOs whose graphs
1087    /// were completed, never past the one in flight. That graph is lost
1088    /// whole (nothing of it was finalized) and the next tick walks it again,
1089    /// down to whatever is now in storage.
1090    ///
1091    /// **The deadline never lands inside a transaction's writes** (the lens
1092    /// fold's HIGH-1). It is cooperative around a finalize submit
1093    /// ([`Engine::finalize_submit_gate`]): a deadline that falls due while
1094    /// one transaction is being written waits for that transaction, and the
1095    /// sync is dropped at the boundary. What a dropped tick leaves of the
1096    /// graph being submitted is an ancestors-first prefix of WHOLE
1097    /// transactions, with the cursor below its UTXO. Everywhere else (a
1098    /// request to the peer, the walk, the anchor check) the deadline drops
1099    /// the sync at once, as a budget against a dead peer must. A dropped tick that finalized a
1100    /// graph or moved the cursor is recorded as a SUCCESSFUL attempt for the
1101    /// quarantine count: a peer serving a long bootstrap is not a dead peer.
1102    /// One with no progress is a failed attempt, as before.
1103    ///
1104    /// The budget bounds the work of a TICK. Without a per-graph budget
1105    /// nothing bounds a GRAPH (parity: the reference has no node cap): one
1106    /// graph whose own walk outlasts the budget is dropped on every tick
1107    /// (`deadline_dropped_graphs`) and never admitted. With one
1108    /// ([`Engine::set_graph_budget`], bsv-low #555) that walk is deferred
1109    /// with its progress kept, here too, and resumed next tick.
1110    pub fn set_peer_sync_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1111        self.peer_sync_budget = Some((sleep, budget_ms));
1112    }
1113
1114    /// Set the per-GRAPH GASP budget and turn DEFERRAL on (bsv-low #555).
1115    ///
1116    /// A graph whose walk makes `max_calls` requests (to the peer, or chain
1117    /// fetches) or runs `budget_ms` in one pass is DEFERRED: its partial walk
1118    /// (the nodes fetched with their proofs, the inputs still pending, the
1119    /// calls spent, its age in passes) is saved as ONE record of the storage
1120    /// ([`Storage::put_deferred_graph`], replaced on every deferral), its
1121    /// UTXO is held below the cursor as a failed one is (the gap guard) and
1122    /// not walked again in the sync (#554), and the sync goes on to the next
1123    /// UTXO and topic. The next sync that is served that UTXO RESUMES the
1124    /// walk from the record, asking only what is pending, under the same
1125    /// budget; a graph so converges over passes and is then completed as
1126    /// any graph is (#551's anchor check, the finalize submits, e1d's
1127    /// rules). A walk cut by the per-peer deadline is deferred the same way.
1128    ///
1129    /// Bounds: a record deferred [`crate::gasp::DEFERRED_GRAPH_MAX_PASSES`]
1130    /// times is dropped with its reason and the UTXO fails as before (the
1131    /// gap guard asks again, from the root); a record has no byte bound
1132    /// (bsv-low #585); at most
1133    /// [`crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC`] records per (peer,
1134    /// topic). Keep `budget_ms` below the per-peer budget so that a deep
1135    /// graph leaves the pass time for the UTXOs after it. Defaults offered:
1136    /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_CALLS`],
1137    /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_MS`].
1138    ///
1139    /// Every resume of one sync shares ONE such budget (the lens fold's M1),
1140    /// so the deferred UTXOs, served first, leave the pass the rest; a walk
1141    /// that fetched nothing keeps no record and is a failed attempt for the
1142    /// quarantine (H1); a walk that cannot be kept, or whose save faulted,
1143    /// goes on under the per-peer budget alone (L4, the delta fold's D-L2);
1144    /// a FRESH walk's error keeps no record (D-M1). With a budget, a sync the
1145    /// peer served work that finalized no graph and moved no cursor is
1146    /// YIELDLESS, and past [`crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED`] in a
1147    /// row AND [`crate::gasp::PEER_YIELDLESS_SECS_ALLOWED`] since the streak
1148    /// began each is a failed attempt (D-M2, the delta-2 fold's D2-M1; the
1149    /// streak is kept by [`Storage::record_peer_sync_yield`], keyed on the
1150    /// peer's [`crate::gasp::peer_origin`] as its quarantine is). A FRESH
1151    /// walk that faults after half its calls or half its time keeps its
1152    /// record (D2-L1); a RESUMED one that faults having appended nothing
1153    /// [`crate::gasp::DEFERRED_GRAPH_MAX_IDLE_FAULTS`] passes in a row is
1154    /// dropped (D2-M2). Each record says whether its peer is configured
1155    /// (`SyncTarget::Peers`), for a storage that reserves part of its
1156    /// ceiling for those (the worker reserves half).
1157    ///
1158    /// Unset (the default) nothing is deferred and the walk is the one
1159    /// before #555 (parity: the reference has no budget and no deferral).
1160    /// The storage must KEEP records (the four required deferred-graph
1161    /// methods of [`Storage`]): over one that refuses every save, every
1162    /// graph past the budget is walked from its root under the per-peer
1163    /// budget alone on every pass, as before #555, so do not set a budget
1164    /// there.
1165    ///
1166    /// The budget has two more limbs (bsv-low #586), in force with it at
1167    /// their defaults: the bytes one graph may be SERVED in one pass
1168    /// ([`crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES`], a budget per pass only:
1169    /// a record has no byte bound since bsv-low #585 door 4) and the nodes it
1170    /// may APPEND ([`crate::gasp::DEFAULT_GRAPH_BUDGET_NODES`], 64);
1171    /// [`Engine::set_graph_budget_limbs`] names others.
1172    pub fn set_graph_budget(&mut self, sleep: SleepFactory, max_calls: u32, budget_ms: u64) {
1173        self.graph_budget = Some((sleep, max_calls, budget_ms));
1174    }
1175
1176    /// The bytes and nodes limbs of the per-graph budget (bsv-low #586,
1177    /// zanaadu-v2 #377): a graph whose walk is SERVED `max_bytes_fetched`
1178    /// bytes in one pass (the hex of each node's raw transaction and proof,
1179    /// as the peer sends it and a record keeps it, every answer counted), or
1180    /// APPENDS `max_nodes` nodes, is DEFERRED exactly as at the call budget
1181    /// (reason `bytes` or `nodes`): its record saved, its UTXO held below
1182    /// the cursor, the walk resumed by the next pass from what is pending.
1183    /// Never a drop, a refusal or a discarded graph: these are budgets PER
1184    /// PASS, not limits on a graph or a BEEF. A pass always makes one
1185    /// request whatever a node's size; the resumes of one sync share one
1186    /// such budget, as they share its calls (M1); a walk that cannot be
1187    /// KEPT goes on under the per-peer budget alone (L4), as at the calls.
1188    /// They bound what one pass adds to the pending graph and to its record
1189    /// while the anchor check and the finalize hold it in memory. No effect
1190    /// until [`Engine::set_graph_budget`] turns deferral on; the reference
1191    /// has neither. This setter is UNCAPPED, as the worker's
1192    /// `GASP_GRAPH_BUDGET_BYTES` var is (bsv-low #585 door 4: the engine keeps
1193    /// no record cap for the limb to sit under). A consumer whose STORAGE caps
1194    /// a record must keep the bytes limb under that cap, or a pass whose
1195    /// record cannot be saved walks afresh (E586-L1).
1196    pub fn set_graph_budget_limbs(&mut self, max_bytes_fetched: u64, max_nodes: u32) {
1197        self.graph_budget_limbs = (max_bytes_fetched, max_nodes);
1198    }
1199
1200    /// Bound ONE transaction's GASP finalize submit (bsv-low #559, the delta
1201    /// lens's DELTA-3). That submit is the write section no deadline drops
1202    /// ([`Engine::finalize_submit_gate`]), so a storage call or lookup hook
1203    /// that never answers inside it held the per-peer budget and any outer
1204    /// race for as long as it hung. With this set EACH storage call, lookup
1205    /// hook and topic manager call of that submit is raced against one
1206    /// `sleep(budget_ms)` of the section (the lens fold of 2026-10-07, F2):
1207    /// a call still unanswered when it falls due is dropped alone and is
1208    /// that call's FAULT, and no call is started after it. The SUBMIT is
1209    /// never dropped: it runs to its end and leaves what a fault leaves
1210    /// ([`Engine::submit_with_report`]: nothing of the transaction, or all
1211    /// of it), the transaction did not land, the rest of its graph is not
1212    /// submitted, its UTXO fails and the cursor stays below it. The undo of
1213    /// a faulted submit has one more `sleep(budget_ms)` of its own, so a
1214    /// peer costs at most its budget plus twice this one. What the backend
1215    /// does with a statement whose caller stopped waiting is its own: a
1216    /// write that lands after its timeout is not seen by this submit.
1217    ///
1218    /// DEFAULT: none (a native engine has no clock). A caller that sets a
1219    /// per-peer budget should set this too.
1220    pub fn set_finalize_submit_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1221        self.finalize_submit_budget = Some((sleep, budget_ms));
1222    }
1223
1224    /// The gate around every GASP finalize submit (bsv-low #552, the lens
1225    /// fold's HIGH-1). `start_gasp_sync` races each peer against its budget
1226    /// with [`crate::gasp::race_or_deadline_guarded`] over this gate. A
1227    /// caller that races `start_gasp_sync` ITSELF against a deadline (the
1228    /// worker's scheduled step) must use the same function over the same
1229    /// gate: a plain `race_or_deadline` there can still drop a finalize
1230    /// submit between its writes.
1231    pub fn finalize_submit_gate(&self) -> &crate::gasp::SubmitGate {
1232        &self.finalize_gate
1233    }
1234
1235    /// Turn reference-parity script verification on submit on or off.
1236    /// **DEFAULT ON** (set in every constructor).
1237    ///
1238    /// On, every submit outside `HistoricalTxNoSpv` executes every unproven
1239    /// input's unlocking script against its source output (and checks every
1240    /// merkle path against the chain tracker), exactly as the reference's
1241    /// `Engine.submit` → `tx.verify(this.chainTracker)` does. Off is an
1242    /// ESCAPE HATCH for an operator who must admit a body the interpreter
1243    /// refuses (a bsv-rs interpreter defect, say) while it is fixed. It is
1244    /// NOT a mode: it restores the pre-2026-09-08 structural check, under
1245    /// which an invalid spend that no broadcaster had yet refused was
1246    /// admitted on BEEF structure alone. Prefer
1247    /// [`crate::builder::EngineBuilder::with_script_verification`].
1248    pub fn set_script_verification(&mut self, enabled: bool) {
1249        self.verify_scripts = enabled;
1250    }
1251
1252    /// Whether submits execute input scripts, see
1253    /// [`Engine::set_script_verification`]. `true` by default.
1254    pub fn script_verification(&self) -> bool {
1255        self.verify_scripts
1256    }
1257
1258    /// The budget of the submit's own script walk (bsv-low #592, the owner's
1259    /// ruling 3a of 2026-10-10). Every submit that walks (every mode but
1260    /// `HistoricalTxNoSpv`), and the GASP anchor check, read the BEEF through
1261    /// the door's stream walk ([`crate::script_door`]) under this budget's
1262    /// two limbs, the work and the memory, each estimated from the bytes
1263    /// before it is spent. A breach is "the walk could not run"
1264    /// ([`WalkCouldNotRun`], on [`MutationReport::walk_could_not_run`]),
1265    /// never a refusal: the submit goes on as under `HistoricalTxNoSpv`.
1266    /// A library consumer that sets nothing gets [`DoorBudget::DEFAULT`]
1267    /// (64 MiB of work, 48 MiB of memory). Prefer
1268    /// [`crate::builder::EngineBuilder::with_walk_budget`].
1269    pub fn set_walk_budget(&mut self, budget: DoorBudget) {
1270        self.walk_budget = budget;
1271    }
1272
1273    /// The budget of the submit's own script walk, see
1274    /// [`Engine::set_walk_budget`].
1275    pub fn walk_budget(&self) -> DoorBudget {
1276        self.walk_budget
1277    }
1278
1279    /// The reference's `tx.verify('scripts only')` over a BEEF: every unproven
1280    /// transaction from `subject_txid` down executes every input's unlocking
1281    /// script against its source output and obeys the value rule, while a
1282    /// transaction that carries a merkle path is TRUSTED AS-IS — no root is
1283    /// computed and no chain tracker is consulted (the ts-sdk's own shape when
1284    /// `chainTracker === 'scripts only'`: a proven tx is added to the verified
1285    /// set and the walk stops there).
1286    ///
1287    /// Built for an admission path whose bar is the NETWORK, not a proof
1288    /// (bsv-low's `broadcast-gated`, register row D1): the overlay broadcasts
1289    /// and admits on network evidence, so a lagging tracker must never read as
1290    /// a refused transaction — but a spend the interpreter refuses is one the
1291    /// network will refuse too, and executing it at the door saves the
1292    /// broadcast and names the fault. Independent of
1293    /// [`Engine::set_script_verification`] (that switch governs `submit`; this
1294    /// is an explicit ask) and of [`Engine::submit`]'s mode (`HistoricalTxNoSpv`
1295    /// still skips, as the reference does).
1296    ///
1297    /// Runs under the engine's budget ([`Engine::set_walk_budget`], the
1298    /// submit's own; [`DoorBudget::DEFAULT`] unless set: one budget per
1299    /// engine, the E592 lens fold's NOTE-2): the work is bounded statically
1300    /// before anything executes, and the interpreter's stack memory limit is
1301    /// the budget's element limit. The body is read through the streaming
1302    /// reader ([`crate::script_door`], bsv-low #585): no bound on its size,
1303    /// its transactions or a transaction's inputs or bytes; the budget's two
1304    /// limbs are the work and the memory beside the body.
1305    ///
1306    /// Errors: [`EngineError::ScriptVerificationFailed`] is the INTERPRETER's
1307    /// verdict and the only refusal; [`EngineError::ScriptWalkInconclusive`]
1308    /// is a structural fault (a transaction or source missing from the BEEF,
1309    /// a parse fault, the value rule) and [`EngineError::ScriptWalkOverBudget`]
1310    /// the door's own bound (the static budget or the memory limit) — both
1311    /// name whether the SUBJECT was judged before the fault, and a caller with
1312    /// a stronger bar behind it does not refuse on either.
1313    #[allow(
1314        clippy::unused_async,
1315        reason = "the door's callers await it on an engine; 'scripts only' asks no tracker"
1316    )]
1317    pub async fn verify_scripts_only(
1318        &self,
1319        beef_bytes: &[u8],
1320        subject_txid: &str,
1321    ) -> Result<WalkStats, EngineError> {
1322        crate::script_door::walk(beef_bytes, subject_txid, self.walk_budget)
1323    }
1324
1325    /// [`Engine::verify_scripts_only`] under a budget the caller names (the
1326    /// pins measure the door with a limb lifted; a caller with another
1327    /// platform's ceiling sets its own).
1328    #[allow(
1329        clippy::unused_async,
1330        clippy::unused_self,
1331        reason = "the twin of `verify_scripts_only`"
1332    )]
1333    pub async fn verify_scripts_only_under(
1334        &self,
1335        beef_bytes: &[u8],
1336        subject_txid: &str,
1337        budget: DoorBudget,
1338    ) -> Result<WalkStats, EngineError> {
1339        crate::script_door::walk(beef_bytes, subject_txid, budget)
1340    }
1341
1342    // ========================================================================
1343    // Submit — 3-phase pipeline
1344    // ========================================================================
1345
1346    /// Validate a tagged BEEF without applying mutations.
1347    ///
1348    /// Runs Phase 1 (topic validation) and Phase 2 (broadcast) but NOT Phase 3
1349    /// (storage mutations). Returns the Steak (admittance decisions) that
1350    /// `submit()` would return.
1351    ///
1352    /// Used by the onSteakReady pattern to return results to clients before
1353    /// mutations are applied via a queue consumer or `ctx.wait_until()`.
1354    ///
1355    /// The topic managers are called with `dry_run: true`
1356    /// (`TopicAdmittanceContext::DRY_RUN`): nothing is admitted on this call, so a
1357    /// manager that writes on admission leaves no trace of it.
1358    pub async fn submit_validate_only(
1359        &self,
1360        tagged_beef: &TaggedBEEF,
1361        mode: SubmitMode,
1362    ) -> Result<Steak, EngineError> {
1363        // A validate-only call admits nothing: a dry run by its own name. A
1364        // manager that writes on admission must not write here, or the real
1365        // submit that follows meets a head this call already advanced.
1366        let (_validations, steak, _tx, _txid, _could_not_run) = self
1367            .run_validation(
1368                tagged_beef,
1369                mode,
1370                &TopicAdmittanceContext::DRY_RUN,
1371                None,
1372                None,
1373                WalkBreachPolicy::NotNow,
1374            )
1375            .await?;
1376        Ok(steak)
1377    }
1378
1379    /// Submit a transaction for processing by the overlay.
1380    ///
1381    /// Three phases:
1382    /// 1. **VALIDATE** — check topics, dedup, find previous coins, call topic managers
1383    /// 2. **BROADCAST** — broadcast to network (if mode is CurrentTx)
1384    /// 3. **MUTATE** — mark spent, delete stale, insert new outputs, notify lookup services
1385    ///
1386    /// Returns a STEAK mapping each topic to its admittance instructions.
1387    ///
1388    /// Phase-3 faults are reported through [`Engine::submit_with_report`];
1389    /// this convenience keeps the historical signature and only LOGS the
1390    /// report's summary. A caller that must not ack an admission whose
1391    /// writes did not land (the `/submit` route — S2) uses the report form.
1392    pub async fn submit(
1393        &self,
1394        tagged_beef: &TaggedBEEF,
1395        mode: SubmitMode,
1396    ) -> Result<Steak, EngineError> {
1397        let (steak, report) = self.submit_with_report(tagged_beef, mode).await?;
1398        if !report.is_durable() {
1399            warn!(
1400                "submit: {} mutation fault(s) — not durable: {}",
1401                report.faults.len(),
1402                report.summary()
1403            );
1404        }
1405        Ok(steak)
1406    }
1407
1408    /// [`Engine::submit`] plus the Phase-3 durability report (S2
1409    /// queue-durable admission, bsv-low 2026-08-29).
1410    ///
1411    /// The STEAK is the admission DECISION; the report says whether the
1412    /// decision was DURABLY WRITTEN. They are returned together because
1413    /// they are answered by different things: Phase 1+2 decide, Phase 3
1414    /// lands, and only the caller knows whether an undurable landing may
1415    /// be acked (never, on the money path — re-queue it instead).
1416    ///
1417    /// Every write failure is still logged AND still non-fatal to the
1418    /// other topics (a faulted topic does not un-admit its siblings); what
1419    /// changed is that the failure is also VISIBLE, and that the faulted
1420    /// topic is not recorded as applied — so a replay of the same bytes is
1421    /// re-validated and re-written (every backend write is idempotent:
1422    /// `INSERT OR IGNORE` / `OR REPLACE` / `UPDATE`).
1423    ///
1424    /// What a fault leaves (bsv-low #559 and its lens fold of 2026-10-07):
1425    /// a topic's previous coins are deleted once EVERY admitted output of
1426    /// the transaction is inserted, and only then. A fault before that
1427    /// (a validation read, the spent mark of a coin the manager retains, an
1428    /// insert) leaves the previous coins held and nothing of the
1429    /// transaction: what did get inserted is taken out again, so the replay
1430    /// is the same judgement and no successor can spend half of it. A fault
1431    /// after it (a lookup notification, the consumed-by update, the applied
1432    /// row) leaves the transaction's outputs and no previous coin. A fault
1433    /// before any delete was started (the record of the spent coin's
1434    /// transaction, the read of the coin) is the first case if the coins
1435    /// are read back still held; once a delete was started its fault never
1436    /// undoes, answered or not (it may land yet): the outputs stay beside
1437    /// the stale coin, marked spent. A submit that deletes a stale coin
1438    /// first records that coin's transaction as applied, so a replay of it
1439    /// after its successor is a dupe. So a non-retaining chain never has
1440    /// two unspent heads and never none, whatever single call faults. The
1441    /// applied row is withheld on ANY fault. And a successor that was
1442    /// judged without the coin an unlanded predecessor has yet to leave is
1443    /// reported as a fault (`predecessor_not_landed`), not recorded as
1444    /// applied.
1445    ///
1446    /// A walk that could not run within the engine's budget is "not now"
1447    /// here ([`WalkBreachPolicy::NotNow`], [`EngineError::WalkCouldNotRun`]):
1448    /// never an admission. [`Engine::submit_with_report_under`] names the
1449    /// policy.
1450    pub async fn submit_with_report(
1451        &self,
1452        tagged_beef: &TaggedBEEF,
1453        mode: SubmitMode,
1454    ) -> Result<(Steak, MutationReport), EngineError> {
1455        self.submit_with_report_under(tagged_beef, mode, WalkBreachPolicy::NotNow)
1456            .await
1457    }
1458
1459    /// [`Engine::submit_with_report`] under an explicit
1460    /// [`WalkBreachPolicy`] (the E592 lens fold, H1): what a walk that could
1461    /// not run within the budget does. `NetworkAccepted` is for a caller that
1462    /// holds the network's accept of these very bytes (the queue's replay of
1463    /// a gated submission) and for nothing else: it admits a breached body
1464    /// with its scripts unrun.
1465    pub async fn submit_with_report_under(
1466        &self,
1467        tagged_beef: &TaggedBEEF,
1468        mode: SubmitMode,
1469        policy: WalkBreachPolicy,
1470    ) -> Result<(Steak, MutationReport), EngineError> {
1471        self.submit_bounded(tagged_beef, mode, None, false, policy)
1472            .await
1473    }
1474
1475    /// [`Engine::submit_with_report`] with every storage call, lookup hook
1476    /// and topic manager call under `bound` (the GASP finalize submit,
1477    /// [`CallBound`]); `None` is the unbounded submit of every other door.
1478    /// `finalize` is `true` for the GASP finalize submit alone, bounded or
1479    /// not: it does not ask the store's predecessor question.
1480    async fn submit_bounded(
1481        &self,
1482        tagged_beef: &TaggedBEEF,
1483        mode: SubmitMode,
1484        bound: Option<&CallBound>,
1485        finalize: bool,
1486        policy: WalkBreachPolicy,
1487    ) -> Result<(Steak, MutationReport), EngineError> {
1488        // The reads the store's predecessor question has made in this
1489        // submit, every topic together ([`PREDECESSOR_READS_PER_SUBMIT`]),
1490        // the predecessors the door lands first from the BEEF included.
1491        let mut question_reads = 0usize;
1492        self.submit_counted(
1493            tagged_beef,
1494            mode,
1495            bound,
1496            finalize,
1497            None,
1498            &mut question_reads,
1499            policy,
1500        )
1501        .await
1502        .map(|(steak, report, _)| (steak, report))
1503    }
1504
1505    /// [`Engine::submit_bounded`] over the submit's read allowance
1506    /// (`question_reads`), which a predecessor the door lands first from the
1507    /// BEEF shares with the successor that carried it (lane E1D's lens fold,
1508    /// M1). `carried` is `Some` for such a predecessor's own submit
1509    /// ([`Engine::land_carried`]), holding the bodies whose SPV walk already
1510    /// passed in this submit (none is walked twice; the delta fold, L4): it
1511    /// lands nothing first itself, and it names instead the carried
1512    /// predecessor that blocks each topic (the third value), so the landing
1513    /// is an explicit stack and never a recursion of submits (nested polls
1514    /// grow the stack, and a Worker's is small). Its manager is asked as a
1515    /// DRY RUN first, and for real only once its topic is not blocked: a
1516    /// link blocked below is judged twice and admitted once. Boxed: the
1517    /// landing is a submit of its own.
1518    #[allow(clippy::too_many_arguments)]
1519    fn submit_counted<'a>(
1520        &'a self,
1521        tagged_beef: &'a TaggedBEEF,
1522        mode: SubmitMode,
1523        bound: Option<&'a CallBound>,
1524        finalize: bool,
1525        carried: Option<&'a HashSet<String>>,
1526        question_reads: &'a mut usize,
1527        policy: WalkBreachPolicy,
1528    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Counted, EngineError>> + 'a>>
1529    {
1530        Box::pin(async move {
1531            self.submit_counted_inner(
1532                tagged_beef,
1533                mode,
1534                bound,
1535                finalize,
1536                carried,
1537                question_reads,
1538                policy,
1539            )
1540            .await
1541        })
1542    }
1543
1544    #[allow(clippy::too_many_arguments)]
1545    async fn submit_counted_inner(
1546        &self,
1547        tagged_beef: &TaggedBEEF,
1548        mode: SubmitMode,
1549        bound: Option<&CallBound>,
1550        finalize: bool,
1551        walked: Option<&HashSet<String>>,
1552        question_reads: &mut usize,
1553        policy: WalkBreachPolicy,
1554    ) -> Result<Counted, EngineError> {
1555        // A submit is a real admission, never a dry run; a carried
1556        // predecessor's is judged dry until its topic is known not to wait.
1557        let carried = walked.is_some();
1558        let context = if carried {
1559            TopicAdmittanceContext::DRY_RUN
1560        } else {
1561            TopicAdmittanceContext::default()
1562        };
1563        let (mut validations, mut steak, tx, txid, could_not_run) = self
1564            .run_validation(tagged_beef, mode, &context, bound, walked, policy)
1565            .await?;
1566        if finalize {
1567            let now = (current_timestamp_ms() / 1000.0) as u64;
1568            for validation in &mut validations {
1569                let manager = &self.managers[&validation.topic];
1570                validation.admittance.outputs_to_admit.retain(|index| {
1571                    tx.outputs.get(*index as usize).is_none_or(|output| {
1572                        manager
1573                            .sync_expiry(&output.locking_script)
1574                            .is_none_or(|expiry| expiry > now)
1575                    })
1576                });
1577                steak.insert(validation.topic.clone(), validation.admittance.clone());
1578            }
1579        }
1580        let mut report = MutationReport {
1581            walk_could_not_run: could_not_run,
1582            ..MutationReport::default()
1583        };
1584        // The body every LOOKUP SERVICE receives NAMES the subject (BRC-95
1585        // atomic prefix over the WHOLE submitted body — bsv-rs's
1586        // `to_binary_atomic` sorts, names, never prunes). Lookup services
1587        // re-parse it with `from_beef(_, None)`; on a plain incomplete body
1588        // that pick is the wire-last ANCESTOR, so the pot index's admit hook
1589        // shape-checked a hop and wrote no record while the engine had
1590        // admitted the pot (loop-2 fleet, 2026-09-05; the live proof after
1591        // the engine's own subject fix). A body that cannot be re-serialized
1592        // (never, for a parsed subject) is handed through as submitted.
1593        let subject_named_beef: Vec<u8> =
1594            beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
1595                .ok()
1596                .and_then(|mut b| b.to_binary_atomic(&txid).ok())
1597                .unwrap_or_else(|| tagged_beef.beef.clone());
1598
1599        // The successor rule, asked of every topic that found no coin before
1600        // anything of this submit is written ([`Engine::successors_waiting`]):
1601        // the topics that must wait, each with why.
1602        let mut blocked: HashMap<String, String> = HashMap::new();
1603        // The landing trusts the subject's walk only if it RAN (the E592
1604        // lens fold, M1): after a breach nothing it reaches was walked.
1605        let landing = Landing {
1606            policy,
1607            subject_walk_ran: report.walk_could_not_run.is_none(),
1608        };
1609        let waits = self
1610            .successors_waiting(
1611                &tx,
1612                &txid,
1613                &subject_named_beef,
1614                &mut validations,
1615                &mut steak,
1616                tagged_beef.off_chain_values.as_deref(),
1617                mode,
1618                bound,
1619                finalize,
1620                !carried,
1621                &mut blocked,
1622                question_reads,
1623                &mut report.landed_predecessors,
1624                landing,
1625                &mut report.landed_walks_could_not_run,
1626            )
1627            .await;
1628        // A carried predecessor's topic that does not wait is judged again,
1629        // for real: what it admits is this call's word, over the same coins.
1630        if carried {
1631            for v in &mut validations {
1632                if v.is_dupe || v.failed || v.read_fault.is_some() || waits.contains_key(&v.topic) {
1633                    continue;
1634                }
1635                match self
1636                    .judge(
1637                        &tx,
1638                        &v.topic,
1639                        &v.previous_coins,
1640                        tagged_beef.off_chain_values.as_deref(),
1641                        mode,
1642                        &TopicAdmittanceContext::default(),
1643                        bound,
1644                    )
1645                    .await
1646                {
1647                    Ok(admittance) => v.admittance = admittance,
1648                    Err(e) => {
1649                        error!("Error validating topic {} during submit: {e}", v.topic);
1650                        v.failed = true;
1651                        v.previous_coins.clear();
1652                        v.previous_outputs.clear();
1653                        v.admittance = AdmittanceInstructions::default();
1654                    }
1655                }
1656                steak.insert(v.topic.clone(), v.admittance.clone());
1657            }
1658        }
1659
1660        // =================================================================
1661        // PHASE 3: MUTATE STORAGE
1662        // =================================================================
1663        for v in &validations {
1664            if v.is_dupe {
1665                report.deduped_topics.push(v.topic.clone());
1666                continue;
1667            }
1668            // A failed topic comes first, a read fault of it included: a
1669            // previous-coin read that faulted and a manager that then ERRED
1670            // is a failed topic with a durable report, no fault and no
1671            // replay. Parity: the reference fails the topic and never
1672            // replays. Nothing was written, so the store is as it was.
1673            if v.failed {
1674                continue;
1675            }
1676
1677            let topic = &v.topic;
1678            let admittance = &v.admittance;
1679            let faults_before = report.faults.len();
1680
1681            // A validation read that faulted: the manager judged without a
1682            // coin the store may hold, so NOTHING is written on that
1683            // judgement (the lens fold of 2026-10-07). Writing its outputs
1684            // would leave them beside the unseen coin, for a successor to
1685            // spend before the replay deletes it.
1686            if let Some((read, err)) = &v.read_fault {
1687                report.fault(topic, read, err.clone());
1688                self.hold_unapplied(&txid, topic, "a validation read faulted, nothing written");
1689                continue;
1690            }
1691
1692            // bsv-low #559 and lane E1D: a topic that found no coin while a
1693            // predecessor's landing is unknown is "not now", recorded
1694            // nowhere ([`Engine::successors_waiting`]).
1695            if let Some(not_now) = waits.get(topic) {
1696                report.fault(topic, "predecessor_not_landed", not_now.clone());
1697                self.hold_unapplied(
1698                    &txid,
1699                    topic,
1700                    &format!("found no coin and its predecessor {not_now}"),
1701                );
1702                continue;
1703            }
1704
1705            // ── Handle stale vs retained previous coins ──
1706            let mut outputs_consumed: Vec<Outpoint> = Vec::new();
1707            let mut stale_coins: Vec<&Output> = Vec::new();
1708
1709            for (coin_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1710                let input_index = v.previous_coins[coin_idx];
1711                if admittance.coins_to_retain.contains(&input_index) {
1712                    // Retained: track as consumed (will update consumedBy later)
1713                    outputs_consumed
1714                        .push(Outpoint::new(&prev_output.txid, prev_output.output_index));
1715                } else {
1716                    // Not retained: mark as stale for deletion
1717                    stale_coins.push(prev_output);
1718                }
1719            }
1720
1721            // Update STEAK with removed coins
1722            if let Some(steak_entry) = steak.get_mut(topic) {
1723                steak_entry.coins_removed = Some(
1724                    stale_coins
1725                        .iter()
1726                        .enumerate()
1727                        .filter_map(|(i, _)| {
1728                            // Map back to input indices
1729                            v.previous_coins.get(i).copied()
1730                        })
1731                        .collect(),
1732                );
1733            }
1734
1735            // ── Mark previous outputs as spent + notify lookup services ──
1736            // A mark that faults does not skip the notification (the
1737            // reference skips it): the coin is spent whatever the row says,
1738            // a stale one is deleted below and its lookup services must
1739            // know. The mark of a RETAINED coin is part of what landed: that
1740            // row stays, and left unspent it would be listed as a UTXO
1741            // beside the output that consumed it.
1742            let mut retained_mark_faulted = false;
1743            for (prev_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1744                if let Err(e) = stored(
1745                    bound,
1746                    self.storage.mark_utxo_as_spent(
1747                        &prev_output.txid,
1748                        prev_output.output_index,
1749                        topic,
1750                    ),
1751                )
1752                .await
1753                {
1754                    error!("Error marking UTXO as spent: {e}");
1755                    report.fault(topic, "mark_utxo_as_spent", e.to_string());
1756                    retained_mark_faulted |= admittance
1757                        .coins_to_retain
1758                        .contains(&v.previous_coins[prev_idx]);
1759                }
1760
1761                // The input index within tx.inputs that spends this previous output.
1762                let spending_input_idx = v.previous_coins[prev_idx] as usize;
1763
1764                // Notify all lookup services about the spent output
1765                for ls in self.lookup_services.values() {
1766                    let payload = match ls.spend_notification_mode() {
1767                        SpendNotificationMode::None => OutputSpent::None {
1768                            txid: prev_output.txid.clone(),
1769                            output_index: prev_output.output_index,
1770                            topic: topic.clone(),
1771                        },
1772                        SpendNotificationMode::Txid => OutputSpent::Txid {
1773                            txid: prev_output.txid.clone(),
1774                            output_index: prev_output.output_index,
1775                            topic: topic.clone(),
1776                            spending_txid: txid.clone(),
1777                        },
1778                        SpendNotificationMode::Script => {
1779                            // Extract unlocking script and sequence from the spending input
1780                            let input = &tx.inputs[spending_input_idx];
1781                            let unlocking_script = input
1782                                .unlocking_script
1783                                .as_ref()
1784                                .map(bsv_rs::UnlockingScript::to_binary)
1785                                .unwrap_or_default();
1786                            OutputSpent::Script {
1787                                txid: prev_output.txid.clone(),
1788                                output_index: prev_output.output_index,
1789                                topic: topic.clone(),
1790                                spending_txid: txid.clone(),
1791                                input_index: spending_input_idx as u32,
1792                                unlocking_script,
1793                                sequence_number: input.sequence,
1794                                off_chain_values: tagged_beef.off_chain_values.clone(),
1795                            }
1796                        }
1797                        SpendNotificationMode::WholeTx => OutputSpent::WholeTx {
1798                            txid: prev_output.txid.clone(),
1799                            output_index: prev_output.output_index,
1800                            topic: topic.clone(),
1801                            spending_atomic_beef: subject_named_beef.clone(),
1802                            off_chain_values: tagged_beef.off_chain_values.clone(),
1803                        },
1804                    };
1805                    if let Err(e) = hooked(bound, ls.output_spent(&payload)).await {
1806                        error!("Error notifying lookup service of spent output: {e}");
1807                        report.fault(topic, "lookup_service.output_spent", e);
1808                    }
1809                }
1810            }
1811            if retained_mark_faulted {
1812                self.hold_unapplied(
1813                    &txid,
1814                    topic,
1815                    "the spent mark of a retained coin faulted, nothing inserted",
1816                );
1817                continue;
1818            }
1819
1820            // ── Insert admitted outputs ──
1821            // The inserts stop at the first one that faults: what follows is
1822            // the undo, not the rest of the transaction.
1823            let mut new_utxos: Vec<Outpoint> = Vec::new();
1824            let mut attempted: Vec<u32> = Vec::new();
1825            let mut insert_faulted = false;
1826
1827            for &output_index in &admittance.outputs_to_admit {
1828                let (script_bytes, sats) =
1829                    if let Some(tx_output) = tx.outputs.get(output_index as usize) {
1830                        (
1831                            tx_output.locking_script.to_binary(),
1832                            tx_output.get_satoshis(),
1833                        )
1834                    } else {
1835                        (Vec::new(), 0)
1836                    };
1837
1838                let output = Output {
1839                    txid: txid.clone(),
1840                    output_index,
1841                    output_script: script_bytes,
1842                    satoshis: sats,
1843                    topic: topic.clone(),
1844                    spent: false,
1845                    outputs_consumed: outputs_consumed.clone(),
1846                    consumed_by: Vec::new(),
1847                    beef: Some(tagged_beef.beef.clone()),
1848                    block_height: None,
1849                    score: Some(current_timestamp_ms()),
1850                };
1851
1852                attempted.push(output_index);
1853                if let Err(e) = stored(
1854                    bound,
1855                    self.storage.insert_output_with_origin(&output, finalize),
1856                )
1857                .await
1858                {
1859                    error!("Error inserting output for topic {topic}: {e}");
1860                    report.fault(topic, "insert_output", e.to_string());
1861                    insert_faulted = true;
1862                    break;
1863                }
1864
1865                new_utxos.push(Outpoint::new(&txid, output_index));
1866
1867                // Notify lookup services
1868                for ls in self.lookup_services.values() {
1869                    let (ls_script, ls_sats) =
1870                        if let Some(tx_out) = tx.outputs.get(output_index as usize) {
1871                            (tx_out.locking_script.to_binary(), tx_out.get_satoshis())
1872                        } else {
1873                            (Vec::new(), 0)
1874                        };
1875
1876                    let payload = match ls.admission_mode() {
1877                        AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
1878                            txid: txid.clone(),
1879                            output_index,
1880                            topic: topic.clone(),
1881                            satoshis: ls_sats,
1882                            locking_script: ls_script,
1883                            off_chain_values: tagged_beef.off_chain_values.clone(),
1884                        },
1885                        AdmissionMode::WholeTx => OutputAdmittedByTopic::WholeTx {
1886                            atomic_beef: subject_named_beef.clone(),
1887                            output_index,
1888                            topic: topic.clone(),
1889                            off_chain_values: tagged_beef.off_chain_values.clone(),
1890                        },
1891                    };
1892
1893                    if let Err(e) = hooked(bound, ls.output_admitted_by_topic(&payload)).await {
1894                        error!("Error notifying lookup service: {e}");
1895                        report.fault(topic, "lookup_service.output_admitted_by_topic", e);
1896                    }
1897                }
1898            }
1899
1900            // An insert faulted: the transaction did not land, so NOTHING of
1901            // it stays (the lens fold of 2026-10-07, F1). The previous coins
1902            // are kept (#559: with the delete first, one faulted insert left
1903            // a non-retaining head chain with NO head) and the outputs that
1904            // were inserted are taken out again, the faulted one included (a
1905            // backend's insert can fault after its row landed). Left in,
1906            // they could be spent and deleted by a successor before the
1907            // replay, and the replay, which still finds the previous coin,
1908            // would insert them a second time: a spent head back among the
1909            // UTXOs for good.
1910            if insert_faulted {
1911                if let Some(bound) = bound {
1912                    bound.renew();
1913                }
1914                self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
1915                    .await;
1916                self.hold_unapplied(
1917                    &txid,
1918                    topic,
1919                    &format!(
1920                        "an insert faulted: {} previous coin(s) kept, nothing of it held",
1921                        stale_coins.len()
1922                    ),
1923                );
1924                continue;
1925            }
1926
1927            // ── Delete stale outputs recursively ──
1928            // AFTER the inserts, and once every one of them landed (bsv-low
1929            // #559 and the lens fold of 2026-10-07, F1). The reference
1930            // deletes first (`applyTopicStorageMutation`: `removeStaleOutputs`,
1931            // then `admitOutput`), catches a throw per topic and never
1932            // replays; here a fault is survived and replayed, so the order
1933            // is ours, an addition. The delete does NOT wait for a clean
1934            // report (c9921ee did, and one faulted mark or notification
1935            // after the insert then left the stale coin beside the new
1936            // output for good: two unspent heads): a fault of the mark, of
1937            // a notification, deletes as the reference does.
1938            //
1939            // Before a stale coin is deleted its transaction is recorded as
1940            // applied in the topic (the delta fold of 2026-10-07, H2; an
1941            // addition, one idempotent write per deleted coin). A held coin
1942            // proves the inserts of its transaction landed, but that
1943            // transaction may have no applied row (a fault after its
1944            // inserts withheld it, or its insert landed after its timeout),
1945            // and then its replay is judged again: a manager that admits it
1946            // with NO previous coin (the opener of a chain) had it inserted
1947            // a second time, unspent, beside the output that spent it, for
1948            // good. With the row its replay is a dupe. A record that faults
1949            // leaves its coin undeleted: that is a delete fault, below.
1950            let due_before_the_delete = bound.is_some_and(CallBound::is_due);
1951            let mut delete_faulted = false;
1952            let mut delete_started = false;
1953            for stale in &stale_coins {
1954                let spent_coin_tx = AppliedTransaction {
1955                    txid: stale.txid.clone(),
1956                    topic: topic.clone(),
1957                };
1958                if let Err(e) = stored(
1959                    bound,
1960                    self.storage.insert_applied_transaction(&spent_coin_tx),
1961                )
1962                .await
1963                {
1964                    error!(
1965                        "Error recording {} as applied before its coin is deleted from topic {topic}: {e}",
1966                        stale.txid
1967                    );
1968                    report.fault(topic, "record_spent_coin_applied", e.to_string());
1969                    delete_faulted = true;
1970                    continue;
1971                }
1972                self.not_landed
1973                    .borrow_mut()
1974                    .remove(&(stale.txid.clone(), topic.clone()));
1975                match stored(
1976                    bound,
1977                    self.storage.find_output(
1978                        &stale.txid,
1979                        stale.output_index,
1980                        Some(topic),
1981                        None,
1982                        false,
1983                    ),
1984                )
1985                .await
1986                {
1987                    Ok(Some(stale_output)) => {
1988                        delete_started = true;
1989                        if let Err(e) = self.delete_utxo_deep(&stale_output, bound).await {
1990                            error!("Error deleting stale output for topic {topic}: {e}");
1991                            report.fault(topic, "delete_utxo_deep", e.to_string());
1992                            delete_faulted = true;
1993                        }
1994                    }
1995                    Ok(None) => {}
1996                    Err(e) => {
1997                        error!("Error reading stale output for topic {topic}: {e}");
1998                        report.fault(topic, "find_output", e.to_string());
1999                        delete_faulted = true;
2000                    }
2001                }
2002            }
2003
2004            // A fault here, and what it leaves. The store is put back as it
2005            // was found (the inserts undone, the replay does the whole
2006            // transaction) only when NO delete was started: the fault is of
2007            // H2's record or of the read before the delete, the call
2008            // answered, and EVERY stale coin is read back still held.
2009            //
2010            // Once a delete was STARTED nothing is undone, whether it
2011            // answered or not (the delta fold of 2026-10-07, H1, for a call
2012            // that did not answer; the second delta fold, M1, for one that
2013            // answered an error). A statement can land after its caller was
2014            // told it failed, as after its caller stopped waiting: the coin
2015            // is read back held now and is deleted a moment later, and the
2016            // undo then left the chain with no head, for good, for a manager
2017            // that needs the previous coin. So the outputs stay, the stale
2018            // coin stays (marked spent) until its delete lands or is done
2019            // again, and there is no applied row. Since H2 every order from
2020            // there converges. The replay first, the delete never landed:
2021            // the coin is found, the manager admits again, the inserts are
2022            // no-ops, the delete is done and the row written. The replay
2023            // first, the delete landed late: no previous coin, so a manager
2024            // that needs one admits nothing and the transaction is recorded
2025            // with its outputs held. A successor first: it recorded this
2026            // transaction as applied (H2) and the replay is a dupe; the
2027            // stale coin's spent row is then left behind, no UTXO.
2028            //
2029            // A call that did not ANSWER before any delete (the record, the
2030            // read) undoes nothing either: it is not known what it did. A
2031            // deadline that was already due BEFORE the first of these calls
2032            // started nothing here, so the coins are certainly held and the
2033            // store is put back.
2034            if delete_faulted {
2035                let unanswered = !due_before_the_delete && bound.is_some_and(CallBound::is_due);
2036                if let Some(bound) = bound {
2037                    bound.renew();
2038                }
2039                let mut all_held = !unanswered && !delete_started;
2040                for stale in &stale_coins {
2041                    if !all_held {
2042                        break;
2043                    }
2044                    let read = stored(
2045                        bound,
2046                        self.storage.find_output(
2047                            &stale.txid,
2048                            stale.output_index,
2049                            Some(topic),
2050                            None,
2051                            false,
2052                        ),
2053                    )
2054                    .await;
2055                    if !matches!(read, Ok(Some(_))) {
2056                        all_held = false;
2057                        break;
2058                    }
2059                }
2060                if all_held {
2061                    self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
2062                        .await;
2063                }
2064                self.hold_unapplied(
2065                    &txid,
2066                    topic,
2067                    if all_held {
2068                        "no delete of a stale coin was started: the coins are held, its outputs taken out"
2069                    } else if unanswered {
2070                        "the delete of a stale coin did not answer: nothing undone, its outputs stay"
2071                    } else if delete_started {
2072                        "the delete of a stale coin faulted: nothing undone, its outputs stay"
2073                    } else {
2074                        "no delete was started and a stale coin was not read back held: nothing undone, its outputs stay"
2075                    },
2076                );
2077                continue;
2078            }
2079
2080            // ── Update consumedBy on retained previous outputs ──
2081            for consumed_outpoint in &outputs_consumed {
2082                let consumed_output = match stored(
2083                    bound,
2084                    self.storage.find_output(
2085                        &consumed_outpoint.txid,
2086                        consumed_outpoint.output_index,
2087                        Some(topic),
2088                        None,
2089                        false,
2090                    ),
2091                )
2092                .await
2093                {
2094                    Ok(Some(o)) => o,
2095                    Ok(None) => continue,
2096                    Err(e) => {
2097                        error!("Error reading consumed output for topic {topic}: {e}");
2098                        report.fault(topic, "find_output", e.to_string());
2099                        continue;
2100                    }
2101                };
2102                {
2103                    let mut new_consumed_by = consumed_output.consumed_by.clone();
2104                    for new_utxo in &new_utxos {
2105                        if !new_consumed_by.iter().any(|c| {
2106                            c.txid == new_utxo.txid && c.output_index == new_utxo.output_index
2107                        }) {
2108                            new_consumed_by.push(new_utxo.clone());
2109                        }
2110                    }
2111                    if let Err(e) = stored(
2112                        bound,
2113                        self.storage.update_consumed_by(
2114                            &consumed_outpoint.txid,
2115                            consumed_outpoint.output_index,
2116                            topic,
2117                            &new_consumed_by,
2118                        ),
2119                    )
2120                    .await
2121                    {
2122                        error!("Error updating consumedBy: {e}");
2123                        report.fault(topic, "update_consumed_by", e.to_string());
2124                    }
2125                }
2126            }
2127
2128            // Record applied transaction — ONLY when every write above
2129            // landed. A faulted topic stays unrecorded so the Phase-1 dedup
2130            // cannot turn its replay into a no-op (S2).
2131            if report.faults.len() > faults_before {
2132                self.hold_unapplied(
2133                    &txid,
2134                    topic,
2135                    &format!(
2136                        "{} mutation fault(s); a replay of these bytes re-applies",
2137                        report.faults.len() - faults_before
2138                    ),
2139                );
2140                continue;
2141            }
2142            let tx_record = AppliedTransaction {
2143                txid: txid.clone(),
2144                topic: topic.clone(),
2145            };
2146            match stored(bound, self.storage.insert_applied_transaction(&tx_record)).await {
2147                Ok(()) => {
2148                    report.applied_topics.push(topic.clone());
2149                    self.not_landed
2150                        .borrow_mut()
2151                        .remove(&(txid.clone(), topic.clone()));
2152                }
2153                Err(e) => {
2154                    error!("Error inserting applied transaction for topic {topic}: {e}");
2155                    report.fault(topic, "insert_applied_transaction", e.to_string());
2156                    self.not_landed
2157                        .borrow_mut()
2158                        .insert((txid.clone(), topic.clone()));
2159                }
2160            }
2161        }
2162
2163        Ok((steak, report, blocked))
2164    }
2165
2166    /// The successor rule (bsv-low #559; lane E1D, bsv-low #575; and its
2167    /// lens fold of 2026-10-08), asked of every topic of a submit that found
2168    /// NO previous coin while the transaction spends something, before
2169    /// anything of the submit is written. Returns the topics that must wait,
2170    /// each with why: those are reported as a fault (`predecessor_not_landed`)
2171    /// and recorded nowhere, "not now". An addition to the reference, which
2172    /// records every non-failed topic.
2173    ///
2174    /// Such a topic was judged without the coin a predecessor has yet to
2175    /// leave. Recorded as applied, its replay was a dupe for good: a
2176    /// transaction that admits nothing ended the chain at the predecessor
2177    /// (#559), and one the manager admits WITHOUT its coin (the pf head
2178    /// manager over its own head state, E1D) was inserted, and the
2179    /// predecessor, landing later, stood unspent beside the tip.
2180    ///
2181    /// "Has not landed" is known two ways: this engine saw it fault
2182    /// (`not_landed`, one invocation, every door), or the store says so
2183    /// ([`Engine::unlanded_predecessor`], any invocation, never at a GASP
2184    /// finalize submit: its graph passed the anchor check, every parent
2185    /// inside the graph was submitted just before it and the sequence stops
2186    /// at the first that does not land; the second delta fold of 2026-10-07,
2187    /// M2). For a successor that admits something, both look only at the
2188    /// inputs the manager NAMES as its history (`identify_needed_inputs`
2189    /// over the subject-named BEEF; the lens fold, L1): a manager that
2190    /// names nothing (all 16 of this workspace) asks nothing there, as in
2191    /// the reference. A successor that admits NOTHING is asked of every
2192    /// input whatever its manager names (D17 (a)), so under such a manager
2193    /// it still waits for, or lands first, a carried body that spends a
2194    /// coin the topic holds: there the store differs from the reference's
2195    /// (the E1D delta fold, L3).
2196    ///
2197    /// The door lands a carried predecessor FIRST (the lens fold, M1; the
2198    /// GASP walk's rule, at the door): when the question names an unlanded
2199    /// predecessor whose body the BEEF carries, that body is submitted on
2200    /// its own to this topic ([`Engine::land_carried`]), under the submit's
2201    /// read allowance, and once it lands the topic is judged again
2202    /// ([`Engine::validate_topic`]) and asked again. The re-judgement's
2203    /// reads (the applied row, one coin per input) are charged to that
2204    /// allowance BEFORE the landing (the E1D delta fold, M1): a landing whose
2205    /// re-judgement would not fit is not made, and the topic waits. So one
2206    /// submit reads at most its own validation and writes plus
2207    /// [`PREDECESSOR_READS_PER_SUBMIT`], landings (on their clean path) and
2208    /// re-judgements included; a faulted landing's read-backs and undo, and
2209    /// a deep delete's walk of retained history, are not charged.
2210    /// Nothing is landed into a topic whose manager or a lookup
2211    /// service reads off-chain values (the delta fold, L1: the landing has
2212    /// none to give, and the predecessor's own submit would be a dupe): the
2213    /// topic waits for that submit. The first predecessor that does not land
2214    /// ends it: the topic waits. A predecessor whose body the BEEF does not
2215    /// carry is never landed here: its successor converges only when it
2216    /// lands (its own replay, a GASP peer, a resubmit).
2217    #[allow(clippy::too_many_arguments)]
2218    async fn successors_waiting(
2219        &self,
2220        tx: &Transaction,
2221        txid: &str,
2222        subject_named_beef: &[u8],
2223        validations: &mut [TopicValidation],
2224        steak: &mut Steak,
2225        off_chain_values: Option<&[u8]>,
2226        mode: SubmitMode,
2227        bound: Option<&CallBound>,
2228        finalize: bool,
2229        lands: bool,
2230        blocked: &mut HashMap<String, String>,
2231        question_reads: &mut usize,
2232        landed: &mut Vec<(String, String)>,
2233        landing: Landing,
2234        landed_breaches: &mut Vec<WalkCouldNotRun>,
2235    ) -> HashMap<String, String> {
2236        let mut waits = HashMap::new();
2237        let spent: Vec<(String, u32)> = tx
2238            .inputs
2239            .iter()
2240            .filter_map(|input| {
2241                let source = input.get_source_txid().ok()?;
2242                (!source.is_empty()).then_some((source, input.source_output_index))
2243            })
2244            .collect();
2245        if spent.is_empty() {
2246            return waits;
2247        }
2248        let found_no_coin = |v: &TopicValidation| {
2249            !v.is_dupe && !v.failed && v.read_fault.is_none() && v.previous_outputs.is_empty()
2250        };
2251        for v in validations.iter_mut() {
2252            if !found_no_coin(v) {
2253                continue;
2254            }
2255            let Some(manager) = self.managers.get(&v.topic) else {
2256                continue;
2257            };
2258            let topic = v.topic.clone();
2259            // The manager's word on the subject's history, asked at most
2260            // once per topic: up front for a successor that admits, by the
2261            // question when it needs it for one that does not.
2262            let mut subject_names: Option<HashSet<String>> = None;
2263            loop {
2264                let admits = !v.admittance.outputs_to_admit.is_empty();
2265                if admits && subject_names.is_none() {
2266                    subject_names = Some(
2267                        self.named_history(manager.as_ref(), subject_named_beef, bound)
2268                            .await,
2269                    );
2270                }
2271                let remembered = spent.iter().find(|(source, vout)| {
2272                    (!admits
2273                        || subject_names
2274                            .as_ref()
2275                            .is_some_and(|named| named.contains(&format!("{source}.{vout}"))))
2276                        && self
2277                            .not_landed
2278                            .borrow()
2279                            .contains(&(source.clone(), topic.clone()))
2280                });
2281                if let Some((predecessor, _)) = remembered {
2282                    waits.insert(topic.clone(), format!("{predecessor} has not landed"));
2283                    break;
2284                }
2285                if finalize {
2286                    break;
2287                }
2288                let Some(not_now) = self
2289                    .unlanded_predecessor(
2290                        tx,
2291                        txid,
2292                        subject_named_beef,
2293                        &topic,
2294                        mode,
2295                        admits,
2296                        &mut subject_names,
2297                        question_reads,
2298                        bound,
2299                    )
2300                    .await
2301                else {
2302                    break;
2303                };
2304                let Some(carried) = not_now.carried else {
2305                    waits.insert(topic.clone(), not_now.why);
2306                    break;
2307                };
2308                if !lands {
2309                    blocked.insert(topic.clone(), carried);
2310                    waits.insert(topic.clone(), not_now.why);
2311                    break;
2312                }
2313                if !self.lands_without_values(&topic) {
2314                    waits.insert(
2315                        topic.clone(),
2316                        format!(
2317                            "{}; not landed first from the BEEF: the topic's manager or a lookup service reads off-chain values, and only the predecessor's own submit carries them",
2318                            not_now.why
2319                        ),
2320                    );
2321                    break;
2322                }
2323                // The re-judgement after the landing, charged before it.
2324                let rejudge = 1 + spent.len();
2325                if *question_reads + rejudge > PREDECESSOR_READS_PER_SUBMIT {
2326                    waits.insert(
2327                        topic.clone(),
2328                        format!(
2329                            "{}; its landing and the re-judgement after it do not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads",
2330                            not_now.why
2331                        ),
2332                    );
2333                    break;
2334                }
2335                *question_reads += rejudge;
2336                match self
2337                    .land_carried(
2338                        subject_named_beef,
2339                        txid,
2340                        &carried,
2341                        &topic,
2342                        mode,
2343                        bound,
2344                        question_reads,
2345                        landed,
2346                        landing,
2347                        landed_breaches,
2348                    )
2349                    .await
2350                {
2351                    Ok(()) => {
2352                        *v = self
2353                            .validate_topic(
2354                                tx,
2355                                txid,
2356                                &topic,
2357                                off_chain_values,
2358                                mode,
2359                                &TopicAdmittanceContext::default(),
2360                                bound,
2361                            )
2362                            .await;
2363                        steak.insert(topic.clone(), v.admittance.clone());
2364                        if !found_no_coin(v) {
2365                            break;
2366                        }
2367                    }
2368                    Err(did_not_land) => {
2369                        // No re-judgement follows: its reads were not made.
2370                        *question_reads -= rejudge;
2371                        waits.insert(
2372                            topic.clone(),
2373                            format!(
2374                                "{}; submitted first from the BEEF, it did not land ({did_not_land})",
2375                                not_now.why
2376                            ),
2377                        );
2378                        break;
2379                    }
2380                }
2381            }
2382            // A subject that already HOLDS an output in the topic landed
2383            // there before (the leftover of its own earlier submit whose
2384            // delete was started and faulted, D17 M1: its outputs held, no
2385            // applied row), and that admission was judged with its coin.
2386            // Its replay finishes it and does not wait (the E1D lens fold,
2387            // L4: a proven head spend that names a decoy no peer serves was
2388            // "not now" on every replay, three and a dead letter). One read,
2389            // only on the way to "not now", inside the allowance.
2390            if !finalize
2391                && waits.contains_key(&topic)
2392                && *question_reads < PREDECESSOR_READS_PER_SUBMIT
2393            {
2394                *question_reads += 1;
2395                if let Ok(outputs) = stored(
2396                    bound,
2397                    self.storage.find_outputs_for_transaction(txid, false),
2398                )
2399                .await
2400                {
2401                    if outputs.iter().any(|o| o.topic == topic) {
2402                        info!("topic {topic}: {txid} holds its outputs already; its replay finishes it");
2403                        waits.remove(&topic);
2404                        blocked.remove(&topic);
2405                    }
2406                }
2407            }
2408        }
2409        waits
2410    }
2411
2412    /// Land an unlanded predecessor whose body the successor's BEEF carries
2413    /// (lane E1D's lens fold, M1): submit it on its own to `topic`, its
2414    /// atomic BEEF out of the successor's, no off-chain values (the caller
2415    /// lands nothing into a topic that reads them; the delta fold, L1),
2416    /// never a broadcast (`current-tx` is submitted as `historical-tx`: the
2417    /// successor's BEEF carries it to the network).
2418    ///
2419    /// Each body is WALKED once in this submit (the delta fold, L4): the
2420    /// successor's own SPV walk (`subject`, the same mode, tracker and
2421    /// switch) already passed over every body it reaches without crossing a
2422    /// proven one, so those are not walked again; a body reached only
2423    /// through a proven one (which the successor's walk does not descend)
2424    /// gets its own walk, trusting what this submit already walked, and so
2425    /// does nothing twice. A walk that refuses a body ends the landing.
2426    ///
2427    /// A predecessor whose own submit waits on a carried predecessor of ITS
2428    /// own goes on a stack under it: ancestors first, the walk's rule at
2429    /// the door. Its manager was asked only a DRY RUN then and nothing was
2430    /// written; once the one under it landed it is submitted again and
2431    /// admitted for real, once ([`Engine::submit_counted`]). Each submit's
2432    /// reads on its clean path (its applied row, one coin per input, and
2433    /// its writes' one read of each coin it spends) are charged to the
2434    /// submit's allowance before it starts, and its predecessor
2435    /// question shares that allowance, so the landings of one submit stop
2436    /// at [`PREDECESSOR_READS_PER_SUBMIT`] reads, however deep the chain.
2437    /// Each body that lands is pushed on `landed` (the report's
2438    /// `landed_predecessors`, for the caller's admission guard; the delta
2439    /// fold, L2). Each body is asked of the caller's landing guard first,
2440    /// once ([`Engine::set_landing_guard`]; the delta-2 fold, L2): a refusal
2441    /// ends the landing before that body is submitted.
2442    /// `Ok` once `predecessor` LANDED in `topic` (recorded there,
2443    /// now or before); otherwise why not, at the first that did not.
2444    #[allow(clippy::too_many_arguments)]
2445    async fn land_carried(
2446        &self,
2447        beef_bytes: &[u8],
2448        subject: &str,
2449        predecessor: &str,
2450        topic: &str,
2451        mode: SubmitMode,
2452        bound: Option<&CallBound>,
2453        question_reads: &mut usize,
2454        landed: &mut Vec<(String, String)>,
2455        landing: Landing,
2456        landed_breaches: &mut Vec<WalkCouldNotRun>,
2457    ) -> Result<(), String> {
2458        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
2459            .map_err(|e| format!("the BEEF could not be read again ({e})"))?;
2460        let mode = match mode {
2461            SubmitMode::CurrentTx => SubmitMode::HistoricalTx,
2462            other => other,
2463        };
2464        let walks = mode != SubmitMode::HistoricalTxNoSpv;
2465        // The trust set is what a walk that RAN covered (the E592 lens fold,
2466        // M1): a subject whose walk breached covered nothing, so each carried
2467        // predecessor is walked by its own submit (and its own breach is
2468        // reported, `landed_breaches`), never trusted by a breached walk.
2469        let mut walked: HashSet<String> = if walks && landing.subject_walk_ran {
2470            walk_cover(&beef, subject)
2471        } else {
2472            HashSet::new()
2473        };
2474        let mut stack = vec![predecessor.to_string()];
2475        let mut guarded: HashSet<String> = HashSet::new();
2476        while let Some(next) = stack.last().cloned() {
2477            let body = beef
2478                .find_atomic_transaction(&next)
2479                .ok_or_else(|| format!("{next}: its body is not in the BEEF"))?;
2480            // The caller's admission predicate, before anything of this body
2481            // is submitted (the delta-2 fold, L2).
2482            if let Some(guard) = &self.landing_guard {
2483                if guarded.insert(next.clone()) {
2484                    bounded(bound, guard(&next, topic))
2485                        .await
2486                        .and_then(|answer| answer)
2487                        .map_err(|why| format!("{next}: refused by the landing guard ({why})"))?;
2488                }
2489            }
2490            // Its validation (the applied row, one coin per input) and its
2491            // writes' one read of each coin it spends.
2492            let cost = 1 + 2 * body.inputs.len();
2493            if *question_reads + cost > PREDECESSOR_READS_PER_SUBMIT {
2494                return Err(format!(
2495                    "{next}: its submit does not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2496                ));
2497            }
2498            *question_reads += cost;
2499            let atomic = body
2500                .to_atomic_beef(true)
2501                .map_err(|e| format!("{next}: its BEEF could not be built ({e})"))?;
2502            let tagged = TaggedBEEF::new(atomic, vec![topic.to_string()]);
2503            let (_, report, blocked) = match self
2504                .submit_counted(
2505                    &tagged,
2506                    mode,
2507                    bound,
2508                    false,
2509                    Some(&walked),
2510                    question_reads,
2511                    landing.policy,
2512                )
2513                .await
2514            {
2515                Ok(counted) => counted,
2516                // Its walk could not run and nothing stands in for it: it does
2517                // not land now, and the successor waits (counted, L3).
2518                Err(EngineError::WalkCouldNotRun(stop)) => {
2519                    let why = format!("{next}: {}", EngineError::WalkCouldNotRun(stop.clone()));
2520                    landed_breaches.push(stop);
2521                    return Err(why);
2522                }
2523                Err(e) => return Err(format!("{next}: {e}")),
2524            };
2525            // Its walk passed, so did that of every body it reaches; a walk
2526            // that went on past a breach (`NetworkAccepted`) passed nothing.
2527            if let Some(stop) = &report.walk_could_not_run {
2528                landed_breaches.push(stop.clone());
2529            } else if walks && !walked.contains(&next) {
2530                walked.extend(walk_cover(&beef, &next));
2531            }
2532            let landed_now = report.applied_topics.iter().any(|t| t == topic);
2533            if report.is_durable()
2534                && (landed_now || report.deduped_topics.iter().any(|t| t == topic))
2535            {
2536                info!("topic {topic}: {next} landed first from its successor's BEEF");
2537                if landed_now {
2538                    landed.push((next.clone(), topic.to_string()));
2539                }
2540                stack.pop();
2541                continue;
2542            }
2543            match blocked.get(topic) {
2544                Some(under) if !stack.contains(under) => stack.push(under.clone()),
2545                _ if report.is_durable() => return Err(format!("{next}: the manager failed it")),
2546                _ => return Err(format!("{next}: {}", report.summary())),
2547            }
2548        }
2549        Ok(())
2550    }
2551
2552    /// A topic of a submit that is NOT recorded as applied: logged, and
2553    /// remembered for the successor rule (`not_landed`).
2554    fn hold_unapplied(&self, txid: &str, topic: &str, why: &str) {
2555        warn!("topic {topic}: {txid}: {why}; applied_transactions NOT recorded (bsv-low #559)");
2556        self.not_landed
2557            .borrow_mut()
2558            .insert((txid.to_string(), topic.to_string()));
2559    }
2560
2561    /// Take out again the outputs a faulted submit inserted (bsv-low #559,
2562    /// the lens fold of 2026-10-07, F1), so that nothing of a transaction
2563    /// that did not land is held. Only a row nobody has touched since is
2564    /// deleted: unspent and consumed by nothing. A plain delete, not the
2565    /// deep one: the row's retained inputs were not pointed at it yet. A
2566    /// fault here is reported (`undo_insert_output`) and the row stays: the
2567    /// limit of an undo made of separate calls.
2568    async fn undo_inserts(
2569        &self,
2570        txid: &str,
2571        topic: &str,
2572        output_indices: &[u32],
2573        bound: Option<&CallBound>,
2574        report: &mut MutationReport,
2575    ) {
2576        for &output_index in output_indices {
2577            let row = stored(
2578                bound,
2579                self.storage
2580                    .find_output(txid, output_index, Some(topic), None, false),
2581            )
2582            .await;
2583            let undone = match row {
2584                Ok(Some(row)) if !row.spent && row.consumed_by.is_empty() => {
2585                    stored(bound, self.storage.delete_output(txid, output_index, topic)).await
2586                }
2587                Ok(_) => Ok(()),
2588                Err(e) => Err(e),
2589            };
2590            if let Err(e) = undone {
2591                error!("Error taking {txid}.{output_index} out of topic {topic} again: {e}");
2592                report.fault(topic, "undo_insert_output", e.to_string());
2593            }
2594        }
2595    }
2596
2597    /// The store's own answer to "has a predecessor of `tx` not landed?"
2598    /// (bsv-low #559, the lens fold of 2026-10-07, F3; lane E1D, bsv-low
2599    /// #575, and its lens fold of 2026-10-08), for a transaction that found
2600    /// no previous coin. The engine's memory of a faulted submit
2601    /// (`not_landed`) dies with the invocation; this does not. A transaction
2602    /// HAS LANDED in `topic` when it has an applied row there or an output
2603    /// held there; anything else is not known to have landed, and it is a
2604    /// PREDECESSOR of `tx` when one of these says so:
2605    ///
2606    /// - its body is in the BEEF and it spends a coin the topic HOLDS (or,
2607    ///   one step up, a coin of another such transaction): a spend of this
2608    ///   topic the store has not taken yet (#559);
2609    /// - its body is in the BEEF, it spends no coin the topic holds, the
2610    ///   manager NAMES the outpoint the walk spends from it as overlay
2611    ///   history (`identify_needed_inputs` over the BEEF of the transaction
2612    ///   that spends it, `tx` or a body the walk reached), and the manager,
2613    ///   asked of that body in a dry run with no coins
2614    ///   (`TopicAdmittanceContext::DRY_RUN`, the submit's mode, no off-chain
2615    ///   values), would admit that named output: an OPENER that has not
2616    ///   landed (E1D, the cure D17 named). Only a NAMED output is dry-run
2617    ///   (the lens fold, H1): with every unheld body dry-run, a manager that
2618    ///   admits on output shape (all 16 of this workspace) made a spend of a
2619    ///   shape-admissible output this node never held (a revocation of an
2620    ///   ad it never saw; a stranger's own few-sat SHIP output) "not now" on
2621    ///   every presentation, three replays and a dead letter each, where the
2622    ///   reference records it. A manager `Err` in the dry run is "not now"
2623    ///   (the manager's contract at the anchor replay, D15), except its
2624    ///   typed refusal `NoAdmissibleOutputs`, which is "not admitted";
2625    /// - its body is NOT in the BEEF (a PROVEN successor carries none) and
2626    ///   the manager names the outpoint `tx` spends from it as overlay
2627    ///   history (D13's word; an `Err` there names nothing, as in the walk).
2628    ///
2629    /// The manager is asked over `beef_bytes`, the SUBJECT-NAMED BEEF of the
2630    /// submit (BRC-95 atomic, the body the lookup services get; the lens
2631    /// fold, M2): over the submitted bytes a manager that parses
2632    /// `from_beef(_, None)` took the wire-LAST transaction of a non-atomic
2633    /// BEEF, named an ancestor's inputs, and the question asked nothing.
2634    ///
2635    /// `admits` says which inputs of `tx` start the walk. A transaction that
2636    /// admits nothing (D17's class) starts from every input. One that admits
2637    /// WITHOUT its coin (E1D) starts only from the inputs the manager names:
2638    /// a manager that admits a successor with no coin has said the coin does
2639    /// not decide the admission, so only its own word makes an input a
2640    /// predecessor. Every manager of this workspace names nothing: an
2641    /// admitting successor is then asked nothing, as in the reference, and
2642    /// so is a spend of an unheld output nobody names (the lens fold, H1);
2643    /// but a successor that admits nothing still finds case (a), a carried
2644    /// body that spends a coin the topic holds, and waits for it or has it
2645    /// landed first, where the reference records the successor and never
2646    /// admits that body (the E1D delta fold, L3). A named input whose body
2647    /// the BEEF carries is still walked and dry-run, so a named decoy the
2648    /// manager would not admit is no predecessor.
2649    ///
2650    /// The answer carries the predecessor when its body is in the BEEF (the
2651    /// first two cases): the door lands it first ([`Engine::successors_waiting`]).
2652    ///
2653    /// The limits, stated. It cannot tell a faulted predecessor from one
2654    /// nobody submitted yet: a successor is "not now" in both until the
2655    /// predecessor lands (the door lands a carried one itself). A named
2656    /// input whose body is absent and which never lands (a decoy no one
2657    /// submits, beside a real predecessor that landed and holds no coin)
2658    /// keeps its successor "not now" at the door; the GASP walk prunes such
2659    /// an input instead (D14). Deeper than the successor's own inputs, an
2660    /// absent body ends the walk as before.
2661    ///
2662    /// "Landed" needs a clean answer (the delta fold of 2026-10-07, M1 and
2663    /// M2): a read that faults, or the question running out of its reads
2664    /// (the 16 of a topic or the 256 of the submit), answers "not now" too,
2665    /// naming the transaction it could not settle. Recording the successor
2666    /// there made its replay a dupe and stopped the chain behind it for
2667    /// good; a retried no-op is the lesser cost.
2668    ///
2669    /// What the bound counts (the second delta fold of 2026-10-07, M2): the
2670    /// reads spent on a body that is NEITHER proven in the BEEF NOR found
2671    /// landed, [`PREDECESSOR_READS`] of them per topic. A body the BEEF
2672    /// proves, and one whose applied row or held output answers "landed", is
2673    /// read and costs nothing against that bound: counted, they made a
2674    /// transaction that admits nothing over 17 landed parents, over six
2675    /// proven parents the topic never saw, or over one proven parent with 15
2676    /// inputs "not now" on every submit, with no unlanded predecessor
2677    /// anywhere. A landed body still needs ROOM under it: each read of an
2678    /// unproven body is tested before it is made, so with 15 counted reads
2679    /// spent one that landed by a held output (no applied row) is refused at
2680    /// its second read, and with 16 spent any unproven body at its first.
2681    ///
2682    /// "Proven" is the BEEF's word here: a body that carries a merkle path
2683    /// (a bump index) is counted as proven for the read bound ONLY. No proof
2684    /// is checked at this question; the SPV walk of the submit and the GASP
2685    /// anchor check are where proofs are checked, and under
2686    /// `historical-tx-no-spv` or the scripts-only walk nothing checked this
2687    /// one, so the 16 are the caller's to switch off. Nothing is recorded on
2688    /// that word (a proven body is walked like any other), and the allowance
2689    /// below does not ask it.
2690    ///
2691    /// The hard allowance (the third delta fold of 2026-10-07, M1):
2692    /// `question_reads`, owned by the submit, counts EVERY read of the
2693    /// question, those of proven and landed bodies included, over every
2694    /// topic of the submit, and stops it at
2695    /// [`PREDECESSOR_READS_PER_SUBMIT`]; the 16 stay inside it. A later
2696    /// topic's question starts from what the earlier ones left.
2697    ///
2698    /// The costs, stated. A transaction that admits nothing, found no coin
2699    /// and carries more UNPROVEN, UNLANDED bodies than 16 reads settle (five
2700    /// single-input ancestors, fewer with more inputs), or a BEEF whose
2701    /// question needs more than 256 reads over the submit's topics (a
2702    /// landed body costs one read by its applied row or two by a held
2703    /// output, a proven, unlanded one two and one per input), is never
2704    /// recorded, where the reference records it: each submit of it is a
2705    /// fault and a replay. The manager CPU it adds: one names call per
2706    /// spender a dry run needs (the subject's, and one per walked body with
2707    /// an unheld source), and at most one dry run per body the reads
2708    /// reached; each candidate is built out of the BEEF parsed once (the
2709    /// lens fold, L2).
2710    ///
2711    /// Returns why the submit must wait, for the report.
2712    #[allow(clippy::too_many_arguments)]
2713    async fn unlanded_predecessor(
2714        &self,
2715        tx: &Transaction,
2716        txid: &str,
2717        beef_bytes: &[u8],
2718        topic: &str,
2719        mode: SubmitMode,
2720        admits: bool,
2721        subject_names: &mut Option<HashSet<String>>,
2722        question_reads: &mut usize,
2723        bound: Option<&CallBound>,
2724    ) -> Option<NotNow> {
2725        let manager = self.managers.get(topic)?;
2726        let wait = |why: String| Some(NotNow { why, carried: None });
2727        // The outpoints `tx` spends, as `txid.vout`.
2728        let starts: Vec<(String, u32)> = tx
2729            .inputs
2730            .iter()
2731            .filter_map(|input| {
2732                let source = input.get_source_txid().ok()?;
2733                (!source.is_empty()).then_some((source, input.source_output_index))
2734            })
2735            .collect();
2736        // The outputs of each candidate that the walk spends, and who spends
2737        // each: a dry run asks only of an output its spender names.
2738        let mut spent_of: HashMap<String, HashSet<u32>> = HashMap::new();
2739        let mut spenders: HashMap<String, Vec<(String, u32)>> = HashMap::new();
2740        let mut ask: Vec<String> = Vec::new();
2741        for (source, vout) in &starts {
2742            if admits
2743                && subject_names
2744                    .as_ref()
2745                    .is_some_and(|named| !named.contains(&format!("{source}.{vout}")))
2746            {
2747                continue;
2748            }
2749            spent_of.entry(source.clone()).or_default().insert(*vout);
2750            spenders
2751                .entry(source.clone())
2752                .or_default()
2753                .push((txid.to_string(), *vout));
2754            ask.push(source.clone());
2755        }
2756        // Nothing to ask (a transaction that admits and names nothing, every
2757        // manager of this workspace): the BEEF is not even parsed.
2758        if ask.is_empty() {
2759            return None;
2760        }
2761        let direct: HashSet<String> = ask.iter().cloned().collect();
2762        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS).ok()?;
2763        // Each body the BEEF carries, and whether the BEEF says it is
2764        // proven (a bump index: its word, no proof is checked here).
2765        let bodies: HashMap<String, (&Transaction, bool)> = beef
2766            .txs
2767            .iter()
2768            .filter_map(|btx| {
2769                btx.tx()
2770                    .map(|body| (btx.txid(), (body, btx.bump_index().is_some())))
2771            })
2772            .collect();
2773        // The manager's names over each walked body other than `tx`.
2774        let mut names_over: HashMap<String, HashSet<String>> = HashMap::new();
2775        // The reads spent on bodies neither proven nor found landed.
2776        let mut reads = 0usize;
2777        let mut asked: HashSet<String> = HashSet::new();
2778        while let Some(candidate) = ask.pop() {
2779            if candidate.is_empty() || !asked.insert(candidate.clone()) {
2780                continue;
2781            }
2782            let spent_outputs = spent_of.get(&candidate).cloned().unwrap_or_default();
2783            // An absent body is asked of the store only when it is one of
2784            // `tx`'s own inputs and the manager names it (E1D).
2785            let body = bodies.get(&candidate).copied();
2786            if body.is_none() {
2787                if !direct.contains(&candidate) {
2788                    continue;
2789                }
2790                if subject_names.is_none() {
2791                    *subject_names = Some(
2792                        self.named_history(manager.as_ref(), beef_bytes, bound)
2793                            .await,
2794                    );
2795                }
2796                let is_named = subject_names.as_ref().is_some_and(|named| {
2797                    spent_outputs
2798                        .iter()
2799                        .any(|vout| named.contains(&format!("{candidate}.{vout}")))
2800                });
2801                if !is_named {
2802                    continue;
2803                }
2804            }
2805            let proven = body.is_some_and(|(_, proven)| proven);
2806            // This candidate's own reads: they join `reads` once it is
2807            // found not landed, and never if the BEEF proves it.
2808            let mut spent = 0usize;
2809            let out_of = |spent: usize| !proven && reads + spent >= PREDECESSOR_READS;
2810            let out_of_reads = || {
2811                wait(format!(
2812                    "{candidate} is not known to have landed: the question ran out of its {PREDECESSOR_READS} reads"
2813                ))
2814            };
2815            // The submit's allowance, over every read: proven or landed,
2816            // this topic or an earlier one.
2817            let out_of_allowance = || {
2818                wait(format!(
2819                    "{candidate} is not known to have landed: the question ran out of the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2820                ))
2821            };
2822            let unread = |e: StorageError| {
2823                wait(format!(
2824                    "{candidate} is not known to have landed: the store could not say ({e})"
2825                ))
2826            };
2827            let carried = |why: String| {
2828                Some(NotNow {
2829                    why,
2830                    carried: Some(candidate.clone()),
2831                })
2832            };
2833            let record = AppliedTransaction {
2834                txid: candidate.clone(),
2835                topic: topic.to_string(),
2836            };
2837            // An applied row, or an output held, ends the question for this
2838            // transaction: it landed.
2839            if out_of(spent) {
2840                return out_of_reads();
2841            }
2842            if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2843                return out_of_allowance();
2844            }
2845            spent += 1;
2846            *question_reads += 1;
2847            match stored(bound, self.storage.does_applied_transaction_exist(&record)).await {
2848                Ok(false) => {}
2849                Ok(true) => continue,
2850                Err(e) => return unread(e),
2851            }
2852            if out_of(spent) {
2853                return out_of_reads();
2854            }
2855            if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2856                return out_of_allowance();
2857            }
2858            spent += 1;
2859            *question_reads += 1;
2860            match stored(
2861                bound,
2862                self.storage.find_outputs_for_transaction(&candidate, false),
2863            )
2864            .await
2865            {
2866                Ok(outputs) if outputs.iter().all(|o| o.topic != topic) => {}
2867                Ok(_) => continue,
2868                Err(e) => return unread(e),
2869            }
2870            // Not landed. With no body to walk, the manager's word is the
2871            // whole answer (E1D).
2872            let Some((body, _)) = body else {
2873                return wait(format!(
2874                    "{candidate} has not landed (the manager names it as history; the BEEF does not carry it)"
2875                ));
2876            };
2877            for input in &body.inputs {
2878                let source = input.get_source_txid().unwrap_or_default();
2879                if source.is_empty() {
2880                    continue;
2881                }
2882                if out_of(spent) {
2883                    return out_of_reads();
2884                }
2885                if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2886                    return out_of_allowance();
2887                }
2888                spent += 1;
2889                *question_reads += 1;
2890                let coin = stored(
2891                    bound,
2892                    self.storage.find_output(
2893                        &source,
2894                        input.source_output_index,
2895                        Some(topic),
2896                        None,
2897                        false,
2898                    ),
2899                )
2900                .await;
2901                match coin {
2902                    Ok(Some(_)) => return carried(format!("{candidate} has not landed")),
2903                    // Not held: its own transaction may be the one that has
2904                    // not landed.
2905                    Ok(None) => {
2906                        spent_of
2907                            .entry(source.clone())
2908                            .or_default()
2909                            .insert(input.source_output_index);
2910                        spenders
2911                            .entry(source.clone())
2912                            .or_default()
2913                            .push((candidate.clone(), input.source_output_index));
2914                        ask.push(source);
2915                    }
2916                    Err(e) => return unread(e),
2917                }
2918            }
2919            // It spends no coin the topic holds. Does a spender NAME an
2920            // output the walk spends of it (the lens fold, H1)? A names call
2921            // per spender, no store read.
2922            let mut named_outputs: HashSet<u32> = HashSet::new();
2923            for (spender, vout) in spenders.get(&candidate).cloned().unwrap_or_default() {
2924                let names = if spender == txid {
2925                    if subject_names.is_none() {
2926                        *subject_names = Some(
2927                            self.named_history(manager.as_ref(), beef_bytes, bound)
2928                                .await,
2929                        );
2930                    }
2931                    subject_names.as_ref()
2932                } else {
2933                    if !names_over.contains_key(&spender) {
2934                        let names = match beef
2935                            .find_atomic_transaction(&spender)
2936                            .and_then(|spender| spender.to_atomic_beef(true).ok())
2937                        {
2938                            Some(spender_beef) => {
2939                                self.named_history(manager.as_ref(), &spender_beef, bound)
2940                                    .await
2941                            }
2942                            None => HashSet::new(),
2943                        };
2944                        names_over.insert(spender.clone(), names);
2945                    }
2946                    names_over.get(&spender)
2947                };
2948                if names.is_some_and(|named| named.contains(&format!("{candidate}.{vout}"))) {
2949                    named_outputs.insert(vout);
2950                }
2951            }
2952            if !named_outputs.is_empty() {
2953                // Would the manager admit a named output of it with no coins
2954                // (E1D, the opener)? A dry run over the candidate built out
2955                // of the BEEF parsed above (its proof and, unproven, its
2956                // sources linked): no store read, nothing counted.
2957                let judged = beef
2958                    .find_atomic_transaction(&candidate)
2959                    .unwrap_or_else(|| body.clone());
2960                let verdict = bounded(
2961                    bound,
2962                    manager.identify_admissible_outputs(
2963                        &judged,
2964                        &[],
2965                        None,
2966                        mode,
2967                        &TopicAdmittanceContext::DRY_RUN,
2968                    ),
2969                )
2970                .await;
2971                match verdict {
2972                    Ok(Ok(admittance)) => {
2973                        if admittance
2974                            .outputs_to_admit
2975                            .iter()
2976                            .any(|vout| named_outputs.contains(vout))
2977                        {
2978                            return carried(format!(
2979                                "{candidate} has not landed (the manager names the output spent from it and would admit it with no coin: an opener)"
2980                            ));
2981                        }
2982                    }
2983                    Ok(Err(TopicManagerError::NoAdmissibleOutputs(_))) => {}
2984                    Ok(Err(e)) => {
2985                        return wait(format!(
2986                            "{candidate} is not known to have landed: the manager could not judge it ({e})"
2987                        ))
2988                    }
2989                    Err(no_answer) => {
2990                        return wait(format!(
2991                            "{candidate} is not known to have landed: the manager did not answer ({no_answer})"
2992                        ))
2993                    }
2994                }
2995            }
2996            if !proven {
2997                reads += spent;
2998            }
2999        }
3000        None
3001    }
3002
3003    /// The outpoints (`txid.vout`) the topic manager NAMES as overlay
3004    /// history of the transaction `beef_bytes` names (`identify_needed_inputs`,
3005    /// D13's word; an atomic BEEF, so a manager that parses
3006    /// `from_beef(_, None)` reads that transaction). An `Err` or no answer
3007    /// names nothing, as the GASP walk treats it (logged).
3008    async fn named_history(
3009        &self,
3010        manager: &dyn TopicManager,
3011        beef_bytes: &[u8],
3012        bound: Option<&CallBound>,
3013    ) -> HashSet<String> {
3014        match bounded(bound, manager.identify_needed_inputs(beef_bytes, None)).await {
3015            Ok(Ok(named)) => named.iter().map(Outpoint::to_graph_id).collect(),
3016            Ok(Err(e)) => {
3017                warn!("the predecessor question: the manager could not name its inputs: {e}");
3018                HashSet::new()
3019            }
3020            Err(no_answer) => {
3021                warn!("the predecessor question: the manager did not name its inputs: {no_answer}");
3022                HashSet::new()
3023            }
3024        }
3025    }
3026
3027    /// The reference's `tx.verify(chainTracker)` on submit: every unproven
3028    /// transaction from the subject down has every input's script executed
3029    /// against its source output and obeys the value rule; a proven one is
3030    /// not descended, and its BUMP's root is checked against the chain
3031    /// tracker (computed and accepted with none).
3032    ///
3033    /// Since bsv-low #592 (the owner's ruling 3a of 2026-10-10) the scripts
3034    /// are walked by the DOOR's stream walk ([`crate::script_door`]) under
3035    /// `budget`, the engine's [`DoorBudget`]: one walker, the same estimates
3036    /// charged from the bytes before anything is parsed or run. Before it this
3037    /// was a walk of its own over a hydrated `Beef` with no memory charge and
3038    /// no interpreter limit: 219.7 MB natively on a 1.9 MB push-only unproven
3039    /// parent, an isolate kill on Workers, met by every `historical-tx` replay
3040    /// of the queue. The roots are then asked for the proven transactions the
3041    /// walk reached, in its order, once per BUMP.
3042    ///
3043    /// `Ok(Some(_))` is a walk that COULD NOT RUN within the budget, never a
3044    /// refusal (below, at the match). The interpreter's refusal and a
3045    /// structural fault are what they were: `ScriptVerificationFailed`, and
3046    /// `SpvError` in the same words.
3047    ///
3048    /// The tracker is an ARGUMENT, not `self`'s: the GASP anchor check
3049    /// ([`verify_spv_like_the_reference`]) runs this same walk from
3050    /// `OverlayGASPStorage`, which borrows the engine's tracker.
3051    ///
3052    /// Why not bsv-rs `Transaction::verify`: it walks `source_transaction`
3053    /// links that `from_beef` builds by CLONING, once per input. Two inputs
3054    /// sourcing the same unproven parent (a covenant head output plus that
3055    /// spend's own change, the shape of every second head spend of a wallet)
3056    /// leave the second clone bare ("Input 0 has no source transaction"), and
3057    /// re-linking every clone materializes the ancestry EXPONENTIALLY along a
3058    /// diamond chain: a 12-deep unmined chain of head spends took a Worker past
3059    /// its memory on beta (zanaadu, 2026-09-08). The stream walk reads each
3060    /// transaction once, in place, keyed by txid.
3061    async fn verify_beef_linear(
3062        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3063        beef_bytes: &[u8],
3064        subject_txid: &str,
3065        trusted: &HashSet<String>,
3066        budget: DoorBudget,
3067    ) -> Result<Option<WalkCouldNotRun>, EngineError> {
3068        // A structural fault is the reference walk's `SpvError`.
3069        let spv =
3070            |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3071
3072        // THE SCRIPTS (bsv-low #592): the door's own stream walk, under the
3073        // engine's budget. One walker (Rule 10): the door and the submit run
3074        // the same function, the same estimates and the same interpreter
3075        // limit; the submit adds the roots below.
3076        let mut trace = crate::script_door::WalkTrace::default();
3077        match crate::script_door::walk_trusting(
3078            beef_bytes,
3079            subject_txid,
3080            budget,
3081            trusted,
3082            &mut trace,
3083        ) {
3084            Ok(_) => {}
3085            // The interpreter's verdict, the reference's refusal: unchanged.
3086            Err(refused @ EngineError::ScriptVerificationFailed { .. }) => return Err(refused),
3087            // A structural fault (a source missing, the value rule, ...):
3088            // the reference walk's `SpvError`, in the same words as before.
3089            Err(EngineError::ScriptWalkInconclusive { reason, .. }) => return Err(spv(reason)),
3090            // The budget, not the transaction: the walk COULD NOT RUN. Not a
3091            // refusal: the bytes are a BEEF the walk was not given the room
3092            // to judge, which says nothing about whether its scripts are
3093            // valid (refusing would refuse a valid BEEF for its size, the
3094            // owner's posture). Not an admission either: the caller's
3095            // [`WalkBreachPolicy`] decides, and only the network's word on
3096            // record lets the submit go on (the E592 lens fold, H1). The roots
3097            // the walk reached are checked first, below.
3098            Err(EngineError::ScriptWalkOverBudget {
3099                at_txid,
3100                subject_judged,
3101                limb,
3102                what,
3103            }) => {
3104                let limb = match limb {
3105                    DoorLimb::Memory => WalkLimb::OverMemory,
3106                    DoorLimb::Work if trace.interpreter_tripped => WalkLimb::InterpreterMemory,
3107                    DoorLimb::Work => WalkLimb::OverWork,
3108                };
3109                // THE ROOTS OF A BREACHED WALK (the E592 lens fold, H1): every
3110                // proven transaction it reached, and every proven source an
3111                // input read, whatever the scripts' verdict. A bad root is
3112                // the `SpvError` it is on a walk that ran: a breach never
3113                // skips a proof the walk relied on. Their cost is a tracker
3114                // question per BUMP, which the budget does not bound and
3115                // does not need to (the clean walk asks the same).
3116                let reached: Vec<String> = trace
3117                    .proven
3118                    .iter()
3119                    .chain(&trace.proven_sources)
3120                    .cloned()
3121                    .collect();
3122                Self::check_reached_roots(chain_tracker, beef_bytes, &reached).await?;
3123                return Ok(Some(WalkCouldNotRun {
3124                    subject_txid: subject_txid.to_string(),
3125                    at_txid,
3126                    subject_judged,
3127                    limb,
3128                    what,
3129                }));
3130            }
3131            Err(other) => return Err(other),
3132        }
3133
3134        // THE ROOTS: every proven transaction the walk reached, in the order
3135        // it reached them, its BUMP's root computed and asked of the tracker
3136        // once per BUMP (NL-6e). With no tracker the roots are computed and
3137        // accepted (`'scripts only'`). The walk trusted them as it went; the
3138        // reference checks them in the same walk, so a body with BOTH a bad
3139        // script and a bad root now names the script.
3140        Self::check_reached_roots(chain_tracker, beef_bytes, &trace.proven).await?;
3141        Ok(None)
3142    }
3143
3144    /// The roots of `reached` (display txids, in order; a repeat or a second
3145    /// transaction of one BUMP asks nothing more), each BUMP's root computed
3146    /// and asked of the tracker once. None reached: the body is not parsed.
3147    async fn check_reached_roots(
3148        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3149        beef_bytes: &[u8],
3150        reached: &[String],
3151    ) -> Result<(), EngineError> {
3152        let spv =
3153            |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3154        if reached.is_empty() {
3155            return Ok(());
3156        }
3157        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
3158            .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3159        let proven_in = proven_index(&beef);
3160        let mut checked: HashSet<usize> = HashSet::new();
3161        for txid in reached {
3162            let Some(&at) = proven_in.get(txid.as_str()) else {
3163                return Err(spv(format!("transaction {txid} is not in the BEEF")));
3164            };
3165            if !checked.insert(at) {
3166                continue; // its BUMP's root is the chain's, asked once
3167            }
3168            let mp = &beef.bumps[at];
3169            let root = mp
3170                .compute_root(Some(txid))
3171                .map_err(|e| spv(format!("invalid merkle path for transaction {txid}: {e}")))?;
3172            if let Some(tracker) = chain_tracker {
3173                match tracker
3174                    .is_valid_root_for_height(&root, mp.block_height)
3175                    .await
3176                {
3177                    Ok(true) => {}
3178                    Ok(false) => {
3179                        return Err(EngineError::SpvError(format!(
3180                            "Invalid merkle path for transaction {txid}: root {root} is not valid for block height {}",
3181                            mp.block_height
3182                        )));
3183                    }
3184                    Err(e) => {
3185                        return Err(EngineError::SpvError(format!(
3186                            "Chain tracker error at height {}: {e}",
3187                            mp.block_height
3188                        )));
3189                    }
3190                }
3191            }
3192        }
3193        Ok(())
3194    }
3195
3196    /// The pre-2026-09-08 SPV block, kept as the
3197    /// [`Engine::set_script_verification`] `false` escape hatch: BEEF
3198    /// structural validity plus every root against the chain tracker, and
3199    /// NOTHING when no tracker is configured. It never executes a script.
3200    ///
3201    /// The structure is the streaming reader's
3202    /// ([`beef_limits::structure_roots`], bsv-rs 0.4.2): one element in hand,
3203    /// each BUMP walked once, linear in its leaves (NL-6e). Until NL-6e it
3204    /// was `Beef::verify_valid`, whose root check at bsv-rs 0.4.0 walked a
3205    /// BUMP once per leaf: one BUMP of 8,192 leaves gave no answer in 120 s
3206    /// (bsv-stack-lean NL-8 W1). The reader reads the wire's order, where
3207    /// `verify_valid` sorted first: an input that names a later transaction
3208    /// is refused here.
3209    async fn verify_spv_structurally(
3210        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3211        beef_bytes: &[u8],
3212    ) -> Result<(), EngineError> {
3213        let Some(chain_tracker) = chain_tracker else {
3214            return Ok(());
3215        };
3216        let roots = beef_limits::structure_roots(beef_bytes)
3217            .map_err(|r| EngineError::SpvError(format!("BEEF structure refused: {r}")))?;
3218        let mut asked: HashSet<(u64, &str)> = HashSet::new();
3219        for (height, root) in &roots {
3220            if !asked.insert((*height, root.as_str())) {
3221                continue;
3222            }
3223            let Ok(height) = u32::try_from(*height) else {
3224                return Err(EngineError::SpvError(format!(
3225                    "Merkle root {root} invalid for block height {height}"
3226                )));
3227            };
3228            match chain_tracker.is_valid_root_for_height(root, height).await {
3229                Ok(true) => {}
3230                Ok(false) => {
3231                    return Err(EngineError::SpvError(format!(
3232                        "Merkle root {root} invalid for block height {height}"
3233                    )));
3234                }
3235                Err(e) => {
3236                    return Err(EngineError::SpvError(format!(
3237                        "Chain tracker error at height {height}: {e}"
3238                    )));
3239                }
3240            }
3241        }
3242        Ok(())
3243    }
3244
3245    /// Phase 1 of ONE topic: the dedup read, the previous coins, the
3246    /// manager's judgement. Read-only. [`Engine::run_validation`] asks it of
3247    /// every topic; the door asks it again of a topic whose predecessor it
3248    /// landed first from the BEEF (lane E1D's lens fold, M1).
3249    #[allow(clippy::too_many_arguments)]
3250    async fn validate_topic(
3251        &self,
3252        tx: &Transaction,
3253        txid: &str,
3254        topic: &str,
3255        off_chain_values: Option<&[u8]>,
3256        mode: SubmitMode,
3257        context: &TopicAdmittanceContext,
3258        bound: Option<&CallBound>,
3259    ) -> TopicValidation {
3260        let tx_record = AppliedTransaction {
3261            txid: txid.to_string(),
3262            topic: topic.to_string(),
3263        };
3264        // A dedup read that FAULTS is the topic's read fault (the delta
3265        // fold of 2026-10-07, H3): nothing is judged and nothing is
3266        // written for the topic, the fault rides the report and the
3267        // replay asks again. The reference fails the topic there
3268        // (`Engine.ts` `submit`: the throw lands in the per-topic catch,
3269        // `failedTopics.add`). Read as "not a dupe", as it was, a
3270        // re-presented transaction the manager admits with no previous
3271        // coin was inserted a second time, unspent, beside the output
3272        // that had spent it, under a durable report.
3273        let is_dupe = match stored(
3274            bound,
3275            self.storage.does_applied_transaction_exist(&tx_record),
3276        )
3277        .await
3278        {
3279            Ok(is_dupe) => is_dupe,
3280            Err(e) => {
3281                error!("Error reading the applied row of {txid} for topic {topic}: {e}");
3282                return TopicValidation {
3283                    topic: topic.to_string(),
3284                    is_dupe: false,
3285                    previous_coins: vec![],
3286                    previous_outputs: vec![],
3287                    admittance: AdmittanceInstructions::default(),
3288                    failed: false,
3289                    read_fault: Some(("does_applied_transaction_exist", e.to_string())),
3290                };
3291            }
3292        };
3293
3294        if is_dupe {
3295            return TopicValidation {
3296                topic: topic.to_string(),
3297                is_dupe: true,
3298                previous_coins: vec![],
3299                previous_outputs: vec![],
3300                admittance: AdmittanceInstructions::default(),
3301                failed: false,
3302                read_fault: None,
3303            };
3304        }
3305
3306        let mut previous_coins: Vec<u32> = Vec::new();
3307        let mut previous_outputs: Vec<Output> = Vec::new();
3308        let mut read_fault: Option<(&'static str, String)> = None;
3309
3310        for (input_idx, input) in tx.inputs.iter().enumerate() {
3311            let source_txid = input.get_source_txid().unwrap_or_default();
3312            if source_txid.is_empty() {
3313                continue;
3314            }
3315            match stored(
3316                bound,
3317                self.storage.find_output(
3318                    &source_txid,
3319                    input.source_output_index,
3320                    Some(topic),
3321                    None,
3322                    false,
3323                ),
3324            )
3325            .await
3326            {
3327                Ok(Some(prev_output)) => {
3328                    previous_coins.push(input_idx as u32);
3329                    previous_outputs.push(prev_output);
3330                }
3331                Ok(None) => {}
3332                Err(e) => {
3333                    // A faulted read is NOT "no previous coin": the
3334                    // spend may consume an admitted output we simply
3335                    // could not read. Validation proceeds (the topic
3336                    // manager decides on what it can see) but the fault
3337                    // rides the report and blocks the applied record.
3338                    error!(
3339                        "Error reading previous output {source_txid}:{} for topic {topic}: {e}",
3340                        input.source_output_index
3341                    );
3342                    if read_fault.is_none() {
3343                        read_fault = Some(("find_output", e.to_string()));
3344                    }
3345                }
3346            }
3347        }
3348
3349        let judged = self
3350            .judge(
3351                tx,
3352                topic,
3353                &previous_coins,
3354                off_chain_values,
3355                mode,
3356                context,
3357                bound,
3358            )
3359            .await;
3360        match judged {
3361            Ok(admittance) => TopicValidation {
3362                topic: topic.to_string(),
3363                is_dupe: false,
3364                previous_coins,
3365                previous_outputs,
3366                admittance,
3367                failed: false,
3368                read_fault: read_fault.clone(),
3369            },
3370            Err(e) => {
3371                error!("Error validating topic {topic} during submit: {e}");
3372                TopicValidation {
3373                    topic: topic.to_string(),
3374                    is_dupe: false,
3375                    previous_coins: vec![],
3376                    previous_outputs: vec![],
3377                    admittance: AdmittanceInstructions::default(),
3378                    failed: true,
3379                    read_fault,
3380                }
3381            }
3382        }
3383    }
3384
3385    /// The topic manager's judgement of `tx` over the previous coins found
3386    /// (input indices), as [`Engine::validate_topic`] asks it; a carried
3387    /// predecessor's submit asks it again for real once its topic does not
3388    /// wait (the E1D delta fold, L4).
3389    #[allow(clippy::too_many_arguments)]
3390    async fn judge(
3391        &self,
3392        tx: &Transaction,
3393        topic: &str,
3394        previous_coins: &[u32],
3395        off_chain_values: Option<&[u8]>,
3396        mode: SubmitMode,
3397        context: &TopicAdmittanceContext,
3398        bound: Option<&CallBound>,
3399    ) -> Result<AdmittanceInstructions, TopicManagerError> {
3400        let manager = &self.managers[topic];
3401        let previous_coin_bytes = previous_coins
3402            .iter()
3403            .flat_map(|i| i.to_le_bytes())
3404            .collect::<Vec<u8>>();
3405        // Under a finalize submit's bound a manager that does not answer
3406        // is a manager that failed: nothing is written for the topic.
3407        bounded(
3408            bound,
3409            manager.identify_admissible_outputs(
3410                // The ONE engine parse of the submitted BEEF, shared by
3411                // every topic on this submit (bsv-low #289 — managers
3412                // used to re-parse the same bytes independently).
3413                tx,
3414                &previous_coin_bytes,
3415                off_chain_values,
3416                mode,
3417                // `dry_run` is `false` on a submit, `true` on a
3418                // validate-only call.
3419                context,
3420            ),
3421        )
3422        .await
3423        .map_err(TopicManagerError::Other)
3424        .and_then(|judged| judged)
3425    }
3426
3427    /// Whether the door may land a carried predecessor into `topic` (the
3428    /// E1D delta fold, L1): neither its manager nor any lookup service
3429    /// reads off-chain values there, which the landing does not have.
3430    fn lands_without_values(&self, topic: &str) -> bool {
3431        self.managers
3432            .get(topic)
3433            .is_some_and(|manager| !manager.reads_off_chain_values())
3434            && self
3435                .lookup_services
3436                .values()
3437                .all(|ls| !ls.reads_off_chain_values(topic))
3438    }
3439
3440    /// Run Phase 1 (topic validation) and Phase 2 (broadcast) without mutating storage.
3441    ///
3442    /// Returns (validations, steak, parsed_tx, txid) so the caller can either
3443    /// stop (validate-only) or proceed with Phase 3 (mutations).
3444    async fn run_validation(
3445        &self,
3446        tagged_beef: &TaggedBEEF,
3447        mode: SubmitMode,
3448        context: &TopicAdmittanceContext,
3449        bound: Option<&CallBound>,
3450        walked: Option<&HashSet<String>>,
3451        policy: WalkBreachPolicy,
3452    ) -> Result<Validated, EngineError> {
3453        // Validate all topics are supported
3454        for topic in &tagged_beef.topics {
3455            if !self.managers.contains_key(topic) {
3456                return Err(EngineError::UnsupportedTopic(topic.clone()));
3457            }
3458        }
3459
3460        // Parse the SUBJECT from the BEEF — order-independent (`subject.rs`,
3461        // loop-2 hardening 2026-09-05): `from_beef(_, None)` takes the last
3462        // tx in wire order, which an SDK-sorted INCOMPLETE ancestry fills
3463        // with a fully-sourced ancestor; the engine then judged a hop, admitted
3464        // nothing and the pot never entered the index while its JOIN mined.
3465        let subject = {
3466            let mut b =
3467                beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
3468                    .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3469            crate::subject::subject_txid_of(&mut b)
3470        };
3471        let tx = beef_limits::transaction_from_beef(
3472            &tagged_beef.beef,
3473            subject.as_deref(),
3474            &beef_limits::ENGINE_BEEF_LIMITS,
3475        )
3476        .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3477        let txid = tx.id();
3478
3479        // SPV verification, skipped ONLY for HistoricalTxNoSpv, exactly the
3480        // reference's `if (mode !== 'historical-tx-no-spv') tx.verify(...)`.
3481        // That mode exists for GASP `finalizeGraph`, whose graphs
3482        // `validateGraphAnchor` already verified, and since bsv-low #551 that
3483        // is TRUE here: `OverlayGASPStorage::validate_graph_anchor` runs this
3484        // same check ([`verify_spv_like_the_reference`], the same tracker and
3485        // the same switch) over the ROOT node's BEEF and replays the graph
3486        // through the topic manager before a single BEEF of it reaches this
3487        // door. Before #551 the anchor check was a no-op and this skip
3488        // admitted a peer's graph on structure alone.
3489        //
3490        // A predecessor the door lands first (`walked`, the E1D delta fold,
3491        // L4) is not walked again when this submit's walks already passed
3492        // over it, and its walk trusts what they passed over.
3493        //
3494        // The walk runs under the engine's budget (bsv-low #592,
3495        // [`Engine::set_walk_budget`]): one that could not run within it is
3496        // NOT a refusal (see `verify_beef_linear`), and NOT an admission
3497        // unless the caller holds the network's accept of these bytes (the
3498        // E592 lens fold, H1, [`WalkBreachPolicy`]): "not now" before any
3499        // manager is asked, or, under `NetworkAccepted`, this submit goes on
3500        // as under `HistoricalTxNoSpv` and the report says so.
3501        let mut could_not_run = None;
3502        if mode != SubmitMode::HistoricalTxNoSpv {
3503            match walked {
3504                Some(walked) if walked.contains(&txid) => {}
3505                trusted => {
3506                    could_not_run = verify_spv_trusting(
3507                        self.chain_tracker.as_deref(),
3508                        self.verify_scripts,
3509                        &tagged_beef.beef,
3510                        &txid,
3511                        trusted.unwrap_or(&HashSet::new()),
3512                        self.walk_budget,
3513                    )
3514                    .await?;
3515                }
3516            }
3517        }
3518        if let Some(stop) = could_not_run.take() {
3519            match policy {
3520                WalkBreachPolicy::NotNow => {
3521                    warn!(
3522                        "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): not now, nothing judged or written; present it proven (bsv-low #592, E592 fold)",
3523                        stop.subject_txid,
3524                        stop.limb.as_str(),
3525                        stop.at_txid,
3526                        stop.subject_judged,
3527                        stop.what
3528                    );
3529                    return Err(EngineError::WalkCouldNotRun(stop));
3530                }
3531                WalkBreachPolicy::NetworkAccepted => {
3532                    warn!(
3533                        "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): the network accepted these bytes, the submit goes on as historical-tx-no-spv (bsv-low #592)",
3534                        stop.subject_txid,
3535                        stop.limb.as_str(),
3536                        stop.at_txid,
3537                        stop.subject_judged,
3538                        stop.what
3539                    );
3540                    could_not_run = Some(stop);
3541                }
3542            }
3543        }
3544
3545        let mut steak = Steak::new();
3546
3547        // =============================================================
3548        // PHASE 1: VALIDATE (read-only)
3549        // =============================================================
3550        let mut validations = Vec::new();
3551
3552        for topic in &tagged_beef.topics {
3553            validations.push(
3554                self.validate_topic(
3555                    &tx,
3556                    &txid,
3557                    topic,
3558                    tagged_beef.off_chain_values.as_deref(),
3559                    mode,
3560                    context,
3561                    bound,
3562                )
3563                .await,
3564            );
3565        }
3566
3567        // Build preliminary STEAK
3568        for v in &validations {
3569            steak.insert(v.topic.clone(), v.admittance.clone());
3570        }
3571
3572        // =============================================================
3573        // PHASE 2: BROADCAST / SHIP propagation (before mutations)
3574        // =============================================================
3575        if mode == SubmitMode::CurrentTx {
3576            // ── ARC network broadcast ──────────────────────────────────
3577            // Broadcast to miners via ARC, matching the TS Engine pattern.
3578            // Skip if the tx already has a merkle path (already mined).
3579            //
3580            // INCIDENT D1-CALLBACK-FLOOD 2026-09-01: also skip when EVERY
3581            // topic classified the tx as a dupe (already applied) — the
3582            // exact `!v.is_dupe` filter the SHIP arm below has always had,
3583            // ten lines away. Without it, every RE-PRESENT of an
3584            // already-applied tx (cron ad-sync, peer crawl, a client
3585            // retrying a default-mode /submit) re-POSTed to Arcade and
3586            // re-registered the status callback, and registrations
3587            // accumulate per POST on Arcade's side — the flood's fuel. A
3588            // genuinely new tx (any topic non-dupe) broadcasts exactly as
3589            // before.
3590            let all_topics_dupe = !validations.is_empty() && validations.iter().all(|v| v.is_dupe);
3591            if let Some(ref arc) = self.arc_broadcaster {
3592                if all_topics_dupe {
3593                    info!(
3594                        "Skipping ARC broadcast — tx already applied to every topic (re-present)"
3595                    );
3596                } else if tx.merkle_path.is_none() {
3597                    let raw_tx_hex = tx.to_hex();
3598                    match arc.broadcast(&raw_tx_hex).await {
3599                        Ok(arc_txid) => {
3600                            info!("ARC broadcast succeeded: txid={arc_txid}");
3601                        }
3602                        Err(e) => {
3603                            error!("ARC broadcast failed (non-fatal): {e}");
3604                        }
3605                    }
3606                } else {
3607                    info!("Skipping ARC broadcast — tx already has merkle proof");
3608                }
3609            }
3610
3611            // ── SHIP peer propagation ──────────────────────────────────
3612            if let Some(ref broadcaster) = self.broadcaster {
3613                let relevant_topics: Vec<String> = validations
3614                    .iter()
3615                    .filter(|v| {
3616                        !v.is_dupe && !v.failed && !v.admittance.outputs_to_admit.is_empty()
3617                    })
3618                    .map(|v| v.topic.clone())
3619                    .collect();
3620
3621                if !relevant_topics.is_empty() {
3622                    if let Some(ship_ls) = self.lookup_services.get("ls_ship") {
3623                        for topic in &relevant_topics {
3624                            let question = LookupQuestion::new(
3625                                "ls_ship",
3626                                serde_json::json!({ "topics": [topic] }),
3627                            );
3628                            match ship_ls.lookup(&question).await {
3629                                Ok(LookupResult::OutputList(refs)) => {
3630                                    for reference in &refs {
3631                                        if let Ok(Some(output)) = self
3632                                            .storage
3633                                            .find_output(
3634                                                &reference.txid,
3635                                                reference.output_index,
3636                                                None,
3637                                                None,
3638                                                false,
3639                                            )
3640                                            .await
3641                                        {
3642                                            if let Some(domain) =
3643                                                parse_ship_domain_from_script(&output.output_script)
3644                                            {
3645                                                if let Some(ref our_url) = self.config.hosting_url {
3646                                                    if domain.trim_end_matches('/')
3647                                                        == our_url.trim_end_matches('/')
3648                                                    {
3649                                                        continue;
3650                                                    }
3651                                                }
3652                                                if let Err(e) = broadcaster
3653                                                    .broadcast_to_host(&domain, tagged_beef)
3654                                                    .await
3655                                                {
3656                                                    error!(
3657                                                        "SHIP propagation to {domain} failed for topic {topic}: {e}"
3658                                                    );
3659                                                }
3660                                            }
3661                                        }
3662                                    }
3663                                }
3664                                Ok(LookupResult::Answer(_)) => {
3665                                    // SHIP only ever returns OutputList; an Answer here means
3666                                    // a misconfigured replacement service. Skip rather than
3667                                    // attempt to extract refs from a freeform/formula payload.
3668                                    error!(
3669                                        "SHIP lookup for topic {topic} returned a pre-formed \
3670                                         LookupAnswer; expected OutputList. Skipping fanout."
3671                                    );
3672                                }
3673                                Err(e) => {
3674                                    error!("SHIP lookup for topic {topic} failed: {e}");
3675                                }
3676                            }
3677                        }
3678                    }
3679                }
3680            }
3681        }
3682
3683        Ok((validations, steak, tx, txid, could_not_run))
3684    }
3685
3686    // ========================================================================
3687    // Lookup
3688    // ========================================================================
3689
3690    /// Answer a lookup query.
3691    ///
3692    /// Delegates to the appropriate LookupService, then hydrates results with BEEF.
3693    ///
3694    /// When `history_selector` is provided, each output is hydrated with its
3695    /// ancestor spend chain (via `get_utxo_history`) before building the response.
3696    /// This matches the TS Engine behavior when a history selector is configured.
3697    pub async fn lookup(
3698        &self,
3699        question: &LookupQuestion,
3700        history_selector: Option<HistorySelector>,
3701    ) -> Result<LookupAnswer, EngineError> {
3702        Ok(self.lookup_with_txids(question, history_selector).await?.0)
3703    }
3704
3705    /// Same as [`Engine::lookup`], additionally returning each hydrated
3706    /// output's txid, aligned index-for-index with the `OutputList` items
3707    /// (empty for pre-formed `Answer`s).
3708    ///
3709    /// The txid IS the storage primary key the row was just fetched by —
3710    /// handing it to the caller lets the aggregated wire serializer write it
3711    /// directly instead of re-deriving it with a full BEEF parse plus a
3712    /// double-SHA256 per output (bsv-low #289).
3713    pub async fn lookup_with_txids(
3714        &self,
3715        question: &LookupQuestion,
3716        history_selector: Option<HistorySelector>,
3717    ) -> Result<(LookupAnswer, Vec<String>), EngineError> {
3718        let service = self
3719            .lookup_services
3720            .get(&question.service)
3721            .ok_or_else(|| EngineError::LookupServiceNotFound(question.service.clone()))?;
3722
3723        // Preserve WHOSE fault it was. A lookup service reports a malformed
3724        // query as `InvalidQuery` / `Unsupported`; stringifying both into
3725        // `LookupFailed` (as this did) threw that away and reported every
3726        // caller mistake as a 500.
3727        let result = service.lookup(question).await.map_err(|e| match e {
3728            LookupServiceError::InvalidQuery(m) | LookupServiceError::Unsupported(m) => {
3729                EngineError::InvalidQuery(m)
3730            }
3731            other => EngineError::LookupFailed(other.to_string()),
3732        })?;
3733
3734        // Two paths per LookupResult:
3735        // - OutputList(refs): the LS yields outpoints; we hydrate each with
3736        //   BEEF (and optional ancestor chain via history_selector) and
3737        //   assemble LookupAnswer::OutputList.
3738        // - Answer(answer): the LS already produced the full answer
3739        //   (Freeform/Formula); pass through verbatim. The Engine does NOT
3740        //   apply history-selector hydration to a pre-formed Answer — the
3741        //   LS is presumed to have embedded whatever ancestry it wants.
3742        let refs = match result {
3743            LookupResult::OutputList(refs) => refs,
3744            LookupResult::Answer(answer) => return Ok((answer, Vec::new())),
3745        };
3746
3747        // ONE batched hydration query for the whole result set instead of a
3748        // D1 round-trip per row (the #289 N+1). Order is preserved: the
3749        // batch contract returns outputs in input-outpoint order, skipping
3750        // outpoints with no stored row. A storage error is a REAL error now
3751        // (it used to be swallowed per-row into a silently-empty list — the
3752        // "vanishing table" failure mode): an uncertain read must never
3753        // masquerade as a confidently-empty lobby.
3754        let outpoints: Vec<Outpoint> = refs
3755            .iter()
3756            .map(|r| Outpoint::new(&r.txid, r.output_index))
3757            .collect();
3758        let found = self
3759            .storage
3760            .find_served_outputs(&outpoints)
3761            .await
3762            .map_err(|e| EngineError::StorageError(e.to_string()))?;
3763
3764        let mut outputs = Vec::new();
3765        let mut txids = Vec::new();
3766        for output in found {
3767            // If history selector provided, hydrate ancestor chain
3768            let final_output = if history_selector.is_some() {
3769                match self
3770                    .get_utxo_history(&output, history_selector.clone())
3771                    .await
3772                {
3773                    Ok(Some(hydrated)) => hydrated,
3774                    _ => output,
3775                }
3776            } else {
3777                output
3778            };
3779
3780            // Skip an output whose BEEF is missing OR empty: a lookup item
3781            // without decodable BEEF is useless to the client (it can only
3782            // drop it), and returning an empty-BEEF row is what made a fresh
3783            // LOW table show up in a query yet be undecodable/invisible to
3784            // opponents (the "vanishing table" — 2026-07-11). Only ever
3785            // return fully-hydrated, decodable outputs.
3786            if let Some(beef) = final_output.beef {
3787                if !beef.is_empty() {
3788                    txids.push(final_output.txid.clone());
3789                    outputs.push(OutputListItem {
3790                        beef,
3791                        output_index: final_output.output_index,
3792                        context: None,
3793                    });
3794                }
3795            }
3796        }
3797
3798        Ok((LookupAnswer::OutputList { outputs }, txids))
3799    }
3800
3801    // ========================================================================
3802    // Metadata
3803    // ========================================================================
3804
3805    // ========================================================================
3806    // UTXO History
3807    // ========================================================================
3808
3809    /// Traverse and return the history of a UTXO.
3810    ///
3811    /// If no `history_selector` is provided, returns the output as-is.
3812    /// If a depth selector is provided, includes up to N levels of ancestor
3813    /// transactions by embedding them as source_transactions in the BEEF.
3814    pub async fn get_utxo_history(
3815        &self,
3816        output: &Output,
3817        history_selector: Option<HistorySelector>,
3818    ) -> Result<Option<Output>, EngineError> {
3819        let Some(selector) = history_selector else {
3820            return Ok(Some(output.clone()));
3821        };
3822
3823        // Verify BEEF exists before attempting hydration
3824        if output.beef.is_none() {
3825            return Err(EngineError::Other(
3826                "Output must have associated transaction BEEF!".into(),
3827            ));
3828        }
3829
3830        match self.hydrate_utxo_history(output, &selector, 0).await {
3831            Ok(Some(hydrated_output)) => Ok(Some(hydrated_output)),
3832            Ok(None) => Ok(None),
3833            Err(e) => Err(EngineError::Other(format!(
3834                "Error retrieving UTXO history: {e}"
3835            ))),
3836        }
3837    }
3838
3839    /// Recursive UTXO history hydration.
3840    fn hydrate_utxo_history<'a>(
3841        &'a self,
3842        output: &'a Output,
3843        selector: &'a HistorySelector,
3844        current_depth: u32,
3845    ) -> std::pin::Pin<
3846        Box<dyn std::future::Future<Output = Result<Option<Output>, EngineError>> + 'a>,
3847    > {
3848        Box::pin(async move {
3849            let beef_data = output.beef.as_ref().ok_or_else(|| {
3850                EngineError::Other("Output must have associated transaction BEEF!".into())
3851            })?;
3852
3853            // Check if we should traverse at this depth
3854            let should_traverse = match selector {
3855                HistorySelector::Depth(max_depth) => current_depth <= *max_depth,
3856            };
3857
3858            if !should_traverse {
3859                return Ok(None);
3860            }
3861
3862            // Start from the STORED BEEF, not from a re-parsed subject.
3863            //
3864            // #4: this used to be `Transaction::from_beef(beef_data, None)` followed
3865            // by `tx.to_beef(true)`, which keeps only the subject transaction and
3866            // therefore DESTROYS the stored BUMPs. Measured on prod before the fix:
3867            // `ls_low` byGameId `73de6e48…` returned 1,172 B / 1 tx / 1 bump (PROVEN)
3868            // with no header, and 676 B / 1 tx / 0 bumps with `x-history-depth: 3` —
3869            // a caller asking for MORE provenance got back a BEEF no wallet can
3870            // verify, silently, behind a 200.
3871            //
3872            // Accumulating into the stored BEEF fixes both halves: its own bumps
3873            // survive, and so does any ancestry that arrived inside the submitted
3874            // bytes but was never admitted as an output of its own (a foreign
3875            // parent), which the `outputs_consumed` walk below cannot see.
3876            let mut acc = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
3877                .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3878
3879            // For each consumed output, recursively hydrate and embed as source transaction
3880            for consumed in &output.outputs_consumed {
3881                if let Ok(Some(child_output)) = self
3882                    .storage
3883                    .find_output(&consumed.txid, consumed.output_index, None, None, true)
3884                    .await
3885                {
3886                    if let Ok(Some(hydrated_child)) = self
3887                        .hydrate_utxo_history(&child_output, selector, current_depth + 1)
3888                        .await
3889                    {
3890                        // MERGE the hydrated ancestor in, rather than embedding it
3891                        // as a `source_transaction` on a re-parsed subject: a merge
3892                        // carries the child's own BUMPs across, which is the whole
3893                        // point of asking for history. An unparseable child is
3894                        // skipped — never allowed to poison the accumulator.
3895                        if let Some(ref child_beef) = hydrated_child.beef {
3896                            if let Ok(child) = Beef::from_binary(child_beef) {
3897                                acc.merge_beef(&child);
3898                            }
3899                        }
3900                    }
3901                }
3902            }
3903
3904            Ok(Some(Output {
3905                beef: Some(acc.to_binary()),
3906                ..output.clone()
3907            }))
3908        })
3909    }
3910
3911    // ========================================================================
3912    // Metadata
3913    // ========================================================================
3914
3915    pub async fn list_topic_managers(&self) -> HashMap<String, ServiceMetadata> {
3916        let mut result = HashMap::new();
3917        for (name, manager) in &self.managers {
3918            let meta = manager.get_metadata().await;
3919            result.insert(name.clone(), meta);
3920        }
3921        result
3922    }
3923
3924    /// List all registered lookup services with their metadata.
3925    pub async fn list_lookup_service_providers(&self) -> HashMap<String, ServiceMetadata> {
3926        let mut result = HashMap::new();
3927        for (name, service) in &self.lookup_services {
3928            let meta = service.get_metadata().await;
3929            result.insert(name.clone(), meta);
3930        }
3931        result
3932    }
3933
3934    /// Get documentation for a specific topic manager.
3935    pub async fn get_documentation_for_topic_manager(&self, name: &str) -> String {
3936        match self.managers.get(name) {
3937            Some(manager) => manager.get_documentation().await,
3938            None => "No documentation found!".to_string(),
3939        }
3940    }
3941
3942    /// Get documentation for a specific lookup service.
3943    pub async fn get_documentation_for_lookup_service(&self, name: &str) -> String {
3944        match self.lookup_services.get(name) {
3945            Some(service) => service.get_documentation().await,
3946            None => "No documentation found!".to_string(),
3947        }
3948    }
3949
3950    // ========================================================================
3951    // GASP endpoints
3952    // ========================================================================
3953
3954    /// Respond to a GASP initial sync request.
3955    ///
3956    /// Returns UTXOs for the given topic since the requested score.
3957    ///
3958    /// The caller-supplied `limit` is CLAMPED to
3959    /// [`Engine::SYNC_RESPONSE_MAX_LIMIT`] (and defaulted when absent):
3960    /// `/requestSyncResponse` is a PUBLIC route, and an absent limit used to
3961    /// mean an unbounded scan of the outputs table (bsv-low #291). Bounding
3962    /// is lossless for sync correctness because the initiator
3963    /// ([`crate::gasp::GASPSync`]) pages for as long as its `since` score
3964    /// cursor advances — a truncated page is fetched by its NEXT request
3965    /// within the same run (gate finding M1: pagination must not demand a
3966    /// full page, which this clamp can make impossible).
3967    pub async fn provide_foreign_sync_response(
3968        &self,
3969        request: &GASPInitialRequest,
3970        topic: &str,
3971    ) -> Result<GASPInitialResponse, EngineError> {
3972        let limit = Self::clamp_sync_limit(request.limit);
3973        let outputs = self
3974            .storage
3975            .find_utxos_for_topic(topic, Some(request.since as f64), Some(limit), false)
3976            .await
3977            .map_err(|e| EngineError::StorageError(e.to_string()))?;
3978
3979        Ok(GASPInitialResponse {
3980            utxo_list: outputs
3981                .iter()
3982                .map(|o| GASPOutput {
3983                    txid: o.txid.clone(),
3984                    output_index: o.output_index,
3985                    score: o.score.unwrap_or(0.0),
3986                })
3987                .collect(),
3988            since: request.since,
3989        })
3990    }
3991
3992    /// Provide a GASPNode for a specific transaction within a graph.
3993    ///
3994    /// Searches the BEEF tree of the root output (identified by graphID) for
3995    /// the requested txid. If not found in the BEEF tree, recurses through
3996    /// outputsConsumed in storage.
3997    pub async fn provide_foreign_gasp_node(
3998        &self,
3999        graph_id: &str,
4000        txid: &str,
4001        output_index: u32,
4002    ) -> Result<GASPNode, EngineError> {
4003        let root_outpoint = Outpoint::from_graph_id(graph_id)
4004            .ok_or_else(|| EngineError::Other(format!("Invalid graphID: {graph_id}")))?;
4005
4006        let root_output = self
4007            .storage
4008            .find_output(
4009                &root_outpoint.txid,
4010                root_outpoint.output_index,
4011                None,
4012                None,
4013                true,
4014            )
4015            .await
4016            .map_err(|e| EngineError::StorageError(e.to_string()))?
4017            .ok_or(EngineError::NodeNotFound)?;
4018
4019        self.hydrate_gasp_node(&root_output, graph_id, txid, output_index)
4020            .await
4021    }
4022
4023    /// The nodes of up to [`crate::node_batch::NODE_BATCH_MAX_OUTPOINTS`]
4024    /// outpoints in one answer of at most
4025    /// [`crate::node_batch::NODE_BATCH_MAX_BYTES`] (engine 0.2.1, #58): each
4026    /// as [`Self::provide_foreign_gasp_node`] answers it for its own graph,
4027    /// in the asked order, a repeat answered once. `NodeNotFound` is the
4028    /// entry `not_found`; any other error the entry `error`. The outpoints
4029    /// from the one that does not fit on are left out (the asker names
4030    /// them again); a node larger than the whole budget is `over_budget`.
4031    pub async fn provide_foreign_gasp_nodes(
4032        &self,
4033        outpoints: &[crate::node_batch::BatchOutpoint],
4034    ) -> Result<crate::node_batch::NodeBatchAnswer, EngineError> {
4035        use crate::node_batch::{
4036            refusal, NodeBatchEntry, Packer, NODE_BATCH_MAX_BYTES, NODE_BATCH_MAX_OUTPOINTS,
4037        };
4038        if outpoints.len() > NODE_BATCH_MAX_OUTPOINTS {
4039            return Err(EngineError::InvalidQuery(format!(
4040                "a node batch names at most {NODE_BATCH_MAX_OUTPOINTS} outpoints"
4041            )));
4042        }
4043        let mut packer = Packer::new(NODE_BATCH_MAX_BYTES);
4044        let mut seen = std::collections::HashSet::new();
4045        for outpoint in outpoints {
4046            if !seen.insert(outpoint) {
4047                continue;
4048            }
4049            let graph_id = outpoint.key();
4050            let answer = self
4051                .provide_foreign_gasp_node(&graph_id, &outpoint.txid, outpoint.vout)
4052                .await;
4053            let (node, refused) = match answer {
4054                Ok(node) => (Some(node), None),
4055                Err(EngineError::NodeNotFound) => (None, Some(refusal::NOT_FOUND)),
4056                Err(_) => (None, Some(refusal::ERROR)),
4057            };
4058            if !packer.push(NodeBatchEntry {
4059                txid: outpoint.txid.clone(),
4060                vout: outpoint.vout,
4061                node,
4062                refused: refused.map(str::to_string),
4063            }) {
4064                break;
4065            }
4066        }
4067        Ok(packer.answer)
4068    }
4069
4070    /// Recursively search a BEEF tree for a specific txid and return a GASPNode.
4071    fn hydrate_gasp_node<'a>(
4072        &'a self,
4073        output: &'a Output,
4074        graph_id: &'a str,
4075        txid: &'a str,
4076        output_index: u32,
4077    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GASPNode, EngineError>> + 'a>>
4078    {
4079        Box::pin(async move {
4080            let beef_data = output.beef.as_ref().ok_or(EngineError::NodeNotFound)?;
4081
4082            // The stored body's SUBJECT by the same order-independent rule
4083            // (`subject.rs`) — a plain stored body's last tx can be an ancestor.
4084            let root = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
4085                .ok()
4086                .and_then(|mut b| crate::subject::subject_txid_of(&mut b));
4087            let root_tx = beef_limits::transaction_from_beef(
4088                beef_data,
4089                root.as_deref(),
4090                &beef_limits::STORED_BEEF_LIMITS,
4091            )
4092            .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
4093
4094            // Search the transaction tree for the requested txid
4095            if let Some(node) = Self::search_tx_tree(&root_tx, graph_id, txid, output_index) {
4096                return Ok(node);
4097            }
4098
4099            // Fallback: try parsing BEEF with the target txid directly
4100            // (some BEEF structures have the tx in the list but not linked as source_transaction)
4101            if let Some(node) = Self::search_beef_for_txid(beef_data, graph_id, txid, output_index)
4102            {
4103                return Ok(node);
4104            }
4105
4106            // Not found in BEEF tree — recurse through outputsConsumed in storage
4107            for consumed in &output.outputs_consumed {
4108                if let Ok(Some(consumed_output)) = self
4109                    .storage
4110                    .find_output(&consumed.txid, consumed.output_index, None, None, true)
4111                    .await
4112                {
4113                    if let Ok(node) = self
4114                        .hydrate_gasp_node(&consumed_output, graph_id, txid, output_index)
4115                        .await
4116                    {
4117                        return Ok(node);
4118                    }
4119                }
4120            }
4121
4122            // The graph root is known but neither its BEEF nor anything it
4123            // consumed holds the requested transaction. That is a definite
4124            // "not held", the same answer as an unknown root: the reference
4125            // throws `Unable to find output associated with your request!`
4126            // here and its route answers 400. A syncing peer reads the 400 as
4127            // `GASPError::NodeNotFound` and prunes a decoy input on it (the D8
4128            // rule, `GASPSync::process_incoming_node`); a 500 would read as a
4129            // fault of the moment and strand the chain behind the decoy.
4130            Err(EngineError::NodeNotFound)
4131        })
4132    }
4133
4134    /// Search a Transaction tree (via sourceTransaction links) for a specific txid.
4135    fn search_tx_tree(
4136        tx: &Transaction,
4137        graph_id: &str,
4138        target_txid: &str,
4139        output_index: u32,
4140    ) -> Option<GASPNode> {
4141        let current_txid = tx.id();
4142
4143        if current_txid == target_txid {
4144            let mut node = GASPNode {
4145                graph_id: graph_id.to_string(),
4146                raw_tx: tx.to_hex(),
4147                output_index,
4148                proof: None,
4149                tx_metadata: None,
4150                output_metadata: None,
4151                inputs: None,
4152            };
4153            if let Some(ref merkle_path) = tx.merkle_path {
4154                node.proof = Some(merkle_path.to_hex());
4155            }
4156            return Some(node);
4157        }
4158
4159        // Recurse into inputs via source_transaction
4160        for input in &tx.inputs {
4161            if let Some(ref source_tx) = input.source_transaction {
4162                if let Some(node) =
4163                    Self::search_tx_tree(source_tx, graph_id, target_txid, output_index)
4164                {
4165                    return Some(node);
4166                }
4167            }
4168        }
4169
4170        None
4171    }
4172
4173    /// Alternative search: try parsing the BEEF with the target txid directly.
4174    /// Some BEEF structures may not link source_transaction on inputs but
4175    /// still contain the transaction in the BEEF's transaction list.
4176    fn search_beef_for_txid(
4177        beef_data: &[u8],
4178        graph_id: &str,
4179        target_txid: &str,
4180        output_index: u32,
4181    ) -> Option<GASPNode> {
4182        // Try parsing BEEF with the specific txid
4183        if let Ok(tx) = beef_limits::transaction_from_beef(
4184            beef_data,
4185            Some(target_txid),
4186            &beef_limits::STORED_BEEF_LIMITS,
4187        ) {
4188            if tx.id() == target_txid {
4189                let mut node = GASPNode {
4190                    graph_id: graph_id.to_string(),
4191                    raw_tx: tx.to_hex(),
4192                    output_index,
4193                    proof: None,
4194                    tx_metadata: None,
4195                    output_metadata: None,
4196                    inputs: None,
4197                };
4198                if let Some(ref merkle_path) = tx.merkle_path {
4199                    node.proof = Some(merkle_path.to_hex());
4200                }
4201                return Some(node);
4202            }
4203        }
4204        None
4205    }
4206
4207    // ========================================================================
4208    // Merkle proof handling
4209    // ========================================================================
4210
4211    /// Handle a new merkle proof for a transaction.
4212    ///
4213    /// When a transaction gets mined, ARC calls back with the merkle proof.
4214    /// This method:
4215    /// 1. Finds all outputs for the txid in storage
4216    /// 2. Parses the BEEF, updates the merkle path in the transaction tree
4217    /// 3. Serializes updated BEEF back to storage
4218    /// 4. Recursively updates the consumedBy chain (outputs that spent this one)
4219    /// 5. Updates blockHeight on the outputs
4220    pub async fn handle_new_merkle_proof(
4221        &self,
4222        txid: &str,
4223        proof_hex: &str,
4224        block_height: Option<u32>,
4225    ) -> Result<(), EngineError> {
4226        let outputs = self
4227            .storage
4228            .find_outputs_for_transaction(txid, true)
4229            .await
4230            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4231
4232        if outputs.is_empty() {
4233            return Err(EngineError::Other(
4234                "Could not find matching transaction outputs for proof ingest!".into(),
4235            ));
4236        }
4237
4238        // Parse merkle proof if provided
4239        let proof = if proof_hex.is_empty() {
4240            None
4241        } else {
4242            Some(
4243                beef_limits::merkle_path_from_hex(proof_hex, beef_limits::PUSH_PROOF_MAX_BYTES)
4244                    .map_err(|e| EngineError::Other(format!("Invalid merkle proof hex: {e}")))?,
4245            )
4246        };
4247
4248        for output in &outputs {
4249            // Stitch the proof into the STORED BEEF (#284) — never rebuild it
4250            // around a re-picked subject; see stitch_proof_into_stored_beef.
4251            if let (Some(ref beef_data), Some(ref proof)) = (&output.beef, &proof) {
4252                if let Some(new_beef) = Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4253                {
4254                    let _ = self
4255                        .storage
4256                        .update_transaction_beef(&output.txid, &new_beef)
4257                        .await;
4258                }
4259            }
4260
4261            // Update block height
4262            if let Some(height) = block_height {
4263                let _ = self
4264                    .storage
4265                    .update_output_block_height(
4266                        &output.txid,
4267                        output.output_index,
4268                        &output.topic,
4269                        height,
4270                    )
4271                    .await;
4272            }
4273
4274            // Recursively update consumedBy chain
4275            for consuming in &output.consumed_by {
4276                if let Ok(consumed_outputs) = self
4277                    .storage
4278                    .find_outputs_for_transaction(&consuming.txid, true)
4279                    .await
4280                {
4281                    for consumed_output in &consumed_outputs {
4282                        // The consuming row's BEEF carries this tx as an
4283                        // ancestor — stitch the same bump in there too, again
4284                        // without rebuilding the tx set (#284: this branch is
4285                        // the one that was observed replacing a head-spend's
4286                        // stored BEEF with parent ancestry only).
4287                        if let (Some(ref beef_data), Some(ref proof)) =
4288                            (&consumed_output.beef, &proof)
4289                        {
4290                            if let Some(new_beef) =
4291                                Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4292                            {
4293                                let _ = self
4294                                    .storage
4295                                    .update_transaction_beef(&consumed_output.txid, &new_beef)
4296                                    .await;
4297                            }
4298                        }
4299                    }
4300                }
4301            }
4302        }
4303
4304        Ok(())
4305    }
4306
4307    /// Complete missing merkle proofs for stored, confirmed transactions.
4308    ///
4309    /// The `/submit`-admitted ingest path stores a proofless BEEF for each
4310    /// transaction (the submitter does not yet have a merkle proof at submit
4311    /// time). When such a transaction later mines, nothing in the overlay
4312    /// re-fetches its proof, so its stored BEEF stays proofless forever — and a
4313    /// frontend that trims `inputBEEF` to `{source tx + its BUMP}` for a P2PKH
4314    /// payment-import spend has no BUMP to trim to, hanging the import (#130).
4315    ///
4316    /// This pass closes that gap from chain (the one blessed server-side WoC
4317    /// use). It:
4318    /// 1. Pulls a bounded page (`limit`) of stored `(txid, beef)` from storage.
4319    /// 2. Parses each BEEF and keeps only those whose target tx still lacks a
4320    ///    merkle proof (`Beef::find_txid(txid).has_proof() == false`).
4321    /// 3. For each proofless candidate, fetches its BUMP via the configured
4322    ///    [`AncestorFetcher`](crate::gasp::AncestorFetcher). If a proof comes
4323    ///    back, it calls [`Self::handle_new_merkle_proof`], which stitches the
4324    ///    BUMP into the stored BEEF (D1 + R2 mirror), updates output block
4325    ///    height, and recurses the `consumedBy` chain.
4326    /// 4. Skips (does NOT error) txs the fetcher can't prove yet (still
4327    ///    unconfirmed) — those are simply retried on a later tick.
4328    ///
4329    /// **No-op when no [`AncestorFetcher`] is configured** — production-safe
4330    /// default (the same opt-in switch as GASP ancestor hydration). Bounded by
4331    /// `limit` (a per-tick budget, like the platform fetcher's own budget).
4332    ///
4333    /// `min_age_secs` is the PUSH-PRIMARY BACKSTOP gate (bsv-low #228 /
4334    /// arcade#259): since the Arcade MINED webhook (`/arc-ingest`) pushes a
4335    /// verified proof ~150 ms after a tx mines, this poll pass is a BACKSTOP,
4336    /// not the primary source — rows stored less than `min_age_secs` ago are
4337    /// skipped entirely (their push is still expected). Pass `0` to disable
4338    /// the gate (poll everything — the pre-#228 behaviour, and the degradation
4339    /// mode if pushes stop: an old-enough row is ALWAYS polled, so webhook
4340    /// loss degrades to polling, never to nothing). Unknown-age rows are
4341    /// always eligible (fail-safe — see the storage-trait doc).
4342    pub async fn complete_missing_proofs(
4343        &self,
4344        limit: u64,
4345        min_age_secs: u64,
4346    ) -> Result<ProofCompletionSummary, EngineError> {
4347        let Some(fetcher) = self.ancestor_fetcher.as_deref() else {
4348            return Ok(ProofCompletionSummary::default());
4349        };
4350        self.complete_missing_proofs_with(fetcher, limit, min_age_secs)
4351            .await
4352    }
4353
4354    /// Complete stored transactions using the caller's dedicated allowance.
4355    /// The engine's other passes retain their configured ancestor courier.
4356    pub async fn complete_missing_proofs_with(
4357        &self,
4358        fetcher: &dyn crate::gasp::AncestorFetcher,
4359        limit: u64,
4360        min_age_secs: u64,
4361    ) -> Result<ProofCompletionSummary, EngineError> {
4362        let mut summary = ProofCompletionSummary::default();
4363
4364        let candidates = self
4365            .storage
4366            .find_transactions_for_proof_check(limit, min_age_secs)
4367            .await
4368            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4369        summary.scanned = candidates.len();
4370
4371        for cand in candidates {
4372            summary.early_gasp += usize::from(cand.early_gasp);
4373            // Read the stored BEEF for the subject's OWN bump by streaming
4374            // folds (one element in hand, no `Beef` built: NL-6); skip
4375            // anything unparseable (don't error the whole pass on one bad row).
4376            let own_bump = match beef_limits::own_proof(&cand.beef, &cand.txid) {
4377                Ok(b) => b,
4378                Err(e) => {
4379                    warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] unparseable BEEF, skipping");
4380                    continue;
4381                }
4382            };
4383
4384            // Already structurally proven? The stored BEEF carries a merkle bump
4385            // for the target tx, but its backend `has_proof` flag may still be 0
4386            // — EITHER a legitimate GASP-synced proof written before overlay
4387            // migration 0010 (which defaulted every existing row to 0), OR an
4388            // admit-time bump that was NEVER SPV-verified (`/submit` skips SPV on
4389            // historical topics) or is outright forged. Serve-time BEEF trimming
4390            // trusts the `has_proof` flag, so latching one on STRUCTURE ALONE
4391            // would let a forged bump be trimmed on (#192/#193 MEDIUM). Re-verify
4392            // the STORED bump against chaintracks (via the fetcher — its header
4393            // source is the only arbiter of a merkle root). Genuine → latch
4394            // (fast, no re-fetch). Otherwise DON'T trust it: fall through to the
4395            // fetch+stitch path below, which overwrites the bump with a
4396            // chaintracks-verified one (or leaves the row proofless for retry).
4397            // Fail-closed throughout.
4398            // the tx's OWN bump (its `bump_index`), never `find_bump`
4399            // (the FIRST bump containing the txid — the stale one after a
4400            // same-height reorg; review HIGH-1).
4401            if let Some(bump_hex) = own_bump
4402                .as_ref()
4403                .map(bsv_rs::transaction::MerklePath::to_hex)
4404            {
4405                if fetcher.verify_proof(&cand.txid, &bump_hex).await {
4406                    // Idempotent + best-effort: a failure here is logged, not
4407                    // fatal — the row simply lingers one more tick.
4408                    // bsv-low M19 R2 round 3 (review MED-2): latch WITH the
4409                    // anchor height (from the tx's OWN verified bump) so the
4410                    // revalidation sweep's transactions leg can window this
4411                    // row — `mark_transaction_proven` alone left every
4412                    // fast-path row anchorless, in no window forever.
4413                    let height = bsv_rs::transaction::MerklePath::from_hex(&bump_hex)
4414                        .ok()
4415                        .map(|mp| u64::from(mp.block_height));
4416                    if let Err(e) = self
4417                        .storage
4418                        .mark_transaction_proven_at(&cand.txid, height)
4419                        .await
4420                    {
4421                        warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] failed to mark verified-proven row");
4422                    } else {
4423                        summary.already_proven += 1;
4424                    }
4425                    continue;
4426                }
4427                warn!(txid = %cand.txid, "[PROOF COMPLETION] stored structural bump FAILED chaintracks re-verify — not trusting it, refetching");
4428            }
4429            // Unverifiable structural bump → treat as proofless (fall through).
4430            summary.proofless += 1;
4431
4432            // Fetch this tx's own VERIFIED bump from chain — PROOF ONLY (the raw
4433            // is already in the stored BEEF, so no redundant raw fetch; #192/#193
4434            // FIX 2). A tx with no verified proof yet (still unconfirmed, or an
4435            // unverifiable/forged courier response) is skipped, not errored.
4436            let Some(proof_hex) = fetcher.verified_proof_for(&cand.txid).await else {
4437                summary.still_unconfirmed += 1;
4438                continue;
4439            };
4440
4441            // Pull the block height out of the BUMP so the output rows update too.
4442            let block_height =
4443                beef_limits::merkle_path_from_hex(&proof_hex, beef_limits::COURIER_PROOF_MAX_BYTES)
4444                    .ok()
4445                    .map(|mp| mp.block_height);
4446
4447            match self
4448                .handle_new_merkle_proof(&cand.txid, &proof_hex, block_height)
4449                .await
4450            {
4451                Ok(()) => summary.completed += 1,
4452                Err(e) => {
4453                    warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] stitch failed");
4454                    summary.stitch_failed += 1;
4455                }
4456            }
4457        }
4458
4459        info!(
4460            scanned = summary.scanned,
4461            proofless = summary.proofless,
4462            completed = summary.completed,
4463            still_unconfirmed = summary.still_unconfirmed,
4464            fetch_failed = summary.fetch_failed,
4465            stitch_failed = summary.stitch_failed,
4466            already_proven = summary.already_proven,
4467            early_gasp = summary.early_gasp,
4468            "[PROOF COMPLETION] pass complete"
4469        );
4470
4471        Ok(summary)
4472    }
4473
4474    /// Recursively update merkle paths in a transaction's input tree.
4475    ///
4476    /// If the transaction's id matches txid, set its merkle_path.
4477    /// Otherwise, recurse into sourceTransactions of each input.
4478    /// Stitch a verified BUMP for `txid` into a STORED BEEF, preserving every
4479    /// transaction and every other proof in it (#284).
4480    ///
4481    /// The old path round-tripped the stored bytes through
4482    /// `Transaction::from_beef(bytes, None)` -> `to_beef(true)`, and
4483    /// `from_beef(_, None)` picks **`txs.last()`** — it ignores the atomic
4484    /// subject pointer entirely. Whenever the subject was not the last tx in
4485    /// wire order, a PARENT was picked, `to_beef(true)` then serialized that
4486    /// parent's world, and the rewrite dropped the subject transaction from its
4487    /// own stored BEEF — observed live on every pf head-spend (list / reprice /
4488    /// delist / buy) the moment it mined, breaking the NEXT spend on that name
4489    /// (zanaadu#284). `update_input_proofs`' "already has a proof — update it"
4490    /// arm could even stamp the child's bump onto that mis-picked parent.
4491    /// This is the same destroy-the-stored-BEEF class the #4 fix note above
4492    /// (`hydrate_utxo_history`) already documents for lookup hydration.
4493    ///
4494    /// Operating on [`Beef`] directly has none of those failure modes:
4495    /// `merge_bump` dedupes against existing bumps by (height, root) and
4496    /// assigns the bump ONLY to transactions that appear as flagged txid
4497    /// leaves, so stitching tx X's proof can never mislabel tx Y, and the tx
4498    /// set is never rebuilt at all.
4499    ///
4500    /// Returns `None` when the bytes do not parse or `txid` is not in this
4501    /// BEEF (nothing to stitch — e.g. a consuming row whose ancestry got
4502    /// trimmed), so callers skip the write instead of writing garbage.
4503    ///
4504    /// bsv-low M19 R2 round 3 (review HIGH-1): a same-height DIFFERENT-root
4505    /// proof (the 2026-09-07 reorg shape) does NOT combine in `merge_bump`
4506    /// (the roots differ) — it is pushed as a SECOND bump, and
4507    /// `update_bump_indices` assigns it only to txs whose `bump_index` is
4508    /// `None`, so the reorged subject keeps its index on the STALE bump.
4509    /// Left alone, `has_proof`/`proofHeight` and every `find_txid(txid)
4510    /// .bump_index()` reader then keep pointing at the orphan bump, and the
4511    /// row refutes again next sweep pass forever. So after the merge, FORCE
4512    /// the subject onto the bump that actually proves it, then drop any bump
4513    /// no transaction references (the orphan's, now unreferenced).
4514    pub fn stitch_proof_into_stored_beef(
4515        stored: &[u8],
4516        txid: &str,
4517        proof: &bsv_rs::transaction::MerklePath,
4518    ) -> Option<Vec<u8>> {
4519        let mut beef = beef_limits::parse_beef(stored, &beef_limits::STORED_BEEF_LIMITS).ok()?;
4520        beef.find_txid(txid)?;
4521        let i = beef.merge_bump(proof.clone());
4522        // Does the merged bump actually prove `txid` (a flagged txid leaf)?
4523        // (It always does for a completion proof; the guard keeps a stray
4524        // caller from mis-anchoring a subject onto an ancestor's bump.)
4525        let proves_subject = beef.bumps.get(i).is_some_and(|b| {
4526            b.path.first().is_some_and(|leaves| {
4527                leaves
4528                    .iter()
4529                    .any(|l| l.txid && l.hash.as_deref() == Some(txid))
4530            })
4531        });
4532        if proves_subject {
4533            if let Some(tx) = beef.find_txid_mut(txid) {
4534                tx.set_bump_index(Some(i));
4535            }
4536        }
4537        Self::gc_unreferenced_bumps(&mut beef);
4538        Some(beef.to_binary())
4539    }
4540
4541    /// Drop every BUMP no transaction references and re-index the survivors
4542    /// (bsv-low M19 R2 round 3, review HIGH-1): after the subject is moved
4543    /// off an orphan bump, that bump is usually dead weight the next reader
4544    /// would still `find_bump`. A bump still referenced by ANOTHER tx (a
4545    /// sibling mined in the same block) is kept.
4546    fn gc_unreferenced_bumps(beef: &mut bsv_rs::transaction::Beef) {
4547        let referenced: std::collections::HashSet<usize> = beef
4548            .txs
4549            .iter()
4550            .filter_map(bsv_rs::transaction::BeefTx::bump_index)
4551            .collect();
4552        if referenced.len() == beef.bumps.len() {
4553            return;
4554        }
4555        let mut remap: Vec<Option<usize>> = vec![None; beef.bumps.len()];
4556        let mut kept = Vec::with_capacity(referenced.len());
4557        for (old, bump) in std::mem::take(&mut beef.bumps).into_iter().enumerate() {
4558            if referenced.contains(&old) {
4559                remap[old] = Some(kept.len());
4560                kept.push(bump);
4561            }
4562        }
4563        beef.bumps = kept;
4564        for tx in &mut beef.txs {
4565            if let Some(old) = tx.bump_index() {
4566                tx.set_bump_index(remap[old]);
4567            }
4568        }
4569    }
4570
4571    // ========================================================================
4572    // Advertisement sync
4573    // ========================================================================
4574
4575    /// Sync SHIP/SLAP advertisements with configured managers and services.
4576    ///
4577    /// Creates missing advertisements and revokes stale ones. Returns a
4578    /// [`SyncAdvertisementsReport`] so a create/submit failure is VISIBLE to
4579    /// the caller (bsv-low #320 defect 3a): this method used to swallow both
4580    /// into `error!` and return `Ok(())`, so `/admin/syncAdvertisements`
4581    /// reported `success` while zero advertisements were admitted locally —
4582    /// a caller could not tell a converged no-op from a silent failure, and
4583    /// every cycle re-created the full ad set.
4584    pub async fn sync_advertisements(&self) -> Result<SyncAdvertisementsReport, EngineError> {
4585        let mut report = SyncAdvertisementsReport::default();
4586
4587        let Some(advertiser) = &self.advertiser else {
4588            return Ok(report); // No advertiser configured
4589        };
4590
4591        let hosting_url = match &self.config.hosting_url {
4592            Some(url) if !url.is_empty() => url.clone(),
4593            _ => return Ok(report), // No hosting URL
4594        };
4595
4596        // Get configured topics and services
4597        let mut configured_topics: Vec<String> = self.managers.keys().cloned().collect();
4598        let mut configured_services: Vec<String> = self.lookup_services.keys().cloned().collect();
4599
4600        if self.config.suppress_default_sync_advertisements {
4601            configured_topics.retain(|t| t != "tm_ship" && t != "tm_slap");
4602            configured_services.retain(|s| s != "ls_ship" && s != "ls_slap");
4603        }
4604
4605        // Fetch current advertisements. A read failure REFUSES creation
4606        // (#320 M2): swallowing it into "current ads = none" made a
4607        // transient D1/storage fault re-create — and re-pay for — the
4608        // entire advertisement set, reported as a legitimate `to_create`.
4609        let current_ship = match advertiser.find_all_advertisements(Protocol::Ship).await {
4610            Ok(ads) => ads,
4611            Err(e) => {
4612                error!("Failed to read current SHIP advertisements — refusing to create: {e}");
4613                report.lookup_error = Some(format!("ship: {e}"));
4614                return Ok(report);
4615            }
4616        };
4617        let current_slap = match advertiser.find_all_advertisements(Protocol::Slap).await {
4618            Ok(ads) => ads,
4619            Err(e) => {
4620                error!("Failed to read current SLAP advertisements — refusing to create: {e}");
4621                report.lookup_error = Some(format!("slap: {e}"));
4622                return Ok(report);
4623            }
4624        };
4625
4626        // Determine what to create
4627        let ships_to_create: Vec<AdvertisementData> = configured_topics
4628            .iter()
4629            .filter(|topic| {
4630                !current_ship
4631                    .iter()
4632                    .any(|a| a.topic_or_service == **topic && a.domain == hosting_url)
4633            })
4634            .map(|topic| AdvertisementData {
4635                protocol: Protocol::Ship,
4636                topic_or_service_name: topic.clone(),
4637            })
4638            .collect();
4639
4640        let slaps_to_create: Vec<AdvertisementData> = configured_services
4641            .iter()
4642            .filter(|service| {
4643                !current_slap
4644                    .iter()
4645                    .any(|a| a.topic_or_service == **service && a.domain == hosting_url)
4646            })
4647            .map(|service| AdvertisementData {
4648                protocol: Protocol::Slap,
4649                topic_or_service_name: service.clone(),
4650            })
4651            .collect();
4652
4653        // Determine what to revoke
4654        let ships_to_revoke: Vec<Advertisement> = current_ship
4655            .into_iter()
4656            .filter(|a| !configured_topics.contains(&a.topic_or_service))
4657            .collect();
4658
4659        let slaps_to_revoke: Vec<Advertisement> = current_slap
4660            .into_iter()
4661            .filter(|a| !configured_services.contains(&a.topic_or_service))
4662            .collect();
4663
4664        // Create new advertisements
4665        let mut all_to_create = ships_to_create;
4666        all_to_create.extend(slaps_to_create);
4667        report.to_create = all_to_create.len();
4668        if !all_to_create.is_empty() {
4669            match advertiser.create_advertisements(&all_to_create).await {
4670                Ok(tagged_beef) => match self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4671                    Ok(steak) => {
4672                        for (topic, instructions) in &steak {
4673                            report
4674                                .admitted
4675                                .insert(topic.clone(), instructions.outputs_to_admit.len());
4676                        }
4677                    }
4678                    Err(e) => {
4679                        error!("Failed to submit new advertisements: {e}");
4680                        report.submit_error = Some(e.to_string());
4681                    }
4682                },
4683                Err(e) => {
4684                    error!("Failed to create advertisements: {e}");
4685                    report.create_error = Some(e.to_string());
4686                }
4687            }
4688        }
4689
4690        // Revoke stale advertisements
4691        let mut all_to_revoke = ships_to_revoke;
4692        all_to_revoke.extend(slaps_to_revoke);
4693        report.to_revoke = all_to_revoke.len();
4694        if !all_to_revoke.is_empty() {
4695            match advertiser.revoke_advertisements(&all_to_revoke).await {
4696                // An advertiser that declines to build a revocation (e.g. the
4697                // CF advertiser's documented v1 no-op) returns an EMPTY
4698                // TaggedBEEF — submitting it would only manufacture a parse
4699                // error. Skip, but say so in the report (#320 L2): the stale
4700                // ads stay on-chain until they age out.
4701                Ok(tagged_beef) if tagged_beef.topics.is_empty() => {
4702                    report.revoke_skipped = all_to_revoke.len();
4703                }
4704                Ok(tagged_beef) => {
4705                    if let Err(e) = self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4706                        error!("Failed to submit revocation: {e}");
4707                        report.revoke_submit_error = Some(e.to_string());
4708                    }
4709                }
4710                Err(e) => {
4711                    error!("Failed to revoke advertisements: {e}");
4712                    report.revoke_error = Some(e.to_string());
4713                }
4714            }
4715        }
4716
4717        Ok(report)
4718    }
4719
4720    // ========================================================================
4721    // Deep deletion
4722    // ========================================================================
4723
4724    /// Recursively delete a UTXO and all stale consumed inputs.
4725    ///
4726    /// Only deletes if the output has no remaining consumers (consumedBy is empty).
4727    /// Then recurses into outputsConsumed, removing the deleted output from their
4728    /// consumedBy lists and deleting them if they become unreferenced.
4729    #[allow(dead_code)] // Used by submit() once BEEF parsing is wired up
4730    ///
4731    /// `bound` is a finalize submit's [`CallBound`] over each call here
4732    /// (bsv-low #559); `None` everywhere else.
4733    fn delete_utxo_deep<'a>(
4734        &'a self,
4735        output: &'a Output,
4736        bound: Option<&'a CallBound>,
4737    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), EngineError>> + 'a>> {
4738        Box::pin(async move {
4739            // Only delete if nothing consumes this output
4740            if output.consumed_by.is_empty() {
4741                stored(
4742                    bound,
4743                    self.storage
4744                        .delete_output(&output.txid, output.output_index, &output.topic),
4745                )
4746                .await
4747                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4748
4749                // Notify lookup services. A hook error is dropped, not
4750                // reported (parity: the reference catches it, best effort).
4751                for ls in self.lookup_services.values() {
4752                    let _ = hooked(
4753                        bound,
4754                        ls.output_no_longer_retained_in_history(
4755                            &output.txid,
4756                            output.output_index,
4757                            &output.topic,
4758                        ),
4759                    )
4760                    .await;
4761                }
4762            }
4763
4764            // Recurse into consumed outputs
4765            for consumed in &output.outputs_consumed {
4766                if let Ok(Some(mut stale_output)) = stored(
4767                    bound,
4768                    self.storage.find_output(
4769                        &consumed.txid,
4770                        consumed.output_index,
4771                        Some(&output.topic),
4772                        None,
4773                        false,
4774                    ),
4775                )
4776                .await
4777                {
4778                    // Remove the deleted output from consumedBy
4779                    stale_output.consumed_by.retain(|c| {
4780                        !(c.txid == output.txid && c.output_index == output.output_index)
4781                    });
4782
4783                    stored(
4784                        bound,
4785                        self.storage.update_consumed_by(
4786                            &consumed.txid,
4787                            consumed.output_index,
4788                            &output.topic,
4789                            &stale_output.consumed_by,
4790                        ),
4791                    )
4792                    .await
4793                    .map_err(|e| EngineError::StorageError(e.to_string()))?;
4794
4795                    // Recurse
4796                    let _ = self.delete_utxo_deep(&stale_output, bound).await;
4797                }
4798            }
4799
4800            Ok(())
4801        }) // Box::pin
4802    }
4803
4804    // ========================================================================
4805    // Eviction
4806    // ========================================================================
4807
4808    /// Evict a specific outpoint from the overlay.
4809    ///
4810    /// 1. Deletes the output from storage for the given topic.
4811    /// 2. Notifies all lookup services via `output_evicted()`.
4812    ///
4813    /// If no topic is specified, finds all outputs for the txid and evicts the
4814    /// matching outputIndex across every topic.
4815    ///
4816    /// Matches TS OverlayExpress `/admin/evictOutpoint` (lines 974-1004).
4817    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): RE-NOTIFY every lookup service
4818    /// of the outputs the engine ALREADY admitted for (`txid`, `topic`).
4819    ///
4820    /// Phase-1 dedup means a (txid, topic) once recorded in
4821    /// `applied_transactions` never re-enters through `/submit`, so a lookup
4822    /// service that missed — or mis-parsed — its admit notification could
4823    /// never be told again (the loop-2 subject trap: the pot index's admit
4824    /// hook shape-checked a wire-last hop and wrote no record while the engine
4825    /// had admitted the pot). This replays `output_admitted_by_topic` from the
4826    /// engine's OWN stored outputs (with their BEEF) and the subject-NAMED body
4827    /// exactly as `submit_with_report` sends it. It validates nothing anew
4828    /// (the topic manager judged these outputs at admit), writes none of the
4829    /// engine's tables, and is idempotent for every lookup service whose
4830    /// writes are (the pot index's `store_record` is insert-if-absent for the
4831    /// spend fields — re-admission never regresses spend state).
4832    /// `outputs == 0` means the engine never admitted this txid on this topic:
4833    /// nothing to re-notify — the caller re-SUBMITS instead.
4834    pub async fn renotify_admitted(
4835        &self,
4836        txid: &str,
4837        topic: &str,
4838    ) -> Result<RenotifyReport, EngineError> {
4839        let txid = txid.to_ascii_lowercase();
4840        let outputs = self
4841            .storage
4842            .find_outputs_for_transaction(&txid, true)
4843            .await
4844            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4845        let mut report = RenotifyReport::default();
4846        for output in outputs.iter().filter(|o| o.topic == topic) {
4847            report.outputs += 1;
4848            report.vouts.push(output.output_index);
4849            // the body every WholeTx service receives NAMES the subject (BRC-95
4850            // atomic prefix) — the same rule as the live submit path
4851            let subject_named: Option<Vec<u8>> = output.beef.as_ref().map(|beef| {
4852                beef_limits::parse_beef(beef, &beef_limits::STORED_BEEF_LIMITS)
4853                    .ok()
4854                    .and_then(|mut b| b.to_binary_atomic(&txid).ok())
4855                    .unwrap_or_else(|| beef.clone())
4856            });
4857            for (name, ls) in &self.lookup_services {
4858                let payload = match ls.admission_mode() {
4859                    AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
4860                        txid: txid.clone(),
4861                        output_index: output.output_index,
4862                        topic: topic.to_string(),
4863                        satoshis: output.satoshis,
4864                        locking_script: output.output_script.clone(),
4865                        off_chain_values: None,
4866                    },
4867                    AdmissionMode::WholeTx => {
4868                        let Some(atomic_beef) = subject_named.as_ref() else {
4869                            report.faults.push(format!(
4870                                "{name}: {txid}:{} is stored without its BEEF — a WholeTx service cannot be re-notified",
4871                                output.output_index
4872                            ));
4873                            continue;
4874                        };
4875                        OutputAdmittedByTopic::WholeTx {
4876                            atomic_beef: atomic_beef.clone(),
4877                            output_index: output.output_index,
4878                            topic: topic.to_string(),
4879                            off_chain_values: None,
4880                        }
4881                    }
4882                };
4883                match ls.output_admitted_by_topic(&payload).await {
4884                    Ok(()) => report.notified += 1,
4885                    Err(e) => report.faults.push(format!("{name}: {e}")),
4886                }
4887            }
4888        }
4889        Ok(report)
4890    }
4891
4892    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): FORGET a PHANTOM applied row —
4893    /// (txid, topic) recorded in `applied_transactions` while the topic holds
4894    /// NO output of that txid (the engine judged the tx and admitted nothing:
4895    /// the loop-2 JOIN `f9e85aab…` under the pre-D5 subject rule). Such a row
4896    /// does nothing but block a correct re-submit through the Phase-1 dedup
4897    /// (`/admin/readmit` on beta answered `deduped: ["tm_pot_beta"]` and the
4898    /// pot stayed `known:false`). A row with ANY stored output on the topic is
4899    /// a real admission and is never touched (returns false, as does a missing
4900    /// row). The forgotten bytes are then re-VALIDATED by the topic manager
4901    /// like any first submit — nothing is admitted here.
4902    pub async fn forget_phantom_applied(
4903        &self,
4904        txid: &str,
4905        topic: &str,
4906    ) -> Result<bool, EngineError> {
4907        let txid = txid.to_ascii_lowercase();
4908        let outputs = self
4909            .storage
4910            .find_outputs_for_transaction(&txid, false)
4911            .await
4912            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4913        if outputs.iter().any(|o| o.topic == topic) {
4914            return Ok(false);
4915        }
4916        let rec = AppliedTransaction {
4917            txid: txid.clone(),
4918            topic: topic.to_string(),
4919        };
4920        let exists = self
4921            .storage
4922            .does_applied_transaction_exist(&rec)
4923            .await
4924            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4925        if !exists {
4926            return Ok(false);
4927        }
4928        self.storage
4929            .delete_applied_transaction(&rec)
4930            .await
4931            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4932        Ok(true)
4933    }
4934
4935    pub async fn evict_output(
4936        &self,
4937        txid: &str,
4938        output_index: u32,
4939        topic: Option<&str>,
4940    ) -> Result<(), EngineError> {
4941        if let Some(topic) = topic {
4942            // Delete from storage
4943            self.storage
4944                .delete_output(txid, output_index, topic)
4945                .await
4946                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4947
4948            // Notify all lookup services
4949            for ls in self.lookup_services.values() {
4950                let _ = ls.output_evicted(txid, output_index).await;
4951            }
4952        } else {
4953            // No topic specified — find all outputs for this txid and evict matching ones
4954            let outputs = self
4955                .storage
4956                .find_outputs_for_transaction(txid, false)
4957                .await
4958                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4959
4960            for output in &outputs {
4961                if output.output_index == output_index {
4962                    self.storage
4963                        .delete_output(txid, output_index, &output.topic)
4964                        .await
4965                        .map_err(|e| EngineError::StorageError(e.to_string()))?;
4966                }
4967            }
4968
4969            // Notify all lookup services once
4970            for ls in self.lookup_services.values() {
4971                let _ = ls.output_evicted(txid, output_index).await;
4972            }
4973        }
4974
4975        Ok(())
4976    }
4977
4978    // ========================================================================
4979    // GASP sync
4980    // ========================================================================
4981
4982    /// Start GASP synchronization with peers for all configured topics.
4983    ///
4984    /// For each topic in `sync_configuration`:
4985    /// - `SyncTarget::Ship`: unions local SHIP records, network discovery via
4986    ///   configured SLAP trackers, and the topic's bootstrap peers.
4987    /// - `SyncTarget::Peers(urls)` — uses the provided peer URLs directly.
4988    /// - `SyncTarget::Disabled` — skips the topic.
4989    ///
4990    /// Filters out our own `hosting_url` to avoid self-sync.
4991    ///
4992    /// Returns a `GASPSyncResult` summarizing discovered peers per topic.
4993    ///
4994    /// Discovers peers for each configured topic and runs GASP sync with each.
4995    ///
4996    /// When a `GASPRemoteFactory` is set (via `set_gasp_remote_factory`), this
4997    /// method creates `OverlayGASPStorage` + `GASPRemote` instances per
4998    /// (topic, peer) pair and runs `GASPSync::sync()` to exchange UTXOs.
4999    ///
5000    /// Without a factory, peer discovery still runs but no actual sync occurs
5001    /// (backwards-compatible with the previous discovery-only behavior).
5002    pub async fn start_gasp_sync(&self) -> Result<GASPSyncResult, EngineError> {
5003        self.start_gasp_sync_filtered(&crate::sync_load::SyncFilter::default())
5004            .await
5005    }
5006
5007    /// Run selected topic/peer pairs; explicit peers bypass network discovery.
5008    pub async fn start_gasp_sync_filtered(
5009        &self,
5010        filter: &crate::sync_load::SyncFilter,
5011    ) -> Result<GASPSyncResult, EngineError> {
5012        let meter = crate::meter::LoadMeter::default();
5013        self.start_gasp_sync_metered(filter, &meter).await
5014    }
5015
5016    /// A caller holding the meter can read partial costs after cancellation.
5017    pub async fn start_gasp_sync_metered(
5018        &self,
5019        filter: &crate::sync_load::SyncFilter,
5020        meter: &crate::meter::LoadMeter,
5021    ) -> Result<GASPSyncResult, EngineError> {
5022        let run = self.gasp_run();
5023        meter.scope(self.run_gasp_sync(filter, &run, None)).await
5024    }
5025
5026    fn gasp_run(&self) -> crate::backfill::Run {
5027        crate::backfill::Run::new(
5028            self.gasp_ancestor_fetcher
5029                .as_ref()
5030                .or(self.ancestor_fetcher.as_ref())
5031                .cloned(),
5032        )
5033    }
5034
5035    /// Walk one selected pair, checkpointing on wall, request or progress bounds.
5036    /// The continuation never supplies a cursor to storage or to the remote.
5037    pub async fn start_gasp_backfill_metered(
5038        &self,
5039        request: &crate::backfill::SyncRequest,
5040        meter: &crate::meter::LoadMeter,
5041    ) -> Result<crate::backfill::BackfillResult, EngineError> {
5042        use crate::backfill::{
5043            BackfillResult, Continuation, Control, BACKFILL_MAX_SLICES, BACKFILL_REQUEST_MS,
5044        };
5045        request.validate().map_err(EngineError::InvalidQuery)?;
5046        let (topic, peer) = request.pair().map_err(EngineError::InvalidQuery)?;
5047        if request.until.is_none() || self.peer_sync_budget.is_none() || self.graph_budget.is_none()
5048        {
5049            return Err(EngineError::InvalidQuery(
5050                "backfill requires until: tip and peer/graph bounds".into(),
5051            ));
5052        }
5053        if self.gasp_remote_factory.is_none()
5054            || !matches!(
5055                self.config.sync_configuration.get(topic),
5056                Some(SyncTarget::Ship | SyncTarget::Peers(_))
5057            )
5058        {
5059            return Err(EngineError::InvalidQuery(
5060                "backfill topic is not enabled for sync".into(),
5061            ));
5062        }
5063        meter
5064            .scope(async {
5065                let initial = self.storage.get_last_interaction(peer, topic).await?;
5066                if let Some(token) = &request.continuation {
5067                    let token = Continuation::decode(token).map_err(EngineError::InvalidQuery)?;
5068                    if token.cursor > initial {
5069                        return Err(EngineError::InvalidQuery(
5070                            "continuation is ahead of the durable cursor".into(),
5071                        ));
5072                    }
5073                }
5074                let filter = request.filter();
5075                let run = self.gasp_run();
5076                let control = Control {
5077                    meter: meter.clone(),
5078                    budget_ms: request.budget_ms.unwrap_or(0).min(BACKFILL_REQUEST_MS),
5079                };
5080                let mut result = BackfillResult {
5081                    sync: GASPSyncResult {
5082                        filter: filter.clone(),
5083                        ..GASPSyncResult::default()
5084                    },
5085                    slices: 0,
5086                    reached_tip: false,
5087                    stop_reason: "slice_allowance".into(),
5088                    continuation: None,
5089                };
5090                let mut previous_cursor = initial;
5091                for _ in 0..BACKFILL_MAX_SLICES {
5092                    if let Some(reason) = control.stop_reason() {
5093                        result.stop_reason = reason.into();
5094                        break;
5095                    }
5096                    // Boxed: the slice's sync is the largest future here
5097                    // (clippy's `large_futures`, past its bound since the
5098                    // run's batch memo of engine 0.2.1).
5099                    let slice = Box::pin(self.run_gasp_sync(&filter, &run, Some(&control))).await?;
5100                    let topic_result = slice.topics_synced.get(topic).ok_or_else(|| {
5101                        EngineError::InvalidQuery("backfill selection matched no topic".into())
5102                    })?;
5103                    if !topic_result.peers.iter().any(|p| p == peer) {
5104                        return Err(EngineError::InvalidQuery(
5105                            "backfill selection matched no peer".into(),
5106                        ));
5107                    }
5108                    let summary = topic_result.peer_results.get(peer).cloned();
5109                    let waiting = !topic_result.peers_skipped.is_empty();
5110                    result.sync.append_slice(slice);
5111                    if let Some(summary) = summary {
5112                        result.slices += summary.slices;
5113                        if summary.reached_tip {
5114                            result.reached_tip = true;
5115                            result.stop_reason = "tip".into();
5116                            break;
5117                        }
5118                        if let Some(reason) = control.stop_reason() {
5119                            result.stop_reason = reason.into();
5120                            break;
5121                        }
5122                        if summary.cursor <= previous_cursor {
5123                            result.stop_reason = "no_progress".into();
5124                            break;
5125                        }
5126                        previous_cursor = summary.cursor;
5127                    } else {
5128                        result.stop_reason =
5129                            if waiting { "peer_wait" } else { "no_progress" }.into();
5130                        break;
5131                    }
5132                }
5133                let cursor = self.storage.get_last_interaction(peer, topic).await?;
5134                if !result.reached_tip {
5135                    result.continuation = Some(Continuation::new(topic, peer, cursor).encode());
5136                }
5137                result.sync.cost = meter.snapshot();
5138                Ok(result)
5139            })
5140            .await
5141    }
5142
5143    async fn run_gasp_sync(
5144        &self,
5145        filter: &crate::sync_load::SyncFilter,
5146        run: &crate::backfill::Run,
5147        control: Option<&crate::backfill::Control>,
5148    ) -> Result<GASPSyncResult, EngineError> {
5149        use crate::gasp::{GASPSync, ENGINE_GASP_SYNC_PAGE_LIMIT};
5150        use crate::gasp_overlay::OverlayGASPStorage;
5151
5152        if self.config.sync_configuration.is_empty() {
5153            info!("[GASP SYNC] No sync configuration — nothing to sync");
5154            return Ok(GASPSyncResult {
5155                cost: crate::meter::SyncCost::default(),
5156                topics_synced: HashMap::new(),
5157                filter: filter.clone(),
5158                topics_filtered: Vec::new(),
5159            });
5160        }
5161
5162        let sync_fetcher = &run.fetcher;
5163        let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
5164        // Resolve SLAP providers once per invocation, sharing their host cap
5165        // across all SHIP topics. Every topic still queries each lookup host.
5166        let mut ship_lookup_hosts = None;
5167        let mut topic_order = sync_order(&self.config.sync_configuration);
5168        // Explicit bootstrap-backed SHIP topics run ahead of large tracker
5169        // imports. Stable sorting preserves the existing order elsewhere.
5170        topic_order.sort_by_key(|(topic, target)| {
5171            !(matches!(target, SyncTarget::Ship)
5172                && self
5173                    .ship_bootstrap_peers
5174                    .get(topic.as_str())
5175                    .is_some_and(|peers| !peers.is_empty()))
5176        });
5177
5178        let mut topics_filtered = Vec::new();
5179        for (topic, target) in topic_order {
5180            if filter
5181                .topics
5182                .as_ref()
5183                .is_some_and(|names| !names.contains(topic))
5184            {
5185                topics_filtered.push(topic.clone());
5186                continue;
5187            }
5188            let cost_before = crate::meter::snapshot();
5189            // A CONFIGURED topic's peers are reserved part of a storage's
5190            // deferred-graph ceiling (bsv-low #555, the delta-2 fold's D2-M2).
5191            let configured_peers = matches!(target, SyncTarget::Peers(_));
5192            let (peers, sync_type) = match target {
5193                SyncTarget::Disabled => {
5194                    info!("[GASP SYNC] Topic {topic} is disabled — skipping");
5195                    continue;
5196                }
5197                SyncTarget::Ship => {
5198                    info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
5199                    let peers = if let Some(peers) = &filter.peers {
5200                        self.ship_peers_excluding_self(peers.clone())
5201                    } else {
5202                        self.discover_ship_peers(topic, &mut ship_lookup_hosts)
5203                            .await
5204                    };
5205                    info!(
5206                        "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
5207                        peers.len()
5208                    );
5209                    (peers, "ship".to_string())
5210                }
5211                SyncTarget::Peers(peer_urls) => {
5212                    info!(
5213                        "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
5214                        peer_urls.len()
5215                    );
5216                    let peers: Vec<String> = peer_urls
5217                        .iter()
5218                        .filter(|url| {
5219                            if let Some(ref our_url) = self.config.hosting_url {
5220                                url.trim_end_matches('/') != our_url.trim_end_matches('/')
5221                            } else {
5222                                true
5223                            }
5224                        })
5225                        .cloned()
5226                        .collect();
5227
5228                    info!(
5229                        "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
5230                        peers.len()
5231                    );
5232                    (peers, "peers".to_string())
5233                }
5234            };
5235
5236            let (peers, peers_filtered): (Vec<_>, Vec<_>) = peers.into_iter().partition(|peer| {
5237                filter
5238                    .peers
5239                    .as_ref()
5240                    .is_none_or(|names| names.contains(peer))
5241            });
5242            let mut peers_skipped = Vec::new();
5243            let advertisements = self.peer_advertisement_versions(topic).await;
5244            let mut errors = Vec::new();
5245            let mut pruned_inputs: u64 = 0;
5246            let mut discarded_graphs: u64 = 0;
5247            let mut expired_refused: u64 = 0;
5248            let mut roots_proven = 0;
5249            let mut roots_unproven = 0;
5250            let mut cursor_held_by = Vec::new();
5251            let expired = run.expired(topic);
5252            let mut peer_results = HashMap::new();
5253            let mut anchor_walk_held_graphs: u64 = 0;
5254            let mut finalized_graphs: u64 = 0;
5255            let mut deadline_dropped_graphs: u64 = 0;
5256            let mut cursor_moves: Vec<CursorMove> = Vec::new();
5257            let mut deferred_graphs: u64 = 0;
5258            let mut resumed_graphs: u64 = 0;
5259            let mut converged_graphs: u64 = 0;
5260            let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
5261            let mut stalled_graphs: u64 = 0;
5262            let mut held_back_graphs: u64 = 0;
5263            let mut dead_graphs = 0;
5264            let mut expired_swept = 0;
5265            let mut memo_folded = 0;
5266            let mut unanchorable_graphs: u64 = 0;
5267            let mut unanchorable_cleared: u64 = 0;
5268            let mut absent_struck: u64 = 0;
5269            let mut route_faults: Vec<crate::route_fault::RouteFaultSeen> = Vec::new();
5270            let mut strikes_refused: u64 = 0;
5271            let mut backed_off_graphs: u64 = 0;
5272            let mut waiting_graphs: u64 = 0;
5273            let mut idle_counted: u64 = 0;
5274            let mut root_bounds = Vec::new();
5275            let mut faulted_roots: Vec<crate::gasp::FaultedRoot> = Vec::new();
5276
5277            // If we have a remote factory, actually run GASP sync
5278            if let Some(ref factory) = self.gasp_remote_factory {
5279                for peer_url in &peers {
5280                    // bsv-low#302 quarantine gate: a peer at
5281                    // PEER_QUARANTINE_THRESHOLD consecutive failures is
5282                    // SKIPPED (no attempt recorded — its last-attempt age
5283                    // keeps growing) until the re-probe window opens. A
5284                    // health-read FAULT treats the peer as healthy: broken
5285                    // bookkeeping must never silence a live peer.
5286                    //
5287                    // The peer's health (its quarantine, its yieldless
5288                    // streak) is keyed on its NORMALIZED ORIGIN (bsv-low
5289                    // #555, the delta-2 fold's D2-M2): keyed on the URL, one
5290                    // server was a fresh peer for every spelling it
5291                    // advertised.
5292                    let health_key = crate::gasp::peer_origin(peer_url);
5293                    let health = self
5294                        .storage
5295                        .get_peer_sync_health(&health_key, topic)
5296                        .await
5297                        .unwrap_or_default();
5298                    if crate::gasp::peer_sync_quarantined(&health) {
5299                        peers_skipped.push(crate::sync_load::SkippedPeer {
5300                            peer: peer_url.clone(),
5301                            reason: "quarantine".into(),
5302                            retry_after_secs: crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5303                                .saturating_sub(health.secs_since_last_attempt.unwrap_or(0)),
5304                        });
5305                        warn!(
5306                            "[GASP SYNC] Peer {peer_url} for {topic} QUARANTINED ({} consecutive failed syncs, last attempt {:?}s ago) — skipped until the {}s re-probe window (bsv-low#302)",
5307                            health.consecutive_failures,
5308                            health.secs_since_last_attempt,
5309                            crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5310                        );
5311                        continue;
5312                    }
5313
5314                    let cadence = self
5315                        .storage
5316                        .get_peer_cadence(&health_key, topic)
5317                        .await
5318                        .unwrap_or_default();
5319                    let advertisement =
5320                        advertisements.get(&health_key).cloned().unwrap_or_default();
5321                    let reset_cadence = filter.explicit() || cadence.advertisement != advertisement;
5322                    if !reset_cadence && cadence.retry_after_secs > 0 {
5323                        peers_skipped.push(crate::sync_load::SkippedPeer {
5324                            peer: peer_url.clone(),
5325                            reason: "no_yield".into(),
5326                            retry_after_secs: cadence.retry_after_secs,
5327                        });
5328                        continue;
5329                    }
5330
5331                    let peer_cost_before = crate::meter::snapshot();
5332                    info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
5333
5334                    // Get last interaction score for this (peer, topic) pair
5335                    let last_interaction = self
5336                        .storage
5337                        .get_last_interaction(peer_url, topic)
5338                        .await
5339                        .unwrap_or(0);
5340
5341                    // Create shared sink for finalized graphs
5342                    let sink = crate::gasp_overlay::new_finalized_graph_sink();
5343
5344                    // OPT-IN ancestor hydration: only when a fetcher is
5345                    // configured do we enable chain-backed ancestor fallback +
5346                    // strict-BEEF finalize. Default (None) = byte-identical to
5347                    // today (tolerant BEEF, peer errors abandon the graph;
5348                    // the one exception is the D8 prune of a manager-named
5349                    // input the peer answers it does not hold, which exists
5350                    // only WITHOUT a fetcher, see
5351                    // `GASPSync::process_incoming_node`).
5352                    let hydration_on = sync_fetcher.is_some();
5353
5354                    // Create storage adapter and remote
5355                    let mut gasp_storage =
5356                        OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5357                            .with_peer(peer_url.as_str())
5358                            .with_configured_peer(configured_peers)
5359                            .with_strict_beef(hydration_on)
5360                            .with_script_verification(self.verify_scripts)
5361                            .with_walk_budget(self.walk_budget);
5362                    if let Some(manager) = self.managers.get(topic) {
5363                        gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5364                    }
5365                    // bsv-low #551: the anchor check verifies each graph's
5366                    // root against THIS engine's tracker, with this engine's
5367                    // script switch, before finalize. No tracker is the
5368                    // reference's 'scripts only'.
5369                    if let Some(tracker) = self.chain_tracker.as_deref() {
5370                        gasp_storage = gasp_storage.with_chain_tracker(tracker);
5371                    }
5372                    let gasp_remote = Box::new(crate::load_remote::MeteredRemote(
5373                        factory.create_remote(peer_url, topic),
5374                    ));
5375
5376                    let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5377                    // Graphs of this peer handed to submit (bsv-low #552).
5378                    let peer_finalized = std::cell::Cell::new(0u64);
5379                    // Engine 0.2.2: this peer's page buffer (`page_batch`).
5380                    let page = match &self.finalize_submit_budget {
5381                        Some((sleep, budget_ms)) => crate::page_batch::PageBuffer::default()
5382                            .with_bound(sleep.clone(), *budget_ms),
5383                        None => crate::page_batch::PageBuffer::default(),
5384                    };
5385                    let mut sync = GASPSync::new(
5386                        Box::new(gasp_storage),
5387                        gasp_remote,
5388                        last_interaction,
5389                        &log_prefix,
5390                        true, // unidirectional — overlay GASP is pull-only (submitNode throws); matches TS Engine.startGASPSync
5391                    )
5392                    .with_ancestor_fetcher(sync_fetcher.clone())
5393                    .with_expired_outpoints(std::rc::Rc::clone(&expired))
5394                    .with_batch_memo(run.single_only(peer_url));
5395                    if let Some(control) = control {
5396                        let control = control.clone();
5397                        sync = sync.with_page_slice(std::rc::Rc::new(move || {
5398                            control.stop_reason().is_some()
5399                        }));
5400                    }
5401                    // bsv-low #555: a graph past its own budget is deferred,
5402                    // not dropped with the sync.
5403                    if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5404                        let (sleep, budget_ms) =
5405                            (sleep.clone(), (*budget_ms).min(crate::meter::ROOT_MAX_MS));
5406                        let half_sleep = sleep.clone();
5407                        let share_sleep = sleep.clone();
5408                        sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5409                            max_calls: *max_calls,
5410                            max_bytes_fetched: self.graph_budget_limbs.0,
5411                            max_nodes: self.graph_budget_limbs.1,
5412                            deadline: Box::new(move || sleep(budget_ms)),
5413                            half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5414                        });
5415                        // Engine 0.1.11: the resumes take a named share of
5416                        // the per-peer slice, the rest is the page's.
5417                        if let Some((_, peer_ms)) = &self.peer_sync_budget {
5418                            let share_ms = crate::gasp::resume_share_ms(*peer_ms, budget_ms);
5419                            sync = sync.with_resume_share(Box::new(move || share_sleep(share_ms)));
5420                        }
5421                    }
5422                    // bsv-low #552: under a budget each graph is submitted as
5423                    // it finalizes, so the deadline cannot take it back. With
5424                    // no budget nothing can drop the sync, and the graphs are
5425                    // submitted after it as they always were.
5426                    if self.peer_sync_budget.is_some() {
5427                        sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5428                            engine: self,
5429                            sink: sink.clone(),
5430                            peer_url,
5431                            topic,
5432                            submitted: &peer_finalized,
5433                        }));
5434                    }
5435
5436                    // bsv-low#302: bound THIS peer's sync to the configured
5437                    // budget. `None` from the race = the budget won and the
5438                    // sync future was dropped mid-flight. What it finalized
5439                    // before that is already submitted (the hook above); the
5440                    // cursor then advances to `completed_cursor` only, see
5441                    // the deadline arm below. The race is GUARDED: it never
5442                    // drops the sync inside one transaction's finalize
5443                    // submit (the lens fold's HIGH-1), it waits for that
5444                    // transaction and the hook stops at the boundary.
5445                    let sync_outcome = match &self.peer_sync_budget {
5446                        Some((sleep, budget_ms)) => {
5447                            let budget_ms =
5448                                control.map_or(*budget_ms, |c| (*budget_ms).min(c.remaining_ms()));
5449                            let mut deadline = sleep(budget_ms);
5450                            let deadline = std::future::poll_fn(|cx| {
5451                                if control.is_some_and(|c| c.stop_reason().is_some())
5452                                    || std::future::Future::poll(deadline.as_mut(), cx).is_ready()
5453                                {
5454                                    std::task::Poll::Ready(())
5455                                } else {
5456                                    std::task::Poll::Pending
5457                                }
5458                            });
5459                            crate::gasp::race_or_deadline_guarded(
5460                                crate::page_batch::scope(
5461                                    page.clone(),
5462                                    sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5463                                ),
5464                                deadline,
5465                                &self.finalize_gate,
5466                            )
5467                            .await
5468                        }
5469                        None => Some(
5470                            crate::page_batch::scope(
5471                                page.clone(),
5472                                sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5473                            )
5474                            .await,
5475                        ),
5476                    };
5477                    // Engine 0.2.2: a sync stopped mid-page (its future
5478                    // dropped at the deadline, or an error) left the page's
5479                    // writes queued. They are written now; failing that,
5480                    // nothing of the page is admitted and the cursor keeps to
5481                    // the page's floor (below).
5482                    let page_lost = page.pending() > 0
5483                        && {
5484                            page.close();
5485                            match page.flush(self.storage.as_ref()).await {
5486                                Ok(()) => false,
5487                                Err(e) => {
5488                                    warn!(
5489                                    "[GASP SYNC] the page's storage batch for {topic} from {peer_url} failed, nothing of it admitted: {e}"
5490                                );
5491                                    page.discard();
5492                                    true
5493                                }
5494                            }
5495                        };
5496                    page.close();
5497
5498                    let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5499                    // Engine 0.2.0 (#57): the platform answered in the peer's
5500                    // place, so this attempt says nothing about the peer.
5501                    let route_faulted = matches!(
5502                        sync_outcome,
5503                        Some(Err(crate::gasp::GASPError::RouteFault { .. }))
5504                    );
5505                    // D8 decoy rule: branches pruned in this peer's walk. Not
5506                    // an error and not a failed UTXO, so it is counted apart
5507                    // from `errors` and never touches the outcome or cursor.
5508                    // Summed over peers: one decoy seen through two peers
5509                    // counts twice.
5510                    pruned_inputs += sync.pruned_inputs();
5511                    expired_refused += sync.expired_refused();
5512                    roots_proven += sync.roots_proven();
5513                    roots_unproven += sync.roots_unproven();
5514                    // bsv-low #551: graphs the anchor check refused. Counted
5515                    // apart from `errors` for the same reason as a prune: a
5516                    // refused graph is not a failed sync (the reference
5517                    // discards it and carries on).
5518                    discarded_graphs += sync.discarded_graphs();
5519                    anchor_walk_held_graphs += sync.anchor_walk_held();
5520                    // Whether the sync ran (to its end, or to the deadline)
5521                    // and whether it moved the cursor (bsv-low #555, the
5522                    // delta fold's D-M2).
5523                    let ran = !matches!(sync_outcome, Some(Err(_)));
5524                    let mut advanced_cursor = false;
5525                    match sync_outcome {
5526                        None => {
5527                            let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5528                            let msg = format!(
5529                                "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5530                            );
5531                            warn!("[GASP SYNC] {msg}");
5532                            errors.push(msg);
5533
5534                            // bsv-low #552: the work COMPLETED before the
5535                            // deadline is kept. Its graphs are already
5536                            // submitted (the hook); the cursor moves past the
5537                            // UTXOs that were completed and stops below the
5538                            // one in flight, whose graph is lost whole and
5539                            // walked again next tick. A graph the deadline
5540                            // met while it was being SUBMITTED is not "in
5541                            // flight" here: it left a prefix of whole
5542                            // transactions, is counted finalized, and its
5543                            // UTXO is below the cursor too.
5544                            let in_flight = u64::from(sync.graph_in_flight());
5545                            deadline_dropped_graphs += in_flight;
5546                            // bsv-low #555: the walk in hand is saved, so the
5547                            // next tick resumes it instead of walking it again.
5548                            sync.defer_in_flight().await;
5549                            let completed = match sync.page_floor() {
5550                                Some(floor) if page_lost => sync.completed_cursor().min(floor),
5551                                _ => sync.completed_cursor(),
5552                            };
5553                            if completed > last_interaction {
5554                                match self
5555                                    .storage
5556                                    .update_last_interaction(peer_url, topic, completed)
5557                                    .await
5558                                {
5559                                    Ok(()) => cursor_moves.push(CursorMove {
5560                                        peer: peer_url.clone(),
5561                                        from: last_interaction,
5562                                        to: completed,
5563                                    }),
5564                                    Err(e) => warn!(
5565                                        "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5566                                    ),
5567                                }
5568                            }
5569                            // A tick that finalized a graph or moved the
5570                            // cursor reached a live peer: not a failed
5571                            // attempt for the quarantine count. So did one
5572                            // whose walk PROGRESSED (bsv-low #555: it
5573                            // appended a node, or completed its graph). A
5574                            // deferral that fetched nothing is no progress
5575                            // (the lens fold's H1: a hung peer deferred with
5576                            // 0 nodes on every tick and was never
5577                            // quarantined).
5578                            //
5579                            // Engine 0.1.8: the deadline drop itself is not a
5580                            // failure of the peer. The per-peer deadline is
5581                            // OUR slice of the tick, set by our cadence and
5582                            // the Worker's limits; a peer that served every
5583                            // page and node we asked for until it fell due
5584                            // did not fail. What the slice did is the test:
5585                            // a sync that refused an expired root, retained
5586                            // a graph or resumed one with progress did work
5587                            // too (`GASPSync::did_work`).
5588                            advanced_cursor = completed > last_interaction;
5589                            outcome_success = peer_finalized.get() > 0
5590                                || (completed > last_interaction && sync.cursor_yielded())
5591                                || sync.deferral_stats().progressed > 0
5592                                || sync.did_work();
5593                            warn!(
5594                                "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5595                                peer_finalized.get(),
5596                                completed.max(last_interaction)
5597                            );
5598                        }
5599                        Some(Ok(())) => {
5600                            // Submit finalized graphs to the Engine (under a
5601                            // budget the hook already did, graph by graph,
5602                            // and the sink is empty here).
5603                            let landed = self
5604                                .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5605                                .await;
5606                            // bsv-low #555, the lens fold's H1: a sync that
5607                            // ran to its end with walks the per-graph budget
5608                            // cut having FETCHED NOTHING, and nothing else
5609                            // got done (no walk progressed, no graph
5610                            // finalized, the cursor did not move), reached
5611                            // a peer that does not answer: a FAILED attempt.
5612                            // Before #555 such a peer held the sync to the
5613                            // per-peer deadline, a failure; the per-graph
5614                            // budget had made it an `Ok` sync. A sync whose
5615                            // UTXOs failed in any other way is unchanged.
5616                            let stats = sync.deferral_stats();
5617                            if stats.stalled > 0
5618                                && stats.progressed == 0
5619                                && peer_finalized.get() == 0
5620                                && !sync.did_work()
5621                                && !(landed
5622                                    && sync.last_interaction > last_interaction
5623                                    && sync.cursor_yielded())
5624                            {
5625                                outcome_success = false;
5626                                let msg = format!(
5627                                    "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5628                                    stats.stalled
5629                                );
5630                                warn!("[GASP SYNC] {msg}");
5631                                errors.push(msg);
5632                            }
5633
5634                            // Advance the persisted cursor when it moved forward — i.e.
5635                            // the peer reported UTXOs at a higher score than our last
5636                            // `since` (the in-memory cursor only grows on SEEN scores,
5637                            // so `>` already excludes the empty-bootstrap case where no
5638                            // UTXOs were seen). Mirrors TS `if (gasp.lastInteraction >
5639                            // lastInteraction)` — NOT gated on a new submission: a sync
5640                            // that re-sees already-known UTXOs must still advance, else
5641                            // every cron re-scans the same range forever (the cursor
5642                            // stayed pinned at 0 in prod).
5643                            //
5644                            // NOT advanced when a finalize submit above did
5645                            // not land (the lens fold's MEDIUM-1): the sink
5646                            // does not say which UTXO that graph was, so the
5647                            // whole cursor stays and the next sync is served
5648                            // the range again (what did land is known and
5649                            // skipped). Under a budget the hook fails that
5650                            // one UTXO instead and the gap guard has already
5651                            // capped `sync.last_interaction` below it.
5652                            advanced_cursor = landed && sync.last_interaction > last_interaction;
5653                            if !landed {
5654                                warn!(
5655                                    "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5656                                );
5657                            } else if sync.last_interaction > last_interaction {
5658                                match self
5659                                    .storage
5660                                    .update_last_interaction(peer_url, topic, sync.last_interaction)
5661                                    .await
5662                                {
5663                                    Ok(()) => cursor_moves.push(CursorMove {
5664                                        peer: peer_url.clone(),
5665                                        from: last_interaction,
5666                                        to: sync.last_interaction,
5667                                    }),
5668                                    Err(e) => warn!(
5669                                        "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5670                                    ),
5671                                }
5672                            }
5673                            info!(
5674                                "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5675                                sync.last_interaction
5676                            );
5677                        }
5678                        Some(Err(e)) => {
5679                            let msg = format!("{peer_url}: {e}");
5680                            warn!("[GASP SYNC] Sync failed: {msg}");
5681                            errors.push(msg);
5682                            if let crate::gasp::GASPError::RouteFault { class, .. } = e {
5683                                route_faults.push(crate::route_fault::RouteFaultSeen {
5684                                    peer: peer_url.clone(),
5685                                    class,
5686                                });
5687                            }
5688                        }
5689                    }
5690                    // Engine 0.1.8: work is not a failure. A sync that
5691                    // finalized a graph, moved the cursor past what it
5692                    // finished, refused an expired root not refused before,
5693                    // retained a graph or resumed one with progress is never
5694                    // a failed attempt (a peer is quarantined for being
5695                    // dead, never for our cadence), even if it then faulted.
5696                    //
5697                    // The YIELD that ends the yieldless streak (and alone
5698                    // lifts a quarantine, D4-L2) is the narrower set: a
5699                    // finalized graph, a moved cursor, a refusal. A held or
5700                    // resumed walk is the peer's word until its anchor is
5701                    // checked: counted as a yield, a peer that lists one
5702                    // fabricated root a tick, serves it and hangs on its
5703                    // input would never be quarantined (bsv-low #555's D-M2).
5704                    // Such syncs stay inside the yieldless allowance (12
5705                    // syncs and 3 h), which an honest deep graph converges in.
5706                    let yielded = peer_finalized.get() > 0
5707                        || (advanced_cursor && sync.cursor_yielded())
5708                        || sync.expired_refused() > 0;
5709                    if yielded || sync.did_work() {
5710                        outcome_success = true;
5711                    }
5712                    // Engine 0.2.0 (#57): a route fault with no work done
5713                    // before it touches none of the peer's health rows: not
5714                    // its failures (the quarantine), not its yieldless
5715                    // streak, not its quiet cadence. The quarantine judges
5716                    // the peer, never the route. A sync the peer served work
5717                    // in before the route faulted is the peer's answer and
5718                    // is recorded as one.
5719                    let route_excused = route_faulted && !outcome_success;
5720                    if route_excused {
5721                        info!(
5722                            "[GASP SYNC] {peer_url} for {topic}: {} before the peer was reached; nothing counted against the peer (#57)",
5723                            route_faults
5724                                .last()
5725                                .map_or(String::new(), crate::route_fault::RouteFaultSeen::label)
5726                        );
5727                    }
5728                    // bsv-low #555, the delta fold's D-M2: "progress" (a node
5729                    // appended) is the peer's word. A sync the peer served
5730                    // work that finalized no graph and moved no cursor is
5731                    // YIELDLESS; past PEER_YIELDLESS_SYNCS_ALLOWED of them in
5732                    // a row each is a FAILED attempt, so a peer that serves
5733                    // one fresh root a tick and hangs on its input is
5734                    // quarantined. A quiet sync (nothing served) leaves the
5735                    // count; a fault of the count is fail-safe (no failure).
5736                    // Only with a per-graph budget: without one nothing is
5737                    // deferred and #302's rule is unchanged.
5738                    if ran
5739                        && self.graph_budget.is_some()
5740                        && (yielded || sync.graphs_attempted() > 0)
5741                    {
5742                        match self
5743                            .storage
5744                            .record_peer_sync_yield(&health_key, topic, yielded)
5745                            .await
5746                        {
5747                            Ok(streak)
5748                                if !yielded
5749                                    && crate::gasp::yieldless_sync_failed(&streak)
5750                                    && outcome_success =>
5751                            {
5752                                outcome_success = false;
5753                                let msg = format!(
5754                                    "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5755                                    streak.yieldless_syncs,
5756                                    streak.secs_since_first.unwrap_or(0),
5757                                    crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5758                                    crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5759                                );
5760                                warn!("[GASP SYNC] {msg}");
5761                                errors.push(msg);
5762                            }
5763                            Ok(_) => {}
5764                            Err(e) => warn!(
5765                                "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5766                            ),
5767                        }
5768                    }
5769                    // Quiet success controls polling cadence, never peer health. Faults
5770                    // retain #302's attempt cadence; retained work keeps its resume slice.
5771                    let quiet = outcome_success
5772                        && !yielded
5773                        && !sync.did_work()
5774                        && sync.faulted_roots().is_empty()
5775                        && sync.deferral_stats().stalled == 0;
5776                    let quiet_syncs = if quiet && !filter.explicit() {
5777                        if reset_cadence {
5778                            1
5779                        } else {
5780                            cadence.quiet_syncs.saturating_add(1)
5781                        }
5782                    } else {
5783                        0
5784                    };
5785                    let next = crate::sync_load::PeerCadence {
5786                        quiet_syncs,
5787                        retry_after_secs: crate::sync_load::quiet_retry_secs(quiet_syncs),
5788                        advertisement,
5789                    };
5790                    if !route_excused {
5791                        if let Err(e) = self
5792                            .storage
5793                            .set_peer_cadence(&health_key, topic, &next)
5794                            .await
5795                        {
5796                            warn!(
5797                                "[GASP SYNC] failed to record cadence for {peer_url}/{topic}: {e}"
5798                            );
5799                        }
5800                    }
5801                    if !advanced_cursor {
5802                        if let Some(outpoint) = sync.cursor_held_by() {
5803                            cursor_held_by.push(outpoint);
5804                        }
5805                    }
5806                    finalized_graphs += peer_finalized.get();
5807                    let deferral = sync.deferral_stats();
5808                    root_bounds.extend(deferral.root_bounds.iter().cloned().map(|mut bound| {
5809                        bound.peer.clone_from(peer_url);
5810                        bound
5811                    }));
5812                    deferred_graphs += deferral.deferred;
5813                    resumed_graphs += deferral.resumed;
5814                    converged_graphs += deferral.converged;
5815                    stalled_graphs += deferral.stalled;
5816                    held_back_graphs += deferral.held_back;
5817                    backed_off_graphs += deferral.backed_off;
5818                    waiting_graphs += deferral.waiting;
5819                    idle_counted += deferral.idle_counted;
5820                    dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5821                        peer: peer_url.clone(),
5822                        outpoint: d.outpoint,
5823                        reason: d.reason.as_str().to_string(),
5824                    }));
5825                    // Engine 0.1.9: the roots dropped, each with why.
5826                    dead_graphs += sync.dead_recorded();
5827                    expired_swept += sync.expired_swept();
5828                    memo_folded += sync.memo_folded();
5829                    unanchorable_graphs += sync.unanchorable_recorded();
5830                    unanchorable_cleared += sync.unanchorable_cleared();
5831                    absent_struck += sync.absent_struck();
5832                    // Engine 0.1.10: a strike not written is a fact.
5833                    strikes_refused += sync.strikes_refused();
5834                    faulted_roots.extend(sync.faulted_roots().into_iter().map(|mut f| {
5835                        f.peer.clone_from(peer_url);
5836                        f
5837                    }));
5838
5839                    // bsv-low#302: record the attempt outcome (success resets
5840                    // the consecutive-failure count; timeout/error increments
5841                    // it). Best-effort — a bookkeeping fault must never fail
5842                    // the sync pass itself.
5843                    //
5844                    // bsv-low #555, the delta-4 fold's D4-L2: with a
5845                    // per-graph budget, a peer that was at the quarantine
5846                    // threshold when this sync began is re-admitted only by
5847                    // a YIELD (a graph finalized, the cursor moved). A sync
5848                    // that did not fail and yielded nothing (an empty
5849                    // listing at its probe, a walk that only progressed) is
5850                    // NOT RECORDED: the failures stay, and so does the
5851                    // last-attempt stamp, so the peer is attended again on
5852                    // the next tick and its next failure re-arms the
5853                    // quarantine at once. Recorded as a success, a hostile
5854                    // peer's empty listing at each probe reset its failures
5855                    // and (the decay being for a peer that is not failed)
5856                    // its yieldless streak: 100 of 200 ticks. A health read
5857                    // that faulted read 0 failures: the old rule, fail-safe.
5858                    if route_excused {
5859                        // Nothing recorded: see `route_excused` above.
5860                    } else if outcome_success
5861                        && !yielded
5862                        && self.graph_budget.is_some()
5863                        && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5864                    {
5865                        info!(
5866                            "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5867                            health.consecutive_failures
5868                        );
5869                    } else if let Err(e) = self
5870                        .storage
5871                        .record_peer_sync_outcome(&health_key, topic, outcome_success)
5872                        .await
5873                    {
5874                        warn!(
5875                            "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5876                        );
5877                    }
5878                    let cursor = self.storage.get_last_interaction(peer_url, topic).await?;
5879                    let cost = crate::meter::snapshot().since(peer_cost_before);
5880                    peer_results.insert(
5881                        peer_url.clone(),
5882                        crate::backfill::PeerSyncResult {
5883                            slices: u64::from(sync.page_advanced() || cost.outputs_walked > 0),
5884                            pages: sync.pages(),
5885                            reached_tip: sync.reached_tip() && cursor >= sync.completed_cursor(),
5886                            cursor,
5887                            cost,
5888                        },
5889                    );
5890                }
5891            }
5892
5893            topics_synced.insert(
5894                topic.clone(),
5895                TopicSyncResult {
5896                    cost: crate::meter::snapshot().since(cost_before),
5897                    root_bounds,
5898                    peers_skipped,
5899                    peers_filtered,
5900                    roots_proven,
5901                    roots_unproven,
5902                    cursor_held_by,
5903                    expired_refused,
5904                    peers,
5905                    sync_type,
5906                    errors,
5907                    pruned_inputs,
5908                    discarded_graphs,
5909                    anchor_walk_held_graphs,
5910                    finalized_graphs,
5911                    deadline_dropped_graphs,
5912                    cursor_moves,
5913                    deferred_graphs,
5914                    resumed_graphs,
5915                    converged_graphs,
5916                    dropped_graphs,
5917                    stalled_graphs,
5918                    held_back_graphs,
5919                    backed_off_graphs,
5920                    waiting_graphs,
5921                    idle_counted,
5922                    peer_results,
5923                    dead_graphs,
5924                    expired_swept,
5925                    memo_folded,
5926                    unanchorable_graphs,
5927                    unanchorable_cleared,
5928                    absent_struck,
5929                    route_faults,
5930                    strikes_refused,
5931                    faulted_roots,
5932                },
5933            );
5934        }
5935
5936        info!(
5937            "[GASP SYNC] Peer discovery complete for {} topic(s)",
5938            topics_synced.len()
5939        );
5940
5941        let expired: u64 = topics_synced.values().map(|r| r.expired_refused).sum();
5942        if expired > 0 {
5943            info!("[GASP SYNC] gaspExpiredRefused={expired}");
5944        }
5945        let unanchorable: u64 = topics_synced.values().map(|r| r.unanchorable_graphs).sum();
5946        if unanchorable > 0 {
5947            info!("[GASP SYNC] gaspUnanchorable={unanchorable}");
5948        }
5949        let absent: u64 = topics_synced.values().map(|r| r.absent_struck).sum();
5950        if absent > 0 {
5951            info!("[GASP SYNC] gaspAbsentStruck={absent}");
5952        }
5953        let routes: usize = topics_synced.values().map(|r| r.route_faults.len()).sum();
5954        if routes > 0 {
5955            info!("[GASP SYNC] gaspRouteFaults={routes}");
5956        }
5957        let refused: u64 = topics_synced.values().map(|r| r.strikes_refused).sum();
5958        if refused > 0 {
5959            info!("[GASP SYNC] gaspStrikesRefused={refused}");
5960        }
5961        Ok(GASPSyncResult {
5962            cost: crate::meter::snapshot(),
5963            topics_synced,
5964            filter: filter.clone(),
5965            topics_filtered,
5966        })
5967    }
5968
5969    /// Drain `sink` and submit every finalized graph in it, ancestors first
5970    /// (`historical-tx-no-spv`: the anchor check already verified them).
5971    /// Each graph is added to `submitted` as its first submit starts. Returns
5972    /// whether EVERY transaction of every graph landed.
5973    ///
5974    /// Each graph here passed the anchor check: its root verified and the
5975    /// replay admitted it, so these submits rest on a checked premise and are
5976    /// not expected to fail. If one does not LAND, the REST OF THAT GRAPH is
5977    /// not submitted: the reference's `finalizeGraph` awaits each submit in
5978    /// order and its throw ends the loop (the BEEFs already submitted stay,
5979    /// it has no rollback either). A later BEEF would otherwise be judged
5980    /// without the coin its ancestor failed to leave, admit nothing and be
5981    /// recorded as applied, a dupe for good. "Landed" is read from the
5982    /// durability report, not from `Ok`: `submit` answers `Ok` when a storage
5983    /// write faulted and when the topic manager failed, and in both it
5984    /// records no applied row. A transaction landed when its topic is applied
5985    /// or was already applied (a dupe). Other graphs carry on, and the caller
5986    /// keeps the cursor from passing a graph that did not land.
5987    ///
5988    /// Each transaction's submit is one write section of the engine's gate
5989    /// ([`Engine::finalize_submit_gate`]) and the gate is asked between two:
5990    /// a deadline that is due stops the sequence there (this future is
5991    /// dropped at that await, between two whole transactions).
5992    async fn submit_finalized_graphs(
5993        &self,
5994        sink: &crate::gasp_overlay::FinalizedGraphSink,
5995        peer_url: &str,
5996        topic: &str,
5997        submitted: &std::cell::Cell<u64>,
5998    ) -> bool {
5999        let finalized: Vec<_> = sink
6000            .lock()
6001            .unwrap_or_else(std::sync::PoisonError::into_inner)
6002            .drain(..)
6003            .collect();
6004
6005        let mut all_landed = true;
6006        for graph in &finalized {
6007            for (position, beef_bytes) in graph.beefs.iter().enumerate() {
6008                self.finalize_gate.stop_if_due().await;
6009                if position == 0 {
6010                    submitted.set(submitted.get() + 1);
6011                }
6012                let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
6013                // No deadline drops a write section, and the section does
6014                // not drop ITSELF either (bsv-low #559, the delta lens's
6015                // DELTA-3 and the lens fold of 2026-10-07, F2,
6016                // `set_finalize_submit_budget`): the submit always runs to
6017                // its end. What is bounded is each storage call and hook
6018                // inside it ([`CallBound`]): one that never answers is that
6019                // call's FAULT, the submit handles it as any fault (nothing
6020                // of the transaction held, or all of it), the transaction
6021                // did not land, its UTXO fails and the cursor stays.
6022                let outcome = {
6023                    let _writing = self.finalize_gate.write_section();
6024                    let bound = self
6025                        .finalize_submit_budget
6026                        .as_ref()
6027                        .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
6028                    self.submit_bounded(
6029                        &tagged,
6030                        SubmitMode::HistoricalTxNoSpv,
6031                        bound.as_ref(),
6032                        true,
6033                        WalkBreachPolicy::NotNow,
6034                    )
6035                    .await
6036                };
6037                let not_landed = match outcome {
6038                    Ok((_, report)) => {
6039                        let landed = report.is_durable()
6040                            && (report.applied_topics.contains(&graph.topic)
6041                                || report.deduped_topics.contains(&graph.topic));
6042                        (!landed).then(|| {
6043                            if report.is_durable() {
6044                                "the topic manager failed on it".to_string()
6045                            } else {
6046                                format!("not durable: {}", report.summary())
6047                            }
6048                        })
6049                    }
6050                    Err(e) => Some(e.to_string()),
6051                };
6052                if let Some(why) = not_landed {
6053                    warn!(
6054                        "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
6055                        graph.topic
6056                    );
6057                    all_landed = false;
6058                    break;
6059                }
6060            }
6061        }
6062
6063        if !finalized.is_empty() {
6064            info!(
6065                "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
6066                finalized.len()
6067            );
6068        }
6069        all_landed
6070    }
6071
6072    /// Union local records, tracker answers, SLAP-named providers and bootstrap.
6073    /// Reference: overlay-services@480d77a src/Engine.ts:839-876 and
6074    /// ts-stack@edf6e03 packages/sdk/src/overlay-tools/LookupResolver.ts:1976.
6075    /// Ask the configured topic peers for held outputs, using the same SHIP
6076    /// discovery as sync. Only a header-verified proof can change stored bytes.
6077    pub async fn rerequest_held_proofs(
6078        &self,
6079        candidates: &[crate::proof_window::HeldOutput],
6080        progress: &std::cell::Cell<crate::proof_window::PeerProofSummary>,
6081    ) {
6082        use crate::proof_window::{PEER_PROOF_REQUEST_MS, PEER_PROOF_ROW_LIMIT};
6083        let Some(factory) = &self.gasp_remote_factory else {
6084            return;
6085        };
6086        let Some(tracker) = self.chain_tracker.as_deref() else {
6087            return;
6088        };
6089        let mut lookup_hosts = None;
6090        let mut topic_peers = HashMap::new();
6091        let mut landed = HashSet::new();
6092        for candidate in candidates.iter().take(PEER_PROOF_ROW_LIMIT) {
6093            if !self.managers.contains_key(&candidate.topic) {
6094                continue;
6095            }
6096            if let std::collections::hash_map::Entry::Vacant(entry) =
6097                topic_peers.entry(candidate.topic.clone())
6098            {
6099                let peers = match self.config.sync_configuration.get(&candidate.topic) {
6100                    Some(SyncTarget::Ship) => {
6101                        self.discover_ship_peers(&candidate.topic, &mut lookup_hosts)
6102                            .await
6103                    }
6104                    Some(SyncTarget::Peers(peers)) => self.ship_peers_excluding_self(peers.clone()),
6105                    _ => Vec::new(),
6106                };
6107                entry.insert(peers);
6108            }
6109            for peer in &topic_peers[&candidate.topic] {
6110                let remote = factory.create_remote(peer, &candidate.topic);
6111                let graph_id = format!("{}.{}", candidate.txid, candidate.output_index);
6112                let mut summary = progress.get();
6113                summary.requests += 1;
6114                progress.set(summary);
6115                let ask =
6116                    remote.request_node(&graph_id, &candidate.txid, candidate.output_index, false);
6117                let response = if let Some((sleep, _)) = &self.peer_sync_budget {
6118                    crate::gasp::race_or_deadline(ask, sleep(PEER_PROOF_REQUEST_MS)).await
6119                } else {
6120                    Some(ask.await)
6121                };
6122                let Some(Ok(node)) = response else {
6123                    let mut summary = progress.get();
6124                    summary.faults += 1;
6125                    progress.set(summary);
6126                    continue;
6127                };
6128                if landed.contains(&candidate.txid) {
6129                    continue;
6130                }
6131                let Some(proof_hex) = node.proof else {
6132                    continue;
6133                };
6134                let Ok(mut proof) = beef_limits::merkle_path_from_hex(
6135                    &proof_hex,
6136                    beef_limits::COURIER_PROOF_MAX_BYTES,
6137                ) else {
6138                    let mut summary = progress.get();
6139                    summary.refused += 1;
6140                    progress.set(summary);
6141                    continue;
6142                };
6143                let valid = match proof.compute_root(Some(&candidate.txid)) {
6144                    Ok(root) => tracker
6145                        .is_valid_root_for_height(&root, proof.block_height)
6146                        .await
6147                        .unwrap_or(false),
6148                    Err(_) => false,
6149                };
6150                if !valid {
6151                    let mut summary = progress.get();
6152                    summary.refused += 1;
6153                    progress.set(summary);
6154                    continue;
6155                }
6156                // Root verification accepts a hash leaf too. Mark the verified
6157                // subject so BEEF attaches the path before its proof flag moves.
6158                for leaf in &mut proof.path[0] {
6159                    if leaf.hash.as_deref() == Some(&candidate.txid) {
6160                        leaf.txid = true;
6161                    }
6162                }
6163                match self.land_peer_proof(candidate, &proof).await {
6164                    Ok(()) => {
6165                        landed.insert(candidate.txid.clone());
6166                        let mut summary = progress.get();
6167                        summary.landed += 1;
6168                        progress.set(summary);
6169                    }
6170                    Err(error) => {
6171                        warn!(%error, "[proof window] verified proof could not be stored");
6172                        let mut summary = progress.get();
6173                        summary.faults += 1;
6174                        progress.set(summary);
6175                    }
6176                }
6177            }
6178        }
6179    }
6180
6181    async fn land_peer_proof(
6182        &self,
6183        candidate: &crate::proof_window::HeldOutput,
6184        proof: &bsv_rs::transaction::MerklePath,
6185    ) -> Result<(), EngineError> {
6186        let output = self
6187            .storage
6188            .find_output(
6189                &candidate.txid,
6190                candidate.output_index,
6191                Some(&candidate.topic),
6192                None,
6193                true,
6194            )
6195            .await
6196            .map_err(|e| EngineError::StorageError(e.to_string()))?
6197            .ok_or(EngineError::NodeNotFound)?;
6198        let beef = output
6199            .beef
6200            .ok_or_else(|| EngineError::BeefParseError("missing stored BEEF".into()))?;
6201        let stitched = Self::stitch_proof_into_stored_beef(&beef, &candidate.txid, proof)
6202            .ok_or_else(|| {
6203                EngineError::BeefParseError("proof does not stitch into stored subject".into())
6204            })?;
6205        self.storage
6206            .update_transaction_beef(&candidate.txid, &stitched)
6207            .await
6208            .map_err(|e| EngineError::StorageError(e.to_string()))?;
6209        self.storage
6210            .mark_transaction_proven_at(&candidate.txid, Some(u64::from(proof.block_height)))
6211            .await
6212            .map_err(|e| EngineError::StorageError(e.to_string()))
6213    }
6214
6215    /// Read our admitted advertisement set, not a foreign history. A changed
6216    /// SLAP outpoint wakes its origin; SHIP scopes that wake to its topic.
6217    async fn peer_advertisement_versions(&self, topic: &str) -> HashMap<String, String> {
6218        let mut versions: HashMap<String, Vec<String>> = HashMap::new();
6219        for (ad_topic, protocol, scope) in [
6220            ("tm_slap", &b"SLAP"[..], None),
6221            ("tm_ship", &b"SHIP"[..], Some(topic)),
6222        ] {
6223            let rows = self
6224                .storage
6225                .find_utxos_for_topic(ad_topic, None, None, false)
6226                .await
6227                .unwrap_or_default();
6228            for row in rows {
6229                if let Some(domain) =
6230                    parse_advertisement_domain_from_script(&row.output_script, protocol, scope)
6231                {
6232                    versions
6233                        .entry(crate::gasp::peer_origin(&domain))
6234                        .or_default()
6235                        .push(format!("{}:{}", row.txid, row.output_index));
6236                }
6237            }
6238        }
6239        versions
6240            .into_iter()
6241            .map(|(peer, mut keys)| {
6242                keys.sort();
6243                (peer, keys.join(","))
6244            })
6245            .collect()
6246    }
6247
6248    async fn discover_ship_peers(
6249        &self,
6250        topic: &str,
6251        lookup_hosts: &mut Option<Vec<String>>,
6252    ) -> Vec<String> {
6253        let mut domains = self.local_ship_peers(topic).await;
6254        if let Some(bootstrap) = self.ship_bootstrap_peers.get(topic) {
6255            domains.extend(bootstrap.iter().cloned());
6256        }
6257        if let Some(client) = &self.ship_lookup_client {
6258            if lookup_hosts.is_none() {
6259                *lookup_hosts = Some(self.discover_ship_lookup_hosts(client.as_ref()).await);
6260            }
6261            let question = LookupQuestion::new("ls_ship", serde_json::json!({"topics": [topic]}));
6262            for host in lookup_hosts.as_ref().into_iter().flatten() {
6263                crate::meter::peer_request();
6264                match client.lookup(host, &question).await {
6265                    Ok(outputs) => {
6266                        domains.extend(outputs.iter().filter_map(|output| {
6267                            parse_lookup_advertisement(output, b"SHIP", topic)
6268                        }));
6269                    }
6270                    Err(e) => warn!("[GASP SYNC] SHIP lookup at {host} for {topic} failed: {e}"),
6271                }
6272            }
6273        }
6274        self.ship_peers_excluding_self(domains)
6275    }
6276
6277    async fn discover_ship_lookup_hosts(
6278        &self,
6279        client: &dyn crate::ship_lookup::ShipLookupClient,
6280    ) -> Vec<String> {
6281        use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
6282        let trackers = self.ship_peers_excluding_self(self.config.slap_trackers.clone());
6283        let tracker_origins: HashSet<_> = trackers
6284            .iter()
6285            .map(|host| crate::gasp::peer_origin(host))
6286            .collect();
6287        let question = LookupQuestion::new("ls_slap", serde_json::json!({"service": "ls_ship"}));
6288        let mut advertised = Vec::new();
6289        for tracker in &trackers {
6290            crate::meter::peer_request();
6291            match client.lookup(tracker, &question).await {
6292                Ok(outputs) => {
6293                    advertised.extend(outputs.iter().filter_map(|output| {
6294                        parse_lookup_advertisement(output, b"SLAP", "ls_ship")
6295                    }));
6296                }
6297                Err(e) => warn!("[GASP SYNC] SLAP lookup at {tracker} failed: {e}"),
6298            }
6299        }
6300        let providers = self
6301            .ship_peers_excluding_self(advertised)
6302            .into_iter()
6303            .filter(|host| !tracker_origins.contains(&crate::gasp::peer_origin(host)))
6304            .take(SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
6305        trackers.into_iter().chain(providers).collect()
6306    }
6307
6308    fn ship_peers_excluding_self(&self, domains: impl IntoIterator<Item = String>) -> Vec<String> {
6309        crate::gasp::ship_peers_by_origin(domains.into_iter().filter(|domain| {
6310            self.config.hosting_url.as_ref().is_none_or(|our_url| {
6311                crate::gasp::peer_origin(domain) != crate::gasp::peer_origin(our_url)
6312            })
6313        }))
6314    }
6315
6316    /// Read local SHIP records independently of network discovery failures.
6317    async fn local_ship_peers(&self, topic: &str) -> Vec<String> {
6318        let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
6319            warn!("[GASP SYNC] No local ls_ship; retaining network and bootstrap discovery");
6320            return Vec::new();
6321        };
6322
6323        let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
6324
6325        let refs = match ship_ls.lookup(&question).await {
6326            Ok(LookupResult::OutputList(refs)) => refs,
6327            Ok(LookupResult::Answer(_)) => {
6328                error!(
6329                    "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
6330                     LookupAnswer; expected OutputList. Skipping."
6331                );
6332                return Vec::new();
6333            }
6334            Err(e) => {
6335                error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
6336                return Vec::new();
6337            }
6338        };
6339
6340        let mut domains = HashSet::new();
6341        for reference in &refs {
6342            if let Ok(Some(output)) = self
6343                .storage
6344                .find_output(&reference.txid, reference.output_index, None, None, false)
6345                .await
6346            {
6347                if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
6348                    // Every spelling of our own origin is ourselves (the
6349                    // delta-2 fold's D2-M2: `https://us/?x` passed the
6350                    // string compare).
6351                    if let Some(ref our_url) = self.config.hosting_url {
6352                        if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
6353                            || crate::gasp::peer_origin(&domain)
6354                                == crate::gasp::peer_origin(our_url)
6355                        {
6356                            continue;
6357                        }
6358                    }
6359                    domains.insert(domain);
6360                }
6361            }
6362        }
6363
6364        // One peer per normalized origin (bsv-low #555, the delta-2 fold's
6365        // D2-M2, restored by the delta-4 fold's D4-M1): eight spellings of
6366        // one server are one peer, the one its health row is keyed by.
6367        crate::gasp::ship_peers_by_origin(domains)
6368    }
6369
6370    // ========================================================================
6371    // Helpers
6372    // ========================================================================
6373
6374    /// Get a reference to the storage backend.
6375    pub fn storage(&self) -> &dyn Storage {
6376        self.storage.as_ref()
6377    }
6378
6379    /// Get the engine configuration.
6380    pub fn config(&self) -> &EngineConfig {
6381        &self.config
6382    }
6383}
6384
6385/// The SPV check of [`Engine::submit`], as one function over a BORROWED
6386/// tracker: the reference's `tx.verify(chainTracker)` (roots against the
6387/// tracker, every unproven input's script executed, the value rule), its
6388/// `tx.verify('scripts only')` when `chain_tracker` is `None` (roots accepted
6389/// unchecked, scripts still run), and the pre-2026-09-08 structural check when
6390/// `verify_scripts` is `false` ([`Engine::set_script_verification`]'s escape
6391/// hatch).
6392///
6393/// There is ONE verifier. `Engine::submit` calls this with its own tracker,
6394/// switch and walk budget; the GASP anchor check
6395/// (`OverlayGASPStorage::validate_graph_anchor`, bsv-low #551) calls it with
6396/// the same three, handed over by `Engine::start_gasp_sync`.
6397///
6398/// `Ok(Some(_))` is a walk that COULD NOT RUN within `budget` (bsv-low #592):
6399/// not a verdict on the transaction. The caller goes on as it would without a
6400/// walk and says so.
6401pub(crate) async fn verify_spv_like_the_reference(
6402    chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6403    verify_scripts: bool,
6404    beef_bytes: &[u8],
6405    subject_txid: &str,
6406    budget: DoorBudget,
6407) -> Result<Option<WalkCouldNotRun>, EngineError> {
6408    verify_spv_trusting(
6409        chain_tracker,
6410        verify_scripts,
6411        beef_bytes,
6412        subject_txid,
6413        &HashSet::new(),
6414        budget,
6415    )
6416    .await
6417}
6418
6419/// [`verify_spv_like_the_reference`] trusting the transactions an earlier
6420/// walk of the same submit passed over (a predecessor the door lands first,
6421/// the E1D delta fold, L4): the script walk neither checks nor descends
6422/// them. The structural escape hatch checks the whole BEEF it is given.
6423async fn verify_spv_trusting(
6424    chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6425    verify_scripts: bool,
6426    beef_bytes: &[u8],
6427    subject_txid: &str,
6428    trusted: &HashSet<String>,
6429    budget: DoorBudget,
6430) -> Result<Option<WalkCouldNotRun>, EngineError> {
6431    if verify_scripts {
6432        Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
6433    } else {
6434        Engine::verify_spv_structurally(chain_tracker, beef_bytes)
6435            .await
6436            .map(|()| None)
6437    }
6438}
6439
6440/// The BUMP that proves each txid of `beef`: the first BUMP whose level 0
6441/// carries it, as `Beef::find_bump` picks, built in one pass over the BUMPs'
6442/// leaves (NL-6e; `find_bump` per transaction scans them all each time).
6443fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
6444    let mut proven: HashMap<&str, usize> = HashMap::new();
6445    for (at, bump) in beef.bumps.iter().enumerate() {
6446        for leaf in bump.path.first().into_iter().flatten() {
6447            if let Some(hash) = leaf.hash.as_deref() {
6448                proven.entry(hash).or_insert(at);
6449            }
6450        }
6451    }
6452    proven
6453}
6454
6455/// The transactions the SPV walk of `from` passes over in `beef`
6456/// ([`Engine::verify_beef_linear_with`]'s traversal, nothing checked): `from`
6457/// and every source the BEEF carries, a proven transaction included and not
6458/// descended. The E1D delta fold, L4: what a landing need not walk again.
6459fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
6460    let bodies: HashMap<String, &Transaction> = beef
6461        .txs
6462        .iter()
6463        .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
6464        .collect();
6465    let proven = proven_index(beef);
6466    let mut cover = HashSet::new();
6467    let mut queue = vec![from.to_string()];
6468    while let Some(txid) = queue.pop() {
6469        if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
6470            continue;
6471        }
6472        if let Some(tx) = bodies.get(&txid) {
6473            queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
6474        }
6475    }
6476    cover
6477}
6478
6479/// The engine's [`crate::gasp::FinalizedGraphHook`] (bsv-low #552): submit
6480/// what a peer's sync just finalized, before its next UTXO is asked for.
6481struct SubmitAsFinalized<'e> {
6482    engine: &'e Engine,
6483    sink: crate::gasp_overlay::FinalizedGraphSink,
6484    peer_url: &'e str,
6485    topic: &'e str,
6486    submitted: &'e std::cell::Cell<u64>,
6487}
6488
6489#[async_trait::async_trait(?Send)]
6490impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
6491    async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
6492        // A graph is counted as its first submit starts, and the deadline
6493        // cannot land inside a submit: a sync dropped in here has written at
6494        // least that transaction whole (an ancestors-first prefix).
6495        let landed = self
6496            .engine
6497            .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
6498            .await;
6499        if landed {
6500            Ok(())
6501        } else {
6502            // Fails this UTXO: the cursor stays below it and the next sync
6503            // asks for it again (the lens fold's MEDIUM-1).
6504            Err(crate::gasp::GASPError::StorageError(
6505                "a finalize submit did not land".to_string(),
6506            ))
6507        }
6508    }
6509}
6510
6511/// Result of a GASP sync operation.
6512///
6513/// Returned by `Engine::start_gasp_sync()` to summarize what happened.
6514#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6515pub struct GASPSyncResult {
6516    #[serde(default, flatten)]
6517    pub cost: crate::meter::SyncCost,
6518    #[serde(default)]
6519    pub filter: crate::sync_load::SyncFilter,
6520    #[serde(default)]
6521    pub topics_filtered: Vec<String>,
6522    /// Per-topic sync results.
6523    pub topics_synced: HashMap<String, TopicSyncResult>,
6524}
6525
6526/// Sync result for a single topic.
6527#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6528pub struct TopicSyncResult {
6529    #[serde(default)]
6530    pub peer_results: HashMap<String, crate::backfill::PeerSyncResult>,
6531    #[serde(default)]
6532    pub dead_graphs: u64,
6533    #[serde(default)]
6534    pub expired_swept: u64,
6535    #[serde(default)]
6536    pub memo_folded: u64,
6537    #[serde(default, flatten)]
6538    pub cost: crate::meter::SyncCost,
6539    #[serde(default)]
6540    pub root_bounds: Vec<crate::sync_load::RootBound>,
6541    #[serde(default)]
6542    pub peers_skipped: Vec<crate::sync_load::SkippedPeer>,
6543    #[serde(default)]
6544    pub peers_filtered: Vec<String>,
6545    /// Root proof status after successful anchor validation and finalization.
6546    #[serde(default)]
6547    pub roots_proven: u64,
6548    #[serde(default)]
6549    pub roots_unproven: u64,
6550    /// Outstanding outpoints on peers whose cursor did not advance.
6551    #[serde(default)]
6552    pub cursor_held_by: Vec<String>,
6553    /// Distinct expired advert roots skipped before any proof request.
6554    #[serde(default)]
6555    pub expired_refused: u64,
6556    /// Peer URLs discovered or configured for this topic.
6557    pub peers: Vec<String>,
6558    /// How peers were determined: "ship" or "peers".
6559    pub sync_type: String,
6560    /// Any errors encountered during sync (peer URL -> error message): a
6561    /// peer whose sync failed or ran over its budget. A single UTXO whose
6562    /// graph ingest failed is NOT listed here: it is warned (`Error ingesting
6563    /// UTXO`), held back by the cursor gap guard and asked for again by the
6564    /// next sync.
6565    pub errors: Vec<String>,
6566    /// Manager-named inputs pruned because a peer answered that it does not
6567    /// hold them (the D8 decoy rule, `GASPSync::pruned_inputs`). Counted per
6568    /// distinct outpoint PER PEER per sync and summed over this topic's
6569    /// peers: one decoy seen through two peers reads 2. Not errors: the
6570    /// graphs completed without those branches.
6571    #[serde(default)]
6572    pub pruned_inputs: u64,
6573    /// Graphs the anchor check REFUSED and discarded whole (bsv-low #551,
6574    /// `GASPSync::discarded_graphs`): a root whose BEEF does not verify, or
6575    /// one the topic manager does not admit at the end of the replay. Summed
6576    /// over this topic's peers. Not errors, and nothing of them was admitted.
6577    #[serde(default)]
6578    pub discarded_graphs: u64,
6579    /// Graphs HELD because their anchor's script walk could not run within
6580    /// the engine's walk budget (bsv-low #592; the E592 lens fold, H1 and
6581    /// L3; `GASPSync::anchor_walk_held`), summed over this topic's peers.
6582    /// Nothing of them was finalized; each UTXO stays below its cursor until
6583    /// the peer serves its root proven. Not errors and not discarded.
6584    #[serde(default)]
6585    pub anchor_walk_held_graphs: u64,
6586    /// Graphs that passed the anchor check and were handed to submit, summed
6587    /// over this topic's peers (bsv-low #552). Under a per-peer budget a
6588    /// graph is counted, and submitted, the moment it finalizes, so the
6589    /// count includes those of a peer the deadline then dropped. A counted
6590    /// graph had its first transaction submitted whole (the deadline never
6591    /// lands inside a submit); one whose later submit did not land is still
6592    /// counted, and its UTXO stays below the cursor.
6593    /// A graph is counted as its first submit STARTS; a first submit that FAULTS
6594    /// still counts here, so this is not a delivered count (bsv-low #559).
6595    #[serde(default)]
6596    pub finalized_graphs: u64,
6597    /// Graphs that were mid-walk when a peer's sync was dropped at its
6598    /// budget (bsv-low #552): at most one per peer per sync. NOTHING of such
6599    /// a graph was admitted; the next sync walks it again. The same root
6600    /// counted tick after tick with no `finalized_graphs` and no
6601    /// `cursor_moves` is ONE graph whose walk outlasts the budget (with a
6602    /// per-graph budget it is also counted in `deferred_graphs` and resumed,
6603    /// bsv-low #555).
6604    #[serde(default)]
6605    pub deadline_dropped_graphs: u64,
6606    /// Every persisted cursor that moved in this sync (bsv-low #552), one
6607    /// entry per peer. After a completed sync `to` is the peer's highest
6608    /// score seen (less the gap guard); after a deadline it is
6609    /// `GASPSync::completed_cursor`: past completed UTXOs only.
6610    #[serde(default)]
6611    pub cursor_moves: Vec<CursorMove>,
6612    /// Graphs DEFERRED in this sync, summed over peers (bsv-low #555,
6613    /// `Engine::set_graph_budget`): a new record, or a resumed graph
6614    /// deferred again. Their UTXOs are held below the cursor.
6615    #[serde(default)]
6616    pub deferred_graphs: u64,
6617    /// Graphs RESUMED from a record in this sync.
6618    #[serde(default)]
6619    pub resumed_graphs: u64,
6620    /// Resumed graphs that completed and landed in this sync (their record
6621    /// deleted).
6622    #[serde(default)]
6623    pub converged_graphs: u64,
6624    /// Records deleted without converging, with their reason.
6625    #[serde(default)]
6626    pub dropped_graphs: Vec<DroppedDeferral>,
6627    /// Walks the per-graph budget (or the per-peer deadline) cut having
6628    /// fetched NOTHING in their pass (bsv-low #555, the lens fold's H1). A
6629    /// peer's sync whose only outcome was these is a failed attempt for the
6630    /// quarantine count.
6631    #[serde(default)]
6632    pub stalled_graphs: u64,
6633    /// Records NOT resumed in this sync: the resumes of a peer's pass share
6634    /// ONE per-graph budget and had spent it (the lens fold's M1). Held for
6635    /// the next sync, untouched.
6636    #[serde(default)]
6637    pub held_back_graphs: u64,
6638    /// Held records this sync sent to the long backoff (engine 0.1.10,
6639    /// `gasp::IDLE_RESUMES_BEFORE_BACKOFF`): resumed that many passes in a
6640    /// row with no node appended, not resumed again for
6641    /// `gasp::IDLE_RESUME_RETRY_SECS`.
6642    #[serde(default)]
6643    pub backed_off_graphs: u64,
6644    /// Held records this sync did not resume because they wait out that
6645    /// backoff: no request, the cursor passes them (engine 0.1.10).
6646    #[serde(default)]
6647    pub waiting_graphs: u64,
6648    /// Held records whose resumed pass this sync counted one idle resume
6649    /// on (engine 0.1.11, `gasp::DeferralStats::idle_counted`): the pass
6650    /// asked the peer and appended nothing it kept, whether it ended in a
6651    /// save, a drop or an error. A captain reads the idle rule firing here.
6652    #[serde(default)]
6653    pub idle_counted: u64,
6654    /// Roots recorded UNANCHORABLE in this sync (engine 0.1.9,
6655    /// `GASPSync::unanchorable_recorded`): their walk faulted in
6656    /// `gasp::UNANCHORABLE_AFTER_FAULTED_RUNS` consecutive runs; the cursor
6657    /// passes them, and each is walked again after
6658    /// `gasp::UNANCHORABLE_RETRY_SECS`. Nothing of them is admitted.
6659    #[serde(default)]
6660    pub unanchorable_graphs: u64,
6661    /// Roots struck `unanchorable:not_found` in this sync (engine 0.1.15,
6662    /// `GASPSync::absent_struck`): the advertising peer said it does not
6663    /// hold the root, or a transaction an unproven node of its graph needs,
6664    /// on `gasp::UNANCHORABLE_AFTER_FAULTED_RUNS` runs at least
6665    /// `gasp::ABSENCE_GAP_SECS` apart. Counted in `unanchorable_graphs` too.
6666    #[serde(default)]
6667    pub absent_struck: u64,
6668    /// Syncs a ROUTE FAULT ended (engine 0.2.0, bsv-overlay-cloudflare #57;
6669    /// [`crate::route_fault`]): the platform answered before the peer was
6670    /// reached. Each names its peer and class; none counted against the
6671    /// peer's health unless the peer had served work before it.
6672    #[serde(default)]
6673    pub route_faults: Vec<crate::route_fault::RouteFaultSeen>,
6674    /// Roots with a fault record that this sync walked again and admitted,
6675    /// or refused on a verdict: their record cleared (engine 0.1.9).
6676    #[serde(default)]
6677    pub unanchorable_cleared: u64,
6678    /// Strikes this sync could not write (engine 0.1.10,
6679    /// `GASPSync::strikes_refused`):
6680    /// at the storage's ceiling or on its fault. Each root is named in
6681    /// `faulted_roots` with its `refused` reason.
6682    #[serde(default)]
6683    pub strikes_refused: u64,
6684    /// Every root a peer's sync dropped in this run, once per (peer, root),
6685    /// named with its fault class, its faulted runs and whether it is now
6686    /// unanchorable (engine 0.1.9, `GASPSync::faulted_roots`). With
6687    /// `cursor_held_by` (what is neither finished nor held) the captain
6688    /// reads why the cursor stopped or moved.
6689    #[serde(default)]
6690    pub faulted_roots: Vec<crate::gasp::FaultedRoot>,
6691}
6692
6693/// A deferred graph's record deleted without converging (bsv-low #555,
6694/// [`TopicSyncResult::dropped_graphs`]).
6695#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6696pub struct DroppedDeferral {
6697    /// The peer URL.
6698    pub peer: String,
6699    /// The graph's root outpoint.
6700    pub outpoint: String,
6701    /// [`crate::gasp::DropReason::as_str`].
6702    pub reason: String,
6703}
6704
6705/// One peer's persisted `last_interaction` cursor moving `from` -> `to` in a
6706/// sync ([`TopicSyncResult::cursor_moves`]).
6707#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6708pub struct CursorMove {
6709    /// The peer URL.
6710    pub peer: String,
6711    /// The cursor the sync entered with.
6712    pub from: u64,
6713    /// The cursor it persisted.
6714    pub to: u64,
6715}
6716
6717/// Get current time in milliseconds (for output scores).
6718///
6719/// Uses `js_sys::Date::now()` on wasm32 (Cloudflare Workers) since
6720/// `std::time::SystemTime` is not available on that platform.
6721pub(crate) fn current_timestamp_ms() -> f64 {
6722    #[cfg(target_arch = "wasm32")]
6723    {
6724        js_sys::Date::now()
6725    }
6726    #[cfg(not(target_arch = "wasm32"))]
6727    {
6728        std::time::SystemTime::now()
6729            .duration_since(std::time::UNIX_EPOCH)
6730            .map_or(0.0, |d| d.as_millis() as f64)
6731    }
6732}
6733
6734/// Extract the domain field from a SHIP PushDrop locking script.
6735///
6736/// SHIP PushDrop format: field[0] = "SHIP", field[1] = identity_key,
6737/// field[2] = domain (UTF-8), field[3] = topic.
6738/// Returns `None` if the script cannot be parsed or is not a SHIP advertisement.
6739fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
6740    parse_advertisement_domain_from_script(output_script, b"SHIP", None)
6741}
6742
6743fn parse_lookup_advertisement(
6744    output: &OutputListItem,
6745    protocol: &[u8],
6746    scope: &str,
6747) -> Option<String> {
6748    let tx = beef_limits::transaction_from_beef(&output.beef, None, &beef_limits::PEER_BEEF_LIMITS)
6749        .ok()?;
6750    let script = tx
6751        .outputs
6752        .get(output.output_index as usize)?
6753        .locking_script
6754        .to_binary();
6755    parse_advertisement_domain_from_script(&script, protocol, Some(scope))
6756}
6757
6758fn parse_advertisement_domain_from_script(
6759    output_script: &[u8],
6760    protocol: &[u8],
6761    scope: Option<&str>,
6762) -> Option<String> {
6763    use bsv_rs::script::templates::PushDrop;
6764
6765    let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
6766    let pushdrop = PushDrop::decode(&script.into()).ok()?;
6767
6768    if pushdrop.fields.len() < 3 {
6769        return None;
6770    }
6771
6772    if pushdrop.fields[0] != protocol {
6773        return None;
6774    }
6775
6776    if scope.is_some_and(|expected| {
6777        pushdrop
6778            .fields
6779            .get(3)
6780            .is_none_or(|actual| actual != expected.as_bytes())
6781    }) {
6782        return None;
6783    }
6784
6785    let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
6786    if domain.is_empty() {
6787        return None;
6788    }
6789
6790    Some(domain)
6791}
6792
6793// ============================================================================
6794// Submit-time spend verification helpers (reference parity, 2026-09-08)
6795// ============================================================================
6796
6797// ============================================================================
6798// Error type
6799// ============================================================================
6800
6801#[derive(Debug, thiserror::Error)]
6802pub enum EngineError {
6803    #[error("This server does not support this topic: {0}")]
6804    UnsupportedTopic(String),
6805
6806    #[error("Lookup service not found for provider: {0}")]
6807    LookupServiceNotFound(String),
6808
6809    /// GASP: `/requestForeignGASPNode` asked for a graph/txid we don't
6810    /// have locally. Matches mainline's 400-class "not found" response.
6811    #[error("No matching output found!")]
6812    NodeNotFound,
6813
6814    #[error("lookup failed: {0}")]
6815    LookupFailed(String),
6816
6817    /// The CALLER's query was malformed — a bad identity key, a gameId that is
6818    /// not 32 bytes of hex, a missing required field. Distinct from
6819    /// `LookupFailed`, which means WE failed: without the distinction every
6820    /// caller mistake was reported as a server fault (HTTP 500), so a malformed
6821    /// query and a genuine outage were indistinguishable in logs and alerts on
6822    /// an unauthenticated endpoint anyone can hit.
6823    #[error("invalid query: {0}")]
6824    InvalidQuery(String),
6825
6826    #[error("storage error: {0}")]
6827    StorageError(String),
6828
6829    #[error("broadcast error: {0}")]
6830    BroadcastError(String),
6831
6832    #[error("SPV verification failed: {0}")]
6833    SpvError(String),
6834
6835    #[error("BEEF parsing failed: {0}")]
6836    BeefParseError(String),
6837
6838    /// A spend in the submitted BEEF does not satisfy its source output's
6839    /// locking script: the input's unlocking script was EXECUTED (reference
6840    /// parity, 2026-09-08) and the interpreter refused it. Distinct from
6841    /// [`EngineError::SpvError`] (a bad or unverifiable PROOF, a missing
6842    /// source, a chain-tracker fault) so an operator can tell a bad spend from
6843    /// a bad proof. `input_index` is the failing input's position in the
6844    /// transaction whose script failed (the subject, or an unproven
6845    /// ancestor of it in the same BEEF); `reason` is the interpreter's own
6846    /// message.
6847    #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
6848    ScriptVerificationFailed {
6849        subject_txid: String,
6850        input_index: u32,
6851        reason: String,
6852    },
6853
6854    /// The DOOR walk ([`Engine::verify_scripts_only`]) hit a STRUCTURAL fault
6855    /// before reaching a verdict on `at_txid` (a source the BEEF lacks, a
6856    /// parse fault, the value rule): not the interpreter's verdict, so not a
6857    /// refusal for a caller whose bar is the network. `subject_judged` says
6858    /// whether the subject's own inputs had all executed before the fault
6859    /// (an ancestor faulted) or not (the subject itself is unjudged).
6860    #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
6861    ScriptWalkInconclusive {
6862        at_txid: String,
6863        subject_judged: bool,
6864        reason: String,
6865    },
6866
6867    /// The DOOR walk exceeded its own bound ([`DoorBudget`]: the static work
6868    /// estimate, the interpreter's memory limit tripping, or the estimate of
6869    /// the door's own memory; `limb` says which): the door's verdict, never
6870    /// the network's — the request proceeds to the network.
6871    #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
6872    ScriptWalkOverBudget {
6873        at_txid: String,
6874        subject_judged: bool,
6875        limb: DoorLimb,
6876        what: String,
6877    },
6878
6879    /// The submit's own script walk COULD NOT RUN within the engine's
6880    /// [`DoorBudget`] (bsv-low #592) and nothing on record stands in for it
6881    /// ([`WalkBreachPolicy::NotNow`], every door but the queue replay of a
6882    /// gated submission; the E592 lens fold, H1). NOT NOW: nothing was
6883    /// judged, nothing written, and it is NOT a refusal either: the budget
6884    /// says nothing about the transaction. The way out is the subject
6885    /// PROVEN: a proven transaction is not walked, so an honest body too
6886    /// big to walk unmined lands once its block is mined (a client retries
6887    /// after the block, not at once).
6888    #[error(
6889        "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
6890        .0.limb.as_str(),
6891        .0.what
6892    )]
6893    WalkCouldNotRun(WalkCouldNotRun),
6894
6895    #[error("{0}")]
6896    Other(String),
6897}
6898
6899impl From<StorageError> for EngineError {
6900    fn from(e: StorageError) -> Self {
6901        EngineError::StorageError(e.to_string())
6902    }
6903}
6904
6905impl From<TopicManagerError> for EngineError {
6906    fn from(e: TopicManagerError) -> Self {
6907        EngineError::Other(e.to_string())
6908    }
6909}
6910
6911impl From<LookupServiceError> for EngineError {
6912    fn from(e: LookupServiceError) -> Self {
6913        EngineError::LookupFailed(e.to_string())
6914    }
6915}
6916
6917impl From<AdvertiserError> for EngineError {
6918    fn from(e: AdvertiserError) -> Self {
6919        EngineError::Other(e.to_string())
6920    }
6921}
6922
6923// ============================================================================
6924// Tests
6925// ============================================================================
6926
6927/// Baseline sync order: configured peer topics before SHIP discovery topics,
6928/// each by name (bsv-low #555, D-M1). `start_gasp_sync` advances SHIP topics
6929/// with explicit bootstrap peers ahead of this order so their sync can run
6930/// without importing tracker records. Deferred-graph reservations still use
6931/// each topic's configured target.
6932pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
6933    let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
6934    order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
6935    order
6936}
6937
6938#[cfg(test)]
6939mod tests {
6940    use super::*;
6941    use crate::lookup_service::LookupService as LookupServiceTrait;
6942    use crate::storage::memory::MemoryStorage;
6943    use crate::topic_manager::TopicManager as TopicManagerTrait;
6944    use async_trait::async_trait;
6945    use std::sync::Mutex;
6946
6947    // bsv-low #555, the delta fold's D-M1: the configured peers' topics are
6948    // synced before the ls_ship-discovered ones, each class by name; the map's
6949    // order was arbitrary.
6950    #[test]
6951    fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6952        let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6953        let config: SyncConfiguration = HashMap::from([
6954            ("tm_a".to_string(), SyncTarget::Ship),
6955            ("tm_z".to_string(), peers("https://z")),
6956            ("tm_b".to_string(), SyncTarget::Ship),
6957            ("tm_m".to_string(), peers("https://m")),
6958            ("tm_off".to_string(), SyncTarget::Disabled),
6959        ]);
6960        let order: Vec<&str> = sync_order(&config)
6961            .iter()
6962            .map(|(t, _)| t.as_str())
6963            .collect();
6964        assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6965    }
6966
6967    // ── Mock TopicManager ──────────────────────────────────────────────
6968
6969    struct MockTopicManager {
6970        admit_indices: Vec<u32>,
6971    }
6972
6973    impl MockTopicManager {
6974        fn admitting(indices: Vec<u32>) -> Self {
6975            Self {
6976                admit_indices: indices,
6977            }
6978        }
6979    }
6980
6981    #[async_trait(?Send)]
6982    impl TopicManagerTrait for MockTopicManager {
6983        async fn identify_admissible_outputs(
6984            &self,
6985            _tx: &Transaction,
6986            _previous_coins: &[u8],
6987            _off_chain_values: Option<&[u8]>,
6988            _mode: SubmitMode,
6989            _context: &TopicAdmittanceContext,
6990        ) -> Result<AdmittanceInstructions, TopicManagerError> {
6991            Ok(AdmittanceInstructions {
6992                outputs_to_admit: self.admit_indices.clone(),
6993                coins_to_retain: vec![],
6994                coins_removed: None,
6995            })
6996        }
6997
6998        async fn get_documentation(&self) -> String {
6999            "Mock topic manager".to_string()
7000        }
7001
7002        async fn get_metadata(&self) -> ServiceMetadata {
7003            ServiceMetadata {
7004                name: "mock-tm".to_string(),
7005                description: Some("Mock for testing".to_string()),
7006                ..Default::default()
7007            }
7008        }
7009    }
7010
7011    // ── Mock LookupService ─────────────────────────────────────────────
7012
7013    struct MockLookupService {
7014        records: Mutex<Vec<UTXOReference>>,
7015    }
7016
7017    impl MockLookupService {
7018        fn new() -> Self {
7019            Self {
7020                records: Mutex::new(Vec::new()),
7021            }
7022        }
7023    }
7024
7025    #[async_trait(?Send)]
7026    impl LookupServiceTrait for MockLookupService {
7027        fn admission_mode(&self) -> AdmissionMode {
7028            AdmissionMode::LockingScript
7029        }
7030
7031        fn spend_notification_mode(&self) -> SpendNotificationMode {
7032            SpendNotificationMode::None
7033        }
7034
7035        async fn output_admitted_by_topic(
7036            &self,
7037            payload: &OutputAdmittedByTopic,
7038        ) -> Result<(), LookupServiceError> {
7039            let (txid, oi) = match payload {
7040                OutputAdmittedByTopic::LockingScript {
7041                    txid, output_index, ..
7042                } => (txid.clone(), *output_index),
7043                OutputAdmittedByTopic::WholeTx { output_index, .. } => {
7044                    ("whole".into(), *output_index)
7045                }
7046            };
7047            self.records.lock().unwrap().push(UTXOReference {
7048                txid,
7049                output_index: oi,
7050            });
7051            Ok(())
7052        }
7053
7054        async fn output_evicted(
7055            &self,
7056            txid: &str,
7057            output_index: u32,
7058        ) -> Result<(), LookupServiceError> {
7059            self.records
7060                .lock()
7061                .unwrap()
7062                .retain(|r| !(r.txid == txid && r.output_index == output_index));
7063            Ok(())
7064        }
7065
7066        async fn lookup(
7067            &self,
7068            _question: &LookupQuestion,
7069        ) -> Result<LookupResult, LookupServiceError> {
7070            Ok(LookupResult::OutputList(
7071                self.records.lock().unwrap().clone(),
7072            ))
7073        }
7074
7075        async fn get_documentation(&self) -> String {
7076            "Mock lookup service".to_string()
7077        }
7078
7079        async fn get_metadata(&self) -> ServiceMetadata {
7080            ServiceMetadata {
7081                name: "mock-ls".to_string(),
7082                ..Default::default()
7083            }
7084        }
7085    }
7086
7087    // ── Test BEEF data ──────────────────────────────────────────────────
7088
7089    /// Real BRC-62 BEEF from TS overlay-services test suite.
7090    const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
7091    const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
7092
7093    fn decode_hex(hex: &str) -> Vec<u8> {
7094        (0..hex.len())
7095            .step_by(2)
7096            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
7097            .collect()
7098    }
7099
7100    fn test_beef() -> Vec<u8> {
7101        decode_hex(TEST_BEEF_HEX)
7102    }
7103
7104    fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
7105        TaggedBEEF::new(
7106            test_beef(),
7107            topics.into_iter().map(str::to_string).collect(),
7108        )
7109    }
7110
7111    // ── Helpers ────────────────────────────────────────────────────────
7112
7113    fn make_engine(admit_indices: Vec<u32>) -> Engine {
7114        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7115        managers.insert(
7116            "tm_test".to_string(),
7117            Box::new(MockTopicManager::admitting(admit_indices)),
7118        );
7119
7120        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7121        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7122
7123        let storage = Box::new(MemoryStorage::new());
7124
7125        Engine::new(
7126            managers,
7127            lookup_services,
7128            storage,
7129            None,
7130            EngineConfig::default(),
7131        )
7132    }
7133
7134    // ── Tests ──────────────────────────────────────────────────────────
7135
7136    /// bsv-low#289 parse-once + zanaadu#284 subject discipline: the tx handed
7137    /// to every topic manager must be the ATOMIC SUBJECT of the submitted
7138    /// BEEF, not whatever sits last in wire order. bsv-rs < 0.3.20's
7139    /// `from_beef(_, None)` picked `txs.last()`; wallet serializers do not all
7140    /// place the subject last, so a TM could be handed a PARENT to validate.
7141    /// This pins the engine on the fixed rule (`txid ?? atomicTxid ?? last`).
7142    #[tokio::test]
7143    async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
7144        use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
7145        use std::cell::RefCell;
7146        use std::rc::Rc;
7147
7148        // Rebuild the fixture chain SUBJECT-FIRST via to_writer (to_binary
7149        // re-sorts parents-first and would hide the trigger).
7150        let parsed = Beef::from_binary(&test_beef()).unwrap();
7151        let parent = parsed.txs[0].tx().unwrap().clone();
7152        let child = parsed.txs[1].tx().unwrap().clone();
7153        let child_id = child.id();
7154        let parent_id = parent.id();
7155        let mut beef = Beef::new();
7156        // The parent's BUMP rides along: the reference-parity walk
7157        // (2026-09-08) executes the subject's script against the parent and
7158        // then trusts the parent on its proof; without the BUMP it would
7159        // demand the parent's own ancestry ("Input 0 has no source
7160        // transaction"), which this fixture does not carry.
7161        for bump in &parsed.bumps {
7162            beef.merge_bump(bump.clone());
7163        }
7164        beef.merge_transaction(child);
7165        beef.merge_transaction(parent);
7166        let mut w = bsv_rs::primitives::encoding::Writer::new();
7167        w.write_u32_le(ATOMIC_BEEF);
7168        let mut le = hex::decode(&child_id).unwrap();
7169        le.reverse();
7170        w.write_bytes(&le);
7171        beef.to_writer(&mut w);
7172        let atomic_wire_order = w.into_bytes();
7173
7174        // A TM that records the txid it is handed.
7175        struct RecordingTm(Rc<RefCell<Option<String>>>);
7176        #[async_trait(?Send)]
7177        impl TopicManagerTrait for RecordingTm {
7178            async fn identify_admissible_outputs(
7179                &self,
7180                tx: &Transaction,
7181                _previous_coins: &[u8],
7182                _off_chain_values: Option<&[u8]>,
7183                _mode: SubmitMode,
7184                _context: &TopicAdmittanceContext,
7185            ) -> Result<AdmittanceInstructions, TopicManagerError> {
7186                *self.0.borrow_mut() = Some(tx.id());
7187                Ok(AdmittanceInstructions {
7188                    outputs_to_admit: vec![0],
7189                    coins_to_retain: vec![],
7190                    coins_removed: None,
7191                })
7192            }
7193            async fn get_documentation(&self) -> String {
7194                String::new()
7195            }
7196            async fn get_metadata(&self) -> ServiceMetadata {
7197                ServiceMetadata {
7198                    name: "tm_test".to_string(),
7199                    ..Default::default()
7200                }
7201            }
7202        }
7203
7204        let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
7205        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7206        managers.insert(
7207            "tm_test".to_string(),
7208            Box::new(RecordingTm(Rc::clone(&seen))),
7209        );
7210        let engine = Engine::new(
7211            managers,
7212            HashMap::new(),
7213            Box::new(MemoryStorage::new()),
7214            None,
7215            EngineConfig::default(),
7216        );
7217
7218        let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
7219        let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
7220
7221        let handed = seen.borrow().clone().expect("TM was not invoked");
7222        assert_eq!(
7223            handed, child_id,
7224            "TM must be handed the atomic subject, got the wire-order pick"
7225        );
7226        assert_ne!(handed, parent_id);
7227    }
7228
7229    /// #4 REGRESSION: history hydration must never DOWNGRADE provenance.
7230    ///
7231    /// `get_utxo_history` used to `Transaction::from_beef(beef, None)` and then
7232    /// `tx.to_beef(true)`, which keeps only the subject and DROPS the stored
7233    /// BUMPs. Measured on prod before the fix: `ls_low` byGameId `73de6e48…`
7234    /// returned 1,172 B / 1 tx / 1 bump (PROVEN) with no header, and 676 B /
7235    /// 1 tx / 0 bumps with `x-history-depth: 3`. A caller asking for MORE
7236    /// provenance got a BEEF no wallet can verify — silently, behind a 200.
7237    ///
7238    /// There was NO test on this function at all, which is exactly how that
7239    /// shipped. This one asserts the invariant that matters: whatever else
7240    /// hydration does, a proof that went in comes back out.
7241    #[tokio::test]
7242    async fn history_hydration_preserves_the_stored_proof() {
7243        use bsv_rs::transaction::Beef;
7244
7245        let engine = make_engine(vec![0]);
7246        let stored = test_beef();
7247        let bumps_before = Beef::from_binary(&stored)
7248            .expect("fixture BEEF parses")
7249            .bumps
7250            .len();
7251        assert!(
7252            bumps_before > 0,
7253            "fixture must actually carry a proof, or this cell proves nothing"
7254        );
7255
7256        let output = Output {
7257            txid: TEST_TXID.to_string(),
7258            output_index: 0,
7259            output_script: vec![0x76, 0xa9],
7260            satoshis: 1000,
7261            topic: "tm_test".to_string(),
7262            spent: false,
7263            // No admitted parents: hydration has nothing to add, so anything
7264            // it REMOVES is pure loss — the cleanest form of the bug.
7265            outputs_consumed: vec![],
7266            consumed_by: vec![],
7267            beef: Some(stored.clone()),
7268            block_height: None,
7269            score: Some(1000.0),
7270        };
7271
7272        let hydrated = engine
7273            .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
7274            .await
7275            .expect("hydration must not error")
7276            .expect("hydration must return the output");
7277        let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
7278            .expect("hydrated BEEF parses");
7279
7280        assert_eq!(
7281            beef.bumps.len(),
7282            bumps_before,
7283            "asking for history must not strip the merkle proof (issue #4)"
7284        );
7285    }
7286
7287    #[tokio::test]
7288    async fn test_submit_unsupported_topic_errors() {
7289        let engine = make_engine(vec![0]);
7290        let beef = test_tagged_beef(vec!["tm_nonexistent"]);
7291
7292        let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
7293        assert!(result.is_err());
7294        assert!(matches!(
7295            result.unwrap_err(),
7296            EngineError::UnsupportedTopic(_)
7297        ));
7298    }
7299
7300    #[tokio::test]
7301    async fn test_submit_admits_outputs() {
7302        let engine = make_engine(vec![0, 1]);
7303        let beef = test_tagged_beef(vec!["tm_test"]);
7304
7305        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7306
7307        // STEAK should have tm_test with outputs 0 and 1 admitted
7308        let instructions = steak.get("tm_test").unwrap();
7309        assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
7310
7311        // Outputs should be in storage with real data
7312        let txid = TEST_TXID;
7313        let found = engine
7314            .storage()
7315            .find_output(txid, 0, Some("tm_test"), None, true)
7316            .await
7317            .unwrap()
7318            .expect("Output 0 should be in storage");
7319
7320        assert_eq!(found.txid, TEST_TXID);
7321        assert_eq!(found.satoshis, 26172); // Real value from BRC62 BEEF
7322        assert!(
7323            !found.output_script.is_empty(),
7324            "Script should be populated"
7325        );
7326        assert!(found.beef.is_some(), "BEEF should be stored");
7327        assert!(!found.spent);
7328    }
7329
7330    #[tokio::test]
7331    async fn test_submit_duplicate_is_skipped() {
7332        let engine = make_engine(vec![0]);
7333        let beef = test_tagged_beef(vec!["tm_test"]);
7334
7335        // First submit
7336        let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7337        assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
7338
7339        // Second submit — should be a dupe, no new outputs admitted
7340        let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7341        assert!(steak2["tm_test"].outputs_to_admit.is_empty());
7342    }
7343
7344    #[tokio::test]
7345    async fn test_submit_notifies_lookup_service() {
7346        let engine = make_engine(vec![0]);
7347        let beef = test_tagged_beef(vec!["tm_test"]);
7348
7349        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7350
7351        // Lookup service should have the output
7352        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7353        let answer = engine.lookup(&question, None).await.unwrap();
7354
7355        match answer {
7356            LookupAnswer::OutputList { outputs } => {
7357                assert_eq!(outputs.len(), 1);
7358            }
7359            _ => panic!("Expected OutputList"),
7360        }
7361    }
7362
7363    /// #289: `lookup_with_txids` returns the storage txid per hydrated
7364    /// output, aligned with the OutputList — the aggregated serializer
7365    /// writes it instead of re-hashing the BEEF.
7366    #[tokio::test]
7367    async fn test_lookup_with_txids_aligns_txids_with_outputs() {
7368        let engine = make_engine(vec![0]);
7369        let beef = test_tagged_beef(vec!["tm_test"]);
7370        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7371
7372        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7373        let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7374        match answer {
7375            LookupAnswer::OutputList { outputs } => {
7376                assert_eq!(outputs.len(), 1);
7377                assert_eq!(txids.len(), outputs.len(), "one txid per output");
7378                assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
7379            }
7380            _ => panic!("Expected OutputList"),
7381        }
7382    }
7383
7384    #[tokio::test]
7385    async fn proof_window_holds_an_aged_child_with_a_proven_parent() {
7386        let mut engine = make_engine(vec![0]);
7387        let storage = std::rc::Rc::new(MemoryStorage::new());
7388        engine.storage = Box::new(storage.clone());
7389        engine
7390            .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7391            .await
7392            .unwrap();
7393        assert!(beef_limits::own_proof(&test_beef(), TEST_TXID)
7394            .unwrap()
7395            .is_none());
7396        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7397        storage.advance_clock((crate::proof_window::PROOFLESS_FLAG_MS / 1000) as u64);
7398        assert_eq!(
7399            engine
7400                .lookup_with_txids(&question, None)
7401                .await
7402                .unwrap()
7403                .1
7404                .len(),
7405            1
7406        );
7407        storage.advance_clock(1);
7408        assert!(
7409            engine
7410                .lookup_with_txids(&question, None)
7411                .await
7412                .unwrap()
7413                .1
7414                .is_empty(),
7415            "an aged unproven child must be held even when its parent is proven"
7416        );
7417    }
7418
7419    #[tokio::test]
7420    async fn proof_window_peer_proof_verifies_then_restores_lookup() {
7421        use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7422        let mut engine = make_engine(vec![0]);
7423        let storage = std::rc::Rc::new(MemoryStorage::new());
7424        engine.storage = Box::new(storage.clone());
7425        engine
7426            .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7427            .await
7428            .unwrap();
7429        storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7430        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7431        let candidate = HeldOutput {
7432            txid: TEST_TXID.into(),
7433            output_index: 0,
7434            topic: "tm_test".into(),
7435        };
7436        let (_, mut remote) = budget_page_engine(1);
7437        remote.node_cost_ms = 0;
7438        remote.nodes = std::rc::Rc::new(HashMap::from([(
7439            TEST_TXID.into(),
7440            crate::types::GASPNode {
7441                graph_id: String::new(),
7442                raw_tx: String::new(),
7443                output_index: 0,
7444                proof: Some(single_leaf_bump_hex(TEST_TXID, 800_000)),
7445                tx_metadata: None,
7446                output_metadata: None,
7447                inputs: None,
7448            },
7449        )]));
7450        engine
7451            .config
7452            .sync_configuration
7453            .insert("tm_test".into(), SyncTarget::Ship);
7454        engine.ship_bootstrap_peers.insert(
7455            "tm_test".into(),
7456            vec!["https://a.example".into(), "https://b.example".into()],
7457        );
7458        engine.set_gasp_remote_factory(Box::new(remote));
7459        let progress = std::cell::Cell::new(PeerProofSummary::default());
7460        engine.chain_tracker = Some(Box::new(bsv_rs::transaction::MockChainTracker::new(
7461            800_001,
7462        )));
7463        engine
7464            .rerequest_held_proofs(std::slice::from_ref(&candidate), &progress)
7465            .await;
7466        assert_eq!(progress.get().requests, 2, "ask every topic peer");
7467        assert_eq!(progress.get().landed, 0);
7468        assert_eq!(progress.get().refused, 2);
7469        assert!(engine
7470            .lookup_with_txids(&question, None)
7471            .await
7472            .unwrap()
7473            .1
7474            .is_empty());
7475        let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7476        tracker.add_root(800_000, TEST_TXID.into());
7477        engine.chain_tracker = Some(Box::new(tracker));
7478        engine.rerequest_held_proofs(&[candidate], &progress).await;
7479        assert_eq!(progress.get().requests, 4);
7480        assert_eq!(
7481            progress.get().landed,
7482            1,
7483            "count durable proofs, not peer answers"
7484        );
7485        let (_, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7486        assert_eq!(txids, vec![TEST_TXID]);
7487        let stored = storage
7488            .find_output(TEST_TXID, 0, None, None, true)
7489            .await
7490            .unwrap()
7491            .unwrap();
7492        assert!(
7493            beef_limits::own_proof(stored.beef.as_ref().unwrap(), TEST_TXID)
7494                .unwrap()
7495                .is_some()
7496        );
7497        assert_eq!(storage.proven_at_height(TEST_TXID), Some(Some(800_000)));
7498    }
7499
7500    #[tokio::test]
7501    async fn proof_window_peer_hash_leaf_is_stored_as_own_proof() {
7502        use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7503        let mut engine = make_engine(vec![0]);
7504        let storage = std::rc::Rc::new(MemoryStorage::new());
7505        engine.storage = Box::new(storage.clone());
7506        engine
7507            .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7508            .await
7509            .unwrap();
7510        storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7511        let mut proof =
7512            bsv_rs::transaction::MerklePath::from_hex(&single_leaf_bump_hex(TEST_TXID, 800_000))
7513                .unwrap();
7514        proof.path[0][0].txid = false;
7515        let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7516        tracker.add_root(800_000, proof.compute_root(Some(TEST_TXID)).unwrap());
7517        engine.chain_tracker = Some(Box::new(tracker));
7518        let (_, mut remote) = budget_page_engine(1);
7519        remote.node_cost_ms = 0;
7520        remote.nodes = std::rc::Rc::new(HashMap::from([(
7521            TEST_TXID.into(),
7522            crate::types::GASPNode {
7523                graph_id: String::new(),
7524                raw_tx: String::new(),
7525                output_index: 0,
7526                proof: Some(proof.to_hex()),
7527                tx_metadata: None,
7528                output_metadata: None,
7529                inputs: None,
7530            },
7531        )]));
7532        engine.config.sync_configuration.insert(
7533            "tm_test".into(),
7534            SyncTarget::Peers(vec!["https://peer.example".into()]),
7535        );
7536        engine.set_gasp_remote_factory(Box::new(remote));
7537        let progress = std::cell::Cell::new(PeerProofSummary::default());
7538        engine
7539            .rerequest_held_proofs(
7540                &[HeldOutput {
7541                    txid: TEST_TXID.into(),
7542                    output_index: 0,
7543                    topic: "tm_test".into(),
7544                }],
7545                &progress,
7546            )
7547            .await;
7548        assert_eq!(progress.get().landed, 1);
7549        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7550        let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7551        assert_eq!(txids, vec![TEST_TXID]);
7552        let LookupAnswer::OutputList { outputs } = answer else {
7553            panic!("expected outputs")
7554        };
7555        assert!(
7556            beef_limits::own_proof(&outputs[0].beef, TEST_TXID)
7557                .unwrap()
7558                .is_some(),
7559            "a proven flag must have the child's own path in the served BEEF"
7560        );
7561    }
7562
7563    #[tokio::test]
7564    async fn proof_window_silent_peers_yield_at_the_request_budget() {
7565        use crate::proof_window::{HeldOutput, PeerProofSummary, PEER_PROOF_REQUEST_MS};
7566        let (mut engine, remote) = budget_page_engine(1);
7567        engine.config.sync_configuration.insert(
7568            "tm_test".into(),
7569            SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
7570        );
7571        let elapsed = remote.elapsed.clone();
7572        engine.set_peer_sync_budget(
7573            std::rc::Rc::new(move |ms| {
7574                assert_eq!(ms, PEER_PROOF_REQUEST_MS);
7575                let deadline = elapsed.get() + ms;
7576                let elapsed = elapsed.clone();
7577                Box::pin(std::future::poll_fn(move |_| {
7578                    if elapsed.get() >= deadline {
7579                        std::task::Poll::Ready(())
7580                    } else {
7581                        std::task::Poll::Pending
7582                    }
7583                }))
7584            }),
7585            30_000,
7586        );
7587        let candidate = HeldOutput {
7588            txid: remote.outputs[0].txid.clone(),
7589            output_index: 0,
7590            topic: "tm_test".into(),
7591        };
7592        engine.set_gasp_remote_factory(Box::new(remote));
7593        let progress = std::cell::Cell::new(PeerProofSummary::default());
7594        engine.rerequest_held_proofs(&[candidate], &progress).await;
7595        assert_eq!(progress.get().requests, 2);
7596        assert_eq!(progress.get().faults, 2);
7597        assert_eq!(progress.get().landed, 0);
7598    }
7599
7600    /// #291: the public sync-response page size is always bounded.
7601    #[test]
7602    fn sync_limit_clamp() {
7603        assert_eq!(
7604            Engine::clamp_sync_limit(None),
7605            Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
7606            "absent limit gets the default page, never unbounded"
7607        );
7608        assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
7609        assert_eq!(
7610            Engine::clamp_sync_limit(Some(u64::MAX)),
7611            Engine::SYNC_RESPONSE_MAX_LIMIT,
7612            "a huge caller-supplied limit is capped"
7613        );
7614    }
7615
7616    #[tokio::test]
7617    async fn test_lookup_unknown_service_errors() {
7618        let engine = make_engine(vec![]);
7619        let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
7620
7621        let result = engine.lookup(&question, None).await;
7622        assert!(result.is_err());
7623        assert!(matches!(
7624            result.unwrap_err(),
7625            EngineError::LookupServiceNotFound(_)
7626        ));
7627    }
7628
7629    #[tokio::test]
7630    async fn test_list_topic_managers() {
7631        let engine = make_engine(vec![]);
7632        let managers = engine.list_topic_managers().await;
7633
7634        assert_eq!(managers.len(), 1);
7635        assert!(managers.contains_key("tm_test"));
7636        assert_eq!(managers["tm_test"].name, "mock-tm");
7637    }
7638
7639    #[tokio::test]
7640    async fn test_list_lookup_service_providers() {
7641        let engine = make_engine(vec![]);
7642        let services = engine.list_lookup_service_providers().await;
7643
7644        assert_eq!(services.len(), 1);
7645        assert!(services.contains_key("ls_test"));
7646        assert_eq!(services["ls_test"].name, "mock-ls");
7647    }
7648
7649    #[tokio::test]
7650    async fn test_get_documentation() {
7651        let engine = make_engine(vec![]);
7652
7653        let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
7654        assert_eq!(tm_docs, "Mock topic manager");
7655
7656        let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
7657        assert_eq!(ls_docs, "Mock lookup service");
7658
7659        let missing = engine
7660            .get_documentation_for_topic_manager("tm_missing")
7661            .await;
7662        assert_eq!(missing, "No documentation found!");
7663    }
7664
7665    #[tokio::test]
7666    async fn test_provide_foreign_sync_response() {
7667        let engine = make_engine(vec![0]);
7668
7669        // Submit data
7670        let beef = test_tagged_beef(vec!["tm_test"]);
7671        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7672
7673        // Query GASP sync
7674        let request = GASPInitialRequest {
7675            version: 1,
7676            since: 0,
7677            limit: Some(100),
7678        };
7679        let response = engine
7680            .provide_foreign_sync_response(&request, "tm_test")
7681            .await
7682            .unwrap();
7683
7684        // One tx admitted with output index 0
7685        assert_eq!(response.utxo_list.len(), 1);
7686        assert_eq!(response.utxo_list[0].txid, TEST_TXID);
7687        assert_eq!(response.since, 0);
7688    }
7689
7690    #[tokio::test]
7691    async fn test_delete_utxo_deep_simple() {
7692        let engine = make_engine(vec![0]);
7693        let beef = test_tagged_beef(vec!["tm_test"]);
7694
7695        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7696
7697        let txid = TEST_TXID;
7698        let output = engine
7699            .storage()
7700            .find_output(txid, 0, Some("tm_test"), None, false)
7701            .await
7702            .unwrap()
7703            .unwrap();
7704
7705        // Delete it
7706        engine.delete_utxo_deep(&output, None).await.unwrap();
7707
7708        // Should be gone
7709        let found = engine
7710            .storage()
7711            .find_output(txid, 0, Some("tm_test"), None, false)
7712            .await
7713            .unwrap();
7714        assert!(found.is_none());
7715    }
7716
7717    #[tokio::test]
7718    async fn test_sync_config_defaults_to_ship() {
7719        let engine = make_engine(vec![]);
7720        let sync = &engine.config.sync_configuration;
7721
7722        // tm_test should default to SHIP sync
7723        assert!(sync.contains_key("tm_test"));
7724        assert!(matches!(sync["tm_test"], SyncTarget::Ship));
7725    }
7726
7727    #[tokio::test]
7728    async fn test_multiple_topics_in_single_submit() {
7729        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7730        managers.insert(
7731            "tm_alpha".to_string(),
7732            Box::new(MockTopicManager::admitting(vec![0])),
7733        );
7734        managers.insert(
7735            "tm_beta".to_string(),
7736            Box::new(MockTopicManager::admitting(vec![1])),
7737        );
7738
7739        let engine = Engine::new(
7740            managers,
7741            HashMap::new(),
7742            Box::new(MemoryStorage::new()),
7743            None,
7744            EngineConfig::default(),
7745        );
7746
7747        let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
7748
7749        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7750
7751        assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
7752        assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
7753    }
7754
7755    #[tokio::test]
7756    async fn test_evict_output_with_topic() {
7757        let engine = make_engine(vec![0]);
7758        let beef = test_tagged_beef(vec!["tm_test"]);
7759        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7760
7761        // Output should exist
7762        let found = engine
7763            .storage()
7764            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7765            .await
7766            .unwrap();
7767        assert!(found.is_some(), "Output should exist before eviction");
7768
7769        // Evict with topic
7770        engine
7771            .evict_output(TEST_TXID, 0, Some("tm_test"))
7772            .await
7773            .unwrap();
7774
7775        // Output should be gone from storage
7776        let found = engine
7777            .storage()
7778            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7779            .await
7780            .unwrap();
7781        assert!(found.is_none(), "Output should be gone after eviction");
7782
7783        // Lookup service should also have been notified (output evicted)
7784        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7785        let answer = engine.lookup(&question, None).await.unwrap();
7786        match answer {
7787            LookupAnswer::OutputList { outputs } => {
7788                assert!(
7789                    outputs.is_empty(),
7790                    "Lookup should return empty after eviction"
7791                );
7792            }
7793            _ => panic!("Expected OutputList"),
7794        }
7795    }
7796
7797    #[tokio::test]
7798    async fn test_evict_output_without_topic() {
7799        let engine = make_engine(vec![0]);
7800        let beef = test_tagged_beef(vec!["tm_test"]);
7801        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7802
7803        // Evict without topic — should find and remove across all topics
7804        engine.evict_output(TEST_TXID, 0, None).await.unwrap();
7805
7806        let found = engine
7807            .storage()
7808            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7809            .await
7810            .unwrap();
7811        assert!(
7812            found.is_none(),
7813            "Output should be gone after topic-less eviction"
7814        );
7815    }
7816
7817    #[tokio::test]
7818    async fn test_evict_nonexistent_output_is_ok() {
7819        let engine = make_engine(vec![]);
7820
7821        // Evicting something that doesn't exist should not error
7822        let result = engine
7823            .evict_output("nonexistent_txid", 99, Some("tm_test"))
7824            .await;
7825        assert!(result.is_ok(), "Evicting nonexistent output should succeed");
7826    }
7827
7828    // ── Mock Broadcaster ──────────────────────────────────────────────
7829
7830    use crate::broadcaster::Broadcaster;
7831    use std::sync::Arc;
7832
7833    type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
7834
7835    struct MockBroadcaster {
7836        calls: BroadcastCallLog,
7837    }
7838
7839    impl MockBroadcaster {
7840        fn new() -> Self {
7841            Self {
7842                calls: Arc::new(Mutex::new(Vec::new())),
7843            }
7844        }
7845
7846        #[allow(dead_code, reason = "kept for future test extension")]
7847        fn call_count(&self) -> usize {
7848            self.calls.lock().unwrap().len()
7849        }
7850
7851        #[allow(dead_code, reason = "kept for future test extension")]
7852        fn calls(&self) -> Vec<(String, Vec<String>)> {
7853            self.calls.lock().unwrap().clone()
7854        }
7855    }
7856
7857    #[async_trait(?Send)]
7858    impl Broadcaster for MockBroadcaster {
7859        async fn broadcast_to_host(
7860            &self,
7861            host_url: &str,
7862            tagged_beef: &TaggedBEEF,
7863        ) -> Result<(), String> {
7864            self.calls
7865                .lock()
7866                .unwrap()
7867                .push((host_url.to_string(), tagged_beef.topics.clone()));
7868            Ok(())
7869        }
7870    }
7871
7872    // ── Mock SHIP LookupService ─────────────────────────────────────
7873
7874    /// A mock "ls_ship" lookup service that returns pre-configured UTXOReferences
7875    /// when queried. Used to test SHIP propagation without depending on
7876    /// overlay-discovery in the engine crate's unit tests.
7877    struct MockSHIPLookupService {
7878        /// Maps topic -> list of (txid, output_index) references
7879        records: Mutex<HashMap<String, Vec<UTXOReference>>>,
7880    }
7881
7882    impl MockSHIPLookupService {
7883        fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
7884            Self {
7885                records: Mutex::new(records),
7886            }
7887        }
7888    }
7889
7890    #[async_trait(?Send)]
7891    impl LookupServiceTrait for MockSHIPLookupService {
7892        fn admission_mode(&self) -> AdmissionMode {
7893            AdmissionMode::LockingScript
7894        }
7895
7896        fn spend_notification_mode(&self) -> SpendNotificationMode {
7897            SpendNotificationMode::None
7898        }
7899
7900        async fn output_admitted_by_topic(
7901            &self,
7902            _payload: &OutputAdmittedByTopic,
7903        ) -> Result<(), LookupServiceError> {
7904            Ok(())
7905        }
7906
7907        async fn output_evicted(
7908            &self,
7909            _txid: &str,
7910            _output_index: u32,
7911        ) -> Result<(), LookupServiceError> {
7912            Ok(())
7913        }
7914
7915        async fn lookup(
7916            &self,
7917            question: &LookupQuestion,
7918        ) -> Result<LookupResult, LookupServiceError> {
7919            // Parse the topics from the query
7920            if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
7921                let records = self.records.lock().unwrap();
7922                let mut results = Vec::new();
7923                for topic_val in topics {
7924                    if let Some(topic) = topic_val.as_str() {
7925                        if let Some(refs) = records.get(topic) {
7926                            results.extend(refs.iter().cloned());
7927                        }
7928                    }
7929                }
7930                return Ok(LookupResult::OutputList(results));
7931            }
7932            Ok(LookupResult::OutputList(vec![]))
7933        }
7934
7935        async fn get_documentation(&self) -> String {
7936            "Mock SHIP lookup service".to_string()
7937        }
7938
7939        async fn get_metadata(&self) -> ServiceMetadata {
7940            ServiceMetadata {
7941                name: "mock-ls-ship".to_string(),
7942                ..Default::default()
7943            }
7944        }
7945    }
7946
7947    /// Build a minimal PushDrop locking script for a SHIP advertisement.
7948    fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
7949        use bsv_rs::script::templates::PushDrop;
7950        use bsv_rs::PublicKey;
7951
7952        let fields = vec![
7953            b"SHIP".to_vec(),
7954            vec![0x02; 33], // fake compressed pubkey bytes for identity_key field
7955            domain.as_bytes().to_vec(),
7956            topic.as_bytes().to_vec(),
7957        ];
7958
7959        // PushDrop requires a real compressed public key for the locking script
7960        // Use a well-known test key (generator point G)
7961        let pubkey = PublicKey::from_hex(
7962            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
7963        )
7964        .expect("valid test pubkey");
7965
7966        let pd = PushDrop::new(pubkey, fields);
7967        pd.lock().to_binary()
7968    }
7969
7970    /// Create engine with a broadcaster and a mock SHIP lookup service.
7971    /// The `ship_domains` parameter provides (domain, topic) pairs to pre-populate.
7972    async fn make_engine_with_broadcaster(
7973        admit_indices: Vec<u32>,
7974        broadcaster: MockBroadcaster,
7975        ship_domains: Vec<(&str, &str)>,
7976        hosting_url: Option<&str>,
7977    ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
7978        let calls = broadcaster.calls.clone();
7979
7980        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7981        managers.insert(
7982            "tm_test".to_string(),
7983            Box::new(MockTopicManager::admitting(admit_indices)),
7984        );
7985
7986        let storage = Box::new(MemoryStorage::new());
7987
7988        // Build SHIP records: map each topic to its UTXO references and
7989        // store outputs in storage with PushDrop scripts so the engine can
7990        // parse the domain when broadcasting.
7991        let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
7992
7993        for (domain, topic) in &ship_domains {
7994            // Deterministic fake txid per domain+topic
7995            let fake_txid = format!("{:064x}", {
7996                let mut h: u64 = 0;
7997                for b in domain.bytes().chain(topic.bytes()) {
7998                    h = h.wrapping_mul(31).wrapping_add(u64::from(b));
7999                }
8000                h
8001            });
8002
8003            // Add to SHIP records for the mock lookup service
8004            ship_records
8005                .entry(topic.to_string())
8006                .or_default()
8007                .push(UTXOReference {
8008                    txid: fake_txid.clone(),
8009                    output_index: 0,
8010                });
8011
8012            // Build PushDrop SHIP script and store as an output in main storage
8013            let script = build_ship_pushdrop_script(domain, topic);
8014
8015            let output = Output {
8016                txid: fake_txid,
8017                output_index: 0,
8018                output_script: script,
8019                satoshis: 1,
8020                topic: "tm_ship".to_string(),
8021                spent: false,
8022                outputs_consumed: vec![],
8023                consumed_by: vec![],
8024                beef: None,
8025                block_height: None,
8026                score: None,
8027            };
8028
8029            storage.insert_output(&output).await.unwrap();
8030        }
8031
8032        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8033        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
8034        lookup_services.insert(
8035            "ls_ship".to_string(),
8036            Box::new(MockSHIPLookupService::with_records(ship_records)),
8037        );
8038
8039        let config = EngineConfig {
8040            hosting_url: hosting_url.map(str::to_string),
8041            ..Default::default()
8042        };
8043
8044        let engine = Engine::with_chain_tracker(
8045            managers,
8046            lookup_services,
8047            storage,
8048            None,
8049            Some(Box::new(broadcaster)),
8050            None,
8051            config,
8052        );
8053
8054        (engine, calls)
8055    }
8056
8057    // ── Broadcaster Tests ─────────────────────────────────────────────
8058
8059    #[tokio::test]
8060    async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
8061        let broadcaster = MockBroadcaster::new();
8062        let (engine, calls) = make_engine_with_broadcaster(
8063            vec![0],
8064            broadcaster,
8065            vec![("https://peer1.example.com", "tm_test")],
8066            Some("https://self.example.com"),
8067        )
8068        .await;
8069
8070        let beef = test_tagged_beef(vec!["tm_test"]);
8071        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8072
8073        // Submit should succeed
8074        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8075
8076        // Broadcaster should have been called for peer1
8077        let recorded = calls.lock().unwrap();
8078        assert_eq!(
8079            recorded.len(),
8080            1,
8081            "Broadcaster should be called once for the one SHIP peer"
8082        );
8083        assert_eq!(recorded[0].0, "https://peer1.example.com");
8084    }
8085
8086    #[tokio::test]
8087    async fn test_broadcaster_not_called_on_historical_tx() {
8088        let broadcaster = MockBroadcaster::new();
8089        let (engine, calls) = make_engine_with_broadcaster(
8090            vec![0],
8091            broadcaster,
8092            vec![("https://peer1.example.com", "tm_test")],
8093            None,
8094        )
8095        .await;
8096
8097        let beef = test_tagged_beef(vec!["tm_test"]);
8098        let steak = engine
8099            .submit(&beef, SubmitMode::HistoricalTx)
8100            .await
8101            .unwrap();
8102
8103        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8104
8105        // Broadcaster should NOT have been called
8106        assert_eq!(
8107            calls.lock().unwrap().len(),
8108            0,
8109            "Broadcaster should not be called for historical TX"
8110        );
8111    }
8112
8113    #[tokio::test]
8114    async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
8115        let broadcaster = MockBroadcaster::new();
8116        let (engine, calls) = make_engine_with_broadcaster(
8117            vec![0],
8118            broadcaster,
8119            vec![("https://peer1.example.com", "tm_test")],
8120            None,
8121        )
8122        .await;
8123
8124        let beef = test_tagged_beef(vec!["tm_test"]);
8125        let steak = engine
8126            .submit(&beef, SubmitMode::HistoricalTxNoSpv)
8127            .await
8128            .unwrap();
8129
8130        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8131
8132        assert_eq!(
8133            calls.lock().unwrap().len(),
8134            0,
8135            "Broadcaster should not be called for historical-tx-no-spv"
8136        );
8137    }
8138
8139    #[tokio::test]
8140    async fn test_broadcaster_skips_self_hosting_url() {
8141        let broadcaster = MockBroadcaster::new();
8142        let (engine, calls) = make_engine_with_broadcaster(
8143            vec![0],
8144            broadcaster,
8145            vec![
8146                ("https://self.example.com", "tm_test"),
8147                ("https://peer2.example.com", "tm_test"),
8148            ],
8149            Some("https://self.example.com"),
8150        )
8151        .await;
8152
8153        let beef = test_tagged_beef(vec!["tm_test"]);
8154        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8155
8156        // Should only broadcast to peer2, not to self
8157        let recorded = calls.lock().unwrap();
8158        assert_eq!(
8159            recorded.len(),
8160            1,
8161            "Should broadcast to peer2 only, skipping self"
8162        );
8163        assert_eq!(recorded[0].0, "https://peer2.example.com");
8164    }
8165
8166    #[tokio::test]
8167    async fn test_broadcaster_not_called_when_no_outputs_admitted() {
8168        let broadcaster = MockBroadcaster::new();
8169        // Topic manager admits nothing
8170        let (engine, calls) = make_engine_with_broadcaster(
8171            vec![],
8172            broadcaster,
8173            vec![("https://peer1.example.com", "tm_test")],
8174            None,
8175        )
8176        .await;
8177
8178        let beef = test_tagged_beef(vec!["tm_test"]);
8179        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8180
8181        assert!(steak["tm_test"].outputs_to_admit.is_empty());
8182
8183        // No outputs admitted -> no broadcast
8184        assert_eq!(
8185            calls.lock().unwrap().len(),
8186            0,
8187            "Broadcaster should not be called when no outputs are admitted"
8188        );
8189    }
8190
8191    #[tokio::test]
8192    async fn test_broadcaster_not_called_when_no_ship_peers() {
8193        let broadcaster = MockBroadcaster::new();
8194        // No SHIP peers registered
8195        let (engine, calls) = make_engine_with_broadcaster(
8196            vec![0],
8197            broadcaster,
8198            vec![], // no SHIP peers
8199            None,
8200        )
8201        .await;
8202
8203        let beef = test_tagged_beef(vec!["tm_test"]);
8204        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8205
8206        assert_eq!(
8207            calls.lock().unwrap().len(),
8208            0,
8209            "Broadcaster should not be called when no SHIP peers exist"
8210        );
8211    }
8212
8213    // ── Configurable SpendNotification mock ─────────────────────────────
8214
8215    /// A mock lookup service whose SpendNotificationMode can be configured.
8216    /// Uses a shared Arc<Mutex<Vec<OutputSpent>>> so the caller can inspect
8217    /// captured payloads after submit().
8218    struct SpendModeLookupService {
8219        mode: SpendNotificationMode,
8220        records: Mutex<Vec<UTXOReference>>,
8221        spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
8222    }
8223
8224    impl SpendModeLookupService {
8225        fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
8226            Self {
8227                mode,
8228                records: Mutex::new(Vec::new()),
8229                spent_payloads: capture,
8230            }
8231        }
8232    }
8233
8234    #[async_trait(?Send)]
8235    impl LookupServiceTrait for SpendModeLookupService {
8236        fn admission_mode(&self) -> AdmissionMode {
8237            AdmissionMode::LockingScript
8238        }
8239
8240        fn spend_notification_mode(&self) -> SpendNotificationMode {
8241            self.mode
8242        }
8243
8244        async fn output_admitted_by_topic(
8245            &self,
8246            payload: &OutputAdmittedByTopic,
8247        ) -> Result<(), LookupServiceError> {
8248            let (txid, oi) = match payload {
8249                OutputAdmittedByTopic::LockingScript {
8250                    txid, output_index, ..
8251                } => (txid.clone(), *output_index),
8252                OutputAdmittedByTopic::WholeTx { output_index, .. } => {
8253                    ("whole".into(), *output_index)
8254                }
8255            };
8256            self.records.lock().unwrap().push(UTXOReference {
8257                txid,
8258                output_index: oi,
8259            });
8260            Ok(())
8261        }
8262
8263        async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
8264            self.spent_payloads.lock().unwrap().push(payload.clone());
8265            Ok(())
8266        }
8267
8268        async fn output_evicted(
8269            &self,
8270            txid: &str,
8271            output_index: u32,
8272        ) -> Result<(), LookupServiceError> {
8273            self.records
8274                .lock()
8275                .unwrap()
8276                .retain(|r| !(r.txid == txid && r.output_index == output_index));
8277            Ok(())
8278        }
8279
8280        async fn lookup(
8281            &self,
8282            _question: &LookupQuestion,
8283        ) -> Result<LookupResult, LookupServiceError> {
8284            Ok(LookupResult::OutputList(
8285                self.records.lock().unwrap().clone(),
8286            ))
8287        }
8288
8289        async fn get_documentation(&self) -> String {
8290            "SpendMode mock lookup service".to_string()
8291        }
8292
8293        async fn get_metadata(&self) -> ServiceMetadata {
8294            ServiceMetadata {
8295                name: "spend-mode-ls".to_string(),
8296                ..Default::default()
8297            }
8298        }
8299    }
8300
8301    /// TXID of the input's source transaction in TEST_BEEF_HEX (the "previous coin").
8302    const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
8303
8304    /// Build an engine with a pre-populated previous output and a SpendModeLookupService.
8305    /// Returns the engine and the shared capture vec for inspecting OutputSpent payloads.
8306    async fn make_spend_mode_engine(
8307        mode: SpendNotificationMode,
8308    ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
8309        let storage = MemoryStorage::new();
8310
8311        // Pre-populate storage with the previous output that TEST_BEEF_HEX's input spends.
8312        let prev_output = Output {
8313            txid: PREVIOUS_TXID.to_string(),
8314            output_index: 0,
8315            output_script: vec![0x76, 0xa9],
8316            satoshis: 26174,
8317            topic: "tm_test".to_string(),
8318            spent: false,
8319            outputs_consumed: vec![],
8320            consumed_by: vec![],
8321            beef: Some(test_beef()),
8322            block_height: None,
8323            score: Some(1000.0),
8324        };
8325        storage.insert_output(&prev_output).await.unwrap();
8326
8327        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8328        managers.insert(
8329            "tm_test".to_string(),
8330            Box::new(MockTopicManager::admitting(vec![0])),
8331        );
8332
8333        let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
8334        let ls = SpendModeLookupService::with_mode(mode, capture.clone());
8335        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8336        lookup_services.insert("ls_test".to_string(), Box::new(ls));
8337
8338        let engine = Engine::new(
8339            managers,
8340            lookup_services,
8341            Box::new(storage),
8342            None,
8343            EngineConfig::default(),
8344        );
8345
8346        (engine, capture)
8347    }
8348
8349    #[tokio::test]
8350    async fn test_spend_notification_mode_script() {
8351        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
8352
8353        let beef = test_tagged_beef(vec!["tm_test"]);
8354        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8355
8356        // The new output should be admitted
8357        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8358
8359        // The lookup service should have received a Script spend notification
8360        let payloads = capture.lock().unwrap().clone();
8361        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8362
8363        match &payloads[0] {
8364            OutputSpent::Script {
8365                txid,
8366                output_index,
8367                topic,
8368                spending_txid,
8369                input_index,
8370                unlocking_script,
8371                sequence_number,
8372                ..
8373            } => {
8374                assert_eq!(txid, PREVIOUS_TXID);
8375                assert_eq!(*output_index, 0);
8376                assert_eq!(topic, "tm_test");
8377                assert_eq!(spending_txid, TEST_TXID);
8378                // The BEEF has a single input at index 0 that spends PREVIOUS_TXID:0
8379                assert_eq!(*input_index, 0);
8380                // The unlocking script should be non-empty (it's a P2PKH scriptSig)
8381                assert!(
8382                    !unlocking_script.is_empty(),
8383                    "Unlocking script should be non-empty"
8384                );
8385                // Standard final sequence number
8386                assert_eq!(*sequence_number, 0xffff_ffff);
8387            }
8388            other => panic!("Expected OutputSpent::Script, got: {other:?}"),
8389        }
8390    }
8391
8392    #[tokio::test]
8393    async fn test_spend_notification_mode_whole_tx() {
8394        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
8395
8396        let beef = test_tagged_beef(vec!["tm_test"]);
8397        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8398
8399        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8400
8401        let payloads = capture.lock().unwrap().clone();
8402        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8403
8404        match &payloads[0] {
8405            OutputSpent::WholeTx {
8406                txid,
8407                output_index,
8408                topic,
8409                spending_atomic_beef,
8410                ..
8411            } => {
8412                assert_eq!(txid, PREVIOUS_TXID);
8413                assert_eq!(*output_index, 0);
8414                assert_eq!(topic, "tm_test");
8415                // The spending BEEF is the entire BEEF we submitted, NAMED
8416                // (atomic prefix) for the lookup service's own re-parse.
8417                let mut named =
8418                    bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
8419                assert!(named.is_atomic());
8420                let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
8421                assert_eq!(
8422                    named.txs.len(),
8423                    submitted.txs.len(),
8424                    "the whole body, never pruned"
8425                );
8426                let tip = crate::subject::subject_txid_of(&mut named).unwrap();
8427                assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
8428            }
8429            other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
8430        }
8431    }
8432
8433    #[tokio::test]
8434    async fn test_spend_notification_mode_txid() {
8435        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
8436
8437        let beef = test_tagged_beef(vec!["tm_test"]);
8438        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8439
8440        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8441
8442        let payloads = capture.lock().unwrap().clone();
8443        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8444
8445        match &payloads[0] {
8446            OutputSpent::Txid {
8447                txid,
8448                output_index,
8449                topic,
8450                spending_txid,
8451            } => {
8452                assert_eq!(txid, PREVIOUS_TXID);
8453                assert_eq!(*output_index, 0);
8454                assert_eq!(topic, "tm_test");
8455                assert_eq!(spending_txid, TEST_TXID);
8456            }
8457            other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
8458        }
8459    }
8460
8461    #[tokio::test]
8462    async fn test_spend_notification_mode_none() {
8463        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
8464
8465        let beef = test_tagged_beef(vec!["tm_test"]);
8466        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8467
8468        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8469
8470        let payloads = capture.lock().unwrap().clone();
8471        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8472
8473        match &payloads[0] {
8474            OutputSpent::None {
8475                txid,
8476                output_index,
8477                topic,
8478            } => {
8479                assert_eq!(txid, PREVIOUS_TXID);
8480                assert_eq!(*output_index, 0);
8481                assert_eq!(topic, "tm_test");
8482            }
8483            other => panic!("Expected OutputSpent::None, got: {other:?}"),
8484        }
8485    }
8486
8487    // ── GASP Sync Tests ───────────────────────────────────────────────
8488
8489    #[tokio::test]
8490    async fn test_start_gasp_sync_empty_config() {
8491        let engine = Engine::new(
8492            HashMap::new(),
8493            HashMap::new(),
8494            Box::new(MemoryStorage::new()),
8495            None,
8496            EngineConfig {
8497                sync_configuration: HashMap::new(),
8498                ..Default::default()
8499            },
8500        );
8501
8502        let result = engine.start_gasp_sync().await.unwrap();
8503        assert!(
8504            result.topics_synced.is_empty(),
8505            "No topics should be synced with empty config"
8506        );
8507    }
8508
8509    #[tokio::test]
8510    async fn test_start_gasp_sync_disabled_topic_skipped() {
8511        let mut sync_config: SyncConfiguration = HashMap::new();
8512        sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
8513
8514        let engine = Engine::new(
8515            HashMap::new(),
8516            HashMap::new(),
8517            Box::new(MemoryStorage::new()),
8518            None,
8519            EngineConfig {
8520                sync_configuration: sync_config,
8521                ..Default::default()
8522            },
8523        );
8524
8525        let result = engine.start_gasp_sync().await.unwrap();
8526        assert!(
8527            result.topics_synced.is_empty(),
8528            "Disabled topics should not appear in results"
8529        );
8530    }
8531
8532    #[tokio::test]
8533    async fn test_start_gasp_sync_peers_config() {
8534        let mut sync_config: SyncConfiguration = HashMap::new();
8535        sync_config.insert(
8536            "tm_test".to_string(),
8537            SyncTarget::Peers(vec![
8538                "https://peer1.example.com".to_string(),
8539                "https://peer2.example.com".to_string(),
8540            ]),
8541        );
8542
8543        let engine = Engine::new(
8544            HashMap::new(),
8545            HashMap::new(),
8546            Box::new(MemoryStorage::new()),
8547            None,
8548            EngineConfig {
8549                sync_configuration: sync_config,
8550                ..Default::default()
8551            },
8552        );
8553
8554        let result = engine.start_gasp_sync().await.unwrap();
8555        assert_eq!(result.topics_synced.len(), 1);
8556
8557        let topic_result = &result.topics_synced["tm_test"];
8558        assert_eq!(topic_result.peers.len(), 2);
8559        assert_eq!(topic_result.sync_type, "peers");
8560        assert!(topic_result.errors.is_empty());
8561    }
8562
8563    #[tokio::test]
8564    async fn test_start_gasp_sync_peers_filters_self() {
8565        let mut sync_config: SyncConfiguration = HashMap::new();
8566        sync_config.insert(
8567            "tm_test".to_string(),
8568            SyncTarget::Peers(vec![
8569                "https://self.example.com".to_string(),
8570                "https://peer2.example.com".to_string(),
8571            ]),
8572        );
8573
8574        let engine = Engine::new(
8575            HashMap::new(),
8576            HashMap::new(),
8577            Box::new(MemoryStorage::new()),
8578            None,
8579            EngineConfig {
8580                hosting_url: Some("https://self.example.com".to_string()),
8581                sync_configuration: sync_config,
8582                ..Default::default()
8583            },
8584        );
8585
8586        let result = engine.start_gasp_sync().await.unwrap();
8587        let topic_result = &result.topics_synced["tm_test"];
8588        assert_eq!(
8589            topic_result.peers.len(),
8590            1,
8591            "Self URL should be filtered out"
8592        );
8593        assert_eq!(topic_result.peers[0], "https://peer2.example.com");
8594    }
8595
8596    #[tokio::test]
8597    async fn load_0112_quiet_peer_costs_no_request_during_backoff() {
8598        let calls = std::rc::Rc::default();
8599        let store = std::rc::Rc::new(MemoryStorage::new());
8600        let mut engine = Engine::new(
8601            HashMap::new(),
8602            HashMap::new(),
8603            Box::new(store.clone()),
8604            None,
8605            EngineConfig {
8606                sync_configuration: HashMap::from([(
8607                    "tm_test".into(),
8608                    SyncTarget::Peers(vec!["https://quiet.example".into()]),
8609                )]),
8610                ..Default::default()
8611            },
8612        );
8613        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8614            calls: std::rc::Rc::clone(&calls),
8615        }));
8616        for _ in 0..4 {
8617            engine.start_gasp_sync().await.unwrap();
8618        }
8619        assert_eq!(calls.borrow().len(), 3);
8620        assert_eq!(
8621            store
8622                .get_peer_sync_health("quiet.example", "tm_test")
8623                .await
8624                .unwrap()
8625                .consecutive_failures,
8626            0
8627        );
8628        store.advance_clock(60);
8629        engine.start_gasp_sync().await.unwrap();
8630        assert_eq!(calls.borrow().len(), 4);
8631        engine.start_gasp_sync().await.unwrap();
8632        assert_eq!(calls.borrow().len(), 4);
8633        engine
8634            .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8635                topics: Some(vec!["tm_test".into()]),
8636                peers: None,
8637            })
8638            .await
8639            .unwrap();
8640        assert_eq!(calls.borrow().len(), 5);
8641        engine.start_gasp_sync().await.unwrap();
8642        assert_eq!(
8643            calls.borrow().len(),
8644            6,
8645            "explicit selection reset the quiet streak"
8646        );
8647    }
8648
8649    #[tokio::test]
8650    async fn load_0112_yield_and_advertisement_reset_cadence() {
8651        let (mut engine, remote) = budget_page_engine(1);
8652        engine.set_gasp_remote_factory(Box::new(remote));
8653        engine
8654            .storage
8655            .set_peer_cadence(
8656                "budget.example",
8657                "tm_test",
8658                &crate::sync_load::PeerCadence {
8659                    quiet_syncs: 8,
8660                    retry_after_secs: 0,
8661                    advertisement: String::new(),
8662                },
8663            )
8664            .await
8665            .unwrap();
8666        let result = engine.start_gasp_sync().await.unwrap();
8667        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
8668        assert_eq!(
8669            engine
8670                .storage
8671                .get_peer_cadence("budget.example", "tm_test")
8672                .await
8673                .unwrap()
8674                .quiet_syncs,
8675            0
8676        );
8677        engine
8678            .storage
8679            .set_peer_cadence(
8680                "budget.example",
8681                "tm_test",
8682                &crate::sync_load::PeerCadence {
8683                    quiet_syncs: 8,
8684                    retry_after_secs: 3_600,
8685                    advertisement: "previous-advertisement".into(),
8686                },
8687            )
8688            .await
8689            .unwrap();
8690        let result = engine.start_gasp_sync().await.unwrap();
8691        assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8692        assert!(
8693            engine
8694                .storage
8695                .get_peer_cadence("budget.example", "tm_test")
8696                .await
8697                .unwrap()
8698                .quiet_syncs
8699                <= 1
8700        );
8701    }
8702
8703    #[tokio::test]
8704    async fn load_0112_an_admitted_slap_change_wakes_its_peer() {
8705        let (mut engine, mut remote) = budget_page_engine(0);
8706        remote.node_cost_ms = 0;
8707        engine.set_gasp_remote_factory(Box::new(remote.clone()));
8708        for _ in 0..3 {
8709            engine.start_gasp_sync().await.unwrap();
8710        }
8711        let skipped = engine.start_gasp_sync().await.unwrap();
8712        assert_eq!(
8713            skipped.topics_synced["tm_test"].peers_skipped[0].reason,
8714            "no_yield"
8715        );
8716        let count = remote.requests.borrow().len();
8717        let ad = discovery_output("SLAP", "https://budget.example", "ls_ship");
8718        let tx = beef_limits::transaction_from_beef(&ad.beef, None, &beef_limits::PEER_BEEF_LIMITS)
8719            .unwrap();
8720        engine
8721            .storage
8722            .insert_output(&Output {
8723                txid: tx.id(),
8724                output_index: 0,
8725                output_script: tx.outputs[0].locking_script.to_binary(),
8726                satoshis: 1,
8727                topic: "tm_slap".into(),
8728                spent: false,
8729                outputs_consumed: vec![],
8730                consumed_by: vec![],
8731                beef: None,
8732                block_height: None,
8733                score: Some(1.0),
8734            })
8735            .await
8736            .unwrap();
8737        let result = engine.start_gasp_sync().await.unwrap();
8738        assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8739        assert_eq!(remote.requests.borrow().len(), count + 1);
8740    }
8741
8742    #[tokio::test]
8743    async fn load_0112_filter_selects_only_named_topic_and_peer() {
8744        let calls = std::rc::Rc::default();
8745        let mut engine = Engine::new(
8746            HashMap::new(),
8747            HashMap::new(),
8748            Box::new(MemoryStorage::new()),
8749            None,
8750            EngineConfig {
8751                sync_configuration: ["tm_alpha", "tm_beta"]
8752                    .into_iter()
8753                    .map(|t| {
8754                        (
8755                            t.to_string(),
8756                            SyncTarget::Peers(vec![
8757                                "https://a.example".into(),
8758                                "https://b.example".into(),
8759                            ]),
8760                        )
8761                    })
8762                    .collect(),
8763                ..Default::default()
8764            },
8765        );
8766        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8767            calls: std::rc::Rc::clone(&calls),
8768        }));
8769        let filter = crate::sync_load::SyncFilter::parse(
8770            r#"{"topics":["tm_beta"],"peers":["https://b.example"]}"#,
8771        )
8772        .unwrap();
8773        let result = engine.start_gasp_sync_filtered(&filter).await.unwrap();
8774        assert_eq!(
8775            *calls.borrow(),
8776            vec![("https://b.example".into(), "tm_beta".into())]
8777        );
8778        assert_eq!(result.topics_synced.len(), 1);
8779        calls.borrow_mut().clear();
8780        engine.start_gasp_sync().await.unwrap();
8781        assert_eq!(calls.borrow().len(), 4);
8782        calls.borrow_mut().clear();
8783        engine
8784            .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8785                topics: Some(vec![]),
8786                peers: None,
8787            })
8788            .await
8789            .unwrap();
8790        assert!(calls.borrow().is_empty());
8791        assert!(crate::sync_load::SyncFilter::parse(r#"{"peers":"bad"}"#).is_err());
8792        assert!(crate::sync_load::SyncFilter::parse("null").is_err());
8793        assert_eq!(
8794            crate::sync_load::SyncFilter::parse("  ").unwrap(),
8795            crate::sync_load::SyncFilter::default()
8796        );
8797    }
8798
8799    #[derive(Clone, Default)]
8800    struct MockShipLookupClient {
8801        records: HashMap<(String, String), Vec<OutputListItem>>,
8802        failures: HashSet<String>,
8803        calls: std::rc::Rc<std::cell::RefCell<Vec<(String, LookupQuestion)>>>,
8804    }
8805
8806    struct RecordingDiscoverySyncFactory {
8807        calls: std::rc::Rc<std::cell::RefCell<Vec<(String, String)>>>,
8808    }
8809
8810    impl crate::gasp::GASPRemoteFactory for RecordingDiscoverySyncFactory {
8811        fn create_remote(&self, peer: &str, topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8812            self.calls
8813                .borrow_mut()
8814                .push((peer.to_string(), topic.to_string()));
8815            Box::new(MockSyncRemote { utxos: Vec::new() })
8816        }
8817    }
8818
8819    #[async_trait(?Send)]
8820    impl crate::ship_lookup::ShipLookupClient for MockShipLookupClient {
8821        async fn lookup(
8822            &self,
8823            host: &str,
8824            question: &LookupQuestion,
8825        ) -> Result<Vec<OutputListItem>, String> {
8826            self.calls
8827                .borrow_mut()
8828                .push((host.to_string(), question.clone()));
8829            if self.failures.contains(host) {
8830                return Err("mock tracker unavailable".to_string());
8831            }
8832            Ok(self
8833                .records
8834                .get(&(host.to_string(), question.service.clone()))
8835                .cloned()
8836                .unwrap_or_default())
8837        }
8838    }
8839
8840    fn discovery_output(protocol: &str, domain: &str, scope: &str) -> OutputListItem {
8841        use bsv_rs::script::templates::PushDrop;
8842        let pubkey = bsv_rs::PublicKey::from_hex(
8843            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
8844        )
8845        .unwrap();
8846        let script = PushDrop::new(
8847            pubkey,
8848            vec![
8849                protocol.as_bytes().to_vec(),
8850                vec![0x02; 33],
8851                domain.as_bytes().to_vec(),
8852                scope.as_bytes().to_vec(),
8853            ],
8854        )
8855        .lock();
8856        let mut tx = Transaction::from_beef(&test_beef(), None).unwrap();
8857        tx.outputs[0].locking_script = script;
8858        tx.merkle_path = None;
8859        tx.invalidate_caches();
8860        let beef = tx.to_atomic_beef(true).unwrap();
8861        let parsed =
8862            beef_limits::transaction_from_beef(&beef, None, &beef_limits::PEER_BEEF_LIMITS)
8863                .unwrap();
8864        assert_eq!(
8865            parsed.outputs[0].locking_script,
8866            tx.outputs[0].locking_script
8867        );
8868        OutputListItem {
8869            beef,
8870            output_index: 0,
8871            context: None,
8872        }
8873    }
8874
8875    #[tokio::test]
8876    async fn ship_network_discovery_unions_local_trackers_and_slap_hosts_by_origin() {
8877        let (mut engine, _) = make_engine_with_broadcaster(
8878            vec![0],
8879            MockBroadcaster::new(),
8880            vec![
8881                ("https://local.example", "tm_test"),
8882                ("https://shared.example/?local", "tm_test"),
8883            ],
8884            Some("https://self.example"),
8885        )
8886        .await;
8887        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8888        engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8889        let mut client = MockShipLookupClient::default();
8890        client.records.insert(
8891            ("https://tracker.example".to_string(), "ls_ship".to_string()),
8892            vec![
8893                discovery_output("SHIP", "https://direct.example", "tm_test"),
8894                discovery_output("SHIP", "https://SHARED.example:443/path", "tm_test"),
8895                discovery_output("SHIP", "http://SELF.example.:8443/?x#y", "tm_test"),
8896                discovery_output("SLAP", "https://wrong-protocol.example", "tm_test"),
8897                discovery_output("SHIP", "https://wrong-topic.example", "tm_other"),
8898                OutputListItem {
8899                    beef: vec![1, 2, 3],
8900                    output_index: 0,
8901                    context: None,
8902                },
8903            ],
8904        );
8905        client.records.insert(
8906            ("https://tracker.example".to_string(), "ls_slap".to_string()),
8907            vec![
8908                discovery_output("SLAP", "https://lookup.example", "ls_ship"),
8909                discovery_output("SLAP", "https://LOOKUP.example:443/path", "ls_ship"),
8910                discovery_output("SLAP", "https://self.example/?slap", "ls_ship"),
8911                discovery_output("SLAP", "https://tracker.example/path", "ls_ship"),
8912                discovery_output("SLAP", "https://other-service.example", "ls_other"),
8913            ],
8914        );
8915        client.records.insert(
8916            ("https://lookup.example".to_string(), "ls_ship".to_string()),
8917            vec![
8918                discovery_output("SHIP", "https://indirect.example", "tm_test"),
8919                discovery_output("SHIP", "https://self.example:443/#ship", "tm_test"),
8920            ],
8921        );
8922        let calls = client.calls.clone();
8923        engine.set_ship_lookup_client(Box::new(client));
8924        let result = engine.start_gasp_sync().await.unwrap();
8925        assert_eq!(
8926            result.topics_synced["tm_test"].peers,
8927            vec![
8928                "https://bootstrap.example",
8929                "https://direct.example",
8930                "https://indirect.example",
8931                "https://local.example",
8932                "https://shared.example",
8933            ]
8934        );
8935        let calls = calls.borrow();
8936        assert_eq!(calls.len(), 3);
8937        for (_, question) in calls.iter() {
8938            let expected = if question.service == "ls_slap" {
8939                serde_json::json!({"service": "ls_ship"})
8940            } else {
8941                serde_json::json!({"topics": ["tm_test"]})
8942            };
8943            assert_eq!(question.query, expected);
8944        }
8945    }
8946
8947    #[tokio::test]
8948    async fn ship_network_discovery_empty_local_finds_all_advertised_peers() {
8949        let (mut engine, _) =
8950            make_engine_with_broadcaster(vec![0], MockBroadcaster::new(), vec![], None).await;
8951        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8952        let mut client = MockShipLookupClient::default();
8953        let outputs = (0..9)
8954            .map(|n| discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"))
8955            .collect();
8956        client.records.insert(
8957            ("https://tracker.example".to_string(), "ls_ship".to_string()),
8958            outputs,
8959        );
8960        engine.set_ship_lookup_client(Box::new(client));
8961        let sync_calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8962        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8963            calls: sync_calls.clone(),
8964        }));
8965        let result = engine.start_gasp_sync().await.unwrap();
8966        let topic = &result.topics_synced["tm_test"];
8967        assert_eq!(topic.sync_type, "ship");
8968        assert_eq!(
8969            topic.peers.len(),
8970            9,
8971            "the lookup-host cap must not truncate sync peers"
8972        );
8973        assert!(topic.errors.is_empty());
8974        assert_eq!(sync_calls.borrow().len(), 9);
8975        for peer in &topic.peers {
8976            assert!(sync_calls
8977                .borrow()
8978                .contains(&(peer.clone(), "tm_test".to_string())));
8979        }
8980    }
8981
8982    #[tokio::test]
8983    async fn ship_network_discovery_tracker_failure_preserves_other_answers() {
8984        let mut engine = make_engine(vec![]);
8985        engine.config.slap_trackers = vec![
8986            "https://dead.example".to_string(),
8987            "https://live.example".to_string(),
8988        ];
8989        let mut client = MockShipLookupClient::default();
8990        client.failures.insert("https://dead.example".to_string());
8991        client.records.insert(
8992            ("https://live.example".to_string(), "ls_ship".to_string()),
8993            vec![discovery_output("SHIP", "https://peer.example", "tm_test")],
8994        );
8995        engine.set_ship_lookup_client(Box::new(client));
8996        let result = engine.start_gasp_sync().await.unwrap();
8997        assert_eq!(
8998            result.topics_synced["tm_test"].peers,
8999            vec!["https://peer.example"]
9000        );
9001    }
9002
9003    #[tokio::test]
9004    async fn ship_network_discovery_provider_cap_is_shared_across_topics() {
9005        use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
9006        let mut engine = make_engine(vec![]);
9007        engine
9008            .config
9009            .sync_configuration
9010            .insert("tm_other".to_string(), SyncTarget::Ship);
9011        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
9012        let mut client = MockShipLookupClient::default();
9013        let mut providers = Vec::new();
9014        for n in 0..SHIP_DISCOVERY_MAX_HOSTS_PER_TICK + 3 {
9015            let host = format!("https://lookup{n}.example");
9016            providers.push(discovery_output("SLAP", &host, "ls_ship"));
9017            client.records.insert(
9018                (host, "ls_ship".to_string()),
9019                vec![
9020                    discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"),
9021                    discovery_output("SHIP", &format!("https://other{n}.example"), "tm_other"),
9022                ],
9023            );
9024        }
9025        client.records.insert(
9026            ("https://tracker.example".to_string(), "ls_slap".to_string()),
9027            providers,
9028        );
9029        let calls = client.calls.clone();
9030        engine.set_ship_lookup_client(Box::new(client));
9031        let result = engine.start_gasp_sync().await.unwrap();
9032        assert_eq!(
9033            result.topics_synced["tm_test"].peers.len(),
9034            SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
9035        );
9036        assert_eq!(
9037            result.topics_synced["tm_other"].peers.len(),
9038            SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
9039        );
9040        let calls = calls.borrow();
9041        assert_eq!(
9042            calls.iter().filter(|(_, q)| q.service == "ls_slap").count(),
9043            1
9044        );
9045        let providers: HashSet<_> = calls
9046            .iter()
9047            .filter(|(h, q)| h != "https://tracker.example" && q.service == "ls_ship")
9048            .map(|(h, _)| h)
9049            .collect();
9050        assert_eq!(providers.len(), SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
9051        assert_eq!(calls.len(), 1 + 2 * (1 + SHIP_DISCOVERY_MAX_HOSTS_PER_TICK));
9052    }
9053
9054    #[tokio::test]
9055    async fn ship_network_discovery_outage_keeps_bootstrap_and_excludes_self() {
9056        let mut engine = make_engine(vec![]);
9057        engine.config.hosting_url = Some("https://self.example".to_string());
9058        engine.config.slap_trackers = vec!["https://dead.example".to_string()];
9059        let mut client = MockShipLookupClient::default();
9060        client.failures.insert("https://dead.example".to_string());
9061        engine.set_ship_lookup_client(Box::new(client));
9062        engine.set_ship_bootstrap_peers(
9063            "tm_test",
9064            vec![
9065                "https://bootstrap.example".to_string(),
9066                "https://BOOTSTRAP.example.:443/?x".to_string(),
9067                "http://SELF.example.:8443/path".to_string(),
9068            ],
9069        );
9070        let result = engine.start_gasp_sync().await.unwrap();
9071        assert_eq!(
9072            result.topics_synced["tm_test"].peers,
9073            vec!["https://bootstrap.example"]
9074        );
9075    }
9076
9077    #[tokio::test]
9078    async fn ship_network_discovery_bootstrap_topic_runs_before_tracker_import() {
9079        let mut engine = make_engine(vec![]);
9080        for topic in ["tm_ship", "tm_slap"] {
9081            engine.config.sync_configuration.insert(
9082                topic.to_string(),
9083                SyncTarget::Peers(vec!["https://tracker.example".to_string()]),
9084            );
9085        }
9086        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
9087        let mut client = MockShipLookupClient::default();
9088        client.records.insert(
9089            ("https://tracker.example".to_string(), "ls_ship".to_string()),
9090            vec![discovery_output(
9091                "SHIP",
9092                "https://discovered.example",
9093                "tm_test",
9094            )],
9095        );
9096        let lookups = client.calls.clone();
9097        engine.set_ship_lookup_client(Box::new(client));
9098        engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
9099        let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
9100        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
9101            calls: calls.clone(),
9102        }));
9103        engine.start_gasp_sync().await.unwrap();
9104        assert_eq!(
9105            calls
9106                .borrow()
9107                .iter()
9108                .map(|(_, topic)| topic.as_str())
9109                .collect::<Vec<_>>(),
9110            vec!["tm_test", "tm_test", "tm_ship", "tm_slap"]
9111        );
9112        assert_eq!(lookups.borrow().len(), 2);
9113    }
9114
9115    #[tokio::test]
9116    async fn test_start_gasp_sync_ship_discovers_peers() {
9117        let broadcaster = MockBroadcaster::new();
9118        let (engine, _calls) = make_engine_with_broadcaster(
9119            vec![0],
9120            broadcaster,
9121            vec![
9122                ("https://peer1.example.com", "tm_test"),
9123                ("https://peer2.example.com", "tm_test"),
9124            ],
9125            Some("https://self.example.com"),
9126        )
9127        .await;
9128
9129        let result = engine.start_gasp_sync().await.unwrap();
9130        assert!(
9131            result.topics_synced.contains_key("tm_test"),
9132            "tm_test should be in sync results"
9133        );
9134
9135        let topic_result = &result.topics_synced["tm_test"];
9136        assert_eq!(topic_result.sync_type, "ship");
9137        assert_eq!(
9138            topic_result.peers.len(),
9139            2,
9140            "Should discover both SHIP peers"
9141        );
9142        assert!(topic_result
9143            .peers
9144            .contains(&"https://peer1.example.com".to_string()));
9145        assert!(topic_result
9146            .peers
9147            .contains(&"https://peer2.example.com".to_string()));
9148    }
9149
9150    #[tokio::test]
9151    async fn test_start_gasp_sync_ship_filters_self() {
9152        let broadcaster = MockBroadcaster::new();
9153        let (engine, _calls) = make_engine_with_broadcaster(
9154            vec![0],
9155            broadcaster,
9156            vec![
9157                ("https://self.example.com", "tm_test"),
9158                ("https://peer2.example.com", "tm_test"),
9159            ],
9160            Some("https://self.example.com"),
9161        )
9162        .await;
9163
9164        let result = engine.start_gasp_sync().await.unwrap();
9165        let topic_result = &result.topics_synced["tm_test"];
9166        assert_eq!(
9167            topic_result.peers.len(),
9168            1,
9169            "Self URL should be filtered out from SHIP discovery"
9170        );
9171        assert_eq!(topic_result.peers[0], "https://peer2.example.com");
9172    }
9173
9174    #[tokio::test]
9175    async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
9176        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9177        managers.insert(
9178            "tm_custom".to_string(),
9179            Box::new(MockTopicManager::admitting(vec![])),
9180        );
9181
9182        let mut sync_config: SyncConfiguration = HashMap::new();
9183        sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
9184
9185        let engine = Engine::new(
9186            managers,
9187            HashMap::new(),
9188            Box::new(MemoryStorage::new()),
9189            None,
9190            EngineConfig {
9191                sync_configuration: sync_config,
9192                ..Default::default()
9193            },
9194        );
9195
9196        let result = engine.start_gasp_sync().await.unwrap();
9197        let topic_result = &result.topics_synced["tm_custom"];
9198        assert!(
9199            topic_result.peers.is_empty(),
9200            "Without ls_ship, SHIP discovery should return no peers"
9201        );
9202    }
9203
9204    #[tokio::test]
9205    async fn test_start_gasp_sync_mixed_config() {
9206        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9207        managers.insert(
9208            "tm_peers".to_string(),
9209            Box::new(MockTopicManager::admitting(vec![])),
9210        );
9211        managers.insert(
9212            "tm_disabled".to_string(),
9213            Box::new(MockTopicManager::admitting(vec![])),
9214        );
9215        managers.insert(
9216            "tm_ship_topic".to_string(),
9217            Box::new(MockTopicManager::admitting(vec![])),
9218        );
9219
9220        let mut sync_config: SyncConfiguration = HashMap::new();
9221        sync_config.insert(
9222            "tm_peers".to_string(),
9223            SyncTarget::Peers(vec!["https://peer.com".to_string()]),
9224        );
9225        sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
9226        sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
9227
9228        let engine = Engine::new(
9229            managers,
9230            HashMap::new(),
9231            Box::new(MemoryStorage::new()),
9232            None,
9233            EngineConfig {
9234                sync_configuration: sync_config,
9235                ..Default::default()
9236            },
9237        );
9238
9239        let result = engine.start_gasp_sync().await.unwrap();
9240        assert_eq!(result.topics_synced.len(), 2);
9241        assert!(result.topics_synced.contains_key("tm_peers"));
9242        assert!(result.topics_synced.contains_key("tm_ship_topic"));
9243        assert!(!result.topics_synced.contains_key("tm_disabled"));
9244
9245        assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
9246        assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
9247        assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
9248    }
9249
9250    #[tokio::test]
9251    async fn test_gasp_sync_result_serialization() {
9252        let mut topics_synced = HashMap::new();
9253        topics_synced.insert(
9254            "tm_test".to_string(),
9255            super::TopicSyncResult {
9256                cost: crate::meter::SyncCost::default(),
9257                root_bounds: Vec::new(),
9258                peers_skipped: Vec::new(),
9259                peers_filtered: Vec::new(),
9260                roots_proven: 0,
9261                roots_unproven: 0,
9262                cursor_held_by: Vec::new(),
9263                expired_refused: 0,
9264                peers: vec!["https://peer.com".to_string()],
9265                sync_type: "ship".to_string(),
9266                errors: vec![],
9267                pruned_inputs: 0,
9268                discarded_graphs: 0,
9269                anchor_walk_held_graphs: 0,
9270                finalized_graphs: 0,
9271                deadline_dropped_graphs: 0,
9272                cursor_moves: Vec::new(),
9273                deferred_graphs: 0,
9274                resumed_graphs: 0,
9275                converged_graphs: 0,
9276                dropped_graphs: Vec::new(),
9277                stalled_graphs: 0,
9278                held_back_graphs: 0,
9279                backed_off_graphs: 0,
9280                waiting_graphs: 0,
9281                idle_counted: 0,
9282                peer_results: HashMap::new(),
9283                dead_graphs: 0,
9284                expired_swept: 0,
9285                memo_folded: 0,
9286                unanchorable_graphs: 0,
9287                unanchorable_cleared: 0,
9288                absent_struck: 0,
9289                route_faults: Vec::new(),
9290                strikes_refused: 0,
9291                faulted_roots: Vec::new(),
9292            },
9293        );
9294
9295        let result = super::GASPSyncResult {
9296            cost: crate::meter::SyncCost::default(),
9297            topics_synced,
9298            filter: crate::sync_load::SyncFilter::default(),
9299            topics_filtered: Vec::new(),
9300        };
9301        let json = serde_json::to_string(&result).unwrap();
9302        let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
9303
9304        assert_eq!(back.topics_synced.len(), 1);
9305        assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
9306        assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
9307    }
9308
9309    // ── GASP Sync With Factory Tests ─────────────────────────────────
9310
9311    /// Mock GASPRemote for factory tests
9312    struct MockSyncRemote {
9313        utxos: Vec<crate::types::GASPOutput>,
9314    }
9315
9316    #[async_trait(?Send)]
9317    impl crate::gasp::GASPRemote for MockSyncRemote {
9318        async fn get_initial_response(
9319            &self,
9320            request: &crate::types::GASPInitialRequest,
9321        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9322            let utxos: Vec<crate::types::GASPOutput> = self
9323                .utxos
9324                .iter()
9325                .filter(|u| u.score as u64 >= request.since)
9326                .cloned()
9327                .collect();
9328            Ok(crate::types::GASPInitialResponse {
9329                utxo_list: utxos,
9330                since: request.since,
9331            })
9332        }
9333        async fn get_initial_reply(
9334            &self,
9335            _: &crate::types::GASPInitialResponse,
9336        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9337            Ok(crate::types::GASPInitialReply {
9338                utxo_list: Vec::new(),
9339            })
9340        }
9341        async fn request_node(
9342            &self,
9343            graph_id: &str,
9344            txid: &str,
9345            output_index: u32,
9346            _: bool,
9347        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9348            Ok(crate::types::GASPNode {
9349                graph_id: graph_id.to_string(),
9350                raw_tx: format!("rawtx_{txid}"),
9351                output_index,
9352                proof: None,
9353                tx_metadata: None,
9354                output_metadata: None,
9355                inputs: None,
9356            })
9357        }
9358        async fn submit_node(
9359            &self,
9360            _: &crate::types::GASPNode,
9361        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9362            Ok(None)
9363        }
9364    }
9365
9366    /// Mock factory that creates MockSyncRemote instances
9367    struct MockGASPRemoteFactory;
9368
9369    #[derive(Clone)]
9370    struct BudgetPageRemote {
9371        outputs: std::rc::Rc<Vec<crate::types::GASPOutput>>,
9372        nodes: std::rc::Rc<HashMap<String, crate::types::GASPNode>>,
9373        elapsed: std::rc::Rc<Cell<u64>>,
9374        requests: std::rc::Rc<std::cell::RefCell<Vec<crate::types::GASPInitialRequest>>>,
9375        clamp: Option<usize>,
9376        node_cost_ms: u64,
9377        /// One request of this txid costs this much more, once.
9378        slow_once: std::rc::Rc<std::cell::RefCell<Option<(String, u64)>>>,
9379        /// Node requests answered, by txid.
9380        asked: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
9381        /// Txids answered with a transport fault (a 503), not `NodeNotFound`.
9382        faults_for: std::rc::Rc<std::cell::RefCell<std::collections::HashSet<String>>>,
9383    }
9384
9385    #[async_trait(?Send)]
9386    impl crate::gasp::GASPRemote for BudgetPageRemote {
9387        async fn get_initial_response(
9388            &self,
9389            request: &crate::types::GASPInitialRequest,
9390        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9391            self.requests.borrow_mut().push(request.clone());
9392            let page = self
9393                .outputs
9394                .iter()
9395                .filter(|u| u.score as u64 >= request.since);
9396            let limit = self.clamp.map_or(usize::MAX, |cap| {
9397                cap.min(request.limit.unwrap_or(u64::MAX) as usize)
9398            });
9399            Ok(crate::types::GASPInitialResponse {
9400                utxo_list: page.take(limit).cloned().collect(),
9401                since: request.since,
9402            })
9403        }
9404
9405        async fn get_initial_reply(
9406            &self,
9407            _: &crate::types::GASPInitialResponse,
9408        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9409            unreachable!()
9410        }
9411
9412        async fn request_node(
9413            &self,
9414            graph_id: &str,
9415            txid: &str,
9416            _: u32,
9417            _: bool,
9418        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9419            self.asked.borrow_mut().push(txid.to_string());
9420            self.elapsed.set(self.elapsed.get() + self.node_cost_ms);
9421            let slow = {
9422                let mut slow = self.slow_once.borrow_mut();
9423                match slow.as_ref() {
9424                    Some((t, _)) if t == txid => slow.take().map(|(_, ms)| ms),
9425                    _ => None,
9426                }
9427            };
9428            if let Some(ms) = slow {
9429                self.elapsed.set(self.elapsed.get() + ms);
9430            }
9431            if self.faults_for.borrow().contains(txid) {
9432                return Err(crate::gasp::GASPError::RemoteError("HTTP 503".into()));
9433            }
9434            if self.node_cost_ms > 0 {
9435                let mut yielded = false;
9436                std::future::poll_fn(|cx| {
9437                    if yielded {
9438                        std::task::Poll::Ready(())
9439                    } else {
9440                        yielded = true;
9441                        cx.waker().wake_by_ref();
9442                        std::task::Poll::Pending
9443                    }
9444                })
9445                .await;
9446            }
9447            let mut node = self
9448                .nodes
9449                .get(txid)
9450                .cloned()
9451                .ok_or_else(|| crate::gasp::GASPError::NodeNotFound(format!("{txid}.0")))?;
9452            node.graph_id = graph_id.to_string();
9453            Ok(node)
9454        }
9455
9456        async fn submit_node(
9457            &self,
9458            _: &crate::types::GASPNode,
9459        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9460            unreachable!()
9461        }
9462    }
9463
9464    impl crate::gasp::GASPRemoteFactory for BudgetPageRemote {
9465        fn create_remote(&self, _: &str, _: &str) -> Box<dyn crate::gasp::GASPRemote> {
9466            Box::new(self.clone())
9467        }
9468    }
9469
9470    fn budget_page_engine(count: u32) -> (Engine, BudgetPageRemote) {
9471        let mut engine = make_engine(vec![0]);
9472        let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
9473        let mut outputs = Vec::new();
9474        let mut nodes = HashMap::new();
9475        for n in 1..=count {
9476            let tx = child_of(&format!("{n:064x}"), 0);
9477            let txid = tx.id();
9478            tracker.add_root(n, txid.clone());
9479            outputs.push(crate::types::GASPOutput {
9480                txid: txid.clone(),
9481                output_index: 0,
9482                score: f64::from(n * 10),
9483            });
9484            nodes.insert(
9485                txid.clone(),
9486                crate::types::GASPNode {
9487                    graph_id: String::new(),
9488                    raw_tx: tx.to_hex(),
9489                    output_index: 0,
9490                    proof: Some(single_leaf_bump_hex(&txid, n)),
9491                    tx_metadata: None,
9492                    output_metadata: None,
9493                    inputs: None,
9494                },
9495            );
9496        }
9497        engine.chain_tracker = Some(Box::new(tracker));
9498        engine.config.sync_configuration.insert(
9499            "tm_test".to_string(),
9500            SyncTarget::Peers(vec!["https://budget.example".to_string()]),
9501        );
9502        let remote = BudgetPageRemote {
9503            outputs: std::rc::Rc::new(outputs),
9504            nodes: std::rc::Rc::new(nodes),
9505            elapsed: std::rc::Rc::new(Cell::new(0)),
9506            requests: std::rc::Rc::default(),
9507            clamp: None,
9508            node_cost_ms: 5000,
9509            slow_once: std::rc::Rc::default(),
9510            asked: std::rc::Rc::default(),
9511            faults_for: std::rc::Rc::default(),
9512        };
9513        (engine, remote)
9514    }
9515
9516    fn virtual_peer_budget(engine: &mut Engine, remote: &BudgetPageRemote) {
9517        let elapsed = remote.elapsed.clone();
9518        engine.set_peer_sync_budget(
9519            std::rc::Rc::new(move |ms| {
9520                assert_eq!(ms, 30_000);
9521                let elapsed = elapsed.clone();
9522                let until = elapsed.get() + ms;
9523                Box::pin(std::future::poll_fn(move |_| {
9524                    if elapsed.get() >= until {
9525                        std::task::Poll::Ready(())
9526                    } else {
9527                        std::task::Poll::Pending
9528                    }
9529                }))
9530            }),
9531            30_000,
9532        );
9533    }
9534
9535    #[tokio::test]
9536    async fn sync_budget_large_page_persists_a_finalized_score_and_resumes() {
9537        // This responder ignores the limit: the initiator must safely handle
9538        // a 3,000-output page under a virtual 30-second peer budget.
9539        let (mut engine, remote) = budget_page_engine(3000);
9540        virtual_peer_budget(&mut engine, &remote);
9541        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9542        for round in 1..=2 {
9543            let result = engine.start_gasp_sync().await.unwrap();
9544            let topic = &result.topics_synced["tm_test"];
9545            assert_eq!(topic.finalized_graphs, 5);
9546            assert_eq!(topic.deadline_dropped_graphs, 1);
9547            assert_eq!(topic.cursor_moves.len(), 1);
9548            let expected = round * 50;
9549            assert_eq!(topic.cursor_moves[0].to, expected);
9550            assert_eq!(
9551                engine
9552                    .storage
9553                    .get_last_interaction("https://budget.example", "tm_test")
9554                    .await
9555                    .unwrap(),
9556                expected
9557            );
9558            {
9559                let requests = remote.requests.borrow();
9560                assert_eq!(requests[(round - 1) as usize].since, (round - 1) * 50);
9561            }
9562            for output in remote.outputs.iter().take((round * 5) as usize) {
9563                assert!(engine
9564                    .storage
9565                    .find_output(&output.txid, 0, Some("tm_test"), None, true)
9566                    .await
9567                    .unwrap()
9568                    .is_some());
9569            }
9570            let unfinished = &remote.outputs[(round * 5) as usize];
9571            assert!(engine
9572                .storage
9573                .find_output(&unfinished.txid, 0, Some("tm_test"), None, true)
9574                .await
9575                .unwrap()
9576                .is_none());
9577        }
9578    }
9579
9580    #[tokio::test]
9581    async fn sync_budget_requests_bounded_pages_and_accepts_responder_clamping() {
9582        let (mut engine, mut remote) = budget_page_engine(7);
9583        remote.clamp = Some(3);
9584        remote.node_cost_ms = 0;
9585        virtual_peer_budget(&mut engine, &remote);
9586        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9587        let result = engine.start_gasp_sync().await.unwrap();
9588        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 7);
9589        let requests = remote.requests.borrow();
9590        assert_eq!(requests.len(), 4);
9591        assert!(requests.iter().all(|request| request.limit == Some(100)));
9592        assert_eq!(
9593            requests.iter().map(|r| r.since).collect::<Vec<_>>(),
9594            vec![0, 30, 50, 70]
9595        );
9596    }
9597
9598    #[tokio::test]
9599    async fn sync_budget_unordered_page_keeps_the_completed_score_below_a_gap() {
9600        let (mut engine, mut remote) = budget_page_engine(3000);
9601        std::rc::Rc::make_mut(&mut remote.outputs).swap(0, 9);
9602        virtual_peer_budget(&mut engine, &remote);
9603        engine.set_gasp_remote_factory(Box::new(remote));
9604        let result = engine.start_gasp_sync().await.unwrap();
9605        let topic = &result.topics_synced["tm_test"];
9606        assert_eq!(topic.finalized_graphs, 5);
9607        // Score 100 landed, but score 10 is still ahead in this unordered
9608        // page. The unfinished lower score must hold the cursor at entry.
9609        assert!(topic.cursor_moves.is_empty());
9610    }
9611
9612    #[tokio::test]
9613    async fn sync_budget_tied_scores_do_not_skip_an_unfinished_output() {
9614        let (mut engine, mut remote) = budget_page_engine(3);
9615        std::rc::Rc::make_mut(&mut remote.outputs)[2].score = 20.0;
9616        remote.node_cost_ms = 10_000;
9617        virtual_peer_budget(&mut engine, &remote);
9618        engine.set_gasp_remote_factory(Box::new(remote));
9619        let result = engine.start_gasp_sync().await.unwrap();
9620        let topic = &result.topics_synced["tm_test"];
9621        assert_eq!(topic.finalized_graphs, 2);
9622        assert_eq!(topic.cursor_moves[0].to, 10);
9623    }
9624
9625    #[tokio::test]
9626    async fn sync_budget_failed_graph_holds_the_cursor_below_later_finalizations() {
9627        let (mut engine, mut remote) = budget_page_engine(3000);
9628        let failed = remote.outputs[2].txid.clone();
9629        std::rc::Rc::make_mut(&mut remote.nodes).remove(&failed);
9630        virtual_peer_budget(&mut engine, &remote);
9631        engine.set_gasp_remote_factory(Box::new(remote));
9632        let result = engine.start_gasp_sync().await.unwrap();
9633        let topic = &result.topics_synced["tm_test"];
9634        assert_eq!(topic.finalized_graphs, 4);
9635        assert_eq!(topic.cursor_moves[0].to, 20);
9636    }
9637
9638    struct ProofOnlyRootFetcher {
9639        proofs: HashMap<String, String>,
9640        requested: std::rc::Rc<Cell<u64>>,
9641    }
9642
9643    #[async_trait(?Send)]
9644    impl AncestorFetcher for ProofOnlyRootFetcher {
9645        async fn fetch_ancestor(&self, _: &str) -> Result<FetchedAncestor, GASPError> {
9646            assert!(
9647                self.requested.get() > 0,
9648                "root proof request must precede ancestry"
9649            );
9650            Err(GASPError::RemoteError("raw source unavailable".to_string()))
9651        }
9652        async fn verified_proof_for(&self, txid: &str) -> Option<String> {
9653            self.requested.set(self.requested.get() + 1);
9654            self.proofs.get(txid).cloned()
9655        }
9656    }
9657
9658    #[tokio::test]
9659    async fn copy_sync_leaves_completion_courier_untouched() {
9660        let (mut engine, mut remote) = budget_page_engine(1);
9661        remote.node_cost_ms = 0;
9662        let txid = remote.outputs[0].txid.clone();
9663        let proof = std::rc::Rc::make_mut(&mut remote.nodes)
9664            .get_mut(&txid)
9665            .unwrap()
9666            .proof
9667            .take()
9668            .unwrap();
9669        let completion_asks = std::rc::Rc::new(Cell::new(0));
9670        let sync_asks = std::rc::Rc::new(Cell::new(0));
9671        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9672            proofs: HashMap::new(),
9673            requested: completion_asks.clone(),
9674        }));
9675        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9676            proofs: HashMap::from([(txid.clone(), proof.clone())]),
9677            requested: sync_asks.clone(),
9678        }));
9679        engine.set_gasp_remote_factory(Box::new(remote));
9680        let result = engine.start_gasp_sync().await.unwrap();
9681        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
9682        assert_eq!(result.topics_synced["tm_test"].roots_proven, 1);
9683        assert_eq!(result.topics_synced["tm_test"].roots_unproven, 0);
9684        let stored = engine
9685            .storage()
9686            .find_output(&txid, 0, Some("tm_test"), None, true)
9687            .await
9688            .unwrap()
9689            .unwrap();
9690        assert!(
9691            crate::beef_limits::own_proof(stored.beef.as_ref().unwrap(), &txid)
9692                .unwrap()
9693                .is_some()
9694        );
9695        assert_eq!(completion_asks.get(), 0);
9696        assert_eq!(sync_asks.get(), 1);
9697        let served = engine
9698            .provide_foreign_gasp_node(&format!("{txid}.0"), &txid, 0)
9699            .await
9700            .unwrap();
9701        assert_eq!(served.proof, Some(proof));
9702    }
9703
9704    #[tokio::test]
9705    async fn sync_budget_proofless_root_uses_own_proof_source_without_raw_refetch() {
9706        for proven in [true, false] {
9707            let (mut engine, mut remote) = budget_page_engine(1);
9708            let txid = remote.outputs[0].txid.clone();
9709            let proof = remote.nodes[&txid].proof.clone().unwrap();
9710            std::rc::Rc::make_mut(&mut remote.nodes)
9711                .get_mut(&txid)
9712                .unwrap()
9713                .proof = None;
9714            let requested = std::rc::Rc::new(Cell::new(0));
9715            engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9716                proofs: if proven {
9717                    HashMap::from([(txid.clone(), proof)])
9718                } else {
9719                    HashMap::new()
9720                },
9721                requested: requested.clone(),
9722            }));
9723            virtual_peer_budget(&mut engine, &remote);
9724            engine.set_gasp_remote_factory(Box::new(remote));
9725            let result = engine.start_gasp_sync().await.unwrap();
9726            assert!(requested.get() > 0);
9727            assert_eq!(
9728                result.topics_synced["tm_test"].finalized_graphs,
9729                u64::from(proven)
9730            );
9731            assert_eq!(
9732                engine
9733                    .storage
9734                    .find_output(&txid, 0, Some("tm_test"), None, true)
9735                    .await
9736                    .unwrap()
9737                    .is_some(),
9738                proven
9739            );
9740            if !proven {
9741                assert!(result.topics_synced["tm_test"].cursor_moves.is_empty());
9742            }
9743        }
9744    }
9745
9746    #[tokio::test]
9747    async fn sync_budget_deferred_root_resumes_when_own_proof_becomes_available() {
9748        let (mut engine, mut remote) = budget_page_engine(1);
9749        let txid = remote.outputs[0].txid.clone();
9750        // Engine 0.1.15: the peer is asked for the parent the fetcher cannot
9751        // serve. This witness's subject is a paid walk kept as a deferral,
9752        // so the peer answers that parent with a transport fault (#56).
9753        let parent = bsv_rs::transaction::Transaction::from_hex(&remote.nodes[&txid].raw_tx)
9754            .unwrap()
9755            .inputs[0]
9756            .source_txid
9757            .clone()
9758            .unwrap();
9759        remote.faults_for.borrow_mut().insert(parent);
9760        let proof = remote.nodes[&txid].proof.clone().unwrap();
9761        std::rc::Rc::make_mut(&mut remote.nodes)
9762            .get_mut(&txid)
9763            .unwrap()
9764            .proof = None;
9765        let requested = std::rc::Rc::new(Cell::new(0));
9766        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9767            proofs: HashMap::new(),
9768            requested: requested.clone(),
9769        }));
9770        virtual_peer_budget(&mut engine, &remote);
9771        engine.set_graph_budget(
9772            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9773            5,
9774            5000,
9775        );
9776        engine.set_gasp_remote_factory(Box::new(remote));
9777        let held = engine.start_gasp_sync().await.unwrap();
9778        assert_eq!(held.topics_synced["tm_test"].finalized_graphs, 0);
9779        assert_eq!(held.topics_synced["tm_test"].deferred_graphs, 1);
9780        assert_eq!(held.topics_synced["tm_test"].cursor_moves[0].to, 10);
9781        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9782            proofs: HashMap::from([(txid.clone(), proof)]),
9783            requested,
9784        }));
9785        engine
9786            .storage()
9787            .update_last_interaction("https://budget.example", "tm_test", 20)
9788            .await
9789            .unwrap();
9790        let landed = engine.start_gasp_sync().await.unwrap();
9791        assert_eq!(landed.topics_synced["tm_test"].resumed_graphs, 1);
9792        assert_eq!(landed.topics_synced["tm_test"].finalized_graphs, 1);
9793        assert!(landed.topics_synced["tm_test"].cursor_moves.is_empty());
9794        assert!(engine
9795            .storage
9796            .find_output(&txid, 0, Some("tm_test"), None, true)
9797            .await
9798            .unwrap()
9799            .is_some());
9800    }
9801
9802    #[tokio::test]
9803    async fn proofs_deadline_stalls_become_durable_after_three_runs() {
9804        let (mut engine, remote) = budget_page_engine(1);
9805        let elapsed = remote.elapsed.clone();
9806        let root = format!("{}.0", remote.outputs[0].txid);
9807        engine.set_graph_budget(
9808            std::rc::Rc::new(move |ms| {
9809                let until = elapsed.get() + ms;
9810                let elapsed = elapsed.clone();
9811                Box::pin(std::future::poll_fn(move |_| {
9812                    if elapsed.get() >= until {
9813                        std::task::Poll::Ready(())
9814                    } else {
9815                        std::task::Poll::Pending
9816                    }
9817                }))
9818            }),
9819            10,
9820            1_000,
9821        );
9822        engine.set_gasp_remote_factory(Box::new(remote));
9823        for round in 1..=crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9824            let result = engine.start_gasp_sync().await.unwrap();
9825            let topic = &result.topics_synced["tm_test"];
9826            if round < crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9827                assert!(topic.cursor_moves.is_empty());
9828                assert_eq!(topic.cursor_held_by, vec![root.clone()]);
9829            } else {
9830                assert_eq!(topic.cursor_moves[0].to, 10);
9831                assert_eq!(topic.deferred_graphs, 1);
9832            }
9833        }
9834        let held = engine
9835            .storage()
9836            .get_deferred_graph("https://budget.example", "tm_test", &root)
9837            .await
9838            .unwrap()
9839            .unwrap();
9840        assert!(held.cursor_passable);
9841        assert!(held.nodes.is_empty());
9842    }
9843
9844    /// 0.1.8 (the cursor): a graph the per-PEER deadline cuts before its
9845    /// root comes back is held at once (`peer_deadline`), the cursor passes
9846    /// it to the page's last finished score, and the next run resumes it
9847    /// ahead of the pages until it converges. On 0.1.7 the cut was a retry
9848    /// for three runs, the cursor stayed at 20 and `cursor_held_by` named it.
9849    #[tokio::test]
9850    async fn cursor_018_the_graph_in_flight_at_the_peer_deadline_is_held_and_passed() {
9851        let (mut engine, remote) = budget_page_engine(6);
9852        let slow = remote.outputs[2].txid.clone();
9853        let root = format!("{slow}.0");
9854        *remote.slow_once.borrow_mut() = Some((slow.clone(), 60_000));
9855        virtual_peer_budget(&mut engine, &remote);
9856        engine.set_graph_budget(
9857            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9858            100,
9859            60_000,
9860        );
9861        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9862        let result = engine.start_gasp_sync().await.unwrap();
9863        let topic = &result.topics_synced["tm_test"];
9864        assert_eq!(topic.finalized_graphs, 2);
9865        assert_eq!(topic.deadline_dropped_graphs, 1);
9866        assert_eq!(topic.deferred_graphs, 1);
9867        assert_eq!(topic.cursor_moves.len(), 1);
9868        assert_eq!(topic.cursor_moves[0].to, 30, "past the held graph");
9869        assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
9870        let held = engine
9871            .storage()
9872            .get_deferred_graph("https://budget.example", "tm_test", &root)
9873            .await
9874            .unwrap()
9875            .unwrap();
9876        assert!(held.cursor_passable);
9877        assert_eq!(held.reason, "peer_deadline");
9878        assert!(held.nodes.is_empty());
9879        assert_eq!(held.score, 30);
9880
9881        let result = engine.start_gasp_sync().await.unwrap();
9882        let topic = &result.topics_synced["tm_test"];
9883        assert_eq!(topic.resumed_graphs, 1);
9884        assert_eq!(topic.converged_graphs, 1);
9885        assert_eq!(topic.finalized_graphs, 4, "the held graph and 40, 50, 60");
9886        assert_eq!(topic.cursor_moves[0].to, 60);
9887        assert!(engine
9888            .storage()
9889            .get_deferred_graph("https://budget.example", "tm_test", &root)
9890            .await
9891            .unwrap()
9892            .is_none());
9893        for output in remote.outputs.iter() {
9894            assert!(engine
9895                .storage
9896                .find_output(&output.txid, 0, Some("tm_test"), None, true)
9897                .await
9898                .unwrap()
9899                .is_some());
9900        }
9901    }
9902
9903    /// Every root's advert expired (the walk refuses each after its node).
9904    struct ExpiredRoots(MockTopicManager);
9905
9906    #[async_trait(?Send)]
9907    impl TopicManagerTrait for ExpiredRoots {
9908        async fn identify_admissible_outputs(
9909            &self,
9910            tx: &Transaction,
9911            previous_coins: &[u8],
9912            off_chain_values: Option<&[u8]>,
9913            mode: SubmitMode,
9914            context: &TopicAdmittanceContext,
9915        ) -> Result<AdmittanceInstructions, TopicManagerError> {
9916            self.0
9917                .identify_admissible_outputs(tx, previous_coins, off_chain_values, mode, context)
9918                .await
9919        }
9920        async fn get_documentation(&self) -> String {
9921            self.0.get_documentation().await
9922        }
9923        async fn get_metadata(&self) -> ServiceMetadata {
9924            self.0.get_metadata().await
9925        }
9926        fn sync_expiry(&self, _: &bsv_rs::script::LockingScript) -> Option<u64> {
9927            Some(1)
9928        }
9929    }
9930
9931    /// A page of dead adverts above one root the peer cannot serve (a gap
9932    /// the cursor holds below), on a shared store.
9933    fn dead_page_engine(count: u32) -> (Engine, BudgetPageRemote, std::rc::Rc<MemoryStorage>) {
9934        let (mut engine, mut remote) = budget_page_engine(count);
9935        let gap = remote.outputs[0].txid.clone();
9936        std::rc::Rc::make_mut(&mut remote.nodes).remove(&gap);
9937        engine.managers.insert(
9938            "tm_test".to_string(),
9939            Box::new(ExpiredRoots(MockTopicManager::admitting(vec![0]))),
9940        );
9941        let store = std::rc::Rc::new(MemoryStorage::new());
9942        engine.storage = Box::new(store.clone());
9943        engine.set_graph_budget(
9944            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9945            100,
9946            60_000,
9947        );
9948        (engine, remote, store)
9949    }
9950
9951    /// 0.1.8: a sync that only refused expired adverts (the cursor held by
9952    /// a gap below them) did work: a yield, never a step of the yieldless
9953    /// streak. On 0.1.7 it was yieldless.
9954    #[tokio::test]
9955    async fn cursor_018_a_sync_that_only_refuses_the_dead_is_a_yield() {
9956        let (mut engine, mut remote, store) = dead_page_engine(3);
9957        remote.node_cost_ms = 0;
9958        virtual_peer_budget(&mut engine, &remote);
9959        engine.set_gasp_remote_factory(Box::new(remote));
9960        let result = engine.start_gasp_sync().await.unwrap();
9961        let topic = &result.topics_synced["tm_test"];
9962        assert_eq!(topic.expired_refused, 2);
9963        assert_eq!(topic.finalized_graphs, 0);
9964        assert!(topic.cursor_moves.is_empty());
9965        let host = crate::gasp::peer_origin("https://budget.example");
9966        assert_eq!(store.peer_yieldless_syncs(&host, "tm_test"), 0);
9967        let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9968        assert_eq!(health.consecutive_failures, 0);
9969    }
9970
9971    /// 0.1.8: a peer that served every page and every node, whose slice
9972    /// the per-peer deadline ended in each of nine runs in a row while it
9973    /// refused dead adverts (the cursor held by a gap below them), is never
9974    /// quarantined: each run did work. On 0.1.7 each was a failed attempt
9975    /// and the ninth was skipped.
9976    #[tokio::test]
9977    async fn cursor_018_deadline_drops_in_a_row_do_not_quarantine_a_serving_peer() {
9978        let (mut engine, remote, store) = dead_page_engine(200);
9979        virtual_peer_budget(&mut engine, &remote);
9980        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9981        let host = crate::gasp::peer_origin("https://budget.example");
9982        let runs = crate::gasp::PEER_QUARANTINE_THRESHOLD + 1;
9983        let gap = format!("{}.0", remote.outputs[0].txid);
9984        for run in 1..=runs {
9985            // Engine 0.1.15: the gap root is absence (`not_found`), counted
9986            // on runs at least ABSENCE_GAP_SECS apart; these runs follow at
9987            // once, so the gap is passed by hand (#56).
9988            if let Some(mut record) = store
9989                .get_deferred_graph("https://budget.example", "tm_test", &gap)
9990                .await
9991                .unwrap()
9992                .filter(|r| r.reason.starts_with("retry:fault:"))
9993            {
9994                record.retry_at = 0;
9995                store.put_deferred_graph(&record).await.unwrap();
9996            }
9997            let asked = remote.asked.borrow().len();
9998            let result = engine.start_gasp_sync().await.unwrap();
9999            let topic = &result.topics_synced["tm_test"];
10000            assert!(remote.asked.borrow().len() > asked, "run {run}: attempted");
10001            assert_eq!(topic.deadline_dropped_graphs, 1, "run {run}");
10002            assert!(topic.expired_refused > 0, "run {run}: {topic:?}");
10003            // 0.1.9: the gap is a root the peer lists and does not serve
10004            // (`not_found` since 0.1.15, `not_served` before); its third
10005            // faulted run records it unanchorable and the cursor passes it.
10006            if run < u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
10007                assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10008            } else if run == u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
10009                assert_eq!(topic.unanchorable_graphs, 1, "run {run}: {topic:?}");
10010                assert!(!topic.cursor_moves.is_empty(), "run {run}: passed");
10011            }
10012            let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
10013            assert_eq!(health.consecutive_failures, 0, "run {run}");
10014        }
10015        // A refused root is never fetched again. The one root fetched twice
10016        // per cut is the graph in flight: its answer was lost with the slice
10017        // and its hold is resumed (and refused) by the next run.
10018        let asked = remote.asked.borrow();
10019        let mut dead: Vec<&String> = asked
10020            .iter()
10021            .filter(|t| **t != remote.outputs[0].txid)
10022            .collect();
10023        let fetched = dead.len();
10024        dead.sort();
10025        dead.dedup();
10026        assert_eq!(
10027            fetched - dead.len(),
10028            (runs - 1) as usize,
10029            "one per resumed cut"
10030        );
10031        assert_eq!(store.expired_outpoints("tm_test").len(), dead.len() - 1);
10032    }
10033
10034    /// A courier that serves ONE ancestor, and only once told to (engine
10035    /// 0.1.9): before, every rung "could not serve" it.
10036    struct GatedCourier {
10037        txid: String,
10038        raw_tx: String,
10039        proof: String,
10040        serve: std::rc::Rc<Cell<bool>>,
10041        asked: std::rc::Rc<Cell<u64>>,
10042    }
10043
10044    #[async_trait(?Send)]
10045    impl AncestorFetcher for GatedCourier {
10046        async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10047            self.asked.set(self.asked.get() + 1);
10048            if self.serve.get() && txid == self.txid {
10049                Ok(FetchedAncestor {
10050                    raw_tx: self.raw_tx.clone(),
10051                    proof: Some(self.proof.clone()),
10052                })
10053            } else {
10054                Err(GASPError::RemoteError(format!(
10055                    "no rung served the raw of {txid}"
10056                )))
10057            }
10058        }
10059        async fn verified_proof_for(&self, _: &str) -> Option<String> {
10060            None
10061        }
10062    }
10063
10064    /// The fixture of 0.1.9: a page of three (scores 10, 20, 30) whose
10065    /// lowest root is served UNPROVEN, so its walk needs its parent, which
10066    /// only the courier can serve (a mined parent, proven at height 900).
10067    #[tokio::test]
10068    async fn load_0112_definitive_absence_strikes_a_resumed_obligation() {
10069        let mut fixture = obligation_fixture(1, 1, 0).await;
10070        fixture
10071            .engine
10072            .set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10073                calls: std::rc::Rc::new(Cell::new(0)),
10074                bounded: false,
10075            }));
10076        // Engine 0.1.15 (#56): the rungs' absence counts one run, never a
10077        // strike by itself (it struck at once on 0.1.12 to 0.1.14); three
10078        // runs with a gap strike it. The obligation stays passable.
10079        let host = "https://budget.example";
10080        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10081        for run in 1..=runs {
10082            let result = fixture.engine.start_gasp_sync().await.unwrap();
10083            let topic = &result.topics_synced["tm_test"];
10084            let record = fixture
10085                .store
10086                .get_deferred_graph(host, "tm_test", &fixture.obligations[0])
10087                .await
10088                .unwrap()
10089                .unwrap();
10090            assert_eq!(record.faults, run);
10091            assert!(record.cursor_passable);
10092            if run == 1 {
10093                assert_eq!(topic.finalized_graphs, 1);
10094            }
10095            if run < runs {
10096                assert_eq!(topic.unanchorable_graphs, 0);
10097                assert_eq!(record.reason, "retry:fault:not_found");
10098                let mut record = record;
10099                record.retry_at = 0;
10100                fixture.store.put_deferred_graph(&record).await.unwrap();
10101            } else {
10102                assert_eq!(topic.unanchorable_graphs, 1);
10103                assert_eq!(record.reason, "unanchorable:not_found");
10104            }
10105        }
10106    }
10107
10108    #[tokio::test]
10109    async fn load_0112_root_time_is_clamped_and_named() {
10110        let (mut engine, mut remote) = budget_page_engine(1);
10111        remote.node_cost_ms = 16_000;
10112        let elapsed = remote.elapsed.clone();
10113        let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10114            let clock = elapsed.clone();
10115            let due = clock.get() + ms;
10116            Box::pin(std::future::poll_fn(move |_| {
10117                if clock.get() >= due {
10118                    std::task::Poll::Ready(())
10119                } else {
10120                    std::task::Poll::Pending
10121                }
10122            }))
10123        });
10124        engine.set_peer_sync_budget(sleep.clone(), 30_000);
10125        engine.set_graph_budget(sleep, 100, 60_000);
10126        engine.set_gasp_remote_factory(Box::new(remote));
10127        let result = engine.start_gasp_sync().await.unwrap();
10128        let topic = &result.topics_synced["tm_test"];
10129        assert_eq!(topic.finalized_graphs, 0);
10130        assert_eq!(topic.root_bounds[0].reason, "time");
10131        assert!(!topic.cursor_held_by.is_empty() || topic.deferred_graphs > 0);
10132    }
10133
10134    #[tokio::test]
10135    async fn load_0112_storage_counts_are_attempts_at_the_boundary() {
10136        let engine = make_engine(vec![0]);
10137        let meter = crate::meter::LoadMeter::default();
10138        meter
10139            .scope(async {
10140                assert_eq!(
10141                    engine
10142                        .storage
10143                        .get_last_interaction("peer", "topic")
10144                        .await
10145                        .unwrap(),
10146                    0
10147                );
10148                engine
10149                    .storage
10150                    .update_last_interaction("peer", "topic", 12)
10151                    .await
10152                    .unwrap();
10153                assert_eq!(
10154                    engine
10155                        .storage
10156                        .get_last_interaction("peer", "topic")
10157                        .await
10158                        .unwrap(),
10159                    12
10160                );
10161            })
10162            .await;
10163        assert_eq!(
10164            (
10165                meter.snapshot().storage_reads,
10166                meter.snapshot().storage_writes
10167            ),
10168            (2, 1)
10169        );
10170        engine
10171            .storage
10172            .get_last_interaction("peer", "topic")
10173            .await
10174            .unwrap();
10175        assert_eq!(meter.snapshot().storage_reads, 2);
10176    }
10177
10178    struct LoadCourier {
10179        calls: std::rc::Rc<Cell<u64>>,
10180        bounded: bool,
10181    }
10182    #[async_trait(?Send)]
10183    impl crate::gasp::AncestorFetcher for LoadCourier {
10184        async fn fetch_ancestor(
10185            &self,
10186            txid: &str,
10187        ) -> Result<crate::gasp::FetchedAncestor, crate::gasp::GASPError> {
10188            self.calls.set(self.calls.get() + 1);
10189            if self.bounded {
10190                for _ in 0..13 {
10191                    crate::meter::courier_request()
10192                        .map_err(|e| crate::gasp::GASPError::Other(e.into()))?;
10193                }
10194                Err(crate::gasp::GASPError::RemoteError(
10195                    "still missing a proof".into(),
10196                ))
10197            } else {
10198                for _ in 0..3 {
10199                    crate::meter::courier_request().unwrap();
10200                }
10201                Err(crate::gasp::GASPError::DefinitiveNotFound(txid.into()))
10202            }
10203        }
10204        async fn verified_proof_for(&self, _: &str) -> Option<String> {
10205            None
10206        }
10207    }
10208
10209    #[tokio::test]
10210    async fn load_0112_definitive_absence_is_shared_across_peers_and_topics() {
10211        let UnanchorableFixture {
10212            mut engine,
10213            remote,
10214            root,
10215            ..
10216        } = unanchorable_fixture();
10217        let calls = std::rc::Rc::new(Cell::new(0));
10218        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10219            calls: calls.clone(),
10220            bounded: false,
10221        }));
10222        engine.managers.insert(
10223            "tm_other".into(),
10224            Box::new(MockTopicManager::admitting(vec![0])),
10225        );
10226        for topic in ["tm_test", "tm_other"] {
10227            engine.config.sync_configuration.insert(
10228                topic.into(),
10229                SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
10230            );
10231        }
10232        let result = engine.start_gasp_sync().await.unwrap();
10233        assert_eq!(calls.get(), 1, "one ladder per missing ancestor per run");
10234        assert_eq!(result.cost.courier_not_found_memo_hits, 3);
10235        assert_eq!(result.cost.courier_rungs, 3);
10236        for (topic, res) in &result.topics_synced {
10237            // Engine 0.1.15 (#56): the rungs' absence counts one run on each
10238            // (peer, topic), not a strike (2 unanchorable on 0.1.12 to 0.1.14).
10239            assert_eq!(res.unanchorable_graphs, 0, "{res:?}");
10240            assert_eq!(
10241                res.cost.courier_rungs + res.cost.peer_requests,
10242                res.cost.calls_out
10243            );
10244            for host in ["https://a.example", "https://b.example"] {
10245                let mut record = engine
10246                    .storage
10247                    .get_deferred_graph(host, topic, &root)
10248                    .await
10249                    .unwrap()
10250                    .unwrap();
10251                assert_eq!(record.reason, "retry:fault:not_found");
10252                assert_eq!(record.faults, 1);
10253                assert!(!record.cursor_passable);
10254                record.retry_at = 0;
10255                engine.storage.put_deferred_graph(&record).await.unwrap();
10256            }
10257        }
10258        assert!(!remote.asked.borrow().is_empty());
10259        engine.start_gasp_sync().await.unwrap();
10260        assert_eq!(calls.get(), 2, "another run can retry absence");
10261    }
10262
10263    #[tokio::test]
10264    async fn load_0112_courier_bound_defers_the_root_and_names_it() {
10265        let UnanchorableFixture {
10266            mut engine, root, ..
10267        } = unanchorable_fixture();
10268        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10269            calls: std::rc::Rc::new(Cell::new(0)),
10270            bounded: true,
10271        }));
10272        let result = engine.start_gasp_sync().await.unwrap();
10273        let record = engine
10274            .storage
10275            .get_deferred_graph("https://budget.example", "tm_test", &root)
10276            .await
10277            .unwrap()
10278            .expect("bounded root must be retained");
10279        assert_eq!(record.reason, "courier_rungs");
10280        assert!(!record.nodes.is_empty());
10281        assert_eq!(result.cost.courier_rungs, 12);
10282        assert_eq!(
10283            result.topics_synced["tm_test"].root_bounds[0].reason,
10284            "courier_rungs"
10285        );
10286        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 2);
10287    }
10288
10289    #[tokio::test]
10290    async fn load_0112_refused_save_does_not_restart_a_bounded_root() {
10291        let UnanchorableFixture {
10292            mut engine,
10293            store,
10294            root,
10295            ..
10296        } = unanchorable_fixture();
10297        store.set_deferred_graph_ceiling(Some(0));
10298        let calls = std::rc::Rc::new(Cell::new(0));
10299        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10300            calls: calls.clone(),
10301            bounded: true,
10302        }));
10303        let result = engine.start_gasp_sync().await.unwrap();
10304        let topic = &result.topics_synced["tm_test"];
10305        assert_eq!(calls.get(), 1, "no retry after a hard root cut");
10306        assert_eq!(topic.root_bounds.len(), 1);
10307        assert_eq!(result.cost.courier_rungs, 12);
10308        assert!(topic.cursor_held_by.contains(&root));
10309        assert_eq!(
10310            topic.strikes_refused, 0,
10311            "a budget cut is not a fault strike"
10312        );
10313        assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10314        assert_eq!(topic.finalized_graphs, 2);
10315    }
10316
10317    #[tokio::test]
10318    async fn load_0112_refused_save_does_not_extend_root_time() {
10319        let (mut engine, mut remote) = budget_page_engine(1);
10320        remote.node_cost_ms = 16_000;
10321        let elapsed = remote.elapsed.clone();
10322        let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10323            let clock = elapsed.clone();
10324            let due = clock.get() + ms;
10325            Box::pin(std::future::poll_fn(move |_| {
10326                if clock.get() >= due {
10327                    std::task::Poll::Ready(())
10328                } else {
10329                    std::task::Poll::Pending
10330                }
10331            }))
10332        });
10333        let store = std::rc::Rc::new(MemoryStorage::new());
10334        store.set_deferred_graph_ceiling(Some(0));
10335        engine.storage = Box::new(store);
10336        engine.set_peer_sync_budget(sleep.clone(), 30_000);
10337        engine.set_graph_budget(sleep, 100, 60_000);
10338        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10339        let result = engine.start_gasp_sync().await.unwrap();
10340        let topic = &result.topics_synced["tm_test"];
10341        assert_eq!(
10342            remote.asked.borrow().len(),
10343            1,
10344            "no request after the time cut"
10345        );
10346        assert_eq!(topic.root_bounds.len(), 1);
10347        assert_eq!(topic.root_bounds[0].reason, "time");
10348        assert_eq!(topic.finalized_graphs, 0);
10349        assert!(!topic.cursor_held_by.is_empty());
10350    }
10351
10352    #[tokio::test]
10353    async fn load_0112_call_limit_fallback_keeps_the_root_deadline() {
10354        let UnanchorableFixture {
10355            mut engine,
10356            mut remote,
10357            store,
10358            root,
10359            serve,
10360            ..
10361        } = unanchorable_fixture();
10362        store.set_deferred_graph_ceiling(Some(0));
10363        serve.set(true);
10364        remote.node_cost_ms = 8_000;
10365        struct DelayedCourier {
10366            inner: std::rc::Rc<dyn AncestorFetcher>,
10367            clock: std::rc::Rc<Cell<u64>>,
10368        }
10369        #[async_trait(?Send)]
10370        impl AncestorFetcher for DelayedCourier {
10371            async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10372                self.clock.set(self.clock.get() + 8_000);
10373                tokio::task::yield_now().await;
10374                self.inner.fetch_ancestor(txid).await
10375            }
10376            async fn verified_proof_for(&self, txid: &str) -> Option<String> {
10377                self.inner.verified_proof_for(txid).await
10378            }
10379        }
10380        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(DelayedCourier {
10381            inner: engine.gasp_ancestor_fetcher.as_ref().unwrap().clone(),
10382            clock: remote.elapsed.clone(),
10383        }));
10384        let elapsed = remote.elapsed.clone();
10385        let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10386            let clock = elapsed.clone();
10387            let due = clock.get() + ms;
10388            Box::pin(std::future::poll_fn(move |_| {
10389                if clock.get() >= due {
10390                    std::task::Poll::Ready(())
10391                } else {
10392                    std::task::Poll::Pending
10393                }
10394            }))
10395        });
10396        engine.set_peer_sync_budget(sleep.clone(), 30_000);
10397        engine.set_graph_budget(sleep, 1, 60_000);
10398        engine.set_gasp_remote_factory(Box::new(remote));
10399        let result = engine.start_gasp_sync().await.unwrap();
10400        let topic = &result.topics_synced["tm_test"];
10401        assert!(
10402            topic
10403                .root_bounds
10404                .iter()
10405                .any(|b| b.outpoint == root && b.reason == "time"),
10406            "{topic:?}"
10407        );
10408        assert!(topic.cursor_held_by.contains(&root));
10409        assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10410    }
10411
10412    #[tokio::test]
10413    async fn load_0112_cost_matches_the_remote_call_log() {
10414        let (mut engine, remote) = budget_page_engine(2);
10415        remote.elapsed.set(0);
10416        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10417        let result = engine.start_gasp_sync().await.unwrap();
10418        let actual_calls = (remote.requests.borrow().len() + remote.asked.borrow().len()) as u64;
10419        assert_eq!(result.cost.peer_requests, actual_calls);
10420        assert_eq!(result.cost.calls_out, actual_calls);
10421        assert!(result.cost.storage_reads > 0);
10422        assert!(result.cost.storage_writes > 0);
10423        assert_eq!(
10424            result.cost.storage_reads,
10425            result.topics_synced["tm_test"].cost.storage_reads
10426        );
10427        assert_eq!(
10428            result.cost.storage_writes,
10429            result.topics_synced["tm_test"].cost.storage_writes
10430        );
10431    }
10432
10433    struct UnanchorableFixture {
10434        engine: Engine,
10435        remote: BudgetPageRemote,
10436        store: std::rc::Rc<MemoryStorage>,
10437        root: String,
10438        serve: std::rc::Rc<Cell<bool>>,
10439        asked: std::rc::Rc<Cell<u64>>,
10440    }
10441
10442    fn unanchorable_fixture() -> UnanchorableFixture {
10443        let (mut engine, mut remote) = budget_page_engine(3);
10444        remote.node_cost_ms = 0;
10445        let parent = child_of(&format!("{:064x}", 0xab), 0);
10446        let child = child_of(&parent.id(), 0);
10447        let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
10448        for n in 1..=3u32 {
10449            tracker.add_root(n, child_of(&format!("{n:064x}"), 0).id());
10450        }
10451        tracker.add_root(900, parent.id());
10452        engine.chain_tracker = Some(Box::new(tracker));
10453        let replaced = std::mem::replace(
10454            &mut std::rc::Rc::make_mut(&mut remote.outputs)[0].txid,
10455            child.id(),
10456        );
10457        let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10458        nodes.remove(&replaced);
10459        nodes.insert(
10460            child.id(),
10461            crate::types::GASPNode {
10462                graph_id: String::new(),
10463                raw_tx: child.to_hex(),
10464                output_index: 0,
10465                proof: None,
10466                tx_metadata: None,
10467                output_metadata: None,
10468                inputs: None,
10469            },
10470        );
10471        // Engine 0.1.15: the peer is asked for an input the courier cannot
10472        // serve. This fixture's subject is the courier's fault, so its peer
10473        // answers that input with a transport fault, which counts nothing
10474        // (#56); the absence witnesses clear it to hear the peer's word.
10475        remote.faults_for.borrow_mut().insert(parent.id());
10476        let serve = std::rc::Rc::new(Cell::new(false));
10477        let asked = std::rc::Rc::new(Cell::new(0));
10478        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(GatedCourier {
10479            txid: parent.id(),
10480            raw_tx: parent.to_hex(),
10481            proof: single_leaf_bump_hex(&parent.id(), 900),
10482            serve: serve.clone(),
10483            asked: asked.clone(),
10484        }));
10485        let store = std::rc::Rc::new(MemoryStorage::new());
10486        engine.storage = Box::new(store.clone());
10487        virtual_peer_budget(&mut engine, &remote);
10488        engine.set_graph_budget(
10489            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10490            100,
10491            60_000,
10492        );
10493        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10494        UnanchorableFixture {
10495            engine,
10496            remote,
10497            store,
10498            root: format!("{}.0", child.id()),
10499            serve,
10500            asked,
10501        }
10502    }
10503
10504    /// 0.1.9 (unanchorable): a root whose ancestor the courier cannot serve
10505    /// faults three runs in a row; each run names it with its class
10506    /// (`courier`) and holds the cursor below it until the third, which
10507    /// records it `unanchorable:courier` and passes the cursor to the page's
10508    /// last finished score. Inside its backoff it costs no request. A later
10509    /// run in which the courier serves the ancestor admits it and clears the
10510    /// record. On 0.1.8 the cursor stayed at 0 on every run (fact 2: the
10511    /// keeper's hand).
10512    #[tokio::test]
10513    async fn unanchorable_019_a_root_faulted_three_runs_is_recorded_passed_and_admitted_later() {
10514        let UnanchorableFixture {
10515            engine,
10516            remote,
10517            store,
10518            root,
10519            serve,
10520            asked,
10521        } = unanchorable_fixture();
10522        let host = "https://budget.example";
10523        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10524        for run in 1..=runs {
10525            let result = engine.start_gasp_sync().await.unwrap();
10526            let topic = &result.topics_synced["tm_test"];
10527            assert_eq!(topic.faulted_roots.len(), 1, "run {run}: {topic:?}");
10528            let named = &topic.faulted_roots[0];
10529            assert_eq!(
10530                (named.peer.as_str(), named.outpoint.as_str()),
10531                (host, root.as_str())
10532            );
10533            assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10534            assert_eq!(named.faults, run, "run {run}");
10535            assert!(named.error.contains("no rung served"), "{named:?}");
10536            let record = engine
10537                .storage()
10538                .get_deferred_graph(host, "tm_test", &root)
10539                .await
10540                .unwrap()
10541                .unwrap();
10542            assert_eq!(record.faults, run);
10543            assert!(record.nodes.is_empty(), "the root alone, asked again");
10544            if run < runs {
10545                assert!(!named.unanchorable);
10546                assert_eq!(topic.unanchorable_graphs, 0);
10547                assert_eq!(record.reason, "retry:fault:courier");
10548                assert!(!record.cursor_passable);
10549                assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10550                assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10551            } else {
10552                assert!(named.unanchorable);
10553                assert_eq!(topic.unanchorable_graphs, 1);
10554                assert_eq!(record.reason, "unanchorable:courier");
10555                assert!(record.cursor_passable);
10556                assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
10557                assert_eq!(topic.cursor_moves.len(), 1);
10558                assert_eq!(
10559                    topic.cursor_moves[0].to, 30,
10560                    "the page's last finished score"
10561                );
10562                assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10563            }
10564            if run == 1 {
10565                assert_eq!(topic.finalized_graphs, 2, "20 and 30");
10566            }
10567        }
10568
10569        // Inside the backoff: the root is not asked of the peer or the
10570        // courier, nothing is named, and the quiet run is no attempt.
10571        let (courier_asks, node_asks) = (asked.get(), remote.asked.borrow().len());
10572        let streak = store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test");
10573        let result = engine.start_gasp_sync().await.unwrap();
10574        let topic = &result.topics_synced["tm_test"];
10575        assert!(topic.faulted_roots.is_empty(), "{topic:?}");
10576        assert!(topic.cursor_held_by.is_empty());
10577        assert_eq!(topic.unanchorable_graphs, 0);
10578        assert_eq!(asked.get(), courier_asks);
10579        assert_eq!(remote.asked.borrow().len(), node_asks);
10580        assert_eq!(
10581            store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test"),
10582            streak
10583        );
10584
10585        // The backoff runs out and the courier serves the ancestor: the
10586        // root is admitted as any root and its record is cleared.
10587        let mut record = engine
10588            .storage()
10589            .get_deferred_graph(host, "tm_test", &root)
10590            .await
10591            .unwrap()
10592            .unwrap();
10593        record.retry_at = 1;
10594        engine.storage().put_deferred_graph(&record).await.unwrap();
10595        serve.set(true);
10596        let result = engine.start_gasp_sync().await.unwrap();
10597        let topic = &result.topics_synced["tm_test"];
10598        assert_eq!(topic.finalized_graphs, 1, "{topic:?}");
10599        assert_eq!(topic.unanchorable_cleared, 1);
10600        assert!(topic.faulted_roots.is_empty());
10601        assert!(engine
10602            .storage()
10603            .get_deferred_graph(host, "tm_test", &root)
10604            .await
10605            .unwrap()
10606            .is_none());
10607        let (txid, _) = crate::gasp::parse_outpoint(&root).unwrap();
10608        assert!(engine
10609            .storage
10610            .find_output(&txid, 0, Some("tm_test"), None, true)
10611            .await
10612            .unwrap()
10613            .is_some());
10614    }
10615
10616    /// 0.1.9: only the run's LOWEST faulted root starts a fault record (the
10617    /// delta fold's D-M1: a peer that faults every node earns one record a
10618    /// run, not one per root); the others are named, class and all, and
10619    /// stay gaps above it.
10620    #[tokio::test]
10621    async fn unanchorable_019_only_the_lowest_faulted_root_is_recorded_and_all_are_named() {
10622        let (mut engine, mut remote) = budget_page_engine(4);
10623        remote.node_cost_ms = 0;
10624        // Engine 0.1.15 (#56): a root the peer does not hold is absence,
10625        // which counts on every root of a run (the 0.1.12 rule of the
10626        // rungs' absence, now the peers'). This witness's subject is D-M1
10627        // for the other striking classes, so its two faulting roots are
10628        // served unproven over a parent the courier cannot serve and the
10629        // peer answers with a transport fault (`courier`); on 0.1.9 to
10630        // 0.1.14 the peer did not hold them (`not_served`).
10631        for idx in [1usize, 3] {
10632            let parent = child_of(&format!("{:064x}", 0xdead_0000 + idx), 0);
10633            let child = child_of(&parent.id(), 0);
10634            let old = std::mem::replace(
10635                &mut std::rc::Rc::make_mut(&mut remote.outputs)[idx].txid,
10636                child.id(),
10637            );
10638            let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10639            nodes.remove(&old);
10640            nodes.insert(
10641                child.id(),
10642                crate::types::GASPNode {
10643                    graph_id: String::new(),
10644                    raw_tx: child.to_hex(),
10645                    output_index: 0,
10646                    proof: None,
10647                    tx_metadata: None,
10648                    output_metadata: None,
10649                    inputs: None,
10650                },
10651            );
10652            remote.faults_for.borrow_mut().insert(parent.id());
10653        }
10654        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
10655            asked: std::rc::Rc::default(),
10656            transient: std::rc::Rc::default(),
10657        }));
10658        let store = std::rc::Rc::new(MemoryStorage::new());
10659        engine.storage = Box::new(store.clone());
10660        virtual_peer_budget(&mut engine, &remote);
10661        engine.set_graph_budget(
10662            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10663            100,
10664            60_000,
10665        );
10666        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10667        let result = engine.start_gasp_sync().await.unwrap();
10668        let topic = &result.topics_synced["tm_test"];
10669        let named: Vec<(&str, &str, u32)> = topic
10670            .faulted_roots
10671            .iter()
10672            .map(|f| (f.outpoint.as_str(), f.class.as_str(), f.faults))
10673            .collect();
10674        let low = format!("{}.0", remote.outputs[1].txid);
10675        let high = format!("{}.0", remote.outputs[3].txid);
10676        assert_eq!(
10677            named,
10678            vec![
10679                (low.as_str(), crate::gasp::fault_class::COURIER, 1),
10680                (high.as_str(), crate::gasp::fault_class::COURIER, 0),
10681            ]
10682        );
10683        assert_eq!(topic.cursor_moves[0].to, 10, "below the lower gap");
10684        let host = "https://budget.example";
10685        assert!(store
10686            .get_deferred_graph(host, "tm_test", &high)
10687            .await
10688            .unwrap()
10689            .is_none());
10690        assert_eq!(
10691            store
10692                .get_deferred_graph(host, "tm_test", &low)
10693                .await
10694                .unwrap()
10695                .unwrap()
10696                .reason,
10697            "retry:fault:courier"
10698        );
10699    }
10700
10701    /// A record of the budget page's peer and topic, holding nothing but
10702    /// its root (engine 0.1.10 tests).
10703    fn bare_record(n: u32, score: u64, reason: &str, passable: bool) -> crate::gasp::DeferredGraph {
10704        let outpoint = format!("{:064x}.0", 0xdead_0000 + n);
10705        crate::gasp::DeferredGraph {
10706            peer: "https://budget.example".into(),
10707            topic: "tm_test".into(),
10708            outpoint: outpoint.clone(),
10709            score,
10710            nodes: Vec::new(),
10711            pending: vec![crate::gasp::PendingInput {
10712                outpoint: outpoint.clone(),
10713                graph_id: outpoint,
10714                metadata: true,
10715                spent_by: None,
10716                parent_proven: false,
10717            }],
10718            calls: 0,
10719            passes: 0,
10720            reason: reason.into(),
10721            configured: true,
10722            idle_faults: 0,
10723            cursor_passable: passable,
10724            faults: 0,
10725            retry_at: 0,
10726            idle_resumes: 0,
10727            unanchorable_retries: 0,
10728            expiry: None,
10729        }
10730    }
10731
10732    /// Fill the budget page's (peer, topic) with `count` root retry
10733    /// obligations the peer does not serve (fact 3 of the 0.1.10 brief:
10734    /// `held:retry`, nodes 0, below the cursor), returning their roots.
10735    async fn hold_obligations(engine: &Engine, count: usize) -> Vec<String> {
10736        let mut roots = Vec::new();
10737        for n in 0..count {
10738            let record = bare_record(n as u32, 1 + n as u64, "retry", true);
10739            roots.push(record.outpoint.clone());
10740            engine.storage().put_deferred_graph(&record).await.unwrap();
10741        }
10742        roots
10743    }
10744
10745    /// 0.1.10 (room): a (peer, topic) whose deferred slots are all held by
10746    /// root retry obligations that never progress still records the root
10747    /// that holds its cursor: a fault record is bounded on its own
10748    /// (the separate compact memo), so the strike is written on each
10749    /// faulted run and the third passes the cursor. On 0.1.9 every strike
10750    /// found "no room" (`gasp.rs:1717`), the root was named with no run and
10751    /// the cursor stayed at 0 for ever (fact 2: the keeper's eighth hand).
10752    #[tokio::test]
10753    async fn room_0110_a_full_store_still_records_the_cursors_holder_and_passes_it() {
10754        let UnanchorableFixture {
10755            engine,
10756            store,
10757            root,
10758            ..
10759        } = unanchorable_fixture();
10760        let host = "https://budget.example";
10761        let obligations =
10762            hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10763        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10764        for run in 1..=runs {
10765            let result = engine.start_gasp_sync().await.unwrap();
10766            let topic = &result.topics_synced["tm_test"];
10767            assert_eq!(topic.strikes_refused, 0, "run {run}: {topic:?}");
10768            let named = topic
10769                .faulted_roots
10770                .iter()
10771                .find(|f| f.outpoint == root)
10772                .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
10773            assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10774            assert_eq!(named.faults, run, "run {run}: the strike was written");
10775            assert!(named.refused.is_empty(), "{named:?}");
10776            if run < runs {
10777                assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10778                assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10779            } else {
10780                assert!(named.unanchorable);
10781                assert_eq!(topic.unanchorable_graphs, 1);
10782                assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10783                assert_eq!(topic.cursor_moves[0].to, 30, "passed");
10784                assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10785            }
10786        }
10787        // The obligations are all still held beside the fault record.
10788        let held: Vec<String> = store
10789            .deferred_graphs()
10790            .into_iter()
10791            .map(|r| r.outpoint)
10792            .collect();
10793        // 0.1.14: the fault fact no longer consumes a partial-graph row.
10794        assert_eq!(held.len(), obligations.len(), "{held:?}");
10795        assert_eq!(store.fault_memos().len(), 1);
10796        assert!(obligations.iter().all(|o| held.contains(o)));
10797        let record = store
10798            .get_deferred_graph(host, "tm_test", &root)
10799            .await
10800            .unwrap()
10801            .unwrap();
10802        assert_eq!(record.reason, "unanchorable:courier");
10803    }
10804
10805    /// 0.1.10 (the reason, readable): a strike the bound or the storage
10806    /// refuses is counted (`strikes_refused`) and named on the root
10807    /// (`refused`): `no_room` at the storage's own ceiling, `store_fault`
10808    /// on its fault. The compact memo replaces the old 32-fact limit.
10809    /// The root stays a gap the cursor holds below, as on 0.1.9, where the
10810    /// refusal was a `warn!` alone.
10811    #[tokio::test]
10812    async fn room_0110_a_refused_strike_is_counted_and_named() {
10813        let UnanchorableFixture {
10814            engine,
10815            store,
10816            root,
10817            ..
10818        } = unanchorable_fixture();
10819        // 0.1.14: only an actual storage ceiling refuses a compact fact.
10820        store.set_fault_memo_ceiling(Some(0));
10821        let refused_as = |result: &super::GASPSyncResult, why: &str| {
10822            let topic = &result.topics_synced["tm_test"];
10823            assert_eq!(topic.strikes_refused, 1, "{topic:?}");
10824            let named = topic
10825                .faulted_roots
10826                .iter()
10827                .find(|f| f.outpoint == root)
10828                .unwrap_or_else(|| panic!("{topic:?}"));
10829            assert_eq!(named.refused, why, "{named:?}");
10830            assert_eq!(named.faults, 0, "no record holds the count");
10831            assert!(topic.cursor_moves.is_empty(), "the gap holds");
10832            assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10833        };
10834        let result = engine.start_gasp_sync().await.unwrap();
10835        refused_as(&result, crate::gasp::strike_refused::NO_ROOM);
10836        assert!(store
10837            .get_deferred_graph("https://budget.example", "tm_test", &root)
10838            .await
10839            .unwrap()
10840            .is_none());
10841
10842        // The storage faults.
10843        store.set_fault_memo_ceiling(None);
10844        store.set_deferred_graph_put_fault(true);
10845        let result = engine.start_gasp_sync().await.unwrap();
10846        refused_as(&result, crate::gasp::strike_refused::STORE_FAULT);
10847
10848        // The storage serves again: the strike is written.
10849        store.set_deferred_graph_put_fault(false);
10850        let result = engine.start_gasp_sync().await.unwrap();
10851        let topic = &result.topics_synced["tm_test"];
10852        assert_eq!(topic.strikes_refused, 0, "{topic:?}");
10853        assert!(store
10854            .get_deferred_graph("https://budget.example", "tm_test", &root)
10855            .await
10856            .unwrap()
10857            .is_some());
10858    }
10859
10860    /// 0.1.10 (the churn bounded): sixteen root retry obligations the peer
10861    /// no longer serves (fact 3: `held:retry`, nodes 0) took every run's
10862    /// slice on 0.1.9, resumed ahead of the page, appending nothing, so the
10863    /// page's fresh roots were never walked. Each now goes to the long
10864    /// backoff after `IDLE_RESUMES_BEFORE_BACKOFF` idle resumes, is asked
10865    /// nothing while it waits, and stays held; the page is walked.
10866    #[tokio::test]
10867    async fn room_0110_idle_obligations_back_off_and_the_page_is_walked() {
10868        let (mut engine, mut remote) = budget_page_engine(3);
10869        remote.node_cost_ms = 2000;
10870        let store = std::rc::Rc::new(MemoryStorage::new());
10871        engine.storage = Box::new(store.clone());
10872        virtual_peer_budget(&mut engine, &remote);
10873        engine.set_graph_budget(
10874            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10875            100,
10876            60_000,
10877        );
10878        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10879        let obligations =
10880            hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10881        let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10882        // The witness of 0.1.9: each run's slice goes to the obligations
10883        // (15 resumed, nothing appended, the last of them cut in flight by
10884        // the per-peer deadline) and the page is never reached.
10885        for run in 1..=idle {
10886            let result = engine.start_gasp_sync().await.unwrap();
10887            let topic = &result.topics_synced["tm_test"];
10888            assert_eq!(topic.finalized_graphs, 0, "run {run}: {topic:?}");
10889            assert!(topic.cursor_moves.is_empty(), "run {run}");
10890            assert_eq!(topic.resumed_graphs, 15, "run {run}");
10891            assert_eq!(topic.waiting_graphs, 0, "run {run}");
10892            let expect = if run == idle { 15 } else { 0 };
10893            assert_eq!(topic.backed_off_graphs, expect, "run {run}: {topic:?}");
10894        }
10895        // The fifteen wait; the slice reaches the page.
10896        let result = engine.start_gasp_sync().await.unwrap();
10897        let topic = &result.topics_synced["tm_test"];
10898        assert_eq!(topic.waiting_graphs, 15, "{topic:?}");
10899        assert_eq!(topic.finalized_graphs, 3, "the page's fresh roots");
10900        assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10901        assert_eq!(topic.cursor_moves[0].to, 30);
10902        // The last one follows; then every obligation waits, asked nothing.
10903        let mut backed_off = 15;
10904        for _ in 0..idle {
10905            let result = engine.start_gasp_sync().await.unwrap();
10906            backed_off += result.topics_synced["tm_test"].backed_off_graphs;
10907        }
10908        assert_eq!(backed_off, obligations.len() as u64);
10909        let asked = remote.asked.borrow().len();
10910        let result = engine.start_gasp_sync().await.unwrap();
10911        let topic = &result.topics_synced["tm_test"];
10912        assert_eq!(topic.waiting_graphs, obligations.len() as u64, "{topic:?}");
10913        assert_eq!(topic.resumed_graphs, 0);
10914        assert!(topic.dropped_graphs.is_empty(), "{topic:?}");
10915        assert_eq!(remote.asked.borrow().len(), asked, "no request");
10916        // Kept, not deleted (bsv-low #555's resume), each on its backoff.
10917        let now = (current_timestamp_ms() / 1000.0) as u64;
10918        let held = store.deferred_graphs();
10919        assert_eq!(held.len(), obligations.len());
10920        for record in &held {
10921            assert!(obligations.contains(&record.outpoint));
10922            assert!(record.cursor_passable);
10923            assert!(record.idle_resumes >= idle, "{record:?}");
10924            assert!(record.retry_at > now, "{record:?}");
10925        }
10926    }
10927
10928    /// 0.1.10: a record waiting out its idle backoff whose root is
10929    /// remembered expired (0.1.8's memo, keyed by topic: any peer's walk that
10930    /// refused it) is swept at the next sync ahead of the backoff gate, with
10931    /// no request: refused and deleted, counted in `dropped_graphs`
10932    /// (`refused`). A record that holds its root node is refused the same
10933    /// way from that node; one holding nothing is refused at its next
10934    /// resume, when its root is fetched.
10935    #[tokio::test]
10936    async fn room_0110_a_waiting_record_whose_root_expired_is_swept() {
10937        let (mut engine, remote) = budget_page_engine(1);
10938        let store = std::rc::Rc::new(MemoryStorage::new());
10939        engine.storage = Box::new(store.clone());
10940        engine.set_graph_budget(
10941            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10942            100,
10943            60_000,
10944        );
10945        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10946        let now = (current_timestamp_ms() / 1000.0) as u64;
10947        let mut waiting = bare_record(0, 1, "retry", true);
10948        waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10949        waiting.retry_at = now + 3600;
10950        let dead = bare_record(1, 2, "retry", true);
10951        let mut dead_waiting = dead.clone();
10952        dead_waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10953        dead_waiting.retry_at = now + 3600;
10954        for record in [&waiting, &dead_waiting] {
10955            engine.storage().put_deferred_graph(record).await.unwrap();
10956        }
10957        engine
10958            .storage()
10959            .remember_expired_outpoint("tm_test", &dead.outpoint, 1)
10960            .await
10961            .unwrap();
10962        let result = engine.start_gasp_sync().await.unwrap();
10963        let topic = &result.topics_synced["tm_test"];
10964        assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
10965        assert_eq!(
10966            topic
10967                .dropped_graphs
10968                .iter()
10969                .map(|d| (d.outpoint.as_str(), d.reason.as_str()))
10970                .collect::<Vec<_>>(),
10971            vec![(dead.outpoint.as_str(), "refused")]
10972        );
10973        let held: Vec<String> = store
10974            .deferred_graphs()
10975            .into_iter()
10976            .map(|r| r.outpoint)
10977            .collect();
10978        assert_eq!(held, vec![waiting.outpoint.clone()]);
10979        let asked = remote.asked.borrow();
10980        assert!(!asked.iter().any(|t| dead.outpoint.starts_with(t.as_str())));
10981        assert!(!asked
10982            .iter()
10983            .any(|t| waiting.outpoint.starts_with(t.as_str())));
10984    }
10985
10986    /// 0.1.10: a resume that appends a node resets the idle resumes (and
10987    /// any backoff), so a record that progresses is never backed off.
10988    #[tokio::test]
10989    async fn room_0110_a_resume_that_appends_a_node_resets_the_count() {
10990        let UnanchorableFixture {
10991            mut engine,
10992            remote,
10993            store,
10994            root,
10995            ..
10996        } = unanchorable_fixture();
10997        // One node a pass: the root is appended and its walk deferred.
10998        engine.set_graph_budget_limbs(u64::MAX, 1);
10999        engine.set_gasp_remote_factory(Box::new(remote));
11000        let mut record = bare_record(0, 10, "retry", true);
11001        record.outpoint.clone_from(&root);
11002        record.pending[0].outpoint.clone_from(&root);
11003        record.pending[0].graph_id.clone_from(&root);
11004        record.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF - 1;
11005        engine.storage().put_deferred_graph(&record).await.unwrap();
11006        let result = engine.start_gasp_sync().await.unwrap();
11007        let topic = &result.topics_synced["tm_test"];
11008        assert_eq!(topic.resumed_graphs, 1, "{topic:?}");
11009        assert_eq!(topic.backed_off_graphs, 0);
11010        let held = store
11011            .get_deferred_graph("https://budget.example", "tm_test", &root)
11012            .await
11013            .unwrap()
11014            .unwrap();
11015        assert_eq!(held.nodes.len(), 1, "{held:?}");
11016        assert_eq!(held.reason, "nodes");
11017        assert_eq!(held.idle_resumes, 0);
11018        assert_eq!(held.retry_at, 0);
11019    }
11020
11021    /// The courier of the 0.1.11 brief's fact 3: every rung exhausted, the
11022    /// answer `NodeNotFound` ("no raw tx for <txid> (bitails + woc +
11023    /// bananablocks exhausted)", `proof_fetcher.rs`), no proof for a root.
11024    struct ExhaustedCourier {
11025        asked: std::rc::Rc<Cell<u64>>,
11026        /// Answer a fault of the moment instead (a rung's rate limit).
11027        transient: std::rc::Rc<Cell<bool>>,
11028    }
11029
11030    #[async_trait(?Send)]
11031    impl AncestorFetcher for ExhaustedCourier {
11032        async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
11033            self.asked.set(self.asked.get() + 1);
11034            if self.transient.get() {
11035                return Err(GASPError::RemoteError(format!(
11036                    "rung rate limited for {txid}"
11037                )));
11038            }
11039            Err(GASPError::NodeNotFound(format!(
11040                "no raw tx for {txid} (bitails + woc + bananablocks exhausted)"
11041            )))
11042        }
11043        async fn verified_proof_for(&self, _: &str) -> Option<String> {
11044            None
11045        }
11046    }
11047
11048    /// The production shape of the 0.1.11 brief (facts 2 and 3): a budget
11049    /// page of `page` proven roots (scores 10, 20, ...) and `count` root
11050    /// retry obligations written by 0.1.8 and 0.1.9 (`reason "retry"`,
11051    /// passable, nodes 0, no `idleResumes`) at scores 1, 2, ..., each a root
11052    /// the peer serves UNPROVEN whose parent no rung serves.
11053    struct ObligationFixture {
11054        engine: Engine,
11055        remote: BudgetPageRemote,
11056        store: std::rc::Rc<MemoryStorage>,
11057        obligations: Vec<String>,
11058        courier: std::rc::Rc<Cell<u64>>,
11059        transient: std::rc::Rc<Cell<bool>>,
11060    }
11061
11062    async fn obligation_fixture(page: u32, count: u32, node_cost_ms: u64) -> ObligationFixture {
11063        let (mut engine, mut remote) = budget_page_engine(page);
11064        remote.node_cost_ms = node_cost_ms;
11065        let mut obligations = Vec::new();
11066        let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
11067        let mut records = Vec::new();
11068        for n in 0..count {
11069            let parent = child_of(&format!("{:064x}", 0xbeef_0000 + n), 0);
11070            let child = child_of(&parent.id(), 0);
11071            // Engine 0.1.15: the peer is asked for an input the courier
11072            // cannot serve. This fixture's subject is the courier, so its
11073            // peer answers the parent with a transport fault, which counts
11074            // nothing (#56).
11075            remote.faults_for.borrow_mut().insert(parent.id());
11076            nodes.insert(
11077                child.id(),
11078                crate::types::GASPNode {
11079                    graph_id: String::new(),
11080                    raw_tx: child.to_hex(),
11081                    output_index: 0,
11082                    proof: None,
11083                    tx_metadata: None,
11084                    output_metadata: None,
11085                    inputs: None,
11086                },
11087            );
11088            let mut record = bare_record(n, 1 + u64::from(n), "retry", true);
11089            let outpoint = format!("{}.0", child.id());
11090            record.outpoint.clone_from(&outpoint);
11091            record.pending[0].outpoint.clone_from(&outpoint);
11092            record.pending[0].graph_id.clone_from(&outpoint);
11093            obligations.push(outpoint);
11094            records.push(record);
11095        }
11096        let courier = std::rc::Rc::new(Cell::new(0));
11097        let transient = std::rc::Rc::new(Cell::new(false));
11098        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
11099            asked: courier.clone(),
11100            transient: transient.clone(),
11101        }));
11102        let store = std::rc::Rc::new(MemoryStorage::new());
11103        engine.storage = Box::new(store.clone());
11104        virtual_peer_budget(&mut engine, &remote);
11105        engine.set_graph_budget(
11106            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
11107            100,
11108            60_000,
11109        );
11110        engine.set_gasp_remote_factory(Box::new(remote.clone()));
11111        for record in &records {
11112            // The JSON 0.1.8 and 0.1.9 wrote: no `idleResumes`, no `faults`.
11113            let json = serde_json::to_value(record).unwrap();
11114            assert!(json.get("idleResumes").is_none() && json.get("faults").is_none());
11115            engine.storage().put_deferred_graph(record).await.unwrap();
11116        }
11117        ObligationFixture {
11118            engine,
11119            remote,
11120            store,
11121            obligations,
11122            courier,
11123            transient,
11124        }
11125    }
11126
11127    /// 0.1.11 (a faulted resume is a strike): a held root retry obligation
11128    /// whose root the peer serves and whose parent the courier cannot serve
11129    /// (facts 2 and 3: `retry`, nodes 0, `courier`, `faults 0` on every run
11130    /// of 0.1.10, never struck, never backed off) is struck on its own slot
11131    /// on each run, recorded `unanchorable:courier` at the third, and not
11132    /// resumed inside its backoff; the page's roots behind it are walked in
11133    /// the same run, every run. The obligation stays passable throughout:
11134    /// its cursor had passed it, and a strike never takes that back.
11135    #[tokio::test]
11136    async fn obligations_0111_a_faulted_resume_is_struck_and_the_page_is_walked() {
11137        let ObligationFixture {
11138            engine,
11139            remote,
11140            store,
11141            obligations,
11142            courier,
11143            ..
11144        } = obligation_fixture(3, 1, 0).await;
11145        let host = "https://budget.example";
11146        let held = &obligations[0];
11147        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
11148        for run in 1..=runs {
11149            let result = engine.start_gasp_sync().await.unwrap();
11150            let topic = &result.topics_synced["tm_test"];
11151            let named = topic
11152                .faulted_roots
11153                .iter()
11154                .find(|f| &f.outpoint == held)
11155                .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11156            assert_eq!(named.class, crate::gasp::fault_class::COURIER);
11157            assert!(named.error.contains("exhausted"), "{named:?}");
11158            assert_eq!(named.faults, run, "run {run}: struck on its own slot");
11159            assert!(named.refused.is_empty(), "{named:?}");
11160            assert_eq!(topic.strikes_refused, 0);
11161            assert!(topic.cursor_held_by.is_empty(), "run {run}: {topic:?}");
11162            if run == 1 {
11163                assert_eq!(topic.finalized_graphs, 3, "the page, the same run");
11164                assert_eq!(topic.cursor_moves[0].to, 30);
11165            }
11166            let record = store
11167                .get_deferred_graph(host, "tm_test", held)
11168                .await
11169                .unwrap()
11170                .unwrap();
11171            assert_eq!(record.faults, run, "{record:?}");
11172            assert!(record.cursor_passable, "run {run}: never holds the cursor");
11173            assert!(record.nodes.is_empty());
11174            if run < runs {
11175                assert!(!named.unanchorable);
11176                assert_eq!(record.reason, "retry:fault:courier");
11177                assert_eq!(topic.unanchorable_graphs, 0);
11178            } else {
11179                assert!(named.unanchorable);
11180                assert_eq!(record.reason, "unanchorable:courier");
11181                assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
11182                assert_eq!(topic.unanchorable_graphs, 1, "{topic:?}");
11183            }
11184        }
11185        // Inside its backoff: not resumed, nothing asked of the peer or the
11186        // courier, nothing named, held.
11187        let (asked, couriered) = (remote.asked.borrow().len(), courier.get());
11188        let result = engine.start_gasp_sync().await.unwrap();
11189        let topic = &result.topics_synced["tm_test"];
11190        assert!(topic.faulted_roots.is_empty(), "{topic:?}");
11191        assert_eq!(topic.resumed_graphs, 0);
11192        assert_eq!(remote.asked.borrow().len(), asked);
11193        assert_eq!(courier.get(), couriered);
11194        // 0.1.14: the obligation's fault fact lives outside partial graphs.
11195        assert!(store.deferred_graphs().is_empty());
11196        assert_eq!(store.fault_memos().len(), 1);
11197    }
11198
11199    /// 0.1.11 (the idle count fires): a resumed pass of a held record that
11200    /// asked and appended nothing it kept counts one idle resume whatever
11201    /// its ending, and the sync's accounting says how many records counted
11202    /// (`idle_counted`). Three endings: (a) the pass's own save (a rung's
11203    /// fault of the moment: the record is kept, reason `fault`); (b) a drop
11204    /// whose record is saved again as an obligation (the peer no longer
11205    /// serves the root, `not_held`); (c) the production shape of fact 3 (the
11206    /// root served, appended and thrown away with the drop, the courier
11207    /// exhausted) when its strike is refused at the fault bound, so the drop
11208    /// stands: on 0.1.10 the post-pass count read the sync's appended nodes,
11209    /// which the thrown-away root had raised, and never fired.
11210    #[tokio::test]
11211    async fn obligations_0111_a_resume_that_appends_nothing_counts_on_every_ending() {
11212        let host = "https://budget.example";
11213        // 0.1.14: exercise the same refusal ending through a real storage
11214        // ceiling, because the old per-peer fault bound no longer exists.
11215        let fixture = obligation_fixture(1, 1, 0).await;
11216        fixture.store.set_fault_memo_ceiling(Some(0));
11217        let held = &fixture.obligations[0];
11218        let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
11219        for run in 1..=idle {
11220            let result = fixture.engine.start_gasp_sync().await.unwrap();
11221            let topic = &result.topics_synced["tm_test"];
11222            let record = fixture
11223                .store
11224                .get_deferred_graph(host, "tm_test", held)
11225                .await
11226                .unwrap()
11227                .unwrap();
11228            assert_eq!(record.idle_resumes, run, "run {run}: {record:?} {topic:?}");
11229            assert_eq!(record.reason, "retry", "the drop stands");
11230            assert!(record.cursor_passable && record.nodes.is_empty());
11231            assert_eq!(topic.idle_counted, 1, "run {run}: {topic:?}");
11232            let named = topic
11233                .faulted_roots
11234                .iter()
11235                .find(|f| &f.outpoint == held)
11236                .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11237            assert_eq!(named.refused, crate::gasp::strike_refused::NO_ROOM);
11238            assert_eq!(named.faults, 0);
11239            assert_eq!(topic.strikes_refused, 1);
11240            assert_eq!(topic.backed_off_graphs, u64::from(run == idle));
11241        }
11242        let result = fixture.engine.start_gasp_sync().await.unwrap();
11243        let topic = &result.topics_synced["tm_test"];
11244        assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
11245        assert_eq!(topic.idle_counted, 0);
11246
11247        // (b) A drop: the peer no longer serves the root.
11248        let mut fixture = obligation_fixture(1, 1, 0).await;
11249        let gone = fixture.obligations[0].clone();
11250        std::rc::Rc::make_mut(&mut fixture.remote.nodes).remove(gone.strip_suffix(".0").unwrap());
11251        fixture
11252            .engine
11253            .set_gasp_remote_factory(Box::new(fixture.remote.clone()));
11254        let result = fixture.engine.start_gasp_sync().await.unwrap();
11255        let topic = &result.topics_synced["tm_test"];
11256        assert_eq!(topic.idle_counted, 1, "{topic:?}");
11257        assert_eq!(
11258            topic
11259                .dropped_graphs
11260                .iter()
11261                .map(|d| d.reason.as_str())
11262                .collect::<Vec<_>>(),
11263            vec!["not_held"]
11264        );
11265        let record = fixture
11266            .store
11267            .get_deferred_graph(host, "tm_test", &gone)
11268            .await
11269            .unwrap()
11270            .unwrap();
11271        assert_eq!((record.idle_resumes, record.faults), (1, 0), "{record:?}");
11272
11273        // (a) The pass's own save: a rung's fault of the moment. The pass
11274        // that appends the root progresses; the next asks and appends
11275        // nothing, and its save counts it.
11276        let fixture = obligation_fixture(1, 1, 0).await;
11277        fixture.transient.set(true);
11278        let kept = &fixture.obligations[0];
11279        let result = fixture.engine.start_gasp_sync().await.unwrap();
11280        assert_eq!(result.topics_synced["tm_test"].idle_counted, 0, "progress");
11281        let result = fixture.engine.start_gasp_sync().await.unwrap();
11282        let topic = &result.topics_synced["tm_test"];
11283        assert_eq!(topic.idle_counted, 1, "{topic:?}");
11284        let record = fixture
11285            .store
11286            .get_deferred_graph(host, "tm_test", kept)
11287            .await
11288            .unwrap()
11289            .unwrap();
11290        assert_eq!(record.reason, "fault");
11291        assert_eq!(
11292            (record.nodes.len(), record.idle_resumes),
11293            (1, 1),
11294            "{record:?}"
11295        );
11296    }
11297
11298    /// 0.1.11 (the page's share): fourteen obligations of fact 3's shape
11299    /// ahead of a page of twelve fresh roots, every request 1,500 ms of a
11300    /// virtual 30,000 ms per-peer slice and a 15,000 ms graph budget. The
11301    /// resumes take at most `resume_share_ms(30_000, 15_000)` = 9,900 ms
11302    /// together; those reached after it are held back (not resumed, not a
11303    /// failure, not stalled, not idle, their records as they were) and the
11304    /// page's twelve roots are all walked and finalized in the same run.
11305    /// On 0.1.10 the resumes shared one graph budget (15,000 ms): ten of
11306    /// them ran, and the per-peer deadline dropped the sync at nine of the
11307    /// page's twelve roots (fact 4: the page got what the resumes left).
11308    #[tokio::test]
11309    async fn obligations_0111_fourteen_obligations_leave_the_page_its_share() {
11310        let ObligationFixture {
11311            mut engine,
11312            remote,
11313            store,
11314            obligations,
11315            ..
11316        } = obligation_fixture(12, 14, 1500).await;
11317        let elapsed = remote.elapsed.clone();
11318        engine.set_graph_budget(
11319            std::rc::Rc::new(move |ms| {
11320                let elapsed = elapsed.clone();
11321                let until = elapsed.get() + ms;
11322                Box::pin(std::future::poll_fn(move |_| {
11323                    if elapsed.get() >= until {
11324                        std::task::Poll::Ready(())
11325                    } else {
11326                        std::task::Poll::Pending
11327                    }
11328                }))
11329            }),
11330            100,
11331            15_000,
11332        );
11333        assert_eq!(crate::gasp::resume_share_ms(30_000, 15_000), 9_900);
11334        let result = engine.start_gasp_sync().await.unwrap();
11335        let topic = &result.topics_synced["tm_test"];
11336        assert!(topic.errors.is_empty(), "inside the slice: {topic:?}");
11337        assert_eq!(topic.finalized_graphs, 12, "the page's share: {topic:?}");
11338        assert_eq!(topic.cursor_moves[0].to, 120);
11339        // Three resumes struck; the fourth cut in flight by the share, having
11340        // asked and appended nothing (counted idle and stalled, as 0.1.10
11341        // counts a deadline's cut); the other ten held back. Engine 0.1.15:
11342        // each resume asks the peer for the parent the courier cannot serve
11343        // (#56), two requests of 1,500 ms, so the 9,900 ms share holds three
11344        // (six on 0.1.11 to 0.1.14, one request each).
11345        assert_eq!(topic.resumed_graphs, 4, "{topic:?}");
11346        assert_eq!(topic.held_back_graphs, 10, "{topic:?}");
11347        assert_eq!(topic.faulted_roots.len(), 3, "{topic:?}");
11348        assert_eq!((topic.stalled_graphs, topic.idle_counted), (1, 1));
11349        assert!(topic.cursor_held_by.is_empty());
11350        let host = "https://budget.example";
11351        let mut reasons = std::collections::BTreeMap::new();
11352        for outpoint in &obligations {
11353            let record = store
11354                .get_deferred_graph(host, "tm_test", outpoint)
11355                .await
11356                .unwrap()
11357                .unwrap();
11358            assert!(record.cursor_passable, "{record:?}");
11359            if record.reason == "retry" {
11360                // Held back: as it was, not counted idle.
11361                assert_eq!((record.idle_resumes, record.retry_at), (0, 0));
11362                assert_eq!((record.faults, record.passes), (0, 0));
11363            }
11364            *reasons.entry(record.reason).or_insert(0) += 1;
11365        }
11366        assert_eq!(
11367            reasons,
11368            std::collections::BTreeMap::from([
11369                ("retry".to_string(), 10),
11370                ("retry:fault:courier".to_string(), 3),
11371                ("time".to_string(), 1),
11372            ])
11373        );
11374    }
11375
11376    #[tokio::test]
11377    async fn sync_budget_own_proof_without_a_valid_header_is_never_admitted() {
11378        let (mut engine, mut remote) = budget_page_engine(1);
11379        let txid = remote.outputs[0].txid.clone();
11380        std::rc::Rc::make_mut(&mut remote.nodes)
11381            .get_mut(&txid)
11382            .unwrap()
11383            .proof = None;
11384        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
11385            proofs: HashMap::from([(txid.clone(), single_leaf_bump_hex(&txid, 999))]),
11386            requested: std::rc::Rc::default(),
11387        }));
11388        virtual_peer_budget(&mut engine, &remote);
11389        engine.set_gasp_remote_factory(Box::new(remote));
11390        let result = engine.start_gasp_sync().await.unwrap();
11391        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 0);
11392        assert!(engine
11393            .storage
11394            .find_output(&txid, 0, Some("tm_test"), None, true)
11395            .await
11396            .unwrap()
11397            .is_none());
11398    }
11399
11400    impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
11401        fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11402            Box::new(MockSyncRemote {
11403                utxos: vec![
11404                    crate::types::GASPOutput {
11405                        txid: "remote_tx1".to_string(),
11406                        output_index: 0,
11407                        score: 100.0,
11408                    },
11409                    crate::types::GASPOutput {
11410                        txid: "remote_tx2".to_string(),
11411                        output_index: 0,
11412                        score: 200.0,
11413                    },
11414                ],
11415            })
11416        }
11417    }
11418
11419    #[tokio::test]
11420    async fn test_start_gasp_sync_with_factory_runs_sync() {
11421        let mut sync_config: SyncConfiguration = HashMap::new();
11422        sync_config.insert(
11423            "tm_test".to_string(),
11424            SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
11425        );
11426
11427        let mut engine = Engine::new(
11428            HashMap::new(),
11429            HashMap::new(),
11430            Box::new(MemoryStorage::new()),
11431            None,
11432            EngineConfig {
11433                sync_configuration: sync_config,
11434                ..Default::default()
11435            },
11436        );
11437
11438        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11439
11440        let result = engine.start_gasp_sync().await.unwrap();
11441        assert_eq!(result.topics_synced.len(), 1);
11442
11443        let topic_result = &result.topics_synced["tm_test"];
11444        assert_eq!(topic_result.peers.len(), 1);
11445        assert!(
11446            topic_result.errors.is_empty(),
11447            "Sync should succeed with mock remote"
11448        );
11449
11450        // Verify last_interaction was persisted
11451        let last = engine
11452            .storage()
11453            .get_last_interaction("https://peer1.example.com", "tm_test")
11454            .await
11455            .unwrap();
11456        // Synthetic nodes cannot assemble into BEEF. No score completed,
11457        // so the entry cursor stays and the next sync re-requests both.
11458        assert_eq!(
11459            last, 0,
11460            "cursor must remain at entry when no output completed"
11461        );
11462    }
11463
11464    #[tokio::test]
11465    async fn test_start_gasp_sync_with_factory_handles_error() {
11466        struct FailingFactory;
11467
11468        impl crate::gasp::GASPRemoteFactory for FailingFactory {
11469            fn create_remote(
11470                &self,
11471                _peer_url: &str,
11472                _topic: &str,
11473            ) -> Box<dyn crate::gasp::GASPRemote> {
11474                struct FailingRemote;
11475                #[async_trait(?Send)]
11476                impl crate::gasp::GASPRemote for FailingRemote {
11477                    async fn get_initial_response(
11478                        &self,
11479                        _: &crate::types::GASPInitialRequest,
11480                    ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
11481                    {
11482                        Err(crate::gasp::GASPError::RemoteError(
11483                            "connection refused".into(),
11484                        ))
11485                    }
11486                    async fn get_initial_reply(
11487                        &self,
11488                        _: &crate::types::GASPInitialResponse,
11489                    ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
11490                    {
11491                        unreachable!()
11492                    }
11493                    async fn request_node(
11494                        &self,
11495                        _: &str,
11496                        _: &str,
11497                        _: u32,
11498                        _: bool,
11499                    ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
11500                    {
11501                        unreachable!()
11502                    }
11503                    async fn submit_node(
11504                        &self,
11505                        _: &crate::types::GASPNode,
11506                    ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
11507                    {
11508                        unreachable!()
11509                    }
11510                }
11511                Box::new(FailingRemote)
11512            }
11513        }
11514
11515        let mut sync_config: SyncConfiguration = HashMap::new();
11516        sync_config.insert(
11517            "tm_test".to_string(),
11518            SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
11519        );
11520
11521        let mut engine = Engine::new(
11522            HashMap::new(),
11523            HashMap::new(),
11524            Box::new(MemoryStorage::new()),
11525            None,
11526            EngineConfig {
11527                sync_configuration: sync_config,
11528                ..Default::default()
11529            },
11530        );
11531
11532        engine.set_gasp_remote_factory(Box::new(FailingFactory));
11533
11534        let result = engine.start_gasp_sync().await.unwrap();
11535        assert_eq!(result.topics_synced.len(), 1);
11536
11537        let topic_result = &result.topics_synced["tm_test"];
11538        assert_eq!(
11539            topic_result.errors.len(),
11540            1,
11541            "Should have one error for the failing peer"
11542        );
11543        assert!(topic_result.errors[0].contains("connection refused"));
11544    }
11545
11546    #[tokio::test]
11547    async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
11548        let mut sync_config: SyncConfiguration = HashMap::new();
11549        sync_config.insert(
11550            "tm_test".to_string(),
11551            SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
11552        );
11553
11554        // No factory set — should still work (just peer discovery, no sync)
11555        let engine = Engine::new(
11556            HashMap::new(),
11557            HashMap::new(),
11558            Box::new(MemoryStorage::new()),
11559            None,
11560            EngineConfig {
11561                sync_configuration: sync_config,
11562                ..Default::default()
11563            },
11564        );
11565
11566        let result = engine.start_gasp_sync().await.unwrap();
11567        assert_eq!(result.topics_synced.len(), 1);
11568
11569        let topic_result = &result.topics_synced["tm_test"];
11570        assert_eq!(topic_result.peers.len(), 1);
11571        assert!(topic_result.errors.is_empty());
11572    }
11573
11574    #[tokio::test]
11575    async fn test_start_gasp_sync_with_factory_multiple_peers() {
11576        let mut sync_config: SyncConfiguration = HashMap::new();
11577        sync_config.insert(
11578            "tm_test".to_string(),
11579            SyncTarget::Peers(vec![
11580                "https://peer1.example.com".to_string(),
11581                "https://peer2.example.com".to_string(),
11582            ]),
11583        );
11584
11585        let mut engine = Engine::new(
11586            HashMap::new(),
11587            HashMap::new(),
11588            Box::new(MemoryStorage::new()),
11589            None,
11590            EngineConfig {
11591                sync_configuration: sync_config,
11592                ..Default::default()
11593            },
11594        );
11595
11596        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11597
11598        let result = engine.start_gasp_sync().await.unwrap();
11599        let topic_result = &result.topics_synced["tm_test"];
11600        assert_eq!(topic_result.peers.len(), 2);
11601        assert!(topic_result.errors.is_empty());
11602
11603        // Neither peer completed its synthetic graphs.
11604        let last1 = engine
11605            .storage()
11606            .get_last_interaction("https://peer1.example.com", "tm_test")
11607            .await
11608            .unwrap();
11609        let last2 = engine
11610            .storage()
11611            .get_last_interaction("https://peer2.example.com", "tm_test")
11612            .await
11613            .unwrap();
11614        assert_eq!(last1, 0);
11615        assert_eq!(last2, 0);
11616    }
11617
11618    // ── Per-peer budget + dead-peer quarantine (bsv-low#302) ──────────
11619
11620    /// A remote whose initial request HANGS forever — models the dead
11621    /// ephemeral peers (ngrok tunnels) whose fetch never returns, the #257
11622    /// root cause the per-peer budget exists for.
11623    struct HangingRemote;
11624
11625    #[async_trait(?Send)]
11626    impl crate::gasp::GASPRemote for HangingRemote {
11627        async fn get_initial_response(
11628            &self,
11629            _: &crate::types::GASPInitialRequest,
11630        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
11631            std::future::pending::<()>().await;
11632            unreachable!()
11633        }
11634        async fn get_initial_reply(
11635            &self,
11636            _: &crate::types::GASPInitialResponse,
11637        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
11638            unreachable!()
11639        }
11640        async fn request_node(
11641            &self,
11642            _: &str,
11643            _: &str,
11644            _: u32,
11645            _: bool,
11646        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
11647            unreachable!()
11648        }
11649        async fn submit_node(
11650            &self,
11651            _: &crate::types::GASPNode,
11652        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
11653            unreachable!()
11654        }
11655    }
11656
11657    /// Factory that HANGS for `hang.example.com` peers and serves the normal
11658    /// mock sync remote for everyone else, counting `create_remote` calls
11659    /// (a quarantine-skipped peer never reaches the factory).
11660    struct SelectiveHangFactory {
11661        created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
11662    }
11663
11664    impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
11665        fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11666            self.created.borrow_mut().push(peer_url.to_string());
11667            if peer_url.contains("hang.example.com") {
11668                Box::new(HangingRemote)
11669            } else {
11670                Box::new(MockSyncRemote {
11671                    utxos: vec![crate::types::GASPOutput {
11672                        txid: "remote_tx1".to_string(),
11673                        output_index: 0,
11674                        score: 100.0,
11675                    }],
11676                })
11677            }
11678        }
11679    }
11680
11681    /// The deterministic "instant deadline" sleep factory: `race_or_deadline`
11682    /// polls the sync future FIRST, so an in-memory sync that never yields
11683    /// still completes; only a future that actually returns Pending (the
11684    /// hanging remote) loses the race.
11685    fn instant_deadline() -> crate::engine::SleepFactory {
11686        std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
11687    }
11688
11689    fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
11690        let mut sync_config: SyncConfiguration = HashMap::new();
11691        sync_config.insert(
11692            "tm_test".to_string(),
11693            SyncTarget::Peers(vec![
11694                "https://hang.example.com".to_string(),
11695                "https://good.example.com".to_string(),
11696            ]),
11697        );
11698        Engine::new(
11699            HashMap::new(),
11700            HashMap::new(),
11701            Box::new(store),
11702            None,
11703            EngineConfig {
11704                sync_configuration: sync_config,
11705                ..Default::default()
11706            },
11707        )
11708    }
11709
11710    #[tokio::test]
11711    async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
11712        let store = std::rc::Rc::new(MemoryStorage::new());
11713        let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11714        let mut engine = two_peer_engine(store.clone());
11715        engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11716            created: created.clone(),
11717        }));
11718        engine.set_peer_sync_budget(instant_deadline(), 1);
11719
11720        let result = engine.start_gasp_sync().await.unwrap();
11721        let topic_result = &result.topics_synced["tm_test"];
11722
11723        // The hanging peer was dropped LOUDLY (an error entry) and the loop
11724        // CONTINUED: the good peer was still attempted and completed.
11725        assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
11726        assert!(
11727            topic_result.errors[0].contains("budget"),
11728            "{:?}",
11729            topic_result.errors
11730        );
11731        assert_eq!(
11732            created.borrow().as_slice(),
11733            ["https://hang.example.com", "https://good.example.com"],
11734            "both peers attempted this tick"
11735        );
11736
11737        // A timed-out peer stays at entry. The other peer was reached but
11738        // served synthetic graphs that cannot complete, so its cursor stays too.
11739        let hang_cursor = store
11740            .get_last_interaction("https://hang.example.com", "tm_test")
11741            .await
11742            .unwrap();
11743        assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
11744        let good_cursor = store
11745            .get_last_interaction("https://good.example.com", "tm_test")
11746            .await
11747            .unwrap();
11748        assert_eq!(good_cursor, 0, "no synthetic graph completed");
11749
11750        // Health bookkeeping: timeout = failure, completion = success.
11751        let hang_health = store
11752            .get_peer_sync_health("hang.example.com", "tm_test")
11753            .await
11754            .unwrap();
11755        assert_eq!(hang_health.consecutive_failures, 1);
11756        let good_health = store
11757            .get_peer_sync_health("good.example.com", "tm_test")
11758            .await
11759            .unwrap();
11760        assert_eq!(good_health.consecutive_failures, 0);
11761    }
11762
11763    #[tokio::test]
11764    async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
11765        let store = std::rc::Rc::new(MemoryStorage::new());
11766        let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11767
11768        let mut sync_config: SyncConfiguration = HashMap::new();
11769        sync_config.insert(
11770            "tm_test".to_string(),
11771            SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
11772        );
11773        let mut engine = Engine::new(
11774            HashMap::new(),
11775            HashMap::new(),
11776            Box::new(store.clone()),
11777            None,
11778            EngineConfig {
11779                sync_configuration: sync_config,
11780                ..Default::default()
11781            },
11782        );
11783        engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11784            created: created.clone(),
11785        }));
11786        engine.set_peer_sync_budget(instant_deadline(), 1);
11787
11788        // Fail the peer up to (but not past) the quarantine threshold — every
11789        // tick attempts it (advance the clock between ticks so the attempts
11790        // are distinguishable in age).
11791        for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
11792            engine.start_gasp_sync().await.unwrap();
11793            store.advance_clock(900);
11794            assert_eq!(
11795                created.borrow().len() as u64,
11796                i + 1,
11797                "peer attempted while below the threshold"
11798            );
11799        }
11800        let health = store
11801            .get_peer_sync_health("hang.example.com", "tm_test")
11802            .await
11803            .unwrap();
11804        assert_eq!(
11805            health.consecutive_failures,
11806            crate::gasp::PEER_QUARANTINE_THRESHOLD
11807        );
11808
11809        // Next tick: QUARANTINED — the factory is never consulted, no error
11810        // is emitted, and the attempt count does not grow (a skip is not an
11811        // attempt, so the re-probe window keeps aging).
11812        let result = engine.start_gasp_sync().await.unwrap();
11813        assert!(result.topics_synced["tm_test"].errors.is_empty());
11814        assert_eq!(
11815            created.borrow().len() as u64,
11816            crate::gasp::PEER_QUARANTINE_THRESHOLD,
11817            "quarantined peer must be skipped"
11818        );
11819
11820        // Age past the re-probe window: exactly one fresh probe is allowed.
11821        store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11822        engine.start_gasp_sync().await.unwrap();
11823        assert_eq!(
11824            created.borrow().len() as u64,
11825            crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
11826            "re-probe attempted after the window"
11827        );
11828        // The probe failed again → failure count kept growing → re-armed.
11829        let health = store
11830            .get_peer_sync_health("hang.example.com", "tm_test")
11831            .await
11832            .unwrap();
11833        assert_eq!(
11834            health.consecutive_failures,
11835            crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
11836        );
11837        let result = engine.start_gasp_sync().await.unwrap();
11838        assert!(
11839            result.topics_synced["tm_test"].errors.is_empty(),
11840            "re-quarantined"
11841        );
11842
11843        // The peer comes back to life: age to the next re-probe, then answer
11844        // like a healthy peer → SUCCESS resets the count to 0 (full
11845        // re-admission — quarantine is never a deletion).
11846        store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11847        // Swap the factory for one that serves the healthy mock for this URL.
11848        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11849        let result = engine.start_gasp_sync().await.unwrap();
11850        assert!(result.topics_synced["tm_test"].errors.is_empty());
11851        let health = store
11852            .get_peer_sync_health("hang.example.com", "tm_test")
11853            .await
11854            .unwrap();
11855        assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
11856    }
11857
11858    // ── Mock ARC Broadcaster ─────────────────────────────────────────
11859
11860    use crate::broadcaster::ArcBroadcaster;
11861
11862    struct MockArcBroadcaster {
11863        calls: Arc<Mutex<Vec<String>>>,
11864        should_fail: bool,
11865    }
11866
11867    impl MockArcBroadcaster {
11868        fn new() -> Self {
11869            Self {
11870                calls: Arc::new(Mutex::new(Vec::new())),
11871                should_fail: false,
11872            }
11873        }
11874
11875        fn failing() -> Self {
11876            Self {
11877                calls: Arc::new(Mutex::new(Vec::new())),
11878                should_fail: true,
11879            }
11880        }
11881    }
11882
11883    #[async_trait(?Send)]
11884    impl ArcBroadcaster for MockArcBroadcaster {
11885        async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
11886            self.calls.lock().unwrap().push(raw_tx_hex.to_string());
11887            if self.should_fail {
11888                Err("mock ARC failure".to_string())
11889            } else {
11890                Ok("mock_txid_from_arc".to_string())
11891            }
11892        }
11893    }
11894
11895    /// Create engine with an ARC broadcaster (and optionally a SHIP broadcaster).
11896    fn make_engine_with_arc(
11897        admit_indices: Vec<u32>,
11898        arc: MockArcBroadcaster,
11899    ) -> (Engine, Arc<Mutex<Vec<String>>>) {
11900        let arc_calls = arc.calls.clone();
11901
11902        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11903        managers.insert(
11904            "tm_test".to_string(),
11905            Box::new(MockTopicManager::admitting(admit_indices)),
11906        );
11907
11908        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
11909        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
11910
11911        let storage = Box::new(MemoryStorage::new());
11912
11913        let engine = Engine::with_all(
11914            managers,
11915            lookup_services,
11916            storage,
11917            None,
11918            None,
11919            Some(Box::new(arc)),
11920            None,
11921            EngineConfig::default(),
11922        );
11923
11924        (engine, arc_calls)
11925    }
11926
11927    // ── ARC Broadcaster Tests ────────────────────────────────────────
11928
11929    #[tokio::test]
11930    async fn test_arc_broadcaster_called_on_current_tx() {
11931        let arc = MockArcBroadcaster::new();
11932        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11933
11934        let beef = test_tagged_beef(vec!["tm_test"]);
11935        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11936
11937        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11938
11939        let calls = arc_calls.lock().unwrap();
11940        assert_eq!(
11941            calls.len(),
11942            1,
11943            "ARC broadcaster should be called once for CurrentTx"
11944        );
11945        assert!(
11946            !calls[0].is_empty(),
11947            "ARC should receive non-empty raw tx hex"
11948        );
11949    }
11950
11951    #[tokio::test]
11952    async fn test_arc_broadcaster_not_called_on_historical_tx() {
11953        let arc = MockArcBroadcaster::new();
11954        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11955
11956        let beef = test_tagged_beef(vec!["tm_test"]);
11957        engine
11958            .submit(&beef, SubmitMode::HistoricalTx)
11959            .await
11960            .unwrap();
11961
11962        assert_eq!(
11963            arc_calls.lock().unwrap().len(),
11964            0,
11965            "ARC broadcaster should NOT be called for HistoricalTx"
11966        );
11967    }
11968
11969    #[tokio::test]
11970    async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
11971        let arc = MockArcBroadcaster::new();
11972        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11973
11974        let beef = test_tagged_beef(vec!["tm_test"]);
11975        engine
11976            .submit(&beef, SubmitMode::HistoricalTxNoSpv)
11977            .await
11978            .unwrap();
11979
11980        assert_eq!(
11981            arc_calls.lock().unwrap().len(),
11982            0,
11983            "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
11984        );
11985    }
11986
11987    #[tokio::test]
11988    async fn test_arc_broadcast_failure_does_not_fail_submit() {
11989        let arc = MockArcBroadcaster::failing();
11990        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11991
11992        let beef = test_tagged_beef(vec!["tm_test"]);
11993        // submit should succeed even when ARC fails
11994        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11995
11996        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11997
11998        // ARC was called (and failed), but submit still succeeded
11999        assert_eq!(
12000            arc_calls.lock().unwrap().len(),
12001            1,
12002            "ARC broadcaster should be attempted even if it will fail"
12003        );
12004    }
12005
12006    #[tokio::test]
12007    async fn test_arc_broadcaster_not_present_still_works() {
12008        // Engine without ARC broadcaster — should work fine
12009        let engine = make_engine(vec![0]);
12010
12011        let beef = test_tagged_beef(vec!["tm_test"]);
12012        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
12013
12014        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
12015    }
12016
12017    // ── Sync advertisements URL validation ─────────────────────────────
12018
12019    /// Mock Advertiser that tracks created advertisements via shared state.
12020    struct TrackingAdvertiser {
12021        created: Arc<Mutex<Vec<AdvertisementData>>>,
12022        existing_ship: Vec<Advertisement>,
12023        existing_slap: Vec<Advertisement>,
12024        /// Topics the returned TaggedBEEF is tagged with — a topic with no
12025        /// registered manager forces the LOCAL submit to fail (#320 3a);
12026        /// several topics exercise independent per-topic admission (D1).
12027        tag_topics: Vec<String>,
12028        /// Simulate a create_advertisements failure (#320 3a).
12029        create_fails: bool,
12030        /// Simulate a find_all_advertisements failure (#320 M2).
12031        find_fails: bool,
12032        /// Return the v1 no-op EMPTY TaggedBEEF from revoke (#320 L2).
12033        revoke_empty: bool,
12034    }
12035
12036    impl TrackingAdvertiser {
12037        fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
12038            let created = Arc::new(Mutex::new(Vec::new()));
12039            (
12040                Self {
12041                    created: created.clone(),
12042                    existing_ship: vec![],
12043                    existing_slap: vec![],
12044                    tag_topics: vec!["tm_test".to_string()],
12045                    create_fails: false,
12046                    find_fails: false,
12047                    revoke_empty: false,
12048                },
12049                created,
12050            )
12051        }
12052    }
12053
12054    use crate::advertiser::{Advertiser, AdvertiserError};
12055
12056    #[async_trait(?Send)]
12057    impl Advertiser for TrackingAdvertiser {
12058        async fn create_advertisements(
12059            &self,
12060            ads: &[AdvertisementData],
12061        ) -> Result<TaggedBEEF, AdvertiserError> {
12062            if self.create_fails {
12063                return Err(AdvertiserError::CreationFailed(
12064                    "mock create refused".into(),
12065                ));
12066            }
12067            self.created.lock().unwrap().extend(ads.iter().cloned());
12068            Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
12069        }
12070
12071        async fn find_all_advertisements(
12072            &self,
12073            protocol: Protocol,
12074        ) -> Result<Vec<Advertisement>, AdvertiserError> {
12075            if self.find_fails {
12076                return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
12077            }
12078            match protocol {
12079                Protocol::Ship => Ok(self.existing_ship.clone()),
12080                Protocol::Slap => Ok(self.existing_slap.clone()),
12081            }
12082        }
12083
12084        async fn revoke_advertisements(
12085            &self,
12086            _ads: &[Advertisement],
12087        ) -> Result<TaggedBEEF, AdvertiserError> {
12088            if self.revoke_empty {
12089                return Ok(TaggedBEEF::new(vec![], vec![]));
12090            }
12091            Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
12092        }
12093
12094        fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
12095            None
12096        }
12097    }
12098
12099    /// TS: "Sync advertisements URL validation"
12100    /// sync_advertisements should be a no-op when hosting_url is empty string,
12101    /// and should skip suppressed topics (tm_ship/tm_slap) when configured.
12102    #[tokio::test]
12103    async fn test_sync_advertisements_url_validation() {
12104        // Case 1: Empty hosting URL — should return early without creating ads
12105        let (adv, created) = TrackingAdvertiser::new();
12106
12107        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12108        managers.insert(
12109            "tm_test".to_string(),
12110            Box::new(MockTopicManager::admitting(vec![0])),
12111        );
12112
12113        let engine = Engine::new(
12114            managers,
12115            HashMap::new(),
12116            Box::new(MemoryStorage::new()),
12117            Some(Box::new(adv)),
12118            EngineConfig {
12119                hosting_url: Some(String::new()), // empty string — invalid
12120                ..Default::default()
12121            },
12122        );
12123
12124        engine.sync_advertisements().await.unwrap();
12125        assert!(
12126            created.lock().unwrap().is_empty(),
12127            "Empty hosting URL should skip advertisement creation"
12128        );
12129
12130        // Case 2: Valid hosting URL — should create SHIP advertisements for non-suppressed topics
12131        let (adv2, created2) = TrackingAdvertiser::new();
12132
12133        let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12134        managers2.insert(
12135            "tm_test".to_string(),
12136            Box::new(MockTopicManager::admitting(vec![0])),
12137        );
12138        // Also register tm_ship to verify it gets suppressed
12139        managers2.insert(
12140            "tm_ship".to_string(),
12141            Box::new(MockTopicManager::admitting(vec![])),
12142        );
12143
12144        let engine2 = Engine::new(
12145            managers2,
12146            HashMap::new(),
12147            Box::new(MemoryStorage::new()),
12148            Some(Box::new(adv2)),
12149            EngineConfig {
12150                hosting_url: Some("https://valid.example.com".to_string()),
12151                suppress_default_sync_advertisements: true,
12152                ..Default::default()
12153            },
12154        );
12155
12156        engine2.sync_advertisements().await.unwrap();
12157        let ads = created2.lock().unwrap();
12158        // tm_ship should be suppressed, only tm_test should get an advertisement
12159        assert!(
12160            ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
12161            "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
12162        );
12163        assert!(
12164            ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
12165            "tm_test should get a SHIP advertisement"
12166        );
12167    }
12168
12169    // ── sync_advertisements report (bsv-low #320 defect 3a) ────────────
12170    //
12171    // Pre-#320 the engine returned `Ok(())` unconditionally: a failed
12172    // create or a failed LOCAL submit was `error!`-logged and invisible to
12173    // the caller — the admin route said `success` while the node's own
12174    // ls_ship/ls_slap never gained its ads, so every cycle re-created the
12175    // whole set. These cells pin the report contract.
12176
12177    fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
12178        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12179        managers.insert(
12180            "tm_test".to_string(),
12181            Box::new(MockTopicManager::admitting(vec![0])),
12182        );
12183        Engine::new(
12184            managers,
12185            HashMap::new(),
12186            Box::new(MemoryStorage::new()),
12187            Some(Box::new(adv)),
12188            EngineConfig {
12189                hosting_url: Some("https://valid.example.com".to_string()),
12190                suppress_default_sync_advertisements: true,
12191                ..Default::default()
12192            },
12193        )
12194    }
12195
12196    #[tokio::test]
12197    async fn sync_report_counts_admitted_outputs_on_success() {
12198        let (adv, _) = TrackingAdvertiser::new();
12199        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12200        assert!(report.ok(), "clean run must report ok: {report:?}");
12201        assert_eq!(report.to_create, 1, "tm_test was missing");
12202        assert_eq!(
12203            report.admitted.get("tm_test"),
12204            Some(&1),
12205            "the STEAK's admitted count must be surfaced: {report:?}"
12206        );
12207    }
12208
12209    #[tokio::test]
12210    async fn sync_report_surfaces_local_submit_failure() {
12211        // The TaggedBEEF comes back tagged for a topic with no registered
12212        // manager → the LOCAL submit fails. That failure must be in the
12213        // report, not swallowed (the live silent-failure shape).
12214        let (mut adv, _) = TrackingAdvertiser::new();
12215        adv.tag_topics = vec!["tm_unregistered".to_string()];
12216        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12217        assert!(!report.ok(), "a failed local submit must not report ok");
12218        assert_eq!(report.to_create, 1);
12219        let submit_error = report.submit_error.as_deref().unwrap_or_default();
12220        assert!(
12221            submit_error.contains("tm_unregistered"),
12222            "verbatim engine error expected, got: {submit_error}"
12223        );
12224        assert!(report.admitted.is_empty());
12225    }
12226
12227    #[tokio::test]
12228    async fn sync_report_surfaces_create_failure() {
12229        let (mut adv, _) = TrackingAdvertiser::new();
12230        adv.create_fails = true;
12231        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12232        assert!(!report.ok());
12233        assert!(
12234            report
12235                .create_error
12236                .as_deref()
12237                .unwrap_or_default()
12238                .contains("mock create refused"),
12239            "{report:?}"
12240        );
12241        assert!(report.submit_error.is_none(), "create failed before submit");
12242    }
12243
12244    #[tokio::test]
12245    async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
12246        // A node whose own storage already lists its ads must attempt ZERO
12247        // creates — the convergence direction of #320 defect 3 (live, the
12248        // missing self-admission made every cycle re-create all 17 ads).
12249        let (mut adv, created) = TrackingAdvertiser::new();
12250        adv.existing_ship = vec![Advertisement {
12251            protocol: Protocol::Ship,
12252            identity_key: "02aa".to_string(),
12253            domain: "https://valid.example.com".to_string(),
12254            topic_or_service: "tm_test".to_string(),
12255            beef: None,
12256            output_index: None,
12257        }];
12258        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12259        assert!(report.ok(), "{report:?}");
12260        assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
12261        assert!(created.lock().unwrap().is_empty());
12262        assert!(
12263            report.effective(),
12264            "converged-noop is the one zero-admit shape that IS success"
12265        );
12266    }
12267
12268    #[tokio::test]
12269    async fn sync_report_zero_admit_is_ok_but_not_effective() {
12270        // #320 M1: the local submit SUCCEEDS but the topic manager admits
12271        // ZERO outputs — a non-error refusal. The ads never enter our own
12272        // ls_ship/ls_slap, so the diff re-creates (re-pays) the full set
12273        // next cycle. `ok()` (errors-only) stays true; `effective()` must
12274        // refuse, and the route keys success on `effective()`.
12275        let (adv, _) = TrackingAdvertiser::new();
12276        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12277        managers.insert(
12278            "tm_test".to_string(),
12279            Box::new(MockTopicManager::admitting(vec![])), // refuses everything
12280        );
12281        let engine = Engine::new(
12282            managers,
12283            HashMap::new(),
12284            Box::new(MemoryStorage::new()),
12285            Some(Box::new(adv)),
12286            EngineConfig {
12287                hosting_url: Some("https://valid.example.com".to_string()),
12288                suppress_default_sync_advertisements: true,
12289                ..Default::default()
12290            },
12291        );
12292        let report = engine.sync_advertisements().await.unwrap();
12293        assert!(report.ok(), "no ERROR occurred: {report:?}");
12294        assert_eq!(report.to_create, 1);
12295        assert_eq!(report.admitted_total(), 0, "{report:?}");
12296        assert!(
12297            !report.effective(),
12298            "zero admitted on a non-empty create must not read as success: {report:?}"
12299        );
12300    }
12301
12302    #[tokio::test]
12303    async fn sync_report_partial_admit_is_not_effective() {
12304        // Delta D1: TWO topics in one submitted tx, per-topic admission
12305        // independent — tm_test admits its output, tm_refuse refuses all.
12306        // admitted_total (1) > 0 but < to_create (2): the refused ads
12307        // would be re-created (re-paid) every cycle, so this must NOT
12308        // read as success.
12309        let (mut adv, _) = TrackingAdvertiser::new();
12310        adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
12311        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12312        managers.insert(
12313            "tm_test".to_string(),
12314            Box::new(MockTopicManager::admitting(vec![0])),
12315        );
12316        managers.insert(
12317            "tm_refuse".to_string(),
12318            Box::new(MockTopicManager::admitting(vec![])), // refuses everything
12319        );
12320        let engine = Engine::new(
12321            managers,
12322            HashMap::new(),
12323            Box::new(MemoryStorage::new()),
12324            Some(Box::new(adv)),
12325            EngineConfig {
12326                hosting_url: Some("https://valid.example.com".to_string()),
12327                suppress_default_sync_advertisements: true,
12328                ..Default::default()
12329            },
12330        );
12331        let report = engine.sync_advertisements().await.unwrap();
12332        assert!(report.ok(), "no ERROR occurred: {report:?}");
12333        assert_eq!(report.to_create, 2, "{report:?}");
12334        assert_eq!(report.admitted_total(), 1, "{report:?}");
12335        assert!(
12336            !report.effective(),
12337            "a partial admit must not read as success: {report:?}"
12338        );
12339    }
12340
12341    #[tokio::test]
12342    async fn sync_report_lookup_failure_refuses_creation() {
12343        // #320 M2: a transient read failure must never become "current ads
12344        // = none" and re-create (re-pay) the whole set — creation is
12345        // REFUSED and the failure lands in the report.
12346        let (mut adv, created) = TrackingAdvertiser::new();
12347        adv.find_fails = true;
12348        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12349        assert!(!report.ok(), "{report:?}");
12350        assert!(!report.effective());
12351        assert!(
12352            report
12353                .lookup_error
12354                .as_deref()
12355                .unwrap_or_default()
12356                .contains("mock lookup refused"),
12357            "{report:?}"
12358        );
12359        assert_eq!(report.to_create, 0, "creation refused, not attempted");
12360        assert!(
12361            created.lock().unwrap().is_empty(),
12362            "no create call on a blind read"
12363        );
12364        assert!(report.submit_error.is_none());
12365    }
12366
12367    #[tokio::test]
12368    async fn sync_report_counts_skipped_revocations() {
12369        // #320 L2: `to_revoke: N` with a v1 no-op advertiser must be
12370        // distinguishable from a revoke that actually ran.
12371        let (mut adv, _) = TrackingAdvertiser::new();
12372        adv.revoke_empty = true;
12373        adv.existing_ship = vec![
12374            Advertisement {
12375                protocol: Protocol::Ship,
12376                identity_key: "02aa".to_string(),
12377                domain: "https://valid.example.com".to_string(),
12378                topic_or_service: "tm_test".to_string(),
12379                beef: None,
12380                output_index: None,
12381            },
12382            Advertisement {
12383                protocol: Protocol::Ship,
12384                identity_key: "02aa".to_string(),
12385                domain: "https://valid.example.com".to_string(),
12386                topic_or_service: "tm_stale".to_string(), // no longer configured
12387                beef: None,
12388                output_index: None,
12389            },
12390        ];
12391        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12392        assert!(report.ok(), "{report:?}");
12393        assert_eq!(report.to_revoke, 1, "tm_stale is stale");
12394        assert_eq!(
12395            report.revoke_skipped, 1,
12396            "the v1 no-op must be visible, not silent: {report:?}"
12397        );
12398        assert!(report.revoke_error.is_none());
12399        assert!(report.revoke_submit_error.is_none());
12400    }
12401
12402    // ── complete_missing_proofs (#130) ─────────────────────────────────
12403
12404    use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
12405    use std::cell::Cell;
12406
12407    /// A single raw mainnet tx (no proof) — used to build a proofless BEEF.
12408    const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12409
12410    /// Build a proofless single-tx BEEF + return `(beef_bytes, txid)`.
12411    fn proofless_beef() -> (Vec<u8>, String) {
12412        use bsv_rs::transaction::{Beef, Transaction};
12413        let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
12414        let txid = tx.id();
12415        let mut beef = Beef::new();
12416        beef.merge_transaction(tx);
12417        (beef.to_binary(), txid)
12418    }
12419
12420    // ── #284: stitching a proof must never rebuild the stored BEEF ─────────
12421
12422    /// The corruption shape observed live on rust-beta: a stored BEEF whose
12423    /// SUBJECT is not the last tx in wire order. The old rewrite went through
12424    /// `Transaction::from_beef(bytes, None)`, whose None arm picks
12425    /// `txs.last()` — the PARENT here — and `to_beef(true)` then serialized the
12426    /// parent's world, dropping the subject from its own row. TEST_BEEF_HEX is
12427    /// exactly a two-tx chain (parent + child spending it), so re-encoding it
12428    /// child-first reproduces the trigger byte-for-byte.
12429    fn subject_not_last_beef() -> (Vec<u8>, String, String) {
12430        use bsv_rs::transaction::Beef;
12431        let parsed = Beef::from_binary(&test_beef()).unwrap();
12432        let parent = parsed.txs[0].tx().unwrap().clone();
12433        let child = parsed.txs[1].tx().unwrap().clone();
12434        let (parent_id, child_id) = (parent.id(), child.id());
12435        assert_ne!(parent_id, child_id);
12436
12437        // Serialize via to_writer, NOT to_binary: to_binary re-sorts
12438        // parents-first (subject last), which is precisely why bsv_rs's own
12439        // output never triggers the bug. The corrupt rows held WALLET
12440        // serialized bytes stored verbatim at admit time, whose wire order is
12441        // whatever the wallet emitted — model that by writing the child first.
12442        let mut beef = Beef::new();
12443        beef.merge_transaction(child); // subject FIRST — the trigger
12444        beef.merge_transaction(parent);
12445        let mut w = bsv_rs::primitives::encoding::Writer::new();
12446        beef.to_writer(&mut w);
12447        let bytes = w.into_bytes();
12448
12449        // The premise the whole test rests on: wire order preserved, subject
12450        // genuinely not last.
12451        let reread = Beef::from_binary(&bytes).unwrap();
12452        assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
12453        (bytes, child_id, parent_id)
12454    }
12455
12456    #[test]
12457    fn stitch_preserves_the_subject_when_it_is_not_last() {
12458        use bsv_rs::transaction::{Beef, MerklePath};
12459        let (stored, child_id, parent_id) = subject_not_last_beef();
12460        let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
12461
12462        let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
12463            .expect("stitch must succeed on a beef that contains the txid");
12464
12465        let after = Beef::from_binary(&out).unwrap();
12466        assert!(
12467            after.find_txid(&child_id).is_some(),
12468            "the SUBJECT must survive its own proof-completion"
12469        );
12470        assert!(
12471            after.find_txid(&parent_id).is_some(),
12472            "the ancestry must survive too"
12473        );
12474        assert!(
12475            after.find_bump(&child_id).is_some(),
12476            "the stitched bump must prove the subject"
12477        );
12478        assert!(
12479            after.find_bump(&parent_id).is_none(),
12480            "the parent must NOT be claimed by the child's bump"
12481        );
12482    }
12483
12484    /// The old path, reproduced against the same input, to pin WHY the new one
12485    /// exists: it demonstrably drops the subject. If bsv_rs ever changes
12486    /// `from_beef(_, None)` to honor the subject, this starts failing and the
12487    /// comment trail can be revisited — until then it documents the hazard.
12488    #[test]
12489    fn the_old_round_trip_demonstrably_dropped_the_subject() {
12490        use bsv_rs::transaction::{Beef, Transaction};
12491        let (stored, child_id, _parent_id) = subject_not_last_beef();
12492        let tx = Transaction::from_beef(&stored, None).unwrap();
12493        assert_ne!(
12494            tx.id(),
12495            child_id,
12496            "from_beef(None) picks txs.last(), not the subject — the trigger"
12497        );
12498        let rebuilt = tx.to_beef(true).unwrap();
12499        let reparsed = Beef::from_binary(&rebuilt).unwrap();
12500        assert!(
12501            reparsed.find_txid(&child_id).is_none(),
12502            "the rebuild loses the subject — exactly the #284 artifact"
12503        );
12504    }
12505
12506    /// bsv-low M19 R2 round 3 (review HIGH-1): a stored BEEF whose SUBJECT
12507    /// sits on a stale (orphan) bump at height H, re-stitched with a
12508    /// same-height DIFFERENT-root proof, must serialize with the subject on
12509    /// the NEW bump and the orphan bump dropped — otherwise the hop leg
12510    /// churns and re-latches the orphan proof forever.
12511    #[test]
12512    fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
12513        use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
12514        // subject spends a real parent; both are raw in the BEEF
12515        let parent = Transaction::from_hex(RAW_PARENT).unwrap();
12516        let parent_id = parent.id();
12517        let subject = child_of(&parent_id, 0);
12518        let subject_id = subject.id();
12519        // the ORPHAN bump: a two-leaf block at H so its root is NOT the txid,
12520        // and it differs from the canonical block's root
12521        let orphan = MerklePath::new_unchecked(
12522            965_771,
12523            vec![vec![
12524                MerklePathLeaf::new_txid(0, subject_id.clone()),
12525                MerklePathLeaf::new(1, "aa".repeat(32)),
12526            ]],
12527        )
12528        .unwrap();
12529        let mut beef = Beef::new();
12530        let bi = beef.merge_bump(orphan.clone());
12531        beef.merge_raw_tx(parent.to_binary(), None);
12532        beef.merge_raw_tx(subject.to_binary(), Some(bi));
12533        let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
12534        let stored = beef.to_binary();
12535        assert_eq!(
12536            Beef::from_binary(&stored)
12537                .unwrap()
12538                .find_bump(&subject_id)
12539                .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
12540            Some(orphan_root.clone())
12541        );
12542        // the canonical proof at the SAME height, a DIFFERENT root
12543        let canonical = MerklePath::new_unchecked(
12544            965_771,
12545            vec![vec![
12546                MerklePathLeaf::new_txid(0, subject_id.clone()),
12547                MerklePathLeaf::new(1, "bb".repeat(32)),
12548            ]],
12549        )
12550        .unwrap();
12551        let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
12552        assert_ne!(
12553            canonical_root, orphan_root,
12554            "the reorg shape: same height, new root"
12555        );
12556        let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
12557        let after = Beef::from_binary(&out).unwrap();
12558        // the subject's OWN bump is the canonical one
12559        let own = after
12560            .find_txid(&subject_id)
12561            .and_then(BeefTx::bump_index)
12562            .and_then(|bi| after.bumps.get(bi))
12563            .unwrap();
12564        assert_eq!(
12565            own.compute_root(Some(&subject_id)).unwrap(),
12566            canonical_root,
12567            "the subject re-anchored to the new bump"
12568        );
12569        // the orphan bump is gone (only the canonical one remains)
12570        assert_eq!(
12571            after.bumps.len(),
12572            1,
12573            "the unreferenced orphan bump was dropped"
12574        );
12575        // and a reader that uses the tx's OWN bump sees canonical, never orphan
12576        assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
12577    }
12578
12579    #[test]
12580    fn stitch_refuses_a_beef_that_lacks_the_txid() {
12581        use bsv_rs::transaction::MerklePath;
12582        let (stored, _c, _p) = subject_not_last_beef();
12583        let foreign = "aa".repeat(32);
12584        let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
12585        assert!(
12586            Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
12587            "no txid in the beef -> no write, never garbage"
12588        );
12589    }
12590
12591    /// A real mainnet raw tx, the unproven parent for the re-anchor pin.
12592    const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12593
12594    /// A minimal raw tx spending `source:vout` with one OP_TRUE output.
12595    fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
12596        let mut sx = String::from("0100000001");
12597        let mut prev = hex::decode(source_txid).unwrap();
12598        prev.reverse();
12599        sx.push_str(&hex::encode(prev));
12600        sx.push_str(&hex::encode(vout.to_le_bytes()));
12601        sx.push_str("00ffffffff01");
12602        sx.push_str(&hex::encode(1000u64.to_le_bytes()));
12603        sx.push_str("015100000000");
12604        bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
12605    }
12606
12607    /// A minimal valid single-leaf BUMP hex proving `txid` at `height`.
12608    fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
12609        use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
12610        let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
12611        MerklePath::new_unchecked(height, vec![vec![leaf]])
12612            .unwrap()
12613            .to_hex()
12614    }
12615
12616    /// Mock fetcher: returns a configured proof (or none) for any txid, and
12617    /// counts how many times it was asked.
12618    struct StubFetcher {
12619        proof: Option<String>,
12620        calls: Cell<u32>,
12621    }
12622
12623    #[async_trait(?Send)]
12624    impl AncestorFetcher for StubFetcher {
12625        async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
12626            self.calls.set(self.calls.get() + 1);
12627            Ok(FetchedAncestor {
12628                raw_tx: PROOFLESS_RAW_TX.to_string(),
12629                proof: self.proof.clone(),
12630            })
12631        }
12632
12633        /// Models a fetcher whose header source CONFIRMS a stored bump — so the
12634        /// already-structurally-proven re-verify gate (the window-clog path)
12635        /// latches without a re-fetch, exactly as before the #192/#193 hardening.
12636        /// (`verified_proof_for` is left at its default: it delegates to
12637        /// `fetch_ancestor` above, so the `calls` counter still tracks fetches.)
12638        async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
12639            true
12640        }
12641    }
12642
12643    /// Build an engine over a storage holding one proofless output, with an
12644    /// optional ancestor fetcher.
12645    async fn engine_with_proofless_output(
12646        fetcher: Option<std::rc::Rc<StubFetcher>>,
12647    ) -> (Engine, String) {
12648        let (beef, txid) = proofless_beef();
12649        let storage = MemoryStorage::new();
12650        storage
12651            .insert_output(&Output {
12652                txid: txid.clone(),
12653                output_index: 0,
12654                output_script: vec![0x76],
12655                satoshis: 1_000_000_000,
12656                topic: "Hello".to_string(),
12657                spent: false,
12658                outputs_consumed: vec![],
12659                consumed_by: vec![],
12660                beef: Some(beef),
12661                block_height: None,
12662                score: Some(1.0),
12663            })
12664            .await
12665            .unwrap();
12666
12667        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12668        managers.insert(
12669            "Hello".into(),
12670            Box::new(MockTopicManager::admitting(vec![0])),
12671        );
12672        let mut engine = Engine::new(
12673            managers,
12674            HashMap::new(),
12675            Box::new(storage),
12676            None,
12677            EngineConfig::default(),
12678        );
12679        if let Some(f) = fetcher {
12680            engine.set_ancestor_fetcher(f);
12681        }
12682        (engine, txid)
12683    }
12684
12685    #[tokio::test]
12686    async fn proofs_completion_uses_dedicated_courier() {
12687        let (_, txid) = proofless_beef();
12688        let shared = std::rc::Rc::new(StubFetcher {
12689            proof: None,
12690            calls: Cell::new(0),
12691        });
12692        let dedicated = StubFetcher {
12693            proof: Some(single_leaf_bump_hex(&txid, 850_000)),
12694            calls: Cell::new(0),
12695        };
12696        let (engine, _) = engine_with_proofless_output(Some(shared.clone())).await;
12697        assert_eq!(
12698            engine
12699                .complete_missing_proofs(1, 0)
12700                .await
12701                .unwrap()
12702                .completed,
12703            0
12704        );
12705        assert_eq!(
12706            engine
12707                .complete_missing_proofs_with(&dedicated, 1, 0)
12708                .await
12709                .unwrap()
12710                .completed,
12711            1
12712        );
12713        assert_eq!(shared.calls.get(), 1);
12714        assert_eq!(dedicated.calls.get(), 1);
12715        let output = engine
12716            .storage()
12717            .find_output(&txid, 0, Some("Hello"), None, true)
12718            .await
12719            .unwrap()
12720            .unwrap();
12721        assert!(
12722            crate::beef_limits::own_proof(output.beef.as_ref().unwrap(), &txid)
12723                .unwrap()
12724                .is_some()
12725        );
12726    }
12727
12728    #[tokio::test]
12729    async fn complete_missing_proofs_noop_without_fetcher() {
12730        // No ancestor fetcher configured → pure no-op (production default).
12731        let (engine, _txid) = engine_with_proofless_output(None).await;
12732        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12733        assert_eq!(summary, ProofCompletionSummary::default());
12734    }
12735
12736    #[tokio::test]
12737    async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
12738        let (_beef, txid) = proofless_beef();
12739        let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12740        let fetcher = std::rc::Rc::new(StubFetcher {
12741            proof: Some(proof_hex),
12742            calls: Cell::new(0),
12743        });
12744        let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12745
12746        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12747        assert_eq!(summary.scanned, 1);
12748        assert_eq!(summary.proofless, 1);
12749        assert_eq!(
12750            summary.completed, 1,
12751            "the proofless BEEF should be completed"
12752        );
12753        assert_eq!(fetcher.calls.get(), 1);
12754
12755        // The stored BEEF now carries a proof for the target tx + the output
12756        // got its block height.
12757        let out = engine
12758            .storage()
12759            .find_output(&txid, 0, Some("Hello"), None, true)
12760            .await
12761            .unwrap()
12762            .unwrap();
12763        assert_eq!(out.block_height, Some(850_000));
12764        let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
12765        assert!(
12766            beef.find_txid(&txid)
12767                .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
12768            "completed BEEF must prove the target tx"
12769        );
12770
12771        // A second pass is a no-op: the tx is now proven, so it is not counted
12772        // proofless and the fetcher is not asked again.
12773        let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
12774        assert_eq!(summary2.proofless, 0);
12775        assert_eq!(summary2.completed, 0);
12776        assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
12777    }
12778
12779    #[tokio::test]
12780    async fn complete_missing_proofs_skips_unconfirmed() {
12781        // Fetcher reachable but the tx is unmined (no BUMP) → skipped, not
12782        // errored, and counted still_unconfirmed for retry.
12783        let fetcher = std::rc::Rc::new(StubFetcher {
12784            proof: None,
12785            calls: Cell::new(0),
12786        });
12787        let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12788        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12789        assert_eq!(summary.proofless, 1);
12790        assert_eq!(summary.completed, 0);
12791        assert_eq!(summary.still_unconfirmed, 1);
12792        assert_eq!(fetcher.calls.get(), 1);
12793    }
12794
12795    /// Build an engine like [`engine_with_proofless_output`] but keep a shared
12796    /// handle to the `MemoryStorage` (via the `Rc<T: Storage>` delegation) so
12797    /// the test can advance its deterministic clock — the push-primary
12798    /// backstop age-gate tests (#228) need to age rows without sleeping.
12799    async fn engine_with_proofless_output_shared(
12800        fetcher: Option<std::rc::Rc<StubFetcher>>,
12801    ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
12802        let (beef, txid) = proofless_beef();
12803        let storage = std::rc::Rc::new(MemoryStorage::new());
12804        storage
12805            .insert_output(&Output {
12806                txid: txid.clone(),
12807                output_index: 0,
12808                output_script: vec![0x76],
12809                satoshis: 1_000_000_000,
12810                topic: "Hello".to_string(),
12811                spent: false,
12812                outputs_consumed: vec![],
12813                consumed_by: vec![],
12814                beef: Some(beef),
12815                block_height: None,
12816                score: Some(1.0),
12817            })
12818            .await
12819            .unwrap();
12820
12821        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12822        managers.insert(
12823            "Hello".into(),
12824            Box::new(MockTopicManager::admitting(vec![0])),
12825        );
12826        let mut engine = Engine::new(
12827            managers,
12828            HashMap::new(),
12829            Box::new(storage.clone()),
12830            None,
12831            EngineConfig::default(),
12832        );
12833        if let Some(f) = fetcher {
12834            engine.set_ancestor_fetcher(f);
12835        }
12836        (engine, txid, storage)
12837    }
12838
12839    #[tokio::test]
12840    async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
12841        // Push-primary backstop (#228): a FRESHLY stored proofless tx must NOT
12842        // be polled — its proof is expected via /arc-ingest at push speed. The
12843        // gated pass scans nothing and never asks the fetcher.
12844        let proof_hex = {
12845            let (_beef, txid) = proofless_beef();
12846            single_leaf_bump_hex(&txid, 850_000)
12847        };
12848        let fetcher = std::rc::Rc::new(StubFetcher {
12849            proof: Some(proof_hex),
12850            calls: Cell::new(0),
12851        });
12852        let (engine, _txid, storage) =
12853            engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12854
12855        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12856        assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
12857        assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
12858
12859        // WEBHOOK-OUTAGE DEGRADATION: no push ever arrives. Once the row is
12860        // older than the gate, the SAME backstop pass polls and completes it
12861        // exactly as the pre-#228 behaviour — degradation is to polling,
12862        // never to nothing.
12863        storage.advance_clock(1800);
12864        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12865        assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
12866        assert_eq!(summary.completed, 1, "the backstop completes the proof");
12867        assert_eq!(fetcher.calls.get(), 1);
12868    }
12869
12870    #[tokio::test]
12871    async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
12872        // Pushed-proof-then-chaser-skips (#228): a proof arriving via the real
12873        // /arc-ingest producer path (`handle_new_merkle_proof` → the
12874        // `update_transaction_beef` stitch, which latches `has_proof`) drops
12875        // the tx out of the poll candidate set entirely — even after the age
12876        // gate opens, the chaser never asks the fetcher about it.
12877        let fetcher = std::rc::Rc::new(StubFetcher {
12878            proof: None,
12879            calls: Cell::new(0),
12880        });
12881        let (engine, txid, storage) =
12882            engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12883
12884        // The push lands (arc-ingest calls exactly this after its own
12885        // chaintracks verify).
12886        let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12887        engine
12888            .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
12889            .await
12890            .unwrap();
12891
12892        // Age the row well past the backstop gate: still skipped — the pushed
12893        // proof, not the age, is what removes it from the chaser's world.
12894        storage.advance_clock(1_000_000);
12895        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12896        assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
12897        assert_eq!(summary.completed, 0);
12898        assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
12899    }
12900
12901    #[tokio::test]
12902    async fn complete_missing_proofs_respects_limit() {
12903        // Two proofless txs, limit 1 → only one row is scanned this tick.
12904        let (beef1, txid1) = proofless_beef();
12905        // A second distinct proofless tx (different raw tx → different txid).
12906        let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
12907        let (beef2, txid2) = {
12908            use bsv_rs::transaction::{Beef, Transaction};
12909            let tx = Transaction::from_hex(raw2).unwrap();
12910            let id = tx.id();
12911            let mut b = Beef::new();
12912            b.merge_transaction(tx);
12913            (b.to_binary(), id)
12914        };
12915
12916        let storage = MemoryStorage::new();
12917        for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
12918            storage
12919                .insert_output(&Output {
12920                    txid: txid.clone(),
12921                    output_index: 0,
12922                    output_script: vec![0x76],
12923                    satoshis: 1000,
12924                    topic: "Hello".to_string(),
12925                    spent: false,
12926                    outputs_consumed: vec![],
12927                    consumed_by: vec![],
12928                    beef: Some(beef),
12929                    block_height: None,
12930                    score: Some(1.0),
12931                })
12932                .await
12933                .unwrap();
12934        }
12935
12936        let fetcher = std::rc::Rc::new(StubFetcher {
12937            proof: None,
12938            calls: Cell::new(0),
12939        });
12940        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12941        managers.insert(
12942            "Hello".into(),
12943            Box::new(MockTopicManager::admitting(vec![0])),
12944        );
12945        let mut engine = Engine::new(
12946            managers,
12947            HashMap::new(),
12948            Box::new(storage),
12949            None,
12950            EngineConfig::default(),
12951        );
12952        engine.set_ancestor_fetcher(fetcher.clone());
12953
12954        let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
12955        assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
12956    }
12957
12958    #[tokio::test]
12959    async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
12960        // Window-clog regression (#130): migration 0010 defaulted EVERY
12961        // existing `transactions` row to has_proof = 0, including rows whose
12962        // stored BEEF already carries a proof (e.g. GASP-synced txs). The
12963        // candidate query (`WHERE has_proof = 0 LIMIT n`) returns those rows
12964        // every tick; the engine skips them but — before this fix — never
12965        // flipped the flag, so they lingered in the bounded window forever and
12966        // starved genuinely-proofless rows behind them. The fix marks each
12967        // already-proven scanned row so it drops out of the NEXT pass.
12968        use bsv_rs::transaction::{Beef, MerklePath, Transaction};
12969
12970        let storage = MemoryStorage::new();
12971
12972        // Seed several ALREADY-PROVEN rows. They are inserted via
12973        // `insert_output`, which (modeling migration 0010's default) does NOT
12974        // set the proof flag — so despite carrying a valid proof they show up
12975        // as has_proof = 0 candidates: the exact clog.
12976        let proven_raws = [
12977            "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
12978            "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
12979        ];
12980        let mut proven_ids = Vec::new();
12981        for raw in proven_raws {
12982            let mut tx = Transaction::from_hex(raw).unwrap();
12983            let id = tx.id();
12984            tx.merkle_path =
12985                Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
12986            // to_beef(true) carries the merkle proof into the BEEF, so the row
12987            // genuinely IS proven — yet its flag stays 0 (insert path).
12988            let proven_beef = tx.to_beef(true).unwrap();
12989            assert_eq!(
12990                Beef::from_binary(&proven_beef)
12991                    .unwrap()
12992                    .find_txid(&id)
12993                    .map(bsv_rs::transaction::BeefTx::has_proof),
12994                Some(true),
12995                "proven fixture row must carry its proof"
12996            );
12997            proven_ids.push(id.clone());
12998            storage
12999                .insert_output(&Output {
13000                    txid: id,
13001                    output_index: 0,
13002                    output_script: vec![0x76],
13003                    satoshis: 1000,
13004                    topic: "Hello".to_string(),
13005                    spent: false,
13006                    outputs_consumed: vec![],
13007                    consumed_by: vec![],
13008                    beef: Some(proven_beef),
13009                    block_height: Some(800_000),
13010                    score: Some(1.0),
13011                })
13012                .await
13013                .unwrap();
13014        }
13015
13016        let fetcher = std::rc::Rc::new(StubFetcher {
13017            proof: None,
13018            calls: Cell::new(0),
13019        });
13020        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
13021        managers.insert(
13022            "Hello".into(),
13023            Box::new(MockTopicManager::admitting(vec![0])),
13024        );
13025        let mut engine = Engine::new(
13026            managers,
13027            HashMap::new(),
13028            Box::new(storage),
13029            None,
13030            EngineConfig::default(),
13031        );
13032        engine.set_ancestor_fetcher(fetcher.clone());
13033
13034        // First pass over a window covering both proven rows: each is detected
13035        // already-proven and marked. Nothing proofless, nothing fetched.
13036        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
13037        assert_eq!(summary.scanned, proven_ids.len());
13038        assert_eq!(summary.proofless, 0);
13039        assert_eq!(summary.completed, 0);
13040        assert_eq!(
13041            summary.already_proven,
13042            proven_ids.len(),
13043            "every already-proven row is marked this pass"
13044        );
13045        assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
13046
13047        // Window has advanced: the NEXT candidate query no longer returns the
13048        // marked rows, so the pass scans nothing.
13049        let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
13050        assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
13051        assert_eq!(summary2.already_proven, 0);
13052    }
13053
13054    #[tokio::test]
13055    async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
13056        // The window-clog in action with a small limit: a genuinely-proofless
13057        // backlog row sits behind already-proven (stale-flag) rows. Each tick
13058        // marks the proven rows it scans (dropping them out), so over a few
13059        // bounded ticks the window advances until the proofless tx is reached
13060        // + completed — instead of being starved forever.
13061        use bsv_rs::transaction::{MerklePath, Transaction};
13062
13063        let storage = MemoryStorage::new();
13064
13065        let proven_raws = [
13066            "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
13067        ];
13068        for raw in proven_raws {
13069            let mut tx = Transaction::from_hex(raw).unwrap();
13070            let id = tx.id();
13071            tx.merkle_path =
13072                Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
13073            let proven_beef = tx.to_beef(true).unwrap();
13074            storage
13075                .insert_output(&Output {
13076                    txid: id,
13077                    output_index: 0,
13078                    output_script: vec![0x76],
13079                    satoshis: 1000,
13080                    topic: "Hello".to_string(),
13081                    spent: false,
13082                    outputs_consumed: vec![],
13083                    consumed_by: vec![],
13084                    beef: Some(proven_beef),
13085                    block_height: Some(800_000),
13086                    score: Some(1.0),
13087                })
13088                .await
13089                .unwrap();
13090        }
13091
13092        // The historical proofless tx (the f584f846-style backlog row).
13093        let (proofless, proofless_txid) = proofless_beef();
13094        storage
13095            .insert_output(&Output {
13096                txid: proofless_txid.clone(),
13097                output_index: 0,
13098                output_script: vec![0x76],
13099                satoshis: 1000,
13100                topic: "Hello".to_string(),
13101                spent: false,
13102                outputs_consumed: vec![],
13103                consumed_by: vec![],
13104                beef: Some(proofless),
13105                block_height: None,
13106                score: Some(1.0),
13107            })
13108            .await
13109            .unwrap();
13110
13111        let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
13112        let fetcher = std::rc::Rc::new(StubFetcher {
13113            proof: Some(proof_hex),
13114            calls: Cell::new(0),
13115        });
13116        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
13117        managers.insert(
13118            "Hello".into(),
13119            Box::new(MockTopicManager::admitting(vec![0])),
13120        );
13121        let mut engine = Engine::new(
13122            managers,
13123            HashMap::new(),
13124            Box::new(storage),
13125            None,
13126            EngineConfig::default(),
13127        );
13128        engine.set_ancestor_fetcher(fetcher.clone());
13129
13130        // Run bounded ticks (limit 1) until the proofless tx is completed. With
13131        // 2 rows total a couple of ticks suffice; cap iterations defensively.
13132        let mut completed_total = 0;
13133        for _ in 0..5 {
13134            let s = engine.complete_missing_proofs(1, 0).await.unwrap();
13135            completed_total += s.completed;
13136            if completed_total > 0 {
13137                break;
13138            }
13139        }
13140        assert_eq!(
13141            completed_total, 1,
13142            "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
13143        );
13144
13145        let out = engine
13146            .storage()
13147            .find_output(&proofless_txid, 0, Some("Hello"), None, true)
13148            .await
13149            .unwrap()
13150            .unwrap();
13151        let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
13152        assert_eq!(
13153            beef.find_txid(&proofless_txid)
13154                .map(bsv_rs::transaction::BeefTx::has_proof),
13155            Some(true),
13156            "the old proofless tx now carries its proof"
13157        );
13158    }
13159
13160    // ── S2 queue-durable admission (bsv-low 2026-08-29): Phase-3 faults ──
13161    //
13162    // The phantom class: under a D1 storm `submit` returned Ok(steak) while
13163    // its writes failed — the ack outlived the admission. These pins hold
13164    // the new contract: a fault is REPORTED, the faulted topic is NOT
13165    // recorded as applied, and a replay of the same bytes re-applies.
13166
13167    use crate::storage::StorageError;
13168    use std::rc::Rc;
13169
13170    /// `MemoryStorage` behind two fault switches. Every method delegates;
13171    /// the two the tests flip model a D1 write fault (`insert_output`) and
13172    /// a D1 read fault (`find_output` — the validation's previous-coin scan
13173    /// AND Phase 3's stale/consumed lookups).
13174    struct FaultingStorage {
13175        inner: MemoryStorage,
13176        fail_insert_output: Cell<bool>,
13177        fail_find_output: Cell<bool>,
13178    }
13179
13180    impl FaultingStorage {
13181        fn new() -> Rc<Self> {
13182            Rc::new(Self {
13183                inner: MemoryStorage::new(),
13184                fail_insert_output: Cell::new(false),
13185                fail_find_output: Cell::new(false),
13186            })
13187        }
13188    }
13189
13190    #[async_trait(?Send)]
13191    impl Storage for FaultingStorage {
13192        async fn insert_output_with_origin(
13193            &self,
13194            output: &Output,
13195            gasp: bool,
13196        ) -> Result<(), StorageError> {
13197            if self.fail_insert_output.get() {
13198                return Err(StorageError::Database(
13199                    "D1_ERROR: storage overloaded".into(),
13200                ));
13201            }
13202            self.inner.insert_output_with_origin(output, gasp).await
13203        }
13204        async fn read_fault_memos(
13205            &self,
13206            host: &str,
13207            topic: &str,
13208            outpoints: Option<&[String]>,
13209        ) -> Result<crate::fault_memo::FaultMemoBatch, StorageError> {
13210            self.inner.read_fault_memos(host, topic, outpoints).await
13211        }
13212
13213        async fn put_deferred_graph(
13214            &self,
13215            record: &crate::gasp::DeferredGraph,
13216        ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
13217            self.inner.put_deferred_graph(record).await
13218        }
13219        async fn reserve_unanchorable_retry(
13220            &self,
13221            record: &crate::gasp::DeferredGraph,
13222        ) -> Result<bool, StorageError> {
13223            self.inner.reserve_unanchorable_retry(record).await
13224        }
13225        async fn find_deferred_graphs(
13226            &self,
13227            host: &str,
13228            topic: &str,
13229        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
13230            self.inner.find_deferred_graphs(host, topic).await
13231        }
13232        async fn get_deferred_graph(
13233            &self,
13234            host: &str,
13235            topic: &str,
13236            outpoint: &str,
13237        ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
13238            self.inner.get_deferred_graph(host, topic, outpoint).await
13239        }
13240        async fn delete_deferred_graph(
13241            &self,
13242            host: &str,
13243            topic: &str,
13244            outpoint: &str,
13245        ) -> Result<(), StorageError> {
13246            self.inner
13247                .delete_deferred_graph(host, topic, outpoint)
13248                .await
13249        }
13250        async fn record_peer_sync_outcome(
13251            &self,
13252            host: &str,
13253            topic: &str,
13254            success: bool,
13255        ) -> Result<(), StorageError> {
13256            self.inner
13257                .record_peer_sync_outcome(host, topic, success)
13258                .await
13259        }
13260        async fn get_peer_sync_health(
13261            &self,
13262            host: &str,
13263            topic: &str,
13264        ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
13265            self.inner.get_peer_sync_health(host, topic).await
13266        }
13267        async fn record_peer_sync_yield(
13268            &self,
13269            host: &str,
13270            topic: &str,
13271            yielded: bool,
13272        ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
13273            self.inner
13274                .record_peer_sync_yield(host, topic, yielded)
13275                .await
13276        }
13277        async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
13278            self.insert_output_with_origin(output, false).await
13279        }
13280        async fn delete_output(
13281            &self,
13282            txid: &str,
13283            output_index: u32,
13284            topic: &str,
13285        ) -> Result<(), StorageError> {
13286            self.inner.delete_output(txid, output_index, topic).await
13287        }
13288        async fn mark_utxo_as_spent(
13289            &self,
13290            txid: &str,
13291            output_index: u32,
13292            topic: &str,
13293        ) -> Result<(), StorageError> {
13294            self.inner
13295                .mark_utxo_as_spent(txid, output_index, topic)
13296                .await
13297        }
13298        async fn update_consumed_by(
13299            &self,
13300            txid: &str,
13301            output_index: u32,
13302            topic: &str,
13303            consumed_by: &[Outpoint],
13304        ) -> Result<(), StorageError> {
13305            self.inner
13306                .update_consumed_by(txid, output_index, topic, consumed_by)
13307                .await
13308        }
13309        async fn update_transaction_beef(
13310            &self,
13311            txid: &str,
13312            beef: &[u8],
13313        ) -> Result<(), StorageError> {
13314            self.inner.update_transaction_beef(txid, beef).await
13315        }
13316        async fn insert_applied_transaction(
13317            &self,
13318            tx: &AppliedTransaction,
13319        ) -> Result<(), StorageError> {
13320            self.inner.insert_applied_transaction(tx).await
13321        }
13322        async fn does_applied_transaction_exist(
13323            &self,
13324            tx: &AppliedTransaction,
13325        ) -> Result<bool, StorageError> {
13326            self.inner.does_applied_transaction_exist(tx).await
13327        }
13328        async fn delete_applied_transaction(
13329            &self,
13330            tx: &AppliedTransaction,
13331        ) -> Result<(), StorageError> {
13332            self.inner.delete_applied_transaction(tx).await
13333        }
13334        async fn find_output(
13335            &self,
13336            txid: &str,
13337            output_index: u32,
13338            topic: Option<&str>,
13339            spent: Option<bool>,
13340            include_beef: bool,
13341        ) -> Result<Option<Output>, StorageError> {
13342            if self.fail_find_output.get() {
13343                return Err(StorageError::Database("D1_ERROR: read timed out".into()));
13344            }
13345            self.inner
13346                .find_output(txid, output_index, topic, spent, include_beef)
13347                .await
13348        }
13349        async fn find_outputs_for_transaction(
13350            &self,
13351            txid: &str,
13352            include_beef: bool,
13353        ) -> Result<Vec<Output>, StorageError> {
13354            self.inner
13355                .find_outputs_for_transaction(txid, include_beef)
13356                .await
13357        }
13358        async fn find_utxos_for_topic(
13359            &self,
13360            topic: &str,
13361            since: Option<f64>,
13362            limit: Option<u64>,
13363            include_beef: bool,
13364        ) -> Result<Vec<Output>, StorageError> {
13365            self.inner
13366                .find_utxos_for_topic(topic, since, limit, include_beef)
13367                .await
13368        }
13369        async fn update_last_interaction(
13370            &self,
13371            host: &str,
13372            topic: &str,
13373            since: u64,
13374        ) -> Result<(), StorageError> {
13375            self.inner.update_last_interaction(host, topic, since).await
13376        }
13377        async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
13378            self.inner.get_last_interaction(host, topic).await
13379        }
13380    }
13381
13382    fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
13383        let storage = FaultingStorage::new();
13384        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13385        managers.insert(
13386            "tm_test".to_string(),
13387            Box::new(MockTopicManager::admitting(admit_indices)),
13388        );
13389        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13390        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
13391        let engine = Engine::new(
13392            managers,
13393            lookup_services,
13394            Box::new(Rc::clone(&storage)),
13395            None,
13396            EngineConfig::default(),
13397        );
13398        (engine, storage)
13399    }
13400
13401    async fn applied(storage: &FaultingStorage) -> bool {
13402        storage
13403            .does_applied_transaction_exist(&AppliedTransaction {
13404                txid: TEST_TXID.to_string(),
13405                topic: "tm_test".to_string(),
13406            })
13407            .await
13408            .unwrap()
13409    }
13410
13411    /// The old behaviour, pinned: a clean submit is durable, records the
13412    /// topic as applied, and the output is in storage.
13413    #[tokio::test]
13414    async fn clean_submit_is_durable_and_records_applied() {
13415        let (engine, storage) = make_faulting_engine(vec![0]);
13416        let (steak, report) = engine
13417            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13418            .await
13419            .unwrap();
13420        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
13421        assert!(report.is_durable(), "{}", report.summary());
13422        assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
13423        assert!(applied(&storage).await);
13424        assert!(storage
13425            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13426            .await
13427            .unwrap()
13428            .is_some());
13429    }
13430
13431    /// THE PHANTOM, red→green: the insert fails, the steak still says
13432    /// "admitted" (the decision stands), but the report says NOT DURABLE and
13433    /// names the site — and the topic is NOT recorded as applied, so nothing
13434    /// downstream can treat the loss as final.
13435    #[tokio::test]
13436    async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
13437        let (engine, storage) = make_faulting_engine(vec![0]);
13438        storage.fail_insert_output.set(true);
13439        let (steak, report) = engine
13440            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13441            .await
13442            .unwrap();
13443        assert_eq!(
13444            steak["tm_test"].outputs_to_admit,
13445            vec![0],
13446            "the admission DECISION is unchanged by a write fault"
13447        );
13448        assert!(!report.is_durable());
13449        assert_eq!(report.faults.len(), 1, "{}", report.summary());
13450        assert_eq!(report.faults[0].topic, "tm_test");
13451        assert_eq!(report.faults[0].site, "insert_output");
13452        assert!(report.faults[0].error.contains("D1_ERROR"));
13453        assert!(report.applied_topics.is_empty());
13454        assert!(
13455            !applied(&storage).await,
13456            "a faulted topic must not be recorded as applied — the replay would be a dupe"
13457        );
13458        assert!(storage
13459            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13460            .await
13461            .unwrap()
13462            .is_none());
13463    }
13464
13465    /// The replay: same bytes, storage healthy again → re-validated (not a
13466    /// dupe), re-written, recorded. A THIRD submit is then the dupe it
13467    /// should be: nothing admitted, nothing to write, durable.
13468    #[tokio::test]
13469    async fn replay_after_a_fault_reapplies_and_records_applied() {
13470        let (engine, storage) = make_faulting_engine(vec![0]);
13471        storage.fail_insert_output.set(true);
13472        let (_, first) = engine
13473            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13474            .await
13475            .unwrap();
13476        assert!(!first.is_durable());
13477
13478        storage.fail_insert_output.set(false);
13479        let (steak, replay) = engine
13480            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13481            .await
13482            .unwrap();
13483        assert_eq!(
13484            steak["tm_test"].outputs_to_admit,
13485            vec![0],
13486            "re-validated, not deduped"
13487        );
13488        assert!(replay.is_durable(), "{}", replay.summary());
13489        assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
13490        assert!(applied(&storage).await);
13491        assert!(storage
13492            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13493            .await
13494            .unwrap()
13495            .is_some());
13496
13497        let (steak, third) = engine
13498            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13499            .await
13500            .unwrap();
13501        assert!(
13502            steak["tm_test"].outputs_to_admit.is_empty(),
13503            "now a genuine dupe: nothing admitted"
13504        );
13505        assert!(third.is_durable() && third.applied_topics.is_empty());
13506    }
13507
13508    /// The SPEND half of the phantom: a `find_output` fault during
13509    /// validation made a settle look like it consumed nothing, so the spend
13510    /// pointer was never written and the ack stood. Now the read fault is
13511    /// reported, the topic stays unrecorded, the previously-admitted coin
13512    /// is untouched — and the healthy replay consumes it.
13513    #[tokio::test]
13514    async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
13515        let (engine, storage) = make_faulting_engine(vec![0]);
13516        // The subject's own input: seed the coin it spends as a previously
13517        // admitted tm_test output.
13518        let tx = Transaction::from_beef(&test_beef(), None).unwrap();
13519        let source_txid = tx.inputs[0].get_source_txid().unwrap();
13520        let source_vout = tx.inputs[0].source_output_index;
13521        storage
13522            .insert_output(&Output {
13523                txid: source_txid.clone(),
13524                output_index: source_vout,
13525                output_script: vec![0x51],
13526                satoshis: 1,
13527                topic: "tm_test".to_string(),
13528                spent: false,
13529                outputs_consumed: vec![],
13530                consumed_by: vec![],
13531                beef: None,
13532                block_height: None,
13533                score: Some(1.0),
13534            })
13535            .await
13536            .unwrap();
13537
13538        storage.fail_find_output.set(true);
13539        let (_, faulted) = engine
13540            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13541            .await
13542            .unwrap();
13543        assert!(!faulted.is_durable());
13544        assert!(
13545            faulted.faults.iter().any(|f| f.site == "find_output"),
13546            "{}",
13547            faulted.summary()
13548        );
13549        assert!(
13550            !applied(&storage).await,
13551            "unrecorded: the replay must re-read"
13552        );
13553        storage.fail_find_output.set(false);
13554        assert!(
13555            storage
13556                .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13557                .await
13558                .unwrap()
13559                .is_some(),
13560            "the coin the faulted pass could not see is still there, untouched"
13561        );
13562
13563        let (_, replay) = engine
13564            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13565            .await
13566            .unwrap();
13567        assert!(replay.is_durable(), "{}", replay.summary());
13568        assert!(applied(&storage).await);
13569        // The mock manager retains no coins, so the consumed coin is stale
13570        // and deleted — the observable proof the spend was PROCESSED.
13571        assert!(
13572            storage
13573                .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13574                .await
13575                .unwrap()
13576                .is_none(),
13577            "the healthy replay consumed the coin the faulted pass missed"
13578        );
13579    }
13580
13581    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): a lookup service that missed
13582    /// its admit notification is told AGAIN from the engine's own stored
13583    /// outputs — one call per admitted output per service, the WholeTx body
13584    /// naming the subject — while `/submit` of the same bytes is deduped and
13585    /// tells nobody (the premise, RED-verified in the same test); a topic the
13586    /// engine never admitted this txid on yields 0 (the caller re-submits).
13587    #[tokio::test]
13588    async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
13589        use std::cell::RefCell;
13590        use std::rc::Rc;
13591
13592        struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
13593        #[async_trait(?Send)]
13594        impl LookupServiceTrait for CountingLs {
13595            fn admission_mode(&self) -> AdmissionMode {
13596                self.1
13597            }
13598            fn spend_notification_mode(&self) -> SpendNotificationMode {
13599                SpendNotificationMode::None
13600            }
13601            async fn output_admitted_by_topic(
13602                &self,
13603                payload: &OutputAdmittedByTopic,
13604            ) -> Result<(), LookupServiceError> {
13605                let rec = match payload {
13606                    OutputAdmittedByTopic::LockingScript {
13607                        txid,
13608                        output_index,
13609                        topic,
13610                        ..
13611                    } => (txid.clone(), *output_index, topic.clone()),
13612                    OutputAdmittedByTopic::WholeTx {
13613                        atomic_beef,
13614                        output_index,
13615                        topic,
13616                        ..
13617                    } => {
13618                        let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
13619                        (
13620                            beef.atomic_txid
13621                                .clone()
13622                                .expect("the WholeTx body NAMES its subject"),
13623                            *output_index,
13624                            topic.clone(),
13625                        )
13626                    }
13627                };
13628                self.0.borrow_mut().push(rec);
13629                Ok(())
13630            }
13631            async fn output_evicted(
13632                &self,
13633                _txid: &str,
13634                _output_index: u32,
13635            ) -> Result<(), LookupServiceError> {
13636                Ok(())
13637            }
13638            async fn lookup(
13639                &self,
13640                _question: &LookupQuestion,
13641            ) -> Result<LookupResult, LookupServiceError> {
13642                Ok(LookupResult::OutputList(Vec::new()))
13643            }
13644            async fn get_documentation(&self) -> String {
13645                "counting".to_string()
13646            }
13647            async fn get_metadata(&self) -> ServiceMetadata {
13648                ServiceMetadata {
13649                    name: "counting-ls".to_string(),
13650                    ..Default::default()
13651                }
13652            }
13653        }
13654
13655        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13656        managers.insert(
13657            "tm_test".to_string(),
13658            Box::new(MockTopicManager::admitting(vec![0])),
13659        );
13660        let seen = Rc::new(RefCell::new(Vec::new()));
13661        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13662        lookup_services.insert(
13663            "ls_whole".to_string(),
13664            Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
13665        );
13666        lookup_services.insert(
13667            "ls_script".to_string(),
13668            Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
13669        );
13670        let engine = Engine::new(
13671            managers,
13672            lookup_services,
13673            Box::new(MemoryStorage::new()),
13674            None,
13675            EngineConfig::default(),
13676        );
13677
13678        let tagged = test_tagged_beef(vec!["tm_test"]);
13679        engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13680        assert_eq!(seen.borrow().len(), 2, "one admit per service");
13681        let (txid, vout, _) = seen.borrow()[0].clone();
13682        assert_eq!(txid, TEST_TXID);
13683
13684        // the premise, RED-verified: the same bytes again are DEDUPED — /submit can never re-notify
13685        let (_, report) = engine
13686            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13687            .await
13688            .unwrap();
13689        assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
13690        assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
13691
13692        // the verb: told again from the stored outputs, the WholeTx body naming the subject
13693        let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
13694        assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
13695        assert_eq!(r.vouts, vec![vout]);
13696        assert_eq!(seen.borrow().len(), 4);
13697        assert!(seen.borrow()[2..]
13698            .iter()
13699            .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
13700
13701        // a topic the engine never admitted this txid on: nothing to re-notify (re-submit instead)
13702        let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
13703        assert_eq!((r0.outputs, r0.notified), (0, 0));
13704        assert_eq!(seen.borrow().len(), 4);
13705
13706        // idempotent: a second re-notify tells everyone again, faults nothing
13707        let r2 = engine
13708            .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
13709            .await
13710            .unwrap();
13711        assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
13712        assert_eq!(seen.borrow().len(), 6);
13713    }
13714
13715    /// bsv-low PLAN-PRE-LOOP4 §H4: a PHANTOM applied row (the topic admitted
13716    /// NOTHING) dedups every re-submit of those bytes; forgetting it lets the
13717    /// same bytes be judged again — while a REAL admission's row is never
13718    /// forgotten and a missing row is nothing to forget.
13719    #[tokio::test]
13720    async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
13721        // phantom: a TM that admits nothing still records the topic as applied
13722        let engine = make_engine(vec![]);
13723        let tagged = test_tagged_beef(vec!["tm_test"]);
13724        let (steak, r1) = engine
13725            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13726            .await
13727            .unwrap();
13728        assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
13729        assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
13730        let (_, r2) = engine
13731            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13732            .await
13733            .unwrap();
13734        assert_eq!(
13735            r2.deduped_topics,
13736            vec!["tm_test".to_string()],
13737            "the phantom row dedups the re-submit"
13738        );
13739        assert!(engine
13740            .forget_phantom_applied(TEST_TXID, "tm_test")
13741            .await
13742            .unwrap());
13743        let (_, r3) = engine
13744            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13745            .await
13746            .unwrap();
13747        assert!(
13748            r3.deduped_topics.is_empty(),
13749            "judged again after the forget"
13750        );
13751        assert!(
13752            !engine
13753                .forget_phantom_applied(TEST_TXID, "tm_other")
13754                .await
13755                .unwrap(),
13756            "no row: nothing to forget"
13757        );
13758
13759        // real: a TM that admits output 0 — its row is a real admission, never forgotten
13760        let real = make_engine(vec![0]);
13761        real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13762        assert!(!real
13763            .forget_phantom_applied(TEST_TXID, "tm_test")
13764            .await
13765            .unwrap());
13766        let (_, r4) = real
13767            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13768            .await
13769            .unwrap();
13770        assert_eq!(
13771            r4.deduped_topics,
13772            vec!["tm_test".to_string()],
13773            "a real admission still dedups"
13774        );
13775    }
13776
13777    // ── NL-6e: the SPV walk is linear in a BUMP's leaves (NL-8 W1) ─────
13778
13779    /// Named and generous (a debug build on a loaded machine): the walk at
13780    /// bsv-rs 0.4.0 took 30 to 36 s at 4,096 leaves in release and gave no
13781    /// answer in 120 s at 8,192 (NL-8 W1).
13782    const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
13783
13784    /// The wide BUMP's height (NL-8's `wide k` row).
13785    const WIDE_HEIGHT: u32 = 800_001;
13786
13787    /// `2^k` txids, display hex: the double SHA-256 of `i` (NL-8's probe).
13788    fn wide_leaves(k: u32) -> Vec<String> {
13789        (0..1u32 << k)
13790            .map(|i| {
13791                let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
13792                id.reverse();
13793                hex::encode(id)
13794            })
13795            .collect()
13796    }
13797
13798    /// One BUMP at [`WIDE_HEIGHT`] whose level 0 carries `txids` as hashed
13799    /// leaves and whose `k - 1` upper levels are empty: every node above
13800    /// level 0 is computed from it. Its root, by one walk.
13801    fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
13802        let k = txids.len().trailing_zeros() as usize;
13803        let mut levels = vec![Vec::new(); k];
13804        levels[0] = txids
13805            .iter()
13806            .enumerate()
13807            .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
13808            .collect();
13809        let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
13810        let root = bump.compute_root(None).unwrap();
13811        (bump, root)
13812    }
13813
13814    /// NL-8 W1's bytes: a V1 BEEF of the one wide BUMP and no transaction
13815    /// (`wide_14` at `2^k` leaves, byte for byte the runner's probe).
13816    fn w1_bytes(k: u32) -> (Vec<u8>, String) {
13817        let (bump, root) = wide_bump(&wide_leaves(k));
13818        let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
13819        beef.extend_from_slice(&bump.to_binary());
13820        beef.push(0x00);
13821        (beef, root)
13822    }
13823
13824    /// The entry, [`verify_spv_like_the_reference`], on its own thread under
13825    /// a tracker that carries `root` at [`WIDE_HEIGHT`]: its answer and its
13826    /// time, or a panic naming the bound.
13827    fn walk_within_bound(
13828        bytes: Vec<u8>,
13829        root: String,
13830        verify_scripts: bool,
13831        subject: String,
13832        what: &str,
13833    ) -> (Result<(), String>, std::time::Duration) {
13834        let (tx, rx) = std::sync::mpsc::channel();
13835        std::thread::spawn(move || {
13836            let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
13837            tracker.add_root(WIDE_HEIGHT, root);
13838            let runtime = tokio::runtime::Builder::new_current_thread()
13839                .build()
13840                .unwrap();
13841            let started = std::time::Instant::now();
13842            let verdict = runtime.block_on(verify_spv_like_the_reference(
13843                Some(&tracker),
13844                verify_scripts,
13845                &bytes,
13846                &subject,
13847                DoorBudget::DEFAULT,
13848            ));
13849            let verdict = match verdict {
13850                Ok(None) => Ok(()),
13851                Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
13852                Err(e) => Err(e.to_string()),
13853            };
13854            let _ = tx.send((verdict, started.elapsed()));
13855        });
13856        rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
13857            panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
13858        })
13859    }
13860
13861    /// The escape hatch's structural walk (`verify_scripts` false) on NL-8
13862    /// W1's bytes, 4,096 and 8,192 leaves: accepted, the one root asked of
13863    /// the tracker, well inside the bound.
13864    #[test]
13865    fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
13866        for k in [12u32, 13] {
13867            let (bytes, root) = w1_bytes(k);
13868            let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
13869            let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
13870            println!(
13871                "NL-6e W1 {what}: {verdict:?} in {:.3} s",
13872                took.as_secs_f64()
13873            );
13874            assert_eq!(verdict, Ok(()), "{what}");
13875        }
13876    }
13877
13878    /// The default walk (scripts run, `verify_scripts` true) over `2^k`
13879    /// proven parents in the one wide BUMP, each spent by a binary tree of
13880    /// two-input transactions whose root is the subject: every parent's
13881    /// root is the BUMP's, asked of the tracker, the inputs `2^(k+1) - 2`.
13882    fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
13883        use bsv_rs::script::{LockingScript, UnlockingScript};
13884        use bsv_rs::transaction::{TransactionInput, TransactionOutput};
13885        let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
13886        let spending = |txid: &str| {
13887            let mut input = TransactionInput::new(txid.to_string(), 0);
13888            input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
13889            input
13890        };
13891        let parents: Vec<Transaction> = (0..1u32 << k)
13892            .map(|i| {
13893                let mut tx = Transaction::new();
13894                // A coinbase's null outpoint, a distinct index each.
13895                let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
13896                input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
13897                tx.inputs.push(input);
13898                tx.outputs.push(TransactionOutput::new(1, op_true()));
13899                tx
13900            })
13901            .collect();
13902        let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
13903        let (bump, root) = wide_bump(&ids);
13904        let mut beef = Beef::new();
13905        let at = beef.merge_bump(bump);
13906        for parent in &parents {
13907            beef.merge_raw_tx(parent.to_binary(), Some(at));
13908        }
13909        let mut layer = ids;
13910        while layer.len() > 1 {
13911            layer = layer
13912                .chunks(2)
13913                .map(|pair| {
13914                    let mut tx = Transaction::new();
13915                    tx.inputs.push(spending(&pair[0]));
13916                    tx.inputs.push(spending(&pair[1]));
13917                    tx.outputs.push(TransactionOutput::new(0, op_true()));
13918                    beef.merge_raw_tx(tx.to_binary(), None);
13919                    tx.id()
13920                })
13921                .collect();
13922        }
13923        (beef.to_binary(), root, layer.remove(0))
13924    }
13925
13926    #[test]
13927    fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
13928        for k in [12u32, 13] {
13929            let (bytes, root, subject) = proven_fan_in(k);
13930            let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
13931            let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
13932            println!(
13933                "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
13934                took.as_secs_f64()
13935            );
13936            assert_eq!(verdict, Ok(()), "{what}");
13937        }
13938    }
13939    include!("minutes_tests.rs");
13940    include!("memo_tests.rs");
13941    include!("absence_tests.rs");
13942    include!("route_fault_tests.rs");
13943    include!("batch_tests.rs");
13944    include!("page_batch_tests.rs");
13945}