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    /// Stored transactions inspected this tick (the bounded page size).
296    pub scanned: usize,
297    /// Of `scanned`, how many still lacked a merkle proof for their own tx.
298    pub proofless: usize,
299    /// Proofless txs whose BUMP was fetched + stitched into the stored BEEF.
300    pub completed: usize,
301    /// Proofless txs the fetcher could serve but that are not yet mined
302    /// (no BUMP yet) — retried on a later tick.
303    pub still_unconfirmed: usize,
304    /// Proofless txs the fetcher errored on (e.g. budget exhausted, 429) —
305    /// retried on a later tick.
306    pub fetch_failed: usize,
307    /// Proofless txs whose proof came back but failed to stitch into storage.
308    pub stitch_failed: usize,
309    /// Scanned rows that ALREADY carried a valid proof (stale `has_proof = 0`
310    /// flag) and were marked proven this tick so they drop out of the candidate
311    /// window — clearing the window-clog (#130).
312    pub already_proven: usize,
313}
314
315/// Outcome of one [`Engine::sync_advertisements`] run (bsv-low #320 defect
316/// 3a).
317///
318/// The engine used to swallow create/submit failures into `error!` and
319/// return `Ok(())`, so the admin route reported `success` while zero
320/// advertisements were admitted locally; a caller could not tell a
321/// converged no-op (`to_create == 0`) from a silent failure. Every field
322/// here is observational — the sync's behavior is unchanged, its outcome is
323/// no longer hidden.
324#[derive(Debug, Default, Clone, Serialize)]
325#[serde(rename_all = "camelCase")]
326pub struct SyncAdvertisementsReport {
327    /// Advertisements the diff found missing (attempted this run). A
328    /// converged node reports 0 here.
329    pub to_create: usize,
330    /// Stale advertisements the diff said to revoke.
331    pub to_revoke: usize,
332    /// `advertiser.create_advertisements` failure, verbatim.
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub create_error: Option<String>,
335    /// Local `Engine::submit` failure for the created ads, verbatim — the
336    /// path that silently hid the self-admission failure live (our own
337    /// ls_ship/ls_slap never gained our ads, so every cycle re-created all
338    /// of them).
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub submit_error: Option<String>,
341    /// Outputs admitted per topic by the local submit (from the STEAK).
342    /// A successful submit that admitted NOTHING (topic-manager refusal)
343    /// shows up here as explicit zeros.
344    pub admitted: std::collections::BTreeMap<String, usize>,
345    /// `advertiser.find_all_advertisements` failure, verbatim (#320 M2).
346    /// When set, creation was REFUSED this run: a blind read must never
347    /// become "current ads = none" and re-create (re-pay) the entire set.
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub lookup_error: Option<String>,
350    /// Stale ads the diff wanted revoked but the advertiser declined to
351    /// build a revocation for (the CF advertiser's documented v1 no-op —
352    /// #320 L2). Distinguishes "revoke happened" from "revoke skipped".
353    pub revoke_skipped: usize,
354    /// `advertiser.revoke_advertisements` failure, verbatim.
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub revoke_error: Option<String>,
357    /// Local `Engine::submit` failure for the revocation, verbatim.
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub revoke_submit_error: Option<String>,
360}
361
362impl SyncAdvertisementsReport {
363    /// True when nothing errored. NOTE: a submit that admitted zero outputs
364    /// is not an error at this layer — that refusal is [`Self::effective`]'s
365    /// job.
366    #[must_use]
367    pub fn ok(&self) -> bool {
368        self.create_error.is_none()
369            && self.submit_error.is_none()
370            && self.lookup_error.is_none()
371            && self.revoke_error.is_none()
372            && self.revoke_submit_error.is_none()
373    }
374
375    /// Total outputs admitted by the local submit across topics.
376    #[must_use]
377    pub fn admitted_total(&self) -> usize {
378        self.admitted.values().sum()
379    }
380
381    /// True when the run errored nowhere AND the local submit admitted
382    /// EVERY created ad (#320 M1 + delta D1): each advertisement is exactly
383    /// one output and change is never a SHIP/SLAP token, so full admission
384    /// means `admitted_total() >= to_create` (defensive `>=`). A weaker
385    /// `> 0` bar let a PARTIAL admit pass — the live set is 9 SHIP + 8 SLAP
386    /// in ONE tx with independent per-topic admission, so tm_ship admitting
387    /// its 9 while tm_slap refused all 8 read as success while the diff
388    /// re-created (re-paid for) the refused 8 every cycle. Subsumes the
389    /// converged no-op (`0 >= 0`).
390    #[must_use]
391    pub fn effective(&self) -> bool {
392        self.ok() && self.admitted_total() >= self.to_create
393    }
394}
395
396/// Internal result from Phase 1 + 2 validation.
397///
398/// Carries per-topic admittance decisions, previous coins, and the parsed
399/// transaction so that Phase 3 (mutations) can proceed without re-parsing.
400struct TopicValidation {
401    topic: String,
402    is_dupe: bool,
403    /// Input indices that spend previously-admitted outputs from this topic.
404    previous_coins: Vec<u32>,
405    /// The previous outputs found in storage (parallel to previous_coins).
406    previous_outputs: Vec<Output>,
407    admittance: AdmittanceInstructions,
408    failed: bool,
409    /// The FIRST storage read fault hit while scanning this topic's
410    /// previous outputs (S2, bsv-low 2026-08-29). A faulted read makes a
411    /// spend look like it consumes nothing — the settle's spend pointer is
412    /// then never written — so it is carried into the [`MutationReport`]
413    /// and blocks the `applied_transactions` record for this topic: a
414    /// replay re-reads instead of being deduplicated away. With the read
415    /// that faulted: `find_output` (a previous coin) or
416    /// `does_applied_transaction_exist` (the dedup read, the delta fold of
417    /// 2026-10-07, H3).
418    read_fault: Option<(&'static str, String)>,
419}
420
421/// One Phase-3 write — or the validation read it depends on — that FAILED
422/// during a submit (S2 queue-durable admission, bsv-low 2026-08-29).
423///
424/// Before this existed every such failure was `error!`-logged and swallowed
425/// while `submit` returned `Ok(steak)`: under a D1-overload storm the
426/// overlay ACKED admissions whose writes never landed (the 2026-08-26
427/// phantom class — `c4d2ed06…`/`62c2a9b3…` absent from the engine store,
428/// beta D1 queried directly). A fault is now a VALUE the caller can act on
429/// (re-queue for an idempotent replay) instead of a log line nobody reads.
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct MutationFault {
432    /// The topic whose mutation faulted.
433    pub topic: String,
434    /// The write (or read) that failed: `insert_output`,
435    /// `mark_utxo_as_spent`, `update_consumed_by`, `delete_utxo_deep`,
436    /// `insert_applied_transaction`, `find_output` (validation read),
437    /// `does_applied_transaction_exist` (the dedup read),
438    /// `lookup_service.output_spent`, `lookup_service.output_admitted_by_topic`,
439    /// `undo_insert_output` (the undo of a faulted submit's inserts),
440    /// `record_spent_coin_applied` (the applied row a spender writes for the
441    /// transaction of a coin it deletes), `predecessor_not_landed`.
442    pub site: &'static str,
443    /// The backend's own error text.
444    pub error: String,
445}
446
447/// What Phase 3 actually landed for a submit — the durability report.
448///
449/// `applied_topics` are the topics whose EVERY write (and lookup-service
450/// notification) succeeded and were therefore recorded in
451/// `applied_transactions`. A topic with any fault is deliberately NOT
452/// recorded: the dedup in Phase 1 would otherwise turn a replay into a
453/// no-op and make the loss permanent (the ordering was already
454/// write-then-record; the record is now conditional on the writes).
455#[derive(Debug, Default, Clone, PartialEq, Eq)]
456pub struct MutationReport {
457    pub faults: Vec<MutationFault>,
458    pub applied_topics: Vec<String>,
459    /// Topics this (txid, topic) pair had ALREADY been applied under — the
460    /// Phase-1 dedup skipped them, so their STEAK entry is an empty default
461    /// that means "nothing new", never "judged another tx" (loop-3 client
462    /// belt re-presents read the empty STEAK as index-pending).
463    pub deduped_topics: Vec<String>,
464    /// The predecessors the door LANDED first from this submit's BEEF, as
465    /// `(txid, topic)`, ancestors first ([`Engine::land_carried`]; the E1D
466    /// delta fold, L2). Each is an admission write of its own that no
467    /// caller asked for: a caller that guards its admission writes (the
468    /// worker's eviction ledger, bsv-low #513) guards these too.
469    pub landed_predecessors: Vec<(String, String)>,
470    /// The submit's own script walk could not run within the engine's
471    /// budget (bsv-low #592) and the submit went on as under
472    /// `HistoricalTxNoSpv` ([`WalkBreachPolicy::NetworkAccepted`] only; under
473    /// `NotNow` a breach is [`EngineError::WalkCouldNotRun`]). `None` when it
474    /// ran (or the mode does not walk). A caller counts it; it never makes a
475    /// report undurable.
476    pub walk_could_not_run: Option<WalkCouldNotRun>,
477    /// The walks of the carried predecessors this submit tried to land
478    /// ([`Engine::land_carried`]) that could not run within the budget (the
479    /// E592 lens fold, L3): under `NotNow` each one's landing did not happen
480    /// (the successor waits), under `NetworkAccepted` each went on. Counted
481    /// by the caller beside `walk_could_not_run`.
482    pub landed_walks_could_not_run: Vec<WalkCouldNotRun>,
483}
484
485/// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): what [`Engine::renotify_admitted`] did.
486#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
487pub struct RenotifyReport {
488    /// stored outputs of (txid, topic) the engine had ALREADY admitted
489    pub outputs: u32,
490    /// lookup-service notifications that returned Ok (outputs × services)
491    pub notified: u32,
492    /// notifications that failed — `<service>: <error>`; the others still ran
493    pub faults: Vec<String>,
494    /// the output indexes re-notified (the caller's spend discovery keys on them)
495    pub vouts: Vec<u32>,
496}
497
498impl MutationReport {
499    fn fault(&mut self, topic: &str, site: &'static str, error: String) {
500        self.faults.push(MutationFault {
501            topic: topic.to_string(),
502            site,
503            error,
504        });
505    }
506
507    /// True when every mutation this submit needed landed (nothing to
508    /// replay). A dupe-only submit is durable (there was nothing to write).
509    #[must_use]
510    pub fn is_durable(&self) -> bool {
511        self.faults.is_empty()
512    }
513
514    /// One log line naming every fault (`topic/site: error`), each error
515    /// clipped so a D1 stack dump cannot flood the log stream.
516    #[must_use]
517    pub fn summary(&self) -> String {
518        self.faults
519            .iter()
520            .map(|f| {
521                let err: String = f.error.chars().take(120).collect();
522                format!("{}/{}: {}", f.topic, f.site, err)
523            })
524            .collect::<Vec<_>>()
525            .join("; ")
526    }
527}
528
529/// The DOOR's static work bound (bsv-low W-A gate MED-1, 2026-09-09). Bitcoin
530/// script has no loops: every opcode runs at most once, so an input's work is
531/// bounded BEFORE execution by its opcode census times the interpreter's
532/// element limit (the stack memory limit bounds every element, and every
533/// CAT / NUM2BIN / SPLIT result with it) plus its signature checks times the
534/// transaction's own size or a floor, whichever is more (a BIP-143 preimage
535/// hashes the prevouts and the outputs; the EC verification costs the same
536/// whatever the transaction weighs). The estimate is computed from the bytes;
537/// nothing executes past the budget, and a budget breach is the DOOR's
538/// verdict (inconclusive: the network judges), never the interpreter's.
539///
540/// THE BODY IS NOT COUNTED (bsv-low #585, door 1). Until #585 the budget also
541/// counted the BODY: 64 unproven transactions, 256 inputs per transaction,
542/// 512 KB per transaction, and the parse's own size and counts. A valid BEEF
543/// is never left unjudged for its size or its counts, so those are gone: the
544/// walk reads the body through the streaming reader ([`crate::script_door`]).
545///
546/// TWO LIMBS, both a budget whose breach is "the network judges" and never a
547/// refusal: the WORK the interpreter will do, and the MEMORY the door will
548/// hold beside the caller's body. Each is estimated from the bytes before it
549/// is spent.
550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub struct DoorBudget {
552    /// The interpreter's stack memory limit: bounds every element, and is the
553    /// unit of the work estimate (a hash-class opcode is charged one element).
554    /// Not a bound on the BEEF: a script that trips it is "the network
555    /// judges", as a breach of the work is.
556    pub memory_limit: usize,
557    /// The whole walk's estimated work, in bytes hashed or pushed.
558    pub max_work_bytes: u64,
559    /// The least a signature check is charged (the doors lens E585-D12-M1).
560    /// A check is charged `max(its transaction's bytes, this)`: the digest
561    /// hashes about the transaction, and the EC verification behind it costs
562    /// the same whatever the transaction weighs (about 95 us native). Charged
563    /// the transaction's bytes alone, a 106 byte `OP_CHECKSIG OP_NOT`
564    /// transaction bought a verification for 106 bytes of budget: 90,000 of
565    /// them walked at a fifth of the budget in 9.6 s. With the floor the
566    /// budget bounds the EC verifications of one walk at
567    /// `max_work_bytes / sig_check_floor`.
568    pub sig_check_floor: u64,
569    /// The whole walk's estimated memory beside the body, in bytes (the doors
570    /// lens E585-D12-L3): what the index, the stream's element in hand and
571    /// the walk's layout of a transaction hold, estimated from the frame's
572    /// lengths and counts by [`crate::stream_sizing::estimate`] BEFORE the
573    /// stream is opened; and, per input, what its two scripts hold once
574    /// parsed and run (the delta lens E585-D12-DELTA-M1: bsv-rs's chunk
575    /// records, their clones, a signature's subscript, the empty stack
576    /// entries the interpreter's memory limit does not count), charged from
577    /// the scripts' chunk count and bytes BEFORE either is parsed
578    /// (`script_door::script_parse_charge`, 512 bytes a chunk and 8 a byte).
579    ///
580    /// How the two compose: the frame's estimate is the most the walk holds
581    /// at any moment apart from the input being run (what is kept, and the
582    /// heaviest transaction in hand, counted once with its raw bytes and the
583    /// interpreter's raw copies of the unlocking script); the scripts' charge
584    /// is the EXPANSION of one input's scripts past those bytes, held while
585    /// that input runs and released after it. An input is run only while the
586    /// frame's estimate plus its own charge is within this limb.
587    pub max_memory_bytes: u64,
588}
589
590impl DoorBudget {
591    /// Sized for LOW's real shapes with a wide margin. What the static census
592    /// BOUNDS: hashing (hash-class ops × the element limit) and signature
593    /// checks (× the transaction's size or the floor, whichever is more; a
594    /// CHECKMULTISIG weighted by its static key count when the count is a
595    /// small-int push, else by the most keys the element limit admits).
596    ///
597    /// THE FLOOR, 64 KiB (half the hash charge), chosen against LOW's shapes
598    /// as the pin `door_low_shapes_sit_under_a_quarter_of_the_work_budget`
599    /// measures them (work, of the 64 MiB; memory, the frame's and the
600    /// largest input's scripts, of the 48 MiB limb; work before the floor in
601    /// brackets):
602    ///
603    /// | shape | inputs | work | memory |
604    /// |---|---|---|---|
605    /// | 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 % |
606    /// | the real covenant refund | 1 | 924,112, 1.4 % (676,284) | 1,282,178, 2.55 % |
607    /// | 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 % |
608    /// | STRESS, not LOW's shape: nine seats, three hops each | 36 | 7,082,619, 10.6 % (4,740,838) | 36,420, 0.07 % |
609    /// | a coin 30 P2PKH hops deep | 30 | 5,902,188, 8.8 % (3,941,856) | 23,720, 0.05 % |
610    ///
611    /// A P2PKH input is charged 196,739 bytes (a hash opcode at
612    /// the element limit, a check at the floor, its 131 script bytes): 341 of
613    /// them are the budget, 85 a quarter of it. The bound on EC verifications
614    /// per walk is 64 MiB / 64 KiB = 1,024 (1,023 with their script bytes):
615    /// measured natively, 1,000 of them walk in 87 ms in the release profile
616    /// (1.4 to 1.8 s in a debug build), and the lens's 17,000 stop at the
617    /// 1,024th in 108 ms. wasm32 was not measured.
618    ///
619    /// THE MEMORY, 48 MiB: three eighths of a 128 MB isolate. Beside it stand,
620    /// for a 10 MB request (the figure the arithmetic is made at: since NL-6
621    /// the route caps no body, so a heavier one is the isolate's), the
622    /// request's body, the completed BEEF the route hands the door (a second
623    /// copy of up to as much) and the EF batch (the route's 2 MB bound, twice
624    /// while it is serialized): 24 MB, which leaves 56 MB for the module, the
625    /// runtime, the allocator's fragmentation and what a native estimate does
626    /// not see of wasm32. The limb bounds the walk (the door's, and since
627    /// bsv-low #592 the submit's own and the GASP anchor's under
628    /// [`Engine::set_walk_budget`]) and nothing else: the route's own
629    /// hydrated parses before it are bounded by the isolate only (the land
630    /// lens E585-LAND-M1: 1.43 GB natively for a 9.9 MB body of 900,000
631    /// minimal transactions, before the route's 429). What
632    /// it lets through, by the door's charges (`script_door::DOOR_CHARGES`,
633    /// each an upper bound: the pins measure 1.3 to 5.3 times the heap the
634    /// walk reaches): a BUMP of up to 72,944 leaves (690 bytes a level-0
635    /// leaf); a single transaction of up to about 12 MB (4 bytes a byte)
636    /// whose walked inputs' scripts are few chunks (a script is charged 520
637    /// bytes a one-byte opcode and 8 a pushed byte: an input whose two
638    /// scripts are about 96,000 opcodes, or a 1 MB `OP_NOP` lock, is past
639    /// the limb before it is parsed, the network judges; LOW's covenant leg,
640    /// 6,608 script bytes, is charged 1.3 MB); a 10 MB body of one-input,
641    /// one-output P2PKH transactions (191 bytes, charged 570: 30 MB). What it
642    /// stops: 111,848 transactions of no input and no output, whatever
643    /// follows them.
644    ///
645    /// THE OPEN RESIDUAL (the W-A gate's delta-verify, 2026-09-09, measured):
646    /// the interpreter itself meters nothing, so two classes stay charged
647    /// only by their script bytes — bignum arithmetic (`OP_MUL`/`OP_DIV`/
648    /// `OP_MOD` on operands up to the element limit: ~1.3 ms per 30 KB×30 KB
649    /// round, linear in rounds) and `OP_NUM2BIN`, which
650    /// allocates its size operand BEFORE the memory limit is consulted (up to
651    /// bsv-rs's 1 GB element size: an isolate kill, not a refusal). None of
652    /// them can refuse a spend (a breach and a trip are the door's bound and
653    /// the request proceeds); all of them cost the beta overlay CPU until
654    /// bsv-rs meters work dynamically (a `work_limit` charged per op with the
655    /// real operand sizes, and a pre-allocation check in NUM2BIN) — OWED
656    /// before any PROD flip of `SCRIPT_VERIFY_NETWORK_GATED`. Since #585 the
657    /// count of such rounds is bounded by the body alone (the script bytes
658    /// are charged, the transactions and inputs are not counted). A third,
659    /// as before #585 (the doors lens N7): an opcode that copies an element
660    /// (`OP_DUP`, `OP_CAT`, `OP_PICK`) is charged its one script byte and can
661    /// copy up to the element limit. The class the floor CLOSED: a
662    /// CHECKMULTISIG whose key count is a computed value was charged the most
663    /// keys the element limit admits (3,971) at its transaction's bytes and
664    /// run (~80 µs per key tried); at the floor those are 260 MB, past the
665    /// budget, so such an input is never run at the door (the network
666    /// judges). LOW's covenant states its count (`OP_3`).
667    pub const DEFAULT: DoorBudget = DoorBudget {
668        memory_limit: 128 * 1024,
669        max_work_bytes: 64 * 1024 * 1024,
670        sig_check_floor: 64 * 1024,
671        max_memory_bytes: 48 * 1024 * 1024,
672    };
673}
674
675/// Which limb of the [`DoorBudget`] a walk went past.
676#[derive(Debug, Clone, Copy, PartialEq, Eq)]
677pub enum DoorLimb {
678    /// The static work estimate, or the interpreter's own memory limit
679    /// tripping inside one input.
680    Work,
681    /// The estimate of what the door holds beside the body.
682    Memory,
683}
684
685/// What [`Engine::run_validation`] answers: the topics' validations, the
686/// preliminary STEAK, the subject and its txid, and whether the walk could
687/// not run within the budget (bsv-low #592).
688type Validated = (
689    Vec<TopicValidation>,
690    Steak,
691    Transaction,
692    String,
693    Option<WalkCouldNotRun>,
694);
695
696/// Which limb of the [`DoorBudget`] a submit's own walk went past (bsv-low
697/// #592): the [`DoorLimb`] of the door's error, with the interpreter's own
698/// memory limit told apart from the static work estimate.
699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700pub enum WalkLimb {
701    /// The static work estimate.
702    OverWork,
703    /// The estimate of what the walk holds beside the body.
704    OverMemory,
705    /// The interpreter's own memory limit (the budget's element limit)
706    /// tripping inside one input.
707    InterpreterMemory,
708}
709
710impl WalkLimb {
711    /// The door's word for the same limb ([`DoorLimb`]: the interpreter's
712    /// trip is `Work` there).
713    #[must_use]
714    pub fn door_limb(self) -> DoorLimb {
715        match self {
716            WalkLimb::OverWork | WalkLimb::InterpreterMemory => DoorLimb::Work,
717            WalkLimb::OverMemory => DoorLimb::Memory,
718        }
719    }
720
721    /// The limb's name in a log line and a counter's reason.
722    #[must_use]
723    pub fn as_str(self) -> &'static str {
724        match self {
725            WalkLimb::OverWork => "over_work",
726            WalkLimb::OverMemory => "over_memory",
727            WalkLimb::InterpreterMemory => "interpreter_memory",
728        }
729    }
730}
731
732/// What a submit does when its own script walk could not run within the
733/// engine's [`DoorBudget`] (bsv-low #592; the E592 lens fold, H1, the owner's
734/// ruling 3a read with the lens): a breach is NEVER an admission without the
735/// network's word on record.
736///
737/// The work limb is a static estimate from the bytes, and a LOCK can be made
738/// to pass it cheaply (a bare `OP_CHECKMULTISIG` in a fabricated parent's
739/// lock is charged 3,971 keys). So a breach says nothing about whether the
740/// spend is valid, and on a path where nothing else judges it an admission
741/// would admit a spend nobody checked: the lens's forged spend of a held coin.
742#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
743pub enum WalkBreachPolicy {
744    /// "Not now" ([`EngineError::WalkCouldNotRun`]): nothing judged, nothing
745    /// written, never a final refusal. Every door but one: the ungated
746    /// modes, `/admin/readmit`, the peer crawler, a library consumer's
747    /// submit; the GASP anchor HOLDS its UTXO the same way.
748    #[default]
749    NotNow,
750    /// The network's ACCEPT of these bytes is on record (the queue's replay
751    /// of a gated submission, whose producer broadcast them and marked the
752    /// message): the submit goes on exactly as under `HistoricalTxNoSpv`,
753    /// reported on [`MutationReport::walk_could_not_run`]. The roots the walk
754    /// reached are still checked, and a bad one is still the `SpvError` it is.
755    NetworkAccepted,
756}
757
758/// A submit whose own script walk COULD NOT RUN within the engine's
759/// [`DoorBudget`] (bsv-low #592, the owner's ruling 3a of 2026-10-10). Shaped
760/// like [`EngineError::ScriptWalkOverBudget`]. Under
761/// [`WalkBreachPolicy::NotNow`] it is carried by
762/// [`EngineError::WalkCouldNotRun`] (not now); under
763/// [`WalkBreachPolicy::NetworkAccepted`] the submit went on as under
764/// `HistoricalTxNoSpv` and this says so on its [`MutationReport`]. The
765/// reference (`Engine.submit`, `tx.verify`) has no budget; this is the
766/// platform's addition, on the door's lineage (D8).
767#[derive(Debug, Clone, PartialEq, Eq)]
768pub struct WalkCouldNotRun {
769    /// The submitted transaction.
770    pub subject_txid: String,
771    /// The transaction the walk stopped at.
772    pub at_txid: String,
773    /// Whether the subject's own inputs had all executed before the stop.
774    pub subject_judged: bool,
775    /// The limb it went past.
776    pub limb: WalkLimb,
777    /// The estimate against the limb, in the door's words.
778    pub what: String,
779}
780
781/// What the walk did (the door's cost instrument, since `Date.now()` is
782/// frozen during synchronous work on Workers and cannot time it).
783#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
784pub struct WalkStats {
785    /// Unproven transactions whose inputs were executed.
786    pub unproven_txs: usize,
787    /// Inputs executed (scripts run).
788    pub inputs_executed: usize,
789    /// Unlocking + locking script bytes executed.
790    pub script_bytes: usize,
791    /// Hash-class opcodes seen (SHA1/SHA256/HASH160/HASH256/RIPEMD160).
792    pub hash_ops: usize,
793    /// Signature checks seen (CHECKSIG counts 1, CHECKMULTISIG counts its
794    /// consensus maximum of 20).
795    pub sig_ops: usize,
796    /// The static work estimate charged against the budget.
797    pub work_bytes: u64,
798    /// The memory estimate charged against the budget: the frame's, made
799    /// before the stream was opened, plus the largest charge of one walked
800    /// input's scripts, made before they were parsed.
801    pub memory_bytes: u64,
802    /// Whether the SUBJECT's own inputs were all executed (an ancestor may
803    /// still have faulted afterwards).
804    pub subject_judged: bool,
805}
806
807/// Static opcode census of one input's scripts: (bytes, hash ops, sig ops).
808///
809/// A CHECKMULTISIG is weighted by its KEY COUNT: the small-int push that
810/// precedes it when the script states the count (the Poc5 covenant's
811/// `OP_3`), else the most 33-byte keys the element limit admits (the count is
812/// a computed value the census cannot see; bsv-rs allows up to `i32::MAX`).
813pub(crate) fn script_census(
814    unlocking: &[u8],
815    locking: &[u8],
816    memory_limit: usize,
817) -> Result<(usize, usize, usize), String> {
818    use bsv_rs::script::op::*;
819    let most_keys = (memory_limit / 33).max(1);
820    let mut hash_ops = 0usize;
821    let mut sig_ops = 0usize;
822    for (label, bytes) in [("unlocking", unlocking), ("locking", locking)] {
823        let script = bsv_rs::script::Script::from_binary(bytes)
824            .map_err(|e| format!("{label} script does not parse: {e}"))?;
825        let chunks = script.chunks();
826        for (i, chunk) in chunks.iter().enumerate() {
827            match chunk.op {
828                OP_RIPEMD160 | OP_SHA1 | OP_SHA256 | OP_HASH160 | OP_HASH256 => hash_ops += 1,
829                OP_CHECKSIG | OP_CHECKSIGVERIFY => sig_ops += 1,
830                OP_CHECKMULTISIG | OP_CHECKMULTISIGVERIFY => {
831                    let stated = i
832                        .checked_sub(1)
833                        .map(|j| chunks[j].op)
834                        .and_then(|op| match op {
835                            OP_1..=OP_16 => Some((op - OP_1 + 1) as usize),
836                            _ => None,
837                        });
838                    sig_ops += stated.unwrap_or(most_keys);
839                }
840                _ => {}
841            }
842        }
843    }
844    Ok((unlocking.len() + locking.len(), hash_ops, sig_ops))
845}
846
847impl Engine {
848    /// Page size used by `/requestSyncResponse` when the (public) caller
849    /// omits `limit`. A bounded page is not lossy for a conforming
850    /// initiator: ours ([`crate::gasp::GASPSync`]) keeps requesting pages
851    /// for as long as the `since` score cursor advances (gate finding M1 —
852    /// it must NOT require a "full" page to continue, because this clamp
853    /// makes a full-by-the-initiator's-limit page impossible).
854    pub const SYNC_RESPONSE_DEFAULT_LIMIT: u64 = 500;
855    /// Hard cap on a `/requestSyncResponse` page. Before bsv-low #291 an
856    /// absent/huge caller-supplied limit meant an unbounded scan of the
857    /// outputs table on a public route.
858    pub const SYNC_RESPONSE_MAX_LIMIT: u64 = 1000;
859
860    /// Clamp the public `/requestSyncResponse` page size: absent → the
861    /// default page; anything larger than the cap → the cap. The result is
862    /// ALWAYS `Some`-worthy — a sync response is never unbounded (bsv-low
863    /// #291). Completeness relies on the INITIATOR paging while its `since`
864    /// cursor advances (see `SYNC_RESPONSE_DEFAULT_LIMIT`'s doc) — the
865    /// responder alone cannot page for it.
866    pub fn clamp_sync_limit(requested: Option<u64>) -> u64 {
867        requested
868            .unwrap_or(Self::SYNC_RESPONSE_DEFAULT_LIMIT)
869            .min(Self::SYNC_RESPONSE_MAX_LIMIT)
870    }
871
872    /// Create a new Overlay Services Engine.
873    pub fn new(
874        managers: HashMap<String, Box<dyn TopicManager>>,
875        lookup_services: HashMap<String, Box<dyn LookupService>>,
876        storage: Box<dyn Storage>,
877        advertiser: Option<Box<dyn Advertiser>>,
878        config: EngineConfig,
879    ) -> Self {
880        Self::with_chain_tracker(
881            managers,
882            lookup_services,
883            storage,
884            advertiser,
885            None,
886            None,
887            config,
888        )
889    }
890
891    /// Create a new Engine with an optional ChainTracker for SPV verification.
892    pub fn with_chain_tracker(
893        managers: HashMap<String, Box<dyn TopicManager>>,
894        lookup_services: HashMap<String, Box<dyn LookupService>>,
895        storage: Box<dyn Storage>,
896        advertiser: Option<Box<dyn Advertiser>>,
897        broadcaster: Option<Box<dyn Broadcaster>>,
898        chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
899        config: EngineConfig,
900    ) -> Self {
901        Self::with_all(
902            managers,
903            lookup_services,
904            storage,
905            advertiser,
906            broadcaster,
907            None,
908            chain_tracker,
909            config,
910        )
911    }
912
913    /// Create a new Engine with all optional components.
914    #[allow(clippy::too_many_arguments)]
915    pub fn with_all(
916        managers: HashMap<String, Box<dyn TopicManager>>,
917        lookup_services: HashMap<String, Box<dyn LookupService>>,
918        storage: Box<dyn Storage>,
919        advertiser: Option<Box<dyn Advertiser>>,
920        broadcaster: Option<Box<dyn Broadcaster>>,
921        arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
922        chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
923        mut config: EngineConfig,
924    ) -> Self {
925        // Build default sync configuration: SHIP for all topics except tm_ship/tm_slap
926        // which get their trackers merged with provided shipTrackers/slapTrackers.
927        for manager_name in managers.keys() {
928            if manager_name == "tm_ship" {
929                if matches!(
930                    config.sync_configuration.get(manager_name),
931                    Some(SyncTarget::Disabled)
932                ) {
933                    continue;
934                }
935                let mut combined: HashSet<String> = HashSet::new();
936                if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
937                {
938                    combined.extend(peers.iter().cloned());
939                }
940                combined.extend(config.ship_trackers.iter().cloned());
941                if !combined.is_empty() {
942                    config.sync_configuration.insert(
943                        manager_name.clone(),
944                        SyncTarget::Peers(combined.into_iter().collect()),
945                    );
946                }
947            } else if manager_name == "tm_slap" {
948                if matches!(
949                    config.sync_configuration.get(manager_name),
950                    Some(SyncTarget::Disabled)
951                ) {
952                    continue;
953                }
954                let mut combined: HashSet<String> = HashSet::new();
955                if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
956                {
957                    combined.extend(peers.iter().cloned());
958                }
959                combined.extend(config.slap_trackers.iter().cloned());
960                if !combined.is_empty() {
961                    config.sync_configuration.insert(
962                        manager_name.clone(),
963                        SyncTarget::Peers(combined.into_iter().collect()),
964                    );
965                }
966            } else if !config.sync_configuration.contains_key(manager_name) {
967                config
968                    .sync_configuration
969                    .insert(manager_name.clone(), SyncTarget::Ship);
970            }
971        }
972
973        Self {
974            managers,
975            lookup_services,
976            storage: Box::new(crate::load_storage::MeteredStorage(storage)),
977            advertiser,
978            broadcaster,
979            arc_broadcaster,
980            chain_tracker,
981            gasp_remote_factory: None,
982            ship_lookup_client: None,
983            ship_bootstrap_peers: HashMap::new(),
984            ancestor_fetcher: None,
985            gasp_ancestor_fetcher: None,
986            peer_sync_budget: None,
987            graph_budget: None,
988            graph_budget_limbs: (
989                crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES,
990                crate::gasp::DEFAULT_GRAPH_BUDGET_NODES,
991            ),
992            finalize_submit_budget: None,
993            finalize_gate: crate::gasp::SubmitGate::default(),
994            not_landed: std::cell::RefCell::new(HashSet::new()),
995            landing_guard: None,
996            verify_scripts: true,
997            walk_budget: DoorBudget::DEFAULT,
998            config,
999        }
1000    }
1001
1002    /// Set the ARC broadcaster for network broadcast to miners.
1003    ///
1004    /// When set, the Engine broadcasts transactions to ARC during Phase 2
1005    /// of `submit()` for `CurrentTx` submissions.
1006    pub fn set_arc_broadcaster(&mut self, arc: Box<dyn ArcBroadcaster>) {
1007        self.arc_broadcaster = Some(arc);
1008    }
1009
1010    /// Set the GASP remote factory for peer communication during sync.
1011    ///
1012    /// Platform-specific crates (like overlay-cloudflare) provide an implementation
1013    /// that creates HTTP-based remotes using their native fetch API.
1014    pub fn set_gasp_remote_factory(&mut self, factory: Box<dyn crate::gasp::GASPRemoteFactory>) {
1015        self.gasp_remote_factory = Some(factory);
1016    }
1017
1018    /// Set the platform lookup transport for network SHIP discovery.
1019    /// Trackers come from [`EngineConfig::slap_trackers`].
1020    pub fn set_ship_lookup_client(
1021        &mut self,
1022        client: Box<dyn crate::ship_lookup::ShipLookupClient>,
1023    ) {
1024        self.ship_lookup_client = Some(client);
1025    }
1026
1027    /// Keep these peers in a topic's SHIP union even when trackers fail.
1028    /// This leaves the existing [`SyncTarget`] serialization unchanged.
1029    /// Such topics run ahead of tracker imports so discovery and sync can
1030    /// proceed without spending the tick on importing discovery records.
1031    pub fn set_ship_bootstrap_peers(&mut self, topic: impl Into<String>, peers: Vec<String>) {
1032        self.ship_bootstrap_peers.insert(topic.into(), peers);
1033    }
1034
1035    /// Set the OPT-IN chain-backed ancestor fetcher for GASP ingest.
1036    ///
1037    /// **OFF BY DEFAULT.** When set, a peer's failure to serve a needed
1038    /// ancestor during GASP ingest falls back to fetching that ancestor's raw
1039    /// tx from chain (via the supplied fetcher), self-healing the graph and
1040    /// also enabling strict-BEEF finalize so missing ancestry fails loud.
1041    ///
1042    /// This is a deliberate one-time-migration escape hatch. Production must
1043    /// NOT call this — when unset, GASP ingest behavior is unchanged.
1044    pub fn set_ancestor_fetcher(&mut self, fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>) {
1045        self.ancestor_fetcher = Some(fetcher);
1046    }
1047
1048    /// Supply a courier reserved for sync. Completion retains its own budget.
1049    pub fn set_gasp_ancestor_fetcher(
1050        &mut self,
1051        fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>,
1052    ) {
1053        self.gasp_ancestor_fetcher = Some(fetcher);
1054    }
1055
1056    /// Set the admission predicate asked BEFORE the door lands a carried
1057    /// predecessor (bsv-low #575, the E1D delta-2 fold, L2;
1058    /// [`Engine::land_carried`]). It is asked once per body the landing
1059    /// would submit, before that body's submit (a dry one included) and
1060    /// before its reads are charged; an `Err` (the caller's refusal, or its
1061    /// own read fault) ends the landing with nothing of that body written
1062    /// and the successor's topic answers "not now", as on `683dffd`. The
1063    /// worker installs its eviction ledger here: an OPEN eviction of the
1064    /// body refuses it. Its read is not one of the submit's 256 (it is the
1065    /// caller's store, at most one per landed body). Unset (default): every
1066    /// carried predecessor may land. What the predicate cannot see (a row
1067    /// opened while the landing writes) stays the caller's to guard after
1068    /// the write ([`MutationReport::landed_predecessors`]).
1069    pub fn set_landing_guard(&mut self, guard: LandingGuard) {
1070        self.landing_guard = Some(guard);
1071    }
1072
1073    /// Set the per-peer GASP sync budget (bsv-low#302).
1074    ///
1075    /// `sleep` is the platform sleep factory (ms → future); `budget_ms` is
1076    /// the wall-clock slice ONE peer's sync may consume. A peer exceeding it
1077    /// is dropped (loudly logged, listed in the topic's `errors`) and the
1078    /// loop moves on. Unset (default) = unbounded, the pre-#302 behavior.
1079    ///
1080    /// **Progress survives the deadline (bsv-low #552).** With a budget set,
1081    /// every graph is submitted AS IT FINALIZES, inside the raced future (the
1082    /// reference submits inside `finalizeGraph` too), so what was finalized
1083    /// before the deadline stays admitted. At the deadline the cursor is
1084    /// advanced to `GASPSync::completed_cursor`: past the UTXOs whose graphs
1085    /// were completed, never past the one in flight. That graph is lost
1086    /// whole (nothing of it was finalized) and the next tick walks it again,
1087    /// down to whatever is now in storage.
1088    ///
1089    /// **The deadline never lands inside a transaction's writes** (the lens
1090    /// fold's HIGH-1). It is cooperative around a finalize submit
1091    /// ([`Engine::finalize_submit_gate`]): a deadline that falls due while
1092    /// one transaction is being written waits for that transaction, and the
1093    /// sync is dropped at the boundary. What a dropped tick leaves of the
1094    /// graph being submitted is an ancestors-first prefix of WHOLE
1095    /// transactions, with the cursor below its UTXO. Everywhere else (a
1096    /// request to the peer, the walk, the anchor check) the deadline drops
1097    /// the sync at once, as a budget against a dead peer must. A dropped tick that finalized a
1098    /// graph or moved the cursor is recorded as a SUCCESSFUL attempt for the
1099    /// quarantine count: a peer serving a long bootstrap is not a dead peer.
1100    /// One with no progress is a failed attempt, as before.
1101    ///
1102    /// The budget bounds the work of a TICK. Without a per-graph budget
1103    /// nothing bounds a GRAPH (parity: the reference has no node cap): one
1104    /// graph whose own walk outlasts the budget is dropped on every tick
1105    /// (`deadline_dropped_graphs`) and never admitted. With one
1106    /// ([`Engine::set_graph_budget`], bsv-low #555) that walk is deferred
1107    /// with its progress kept, here too, and resumed next tick.
1108    pub fn set_peer_sync_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1109        self.peer_sync_budget = Some((sleep, budget_ms));
1110    }
1111
1112    /// Set the per-GRAPH GASP budget and turn DEFERRAL on (bsv-low #555).
1113    ///
1114    /// A graph whose walk makes `max_calls` requests (to the peer, or chain
1115    /// fetches) or runs `budget_ms` in one pass is DEFERRED: its partial walk
1116    /// (the nodes fetched with their proofs, the inputs still pending, the
1117    /// calls spent, its age in passes) is saved as ONE record of the storage
1118    /// ([`Storage::put_deferred_graph`], replaced on every deferral), its
1119    /// UTXO is held below the cursor as a failed one is (the gap guard) and
1120    /// not walked again in the sync (#554), and the sync goes on to the next
1121    /// UTXO and topic. The next sync that is served that UTXO RESUMES the
1122    /// walk from the record, asking only what is pending, under the same
1123    /// budget; a graph so converges over passes and is then completed as
1124    /// any graph is (#551's anchor check, the finalize submits, e1d's
1125    /// rules). A walk cut by the per-peer deadline is deferred the same way.
1126    ///
1127    /// Bounds: a record deferred [`crate::gasp::DEFERRED_GRAPH_MAX_PASSES`]
1128    /// times is dropped with its reason and the UTXO fails as before (the
1129    /// gap guard asks again, from the root); a record has no byte bound
1130    /// (bsv-low #585); at most
1131    /// [`crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC`] records per (peer,
1132    /// topic). Keep `budget_ms` below the per-peer budget so that a deep
1133    /// graph leaves the pass time for the UTXOs after it. Defaults offered:
1134    /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_CALLS`],
1135    /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_MS`].
1136    ///
1137    /// Every resume of one sync shares ONE such budget (the lens fold's M1),
1138    /// so the deferred UTXOs, served first, leave the pass the rest; a walk
1139    /// that fetched nothing keeps no record and is a failed attempt for the
1140    /// quarantine (H1); a walk that cannot be kept, or whose save faulted,
1141    /// goes on under the per-peer budget alone (L4, the delta fold's D-L2);
1142    /// a FRESH walk's error keeps no record (D-M1). With a budget, a sync the
1143    /// peer served work that finalized no graph and moved no cursor is
1144    /// YIELDLESS, and past [`crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED`] in a
1145    /// row AND [`crate::gasp::PEER_YIELDLESS_SECS_ALLOWED`] since the streak
1146    /// began each is a failed attempt (D-M2, the delta-2 fold's D2-M1; the
1147    /// streak is kept by [`Storage::record_peer_sync_yield`], keyed on the
1148    /// peer's [`crate::gasp::peer_origin`] as its quarantine is). A FRESH
1149    /// walk that faults after half its calls or half its time keeps its
1150    /// record (D2-L1); a RESUMED one that faults having appended nothing
1151    /// [`crate::gasp::DEFERRED_GRAPH_MAX_IDLE_FAULTS`] passes in a row is
1152    /// dropped (D2-M2). Each record says whether its peer is configured
1153    /// (`SyncTarget::Peers`), for a storage that reserves part of its
1154    /// ceiling for those (the worker reserves half).
1155    ///
1156    /// Unset (the default) nothing is deferred and the walk is the one
1157    /// before #555 (parity: the reference has no budget and no deferral).
1158    /// The storage must KEEP records (the four required deferred-graph
1159    /// methods of [`Storage`]): over one that refuses every save, every
1160    /// graph past the budget is walked from its root under the per-peer
1161    /// budget alone on every pass, as before #555, so do not set a budget
1162    /// there.
1163    ///
1164    /// The budget has two more limbs (bsv-low #586), in force with it at
1165    /// their defaults: the bytes one graph may be SERVED in one pass
1166    /// ([`crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES`], a budget per pass only:
1167    /// a record has no byte bound since bsv-low #585 door 4) and the nodes it
1168    /// may APPEND ([`crate::gasp::DEFAULT_GRAPH_BUDGET_NODES`], 64);
1169    /// [`Engine::set_graph_budget_limbs`] names others.
1170    pub fn set_graph_budget(&mut self, sleep: SleepFactory, max_calls: u32, budget_ms: u64) {
1171        self.graph_budget = Some((sleep, max_calls, budget_ms));
1172    }
1173
1174    /// The bytes and nodes limbs of the per-graph budget (bsv-low #586,
1175    /// zanaadu-v2 #377): a graph whose walk is SERVED `max_bytes_fetched`
1176    /// bytes in one pass (the hex of each node's raw transaction and proof,
1177    /// as the peer sends it and a record keeps it, every answer counted), or
1178    /// APPENDS `max_nodes` nodes, is DEFERRED exactly as at the call budget
1179    /// (reason `bytes` or `nodes`): its record saved, its UTXO held below
1180    /// the cursor, the walk resumed by the next pass from what is pending.
1181    /// Never a drop, a refusal or a discarded graph: these are budgets PER
1182    /// PASS, not limits on a graph or a BEEF. A pass always makes one
1183    /// request whatever a node's size; the resumes of one sync share one
1184    /// such budget, as they share its calls (M1); a walk that cannot be
1185    /// KEPT goes on under the per-peer budget alone (L4), as at the calls.
1186    /// They bound what one pass adds to the pending graph and to its record
1187    /// while the anchor check and the finalize hold it in memory. No effect
1188    /// until [`Engine::set_graph_budget`] turns deferral on; the reference
1189    /// has neither. This setter is UNCAPPED, as the worker's
1190    /// `GASP_GRAPH_BUDGET_BYTES` var is (bsv-low #585 door 4: the engine keeps
1191    /// no record cap for the limb to sit under). A consumer whose STORAGE caps
1192    /// a record must keep the bytes limb under that cap, or a pass whose
1193    /// record cannot be saved walks afresh (E586-L1).
1194    pub fn set_graph_budget_limbs(&mut self, max_bytes_fetched: u64, max_nodes: u32) {
1195        self.graph_budget_limbs = (max_bytes_fetched, max_nodes);
1196    }
1197
1198    /// Bound ONE transaction's GASP finalize submit (bsv-low #559, the delta
1199    /// lens's DELTA-3). That submit is the write section no deadline drops
1200    /// ([`Engine::finalize_submit_gate`]), so a storage call or lookup hook
1201    /// that never answers inside it held the per-peer budget and any outer
1202    /// race for as long as it hung. With this set EACH storage call, lookup
1203    /// hook and topic manager call of that submit is raced against one
1204    /// `sleep(budget_ms)` of the section (the lens fold of 2026-10-07, F2):
1205    /// a call still unanswered when it falls due is dropped alone and is
1206    /// that call's FAULT, and no call is started after it. The SUBMIT is
1207    /// never dropped: it runs to its end and leaves what a fault leaves
1208    /// ([`Engine::submit_with_report`]: nothing of the transaction, or all
1209    /// of it), the transaction did not land, the rest of its graph is not
1210    /// submitted, its UTXO fails and the cursor stays below it. The undo of
1211    /// a faulted submit has one more `sleep(budget_ms)` of its own, so a
1212    /// peer costs at most its budget plus twice this one. What the backend
1213    /// does with a statement whose caller stopped waiting is its own: a
1214    /// write that lands after its timeout is not seen by this submit.
1215    ///
1216    /// DEFAULT: none (a native engine has no clock). A caller that sets a
1217    /// per-peer budget should set this too.
1218    pub fn set_finalize_submit_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1219        self.finalize_submit_budget = Some((sleep, budget_ms));
1220    }
1221
1222    /// The gate around every GASP finalize submit (bsv-low #552, the lens
1223    /// fold's HIGH-1). `start_gasp_sync` races each peer against its budget
1224    /// with [`crate::gasp::race_or_deadline_guarded`] over this gate. A
1225    /// caller that races `start_gasp_sync` ITSELF against a deadline (the
1226    /// worker's scheduled step) must use the same function over the same
1227    /// gate: a plain `race_or_deadline` there can still drop a finalize
1228    /// submit between its writes.
1229    pub fn finalize_submit_gate(&self) -> &crate::gasp::SubmitGate {
1230        &self.finalize_gate
1231    }
1232
1233    /// Turn reference-parity script verification on submit on or off.
1234    /// **DEFAULT ON** (set in every constructor).
1235    ///
1236    /// On, every submit outside `HistoricalTxNoSpv` executes every unproven
1237    /// input's unlocking script against its source output (and checks every
1238    /// merkle path against the chain tracker), exactly as the reference's
1239    /// `Engine.submit` → `tx.verify(this.chainTracker)` does. Off is an
1240    /// ESCAPE HATCH for an operator who must admit a body the interpreter
1241    /// refuses (a bsv-rs interpreter defect, say) while it is fixed. It is
1242    /// NOT a mode: it restores the pre-2026-09-08 structural check, under
1243    /// which an invalid spend that no broadcaster had yet refused was
1244    /// admitted on BEEF structure alone. Prefer
1245    /// [`crate::builder::EngineBuilder::with_script_verification`].
1246    pub fn set_script_verification(&mut self, enabled: bool) {
1247        self.verify_scripts = enabled;
1248    }
1249
1250    /// Whether submits execute input scripts, see
1251    /// [`Engine::set_script_verification`]. `true` by default.
1252    pub fn script_verification(&self) -> bool {
1253        self.verify_scripts
1254    }
1255
1256    /// The budget of the submit's own script walk (bsv-low #592, the owner's
1257    /// ruling 3a of 2026-10-10). Every submit that walks (every mode but
1258    /// `HistoricalTxNoSpv`), and the GASP anchor check, read the BEEF through
1259    /// the door's stream walk ([`crate::script_door`]) under this budget's
1260    /// two limbs, the work and the memory, each estimated from the bytes
1261    /// before it is spent. A breach is "the walk could not run"
1262    /// ([`WalkCouldNotRun`], on [`MutationReport::walk_could_not_run`]),
1263    /// never a refusal: the submit goes on as under `HistoricalTxNoSpv`.
1264    /// A library consumer that sets nothing gets [`DoorBudget::DEFAULT`]
1265    /// (64 MiB of work, 48 MiB of memory). Prefer
1266    /// [`crate::builder::EngineBuilder::with_walk_budget`].
1267    pub fn set_walk_budget(&mut self, budget: DoorBudget) {
1268        self.walk_budget = budget;
1269    }
1270
1271    /// The budget of the submit's own script walk, see
1272    /// [`Engine::set_walk_budget`].
1273    pub fn walk_budget(&self) -> DoorBudget {
1274        self.walk_budget
1275    }
1276
1277    /// The reference's `tx.verify('scripts only')` over a BEEF: every unproven
1278    /// transaction from `subject_txid` down executes every input's unlocking
1279    /// script against its source output and obeys the value rule, while a
1280    /// transaction that carries a merkle path is TRUSTED AS-IS — no root is
1281    /// computed and no chain tracker is consulted (the ts-sdk's own shape when
1282    /// `chainTracker === 'scripts only'`: a proven tx is added to the verified
1283    /// set and the walk stops there).
1284    ///
1285    /// Built for an admission path whose bar is the NETWORK, not a proof
1286    /// (bsv-low's `broadcast-gated`, register row D1): the overlay broadcasts
1287    /// and admits on network evidence, so a lagging tracker must never read as
1288    /// a refused transaction — but a spend the interpreter refuses is one the
1289    /// network will refuse too, and executing it at the door saves the
1290    /// broadcast and names the fault. Independent of
1291    /// [`Engine::set_script_verification`] (that switch governs `submit`; this
1292    /// is an explicit ask) and of [`Engine::submit`]'s mode (`HistoricalTxNoSpv`
1293    /// still skips, as the reference does).
1294    ///
1295    /// Runs under the engine's budget ([`Engine::set_walk_budget`], the
1296    /// submit's own; [`DoorBudget::DEFAULT`] unless set: one budget per
1297    /// engine, the E592 lens fold's NOTE-2): the work is bounded statically
1298    /// before anything executes, and the interpreter's stack memory limit is
1299    /// the budget's element limit. The body is read through the streaming
1300    /// reader ([`crate::script_door`], bsv-low #585): no bound on its size,
1301    /// its transactions or a transaction's inputs or bytes; the budget's two
1302    /// limbs are the work and the memory beside the body.
1303    ///
1304    /// Errors: [`EngineError::ScriptVerificationFailed`] is the INTERPRETER's
1305    /// verdict and the only refusal; [`EngineError::ScriptWalkInconclusive`]
1306    /// is a structural fault (a transaction or source missing from the BEEF,
1307    /// a parse fault, the value rule) and [`EngineError::ScriptWalkOverBudget`]
1308    /// the door's own bound (the static budget or the memory limit) — both
1309    /// name whether the SUBJECT was judged before the fault, and a caller with
1310    /// a stronger bar behind it does not refuse on either.
1311    #[allow(
1312        clippy::unused_async,
1313        reason = "the door's callers await it on an engine; 'scripts only' asks no tracker"
1314    )]
1315    pub async fn verify_scripts_only(
1316        &self,
1317        beef_bytes: &[u8],
1318        subject_txid: &str,
1319    ) -> Result<WalkStats, EngineError> {
1320        crate::script_door::walk(beef_bytes, subject_txid, self.walk_budget)
1321    }
1322
1323    /// [`Engine::verify_scripts_only`] under a budget the caller names (the
1324    /// pins measure the door with a limb lifted; a caller with another
1325    /// platform's ceiling sets its own).
1326    #[allow(
1327        clippy::unused_async,
1328        clippy::unused_self,
1329        reason = "the twin of `verify_scripts_only`"
1330    )]
1331    pub async fn verify_scripts_only_under(
1332        &self,
1333        beef_bytes: &[u8],
1334        subject_txid: &str,
1335        budget: DoorBudget,
1336    ) -> Result<WalkStats, EngineError> {
1337        crate::script_door::walk(beef_bytes, subject_txid, budget)
1338    }
1339
1340    // ========================================================================
1341    // Submit — 3-phase pipeline
1342    // ========================================================================
1343
1344    /// Validate a tagged BEEF without applying mutations.
1345    ///
1346    /// Runs Phase 1 (topic validation) and Phase 2 (broadcast) but NOT Phase 3
1347    /// (storage mutations). Returns the Steak (admittance decisions) that
1348    /// `submit()` would return.
1349    ///
1350    /// Used by the onSteakReady pattern to return results to clients before
1351    /// mutations are applied via a queue consumer or `ctx.wait_until()`.
1352    ///
1353    /// The topic managers are called with `dry_run: true`
1354    /// (`TopicAdmittanceContext::DRY_RUN`): nothing is admitted on this call, so a
1355    /// manager that writes on admission leaves no trace of it.
1356    pub async fn submit_validate_only(
1357        &self,
1358        tagged_beef: &TaggedBEEF,
1359        mode: SubmitMode,
1360    ) -> Result<Steak, EngineError> {
1361        // A validate-only call admits nothing: a dry run by its own name. A
1362        // manager that writes on admission must not write here, or the real
1363        // submit that follows meets a head this call already advanced.
1364        let (_validations, steak, _tx, _txid, _could_not_run) = self
1365            .run_validation(
1366                tagged_beef,
1367                mode,
1368                &TopicAdmittanceContext::DRY_RUN,
1369                None,
1370                None,
1371                WalkBreachPolicy::NotNow,
1372            )
1373            .await?;
1374        Ok(steak)
1375    }
1376
1377    /// Submit a transaction for processing by the overlay.
1378    ///
1379    /// Three phases:
1380    /// 1. **VALIDATE** — check topics, dedup, find previous coins, call topic managers
1381    /// 2. **BROADCAST** — broadcast to network (if mode is CurrentTx)
1382    /// 3. **MUTATE** — mark spent, delete stale, insert new outputs, notify lookup services
1383    ///
1384    /// Returns a STEAK mapping each topic to its admittance instructions.
1385    ///
1386    /// Phase-3 faults are reported through [`Engine::submit_with_report`];
1387    /// this convenience keeps the historical signature and only LOGS the
1388    /// report's summary. A caller that must not ack an admission whose
1389    /// writes did not land (the `/submit` route — S2) uses the report form.
1390    pub async fn submit(
1391        &self,
1392        tagged_beef: &TaggedBEEF,
1393        mode: SubmitMode,
1394    ) -> Result<Steak, EngineError> {
1395        let (steak, report) = self.submit_with_report(tagged_beef, mode).await?;
1396        if !report.is_durable() {
1397            warn!(
1398                "submit: {} mutation fault(s) — not durable: {}",
1399                report.faults.len(),
1400                report.summary()
1401            );
1402        }
1403        Ok(steak)
1404    }
1405
1406    /// [`Engine::submit`] plus the Phase-3 durability report (S2
1407    /// queue-durable admission, bsv-low 2026-08-29).
1408    ///
1409    /// The STEAK is the admission DECISION; the report says whether the
1410    /// decision was DURABLY WRITTEN. They are returned together because
1411    /// they are answered by different things: Phase 1+2 decide, Phase 3
1412    /// lands, and only the caller knows whether an undurable landing may
1413    /// be acked (never, on the money path — re-queue it instead).
1414    ///
1415    /// Every write failure is still logged AND still non-fatal to the
1416    /// other topics (a faulted topic does not un-admit its siblings); what
1417    /// changed is that the failure is also VISIBLE, and that the faulted
1418    /// topic is not recorded as applied — so a replay of the same bytes is
1419    /// re-validated and re-written (every backend write is idempotent:
1420    /// `INSERT OR IGNORE` / `OR REPLACE` / `UPDATE`).
1421    ///
1422    /// What a fault leaves (bsv-low #559 and its lens fold of 2026-10-07):
1423    /// a topic's previous coins are deleted once EVERY admitted output of
1424    /// the transaction is inserted, and only then. A fault before that
1425    /// (a validation read, the spent mark of a coin the manager retains, an
1426    /// insert) leaves the previous coins held and nothing of the
1427    /// transaction: what did get inserted is taken out again, so the replay
1428    /// is the same judgement and no successor can spend half of it. A fault
1429    /// after it (a lookup notification, the consumed-by update, the applied
1430    /// row) leaves the transaction's outputs and no previous coin. A fault
1431    /// before any delete was started (the record of the spent coin's
1432    /// transaction, the read of the coin) is the first case if the coins
1433    /// are read back still held; once a delete was started its fault never
1434    /// undoes, answered or not (it may land yet): the outputs stay beside
1435    /// the stale coin, marked spent. A submit that deletes a stale coin
1436    /// first records that coin's transaction as applied, so a replay of it
1437    /// after its successor is a dupe. So a non-retaining chain never has
1438    /// two unspent heads and never none, whatever single call faults. The
1439    /// applied row is withheld on ANY fault. And a successor that was
1440    /// judged without the coin an unlanded predecessor has yet to leave is
1441    /// reported as a fault (`predecessor_not_landed`), not recorded as
1442    /// applied.
1443    ///
1444    /// A walk that could not run within the engine's budget is "not now"
1445    /// here ([`WalkBreachPolicy::NotNow`], [`EngineError::WalkCouldNotRun`]):
1446    /// never an admission. [`Engine::submit_with_report_under`] names the
1447    /// policy.
1448    pub async fn submit_with_report(
1449        &self,
1450        tagged_beef: &TaggedBEEF,
1451        mode: SubmitMode,
1452    ) -> Result<(Steak, MutationReport), EngineError> {
1453        self.submit_with_report_under(tagged_beef, mode, WalkBreachPolicy::NotNow)
1454            .await
1455    }
1456
1457    /// [`Engine::submit_with_report`] under an explicit
1458    /// [`WalkBreachPolicy`] (the E592 lens fold, H1): what a walk that could
1459    /// not run within the budget does. `NetworkAccepted` is for a caller that
1460    /// holds the network's accept of these very bytes (the queue's replay of
1461    /// a gated submission) and for nothing else: it admits a breached body
1462    /// with its scripts unrun.
1463    pub async fn submit_with_report_under(
1464        &self,
1465        tagged_beef: &TaggedBEEF,
1466        mode: SubmitMode,
1467        policy: WalkBreachPolicy,
1468    ) -> Result<(Steak, MutationReport), EngineError> {
1469        self.submit_bounded(tagged_beef, mode, None, false, policy)
1470            .await
1471    }
1472
1473    /// [`Engine::submit_with_report`] with every storage call, lookup hook
1474    /// and topic manager call under `bound` (the GASP finalize submit,
1475    /// [`CallBound`]); `None` is the unbounded submit of every other door.
1476    /// `finalize` is `true` for the GASP finalize submit alone, bounded or
1477    /// not: it does not ask the store's predecessor question.
1478    async fn submit_bounded(
1479        &self,
1480        tagged_beef: &TaggedBEEF,
1481        mode: SubmitMode,
1482        bound: Option<&CallBound>,
1483        finalize: bool,
1484        policy: WalkBreachPolicy,
1485    ) -> Result<(Steak, MutationReport), EngineError> {
1486        // The reads the store's predecessor question has made in this
1487        // submit, every topic together ([`PREDECESSOR_READS_PER_SUBMIT`]),
1488        // the predecessors the door lands first from the BEEF included.
1489        let mut question_reads = 0usize;
1490        self.submit_counted(
1491            tagged_beef,
1492            mode,
1493            bound,
1494            finalize,
1495            None,
1496            &mut question_reads,
1497            policy,
1498        )
1499        .await
1500        .map(|(steak, report, _)| (steak, report))
1501    }
1502
1503    /// [`Engine::submit_bounded`] over the submit's read allowance
1504    /// (`question_reads`), which a predecessor the door lands first from the
1505    /// BEEF shares with the successor that carried it (lane E1D's lens fold,
1506    /// M1). `carried` is `Some` for such a predecessor's own submit
1507    /// ([`Engine::land_carried`]), holding the bodies whose SPV walk already
1508    /// passed in this submit (none is walked twice; the delta fold, L4): it
1509    /// lands nothing first itself, and it names instead the carried
1510    /// predecessor that blocks each topic (the third value), so the landing
1511    /// is an explicit stack and never a recursion of submits (nested polls
1512    /// grow the stack, and a Worker's is small). Its manager is asked as a
1513    /// DRY RUN first, and for real only once its topic is not blocked: a
1514    /// link blocked below is judged twice and admitted once. Boxed: the
1515    /// landing is a submit of its own.
1516    #[allow(clippy::too_many_arguments)]
1517    fn submit_counted<'a>(
1518        &'a self,
1519        tagged_beef: &'a TaggedBEEF,
1520        mode: SubmitMode,
1521        bound: Option<&'a CallBound>,
1522        finalize: bool,
1523        carried: Option<&'a HashSet<String>>,
1524        question_reads: &'a mut usize,
1525        policy: WalkBreachPolicy,
1526    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Counted, EngineError>> + 'a>>
1527    {
1528        Box::pin(async move {
1529            self.submit_counted_inner(
1530                tagged_beef,
1531                mode,
1532                bound,
1533                finalize,
1534                carried,
1535                question_reads,
1536                policy,
1537            )
1538            .await
1539        })
1540    }
1541
1542    #[allow(clippy::too_many_arguments)]
1543    async fn submit_counted_inner(
1544        &self,
1545        tagged_beef: &TaggedBEEF,
1546        mode: SubmitMode,
1547        bound: Option<&CallBound>,
1548        finalize: bool,
1549        walked: Option<&HashSet<String>>,
1550        question_reads: &mut usize,
1551        policy: WalkBreachPolicy,
1552    ) -> Result<Counted, EngineError> {
1553        // A submit is a real admission, never a dry run; a carried
1554        // predecessor's is judged dry until its topic is known not to wait.
1555        let carried = walked.is_some();
1556        let context = if carried {
1557            TopicAdmittanceContext::DRY_RUN
1558        } else {
1559            TopicAdmittanceContext::default()
1560        };
1561        let (mut validations, mut steak, tx, txid, could_not_run) = self
1562            .run_validation(tagged_beef, mode, &context, bound, walked, policy)
1563            .await?;
1564        if finalize {
1565            let now = (current_timestamp_ms() / 1000.0) as u64;
1566            for validation in &mut validations {
1567                let manager = &self.managers[&validation.topic];
1568                validation.admittance.outputs_to_admit.retain(|index| {
1569                    tx.outputs.get(*index as usize).is_none_or(|output| {
1570                        manager
1571                            .sync_expiry(&output.locking_script)
1572                            .is_none_or(|expiry| expiry > now)
1573                    })
1574                });
1575                steak.insert(validation.topic.clone(), validation.admittance.clone());
1576            }
1577        }
1578        let mut report = MutationReport {
1579            walk_could_not_run: could_not_run,
1580            ..MutationReport::default()
1581        };
1582        // The body every LOOKUP SERVICE receives NAMES the subject (BRC-95
1583        // atomic prefix over the WHOLE submitted body — bsv-rs's
1584        // `to_binary_atomic` sorts, names, never prunes). Lookup services
1585        // re-parse it with `from_beef(_, None)`; on a plain incomplete body
1586        // that pick is the wire-last ANCESTOR, so the pot index's admit hook
1587        // shape-checked a hop and wrote no record while the engine had
1588        // admitted the pot (loop-2 fleet, 2026-09-05; the live proof after
1589        // the engine's own subject fix). A body that cannot be re-serialized
1590        // (never, for a parsed subject) is handed through as submitted.
1591        let subject_named_beef: Vec<u8> =
1592            beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
1593                .ok()
1594                .and_then(|mut b| b.to_binary_atomic(&txid).ok())
1595                .unwrap_or_else(|| tagged_beef.beef.clone());
1596
1597        // The successor rule, asked of every topic that found no coin before
1598        // anything of this submit is written ([`Engine::successors_waiting`]):
1599        // the topics that must wait, each with why.
1600        let mut blocked: HashMap<String, String> = HashMap::new();
1601        // The landing trusts the subject's walk only if it RAN (the E592
1602        // lens fold, M1): after a breach nothing it reaches was walked.
1603        let landing = Landing {
1604            policy,
1605            subject_walk_ran: report.walk_could_not_run.is_none(),
1606        };
1607        let waits = self
1608            .successors_waiting(
1609                &tx,
1610                &txid,
1611                &subject_named_beef,
1612                &mut validations,
1613                &mut steak,
1614                tagged_beef.off_chain_values.as_deref(),
1615                mode,
1616                bound,
1617                finalize,
1618                !carried,
1619                &mut blocked,
1620                question_reads,
1621                &mut report.landed_predecessors,
1622                landing,
1623                &mut report.landed_walks_could_not_run,
1624            )
1625            .await;
1626        // A carried predecessor's topic that does not wait is judged again,
1627        // for real: what it admits is this call's word, over the same coins.
1628        if carried {
1629            for v in &mut validations {
1630                if v.is_dupe || v.failed || v.read_fault.is_some() || waits.contains_key(&v.topic) {
1631                    continue;
1632                }
1633                match self
1634                    .judge(
1635                        &tx,
1636                        &v.topic,
1637                        &v.previous_coins,
1638                        tagged_beef.off_chain_values.as_deref(),
1639                        mode,
1640                        &TopicAdmittanceContext::default(),
1641                        bound,
1642                    )
1643                    .await
1644                {
1645                    Ok(admittance) => v.admittance = admittance,
1646                    Err(e) => {
1647                        error!("Error validating topic {} during submit: {e}", v.topic);
1648                        v.failed = true;
1649                        v.previous_coins.clear();
1650                        v.previous_outputs.clear();
1651                        v.admittance = AdmittanceInstructions::default();
1652                    }
1653                }
1654                steak.insert(v.topic.clone(), v.admittance.clone());
1655            }
1656        }
1657
1658        // =================================================================
1659        // PHASE 3: MUTATE STORAGE
1660        // =================================================================
1661        for v in &validations {
1662            if v.is_dupe {
1663                report.deduped_topics.push(v.topic.clone());
1664                continue;
1665            }
1666            // A failed topic comes first, a read fault of it included: a
1667            // previous-coin read that faulted and a manager that then ERRED
1668            // is a failed topic with a durable report, no fault and no
1669            // replay. Parity: the reference fails the topic and never
1670            // replays. Nothing was written, so the store is as it was.
1671            if v.failed {
1672                continue;
1673            }
1674
1675            let topic = &v.topic;
1676            let admittance = &v.admittance;
1677            let faults_before = report.faults.len();
1678
1679            // A validation read that faulted: the manager judged without a
1680            // coin the store may hold, so NOTHING is written on that
1681            // judgement (the lens fold of 2026-10-07). Writing its outputs
1682            // would leave them beside the unseen coin, for a successor to
1683            // spend before the replay deletes it.
1684            if let Some((read, err)) = &v.read_fault {
1685                report.fault(topic, read, err.clone());
1686                self.hold_unapplied(&txid, topic, "a validation read faulted, nothing written");
1687                continue;
1688            }
1689
1690            // bsv-low #559 and lane E1D: a topic that found no coin while a
1691            // predecessor's landing is unknown is "not now", recorded
1692            // nowhere ([`Engine::successors_waiting`]).
1693            if let Some(not_now) = waits.get(topic) {
1694                report.fault(topic, "predecessor_not_landed", not_now.clone());
1695                self.hold_unapplied(
1696                    &txid,
1697                    topic,
1698                    &format!("found no coin and its predecessor {not_now}"),
1699                );
1700                continue;
1701            }
1702
1703            // ── Handle stale vs retained previous coins ──
1704            let mut outputs_consumed: Vec<Outpoint> = Vec::new();
1705            let mut stale_coins: Vec<&Output> = Vec::new();
1706
1707            for (coin_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1708                let input_index = v.previous_coins[coin_idx];
1709                if admittance.coins_to_retain.contains(&input_index) {
1710                    // Retained: track as consumed (will update consumedBy later)
1711                    outputs_consumed
1712                        .push(Outpoint::new(&prev_output.txid, prev_output.output_index));
1713                } else {
1714                    // Not retained: mark as stale for deletion
1715                    stale_coins.push(prev_output);
1716                }
1717            }
1718
1719            // Update STEAK with removed coins
1720            if let Some(steak_entry) = steak.get_mut(topic) {
1721                steak_entry.coins_removed = Some(
1722                    stale_coins
1723                        .iter()
1724                        .enumerate()
1725                        .filter_map(|(i, _)| {
1726                            // Map back to input indices
1727                            v.previous_coins.get(i).copied()
1728                        })
1729                        .collect(),
1730                );
1731            }
1732
1733            // ── Mark previous outputs as spent + notify lookup services ──
1734            // A mark that faults does not skip the notification (the
1735            // reference skips it): the coin is spent whatever the row says,
1736            // a stale one is deleted below and its lookup services must
1737            // know. The mark of a RETAINED coin is part of what landed: that
1738            // row stays, and left unspent it would be listed as a UTXO
1739            // beside the output that consumed it.
1740            let mut retained_mark_faulted = false;
1741            for (prev_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1742                if let Err(e) = stored(
1743                    bound,
1744                    self.storage.mark_utxo_as_spent(
1745                        &prev_output.txid,
1746                        prev_output.output_index,
1747                        topic,
1748                    ),
1749                )
1750                .await
1751                {
1752                    error!("Error marking UTXO as spent: {e}");
1753                    report.fault(topic, "mark_utxo_as_spent", e.to_string());
1754                    retained_mark_faulted |= admittance
1755                        .coins_to_retain
1756                        .contains(&v.previous_coins[prev_idx]);
1757                }
1758
1759                // The input index within tx.inputs that spends this previous output.
1760                let spending_input_idx = v.previous_coins[prev_idx] as usize;
1761
1762                // Notify all lookup services about the spent output
1763                for ls in self.lookup_services.values() {
1764                    let payload = match ls.spend_notification_mode() {
1765                        SpendNotificationMode::None => OutputSpent::None {
1766                            txid: prev_output.txid.clone(),
1767                            output_index: prev_output.output_index,
1768                            topic: topic.clone(),
1769                        },
1770                        SpendNotificationMode::Txid => OutputSpent::Txid {
1771                            txid: prev_output.txid.clone(),
1772                            output_index: prev_output.output_index,
1773                            topic: topic.clone(),
1774                            spending_txid: txid.clone(),
1775                        },
1776                        SpendNotificationMode::Script => {
1777                            // Extract unlocking script and sequence from the spending input
1778                            let input = &tx.inputs[spending_input_idx];
1779                            let unlocking_script = input
1780                                .unlocking_script
1781                                .as_ref()
1782                                .map(bsv_rs::UnlockingScript::to_binary)
1783                                .unwrap_or_default();
1784                            OutputSpent::Script {
1785                                txid: prev_output.txid.clone(),
1786                                output_index: prev_output.output_index,
1787                                topic: topic.clone(),
1788                                spending_txid: txid.clone(),
1789                                input_index: spending_input_idx as u32,
1790                                unlocking_script,
1791                                sequence_number: input.sequence,
1792                                off_chain_values: tagged_beef.off_chain_values.clone(),
1793                            }
1794                        }
1795                        SpendNotificationMode::WholeTx => OutputSpent::WholeTx {
1796                            txid: prev_output.txid.clone(),
1797                            output_index: prev_output.output_index,
1798                            topic: topic.clone(),
1799                            spending_atomic_beef: subject_named_beef.clone(),
1800                            off_chain_values: tagged_beef.off_chain_values.clone(),
1801                        },
1802                    };
1803                    if let Err(e) = hooked(bound, ls.output_spent(&payload)).await {
1804                        error!("Error notifying lookup service of spent output: {e}");
1805                        report.fault(topic, "lookup_service.output_spent", e);
1806                    }
1807                }
1808            }
1809            if retained_mark_faulted {
1810                self.hold_unapplied(
1811                    &txid,
1812                    topic,
1813                    "the spent mark of a retained coin faulted, nothing inserted",
1814                );
1815                continue;
1816            }
1817
1818            // ── Insert admitted outputs ──
1819            // The inserts stop at the first one that faults: what follows is
1820            // the undo, not the rest of the transaction.
1821            let mut new_utxos: Vec<Outpoint> = Vec::new();
1822            let mut attempted: Vec<u32> = Vec::new();
1823            let mut insert_faulted = false;
1824
1825            for &output_index in &admittance.outputs_to_admit {
1826                let (script_bytes, sats) =
1827                    if let Some(tx_output) = tx.outputs.get(output_index as usize) {
1828                        (
1829                            tx_output.locking_script.to_binary(),
1830                            tx_output.get_satoshis(),
1831                        )
1832                    } else {
1833                        (Vec::new(), 0)
1834                    };
1835
1836                let output = Output {
1837                    txid: txid.clone(),
1838                    output_index,
1839                    output_script: script_bytes,
1840                    satoshis: sats,
1841                    topic: topic.clone(),
1842                    spent: false,
1843                    outputs_consumed: outputs_consumed.clone(),
1844                    consumed_by: Vec::new(),
1845                    beef: Some(tagged_beef.beef.clone()),
1846                    block_height: None,
1847                    score: Some(current_timestamp_ms()),
1848                };
1849
1850                attempted.push(output_index);
1851                if let Err(e) = stored(bound, self.storage.insert_output(&output)).await {
1852                    error!("Error inserting output for topic {topic}: {e}");
1853                    report.fault(topic, "insert_output", e.to_string());
1854                    insert_faulted = true;
1855                    break;
1856                }
1857
1858                new_utxos.push(Outpoint::new(&txid, output_index));
1859
1860                // Notify lookup services
1861                for ls in self.lookup_services.values() {
1862                    let (ls_script, ls_sats) =
1863                        if let Some(tx_out) = tx.outputs.get(output_index as usize) {
1864                            (tx_out.locking_script.to_binary(), tx_out.get_satoshis())
1865                        } else {
1866                            (Vec::new(), 0)
1867                        };
1868
1869                    let payload = match ls.admission_mode() {
1870                        AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
1871                            txid: txid.clone(),
1872                            output_index,
1873                            topic: topic.clone(),
1874                            satoshis: ls_sats,
1875                            locking_script: ls_script,
1876                            off_chain_values: tagged_beef.off_chain_values.clone(),
1877                        },
1878                        AdmissionMode::WholeTx => OutputAdmittedByTopic::WholeTx {
1879                            atomic_beef: subject_named_beef.clone(),
1880                            output_index,
1881                            topic: topic.clone(),
1882                            off_chain_values: tagged_beef.off_chain_values.clone(),
1883                        },
1884                    };
1885
1886                    if let Err(e) = hooked(bound, ls.output_admitted_by_topic(&payload)).await {
1887                        error!("Error notifying lookup service: {e}");
1888                        report.fault(topic, "lookup_service.output_admitted_by_topic", e);
1889                    }
1890                }
1891            }
1892
1893            // An insert faulted: the transaction did not land, so NOTHING of
1894            // it stays (the lens fold of 2026-10-07, F1). The previous coins
1895            // are kept (#559: with the delete first, one faulted insert left
1896            // a non-retaining head chain with NO head) and the outputs that
1897            // were inserted are taken out again, the faulted one included (a
1898            // backend's insert can fault after its row landed). Left in,
1899            // they could be spent and deleted by a successor before the
1900            // replay, and the replay, which still finds the previous coin,
1901            // would insert them a second time: a spent head back among the
1902            // UTXOs for good.
1903            if insert_faulted {
1904                if let Some(bound) = bound {
1905                    bound.renew();
1906                }
1907                self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
1908                    .await;
1909                self.hold_unapplied(
1910                    &txid,
1911                    topic,
1912                    &format!(
1913                        "an insert faulted: {} previous coin(s) kept, nothing of it held",
1914                        stale_coins.len()
1915                    ),
1916                );
1917                continue;
1918            }
1919
1920            // ── Delete stale outputs recursively ──
1921            // AFTER the inserts, and once every one of them landed (bsv-low
1922            // #559 and the lens fold of 2026-10-07, F1). The reference
1923            // deletes first (`applyTopicStorageMutation`: `removeStaleOutputs`,
1924            // then `admitOutput`), catches a throw per topic and never
1925            // replays; here a fault is survived and replayed, so the order
1926            // is ours, an addition. The delete does NOT wait for a clean
1927            // report (c9921ee did, and one faulted mark or notification
1928            // after the insert then left the stale coin beside the new
1929            // output for good: two unspent heads): a fault of the mark, of
1930            // a notification, deletes as the reference does.
1931            //
1932            // Before a stale coin is deleted its transaction is recorded as
1933            // applied in the topic (the delta fold of 2026-10-07, H2; an
1934            // addition, one idempotent write per deleted coin). A held coin
1935            // proves the inserts of its transaction landed, but that
1936            // transaction may have no applied row (a fault after its
1937            // inserts withheld it, or its insert landed after its timeout),
1938            // and then its replay is judged again: a manager that admits it
1939            // with NO previous coin (the opener of a chain) had it inserted
1940            // a second time, unspent, beside the output that spent it, for
1941            // good. With the row its replay is a dupe. A record that faults
1942            // leaves its coin undeleted: that is a delete fault, below.
1943            let due_before_the_delete = bound.is_some_and(CallBound::is_due);
1944            let mut delete_faulted = false;
1945            let mut delete_started = false;
1946            for stale in &stale_coins {
1947                let spent_coin_tx = AppliedTransaction {
1948                    txid: stale.txid.clone(),
1949                    topic: topic.clone(),
1950                };
1951                if let Err(e) = stored(
1952                    bound,
1953                    self.storage.insert_applied_transaction(&spent_coin_tx),
1954                )
1955                .await
1956                {
1957                    error!(
1958                        "Error recording {} as applied before its coin is deleted from topic {topic}: {e}",
1959                        stale.txid
1960                    );
1961                    report.fault(topic, "record_spent_coin_applied", e.to_string());
1962                    delete_faulted = true;
1963                    continue;
1964                }
1965                self.not_landed
1966                    .borrow_mut()
1967                    .remove(&(stale.txid.clone(), topic.clone()));
1968                match stored(
1969                    bound,
1970                    self.storage.find_output(
1971                        &stale.txid,
1972                        stale.output_index,
1973                        Some(topic),
1974                        None,
1975                        false,
1976                    ),
1977                )
1978                .await
1979                {
1980                    Ok(Some(stale_output)) => {
1981                        delete_started = true;
1982                        if let Err(e) = self.delete_utxo_deep(&stale_output, bound).await {
1983                            error!("Error deleting stale output for topic {topic}: {e}");
1984                            report.fault(topic, "delete_utxo_deep", e.to_string());
1985                            delete_faulted = true;
1986                        }
1987                    }
1988                    Ok(None) => {}
1989                    Err(e) => {
1990                        error!("Error reading stale output for topic {topic}: {e}");
1991                        report.fault(topic, "find_output", e.to_string());
1992                        delete_faulted = true;
1993                    }
1994                }
1995            }
1996
1997            // A fault here, and what it leaves. The store is put back as it
1998            // was found (the inserts undone, the replay does the whole
1999            // transaction) only when NO delete was started: the fault is of
2000            // H2's record or of the read before the delete, the call
2001            // answered, and EVERY stale coin is read back still held.
2002            //
2003            // Once a delete was STARTED nothing is undone, whether it
2004            // answered or not (the delta fold of 2026-10-07, H1, for a call
2005            // that did not answer; the second delta fold, M1, for one that
2006            // answered an error). A statement can land after its caller was
2007            // told it failed, as after its caller stopped waiting: the coin
2008            // is read back held now and is deleted a moment later, and the
2009            // undo then left the chain with no head, for good, for a manager
2010            // that needs the previous coin. So the outputs stay, the stale
2011            // coin stays (marked spent) until its delete lands or is done
2012            // again, and there is no applied row. Since H2 every order from
2013            // there converges. The replay first, the delete never landed:
2014            // the coin is found, the manager admits again, the inserts are
2015            // no-ops, the delete is done and the row written. The replay
2016            // first, the delete landed late: no previous coin, so a manager
2017            // that needs one admits nothing and the transaction is recorded
2018            // with its outputs held. A successor first: it recorded this
2019            // transaction as applied (H2) and the replay is a dupe; the
2020            // stale coin's spent row is then left behind, no UTXO.
2021            //
2022            // A call that did not ANSWER before any delete (the record, the
2023            // read) undoes nothing either: it is not known what it did. A
2024            // deadline that was already due BEFORE the first of these calls
2025            // started nothing here, so the coins are certainly held and the
2026            // store is put back.
2027            if delete_faulted {
2028                let unanswered = !due_before_the_delete && bound.is_some_and(CallBound::is_due);
2029                if let Some(bound) = bound {
2030                    bound.renew();
2031                }
2032                let mut all_held = !unanswered && !delete_started;
2033                for stale in &stale_coins {
2034                    if !all_held {
2035                        break;
2036                    }
2037                    let read = stored(
2038                        bound,
2039                        self.storage.find_output(
2040                            &stale.txid,
2041                            stale.output_index,
2042                            Some(topic),
2043                            None,
2044                            false,
2045                        ),
2046                    )
2047                    .await;
2048                    if !matches!(read, Ok(Some(_))) {
2049                        all_held = false;
2050                        break;
2051                    }
2052                }
2053                if all_held {
2054                    self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
2055                        .await;
2056                }
2057                self.hold_unapplied(
2058                    &txid,
2059                    topic,
2060                    if all_held {
2061                        "no delete of a stale coin was started: the coins are held, its outputs taken out"
2062                    } else if unanswered {
2063                        "the delete of a stale coin did not answer: nothing undone, its outputs stay"
2064                    } else if delete_started {
2065                        "the delete of a stale coin faulted: nothing undone, its outputs stay"
2066                    } else {
2067                        "no delete was started and a stale coin was not read back held: nothing undone, its outputs stay"
2068                    },
2069                );
2070                continue;
2071            }
2072
2073            // ── Update consumedBy on retained previous outputs ──
2074            for consumed_outpoint in &outputs_consumed {
2075                let consumed_output = match stored(
2076                    bound,
2077                    self.storage.find_output(
2078                        &consumed_outpoint.txid,
2079                        consumed_outpoint.output_index,
2080                        Some(topic),
2081                        None,
2082                        false,
2083                    ),
2084                )
2085                .await
2086                {
2087                    Ok(Some(o)) => o,
2088                    Ok(None) => continue,
2089                    Err(e) => {
2090                        error!("Error reading consumed output for topic {topic}: {e}");
2091                        report.fault(topic, "find_output", e.to_string());
2092                        continue;
2093                    }
2094                };
2095                {
2096                    let mut new_consumed_by = consumed_output.consumed_by.clone();
2097                    for new_utxo in &new_utxos {
2098                        if !new_consumed_by.iter().any(|c| {
2099                            c.txid == new_utxo.txid && c.output_index == new_utxo.output_index
2100                        }) {
2101                            new_consumed_by.push(new_utxo.clone());
2102                        }
2103                    }
2104                    if let Err(e) = stored(
2105                        bound,
2106                        self.storage.update_consumed_by(
2107                            &consumed_outpoint.txid,
2108                            consumed_outpoint.output_index,
2109                            topic,
2110                            &new_consumed_by,
2111                        ),
2112                    )
2113                    .await
2114                    {
2115                        error!("Error updating consumedBy: {e}");
2116                        report.fault(topic, "update_consumed_by", e.to_string());
2117                    }
2118                }
2119            }
2120
2121            // Record applied transaction — ONLY when every write above
2122            // landed. A faulted topic stays unrecorded so the Phase-1 dedup
2123            // cannot turn its replay into a no-op (S2).
2124            if report.faults.len() > faults_before {
2125                self.hold_unapplied(
2126                    &txid,
2127                    topic,
2128                    &format!(
2129                        "{} mutation fault(s); a replay of these bytes re-applies",
2130                        report.faults.len() - faults_before
2131                    ),
2132                );
2133                continue;
2134            }
2135            let tx_record = AppliedTransaction {
2136                txid: txid.clone(),
2137                topic: topic.clone(),
2138            };
2139            match stored(bound, self.storage.insert_applied_transaction(&tx_record)).await {
2140                Ok(()) => {
2141                    report.applied_topics.push(topic.clone());
2142                    self.not_landed
2143                        .borrow_mut()
2144                        .remove(&(txid.clone(), topic.clone()));
2145                }
2146                Err(e) => {
2147                    error!("Error inserting applied transaction for topic {topic}: {e}");
2148                    report.fault(topic, "insert_applied_transaction", e.to_string());
2149                    self.not_landed
2150                        .borrow_mut()
2151                        .insert((txid.clone(), topic.clone()));
2152                }
2153            }
2154        }
2155
2156        Ok((steak, report, blocked))
2157    }
2158
2159    /// The successor rule (bsv-low #559; lane E1D, bsv-low #575; and its
2160    /// lens fold of 2026-10-08), asked of every topic of a submit that found
2161    /// NO previous coin while the transaction spends something, before
2162    /// anything of the submit is written. Returns the topics that must wait,
2163    /// each with why: those are reported as a fault (`predecessor_not_landed`)
2164    /// and recorded nowhere, "not now". An addition to the reference, which
2165    /// records every non-failed topic.
2166    ///
2167    /// Such a topic was judged without the coin a predecessor has yet to
2168    /// leave. Recorded as applied, its replay was a dupe for good: a
2169    /// transaction that admits nothing ended the chain at the predecessor
2170    /// (#559), and one the manager admits WITHOUT its coin (the pf head
2171    /// manager over its own head state, E1D) was inserted, and the
2172    /// predecessor, landing later, stood unspent beside the tip.
2173    ///
2174    /// "Has not landed" is known two ways: this engine saw it fault
2175    /// (`not_landed`, one invocation, every door), or the store says so
2176    /// ([`Engine::unlanded_predecessor`], any invocation, never at a GASP
2177    /// finalize submit: its graph passed the anchor check, every parent
2178    /// inside the graph was submitted just before it and the sequence stops
2179    /// at the first that does not land; the second delta fold of 2026-10-07,
2180    /// M2). For a successor that admits something, both look only at the
2181    /// inputs the manager NAMES as its history (`identify_needed_inputs`
2182    /// over the subject-named BEEF; the lens fold, L1): a manager that
2183    /// names nothing (all 16 of this workspace) asks nothing there, as in
2184    /// the reference. A successor that admits NOTHING is asked of every
2185    /// input whatever its manager names (D17 (a)), so under such a manager
2186    /// it still waits for, or lands first, a carried body that spends a
2187    /// coin the topic holds: there the store differs from the reference's
2188    /// (the E1D delta fold, L3).
2189    ///
2190    /// The door lands a carried predecessor FIRST (the lens fold, M1; the
2191    /// GASP walk's rule, at the door): when the question names an unlanded
2192    /// predecessor whose body the BEEF carries, that body is submitted on
2193    /// its own to this topic ([`Engine::land_carried`]), under the submit's
2194    /// read allowance, and once it lands the topic is judged again
2195    /// ([`Engine::validate_topic`]) and asked again. The re-judgement's
2196    /// reads (the applied row, one coin per input) are charged to that
2197    /// allowance BEFORE the landing (the E1D delta fold, M1): a landing whose
2198    /// re-judgement would not fit is not made, and the topic waits. So one
2199    /// submit reads at most its own validation and writes plus
2200    /// [`PREDECESSOR_READS_PER_SUBMIT`], landings (on their clean path) and
2201    /// re-judgements included; a faulted landing's read-backs and undo, and
2202    /// a deep delete's walk of retained history, are not charged.
2203    /// Nothing is landed into a topic whose manager or a lookup
2204    /// service reads off-chain values (the delta fold, L1: the landing has
2205    /// none to give, and the predecessor's own submit would be a dupe): the
2206    /// topic waits for that submit. The first predecessor that does not land
2207    /// ends it: the topic waits. A predecessor whose body the BEEF does not
2208    /// carry is never landed here: its successor converges only when it
2209    /// lands (its own replay, a GASP peer, a resubmit).
2210    #[allow(clippy::too_many_arguments)]
2211    async fn successors_waiting(
2212        &self,
2213        tx: &Transaction,
2214        txid: &str,
2215        subject_named_beef: &[u8],
2216        validations: &mut [TopicValidation],
2217        steak: &mut Steak,
2218        off_chain_values: Option<&[u8]>,
2219        mode: SubmitMode,
2220        bound: Option<&CallBound>,
2221        finalize: bool,
2222        lands: bool,
2223        blocked: &mut HashMap<String, String>,
2224        question_reads: &mut usize,
2225        landed: &mut Vec<(String, String)>,
2226        landing: Landing,
2227        landed_breaches: &mut Vec<WalkCouldNotRun>,
2228    ) -> HashMap<String, String> {
2229        let mut waits = HashMap::new();
2230        let spent: Vec<(String, u32)> = tx
2231            .inputs
2232            .iter()
2233            .filter_map(|input| {
2234                let source = input.get_source_txid().ok()?;
2235                (!source.is_empty()).then_some((source, input.source_output_index))
2236            })
2237            .collect();
2238        if spent.is_empty() {
2239            return waits;
2240        }
2241        let found_no_coin = |v: &TopicValidation| {
2242            !v.is_dupe && !v.failed && v.read_fault.is_none() && v.previous_outputs.is_empty()
2243        };
2244        for v in validations.iter_mut() {
2245            if !found_no_coin(v) {
2246                continue;
2247            }
2248            let Some(manager) = self.managers.get(&v.topic) else {
2249                continue;
2250            };
2251            let topic = v.topic.clone();
2252            // The manager's word on the subject's history, asked at most
2253            // once per topic: up front for a successor that admits, by the
2254            // question when it needs it for one that does not.
2255            let mut subject_names: Option<HashSet<String>> = None;
2256            loop {
2257                let admits = !v.admittance.outputs_to_admit.is_empty();
2258                if admits && subject_names.is_none() {
2259                    subject_names = Some(
2260                        self.named_history(manager.as_ref(), subject_named_beef, bound)
2261                            .await,
2262                    );
2263                }
2264                let remembered = spent.iter().find(|(source, vout)| {
2265                    (!admits
2266                        || subject_names
2267                            .as_ref()
2268                            .is_some_and(|named| named.contains(&format!("{source}.{vout}"))))
2269                        && self
2270                            .not_landed
2271                            .borrow()
2272                            .contains(&(source.clone(), topic.clone()))
2273                });
2274                if let Some((predecessor, _)) = remembered {
2275                    waits.insert(topic.clone(), format!("{predecessor} has not landed"));
2276                    break;
2277                }
2278                if finalize {
2279                    break;
2280                }
2281                let Some(not_now) = self
2282                    .unlanded_predecessor(
2283                        tx,
2284                        txid,
2285                        subject_named_beef,
2286                        &topic,
2287                        mode,
2288                        admits,
2289                        &mut subject_names,
2290                        question_reads,
2291                        bound,
2292                    )
2293                    .await
2294                else {
2295                    break;
2296                };
2297                let Some(carried) = not_now.carried else {
2298                    waits.insert(topic.clone(), not_now.why);
2299                    break;
2300                };
2301                if !lands {
2302                    blocked.insert(topic.clone(), carried);
2303                    waits.insert(topic.clone(), not_now.why);
2304                    break;
2305                }
2306                if !self.lands_without_values(&topic) {
2307                    waits.insert(
2308                        topic.clone(),
2309                        format!(
2310                            "{}; 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",
2311                            not_now.why
2312                        ),
2313                    );
2314                    break;
2315                }
2316                // The re-judgement after the landing, charged before it.
2317                let rejudge = 1 + spent.len();
2318                if *question_reads + rejudge > PREDECESSOR_READS_PER_SUBMIT {
2319                    waits.insert(
2320                        topic.clone(),
2321                        format!(
2322                            "{}; its landing and the re-judgement after it do not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads",
2323                            not_now.why
2324                        ),
2325                    );
2326                    break;
2327                }
2328                *question_reads += rejudge;
2329                match self
2330                    .land_carried(
2331                        subject_named_beef,
2332                        txid,
2333                        &carried,
2334                        &topic,
2335                        mode,
2336                        bound,
2337                        question_reads,
2338                        landed,
2339                        landing,
2340                        landed_breaches,
2341                    )
2342                    .await
2343                {
2344                    Ok(()) => {
2345                        *v = self
2346                            .validate_topic(
2347                                tx,
2348                                txid,
2349                                &topic,
2350                                off_chain_values,
2351                                mode,
2352                                &TopicAdmittanceContext::default(),
2353                                bound,
2354                            )
2355                            .await;
2356                        steak.insert(topic.clone(), v.admittance.clone());
2357                        if !found_no_coin(v) {
2358                            break;
2359                        }
2360                    }
2361                    Err(did_not_land) => {
2362                        // No re-judgement follows: its reads were not made.
2363                        *question_reads -= rejudge;
2364                        waits.insert(
2365                            topic.clone(),
2366                            format!(
2367                                "{}; submitted first from the BEEF, it did not land ({did_not_land})",
2368                                not_now.why
2369                            ),
2370                        );
2371                        break;
2372                    }
2373                }
2374            }
2375            // A subject that already HOLDS an output in the topic landed
2376            // there before (the leftover of its own earlier submit whose
2377            // delete was started and faulted, D17 M1: its outputs held, no
2378            // applied row), and that admission was judged with its coin.
2379            // Its replay finishes it and does not wait (the E1D lens fold,
2380            // L4: a proven head spend that names a decoy no peer serves was
2381            // "not now" on every replay, three and a dead letter). One read,
2382            // only on the way to "not now", inside the allowance.
2383            if !finalize
2384                && waits.contains_key(&topic)
2385                && *question_reads < PREDECESSOR_READS_PER_SUBMIT
2386            {
2387                *question_reads += 1;
2388                if let Ok(outputs) = stored(
2389                    bound,
2390                    self.storage.find_outputs_for_transaction(txid, false),
2391                )
2392                .await
2393                {
2394                    if outputs.iter().any(|o| o.topic == topic) {
2395                        info!("topic {topic}: {txid} holds its outputs already; its replay finishes it");
2396                        waits.remove(&topic);
2397                        blocked.remove(&topic);
2398                    }
2399                }
2400            }
2401        }
2402        waits
2403    }
2404
2405    /// Land an unlanded predecessor whose body the successor's BEEF carries
2406    /// (lane E1D's lens fold, M1): submit it on its own to `topic`, its
2407    /// atomic BEEF out of the successor's, no off-chain values (the caller
2408    /// lands nothing into a topic that reads them; the delta fold, L1),
2409    /// never a broadcast (`current-tx` is submitted as `historical-tx`: the
2410    /// successor's BEEF carries it to the network).
2411    ///
2412    /// Each body is WALKED once in this submit (the delta fold, L4): the
2413    /// successor's own SPV walk (`subject`, the same mode, tracker and
2414    /// switch) already passed over every body it reaches without crossing a
2415    /// proven one, so those are not walked again; a body reached only
2416    /// through a proven one (which the successor's walk does not descend)
2417    /// gets its own walk, trusting what this submit already walked, and so
2418    /// does nothing twice. A walk that refuses a body ends the landing.
2419    ///
2420    /// A predecessor whose own submit waits on a carried predecessor of ITS
2421    /// own goes on a stack under it: ancestors first, the walk's rule at
2422    /// the door. Its manager was asked only a DRY RUN then and nothing was
2423    /// written; once the one under it landed it is submitted again and
2424    /// admitted for real, once ([`Engine::submit_counted`]). Each submit's
2425    /// reads on its clean path (its applied row, one coin per input, and
2426    /// its writes' one read of each coin it spends) are charged to the
2427    /// submit's allowance before it starts, and its predecessor
2428    /// question shares that allowance, so the landings of one submit stop
2429    /// at [`PREDECESSOR_READS_PER_SUBMIT`] reads, however deep the chain.
2430    /// Each body that lands is pushed on `landed` (the report's
2431    /// `landed_predecessors`, for the caller's admission guard; the delta
2432    /// fold, L2). Each body is asked of the caller's landing guard first,
2433    /// once ([`Engine::set_landing_guard`]; the delta-2 fold, L2): a refusal
2434    /// ends the landing before that body is submitted.
2435    /// `Ok` once `predecessor` LANDED in `topic` (recorded there,
2436    /// now or before); otherwise why not, at the first that did not.
2437    #[allow(clippy::too_many_arguments)]
2438    async fn land_carried(
2439        &self,
2440        beef_bytes: &[u8],
2441        subject: &str,
2442        predecessor: &str,
2443        topic: &str,
2444        mode: SubmitMode,
2445        bound: Option<&CallBound>,
2446        question_reads: &mut usize,
2447        landed: &mut Vec<(String, String)>,
2448        landing: Landing,
2449        landed_breaches: &mut Vec<WalkCouldNotRun>,
2450    ) -> Result<(), String> {
2451        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
2452            .map_err(|e| format!("the BEEF could not be read again ({e})"))?;
2453        let mode = match mode {
2454            SubmitMode::CurrentTx => SubmitMode::HistoricalTx,
2455            other => other,
2456        };
2457        let walks = mode != SubmitMode::HistoricalTxNoSpv;
2458        // The trust set is what a walk that RAN covered (the E592 lens fold,
2459        // M1): a subject whose walk breached covered nothing, so each carried
2460        // predecessor is walked by its own submit (and its own breach is
2461        // reported, `landed_breaches`), never trusted by a breached walk.
2462        let mut walked: HashSet<String> = if walks && landing.subject_walk_ran {
2463            walk_cover(&beef, subject)
2464        } else {
2465            HashSet::new()
2466        };
2467        let mut stack = vec![predecessor.to_string()];
2468        let mut guarded: HashSet<String> = HashSet::new();
2469        while let Some(next) = stack.last().cloned() {
2470            let body = beef
2471                .find_atomic_transaction(&next)
2472                .ok_or_else(|| format!("{next}: its body is not in the BEEF"))?;
2473            // The caller's admission predicate, before anything of this body
2474            // is submitted (the delta-2 fold, L2).
2475            if let Some(guard) = &self.landing_guard {
2476                if guarded.insert(next.clone()) {
2477                    bounded(bound, guard(&next, topic))
2478                        .await
2479                        .and_then(|answer| answer)
2480                        .map_err(|why| format!("{next}: refused by the landing guard ({why})"))?;
2481                }
2482            }
2483            // Its validation (the applied row, one coin per input) and its
2484            // writes' one read of each coin it spends.
2485            let cost = 1 + 2 * body.inputs.len();
2486            if *question_reads + cost > PREDECESSOR_READS_PER_SUBMIT {
2487                return Err(format!(
2488                    "{next}: its submit does not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2489                ));
2490            }
2491            *question_reads += cost;
2492            let atomic = body
2493                .to_atomic_beef(true)
2494                .map_err(|e| format!("{next}: its BEEF could not be built ({e})"))?;
2495            let tagged = TaggedBEEF::new(atomic, vec![topic.to_string()]);
2496            let (_, report, blocked) = match self
2497                .submit_counted(
2498                    &tagged,
2499                    mode,
2500                    bound,
2501                    false,
2502                    Some(&walked),
2503                    question_reads,
2504                    landing.policy,
2505                )
2506                .await
2507            {
2508                Ok(counted) => counted,
2509                // Its walk could not run and nothing stands in for it: it does
2510                // not land now, and the successor waits (counted, L3).
2511                Err(EngineError::WalkCouldNotRun(stop)) => {
2512                    let why = format!("{next}: {}", EngineError::WalkCouldNotRun(stop.clone()));
2513                    landed_breaches.push(stop);
2514                    return Err(why);
2515                }
2516                Err(e) => return Err(format!("{next}: {e}")),
2517            };
2518            // Its walk passed, so did that of every body it reaches; a walk
2519            // that went on past a breach (`NetworkAccepted`) passed nothing.
2520            if let Some(stop) = &report.walk_could_not_run {
2521                landed_breaches.push(stop.clone());
2522            } else if walks && !walked.contains(&next) {
2523                walked.extend(walk_cover(&beef, &next));
2524            }
2525            let landed_now = report.applied_topics.iter().any(|t| t == topic);
2526            if report.is_durable()
2527                && (landed_now || report.deduped_topics.iter().any(|t| t == topic))
2528            {
2529                info!("topic {topic}: {next} landed first from its successor's BEEF");
2530                if landed_now {
2531                    landed.push((next.clone(), topic.to_string()));
2532                }
2533                stack.pop();
2534                continue;
2535            }
2536            match blocked.get(topic) {
2537                Some(under) if !stack.contains(under) => stack.push(under.clone()),
2538                _ if report.is_durable() => return Err(format!("{next}: the manager failed it")),
2539                _ => return Err(format!("{next}: {}", report.summary())),
2540            }
2541        }
2542        Ok(())
2543    }
2544
2545    /// A topic of a submit that is NOT recorded as applied: logged, and
2546    /// remembered for the successor rule (`not_landed`).
2547    fn hold_unapplied(&self, txid: &str, topic: &str, why: &str) {
2548        warn!("topic {topic}: {txid}: {why}; applied_transactions NOT recorded (bsv-low #559)");
2549        self.not_landed
2550            .borrow_mut()
2551            .insert((txid.to_string(), topic.to_string()));
2552    }
2553
2554    /// Take out again the outputs a faulted submit inserted (bsv-low #559,
2555    /// the lens fold of 2026-10-07, F1), so that nothing of a transaction
2556    /// that did not land is held. Only a row nobody has touched since is
2557    /// deleted: unspent and consumed by nothing. A plain delete, not the
2558    /// deep one: the row's retained inputs were not pointed at it yet. A
2559    /// fault here is reported (`undo_insert_output`) and the row stays: the
2560    /// limit of an undo made of separate calls.
2561    async fn undo_inserts(
2562        &self,
2563        txid: &str,
2564        topic: &str,
2565        output_indices: &[u32],
2566        bound: Option<&CallBound>,
2567        report: &mut MutationReport,
2568    ) {
2569        for &output_index in output_indices {
2570            let row = stored(
2571                bound,
2572                self.storage
2573                    .find_output(txid, output_index, Some(topic), None, false),
2574            )
2575            .await;
2576            let undone = match row {
2577                Ok(Some(row)) if !row.spent && row.consumed_by.is_empty() => {
2578                    stored(bound, self.storage.delete_output(txid, output_index, topic)).await
2579                }
2580                Ok(_) => Ok(()),
2581                Err(e) => Err(e),
2582            };
2583            if let Err(e) = undone {
2584                error!("Error taking {txid}.{output_index} out of topic {topic} again: {e}");
2585                report.fault(topic, "undo_insert_output", e.to_string());
2586            }
2587        }
2588    }
2589
2590    /// The store's own answer to "has a predecessor of `tx` not landed?"
2591    /// (bsv-low #559, the lens fold of 2026-10-07, F3; lane E1D, bsv-low
2592    /// #575, and its lens fold of 2026-10-08), for a transaction that found
2593    /// no previous coin. The engine's memory of a faulted submit
2594    /// (`not_landed`) dies with the invocation; this does not. A transaction
2595    /// HAS LANDED in `topic` when it has an applied row there or an output
2596    /// held there; anything else is not known to have landed, and it is a
2597    /// PREDECESSOR of `tx` when one of these says so:
2598    ///
2599    /// - its body is in the BEEF and it spends a coin the topic HOLDS (or,
2600    ///   one step up, a coin of another such transaction): a spend of this
2601    ///   topic the store has not taken yet (#559);
2602    /// - its body is in the BEEF, it spends no coin the topic holds, the
2603    ///   manager NAMES the outpoint the walk spends from it as overlay
2604    ///   history (`identify_needed_inputs` over the BEEF of the transaction
2605    ///   that spends it, `tx` or a body the walk reached), and the manager,
2606    ///   asked of that body in a dry run with no coins
2607    ///   (`TopicAdmittanceContext::DRY_RUN`, the submit's mode, no off-chain
2608    ///   values), would admit that named output: an OPENER that has not
2609    ///   landed (E1D, the cure D17 named). Only a NAMED output is dry-run
2610    ///   (the lens fold, H1): with every unheld body dry-run, a manager that
2611    ///   admits on output shape (all 16 of this workspace) made a spend of a
2612    ///   shape-admissible output this node never held (a revocation of an
2613    ///   ad it never saw; a stranger's own few-sat SHIP output) "not now" on
2614    ///   every presentation, three replays and a dead letter each, where the
2615    ///   reference records it. A manager `Err` in the dry run is "not now"
2616    ///   (the manager's contract at the anchor replay, D15), except its
2617    ///   typed refusal `NoAdmissibleOutputs`, which is "not admitted";
2618    /// - its body is NOT in the BEEF (a PROVEN successor carries none) and
2619    ///   the manager names the outpoint `tx` spends from it as overlay
2620    ///   history (D13's word; an `Err` there names nothing, as in the walk).
2621    ///
2622    /// The manager is asked over `beef_bytes`, the SUBJECT-NAMED BEEF of the
2623    /// submit (BRC-95 atomic, the body the lookup services get; the lens
2624    /// fold, M2): over the submitted bytes a manager that parses
2625    /// `from_beef(_, None)` took the wire-LAST transaction of a non-atomic
2626    /// BEEF, named an ancestor's inputs, and the question asked nothing.
2627    ///
2628    /// `admits` says which inputs of `tx` start the walk. A transaction that
2629    /// admits nothing (D17's class) starts from every input. One that admits
2630    /// WITHOUT its coin (E1D) starts only from the inputs the manager names:
2631    /// a manager that admits a successor with no coin has said the coin does
2632    /// not decide the admission, so only its own word makes an input a
2633    /// predecessor. Every manager of this workspace names nothing: an
2634    /// admitting successor is then asked nothing, as in the reference, and
2635    /// so is a spend of an unheld output nobody names (the lens fold, H1);
2636    /// but a successor that admits nothing still finds case (a), a carried
2637    /// body that spends a coin the topic holds, and waits for it or has it
2638    /// landed first, where the reference records the successor and never
2639    /// admits that body (the E1D delta fold, L3). A named input whose body
2640    /// the BEEF carries is still walked and dry-run, so a named decoy the
2641    /// manager would not admit is no predecessor.
2642    ///
2643    /// The answer carries the predecessor when its body is in the BEEF (the
2644    /// first two cases): the door lands it first ([`Engine::successors_waiting`]).
2645    ///
2646    /// The limits, stated. It cannot tell a faulted predecessor from one
2647    /// nobody submitted yet: a successor is "not now" in both until the
2648    /// predecessor lands (the door lands a carried one itself). A named
2649    /// input whose body is absent and which never lands (a decoy no one
2650    /// submits, beside a real predecessor that landed and holds no coin)
2651    /// keeps its successor "not now" at the door; the GASP walk prunes such
2652    /// an input instead (D14). Deeper than the successor's own inputs, an
2653    /// absent body ends the walk as before.
2654    ///
2655    /// "Landed" needs a clean answer (the delta fold of 2026-10-07, M1 and
2656    /// M2): a read that faults, or the question running out of its reads
2657    /// (the 16 of a topic or the 256 of the submit), answers "not now" too,
2658    /// naming the transaction it could not settle. Recording the successor
2659    /// there made its replay a dupe and stopped the chain behind it for
2660    /// good; a retried no-op is the lesser cost.
2661    ///
2662    /// What the bound counts (the second delta fold of 2026-10-07, M2): the
2663    /// reads spent on a body that is NEITHER proven in the BEEF NOR found
2664    /// landed, [`PREDECESSOR_READS`] of them per topic. A body the BEEF
2665    /// proves, and one whose applied row or held output answers "landed", is
2666    /// read and costs nothing against that bound: counted, they made a
2667    /// transaction that admits nothing over 17 landed parents, over six
2668    /// proven parents the topic never saw, or over one proven parent with 15
2669    /// inputs "not now" on every submit, with no unlanded predecessor
2670    /// anywhere. A landed body still needs ROOM under it: each read of an
2671    /// unproven body is tested before it is made, so with 15 counted reads
2672    /// spent one that landed by a held output (no applied row) is refused at
2673    /// its second read, and with 16 spent any unproven body at its first.
2674    ///
2675    /// "Proven" is the BEEF's word here: a body that carries a merkle path
2676    /// (a bump index) is counted as proven for the read bound ONLY. No proof
2677    /// is checked at this question; the SPV walk of the submit and the GASP
2678    /// anchor check are where proofs are checked, and under
2679    /// `historical-tx-no-spv` or the scripts-only walk nothing checked this
2680    /// one, so the 16 are the caller's to switch off. Nothing is recorded on
2681    /// that word (a proven body is walked like any other), and the allowance
2682    /// below does not ask it.
2683    ///
2684    /// The hard allowance (the third delta fold of 2026-10-07, M1):
2685    /// `question_reads`, owned by the submit, counts EVERY read of the
2686    /// question, those of proven and landed bodies included, over every
2687    /// topic of the submit, and stops it at
2688    /// [`PREDECESSOR_READS_PER_SUBMIT`]; the 16 stay inside it. A later
2689    /// topic's question starts from what the earlier ones left.
2690    ///
2691    /// The costs, stated. A transaction that admits nothing, found no coin
2692    /// and carries more UNPROVEN, UNLANDED bodies than 16 reads settle (five
2693    /// single-input ancestors, fewer with more inputs), or a BEEF whose
2694    /// question needs more than 256 reads over the submit's topics (a
2695    /// landed body costs one read by its applied row or two by a held
2696    /// output, a proven, unlanded one two and one per input), is never
2697    /// recorded, where the reference records it: each submit of it is a
2698    /// fault and a replay. The manager CPU it adds: one names call per
2699    /// spender a dry run needs (the subject's, and one per walked body with
2700    /// an unheld source), and at most one dry run per body the reads
2701    /// reached; each candidate is built out of the BEEF parsed once (the
2702    /// lens fold, L2).
2703    ///
2704    /// Returns why the submit must wait, for the report.
2705    #[allow(clippy::too_many_arguments)]
2706    async fn unlanded_predecessor(
2707        &self,
2708        tx: &Transaction,
2709        txid: &str,
2710        beef_bytes: &[u8],
2711        topic: &str,
2712        mode: SubmitMode,
2713        admits: bool,
2714        subject_names: &mut Option<HashSet<String>>,
2715        question_reads: &mut usize,
2716        bound: Option<&CallBound>,
2717    ) -> Option<NotNow> {
2718        let manager = self.managers.get(topic)?;
2719        let wait = |why: String| Some(NotNow { why, carried: None });
2720        // The outpoints `tx` spends, as `txid.vout`.
2721        let starts: Vec<(String, u32)> = tx
2722            .inputs
2723            .iter()
2724            .filter_map(|input| {
2725                let source = input.get_source_txid().ok()?;
2726                (!source.is_empty()).then_some((source, input.source_output_index))
2727            })
2728            .collect();
2729        // The outputs of each candidate that the walk spends, and who spends
2730        // each: a dry run asks only of an output its spender names.
2731        let mut spent_of: HashMap<String, HashSet<u32>> = HashMap::new();
2732        let mut spenders: HashMap<String, Vec<(String, u32)>> = HashMap::new();
2733        let mut ask: Vec<String> = Vec::new();
2734        for (source, vout) in &starts {
2735            if admits
2736                && subject_names
2737                    .as_ref()
2738                    .is_some_and(|named| !named.contains(&format!("{source}.{vout}")))
2739            {
2740                continue;
2741            }
2742            spent_of.entry(source.clone()).or_default().insert(*vout);
2743            spenders
2744                .entry(source.clone())
2745                .or_default()
2746                .push((txid.to_string(), *vout));
2747            ask.push(source.clone());
2748        }
2749        // Nothing to ask (a transaction that admits and names nothing, every
2750        // manager of this workspace): the BEEF is not even parsed.
2751        if ask.is_empty() {
2752            return None;
2753        }
2754        let direct: HashSet<String> = ask.iter().cloned().collect();
2755        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS).ok()?;
2756        // Each body the BEEF carries, and whether the BEEF says it is
2757        // proven (a bump index: its word, no proof is checked here).
2758        let bodies: HashMap<String, (&Transaction, bool)> = beef
2759            .txs
2760            .iter()
2761            .filter_map(|btx| {
2762                btx.tx()
2763                    .map(|body| (btx.txid(), (body, btx.bump_index().is_some())))
2764            })
2765            .collect();
2766        // The manager's names over each walked body other than `tx`.
2767        let mut names_over: HashMap<String, HashSet<String>> = HashMap::new();
2768        // The reads spent on bodies neither proven nor found landed.
2769        let mut reads = 0usize;
2770        let mut asked: HashSet<String> = HashSet::new();
2771        while let Some(candidate) = ask.pop() {
2772            if candidate.is_empty() || !asked.insert(candidate.clone()) {
2773                continue;
2774            }
2775            let spent_outputs = spent_of.get(&candidate).cloned().unwrap_or_default();
2776            // An absent body is asked of the store only when it is one of
2777            // `tx`'s own inputs and the manager names it (E1D).
2778            let body = bodies.get(&candidate).copied();
2779            if body.is_none() {
2780                if !direct.contains(&candidate) {
2781                    continue;
2782                }
2783                if subject_names.is_none() {
2784                    *subject_names = Some(
2785                        self.named_history(manager.as_ref(), beef_bytes, bound)
2786                            .await,
2787                    );
2788                }
2789                let is_named = subject_names.as_ref().is_some_and(|named| {
2790                    spent_outputs
2791                        .iter()
2792                        .any(|vout| named.contains(&format!("{candidate}.{vout}")))
2793                });
2794                if !is_named {
2795                    continue;
2796                }
2797            }
2798            let proven = body.is_some_and(|(_, proven)| proven);
2799            // This candidate's own reads: they join `reads` once it is
2800            // found not landed, and never if the BEEF proves it.
2801            let mut spent = 0usize;
2802            let out_of = |spent: usize| !proven && reads + spent >= PREDECESSOR_READS;
2803            let out_of_reads = || {
2804                wait(format!(
2805                    "{candidate} is not known to have landed: the question ran out of its {PREDECESSOR_READS} reads"
2806                ))
2807            };
2808            // The submit's allowance, over every read: proven or landed,
2809            // this topic or an earlier one.
2810            let out_of_allowance = || {
2811                wait(format!(
2812                    "{candidate} is not known to have landed: the question ran out of the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2813                ))
2814            };
2815            let unread = |e: StorageError| {
2816                wait(format!(
2817                    "{candidate} is not known to have landed: the store could not say ({e})"
2818                ))
2819            };
2820            let carried = |why: String| {
2821                Some(NotNow {
2822                    why,
2823                    carried: Some(candidate.clone()),
2824                })
2825            };
2826            let record = AppliedTransaction {
2827                txid: candidate.clone(),
2828                topic: topic.to_string(),
2829            };
2830            // An applied row, or an output held, ends the question for this
2831            // transaction: it landed.
2832            if out_of(spent) {
2833                return out_of_reads();
2834            }
2835            if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2836                return out_of_allowance();
2837            }
2838            spent += 1;
2839            *question_reads += 1;
2840            match stored(bound, self.storage.does_applied_transaction_exist(&record)).await {
2841                Ok(false) => {}
2842                Ok(true) => continue,
2843                Err(e) => return unread(e),
2844            }
2845            if out_of(spent) {
2846                return out_of_reads();
2847            }
2848            if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2849                return out_of_allowance();
2850            }
2851            spent += 1;
2852            *question_reads += 1;
2853            match stored(
2854                bound,
2855                self.storage.find_outputs_for_transaction(&candidate, false),
2856            )
2857            .await
2858            {
2859                Ok(outputs) if outputs.iter().all(|o| o.topic != topic) => {}
2860                Ok(_) => continue,
2861                Err(e) => return unread(e),
2862            }
2863            // Not landed. With no body to walk, the manager's word is the
2864            // whole answer (E1D).
2865            let Some((body, _)) = body else {
2866                return wait(format!(
2867                    "{candidate} has not landed (the manager names it as history; the BEEF does not carry it)"
2868                ));
2869            };
2870            for input in &body.inputs {
2871                let source = input.get_source_txid().unwrap_or_default();
2872                if source.is_empty() {
2873                    continue;
2874                }
2875                if out_of(spent) {
2876                    return out_of_reads();
2877                }
2878                if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2879                    return out_of_allowance();
2880                }
2881                spent += 1;
2882                *question_reads += 1;
2883                let coin = stored(
2884                    bound,
2885                    self.storage.find_output(
2886                        &source,
2887                        input.source_output_index,
2888                        Some(topic),
2889                        None,
2890                        false,
2891                    ),
2892                )
2893                .await;
2894                match coin {
2895                    Ok(Some(_)) => return carried(format!("{candidate} has not landed")),
2896                    // Not held: its own transaction may be the one that has
2897                    // not landed.
2898                    Ok(None) => {
2899                        spent_of
2900                            .entry(source.clone())
2901                            .or_default()
2902                            .insert(input.source_output_index);
2903                        spenders
2904                            .entry(source.clone())
2905                            .or_default()
2906                            .push((candidate.clone(), input.source_output_index));
2907                        ask.push(source);
2908                    }
2909                    Err(e) => return unread(e),
2910                }
2911            }
2912            // It spends no coin the topic holds. Does a spender NAME an
2913            // output the walk spends of it (the lens fold, H1)? A names call
2914            // per spender, no store read.
2915            let mut named_outputs: HashSet<u32> = HashSet::new();
2916            for (spender, vout) in spenders.get(&candidate).cloned().unwrap_or_default() {
2917                let names = if spender == txid {
2918                    if subject_names.is_none() {
2919                        *subject_names = Some(
2920                            self.named_history(manager.as_ref(), beef_bytes, bound)
2921                                .await,
2922                        );
2923                    }
2924                    subject_names.as_ref()
2925                } else {
2926                    if !names_over.contains_key(&spender) {
2927                        let names = match beef
2928                            .find_atomic_transaction(&spender)
2929                            .and_then(|spender| spender.to_atomic_beef(true).ok())
2930                        {
2931                            Some(spender_beef) => {
2932                                self.named_history(manager.as_ref(), &spender_beef, bound)
2933                                    .await
2934                            }
2935                            None => HashSet::new(),
2936                        };
2937                        names_over.insert(spender.clone(), names);
2938                    }
2939                    names_over.get(&spender)
2940                };
2941                if names.is_some_and(|named| named.contains(&format!("{candidate}.{vout}"))) {
2942                    named_outputs.insert(vout);
2943                }
2944            }
2945            if !named_outputs.is_empty() {
2946                // Would the manager admit a named output of it with no coins
2947                // (E1D, the opener)? A dry run over the candidate built out
2948                // of the BEEF parsed above (its proof and, unproven, its
2949                // sources linked): no store read, nothing counted.
2950                let judged = beef
2951                    .find_atomic_transaction(&candidate)
2952                    .unwrap_or_else(|| body.clone());
2953                let verdict = bounded(
2954                    bound,
2955                    manager.identify_admissible_outputs(
2956                        &judged,
2957                        &[],
2958                        None,
2959                        mode,
2960                        &TopicAdmittanceContext::DRY_RUN,
2961                    ),
2962                )
2963                .await;
2964                match verdict {
2965                    Ok(Ok(admittance)) => {
2966                        if admittance
2967                            .outputs_to_admit
2968                            .iter()
2969                            .any(|vout| named_outputs.contains(vout))
2970                        {
2971                            return carried(format!(
2972                                "{candidate} has not landed (the manager names the output spent from it and would admit it with no coin: an opener)"
2973                            ));
2974                        }
2975                    }
2976                    Ok(Err(TopicManagerError::NoAdmissibleOutputs(_))) => {}
2977                    Ok(Err(e)) => {
2978                        return wait(format!(
2979                            "{candidate} is not known to have landed: the manager could not judge it ({e})"
2980                        ))
2981                    }
2982                    Err(no_answer) => {
2983                        return wait(format!(
2984                            "{candidate} is not known to have landed: the manager did not answer ({no_answer})"
2985                        ))
2986                    }
2987                }
2988            }
2989            if !proven {
2990                reads += spent;
2991            }
2992        }
2993        None
2994    }
2995
2996    /// The outpoints (`txid.vout`) the topic manager NAMES as overlay
2997    /// history of the transaction `beef_bytes` names (`identify_needed_inputs`,
2998    /// D13's word; an atomic BEEF, so a manager that parses
2999    /// `from_beef(_, None)` reads that transaction). An `Err` or no answer
3000    /// names nothing, as the GASP walk treats it (logged).
3001    async fn named_history(
3002        &self,
3003        manager: &dyn TopicManager,
3004        beef_bytes: &[u8],
3005        bound: Option<&CallBound>,
3006    ) -> HashSet<String> {
3007        match bounded(bound, manager.identify_needed_inputs(beef_bytes, None)).await {
3008            Ok(Ok(named)) => named.iter().map(Outpoint::to_graph_id).collect(),
3009            Ok(Err(e)) => {
3010                warn!("the predecessor question: the manager could not name its inputs: {e}");
3011                HashSet::new()
3012            }
3013            Err(no_answer) => {
3014                warn!("the predecessor question: the manager did not name its inputs: {no_answer}");
3015                HashSet::new()
3016            }
3017        }
3018    }
3019
3020    /// The reference's `tx.verify(chainTracker)` on submit: every unproven
3021    /// transaction from the subject down has every input's script executed
3022    /// against its source output and obeys the value rule; a proven one is
3023    /// not descended, and its BUMP's root is checked against the chain
3024    /// tracker (computed and accepted with none).
3025    ///
3026    /// Since bsv-low #592 (the owner's ruling 3a of 2026-10-10) the scripts
3027    /// are walked by the DOOR's stream walk ([`crate::script_door`]) under
3028    /// `budget`, the engine's [`DoorBudget`]: one walker, the same estimates
3029    /// charged from the bytes before anything is parsed or run. Before it this
3030    /// was a walk of its own over a hydrated `Beef` with no memory charge and
3031    /// no interpreter limit: 219.7 MB natively on a 1.9 MB push-only unproven
3032    /// parent, an isolate kill on Workers, met by every `historical-tx` replay
3033    /// of the queue. The roots are then asked for the proven transactions the
3034    /// walk reached, in its order, once per BUMP.
3035    ///
3036    /// `Ok(Some(_))` is a walk that COULD NOT RUN within the budget, never a
3037    /// refusal (below, at the match). The interpreter's refusal and a
3038    /// structural fault are what they were: `ScriptVerificationFailed`, and
3039    /// `SpvError` in the same words.
3040    ///
3041    /// The tracker is an ARGUMENT, not `self`'s: the GASP anchor check
3042    /// ([`verify_spv_like_the_reference`]) runs this same walk from
3043    /// `OverlayGASPStorage`, which borrows the engine's tracker.
3044    ///
3045    /// Why not bsv-rs `Transaction::verify`: it walks `source_transaction`
3046    /// links that `from_beef` builds by CLONING, once per input. Two inputs
3047    /// sourcing the same unproven parent (a covenant head output plus that
3048    /// spend's own change, the shape of every second head spend of a wallet)
3049    /// leave the second clone bare ("Input 0 has no source transaction"), and
3050    /// re-linking every clone materializes the ancestry EXPONENTIALLY along a
3051    /// diamond chain: a 12-deep unmined chain of head spends took a Worker past
3052    /// its memory on beta (zanaadu, 2026-09-08). The stream walk reads each
3053    /// transaction once, in place, keyed by txid.
3054    async fn verify_beef_linear(
3055        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3056        beef_bytes: &[u8],
3057        subject_txid: &str,
3058        trusted: &HashSet<String>,
3059        budget: DoorBudget,
3060    ) -> Result<Option<WalkCouldNotRun>, EngineError> {
3061        // A structural fault is the reference walk's `SpvError`.
3062        let spv =
3063            |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3064
3065        // THE SCRIPTS (bsv-low #592): the door's own stream walk, under the
3066        // engine's budget. One walker (Rule 10): the door and the submit run
3067        // the same function, the same estimates and the same interpreter
3068        // limit; the submit adds the roots below.
3069        let mut trace = crate::script_door::WalkTrace::default();
3070        match crate::script_door::walk_trusting(
3071            beef_bytes,
3072            subject_txid,
3073            budget,
3074            trusted,
3075            &mut trace,
3076        ) {
3077            Ok(_) => {}
3078            // The interpreter's verdict, the reference's refusal: unchanged.
3079            Err(refused @ EngineError::ScriptVerificationFailed { .. }) => return Err(refused),
3080            // A structural fault (a source missing, the value rule, ...):
3081            // the reference walk's `SpvError`, in the same words as before.
3082            Err(EngineError::ScriptWalkInconclusive { reason, .. }) => return Err(spv(reason)),
3083            // The budget, not the transaction: the walk COULD NOT RUN. Not a
3084            // refusal: the bytes are a BEEF the walk was not given the room
3085            // to judge, which says nothing about whether its scripts are
3086            // valid (refusing would refuse a valid BEEF for its size, the
3087            // owner's posture). Not an admission either: the caller's
3088            // [`WalkBreachPolicy`] decides, and only the network's word on
3089            // record lets the submit go on (the E592 lens fold, H1). The roots
3090            // the walk reached are checked first, below.
3091            Err(EngineError::ScriptWalkOverBudget {
3092                at_txid,
3093                subject_judged,
3094                limb,
3095                what,
3096            }) => {
3097                let limb = match limb {
3098                    DoorLimb::Memory => WalkLimb::OverMemory,
3099                    DoorLimb::Work if trace.interpreter_tripped => WalkLimb::InterpreterMemory,
3100                    DoorLimb::Work => WalkLimb::OverWork,
3101                };
3102                // THE ROOTS OF A BREACHED WALK (the E592 lens fold, H1): every
3103                // proven transaction it reached, and every proven source an
3104                // input read, whatever the scripts' verdict. A bad root is
3105                // the `SpvError` it is on a walk that ran: a breach never
3106                // skips a proof the walk relied on. Their cost is a tracker
3107                // question per BUMP, which the budget does not bound and
3108                // does not need to (the clean walk asks the same).
3109                let reached: Vec<String> = trace
3110                    .proven
3111                    .iter()
3112                    .chain(&trace.proven_sources)
3113                    .cloned()
3114                    .collect();
3115                Self::check_reached_roots(chain_tracker, beef_bytes, &reached).await?;
3116                return Ok(Some(WalkCouldNotRun {
3117                    subject_txid: subject_txid.to_string(),
3118                    at_txid,
3119                    subject_judged,
3120                    limb,
3121                    what,
3122                }));
3123            }
3124            Err(other) => return Err(other),
3125        }
3126
3127        // THE ROOTS: every proven transaction the walk reached, in the order
3128        // it reached them, its BUMP's root computed and asked of the tracker
3129        // once per BUMP (NL-6e). With no tracker the roots are computed and
3130        // accepted (`'scripts only'`). The walk trusted them as it went; the
3131        // reference checks them in the same walk, so a body with BOTH a bad
3132        // script and a bad root now names the script.
3133        Self::check_reached_roots(chain_tracker, beef_bytes, &trace.proven).await?;
3134        Ok(None)
3135    }
3136
3137    /// The roots of `reached` (display txids, in order; a repeat or a second
3138    /// transaction of one BUMP asks nothing more), each BUMP's root computed
3139    /// and asked of the tracker once. None reached: the body is not parsed.
3140    async fn check_reached_roots(
3141        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3142        beef_bytes: &[u8],
3143        reached: &[String],
3144    ) -> Result<(), EngineError> {
3145        let spv =
3146            |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3147        if reached.is_empty() {
3148            return Ok(());
3149        }
3150        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
3151            .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3152        let proven_in = proven_index(&beef);
3153        let mut checked: HashSet<usize> = HashSet::new();
3154        for txid in reached {
3155            let Some(&at) = proven_in.get(txid.as_str()) else {
3156                return Err(spv(format!("transaction {txid} is not in the BEEF")));
3157            };
3158            if !checked.insert(at) {
3159                continue; // its BUMP's root is the chain's, asked once
3160            }
3161            let mp = &beef.bumps[at];
3162            let root = mp
3163                .compute_root(Some(txid))
3164                .map_err(|e| spv(format!("invalid merkle path for transaction {txid}: {e}")))?;
3165            if let Some(tracker) = chain_tracker {
3166                match tracker
3167                    .is_valid_root_for_height(&root, mp.block_height)
3168                    .await
3169                {
3170                    Ok(true) => {}
3171                    Ok(false) => {
3172                        return Err(EngineError::SpvError(format!(
3173                            "Invalid merkle path for transaction {txid}: root {root} is not valid for block height {}",
3174                            mp.block_height
3175                        )));
3176                    }
3177                    Err(e) => {
3178                        return Err(EngineError::SpvError(format!(
3179                            "Chain tracker error at height {}: {e}",
3180                            mp.block_height
3181                        )));
3182                    }
3183                }
3184            }
3185        }
3186        Ok(())
3187    }
3188
3189    /// The pre-2026-09-08 SPV block, kept as the
3190    /// [`Engine::set_script_verification`] `false` escape hatch: BEEF
3191    /// structural validity plus every root against the chain tracker, and
3192    /// NOTHING when no tracker is configured. It never executes a script.
3193    ///
3194    /// The structure is the streaming reader's
3195    /// ([`beef_limits::structure_roots`], bsv-rs 0.4.2): one element in hand,
3196    /// each BUMP walked once, linear in its leaves (NL-6e). Until NL-6e it
3197    /// was `Beef::verify_valid`, whose root check at bsv-rs 0.4.0 walked a
3198    /// BUMP once per leaf: one BUMP of 8,192 leaves gave no answer in 120 s
3199    /// (bsv-stack-lean NL-8 W1). The reader reads the wire's order, where
3200    /// `verify_valid` sorted first: an input that names a later transaction
3201    /// is refused here.
3202    async fn verify_spv_structurally(
3203        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3204        beef_bytes: &[u8],
3205    ) -> Result<(), EngineError> {
3206        let Some(chain_tracker) = chain_tracker else {
3207            return Ok(());
3208        };
3209        let roots = beef_limits::structure_roots(beef_bytes)
3210            .map_err(|r| EngineError::SpvError(format!("BEEF structure refused: {r}")))?;
3211        let mut asked: HashSet<(u64, &str)> = HashSet::new();
3212        for (height, root) in &roots {
3213            if !asked.insert((*height, root.as_str())) {
3214                continue;
3215            }
3216            let Ok(height) = u32::try_from(*height) else {
3217                return Err(EngineError::SpvError(format!(
3218                    "Merkle root {root} invalid for block height {height}"
3219                )));
3220            };
3221            match chain_tracker.is_valid_root_for_height(root, height).await {
3222                Ok(true) => {}
3223                Ok(false) => {
3224                    return Err(EngineError::SpvError(format!(
3225                        "Merkle root {root} invalid for block height {height}"
3226                    )));
3227                }
3228                Err(e) => {
3229                    return Err(EngineError::SpvError(format!(
3230                        "Chain tracker error at height {height}: {e}"
3231                    )));
3232                }
3233            }
3234        }
3235        Ok(())
3236    }
3237
3238    /// Phase 1 of ONE topic: the dedup read, the previous coins, the
3239    /// manager's judgement. Read-only. [`Engine::run_validation`] asks it of
3240    /// every topic; the door asks it again of a topic whose predecessor it
3241    /// landed first from the BEEF (lane E1D's lens fold, M1).
3242    #[allow(clippy::too_many_arguments)]
3243    async fn validate_topic(
3244        &self,
3245        tx: &Transaction,
3246        txid: &str,
3247        topic: &str,
3248        off_chain_values: Option<&[u8]>,
3249        mode: SubmitMode,
3250        context: &TopicAdmittanceContext,
3251        bound: Option<&CallBound>,
3252    ) -> TopicValidation {
3253        let tx_record = AppliedTransaction {
3254            txid: txid.to_string(),
3255            topic: topic.to_string(),
3256        };
3257        // A dedup read that FAULTS is the topic's read fault (the delta
3258        // fold of 2026-10-07, H3): nothing is judged and nothing is
3259        // written for the topic, the fault rides the report and the
3260        // replay asks again. The reference fails the topic there
3261        // (`Engine.ts` `submit`: the throw lands in the per-topic catch,
3262        // `failedTopics.add`). Read as "not a dupe", as it was, a
3263        // re-presented transaction the manager admits with no previous
3264        // coin was inserted a second time, unspent, beside the output
3265        // that had spent it, under a durable report.
3266        let is_dupe = match stored(
3267            bound,
3268            self.storage.does_applied_transaction_exist(&tx_record),
3269        )
3270        .await
3271        {
3272            Ok(is_dupe) => is_dupe,
3273            Err(e) => {
3274                error!("Error reading the applied row of {txid} for topic {topic}: {e}");
3275                return TopicValidation {
3276                    topic: topic.to_string(),
3277                    is_dupe: false,
3278                    previous_coins: vec![],
3279                    previous_outputs: vec![],
3280                    admittance: AdmittanceInstructions::default(),
3281                    failed: false,
3282                    read_fault: Some(("does_applied_transaction_exist", e.to_string())),
3283                };
3284            }
3285        };
3286
3287        if is_dupe {
3288            return TopicValidation {
3289                topic: topic.to_string(),
3290                is_dupe: true,
3291                previous_coins: vec![],
3292                previous_outputs: vec![],
3293                admittance: AdmittanceInstructions::default(),
3294                failed: false,
3295                read_fault: None,
3296            };
3297        }
3298
3299        let mut previous_coins: Vec<u32> = Vec::new();
3300        let mut previous_outputs: Vec<Output> = Vec::new();
3301        let mut read_fault: Option<(&'static str, String)> = None;
3302
3303        for (input_idx, input) in tx.inputs.iter().enumerate() {
3304            let source_txid = input.get_source_txid().unwrap_or_default();
3305            if source_txid.is_empty() {
3306                continue;
3307            }
3308            match stored(
3309                bound,
3310                self.storage.find_output(
3311                    &source_txid,
3312                    input.source_output_index,
3313                    Some(topic),
3314                    None,
3315                    false,
3316                ),
3317            )
3318            .await
3319            {
3320                Ok(Some(prev_output)) => {
3321                    previous_coins.push(input_idx as u32);
3322                    previous_outputs.push(prev_output);
3323                }
3324                Ok(None) => {}
3325                Err(e) => {
3326                    // A faulted read is NOT "no previous coin": the
3327                    // spend may consume an admitted output we simply
3328                    // could not read. Validation proceeds (the topic
3329                    // manager decides on what it can see) but the fault
3330                    // rides the report and blocks the applied record.
3331                    error!(
3332                        "Error reading previous output {source_txid}:{} for topic {topic}: {e}",
3333                        input.source_output_index
3334                    );
3335                    if read_fault.is_none() {
3336                        read_fault = Some(("find_output", e.to_string()));
3337                    }
3338                }
3339            }
3340        }
3341
3342        let judged = self
3343            .judge(
3344                tx,
3345                topic,
3346                &previous_coins,
3347                off_chain_values,
3348                mode,
3349                context,
3350                bound,
3351            )
3352            .await;
3353        match judged {
3354            Ok(admittance) => TopicValidation {
3355                topic: topic.to_string(),
3356                is_dupe: false,
3357                previous_coins,
3358                previous_outputs,
3359                admittance,
3360                failed: false,
3361                read_fault: read_fault.clone(),
3362            },
3363            Err(e) => {
3364                error!("Error validating topic {topic} during submit: {e}");
3365                TopicValidation {
3366                    topic: topic.to_string(),
3367                    is_dupe: false,
3368                    previous_coins: vec![],
3369                    previous_outputs: vec![],
3370                    admittance: AdmittanceInstructions::default(),
3371                    failed: true,
3372                    read_fault,
3373                }
3374            }
3375        }
3376    }
3377
3378    /// The topic manager's judgement of `tx` over the previous coins found
3379    /// (input indices), as [`Engine::validate_topic`] asks it; a carried
3380    /// predecessor's submit asks it again for real once its topic does not
3381    /// wait (the E1D delta fold, L4).
3382    #[allow(clippy::too_many_arguments)]
3383    async fn judge(
3384        &self,
3385        tx: &Transaction,
3386        topic: &str,
3387        previous_coins: &[u32],
3388        off_chain_values: Option<&[u8]>,
3389        mode: SubmitMode,
3390        context: &TopicAdmittanceContext,
3391        bound: Option<&CallBound>,
3392    ) -> Result<AdmittanceInstructions, TopicManagerError> {
3393        let manager = &self.managers[topic];
3394        let previous_coin_bytes = previous_coins
3395            .iter()
3396            .flat_map(|i| i.to_le_bytes())
3397            .collect::<Vec<u8>>();
3398        // Under a finalize submit's bound a manager that does not answer
3399        // is a manager that failed: nothing is written for the topic.
3400        bounded(
3401            bound,
3402            manager.identify_admissible_outputs(
3403                // The ONE engine parse of the submitted BEEF, shared by
3404                // every topic on this submit (bsv-low #289 — managers
3405                // used to re-parse the same bytes independently).
3406                tx,
3407                &previous_coin_bytes,
3408                off_chain_values,
3409                mode,
3410                // `dry_run` is `false` on a submit, `true` on a
3411                // validate-only call.
3412                context,
3413            ),
3414        )
3415        .await
3416        .map_err(TopicManagerError::Other)
3417        .and_then(|judged| judged)
3418    }
3419
3420    /// Whether the door may land a carried predecessor into `topic` (the
3421    /// E1D delta fold, L1): neither its manager nor any lookup service
3422    /// reads off-chain values there, which the landing does not have.
3423    fn lands_without_values(&self, topic: &str) -> bool {
3424        self.managers
3425            .get(topic)
3426            .is_some_and(|manager| !manager.reads_off_chain_values())
3427            && self
3428                .lookup_services
3429                .values()
3430                .all(|ls| !ls.reads_off_chain_values(topic))
3431    }
3432
3433    /// Run Phase 1 (topic validation) and Phase 2 (broadcast) without mutating storage.
3434    ///
3435    /// Returns (validations, steak, parsed_tx, txid) so the caller can either
3436    /// stop (validate-only) or proceed with Phase 3 (mutations).
3437    async fn run_validation(
3438        &self,
3439        tagged_beef: &TaggedBEEF,
3440        mode: SubmitMode,
3441        context: &TopicAdmittanceContext,
3442        bound: Option<&CallBound>,
3443        walked: Option<&HashSet<String>>,
3444        policy: WalkBreachPolicy,
3445    ) -> Result<Validated, EngineError> {
3446        // Validate all topics are supported
3447        for topic in &tagged_beef.topics {
3448            if !self.managers.contains_key(topic) {
3449                return Err(EngineError::UnsupportedTopic(topic.clone()));
3450            }
3451        }
3452
3453        // Parse the SUBJECT from the BEEF — order-independent (`subject.rs`,
3454        // loop-2 hardening 2026-09-05): `from_beef(_, None)` takes the last
3455        // tx in wire order, which an SDK-sorted INCOMPLETE ancestry fills
3456        // with a fully-sourced ancestor; the engine then judged a hop, admitted
3457        // nothing and the pot never entered the index while its JOIN mined.
3458        let subject = {
3459            let mut b =
3460                beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
3461                    .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3462            crate::subject::subject_txid_of(&mut b)
3463        };
3464        let tx = beef_limits::transaction_from_beef(
3465            &tagged_beef.beef,
3466            subject.as_deref(),
3467            &beef_limits::ENGINE_BEEF_LIMITS,
3468        )
3469        .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3470        let txid = tx.id();
3471
3472        // SPV verification, skipped ONLY for HistoricalTxNoSpv, exactly the
3473        // reference's `if (mode !== 'historical-tx-no-spv') tx.verify(...)`.
3474        // That mode exists for GASP `finalizeGraph`, whose graphs
3475        // `validateGraphAnchor` already verified, and since bsv-low #551 that
3476        // is TRUE here: `OverlayGASPStorage::validate_graph_anchor` runs this
3477        // same check ([`verify_spv_like_the_reference`], the same tracker and
3478        // the same switch) over the ROOT node's BEEF and replays the graph
3479        // through the topic manager before a single BEEF of it reaches this
3480        // door. Before #551 the anchor check was a no-op and this skip
3481        // admitted a peer's graph on structure alone.
3482        //
3483        // A predecessor the door lands first (`walked`, the E1D delta fold,
3484        // L4) is not walked again when this submit's walks already passed
3485        // over it, and its walk trusts what they passed over.
3486        //
3487        // The walk runs under the engine's budget (bsv-low #592,
3488        // [`Engine::set_walk_budget`]): one that could not run within it is
3489        // NOT a refusal (see `verify_beef_linear`), and NOT an admission
3490        // unless the caller holds the network's accept of these bytes (the
3491        // E592 lens fold, H1, [`WalkBreachPolicy`]): "not now" before any
3492        // manager is asked, or, under `NetworkAccepted`, this submit goes on
3493        // as under `HistoricalTxNoSpv` and the report says so.
3494        let mut could_not_run = None;
3495        if mode != SubmitMode::HistoricalTxNoSpv {
3496            match walked {
3497                Some(walked) if walked.contains(&txid) => {}
3498                trusted => {
3499                    could_not_run = verify_spv_trusting(
3500                        self.chain_tracker.as_deref(),
3501                        self.verify_scripts,
3502                        &tagged_beef.beef,
3503                        &txid,
3504                        trusted.unwrap_or(&HashSet::new()),
3505                        self.walk_budget,
3506                    )
3507                    .await?;
3508                }
3509            }
3510        }
3511        if let Some(stop) = could_not_run.take() {
3512            match policy {
3513                WalkBreachPolicy::NotNow => {
3514                    warn!(
3515                        "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)",
3516                        stop.subject_txid,
3517                        stop.limb.as_str(),
3518                        stop.at_txid,
3519                        stop.subject_judged,
3520                        stop.what
3521                    );
3522                    return Err(EngineError::WalkCouldNotRun(stop));
3523                }
3524                WalkBreachPolicy::NetworkAccepted => {
3525                    warn!(
3526                        "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)",
3527                        stop.subject_txid,
3528                        stop.limb.as_str(),
3529                        stop.at_txid,
3530                        stop.subject_judged,
3531                        stop.what
3532                    );
3533                    could_not_run = Some(stop);
3534                }
3535            }
3536        }
3537
3538        let mut steak = Steak::new();
3539
3540        // =============================================================
3541        // PHASE 1: VALIDATE (read-only)
3542        // =============================================================
3543        let mut validations = Vec::new();
3544
3545        for topic in &tagged_beef.topics {
3546            validations.push(
3547                self.validate_topic(
3548                    &tx,
3549                    &txid,
3550                    topic,
3551                    tagged_beef.off_chain_values.as_deref(),
3552                    mode,
3553                    context,
3554                    bound,
3555                )
3556                .await,
3557            );
3558        }
3559
3560        // Build preliminary STEAK
3561        for v in &validations {
3562            steak.insert(v.topic.clone(), v.admittance.clone());
3563        }
3564
3565        // =============================================================
3566        // PHASE 2: BROADCAST / SHIP propagation (before mutations)
3567        // =============================================================
3568        if mode == SubmitMode::CurrentTx {
3569            // ── ARC network broadcast ──────────────────────────────────
3570            // Broadcast to miners via ARC, matching the TS Engine pattern.
3571            // Skip if the tx already has a merkle path (already mined).
3572            //
3573            // INCIDENT D1-CALLBACK-FLOOD 2026-09-01: also skip when EVERY
3574            // topic classified the tx as a dupe (already applied) — the
3575            // exact `!v.is_dupe` filter the SHIP arm below has always had,
3576            // ten lines away. Without it, every RE-PRESENT of an
3577            // already-applied tx (cron ad-sync, peer crawl, a client
3578            // retrying a default-mode /submit) re-POSTed to Arcade and
3579            // re-registered the status callback, and registrations
3580            // accumulate per POST on Arcade's side — the flood's fuel. A
3581            // genuinely new tx (any topic non-dupe) broadcasts exactly as
3582            // before.
3583            let all_topics_dupe = !validations.is_empty() && validations.iter().all(|v| v.is_dupe);
3584            if let Some(ref arc) = self.arc_broadcaster {
3585                if all_topics_dupe {
3586                    info!(
3587                        "Skipping ARC broadcast — tx already applied to every topic (re-present)"
3588                    );
3589                } else if tx.merkle_path.is_none() {
3590                    let raw_tx_hex = tx.to_hex();
3591                    match arc.broadcast(&raw_tx_hex).await {
3592                        Ok(arc_txid) => {
3593                            info!("ARC broadcast succeeded: txid={arc_txid}");
3594                        }
3595                        Err(e) => {
3596                            error!("ARC broadcast failed (non-fatal): {e}");
3597                        }
3598                    }
3599                } else {
3600                    info!("Skipping ARC broadcast — tx already has merkle proof");
3601                }
3602            }
3603
3604            // ── SHIP peer propagation ──────────────────────────────────
3605            if let Some(ref broadcaster) = self.broadcaster {
3606                let relevant_topics: Vec<String> = validations
3607                    .iter()
3608                    .filter(|v| {
3609                        !v.is_dupe && !v.failed && !v.admittance.outputs_to_admit.is_empty()
3610                    })
3611                    .map(|v| v.topic.clone())
3612                    .collect();
3613
3614                if !relevant_topics.is_empty() {
3615                    if let Some(ship_ls) = self.lookup_services.get("ls_ship") {
3616                        for topic in &relevant_topics {
3617                            let question = LookupQuestion::new(
3618                                "ls_ship",
3619                                serde_json::json!({ "topics": [topic] }),
3620                            );
3621                            match ship_ls.lookup(&question).await {
3622                                Ok(LookupResult::OutputList(refs)) => {
3623                                    for reference in &refs {
3624                                        if let Ok(Some(output)) = self
3625                                            .storage
3626                                            .find_output(
3627                                                &reference.txid,
3628                                                reference.output_index,
3629                                                None,
3630                                                None,
3631                                                false,
3632                                            )
3633                                            .await
3634                                        {
3635                                            if let Some(domain) =
3636                                                parse_ship_domain_from_script(&output.output_script)
3637                                            {
3638                                                if let Some(ref our_url) = self.config.hosting_url {
3639                                                    if domain.trim_end_matches('/')
3640                                                        == our_url.trim_end_matches('/')
3641                                                    {
3642                                                        continue;
3643                                                    }
3644                                                }
3645                                                if let Err(e) = broadcaster
3646                                                    .broadcast_to_host(&domain, tagged_beef)
3647                                                    .await
3648                                                {
3649                                                    error!(
3650                                                        "SHIP propagation to {domain} failed for topic {topic}: {e}"
3651                                                    );
3652                                                }
3653                                            }
3654                                        }
3655                                    }
3656                                }
3657                                Ok(LookupResult::Answer(_)) => {
3658                                    // SHIP only ever returns OutputList; an Answer here means
3659                                    // a misconfigured replacement service. Skip rather than
3660                                    // attempt to extract refs from a freeform/formula payload.
3661                                    error!(
3662                                        "SHIP lookup for topic {topic} returned a pre-formed \
3663                                         LookupAnswer; expected OutputList. Skipping fanout."
3664                                    );
3665                                }
3666                                Err(e) => {
3667                                    error!("SHIP lookup for topic {topic} failed: {e}");
3668                                }
3669                            }
3670                        }
3671                    }
3672                }
3673            }
3674        }
3675
3676        Ok((validations, steak, tx, txid, could_not_run))
3677    }
3678
3679    // ========================================================================
3680    // Lookup
3681    // ========================================================================
3682
3683    /// Answer a lookup query.
3684    ///
3685    /// Delegates to the appropriate LookupService, then hydrates results with BEEF.
3686    ///
3687    /// When `history_selector` is provided, each output is hydrated with its
3688    /// ancestor spend chain (via `get_utxo_history`) before building the response.
3689    /// This matches the TS Engine behavior when a history selector is configured.
3690    pub async fn lookup(
3691        &self,
3692        question: &LookupQuestion,
3693        history_selector: Option<HistorySelector>,
3694    ) -> Result<LookupAnswer, EngineError> {
3695        Ok(self.lookup_with_txids(question, history_selector).await?.0)
3696    }
3697
3698    /// Same as [`Engine::lookup`], additionally returning each hydrated
3699    /// output's txid, aligned index-for-index with the `OutputList` items
3700    /// (empty for pre-formed `Answer`s).
3701    ///
3702    /// The txid IS the storage primary key the row was just fetched by —
3703    /// handing it to the caller lets the aggregated wire serializer write it
3704    /// directly instead of re-deriving it with a full BEEF parse plus a
3705    /// double-SHA256 per output (bsv-low #289).
3706    pub async fn lookup_with_txids(
3707        &self,
3708        question: &LookupQuestion,
3709        history_selector: Option<HistorySelector>,
3710    ) -> Result<(LookupAnswer, Vec<String>), EngineError> {
3711        let service = self
3712            .lookup_services
3713            .get(&question.service)
3714            .ok_or_else(|| EngineError::LookupServiceNotFound(question.service.clone()))?;
3715
3716        // Preserve WHOSE fault it was. A lookup service reports a malformed
3717        // query as `InvalidQuery` / `Unsupported`; stringifying both into
3718        // `LookupFailed` (as this did) threw that away and reported every
3719        // caller mistake as a 500.
3720        let result = service.lookup(question).await.map_err(|e| match e {
3721            LookupServiceError::InvalidQuery(m) | LookupServiceError::Unsupported(m) => {
3722                EngineError::InvalidQuery(m)
3723            }
3724            other => EngineError::LookupFailed(other.to_string()),
3725        })?;
3726
3727        // Two paths per LookupResult:
3728        // - OutputList(refs): the LS yields outpoints; we hydrate each with
3729        //   BEEF (and optional ancestor chain via history_selector) and
3730        //   assemble LookupAnswer::OutputList.
3731        // - Answer(answer): the LS already produced the full answer
3732        //   (Freeform/Formula); pass through verbatim. The Engine does NOT
3733        //   apply history-selector hydration to a pre-formed Answer — the
3734        //   LS is presumed to have embedded whatever ancestry it wants.
3735        let refs = match result {
3736            LookupResult::OutputList(refs) => refs,
3737            LookupResult::Answer(answer) => return Ok((answer, Vec::new())),
3738        };
3739
3740        // ONE batched hydration query for the whole result set instead of a
3741        // D1 round-trip per row (the #289 N+1). Order is preserved: the
3742        // batch contract returns outputs in input-outpoint order, skipping
3743        // outpoints with no stored row. A storage error is a REAL error now
3744        // (it used to be swallowed per-row into a silently-empty list — the
3745        // "vanishing table" failure mode): an uncertain read must never
3746        // masquerade as a confidently-empty lobby.
3747        let outpoints: Vec<Outpoint> = refs
3748            .iter()
3749            .map(|r| Outpoint::new(&r.txid, r.output_index))
3750            .collect();
3751        let found = self
3752            .storage
3753            .find_served_outputs(&outpoints)
3754            .await
3755            .map_err(|e| EngineError::StorageError(e.to_string()))?;
3756
3757        let mut outputs = Vec::new();
3758        let mut txids = Vec::new();
3759        for output in found {
3760            // If history selector provided, hydrate ancestor chain
3761            let final_output = if history_selector.is_some() {
3762                match self
3763                    .get_utxo_history(&output, history_selector.clone())
3764                    .await
3765                {
3766                    Ok(Some(hydrated)) => hydrated,
3767                    _ => output,
3768                }
3769            } else {
3770                output
3771            };
3772
3773            // Skip an output whose BEEF is missing OR empty: a lookup item
3774            // without decodable BEEF is useless to the client (it can only
3775            // drop it), and returning an empty-BEEF row is what made a fresh
3776            // LOW table show up in a query yet be undecodable/invisible to
3777            // opponents (the "vanishing table" — 2026-07-11). Only ever
3778            // return fully-hydrated, decodable outputs.
3779            if let Some(beef) = final_output.beef {
3780                if !beef.is_empty() {
3781                    txids.push(final_output.txid.clone());
3782                    outputs.push(OutputListItem {
3783                        beef,
3784                        output_index: final_output.output_index,
3785                        context: None,
3786                    });
3787                }
3788            }
3789        }
3790
3791        Ok((LookupAnswer::OutputList { outputs }, txids))
3792    }
3793
3794    // ========================================================================
3795    // Metadata
3796    // ========================================================================
3797
3798    // ========================================================================
3799    // UTXO History
3800    // ========================================================================
3801
3802    /// Traverse and return the history of a UTXO.
3803    ///
3804    /// If no `history_selector` is provided, returns the output as-is.
3805    /// If a depth selector is provided, includes up to N levels of ancestor
3806    /// transactions by embedding them as source_transactions in the BEEF.
3807    pub async fn get_utxo_history(
3808        &self,
3809        output: &Output,
3810        history_selector: Option<HistorySelector>,
3811    ) -> Result<Option<Output>, EngineError> {
3812        let Some(selector) = history_selector else {
3813            return Ok(Some(output.clone()));
3814        };
3815
3816        // Verify BEEF exists before attempting hydration
3817        if output.beef.is_none() {
3818            return Err(EngineError::Other(
3819                "Output must have associated transaction BEEF!".into(),
3820            ));
3821        }
3822
3823        match self.hydrate_utxo_history(output, &selector, 0).await {
3824            Ok(Some(hydrated_output)) => Ok(Some(hydrated_output)),
3825            Ok(None) => Ok(None),
3826            Err(e) => Err(EngineError::Other(format!(
3827                "Error retrieving UTXO history: {e}"
3828            ))),
3829        }
3830    }
3831
3832    /// Recursive UTXO history hydration.
3833    fn hydrate_utxo_history<'a>(
3834        &'a self,
3835        output: &'a Output,
3836        selector: &'a HistorySelector,
3837        current_depth: u32,
3838    ) -> std::pin::Pin<
3839        Box<dyn std::future::Future<Output = Result<Option<Output>, EngineError>> + 'a>,
3840    > {
3841        Box::pin(async move {
3842            let beef_data = output.beef.as_ref().ok_or_else(|| {
3843                EngineError::Other("Output must have associated transaction BEEF!".into())
3844            })?;
3845
3846            // Check if we should traverse at this depth
3847            let should_traverse = match selector {
3848                HistorySelector::Depth(max_depth) => current_depth <= *max_depth,
3849            };
3850
3851            if !should_traverse {
3852                return Ok(None);
3853            }
3854
3855            // Start from the STORED BEEF, not from a re-parsed subject.
3856            //
3857            // #4: this used to be `Transaction::from_beef(beef_data, None)` followed
3858            // by `tx.to_beef(true)`, which keeps only the subject transaction and
3859            // therefore DESTROYS the stored BUMPs. Measured on prod before the fix:
3860            // `ls_low` byGameId `73de6e48…` returned 1,172 B / 1 tx / 1 bump (PROVEN)
3861            // with no header, and 676 B / 1 tx / 0 bumps with `x-history-depth: 3` —
3862            // a caller asking for MORE provenance got back a BEEF no wallet can
3863            // verify, silently, behind a 200.
3864            //
3865            // Accumulating into the stored BEEF fixes both halves: its own bumps
3866            // survive, and so does any ancestry that arrived inside the submitted
3867            // bytes but was never admitted as an output of its own (a foreign
3868            // parent), which the `outputs_consumed` walk below cannot see.
3869            let mut acc = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
3870                .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3871
3872            // For each consumed output, recursively hydrate and embed as source transaction
3873            for consumed in &output.outputs_consumed {
3874                if let Ok(Some(child_output)) = self
3875                    .storage
3876                    .find_output(&consumed.txid, consumed.output_index, None, None, true)
3877                    .await
3878                {
3879                    if let Ok(Some(hydrated_child)) = self
3880                        .hydrate_utxo_history(&child_output, selector, current_depth + 1)
3881                        .await
3882                    {
3883                        // MERGE the hydrated ancestor in, rather than embedding it
3884                        // as a `source_transaction` on a re-parsed subject: a merge
3885                        // carries the child's own BUMPs across, which is the whole
3886                        // point of asking for history. An unparseable child is
3887                        // skipped — never allowed to poison the accumulator.
3888                        if let Some(ref child_beef) = hydrated_child.beef {
3889                            if let Ok(child) = Beef::from_binary(child_beef) {
3890                                acc.merge_beef(&child);
3891                            }
3892                        }
3893                    }
3894                }
3895            }
3896
3897            Ok(Some(Output {
3898                beef: Some(acc.to_binary()),
3899                ..output.clone()
3900            }))
3901        })
3902    }
3903
3904    // ========================================================================
3905    // Metadata
3906    // ========================================================================
3907
3908    pub async fn list_topic_managers(&self) -> HashMap<String, ServiceMetadata> {
3909        let mut result = HashMap::new();
3910        for (name, manager) in &self.managers {
3911            let meta = manager.get_metadata().await;
3912            result.insert(name.clone(), meta);
3913        }
3914        result
3915    }
3916
3917    /// List all registered lookup services with their metadata.
3918    pub async fn list_lookup_service_providers(&self) -> HashMap<String, ServiceMetadata> {
3919        let mut result = HashMap::new();
3920        for (name, service) in &self.lookup_services {
3921            let meta = service.get_metadata().await;
3922            result.insert(name.clone(), meta);
3923        }
3924        result
3925    }
3926
3927    /// Get documentation for a specific topic manager.
3928    pub async fn get_documentation_for_topic_manager(&self, name: &str) -> String {
3929        match self.managers.get(name) {
3930            Some(manager) => manager.get_documentation().await,
3931            None => "No documentation found!".to_string(),
3932        }
3933    }
3934
3935    /// Get documentation for a specific lookup service.
3936    pub async fn get_documentation_for_lookup_service(&self, name: &str) -> String {
3937        match self.lookup_services.get(name) {
3938            Some(service) => service.get_documentation().await,
3939            None => "No documentation found!".to_string(),
3940        }
3941    }
3942
3943    // ========================================================================
3944    // GASP endpoints
3945    // ========================================================================
3946
3947    /// Respond to a GASP initial sync request.
3948    ///
3949    /// Returns UTXOs for the given topic since the requested score.
3950    ///
3951    /// The caller-supplied `limit` is CLAMPED to
3952    /// [`Engine::SYNC_RESPONSE_MAX_LIMIT`] (and defaulted when absent):
3953    /// `/requestSyncResponse` is a PUBLIC route, and an absent limit used to
3954    /// mean an unbounded scan of the outputs table (bsv-low #291). Bounding
3955    /// is lossless for sync correctness because the initiator
3956    /// ([`crate::gasp::GASPSync`]) pages for as long as its `since` score
3957    /// cursor advances — a truncated page is fetched by its NEXT request
3958    /// within the same run (gate finding M1: pagination must not demand a
3959    /// full page, which this clamp can make impossible).
3960    pub async fn provide_foreign_sync_response(
3961        &self,
3962        request: &GASPInitialRequest,
3963        topic: &str,
3964    ) -> Result<GASPInitialResponse, EngineError> {
3965        let limit = Self::clamp_sync_limit(request.limit);
3966        let outputs = self
3967            .storage
3968            .find_utxos_for_topic(topic, Some(request.since as f64), Some(limit), false)
3969            .await
3970            .map_err(|e| EngineError::StorageError(e.to_string()))?;
3971
3972        Ok(GASPInitialResponse {
3973            utxo_list: outputs
3974                .iter()
3975                .map(|o| GASPOutput {
3976                    txid: o.txid.clone(),
3977                    output_index: o.output_index,
3978                    score: o.score.unwrap_or(0.0),
3979                })
3980                .collect(),
3981            since: request.since,
3982        })
3983    }
3984
3985    /// Provide a GASPNode for a specific transaction within a graph.
3986    ///
3987    /// Searches the BEEF tree of the root output (identified by graphID) for
3988    /// the requested txid. If not found in the BEEF tree, recurses through
3989    /// outputsConsumed in storage.
3990    pub async fn provide_foreign_gasp_node(
3991        &self,
3992        graph_id: &str,
3993        txid: &str,
3994        output_index: u32,
3995    ) -> Result<GASPNode, EngineError> {
3996        let root_outpoint = Outpoint::from_graph_id(graph_id)
3997            .ok_or_else(|| EngineError::Other(format!("Invalid graphID: {graph_id}")))?;
3998
3999        let root_output = self
4000            .storage
4001            .find_output(
4002                &root_outpoint.txid,
4003                root_outpoint.output_index,
4004                None,
4005                None,
4006                true,
4007            )
4008            .await
4009            .map_err(|e| EngineError::StorageError(e.to_string()))?
4010            .ok_or(EngineError::NodeNotFound)?;
4011
4012        self.hydrate_gasp_node(&root_output, graph_id, txid, output_index)
4013            .await
4014    }
4015
4016    /// Recursively search a BEEF tree for a specific txid and return a GASPNode.
4017    fn hydrate_gasp_node<'a>(
4018        &'a self,
4019        output: &'a Output,
4020        graph_id: &'a str,
4021        txid: &'a str,
4022        output_index: u32,
4023    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GASPNode, EngineError>> + 'a>>
4024    {
4025        Box::pin(async move {
4026            let beef_data = output.beef.as_ref().ok_or(EngineError::NodeNotFound)?;
4027
4028            // The stored body's SUBJECT by the same order-independent rule
4029            // (`subject.rs`) — a plain stored body's last tx can be an ancestor.
4030            let root = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
4031                .ok()
4032                .and_then(|mut b| crate::subject::subject_txid_of(&mut b));
4033            let root_tx = beef_limits::transaction_from_beef(
4034                beef_data,
4035                root.as_deref(),
4036                &beef_limits::STORED_BEEF_LIMITS,
4037            )
4038            .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
4039
4040            // Search the transaction tree for the requested txid
4041            if let Some(node) = Self::search_tx_tree(&root_tx, graph_id, txid, output_index) {
4042                return Ok(node);
4043            }
4044
4045            // Fallback: try parsing BEEF with the target txid directly
4046            // (some BEEF structures have the tx in the list but not linked as source_transaction)
4047            if let Some(node) = Self::search_beef_for_txid(beef_data, graph_id, txid, output_index)
4048            {
4049                return Ok(node);
4050            }
4051
4052            // Not found in BEEF tree — recurse through outputsConsumed in storage
4053            for consumed in &output.outputs_consumed {
4054                if let Ok(Some(consumed_output)) = self
4055                    .storage
4056                    .find_output(&consumed.txid, consumed.output_index, None, None, true)
4057                    .await
4058                {
4059                    if let Ok(node) = self
4060                        .hydrate_gasp_node(&consumed_output, graph_id, txid, output_index)
4061                        .await
4062                    {
4063                        return Ok(node);
4064                    }
4065                }
4066            }
4067
4068            // The graph root is known but neither its BEEF nor anything it
4069            // consumed holds the requested transaction. That is a definite
4070            // "not held", the same answer as an unknown root: the reference
4071            // throws `Unable to find output associated with your request!`
4072            // here and its route answers 400. A syncing peer reads the 400 as
4073            // `GASPError::NodeNotFound` and prunes a decoy input on it (the D8
4074            // rule, `GASPSync::process_incoming_node`); a 500 would read as a
4075            // fault of the moment and strand the chain behind the decoy.
4076            Err(EngineError::NodeNotFound)
4077        })
4078    }
4079
4080    /// Search a Transaction tree (via sourceTransaction links) for a specific txid.
4081    fn search_tx_tree(
4082        tx: &Transaction,
4083        graph_id: &str,
4084        target_txid: &str,
4085        output_index: u32,
4086    ) -> Option<GASPNode> {
4087        let current_txid = tx.id();
4088
4089        if current_txid == target_txid {
4090            let mut node = GASPNode {
4091                graph_id: graph_id.to_string(),
4092                raw_tx: tx.to_hex(),
4093                output_index,
4094                proof: None,
4095                tx_metadata: None,
4096                output_metadata: None,
4097                inputs: None,
4098            };
4099            if let Some(ref merkle_path) = tx.merkle_path {
4100                node.proof = Some(merkle_path.to_hex());
4101            }
4102            return Some(node);
4103        }
4104
4105        // Recurse into inputs via source_transaction
4106        for input in &tx.inputs {
4107            if let Some(ref source_tx) = input.source_transaction {
4108                if let Some(node) =
4109                    Self::search_tx_tree(source_tx, graph_id, target_txid, output_index)
4110                {
4111                    return Some(node);
4112                }
4113            }
4114        }
4115
4116        None
4117    }
4118
4119    /// Alternative search: try parsing the BEEF with the target txid directly.
4120    /// Some BEEF structures may not link source_transaction on inputs but
4121    /// still contain the transaction in the BEEF's transaction list.
4122    fn search_beef_for_txid(
4123        beef_data: &[u8],
4124        graph_id: &str,
4125        target_txid: &str,
4126        output_index: u32,
4127    ) -> Option<GASPNode> {
4128        // Try parsing BEEF with the specific txid
4129        if let Ok(tx) = beef_limits::transaction_from_beef(
4130            beef_data,
4131            Some(target_txid),
4132            &beef_limits::STORED_BEEF_LIMITS,
4133        ) {
4134            if tx.id() == target_txid {
4135                let mut node = GASPNode {
4136                    graph_id: graph_id.to_string(),
4137                    raw_tx: tx.to_hex(),
4138                    output_index,
4139                    proof: None,
4140                    tx_metadata: None,
4141                    output_metadata: None,
4142                    inputs: None,
4143                };
4144                if let Some(ref merkle_path) = tx.merkle_path {
4145                    node.proof = Some(merkle_path.to_hex());
4146                }
4147                return Some(node);
4148            }
4149        }
4150        None
4151    }
4152
4153    // ========================================================================
4154    // Merkle proof handling
4155    // ========================================================================
4156
4157    /// Handle a new merkle proof for a transaction.
4158    ///
4159    /// When a transaction gets mined, ARC calls back with the merkle proof.
4160    /// This method:
4161    /// 1. Finds all outputs for the txid in storage
4162    /// 2. Parses the BEEF, updates the merkle path in the transaction tree
4163    /// 3. Serializes updated BEEF back to storage
4164    /// 4. Recursively updates the consumedBy chain (outputs that spent this one)
4165    /// 5. Updates blockHeight on the outputs
4166    pub async fn handle_new_merkle_proof(
4167        &self,
4168        txid: &str,
4169        proof_hex: &str,
4170        block_height: Option<u32>,
4171    ) -> Result<(), EngineError> {
4172        let outputs = self
4173            .storage
4174            .find_outputs_for_transaction(txid, true)
4175            .await
4176            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4177
4178        if outputs.is_empty() {
4179            return Err(EngineError::Other(
4180                "Could not find matching transaction outputs for proof ingest!".into(),
4181            ));
4182        }
4183
4184        // Parse merkle proof if provided
4185        let proof = if proof_hex.is_empty() {
4186            None
4187        } else {
4188            Some(
4189                beef_limits::merkle_path_from_hex(proof_hex, beef_limits::PUSH_PROOF_MAX_BYTES)
4190                    .map_err(|e| EngineError::Other(format!("Invalid merkle proof hex: {e}")))?,
4191            )
4192        };
4193
4194        for output in &outputs {
4195            // Stitch the proof into the STORED BEEF (#284) — never rebuild it
4196            // around a re-picked subject; see stitch_proof_into_stored_beef.
4197            if let (Some(ref beef_data), Some(ref proof)) = (&output.beef, &proof) {
4198                if let Some(new_beef) = Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4199                {
4200                    let _ = self
4201                        .storage
4202                        .update_transaction_beef(&output.txid, &new_beef)
4203                        .await;
4204                }
4205            }
4206
4207            // Update block height
4208            if let Some(height) = block_height {
4209                let _ = self
4210                    .storage
4211                    .update_output_block_height(
4212                        &output.txid,
4213                        output.output_index,
4214                        &output.topic,
4215                        height,
4216                    )
4217                    .await;
4218            }
4219
4220            // Recursively update consumedBy chain
4221            for consuming in &output.consumed_by {
4222                if let Ok(consumed_outputs) = self
4223                    .storage
4224                    .find_outputs_for_transaction(&consuming.txid, true)
4225                    .await
4226                {
4227                    for consumed_output in &consumed_outputs {
4228                        // The consuming row's BEEF carries this tx as an
4229                        // ancestor — stitch the same bump in there too, again
4230                        // without rebuilding the tx set (#284: this branch is
4231                        // the one that was observed replacing a head-spend's
4232                        // stored BEEF with parent ancestry only).
4233                        if let (Some(ref beef_data), Some(ref proof)) =
4234                            (&consumed_output.beef, &proof)
4235                        {
4236                            if let Some(new_beef) =
4237                                Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4238                            {
4239                                let _ = self
4240                                    .storage
4241                                    .update_transaction_beef(&consumed_output.txid, &new_beef)
4242                                    .await;
4243                            }
4244                        }
4245                    }
4246                }
4247            }
4248        }
4249
4250        Ok(())
4251    }
4252
4253    /// Complete missing merkle proofs for stored, confirmed transactions.
4254    ///
4255    /// The `/submit`-admitted ingest path stores a proofless BEEF for each
4256    /// transaction (the submitter does not yet have a merkle proof at submit
4257    /// time). When such a transaction later mines, nothing in the overlay
4258    /// re-fetches its proof, so its stored BEEF stays proofless forever — and a
4259    /// frontend that trims `inputBEEF` to `{source tx + its BUMP}` for a P2PKH
4260    /// payment-import spend has no BUMP to trim to, hanging the import (#130).
4261    ///
4262    /// This pass closes that gap from chain (the one blessed server-side WoC
4263    /// use). It:
4264    /// 1. Pulls a bounded page (`limit`) of stored `(txid, beef)` from storage.
4265    /// 2. Parses each BEEF and keeps only those whose target tx still lacks a
4266    ///    merkle proof (`Beef::find_txid(txid).has_proof() == false`).
4267    /// 3. For each proofless candidate, fetches its BUMP via the configured
4268    ///    [`AncestorFetcher`](crate::gasp::AncestorFetcher). If a proof comes
4269    ///    back, it calls [`Self::handle_new_merkle_proof`], which stitches the
4270    ///    BUMP into the stored BEEF (D1 + R2 mirror), updates output block
4271    ///    height, and recurses the `consumedBy` chain.
4272    /// 4. Skips (does NOT error) txs the fetcher can't prove yet (still
4273    ///    unconfirmed) — those are simply retried on a later tick.
4274    ///
4275    /// **No-op when no [`AncestorFetcher`] is configured** — production-safe
4276    /// default (the same opt-in switch as GASP ancestor hydration). Bounded by
4277    /// `limit` (a per-tick budget, like the platform fetcher's own budget).
4278    ///
4279    /// `min_age_secs` is the PUSH-PRIMARY BACKSTOP gate (bsv-low #228 /
4280    /// arcade#259): since the Arcade MINED webhook (`/arc-ingest`) pushes a
4281    /// verified proof ~150 ms after a tx mines, this poll pass is a BACKSTOP,
4282    /// not the primary source — rows stored less than `min_age_secs` ago are
4283    /// skipped entirely (their push is still expected). Pass `0` to disable
4284    /// the gate (poll everything — the pre-#228 behaviour, and the degradation
4285    /// mode if pushes stop: an old-enough row is ALWAYS polled, so webhook
4286    /// loss degrades to polling, never to nothing). Unknown-age rows are
4287    /// always eligible (fail-safe — see the storage-trait doc).
4288    pub async fn complete_missing_proofs(
4289        &self,
4290        limit: u64,
4291        min_age_secs: u64,
4292    ) -> Result<ProofCompletionSummary, EngineError> {
4293        let Some(fetcher) = self.ancestor_fetcher.as_deref() else {
4294            return Ok(ProofCompletionSummary::default());
4295        };
4296        self.complete_missing_proofs_with(fetcher, limit, min_age_secs)
4297            .await
4298    }
4299
4300    /// Complete stored transactions using the caller's dedicated allowance.
4301    /// The engine's other passes retain their configured ancestor courier.
4302    pub async fn complete_missing_proofs_with(
4303        &self,
4304        fetcher: &dyn crate::gasp::AncestorFetcher,
4305        limit: u64,
4306        min_age_secs: u64,
4307    ) -> Result<ProofCompletionSummary, EngineError> {
4308        let mut summary = ProofCompletionSummary::default();
4309
4310        let candidates = self
4311            .storage
4312            .find_transactions_for_proof_check(limit, min_age_secs)
4313            .await
4314            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4315        summary.scanned = candidates.len();
4316
4317        for cand in candidates {
4318            // Read the stored BEEF for the subject's OWN bump by streaming
4319            // folds (one element in hand, no `Beef` built: NL-6); skip
4320            // anything unparseable (don't error the whole pass on one bad row).
4321            let own_bump = match beef_limits::own_proof(&cand.beef, &cand.txid) {
4322                Ok(b) => b,
4323                Err(e) => {
4324                    warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] unparseable BEEF, skipping");
4325                    continue;
4326                }
4327            };
4328
4329            // Already structurally proven? The stored BEEF carries a merkle bump
4330            // for the target tx, but its backend `has_proof` flag may still be 0
4331            // — EITHER a legitimate GASP-synced proof written before overlay
4332            // migration 0010 (which defaulted every existing row to 0), OR an
4333            // admit-time bump that was NEVER SPV-verified (`/submit` skips SPV on
4334            // historical topics) or is outright forged. Serve-time BEEF trimming
4335            // trusts the `has_proof` flag, so latching one on STRUCTURE ALONE
4336            // would let a forged bump be trimmed on (#192/#193 MEDIUM). Re-verify
4337            // the STORED bump against chaintracks (via the fetcher — its header
4338            // source is the only arbiter of a merkle root). Genuine → latch
4339            // (fast, no re-fetch). Otherwise DON'T trust it: fall through to the
4340            // fetch+stitch path below, which overwrites the bump with a
4341            // chaintracks-verified one (or leaves the row proofless for retry).
4342            // Fail-closed throughout.
4343            // the tx's OWN bump (its `bump_index`), never `find_bump`
4344            // (the FIRST bump containing the txid — the stale one after a
4345            // same-height reorg; review HIGH-1).
4346            if let Some(bump_hex) = own_bump
4347                .as_ref()
4348                .map(bsv_rs::transaction::MerklePath::to_hex)
4349            {
4350                if fetcher.verify_proof(&cand.txid, &bump_hex).await {
4351                    // Idempotent + best-effort: a failure here is logged, not
4352                    // fatal — the row simply lingers one more tick.
4353                    // bsv-low M19 R2 round 3 (review MED-2): latch WITH the
4354                    // anchor height (from the tx's OWN verified bump) so the
4355                    // revalidation sweep's transactions leg can window this
4356                    // row — `mark_transaction_proven` alone left every
4357                    // fast-path row anchorless, in no window forever.
4358                    let height = bsv_rs::transaction::MerklePath::from_hex(&bump_hex)
4359                        .ok()
4360                        .map(|mp| u64::from(mp.block_height));
4361                    if let Err(e) = self
4362                        .storage
4363                        .mark_transaction_proven_at(&cand.txid, height)
4364                        .await
4365                    {
4366                        warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] failed to mark verified-proven row");
4367                    } else {
4368                        summary.already_proven += 1;
4369                    }
4370                    continue;
4371                }
4372                warn!(txid = %cand.txid, "[PROOF COMPLETION] stored structural bump FAILED chaintracks re-verify — not trusting it, refetching");
4373            }
4374            // Unverifiable structural bump → treat as proofless (fall through).
4375            summary.proofless += 1;
4376
4377            // Fetch this tx's own VERIFIED bump from chain — PROOF ONLY (the raw
4378            // is already in the stored BEEF, so no redundant raw fetch; #192/#193
4379            // FIX 2). A tx with no verified proof yet (still unconfirmed, or an
4380            // unverifiable/forged courier response) is skipped, not errored.
4381            let Some(proof_hex) = fetcher.verified_proof_for(&cand.txid).await else {
4382                summary.still_unconfirmed += 1;
4383                continue;
4384            };
4385
4386            // Pull the block height out of the BUMP so the output rows update too.
4387            let block_height =
4388                beef_limits::merkle_path_from_hex(&proof_hex, beef_limits::COURIER_PROOF_MAX_BYTES)
4389                    .ok()
4390                    .map(|mp| mp.block_height);
4391
4392            match self
4393                .handle_new_merkle_proof(&cand.txid, &proof_hex, block_height)
4394                .await
4395            {
4396                Ok(()) => summary.completed += 1,
4397                Err(e) => {
4398                    warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] stitch failed");
4399                    summary.stitch_failed += 1;
4400                }
4401            }
4402        }
4403
4404        info!(
4405            scanned = summary.scanned,
4406            proofless = summary.proofless,
4407            completed = summary.completed,
4408            still_unconfirmed = summary.still_unconfirmed,
4409            fetch_failed = summary.fetch_failed,
4410            stitch_failed = summary.stitch_failed,
4411            already_proven = summary.already_proven,
4412            "[PROOF COMPLETION] pass complete"
4413        );
4414
4415        Ok(summary)
4416    }
4417
4418    /// Recursively update merkle paths in a transaction's input tree.
4419    ///
4420    /// If the transaction's id matches txid, set its merkle_path.
4421    /// Otherwise, recurse into sourceTransactions of each input.
4422    /// Stitch a verified BUMP for `txid` into a STORED BEEF, preserving every
4423    /// transaction and every other proof in it (#284).
4424    ///
4425    /// The old path round-tripped the stored bytes through
4426    /// `Transaction::from_beef(bytes, None)` -> `to_beef(true)`, and
4427    /// `from_beef(_, None)` picks **`txs.last()`** — it ignores the atomic
4428    /// subject pointer entirely. Whenever the subject was not the last tx in
4429    /// wire order, a PARENT was picked, `to_beef(true)` then serialized that
4430    /// parent's world, and the rewrite dropped the subject transaction from its
4431    /// own stored BEEF — observed live on every pf head-spend (list / reprice /
4432    /// delist / buy) the moment it mined, breaking the NEXT spend on that name
4433    /// (zanaadu#284). `update_input_proofs`' "already has a proof — update it"
4434    /// arm could even stamp the child's bump onto that mis-picked parent.
4435    /// This is the same destroy-the-stored-BEEF class the #4 fix note above
4436    /// (`hydrate_utxo_history`) already documents for lookup hydration.
4437    ///
4438    /// Operating on [`Beef`] directly has none of those failure modes:
4439    /// `merge_bump` dedupes against existing bumps by (height, root) and
4440    /// assigns the bump ONLY to transactions that appear as flagged txid
4441    /// leaves, so stitching tx X's proof can never mislabel tx Y, and the tx
4442    /// set is never rebuilt at all.
4443    ///
4444    /// Returns `None` when the bytes do not parse or `txid` is not in this
4445    /// BEEF (nothing to stitch — e.g. a consuming row whose ancestry got
4446    /// trimmed), so callers skip the write instead of writing garbage.
4447    ///
4448    /// bsv-low M19 R2 round 3 (review HIGH-1): a same-height DIFFERENT-root
4449    /// proof (the 2026-09-07 reorg shape) does NOT combine in `merge_bump`
4450    /// (the roots differ) — it is pushed as a SECOND bump, and
4451    /// `update_bump_indices` assigns it only to txs whose `bump_index` is
4452    /// `None`, so the reorged subject keeps its index on the STALE bump.
4453    /// Left alone, `has_proof`/`proofHeight` and every `find_txid(txid)
4454    /// .bump_index()` reader then keep pointing at the orphan bump, and the
4455    /// row refutes again next sweep pass forever. So after the merge, FORCE
4456    /// the subject onto the bump that actually proves it, then drop any bump
4457    /// no transaction references (the orphan's, now unreferenced).
4458    pub fn stitch_proof_into_stored_beef(
4459        stored: &[u8],
4460        txid: &str,
4461        proof: &bsv_rs::transaction::MerklePath,
4462    ) -> Option<Vec<u8>> {
4463        let mut beef = beef_limits::parse_beef(stored, &beef_limits::STORED_BEEF_LIMITS).ok()?;
4464        beef.find_txid(txid)?;
4465        let i = beef.merge_bump(proof.clone());
4466        // Does the merged bump actually prove `txid` (a flagged txid leaf)?
4467        // (It always does for a completion proof; the guard keeps a stray
4468        // caller from mis-anchoring a subject onto an ancestor's bump.)
4469        let proves_subject = beef.bumps.get(i).is_some_and(|b| {
4470            b.path.first().is_some_and(|leaves| {
4471                leaves
4472                    .iter()
4473                    .any(|l| l.txid && l.hash.as_deref() == Some(txid))
4474            })
4475        });
4476        if proves_subject {
4477            if let Some(tx) = beef.find_txid_mut(txid) {
4478                tx.set_bump_index(Some(i));
4479            }
4480        }
4481        Self::gc_unreferenced_bumps(&mut beef);
4482        Some(beef.to_binary())
4483    }
4484
4485    /// Drop every BUMP no transaction references and re-index the survivors
4486    /// (bsv-low M19 R2 round 3, review HIGH-1): after the subject is moved
4487    /// off an orphan bump, that bump is usually dead weight the next reader
4488    /// would still `find_bump`. A bump still referenced by ANOTHER tx (a
4489    /// sibling mined in the same block) is kept.
4490    fn gc_unreferenced_bumps(beef: &mut bsv_rs::transaction::Beef) {
4491        let referenced: std::collections::HashSet<usize> = beef
4492            .txs
4493            .iter()
4494            .filter_map(bsv_rs::transaction::BeefTx::bump_index)
4495            .collect();
4496        if referenced.len() == beef.bumps.len() {
4497            return;
4498        }
4499        let mut remap: Vec<Option<usize>> = vec![None; beef.bumps.len()];
4500        let mut kept = Vec::with_capacity(referenced.len());
4501        for (old, bump) in std::mem::take(&mut beef.bumps).into_iter().enumerate() {
4502            if referenced.contains(&old) {
4503                remap[old] = Some(kept.len());
4504                kept.push(bump);
4505            }
4506        }
4507        beef.bumps = kept;
4508        for tx in &mut beef.txs {
4509            if let Some(old) = tx.bump_index() {
4510                tx.set_bump_index(remap[old]);
4511            }
4512        }
4513    }
4514
4515    // ========================================================================
4516    // Advertisement sync
4517    // ========================================================================
4518
4519    /// Sync SHIP/SLAP advertisements with configured managers and services.
4520    ///
4521    /// Creates missing advertisements and revokes stale ones. Returns a
4522    /// [`SyncAdvertisementsReport`] so a create/submit failure is VISIBLE to
4523    /// the caller (bsv-low #320 defect 3a): this method used to swallow both
4524    /// into `error!` and return `Ok(())`, so `/admin/syncAdvertisements`
4525    /// reported `success` while zero advertisements were admitted locally —
4526    /// a caller could not tell a converged no-op from a silent failure, and
4527    /// every cycle re-created the full ad set.
4528    pub async fn sync_advertisements(&self) -> Result<SyncAdvertisementsReport, EngineError> {
4529        let mut report = SyncAdvertisementsReport::default();
4530
4531        let Some(advertiser) = &self.advertiser else {
4532            return Ok(report); // No advertiser configured
4533        };
4534
4535        let hosting_url = match &self.config.hosting_url {
4536            Some(url) if !url.is_empty() => url.clone(),
4537            _ => return Ok(report), // No hosting URL
4538        };
4539
4540        // Get configured topics and services
4541        let mut configured_topics: Vec<String> = self.managers.keys().cloned().collect();
4542        let mut configured_services: Vec<String> = self.lookup_services.keys().cloned().collect();
4543
4544        if self.config.suppress_default_sync_advertisements {
4545            configured_topics.retain(|t| t != "tm_ship" && t != "tm_slap");
4546            configured_services.retain(|s| s != "ls_ship" && s != "ls_slap");
4547        }
4548
4549        // Fetch current advertisements. A read failure REFUSES creation
4550        // (#320 M2): swallowing it into "current ads = none" made a
4551        // transient D1/storage fault re-create — and re-pay for — the
4552        // entire advertisement set, reported as a legitimate `to_create`.
4553        let current_ship = match advertiser.find_all_advertisements(Protocol::Ship).await {
4554            Ok(ads) => ads,
4555            Err(e) => {
4556                error!("Failed to read current SHIP advertisements — refusing to create: {e}");
4557                report.lookup_error = Some(format!("ship: {e}"));
4558                return Ok(report);
4559            }
4560        };
4561        let current_slap = match advertiser.find_all_advertisements(Protocol::Slap).await {
4562            Ok(ads) => ads,
4563            Err(e) => {
4564                error!("Failed to read current SLAP advertisements — refusing to create: {e}");
4565                report.lookup_error = Some(format!("slap: {e}"));
4566                return Ok(report);
4567            }
4568        };
4569
4570        // Determine what to create
4571        let ships_to_create: Vec<AdvertisementData> = configured_topics
4572            .iter()
4573            .filter(|topic| {
4574                !current_ship
4575                    .iter()
4576                    .any(|a| a.topic_or_service == **topic && a.domain == hosting_url)
4577            })
4578            .map(|topic| AdvertisementData {
4579                protocol: Protocol::Ship,
4580                topic_or_service_name: topic.clone(),
4581            })
4582            .collect();
4583
4584        let slaps_to_create: Vec<AdvertisementData> = configured_services
4585            .iter()
4586            .filter(|service| {
4587                !current_slap
4588                    .iter()
4589                    .any(|a| a.topic_or_service == **service && a.domain == hosting_url)
4590            })
4591            .map(|service| AdvertisementData {
4592                protocol: Protocol::Slap,
4593                topic_or_service_name: service.clone(),
4594            })
4595            .collect();
4596
4597        // Determine what to revoke
4598        let ships_to_revoke: Vec<Advertisement> = current_ship
4599            .into_iter()
4600            .filter(|a| !configured_topics.contains(&a.topic_or_service))
4601            .collect();
4602
4603        let slaps_to_revoke: Vec<Advertisement> = current_slap
4604            .into_iter()
4605            .filter(|a| !configured_services.contains(&a.topic_or_service))
4606            .collect();
4607
4608        // Create new advertisements
4609        let mut all_to_create = ships_to_create;
4610        all_to_create.extend(slaps_to_create);
4611        report.to_create = all_to_create.len();
4612        if !all_to_create.is_empty() {
4613            match advertiser.create_advertisements(&all_to_create).await {
4614                Ok(tagged_beef) => match self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4615                    Ok(steak) => {
4616                        for (topic, instructions) in &steak {
4617                            report
4618                                .admitted
4619                                .insert(topic.clone(), instructions.outputs_to_admit.len());
4620                        }
4621                    }
4622                    Err(e) => {
4623                        error!("Failed to submit new advertisements: {e}");
4624                        report.submit_error = Some(e.to_string());
4625                    }
4626                },
4627                Err(e) => {
4628                    error!("Failed to create advertisements: {e}");
4629                    report.create_error = Some(e.to_string());
4630                }
4631            }
4632        }
4633
4634        // Revoke stale advertisements
4635        let mut all_to_revoke = ships_to_revoke;
4636        all_to_revoke.extend(slaps_to_revoke);
4637        report.to_revoke = all_to_revoke.len();
4638        if !all_to_revoke.is_empty() {
4639            match advertiser.revoke_advertisements(&all_to_revoke).await {
4640                // An advertiser that declines to build a revocation (e.g. the
4641                // CF advertiser's documented v1 no-op) returns an EMPTY
4642                // TaggedBEEF — submitting it would only manufacture a parse
4643                // error. Skip, but say so in the report (#320 L2): the stale
4644                // ads stay on-chain until they age out.
4645                Ok(tagged_beef) if tagged_beef.topics.is_empty() => {
4646                    report.revoke_skipped = all_to_revoke.len();
4647                }
4648                Ok(tagged_beef) => {
4649                    if let Err(e) = self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4650                        error!("Failed to submit revocation: {e}");
4651                        report.revoke_submit_error = Some(e.to_string());
4652                    }
4653                }
4654                Err(e) => {
4655                    error!("Failed to revoke advertisements: {e}");
4656                    report.revoke_error = Some(e.to_string());
4657                }
4658            }
4659        }
4660
4661        Ok(report)
4662    }
4663
4664    // ========================================================================
4665    // Deep deletion
4666    // ========================================================================
4667
4668    /// Recursively delete a UTXO and all stale consumed inputs.
4669    ///
4670    /// Only deletes if the output has no remaining consumers (consumedBy is empty).
4671    /// Then recurses into outputsConsumed, removing the deleted output from their
4672    /// consumedBy lists and deleting them if they become unreferenced.
4673    #[allow(dead_code)] // Used by submit() once BEEF parsing is wired up
4674    ///
4675    /// `bound` is a finalize submit's [`CallBound`] over each call here
4676    /// (bsv-low #559); `None` everywhere else.
4677    fn delete_utxo_deep<'a>(
4678        &'a self,
4679        output: &'a Output,
4680        bound: Option<&'a CallBound>,
4681    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), EngineError>> + 'a>> {
4682        Box::pin(async move {
4683            // Only delete if nothing consumes this output
4684            if output.consumed_by.is_empty() {
4685                stored(
4686                    bound,
4687                    self.storage
4688                        .delete_output(&output.txid, output.output_index, &output.topic),
4689                )
4690                .await
4691                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4692
4693                // Notify lookup services. A hook error is dropped, not
4694                // reported (parity: the reference catches it, best effort).
4695                for ls in self.lookup_services.values() {
4696                    let _ = hooked(
4697                        bound,
4698                        ls.output_no_longer_retained_in_history(
4699                            &output.txid,
4700                            output.output_index,
4701                            &output.topic,
4702                        ),
4703                    )
4704                    .await;
4705                }
4706            }
4707
4708            // Recurse into consumed outputs
4709            for consumed in &output.outputs_consumed {
4710                if let Ok(Some(mut stale_output)) = stored(
4711                    bound,
4712                    self.storage.find_output(
4713                        &consumed.txid,
4714                        consumed.output_index,
4715                        Some(&output.topic),
4716                        None,
4717                        false,
4718                    ),
4719                )
4720                .await
4721                {
4722                    // Remove the deleted output from consumedBy
4723                    stale_output.consumed_by.retain(|c| {
4724                        !(c.txid == output.txid && c.output_index == output.output_index)
4725                    });
4726
4727                    stored(
4728                        bound,
4729                        self.storage.update_consumed_by(
4730                            &consumed.txid,
4731                            consumed.output_index,
4732                            &output.topic,
4733                            &stale_output.consumed_by,
4734                        ),
4735                    )
4736                    .await
4737                    .map_err(|e| EngineError::StorageError(e.to_string()))?;
4738
4739                    // Recurse
4740                    let _ = self.delete_utxo_deep(&stale_output, bound).await;
4741                }
4742            }
4743
4744            Ok(())
4745        }) // Box::pin
4746    }
4747
4748    // ========================================================================
4749    // Eviction
4750    // ========================================================================
4751
4752    /// Evict a specific outpoint from the overlay.
4753    ///
4754    /// 1. Deletes the output from storage for the given topic.
4755    /// 2. Notifies all lookup services via `output_evicted()`.
4756    ///
4757    /// If no topic is specified, finds all outputs for the txid and evicts the
4758    /// matching outputIndex across every topic.
4759    ///
4760    /// Matches TS OverlayExpress `/admin/evictOutpoint` (lines 974-1004).
4761    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): RE-NOTIFY every lookup service
4762    /// of the outputs the engine ALREADY admitted for (`txid`, `topic`).
4763    ///
4764    /// Phase-1 dedup means a (txid, topic) once recorded in
4765    /// `applied_transactions` never re-enters through `/submit`, so a lookup
4766    /// service that missed — or mis-parsed — its admit notification could
4767    /// never be told again (the loop-2 subject trap: the pot index's admit
4768    /// hook shape-checked a wire-last hop and wrote no record while the engine
4769    /// had admitted the pot). This replays `output_admitted_by_topic` from the
4770    /// engine's OWN stored outputs (with their BEEF) and the subject-NAMED body
4771    /// exactly as `submit_with_report` sends it. It validates nothing anew
4772    /// (the topic manager judged these outputs at admit), writes none of the
4773    /// engine's tables, and is idempotent for every lookup service whose
4774    /// writes are (the pot index's `store_record` is insert-if-absent for the
4775    /// spend fields — re-admission never regresses spend state).
4776    /// `outputs == 0` means the engine never admitted this txid on this topic:
4777    /// nothing to re-notify — the caller re-SUBMITS instead.
4778    pub async fn renotify_admitted(
4779        &self,
4780        txid: &str,
4781        topic: &str,
4782    ) -> Result<RenotifyReport, EngineError> {
4783        let txid = txid.to_ascii_lowercase();
4784        let outputs = self
4785            .storage
4786            .find_outputs_for_transaction(&txid, true)
4787            .await
4788            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4789        let mut report = RenotifyReport::default();
4790        for output in outputs.iter().filter(|o| o.topic == topic) {
4791            report.outputs += 1;
4792            report.vouts.push(output.output_index);
4793            // the body every WholeTx service receives NAMES the subject (BRC-95
4794            // atomic prefix) — the same rule as the live submit path
4795            let subject_named: Option<Vec<u8>> = output.beef.as_ref().map(|beef| {
4796                beef_limits::parse_beef(beef, &beef_limits::STORED_BEEF_LIMITS)
4797                    .ok()
4798                    .and_then(|mut b| b.to_binary_atomic(&txid).ok())
4799                    .unwrap_or_else(|| beef.clone())
4800            });
4801            for (name, ls) in &self.lookup_services {
4802                let payload = match ls.admission_mode() {
4803                    AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
4804                        txid: txid.clone(),
4805                        output_index: output.output_index,
4806                        topic: topic.to_string(),
4807                        satoshis: output.satoshis,
4808                        locking_script: output.output_script.clone(),
4809                        off_chain_values: None,
4810                    },
4811                    AdmissionMode::WholeTx => {
4812                        let Some(atomic_beef) = subject_named.as_ref() else {
4813                            report.faults.push(format!(
4814                                "{name}: {txid}:{} is stored without its BEEF — a WholeTx service cannot be re-notified",
4815                                output.output_index
4816                            ));
4817                            continue;
4818                        };
4819                        OutputAdmittedByTopic::WholeTx {
4820                            atomic_beef: atomic_beef.clone(),
4821                            output_index: output.output_index,
4822                            topic: topic.to_string(),
4823                            off_chain_values: None,
4824                        }
4825                    }
4826                };
4827                match ls.output_admitted_by_topic(&payload).await {
4828                    Ok(()) => report.notified += 1,
4829                    Err(e) => report.faults.push(format!("{name}: {e}")),
4830                }
4831            }
4832        }
4833        Ok(report)
4834    }
4835
4836    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): FORGET a PHANTOM applied row —
4837    /// (txid, topic) recorded in `applied_transactions` while the topic holds
4838    /// NO output of that txid (the engine judged the tx and admitted nothing:
4839    /// the loop-2 JOIN `f9e85aab…` under the pre-D5 subject rule). Such a row
4840    /// does nothing but block a correct re-submit through the Phase-1 dedup
4841    /// (`/admin/readmit` on beta answered `deduped: ["tm_pot_beta"]` and the
4842    /// pot stayed `known:false`). A row with ANY stored output on the topic is
4843    /// a real admission and is never touched (returns false, as does a missing
4844    /// row). The forgotten bytes are then re-VALIDATED by the topic manager
4845    /// like any first submit — nothing is admitted here.
4846    pub async fn forget_phantom_applied(
4847        &self,
4848        txid: &str,
4849        topic: &str,
4850    ) -> Result<bool, EngineError> {
4851        let txid = txid.to_ascii_lowercase();
4852        let outputs = self
4853            .storage
4854            .find_outputs_for_transaction(&txid, false)
4855            .await
4856            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4857        if outputs.iter().any(|o| o.topic == topic) {
4858            return Ok(false);
4859        }
4860        let rec = AppliedTransaction {
4861            txid: txid.clone(),
4862            topic: topic.to_string(),
4863        };
4864        let exists = self
4865            .storage
4866            .does_applied_transaction_exist(&rec)
4867            .await
4868            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4869        if !exists {
4870            return Ok(false);
4871        }
4872        self.storage
4873            .delete_applied_transaction(&rec)
4874            .await
4875            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4876        Ok(true)
4877    }
4878
4879    pub async fn evict_output(
4880        &self,
4881        txid: &str,
4882        output_index: u32,
4883        topic: Option<&str>,
4884    ) -> Result<(), EngineError> {
4885        if let Some(topic) = topic {
4886            // Delete from storage
4887            self.storage
4888                .delete_output(txid, output_index, topic)
4889                .await
4890                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4891
4892            // Notify all lookup services
4893            for ls in self.lookup_services.values() {
4894                let _ = ls.output_evicted(txid, output_index).await;
4895            }
4896        } else {
4897            // No topic specified — find all outputs for this txid and evict matching ones
4898            let outputs = self
4899                .storage
4900                .find_outputs_for_transaction(txid, false)
4901                .await
4902                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4903
4904            for output in &outputs {
4905                if output.output_index == output_index {
4906                    self.storage
4907                        .delete_output(txid, output_index, &output.topic)
4908                        .await
4909                        .map_err(|e| EngineError::StorageError(e.to_string()))?;
4910                }
4911            }
4912
4913            // Notify all lookup services once
4914            for ls in self.lookup_services.values() {
4915                let _ = ls.output_evicted(txid, output_index).await;
4916            }
4917        }
4918
4919        Ok(())
4920    }
4921
4922    // ========================================================================
4923    // GASP sync
4924    // ========================================================================
4925
4926    /// Start GASP synchronization with peers for all configured topics.
4927    ///
4928    /// For each topic in `sync_configuration`:
4929    /// - `SyncTarget::Ship`: unions local SHIP records, network discovery via
4930    ///   configured SLAP trackers, and the topic's bootstrap peers.
4931    /// - `SyncTarget::Peers(urls)` — uses the provided peer URLs directly.
4932    /// - `SyncTarget::Disabled` — skips the topic.
4933    ///
4934    /// Filters out our own `hosting_url` to avoid self-sync.
4935    ///
4936    /// Returns a `GASPSyncResult` summarizing discovered peers per topic.
4937    ///
4938    /// Discovers peers for each configured topic and runs GASP sync with each.
4939    ///
4940    /// When a `GASPRemoteFactory` is set (via `set_gasp_remote_factory`), this
4941    /// method creates `OverlayGASPStorage` + `GASPRemote` instances per
4942    /// (topic, peer) pair and runs `GASPSync::sync()` to exchange UTXOs.
4943    ///
4944    /// Without a factory, peer discovery still runs but no actual sync occurs
4945    /// (backwards-compatible with the previous discovery-only behavior).
4946    pub async fn start_gasp_sync(&self) -> Result<GASPSyncResult, EngineError> {
4947        self.start_gasp_sync_filtered(&crate::sync_load::SyncFilter::default())
4948            .await
4949    }
4950
4951    /// Run selected topic/peer pairs; explicit peers bypass network discovery.
4952    pub async fn start_gasp_sync_filtered(
4953        &self,
4954        filter: &crate::sync_load::SyncFilter,
4955    ) -> Result<GASPSyncResult, EngineError> {
4956        let meter = crate::meter::LoadMeter::default();
4957        self.start_gasp_sync_metered(filter, &meter).await
4958    }
4959
4960    /// A caller holding the meter can read partial costs after cancellation.
4961    pub async fn start_gasp_sync_metered(
4962        &self,
4963        filter: &crate::sync_load::SyncFilter,
4964        meter: &crate::meter::LoadMeter,
4965    ) -> Result<GASPSyncResult, EngineError> {
4966        let run = self.gasp_run();
4967        meter.scope(self.run_gasp_sync(filter, &run, None)).await
4968    }
4969
4970    fn gasp_run(&self) -> crate::backfill::Run {
4971        crate::backfill::Run::new(
4972            self.gasp_ancestor_fetcher
4973                .as_ref()
4974                .or(self.ancestor_fetcher.as_ref())
4975                .cloned(),
4976        )
4977    }
4978
4979    /// Walk one selected pair, checkpointing on wall, request or progress bounds.
4980    /// The continuation never supplies a cursor to storage or to the remote.
4981    pub async fn start_gasp_backfill_metered(
4982        &self,
4983        request: &crate::backfill::SyncRequest,
4984        meter: &crate::meter::LoadMeter,
4985    ) -> Result<crate::backfill::BackfillResult, EngineError> {
4986        use crate::backfill::{
4987            BackfillResult, Continuation, Control, BACKFILL_MAX_SLICES, BACKFILL_REQUEST_MS,
4988        };
4989        request.validate().map_err(EngineError::InvalidQuery)?;
4990        let (topic, peer) = request.pair().map_err(EngineError::InvalidQuery)?;
4991        if request.until.is_none() || self.peer_sync_budget.is_none() || self.graph_budget.is_none()
4992        {
4993            return Err(EngineError::InvalidQuery(
4994                "backfill requires until: tip and peer/graph bounds".into(),
4995            ));
4996        }
4997        if self.gasp_remote_factory.is_none()
4998            || !matches!(
4999                self.config.sync_configuration.get(topic),
5000                Some(SyncTarget::Ship | SyncTarget::Peers(_))
5001            )
5002        {
5003            return Err(EngineError::InvalidQuery(
5004                "backfill topic is not enabled for sync".into(),
5005            ));
5006        }
5007        meter
5008            .scope(async {
5009                let initial = self.storage.get_last_interaction(peer, topic).await?;
5010                if let Some(token) = &request.continuation {
5011                    let token = Continuation::decode(token).map_err(EngineError::InvalidQuery)?;
5012                    if token.cursor > initial {
5013                        return Err(EngineError::InvalidQuery(
5014                            "continuation is ahead of the durable cursor".into(),
5015                        ));
5016                    }
5017                }
5018                let filter = request.filter();
5019                let run = self.gasp_run();
5020                let control = Control {
5021                    meter: meter.clone(),
5022                    budget_ms: request.budget_ms.unwrap_or(0).min(BACKFILL_REQUEST_MS),
5023                };
5024                let mut result = BackfillResult {
5025                    sync: GASPSyncResult {
5026                        filter: filter.clone(),
5027                        ..GASPSyncResult::default()
5028                    },
5029                    slices: 0,
5030                    reached_tip: false,
5031                    stop_reason: "slice_allowance".into(),
5032                    continuation: None,
5033                };
5034                let mut previous_cursor = initial;
5035                for _ in 0..BACKFILL_MAX_SLICES {
5036                    if let Some(reason) = control.stop_reason() {
5037                        result.stop_reason = reason.into();
5038                        break;
5039                    }
5040                    let slice = self.run_gasp_sync(&filter, &run, Some(&control)).await?;
5041                    let topic_result = slice.topics_synced.get(topic).ok_or_else(|| {
5042                        EngineError::InvalidQuery("backfill selection matched no topic".into())
5043                    })?;
5044                    if !topic_result.peers.iter().any(|p| p == peer) {
5045                        return Err(EngineError::InvalidQuery(
5046                            "backfill selection matched no peer".into(),
5047                        ));
5048                    }
5049                    let summary = topic_result.peer_results.get(peer).cloned();
5050                    let waiting = !topic_result.peers_skipped.is_empty();
5051                    result.sync.append_slice(slice);
5052                    if let Some(summary) = summary {
5053                        result.slices += summary.slices;
5054                        if summary.reached_tip {
5055                            result.reached_tip = true;
5056                            result.stop_reason = "tip".into();
5057                            break;
5058                        }
5059                        if let Some(reason) = control.stop_reason() {
5060                            result.stop_reason = reason.into();
5061                            break;
5062                        }
5063                        if summary.cursor <= previous_cursor {
5064                            result.stop_reason = "no_progress".into();
5065                            break;
5066                        }
5067                        previous_cursor = summary.cursor;
5068                    } else {
5069                        result.stop_reason =
5070                            if waiting { "peer_wait" } else { "no_progress" }.into();
5071                        break;
5072                    }
5073                }
5074                let cursor = self.storage.get_last_interaction(peer, topic).await?;
5075                if !result.reached_tip {
5076                    result.continuation = Some(Continuation::new(topic, peer, cursor).encode());
5077                }
5078                result.sync.cost = meter.snapshot();
5079                Ok(result)
5080            })
5081            .await
5082    }
5083
5084    async fn run_gasp_sync(
5085        &self,
5086        filter: &crate::sync_load::SyncFilter,
5087        run: &crate::backfill::Run,
5088        control: Option<&crate::backfill::Control>,
5089    ) -> Result<GASPSyncResult, EngineError> {
5090        use crate::gasp::{GASPSync, ENGINE_GASP_SYNC_PAGE_LIMIT};
5091        use crate::gasp_overlay::OverlayGASPStorage;
5092
5093        if self.config.sync_configuration.is_empty() {
5094            info!("[GASP SYNC] No sync configuration — nothing to sync");
5095            return Ok(GASPSyncResult {
5096                cost: crate::meter::SyncCost::default(),
5097                topics_synced: HashMap::new(),
5098                filter: filter.clone(),
5099                topics_filtered: Vec::new(),
5100            });
5101        }
5102
5103        let sync_fetcher = &run.fetcher;
5104        let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
5105        // Resolve SLAP providers once per invocation, sharing their host cap
5106        // across all SHIP topics. Every topic still queries each lookup host.
5107        let mut ship_lookup_hosts = None;
5108        let mut topic_order = sync_order(&self.config.sync_configuration);
5109        // Explicit bootstrap-backed SHIP topics run ahead of large tracker
5110        // imports. Stable sorting preserves the existing order elsewhere.
5111        topic_order.sort_by_key(|(topic, target)| {
5112            !(matches!(target, SyncTarget::Ship)
5113                && self
5114                    .ship_bootstrap_peers
5115                    .get(topic.as_str())
5116                    .is_some_and(|peers| !peers.is_empty()))
5117        });
5118
5119        let mut topics_filtered = Vec::new();
5120        for (topic, target) in topic_order {
5121            if filter
5122                .topics
5123                .as_ref()
5124                .is_some_and(|names| !names.contains(topic))
5125            {
5126                topics_filtered.push(topic.clone());
5127                continue;
5128            }
5129            let cost_before = crate::meter::snapshot();
5130            // A CONFIGURED topic's peers are reserved part of a storage's
5131            // deferred-graph ceiling (bsv-low #555, the delta-2 fold's D2-M2).
5132            let configured_peers = matches!(target, SyncTarget::Peers(_));
5133            let (peers, sync_type) = match target {
5134                SyncTarget::Disabled => {
5135                    info!("[GASP SYNC] Topic {topic} is disabled — skipping");
5136                    continue;
5137                }
5138                SyncTarget::Ship => {
5139                    info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
5140                    let peers = if let Some(peers) = &filter.peers {
5141                        self.ship_peers_excluding_self(peers.clone())
5142                    } else {
5143                        self.discover_ship_peers(topic, &mut ship_lookup_hosts)
5144                            .await
5145                    };
5146                    info!(
5147                        "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
5148                        peers.len()
5149                    );
5150                    (peers, "ship".to_string())
5151                }
5152                SyncTarget::Peers(peer_urls) => {
5153                    info!(
5154                        "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
5155                        peer_urls.len()
5156                    );
5157                    let peers: Vec<String> = peer_urls
5158                        .iter()
5159                        .filter(|url| {
5160                            if let Some(ref our_url) = self.config.hosting_url {
5161                                url.trim_end_matches('/') != our_url.trim_end_matches('/')
5162                            } else {
5163                                true
5164                            }
5165                        })
5166                        .cloned()
5167                        .collect();
5168
5169                    info!(
5170                        "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
5171                        peers.len()
5172                    );
5173                    (peers, "peers".to_string())
5174                }
5175            };
5176
5177            let (peers, peers_filtered): (Vec<_>, Vec<_>) = peers.into_iter().partition(|peer| {
5178                filter
5179                    .peers
5180                    .as_ref()
5181                    .is_none_or(|names| names.contains(peer))
5182            });
5183            let mut peers_skipped = Vec::new();
5184            let advertisements = self.peer_advertisement_versions(topic).await;
5185            let mut errors = Vec::new();
5186            let mut pruned_inputs: u64 = 0;
5187            let mut discarded_graphs: u64 = 0;
5188            let mut expired_refused: u64 = 0;
5189            let mut roots_proven = 0;
5190            let mut roots_unproven = 0;
5191            let mut cursor_held_by = Vec::new();
5192            let expired = run.expired(topic);
5193            let mut peer_results = HashMap::new();
5194            let mut anchor_walk_held_graphs: u64 = 0;
5195            let mut finalized_graphs: u64 = 0;
5196            let mut deadline_dropped_graphs: u64 = 0;
5197            let mut cursor_moves: Vec<CursorMove> = Vec::new();
5198            let mut deferred_graphs: u64 = 0;
5199            let mut resumed_graphs: u64 = 0;
5200            let mut converged_graphs: u64 = 0;
5201            let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
5202            let mut stalled_graphs: u64 = 0;
5203            let mut held_back_graphs: u64 = 0;
5204            let mut dead_graphs = 0;
5205            let mut expired_swept = 0;
5206            let mut unanchorable_graphs: u64 = 0;
5207            let mut unanchorable_cleared: u64 = 0;
5208            let mut strikes_refused: u64 = 0;
5209            let mut backed_off_graphs: u64 = 0;
5210            let mut waiting_graphs: u64 = 0;
5211            let mut idle_counted: u64 = 0;
5212            let mut root_bounds = Vec::new();
5213            let mut faulted_roots: Vec<crate::gasp::FaultedRoot> = Vec::new();
5214
5215            // If we have a remote factory, actually run GASP sync
5216            if let Some(ref factory) = self.gasp_remote_factory {
5217                for peer_url in &peers {
5218                    // bsv-low#302 quarantine gate: a peer at
5219                    // PEER_QUARANTINE_THRESHOLD consecutive failures is
5220                    // SKIPPED (no attempt recorded — its last-attempt age
5221                    // keeps growing) until the re-probe window opens. A
5222                    // health-read FAULT treats the peer as healthy: broken
5223                    // bookkeeping must never silence a live peer.
5224                    //
5225                    // The peer's health (its quarantine, its yieldless
5226                    // streak) is keyed on its NORMALIZED ORIGIN (bsv-low
5227                    // #555, the delta-2 fold's D2-M2): keyed on the URL, one
5228                    // server was a fresh peer for every spelling it
5229                    // advertised.
5230                    let health_key = crate::gasp::peer_origin(peer_url);
5231                    let health = self
5232                        .storage
5233                        .get_peer_sync_health(&health_key, topic)
5234                        .await
5235                        .unwrap_or_default();
5236                    if crate::gasp::peer_sync_quarantined(&health) {
5237                        peers_skipped.push(crate::sync_load::SkippedPeer {
5238                            peer: peer_url.clone(),
5239                            reason: "quarantine".into(),
5240                            retry_after_secs: crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5241                                .saturating_sub(health.secs_since_last_attempt.unwrap_or(0)),
5242                        });
5243                        warn!(
5244                            "[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)",
5245                            health.consecutive_failures,
5246                            health.secs_since_last_attempt,
5247                            crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5248                        );
5249                        continue;
5250                    }
5251
5252                    let cadence = self
5253                        .storage
5254                        .get_peer_cadence(&health_key, topic)
5255                        .await
5256                        .unwrap_or_default();
5257                    let advertisement =
5258                        advertisements.get(&health_key).cloned().unwrap_or_default();
5259                    let reset_cadence = filter.explicit() || cadence.advertisement != advertisement;
5260                    if !reset_cadence && cadence.retry_after_secs > 0 {
5261                        peers_skipped.push(crate::sync_load::SkippedPeer {
5262                            peer: peer_url.clone(),
5263                            reason: "no_yield".into(),
5264                            retry_after_secs: cadence.retry_after_secs,
5265                        });
5266                        continue;
5267                    }
5268
5269                    let peer_cost_before = crate::meter::snapshot();
5270                    info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
5271
5272                    // Get last interaction score for this (peer, topic) pair
5273                    let last_interaction = self
5274                        .storage
5275                        .get_last_interaction(peer_url, topic)
5276                        .await
5277                        .unwrap_or(0);
5278
5279                    // Create shared sink for finalized graphs
5280                    let sink = crate::gasp_overlay::new_finalized_graph_sink();
5281
5282                    // OPT-IN ancestor hydration: only when a fetcher is
5283                    // configured do we enable chain-backed ancestor fallback +
5284                    // strict-BEEF finalize. Default (None) = byte-identical to
5285                    // today (tolerant BEEF, peer errors abandon the graph;
5286                    // the one exception is the D8 prune of a manager-named
5287                    // input the peer answers it does not hold, which exists
5288                    // only WITHOUT a fetcher, see
5289                    // `GASPSync::process_incoming_node`).
5290                    let hydration_on = sync_fetcher.is_some();
5291
5292                    // Create storage adapter and remote
5293                    let mut gasp_storage =
5294                        OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5295                            .with_peer(peer_url.as_str())
5296                            .with_configured_peer(configured_peers)
5297                            .with_strict_beef(hydration_on)
5298                            .with_script_verification(self.verify_scripts)
5299                            .with_walk_budget(self.walk_budget);
5300                    if let Some(manager) = self.managers.get(topic) {
5301                        gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5302                    }
5303                    // bsv-low #551: the anchor check verifies each graph's
5304                    // root against THIS engine's tracker, with this engine's
5305                    // script switch, before finalize. No tracker is the
5306                    // reference's 'scripts only'.
5307                    if let Some(tracker) = self.chain_tracker.as_deref() {
5308                        gasp_storage = gasp_storage.with_chain_tracker(tracker);
5309                    }
5310                    let gasp_remote = Box::new(crate::load_remote::MeteredRemote(
5311                        factory.create_remote(peer_url, topic),
5312                    ));
5313
5314                    let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5315                    // Graphs of this peer handed to submit (bsv-low #552).
5316                    let peer_finalized = std::cell::Cell::new(0u64);
5317                    let mut sync = GASPSync::new(
5318                        Box::new(gasp_storage),
5319                        gasp_remote,
5320                        last_interaction,
5321                        &log_prefix,
5322                        true, // unidirectional — overlay GASP is pull-only (submitNode throws); matches TS Engine.startGASPSync
5323                    )
5324                    .with_ancestor_fetcher(sync_fetcher.clone())
5325                    .with_expired_outpoints(std::rc::Rc::clone(&expired));
5326                    if let Some(control) = control {
5327                        let control = control.clone();
5328                        sync = sync.with_page_slice(std::rc::Rc::new(move || {
5329                            control.stop_reason().is_some()
5330                        }));
5331                    }
5332                    // bsv-low #555: a graph past its own budget is deferred,
5333                    // not dropped with the sync.
5334                    if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5335                        let (sleep, budget_ms) =
5336                            (sleep.clone(), (*budget_ms).min(crate::meter::ROOT_MAX_MS));
5337                        let half_sleep = sleep.clone();
5338                        let share_sleep = sleep.clone();
5339                        sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5340                            max_calls: *max_calls,
5341                            max_bytes_fetched: self.graph_budget_limbs.0,
5342                            max_nodes: self.graph_budget_limbs.1,
5343                            deadline: Box::new(move || sleep(budget_ms)),
5344                            half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5345                        });
5346                        // Engine 0.1.11: the resumes take a named share of
5347                        // the per-peer slice, the rest is the page's.
5348                        if let Some((_, peer_ms)) = &self.peer_sync_budget {
5349                            let share_ms = crate::gasp::resume_share_ms(*peer_ms, budget_ms);
5350                            sync = sync.with_resume_share(Box::new(move || share_sleep(share_ms)));
5351                        }
5352                    }
5353                    // bsv-low #552: under a budget each graph is submitted as
5354                    // it finalizes, so the deadline cannot take it back. With
5355                    // no budget nothing can drop the sync, and the graphs are
5356                    // submitted after it as they always were.
5357                    if self.peer_sync_budget.is_some() {
5358                        sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5359                            engine: self,
5360                            sink: sink.clone(),
5361                            peer_url,
5362                            topic,
5363                            submitted: &peer_finalized,
5364                        }));
5365                    }
5366
5367                    // bsv-low#302: bound THIS peer's sync to the configured
5368                    // budget. `None` from the race = the budget won and the
5369                    // sync future was dropped mid-flight. What it finalized
5370                    // before that is already submitted (the hook above); the
5371                    // cursor then advances to `completed_cursor` only, see
5372                    // the deadline arm below. The race is GUARDED: it never
5373                    // drops the sync inside one transaction's finalize
5374                    // submit (the lens fold's HIGH-1), it waits for that
5375                    // transaction and the hook stops at the boundary.
5376                    let sync_outcome = match &self.peer_sync_budget {
5377                        Some((sleep, budget_ms)) => {
5378                            let budget_ms =
5379                                control.map_or(*budget_ms, |c| (*budget_ms).min(c.remaining_ms()));
5380                            let mut deadline = sleep(budget_ms);
5381                            let deadline = std::future::poll_fn(|cx| {
5382                                if control.is_some_and(|c| c.stop_reason().is_some())
5383                                    || std::future::Future::poll(deadline.as_mut(), cx).is_ready()
5384                                {
5385                                    std::task::Poll::Ready(())
5386                                } else {
5387                                    std::task::Poll::Pending
5388                                }
5389                            });
5390                            crate::gasp::race_or_deadline_guarded(
5391                                sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5392                                deadline,
5393                                &self.finalize_gate,
5394                            )
5395                            .await
5396                        }
5397                        None => Some(sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)).await),
5398                    };
5399
5400                    let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5401                    // D8 decoy rule: branches pruned in this peer's walk. Not
5402                    // an error and not a failed UTXO, so it is counted apart
5403                    // from `errors` and never touches the outcome or cursor.
5404                    // Summed over peers: one decoy seen through two peers
5405                    // counts twice.
5406                    pruned_inputs += sync.pruned_inputs();
5407                    expired_refused += sync.expired_refused();
5408                    roots_proven += sync.roots_proven();
5409                    roots_unproven += sync.roots_unproven();
5410                    // bsv-low #551: graphs the anchor check refused. Counted
5411                    // apart from `errors` for the same reason as a prune: a
5412                    // refused graph is not a failed sync (the reference
5413                    // discards it and carries on).
5414                    discarded_graphs += sync.discarded_graphs();
5415                    anchor_walk_held_graphs += sync.anchor_walk_held();
5416                    // Whether the sync ran (to its end, or to the deadline)
5417                    // and whether it moved the cursor (bsv-low #555, the
5418                    // delta fold's D-M2).
5419                    let ran = !matches!(sync_outcome, Some(Err(_)));
5420                    let mut advanced_cursor = false;
5421                    match sync_outcome {
5422                        None => {
5423                            let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5424                            let msg = format!(
5425                                "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5426                            );
5427                            warn!("[GASP SYNC] {msg}");
5428                            errors.push(msg);
5429
5430                            // bsv-low #552: the work COMPLETED before the
5431                            // deadline is kept. Its graphs are already
5432                            // submitted (the hook); the cursor moves past the
5433                            // UTXOs that were completed and stops below the
5434                            // one in flight, whose graph is lost whole and
5435                            // walked again next tick. A graph the deadline
5436                            // met while it was being SUBMITTED is not "in
5437                            // flight" here: it left a prefix of whole
5438                            // transactions, is counted finalized, and its
5439                            // UTXO is below the cursor too.
5440                            let in_flight = u64::from(sync.graph_in_flight());
5441                            deadline_dropped_graphs += in_flight;
5442                            // bsv-low #555: the walk in hand is saved, so the
5443                            // next tick resumes it instead of walking it again.
5444                            sync.defer_in_flight().await;
5445                            let completed = sync.completed_cursor();
5446                            if completed > last_interaction {
5447                                match self
5448                                    .storage
5449                                    .update_last_interaction(peer_url, topic, completed)
5450                                    .await
5451                                {
5452                                    Ok(()) => cursor_moves.push(CursorMove {
5453                                        peer: peer_url.clone(),
5454                                        from: last_interaction,
5455                                        to: completed,
5456                                    }),
5457                                    Err(e) => warn!(
5458                                        "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5459                                    ),
5460                                }
5461                            }
5462                            // A tick that finalized a graph or moved the
5463                            // cursor reached a live peer: not a failed
5464                            // attempt for the quarantine count. So did one
5465                            // whose walk PROGRESSED (bsv-low #555: it
5466                            // appended a node, or completed its graph). A
5467                            // deferral that fetched nothing is no progress
5468                            // (the lens fold's H1: a hung peer deferred with
5469                            // 0 nodes on every tick and was never
5470                            // quarantined).
5471                            //
5472                            // Engine 0.1.8: the deadline drop itself is not a
5473                            // failure of the peer. The per-peer deadline is
5474                            // OUR slice of the tick, set by our cadence and
5475                            // the Worker's limits; a peer that served every
5476                            // page and node we asked for until it fell due
5477                            // did not fail. What the slice did is the test:
5478                            // a sync that refused an expired root, retained
5479                            // a graph or resumed one with progress did work
5480                            // too (`GASPSync::did_work`).
5481                            advanced_cursor = completed > last_interaction;
5482                            outcome_success = peer_finalized.get() > 0
5483                                || (completed > last_interaction && sync.cursor_yielded())
5484                                || sync.deferral_stats().progressed > 0
5485                                || sync.did_work();
5486                            warn!(
5487                                "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5488                                peer_finalized.get(),
5489                                completed.max(last_interaction)
5490                            );
5491                        }
5492                        Some(Ok(())) => {
5493                            // Submit finalized graphs to the Engine (under a
5494                            // budget the hook already did, graph by graph,
5495                            // and the sink is empty here).
5496                            let landed = self
5497                                .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5498                                .await;
5499                            // bsv-low #555, the lens fold's H1: a sync that
5500                            // ran to its end with walks the per-graph budget
5501                            // cut having FETCHED NOTHING, and nothing else
5502                            // got done (no walk progressed, no graph
5503                            // finalized, the cursor did not move), reached
5504                            // a peer that does not answer: a FAILED attempt.
5505                            // Before #555 such a peer held the sync to the
5506                            // per-peer deadline, a failure; the per-graph
5507                            // budget had made it an `Ok` sync. A sync whose
5508                            // UTXOs failed in any other way is unchanged.
5509                            let stats = sync.deferral_stats();
5510                            if stats.stalled > 0
5511                                && stats.progressed == 0
5512                                && peer_finalized.get() == 0
5513                                && !sync.did_work()
5514                                && !(landed
5515                                    && sync.last_interaction > last_interaction
5516                                    && sync.cursor_yielded())
5517                            {
5518                                outcome_success = false;
5519                                let msg = format!(
5520                                    "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5521                                    stats.stalled
5522                                );
5523                                warn!("[GASP SYNC] {msg}");
5524                                errors.push(msg);
5525                            }
5526
5527                            // Advance the persisted cursor when it moved forward — i.e.
5528                            // the peer reported UTXOs at a higher score than our last
5529                            // `since` (the in-memory cursor only grows on SEEN scores,
5530                            // so `>` already excludes the empty-bootstrap case where no
5531                            // UTXOs were seen). Mirrors TS `if (gasp.lastInteraction >
5532                            // lastInteraction)` — NOT gated on a new submission: a sync
5533                            // that re-sees already-known UTXOs must still advance, else
5534                            // every cron re-scans the same range forever (the cursor
5535                            // stayed pinned at 0 in prod).
5536                            //
5537                            // NOT advanced when a finalize submit above did
5538                            // not land (the lens fold's MEDIUM-1): the sink
5539                            // does not say which UTXO that graph was, so the
5540                            // whole cursor stays and the next sync is served
5541                            // the range again (what did land is known and
5542                            // skipped). Under a budget the hook fails that
5543                            // one UTXO instead and the gap guard has already
5544                            // capped `sync.last_interaction` below it.
5545                            advanced_cursor = landed && sync.last_interaction > last_interaction;
5546                            if !landed {
5547                                warn!(
5548                                    "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5549                                );
5550                            } else if sync.last_interaction > last_interaction {
5551                                match self
5552                                    .storage
5553                                    .update_last_interaction(peer_url, topic, sync.last_interaction)
5554                                    .await
5555                                {
5556                                    Ok(()) => cursor_moves.push(CursorMove {
5557                                        peer: peer_url.clone(),
5558                                        from: last_interaction,
5559                                        to: sync.last_interaction,
5560                                    }),
5561                                    Err(e) => warn!(
5562                                        "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5563                                    ),
5564                                }
5565                            }
5566                            info!(
5567                                "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5568                                sync.last_interaction
5569                            );
5570                        }
5571                        Some(Err(e)) => {
5572                            let msg = format!("{peer_url}: {e}");
5573                            warn!("[GASP SYNC] Sync failed: {msg}");
5574                            errors.push(msg);
5575                        }
5576                    }
5577                    // Engine 0.1.8: work is not a failure. A sync that
5578                    // finalized a graph, moved the cursor past what it
5579                    // finished, refused an expired root not refused before,
5580                    // retained a graph or resumed one with progress is never
5581                    // a failed attempt (a peer is quarantined for being
5582                    // dead, never for our cadence), even if it then faulted.
5583                    //
5584                    // The YIELD that ends the yieldless streak (and alone
5585                    // lifts a quarantine, D4-L2) is the narrower set: a
5586                    // finalized graph, a moved cursor, a refusal. A held or
5587                    // resumed walk is the peer's word until its anchor is
5588                    // checked: counted as a yield, a peer that lists one
5589                    // fabricated root a tick, serves it and hangs on its
5590                    // input would never be quarantined (bsv-low #555's D-M2).
5591                    // Such syncs stay inside the yieldless allowance (12
5592                    // syncs and 3 h), which an honest deep graph converges in.
5593                    let yielded = peer_finalized.get() > 0
5594                        || (advanced_cursor && sync.cursor_yielded())
5595                        || sync.expired_refused() > 0;
5596                    if yielded || sync.did_work() {
5597                        outcome_success = true;
5598                    }
5599                    // bsv-low #555, the delta fold's D-M2: "progress" (a node
5600                    // appended) is the peer's word. A sync the peer served
5601                    // work that finalized no graph and moved no cursor is
5602                    // YIELDLESS; past PEER_YIELDLESS_SYNCS_ALLOWED of them in
5603                    // a row each is a FAILED attempt, so a peer that serves
5604                    // one fresh root a tick and hangs on its input is
5605                    // quarantined. A quiet sync (nothing served) leaves the
5606                    // count; a fault of the count is fail-safe (no failure).
5607                    // Only with a per-graph budget: without one nothing is
5608                    // deferred and #302's rule is unchanged.
5609                    if ran
5610                        && self.graph_budget.is_some()
5611                        && (yielded || sync.graphs_attempted() > 0)
5612                    {
5613                        match self
5614                            .storage
5615                            .record_peer_sync_yield(&health_key, topic, yielded)
5616                            .await
5617                        {
5618                            Ok(streak)
5619                                if !yielded
5620                                    && crate::gasp::yieldless_sync_failed(&streak)
5621                                    && outcome_success =>
5622                            {
5623                                outcome_success = false;
5624                                let msg = format!(
5625                                    "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5626                                    streak.yieldless_syncs,
5627                                    streak.secs_since_first.unwrap_or(0),
5628                                    crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5629                                    crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5630                                );
5631                                warn!("[GASP SYNC] {msg}");
5632                                errors.push(msg);
5633                            }
5634                            Ok(_) => {}
5635                            Err(e) => warn!(
5636                                "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5637                            ),
5638                        }
5639                    }
5640                    // Quiet success controls polling cadence, never peer health. Faults
5641                    // retain #302's attempt cadence; retained work keeps its resume slice.
5642                    let quiet = outcome_success
5643                        && !yielded
5644                        && !sync.did_work()
5645                        && sync.faulted_roots().is_empty()
5646                        && sync.deferral_stats().stalled == 0;
5647                    let quiet_syncs = if quiet && !filter.explicit() {
5648                        if reset_cadence {
5649                            1
5650                        } else {
5651                            cadence.quiet_syncs.saturating_add(1)
5652                        }
5653                    } else {
5654                        0
5655                    };
5656                    let next = crate::sync_load::PeerCadence {
5657                        quiet_syncs,
5658                        retry_after_secs: crate::sync_load::quiet_retry_secs(quiet_syncs),
5659                        advertisement,
5660                    };
5661                    if let Err(e) = self
5662                        .storage
5663                        .set_peer_cadence(&health_key, topic, &next)
5664                        .await
5665                    {
5666                        warn!("[GASP SYNC] failed to record cadence for {peer_url}/{topic}: {e}");
5667                    }
5668                    if !advanced_cursor {
5669                        if let Some(outpoint) = sync.cursor_held_by() {
5670                            cursor_held_by.push(outpoint);
5671                        }
5672                    }
5673                    finalized_graphs += peer_finalized.get();
5674                    let deferral = sync.deferral_stats();
5675                    root_bounds.extend(deferral.root_bounds.iter().cloned().map(|mut bound| {
5676                        bound.peer.clone_from(peer_url);
5677                        bound
5678                    }));
5679                    deferred_graphs += deferral.deferred;
5680                    resumed_graphs += deferral.resumed;
5681                    converged_graphs += deferral.converged;
5682                    stalled_graphs += deferral.stalled;
5683                    held_back_graphs += deferral.held_back;
5684                    backed_off_graphs += deferral.backed_off;
5685                    waiting_graphs += deferral.waiting;
5686                    idle_counted += deferral.idle_counted;
5687                    dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5688                        peer: peer_url.clone(),
5689                        outpoint: d.outpoint,
5690                        reason: d.reason.as_str().to_string(),
5691                    }));
5692                    // Engine 0.1.9: the roots dropped, each with why.
5693                    dead_graphs += sync.dead_recorded();
5694                    expired_swept += sync.expired_swept();
5695                    unanchorable_graphs += sync.unanchorable_recorded();
5696                    unanchorable_cleared += sync.unanchorable_cleared();
5697                    // Engine 0.1.10: a strike not written is a fact.
5698                    strikes_refused += sync.strikes_refused();
5699                    faulted_roots.extend(sync.faulted_roots().into_iter().map(|mut f| {
5700                        f.peer.clone_from(peer_url);
5701                        f
5702                    }));
5703
5704                    // bsv-low#302: record the attempt outcome (success resets
5705                    // the consecutive-failure count; timeout/error increments
5706                    // it). Best-effort — a bookkeeping fault must never fail
5707                    // the sync pass itself.
5708                    //
5709                    // bsv-low #555, the delta-4 fold's D4-L2: with a
5710                    // per-graph budget, a peer that was at the quarantine
5711                    // threshold when this sync began is re-admitted only by
5712                    // a YIELD (a graph finalized, the cursor moved). A sync
5713                    // that did not fail and yielded nothing (an empty
5714                    // listing at its probe, a walk that only progressed) is
5715                    // NOT RECORDED: the failures stay, and so does the
5716                    // last-attempt stamp, so the peer is attended again on
5717                    // the next tick and its next failure re-arms the
5718                    // quarantine at once. Recorded as a success, a hostile
5719                    // peer's empty listing at each probe reset its failures
5720                    // and (the decay being for a peer that is not failed)
5721                    // its yieldless streak: 100 of 200 ticks. A health read
5722                    // that faulted read 0 failures: the old rule, fail-safe.
5723                    if outcome_success
5724                        && !yielded
5725                        && self.graph_budget.is_some()
5726                        && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5727                    {
5728                        info!(
5729                            "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5730                            health.consecutive_failures
5731                        );
5732                    } else if let Err(e) = self
5733                        .storage
5734                        .record_peer_sync_outcome(&health_key, topic, outcome_success)
5735                        .await
5736                    {
5737                        warn!(
5738                            "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5739                        );
5740                    }
5741                    let cursor = self.storage.get_last_interaction(peer_url, topic).await?;
5742                    let cost = crate::meter::snapshot().since(peer_cost_before);
5743                    peer_results.insert(
5744                        peer_url.clone(),
5745                        crate::backfill::PeerSyncResult {
5746                            slices: u64::from(sync.page_advanced() || cost.outputs_walked > 0),
5747                            pages: sync.pages(),
5748                            reached_tip: sync.reached_tip() && cursor >= sync.completed_cursor(),
5749                            cursor,
5750                            cost,
5751                        },
5752                    );
5753                }
5754            }
5755
5756            topics_synced.insert(
5757                topic.clone(),
5758                TopicSyncResult {
5759                    cost: crate::meter::snapshot().since(cost_before),
5760                    root_bounds,
5761                    peers_skipped,
5762                    peers_filtered,
5763                    roots_proven,
5764                    roots_unproven,
5765                    cursor_held_by,
5766                    expired_refused,
5767                    peers,
5768                    sync_type,
5769                    errors,
5770                    pruned_inputs,
5771                    discarded_graphs,
5772                    anchor_walk_held_graphs,
5773                    finalized_graphs,
5774                    deadline_dropped_graphs,
5775                    cursor_moves,
5776                    deferred_graphs,
5777                    resumed_graphs,
5778                    converged_graphs,
5779                    dropped_graphs,
5780                    stalled_graphs,
5781                    held_back_graphs,
5782                    backed_off_graphs,
5783                    waiting_graphs,
5784                    idle_counted,
5785                    peer_results,
5786                    dead_graphs,
5787                    expired_swept,
5788                    unanchorable_graphs,
5789                    unanchorable_cleared,
5790                    strikes_refused,
5791                    faulted_roots,
5792                },
5793            );
5794        }
5795
5796        info!(
5797            "[GASP SYNC] Peer discovery complete for {} topic(s)",
5798            topics_synced.len()
5799        );
5800
5801        let expired: u64 = topics_synced.values().map(|r| r.expired_refused).sum();
5802        if expired > 0 {
5803            info!("[GASP SYNC] gaspExpiredRefused={expired}");
5804        }
5805        let unanchorable: u64 = topics_synced.values().map(|r| r.unanchorable_graphs).sum();
5806        if unanchorable > 0 {
5807            info!("[GASP SYNC] gaspUnanchorable={unanchorable}");
5808        }
5809        let refused: u64 = topics_synced.values().map(|r| r.strikes_refused).sum();
5810        if refused > 0 {
5811            info!("[GASP SYNC] gaspStrikesRefused={refused}");
5812        }
5813        Ok(GASPSyncResult {
5814            cost: crate::meter::snapshot(),
5815            topics_synced,
5816            filter: filter.clone(),
5817            topics_filtered,
5818        })
5819    }
5820
5821    /// Drain `sink` and submit every finalized graph in it, ancestors first
5822    /// (`historical-tx-no-spv`: the anchor check already verified them).
5823    /// Each graph is added to `submitted` as its first submit starts. Returns
5824    /// whether EVERY transaction of every graph landed.
5825    ///
5826    /// Each graph here passed the anchor check: its root verified and the
5827    /// replay admitted it, so these submits rest on a checked premise and are
5828    /// not expected to fail. If one does not LAND, the REST OF THAT GRAPH is
5829    /// not submitted: the reference's `finalizeGraph` awaits each submit in
5830    /// order and its throw ends the loop (the BEEFs already submitted stay,
5831    /// it has no rollback either). A later BEEF would otherwise be judged
5832    /// without the coin its ancestor failed to leave, admit nothing and be
5833    /// recorded as applied, a dupe for good. "Landed" is read from the
5834    /// durability report, not from `Ok`: `submit` answers `Ok` when a storage
5835    /// write faulted and when the topic manager failed, and in both it
5836    /// records no applied row. A transaction landed when its topic is applied
5837    /// or was already applied (a dupe). Other graphs carry on, and the caller
5838    /// keeps the cursor from passing a graph that did not land.
5839    ///
5840    /// Each transaction's submit is one write section of the engine's gate
5841    /// ([`Engine::finalize_submit_gate`]) and the gate is asked between two:
5842    /// a deadline that is due stops the sequence there (this future is
5843    /// dropped at that await, between two whole transactions).
5844    async fn submit_finalized_graphs(
5845        &self,
5846        sink: &crate::gasp_overlay::FinalizedGraphSink,
5847        peer_url: &str,
5848        topic: &str,
5849        submitted: &std::cell::Cell<u64>,
5850    ) -> bool {
5851        let finalized: Vec<_> = sink
5852            .lock()
5853            .unwrap_or_else(std::sync::PoisonError::into_inner)
5854            .drain(..)
5855            .collect();
5856
5857        let mut all_landed = true;
5858        for graph in &finalized {
5859            for (position, beef_bytes) in graph.beefs.iter().enumerate() {
5860                self.finalize_gate.stop_if_due().await;
5861                if position == 0 {
5862                    submitted.set(submitted.get() + 1);
5863                }
5864                let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
5865                // No deadline drops a write section, and the section does
5866                // not drop ITSELF either (bsv-low #559, the delta lens's
5867                // DELTA-3 and the lens fold of 2026-10-07, F2,
5868                // `set_finalize_submit_budget`): the submit always runs to
5869                // its end. What is bounded is each storage call and hook
5870                // inside it ([`CallBound`]): one that never answers is that
5871                // call's FAULT, the submit handles it as any fault (nothing
5872                // of the transaction held, or all of it), the transaction
5873                // did not land, its UTXO fails and the cursor stays.
5874                let outcome = {
5875                    let _writing = self.finalize_gate.write_section();
5876                    let bound = self
5877                        .finalize_submit_budget
5878                        .as_ref()
5879                        .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
5880                    self.submit_bounded(
5881                        &tagged,
5882                        SubmitMode::HistoricalTxNoSpv,
5883                        bound.as_ref(),
5884                        true,
5885                        WalkBreachPolicy::NotNow,
5886                    )
5887                    .await
5888                };
5889                let not_landed = match outcome {
5890                    Ok((_, report)) => {
5891                        let landed = report.is_durable()
5892                            && (report.applied_topics.contains(&graph.topic)
5893                                || report.deduped_topics.contains(&graph.topic));
5894                        (!landed).then(|| {
5895                            if report.is_durable() {
5896                                "the topic manager failed on it".to_string()
5897                            } else {
5898                                format!("not durable: {}", report.summary())
5899                            }
5900                        })
5901                    }
5902                    Err(e) => Some(e.to_string()),
5903                };
5904                if let Some(why) = not_landed {
5905                    warn!(
5906                        "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
5907                        graph.topic
5908                    );
5909                    all_landed = false;
5910                    break;
5911                }
5912            }
5913        }
5914
5915        if !finalized.is_empty() {
5916            info!(
5917                "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
5918                finalized.len()
5919            );
5920        }
5921        all_landed
5922    }
5923
5924    /// Union local records, tracker answers, SLAP-named providers and bootstrap.
5925    /// Reference: overlay-services@480d77a src/Engine.ts:839-876 and
5926    /// ts-stack@edf6e03 packages/sdk/src/overlay-tools/LookupResolver.ts:1976.
5927    /// Ask the configured topic peers for held outputs, using the same SHIP
5928    /// discovery as sync. Only a header-verified proof can change stored bytes.
5929    pub async fn rerequest_held_proofs(
5930        &self,
5931        candidates: &[crate::proof_window::HeldOutput],
5932        progress: &std::cell::Cell<crate::proof_window::PeerProofSummary>,
5933    ) {
5934        use crate::proof_window::{PEER_PROOF_REQUEST_MS, PEER_PROOF_ROW_LIMIT};
5935        let Some(factory) = &self.gasp_remote_factory else {
5936            return;
5937        };
5938        let Some(tracker) = self.chain_tracker.as_deref() else {
5939            return;
5940        };
5941        let mut lookup_hosts = None;
5942        let mut topic_peers = HashMap::new();
5943        let mut landed = HashSet::new();
5944        for candidate in candidates.iter().take(PEER_PROOF_ROW_LIMIT) {
5945            if !self.managers.contains_key(&candidate.topic) {
5946                continue;
5947            }
5948            if let std::collections::hash_map::Entry::Vacant(entry) =
5949                topic_peers.entry(candidate.topic.clone())
5950            {
5951                let peers = match self.config.sync_configuration.get(&candidate.topic) {
5952                    Some(SyncTarget::Ship) => {
5953                        self.discover_ship_peers(&candidate.topic, &mut lookup_hosts)
5954                            .await
5955                    }
5956                    Some(SyncTarget::Peers(peers)) => self.ship_peers_excluding_self(peers.clone()),
5957                    _ => Vec::new(),
5958                };
5959                entry.insert(peers);
5960            }
5961            for peer in &topic_peers[&candidate.topic] {
5962                let remote = factory.create_remote(peer, &candidate.topic);
5963                let graph_id = format!("{}.{}", candidate.txid, candidate.output_index);
5964                let mut summary = progress.get();
5965                summary.requests += 1;
5966                progress.set(summary);
5967                let ask =
5968                    remote.request_node(&graph_id, &candidate.txid, candidate.output_index, false);
5969                let response = if let Some((sleep, _)) = &self.peer_sync_budget {
5970                    crate::gasp::race_or_deadline(ask, sleep(PEER_PROOF_REQUEST_MS)).await
5971                } else {
5972                    Some(ask.await)
5973                };
5974                let Some(Ok(node)) = response else {
5975                    let mut summary = progress.get();
5976                    summary.faults += 1;
5977                    progress.set(summary);
5978                    continue;
5979                };
5980                if landed.contains(&candidate.txid) {
5981                    continue;
5982                }
5983                let Some(proof_hex) = node.proof else {
5984                    continue;
5985                };
5986                let Ok(mut proof) = beef_limits::merkle_path_from_hex(
5987                    &proof_hex,
5988                    beef_limits::COURIER_PROOF_MAX_BYTES,
5989                ) else {
5990                    let mut summary = progress.get();
5991                    summary.refused += 1;
5992                    progress.set(summary);
5993                    continue;
5994                };
5995                let valid = match proof.compute_root(Some(&candidate.txid)) {
5996                    Ok(root) => tracker
5997                        .is_valid_root_for_height(&root, proof.block_height)
5998                        .await
5999                        .unwrap_or(false),
6000                    Err(_) => false,
6001                };
6002                if !valid {
6003                    let mut summary = progress.get();
6004                    summary.refused += 1;
6005                    progress.set(summary);
6006                    continue;
6007                }
6008                // Root verification accepts a hash leaf too. Mark the verified
6009                // subject so BEEF attaches the path before its proof flag moves.
6010                for leaf in &mut proof.path[0] {
6011                    if leaf.hash.as_deref() == Some(&candidate.txid) {
6012                        leaf.txid = true;
6013                    }
6014                }
6015                match self.land_peer_proof(candidate, &proof).await {
6016                    Ok(()) => {
6017                        landed.insert(candidate.txid.clone());
6018                        let mut summary = progress.get();
6019                        summary.landed += 1;
6020                        progress.set(summary);
6021                    }
6022                    Err(error) => {
6023                        warn!(%error, "[proof window] verified proof could not be stored");
6024                        let mut summary = progress.get();
6025                        summary.faults += 1;
6026                        progress.set(summary);
6027                    }
6028                }
6029            }
6030        }
6031    }
6032
6033    async fn land_peer_proof(
6034        &self,
6035        candidate: &crate::proof_window::HeldOutput,
6036        proof: &bsv_rs::transaction::MerklePath,
6037    ) -> Result<(), EngineError> {
6038        let output = self
6039            .storage
6040            .find_output(
6041                &candidate.txid,
6042                candidate.output_index,
6043                Some(&candidate.topic),
6044                None,
6045                true,
6046            )
6047            .await
6048            .map_err(|e| EngineError::StorageError(e.to_string()))?
6049            .ok_or(EngineError::NodeNotFound)?;
6050        let beef = output
6051            .beef
6052            .ok_or_else(|| EngineError::BeefParseError("missing stored BEEF".into()))?;
6053        let stitched = Self::stitch_proof_into_stored_beef(&beef, &candidate.txid, proof)
6054            .ok_or_else(|| {
6055                EngineError::BeefParseError("proof does not stitch into stored subject".into())
6056            })?;
6057        self.storage
6058            .update_transaction_beef(&candidate.txid, &stitched)
6059            .await
6060            .map_err(|e| EngineError::StorageError(e.to_string()))?;
6061        self.storage
6062            .mark_transaction_proven_at(&candidate.txid, Some(u64::from(proof.block_height)))
6063            .await
6064            .map_err(|e| EngineError::StorageError(e.to_string()))
6065    }
6066
6067    /// Read our admitted advertisement set, not a foreign history. A changed
6068    /// SLAP outpoint wakes its origin; SHIP scopes that wake to its topic.
6069    async fn peer_advertisement_versions(&self, topic: &str) -> HashMap<String, String> {
6070        let mut versions: HashMap<String, Vec<String>> = HashMap::new();
6071        for (ad_topic, protocol, scope) in [
6072            ("tm_slap", &b"SLAP"[..], None),
6073            ("tm_ship", &b"SHIP"[..], Some(topic)),
6074        ] {
6075            let rows = self
6076                .storage
6077                .find_utxos_for_topic(ad_topic, None, None, false)
6078                .await
6079                .unwrap_or_default();
6080            for row in rows {
6081                if let Some(domain) =
6082                    parse_advertisement_domain_from_script(&row.output_script, protocol, scope)
6083                {
6084                    versions
6085                        .entry(crate::gasp::peer_origin(&domain))
6086                        .or_default()
6087                        .push(format!("{}:{}", row.txid, row.output_index));
6088                }
6089            }
6090        }
6091        versions
6092            .into_iter()
6093            .map(|(peer, mut keys)| {
6094                keys.sort();
6095                (peer, keys.join(","))
6096            })
6097            .collect()
6098    }
6099
6100    async fn discover_ship_peers(
6101        &self,
6102        topic: &str,
6103        lookup_hosts: &mut Option<Vec<String>>,
6104    ) -> Vec<String> {
6105        let mut domains = self.local_ship_peers(topic).await;
6106        if let Some(bootstrap) = self.ship_bootstrap_peers.get(topic) {
6107            domains.extend(bootstrap.iter().cloned());
6108        }
6109        if let Some(client) = &self.ship_lookup_client {
6110            if lookup_hosts.is_none() {
6111                *lookup_hosts = Some(self.discover_ship_lookup_hosts(client.as_ref()).await);
6112            }
6113            let question = LookupQuestion::new("ls_ship", serde_json::json!({"topics": [topic]}));
6114            for host in lookup_hosts.as_ref().into_iter().flatten() {
6115                crate::meter::peer_request();
6116                match client.lookup(host, &question).await {
6117                    Ok(outputs) => {
6118                        domains.extend(outputs.iter().filter_map(|output| {
6119                            parse_lookup_advertisement(output, b"SHIP", topic)
6120                        }));
6121                    }
6122                    Err(e) => warn!("[GASP SYNC] SHIP lookup at {host} for {topic} failed: {e}"),
6123                }
6124            }
6125        }
6126        self.ship_peers_excluding_self(domains)
6127    }
6128
6129    async fn discover_ship_lookup_hosts(
6130        &self,
6131        client: &dyn crate::ship_lookup::ShipLookupClient,
6132    ) -> Vec<String> {
6133        use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
6134        let trackers = self.ship_peers_excluding_self(self.config.slap_trackers.clone());
6135        let tracker_origins: HashSet<_> = trackers
6136            .iter()
6137            .map(|host| crate::gasp::peer_origin(host))
6138            .collect();
6139        let question = LookupQuestion::new("ls_slap", serde_json::json!({"service": "ls_ship"}));
6140        let mut advertised = Vec::new();
6141        for tracker in &trackers {
6142            crate::meter::peer_request();
6143            match client.lookup(tracker, &question).await {
6144                Ok(outputs) => {
6145                    advertised.extend(outputs.iter().filter_map(|output| {
6146                        parse_lookup_advertisement(output, b"SLAP", "ls_ship")
6147                    }));
6148                }
6149                Err(e) => warn!("[GASP SYNC] SLAP lookup at {tracker} failed: {e}"),
6150            }
6151        }
6152        let providers = self
6153            .ship_peers_excluding_self(advertised)
6154            .into_iter()
6155            .filter(|host| !tracker_origins.contains(&crate::gasp::peer_origin(host)))
6156            .take(SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
6157        trackers.into_iter().chain(providers).collect()
6158    }
6159
6160    fn ship_peers_excluding_self(&self, domains: impl IntoIterator<Item = String>) -> Vec<String> {
6161        crate::gasp::ship_peers_by_origin(domains.into_iter().filter(|domain| {
6162            self.config.hosting_url.as_ref().is_none_or(|our_url| {
6163                crate::gasp::peer_origin(domain) != crate::gasp::peer_origin(our_url)
6164            })
6165        }))
6166    }
6167
6168    /// Read local SHIP records independently of network discovery failures.
6169    async fn local_ship_peers(&self, topic: &str) -> Vec<String> {
6170        let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
6171            warn!("[GASP SYNC] No local ls_ship; retaining network and bootstrap discovery");
6172            return Vec::new();
6173        };
6174
6175        let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
6176
6177        let refs = match ship_ls.lookup(&question).await {
6178            Ok(LookupResult::OutputList(refs)) => refs,
6179            Ok(LookupResult::Answer(_)) => {
6180                error!(
6181                    "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
6182                     LookupAnswer; expected OutputList. Skipping."
6183                );
6184                return Vec::new();
6185            }
6186            Err(e) => {
6187                error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
6188                return Vec::new();
6189            }
6190        };
6191
6192        let mut domains = HashSet::new();
6193        for reference in &refs {
6194            if let Ok(Some(output)) = self
6195                .storage
6196                .find_output(&reference.txid, reference.output_index, None, None, false)
6197                .await
6198            {
6199                if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
6200                    // Every spelling of our own origin is ourselves (the
6201                    // delta-2 fold's D2-M2: `https://us/?x` passed the
6202                    // string compare).
6203                    if let Some(ref our_url) = self.config.hosting_url {
6204                        if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
6205                            || crate::gasp::peer_origin(&domain)
6206                                == crate::gasp::peer_origin(our_url)
6207                        {
6208                            continue;
6209                        }
6210                    }
6211                    domains.insert(domain);
6212                }
6213            }
6214        }
6215
6216        // One peer per normalized origin (bsv-low #555, the delta-2 fold's
6217        // D2-M2, restored by the delta-4 fold's D4-M1): eight spellings of
6218        // one server are one peer, the one its health row is keyed by.
6219        crate::gasp::ship_peers_by_origin(domains)
6220    }
6221
6222    // ========================================================================
6223    // Helpers
6224    // ========================================================================
6225
6226    /// Get a reference to the storage backend.
6227    pub fn storage(&self) -> &dyn Storage {
6228        self.storage.as_ref()
6229    }
6230
6231    /// Get the engine configuration.
6232    pub fn config(&self) -> &EngineConfig {
6233        &self.config
6234    }
6235}
6236
6237/// The SPV check of [`Engine::submit`], as one function over a BORROWED
6238/// tracker: the reference's `tx.verify(chainTracker)` (roots against the
6239/// tracker, every unproven input's script executed, the value rule), its
6240/// `tx.verify('scripts only')` when `chain_tracker` is `None` (roots accepted
6241/// unchecked, scripts still run), and the pre-2026-09-08 structural check when
6242/// `verify_scripts` is `false` ([`Engine::set_script_verification`]'s escape
6243/// hatch).
6244///
6245/// There is ONE verifier. `Engine::submit` calls this with its own tracker,
6246/// switch and walk budget; the GASP anchor check
6247/// (`OverlayGASPStorage::validate_graph_anchor`, bsv-low #551) calls it with
6248/// the same three, handed over by `Engine::start_gasp_sync`.
6249///
6250/// `Ok(Some(_))` is a walk that COULD NOT RUN within `budget` (bsv-low #592):
6251/// not a verdict on the transaction. The caller goes on as it would without a
6252/// walk and says so.
6253pub(crate) async fn verify_spv_like_the_reference(
6254    chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6255    verify_scripts: bool,
6256    beef_bytes: &[u8],
6257    subject_txid: &str,
6258    budget: DoorBudget,
6259) -> Result<Option<WalkCouldNotRun>, EngineError> {
6260    verify_spv_trusting(
6261        chain_tracker,
6262        verify_scripts,
6263        beef_bytes,
6264        subject_txid,
6265        &HashSet::new(),
6266        budget,
6267    )
6268    .await
6269}
6270
6271/// [`verify_spv_like_the_reference`] trusting the transactions an earlier
6272/// walk of the same submit passed over (a predecessor the door lands first,
6273/// the E1D delta fold, L4): the script walk neither checks nor descends
6274/// them. The structural escape hatch checks the whole BEEF it is given.
6275async fn verify_spv_trusting(
6276    chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6277    verify_scripts: bool,
6278    beef_bytes: &[u8],
6279    subject_txid: &str,
6280    trusted: &HashSet<String>,
6281    budget: DoorBudget,
6282) -> Result<Option<WalkCouldNotRun>, EngineError> {
6283    if verify_scripts {
6284        Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
6285    } else {
6286        Engine::verify_spv_structurally(chain_tracker, beef_bytes)
6287            .await
6288            .map(|()| None)
6289    }
6290}
6291
6292/// The BUMP that proves each txid of `beef`: the first BUMP whose level 0
6293/// carries it, as `Beef::find_bump` picks, built in one pass over the BUMPs'
6294/// leaves (NL-6e; `find_bump` per transaction scans them all each time).
6295fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
6296    let mut proven: HashMap<&str, usize> = HashMap::new();
6297    for (at, bump) in beef.bumps.iter().enumerate() {
6298        for leaf in bump.path.first().into_iter().flatten() {
6299            if let Some(hash) = leaf.hash.as_deref() {
6300                proven.entry(hash).or_insert(at);
6301            }
6302        }
6303    }
6304    proven
6305}
6306
6307/// The transactions the SPV walk of `from` passes over in `beef`
6308/// ([`Engine::verify_beef_linear_with`]'s traversal, nothing checked): `from`
6309/// and every source the BEEF carries, a proven transaction included and not
6310/// descended. The E1D delta fold, L4: what a landing need not walk again.
6311fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
6312    let bodies: HashMap<String, &Transaction> = beef
6313        .txs
6314        .iter()
6315        .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
6316        .collect();
6317    let proven = proven_index(beef);
6318    let mut cover = HashSet::new();
6319    let mut queue = vec![from.to_string()];
6320    while let Some(txid) = queue.pop() {
6321        if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
6322            continue;
6323        }
6324        if let Some(tx) = bodies.get(&txid) {
6325            queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
6326        }
6327    }
6328    cover
6329}
6330
6331/// The engine's [`crate::gasp::FinalizedGraphHook`] (bsv-low #552): submit
6332/// what a peer's sync just finalized, before its next UTXO is asked for.
6333struct SubmitAsFinalized<'e> {
6334    engine: &'e Engine,
6335    sink: crate::gasp_overlay::FinalizedGraphSink,
6336    peer_url: &'e str,
6337    topic: &'e str,
6338    submitted: &'e std::cell::Cell<u64>,
6339}
6340
6341#[async_trait::async_trait(?Send)]
6342impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
6343    async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
6344        // A graph is counted as its first submit starts, and the deadline
6345        // cannot land inside a submit: a sync dropped in here has written at
6346        // least that transaction whole (an ancestors-first prefix).
6347        let landed = self
6348            .engine
6349            .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
6350            .await;
6351        if landed {
6352            Ok(())
6353        } else {
6354            // Fails this UTXO: the cursor stays below it and the next sync
6355            // asks for it again (the lens fold's MEDIUM-1).
6356            Err(crate::gasp::GASPError::StorageError(
6357                "a finalize submit did not land".to_string(),
6358            ))
6359        }
6360    }
6361}
6362
6363/// Result of a GASP sync operation.
6364///
6365/// Returned by `Engine::start_gasp_sync()` to summarize what happened.
6366#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6367pub struct GASPSyncResult {
6368    #[serde(default, flatten)]
6369    pub cost: crate::meter::SyncCost,
6370    #[serde(default)]
6371    pub filter: crate::sync_load::SyncFilter,
6372    #[serde(default)]
6373    pub topics_filtered: Vec<String>,
6374    /// Per-topic sync results.
6375    pub topics_synced: HashMap<String, TopicSyncResult>,
6376}
6377
6378/// Sync result for a single topic.
6379#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6380pub struct TopicSyncResult {
6381    #[serde(default)]
6382    pub peer_results: HashMap<String, crate::backfill::PeerSyncResult>,
6383    #[serde(default)]
6384    pub dead_graphs: u64,
6385    #[serde(default)]
6386    pub expired_swept: u64,
6387    #[serde(default, flatten)]
6388    pub cost: crate::meter::SyncCost,
6389    #[serde(default)]
6390    pub root_bounds: Vec<crate::sync_load::RootBound>,
6391    #[serde(default)]
6392    pub peers_skipped: Vec<crate::sync_load::SkippedPeer>,
6393    #[serde(default)]
6394    pub peers_filtered: Vec<String>,
6395    /// Root proof status after successful anchor validation and finalization.
6396    #[serde(default)]
6397    pub roots_proven: u64,
6398    #[serde(default)]
6399    pub roots_unproven: u64,
6400    /// Outstanding outpoints on peers whose cursor did not advance.
6401    #[serde(default)]
6402    pub cursor_held_by: Vec<String>,
6403    /// Distinct expired advert roots skipped before any proof request.
6404    #[serde(default)]
6405    pub expired_refused: u64,
6406    /// Peer URLs discovered or configured for this topic.
6407    pub peers: Vec<String>,
6408    /// How peers were determined: "ship" or "peers".
6409    pub sync_type: String,
6410    /// Any errors encountered during sync (peer URL -> error message): a
6411    /// peer whose sync failed or ran over its budget. A single UTXO whose
6412    /// graph ingest failed is NOT listed here: it is warned (`Error ingesting
6413    /// UTXO`), held back by the cursor gap guard and asked for again by the
6414    /// next sync.
6415    pub errors: Vec<String>,
6416    /// Manager-named inputs pruned because a peer answered that it does not
6417    /// hold them (the D8 decoy rule, `GASPSync::pruned_inputs`). Counted per
6418    /// distinct outpoint PER PEER per sync and summed over this topic's
6419    /// peers: one decoy seen through two peers reads 2. Not errors: the
6420    /// graphs completed without those branches.
6421    #[serde(default)]
6422    pub pruned_inputs: u64,
6423    /// Graphs the anchor check REFUSED and discarded whole (bsv-low #551,
6424    /// `GASPSync::discarded_graphs`): a root whose BEEF does not verify, or
6425    /// one the topic manager does not admit at the end of the replay. Summed
6426    /// over this topic's peers. Not errors, and nothing of them was admitted.
6427    #[serde(default)]
6428    pub discarded_graphs: u64,
6429    /// Graphs HELD because their anchor's script walk could not run within
6430    /// the engine's walk budget (bsv-low #592; the E592 lens fold, H1 and
6431    /// L3; `GASPSync::anchor_walk_held`), summed over this topic's peers.
6432    /// Nothing of them was finalized; each UTXO stays below its cursor until
6433    /// the peer serves its root proven. Not errors and not discarded.
6434    #[serde(default)]
6435    pub anchor_walk_held_graphs: u64,
6436    /// Graphs that passed the anchor check and were handed to submit, summed
6437    /// over this topic's peers (bsv-low #552). Under a per-peer budget a
6438    /// graph is counted, and submitted, the moment it finalizes, so the
6439    /// count includes those of a peer the deadline then dropped. A counted
6440    /// graph had its first transaction submitted whole (the deadline never
6441    /// lands inside a submit); one whose later submit did not land is still
6442    /// counted, and its UTXO stays below the cursor.
6443    /// A graph is counted as its first submit STARTS; a first submit that FAULTS
6444    /// still counts here, so this is not a delivered count (bsv-low #559).
6445    #[serde(default)]
6446    pub finalized_graphs: u64,
6447    /// Graphs that were mid-walk when a peer's sync was dropped at its
6448    /// budget (bsv-low #552): at most one per peer per sync. NOTHING of such
6449    /// a graph was admitted; the next sync walks it again. The same root
6450    /// counted tick after tick with no `finalized_graphs` and no
6451    /// `cursor_moves` is ONE graph whose walk outlasts the budget (with a
6452    /// per-graph budget it is also counted in `deferred_graphs` and resumed,
6453    /// bsv-low #555).
6454    #[serde(default)]
6455    pub deadline_dropped_graphs: u64,
6456    /// Every persisted cursor that moved in this sync (bsv-low #552), one
6457    /// entry per peer. After a completed sync `to` is the peer's highest
6458    /// score seen (less the gap guard); after a deadline it is
6459    /// `GASPSync::completed_cursor`: past completed UTXOs only.
6460    #[serde(default)]
6461    pub cursor_moves: Vec<CursorMove>,
6462    /// Graphs DEFERRED in this sync, summed over peers (bsv-low #555,
6463    /// `Engine::set_graph_budget`): a new record, or a resumed graph
6464    /// deferred again. Their UTXOs are held below the cursor.
6465    #[serde(default)]
6466    pub deferred_graphs: u64,
6467    /// Graphs RESUMED from a record in this sync.
6468    #[serde(default)]
6469    pub resumed_graphs: u64,
6470    /// Resumed graphs that completed and landed in this sync (their record
6471    /// deleted).
6472    #[serde(default)]
6473    pub converged_graphs: u64,
6474    /// Records deleted without converging, with their reason.
6475    #[serde(default)]
6476    pub dropped_graphs: Vec<DroppedDeferral>,
6477    /// Walks the per-graph budget (or the per-peer deadline) cut having
6478    /// fetched NOTHING in their pass (bsv-low #555, the lens fold's H1). A
6479    /// peer's sync whose only outcome was these is a failed attempt for the
6480    /// quarantine count.
6481    #[serde(default)]
6482    pub stalled_graphs: u64,
6483    /// Records NOT resumed in this sync: the resumes of a peer's pass share
6484    /// ONE per-graph budget and had spent it (the lens fold's M1). Held for
6485    /// the next sync, untouched.
6486    #[serde(default)]
6487    pub held_back_graphs: u64,
6488    /// Held records this sync sent to the long backoff (engine 0.1.10,
6489    /// `gasp::IDLE_RESUMES_BEFORE_BACKOFF`): resumed that many passes in a
6490    /// row with no node appended, not resumed again for
6491    /// `gasp::IDLE_RESUME_RETRY_SECS`.
6492    #[serde(default)]
6493    pub backed_off_graphs: u64,
6494    /// Held records this sync did not resume because they wait out that
6495    /// backoff: no request, the cursor passes them (engine 0.1.10).
6496    #[serde(default)]
6497    pub waiting_graphs: u64,
6498    /// Held records whose resumed pass this sync counted one idle resume
6499    /// on (engine 0.1.11, `gasp::DeferralStats::idle_counted`): the pass
6500    /// asked the peer and appended nothing it kept, whether it ended in a
6501    /// save, a drop or an error. A captain reads the idle rule firing here.
6502    #[serde(default)]
6503    pub idle_counted: u64,
6504    /// Roots recorded UNANCHORABLE in this sync (engine 0.1.9,
6505    /// `GASPSync::unanchorable_recorded`): their walk faulted in
6506    /// `gasp::UNANCHORABLE_AFTER_FAULTED_RUNS` consecutive runs; the cursor
6507    /// passes them, and each is walked again after
6508    /// `gasp::UNANCHORABLE_RETRY_SECS`. Nothing of them is admitted.
6509    #[serde(default)]
6510    pub unanchorable_graphs: u64,
6511    /// Roots with a fault record that this sync walked again and admitted,
6512    /// or refused on a verdict: their record cleared (engine 0.1.9).
6513    #[serde(default)]
6514    pub unanchorable_cleared: u64,
6515    /// Strikes this sync could not write (engine 0.1.10,
6516    /// `GASPSync::strikes_refused`): past `gasp::FAULT_RECORDS_PER_PEER_TOPIC`,
6517    /// at the storage's ceiling or on its fault. Each root is named in
6518    /// `faulted_roots` with its `refused` reason.
6519    #[serde(default)]
6520    pub strikes_refused: u64,
6521    /// Every root a peer's sync dropped in this run, once per (peer, root),
6522    /// named with its fault class, its faulted runs and whether it is now
6523    /// unanchorable (engine 0.1.9, `GASPSync::faulted_roots`). With
6524    /// `cursor_held_by` (what is neither finished nor held) the captain
6525    /// reads why the cursor stopped or moved.
6526    #[serde(default)]
6527    pub faulted_roots: Vec<crate::gasp::FaultedRoot>,
6528}
6529
6530/// A deferred graph's record deleted without converging (bsv-low #555,
6531/// [`TopicSyncResult::dropped_graphs`]).
6532#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6533pub struct DroppedDeferral {
6534    /// The peer URL.
6535    pub peer: String,
6536    /// The graph's root outpoint.
6537    pub outpoint: String,
6538    /// [`crate::gasp::DropReason::as_str`].
6539    pub reason: String,
6540}
6541
6542/// One peer's persisted `last_interaction` cursor moving `from` -> `to` in a
6543/// sync ([`TopicSyncResult::cursor_moves`]).
6544#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6545pub struct CursorMove {
6546    /// The peer URL.
6547    pub peer: String,
6548    /// The cursor the sync entered with.
6549    pub from: u64,
6550    /// The cursor it persisted.
6551    pub to: u64,
6552}
6553
6554/// Get current time in milliseconds (for output scores).
6555///
6556/// Uses `js_sys::Date::now()` on wasm32 (Cloudflare Workers) since
6557/// `std::time::SystemTime` is not available on that platform.
6558pub(crate) fn current_timestamp_ms() -> f64 {
6559    #[cfg(target_arch = "wasm32")]
6560    {
6561        js_sys::Date::now()
6562    }
6563    #[cfg(not(target_arch = "wasm32"))]
6564    {
6565        std::time::SystemTime::now()
6566            .duration_since(std::time::UNIX_EPOCH)
6567            .map_or(0.0, |d| d.as_millis() as f64)
6568    }
6569}
6570
6571/// Extract the domain field from a SHIP PushDrop locking script.
6572///
6573/// SHIP PushDrop format: field[0] = "SHIP", field[1] = identity_key,
6574/// field[2] = domain (UTF-8), field[3] = topic.
6575/// Returns `None` if the script cannot be parsed or is not a SHIP advertisement.
6576fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
6577    parse_advertisement_domain_from_script(output_script, b"SHIP", None)
6578}
6579
6580fn parse_lookup_advertisement(
6581    output: &OutputListItem,
6582    protocol: &[u8],
6583    scope: &str,
6584) -> Option<String> {
6585    let tx = beef_limits::transaction_from_beef(&output.beef, None, &beef_limits::PEER_BEEF_LIMITS)
6586        .ok()?;
6587    let script = tx
6588        .outputs
6589        .get(output.output_index as usize)?
6590        .locking_script
6591        .to_binary();
6592    parse_advertisement_domain_from_script(&script, protocol, Some(scope))
6593}
6594
6595fn parse_advertisement_domain_from_script(
6596    output_script: &[u8],
6597    protocol: &[u8],
6598    scope: Option<&str>,
6599) -> Option<String> {
6600    use bsv_rs::script::templates::PushDrop;
6601
6602    let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
6603    let pushdrop = PushDrop::decode(&script.into()).ok()?;
6604
6605    if pushdrop.fields.len() < 3 {
6606        return None;
6607    }
6608
6609    if pushdrop.fields[0] != protocol {
6610        return None;
6611    }
6612
6613    if scope.is_some_and(|expected| {
6614        pushdrop
6615            .fields
6616            .get(3)
6617            .is_none_or(|actual| actual != expected.as_bytes())
6618    }) {
6619        return None;
6620    }
6621
6622    let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
6623    if domain.is_empty() {
6624        return None;
6625    }
6626
6627    Some(domain)
6628}
6629
6630// ============================================================================
6631// Submit-time spend verification helpers (reference parity, 2026-09-08)
6632// ============================================================================
6633
6634// ============================================================================
6635// Error type
6636// ============================================================================
6637
6638#[derive(Debug, thiserror::Error)]
6639pub enum EngineError {
6640    #[error("This server does not support this topic: {0}")]
6641    UnsupportedTopic(String),
6642
6643    #[error("Lookup service not found for provider: {0}")]
6644    LookupServiceNotFound(String),
6645
6646    /// GASP: `/requestForeignGASPNode` asked for a graph/txid we don't
6647    /// have locally. Matches mainline's 400-class "not found" response.
6648    #[error("No matching output found!")]
6649    NodeNotFound,
6650
6651    #[error("lookup failed: {0}")]
6652    LookupFailed(String),
6653
6654    /// The CALLER's query was malformed — a bad identity key, a gameId that is
6655    /// not 32 bytes of hex, a missing required field. Distinct from
6656    /// `LookupFailed`, which means WE failed: without the distinction every
6657    /// caller mistake was reported as a server fault (HTTP 500), so a malformed
6658    /// query and a genuine outage were indistinguishable in logs and alerts on
6659    /// an unauthenticated endpoint anyone can hit.
6660    #[error("invalid query: {0}")]
6661    InvalidQuery(String),
6662
6663    #[error("storage error: {0}")]
6664    StorageError(String),
6665
6666    #[error("broadcast error: {0}")]
6667    BroadcastError(String),
6668
6669    #[error("SPV verification failed: {0}")]
6670    SpvError(String),
6671
6672    #[error("BEEF parsing failed: {0}")]
6673    BeefParseError(String),
6674
6675    /// A spend in the submitted BEEF does not satisfy its source output's
6676    /// locking script: the input's unlocking script was EXECUTED (reference
6677    /// parity, 2026-09-08) and the interpreter refused it. Distinct from
6678    /// [`EngineError::SpvError`] (a bad or unverifiable PROOF, a missing
6679    /// source, a chain-tracker fault) so an operator can tell a bad spend from
6680    /// a bad proof. `input_index` is the failing input's position in the
6681    /// transaction whose script failed (the subject, or an unproven
6682    /// ancestor of it in the same BEEF); `reason` is the interpreter's own
6683    /// message.
6684    #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
6685    ScriptVerificationFailed {
6686        subject_txid: String,
6687        input_index: u32,
6688        reason: String,
6689    },
6690
6691    /// The DOOR walk ([`Engine::verify_scripts_only`]) hit a STRUCTURAL fault
6692    /// before reaching a verdict on `at_txid` (a source the BEEF lacks, a
6693    /// parse fault, the value rule): not the interpreter's verdict, so not a
6694    /// refusal for a caller whose bar is the network. `subject_judged` says
6695    /// whether the subject's own inputs had all executed before the fault
6696    /// (an ancestor faulted) or not (the subject itself is unjudged).
6697    #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
6698    ScriptWalkInconclusive {
6699        at_txid: String,
6700        subject_judged: bool,
6701        reason: String,
6702    },
6703
6704    /// The DOOR walk exceeded its own bound ([`DoorBudget`]: the static work
6705    /// estimate, the interpreter's memory limit tripping, or the estimate of
6706    /// the door's own memory; `limb` says which): the door's verdict, never
6707    /// the network's — the request proceeds to the network.
6708    #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
6709    ScriptWalkOverBudget {
6710        at_txid: String,
6711        subject_judged: bool,
6712        limb: DoorLimb,
6713        what: String,
6714    },
6715
6716    /// The submit's own script walk COULD NOT RUN within the engine's
6717    /// [`DoorBudget`] (bsv-low #592) and nothing on record stands in for it
6718    /// ([`WalkBreachPolicy::NotNow`], every door but the queue replay of a
6719    /// gated submission; the E592 lens fold, H1). NOT NOW: nothing was
6720    /// judged, nothing written, and it is NOT a refusal either: the budget
6721    /// says nothing about the transaction. The way out is the subject
6722    /// PROVEN: a proven transaction is not walked, so an honest body too
6723    /// big to walk unmined lands once its block is mined (a client retries
6724    /// after the block, not at once).
6725    #[error(
6726        "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
6727        .0.limb.as_str(),
6728        .0.what
6729    )]
6730    WalkCouldNotRun(WalkCouldNotRun),
6731
6732    #[error("{0}")]
6733    Other(String),
6734}
6735
6736impl From<StorageError> for EngineError {
6737    fn from(e: StorageError) -> Self {
6738        EngineError::StorageError(e.to_string())
6739    }
6740}
6741
6742impl From<TopicManagerError> for EngineError {
6743    fn from(e: TopicManagerError) -> Self {
6744        EngineError::Other(e.to_string())
6745    }
6746}
6747
6748impl From<LookupServiceError> for EngineError {
6749    fn from(e: LookupServiceError) -> Self {
6750        EngineError::LookupFailed(e.to_string())
6751    }
6752}
6753
6754impl From<AdvertiserError> for EngineError {
6755    fn from(e: AdvertiserError) -> Self {
6756        EngineError::Other(e.to_string())
6757    }
6758}
6759
6760// ============================================================================
6761// Tests
6762// ============================================================================
6763
6764/// Baseline sync order: configured peer topics before SHIP discovery topics,
6765/// each by name (bsv-low #555, D-M1). `start_gasp_sync` advances SHIP topics
6766/// with explicit bootstrap peers ahead of this order so their sync can run
6767/// without importing tracker records. Deferred-graph reservations still use
6768/// each topic's configured target.
6769pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
6770    let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
6771    order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
6772    order
6773}
6774
6775#[cfg(test)]
6776mod tests {
6777    use super::*;
6778    use crate::lookup_service::LookupService as LookupServiceTrait;
6779    use crate::storage::memory::MemoryStorage;
6780    use crate::topic_manager::TopicManager as TopicManagerTrait;
6781    use async_trait::async_trait;
6782    use std::sync::Mutex;
6783
6784    // bsv-low #555, the delta fold's D-M1: the configured peers' topics are
6785    // synced before the ls_ship-discovered ones, each class by name; the map's
6786    // order was arbitrary.
6787    #[test]
6788    fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6789        let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6790        let config: SyncConfiguration = HashMap::from([
6791            ("tm_a".to_string(), SyncTarget::Ship),
6792            ("tm_z".to_string(), peers("https://z")),
6793            ("tm_b".to_string(), SyncTarget::Ship),
6794            ("tm_m".to_string(), peers("https://m")),
6795            ("tm_off".to_string(), SyncTarget::Disabled),
6796        ]);
6797        let order: Vec<&str> = sync_order(&config)
6798            .iter()
6799            .map(|(t, _)| t.as_str())
6800            .collect();
6801        assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6802    }
6803
6804    // ── Mock TopicManager ──────────────────────────────────────────────
6805
6806    struct MockTopicManager {
6807        admit_indices: Vec<u32>,
6808    }
6809
6810    impl MockTopicManager {
6811        fn admitting(indices: Vec<u32>) -> Self {
6812            Self {
6813                admit_indices: indices,
6814            }
6815        }
6816    }
6817
6818    #[async_trait(?Send)]
6819    impl TopicManagerTrait for MockTopicManager {
6820        async fn identify_admissible_outputs(
6821            &self,
6822            _tx: &Transaction,
6823            _previous_coins: &[u8],
6824            _off_chain_values: Option<&[u8]>,
6825            _mode: SubmitMode,
6826            _context: &TopicAdmittanceContext,
6827        ) -> Result<AdmittanceInstructions, TopicManagerError> {
6828            Ok(AdmittanceInstructions {
6829                outputs_to_admit: self.admit_indices.clone(),
6830                coins_to_retain: vec![],
6831                coins_removed: None,
6832            })
6833        }
6834
6835        async fn get_documentation(&self) -> String {
6836            "Mock topic manager".to_string()
6837        }
6838
6839        async fn get_metadata(&self) -> ServiceMetadata {
6840            ServiceMetadata {
6841                name: "mock-tm".to_string(),
6842                description: Some("Mock for testing".to_string()),
6843                ..Default::default()
6844            }
6845        }
6846    }
6847
6848    // ── Mock LookupService ─────────────────────────────────────────────
6849
6850    struct MockLookupService {
6851        records: Mutex<Vec<UTXOReference>>,
6852    }
6853
6854    impl MockLookupService {
6855        fn new() -> Self {
6856            Self {
6857                records: Mutex::new(Vec::new()),
6858            }
6859        }
6860    }
6861
6862    #[async_trait(?Send)]
6863    impl LookupServiceTrait for MockLookupService {
6864        fn admission_mode(&self) -> AdmissionMode {
6865            AdmissionMode::LockingScript
6866        }
6867
6868        fn spend_notification_mode(&self) -> SpendNotificationMode {
6869            SpendNotificationMode::None
6870        }
6871
6872        async fn output_admitted_by_topic(
6873            &self,
6874            payload: &OutputAdmittedByTopic,
6875        ) -> Result<(), LookupServiceError> {
6876            let (txid, oi) = match payload {
6877                OutputAdmittedByTopic::LockingScript {
6878                    txid, output_index, ..
6879                } => (txid.clone(), *output_index),
6880                OutputAdmittedByTopic::WholeTx { output_index, .. } => {
6881                    ("whole".into(), *output_index)
6882                }
6883            };
6884            self.records.lock().unwrap().push(UTXOReference {
6885                txid,
6886                output_index: oi,
6887            });
6888            Ok(())
6889        }
6890
6891        async fn output_evicted(
6892            &self,
6893            txid: &str,
6894            output_index: u32,
6895        ) -> Result<(), LookupServiceError> {
6896            self.records
6897                .lock()
6898                .unwrap()
6899                .retain(|r| !(r.txid == txid && r.output_index == output_index));
6900            Ok(())
6901        }
6902
6903        async fn lookup(
6904            &self,
6905            _question: &LookupQuestion,
6906        ) -> Result<LookupResult, LookupServiceError> {
6907            Ok(LookupResult::OutputList(
6908                self.records.lock().unwrap().clone(),
6909            ))
6910        }
6911
6912        async fn get_documentation(&self) -> String {
6913            "Mock lookup service".to_string()
6914        }
6915
6916        async fn get_metadata(&self) -> ServiceMetadata {
6917            ServiceMetadata {
6918                name: "mock-ls".to_string(),
6919                ..Default::default()
6920            }
6921        }
6922    }
6923
6924    // ── Test BEEF data ──────────────────────────────────────────────────
6925
6926    /// Real BRC-62 BEEF from TS overlay-services test suite.
6927    const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
6928    const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
6929
6930    fn decode_hex(hex: &str) -> Vec<u8> {
6931        (0..hex.len())
6932            .step_by(2)
6933            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
6934            .collect()
6935    }
6936
6937    fn test_beef() -> Vec<u8> {
6938        decode_hex(TEST_BEEF_HEX)
6939    }
6940
6941    fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
6942        TaggedBEEF::new(
6943            test_beef(),
6944            topics.into_iter().map(str::to_string).collect(),
6945        )
6946    }
6947
6948    // ── Helpers ────────────────────────────────────────────────────────
6949
6950    fn make_engine(admit_indices: Vec<u32>) -> Engine {
6951        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
6952        managers.insert(
6953            "tm_test".to_string(),
6954            Box::new(MockTopicManager::admitting(admit_indices)),
6955        );
6956
6957        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
6958        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
6959
6960        let storage = Box::new(MemoryStorage::new());
6961
6962        Engine::new(
6963            managers,
6964            lookup_services,
6965            storage,
6966            None,
6967            EngineConfig::default(),
6968        )
6969    }
6970
6971    // ── Tests ──────────────────────────────────────────────────────────
6972
6973    /// bsv-low#289 parse-once + zanaadu#284 subject discipline: the tx handed
6974    /// to every topic manager must be the ATOMIC SUBJECT of the submitted
6975    /// BEEF, not whatever sits last in wire order. bsv-rs < 0.3.20's
6976    /// `from_beef(_, None)` picked `txs.last()`; wallet serializers do not all
6977    /// place the subject last, so a TM could be handed a PARENT to validate.
6978    /// This pins the engine on the fixed rule (`txid ?? atomicTxid ?? last`).
6979    #[tokio::test]
6980    async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
6981        use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
6982        use std::cell::RefCell;
6983        use std::rc::Rc;
6984
6985        // Rebuild the fixture chain SUBJECT-FIRST via to_writer (to_binary
6986        // re-sorts parents-first and would hide the trigger).
6987        let parsed = Beef::from_binary(&test_beef()).unwrap();
6988        let parent = parsed.txs[0].tx().unwrap().clone();
6989        let child = parsed.txs[1].tx().unwrap().clone();
6990        let child_id = child.id();
6991        let parent_id = parent.id();
6992        let mut beef = Beef::new();
6993        // The parent's BUMP rides along: the reference-parity walk
6994        // (2026-09-08) executes the subject's script against the parent and
6995        // then trusts the parent on its proof; without the BUMP it would
6996        // demand the parent's own ancestry ("Input 0 has no source
6997        // transaction"), which this fixture does not carry.
6998        for bump in &parsed.bumps {
6999            beef.merge_bump(bump.clone());
7000        }
7001        beef.merge_transaction(child);
7002        beef.merge_transaction(parent);
7003        let mut w = bsv_rs::primitives::encoding::Writer::new();
7004        w.write_u32_le(ATOMIC_BEEF);
7005        let mut le = hex::decode(&child_id).unwrap();
7006        le.reverse();
7007        w.write_bytes(&le);
7008        beef.to_writer(&mut w);
7009        let atomic_wire_order = w.into_bytes();
7010
7011        // A TM that records the txid it is handed.
7012        struct RecordingTm(Rc<RefCell<Option<String>>>);
7013        #[async_trait(?Send)]
7014        impl TopicManagerTrait for RecordingTm {
7015            async fn identify_admissible_outputs(
7016                &self,
7017                tx: &Transaction,
7018                _previous_coins: &[u8],
7019                _off_chain_values: Option<&[u8]>,
7020                _mode: SubmitMode,
7021                _context: &TopicAdmittanceContext,
7022            ) -> Result<AdmittanceInstructions, TopicManagerError> {
7023                *self.0.borrow_mut() = Some(tx.id());
7024                Ok(AdmittanceInstructions {
7025                    outputs_to_admit: vec![0],
7026                    coins_to_retain: vec![],
7027                    coins_removed: None,
7028                })
7029            }
7030            async fn get_documentation(&self) -> String {
7031                String::new()
7032            }
7033            async fn get_metadata(&self) -> ServiceMetadata {
7034                ServiceMetadata {
7035                    name: "tm_test".to_string(),
7036                    ..Default::default()
7037                }
7038            }
7039        }
7040
7041        let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
7042        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7043        managers.insert(
7044            "tm_test".to_string(),
7045            Box::new(RecordingTm(Rc::clone(&seen))),
7046        );
7047        let engine = Engine::new(
7048            managers,
7049            HashMap::new(),
7050            Box::new(MemoryStorage::new()),
7051            None,
7052            EngineConfig::default(),
7053        );
7054
7055        let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
7056        let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
7057
7058        let handed = seen.borrow().clone().expect("TM was not invoked");
7059        assert_eq!(
7060            handed, child_id,
7061            "TM must be handed the atomic subject, got the wire-order pick"
7062        );
7063        assert_ne!(handed, parent_id);
7064    }
7065
7066    /// #4 REGRESSION: history hydration must never DOWNGRADE provenance.
7067    ///
7068    /// `get_utxo_history` used to `Transaction::from_beef(beef, None)` and then
7069    /// `tx.to_beef(true)`, which keeps only the subject and DROPS the stored
7070    /// BUMPs. Measured on prod before the fix: `ls_low` byGameId `73de6e48…`
7071    /// returned 1,172 B / 1 tx / 1 bump (PROVEN) with no header, and 676 B /
7072    /// 1 tx / 0 bumps with `x-history-depth: 3`. A caller asking for MORE
7073    /// provenance got a BEEF no wallet can verify — silently, behind a 200.
7074    ///
7075    /// There was NO test on this function at all, which is exactly how that
7076    /// shipped. This one asserts the invariant that matters: whatever else
7077    /// hydration does, a proof that went in comes back out.
7078    #[tokio::test]
7079    async fn history_hydration_preserves_the_stored_proof() {
7080        use bsv_rs::transaction::Beef;
7081
7082        let engine = make_engine(vec![0]);
7083        let stored = test_beef();
7084        let bumps_before = Beef::from_binary(&stored)
7085            .expect("fixture BEEF parses")
7086            .bumps
7087            .len();
7088        assert!(
7089            bumps_before > 0,
7090            "fixture must actually carry a proof, or this cell proves nothing"
7091        );
7092
7093        let output = Output {
7094            txid: TEST_TXID.to_string(),
7095            output_index: 0,
7096            output_script: vec![0x76, 0xa9],
7097            satoshis: 1000,
7098            topic: "tm_test".to_string(),
7099            spent: false,
7100            // No admitted parents: hydration has nothing to add, so anything
7101            // it REMOVES is pure loss — the cleanest form of the bug.
7102            outputs_consumed: vec![],
7103            consumed_by: vec![],
7104            beef: Some(stored.clone()),
7105            block_height: None,
7106            score: Some(1000.0),
7107        };
7108
7109        let hydrated = engine
7110            .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
7111            .await
7112            .expect("hydration must not error")
7113            .expect("hydration must return the output");
7114        let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
7115            .expect("hydrated BEEF parses");
7116
7117        assert_eq!(
7118            beef.bumps.len(),
7119            bumps_before,
7120            "asking for history must not strip the merkle proof (issue #4)"
7121        );
7122    }
7123
7124    #[tokio::test]
7125    async fn test_submit_unsupported_topic_errors() {
7126        let engine = make_engine(vec![0]);
7127        let beef = test_tagged_beef(vec!["tm_nonexistent"]);
7128
7129        let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
7130        assert!(result.is_err());
7131        assert!(matches!(
7132            result.unwrap_err(),
7133            EngineError::UnsupportedTopic(_)
7134        ));
7135    }
7136
7137    #[tokio::test]
7138    async fn test_submit_admits_outputs() {
7139        let engine = make_engine(vec![0, 1]);
7140        let beef = test_tagged_beef(vec!["tm_test"]);
7141
7142        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7143
7144        // STEAK should have tm_test with outputs 0 and 1 admitted
7145        let instructions = steak.get("tm_test").unwrap();
7146        assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
7147
7148        // Outputs should be in storage with real data
7149        let txid = TEST_TXID;
7150        let found = engine
7151            .storage()
7152            .find_output(txid, 0, Some("tm_test"), None, true)
7153            .await
7154            .unwrap()
7155            .expect("Output 0 should be in storage");
7156
7157        assert_eq!(found.txid, TEST_TXID);
7158        assert_eq!(found.satoshis, 26172); // Real value from BRC62 BEEF
7159        assert!(
7160            !found.output_script.is_empty(),
7161            "Script should be populated"
7162        );
7163        assert!(found.beef.is_some(), "BEEF should be stored");
7164        assert!(!found.spent);
7165    }
7166
7167    #[tokio::test]
7168    async fn test_submit_duplicate_is_skipped() {
7169        let engine = make_engine(vec![0]);
7170        let beef = test_tagged_beef(vec!["tm_test"]);
7171
7172        // First submit
7173        let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7174        assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
7175
7176        // Second submit — should be a dupe, no new outputs admitted
7177        let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7178        assert!(steak2["tm_test"].outputs_to_admit.is_empty());
7179    }
7180
7181    #[tokio::test]
7182    async fn test_submit_notifies_lookup_service() {
7183        let engine = make_engine(vec![0]);
7184        let beef = test_tagged_beef(vec!["tm_test"]);
7185
7186        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7187
7188        // Lookup service should have the output
7189        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7190        let answer = engine.lookup(&question, None).await.unwrap();
7191
7192        match answer {
7193            LookupAnswer::OutputList { outputs } => {
7194                assert_eq!(outputs.len(), 1);
7195            }
7196            _ => panic!("Expected OutputList"),
7197        }
7198    }
7199
7200    /// #289: `lookup_with_txids` returns the storage txid per hydrated
7201    /// output, aligned with the OutputList — the aggregated serializer
7202    /// writes it instead of re-hashing the BEEF.
7203    #[tokio::test]
7204    async fn test_lookup_with_txids_aligns_txids_with_outputs() {
7205        let engine = make_engine(vec![0]);
7206        let beef = test_tagged_beef(vec!["tm_test"]);
7207        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7208
7209        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7210        let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7211        match answer {
7212            LookupAnswer::OutputList { outputs } => {
7213                assert_eq!(outputs.len(), 1);
7214                assert_eq!(txids.len(), outputs.len(), "one txid per output");
7215                assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
7216            }
7217            _ => panic!("Expected OutputList"),
7218        }
7219    }
7220
7221    #[tokio::test]
7222    async fn proof_window_holds_an_aged_child_with_a_proven_parent() {
7223        let mut engine = make_engine(vec![0]);
7224        let storage = std::rc::Rc::new(MemoryStorage::new());
7225        engine.storage = Box::new(storage.clone());
7226        engine
7227            .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7228            .await
7229            .unwrap();
7230        assert!(beef_limits::own_proof(&test_beef(), TEST_TXID)
7231            .unwrap()
7232            .is_none());
7233        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7234        storage.advance_clock((crate::proof_window::PROOFLESS_FLAG_MS / 1000) as u64);
7235        assert_eq!(
7236            engine
7237                .lookup_with_txids(&question, None)
7238                .await
7239                .unwrap()
7240                .1
7241                .len(),
7242            1
7243        );
7244        storage.advance_clock(1);
7245        assert!(
7246            engine
7247                .lookup_with_txids(&question, None)
7248                .await
7249                .unwrap()
7250                .1
7251                .is_empty(),
7252            "an aged unproven child must be held even when its parent is proven"
7253        );
7254    }
7255
7256    #[tokio::test]
7257    async fn proof_window_peer_proof_verifies_then_restores_lookup() {
7258        use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7259        let mut engine = make_engine(vec![0]);
7260        let storage = std::rc::Rc::new(MemoryStorage::new());
7261        engine.storage = Box::new(storage.clone());
7262        engine
7263            .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7264            .await
7265            .unwrap();
7266        storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7267        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7268        let candidate = HeldOutput {
7269            txid: TEST_TXID.into(),
7270            output_index: 0,
7271            topic: "tm_test".into(),
7272        };
7273        let (_, mut remote) = budget_page_engine(1);
7274        remote.node_cost_ms = 0;
7275        remote.nodes = std::rc::Rc::new(HashMap::from([(
7276            TEST_TXID.into(),
7277            crate::types::GASPNode {
7278                graph_id: String::new(),
7279                raw_tx: String::new(),
7280                output_index: 0,
7281                proof: Some(single_leaf_bump_hex(TEST_TXID, 800_000)),
7282                tx_metadata: None,
7283                output_metadata: None,
7284                inputs: None,
7285            },
7286        )]));
7287        engine
7288            .config
7289            .sync_configuration
7290            .insert("tm_test".into(), SyncTarget::Ship);
7291        engine.ship_bootstrap_peers.insert(
7292            "tm_test".into(),
7293            vec!["https://a.example".into(), "https://b.example".into()],
7294        );
7295        engine.set_gasp_remote_factory(Box::new(remote));
7296        let progress = std::cell::Cell::new(PeerProofSummary::default());
7297        engine.chain_tracker = Some(Box::new(bsv_rs::transaction::MockChainTracker::new(
7298            800_001,
7299        )));
7300        engine
7301            .rerequest_held_proofs(std::slice::from_ref(&candidate), &progress)
7302            .await;
7303        assert_eq!(progress.get().requests, 2, "ask every topic peer");
7304        assert_eq!(progress.get().landed, 0);
7305        assert_eq!(progress.get().refused, 2);
7306        assert!(engine
7307            .lookup_with_txids(&question, None)
7308            .await
7309            .unwrap()
7310            .1
7311            .is_empty());
7312        let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7313        tracker.add_root(800_000, TEST_TXID.into());
7314        engine.chain_tracker = Some(Box::new(tracker));
7315        engine.rerequest_held_proofs(&[candidate], &progress).await;
7316        assert_eq!(progress.get().requests, 4);
7317        assert_eq!(
7318            progress.get().landed,
7319            1,
7320            "count durable proofs, not peer answers"
7321        );
7322        let (_, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7323        assert_eq!(txids, vec![TEST_TXID]);
7324        let stored = storage
7325            .find_output(TEST_TXID, 0, None, None, true)
7326            .await
7327            .unwrap()
7328            .unwrap();
7329        assert!(
7330            beef_limits::own_proof(stored.beef.as_ref().unwrap(), TEST_TXID)
7331                .unwrap()
7332                .is_some()
7333        );
7334        assert_eq!(storage.proven_at_height(TEST_TXID), Some(Some(800_000)));
7335    }
7336
7337    #[tokio::test]
7338    async fn proof_window_peer_hash_leaf_is_stored_as_own_proof() {
7339        use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7340        let mut engine = make_engine(vec![0]);
7341        let storage = std::rc::Rc::new(MemoryStorage::new());
7342        engine.storage = Box::new(storage.clone());
7343        engine
7344            .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7345            .await
7346            .unwrap();
7347        storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7348        let mut proof =
7349            bsv_rs::transaction::MerklePath::from_hex(&single_leaf_bump_hex(TEST_TXID, 800_000))
7350                .unwrap();
7351        proof.path[0][0].txid = false;
7352        let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7353        tracker.add_root(800_000, proof.compute_root(Some(TEST_TXID)).unwrap());
7354        engine.chain_tracker = Some(Box::new(tracker));
7355        let (_, mut remote) = budget_page_engine(1);
7356        remote.node_cost_ms = 0;
7357        remote.nodes = std::rc::Rc::new(HashMap::from([(
7358            TEST_TXID.into(),
7359            crate::types::GASPNode {
7360                graph_id: String::new(),
7361                raw_tx: String::new(),
7362                output_index: 0,
7363                proof: Some(proof.to_hex()),
7364                tx_metadata: None,
7365                output_metadata: None,
7366                inputs: None,
7367            },
7368        )]));
7369        engine.config.sync_configuration.insert(
7370            "tm_test".into(),
7371            SyncTarget::Peers(vec!["https://peer.example".into()]),
7372        );
7373        engine.set_gasp_remote_factory(Box::new(remote));
7374        let progress = std::cell::Cell::new(PeerProofSummary::default());
7375        engine
7376            .rerequest_held_proofs(
7377                &[HeldOutput {
7378                    txid: TEST_TXID.into(),
7379                    output_index: 0,
7380                    topic: "tm_test".into(),
7381                }],
7382                &progress,
7383            )
7384            .await;
7385        assert_eq!(progress.get().landed, 1);
7386        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7387        let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7388        assert_eq!(txids, vec![TEST_TXID]);
7389        let LookupAnswer::OutputList { outputs } = answer else {
7390            panic!("expected outputs")
7391        };
7392        assert!(
7393            beef_limits::own_proof(&outputs[0].beef, TEST_TXID)
7394                .unwrap()
7395                .is_some(),
7396            "a proven flag must have the child's own path in the served BEEF"
7397        );
7398    }
7399
7400    #[tokio::test]
7401    async fn proof_window_silent_peers_yield_at_the_request_budget() {
7402        use crate::proof_window::{HeldOutput, PeerProofSummary, PEER_PROOF_REQUEST_MS};
7403        let (mut engine, remote) = budget_page_engine(1);
7404        engine.config.sync_configuration.insert(
7405            "tm_test".into(),
7406            SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
7407        );
7408        let elapsed = remote.elapsed.clone();
7409        engine.set_peer_sync_budget(
7410            std::rc::Rc::new(move |ms| {
7411                assert_eq!(ms, PEER_PROOF_REQUEST_MS);
7412                let deadline = elapsed.get() + ms;
7413                let elapsed = elapsed.clone();
7414                Box::pin(std::future::poll_fn(move |_| {
7415                    if elapsed.get() >= deadline {
7416                        std::task::Poll::Ready(())
7417                    } else {
7418                        std::task::Poll::Pending
7419                    }
7420                }))
7421            }),
7422            30_000,
7423        );
7424        let candidate = HeldOutput {
7425            txid: remote.outputs[0].txid.clone(),
7426            output_index: 0,
7427            topic: "tm_test".into(),
7428        };
7429        engine.set_gasp_remote_factory(Box::new(remote));
7430        let progress = std::cell::Cell::new(PeerProofSummary::default());
7431        engine.rerequest_held_proofs(&[candidate], &progress).await;
7432        assert_eq!(progress.get().requests, 2);
7433        assert_eq!(progress.get().faults, 2);
7434        assert_eq!(progress.get().landed, 0);
7435    }
7436
7437    /// #291: the public sync-response page size is always bounded.
7438    #[test]
7439    fn sync_limit_clamp() {
7440        assert_eq!(
7441            Engine::clamp_sync_limit(None),
7442            Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
7443            "absent limit gets the default page, never unbounded"
7444        );
7445        assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
7446        assert_eq!(
7447            Engine::clamp_sync_limit(Some(u64::MAX)),
7448            Engine::SYNC_RESPONSE_MAX_LIMIT,
7449            "a huge caller-supplied limit is capped"
7450        );
7451    }
7452
7453    #[tokio::test]
7454    async fn test_lookup_unknown_service_errors() {
7455        let engine = make_engine(vec![]);
7456        let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
7457
7458        let result = engine.lookup(&question, None).await;
7459        assert!(result.is_err());
7460        assert!(matches!(
7461            result.unwrap_err(),
7462            EngineError::LookupServiceNotFound(_)
7463        ));
7464    }
7465
7466    #[tokio::test]
7467    async fn test_list_topic_managers() {
7468        let engine = make_engine(vec![]);
7469        let managers = engine.list_topic_managers().await;
7470
7471        assert_eq!(managers.len(), 1);
7472        assert!(managers.contains_key("tm_test"));
7473        assert_eq!(managers["tm_test"].name, "mock-tm");
7474    }
7475
7476    #[tokio::test]
7477    async fn test_list_lookup_service_providers() {
7478        let engine = make_engine(vec![]);
7479        let services = engine.list_lookup_service_providers().await;
7480
7481        assert_eq!(services.len(), 1);
7482        assert!(services.contains_key("ls_test"));
7483        assert_eq!(services["ls_test"].name, "mock-ls");
7484    }
7485
7486    #[tokio::test]
7487    async fn test_get_documentation() {
7488        let engine = make_engine(vec![]);
7489
7490        let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
7491        assert_eq!(tm_docs, "Mock topic manager");
7492
7493        let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
7494        assert_eq!(ls_docs, "Mock lookup service");
7495
7496        let missing = engine
7497            .get_documentation_for_topic_manager("tm_missing")
7498            .await;
7499        assert_eq!(missing, "No documentation found!");
7500    }
7501
7502    #[tokio::test]
7503    async fn test_provide_foreign_sync_response() {
7504        let engine = make_engine(vec![0]);
7505
7506        // Submit data
7507        let beef = test_tagged_beef(vec!["tm_test"]);
7508        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7509
7510        // Query GASP sync
7511        let request = GASPInitialRequest {
7512            version: 1,
7513            since: 0,
7514            limit: Some(100),
7515        };
7516        let response = engine
7517            .provide_foreign_sync_response(&request, "tm_test")
7518            .await
7519            .unwrap();
7520
7521        // One tx admitted with output index 0
7522        assert_eq!(response.utxo_list.len(), 1);
7523        assert_eq!(response.utxo_list[0].txid, TEST_TXID);
7524        assert_eq!(response.since, 0);
7525    }
7526
7527    #[tokio::test]
7528    async fn test_delete_utxo_deep_simple() {
7529        let engine = make_engine(vec![0]);
7530        let beef = test_tagged_beef(vec!["tm_test"]);
7531
7532        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7533
7534        let txid = TEST_TXID;
7535        let output = engine
7536            .storage()
7537            .find_output(txid, 0, Some("tm_test"), None, false)
7538            .await
7539            .unwrap()
7540            .unwrap();
7541
7542        // Delete it
7543        engine.delete_utxo_deep(&output, None).await.unwrap();
7544
7545        // Should be gone
7546        let found = engine
7547            .storage()
7548            .find_output(txid, 0, Some("tm_test"), None, false)
7549            .await
7550            .unwrap();
7551        assert!(found.is_none());
7552    }
7553
7554    #[tokio::test]
7555    async fn test_sync_config_defaults_to_ship() {
7556        let engine = make_engine(vec![]);
7557        let sync = &engine.config.sync_configuration;
7558
7559        // tm_test should default to SHIP sync
7560        assert!(sync.contains_key("tm_test"));
7561        assert!(matches!(sync["tm_test"], SyncTarget::Ship));
7562    }
7563
7564    #[tokio::test]
7565    async fn test_multiple_topics_in_single_submit() {
7566        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7567        managers.insert(
7568            "tm_alpha".to_string(),
7569            Box::new(MockTopicManager::admitting(vec![0])),
7570        );
7571        managers.insert(
7572            "tm_beta".to_string(),
7573            Box::new(MockTopicManager::admitting(vec![1])),
7574        );
7575
7576        let engine = Engine::new(
7577            managers,
7578            HashMap::new(),
7579            Box::new(MemoryStorage::new()),
7580            None,
7581            EngineConfig::default(),
7582        );
7583
7584        let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
7585
7586        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7587
7588        assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
7589        assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
7590    }
7591
7592    #[tokio::test]
7593    async fn test_evict_output_with_topic() {
7594        let engine = make_engine(vec![0]);
7595        let beef = test_tagged_beef(vec!["tm_test"]);
7596        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7597
7598        // Output should exist
7599        let found = engine
7600            .storage()
7601            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7602            .await
7603            .unwrap();
7604        assert!(found.is_some(), "Output should exist before eviction");
7605
7606        // Evict with topic
7607        engine
7608            .evict_output(TEST_TXID, 0, Some("tm_test"))
7609            .await
7610            .unwrap();
7611
7612        // Output should be gone from storage
7613        let found = engine
7614            .storage()
7615            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7616            .await
7617            .unwrap();
7618        assert!(found.is_none(), "Output should be gone after eviction");
7619
7620        // Lookup service should also have been notified (output evicted)
7621        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7622        let answer = engine.lookup(&question, None).await.unwrap();
7623        match answer {
7624            LookupAnswer::OutputList { outputs } => {
7625                assert!(
7626                    outputs.is_empty(),
7627                    "Lookup should return empty after eviction"
7628                );
7629            }
7630            _ => panic!("Expected OutputList"),
7631        }
7632    }
7633
7634    #[tokio::test]
7635    async fn test_evict_output_without_topic() {
7636        let engine = make_engine(vec![0]);
7637        let beef = test_tagged_beef(vec!["tm_test"]);
7638        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7639
7640        // Evict without topic — should find and remove across all topics
7641        engine.evict_output(TEST_TXID, 0, None).await.unwrap();
7642
7643        let found = engine
7644            .storage()
7645            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7646            .await
7647            .unwrap();
7648        assert!(
7649            found.is_none(),
7650            "Output should be gone after topic-less eviction"
7651        );
7652    }
7653
7654    #[tokio::test]
7655    async fn test_evict_nonexistent_output_is_ok() {
7656        let engine = make_engine(vec![]);
7657
7658        // Evicting something that doesn't exist should not error
7659        let result = engine
7660            .evict_output("nonexistent_txid", 99, Some("tm_test"))
7661            .await;
7662        assert!(result.is_ok(), "Evicting nonexistent output should succeed");
7663    }
7664
7665    // ── Mock Broadcaster ──────────────────────────────────────────────
7666
7667    use crate::broadcaster::Broadcaster;
7668    use std::sync::Arc;
7669
7670    type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
7671
7672    struct MockBroadcaster {
7673        calls: BroadcastCallLog,
7674    }
7675
7676    impl MockBroadcaster {
7677        fn new() -> Self {
7678            Self {
7679                calls: Arc::new(Mutex::new(Vec::new())),
7680            }
7681        }
7682
7683        #[allow(dead_code, reason = "kept for future test extension")]
7684        fn call_count(&self) -> usize {
7685            self.calls.lock().unwrap().len()
7686        }
7687
7688        #[allow(dead_code, reason = "kept for future test extension")]
7689        fn calls(&self) -> Vec<(String, Vec<String>)> {
7690            self.calls.lock().unwrap().clone()
7691        }
7692    }
7693
7694    #[async_trait(?Send)]
7695    impl Broadcaster for MockBroadcaster {
7696        async fn broadcast_to_host(
7697            &self,
7698            host_url: &str,
7699            tagged_beef: &TaggedBEEF,
7700        ) -> Result<(), String> {
7701            self.calls
7702                .lock()
7703                .unwrap()
7704                .push((host_url.to_string(), tagged_beef.topics.clone()));
7705            Ok(())
7706        }
7707    }
7708
7709    // ── Mock SHIP LookupService ─────────────────────────────────────
7710
7711    /// A mock "ls_ship" lookup service that returns pre-configured UTXOReferences
7712    /// when queried. Used to test SHIP propagation without depending on
7713    /// overlay-discovery in the engine crate's unit tests.
7714    struct MockSHIPLookupService {
7715        /// Maps topic -> list of (txid, output_index) references
7716        records: Mutex<HashMap<String, Vec<UTXOReference>>>,
7717    }
7718
7719    impl MockSHIPLookupService {
7720        fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
7721            Self {
7722                records: Mutex::new(records),
7723            }
7724        }
7725    }
7726
7727    #[async_trait(?Send)]
7728    impl LookupServiceTrait for MockSHIPLookupService {
7729        fn admission_mode(&self) -> AdmissionMode {
7730            AdmissionMode::LockingScript
7731        }
7732
7733        fn spend_notification_mode(&self) -> SpendNotificationMode {
7734            SpendNotificationMode::None
7735        }
7736
7737        async fn output_admitted_by_topic(
7738            &self,
7739            _payload: &OutputAdmittedByTopic,
7740        ) -> Result<(), LookupServiceError> {
7741            Ok(())
7742        }
7743
7744        async fn output_evicted(
7745            &self,
7746            _txid: &str,
7747            _output_index: u32,
7748        ) -> Result<(), LookupServiceError> {
7749            Ok(())
7750        }
7751
7752        async fn lookup(
7753            &self,
7754            question: &LookupQuestion,
7755        ) -> Result<LookupResult, LookupServiceError> {
7756            // Parse the topics from the query
7757            if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
7758                let records = self.records.lock().unwrap();
7759                let mut results = Vec::new();
7760                for topic_val in topics {
7761                    if let Some(topic) = topic_val.as_str() {
7762                        if let Some(refs) = records.get(topic) {
7763                            results.extend(refs.iter().cloned());
7764                        }
7765                    }
7766                }
7767                return Ok(LookupResult::OutputList(results));
7768            }
7769            Ok(LookupResult::OutputList(vec![]))
7770        }
7771
7772        async fn get_documentation(&self) -> String {
7773            "Mock SHIP lookup service".to_string()
7774        }
7775
7776        async fn get_metadata(&self) -> ServiceMetadata {
7777            ServiceMetadata {
7778                name: "mock-ls-ship".to_string(),
7779                ..Default::default()
7780            }
7781        }
7782    }
7783
7784    /// Build a minimal PushDrop locking script for a SHIP advertisement.
7785    fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
7786        use bsv_rs::script::templates::PushDrop;
7787        use bsv_rs::PublicKey;
7788
7789        let fields = vec![
7790            b"SHIP".to_vec(),
7791            vec![0x02; 33], // fake compressed pubkey bytes for identity_key field
7792            domain.as_bytes().to_vec(),
7793            topic.as_bytes().to_vec(),
7794        ];
7795
7796        // PushDrop requires a real compressed public key for the locking script
7797        // Use a well-known test key (generator point G)
7798        let pubkey = PublicKey::from_hex(
7799            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
7800        )
7801        .expect("valid test pubkey");
7802
7803        let pd = PushDrop::new(pubkey, fields);
7804        pd.lock().to_binary()
7805    }
7806
7807    /// Create engine with a broadcaster and a mock SHIP lookup service.
7808    /// The `ship_domains` parameter provides (domain, topic) pairs to pre-populate.
7809    async fn make_engine_with_broadcaster(
7810        admit_indices: Vec<u32>,
7811        broadcaster: MockBroadcaster,
7812        ship_domains: Vec<(&str, &str)>,
7813        hosting_url: Option<&str>,
7814    ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
7815        let calls = broadcaster.calls.clone();
7816
7817        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7818        managers.insert(
7819            "tm_test".to_string(),
7820            Box::new(MockTopicManager::admitting(admit_indices)),
7821        );
7822
7823        let storage = Box::new(MemoryStorage::new());
7824
7825        // Build SHIP records: map each topic to its UTXO references and
7826        // store outputs in storage with PushDrop scripts so the engine can
7827        // parse the domain when broadcasting.
7828        let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
7829
7830        for (domain, topic) in &ship_domains {
7831            // Deterministic fake txid per domain+topic
7832            let fake_txid = format!("{:064x}", {
7833                let mut h: u64 = 0;
7834                for b in domain.bytes().chain(topic.bytes()) {
7835                    h = h.wrapping_mul(31).wrapping_add(u64::from(b));
7836                }
7837                h
7838            });
7839
7840            // Add to SHIP records for the mock lookup service
7841            ship_records
7842                .entry(topic.to_string())
7843                .or_default()
7844                .push(UTXOReference {
7845                    txid: fake_txid.clone(),
7846                    output_index: 0,
7847                });
7848
7849            // Build PushDrop SHIP script and store as an output in main storage
7850            let script = build_ship_pushdrop_script(domain, topic);
7851
7852            let output = Output {
7853                txid: fake_txid,
7854                output_index: 0,
7855                output_script: script,
7856                satoshis: 1,
7857                topic: "tm_ship".to_string(),
7858                spent: false,
7859                outputs_consumed: vec![],
7860                consumed_by: vec![],
7861                beef: None,
7862                block_height: None,
7863                score: None,
7864            };
7865
7866            storage.insert_output(&output).await.unwrap();
7867        }
7868
7869        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7870        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7871        lookup_services.insert(
7872            "ls_ship".to_string(),
7873            Box::new(MockSHIPLookupService::with_records(ship_records)),
7874        );
7875
7876        let config = EngineConfig {
7877            hosting_url: hosting_url.map(str::to_string),
7878            ..Default::default()
7879        };
7880
7881        let engine = Engine::with_chain_tracker(
7882            managers,
7883            lookup_services,
7884            storage,
7885            None,
7886            Some(Box::new(broadcaster)),
7887            None,
7888            config,
7889        );
7890
7891        (engine, calls)
7892    }
7893
7894    // ── Broadcaster Tests ─────────────────────────────────────────────
7895
7896    #[tokio::test]
7897    async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
7898        let broadcaster = MockBroadcaster::new();
7899        let (engine, calls) = make_engine_with_broadcaster(
7900            vec![0],
7901            broadcaster,
7902            vec![("https://peer1.example.com", "tm_test")],
7903            Some("https://self.example.com"),
7904        )
7905        .await;
7906
7907        let beef = test_tagged_beef(vec!["tm_test"]);
7908        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7909
7910        // Submit should succeed
7911        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7912
7913        // Broadcaster should have been called for peer1
7914        let recorded = calls.lock().unwrap();
7915        assert_eq!(
7916            recorded.len(),
7917            1,
7918            "Broadcaster should be called once for the one SHIP peer"
7919        );
7920        assert_eq!(recorded[0].0, "https://peer1.example.com");
7921    }
7922
7923    #[tokio::test]
7924    async fn test_broadcaster_not_called_on_historical_tx() {
7925        let broadcaster = MockBroadcaster::new();
7926        let (engine, calls) = make_engine_with_broadcaster(
7927            vec![0],
7928            broadcaster,
7929            vec![("https://peer1.example.com", "tm_test")],
7930            None,
7931        )
7932        .await;
7933
7934        let beef = test_tagged_beef(vec!["tm_test"]);
7935        let steak = engine
7936            .submit(&beef, SubmitMode::HistoricalTx)
7937            .await
7938            .unwrap();
7939
7940        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7941
7942        // Broadcaster should NOT have been called
7943        assert_eq!(
7944            calls.lock().unwrap().len(),
7945            0,
7946            "Broadcaster should not be called for historical TX"
7947        );
7948    }
7949
7950    #[tokio::test]
7951    async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
7952        let broadcaster = MockBroadcaster::new();
7953        let (engine, calls) = make_engine_with_broadcaster(
7954            vec![0],
7955            broadcaster,
7956            vec![("https://peer1.example.com", "tm_test")],
7957            None,
7958        )
7959        .await;
7960
7961        let beef = test_tagged_beef(vec!["tm_test"]);
7962        let steak = engine
7963            .submit(&beef, SubmitMode::HistoricalTxNoSpv)
7964            .await
7965            .unwrap();
7966
7967        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
7968
7969        assert_eq!(
7970            calls.lock().unwrap().len(),
7971            0,
7972            "Broadcaster should not be called for historical-tx-no-spv"
7973        );
7974    }
7975
7976    #[tokio::test]
7977    async fn test_broadcaster_skips_self_hosting_url() {
7978        let broadcaster = MockBroadcaster::new();
7979        let (engine, calls) = make_engine_with_broadcaster(
7980            vec![0],
7981            broadcaster,
7982            vec![
7983                ("https://self.example.com", "tm_test"),
7984                ("https://peer2.example.com", "tm_test"),
7985            ],
7986            Some("https://self.example.com"),
7987        )
7988        .await;
7989
7990        let beef = test_tagged_beef(vec!["tm_test"]);
7991        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7992
7993        // Should only broadcast to peer2, not to self
7994        let recorded = calls.lock().unwrap();
7995        assert_eq!(
7996            recorded.len(),
7997            1,
7998            "Should broadcast to peer2 only, skipping self"
7999        );
8000        assert_eq!(recorded[0].0, "https://peer2.example.com");
8001    }
8002
8003    #[tokio::test]
8004    async fn test_broadcaster_not_called_when_no_outputs_admitted() {
8005        let broadcaster = MockBroadcaster::new();
8006        // Topic manager admits nothing
8007        let (engine, calls) = make_engine_with_broadcaster(
8008            vec![],
8009            broadcaster,
8010            vec![("https://peer1.example.com", "tm_test")],
8011            None,
8012        )
8013        .await;
8014
8015        let beef = test_tagged_beef(vec!["tm_test"]);
8016        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8017
8018        assert!(steak["tm_test"].outputs_to_admit.is_empty());
8019
8020        // No outputs admitted -> no broadcast
8021        assert_eq!(
8022            calls.lock().unwrap().len(),
8023            0,
8024            "Broadcaster should not be called when no outputs are admitted"
8025        );
8026    }
8027
8028    #[tokio::test]
8029    async fn test_broadcaster_not_called_when_no_ship_peers() {
8030        let broadcaster = MockBroadcaster::new();
8031        // No SHIP peers registered
8032        let (engine, calls) = make_engine_with_broadcaster(
8033            vec![0],
8034            broadcaster,
8035            vec![], // no SHIP peers
8036            None,
8037        )
8038        .await;
8039
8040        let beef = test_tagged_beef(vec!["tm_test"]);
8041        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8042
8043        assert_eq!(
8044            calls.lock().unwrap().len(),
8045            0,
8046            "Broadcaster should not be called when no SHIP peers exist"
8047        );
8048    }
8049
8050    // ── Configurable SpendNotification mock ─────────────────────────────
8051
8052    /// A mock lookup service whose SpendNotificationMode can be configured.
8053    /// Uses a shared Arc<Mutex<Vec<OutputSpent>>> so the caller can inspect
8054    /// captured payloads after submit().
8055    struct SpendModeLookupService {
8056        mode: SpendNotificationMode,
8057        records: Mutex<Vec<UTXOReference>>,
8058        spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
8059    }
8060
8061    impl SpendModeLookupService {
8062        fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
8063            Self {
8064                mode,
8065                records: Mutex::new(Vec::new()),
8066                spent_payloads: capture,
8067            }
8068        }
8069    }
8070
8071    #[async_trait(?Send)]
8072    impl LookupServiceTrait for SpendModeLookupService {
8073        fn admission_mode(&self) -> AdmissionMode {
8074            AdmissionMode::LockingScript
8075        }
8076
8077        fn spend_notification_mode(&self) -> SpendNotificationMode {
8078            self.mode
8079        }
8080
8081        async fn output_admitted_by_topic(
8082            &self,
8083            payload: &OutputAdmittedByTopic,
8084        ) -> Result<(), LookupServiceError> {
8085            let (txid, oi) = match payload {
8086                OutputAdmittedByTopic::LockingScript {
8087                    txid, output_index, ..
8088                } => (txid.clone(), *output_index),
8089                OutputAdmittedByTopic::WholeTx { output_index, .. } => {
8090                    ("whole".into(), *output_index)
8091                }
8092            };
8093            self.records.lock().unwrap().push(UTXOReference {
8094                txid,
8095                output_index: oi,
8096            });
8097            Ok(())
8098        }
8099
8100        async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
8101            self.spent_payloads.lock().unwrap().push(payload.clone());
8102            Ok(())
8103        }
8104
8105        async fn output_evicted(
8106            &self,
8107            txid: &str,
8108            output_index: u32,
8109        ) -> Result<(), LookupServiceError> {
8110            self.records
8111                .lock()
8112                .unwrap()
8113                .retain(|r| !(r.txid == txid && r.output_index == output_index));
8114            Ok(())
8115        }
8116
8117        async fn lookup(
8118            &self,
8119            _question: &LookupQuestion,
8120        ) -> Result<LookupResult, LookupServiceError> {
8121            Ok(LookupResult::OutputList(
8122                self.records.lock().unwrap().clone(),
8123            ))
8124        }
8125
8126        async fn get_documentation(&self) -> String {
8127            "SpendMode mock lookup service".to_string()
8128        }
8129
8130        async fn get_metadata(&self) -> ServiceMetadata {
8131            ServiceMetadata {
8132                name: "spend-mode-ls".to_string(),
8133                ..Default::default()
8134            }
8135        }
8136    }
8137
8138    /// TXID of the input's source transaction in TEST_BEEF_HEX (the "previous coin").
8139    const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
8140
8141    /// Build an engine with a pre-populated previous output and a SpendModeLookupService.
8142    /// Returns the engine and the shared capture vec for inspecting OutputSpent payloads.
8143    async fn make_spend_mode_engine(
8144        mode: SpendNotificationMode,
8145    ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
8146        let storage = MemoryStorage::new();
8147
8148        // Pre-populate storage with the previous output that TEST_BEEF_HEX's input spends.
8149        let prev_output = Output {
8150            txid: PREVIOUS_TXID.to_string(),
8151            output_index: 0,
8152            output_script: vec![0x76, 0xa9],
8153            satoshis: 26174,
8154            topic: "tm_test".to_string(),
8155            spent: false,
8156            outputs_consumed: vec![],
8157            consumed_by: vec![],
8158            beef: Some(test_beef()),
8159            block_height: None,
8160            score: Some(1000.0),
8161        };
8162        storage.insert_output(&prev_output).await.unwrap();
8163
8164        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8165        managers.insert(
8166            "tm_test".to_string(),
8167            Box::new(MockTopicManager::admitting(vec![0])),
8168        );
8169
8170        let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
8171        let ls = SpendModeLookupService::with_mode(mode, capture.clone());
8172        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8173        lookup_services.insert("ls_test".to_string(), Box::new(ls));
8174
8175        let engine = Engine::new(
8176            managers,
8177            lookup_services,
8178            Box::new(storage),
8179            None,
8180            EngineConfig::default(),
8181        );
8182
8183        (engine, capture)
8184    }
8185
8186    #[tokio::test]
8187    async fn test_spend_notification_mode_script() {
8188        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
8189
8190        let beef = test_tagged_beef(vec!["tm_test"]);
8191        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8192
8193        // The new output should be admitted
8194        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8195
8196        // The lookup service should have received a Script spend notification
8197        let payloads = capture.lock().unwrap().clone();
8198        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8199
8200        match &payloads[0] {
8201            OutputSpent::Script {
8202                txid,
8203                output_index,
8204                topic,
8205                spending_txid,
8206                input_index,
8207                unlocking_script,
8208                sequence_number,
8209                ..
8210            } => {
8211                assert_eq!(txid, PREVIOUS_TXID);
8212                assert_eq!(*output_index, 0);
8213                assert_eq!(topic, "tm_test");
8214                assert_eq!(spending_txid, TEST_TXID);
8215                // The BEEF has a single input at index 0 that spends PREVIOUS_TXID:0
8216                assert_eq!(*input_index, 0);
8217                // The unlocking script should be non-empty (it's a P2PKH scriptSig)
8218                assert!(
8219                    !unlocking_script.is_empty(),
8220                    "Unlocking script should be non-empty"
8221                );
8222                // Standard final sequence number
8223                assert_eq!(*sequence_number, 0xffff_ffff);
8224            }
8225            other => panic!("Expected OutputSpent::Script, got: {other:?}"),
8226        }
8227    }
8228
8229    #[tokio::test]
8230    async fn test_spend_notification_mode_whole_tx() {
8231        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
8232
8233        let beef = test_tagged_beef(vec!["tm_test"]);
8234        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8235
8236        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8237
8238        let payloads = capture.lock().unwrap().clone();
8239        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8240
8241        match &payloads[0] {
8242            OutputSpent::WholeTx {
8243                txid,
8244                output_index,
8245                topic,
8246                spending_atomic_beef,
8247                ..
8248            } => {
8249                assert_eq!(txid, PREVIOUS_TXID);
8250                assert_eq!(*output_index, 0);
8251                assert_eq!(topic, "tm_test");
8252                // The spending BEEF is the entire BEEF we submitted, NAMED
8253                // (atomic prefix) for the lookup service's own re-parse.
8254                let mut named =
8255                    bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
8256                assert!(named.is_atomic());
8257                let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
8258                assert_eq!(
8259                    named.txs.len(),
8260                    submitted.txs.len(),
8261                    "the whole body, never pruned"
8262                );
8263                let tip = crate::subject::subject_txid_of(&mut named).unwrap();
8264                assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
8265            }
8266            other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
8267        }
8268    }
8269
8270    #[tokio::test]
8271    async fn test_spend_notification_mode_txid() {
8272        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
8273
8274        let beef = test_tagged_beef(vec!["tm_test"]);
8275        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8276
8277        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8278
8279        let payloads = capture.lock().unwrap().clone();
8280        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8281
8282        match &payloads[0] {
8283            OutputSpent::Txid {
8284                txid,
8285                output_index,
8286                topic,
8287                spending_txid,
8288            } => {
8289                assert_eq!(txid, PREVIOUS_TXID);
8290                assert_eq!(*output_index, 0);
8291                assert_eq!(topic, "tm_test");
8292                assert_eq!(spending_txid, TEST_TXID);
8293            }
8294            other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
8295        }
8296    }
8297
8298    #[tokio::test]
8299    async fn test_spend_notification_mode_none() {
8300        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
8301
8302        let beef = test_tagged_beef(vec!["tm_test"]);
8303        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8304
8305        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8306
8307        let payloads = capture.lock().unwrap().clone();
8308        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8309
8310        match &payloads[0] {
8311            OutputSpent::None {
8312                txid,
8313                output_index,
8314                topic,
8315            } => {
8316                assert_eq!(txid, PREVIOUS_TXID);
8317                assert_eq!(*output_index, 0);
8318                assert_eq!(topic, "tm_test");
8319            }
8320            other => panic!("Expected OutputSpent::None, got: {other:?}"),
8321        }
8322    }
8323
8324    // ── GASP Sync Tests ───────────────────────────────────────────────
8325
8326    #[tokio::test]
8327    async fn test_start_gasp_sync_empty_config() {
8328        let engine = Engine::new(
8329            HashMap::new(),
8330            HashMap::new(),
8331            Box::new(MemoryStorage::new()),
8332            None,
8333            EngineConfig {
8334                sync_configuration: HashMap::new(),
8335                ..Default::default()
8336            },
8337        );
8338
8339        let result = engine.start_gasp_sync().await.unwrap();
8340        assert!(
8341            result.topics_synced.is_empty(),
8342            "No topics should be synced with empty config"
8343        );
8344    }
8345
8346    #[tokio::test]
8347    async fn test_start_gasp_sync_disabled_topic_skipped() {
8348        let mut sync_config: SyncConfiguration = HashMap::new();
8349        sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
8350
8351        let engine = Engine::new(
8352            HashMap::new(),
8353            HashMap::new(),
8354            Box::new(MemoryStorage::new()),
8355            None,
8356            EngineConfig {
8357                sync_configuration: sync_config,
8358                ..Default::default()
8359            },
8360        );
8361
8362        let result = engine.start_gasp_sync().await.unwrap();
8363        assert!(
8364            result.topics_synced.is_empty(),
8365            "Disabled topics should not appear in results"
8366        );
8367    }
8368
8369    #[tokio::test]
8370    async fn test_start_gasp_sync_peers_config() {
8371        let mut sync_config: SyncConfiguration = HashMap::new();
8372        sync_config.insert(
8373            "tm_test".to_string(),
8374            SyncTarget::Peers(vec![
8375                "https://peer1.example.com".to_string(),
8376                "https://peer2.example.com".to_string(),
8377            ]),
8378        );
8379
8380        let engine = Engine::new(
8381            HashMap::new(),
8382            HashMap::new(),
8383            Box::new(MemoryStorage::new()),
8384            None,
8385            EngineConfig {
8386                sync_configuration: sync_config,
8387                ..Default::default()
8388            },
8389        );
8390
8391        let result = engine.start_gasp_sync().await.unwrap();
8392        assert_eq!(result.topics_synced.len(), 1);
8393
8394        let topic_result = &result.topics_synced["tm_test"];
8395        assert_eq!(topic_result.peers.len(), 2);
8396        assert_eq!(topic_result.sync_type, "peers");
8397        assert!(topic_result.errors.is_empty());
8398    }
8399
8400    #[tokio::test]
8401    async fn test_start_gasp_sync_peers_filters_self() {
8402        let mut sync_config: SyncConfiguration = HashMap::new();
8403        sync_config.insert(
8404            "tm_test".to_string(),
8405            SyncTarget::Peers(vec![
8406                "https://self.example.com".to_string(),
8407                "https://peer2.example.com".to_string(),
8408            ]),
8409        );
8410
8411        let engine = Engine::new(
8412            HashMap::new(),
8413            HashMap::new(),
8414            Box::new(MemoryStorage::new()),
8415            None,
8416            EngineConfig {
8417                hosting_url: Some("https://self.example.com".to_string()),
8418                sync_configuration: sync_config,
8419                ..Default::default()
8420            },
8421        );
8422
8423        let result = engine.start_gasp_sync().await.unwrap();
8424        let topic_result = &result.topics_synced["tm_test"];
8425        assert_eq!(
8426            topic_result.peers.len(),
8427            1,
8428            "Self URL should be filtered out"
8429        );
8430        assert_eq!(topic_result.peers[0], "https://peer2.example.com");
8431    }
8432
8433    #[tokio::test]
8434    async fn load_0112_quiet_peer_costs_no_request_during_backoff() {
8435        let calls = std::rc::Rc::default();
8436        let store = std::rc::Rc::new(MemoryStorage::new());
8437        let mut engine = Engine::new(
8438            HashMap::new(),
8439            HashMap::new(),
8440            Box::new(store.clone()),
8441            None,
8442            EngineConfig {
8443                sync_configuration: HashMap::from([(
8444                    "tm_test".into(),
8445                    SyncTarget::Peers(vec!["https://quiet.example".into()]),
8446                )]),
8447                ..Default::default()
8448            },
8449        );
8450        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8451            calls: std::rc::Rc::clone(&calls),
8452        }));
8453        for _ in 0..4 {
8454            engine.start_gasp_sync().await.unwrap();
8455        }
8456        assert_eq!(calls.borrow().len(), 3);
8457        assert_eq!(
8458            store
8459                .get_peer_sync_health("quiet.example", "tm_test")
8460                .await
8461                .unwrap()
8462                .consecutive_failures,
8463            0
8464        );
8465        store.advance_clock(60);
8466        engine.start_gasp_sync().await.unwrap();
8467        assert_eq!(calls.borrow().len(), 4);
8468        engine.start_gasp_sync().await.unwrap();
8469        assert_eq!(calls.borrow().len(), 4);
8470        engine
8471            .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8472                topics: Some(vec!["tm_test".into()]),
8473                peers: None,
8474            })
8475            .await
8476            .unwrap();
8477        assert_eq!(calls.borrow().len(), 5);
8478        engine.start_gasp_sync().await.unwrap();
8479        assert_eq!(
8480            calls.borrow().len(),
8481            6,
8482            "explicit selection reset the quiet streak"
8483        );
8484    }
8485
8486    #[tokio::test]
8487    async fn load_0112_yield_and_advertisement_reset_cadence() {
8488        let (mut engine, remote) = budget_page_engine(1);
8489        engine.set_gasp_remote_factory(Box::new(remote));
8490        engine
8491            .storage
8492            .set_peer_cadence(
8493                "budget.example",
8494                "tm_test",
8495                &crate::sync_load::PeerCadence {
8496                    quiet_syncs: 8,
8497                    retry_after_secs: 0,
8498                    advertisement: String::new(),
8499                },
8500            )
8501            .await
8502            .unwrap();
8503        let result = engine.start_gasp_sync().await.unwrap();
8504        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
8505        assert_eq!(
8506            engine
8507                .storage
8508                .get_peer_cadence("budget.example", "tm_test")
8509                .await
8510                .unwrap()
8511                .quiet_syncs,
8512            0
8513        );
8514        engine
8515            .storage
8516            .set_peer_cadence(
8517                "budget.example",
8518                "tm_test",
8519                &crate::sync_load::PeerCadence {
8520                    quiet_syncs: 8,
8521                    retry_after_secs: 3_600,
8522                    advertisement: "previous-advertisement".into(),
8523                },
8524            )
8525            .await
8526            .unwrap();
8527        let result = engine.start_gasp_sync().await.unwrap();
8528        assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8529        assert!(
8530            engine
8531                .storage
8532                .get_peer_cadence("budget.example", "tm_test")
8533                .await
8534                .unwrap()
8535                .quiet_syncs
8536                <= 1
8537        );
8538    }
8539
8540    #[tokio::test]
8541    async fn load_0112_an_admitted_slap_change_wakes_its_peer() {
8542        let (mut engine, mut remote) = budget_page_engine(0);
8543        remote.node_cost_ms = 0;
8544        engine.set_gasp_remote_factory(Box::new(remote.clone()));
8545        for _ in 0..3 {
8546            engine.start_gasp_sync().await.unwrap();
8547        }
8548        let skipped = engine.start_gasp_sync().await.unwrap();
8549        assert_eq!(
8550            skipped.topics_synced["tm_test"].peers_skipped[0].reason,
8551            "no_yield"
8552        );
8553        let count = remote.requests.borrow().len();
8554        let ad = discovery_output("SLAP", "https://budget.example", "ls_ship");
8555        let tx = beef_limits::transaction_from_beef(&ad.beef, None, &beef_limits::PEER_BEEF_LIMITS)
8556            .unwrap();
8557        engine
8558            .storage
8559            .insert_output(&Output {
8560                txid: tx.id(),
8561                output_index: 0,
8562                output_script: tx.outputs[0].locking_script.to_binary(),
8563                satoshis: 1,
8564                topic: "tm_slap".into(),
8565                spent: false,
8566                outputs_consumed: vec![],
8567                consumed_by: vec![],
8568                beef: None,
8569                block_height: None,
8570                score: Some(1.0),
8571            })
8572            .await
8573            .unwrap();
8574        let result = engine.start_gasp_sync().await.unwrap();
8575        assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8576        assert_eq!(remote.requests.borrow().len(), count + 1);
8577    }
8578
8579    #[tokio::test]
8580    async fn load_0112_filter_selects_only_named_topic_and_peer() {
8581        let calls = std::rc::Rc::default();
8582        let mut engine = Engine::new(
8583            HashMap::new(),
8584            HashMap::new(),
8585            Box::new(MemoryStorage::new()),
8586            None,
8587            EngineConfig {
8588                sync_configuration: ["tm_alpha", "tm_beta"]
8589                    .into_iter()
8590                    .map(|t| {
8591                        (
8592                            t.to_string(),
8593                            SyncTarget::Peers(vec![
8594                                "https://a.example".into(),
8595                                "https://b.example".into(),
8596                            ]),
8597                        )
8598                    })
8599                    .collect(),
8600                ..Default::default()
8601            },
8602        );
8603        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8604            calls: std::rc::Rc::clone(&calls),
8605        }));
8606        let filter = crate::sync_load::SyncFilter::parse(
8607            r#"{"topics":["tm_beta"],"peers":["https://b.example"]}"#,
8608        )
8609        .unwrap();
8610        let result = engine.start_gasp_sync_filtered(&filter).await.unwrap();
8611        assert_eq!(
8612            *calls.borrow(),
8613            vec![("https://b.example".into(), "tm_beta".into())]
8614        );
8615        assert_eq!(result.topics_synced.len(), 1);
8616        calls.borrow_mut().clear();
8617        engine.start_gasp_sync().await.unwrap();
8618        assert_eq!(calls.borrow().len(), 4);
8619        calls.borrow_mut().clear();
8620        engine
8621            .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8622                topics: Some(vec![]),
8623                peers: None,
8624            })
8625            .await
8626            .unwrap();
8627        assert!(calls.borrow().is_empty());
8628        assert!(crate::sync_load::SyncFilter::parse(r#"{"peers":"bad"}"#).is_err());
8629        assert!(crate::sync_load::SyncFilter::parse("null").is_err());
8630        assert_eq!(
8631            crate::sync_load::SyncFilter::parse("  ").unwrap(),
8632            crate::sync_load::SyncFilter::default()
8633        );
8634    }
8635
8636    #[derive(Clone, Default)]
8637    struct MockShipLookupClient {
8638        records: HashMap<(String, String), Vec<OutputListItem>>,
8639        failures: HashSet<String>,
8640        calls: std::rc::Rc<std::cell::RefCell<Vec<(String, LookupQuestion)>>>,
8641    }
8642
8643    struct RecordingDiscoverySyncFactory {
8644        calls: std::rc::Rc<std::cell::RefCell<Vec<(String, String)>>>,
8645    }
8646
8647    impl crate::gasp::GASPRemoteFactory for RecordingDiscoverySyncFactory {
8648        fn create_remote(&self, peer: &str, topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8649            self.calls
8650                .borrow_mut()
8651                .push((peer.to_string(), topic.to_string()));
8652            Box::new(MockSyncRemote { utxos: Vec::new() })
8653        }
8654    }
8655
8656    #[async_trait(?Send)]
8657    impl crate::ship_lookup::ShipLookupClient for MockShipLookupClient {
8658        async fn lookup(
8659            &self,
8660            host: &str,
8661            question: &LookupQuestion,
8662        ) -> Result<Vec<OutputListItem>, String> {
8663            self.calls
8664                .borrow_mut()
8665                .push((host.to_string(), question.clone()));
8666            if self.failures.contains(host) {
8667                return Err("mock tracker unavailable".to_string());
8668            }
8669            Ok(self
8670                .records
8671                .get(&(host.to_string(), question.service.clone()))
8672                .cloned()
8673                .unwrap_or_default())
8674        }
8675    }
8676
8677    fn discovery_output(protocol: &str, domain: &str, scope: &str) -> OutputListItem {
8678        use bsv_rs::script::templates::PushDrop;
8679        let pubkey = bsv_rs::PublicKey::from_hex(
8680            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
8681        )
8682        .unwrap();
8683        let script = PushDrop::new(
8684            pubkey,
8685            vec![
8686                protocol.as_bytes().to_vec(),
8687                vec![0x02; 33],
8688                domain.as_bytes().to_vec(),
8689                scope.as_bytes().to_vec(),
8690            ],
8691        )
8692        .lock();
8693        let mut tx = Transaction::from_beef(&test_beef(), None).unwrap();
8694        tx.outputs[0].locking_script = script;
8695        tx.merkle_path = None;
8696        tx.invalidate_caches();
8697        let beef = tx.to_atomic_beef(true).unwrap();
8698        let parsed =
8699            beef_limits::transaction_from_beef(&beef, None, &beef_limits::PEER_BEEF_LIMITS)
8700                .unwrap();
8701        assert_eq!(
8702            parsed.outputs[0].locking_script,
8703            tx.outputs[0].locking_script
8704        );
8705        OutputListItem {
8706            beef,
8707            output_index: 0,
8708            context: None,
8709        }
8710    }
8711
8712    #[tokio::test]
8713    async fn ship_network_discovery_unions_local_trackers_and_slap_hosts_by_origin() {
8714        let (mut engine, _) = make_engine_with_broadcaster(
8715            vec![0],
8716            MockBroadcaster::new(),
8717            vec![
8718                ("https://local.example", "tm_test"),
8719                ("https://shared.example/?local", "tm_test"),
8720            ],
8721            Some("https://self.example"),
8722        )
8723        .await;
8724        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8725        engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8726        let mut client = MockShipLookupClient::default();
8727        client.records.insert(
8728            ("https://tracker.example".to_string(), "ls_ship".to_string()),
8729            vec![
8730                discovery_output("SHIP", "https://direct.example", "tm_test"),
8731                discovery_output("SHIP", "https://SHARED.example:443/path", "tm_test"),
8732                discovery_output("SHIP", "http://SELF.example.:8443/?x#y", "tm_test"),
8733                discovery_output("SLAP", "https://wrong-protocol.example", "tm_test"),
8734                discovery_output("SHIP", "https://wrong-topic.example", "tm_other"),
8735                OutputListItem {
8736                    beef: vec![1, 2, 3],
8737                    output_index: 0,
8738                    context: None,
8739                },
8740            ],
8741        );
8742        client.records.insert(
8743            ("https://tracker.example".to_string(), "ls_slap".to_string()),
8744            vec![
8745                discovery_output("SLAP", "https://lookup.example", "ls_ship"),
8746                discovery_output("SLAP", "https://LOOKUP.example:443/path", "ls_ship"),
8747                discovery_output("SLAP", "https://self.example/?slap", "ls_ship"),
8748                discovery_output("SLAP", "https://tracker.example/path", "ls_ship"),
8749                discovery_output("SLAP", "https://other-service.example", "ls_other"),
8750            ],
8751        );
8752        client.records.insert(
8753            ("https://lookup.example".to_string(), "ls_ship".to_string()),
8754            vec![
8755                discovery_output("SHIP", "https://indirect.example", "tm_test"),
8756                discovery_output("SHIP", "https://self.example:443/#ship", "tm_test"),
8757            ],
8758        );
8759        let calls = client.calls.clone();
8760        engine.set_ship_lookup_client(Box::new(client));
8761        let result = engine.start_gasp_sync().await.unwrap();
8762        assert_eq!(
8763            result.topics_synced["tm_test"].peers,
8764            vec![
8765                "https://bootstrap.example",
8766                "https://direct.example",
8767                "https://indirect.example",
8768                "https://local.example",
8769                "https://shared.example",
8770            ]
8771        );
8772        let calls = calls.borrow();
8773        assert_eq!(calls.len(), 3);
8774        for (_, question) in calls.iter() {
8775            let expected = if question.service == "ls_slap" {
8776                serde_json::json!({"service": "ls_ship"})
8777            } else {
8778                serde_json::json!({"topics": ["tm_test"]})
8779            };
8780            assert_eq!(question.query, expected);
8781        }
8782    }
8783
8784    #[tokio::test]
8785    async fn ship_network_discovery_empty_local_finds_all_advertised_peers() {
8786        let (mut engine, _) =
8787            make_engine_with_broadcaster(vec![0], MockBroadcaster::new(), vec![], None).await;
8788        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8789        let mut client = MockShipLookupClient::default();
8790        let outputs = (0..9)
8791            .map(|n| discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"))
8792            .collect();
8793        client.records.insert(
8794            ("https://tracker.example".to_string(), "ls_ship".to_string()),
8795            outputs,
8796        );
8797        engine.set_ship_lookup_client(Box::new(client));
8798        let sync_calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8799        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8800            calls: sync_calls.clone(),
8801        }));
8802        let result = engine.start_gasp_sync().await.unwrap();
8803        let topic = &result.topics_synced["tm_test"];
8804        assert_eq!(topic.sync_type, "ship");
8805        assert_eq!(
8806            topic.peers.len(),
8807            9,
8808            "the lookup-host cap must not truncate sync peers"
8809        );
8810        assert!(topic.errors.is_empty());
8811        assert_eq!(sync_calls.borrow().len(), 9);
8812        for peer in &topic.peers {
8813            assert!(sync_calls
8814                .borrow()
8815                .contains(&(peer.clone(), "tm_test".to_string())));
8816        }
8817    }
8818
8819    #[tokio::test]
8820    async fn ship_network_discovery_tracker_failure_preserves_other_answers() {
8821        let mut engine = make_engine(vec![]);
8822        engine.config.slap_trackers = vec![
8823            "https://dead.example".to_string(),
8824            "https://live.example".to_string(),
8825        ];
8826        let mut client = MockShipLookupClient::default();
8827        client.failures.insert("https://dead.example".to_string());
8828        client.records.insert(
8829            ("https://live.example".to_string(), "ls_ship".to_string()),
8830            vec![discovery_output("SHIP", "https://peer.example", "tm_test")],
8831        );
8832        engine.set_ship_lookup_client(Box::new(client));
8833        let result = engine.start_gasp_sync().await.unwrap();
8834        assert_eq!(
8835            result.topics_synced["tm_test"].peers,
8836            vec!["https://peer.example"]
8837        );
8838    }
8839
8840    #[tokio::test]
8841    async fn ship_network_discovery_provider_cap_is_shared_across_topics() {
8842        use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
8843        let mut engine = make_engine(vec![]);
8844        engine
8845            .config
8846            .sync_configuration
8847            .insert("tm_other".to_string(), SyncTarget::Ship);
8848        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8849        let mut client = MockShipLookupClient::default();
8850        let mut providers = Vec::new();
8851        for n in 0..SHIP_DISCOVERY_MAX_HOSTS_PER_TICK + 3 {
8852            let host = format!("https://lookup{n}.example");
8853            providers.push(discovery_output("SLAP", &host, "ls_ship"));
8854            client.records.insert(
8855                (host, "ls_ship".to_string()),
8856                vec![
8857                    discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"),
8858                    discovery_output("SHIP", &format!("https://other{n}.example"), "tm_other"),
8859                ],
8860            );
8861        }
8862        client.records.insert(
8863            ("https://tracker.example".to_string(), "ls_slap".to_string()),
8864            providers,
8865        );
8866        let calls = client.calls.clone();
8867        engine.set_ship_lookup_client(Box::new(client));
8868        let result = engine.start_gasp_sync().await.unwrap();
8869        assert_eq!(
8870            result.topics_synced["tm_test"].peers.len(),
8871            SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8872        );
8873        assert_eq!(
8874            result.topics_synced["tm_other"].peers.len(),
8875            SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8876        );
8877        let calls = calls.borrow();
8878        assert_eq!(
8879            calls.iter().filter(|(_, q)| q.service == "ls_slap").count(),
8880            1
8881        );
8882        let providers: HashSet<_> = calls
8883            .iter()
8884            .filter(|(h, q)| h != "https://tracker.example" && q.service == "ls_ship")
8885            .map(|(h, _)| h)
8886            .collect();
8887        assert_eq!(providers.len(), SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
8888        assert_eq!(calls.len(), 1 + 2 * (1 + SHIP_DISCOVERY_MAX_HOSTS_PER_TICK));
8889    }
8890
8891    #[tokio::test]
8892    async fn ship_network_discovery_outage_keeps_bootstrap_and_excludes_self() {
8893        let mut engine = make_engine(vec![]);
8894        engine.config.hosting_url = Some("https://self.example".to_string());
8895        engine.config.slap_trackers = vec!["https://dead.example".to_string()];
8896        let mut client = MockShipLookupClient::default();
8897        client.failures.insert("https://dead.example".to_string());
8898        engine.set_ship_lookup_client(Box::new(client));
8899        engine.set_ship_bootstrap_peers(
8900            "tm_test",
8901            vec![
8902                "https://bootstrap.example".to_string(),
8903                "https://BOOTSTRAP.example.:443/?x".to_string(),
8904                "http://SELF.example.:8443/path".to_string(),
8905            ],
8906        );
8907        let result = engine.start_gasp_sync().await.unwrap();
8908        assert_eq!(
8909            result.topics_synced["tm_test"].peers,
8910            vec!["https://bootstrap.example"]
8911        );
8912    }
8913
8914    #[tokio::test]
8915    async fn ship_network_discovery_bootstrap_topic_runs_before_tracker_import() {
8916        let mut engine = make_engine(vec![]);
8917        for topic in ["tm_ship", "tm_slap"] {
8918            engine.config.sync_configuration.insert(
8919                topic.to_string(),
8920                SyncTarget::Peers(vec!["https://tracker.example".to_string()]),
8921            );
8922        }
8923        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8924        let mut client = MockShipLookupClient::default();
8925        client.records.insert(
8926            ("https://tracker.example".to_string(), "ls_ship".to_string()),
8927            vec![discovery_output(
8928                "SHIP",
8929                "https://discovered.example",
8930                "tm_test",
8931            )],
8932        );
8933        let lookups = client.calls.clone();
8934        engine.set_ship_lookup_client(Box::new(client));
8935        engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8936        let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8937        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8938            calls: calls.clone(),
8939        }));
8940        engine.start_gasp_sync().await.unwrap();
8941        assert_eq!(
8942            calls
8943                .borrow()
8944                .iter()
8945                .map(|(_, topic)| topic.as_str())
8946                .collect::<Vec<_>>(),
8947            vec!["tm_test", "tm_test", "tm_ship", "tm_slap"]
8948        );
8949        assert_eq!(lookups.borrow().len(), 2);
8950    }
8951
8952    #[tokio::test]
8953    async fn test_start_gasp_sync_ship_discovers_peers() {
8954        let broadcaster = MockBroadcaster::new();
8955        let (engine, _calls) = make_engine_with_broadcaster(
8956            vec![0],
8957            broadcaster,
8958            vec![
8959                ("https://peer1.example.com", "tm_test"),
8960                ("https://peer2.example.com", "tm_test"),
8961            ],
8962            Some("https://self.example.com"),
8963        )
8964        .await;
8965
8966        let result = engine.start_gasp_sync().await.unwrap();
8967        assert!(
8968            result.topics_synced.contains_key("tm_test"),
8969            "tm_test should be in sync results"
8970        );
8971
8972        let topic_result = &result.topics_synced["tm_test"];
8973        assert_eq!(topic_result.sync_type, "ship");
8974        assert_eq!(
8975            topic_result.peers.len(),
8976            2,
8977            "Should discover both SHIP peers"
8978        );
8979        assert!(topic_result
8980            .peers
8981            .contains(&"https://peer1.example.com".to_string()));
8982        assert!(topic_result
8983            .peers
8984            .contains(&"https://peer2.example.com".to_string()));
8985    }
8986
8987    #[tokio::test]
8988    async fn test_start_gasp_sync_ship_filters_self() {
8989        let broadcaster = MockBroadcaster::new();
8990        let (engine, _calls) = make_engine_with_broadcaster(
8991            vec![0],
8992            broadcaster,
8993            vec![
8994                ("https://self.example.com", "tm_test"),
8995                ("https://peer2.example.com", "tm_test"),
8996            ],
8997            Some("https://self.example.com"),
8998        )
8999        .await;
9000
9001        let result = engine.start_gasp_sync().await.unwrap();
9002        let topic_result = &result.topics_synced["tm_test"];
9003        assert_eq!(
9004            topic_result.peers.len(),
9005            1,
9006            "Self URL should be filtered out from SHIP discovery"
9007        );
9008        assert_eq!(topic_result.peers[0], "https://peer2.example.com");
9009    }
9010
9011    #[tokio::test]
9012    async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
9013        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9014        managers.insert(
9015            "tm_custom".to_string(),
9016            Box::new(MockTopicManager::admitting(vec![])),
9017        );
9018
9019        let mut sync_config: SyncConfiguration = HashMap::new();
9020        sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
9021
9022        let engine = Engine::new(
9023            managers,
9024            HashMap::new(),
9025            Box::new(MemoryStorage::new()),
9026            None,
9027            EngineConfig {
9028                sync_configuration: sync_config,
9029                ..Default::default()
9030            },
9031        );
9032
9033        let result = engine.start_gasp_sync().await.unwrap();
9034        let topic_result = &result.topics_synced["tm_custom"];
9035        assert!(
9036            topic_result.peers.is_empty(),
9037            "Without ls_ship, SHIP discovery should return no peers"
9038        );
9039    }
9040
9041    #[tokio::test]
9042    async fn test_start_gasp_sync_mixed_config() {
9043        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9044        managers.insert(
9045            "tm_peers".to_string(),
9046            Box::new(MockTopicManager::admitting(vec![])),
9047        );
9048        managers.insert(
9049            "tm_disabled".to_string(),
9050            Box::new(MockTopicManager::admitting(vec![])),
9051        );
9052        managers.insert(
9053            "tm_ship_topic".to_string(),
9054            Box::new(MockTopicManager::admitting(vec![])),
9055        );
9056
9057        let mut sync_config: SyncConfiguration = HashMap::new();
9058        sync_config.insert(
9059            "tm_peers".to_string(),
9060            SyncTarget::Peers(vec!["https://peer.com".to_string()]),
9061        );
9062        sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
9063        sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
9064
9065        let engine = Engine::new(
9066            managers,
9067            HashMap::new(),
9068            Box::new(MemoryStorage::new()),
9069            None,
9070            EngineConfig {
9071                sync_configuration: sync_config,
9072                ..Default::default()
9073            },
9074        );
9075
9076        let result = engine.start_gasp_sync().await.unwrap();
9077        assert_eq!(result.topics_synced.len(), 2);
9078        assert!(result.topics_synced.contains_key("tm_peers"));
9079        assert!(result.topics_synced.contains_key("tm_ship_topic"));
9080        assert!(!result.topics_synced.contains_key("tm_disabled"));
9081
9082        assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
9083        assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
9084        assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
9085    }
9086
9087    #[tokio::test]
9088    async fn test_gasp_sync_result_serialization() {
9089        let mut topics_synced = HashMap::new();
9090        topics_synced.insert(
9091            "tm_test".to_string(),
9092            super::TopicSyncResult {
9093                cost: crate::meter::SyncCost::default(),
9094                root_bounds: Vec::new(),
9095                peers_skipped: Vec::new(),
9096                peers_filtered: Vec::new(),
9097                roots_proven: 0,
9098                roots_unproven: 0,
9099                cursor_held_by: Vec::new(),
9100                expired_refused: 0,
9101                peers: vec!["https://peer.com".to_string()],
9102                sync_type: "ship".to_string(),
9103                errors: vec![],
9104                pruned_inputs: 0,
9105                discarded_graphs: 0,
9106                anchor_walk_held_graphs: 0,
9107                finalized_graphs: 0,
9108                deadline_dropped_graphs: 0,
9109                cursor_moves: Vec::new(),
9110                deferred_graphs: 0,
9111                resumed_graphs: 0,
9112                converged_graphs: 0,
9113                dropped_graphs: Vec::new(),
9114                stalled_graphs: 0,
9115                held_back_graphs: 0,
9116                backed_off_graphs: 0,
9117                waiting_graphs: 0,
9118                idle_counted: 0,
9119                peer_results: HashMap::new(),
9120                dead_graphs: 0,
9121                expired_swept: 0,
9122                unanchorable_graphs: 0,
9123                unanchorable_cleared: 0,
9124                strikes_refused: 0,
9125                faulted_roots: Vec::new(),
9126            },
9127        );
9128
9129        let result = super::GASPSyncResult {
9130            cost: crate::meter::SyncCost::default(),
9131            topics_synced,
9132            filter: crate::sync_load::SyncFilter::default(),
9133            topics_filtered: Vec::new(),
9134        };
9135        let json = serde_json::to_string(&result).unwrap();
9136        let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
9137
9138        assert_eq!(back.topics_synced.len(), 1);
9139        assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
9140        assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
9141    }
9142
9143    // ── GASP Sync With Factory Tests ─────────────────────────────────
9144
9145    /// Mock GASPRemote for factory tests
9146    struct MockSyncRemote {
9147        utxos: Vec<crate::types::GASPOutput>,
9148    }
9149
9150    #[async_trait(?Send)]
9151    impl crate::gasp::GASPRemote for MockSyncRemote {
9152        async fn get_initial_response(
9153            &self,
9154            request: &crate::types::GASPInitialRequest,
9155        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9156            let utxos: Vec<crate::types::GASPOutput> = self
9157                .utxos
9158                .iter()
9159                .filter(|u| u.score as u64 >= request.since)
9160                .cloned()
9161                .collect();
9162            Ok(crate::types::GASPInitialResponse {
9163                utxo_list: utxos,
9164                since: request.since,
9165            })
9166        }
9167        async fn get_initial_reply(
9168            &self,
9169            _: &crate::types::GASPInitialResponse,
9170        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9171            Ok(crate::types::GASPInitialReply {
9172                utxo_list: Vec::new(),
9173            })
9174        }
9175        async fn request_node(
9176            &self,
9177            graph_id: &str,
9178            txid: &str,
9179            output_index: u32,
9180            _: bool,
9181        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9182            Ok(crate::types::GASPNode {
9183                graph_id: graph_id.to_string(),
9184                raw_tx: format!("rawtx_{txid}"),
9185                output_index,
9186                proof: None,
9187                tx_metadata: None,
9188                output_metadata: None,
9189                inputs: None,
9190            })
9191        }
9192        async fn submit_node(
9193            &self,
9194            _: &crate::types::GASPNode,
9195        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9196            Ok(None)
9197        }
9198    }
9199
9200    /// Mock factory that creates MockSyncRemote instances
9201    struct MockGASPRemoteFactory;
9202
9203    #[derive(Clone)]
9204    struct BudgetPageRemote {
9205        outputs: std::rc::Rc<Vec<crate::types::GASPOutput>>,
9206        nodes: std::rc::Rc<HashMap<String, crate::types::GASPNode>>,
9207        elapsed: std::rc::Rc<Cell<u64>>,
9208        requests: std::rc::Rc<std::cell::RefCell<Vec<crate::types::GASPInitialRequest>>>,
9209        clamp: Option<usize>,
9210        node_cost_ms: u64,
9211        /// One request of this txid costs this much more, once.
9212        slow_once: std::rc::Rc<std::cell::RefCell<Option<(String, u64)>>>,
9213        /// Node requests answered, by txid.
9214        asked: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
9215    }
9216
9217    #[async_trait(?Send)]
9218    impl crate::gasp::GASPRemote for BudgetPageRemote {
9219        async fn get_initial_response(
9220            &self,
9221            request: &crate::types::GASPInitialRequest,
9222        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9223            self.requests.borrow_mut().push(request.clone());
9224            let page = self
9225                .outputs
9226                .iter()
9227                .filter(|u| u.score as u64 >= request.since);
9228            let limit = self.clamp.map_or(usize::MAX, |cap| {
9229                cap.min(request.limit.unwrap_or(u64::MAX) as usize)
9230            });
9231            Ok(crate::types::GASPInitialResponse {
9232                utxo_list: page.take(limit).cloned().collect(),
9233                since: request.since,
9234            })
9235        }
9236
9237        async fn get_initial_reply(
9238            &self,
9239            _: &crate::types::GASPInitialResponse,
9240        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9241            unreachable!()
9242        }
9243
9244        async fn request_node(
9245            &self,
9246            graph_id: &str,
9247            txid: &str,
9248            _: u32,
9249            _: bool,
9250        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9251            self.asked.borrow_mut().push(txid.to_string());
9252            self.elapsed.set(self.elapsed.get() + self.node_cost_ms);
9253            let slow = {
9254                let mut slow = self.slow_once.borrow_mut();
9255                match slow.as_ref() {
9256                    Some((t, _)) if t == txid => slow.take().map(|(_, ms)| ms),
9257                    _ => None,
9258                }
9259            };
9260            if let Some(ms) = slow {
9261                self.elapsed.set(self.elapsed.get() + ms);
9262            }
9263            if self.node_cost_ms > 0 {
9264                let mut yielded = false;
9265                std::future::poll_fn(|cx| {
9266                    if yielded {
9267                        std::task::Poll::Ready(())
9268                    } else {
9269                        yielded = true;
9270                        cx.waker().wake_by_ref();
9271                        std::task::Poll::Pending
9272                    }
9273                })
9274                .await;
9275            }
9276            let mut node = self
9277                .nodes
9278                .get(txid)
9279                .cloned()
9280                .ok_or_else(|| crate::gasp::GASPError::NodeNotFound(format!("{txid}.0")))?;
9281            node.graph_id = graph_id.to_string();
9282            Ok(node)
9283        }
9284
9285        async fn submit_node(
9286            &self,
9287            _: &crate::types::GASPNode,
9288        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9289            unreachable!()
9290        }
9291    }
9292
9293    impl crate::gasp::GASPRemoteFactory for BudgetPageRemote {
9294        fn create_remote(&self, _: &str, _: &str) -> Box<dyn crate::gasp::GASPRemote> {
9295            Box::new(self.clone())
9296        }
9297    }
9298
9299    fn budget_page_engine(count: u32) -> (Engine, BudgetPageRemote) {
9300        let mut engine = make_engine(vec![0]);
9301        let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
9302        let mut outputs = Vec::new();
9303        let mut nodes = HashMap::new();
9304        for n in 1..=count {
9305            let tx = child_of(&format!("{n:064x}"), 0);
9306            let txid = tx.id();
9307            tracker.add_root(n, txid.clone());
9308            outputs.push(crate::types::GASPOutput {
9309                txid: txid.clone(),
9310                output_index: 0,
9311                score: f64::from(n * 10),
9312            });
9313            nodes.insert(
9314                txid.clone(),
9315                crate::types::GASPNode {
9316                    graph_id: String::new(),
9317                    raw_tx: tx.to_hex(),
9318                    output_index: 0,
9319                    proof: Some(single_leaf_bump_hex(&txid, n)),
9320                    tx_metadata: None,
9321                    output_metadata: None,
9322                    inputs: None,
9323                },
9324            );
9325        }
9326        engine.chain_tracker = Some(Box::new(tracker));
9327        engine.config.sync_configuration.insert(
9328            "tm_test".to_string(),
9329            SyncTarget::Peers(vec!["https://budget.example".to_string()]),
9330        );
9331        let remote = BudgetPageRemote {
9332            outputs: std::rc::Rc::new(outputs),
9333            nodes: std::rc::Rc::new(nodes),
9334            elapsed: std::rc::Rc::new(Cell::new(0)),
9335            requests: std::rc::Rc::default(),
9336            clamp: None,
9337            node_cost_ms: 5000,
9338            slow_once: std::rc::Rc::default(),
9339            asked: std::rc::Rc::default(),
9340        };
9341        (engine, remote)
9342    }
9343
9344    fn virtual_peer_budget(engine: &mut Engine, remote: &BudgetPageRemote) {
9345        let elapsed = remote.elapsed.clone();
9346        engine.set_peer_sync_budget(
9347            std::rc::Rc::new(move |ms| {
9348                assert_eq!(ms, 30_000);
9349                let elapsed = elapsed.clone();
9350                let until = elapsed.get() + ms;
9351                Box::pin(std::future::poll_fn(move |_| {
9352                    if elapsed.get() >= until {
9353                        std::task::Poll::Ready(())
9354                    } else {
9355                        std::task::Poll::Pending
9356                    }
9357                }))
9358            }),
9359            30_000,
9360        );
9361    }
9362
9363    #[tokio::test]
9364    async fn sync_budget_large_page_persists_a_finalized_score_and_resumes() {
9365        // This responder ignores the limit: the initiator must safely handle
9366        // a 3,000-output page under a virtual 30-second peer budget.
9367        let (mut engine, remote) = budget_page_engine(3000);
9368        virtual_peer_budget(&mut engine, &remote);
9369        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9370        for round in 1..=2 {
9371            let result = engine.start_gasp_sync().await.unwrap();
9372            let topic = &result.topics_synced["tm_test"];
9373            assert_eq!(topic.finalized_graphs, 5);
9374            assert_eq!(topic.deadline_dropped_graphs, 1);
9375            assert_eq!(topic.cursor_moves.len(), 1);
9376            let expected = round * 50;
9377            assert_eq!(topic.cursor_moves[0].to, expected);
9378            assert_eq!(
9379                engine
9380                    .storage
9381                    .get_last_interaction("https://budget.example", "tm_test")
9382                    .await
9383                    .unwrap(),
9384                expected
9385            );
9386            {
9387                let requests = remote.requests.borrow();
9388                assert_eq!(requests[(round - 1) as usize].since, (round - 1) * 50);
9389            }
9390            for output in remote.outputs.iter().take((round * 5) as usize) {
9391                assert!(engine
9392                    .storage
9393                    .find_output(&output.txid, 0, Some("tm_test"), None, true)
9394                    .await
9395                    .unwrap()
9396                    .is_some());
9397            }
9398            let unfinished = &remote.outputs[(round * 5) as usize];
9399            assert!(engine
9400                .storage
9401                .find_output(&unfinished.txid, 0, Some("tm_test"), None, true)
9402                .await
9403                .unwrap()
9404                .is_none());
9405        }
9406    }
9407
9408    #[tokio::test]
9409    async fn sync_budget_requests_bounded_pages_and_accepts_responder_clamping() {
9410        let (mut engine, mut remote) = budget_page_engine(7);
9411        remote.clamp = Some(3);
9412        remote.node_cost_ms = 0;
9413        virtual_peer_budget(&mut engine, &remote);
9414        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9415        let result = engine.start_gasp_sync().await.unwrap();
9416        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 7);
9417        let requests = remote.requests.borrow();
9418        assert_eq!(requests.len(), 4);
9419        assert!(requests.iter().all(|request| request.limit == Some(100)));
9420        assert_eq!(
9421            requests.iter().map(|r| r.since).collect::<Vec<_>>(),
9422            vec![0, 30, 50, 70]
9423        );
9424    }
9425
9426    #[tokio::test]
9427    async fn sync_budget_unordered_page_keeps_the_completed_score_below_a_gap() {
9428        let (mut engine, mut remote) = budget_page_engine(3000);
9429        std::rc::Rc::make_mut(&mut remote.outputs).swap(0, 9);
9430        virtual_peer_budget(&mut engine, &remote);
9431        engine.set_gasp_remote_factory(Box::new(remote));
9432        let result = engine.start_gasp_sync().await.unwrap();
9433        let topic = &result.topics_synced["tm_test"];
9434        assert_eq!(topic.finalized_graphs, 5);
9435        // Score 100 landed, but score 10 is still ahead in this unordered
9436        // page. The unfinished lower score must hold the cursor at entry.
9437        assert!(topic.cursor_moves.is_empty());
9438    }
9439
9440    #[tokio::test]
9441    async fn sync_budget_tied_scores_do_not_skip_an_unfinished_output() {
9442        let (mut engine, mut remote) = budget_page_engine(3);
9443        std::rc::Rc::make_mut(&mut remote.outputs)[2].score = 20.0;
9444        remote.node_cost_ms = 10_000;
9445        virtual_peer_budget(&mut engine, &remote);
9446        engine.set_gasp_remote_factory(Box::new(remote));
9447        let result = engine.start_gasp_sync().await.unwrap();
9448        let topic = &result.topics_synced["tm_test"];
9449        assert_eq!(topic.finalized_graphs, 2);
9450        assert_eq!(topic.cursor_moves[0].to, 10);
9451    }
9452
9453    #[tokio::test]
9454    async fn sync_budget_failed_graph_holds_the_cursor_below_later_finalizations() {
9455        let (mut engine, mut remote) = budget_page_engine(3000);
9456        let failed = remote.outputs[2].txid.clone();
9457        std::rc::Rc::make_mut(&mut remote.nodes).remove(&failed);
9458        virtual_peer_budget(&mut engine, &remote);
9459        engine.set_gasp_remote_factory(Box::new(remote));
9460        let result = engine.start_gasp_sync().await.unwrap();
9461        let topic = &result.topics_synced["tm_test"];
9462        assert_eq!(topic.finalized_graphs, 4);
9463        assert_eq!(topic.cursor_moves[0].to, 20);
9464    }
9465
9466    struct ProofOnlyRootFetcher {
9467        proofs: HashMap<String, String>,
9468        requested: std::rc::Rc<Cell<u64>>,
9469    }
9470
9471    #[async_trait(?Send)]
9472    impl AncestorFetcher for ProofOnlyRootFetcher {
9473        async fn fetch_ancestor(&self, _: &str) -> Result<FetchedAncestor, GASPError> {
9474            assert!(
9475                self.requested.get() > 0,
9476                "root proof request must precede ancestry"
9477            );
9478            Err(GASPError::RemoteError("raw source unavailable".to_string()))
9479        }
9480        async fn verified_proof_for(&self, txid: &str) -> Option<String> {
9481            self.requested.set(self.requested.get() + 1);
9482            self.proofs.get(txid).cloned()
9483        }
9484    }
9485
9486    #[tokio::test]
9487    async fn copy_sync_leaves_completion_courier_untouched() {
9488        let (mut engine, mut remote) = budget_page_engine(1);
9489        remote.node_cost_ms = 0;
9490        let txid = remote.outputs[0].txid.clone();
9491        let proof = std::rc::Rc::make_mut(&mut remote.nodes)
9492            .get_mut(&txid)
9493            .unwrap()
9494            .proof
9495            .take()
9496            .unwrap();
9497        let completion_asks = std::rc::Rc::new(Cell::new(0));
9498        let sync_asks = std::rc::Rc::new(Cell::new(0));
9499        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9500            proofs: HashMap::new(),
9501            requested: completion_asks.clone(),
9502        }));
9503        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9504            proofs: HashMap::from([(txid.clone(), proof.clone())]),
9505            requested: sync_asks.clone(),
9506        }));
9507        engine.set_gasp_remote_factory(Box::new(remote));
9508        let result = engine.start_gasp_sync().await.unwrap();
9509        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
9510        assert_eq!(result.topics_synced["tm_test"].roots_proven, 1);
9511        assert_eq!(result.topics_synced["tm_test"].roots_unproven, 0);
9512        let stored = engine
9513            .storage()
9514            .find_output(&txid, 0, Some("tm_test"), None, true)
9515            .await
9516            .unwrap()
9517            .unwrap();
9518        assert!(
9519            crate::beef_limits::own_proof(stored.beef.as_ref().unwrap(), &txid)
9520                .unwrap()
9521                .is_some()
9522        );
9523        assert_eq!(completion_asks.get(), 0);
9524        assert_eq!(sync_asks.get(), 1);
9525        let served = engine
9526            .provide_foreign_gasp_node(&format!("{txid}.0"), &txid, 0)
9527            .await
9528            .unwrap();
9529        assert_eq!(served.proof, Some(proof));
9530    }
9531
9532    #[tokio::test]
9533    async fn sync_budget_proofless_root_uses_own_proof_source_without_raw_refetch() {
9534        for proven in [true, false] {
9535            let (mut engine, mut remote) = budget_page_engine(1);
9536            let txid = remote.outputs[0].txid.clone();
9537            let proof = remote.nodes[&txid].proof.clone().unwrap();
9538            std::rc::Rc::make_mut(&mut remote.nodes)
9539                .get_mut(&txid)
9540                .unwrap()
9541                .proof = None;
9542            let requested = std::rc::Rc::new(Cell::new(0));
9543            engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9544                proofs: if proven {
9545                    HashMap::from([(txid.clone(), proof)])
9546                } else {
9547                    HashMap::new()
9548                },
9549                requested: requested.clone(),
9550            }));
9551            virtual_peer_budget(&mut engine, &remote);
9552            engine.set_gasp_remote_factory(Box::new(remote));
9553            let result = engine.start_gasp_sync().await.unwrap();
9554            assert!(requested.get() > 0);
9555            assert_eq!(
9556                result.topics_synced["tm_test"].finalized_graphs,
9557                u64::from(proven)
9558            );
9559            assert_eq!(
9560                engine
9561                    .storage
9562                    .find_output(&txid, 0, Some("tm_test"), None, true)
9563                    .await
9564                    .unwrap()
9565                    .is_some(),
9566                proven
9567            );
9568            if !proven {
9569                assert!(result.topics_synced["tm_test"].cursor_moves.is_empty());
9570            }
9571        }
9572    }
9573
9574    #[tokio::test]
9575    async fn sync_budget_deferred_root_resumes_when_own_proof_becomes_available() {
9576        let (mut engine, mut remote) = budget_page_engine(1);
9577        let txid = remote.outputs[0].txid.clone();
9578        let proof = remote.nodes[&txid].proof.clone().unwrap();
9579        std::rc::Rc::make_mut(&mut remote.nodes)
9580            .get_mut(&txid)
9581            .unwrap()
9582            .proof = None;
9583        let requested = std::rc::Rc::new(Cell::new(0));
9584        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9585            proofs: HashMap::new(),
9586            requested: requested.clone(),
9587        }));
9588        virtual_peer_budget(&mut engine, &remote);
9589        engine.set_graph_budget(
9590            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9591            5,
9592            5000,
9593        );
9594        engine.set_gasp_remote_factory(Box::new(remote));
9595        let held = engine.start_gasp_sync().await.unwrap();
9596        assert_eq!(held.topics_synced["tm_test"].finalized_graphs, 0);
9597        assert_eq!(held.topics_synced["tm_test"].deferred_graphs, 1);
9598        assert_eq!(held.topics_synced["tm_test"].cursor_moves[0].to, 10);
9599        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9600            proofs: HashMap::from([(txid.clone(), proof)]),
9601            requested,
9602        }));
9603        engine
9604            .storage()
9605            .update_last_interaction("https://budget.example", "tm_test", 20)
9606            .await
9607            .unwrap();
9608        let landed = engine.start_gasp_sync().await.unwrap();
9609        assert_eq!(landed.topics_synced["tm_test"].resumed_graphs, 1);
9610        assert_eq!(landed.topics_synced["tm_test"].finalized_graphs, 1);
9611        assert!(landed.topics_synced["tm_test"].cursor_moves.is_empty());
9612        assert!(engine
9613            .storage
9614            .find_output(&txid, 0, Some("tm_test"), None, true)
9615            .await
9616            .unwrap()
9617            .is_some());
9618    }
9619
9620    #[tokio::test]
9621    async fn proofs_deadline_stalls_become_durable_after_three_runs() {
9622        let (mut engine, remote) = budget_page_engine(1);
9623        let elapsed = remote.elapsed.clone();
9624        let root = format!("{}.0", remote.outputs[0].txid);
9625        engine.set_graph_budget(
9626            std::rc::Rc::new(move |ms| {
9627                let until = elapsed.get() + ms;
9628                let elapsed = elapsed.clone();
9629                Box::pin(std::future::poll_fn(move |_| {
9630                    if elapsed.get() >= until {
9631                        std::task::Poll::Ready(())
9632                    } else {
9633                        std::task::Poll::Pending
9634                    }
9635                }))
9636            }),
9637            10,
9638            1_000,
9639        );
9640        engine.set_gasp_remote_factory(Box::new(remote));
9641        for round in 1..=crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9642            let result = engine.start_gasp_sync().await.unwrap();
9643            let topic = &result.topics_synced["tm_test"];
9644            if round < crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9645                assert!(topic.cursor_moves.is_empty());
9646                assert_eq!(topic.cursor_held_by, vec![root.clone()]);
9647            } else {
9648                assert_eq!(topic.cursor_moves[0].to, 10);
9649                assert_eq!(topic.deferred_graphs, 1);
9650            }
9651        }
9652        let held = engine
9653            .storage()
9654            .get_deferred_graph("https://budget.example", "tm_test", &root)
9655            .await
9656            .unwrap()
9657            .unwrap();
9658        assert!(held.cursor_passable);
9659        assert!(held.nodes.is_empty());
9660    }
9661
9662    /// 0.1.8 (the cursor): a graph the per-PEER deadline cuts before its
9663    /// root comes back is held at once (`peer_deadline`), the cursor passes
9664    /// it to the page's last finished score, and the next run resumes it
9665    /// ahead of the pages until it converges. On 0.1.7 the cut was a retry
9666    /// for three runs, the cursor stayed at 20 and `cursor_held_by` named it.
9667    #[tokio::test]
9668    async fn cursor_018_the_graph_in_flight_at_the_peer_deadline_is_held_and_passed() {
9669        let (mut engine, remote) = budget_page_engine(6);
9670        let slow = remote.outputs[2].txid.clone();
9671        let root = format!("{slow}.0");
9672        *remote.slow_once.borrow_mut() = Some((slow.clone(), 60_000));
9673        virtual_peer_budget(&mut engine, &remote);
9674        engine.set_graph_budget(
9675            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9676            100,
9677            60_000,
9678        );
9679        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9680        let result = engine.start_gasp_sync().await.unwrap();
9681        let topic = &result.topics_synced["tm_test"];
9682        assert_eq!(topic.finalized_graphs, 2);
9683        assert_eq!(topic.deadline_dropped_graphs, 1);
9684        assert_eq!(topic.deferred_graphs, 1);
9685        assert_eq!(topic.cursor_moves.len(), 1);
9686        assert_eq!(topic.cursor_moves[0].to, 30, "past the held graph");
9687        assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
9688        let held = engine
9689            .storage()
9690            .get_deferred_graph("https://budget.example", "tm_test", &root)
9691            .await
9692            .unwrap()
9693            .unwrap();
9694        assert!(held.cursor_passable);
9695        assert_eq!(held.reason, "peer_deadline");
9696        assert!(held.nodes.is_empty());
9697        assert_eq!(held.score, 30);
9698
9699        let result = engine.start_gasp_sync().await.unwrap();
9700        let topic = &result.topics_synced["tm_test"];
9701        assert_eq!(topic.resumed_graphs, 1);
9702        assert_eq!(topic.converged_graphs, 1);
9703        assert_eq!(topic.finalized_graphs, 4, "the held graph and 40, 50, 60");
9704        assert_eq!(topic.cursor_moves[0].to, 60);
9705        assert!(engine
9706            .storage()
9707            .get_deferred_graph("https://budget.example", "tm_test", &root)
9708            .await
9709            .unwrap()
9710            .is_none());
9711        for output in remote.outputs.iter() {
9712            assert!(engine
9713                .storage
9714                .find_output(&output.txid, 0, Some("tm_test"), None, true)
9715                .await
9716                .unwrap()
9717                .is_some());
9718        }
9719    }
9720
9721    /// Every root's advert expired (the walk refuses each after its node).
9722    struct ExpiredRoots(MockTopicManager);
9723
9724    #[async_trait(?Send)]
9725    impl TopicManagerTrait for ExpiredRoots {
9726        async fn identify_admissible_outputs(
9727            &self,
9728            tx: &Transaction,
9729            previous_coins: &[u8],
9730            off_chain_values: Option<&[u8]>,
9731            mode: SubmitMode,
9732            context: &TopicAdmittanceContext,
9733        ) -> Result<AdmittanceInstructions, TopicManagerError> {
9734            self.0
9735                .identify_admissible_outputs(tx, previous_coins, off_chain_values, mode, context)
9736                .await
9737        }
9738        async fn get_documentation(&self) -> String {
9739            self.0.get_documentation().await
9740        }
9741        async fn get_metadata(&self) -> ServiceMetadata {
9742            self.0.get_metadata().await
9743        }
9744        fn sync_expiry(&self, _: &bsv_rs::script::LockingScript) -> Option<u64> {
9745            Some(1)
9746        }
9747    }
9748
9749    /// A page of dead adverts above one root the peer cannot serve (a gap
9750    /// the cursor holds below), on a shared store.
9751    fn dead_page_engine(count: u32) -> (Engine, BudgetPageRemote, std::rc::Rc<MemoryStorage>) {
9752        let (mut engine, mut remote) = budget_page_engine(count);
9753        let gap = remote.outputs[0].txid.clone();
9754        std::rc::Rc::make_mut(&mut remote.nodes).remove(&gap);
9755        engine.managers.insert(
9756            "tm_test".to_string(),
9757            Box::new(ExpiredRoots(MockTopicManager::admitting(vec![0]))),
9758        );
9759        let store = std::rc::Rc::new(MemoryStorage::new());
9760        engine.storage = Box::new(store.clone());
9761        engine.set_graph_budget(
9762            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9763            100,
9764            60_000,
9765        );
9766        (engine, remote, store)
9767    }
9768
9769    /// 0.1.8: a sync that only refused expired adverts (the cursor held by
9770    /// a gap below them) did work: a yield, never a step of the yieldless
9771    /// streak. On 0.1.7 it was yieldless.
9772    #[tokio::test]
9773    async fn cursor_018_a_sync_that_only_refuses_the_dead_is_a_yield() {
9774        let (mut engine, mut remote, store) = dead_page_engine(3);
9775        remote.node_cost_ms = 0;
9776        virtual_peer_budget(&mut engine, &remote);
9777        engine.set_gasp_remote_factory(Box::new(remote));
9778        let result = engine.start_gasp_sync().await.unwrap();
9779        let topic = &result.topics_synced["tm_test"];
9780        assert_eq!(topic.expired_refused, 2);
9781        assert_eq!(topic.finalized_graphs, 0);
9782        assert!(topic.cursor_moves.is_empty());
9783        let host = crate::gasp::peer_origin("https://budget.example");
9784        assert_eq!(store.peer_yieldless_syncs(&host, "tm_test"), 0);
9785        let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9786        assert_eq!(health.consecutive_failures, 0);
9787    }
9788
9789    /// 0.1.8: a peer that served every page and every node, whose slice
9790    /// the per-peer deadline ended in each of nine runs in a row while it
9791    /// refused dead adverts (the cursor held by a gap below them), is never
9792    /// quarantined: each run did work. On 0.1.7 each was a failed attempt
9793    /// and the ninth was skipped.
9794    #[tokio::test]
9795    async fn cursor_018_deadline_drops_in_a_row_do_not_quarantine_a_serving_peer() {
9796        let (mut engine, remote, store) = dead_page_engine(200);
9797        virtual_peer_budget(&mut engine, &remote);
9798        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9799        let host = crate::gasp::peer_origin("https://budget.example");
9800        let runs = crate::gasp::PEER_QUARANTINE_THRESHOLD + 1;
9801        for run in 1..=runs {
9802            let asked = remote.asked.borrow().len();
9803            let result = engine.start_gasp_sync().await.unwrap();
9804            let topic = &result.topics_synced["tm_test"];
9805            assert!(remote.asked.borrow().len() > asked, "run {run}: attempted");
9806            assert_eq!(topic.deadline_dropped_graphs, 1, "run {run}");
9807            assert!(topic.expired_refused > 0, "run {run}: {topic:?}");
9808            // 0.1.9: the gap is a root the peer lists and does not serve
9809            // (`not_served`); its third faulted run records it unanchorable
9810            // and the cursor passes it.
9811            if run < u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9812                assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
9813            } else if run == u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9814                assert_eq!(topic.unanchorable_graphs, 1, "run {run}: {topic:?}");
9815                assert!(!topic.cursor_moves.is_empty(), "run {run}: passed");
9816            }
9817            let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9818            assert_eq!(health.consecutive_failures, 0, "run {run}");
9819        }
9820        // A refused root is never fetched again. The one root fetched twice
9821        // per cut is the graph in flight: its answer was lost with the slice
9822        // and its hold is resumed (and refused) by the next run.
9823        let asked = remote.asked.borrow();
9824        let mut dead: Vec<&String> = asked
9825            .iter()
9826            .filter(|t| **t != remote.outputs[0].txid)
9827            .collect();
9828        let fetched = dead.len();
9829        dead.sort();
9830        dead.dedup();
9831        assert_eq!(
9832            fetched - dead.len(),
9833            (runs - 1) as usize,
9834            "one per resumed cut"
9835        );
9836        assert_eq!(store.expired_outpoints("tm_test").len(), dead.len() - 1);
9837    }
9838
9839    /// A courier that serves ONE ancestor, and only once told to (engine
9840    /// 0.1.9): before, every rung "could not serve" it.
9841    struct GatedCourier {
9842        txid: String,
9843        raw_tx: String,
9844        proof: String,
9845        serve: std::rc::Rc<Cell<bool>>,
9846        asked: std::rc::Rc<Cell<u64>>,
9847    }
9848
9849    #[async_trait(?Send)]
9850    impl AncestorFetcher for GatedCourier {
9851        async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
9852            self.asked.set(self.asked.get() + 1);
9853            if self.serve.get() && txid == self.txid {
9854                Ok(FetchedAncestor {
9855                    raw_tx: self.raw_tx.clone(),
9856                    proof: Some(self.proof.clone()),
9857                })
9858            } else {
9859                Err(GASPError::RemoteError(format!(
9860                    "no rung served the raw of {txid}"
9861                )))
9862            }
9863        }
9864        async fn verified_proof_for(&self, _: &str) -> Option<String> {
9865            None
9866        }
9867    }
9868
9869    /// The fixture of 0.1.9: a page of three (scores 10, 20, 30) whose
9870    /// lowest root is served UNPROVEN, so its walk needs its parent, which
9871    /// only the courier can serve (a mined parent, proven at height 900).
9872    #[tokio::test]
9873    async fn load_0112_definitive_absence_strikes_a_resumed_obligation() {
9874        let mut fixture = obligation_fixture(1, 1, 0).await;
9875        fixture
9876            .engine
9877            .set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
9878                calls: std::rc::Rc::new(Cell::new(0)),
9879                bounded: false,
9880            }));
9881        let result = fixture.engine.start_gasp_sync().await.unwrap();
9882        let topic = &result.topics_synced["tm_test"];
9883        assert_eq!((topic.unanchorable_graphs, topic.finalized_graphs), (1, 1));
9884        let record = fixture
9885            .store
9886            .get_deferred_graph("https://budget.example", "tm_test", &fixture.obligations[0])
9887            .await
9888            .unwrap()
9889            .unwrap();
9890        assert_eq!(
9891            (record.reason.as_str(), record.faults),
9892            ("unanchorable:not_found", 1)
9893        );
9894    }
9895
9896    #[tokio::test]
9897    async fn load_0112_root_time_is_clamped_and_named() {
9898        let (mut engine, mut remote) = budget_page_engine(1);
9899        remote.node_cost_ms = 16_000;
9900        let elapsed = remote.elapsed.clone();
9901        let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
9902            let clock = elapsed.clone();
9903            let due = clock.get() + ms;
9904            Box::pin(std::future::poll_fn(move |_| {
9905                if clock.get() >= due {
9906                    std::task::Poll::Ready(())
9907                } else {
9908                    std::task::Poll::Pending
9909                }
9910            }))
9911        });
9912        engine.set_peer_sync_budget(sleep.clone(), 30_000);
9913        engine.set_graph_budget(sleep, 100, 60_000);
9914        engine.set_gasp_remote_factory(Box::new(remote));
9915        let result = engine.start_gasp_sync().await.unwrap();
9916        let topic = &result.topics_synced["tm_test"];
9917        assert_eq!(topic.finalized_graphs, 0);
9918        assert_eq!(topic.root_bounds[0].reason, "time");
9919        assert!(!topic.cursor_held_by.is_empty() || topic.deferred_graphs > 0);
9920    }
9921
9922    #[tokio::test]
9923    async fn load_0112_storage_counts_are_attempts_at_the_boundary() {
9924        let engine = make_engine(vec![0]);
9925        let meter = crate::meter::LoadMeter::default();
9926        meter
9927            .scope(async {
9928                assert_eq!(
9929                    engine
9930                        .storage
9931                        .get_last_interaction("peer", "topic")
9932                        .await
9933                        .unwrap(),
9934                    0
9935                );
9936                engine
9937                    .storage
9938                    .update_last_interaction("peer", "topic", 12)
9939                    .await
9940                    .unwrap();
9941                assert_eq!(
9942                    engine
9943                        .storage
9944                        .get_last_interaction("peer", "topic")
9945                        .await
9946                        .unwrap(),
9947                    12
9948                );
9949            })
9950            .await;
9951        assert_eq!(
9952            (
9953                meter.snapshot().storage_reads,
9954                meter.snapshot().storage_writes
9955            ),
9956            (2, 1)
9957        );
9958        engine
9959            .storage
9960            .get_last_interaction("peer", "topic")
9961            .await
9962            .unwrap();
9963        assert_eq!(meter.snapshot().storage_reads, 2);
9964    }
9965
9966    struct LoadCourier {
9967        calls: std::rc::Rc<Cell<u64>>,
9968        bounded: bool,
9969    }
9970    #[async_trait(?Send)]
9971    impl crate::gasp::AncestorFetcher for LoadCourier {
9972        async fn fetch_ancestor(
9973            &self,
9974            txid: &str,
9975        ) -> Result<crate::gasp::FetchedAncestor, crate::gasp::GASPError> {
9976            self.calls.set(self.calls.get() + 1);
9977            if self.bounded {
9978                for _ in 0..13 {
9979                    crate::meter::courier_request()
9980                        .map_err(|e| crate::gasp::GASPError::Other(e.into()))?;
9981                }
9982                Err(crate::gasp::GASPError::RemoteError(
9983                    "still missing a proof".into(),
9984                ))
9985            } else {
9986                for _ in 0..3 {
9987                    crate::meter::courier_request().unwrap();
9988                }
9989                Err(crate::gasp::GASPError::DefinitiveNotFound(txid.into()))
9990            }
9991        }
9992        async fn verified_proof_for(&self, _: &str) -> Option<String> {
9993            None
9994        }
9995    }
9996
9997    #[tokio::test]
9998    async fn load_0112_definitive_absence_is_shared_across_peers_and_topics() {
9999        let UnanchorableFixture {
10000            mut engine,
10001            remote,
10002            root,
10003            ..
10004        } = unanchorable_fixture();
10005        let calls = std::rc::Rc::new(Cell::new(0));
10006        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10007            calls: calls.clone(),
10008            bounded: false,
10009        }));
10010        engine.managers.insert(
10011            "tm_other".into(),
10012            Box::new(MockTopicManager::admitting(vec![0])),
10013        );
10014        for topic in ["tm_test", "tm_other"] {
10015            engine.config.sync_configuration.insert(
10016                topic.into(),
10017                SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
10018            );
10019        }
10020        let result = engine.start_gasp_sync().await.unwrap();
10021        assert_eq!(calls.get(), 1, "one ladder per missing ancestor per run");
10022        assert_eq!(result.cost.courier_not_found_memo_hits, 3);
10023        assert_eq!(result.cost.courier_rungs, 3);
10024        for (topic, res) in &result.topics_synced {
10025            assert_eq!(res.unanchorable_graphs, 2, "{res:?}");
10026            assert_eq!(
10027                res.cost.courier_rungs + res.cost.peer_requests,
10028                res.cost.calls_out
10029            );
10030            for host in ["https://a.example", "https://b.example"] {
10031                let mut record = engine
10032                    .storage
10033                    .get_deferred_graph(host, topic, &root)
10034                    .await
10035                    .unwrap()
10036                    .unwrap();
10037                assert_eq!(record.reason, "unanchorable:not_found");
10038                assert_eq!(record.faults, 1);
10039                assert!(record.cursor_passable);
10040                record.retry_at = 0;
10041                engine.storage.put_deferred_graph(&record).await.unwrap();
10042            }
10043        }
10044        assert!(!remote.asked.borrow().is_empty());
10045        engine.start_gasp_sync().await.unwrap();
10046        assert_eq!(calls.get(), 2, "another run can retry absence");
10047    }
10048
10049    #[tokio::test]
10050    async fn load_0112_courier_bound_defers_the_root_and_names_it() {
10051        let UnanchorableFixture {
10052            mut engine, root, ..
10053        } = unanchorable_fixture();
10054        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10055            calls: std::rc::Rc::new(Cell::new(0)),
10056            bounded: true,
10057        }));
10058        let result = engine.start_gasp_sync().await.unwrap();
10059        let record = engine
10060            .storage
10061            .get_deferred_graph("https://budget.example", "tm_test", &root)
10062            .await
10063            .unwrap()
10064            .expect("bounded root must be retained");
10065        assert_eq!(record.reason, "courier_rungs");
10066        assert!(!record.nodes.is_empty());
10067        assert_eq!(result.cost.courier_rungs, 12);
10068        assert_eq!(
10069            result.topics_synced["tm_test"].root_bounds[0].reason,
10070            "courier_rungs"
10071        );
10072        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 2);
10073    }
10074
10075    #[tokio::test]
10076    async fn load_0112_refused_save_does_not_restart_a_bounded_root() {
10077        let UnanchorableFixture {
10078            mut engine,
10079            store,
10080            root,
10081            ..
10082        } = unanchorable_fixture();
10083        store.set_deferred_graph_ceiling(Some(0));
10084        let calls = std::rc::Rc::new(Cell::new(0));
10085        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10086            calls: calls.clone(),
10087            bounded: true,
10088        }));
10089        let result = engine.start_gasp_sync().await.unwrap();
10090        let topic = &result.topics_synced["tm_test"];
10091        assert_eq!(calls.get(), 1, "no retry after a hard root cut");
10092        assert_eq!(topic.root_bounds.len(), 1);
10093        assert_eq!(result.cost.courier_rungs, 12);
10094        assert!(topic.cursor_held_by.contains(&root));
10095        assert_eq!(
10096            topic.strikes_refused, 0,
10097            "a budget cut is not a fault strike"
10098        );
10099        assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10100        assert_eq!(topic.finalized_graphs, 2);
10101    }
10102
10103    #[tokio::test]
10104    async fn load_0112_refused_save_does_not_extend_root_time() {
10105        let (mut engine, mut remote) = budget_page_engine(1);
10106        remote.node_cost_ms = 16_000;
10107        let elapsed = remote.elapsed.clone();
10108        let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10109            let clock = elapsed.clone();
10110            let due = clock.get() + ms;
10111            Box::pin(std::future::poll_fn(move |_| {
10112                if clock.get() >= due {
10113                    std::task::Poll::Ready(())
10114                } else {
10115                    std::task::Poll::Pending
10116                }
10117            }))
10118        });
10119        let store = std::rc::Rc::new(MemoryStorage::new());
10120        store.set_deferred_graph_ceiling(Some(0));
10121        engine.storage = Box::new(store);
10122        engine.set_peer_sync_budget(sleep.clone(), 30_000);
10123        engine.set_graph_budget(sleep, 100, 60_000);
10124        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10125        let result = engine.start_gasp_sync().await.unwrap();
10126        let topic = &result.topics_synced["tm_test"];
10127        assert_eq!(
10128            remote.asked.borrow().len(),
10129            1,
10130            "no request after the time cut"
10131        );
10132        assert_eq!(topic.root_bounds.len(), 1);
10133        assert_eq!(topic.root_bounds[0].reason, "time");
10134        assert_eq!(topic.finalized_graphs, 0);
10135        assert!(!topic.cursor_held_by.is_empty());
10136    }
10137
10138    #[tokio::test]
10139    async fn load_0112_call_limit_fallback_keeps_the_root_deadline() {
10140        let UnanchorableFixture {
10141            mut engine,
10142            mut remote,
10143            store,
10144            root,
10145            serve,
10146            ..
10147        } = unanchorable_fixture();
10148        store.set_deferred_graph_ceiling(Some(0));
10149        serve.set(true);
10150        remote.node_cost_ms = 8_000;
10151        struct DelayedCourier {
10152            inner: std::rc::Rc<dyn AncestorFetcher>,
10153            clock: std::rc::Rc<Cell<u64>>,
10154        }
10155        #[async_trait(?Send)]
10156        impl AncestorFetcher for DelayedCourier {
10157            async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10158                self.clock.set(self.clock.get() + 8_000);
10159                tokio::task::yield_now().await;
10160                self.inner.fetch_ancestor(txid).await
10161            }
10162            async fn verified_proof_for(&self, txid: &str) -> Option<String> {
10163                self.inner.verified_proof_for(txid).await
10164            }
10165        }
10166        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(DelayedCourier {
10167            inner: engine.gasp_ancestor_fetcher.as_ref().unwrap().clone(),
10168            clock: remote.elapsed.clone(),
10169        }));
10170        let elapsed = remote.elapsed.clone();
10171        let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10172            let clock = elapsed.clone();
10173            let due = clock.get() + ms;
10174            Box::pin(std::future::poll_fn(move |_| {
10175                if clock.get() >= due {
10176                    std::task::Poll::Ready(())
10177                } else {
10178                    std::task::Poll::Pending
10179                }
10180            }))
10181        });
10182        engine.set_peer_sync_budget(sleep.clone(), 30_000);
10183        engine.set_graph_budget(sleep, 1, 60_000);
10184        engine.set_gasp_remote_factory(Box::new(remote));
10185        let result = engine.start_gasp_sync().await.unwrap();
10186        let topic = &result.topics_synced["tm_test"];
10187        assert!(
10188            topic
10189                .root_bounds
10190                .iter()
10191                .any(|b| b.outpoint == root && b.reason == "time"),
10192            "{topic:?}"
10193        );
10194        assert!(topic.cursor_held_by.contains(&root));
10195        assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10196    }
10197
10198    #[tokio::test]
10199    async fn load_0112_cost_matches_the_remote_call_log() {
10200        let (mut engine, remote) = budget_page_engine(2);
10201        remote.elapsed.set(0);
10202        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10203        let result = engine.start_gasp_sync().await.unwrap();
10204        let actual_calls = (remote.requests.borrow().len() + remote.asked.borrow().len()) as u64;
10205        assert_eq!(result.cost.peer_requests, actual_calls);
10206        assert_eq!(result.cost.calls_out, actual_calls);
10207        assert!(result.cost.storage_reads > 0);
10208        assert!(result.cost.storage_writes > 0);
10209        assert_eq!(
10210            result.cost.storage_reads,
10211            result.topics_synced["tm_test"].cost.storage_reads
10212        );
10213        assert_eq!(
10214            result.cost.storage_writes,
10215            result.topics_synced["tm_test"].cost.storage_writes
10216        );
10217    }
10218
10219    struct UnanchorableFixture {
10220        engine: Engine,
10221        remote: BudgetPageRemote,
10222        store: std::rc::Rc<MemoryStorage>,
10223        root: String,
10224        serve: std::rc::Rc<Cell<bool>>,
10225        asked: std::rc::Rc<Cell<u64>>,
10226    }
10227
10228    fn unanchorable_fixture() -> UnanchorableFixture {
10229        let (mut engine, mut remote) = budget_page_engine(3);
10230        remote.node_cost_ms = 0;
10231        let parent = child_of(&format!("{:064x}", 0xab), 0);
10232        let child = child_of(&parent.id(), 0);
10233        let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
10234        for n in 1..=3u32 {
10235            tracker.add_root(n, child_of(&format!("{n:064x}"), 0).id());
10236        }
10237        tracker.add_root(900, parent.id());
10238        engine.chain_tracker = Some(Box::new(tracker));
10239        let replaced = std::mem::replace(
10240            &mut std::rc::Rc::make_mut(&mut remote.outputs)[0].txid,
10241            child.id(),
10242        );
10243        let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10244        nodes.remove(&replaced);
10245        nodes.insert(
10246            child.id(),
10247            crate::types::GASPNode {
10248                graph_id: String::new(),
10249                raw_tx: child.to_hex(),
10250                output_index: 0,
10251                proof: None,
10252                tx_metadata: None,
10253                output_metadata: None,
10254                inputs: None,
10255            },
10256        );
10257        let serve = std::rc::Rc::new(Cell::new(false));
10258        let asked = std::rc::Rc::new(Cell::new(0));
10259        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(GatedCourier {
10260            txid: parent.id(),
10261            raw_tx: parent.to_hex(),
10262            proof: single_leaf_bump_hex(&parent.id(), 900),
10263            serve: serve.clone(),
10264            asked: asked.clone(),
10265        }));
10266        let store = std::rc::Rc::new(MemoryStorage::new());
10267        engine.storage = Box::new(store.clone());
10268        virtual_peer_budget(&mut engine, &remote);
10269        engine.set_graph_budget(
10270            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10271            100,
10272            60_000,
10273        );
10274        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10275        UnanchorableFixture {
10276            engine,
10277            remote,
10278            store,
10279            root: format!("{}.0", child.id()),
10280            serve,
10281            asked,
10282        }
10283    }
10284
10285    /// 0.1.9 (unanchorable): a root whose ancestor the courier cannot serve
10286    /// faults three runs in a row; each run names it with its class
10287    /// (`courier`) and holds the cursor below it until the third, which
10288    /// records it `unanchorable:courier` and passes the cursor to the page's
10289    /// last finished score. Inside its backoff it costs no request. A later
10290    /// run in which the courier serves the ancestor admits it and clears the
10291    /// record. On 0.1.8 the cursor stayed at 0 on every run (fact 2: the
10292    /// keeper's hand).
10293    #[tokio::test]
10294    async fn unanchorable_019_a_root_faulted_three_runs_is_recorded_passed_and_admitted_later() {
10295        let UnanchorableFixture {
10296            engine,
10297            remote,
10298            store,
10299            root,
10300            serve,
10301            asked,
10302        } = unanchorable_fixture();
10303        let host = "https://budget.example";
10304        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10305        for run in 1..=runs {
10306            let result = engine.start_gasp_sync().await.unwrap();
10307            let topic = &result.topics_synced["tm_test"];
10308            assert_eq!(topic.faulted_roots.len(), 1, "run {run}: {topic:?}");
10309            let named = &topic.faulted_roots[0];
10310            assert_eq!(
10311                (named.peer.as_str(), named.outpoint.as_str()),
10312                (host, root.as_str())
10313            );
10314            assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10315            assert_eq!(named.faults, run, "run {run}");
10316            assert!(named.error.contains("no rung served"), "{named:?}");
10317            let record = engine
10318                .storage()
10319                .get_deferred_graph(host, "tm_test", &root)
10320                .await
10321                .unwrap()
10322                .unwrap();
10323            assert_eq!(record.faults, run);
10324            assert!(record.nodes.is_empty(), "the root alone, asked again");
10325            if run < runs {
10326                assert!(!named.unanchorable);
10327                assert_eq!(topic.unanchorable_graphs, 0);
10328                assert_eq!(record.reason, "retry:fault:courier");
10329                assert!(!record.cursor_passable);
10330                assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10331                assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10332            } else {
10333                assert!(named.unanchorable);
10334                assert_eq!(topic.unanchorable_graphs, 1);
10335                assert_eq!(record.reason, "unanchorable:courier");
10336                assert!(record.cursor_passable);
10337                assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
10338                assert_eq!(topic.cursor_moves.len(), 1);
10339                assert_eq!(
10340                    topic.cursor_moves[0].to, 30,
10341                    "the page's last finished score"
10342                );
10343                assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10344            }
10345            if run == 1 {
10346                assert_eq!(topic.finalized_graphs, 2, "20 and 30");
10347            }
10348        }
10349
10350        // Inside the backoff: the root is not asked of the peer or the
10351        // courier, nothing is named, and the quiet run is no attempt.
10352        let (courier_asks, node_asks) = (asked.get(), remote.asked.borrow().len());
10353        let streak = store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test");
10354        let result = engine.start_gasp_sync().await.unwrap();
10355        let topic = &result.topics_synced["tm_test"];
10356        assert!(topic.faulted_roots.is_empty(), "{topic:?}");
10357        assert!(topic.cursor_held_by.is_empty());
10358        assert_eq!(topic.unanchorable_graphs, 0);
10359        assert_eq!(asked.get(), courier_asks);
10360        assert_eq!(remote.asked.borrow().len(), node_asks);
10361        assert_eq!(
10362            store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test"),
10363            streak
10364        );
10365
10366        // The backoff runs out and the courier serves the ancestor: the
10367        // root is admitted as any root and its record is cleared.
10368        let mut record = engine
10369            .storage()
10370            .get_deferred_graph(host, "tm_test", &root)
10371            .await
10372            .unwrap()
10373            .unwrap();
10374        record.retry_at = 1;
10375        engine.storage().put_deferred_graph(&record).await.unwrap();
10376        serve.set(true);
10377        let result = engine.start_gasp_sync().await.unwrap();
10378        let topic = &result.topics_synced["tm_test"];
10379        assert_eq!(topic.finalized_graphs, 1, "{topic:?}");
10380        assert_eq!(topic.unanchorable_cleared, 1);
10381        assert!(topic.faulted_roots.is_empty());
10382        assert!(engine
10383            .storage()
10384            .get_deferred_graph(host, "tm_test", &root)
10385            .await
10386            .unwrap()
10387            .is_none());
10388        let (txid, _) = crate::gasp::parse_outpoint(&root).unwrap();
10389        assert!(engine
10390            .storage
10391            .find_output(&txid, 0, Some("tm_test"), None, true)
10392            .await
10393            .unwrap()
10394            .is_some());
10395    }
10396
10397    /// 0.1.9: only the run's LOWEST faulted root starts a fault record (the
10398    /// delta fold's D-M1: a peer that faults every node earns one record a
10399    /// run, not one per root); the others are named, class and all, and
10400    /// stay gaps above it.
10401    #[tokio::test]
10402    async fn unanchorable_019_only_the_lowest_faulted_root_is_recorded_and_all_are_named() {
10403        let (mut engine, mut remote) = budget_page_engine(4);
10404        remote.node_cost_ms = 0;
10405        let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10406        nodes.remove(&remote.outputs[1].txid);
10407        nodes.remove(&remote.outputs[3].txid);
10408        let store = std::rc::Rc::new(MemoryStorage::new());
10409        engine.storage = Box::new(store.clone());
10410        virtual_peer_budget(&mut engine, &remote);
10411        engine.set_graph_budget(
10412            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10413            100,
10414            60_000,
10415        );
10416        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10417        let result = engine.start_gasp_sync().await.unwrap();
10418        let topic = &result.topics_synced["tm_test"];
10419        let named: Vec<(&str, &str, u32)> = topic
10420            .faulted_roots
10421            .iter()
10422            .map(|f| (f.outpoint.as_str(), f.class.as_str(), f.faults))
10423            .collect();
10424        let low = format!("{}.0", remote.outputs[1].txid);
10425        let high = format!("{}.0", remote.outputs[3].txid);
10426        assert_eq!(
10427            named,
10428            vec![
10429                (low.as_str(), crate::gasp::fault_class::NOT_SERVED, 1),
10430                (high.as_str(), crate::gasp::fault_class::NOT_SERVED, 0),
10431            ]
10432        );
10433        assert_eq!(topic.cursor_moves[0].to, 10, "below the lower gap");
10434        let host = "https://budget.example";
10435        assert!(store
10436            .get_deferred_graph(host, "tm_test", &high)
10437            .await
10438            .unwrap()
10439            .is_none());
10440        assert_eq!(
10441            store
10442                .get_deferred_graph(host, "tm_test", &low)
10443                .await
10444                .unwrap()
10445                .unwrap()
10446                .reason,
10447            "retry:fault:not_served"
10448        );
10449    }
10450
10451    /// A record of the budget page's peer and topic, holding nothing but
10452    /// its root (engine 0.1.10 tests).
10453    fn bare_record(n: u32, score: u64, reason: &str, passable: bool) -> crate::gasp::DeferredGraph {
10454        let outpoint = format!("{:064x}.0", 0xdead_0000 + n);
10455        crate::gasp::DeferredGraph {
10456            peer: "https://budget.example".into(),
10457            topic: "tm_test".into(),
10458            outpoint: outpoint.clone(),
10459            score,
10460            nodes: Vec::new(),
10461            pending: vec![crate::gasp::PendingInput {
10462                outpoint: outpoint.clone(),
10463                graph_id: outpoint,
10464                metadata: true,
10465                spent_by: None,
10466                parent_proven: false,
10467            }],
10468            calls: 0,
10469            passes: 0,
10470            reason: reason.into(),
10471            configured: true,
10472            idle_faults: 0,
10473            cursor_passable: passable,
10474            faults: 0,
10475            retry_at: 0,
10476            idle_resumes: 0,
10477            unanchorable_retries: 0,
10478            expiry: None,
10479        }
10480    }
10481
10482    /// Fill the budget page's (peer, topic) with `count` root retry
10483    /// obligations the peer does not serve (fact 3 of the 0.1.10 brief:
10484    /// `held:retry`, nodes 0, below the cursor), returning their roots.
10485    async fn hold_obligations(engine: &Engine, count: usize) -> Vec<String> {
10486        let mut roots = Vec::new();
10487        for n in 0..count {
10488            let record = bare_record(n as u32, 1 + n as u64, "retry", true);
10489            roots.push(record.outpoint.clone());
10490            engine.storage().put_deferred_graph(&record).await.unwrap();
10491        }
10492        roots
10493    }
10494
10495    /// 0.1.10 (room): a (peer, topic) whose deferred slots are all held by
10496    /// root retry obligations that never progress still records the root
10497    /// that holds its cursor: a fault record is bounded on its own
10498    /// (`FAULT_RECORDS_PER_PEER_TOPIC`), so the strike is written on each
10499    /// faulted run and the third passes the cursor. On 0.1.9 every strike
10500    /// found "no room" (`gasp.rs:1717`), the root was named with no run and
10501    /// the cursor stayed at 0 for ever (fact 2: the keeper's eighth hand).
10502    #[tokio::test]
10503    async fn room_0110_a_full_store_still_records_the_cursors_holder_and_passes_it() {
10504        let UnanchorableFixture {
10505            engine,
10506            store,
10507            root,
10508            ..
10509        } = unanchorable_fixture();
10510        let host = "https://budget.example";
10511        let obligations =
10512            hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10513        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10514        for run in 1..=runs {
10515            let result = engine.start_gasp_sync().await.unwrap();
10516            let topic = &result.topics_synced["tm_test"];
10517            assert_eq!(topic.strikes_refused, 0, "run {run}: {topic:?}");
10518            let named = topic
10519                .faulted_roots
10520                .iter()
10521                .find(|f| f.outpoint == root)
10522                .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
10523            assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10524            assert_eq!(named.faults, run, "run {run}: the strike was written");
10525            assert!(named.refused.is_empty(), "{named:?}");
10526            if run < runs {
10527                assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10528                assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10529            } else {
10530                assert!(named.unanchorable);
10531                assert_eq!(topic.unanchorable_graphs, 1);
10532                assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10533                assert_eq!(topic.cursor_moves[0].to, 30, "passed");
10534                assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10535            }
10536        }
10537        // The obligations are all still held beside the fault record.
10538        let held: Vec<String> = store
10539            .deferred_graphs()
10540            .into_iter()
10541            .map(|r| r.outpoint)
10542            .collect();
10543        assert_eq!(held.len(), obligations.len() + 1, "{held:?}");
10544        assert!(obligations.iter().all(|o| held.contains(o)));
10545        let record = store
10546            .get_deferred_graph(host, "tm_test", &root)
10547            .await
10548            .unwrap()
10549            .unwrap();
10550        assert_eq!(record.reason, "unanchorable:courier");
10551    }
10552
10553    /// 0.1.10 (the reason, readable): a strike the bound or the storage
10554    /// refuses is counted (`strikes_refused`) and named on the root
10555    /// (`refused`): `no_room` past `FAULT_RECORDS_PER_PEER_TOPIC` fault
10556    /// records and at the storage's ceiling, `store_fault` on its fault.
10557    /// The root stays a gap the cursor holds below, as on 0.1.9, where the
10558    /// refusal was a `warn!` alone.
10559    #[tokio::test]
10560    async fn room_0110_a_refused_strike_is_counted_and_named() {
10561        let UnanchorableFixture {
10562            engine,
10563            store,
10564            root,
10565            ..
10566        } = unanchorable_fixture();
10567        // The fault bound: as many unanchorable roots, inside their backoff
10568        // (passable, never asked), as a (peer, topic) may hold.
10569        let later = (current_timestamp_ms() / 1000.0) as u64 + 3600;
10570        for n in 0..crate::gasp::FAULT_RECORDS_PER_PEER_TOPIC {
10571            let mut record = bare_record(n as u32, 1, "unanchorable:courier", true);
10572            record.faults = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10573            record.retry_at = later;
10574            engine.storage().put_deferred_graph(&record).await.unwrap();
10575        }
10576        let refused_as = |result: &super::GASPSyncResult, why: &str| {
10577            let topic = &result.topics_synced["tm_test"];
10578            assert_eq!(topic.strikes_refused, 1, "{topic:?}");
10579            let named = topic
10580                .faulted_roots
10581                .iter()
10582                .find(|f| f.outpoint == root)
10583                .unwrap_or_else(|| panic!("{topic:?}"));
10584            assert_eq!(named.refused, why, "{named:?}");
10585            assert_eq!(named.faults, 0, "no record holds the count");
10586            assert!(topic.cursor_moves.is_empty(), "the gap holds");
10587            assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10588        };
10589        let result = engine.start_gasp_sync().await.unwrap();
10590        refused_as(&result, crate::gasp::strike_refused::NO_ROOM);
10591        assert!(store
10592            .get_deferred_graph("https://budget.example", "tm_test", &root)
10593            .await
10594            .unwrap()
10595            .is_none());
10596
10597        // Room again among the fault records, none in the storage.
10598        let gone = bare_record(0, 1, "", false).outpoint;
10599        engine
10600            .storage()
10601            .delete_deferred_graph("https://budget.example", "tm_test", &gone)
10602            .await
10603            .unwrap();
10604        store.set_deferred_graph_ceiling(Some(store.deferred_graphs().len()));
10605        let result = engine.start_gasp_sync().await.unwrap();
10606        refused_as(&result, crate::gasp::strike_refused::NO_ROOM);
10607
10608        // The storage faults.
10609        store.set_deferred_graph_ceiling(None);
10610        store.set_deferred_graph_put_fault(true);
10611        let result = engine.start_gasp_sync().await.unwrap();
10612        refused_as(&result, crate::gasp::strike_refused::STORE_FAULT);
10613
10614        // The storage serves again: the strike is written.
10615        store.set_deferred_graph_put_fault(false);
10616        let result = engine.start_gasp_sync().await.unwrap();
10617        let topic = &result.topics_synced["tm_test"];
10618        assert_eq!(topic.strikes_refused, 0, "{topic:?}");
10619        assert!(store
10620            .get_deferred_graph("https://budget.example", "tm_test", &root)
10621            .await
10622            .unwrap()
10623            .is_some());
10624    }
10625
10626    /// 0.1.10 (the churn bounded): sixteen root retry obligations the peer
10627    /// no longer serves (fact 3: `held:retry`, nodes 0) took every run's
10628    /// slice on 0.1.9, resumed ahead of the page, appending nothing, so the
10629    /// page's fresh roots were never walked. Each now goes to the long
10630    /// backoff after `IDLE_RESUMES_BEFORE_BACKOFF` idle resumes, is asked
10631    /// nothing while it waits, and stays held; the page is walked.
10632    #[tokio::test]
10633    async fn room_0110_idle_obligations_back_off_and_the_page_is_walked() {
10634        let (mut engine, mut remote) = budget_page_engine(3);
10635        remote.node_cost_ms = 2000;
10636        let store = std::rc::Rc::new(MemoryStorage::new());
10637        engine.storage = Box::new(store.clone());
10638        virtual_peer_budget(&mut engine, &remote);
10639        engine.set_graph_budget(
10640            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10641            100,
10642            60_000,
10643        );
10644        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10645        let obligations =
10646            hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10647        let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10648        // The witness of 0.1.9: each run's slice goes to the obligations
10649        // (15 resumed, nothing appended, the last of them cut in flight by
10650        // the per-peer deadline) and the page is never reached.
10651        for run in 1..=idle {
10652            let result = engine.start_gasp_sync().await.unwrap();
10653            let topic = &result.topics_synced["tm_test"];
10654            assert_eq!(topic.finalized_graphs, 0, "run {run}: {topic:?}");
10655            assert!(topic.cursor_moves.is_empty(), "run {run}");
10656            assert_eq!(topic.resumed_graphs, 15, "run {run}");
10657            assert_eq!(topic.waiting_graphs, 0, "run {run}");
10658            let expect = if run == idle { 15 } else { 0 };
10659            assert_eq!(topic.backed_off_graphs, expect, "run {run}: {topic:?}");
10660        }
10661        // The fifteen wait; the slice reaches the page.
10662        let result = engine.start_gasp_sync().await.unwrap();
10663        let topic = &result.topics_synced["tm_test"];
10664        assert_eq!(topic.waiting_graphs, 15, "{topic:?}");
10665        assert_eq!(topic.finalized_graphs, 3, "the page's fresh roots");
10666        assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10667        assert_eq!(topic.cursor_moves[0].to, 30);
10668        // The last one follows; then every obligation waits, asked nothing.
10669        let mut backed_off = 15;
10670        for _ in 0..idle {
10671            let result = engine.start_gasp_sync().await.unwrap();
10672            backed_off += result.topics_synced["tm_test"].backed_off_graphs;
10673        }
10674        assert_eq!(backed_off, obligations.len() as u64);
10675        let asked = remote.asked.borrow().len();
10676        let result = engine.start_gasp_sync().await.unwrap();
10677        let topic = &result.topics_synced["tm_test"];
10678        assert_eq!(topic.waiting_graphs, obligations.len() as u64, "{topic:?}");
10679        assert_eq!(topic.resumed_graphs, 0);
10680        assert!(topic.dropped_graphs.is_empty(), "{topic:?}");
10681        assert_eq!(remote.asked.borrow().len(), asked, "no request");
10682        // Kept, not deleted (bsv-low #555's resume), each on its backoff.
10683        let now = (current_timestamp_ms() / 1000.0) as u64;
10684        let held = store.deferred_graphs();
10685        assert_eq!(held.len(), obligations.len());
10686        for record in &held {
10687            assert!(obligations.contains(&record.outpoint));
10688            assert!(record.cursor_passable);
10689            assert!(record.idle_resumes >= idle, "{record:?}");
10690            assert!(record.retry_at > now, "{record:?}");
10691        }
10692    }
10693
10694    /// 0.1.10: a record waiting out its idle backoff whose root is
10695    /// remembered expired (0.1.8's memo, keyed by topic: any peer's walk that
10696    /// refused it) is swept at the next sync ahead of the backoff gate, with
10697    /// no request: refused and deleted, counted in `dropped_graphs`
10698    /// (`refused`). A record that holds its root node is refused the same
10699    /// way from that node; one holding nothing is refused at its next
10700    /// resume, when its root is fetched.
10701    #[tokio::test]
10702    async fn room_0110_a_waiting_record_whose_root_expired_is_swept() {
10703        let (mut engine, remote) = budget_page_engine(1);
10704        let store = std::rc::Rc::new(MemoryStorage::new());
10705        engine.storage = Box::new(store.clone());
10706        engine.set_graph_budget(
10707            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10708            100,
10709            60_000,
10710        );
10711        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10712        let now = (current_timestamp_ms() / 1000.0) as u64;
10713        let mut waiting = bare_record(0, 1, "retry", true);
10714        waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10715        waiting.retry_at = now + 3600;
10716        let dead = bare_record(1, 2, "retry", true);
10717        let mut dead_waiting = dead.clone();
10718        dead_waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10719        dead_waiting.retry_at = now + 3600;
10720        for record in [&waiting, &dead_waiting] {
10721            engine.storage().put_deferred_graph(record).await.unwrap();
10722        }
10723        engine
10724            .storage()
10725            .remember_expired_outpoint("tm_test", &dead.outpoint, 1)
10726            .await
10727            .unwrap();
10728        let result = engine.start_gasp_sync().await.unwrap();
10729        let topic = &result.topics_synced["tm_test"];
10730        assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
10731        assert_eq!(
10732            topic
10733                .dropped_graphs
10734                .iter()
10735                .map(|d| (d.outpoint.as_str(), d.reason.as_str()))
10736                .collect::<Vec<_>>(),
10737            vec![(dead.outpoint.as_str(), "refused")]
10738        );
10739        let held: Vec<String> = store
10740            .deferred_graphs()
10741            .into_iter()
10742            .map(|r| r.outpoint)
10743            .collect();
10744        assert_eq!(held, vec![waiting.outpoint.clone()]);
10745        let asked = remote.asked.borrow();
10746        assert!(!asked.iter().any(|t| dead.outpoint.starts_with(t.as_str())));
10747        assert!(!asked
10748            .iter()
10749            .any(|t| waiting.outpoint.starts_with(t.as_str())));
10750    }
10751
10752    /// 0.1.10: a resume that appends a node resets the idle resumes (and
10753    /// any backoff), so a record that progresses is never backed off.
10754    #[tokio::test]
10755    async fn room_0110_a_resume_that_appends_a_node_resets_the_count() {
10756        let UnanchorableFixture {
10757            mut engine,
10758            remote,
10759            store,
10760            root,
10761            ..
10762        } = unanchorable_fixture();
10763        // One node a pass: the root is appended and its walk deferred.
10764        engine.set_graph_budget_limbs(u64::MAX, 1);
10765        engine.set_gasp_remote_factory(Box::new(remote));
10766        let mut record = bare_record(0, 10, "retry", true);
10767        record.outpoint.clone_from(&root);
10768        record.pending[0].outpoint.clone_from(&root);
10769        record.pending[0].graph_id.clone_from(&root);
10770        record.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF - 1;
10771        engine.storage().put_deferred_graph(&record).await.unwrap();
10772        let result = engine.start_gasp_sync().await.unwrap();
10773        let topic = &result.topics_synced["tm_test"];
10774        assert_eq!(topic.resumed_graphs, 1, "{topic:?}");
10775        assert_eq!(topic.backed_off_graphs, 0);
10776        let held = store
10777            .get_deferred_graph("https://budget.example", "tm_test", &root)
10778            .await
10779            .unwrap()
10780            .unwrap();
10781        assert_eq!(held.nodes.len(), 1, "{held:?}");
10782        assert_eq!(held.reason, "nodes");
10783        assert_eq!(held.idle_resumes, 0);
10784        assert_eq!(held.retry_at, 0);
10785    }
10786
10787    /// The courier of the 0.1.11 brief's fact 3: every rung exhausted, the
10788    /// answer `NodeNotFound` ("no raw tx for <txid> (bitails + woc +
10789    /// bananablocks exhausted)", `proof_fetcher.rs`), no proof for a root.
10790    struct ExhaustedCourier {
10791        asked: std::rc::Rc<Cell<u64>>,
10792        /// Answer a fault of the moment instead (a rung's rate limit).
10793        transient: std::rc::Rc<Cell<bool>>,
10794    }
10795
10796    #[async_trait(?Send)]
10797    impl AncestorFetcher for ExhaustedCourier {
10798        async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10799            self.asked.set(self.asked.get() + 1);
10800            if self.transient.get() {
10801                return Err(GASPError::RemoteError(format!(
10802                    "rung rate limited for {txid}"
10803                )));
10804            }
10805            Err(GASPError::NodeNotFound(format!(
10806                "no raw tx for {txid} (bitails + woc + bananablocks exhausted)"
10807            )))
10808        }
10809        async fn verified_proof_for(&self, _: &str) -> Option<String> {
10810            None
10811        }
10812    }
10813
10814    /// The production shape of the 0.1.11 brief (facts 2 and 3): a budget
10815    /// page of `page` proven roots (scores 10, 20, ...) and `count` root
10816    /// retry obligations written by 0.1.8 and 0.1.9 (`reason "retry"`,
10817    /// passable, nodes 0, no `idleResumes`) at scores 1, 2, ..., each a root
10818    /// the peer serves UNPROVEN whose parent no rung serves.
10819    struct ObligationFixture {
10820        engine: Engine,
10821        remote: BudgetPageRemote,
10822        store: std::rc::Rc<MemoryStorage>,
10823        obligations: Vec<String>,
10824        courier: std::rc::Rc<Cell<u64>>,
10825        transient: std::rc::Rc<Cell<bool>>,
10826    }
10827
10828    async fn obligation_fixture(page: u32, count: u32, node_cost_ms: u64) -> ObligationFixture {
10829        let (mut engine, mut remote) = budget_page_engine(page);
10830        remote.node_cost_ms = node_cost_ms;
10831        let mut obligations = Vec::new();
10832        let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10833        let mut records = Vec::new();
10834        for n in 0..count {
10835            let parent = child_of(&format!("{:064x}", 0xbeef_0000 + n), 0);
10836            let child = child_of(&parent.id(), 0);
10837            nodes.insert(
10838                child.id(),
10839                crate::types::GASPNode {
10840                    graph_id: String::new(),
10841                    raw_tx: child.to_hex(),
10842                    output_index: 0,
10843                    proof: None,
10844                    tx_metadata: None,
10845                    output_metadata: None,
10846                    inputs: None,
10847                },
10848            );
10849            let mut record = bare_record(n, 1 + u64::from(n), "retry", true);
10850            let outpoint = format!("{}.0", child.id());
10851            record.outpoint.clone_from(&outpoint);
10852            record.pending[0].outpoint.clone_from(&outpoint);
10853            record.pending[0].graph_id.clone_from(&outpoint);
10854            obligations.push(outpoint);
10855            records.push(record);
10856        }
10857        let courier = std::rc::Rc::new(Cell::new(0));
10858        let transient = std::rc::Rc::new(Cell::new(false));
10859        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
10860            asked: courier.clone(),
10861            transient: transient.clone(),
10862        }));
10863        let store = std::rc::Rc::new(MemoryStorage::new());
10864        engine.storage = Box::new(store.clone());
10865        virtual_peer_budget(&mut engine, &remote);
10866        engine.set_graph_budget(
10867            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10868            100,
10869            60_000,
10870        );
10871        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10872        for record in &records {
10873            // The JSON 0.1.8 and 0.1.9 wrote: no `idleResumes`, no `faults`.
10874            let json = serde_json::to_value(record).unwrap();
10875            assert!(json.get("idleResumes").is_none() && json.get("faults").is_none());
10876            engine.storage().put_deferred_graph(record).await.unwrap();
10877        }
10878        ObligationFixture {
10879            engine,
10880            remote,
10881            store,
10882            obligations,
10883            courier,
10884            transient,
10885        }
10886    }
10887
10888    /// 0.1.11 (a faulted resume is a strike): a held root retry obligation
10889    /// whose root the peer serves and whose parent the courier cannot serve
10890    /// (facts 2 and 3: `retry`, nodes 0, `courier`, `faults 0` on every run
10891    /// of 0.1.10, never struck, never backed off) is struck on its own slot
10892    /// on each run, recorded `unanchorable:courier` at the third, and not
10893    /// resumed inside its backoff; the page's roots behind it are walked in
10894    /// the same run, every run. The obligation stays passable throughout:
10895    /// its cursor had passed it, and a strike never takes that back.
10896    #[tokio::test]
10897    async fn obligations_0111_a_faulted_resume_is_struck_and_the_page_is_walked() {
10898        let ObligationFixture {
10899            engine,
10900            remote,
10901            store,
10902            obligations,
10903            courier,
10904            ..
10905        } = obligation_fixture(3, 1, 0).await;
10906        let host = "https://budget.example";
10907        let held = &obligations[0];
10908        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10909        for run in 1..=runs {
10910            let result = engine.start_gasp_sync().await.unwrap();
10911            let topic = &result.topics_synced["tm_test"];
10912            let named = topic
10913                .faulted_roots
10914                .iter()
10915                .find(|f| &f.outpoint == held)
10916                .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
10917            assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10918            assert!(named.error.contains("exhausted"), "{named:?}");
10919            assert_eq!(named.faults, run, "run {run}: struck on its own slot");
10920            assert!(named.refused.is_empty(), "{named:?}");
10921            assert_eq!(topic.strikes_refused, 0);
10922            assert!(topic.cursor_held_by.is_empty(), "run {run}: {topic:?}");
10923            if run == 1 {
10924                assert_eq!(topic.finalized_graphs, 3, "the page, the same run");
10925                assert_eq!(topic.cursor_moves[0].to, 30);
10926            }
10927            let record = store
10928                .get_deferred_graph(host, "tm_test", held)
10929                .await
10930                .unwrap()
10931                .unwrap();
10932            assert_eq!(record.faults, run, "{record:?}");
10933            assert!(record.cursor_passable, "run {run}: never holds the cursor");
10934            assert!(record.nodes.is_empty());
10935            if run < runs {
10936                assert!(!named.unanchorable);
10937                assert_eq!(record.reason, "retry:fault:courier");
10938                assert_eq!(topic.unanchorable_graphs, 0);
10939            } else {
10940                assert!(named.unanchorable);
10941                assert_eq!(record.reason, "unanchorable:courier");
10942                assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
10943                assert_eq!(topic.unanchorable_graphs, 1, "{topic:?}");
10944            }
10945        }
10946        // Inside its backoff: not resumed, nothing asked of the peer or the
10947        // courier, nothing named, held.
10948        let (asked, couriered) = (remote.asked.borrow().len(), courier.get());
10949        let result = engine.start_gasp_sync().await.unwrap();
10950        let topic = &result.topics_synced["tm_test"];
10951        assert!(topic.faulted_roots.is_empty(), "{topic:?}");
10952        assert_eq!(topic.resumed_graphs, 0);
10953        assert_eq!(remote.asked.borrow().len(), asked);
10954        assert_eq!(courier.get(), couriered);
10955        assert_eq!(store.deferred_graphs().len(), 1);
10956    }
10957
10958    /// 0.1.11 (the idle count fires): a resumed pass of a held record that
10959    /// asked and appended nothing it kept counts one idle resume whatever
10960    /// its ending, and the sync's accounting says how many records counted
10961    /// (`idle_counted`). Three endings: (a) the pass's own save (a rung's
10962    /// fault of the moment: the record is kept, reason `fault`); (b) a drop
10963    /// whose record is saved again as an obligation (the peer no longer
10964    /// serves the root, `not_held`); (c) the production shape of fact 3 (the
10965    /// root served, appended and thrown away with the drop, the courier
10966    /// exhausted) when its strike is refused at the fault bound, so the drop
10967    /// stands: on 0.1.10 the post-pass count read the sync's appended nodes,
10968    /// which the thrown-away root had raised, and never fired.
10969    #[tokio::test]
10970    async fn obligations_0111_a_resume_that_appends_nothing_counts_on_every_ending() {
10971        let host = "https://budget.example";
10972        // (c) The production shape, its strike refused: the fault bound is
10973        // full of unanchorable roots waiting out their backoff.
10974        let fixture = obligation_fixture(1, 1, 0).await;
10975        let later = (current_timestamp_ms() / 1000.0) as u64 + 3600;
10976        for n in 0..crate::gasp::FAULT_RECORDS_PER_PEER_TOPIC {
10977            let mut record = bare_record(100 + n as u32, 50, "unanchorable:courier", true);
10978            record.faults = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10979            record.retry_at = later;
10980            fixture
10981                .engine
10982                .storage()
10983                .put_deferred_graph(&record)
10984                .await
10985                .unwrap();
10986        }
10987        let held = &fixture.obligations[0];
10988        let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10989        for run in 1..=idle {
10990            let result = fixture.engine.start_gasp_sync().await.unwrap();
10991            let topic = &result.topics_synced["tm_test"];
10992            let record = fixture
10993                .store
10994                .get_deferred_graph(host, "tm_test", held)
10995                .await
10996                .unwrap()
10997                .unwrap();
10998            assert_eq!(record.idle_resumes, run, "run {run}: {record:?} {topic:?}");
10999            assert_eq!(record.reason, "retry", "the drop stands");
11000            assert!(record.cursor_passable && record.nodes.is_empty());
11001            assert_eq!(topic.idle_counted, 1, "run {run}: {topic:?}");
11002            let named = topic
11003                .faulted_roots
11004                .iter()
11005                .find(|f| &f.outpoint == held)
11006                .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11007            assert_eq!(named.refused, crate::gasp::strike_refused::NO_ROOM);
11008            assert_eq!(named.faults, 0);
11009            assert_eq!(topic.strikes_refused, 1);
11010            assert_eq!(topic.backed_off_graphs, u64::from(run == idle));
11011        }
11012        let result = fixture.engine.start_gasp_sync().await.unwrap();
11013        let topic = &result.topics_synced["tm_test"];
11014        assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
11015        assert_eq!(topic.idle_counted, 0);
11016
11017        // (b) A drop: the peer no longer serves the root.
11018        let mut fixture = obligation_fixture(1, 1, 0).await;
11019        let gone = fixture.obligations[0].clone();
11020        std::rc::Rc::make_mut(&mut fixture.remote.nodes).remove(gone.strip_suffix(".0").unwrap());
11021        fixture
11022            .engine
11023            .set_gasp_remote_factory(Box::new(fixture.remote.clone()));
11024        let result = fixture.engine.start_gasp_sync().await.unwrap();
11025        let topic = &result.topics_synced["tm_test"];
11026        assert_eq!(topic.idle_counted, 1, "{topic:?}");
11027        assert_eq!(
11028            topic
11029                .dropped_graphs
11030                .iter()
11031                .map(|d| d.reason.as_str())
11032                .collect::<Vec<_>>(),
11033            vec!["not_held"]
11034        );
11035        let record = fixture
11036            .store
11037            .get_deferred_graph(host, "tm_test", &gone)
11038            .await
11039            .unwrap()
11040            .unwrap();
11041        assert_eq!((record.idle_resumes, record.faults), (1, 0), "{record:?}");
11042
11043        // (a) The pass's own save: a rung's fault of the moment. The pass
11044        // that appends the root progresses; the next asks and appends
11045        // nothing, and its save counts it.
11046        let fixture = obligation_fixture(1, 1, 0).await;
11047        fixture.transient.set(true);
11048        let kept = &fixture.obligations[0];
11049        let result = fixture.engine.start_gasp_sync().await.unwrap();
11050        assert_eq!(result.topics_synced["tm_test"].idle_counted, 0, "progress");
11051        let result = fixture.engine.start_gasp_sync().await.unwrap();
11052        let topic = &result.topics_synced["tm_test"];
11053        assert_eq!(topic.idle_counted, 1, "{topic:?}");
11054        let record = fixture
11055            .store
11056            .get_deferred_graph(host, "tm_test", kept)
11057            .await
11058            .unwrap()
11059            .unwrap();
11060        assert_eq!(record.reason, "fault");
11061        assert_eq!(
11062            (record.nodes.len(), record.idle_resumes),
11063            (1, 1),
11064            "{record:?}"
11065        );
11066    }
11067
11068    /// 0.1.11 (the page's share): fourteen obligations of fact 3's shape
11069    /// ahead of a page of twelve fresh roots, every request 1,500 ms of a
11070    /// virtual 30,000 ms per-peer slice and a 15,000 ms graph budget. The
11071    /// resumes take at most `resume_share_ms(30_000, 15_000)` = 9,900 ms
11072    /// together; those reached after it are held back (not resumed, not a
11073    /// failure, not stalled, not idle, their records as they were) and the
11074    /// page's twelve roots are all walked and finalized in the same run.
11075    /// On 0.1.10 the resumes shared one graph budget (15,000 ms): ten of
11076    /// them ran, and the per-peer deadline dropped the sync at nine of the
11077    /// page's twelve roots (fact 4: the page got what the resumes left).
11078    #[tokio::test]
11079    async fn obligations_0111_fourteen_obligations_leave_the_page_its_share() {
11080        let ObligationFixture {
11081            mut engine,
11082            remote,
11083            store,
11084            obligations,
11085            ..
11086        } = obligation_fixture(12, 14, 1500).await;
11087        let elapsed = remote.elapsed.clone();
11088        engine.set_graph_budget(
11089            std::rc::Rc::new(move |ms| {
11090                let elapsed = elapsed.clone();
11091                let until = elapsed.get() + ms;
11092                Box::pin(std::future::poll_fn(move |_| {
11093                    if elapsed.get() >= until {
11094                        std::task::Poll::Ready(())
11095                    } else {
11096                        std::task::Poll::Pending
11097                    }
11098                }))
11099            }),
11100            100,
11101            15_000,
11102        );
11103        assert_eq!(crate::gasp::resume_share_ms(30_000, 15_000), 9_900);
11104        let result = engine.start_gasp_sync().await.unwrap();
11105        let topic = &result.topics_synced["tm_test"];
11106        assert!(topic.errors.is_empty(), "inside the slice: {topic:?}");
11107        assert_eq!(topic.finalized_graphs, 12, "the page's share: {topic:?}");
11108        assert_eq!(topic.cursor_moves[0].to, 120);
11109        // Six resumes struck; the seventh cut in flight by the share, having
11110        // asked and appended nothing (counted idle and stalled, as 0.1.10
11111        // counts a deadline's cut); the other seven held back.
11112        assert_eq!(topic.resumed_graphs, 7, "{topic:?}");
11113        assert_eq!(topic.held_back_graphs, 7, "{topic:?}");
11114        assert_eq!(topic.faulted_roots.len(), 6, "{topic:?}");
11115        assert_eq!((topic.stalled_graphs, topic.idle_counted), (1, 1));
11116        assert!(topic.cursor_held_by.is_empty());
11117        let host = "https://budget.example";
11118        let mut reasons = std::collections::BTreeMap::new();
11119        for outpoint in &obligations {
11120            let record = store
11121                .get_deferred_graph(host, "tm_test", outpoint)
11122                .await
11123                .unwrap()
11124                .unwrap();
11125            assert!(record.cursor_passable, "{record:?}");
11126            if record.reason == "retry" {
11127                // Held back: as it was, not counted idle.
11128                assert_eq!((record.idle_resumes, record.retry_at), (0, 0));
11129                assert_eq!((record.faults, record.passes), (0, 0));
11130            }
11131            *reasons.entry(record.reason).or_insert(0) += 1;
11132        }
11133        assert_eq!(
11134            reasons,
11135            std::collections::BTreeMap::from([
11136                ("retry".to_string(), 7),
11137                ("retry:fault:courier".to_string(), 6),
11138                ("time".to_string(), 1),
11139            ])
11140        );
11141    }
11142
11143    #[tokio::test]
11144    async fn sync_budget_own_proof_without_a_valid_header_is_never_admitted() {
11145        let (mut engine, mut remote) = budget_page_engine(1);
11146        let txid = remote.outputs[0].txid.clone();
11147        std::rc::Rc::make_mut(&mut remote.nodes)
11148            .get_mut(&txid)
11149            .unwrap()
11150            .proof = None;
11151        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
11152            proofs: HashMap::from([(txid.clone(), single_leaf_bump_hex(&txid, 999))]),
11153            requested: std::rc::Rc::default(),
11154        }));
11155        virtual_peer_budget(&mut engine, &remote);
11156        engine.set_gasp_remote_factory(Box::new(remote));
11157        let result = engine.start_gasp_sync().await.unwrap();
11158        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 0);
11159        assert!(engine
11160            .storage
11161            .find_output(&txid, 0, Some("tm_test"), None, true)
11162            .await
11163            .unwrap()
11164            .is_none());
11165    }
11166
11167    impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
11168        fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11169            Box::new(MockSyncRemote {
11170                utxos: vec![
11171                    crate::types::GASPOutput {
11172                        txid: "remote_tx1".to_string(),
11173                        output_index: 0,
11174                        score: 100.0,
11175                    },
11176                    crate::types::GASPOutput {
11177                        txid: "remote_tx2".to_string(),
11178                        output_index: 0,
11179                        score: 200.0,
11180                    },
11181                ],
11182            })
11183        }
11184    }
11185
11186    #[tokio::test]
11187    async fn test_start_gasp_sync_with_factory_runs_sync() {
11188        let mut sync_config: SyncConfiguration = HashMap::new();
11189        sync_config.insert(
11190            "tm_test".to_string(),
11191            SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
11192        );
11193
11194        let mut engine = Engine::new(
11195            HashMap::new(),
11196            HashMap::new(),
11197            Box::new(MemoryStorage::new()),
11198            None,
11199            EngineConfig {
11200                sync_configuration: sync_config,
11201                ..Default::default()
11202            },
11203        );
11204
11205        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11206
11207        let result = engine.start_gasp_sync().await.unwrap();
11208        assert_eq!(result.topics_synced.len(), 1);
11209
11210        let topic_result = &result.topics_synced["tm_test"];
11211        assert_eq!(topic_result.peers.len(), 1);
11212        assert!(
11213            topic_result.errors.is_empty(),
11214            "Sync should succeed with mock remote"
11215        );
11216
11217        // Verify last_interaction was persisted
11218        let last = engine
11219            .storage()
11220            .get_last_interaction("https://peer1.example.com", "tm_test")
11221            .await
11222            .unwrap();
11223        // Synthetic nodes cannot assemble into BEEF. No score completed,
11224        // so the entry cursor stays and the next sync re-requests both.
11225        assert_eq!(
11226            last, 0,
11227            "cursor must remain at entry when no output completed"
11228        );
11229    }
11230
11231    #[tokio::test]
11232    async fn test_start_gasp_sync_with_factory_handles_error() {
11233        struct FailingFactory;
11234
11235        impl crate::gasp::GASPRemoteFactory for FailingFactory {
11236            fn create_remote(
11237                &self,
11238                _peer_url: &str,
11239                _topic: &str,
11240            ) -> Box<dyn crate::gasp::GASPRemote> {
11241                struct FailingRemote;
11242                #[async_trait(?Send)]
11243                impl crate::gasp::GASPRemote for FailingRemote {
11244                    async fn get_initial_response(
11245                        &self,
11246                        _: &crate::types::GASPInitialRequest,
11247                    ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
11248                    {
11249                        Err(crate::gasp::GASPError::RemoteError(
11250                            "connection refused".into(),
11251                        ))
11252                    }
11253                    async fn get_initial_reply(
11254                        &self,
11255                        _: &crate::types::GASPInitialResponse,
11256                    ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
11257                    {
11258                        unreachable!()
11259                    }
11260                    async fn request_node(
11261                        &self,
11262                        _: &str,
11263                        _: &str,
11264                        _: u32,
11265                        _: bool,
11266                    ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
11267                    {
11268                        unreachable!()
11269                    }
11270                    async fn submit_node(
11271                        &self,
11272                        _: &crate::types::GASPNode,
11273                    ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
11274                    {
11275                        unreachable!()
11276                    }
11277                }
11278                Box::new(FailingRemote)
11279            }
11280        }
11281
11282        let mut sync_config: SyncConfiguration = HashMap::new();
11283        sync_config.insert(
11284            "tm_test".to_string(),
11285            SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
11286        );
11287
11288        let mut engine = Engine::new(
11289            HashMap::new(),
11290            HashMap::new(),
11291            Box::new(MemoryStorage::new()),
11292            None,
11293            EngineConfig {
11294                sync_configuration: sync_config,
11295                ..Default::default()
11296            },
11297        );
11298
11299        engine.set_gasp_remote_factory(Box::new(FailingFactory));
11300
11301        let result = engine.start_gasp_sync().await.unwrap();
11302        assert_eq!(result.topics_synced.len(), 1);
11303
11304        let topic_result = &result.topics_synced["tm_test"];
11305        assert_eq!(
11306            topic_result.errors.len(),
11307            1,
11308            "Should have one error for the failing peer"
11309        );
11310        assert!(topic_result.errors[0].contains("connection refused"));
11311    }
11312
11313    #[tokio::test]
11314    async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
11315        let mut sync_config: SyncConfiguration = HashMap::new();
11316        sync_config.insert(
11317            "tm_test".to_string(),
11318            SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
11319        );
11320
11321        // No factory set — should still work (just peer discovery, no sync)
11322        let engine = Engine::new(
11323            HashMap::new(),
11324            HashMap::new(),
11325            Box::new(MemoryStorage::new()),
11326            None,
11327            EngineConfig {
11328                sync_configuration: sync_config,
11329                ..Default::default()
11330            },
11331        );
11332
11333        let result = engine.start_gasp_sync().await.unwrap();
11334        assert_eq!(result.topics_synced.len(), 1);
11335
11336        let topic_result = &result.topics_synced["tm_test"];
11337        assert_eq!(topic_result.peers.len(), 1);
11338        assert!(topic_result.errors.is_empty());
11339    }
11340
11341    #[tokio::test]
11342    async fn test_start_gasp_sync_with_factory_multiple_peers() {
11343        let mut sync_config: SyncConfiguration = HashMap::new();
11344        sync_config.insert(
11345            "tm_test".to_string(),
11346            SyncTarget::Peers(vec![
11347                "https://peer1.example.com".to_string(),
11348                "https://peer2.example.com".to_string(),
11349            ]),
11350        );
11351
11352        let mut engine = Engine::new(
11353            HashMap::new(),
11354            HashMap::new(),
11355            Box::new(MemoryStorage::new()),
11356            None,
11357            EngineConfig {
11358                sync_configuration: sync_config,
11359                ..Default::default()
11360            },
11361        );
11362
11363        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11364
11365        let result = engine.start_gasp_sync().await.unwrap();
11366        let topic_result = &result.topics_synced["tm_test"];
11367        assert_eq!(topic_result.peers.len(), 2);
11368        assert!(topic_result.errors.is_empty());
11369
11370        // Neither peer completed its synthetic graphs.
11371        let last1 = engine
11372            .storage()
11373            .get_last_interaction("https://peer1.example.com", "tm_test")
11374            .await
11375            .unwrap();
11376        let last2 = engine
11377            .storage()
11378            .get_last_interaction("https://peer2.example.com", "tm_test")
11379            .await
11380            .unwrap();
11381        assert_eq!(last1, 0);
11382        assert_eq!(last2, 0);
11383    }
11384
11385    // ── Per-peer budget + dead-peer quarantine (bsv-low#302) ──────────
11386
11387    /// A remote whose initial request HANGS forever — models the dead
11388    /// ephemeral peers (ngrok tunnels) whose fetch never returns, the #257
11389    /// root cause the per-peer budget exists for.
11390    struct HangingRemote;
11391
11392    #[async_trait(?Send)]
11393    impl crate::gasp::GASPRemote for HangingRemote {
11394        async fn get_initial_response(
11395            &self,
11396            _: &crate::types::GASPInitialRequest,
11397        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
11398            std::future::pending::<()>().await;
11399            unreachable!()
11400        }
11401        async fn get_initial_reply(
11402            &self,
11403            _: &crate::types::GASPInitialResponse,
11404        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
11405            unreachable!()
11406        }
11407        async fn request_node(
11408            &self,
11409            _: &str,
11410            _: &str,
11411            _: u32,
11412            _: bool,
11413        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
11414            unreachable!()
11415        }
11416        async fn submit_node(
11417            &self,
11418            _: &crate::types::GASPNode,
11419        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
11420            unreachable!()
11421        }
11422    }
11423
11424    /// Factory that HANGS for `hang.example.com` peers and serves the normal
11425    /// mock sync remote for everyone else, counting `create_remote` calls
11426    /// (a quarantine-skipped peer never reaches the factory).
11427    struct SelectiveHangFactory {
11428        created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
11429    }
11430
11431    impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
11432        fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11433            self.created.borrow_mut().push(peer_url.to_string());
11434            if peer_url.contains("hang.example.com") {
11435                Box::new(HangingRemote)
11436            } else {
11437                Box::new(MockSyncRemote {
11438                    utxos: vec![crate::types::GASPOutput {
11439                        txid: "remote_tx1".to_string(),
11440                        output_index: 0,
11441                        score: 100.0,
11442                    }],
11443                })
11444            }
11445        }
11446    }
11447
11448    /// The deterministic "instant deadline" sleep factory: `race_or_deadline`
11449    /// polls the sync future FIRST, so an in-memory sync that never yields
11450    /// still completes; only a future that actually returns Pending (the
11451    /// hanging remote) loses the race.
11452    fn instant_deadline() -> crate::engine::SleepFactory {
11453        std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
11454    }
11455
11456    fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
11457        let mut sync_config: SyncConfiguration = HashMap::new();
11458        sync_config.insert(
11459            "tm_test".to_string(),
11460            SyncTarget::Peers(vec![
11461                "https://hang.example.com".to_string(),
11462                "https://good.example.com".to_string(),
11463            ]),
11464        );
11465        Engine::new(
11466            HashMap::new(),
11467            HashMap::new(),
11468            Box::new(store),
11469            None,
11470            EngineConfig {
11471                sync_configuration: sync_config,
11472                ..Default::default()
11473            },
11474        )
11475    }
11476
11477    #[tokio::test]
11478    async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
11479        let store = std::rc::Rc::new(MemoryStorage::new());
11480        let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11481        let mut engine = two_peer_engine(store.clone());
11482        engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11483            created: created.clone(),
11484        }));
11485        engine.set_peer_sync_budget(instant_deadline(), 1);
11486
11487        let result = engine.start_gasp_sync().await.unwrap();
11488        let topic_result = &result.topics_synced["tm_test"];
11489
11490        // The hanging peer was dropped LOUDLY (an error entry) and the loop
11491        // CONTINUED: the good peer was still attempted and completed.
11492        assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
11493        assert!(
11494            topic_result.errors[0].contains("budget"),
11495            "{:?}",
11496            topic_result.errors
11497        );
11498        assert_eq!(
11499            created.borrow().as_slice(),
11500            ["https://hang.example.com", "https://good.example.com"],
11501            "both peers attempted this tick"
11502        );
11503
11504        // A timed-out peer stays at entry. The other peer was reached but
11505        // served synthetic graphs that cannot complete, so its cursor stays too.
11506        let hang_cursor = store
11507            .get_last_interaction("https://hang.example.com", "tm_test")
11508            .await
11509            .unwrap();
11510        assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
11511        let good_cursor = store
11512            .get_last_interaction("https://good.example.com", "tm_test")
11513            .await
11514            .unwrap();
11515        assert_eq!(good_cursor, 0, "no synthetic graph completed");
11516
11517        // Health bookkeeping: timeout = failure, completion = success.
11518        let hang_health = store
11519            .get_peer_sync_health("hang.example.com", "tm_test")
11520            .await
11521            .unwrap();
11522        assert_eq!(hang_health.consecutive_failures, 1);
11523        let good_health = store
11524            .get_peer_sync_health("good.example.com", "tm_test")
11525            .await
11526            .unwrap();
11527        assert_eq!(good_health.consecutive_failures, 0);
11528    }
11529
11530    #[tokio::test]
11531    async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
11532        let store = std::rc::Rc::new(MemoryStorage::new());
11533        let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11534
11535        let mut sync_config: SyncConfiguration = HashMap::new();
11536        sync_config.insert(
11537            "tm_test".to_string(),
11538            SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
11539        );
11540        let mut engine = Engine::new(
11541            HashMap::new(),
11542            HashMap::new(),
11543            Box::new(store.clone()),
11544            None,
11545            EngineConfig {
11546                sync_configuration: sync_config,
11547                ..Default::default()
11548            },
11549        );
11550        engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11551            created: created.clone(),
11552        }));
11553        engine.set_peer_sync_budget(instant_deadline(), 1);
11554
11555        // Fail the peer up to (but not past) the quarantine threshold — every
11556        // tick attempts it (advance the clock between ticks so the attempts
11557        // are distinguishable in age).
11558        for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
11559            engine.start_gasp_sync().await.unwrap();
11560            store.advance_clock(900);
11561            assert_eq!(
11562                created.borrow().len() as u64,
11563                i + 1,
11564                "peer attempted while below the threshold"
11565            );
11566        }
11567        let health = store
11568            .get_peer_sync_health("hang.example.com", "tm_test")
11569            .await
11570            .unwrap();
11571        assert_eq!(
11572            health.consecutive_failures,
11573            crate::gasp::PEER_QUARANTINE_THRESHOLD
11574        );
11575
11576        // Next tick: QUARANTINED — the factory is never consulted, no error
11577        // is emitted, and the attempt count does not grow (a skip is not an
11578        // attempt, so the re-probe window keeps aging).
11579        let result = engine.start_gasp_sync().await.unwrap();
11580        assert!(result.topics_synced["tm_test"].errors.is_empty());
11581        assert_eq!(
11582            created.borrow().len() as u64,
11583            crate::gasp::PEER_QUARANTINE_THRESHOLD,
11584            "quarantined peer must be skipped"
11585        );
11586
11587        // Age past the re-probe window: exactly one fresh probe is allowed.
11588        store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11589        engine.start_gasp_sync().await.unwrap();
11590        assert_eq!(
11591            created.borrow().len() as u64,
11592            crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
11593            "re-probe attempted after the window"
11594        );
11595        // The probe failed again → failure count kept growing → re-armed.
11596        let health = store
11597            .get_peer_sync_health("hang.example.com", "tm_test")
11598            .await
11599            .unwrap();
11600        assert_eq!(
11601            health.consecutive_failures,
11602            crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
11603        );
11604        let result = engine.start_gasp_sync().await.unwrap();
11605        assert!(
11606            result.topics_synced["tm_test"].errors.is_empty(),
11607            "re-quarantined"
11608        );
11609
11610        // The peer comes back to life: age to the next re-probe, then answer
11611        // like a healthy peer → SUCCESS resets the count to 0 (full
11612        // re-admission — quarantine is never a deletion).
11613        store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11614        // Swap the factory for one that serves the healthy mock for this URL.
11615        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11616        let result = engine.start_gasp_sync().await.unwrap();
11617        assert!(result.topics_synced["tm_test"].errors.is_empty());
11618        let health = store
11619            .get_peer_sync_health("hang.example.com", "tm_test")
11620            .await
11621            .unwrap();
11622        assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
11623    }
11624
11625    // ── Mock ARC Broadcaster ─────────────────────────────────────────
11626
11627    use crate::broadcaster::ArcBroadcaster;
11628
11629    struct MockArcBroadcaster {
11630        calls: Arc<Mutex<Vec<String>>>,
11631        should_fail: bool,
11632    }
11633
11634    impl MockArcBroadcaster {
11635        fn new() -> Self {
11636            Self {
11637                calls: Arc::new(Mutex::new(Vec::new())),
11638                should_fail: false,
11639            }
11640        }
11641
11642        fn failing() -> Self {
11643            Self {
11644                calls: Arc::new(Mutex::new(Vec::new())),
11645                should_fail: true,
11646            }
11647        }
11648    }
11649
11650    #[async_trait(?Send)]
11651    impl ArcBroadcaster for MockArcBroadcaster {
11652        async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
11653            self.calls.lock().unwrap().push(raw_tx_hex.to_string());
11654            if self.should_fail {
11655                Err("mock ARC failure".to_string())
11656            } else {
11657                Ok("mock_txid_from_arc".to_string())
11658            }
11659        }
11660    }
11661
11662    /// Create engine with an ARC broadcaster (and optionally a SHIP broadcaster).
11663    fn make_engine_with_arc(
11664        admit_indices: Vec<u32>,
11665        arc: MockArcBroadcaster,
11666    ) -> (Engine, Arc<Mutex<Vec<String>>>) {
11667        let arc_calls = arc.calls.clone();
11668
11669        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11670        managers.insert(
11671            "tm_test".to_string(),
11672            Box::new(MockTopicManager::admitting(admit_indices)),
11673        );
11674
11675        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
11676        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
11677
11678        let storage = Box::new(MemoryStorage::new());
11679
11680        let engine = Engine::with_all(
11681            managers,
11682            lookup_services,
11683            storage,
11684            None,
11685            None,
11686            Some(Box::new(arc)),
11687            None,
11688            EngineConfig::default(),
11689        );
11690
11691        (engine, arc_calls)
11692    }
11693
11694    // ── ARC Broadcaster Tests ────────────────────────────────────────
11695
11696    #[tokio::test]
11697    async fn test_arc_broadcaster_called_on_current_tx() {
11698        let arc = MockArcBroadcaster::new();
11699        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11700
11701        let beef = test_tagged_beef(vec!["tm_test"]);
11702        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11703
11704        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11705
11706        let calls = arc_calls.lock().unwrap();
11707        assert_eq!(
11708            calls.len(),
11709            1,
11710            "ARC broadcaster should be called once for CurrentTx"
11711        );
11712        assert!(
11713            !calls[0].is_empty(),
11714            "ARC should receive non-empty raw tx hex"
11715        );
11716    }
11717
11718    #[tokio::test]
11719    async fn test_arc_broadcaster_not_called_on_historical_tx() {
11720        let arc = MockArcBroadcaster::new();
11721        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11722
11723        let beef = test_tagged_beef(vec!["tm_test"]);
11724        engine
11725            .submit(&beef, SubmitMode::HistoricalTx)
11726            .await
11727            .unwrap();
11728
11729        assert_eq!(
11730            arc_calls.lock().unwrap().len(),
11731            0,
11732            "ARC broadcaster should NOT be called for HistoricalTx"
11733        );
11734    }
11735
11736    #[tokio::test]
11737    async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
11738        let arc = MockArcBroadcaster::new();
11739        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11740
11741        let beef = test_tagged_beef(vec!["tm_test"]);
11742        engine
11743            .submit(&beef, SubmitMode::HistoricalTxNoSpv)
11744            .await
11745            .unwrap();
11746
11747        assert_eq!(
11748            arc_calls.lock().unwrap().len(),
11749            0,
11750            "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
11751        );
11752    }
11753
11754    #[tokio::test]
11755    async fn test_arc_broadcast_failure_does_not_fail_submit() {
11756        let arc = MockArcBroadcaster::failing();
11757        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11758
11759        let beef = test_tagged_beef(vec!["tm_test"]);
11760        // submit should succeed even when ARC fails
11761        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11762
11763        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11764
11765        // ARC was called (and failed), but submit still succeeded
11766        assert_eq!(
11767            arc_calls.lock().unwrap().len(),
11768            1,
11769            "ARC broadcaster should be attempted even if it will fail"
11770        );
11771    }
11772
11773    #[tokio::test]
11774    async fn test_arc_broadcaster_not_present_still_works() {
11775        // Engine without ARC broadcaster — should work fine
11776        let engine = make_engine(vec![0]);
11777
11778        let beef = test_tagged_beef(vec!["tm_test"]);
11779        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11780
11781        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11782    }
11783
11784    // ── Sync advertisements URL validation ─────────────────────────────
11785
11786    /// Mock Advertiser that tracks created advertisements via shared state.
11787    struct TrackingAdvertiser {
11788        created: Arc<Mutex<Vec<AdvertisementData>>>,
11789        existing_ship: Vec<Advertisement>,
11790        existing_slap: Vec<Advertisement>,
11791        /// Topics the returned TaggedBEEF is tagged with — a topic with no
11792        /// registered manager forces the LOCAL submit to fail (#320 3a);
11793        /// several topics exercise independent per-topic admission (D1).
11794        tag_topics: Vec<String>,
11795        /// Simulate a create_advertisements failure (#320 3a).
11796        create_fails: bool,
11797        /// Simulate a find_all_advertisements failure (#320 M2).
11798        find_fails: bool,
11799        /// Return the v1 no-op EMPTY TaggedBEEF from revoke (#320 L2).
11800        revoke_empty: bool,
11801    }
11802
11803    impl TrackingAdvertiser {
11804        fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
11805            let created = Arc::new(Mutex::new(Vec::new()));
11806            (
11807                Self {
11808                    created: created.clone(),
11809                    existing_ship: vec![],
11810                    existing_slap: vec![],
11811                    tag_topics: vec!["tm_test".to_string()],
11812                    create_fails: false,
11813                    find_fails: false,
11814                    revoke_empty: false,
11815                },
11816                created,
11817            )
11818        }
11819    }
11820
11821    use crate::advertiser::{Advertiser, AdvertiserError};
11822
11823    #[async_trait(?Send)]
11824    impl Advertiser for TrackingAdvertiser {
11825        async fn create_advertisements(
11826            &self,
11827            ads: &[AdvertisementData],
11828        ) -> Result<TaggedBEEF, AdvertiserError> {
11829            if self.create_fails {
11830                return Err(AdvertiserError::CreationFailed(
11831                    "mock create refused".into(),
11832                ));
11833            }
11834            self.created.lock().unwrap().extend(ads.iter().cloned());
11835            Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
11836        }
11837
11838        async fn find_all_advertisements(
11839            &self,
11840            protocol: Protocol,
11841        ) -> Result<Vec<Advertisement>, AdvertiserError> {
11842            if self.find_fails {
11843                return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
11844            }
11845            match protocol {
11846                Protocol::Ship => Ok(self.existing_ship.clone()),
11847                Protocol::Slap => Ok(self.existing_slap.clone()),
11848            }
11849        }
11850
11851        async fn revoke_advertisements(
11852            &self,
11853            _ads: &[Advertisement],
11854        ) -> Result<TaggedBEEF, AdvertiserError> {
11855            if self.revoke_empty {
11856                return Ok(TaggedBEEF::new(vec![], vec![]));
11857            }
11858            Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
11859        }
11860
11861        fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
11862            None
11863        }
11864    }
11865
11866    /// TS: "Sync advertisements URL validation"
11867    /// sync_advertisements should be a no-op when hosting_url is empty string,
11868    /// and should skip suppressed topics (tm_ship/tm_slap) when configured.
11869    #[tokio::test]
11870    async fn test_sync_advertisements_url_validation() {
11871        // Case 1: Empty hosting URL — should return early without creating ads
11872        let (adv, created) = TrackingAdvertiser::new();
11873
11874        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11875        managers.insert(
11876            "tm_test".to_string(),
11877            Box::new(MockTopicManager::admitting(vec![0])),
11878        );
11879
11880        let engine = Engine::new(
11881            managers,
11882            HashMap::new(),
11883            Box::new(MemoryStorage::new()),
11884            Some(Box::new(adv)),
11885            EngineConfig {
11886                hosting_url: Some(String::new()), // empty string — invalid
11887                ..Default::default()
11888            },
11889        );
11890
11891        engine.sync_advertisements().await.unwrap();
11892        assert!(
11893            created.lock().unwrap().is_empty(),
11894            "Empty hosting URL should skip advertisement creation"
11895        );
11896
11897        // Case 2: Valid hosting URL — should create SHIP advertisements for non-suppressed topics
11898        let (adv2, created2) = TrackingAdvertiser::new();
11899
11900        let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11901        managers2.insert(
11902            "tm_test".to_string(),
11903            Box::new(MockTopicManager::admitting(vec![0])),
11904        );
11905        // Also register tm_ship to verify it gets suppressed
11906        managers2.insert(
11907            "tm_ship".to_string(),
11908            Box::new(MockTopicManager::admitting(vec![])),
11909        );
11910
11911        let engine2 = Engine::new(
11912            managers2,
11913            HashMap::new(),
11914            Box::new(MemoryStorage::new()),
11915            Some(Box::new(adv2)),
11916            EngineConfig {
11917                hosting_url: Some("https://valid.example.com".to_string()),
11918                suppress_default_sync_advertisements: true,
11919                ..Default::default()
11920            },
11921        );
11922
11923        engine2.sync_advertisements().await.unwrap();
11924        let ads = created2.lock().unwrap();
11925        // tm_ship should be suppressed, only tm_test should get an advertisement
11926        assert!(
11927            ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
11928            "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
11929        );
11930        assert!(
11931            ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
11932            "tm_test should get a SHIP advertisement"
11933        );
11934    }
11935
11936    // ── sync_advertisements report (bsv-low #320 defect 3a) ────────────
11937    //
11938    // Pre-#320 the engine returned `Ok(())` unconditionally: a failed
11939    // create or a failed LOCAL submit was `error!`-logged and invisible to
11940    // the caller — the admin route said `success` while the node's own
11941    // ls_ship/ls_slap never gained its ads, so every cycle re-created the
11942    // whole set. These cells pin the report contract.
11943
11944    fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
11945        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11946        managers.insert(
11947            "tm_test".to_string(),
11948            Box::new(MockTopicManager::admitting(vec![0])),
11949        );
11950        Engine::new(
11951            managers,
11952            HashMap::new(),
11953            Box::new(MemoryStorage::new()),
11954            Some(Box::new(adv)),
11955            EngineConfig {
11956                hosting_url: Some("https://valid.example.com".to_string()),
11957                suppress_default_sync_advertisements: true,
11958                ..Default::default()
11959            },
11960        )
11961    }
11962
11963    #[tokio::test]
11964    async fn sync_report_counts_admitted_outputs_on_success() {
11965        let (adv, _) = TrackingAdvertiser::new();
11966        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
11967        assert!(report.ok(), "clean run must report ok: {report:?}");
11968        assert_eq!(report.to_create, 1, "tm_test was missing");
11969        assert_eq!(
11970            report.admitted.get("tm_test"),
11971            Some(&1),
11972            "the STEAK's admitted count must be surfaced: {report:?}"
11973        );
11974    }
11975
11976    #[tokio::test]
11977    async fn sync_report_surfaces_local_submit_failure() {
11978        // The TaggedBEEF comes back tagged for a topic with no registered
11979        // manager → the LOCAL submit fails. That failure must be in the
11980        // report, not swallowed (the live silent-failure shape).
11981        let (mut adv, _) = TrackingAdvertiser::new();
11982        adv.tag_topics = vec!["tm_unregistered".to_string()];
11983        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
11984        assert!(!report.ok(), "a failed local submit must not report ok");
11985        assert_eq!(report.to_create, 1);
11986        let submit_error = report.submit_error.as_deref().unwrap_or_default();
11987        assert!(
11988            submit_error.contains("tm_unregistered"),
11989            "verbatim engine error expected, got: {submit_error}"
11990        );
11991        assert!(report.admitted.is_empty());
11992    }
11993
11994    #[tokio::test]
11995    async fn sync_report_surfaces_create_failure() {
11996        let (mut adv, _) = TrackingAdvertiser::new();
11997        adv.create_fails = true;
11998        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
11999        assert!(!report.ok());
12000        assert!(
12001            report
12002                .create_error
12003                .as_deref()
12004                .unwrap_or_default()
12005                .contains("mock create refused"),
12006            "{report:?}"
12007        );
12008        assert!(report.submit_error.is_none(), "create failed before submit");
12009    }
12010
12011    #[tokio::test]
12012    async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
12013        // A node whose own storage already lists its ads must attempt ZERO
12014        // creates — the convergence direction of #320 defect 3 (live, the
12015        // missing self-admission made every cycle re-create all 17 ads).
12016        let (mut adv, created) = TrackingAdvertiser::new();
12017        adv.existing_ship = vec![Advertisement {
12018            protocol: Protocol::Ship,
12019            identity_key: "02aa".to_string(),
12020            domain: "https://valid.example.com".to_string(),
12021            topic_or_service: "tm_test".to_string(),
12022            beef: None,
12023            output_index: None,
12024        }];
12025        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12026        assert!(report.ok(), "{report:?}");
12027        assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
12028        assert!(created.lock().unwrap().is_empty());
12029        assert!(
12030            report.effective(),
12031            "converged-noop is the one zero-admit shape that IS success"
12032        );
12033    }
12034
12035    #[tokio::test]
12036    async fn sync_report_zero_admit_is_ok_but_not_effective() {
12037        // #320 M1: the local submit SUCCEEDS but the topic manager admits
12038        // ZERO outputs — a non-error refusal. The ads never enter our own
12039        // ls_ship/ls_slap, so the diff re-creates (re-pays) the full set
12040        // next cycle. `ok()` (errors-only) stays true; `effective()` must
12041        // refuse, and the route keys success on `effective()`.
12042        let (adv, _) = TrackingAdvertiser::new();
12043        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12044        managers.insert(
12045            "tm_test".to_string(),
12046            Box::new(MockTopicManager::admitting(vec![])), // refuses everything
12047        );
12048        let engine = Engine::new(
12049            managers,
12050            HashMap::new(),
12051            Box::new(MemoryStorage::new()),
12052            Some(Box::new(adv)),
12053            EngineConfig {
12054                hosting_url: Some("https://valid.example.com".to_string()),
12055                suppress_default_sync_advertisements: true,
12056                ..Default::default()
12057            },
12058        );
12059        let report = engine.sync_advertisements().await.unwrap();
12060        assert!(report.ok(), "no ERROR occurred: {report:?}");
12061        assert_eq!(report.to_create, 1);
12062        assert_eq!(report.admitted_total(), 0, "{report:?}");
12063        assert!(
12064            !report.effective(),
12065            "zero admitted on a non-empty create must not read as success: {report:?}"
12066        );
12067    }
12068
12069    #[tokio::test]
12070    async fn sync_report_partial_admit_is_not_effective() {
12071        // Delta D1: TWO topics in one submitted tx, per-topic admission
12072        // independent — tm_test admits its output, tm_refuse refuses all.
12073        // admitted_total (1) > 0 but < to_create (2): the refused ads
12074        // would be re-created (re-paid) every cycle, so this must NOT
12075        // read as success.
12076        let (mut adv, _) = TrackingAdvertiser::new();
12077        adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
12078        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12079        managers.insert(
12080            "tm_test".to_string(),
12081            Box::new(MockTopicManager::admitting(vec![0])),
12082        );
12083        managers.insert(
12084            "tm_refuse".to_string(),
12085            Box::new(MockTopicManager::admitting(vec![])), // refuses everything
12086        );
12087        let engine = Engine::new(
12088            managers,
12089            HashMap::new(),
12090            Box::new(MemoryStorage::new()),
12091            Some(Box::new(adv)),
12092            EngineConfig {
12093                hosting_url: Some("https://valid.example.com".to_string()),
12094                suppress_default_sync_advertisements: true,
12095                ..Default::default()
12096            },
12097        );
12098        let report = engine.sync_advertisements().await.unwrap();
12099        assert!(report.ok(), "no ERROR occurred: {report:?}");
12100        assert_eq!(report.to_create, 2, "{report:?}");
12101        assert_eq!(report.admitted_total(), 1, "{report:?}");
12102        assert!(
12103            !report.effective(),
12104            "a partial admit must not read as success: {report:?}"
12105        );
12106    }
12107
12108    #[tokio::test]
12109    async fn sync_report_lookup_failure_refuses_creation() {
12110        // #320 M2: a transient read failure must never become "current ads
12111        // = none" and re-create (re-pay) the whole set — creation is
12112        // REFUSED and the failure lands in the report.
12113        let (mut adv, created) = TrackingAdvertiser::new();
12114        adv.find_fails = true;
12115        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12116        assert!(!report.ok(), "{report:?}");
12117        assert!(!report.effective());
12118        assert!(
12119            report
12120                .lookup_error
12121                .as_deref()
12122                .unwrap_or_default()
12123                .contains("mock lookup refused"),
12124            "{report:?}"
12125        );
12126        assert_eq!(report.to_create, 0, "creation refused, not attempted");
12127        assert!(
12128            created.lock().unwrap().is_empty(),
12129            "no create call on a blind read"
12130        );
12131        assert!(report.submit_error.is_none());
12132    }
12133
12134    #[tokio::test]
12135    async fn sync_report_counts_skipped_revocations() {
12136        // #320 L2: `to_revoke: N` with a v1 no-op advertiser must be
12137        // distinguishable from a revoke that actually ran.
12138        let (mut adv, _) = TrackingAdvertiser::new();
12139        adv.revoke_empty = true;
12140        adv.existing_ship = vec![
12141            Advertisement {
12142                protocol: Protocol::Ship,
12143                identity_key: "02aa".to_string(),
12144                domain: "https://valid.example.com".to_string(),
12145                topic_or_service: "tm_test".to_string(),
12146                beef: None,
12147                output_index: None,
12148            },
12149            Advertisement {
12150                protocol: Protocol::Ship,
12151                identity_key: "02aa".to_string(),
12152                domain: "https://valid.example.com".to_string(),
12153                topic_or_service: "tm_stale".to_string(), // no longer configured
12154                beef: None,
12155                output_index: None,
12156            },
12157        ];
12158        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12159        assert!(report.ok(), "{report:?}");
12160        assert_eq!(report.to_revoke, 1, "tm_stale is stale");
12161        assert_eq!(
12162            report.revoke_skipped, 1,
12163            "the v1 no-op must be visible, not silent: {report:?}"
12164        );
12165        assert!(report.revoke_error.is_none());
12166        assert!(report.revoke_submit_error.is_none());
12167    }
12168
12169    // ── complete_missing_proofs (#130) ─────────────────────────────────
12170
12171    use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
12172    use std::cell::Cell;
12173
12174    /// A single raw mainnet tx (no proof) — used to build a proofless BEEF.
12175    const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12176
12177    /// Build a proofless single-tx BEEF + return `(beef_bytes, txid)`.
12178    fn proofless_beef() -> (Vec<u8>, String) {
12179        use bsv_rs::transaction::{Beef, Transaction};
12180        let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
12181        let txid = tx.id();
12182        let mut beef = Beef::new();
12183        beef.merge_transaction(tx);
12184        (beef.to_binary(), txid)
12185    }
12186
12187    // ── #284: stitching a proof must never rebuild the stored BEEF ─────────
12188
12189    /// The corruption shape observed live on rust-beta: a stored BEEF whose
12190    /// SUBJECT is not the last tx in wire order. The old rewrite went through
12191    /// `Transaction::from_beef(bytes, None)`, whose None arm picks
12192    /// `txs.last()` — the PARENT here — and `to_beef(true)` then serialized the
12193    /// parent's world, dropping the subject from its own row. TEST_BEEF_HEX is
12194    /// exactly a two-tx chain (parent + child spending it), so re-encoding it
12195    /// child-first reproduces the trigger byte-for-byte.
12196    fn subject_not_last_beef() -> (Vec<u8>, String, String) {
12197        use bsv_rs::transaction::Beef;
12198        let parsed = Beef::from_binary(&test_beef()).unwrap();
12199        let parent = parsed.txs[0].tx().unwrap().clone();
12200        let child = parsed.txs[1].tx().unwrap().clone();
12201        let (parent_id, child_id) = (parent.id(), child.id());
12202        assert_ne!(parent_id, child_id);
12203
12204        // Serialize via to_writer, NOT to_binary: to_binary re-sorts
12205        // parents-first (subject last), which is precisely why bsv_rs's own
12206        // output never triggers the bug. The corrupt rows held WALLET
12207        // serialized bytes stored verbatim at admit time, whose wire order is
12208        // whatever the wallet emitted — model that by writing the child first.
12209        let mut beef = Beef::new();
12210        beef.merge_transaction(child); // subject FIRST — the trigger
12211        beef.merge_transaction(parent);
12212        let mut w = bsv_rs::primitives::encoding::Writer::new();
12213        beef.to_writer(&mut w);
12214        let bytes = w.into_bytes();
12215
12216        // The premise the whole test rests on: wire order preserved, subject
12217        // genuinely not last.
12218        let reread = Beef::from_binary(&bytes).unwrap();
12219        assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
12220        (bytes, child_id, parent_id)
12221    }
12222
12223    #[test]
12224    fn stitch_preserves_the_subject_when_it_is_not_last() {
12225        use bsv_rs::transaction::{Beef, MerklePath};
12226        let (stored, child_id, parent_id) = subject_not_last_beef();
12227        let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
12228
12229        let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
12230            .expect("stitch must succeed on a beef that contains the txid");
12231
12232        let after = Beef::from_binary(&out).unwrap();
12233        assert!(
12234            after.find_txid(&child_id).is_some(),
12235            "the SUBJECT must survive its own proof-completion"
12236        );
12237        assert!(
12238            after.find_txid(&parent_id).is_some(),
12239            "the ancestry must survive too"
12240        );
12241        assert!(
12242            after.find_bump(&child_id).is_some(),
12243            "the stitched bump must prove the subject"
12244        );
12245        assert!(
12246            after.find_bump(&parent_id).is_none(),
12247            "the parent must NOT be claimed by the child's bump"
12248        );
12249    }
12250
12251    /// The old path, reproduced against the same input, to pin WHY the new one
12252    /// exists: it demonstrably drops the subject. If bsv_rs ever changes
12253    /// `from_beef(_, None)` to honor the subject, this starts failing and the
12254    /// comment trail can be revisited — until then it documents the hazard.
12255    #[test]
12256    fn the_old_round_trip_demonstrably_dropped_the_subject() {
12257        use bsv_rs::transaction::{Beef, Transaction};
12258        let (stored, child_id, _parent_id) = subject_not_last_beef();
12259        let tx = Transaction::from_beef(&stored, None).unwrap();
12260        assert_ne!(
12261            tx.id(),
12262            child_id,
12263            "from_beef(None) picks txs.last(), not the subject — the trigger"
12264        );
12265        let rebuilt = tx.to_beef(true).unwrap();
12266        let reparsed = Beef::from_binary(&rebuilt).unwrap();
12267        assert!(
12268            reparsed.find_txid(&child_id).is_none(),
12269            "the rebuild loses the subject — exactly the #284 artifact"
12270        );
12271    }
12272
12273    /// bsv-low M19 R2 round 3 (review HIGH-1): a stored BEEF whose SUBJECT
12274    /// sits on a stale (orphan) bump at height H, re-stitched with a
12275    /// same-height DIFFERENT-root proof, must serialize with the subject on
12276    /// the NEW bump and the orphan bump dropped — otherwise the hop leg
12277    /// churns and re-latches the orphan proof forever.
12278    #[test]
12279    fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
12280        use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
12281        // subject spends a real parent; both are raw in the BEEF
12282        let parent = Transaction::from_hex(RAW_PARENT).unwrap();
12283        let parent_id = parent.id();
12284        let subject = child_of(&parent_id, 0);
12285        let subject_id = subject.id();
12286        // the ORPHAN bump: a two-leaf block at H so its root is NOT the txid,
12287        // and it differs from the canonical block's root
12288        let orphan = MerklePath::new_unchecked(
12289            965_771,
12290            vec![vec![
12291                MerklePathLeaf::new_txid(0, subject_id.clone()),
12292                MerklePathLeaf::new(1, "aa".repeat(32)),
12293            ]],
12294        )
12295        .unwrap();
12296        let mut beef = Beef::new();
12297        let bi = beef.merge_bump(orphan.clone());
12298        beef.merge_raw_tx(parent.to_binary(), None);
12299        beef.merge_raw_tx(subject.to_binary(), Some(bi));
12300        let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
12301        let stored = beef.to_binary();
12302        assert_eq!(
12303            Beef::from_binary(&stored)
12304                .unwrap()
12305                .find_bump(&subject_id)
12306                .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
12307            Some(orphan_root.clone())
12308        );
12309        // the canonical proof at the SAME height, a DIFFERENT root
12310        let canonical = MerklePath::new_unchecked(
12311            965_771,
12312            vec![vec![
12313                MerklePathLeaf::new_txid(0, subject_id.clone()),
12314                MerklePathLeaf::new(1, "bb".repeat(32)),
12315            ]],
12316        )
12317        .unwrap();
12318        let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
12319        assert_ne!(
12320            canonical_root, orphan_root,
12321            "the reorg shape: same height, new root"
12322        );
12323        let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
12324        let after = Beef::from_binary(&out).unwrap();
12325        // the subject's OWN bump is the canonical one
12326        let own = after
12327            .find_txid(&subject_id)
12328            .and_then(BeefTx::bump_index)
12329            .and_then(|bi| after.bumps.get(bi))
12330            .unwrap();
12331        assert_eq!(
12332            own.compute_root(Some(&subject_id)).unwrap(),
12333            canonical_root,
12334            "the subject re-anchored to the new bump"
12335        );
12336        // the orphan bump is gone (only the canonical one remains)
12337        assert_eq!(
12338            after.bumps.len(),
12339            1,
12340            "the unreferenced orphan bump was dropped"
12341        );
12342        // and a reader that uses the tx's OWN bump sees canonical, never orphan
12343        assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
12344    }
12345
12346    #[test]
12347    fn stitch_refuses_a_beef_that_lacks_the_txid() {
12348        use bsv_rs::transaction::MerklePath;
12349        let (stored, _c, _p) = subject_not_last_beef();
12350        let foreign = "aa".repeat(32);
12351        let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
12352        assert!(
12353            Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
12354            "no txid in the beef -> no write, never garbage"
12355        );
12356    }
12357
12358    /// A real mainnet raw tx, the unproven parent for the re-anchor pin.
12359    const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12360
12361    /// A minimal raw tx spending `source:vout` with one OP_TRUE output.
12362    fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
12363        let mut sx = String::from("0100000001");
12364        let mut prev = hex::decode(source_txid).unwrap();
12365        prev.reverse();
12366        sx.push_str(&hex::encode(prev));
12367        sx.push_str(&hex::encode(vout.to_le_bytes()));
12368        sx.push_str("00ffffffff01");
12369        sx.push_str(&hex::encode(1000u64.to_le_bytes()));
12370        sx.push_str("015100000000");
12371        bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
12372    }
12373
12374    /// A minimal valid single-leaf BUMP hex proving `txid` at `height`.
12375    fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
12376        use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
12377        let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
12378        MerklePath::new_unchecked(height, vec![vec![leaf]])
12379            .unwrap()
12380            .to_hex()
12381    }
12382
12383    /// Mock fetcher: returns a configured proof (or none) for any txid, and
12384    /// counts how many times it was asked.
12385    struct StubFetcher {
12386        proof: Option<String>,
12387        calls: Cell<u32>,
12388    }
12389
12390    #[async_trait(?Send)]
12391    impl AncestorFetcher for StubFetcher {
12392        async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
12393            self.calls.set(self.calls.get() + 1);
12394            Ok(FetchedAncestor {
12395                raw_tx: PROOFLESS_RAW_TX.to_string(),
12396                proof: self.proof.clone(),
12397            })
12398        }
12399
12400        /// Models a fetcher whose header source CONFIRMS a stored bump — so the
12401        /// already-structurally-proven re-verify gate (the window-clog path)
12402        /// latches without a re-fetch, exactly as before the #192/#193 hardening.
12403        /// (`verified_proof_for` is left at its default: it delegates to
12404        /// `fetch_ancestor` above, so the `calls` counter still tracks fetches.)
12405        async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
12406            true
12407        }
12408    }
12409
12410    /// Build an engine over a storage holding one proofless output, with an
12411    /// optional ancestor fetcher.
12412    async fn engine_with_proofless_output(
12413        fetcher: Option<std::rc::Rc<StubFetcher>>,
12414    ) -> (Engine, String) {
12415        let (beef, txid) = proofless_beef();
12416        let storage = MemoryStorage::new();
12417        storage
12418            .insert_output(&Output {
12419                txid: txid.clone(),
12420                output_index: 0,
12421                output_script: vec![0x76],
12422                satoshis: 1_000_000_000,
12423                topic: "Hello".to_string(),
12424                spent: false,
12425                outputs_consumed: vec![],
12426                consumed_by: vec![],
12427                beef: Some(beef),
12428                block_height: None,
12429                score: Some(1.0),
12430            })
12431            .await
12432            .unwrap();
12433
12434        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12435        managers.insert(
12436            "Hello".into(),
12437            Box::new(MockTopicManager::admitting(vec![0])),
12438        );
12439        let mut engine = Engine::new(
12440            managers,
12441            HashMap::new(),
12442            Box::new(storage),
12443            None,
12444            EngineConfig::default(),
12445        );
12446        if let Some(f) = fetcher {
12447            engine.set_ancestor_fetcher(f);
12448        }
12449        (engine, txid)
12450    }
12451
12452    #[tokio::test]
12453    async fn proofs_completion_uses_dedicated_courier() {
12454        let (_, txid) = proofless_beef();
12455        let shared = std::rc::Rc::new(StubFetcher {
12456            proof: None,
12457            calls: Cell::new(0),
12458        });
12459        let dedicated = StubFetcher {
12460            proof: Some(single_leaf_bump_hex(&txid, 850_000)),
12461            calls: Cell::new(0),
12462        };
12463        let (engine, _) = engine_with_proofless_output(Some(shared.clone())).await;
12464        assert_eq!(
12465            engine
12466                .complete_missing_proofs(1, 0)
12467                .await
12468                .unwrap()
12469                .completed,
12470            0
12471        );
12472        assert_eq!(
12473            engine
12474                .complete_missing_proofs_with(&dedicated, 1, 0)
12475                .await
12476                .unwrap()
12477                .completed,
12478            1
12479        );
12480        assert_eq!(shared.calls.get(), 1);
12481        assert_eq!(dedicated.calls.get(), 1);
12482        let output = engine
12483            .storage()
12484            .find_output(&txid, 0, Some("Hello"), None, true)
12485            .await
12486            .unwrap()
12487            .unwrap();
12488        assert!(
12489            crate::beef_limits::own_proof(output.beef.as_ref().unwrap(), &txid)
12490                .unwrap()
12491                .is_some()
12492        );
12493    }
12494
12495    #[tokio::test]
12496    async fn complete_missing_proofs_noop_without_fetcher() {
12497        // No ancestor fetcher configured → pure no-op (production default).
12498        let (engine, _txid) = engine_with_proofless_output(None).await;
12499        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12500        assert_eq!(summary, ProofCompletionSummary::default());
12501    }
12502
12503    #[tokio::test]
12504    async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
12505        let (_beef, txid) = proofless_beef();
12506        let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12507        let fetcher = std::rc::Rc::new(StubFetcher {
12508            proof: Some(proof_hex),
12509            calls: Cell::new(0),
12510        });
12511        let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12512
12513        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12514        assert_eq!(summary.scanned, 1);
12515        assert_eq!(summary.proofless, 1);
12516        assert_eq!(
12517            summary.completed, 1,
12518            "the proofless BEEF should be completed"
12519        );
12520        assert_eq!(fetcher.calls.get(), 1);
12521
12522        // The stored BEEF now carries a proof for the target tx + the output
12523        // got its block height.
12524        let out = engine
12525            .storage()
12526            .find_output(&txid, 0, Some("Hello"), None, true)
12527            .await
12528            .unwrap()
12529            .unwrap();
12530        assert_eq!(out.block_height, Some(850_000));
12531        let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
12532        assert!(
12533            beef.find_txid(&txid)
12534                .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
12535            "completed BEEF must prove the target tx"
12536        );
12537
12538        // A second pass is a no-op: the tx is now proven, so it is not counted
12539        // proofless and the fetcher is not asked again.
12540        let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
12541        assert_eq!(summary2.proofless, 0);
12542        assert_eq!(summary2.completed, 0);
12543        assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
12544    }
12545
12546    #[tokio::test]
12547    async fn complete_missing_proofs_skips_unconfirmed() {
12548        // Fetcher reachable but the tx is unmined (no BUMP) → skipped, not
12549        // errored, and counted still_unconfirmed for retry.
12550        let fetcher = std::rc::Rc::new(StubFetcher {
12551            proof: None,
12552            calls: Cell::new(0),
12553        });
12554        let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12555        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12556        assert_eq!(summary.proofless, 1);
12557        assert_eq!(summary.completed, 0);
12558        assert_eq!(summary.still_unconfirmed, 1);
12559        assert_eq!(fetcher.calls.get(), 1);
12560    }
12561
12562    /// Build an engine like [`engine_with_proofless_output`] but keep a shared
12563    /// handle to the `MemoryStorage` (via the `Rc<T: Storage>` delegation) so
12564    /// the test can advance its deterministic clock — the push-primary
12565    /// backstop age-gate tests (#228) need to age rows without sleeping.
12566    async fn engine_with_proofless_output_shared(
12567        fetcher: Option<std::rc::Rc<StubFetcher>>,
12568    ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
12569        let (beef, txid) = proofless_beef();
12570        let storage = std::rc::Rc::new(MemoryStorage::new());
12571        storage
12572            .insert_output(&Output {
12573                txid: txid.clone(),
12574                output_index: 0,
12575                output_script: vec![0x76],
12576                satoshis: 1_000_000_000,
12577                topic: "Hello".to_string(),
12578                spent: false,
12579                outputs_consumed: vec![],
12580                consumed_by: vec![],
12581                beef: Some(beef),
12582                block_height: None,
12583                score: Some(1.0),
12584            })
12585            .await
12586            .unwrap();
12587
12588        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12589        managers.insert(
12590            "Hello".into(),
12591            Box::new(MockTopicManager::admitting(vec![0])),
12592        );
12593        let mut engine = Engine::new(
12594            managers,
12595            HashMap::new(),
12596            Box::new(storage.clone()),
12597            None,
12598            EngineConfig::default(),
12599        );
12600        if let Some(f) = fetcher {
12601            engine.set_ancestor_fetcher(f);
12602        }
12603        (engine, txid, storage)
12604    }
12605
12606    #[tokio::test]
12607    async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
12608        // Push-primary backstop (#228): a FRESHLY stored proofless tx must NOT
12609        // be polled — its proof is expected via /arc-ingest at push speed. The
12610        // gated pass scans nothing and never asks the fetcher.
12611        let proof_hex = {
12612            let (_beef, txid) = proofless_beef();
12613            single_leaf_bump_hex(&txid, 850_000)
12614        };
12615        let fetcher = std::rc::Rc::new(StubFetcher {
12616            proof: Some(proof_hex),
12617            calls: Cell::new(0),
12618        });
12619        let (engine, _txid, storage) =
12620            engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12621
12622        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12623        assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
12624        assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
12625
12626        // WEBHOOK-OUTAGE DEGRADATION: no push ever arrives. Once the row is
12627        // older than the gate, the SAME backstop pass polls and completes it
12628        // exactly as the pre-#228 behaviour — degradation is to polling,
12629        // never to nothing.
12630        storage.advance_clock(1800);
12631        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12632        assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
12633        assert_eq!(summary.completed, 1, "the backstop completes the proof");
12634        assert_eq!(fetcher.calls.get(), 1);
12635    }
12636
12637    #[tokio::test]
12638    async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
12639        // Pushed-proof-then-chaser-skips (#228): a proof arriving via the real
12640        // /arc-ingest producer path (`handle_new_merkle_proof` → the
12641        // `update_transaction_beef` stitch, which latches `has_proof`) drops
12642        // the tx out of the poll candidate set entirely — even after the age
12643        // gate opens, the chaser never asks the fetcher about it.
12644        let fetcher = std::rc::Rc::new(StubFetcher {
12645            proof: None,
12646            calls: Cell::new(0),
12647        });
12648        let (engine, txid, storage) =
12649            engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12650
12651        // The push lands (arc-ingest calls exactly this after its own
12652        // chaintracks verify).
12653        let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12654        engine
12655            .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
12656            .await
12657            .unwrap();
12658
12659        // Age the row well past the backstop gate: still skipped — the pushed
12660        // proof, not the age, is what removes it from the chaser's world.
12661        storage.advance_clock(1_000_000);
12662        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12663        assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
12664        assert_eq!(summary.completed, 0);
12665        assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
12666    }
12667
12668    #[tokio::test]
12669    async fn complete_missing_proofs_respects_limit() {
12670        // Two proofless txs, limit 1 → only one row is scanned this tick.
12671        let (beef1, txid1) = proofless_beef();
12672        // A second distinct proofless tx (different raw tx → different txid).
12673        let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
12674        let (beef2, txid2) = {
12675            use bsv_rs::transaction::{Beef, Transaction};
12676            let tx = Transaction::from_hex(raw2).unwrap();
12677            let id = tx.id();
12678            let mut b = Beef::new();
12679            b.merge_transaction(tx);
12680            (b.to_binary(), id)
12681        };
12682
12683        let storage = MemoryStorage::new();
12684        for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
12685            storage
12686                .insert_output(&Output {
12687                    txid: txid.clone(),
12688                    output_index: 0,
12689                    output_script: vec![0x76],
12690                    satoshis: 1000,
12691                    topic: "Hello".to_string(),
12692                    spent: false,
12693                    outputs_consumed: vec![],
12694                    consumed_by: vec![],
12695                    beef: Some(beef),
12696                    block_height: None,
12697                    score: Some(1.0),
12698                })
12699                .await
12700                .unwrap();
12701        }
12702
12703        let fetcher = std::rc::Rc::new(StubFetcher {
12704            proof: None,
12705            calls: Cell::new(0),
12706        });
12707        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12708        managers.insert(
12709            "Hello".into(),
12710            Box::new(MockTopicManager::admitting(vec![0])),
12711        );
12712        let mut engine = Engine::new(
12713            managers,
12714            HashMap::new(),
12715            Box::new(storage),
12716            None,
12717            EngineConfig::default(),
12718        );
12719        engine.set_ancestor_fetcher(fetcher.clone());
12720
12721        let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
12722        assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
12723    }
12724
12725    #[tokio::test]
12726    async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
12727        // Window-clog regression (#130): migration 0010 defaulted EVERY
12728        // existing `transactions` row to has_proof = 0, including rows whose
12729        // stored BEEF already carries a proof (e.g. GASP-synced txs). The
12730        // candidate query (`WHERE has_proof = 0 LIMIT n`) returns those rows
12731        // every tick; the engine skips them but — before this fix — never
12732        // flipped the flag, so they lingered in the bounded window forever and
12733        // starved genuinely-proofless rows behind them. The fix marks each
12734        // already-proven scanned row so it drops out of the NEXT pass.
12735        use bsv_rs::transaction::{Beef, MerklePath, Transaction};
12736
12737        let storage = MemoryStorage::new();
12738
12739        // Seed several ALREADY-PROVEN rows. They are inserted via
12740        // `insert_output`, which (modeling migration 0010's default) does NOT
12741        // set the proof flag — so despite carrying a valid proof they show up
12742        // as has_proof = 0 candidates: the exact clog.
12743        let proven_raws = [
12744            "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
12745            "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
12746        ];
12747        let mut proven_ids = Vec::new();
12748        for raw in proven_raws {
12749            let mut tx = Transaction::from_hex(raw).unwrap();
12750            let id = tx.id();
12751            tx.merkle_path =
12752                Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
12753            // to_beef(true) carries the merkle proof into the BEEF, so the row
12754            // genuinely IS proven — yet its flag stays 0 (insert path).
12755            let proven_beef = tx.to_beef(true).unwrap();
12756            assert_eq!(
12757                Beef::from_binary(&proven_beef)
12758                    .unwrap()
12759                    .find_txid(&id)
12760                    .map(bsv_rs::transaction::BeefTx::has_proof),
12761                Some(true),
12762                "proven fixture row must carry its proof"
12763            );
12764            proven_ids.push(id.clone());
12765            storage
12766                .insert_output(&Output {
12767                    txid: id,
12768                    output_index: 0,
12769                    output_script: vec![0x76],
12770                    satoshis: 1000,
12771                    topic: "Hello".to_string(),
12772                    spent: false,
12773                    outputs_consumed: vec![],
12774                    consumed_by: vec![],
12775                    beef: Some(proven_beef),
12776                    block_height: Some(800_000),
12777                    score: Some(1.0),
12778                })
12779                .await
12780                .unwrap();
12781        }
12782
12783        let fetcher = std::rc::Rc::new(StubFetcher {
12784            proof: None,
12785            calls: Cell::new(0),
12786        });
12787        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12788        managers.insert(
12789            "Hello".into(),
12790            Box::new(MockTopicManager::admitting(vec![0])),
12791        );
12792        let mut engine = Engine::new(
12793            managers,
12794            HashMap::new(),
12795            Box::new(storage),
12796            None,
12797            EngineConfig::default(),
12798        );
12799        engine.set_ancestor_fetcher(fetcher.clone());
12800
12801        // First pass over a window covering both proven rows: each is detected
12802        // already-proven and marked. Nothing proofless, nothing fetched.
12803        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12804        assert_eq!(summary.scanned, proven_ids.len());
12805        assert_eq!(summary.proofless, 0);
12806        assert_eq!(summary.completed, 0);
12807        assert_eq!(
12808            summary.already_proven,
12809            proven_ids.len(),
12810            "every already-proven row is marked this pass"
12811        );
12812        assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
12813
12814        // Window has advanced: the NEXT candidate query no longer returns the
12815        // marked rows, so the pass scans nothing.
12816        let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
12817        assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
12818        assert_eq!(summary2.already_proven, 0);
12819    }
12820
12821    #[tokio::test]
12822    async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
12823        // The window-clog in action with a small limit: a genuinely-proofless
12824        // backlog row sits behind already-proven (stale-flag) rows. Each tick
12825        // marks the proven rows it scans (dropping them out), so over a few
12826        // bounded ticks the window advances until the proofless tx is reached
12827        // + completed — instead of being starved forever.
12828        use bsv_rs::transaction::{MerklePath, Transaction};
12829
12830        let storage = MemoryStorage::new();
12831
12832        let proven_raws = [
12833            "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
12834        ];
12835        for raw in proven_raws {
12836            let mut tx = Transaction::from_hex(raw).unwrap();
12837            let id = tx.id();
12838            tx.merkle_path =
12839                Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
12840            let proven_beef = tx.to_beef(true).unwrap();
12841            storage
12842                .insert_output(&Output {
12843                    txid: id,
12844                    output_index: 0,
12845                    output_script: vec![0x76],
12846                    satoshis: 1000,
12847                    topic: "Hello".to_string(),
12848                    spent: false,
12849                    outputs_consumed: vec![],
12850                    consumed_by: vec![],
12851                    beef: Some(proven_beef),
12852                    block_height: Some(800_000),
12853                    score: Some(1.0),
12854                })
12855                .await
12856                .unwrap();
12857        }
12858
12859        // The historical proofless tx (the f584f846-style backlog row).
12860        let (proofless, proofless_txid) = proofless_beef();
12861        storage
12862            .insert_output(&Output {
12863                txid: proofless_txid.clone(),
12864                output_index: 0,
12865                output_script: vec![0x76],
12866                satoshis: 1000,
12867                topic: "Hello".to_string(),
12868                spent: false,
12869                outputs_consumed: vec![],
12870                consumed_by: vec![],
12871                beef: Some(proofless),
12872                block_height: None,
12873                score: Some(1.0),
12874            })
12875            .await
12876            .unwrap();
12877
12878        let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
12879        let fetcher = std::rc::Rc::new(StubFetcher {
12880            proof: Some(proof_hex),
12881            calls: Cell::new(0),
12882        });
12883        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12884        managers.insert(
12885            "Hello".into(),
12886            Box::new(MockTopicManager::admitting(vec![0])),
12887        );
12888        let mut engine = Engine::new(
12889            managers,
12890            HashMap::new(),
12891            Box::new(storage),
12892            None,
12893            EngineConfig::default(),
12894        );
12895        engine.set_ancestor_fetcher(fetcher.clone());
12896
12897        // Run bounded ticks (limit 1) until the proofless tx is completed. With
12898        // 2 rows total a couple of ticks suffice; cap iterations defensively.
12899        let mut completed_total = 0;
12900        for _ in 0..5 {
12901            let s = engine.complete_missing_proofs(1, 0).await.unwrap();
12902            completed_total += s.completed;
12903            if completed_total > 0 {
12904                break;
12905            }
12906        }
12907        assert_eq!(
12908            completed_total, 1,
12909            "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
12910        );
12911
12912        let out = engine
12913            .storage()
12914            .find_output(&proofless_txid, 0, Some("Hello"), None, true)
12915            .await
12916            .unwrap()
12917            .unwrap();
12918        let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
12919        assert_eq!(
12920            beef.find_txid(&proofless_txid)
12921                .map(bsv_rs::transaction::BeefTx::has_proof),
12922            Some(true),
12923            "the old proofless tx now carries its proof"
12924        );
12925    }
12926
12927    // ── S2 queue-durable admission (bsv-low 2026-08-29): Phase-3 faults ──
12928    //
12929    // The phantom class: under a D1 storm `submit` returned Ok(steak) while
12930    // its writes failed — the ack outlived the admission. These pins hold
12931    // the new contract: a fault is REPORTED, the faulted topic is NOT
12932    // recorded as applied, and a replay of the same bytes re-applies.
12933
12934    use crate::storage::StorageError;
12935    use std::rc::Rc;
12936
12937    /// `MemoryStorage` behind two fault switches. Every method delegates;
12938    /// the two the tests flip model a D1 write fault (`insert_output`) and
12939    /// a D1 read fault (`find_output` — the validation's previous-coin scan
12940    /// AND Phase 3's stale/consumed lookups).
12941    struct FaultingStorage {
12942        inner: MemoryStorage,
12943        fail_insert_output: Cell<bool>,
12944        fail_find_output: Cell<bool>,
12945    }
12946
12947    impl FaultingStorage {
12948        fn new() -> Rc<Self> {
12949            Rc::new(Self {
12950                inner: MemoryStorage::new(),
12951                fail_insert_output: Cell::new(false),
12952                fail_find_output: Cell::new(false),
12953            })
12954        }
12955    }
12956
12957    #[async_trait(?Send)]
12958    impl Storage for FaultingStorage {
12959        async fn put_deferred_graph(
12960            &self,
12961            record: &crate::gasp::DeferredGraph,
12962        ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
12963            self.inner.put_deferred_graph(record).await
12964        }
12965        async fn reserve_unanchorable_retry(
12966            &self,
12967            record: &crate::gasp::DeferredGraph,
12968        ) -> Result<bool, StorageError> {
12969            self.inner.reserve_unanchorable_retry(record).await
12970        }
12971        async fn find_deferred_graphs(
12972            &self,
12973            host: &str,
12974            topic: &str,
12975        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
12976            self.inner.find_deferred_graphs(host, topic).await
12977        }
12978        async fn get_deferred_graph(
12979            &self,
12980            host: &str,
12981            topic: &str,
12982            outpoint: &str,
12983        ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
12984            self.inner.get_deferred_graph(host, topic, outpoint).await
12985        }
12986        async fn delete_deferred_graph(
12987            &self,
12988            host: &str,
12989            topic: &str,
12990            outpoint: &str,
12991        ) -> Result<(), StorageError> {
12992            self.inner
12993                .delete_deferred_graph(host, topic, outpoint)
12994                .await
12995        }
12996        async fn record_peer_sync_outcome(
12997            &self,
12998            host: &str,
12999            topic: &str,
13000            success: bool,
13001        ) -> Result<(), StorageError> {
13002            self.inner
13003                .record_peer_sync_outcome(host, topic, success)
13004                .await
13005        }
13006        async fn get_peer_sync_health(
13007            &self,
13008            host: &str,
13009            topic: &str,
13010        ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
13011            self.inner.get_peer_sync_health(host, topic).await
13012        }
13013        async fn record_peer_sync_yield(
13014            &self,
13015            host: &str,
13016            topic: &str,
13017            yielded: bool,
13018        ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
13019            self.inner
13020                .record_peer_sync_yield(host, topic, yielded)
13021                .await
13022        }
13023        async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
13024            if self.fail_insert_output.get() {
13025                return Err(StorageError::Database(
13026                    "D1_ERROR: storage overloaded".into(),
13027                ));
13028            }
13029            self.inner.insert_output(output).await
13030        }
13031        async fn delete_output(
13032            &self,
13033            txid: &str,
13034            output_index: u32,
13035            topic: &str,
13036        ) -> Result<(), StorageError> {
13037            self.inner.delete_output(txid, output_index, topic).await
13038        }
13039        async fn mark_utxo_as_spent(
13040            &self,
13041            txid: &str,
13042            output_index: u32,
13043            topic: &str,
13044        ) -> Result<(), StorageError> {
13045            self.inner
13046                .mark_utxo_as_spent(txid, output_index, topic)
13047                .await
13048        }
13049        async fn update_consumed_by(
13050            &self,
13051            txid: &str,
13052            output_index: u32,
13053            topic: &str,
13054            consumed_by: &[Outpoint],
13055        ) -> Result<(), StorageError> {
13056            self.inner
13057                .update_consumed_by(txid, output_index, topic, consumed_by)
13058                .await
13059        }
13060        async fn update_transaction_beef(
13061            &self,
13062            txid: &str,
13063            beef: &[u8],
13064        ) -> Result<(), StorageError> {
13065            self.inner.update_transaction_beef(txid, beef).await
13066        }
13067        async fn insert_applied_transaction(
13068            &self,
13069            tx: &AppliedTransaction,
13070        ) -> Result<(), StorageError> {
13071            self.inner.insert_applied_transaction(tx).await
13072        }
13073        async fn does_applied_transaction_exist(
13074            &self,
13075            tx: &AppliedTransaction,
13076        ) -> Result<bool, StorageError> {
13077            self.inner.does_applied_transaction_exist(tx).await
13078        }
13079        async fn delete_applied_transaction(
13080            &self,
13081            tx: &AppliedTransaction,
13082        ) -> Result<(), StorageError> {
13083            self.inner.delete_applied_transaction(tx).await
13084        }
13085        async fn find_output(
13086            &self,
13087            txid: &str,
13088            output_index: u32,
13089            topic: Option<&str>,
13090            spent: Option<bool>,
13091            include_beef: bool,
13092        ) -> Result<Option<Output>, StorageError> {
13093            if self.fail_find_output.get() {
13094                return Err(StorageError::Database("D1_ERROR: read timed out".into()));
13095            }
13096            self.inner
13097                .find_output(txid, output_index, topic, spent, include_beef)
13098                .await
13099        }
13100        async fn find_outputs_for_transaction(
13101            &self,
13102            txid: &str,
13103            include_beef: bool,
13104        ) -> Result<Vec<Output>, StorageError> {
13105            self.inner
13106                .find_outputs_for_transaction(txid, include_beef)
13107                .await
13108        }
13109        async fn find_utxos_for_topic(
13110            &self,
13111            topic: &str,
13112            since: Option<f64>,
13113            limit: Option<u64>,
13114            include_beef: bool,
13115        ) -> Result<Vec<Output>, StorageError> {
13116            self.inner
13117                .find_utxos_for_topic(topic, since, limit, include_beef)
13118                .await
13119        }
13120        async fn update_last_interaction(
13121            &self,
13122            host: &str,
13123            topic: &str,
13124            since: u64,
13125        ) -> Result<(), StorageError> {
13126            self.inner.update_last_interaction(host, topic, since).await
13127        }
13128        async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
13129            self.inner.get_last_interaction(host, topic).await
13130        }
13131    }
13132
13133    fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
13134        let storage = FaultingStorage::new();
13135        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13136        managers.insert(
13137            "tm_test".to_string(),
13138            Box::new(MockTopicManager::admitting(admit_indices)),
13139        );
13140        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13141        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
13142        let engine = Engine::new(
13143            managers,
13144            lookup_services,
13145            Box::new(Rc::clone(&storage)),
13146            None,
13147            EngineConfig::default(),
13148        );
13149        (engine, storage)
13150    }
13151
13152    async fn applied(storage: &FaultingStorage) -> bool {
13153        storage
13154            .does_applied_transaction_exist(&AppliedTransaction {
13155                txid: TEST_TXID.to_string(),
13156                topic: "tm_test".to_string(),
13157            })
13158            .await
13159            .unwrap()
13160    }
13161
13162    /// The old behaviour, pinned: a clean submit is durable, records the
13163    /// topic as applied, and the output is in storage.
13164    #[tokio::test]
13165    async fn clean_submit_is_durable_and_records_applied() {
13166        let (engine, storage) = make_faulting_engine(vec![0]);
13167        let (steak, report) = engine
13168            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13169            .await
13170            .unwrap();
13171        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
13172        assert!(report.is_durable(), "{}", report.summary());
13173        assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
13174        assert!(applied(&storage).await);
13175        assert!(storage
13176            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13177            .await
13178            .unwrap()
13179            .is_some());
13180    }
13181
13182    /// THE PHANTOM, red→green: the insert fails, the steak still says
13183    /// "admitted" (the decision stands), but the report says NOT DURABLE and
13184    /// names the site — and the topic is NOT recorded as applied, so nothing
13185    /// downstream can treat the loss as final.
13186    #[tokio::test]
13187    async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
13188        let (engine, storage) = make_faulting_engine(vec![0]);
13189        storage.fail_insert_output.set(true);
13190        let (steak, report) = engine
13191            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13192            .await
13193            .unwrap();
13194        assert_eq!(
13195            steak["tm_test"].outputs_to_admit,
13196            vec![0],
13197            "the admission DECISION is unchanged by a write fault"
13198        );
13199        assert!(!report.is_durable());
13200        assert_eq!(report.faults.len(), 1, "{}", report.summary());
13201        assert_eq!(report.faults[0].topic, "tm_test");
13202        assert_eq!(report.faults[0].site, "insert_output");
13203        assert!(report.faults[0].error.contains("D1_ERROR"));
13204        assert!(report.applied_topics.is_empty());
13205        assert!(
13206            !applied(&storage).await,
13207            "a faulted topic must not be recorded as applied — the replay would be a dupe"
13208        );
13209        assert!(storage
13210            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13211            .await
13212            .unwrap()
13213            .is_none());
13214    }
13215
13216    /// The replay: same bytes, storage healthy again → re-validated (not a
13217    /// dupe), re-written, recorded. A THIRD submit is then the dupe it
13218    /// should be: nothing admitted, nothing to write, durable.
13219    #[tokio::test]
13220    async fn replay_after_a_fault_reapplies_and_records_applied() {
13221        let (engine, storage) = make_faulting_engine(vec![0]);
13222        storage.fail_insert_output.set(true);
13223        let (_, first) = engine
13224            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13225            .await
13226            .unwrap();
13227        assert!(!first.is_durable());
13228
13229        storage.fail_insert_output.set(false);
13230        let (steak, replay) = engine
13231            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13232            .await
13233            .unwrap();
13234        assert_eq!(
13235            steak["tm_test"].outputs_to_admit,
13236            vec![0],
13237            "re-validated, not deduped"
13238        );
13239        assert!(replay.is_durable(), "{}", replay.summary());
13240        assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
13241        assert!(applied(&storage).await);
13242        assert!(storage
13243            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13244            .await
13245            .unwrap()
13246            .is_some());
13247
13248        let (steak, third) = engine
13249            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13250            .await
13251            .unwrap();
13252        assert!(
13253            steak["tm_test"].outputs_to_admit.is_empty(),
13254            "now a genuine dupe: nothing admitted"
13255        );
13256        assert!(third.is_durable() && third.applied_topics.is_empty());
13257    }
13258
13259    /// The SPEND half of the phantom: a `find_output` fault during
13260    /// validation made a settle look like it consumed nothing, so the spend
13261    /// pointer was never written and the ack stood. Now the read fault is
13262    /// reported, the topic stays unrecorded, the previously-admitted coin
13263    /// is untouched — and the healthy replay consumes it.
13264    #[tokio::test]
13265    async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
13266        let (engine, storage) = make_faulting_engine(vec![0]);
13267        // The subject's own input: seed the coin it spends as a previously
13268        // admitted tm_test output.
13269        let tx = Transaction::from_beef(&test_beef(), None).unwrap();
13270        let source_txid = tx.inputs[0].get_source_txid().unwrap();
13271        let source_vout = tx.inputs[0].source_output_index;
13272        storage
13273            .insert_output(&Output {
13274                txid: source_txid.clone(),
13275                output_index: source_vout,
13276                output_script: vec![0x51],
13277                satoshis: 1,
13278                topic: "tm_test".to_string(),
13279                spent: false,
13280                outputs_consumed: vec![],
13281                consumed_by: vec![],
13282                beef: None,
13283                block_height: None,
13284                score: Some(1.0),
13285            })
13286            .await
13287            .unwrap();
13288
13289        storage.fail_find_output.set(true);
13290        let (_, faulted) = engine
13291            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13292            .await
13293            .unwrap();
13294        assert!(!faulted.is_durable());
13295        assert!(
13296            faulted.faults.iter().any(|f| f.site == "find_output"),
13297            "{}",
13298            faulted.summary()
13299        );
13300        assert!(
13301            !applied(&storage).await,
13302            "unrecorded: the replay must re-read"
13303        );
13304        storage.fail_find_output.set(false);
13305        assert!(
13306            storage
13307                .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13308                .await
13309                .unwrap()
13310                .is_some(),
13311            "the coin the faulted pass could not see is still there, untouched"
13312        );
13313
13314        let (_, replay) = engine
13315            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13316            .await
13317            .unwrap();
13318        assert!(replay.is_durable(), "{}", replay.summary());
13319        assert!(applied(&storage).await);
13320        // The mock manager retains no coins, so the consumed coin is stale
13321        // and deleted — the observable proof the spend was PROCESSED.
13322        assert!(
13323            storage
13324                .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13325                .await
13326                .unwrap()
13327                .is_none(),
13328            "the healthy replay consumed the coin the faulted pass missed"
13329        );
13330    }
13331
13332    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): a lookup service that missed
13333    /// its admit notification is told AGAIN from the engine's own stored
13334    /// outputs — one call per admitted output per service, the WholeTx body
13335    /// naming the subject — while `/submit` of the same bytes is deduped and
13336    /// tells nobody (the premise, RED-verified in the same test); a topic the
13337    /// engine never admitted this txid on yields 0 (the caller re-submits).
13338    #[tokio::test]
13339    async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
13340        use std::cell::RefCell;
13341        use std::rc::Rc;
13342
13343        struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
13344        #[async_trait(?Send)]
13345        impl LookupServiceTrait for CountingLs {
13346            fn admission_mode(&self) -> AdmissionMode {
13347                self.1
13348            }
13349            fn spend_notification_mode(&self) -> SpendNotificationMode {
13350                SpendNotificationMode::None
13351            }
13352            async fn output_admitted_by_topic(
13353                &self,
13354                payload: &OutputAdmittedByTopic,
13355            ) -> Result<(), LookupServiceError> {
13356                let rec = match payload {
13357                    OutputAdmittedByTopic::LockingScript {
13358                        txid,
13359                        output_index,
13360                        topic,
13361                        ..
13362                    } => (txid.clone(), *output_index, topic.clone()),
13363                    OutputAdmittedByTopic::WholeTx {
13364                        atomic_beef,
13365                        output_index,
13366                        topic,
13367                        ..
13368                    } => {
13369                        let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
13370                        (
13371                            beef.atomic_txid
13372                                .clone()
13373                                .expect("the WholeTx body NAMES its subject"),
13374                            *output_index,
13375                            topic.clone(),
13376                        )
13377                    }
13378                };
13379                self.0.borrow_mut().push(rec);
13380                Ok(())
13381            }
13382            async fn output_evicted(
13383                &self,
13384                _txid: &str,
13385                _output_index: u32,
13386            ) -> Result<(), LookupServiceError> {
13387                Ok(())
13388            }
13389            async fn lookup(
13390                &self,
13391                _question: &LookupQuestion,
13392            ) -> Result<LookupResult, LookupServiceError> {
13393                Ok(LookupResult::OutputList(Vec::new()))
13394            }
13395            async fn get_documentation(&self) -> String {
13396                "counting".to_string()
13397            }
13398            async fn get_metadata(&self) -> ServiceMetadata {
13399                ServiceMetadata {
13400                    name: "counting-ls".to_string(),
13401                    ..Default::default()
13402                }
13403            }
13404        }
13405
13406        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13407        managers.insert(
13408            "tm_test".to_string(),
13409            Box::new(MockTopicManager::admitting(vec![0])),
13410        );
13411        let seen = Rc::new(RefCell::new(Vec::new()));
13412        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13413        lookup_services.insert(
13414            "ls_whole".to_string(),
13415            Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
13416        );
13417        lookup_services.insert(
13418            "ls_script".to_string(),
13419            Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
13420        );
13421        let engine = Engine::new(
13422            managers,
13423            lookup_services,
13424            Box::new(MemoryStorage::new()),
13425            None,
13426            EngineConfig::default(),
13427        );
13428
13429        let tagged = test_tagged_beef(vec!["tm_test"]);
13430        engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13431        assert_eq!(seen.borrow().len(), 2, "one admit per service");
13432        let (txid, vout, _) = seen.borrow()[0].clone();
13433        assert_eq!(txid, TEST_TXID);
13434
13435        // the premise, RED-verified: the same bytes again are DEDUPED — /submit can never re-notify
13436        let (_, report) = engine
13437            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13438            .await
13439            .unwrap();
13440        assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
13441        assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
13442
13443        // the verb: told again from the stored outputs, the WholeTx body naming the subject
13444        let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
13445        assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
13446        assert_eq!(r.vouts, vec![vout]);
13447        assert_eq!(seen.borrow().len(), 4);
13448        assert!(seen.borrow()[2..]
13449            .iter()
13450            .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
13451
13452        // a topic the engine never admitted this txid on: nothing to re-notify (re-submit instead)
13453        let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
13454        assert_eq!((r0.outputs, r0.notified), (0, 0));
13455        assert_eq!(seen.borrow().len(), 4);
13456
13457        // idempotent: a second re-notify tells everyone again, faults nothing
13458        let r2 = engine
13459            .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
13460            .await
13461            .unwrap();
13462        assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
13463        assert_eq!(seen.borrow().len(), 6);
13464    }
13465
13466    /// bsv-low PLAN-PRE-LOOP4 §H4: a PHANTOM applied row (the topic admitted
13467    /// NOTHING) dedups every re-submit of those bytes; forgetting it lets the
13468    /// same bytes be judged again — while a REAL admission's row is never
13469    /// forgotten and a missing row is nothing to forget.
13470    #[tokio::test]
13471    async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
13472        // phantom: a TM that admits nothing still records the topic as applied
13473        let engine = make_engine(vec![]);
13474        let tagged = test_tagged_beef(vec!["tm_test"]);
13475        let (steak, r1) = engine
13476            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13477            .await
13478            .unwrap();
13479        assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
13480        assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
13481        let (_, r2) = engine
13482            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13483            .await
13484            .unwrap();
13485        assert_eq!(
13486            r2.deduped_topics,
13487            vec!["tm_test".to_string()],
13488            "the phantom row dedups the re-submit"
13489        );
13490        assert!(engine
13491            .forget_phantom_applied(TEST_TXID, "tm_test")
13492            .await
13493            .unwrap());
13494        let (_, r3) = engine
13495            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13496            .await
13497            .unwrap();
13498        assert!(
13499            r3.deduped_topics.is_empty(),
13500            "judged again after the forget"
13501        );
13502        assert!(
13503            !engine
13504                .forget_phantom_applied(TEST_TXID, "tm_other")
13505                .await
13506                .unwrap(),
13507            "no row: nothing to forget"
13508        );
13509
13510        // real: a TM that admits output 0 — its row is a real admission, never forgotten
13511        let real = make_engine(vec![0]);
13512        real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13513        assert!(!real
13514            .forget_phantom_applied(TEST_TXID, "tm_test")
13515            .await
13516            .unwrap());
13517        let (_, r4) = real
13518            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13519            .await
13520            .unwrap();
13521        assert_eq!(
13522            r4.deduped_topics,
13523            vec!["tm_test".to_string()],
13524            "a real admission still dedups"
13525        );
13526    }
13527
13528    // ── NL-6e: the SPV walk is linear in a BUMP's leaves (NL-8 W1) ─────
13529
13530    /// Named and generous (a debug build on a loaded machine): the walk at
13531    /// bsv-rs 0.4.0 took 30 to 36 s at 4,096 leaves in release and gave no
13532    /// answer in 120 s at 8,192 (NL-8 W1).
13533    const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
13534
13535    /// The wide BUMP's height (NL-8's `wide k` row).
13536    const WIDE_HEIGHT: u32 = 800_001;
13537
13538    /// `2^k` txids, display hex: the double SHA-256 of `i` (NL-8's probe).
13539    fn wide_leaves(k: u32) -> Vec<String> {
13540        (0..1u32 << k)
13541            .map(|i| {
13542                let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
13543                id.reverse();
13544                hex::encode(id)
13545            })
13546            .collect()
13547    }
13548
13549    /// One BUMP at [`WIDE_HEIGHT`] whose level 0 carries `txids` as hashed
13550    /// leaves and whose `k - 1` upper levels are empty: every node above
13551    /// level 0 is computed from it. Its root, by one walk.
13552    fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
13553        let k = txids.len().trailing_zeros() as usize;
13554        let mut levels = vec![Vec::new(); k];
13555        levels[0] = txids
13556            .iter()
13557            .enumerate()
13558            .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
13559            .collect();
13560        let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
13561        let root = bump.compute_root(None).unwrap();
13562        (bump, root)
13563    }
13564
13565    /// NL-8 W1's bytes: a V1 BEEF of the one wide BUMP and no transaction
13566    /// (`wide_14` at `2^k` leaves, byte for byte the runner's probe).
13567    fn w1_bytes(k: u32) -> (Vec<u8>, String) {
13568        let (bump, root) = wide_bump(&wide_leaves(k));
13569        let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
13570        beef.extend_from_slice(&bump.to_binary());
13571        beef.push(0x00);
13572        (beef, root)
13573    }
13574
13575    /// The entry, [`verify_spv_like_the_reference`], on its own thread under
13576    /// a tracker that carries `root` at [`WIDE_HEIGHT`]: its answer and its
13577    /// time, or a panic naming the bound.
13578    fn walk_within_bound(
13579        bytes: Vec<u8>,
13580        root: String,
13581        verify_scripts: bool,
13582        subject: String,
13583        what: &str,
13584    ) -> (Result<(), String>, std::time::Duration) {
13585        let (tx, rx) = std::sync::mpsc::channel();
13586        std::thread::spawn(move || {
13587            let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
13588            tracker.add_root(WIDE_HEIGHT, root);
13589            let runtime = tokio::runtime::Builder::new_current_thread()
13590                .build()
13591                .unwrap();
13592            let started = std::time::Instant::now();
13593            let verdict = runtime.block_on(verify_spv_like_the_reference(
13594                Some(&tracker),
13595                verify_scripts,
13596                &bytes,
13597                &subject,
13598                DoorBudget::DEFAULT,
13599            ));
13600            let verdict = match verdict {
13601                Ok(None) => Ok(()),
13602                Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
13603                Err(e) => Err(e.to_string()),
13604            };
13605            let _ = tx.send((verdict, started.elapsed()));
13606        });
13607        rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
13608            panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
13609        })
13610    }
13611
13612    /// The escape hatch's structural walk (`verify_scripts` false) on NL-8
13613    /// W1's bytes, 4,096 and 8,192 leaves: accepted, the one root asked of
13614    /// the tracker, well inside the bound.
13615    #[test]
13616    fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
13617        for k in [12u32, 13] {
13618            let (bytes, root) = w1_bytes(k);
13619            let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
13620            let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
13621            println!(
13622                "NL-6e W1 {what}: {verdict:?} in {:.3} s",
13623                took.as_secs_f64()
13624            );
13625            assert_eq!(verdict, Ok(()), "{what}");
13626        }
13627    }
13628
13629    /// The default walk (scripts run, `verify_scripts` true) over `2^k`
13630    /// proven parents in the one wide BUMP, each spent by a binary tree of
13631    /// two-input transactions whose root is the subject: every parent's
13632    /// root is the BUMP's, asked of the tracker, the inputs `2^(k+1) - 2`.
13633    fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
13634        use bsv_rs::script::{LockingScript, UnlockingScript};
13635        use bsv_rs::transaction::{TransactionInput, TransactionOutput};
13636        let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
13637        let spending = |txid: &str| {
13638            let mut input = TransactionInput::new(txid.to_string(), 0);
13639            input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
13640            input
13641        };
13642        let parents: Vec<Transaction> = (0..1u32 << k)
13643            .map(|i| {
13644                let mut tx = Transaction::new();
13645                // A coinbase's null outpoint, a distinct index each.
13646                let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
13647                input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
13648                tx.inputs.push(input);
13649                tx.outputs.push(TransactionOutput::new(1, op_true()));
13650                tx
13651            })
13652            .collect();
13653        let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
13654        let (bump, root) = wide_bump(&ids);
13655        let mut beef = Beef::new();
13656        let at = beef.merge_bump(bump);
13657        for parent in &parents {
13658            beef.merge_raw_tx(parent.to_binary(), Some(at));
13659        }
13660        let mut layer = ids;
13661        while layer.len() > 1 {
13662            layer = layer
13663                .chunks(2)
13664                .map(|pair| {
13665                    let mut tx = Transaction::new();
13666                    tx.inputs.push(spending(&pair[0]));
13667                    tx.inputs.push(spending(&pair[1]));
13668                    tx.outputs.push(TransactionOutput::new(0, op_true()));
13669                    beef.merge_raw_tx(tx.to_binary(), None);
13670                    tx.id()
13671                })
13672                .collect();
13673        }
13674        (beef.to_binary(), root, layer.remove(0))
13675    }
13676
13677    #[test]
13678    fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
13679        for k in [12u32, 13] {
13680            let (bytes, root, subject) = proven_fan_in(k);
13681            let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
13682            let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
13683            println!(
13684                "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
13685                took.as_secs_f64()
13686            );
13687            assert_eq!(verdict, Ok(()), "{what}");
13688        }
13689    }
13690    include!("minutes_tests.rs");
13691}