Skip to main content

bsv_overlay_engine/
engine.rs

1//! Overlay Services Engine — the core orchestrator.
2//!
3//! Receives transactions (submit), answers queries (lookup), manages advertisements,
4//! and coordinates GASP sync. All storage, topic management, and lookup service
5//! operations go through trait interfaces.
6//!
7//! Ported from `~/bsv/overlay-services/src/Engine.ts` (1,337 lines).
8
9use crate::beef_limits;
10
11use bsv_rs::transaction::{Beef, Transaction};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use tracing::{error, info, warn};
15
16use crate::advertiser::{Advertiser, AdvertiserError};
17use crate::broadcaster::{ArcBroadcaster, Broadcaster};
18use crate::lookup_service::{LookupService, LookupServiceError};
19use crate::storage::{Storage, StorageError};
20use crate::topic_manager::{TopicManager, TopicManagerError};
21use crate::types::*;
22
23/// Controls how much spend history to include in lookup responses.
24///
25/// Maps to the TS `historySelector` parameter which can be a number
26/// (depth limit) or an async function (per-output decider).
27#[derive(Debug, Clone)]
28pub enum HistorySelector {
29    /// Include up to N levels of ancestor spend history.
30    Depth(u32),
31}
32
33/// Configuration for constructing the Engine.
34pub struct EngineConfig {
35    /// URL where this engine is hosted. Required for advertisement sync.
36    pub hosting_url: Option<String>,
37    /// Known SHIP tracker URLs for bootstrapping.
38    pub ship_trackers: Vec<String>,
39    /// Known SLAP tracker URLs for bootstrapping.
40    pub slap_trackers: Vec<String>,
41    /// Configuration for GASP topic synchronization.
42    pub sync_configuration: SyncConfiguration,
43    /// Whether to suppress default SHIP/SLAP sync advertisements.
44    pub suppress_default_sync_advertisements: bool,
45}
46
47impl Default for EngineConfig {
48    fn default() -> Self {
49        Self {
50            hosting_url: None,
51            ship_trackers: Vec::new(),
52            slap_trackers: Vec::new(),
53            sync_configuration: HashMap::new(),
54            suppress_default_sync_advertisements: true,
55        }
56    }
57}
58
59/// The Overlay Services Engine.
60///
61/// Orchestrates topic managers, lookup services, storage, and advertisements.
62/// Does NOT include an HTTP server — that's the job of overlay-cloudflare.
63pub struct Engine {
64    managers: HashMap<String, Box<dyn TopicManager>>,
65    lookup_services: HashMap<String, Box<dyn LookupService>>,
66    storage: Box<dyn Storage>,
67    advertiser: Option<Box<dyn Advertiser>>,
68    broadcaster: Option<Box<dyn Broadcaster>>,
69    arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
70    chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
71    gasp_remote_factory: Option<Box<dyn crate::gasp::GASPRemoteFactory>>,
72    ship_lookup_client: Option<Box<dyn crate::ship_lookup::ShipLookupClient>>,
73    ship_bootstrap_peers: HashMap<String, Vec<String>>,
74    /// OPT-IN / OFF BY DEFAULT chain-backed ancestor fetcher for GASP ingest.
75    /// When `None` (default / production), GASP ingest is byte-identical to
76    /// today. A platform crate may set `Some` (one-time-migration only) to let
77    /// ingest self-heal missing ancestry by fetching it from chain.
78    ancestor_fetcher: Option<std::rc::Rc<dyn crate::gasp::AncestorFetcher>>,
79    gasp_ancestor_fetcher: Option<std::rc::Rc<dyn crate::gasp::AncestorFetcher>>,
80    /// bsv-low#302: per-peer GASP sync wall-clock budget —
81    /// `(sleep factory, budget ms)`. `None` (default) = unbounded per-peer
82    /// sync, byte-identical to the pre-#302 behavior. When set, each peer's
83    /// `GASPSync::sync` is raced against `sleep(budget_ms)`; a peer that
84    /// exceeds the budget is DROPPED (loud log, failure recorded, cursor NOT
85    /// advanced) and the loop continues with the next peer.
86    peer_sync_budget: Option<(SleepFactory, u64)>,
87    /// The per-GRAPH GASP budget (bsv-low #555): the sleep, the calls, the
88    /// ms. `None`: no graph is deferred.
89    graph_budget: Option<(SleepFactory, u32, u64)>,
90    /// The per-graph budget's bytes and nodes limbs (bsv-low #586,
91    /// [`Engine::set_graph_budget_limbs`]): in force once a graph budget is
92    /// set.
93    graph_budget_limbs: (u64, u32),
94    /// The bound of one transaction's finalize submit (bsv-low #559), see
95    /// [`Engine::set_finalize_submit_budget`].
96    finalize_submit_budget: Option<(SleepFactory, u64)>,
97    /// Open around each GASP finalize submit, so no deadline that races the
98    /// sync drops a transaction between its writes (bsv-low #552, the lens
99    /// fold's HIGH-1). See [`Engine::finalize_submit_gate`].
100    finalize_gate: crate::gasp::SubmitGate,
101    /// (txid, topic) pairs whose Phase-3 writes FAULTED in this engine and
102    /// have not landed since (bsv-low #559): a successor that finds no coin
103    /// while its predecessor is in here is not recorded as applied. One
104    /// invocation's memory; across invocations the same question is put to
105    /// the store ([`Engine::unlanded_predecessor`]).
106    not_landed: std::cell::RefCell<HashSet<(String, String)>>,
107    /// Asked before the door lands a carried predecessor (bsv-low #575, the
108    /// E1D delta-2 fold, L2). See [`Engine::set_landing_guard`].
109    landing_guard: Option<LandingGuard>,
110    /// Reference-parity spend verification on submit (2026-09-08). `true`
111    /// (DEFAULT): every submit outside `HistoricalTxNoSpv` runs the
112    /// reference's `Transaction.verify` walk: merkle paths against the chain
113    /// tracker AND every input's unlocking script EXECUTED, recursively over
114    /// the unproven ancestry in the BEEF; with no chain tracker the walk is
115    /// the ts-sdk's `'scripts only'` (roots accepted unchecked, scripts still
116    /// run). `false` is an ESCAPE HATCH, not a mode: it restores the
117    /// pre-2026-09-08 structural check (`Beef::verify_valid` + every root
118    /// against the tracker; NOTHING without a tracker), which admitted an
119    /// invalid spend on structure alone. See
120    /// [`Engine::set_script_verification`].
121    verify_scripts: bool,
122    /// The two limbs of the submit's own script walk (bsv-low #592): the
123    /// door's [`DoorBudget`], [`DoorBudget::DEFAULT`] unless set. See
124    /// [`Engine::set_walk_budget`].
125    walk_budget: DoorBudget,
126    config: EngineConfig,
127}
128
129/// Boxed platform sleep future (bsv-low#302). Not `Send` — the engine runs
130/// single-threaded on wasm (Workers) and under `#[tokio::test]` locally.
131pub type SleepFuture = std::pin::Pin<Box<dyn std::future::Future<Output = ()>>>;
132
133/// Platform sleep factory (bsv-low#302): milliseconds → a future that
134/// resolves after that delay. The Cloudflare crate passes its `sleep_ms`;
135/// tests pass `ready(())` (instant deadline) or `pending()` (no deadline)
136/// to drive the race deterministically.
137pub type SleepFactory = std::rc::Rc<dyn Fn(u64) -> SleepFuture>;
138
139/// The answer of a [`LandingGuard`]: `Ok` lets the body land, `Err` says why
140/// it may not (the topic waits).
141pub type LandingGuardFuture =
142    std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), String>>>>;
143
144/// The caller's admission predicate over a carried predecessor the door is
145/// about to land: `(predecessor txid, topic)`. See
146/// [`Engine::set_landing_guard`].
147pub type LandingGuard = std::rc::Rc<dyn Fn(&str, &str) -> LandingGuardFuture>;
148
149/// The bound of ONE GASP finalize submit's storage calls and hooks (bsv-low
150/// #559, the lens fold of 2026-10-07, F2). The submit itself is never
151/// dropped: it is the write section no deadline drops, and c9921ee, which
152/// raced the whole submit against its budget inside the section, dropped it
153/// between two writes all the same. Each storage call, lookup hook and topic
154/// manager call inside it is raced against the section's ONE deadline
155/// instead, and a call that has not answered when the deadline falls due is
156/// dropped ALONE and reported as that call's fault: the submit runs on to
157/// its end as for any storage fault. Once the deadline is due no further
158/// call is started (each answers "no answer" at once), so a transaction
159/// costs at most its budget, and once more for the undo
160/// ([`CallBound::renew`]).
161struct CallBound {
162    sleep: SleepFactory,
163    budget_ms: u64,
164    deadline: std::cell::RefCell<SleepFuture>,
165    due: std::cell::Cell<bool>,
166}
167
168impl CallBound {
169    fn new(sleep: &SleepFactory, budget_ms: u64) -> Self {
170        Self {
171            deadline: std::cell::RefCell::new(sleep(budget_ms)),
172            sleep: sleep.clone(),
173            budget_ms,
174            due: std::cell::Cell::new(false),
175        }
176    }
177
178    /// A fresh allowance of the same length, for the undo of a faulted
179    /// submit: the calls that put the store back as it was found must not be
180    /// refused because the deadline the fault answered to has passed.
181    fn renew(&self) {
182        *self.deadline.borrow_mut() = (self.sleep)(self.budget_ms);
183        self.due.set(false);
184    }
185
186    /// Whether a call under this bound went unanswered (or was not started
187    /// because one had) since the last [`CallBound::renew`].
188    fn is_due(&self) -> bool {
189        self.due.get()
190    }
191
192    /// `call`, or `None` when the deadline fell due first.
193    async fn call<T>(&self, call: impl std::future::Future<Output = T>) -> Option<T> {
194        if self.due.get() {
195            return None;
196        }
197        let mut call = std::pin::pin!(call);
198        std::future::poll_fn(|cx| {
199            if let std::task::Poll::Ready(v) = call.as_mut().poll(cx) {
200                return std::task::Poll::Ready(Some(v));
201            }
202            if self.deadline.borrow_mut().as_mut().poll(cx).is_ready() {
203                self.due.set(true);
204                return std::task::Poll::Ready(None);
205            }
206            std::task::Poll::Pending
207        })
208        .await
209    }
210}
211
212/// `call` under `bound` (or unbounded with none); `Err` is "no answer".
213async fn bounded<T>(
214    bound: Option<&CallBound>,
215    call: impl std::future::Future<Output = T>,
216) -> Result<T, String> {
217    match bound {
218        Some(bound) => bound
219            .call(call)
220            .await
221            .ok_or_else(|| format!("no answer within {} ms", bound.budget_ms)),
222        None => Ok(call.await),
223    }
224}
225
226/// A storage call under `bound`: no answer in time is that call's fault.
227async fn stored<T>(
228    bound: Option<&CallBound>,
229    call: impl std::future::Future<Output = Result<T, StorageError>>,
230) -> Result<T, StorageError> {
231    match bounded(bound, call).await {
232        Ok(answer) => answer,
233        Err(no_answer) => Err(StorageError::Database(no_answer)),
234    }
235}
236
237/// A lookup hook under `bound`, its error as the report carries it.
238async fn hooked(
239    bound: Option<&CallBound>,
240    call: impl std::future::Future<Output = Result<(), LookupServiceError>>,
241) -> Result<(), String> {
242    bounded(bound, call).await?.map_err(|e| e.to_string())
243}
244
245/// The most store reads [`Engine::unlanded_predecessor`] makes for one
246/// topic of one submit (bsv-low #559, F3): the question is asked of every
247/// transaction that admits nothing and found no coin, so it is kept cheap.
248/// Counted are the reads spent on a body the BEEF does not prove and the
249/// store does not hold as landed (the second delta fold of 2026-10-07, M2);
250/// the 17th of those is never made: out of reads is "not now" (the delta
251/// fold of 2026-10-07, M2). An unproven body still needs room under it for
252/// the reads that find it landed (one for an applied row, two for a held
253/// output): each is tested before it is made.
254const PREDECESSOR_READS: usize = 16;
255
256/// The most store reads [`Engine::unlanded_predecessor`] makes for one
257/// SUBMIT (the third delta fold of 2026-10-07, M1): every read of the
258/// question, counted against [`PREDECESSOR_READS`] or not (those of proven
259/// and landed bodies included), in every topic of the submit together. The
260/// 257th is never made: past the allowance the answer is "not now", never
261/// "landed". Without it the question read as far as the BEEF reached (one
262/// read per 41 bytes: 66,305 reads measured from one 2.7 MB BEEF), on a
263/// public route. Per submit and not per topic because what it protects, the
264/// invocation's D1 allowance, is per invocation; 256 is the door's own unit
265/// ([`DoorBudget`]'s inputs per transaction), no derived figure.
266const PREDECESSOR_READS_PER_SUBMIT: usize = 256;
267
268/// Why a successor must wait ([`Engine::unlanded_predecessor`]), and the
269/// unlanded predecessor whose body the BEEF carries, when the answer names
270/// one: the door lands it first ([`Engine::land_carried`]).
271/// A submit's STEAK and report, and the carried predecessor that blocks each
272/// topic of a carried predecessor's own submit ([`Engine::submit_counted`]).
273type Counted = (Steak, MutationReport, HashMap<String, String>);
274
275/// What the landings of carried predecessors take from their successor's
276/// submit (the E592 lens fold, M1): its breach policy, and whether its own
277/// walk RAN (only then are the bodies it reached trusted).
278#[derive(Debug, Clone, Copy)]
279struct Landing {
280    policy: WalkBreachPolicy,
281    subject_walk_ran: bool,
282}
283
284struct NotNow {
285    why: String,
286    carried: Option<String>,
287}
288
289/// Summary of an [`Engine::complete_missing_proofs`] pass.
290///
291/// All counts are over the single bounded page scanned this tick. A no-fetcher
292/// (production) build returns the all-zero default.
293#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
294pub struct ProofCompletionSummary {
295    /// Young walk-admitted rows selected without waiting for a callback.
296    pub early_gasp: usize,
297    /// Stored transactions inspected this tick (the bounded page size).
298    pub scanned: usize,
299    /// Of `scanned`, how many still lacked a merkle proof for their own tx.
300    pub proofless: usize,
301    /// Proofless txs whose BUMP was fetched + stitched into the stored BEEF.
302    pub completed: usize,
303    /// Proofless txs the fetcher could serve but that are not yet mined
304    /// (no BUMP yet) — retried on a later tick.
305    pub still_unconfirmed: usize,
306    /// Proofless txs the fetcher errored on (e.g. budget exhausted, 429) —
307    /// retried on a later tick.
308    pub fetch_failed: usize,
309    /// Proofless txs whose proof came back but failed to stitch into storage.
310    pub stitch_failed: usize,
311    /// Scanned rows that ALREADY carried a valid proof (stale `has_proof = 0`
312    /// flag) and were marked proven this tick so they drop out of the candidate
313    /// window — clearing the window-clog (#130).
314    pub already_proven: usize,
315}
316
317/// Outcome of one [`Engine::sync_advertisements`] run (bsv-low #320 defect
318/// 3a).
319///
320/// The engine used to swallow create/submit failures into `error!` and
321/// return `Ok(())`, so the admin route reported `success` while zero
322/// advertisements were admitted locally; a caller could not tell a
323/// converged no-op (`to_create == 0`) from a silent failure. Every field
324/// here is observational — the sync's behavior is unchanged, its outcome is
325/// no longer hidden.
326#[derive(Debug, Default, Clone, Serialize)]
327#[serde(rename_all = "camelCase")]
328pub struct SyncAdvertisementsReport {
329    /// Advertisements the diff found missing (attempted this run). A
330    /// converged node reports 0 here.
331    pub to_create: usize,
332    /// Stale advertisements the diff said to revoke.
333    pub to_revoke: usize,
334    /// `advertiser.create_advertisements` failure, verbatim.
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub create_error: Option<String>,
337    /// Local `Engine::submit` failure for the created ads, verbatim — the
338    /// path that silently hid the self-admission failure live (our own
339    /// ls_ship/ls_slap never gained our ads, so every cycle re-created all
340    /// of them).
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub submit_error: Option<String>,
343    /// Outputs admitted per topic by the local submit (from the STEAK).
344    /// A successful submit that admitted NOTHING (topic-manager refusal)
345    /// shows up here as explicit zeros.
346    pub admitted: std::collections::BTreeMap<String, usize>,
347    /// `advertiser.find_all_advertisements` failure, verbatim (#320 M2).
348    /// When set, creation was REFUSED this run: a blind read must never
349    /// become "current ads = none" and re-create (re-pay) the entire set.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub lookup_error: Option<String>,
352    /// Stale ads the diff wanted revoked but the advertiser declined to
353    /// build a revocation for (the CF advertiser's documented v1 no-op —
354    /// #320 L2). Distinguishes "revoke happened" from "revoke skipped".
355    pub revoke_skipped: usize,
356    /// `advertiser.revoke_advertisements` failure, verbatim.
357    #[serde(skip_serializing_if = "Option::is_none")]
358    pub revoke_error: Option<String>,
359    /// Local `Engine::submit` failure for the revocation, verbatim.
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub revoke_submit_error: Option<String>,
362}
363
364impl SyncAdvertisementsReport {
365    /// True when nothing errored. NOTE: a submit that admitted zero outputs
366    /// is not an error at this layer — that refusal is [`Self::effective`]'s
367    /// job.
368    #[must_use]
369    pub fn ok(&self) -> bool {
370        self.create_error.is_none()
371            && self.submit_error.is_none()
372            && self.lookup_error.is_none()
373            && self.revoke_error.is_none()
374            && self.revoke_submit_error.is_none()
375    }
376
377    /// Total outputs admitted by the local submit across topics.
378    #[must_use]
379    pub fn admitted_total(&self) -> usize {
380        self.admitted.values().sum()
381    }
382
383    /// True when the run errored nowhere AND the local submit admitted
384    /// EVERY created ad (#320 M1 + delta D1): each advertisement is exactly
385    /// one output and change is never a SHIP/SLAP token, so full admission
386    /// means `admitted_total() >= to_create` (defensive `>=`). A weaker
387    /// `> 0` bar let a PARTIAL admit pass — the live set is 9 SHIP + 8 SLAP
388    /// in ONE tx with independent per-topic admission, so tm_ship admitting
389    /// its 9 while tm_slap refused all 8 read as success while the diff
390    /// re-created (re-paid for) the refused 8 every cycle. Subsumes the
391    /// converged no-op (`0 >= 0`).
392    #[must_use]
393    pub fn effective(&self) -> bool {
394        self.ok() && self.admitted_total() >= self.to_create
395    }
396}
397
398/// Internal result from Phase 1 + 2 validation.
399///
400/// Carries per-topic admittance decisions, previous coins, and the parsed
401/// transaction so that Phase 3 (mutations) can proceed without re-parsing.
402struct TopicValidation {
403    topic: String,
404    is_dupe: bool,
405    /// Input indices that spend previously-admitted outputs from this topic.
406    previous_coins: Vec<u32>,
407    /// The previous outputs found in storage (parallel to previous_coins).
408    previous_outputs: Vec<Output>,
409    admittance: AdmittanceInstructions,
410    failed: bool,
411    /// The FIRST storage read fault hit while scanning this topic's
412    /// previous outputs (S2, bsv-low 2026-08-29). A faulted read makes a
413    /// spend look like it consumes nothing — the settle's spend pointer is
414    /// then never written — so it is carried into the [`MutationReport`]
415    /// and blocks the `applied_transactions` record for this topic: a
416    /// replay re-reads instead of being deduplicated away. With the read
417    /// that faulted: `find_output` (a previous coin) or
418    /// `does_applied_transaction_exist` (the dedup read, the delta fold of
419    /// 2026-10-07, H3).
420    read_fault: Option<(&'static str, String)>,
421}
422
423/// One Phase-3 write — or the validation read it depends on — that FAILED
424/// during a submit (S2 queue-durable admission, bsv-low 2026-08-29).
425///
426/// Before this existed every such failure was `error!`-logged and swallowed
427/// while `submit` returned `Ok(steak)`: under a D1-overload storm the
428/// overlay ACKED admissions whose writes never landed (the 2026-08-26
429/// phantom class — `c4d2ed06…`/`62c2a9b3…` absent from the engine store,
430/// beta D1 queried directly). A fault is now a VALUE the caller can act on
431/// (re-queue for an idempotent replay) instead of a log line nobody reads.
432#[derive(Debug, Clone, PartialEq, Eq)]
433pub struct MutationFault {
434    /// The topic whose mutation faulted.
435    pub topic: String,
436    /// The write (or read) that failed: `insert_output`,
437    /// `mark_utxo_as_spent`, `update_consumed_by`, `delete_utxo_deep`,
438    /// `insert_applied_transaction`, `find_output` (validation read),
439    /// `does_applied_transaction_exist` (the dedup read),
440    /// `lookup_service.output_spent`, `lookup_service.output_admitted_by_topic`,
441    /// `undo_insert_output` (the undo of a faulted submit's inserts),
442    /// `record_spent_coin_applied` (the applied row a spender writes for the
443    /// transaction of a coin it deletes), `predecessor_not_landed`.
444    pub site: &'static str,
445    /// The backend's own error text.
446    pub error: String,
447}
448
449/// What Phase 3 actually landed for a submit — the durability report.
450///
451/// `applied_topics` are the topics whose EVERY write (and lookup-service
452/// notification) succeeded and were therefore recorded in
453/// `applied_transactions`. A topic with any fault is deliberately NOT
454/// recorded: the dedup in Phase 1 would otherwise turn a replay into a
455/// no-op and make the loss permanent (the ordering was already
456/// write-then-record; the record is now conditional on the writes).
457#[derive(Debug, Default, Clone, PartialEq, Eq)]
458pub struct MutationReport {
459    pub faults: Vec<MutationFault>,
460    pub applied_topics: Vec<String>,
461    /// Topics this (txid, topic) pair had ALREADY been applied under — the
462    /// Phase-1 dedup skipped them, so their STEAK entry is an empty default
463    /// that means "nothing new", never "judged another tx" (loop-3 client
464    /// belt re-presents read the empty STEAK as index-pending).
465    pub deduped_topics: Vec<String>,
466    /// The predecessors the door LANDED first from this submit's BEEF, as
467    /// `(txid, topic)`, ancestors first ([`Engine::land_carried`]; the E1D
468    /// delta fold, L2). Each is an admission write of its own that no
469    /// caller asked for: a caller that guards its admission writes (the
470    /// worker's eviction ledger, bsv-low #513) guards these too.
471    pub landed_predecessors: Vec<(String, String)>,
472    /// The submit's own script walk could not run within the engine's
473    /// budget (bsv-low #592) and the submit went on as under
474    /// `HistoricalTxNoSpv` ([`WalkBreachPolicy::NetworkAccepted`] only; under
475    /// `NotNow` a breach is [`EngineError::WalkCouldNotRun`]). `None` when it
476    /// ran (or the mode does not walk). A caller counts it; it never makes a
477    /// report undurable.
478    pub walk_could_not_run: Option<WalkCouldNotRun>,
479    /// The walks of the carried predecessors this submit tried to land
480    /// ([`Engine::land_carried`]) that could not run within the budget (the
481    /// E592 lens fold, L3): under `NotNow` each one's landing did not happen
482    /// (the successor waits), under `NetworkAccepted` each went on. Counted
483    /// by the caller beside `walk_could_not_run`.
484    pub landed_walks_could_not_run: Vec<WalkCouldNotRun>,
485}
486
487/// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): what [`Engine::renotify_admitted`] did.
488#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize)]
489pub struct RenotifyReport {
490    /// stored outputs of (txid, topic) the engine had ALREADY admitted
491    pub outputs: u32,
492    /// lookup-service notifications that returned Ok (outputs × services)
493    pub notified: u32,
494    /// notifications that failed — `<service>: <error>`; the others still ran
495    pub faults: Vec<String>,
496    /// the output indexes re-notified (the caller's spend discovery keys on them)
497    pub vouts: Vec<u32>,
498}
499
500impl MutationReport {
501    fn fault(&mut self, topic: &str, site: &'static str, error: String) {
502        self.faults.push(MutationFault {
503            topic: topic.to_string(),
504            site,
505            error,
506        });
507    }
508
509    /// True when every mutation this submit needed landed (nothing to
510    /// replay). A dupe-only submit is durable (there was nothing to write).
511    #[must_use]
512    pub fn is_durable(&self) -> bool {
513        self.faults.is_empty()
514    }
515
516    /// One log line naming every fault (`topic/site: error`), each error
517    /// clipped so a D1 stack dump cannot flood the log stream.
518    #[must_use]
519    pub fn summary(&self) -> String {
520        self.faults
521            .iter()
522            .map(|f| {
523                let err: String = f.error.chars().take(120).collect();
524                format!("{}/{}: {}", f.topic, f.site, err)
525            })
526            .collect::<Vec<_>>()
527            .join("; ")
528    }
529}
530
531/// The DOOR's static work bound (bsv-low W-A gate MED-1, 2026-09-09). Bitcoin
532/// script has no loops: every opcode runs at most once, so an input's work is
533/// bounded BEFORE execution by its opcode census times the interpreter's
534/// element limit (the stack memory limit bounds every element, and every
535/// CAT / NUM2BIN / SPLIT result with it) plus its signature checks times the
536/// transaction's own size or a floor, whichever is more (a BIP-143 preimage
537/// hashes the prevouts and the outputs; the EC verification costs the same
538/// whatever the transaction weighs). The estimate is computed from the bytes;
539/// nothing executes past the budget, and a budget breach is the DOOR's
540/// verdict (inconclusive: the network judges), never the interpreter's.
541///
542/// THE BODY IS NOT COUNTED (bsv-low #585, door 1). Until #585 the budget also
543/// counted the BODY: 64 unproven transactions, 256 inputs per transaction,
544/// 512 KB per transaction, and the parse's own size and counts. A valid BEEF
545/// is never left unjudged for its size or its counts, so those are gone: the
546/// walk reads the body through the streaming reader ([`crate::script_door`]).
547///
548/// TWO LIMBS, both a budget whose breach is "the network judges" and never a
549/// refusal: the WORK the interpreter will do, and the MEMORY the door will
550/// hold beside the caller's body. Each is estimated from the bytes before it
551/// is spent.
552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
553pub struct DoorBudget {
554    /// The interpreter's stack memory limit: bounds every element, and is the
555    /// unit of the work estimate (a hash-class opcode is charged one element).
556    /// Not a bound on the BEEF: a script that trips it is "the network
557    /// judges", as a breach of the work is.
558    pub memory_limit: usize,
559    /// The whole walk's estimated work, in bytes hashed or pushed.
560    pub max_work_bytes: u64,
561    /// The least a signature check is charged (the doors lens E585-D12-M1).
562    /// A check is charged `max(its transaction's bytes, this)`: the digest
563    /// hashes about the transaction, and the EC verification behind it costs
564    /// the same whatever the transaction weighs (about 95 us native). Charged
565    /// the transaction's bytes alone, a 106 byte `OP_CHECKSIG OP_NOT`
566    /// transaction bought a verification for 106 bytes of budget: 90,000 of
567    /// them walked at a fifth of the budget in 9.6 s. With the floor the
568    /// budget bounds the EC verifications of one walk at
569    /// `max_work_bytes / sig_check_floor`.
570    pub sig_check_floor: u64,
571    /// The whole walk's estimated memory beside the body, in bytes (the doors
572    /// lens E585-D12-L3): what the index, the stream's element in hand and
573    /// the walk's layout of a transaction hold, estimated from the frame's
574    /// lengths and counts by [`crate::stream_sizing::estimate`] BEFORE the
575    /// stream is opened; and, per input, what its two scripts hold once
576    /// parsed and run (the delta lens E585-D12-DELTA-M1: bsv-rs's chunk
577    /// records, their clones, a signature's subscript, the empty stack
578    /// entries the interpreter's memory limit does not count), charged from
579    /// the scripts' chunk count and bytes BEFORE either is parsed
580    /// (`script_door::script_parse_charge`, 512 bytes a chunk and 8 a byte).
581    ///
582    /// How the two compose: the frame's estimate is the most the walk holds
583    /// at any moment apart from the input being run (what is kept, and the
584    /// heaviest transaction in hand, counted once with its raw bytes and the
585    /// interpreter's raw copies of the unlocking script); the scripts' charge
586    /// is the EXPANSION of one input's scripts past those bytes, held while
587    /// that input runs and released after it. An input is run only while the
588    /// frame's estimate plus its own charge is within this limb.
589    pub max_memory_bytes: u64,
590}
591
592impl DoorBudget {
593    /// Sized for LOW's real shapes with a wide margin. What the static census
594    /// BOUNDS: hashing (hash-class ops × the element limit) and signature
595    /// checks (× the transaction's size or the floor, whichever is more; a
596    /// CHECKMULTISIG weighted by its static key count when the count is a
597    /// small-int push, else by the most keys the element limit admits).
598    ///
599    /// THE FLOOR, 64 KiB (half the hash charge), chosen against LOW's shapes
600    /// as the pin `door_low_shapes_sit_under_a_quarter_of_the_work_budget`
601    /// measures them (work, of the 64 MiB; memory, the frame's and the
602    /// largest input's scripts, of the 48 MiB limb; work before the floor in
603    /// brackets):
604    ///
605    /// | shape | inputs | work | memory |
606    /// |---|---|---|---|
607    /// | the real covenant settle (5 hash opcodes, 4 checks: a CHECKSIG and the stated `OP_3` CHECKMULTISIG) | 1 | 924,112, 1.4 % (676,284) | 1,282,178, 2.55 % |
608    /// | the real covenant refund | 1 | 924,112, 1.4 % (676,284) | 1,282,178, 2.55 % |
609    /// | LOW's JOIN: two seats (bsv-low `template_tx_sizes.rs:76`), the real pot lock, each coin 40 unproven P2PKH hops deep (the deepest the fleet's record names) | 82 | 16,132,638, 24.0 % | 67,026, 0.13 % |
610    /// | STRESS, not LOW's shape: nine seats, three hops each | 36 | 7,082,619, 10.6 % (4,740,838) | 36,420, 0.07 % |
611    /// | a coin 30 P2PKH hops deep | 30 | 5,902,188, 8.8 % (3,941,856) | 23,720, 0.05 % |
612    ///
613    /// A P2PKH input is charged 196,739 bytes (a hash opcode at
614    /// the element limit, a check at the floor, its 131 script bytes): 341 of
615    /// them are the budget, 85 a quarter of it. The bound on EC verifications
616    /// per walk is 64 MiB / 64 KiB = 1,024 (1,023 with their script bytes):
617    /// measured natively, 1,000 of them walk in 87 ms in the release profile
618    /// (1.4 to 1.8 s in a debug build), and the lens's 17,000 stop at the
619    /// 1,024th in 108 ms. wasm32 was not measured.
620    ///
621    /// THE MEMORY, 48 MiB: three eighths of a 128 MB isolate. Beside it stand,
622    /// for a 10 MB request (the figure the arithmetic is made at: since NL-6
623    /// the route caps no body, so a heavier one is the isolate's), the
624    /// request's body, the completed BEEF the route hands the door (a second
625    /// copy of up to as much) and the EF batch (the route's 2 MB bound, twice
626    /// while it is serialized): 24 MB, which leaves 56 MB for the module, the
627    /// runtime, the allocator's fragmentation and what a native estimate does
628    /// not see of wasm32. The limb bounds the walk (the door's, and since
629    /// bsv-low #592 the submit's own and the GASP anchor's under
630    /// [`Engine::set_walk_budget`]) and nothing else: the route's own
631    /// hydrated parses before it are bounded by the isolate only (the land
632    /// lens E585-LAND-M1: 1.43 GB natively for a 9.9 MB body of 900,000
633    /// minimal transactions, before the route's 429). What
634    /// it lets through, by the door's charges (`script_door::DOOR_CHARGES`,
635    /// each an upper bound: the pins measure 1.3 to 5.3 times the heap the
636    /// walk reaches): a BUMP of up to 72,944 leaves (690 bytes a level-0
637    /// leaf); a single transaction of up to about 12 MB (4 bytes a byte)
638    /// whose walked inputs' scripts are few chunks (a script is charged 520
639    /// bytes a one-byte opcode and 8 a pushed byte: an input whose two
640    /// scripts are about 96,000 opcodes, or a 1 MB `OP_NOP` lock, is past
641    /// the limb before it is parsed, the network judges; LOW's covenant leg,
642    /// 6,608 script bytes, is charged 1.3 MB); a 10 MB body of one-input,
643    /// one-output P2PKH transactions (191 bytes, charged 570: 30 MB). What it
644    /// stops: 111,848 transactions of no input and no output, whatever
645    /// follows them.
646    ///
647    /// THE OPEN RESIDUAL (the W-A gate's delta-verify, 2026-09-09, measured):
648    /// the interpreter itself meters nothing, so two classes stay charged
649    /// only by their script bytes — bignum arithmetic (`OP_MUL`/`OP_DIV`/
650    /// `OP_MOD` on operands up to the element limit: ~1.3 ms per 30 KB×30 KB
651    /// round, linear in rounds) and `OP_NUM2BIN`, which
652    /// allocates its size operand BEFORE the memory limit is consulted (up to
653    /// bsv-rs's 1 GB element size: an isolate kill, not a refusal). None of
654    /// them can refuse a spend (a breach and a trip are the door's bound and
655    /// the request proceeds); all of them cost the beta overlay CPU until
656    /// bsv-rs meters work dynamically (a `work_limit` charged per op with the
657    /// real operand sizes, and a pre-allocation check in NUM2BIN) — OWED
658    /// before any PROD flip of `SCRIPT_VERIFY_NETWORK_GATED`. Since #585 the
659    /// count of such rounds is bounded by the body alone (the script bytes
660    /// are charged, the transactions and inputs are not counted). A third,
661    /// as before #585 (the doors lens N7): an opcode that copies an element
662    /// (`OP_DUP`, `OP_CAT`, `OP_PICK`) is charged its one script byte and can
663    /// copy up to the element limit. The class the floor CLOSED: a
664    /// CHECKMULTISIG whose key count is a computed value was charged the most
665    /// keys the element limit admits (3,971) at its transaction's bytes and
666    /// run (~80 µs per key tried); at the floor those are 260 MB, past the
667    /// budget, so such an input is never run at the door (the network
668    /// judges). LOW's covenant states its count (`OP_3`).
669    pub const DEFAULT: DoorBudget = DoorBudget {
670        memory_limit: 128 * 1024,
671        max_work_bytes: 64 * 1024 * 1024,
672        sig_check_floor: 64 * 1024,
673        max_memory_bytes: 48 * 1024 * 1024,
674    };
675}
676
677/// Which limb of the [`DoorBudget`] a walk went past.
678#[derive(Debug, Clone, Copy, PartialEq, Eq)]
679pub enum DoorLimb {
680    /// The static work estimate, or the interpreter's own memory limit
681    /// tripping inside one input.
682    Work,
683    /// The estimate of what the door holds beside the body.
684    Memory,
685}
686
687/// What [`Engine::run_validation`] answers: the topics' validations, the
688/// preliminary STEAK, the subject and its txid, and whether the walk could
689/// not run within the budget (bsv-low #592).
690type Validated = (
691    Vec<TopicValidation>,
692    Steak,
693    Transaction,
694    String,
695    Option<WalkCouldNotRun>,
696);
697
698/// Which limb of the [`DoorBudget`] a submit's own walk went past (bsv-low
699/// #592): the [`DoorLimb`] of the door's error, with the interpreter's own
700/// memory limit told apart from the static work estimate.
701#[derive(Debug, Clone, Copy, PartialEq, Eq)]
702pub enum WalkLimb {
703    /// The static work estimate.
704    OverWork,
705    /// The estimate of what the walk holds beside the body.
706    OverMemory,
707    /// The interpreter's own memory limit (the budget's element limit)
708    /// tripping inside one input.
709    InterpreterMemory,
710}
711
712impl WalkLimb {
713    /// The door's word for the same limb ([`DoorLimb`]: the interpreter's
714    /// trip is `Work` there).
715    #[must_use]
716    pub fn door_limb(self) -> DoorLimb {
717        match self {
718            WalkLimb::OverWork | WalkLimb::InterpreterMemory => DoorLimb::Work,
719            WalkLimb::OverMemory => DoorLimb::Memory,
720        }
721    }
722
723    /// The limb's name in a log line and a counter's reason.
724    #[must_use]
725    pub fn as_str(self) -> &'static str {
726        match self {
727            WalkLimb::OverWork => "over_work",
728            WalkLimb::OverMemory => "over_memory",
729            WalkLimb::InterpreterMemory => "interpreter_memory",
730        }
731    }
732}
733
734/// What a submit does when its own script walk could not run within the
735/// engine's [`DoorBudget`] (bsv-low #592; the E592 lens fold, H1, the owner's
736/// ruling 3a read with the lens): a breach is NEVER an admission without the
737/// network's word on record.
738///
739/// The work limb is a static estimate from the bytes, and a LOCK can be made
740/// to pass it cheaply (a bare `OP_CHECKMULTISIG` in a fabricated parent's
741/// lock is charged 3,971 keys). So a breach says nothing about whether the
742/// spend is valid, and on a path where nothing else judges it an admission
743/// would admit a spend nobody checked: the lens's forged spend of a held coin.
744#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
745pub enum WalkBreachPolicy {
746    /// "Not now" ([`EngineError::WalkCouldNotRun`]): nothing judged, nothing
747    /// written, never a final refusal. Every door but one: the ungated
748    /// modes, `/admin/readmit`, the peer crawler, a library consumer's
749    /// submit; the GASP anchor HOLDS its UTXO the same way.
750    #[default]
751    NotNow,
752    /// The network's ACCEPT of these bytes is on record (the queue's replay
753    /// of a gated submission, whose producer broadcast them and marked the
754    /// message): the submit goes on exactly as under `HistoricalTxNoSpv`,
755    /// reported on [`MutationReport::walk_could_not_run`]. The roots the walk
756    /// reached are still checked, and a bad one is still the `SpvError` it is.
757    NetworkAccepted,
758}
759
760/// A submit whose own script walk COULD NOT RUN within the engine's
761/// [`DoorBudget`] (bsv-low #592, the owner's ruling 3a of 2026-10-10). Shaped
762/// like [`EngineError::ScriptWalkOverBudget`]. Under
763/// [`WalkBreachPolicy::NotNow`] it is carried by
764/// [`EngineError::WalkCouldNotRun`] (not now); under
765/// [`WalkBreachPolicy::NetworkAccepted`] the submit went on as under
766/// `HistoricalTxNoSpv` and this says so on its [`MutationReport`]. The
767/// reference (`Engine.submit`, `tx.verify`) has no budget; this is the
768/// platform's addition, on the door's lineage (D8).
769#[derive(Debug, Clone, PartialEq, Eq)]
770pub struct WalkCouldNotRun {
771    /// The submitted transaction.
772    pub subject_txid: String,
773    /// The transaction the walk stopped at.
774    pub at_txid: String,
775    /// Whether the subject's own inputs had all executed before the stop.
776    pub subject_judged: bool,
777    /// The limb it went past.
778    pub limb: WalkLimb,
779    /// The estimate against the limb, in the door's words.
780    pub what: String,
781}
782
783/// What the walk did (the door's cost instrument, since `Date.now()` is
784/// frozen during synchronous work on Workers and cannot time it).
785#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
786pub struct WalkStats {
787    /// Unproven transactions whose inputs were executed.
788    pub unproven_txs: usize,
789    /// Inputs executed (scripts run).
790    pub inputs_executed: usize,
791    /// Unlocking + locking script bytes executed.
792    pub script_bytes: usize,
793    /// Hash-class opcodes seen (SHA1/SHA256/HASH160/HASH256/RIPEMD160).
794    pub hash_ops: usize,
795    /// Signature checks seen (CHECKSIG counts 1, CHECKMULTISIG counts its
796    /// consensus maximum of 20).
797    pub sig_ops: usize,
798    /// The static work estimate charged against the budget.
799    pub work_bytes: u64,
800    /// The memory estimate charged against the budget: the frame's, made
801    /// before the stream was opened, plus the largest charge of one walked
802    /// input's scripts, made before they were parsed.
803    pub memory_bytes: u64,
804    /// Whether the SUBJECT's own inputs were all executed (an ancestor may
805    /// still have faulted afterwards).
806    pub subject_judged: bool,
807}
808
809/// Static opcode census of one input's scripts: (bytes, hash ops, sig ops).
810///
811/// A CHECKMULTISIG is weighted by its KEY COUNT: the small-int push that
812/// precedes it when the script states the count (the Poc5 covenant's
813/// `OP_3`), else the most 33-byte keys the element limit admits (the count is
814/// a computed value the census cannot see; bsv-rs allows up to `i32::MAX`).
815pub(crate) fn script_census(
816    unlocking: &[u8],
817    locking: &[u8],
818    memory_limit: usize,
819) -> Result<(usize, usize, usize), String> {
820    use bsv_rs::script::op::*;
821    let most_keys = (memory_limit / 33).max(1);
822    let mut hash_ops = 0usize;
823    let mut sig_ops = 0usize;
824    for (label, bytes) in [("unlocking", unlocking), ("locking", locking)] {
825        let script = bsv_rs::script::Script::from_binary(bytes)
826            .map_err(|e| format!("{label} script does not parse: {e}"))?;
827        let chunks = script.chunks();
828        for (i, chunk) in chunks.iter().enumerate() {
829            match chunk.op {
830                OP_RIPEMD160 | OP_SHA1 | OP_SHA256 | OP_HASH160 | OP_HASH256 => hash_ops += 1,
831                OP_CHECKSIG | OP_CHECKSIGVERIFY => sig_ops += 1,
832                OP_CHECKMULTISIG | OP_CHECKMULTISIGVERIFY => {
833                    let stated = i
834                        .checked_sub(1)
835                        .map(|j| chunks[j].op)
836                        .and_then(|op| match op {
837                            OP_1..=OP_16 => Some((op - OP_1 + 1) as usize),
838                            _ => None,
839                        });
840                    sig_ops += stated.unwrap_or(most_keys);
841                }
842                _ => {}
843            }
844        }
845    }
846    Ok((unlocking.len() + locking.len(), hash_ops, sig_ops))
847}
848
849impl Engine {
850    /// Page size used by `/requestSyncResponse` when the (public) caller
851    /// omits `limit`. A bounded page is not lossy for a conforming
852    /// initiator: ours ([`crate::gasp::GASPSync`]) keeps requesting pages
853    /// for as long as the `since` score cursor advances (gate finding M1 —
854    /// it must NOT require a "full" page to continue, because this clamp
855    /// makes a full-by-the-initiator's-limit page impossible).
856    pub const SYNC_RESPONSE_DEFAULT_LIMIT: u64 = 500;
857    /// Hard cap on a `/requestSyncResponse` page. Before bsv-low #291 an
858    /// absent/huge caller-supplied limit meant an unbounded scan of the
859    /// outputs table on a public route.
860    pub const SYNC_RESPONSE_MAX_LIMIT: u64 = 1000;
861
862    /// Clamp the public `/requestSyncResponse` page size: absent → the
863    /// default page; anything larger than the cap → the cap. The result is
864    /// ALWAYS `Some`-worthy — a sync response is never unbounded (bsv-low
865    /// #291). Completeness relies on the INITIATOR paging while its `since`
866    /// cursor advances (see `SYNC_RESPONSE_DEFAULT_LIMIT`'s doc) — the
867    /// responder alone cannot page for it.
868    pub fn clamp_sync_limit(requested: Option<u64>) -> u64 {
869        requested
870            .unwrap_or(Self::SYNC_RESPONSE_DEFAULT_LIMIT)
871            .min(Self::SYNC_RESPONSE_MAX_LIMIT)
872    }
873
874    /// Create a new Overlay Services Engine.
875    pub fn new(
876        managers: HashMap<String, Box<dyn TopicManager>>,
877        lookup_services: HashMap<String, Box<dyn LookupService>>,
878        storage: Box<dyn Storage>,
879        advertiser: Option<Box<dyn Advertiser>>,
880        config: EngineConfig,
881    ) -> Self {
882        Self::with_chain_tracker(
883            managers,
884            lookup_services,
885            storage,
886            advertiser,
887            None,
888            None,
889            config,
890        )
891    }
892
893    /// Create a new Engine with an optional ChainTracker for SPV verification.
894    pub fn with_chain_tracker(
895        managers: HashMap<String, Box<dyn TopicManager>>,
896        lookup_services: HashMap<String, Box<dyn LookupService>>,
897        storage: Box<dyn Storage>,
898        advertiser: Option<Box<dyn Advertiser>>,
899        broadcaster: Option<Box<dyn Broadcaster>>,
900        chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
901        config: EngineConfig,
902    ) -> Self {
903        Self::with_all(
904            managers,
905            lookup_services,
906            storage,
907            advertiser,
908            broadcaster,
909            None,
910            chain_tracker,
911            config,
912        )
913    }
914
915    /// Create a new Engine with all optional components.
916    #[allow(clippy::too_many_arguments)]
917    pub fn with_all(
918        managers: HashMap<String, Box<dyn TopicManager>>,
919        lookup_services: HashMap<String, Box<dyn LookupService>>,
920        storage: Box<dyn Storage>,
921        advertiser: Option<Box<dyn Advertiser>>,
922        broadcaster: Option<Box<dyn Broadcaster>>,
923        arc_broadcaster: Option<Box<dyn ArcBroadcaster>>,
924        chain_tracker: Option<Box<dyn bsv_rs::transaction::ChainTracker>>,
925        mut config: EngineConfig,
926    ) -> Self {
927        // Build default sync configuration: SHIP for all topics except tm_ship/tm_slap
928        // which get their trackers merged with provided shipTrackers/slapTrackers.
929        for manager_name in managers.keys() {
930            if manager_name == "tm_ship" {
931                if matches!(
932                    config.sync_configuration.get(manager_name),
933                    Some(SyncTarget::Disabled)
934                ) {
935                    continue;
936                }
937                let mut combined: HashSet<String> = HashSet::new();
938                if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
939                {
940                    combined.extend(peers.iter().cloned());
941                }
942                combined.extend(config.ship_trackers.iter().cloned());
943                if !combined.is_empty() {
944                    config.sync_configuration.insert(
945                        manager_name.clone(),
946                        SyncTarget::Peers(combined.into_iter().collect()),
947                    );
948                }
949            } else if manager_name == "tm_slap" {
950                if matches!(
951                    config.sync_configuration.get(manager_name),
952                    Some(SyncTarget::Disabled)
953                ) {
954                    continue;
955                }
956                let mut combined: HashSet<String> = HashSet::new();
957                if let Some(SyncTarget::Peers(peers)) = config.sync_configuration.get(manager_name)
958                {
959                    combined.extend(peers.iter().cloned());
960                }
961                combined.extend(config.slap_trackers.iter().cloned());
962                if !combined.is_empty() {
963                    config.sync_configuration.insert(
964                        manager_name.clone(),
965                        SyncTarget::Peers(combined.into_iter().collect()),
966                    );
967                }
968            } else if !config.sync_configuration.contains_key(manager_name) {
969                config
970                    .sync_configuration
971                    .insert(manager_name.clone(), SyncTarget::Ship);
972            }
973        }
974
975        Self {
976            managers,
977            lookup_services,
978            storage: Box::new(crate::load_storage::MeteredStorage(storage)),
979            advertiser,
980            broadcaster,
981            arc_broadcaster,
982            chain_tracker,
983            gasp_remote_factory: None,
984            ship_lookup_client: None,
985            ship_bootstrap_peers: HashMap::new(),
986            ancestor_fetcher: None,
987            gasp_ancestor_fetcher: None,
988            peer_sync_budget: None,
989            graph_budget: None,
990            graph_budget_limbs: (
991                crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES,
992                crate::gasp::DEFAULT_GRAPH_BUDGET_NODES,
993            ),
994            finalize_submit_budget: None,
995            finalize_gate: crate::gasp::SubmitGate::default(),
996            not_landed: std::cell::RefCell::new(HashSet::new()),
997            landing_guard: None,
998            verify_scripts: true,
999            walk_budget: DoorBudget::DEFAULT,
1000            config,
1001        }
1002    }
1003
1004    /// Set the ARC broadcaster for network broadcast to miners.
1005    ///
1006    /// When set, the Engine broadcasts transactions to ARC during Phase 2
1007    /// of `submit()` for `CurrentTx` submissions.
1008    pub fn set_arc_broadcaster(&mut self, arc: Box<dyn ArcBroadcaster>) {
1009        self.arc_broadcaster = Some(arc);
1010    }
1011
1012    /// Set the GASP remote factory for peer communication during sync.
1013    ///
1014    /// Platform-specific crates (like overlay-cloudflare) provide an implementation
1015    /// that creates HTTP-based remotes using their native fetch API.
1016    pub fn set_gasp_remote_factory(&mut self, factory: Box<dyn crate::gasp::GASPRemoteFactory>) {
1017        self.gasp_remote_factory = Some(factory);
1018    }
1019
1020    /// Set the platform lookup transport for network SHIP discovery.
1021    /// Trackers come from [`EngineConfig::slap_trackers`].
1022    pub fn set_ship_lookup_client(
1023        &mut self,
1024        client: Box<dyn crate::ship_lookup::ShipLookupClient>,
1025    ) {
1026        self.ship_lookup_client = Some(client);
1027    }
1028
1029    /// Keep these peers in a topic's SHIP union even when trackers fail.
1030    /// This leaves the existing [`SyncTarget`] serialization unchanged.
1031    /// Such topics run ahead of tracker imports so discovery and sync can
1032    /// proceed without spending the tick on importing discovery records.
1033    pub fn set_ship_bootstrap_peers(&mut self, topic: impl Into<String>, peers: Vec<String>) {
1034        self.ship_bootstrap_peers.insert(topic.into(), peers);
1035    }
1036
1037    /// Set the OPT-IN chain-backed ancestor fetcher for GASP ingest.
1038    ///
1039    /// **OFF BY DEFAULT.** When set, a peer's failure to serve a needed
1040    /// ancestor during GASP ingest falls back to fetching that ancestor's raw
1041    /// tx from chain (via the supplied fetcher), self-healing the graph and
1042    /// also enabling strict-BEEF finalize so missing ancestry fails loud.
1043    ///
1044    /// This is a deliberate one-time-migration escape hatch. Production must
1045    /// NOT call this — when unset, GASP ingest behavior is unchanged.
1046    pub fn set_ancestor_fetcher(&mut self, fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>) {
1047        self.ancestor_fetcher = Some(fetcher);
1048    }
1049
1050    /// Supply a courier reserved for sync. Completion retains its own budget.
1051    pub fn set_gasp_ancestor_fetcher(
1052        &mut self,
1053        fetcher: std::rc::Rc<dyn crate::gasp::AncestorFetcher>,
1054    ) {
1055        self.gasp_ancestor_fetcher = Some(fetcher);
1056    }
1057
1058    /// Set the admission predicate asked BEFORE the door lands a carried
1059    /// predecessor (bsv-low #575, the E1D delta-2 fold, L2;
1060    /// [`Engine::land_carried`]). It is asked once per body the landing
1061    /// would submit, before that body's submit (a dry one included) and
1062    /// before its reads are charged; an `Err` (the caller's refusal, or its
1063    /// own read fault) ends the landing with nothing of that body written
1064    /// and the successor's topic answers "not now", as on `683dffd`. The
1065    /// worker installs its eviction ledger here: an OPEN eviction of the
1066    /// body refuses it. Its read is not one of the submit's 256 (it is the
1067    /// caller's store, at most one per landed body). Unset (default): every
1068    /// carried predecessor may land. What the predicate cannot see (a row
1069    /// opened while the landing writes) stays the caller's to guard after
1070    /// the write ([`MutationReport::landed_predecessors`]).
1071    pub fn set_landing_guard(&mut self, guard: LandingGuard) {
1072        self.landing_guard = Some(guard);
1073    }
1074
1075    /// Set the per-peer GASP sync budget (bsv-low#302).
1076    ///
1077    /// `sleep` is the platform sleep factory (ms → future); `budget_ms` is
1078    /// the wall-clock slice ONE peer's sync may consume. A peer exceeding it
1079    /// is dropped (loudly logged, listed in the topic's `errors`) and the
1080    /// loop moves on. Unset (default) = unbounded, the pre-#302 behavior.
1081    ///
1082    /// **Progress survives the deadline (bsv-low #552).** With a budget set,
1083    /// every graph is submitted AS IT FINALIZES, inside the raced future (the
1084    /// reference submits inside `finalizeGraph` too), so what was finalized
1085    /// before the deadline stays admitted. At the deadline the cursor is
1086    /// advanced to `GASPSync::completed_cursor`: past the UTXOs whose graphs
1087    /// were completed, never past the one in flight. That graph is lost
1088    /// whole (nothing of it was finalized) and the next tick walks it again,
1089    /// down to whatever is now in storage.
1090    ///
1091    /// **The deadline never lands inside a transaction's writes** (the lens
1092    /// fold's HIGH-1). It is cooperative around a finalize submit
1093    /// ([`Engine::finalize_submit_gate`]): a deadline that falls due while
1094    /// one transaction is being written waits for that transaction, and the
1095    /// sync is dropped at the boundary. What a dropped tick leaves of the
1096    /// graph being submitted is an ancestors-first prefix of WHOLE
1097    /// transactions, with the cursor below its UTXO. Everywhere else (a
1098    /// request to the peer, the walk, the anchor check) the deadline drops
1099    /// the sync at once, as a budget against a dead peer must. A dropped tick that finalized a
1100    /// graph or moved the cursor is recorded as a SUCCESSFUL attempt for the
1101    /// quarantine count: a peer serving a long bootstrap is not a dead peer.
1102    /// One with no progress is a failed attempt, as before.
1103    ///
1104    /// The budget bounds the work of a TICK. Without a per-graph budget
1105    /// nothing bounds a GRAPH (parity: the reference has no node cap): one
1106    /// graph whose own walk outlasts the budget is dropped on every tick
1107    /// (`deadline_dropped_graphs`) and never admitted. With one
1108    /// ([`Engine::set_graph_budget`], bsv-low #555) that walk is deferred
1109    /// with its progress kept, here too, and resumed next tick.
1110    pub fn set_peer_sync_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1111        self.peer_sync_budget = Some((sleep, budget_ms));
1112    }
1113
1114    /// Set the per-GRAPH GASP budget and turn DEFERRAL on (bsv-low #555).
1115    ///
1116    /// A graph whose walk makes `max_calls` requests (to the peer, or chain
1117    /// fetches) or runs `budget_ms` in one pass is DEFERRED: its partial walk
1118    /// (the nodes fetched with their proofs, the inputs still pending, the
1119    /// calls spent, its age in passes) is saved as ONE record of the storage
1120    /// ([`Storage::put_deferred_graph`], replaced on every deferral), its
1121    /// UTXO is held below the cursor as a failed one is (the gap guard) and
1122    /// not walked again in the sync (#554), and the sync goes on to the next
1123    /// UTXO and topic. The next sync that is served that UTXO RESUMES the
1124    /// walk from the record, asking only what is pending, under the same
1125    /// budget; a graph so converges over passes and is then completed as
1126    /// any graph is (#551's anchor check, the finalize submits, e1d's
1127    /// rules). A walk cut by the per-peer deadline is deferred the same way.
1128    ///
1129    /// Bounds: a record deferred [`crate::gasp::DEFERRED_GRAPH_MAX_PASSES`]
1130    /// times is dropped with its reason and the UTXO fails as before (the
1131    /// gap guard asks again, from the root); a record has no byte bound
1132    /// (bsv-low #585); at most
1133    /// [`crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC`] records per (peer,
1134    /// topic). Keep `budget_ms` below the per-peer budget so that a deep
1135    /// graph leaves the pass time for the UTXOs after it. Defaults offered:
1136    /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_CALLS`],
1137    /// [`crate::gasp::DEFAULT_GRAPH_BUDGET_MS`].
1138    ///
1139    /// Every resume of one sync shares ONE such budget (the lens fold's M1),
1140    /// so the deferred UTXOs, served first, leave the pass the rest; a walk
1141    /// that fetched nothing keeps no record and is a failed attempt for the
1142    /// quarantine (H1); a walk that cannot be kept, or whose save faulted,
1143    /// goes on under the per-peer budget alone (L4, the delta fold's D-L2);
1144    /// a FRESH walk's error keeps no record (D-M1). With a budget, a sync the
1145    /// peer served work that finalized no graph and moved no cursor is
1146    /// YIELDLESS, and past [`crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED`] in a
1147    /// row AND [`crate::gasp::PEER_YIELDLESS_SECS_ALLOWED`] since the streak
1148    /// began each is a failed attempt (D-M2, the delta-2 fold's D2-M1; the
1149    /// streak is kept by [`Storage::record_peer_sync_yield`], keyed on the
1150    /// peer's [`crate::gasp::peer_origin`] as its quarantine is). A FRESH
1151    /// walk that faults after half its calls or half its time keeps its
1152    /// record (D2-L1); a RESUMED one that faults having appended nothing
1153    /// [`crate::gasp::DEFERRED_GRAPH_MAX_IDLE_FAULTS`] passes in a row is
1154    /// dropped (D2-M2). Each record says whether its peer is configured
1155    /// (`SyncTarget::Peers`), for a storage that reserves part of its
1156    /// ceiling for those (the worker reserves half).
1157    ///
1158    /// Unset (the default) nothing is deferred and the walk is the one
1159    /// before #555 (parity: the reference has no budget and no deferral).
1160    /// The storage must KEEP records (the four required deferred-graph
1161    /// methods of [`Storage`]): over one that refuses every save, every
1162    /// graph past the budget is walked from its root under the per-peer
1163    /// budget alone on every pass, as before #555, so do not set a budget
1164    /// there.
1165    ///
1166    /// The budget has two more limbs (bsv-low #586), in force with it at
1167    /// their defaults: the bytes one graph may be SERVED in one pass
1168    /// ([`crate::gasp::DEFAULT_GRAPH_BUDGET_BYTES`], a budget per pass only:
1169    /// a record has no byte bound since bsv-low #585 door 4) and the nodes it
1170    /// may APPEND ([`crate::gasp::DEFAULT_GRAPH_BUDGET_NODES`], 64);
1171    /// [`Engine::set_graph_budget_limbs`] names others.
1172    pub fn set_graph_budget(&mut self, sleep: SleepFactory, max_calls: u32, budget_ms: u64) {
1173        self.graph_budget = Some((sleep, max_calls, budget_ms));
1174    }
1175
1176    /// The bytes and nodes limbs of the per-graph budget (bsv-low #586,
1177    /// zanaadu-v2 #377): a graph whose walk is SERVED `max_bytes_fetched`
1178    /// bytes in one pass (the hex of each node's raw transaction and proof,
1179    /// as the peer sends it and a record keeps it, every answer counted), or
1180    /// APPENDS `max_nodes` nodes, is DEFERRED exactly as at the call budget
1181    /// (reason `bytes` or `nodes`): its record saved, its UTXO held below
1182    /// the cursor, the walk resumed by the next pass from what is pending.
1183    /// Never a drop, a refusal or a discarded graph: these are budgets PER
1184    /// PASS, not limits on a graph or a BEEF. A pass always makes one
1185    /// request whatever a node's size; the resumes of one sync share one
1186    /// such budget, as they share its calls (M1); a walk that cannot be
1187    /// KEPT goes on under the per-peer budget alone (L4), as at the calls.
1188    /// They bound what one pass adds to the pending graph and to its record
1189    /// while the anchor check and the finalize hold it in memory. No effect
1190    /// until [`Engine::set_graph_budget`] turns deferral on; the reference
1191    /// has neither. This setter is UNCAPPED, as the worker's
1192    /// `GASP_GRAPH_BUDGET_BYTES` var is (bsv-low #585 door 4: the engine keeps
1193    /// no record cap for the limb to sit under). A consumer whose STORAGE caps
1194    /// a record must keep the bytes limb under that cap, or a pass whose
1195    /// record cannot be saved walks afresh (E586-L1).
1196    pub fn set_graph_budget_limbs(&mut self, max_bytes_fetched: u64, max_nodes: u32) {
1197        self.graph_budget_limbs = (max_bytes_fetched, max_nodes);
1198    }
1199
1200    /// Bound ONE transaction's GASP finalize submit (bsv-low #559, the delta
1201    /// lens's DELTA-3). That submit is the write section no deadline drops
1202    /// ([`Engine::finalize_submit_gate`]), so a storage call or lookup hook
1203    /// that never answers inside it held the per-peer budget and any outer
1204    /// race for as long as it hung. With this set EACH storage call, lookup
1205    /// hook and topic manager call of that submit is raced against one
1206    /// `sleep(budget_ms)` of the section (the lens fold of 2026-10-07, F2):
1207    /// a call still unanswered when it falls due is dropped alone and is
1208    /// that call's FAULT, and no call is started after it. The SUBMIT is
1209    /// never dropped: it runs to its end and leaves what a fault leaves
1210    /// ([`Engine::submit_with_report`]: nothing of the transaction, or all
1211    /// of it), the transaction did not land, the rest of its graph is not
1212    /// submitted, its UTXO fails and the cursor stays below it. The undo of
1213    /// a faulted submit has one more `sleep(budget_ms)` of its own, so a
1214    /// peer costs at most its budget plus twice this one. What the backend
1215    /// does with a statement whose caller stopped waiting is its own: a
1216    /// write that lands after its timeout is not seen by this submit.
1217    ///
1218    /// DEFAULT: none (a native engine has no clock). A caller that sets a
1219    /// per-peer budget should set this too.
1220    pub fn set_finalize_submit_budget(&mut self, sleep: SleepFactory, budget_ms: u64) {
1221        self.finalize_submit_budget = Some((sleep, budget_ms));
1222    }
1223
1224    /// The gate around every GASP finalize submit (bsv-low #552, the lens
1225    /// fold's HIGH-1). `start_gasp_sync` races each peer against its budget
1226    /// with [`crate::gasp::race_or_deadline_guarded`] over this gate. A
1227    /// caller that races `start_gasp_sync` ITSELF against a deadline (the
1228    /// worker's scheduled step) must use the same function over the same
1229    /// gate: a plain `race_or_deadline` there can still drop a finalize
1230    /// submit between its writes.
1231    pub fn finalize_submit_gate(&self) -> &crate::gasp::SubmitGate {
1232        &self.finalize_gate
1233    }
1234
1235    /// Turn reference-parity script verification on submit on or off.
1236    /// **DEFAULT ON** (set in every constructor).
1237    ///
1238    /// On, every submit outside `HistoricalTxNoSpv` executes every unproven
1239    /// input's unlocking script against its source output (and checks every
1240    /// merkle path against the chain tracker), exactly as the reference's
1241    /// `Engine.submit` → `tx.verify(this.chainTracker)` does. Off is an
1242    /// ESCAPE HATCH for an operator who must admit a body the interpreter
1243    /// refuses (a bsv-rs interpreter defect, say) while it is fixed. It is
1244    /// NOT a mode: it restores the pre-2026-09-08 structural check, under
1245    /// which an invalid spend that no broadcaster had yet refused was
1246    /// admitted on BEEF structure alone. Prefer
1247    /// [`crate::builder::EngineBuilder::with_script_verification`].
1248    pub fn set_script_verification(&mut self, enabled: bool) {
1249        self.verify_scripts = enabled;
1250    }
1251
1252    /// Whether submits execute input scripts, see
1253    /// [`Engine::set_script_verification`]. `true` by default.
1254    pub fn script_verification(&self) -> bool {
1255        self.verify_scripts
1256    }
1257
1258    /// The budget of the submit's own script walk (bsv-low #592, the owner's
1259    /// ruling 3a of 2026-10-10). Every submit that walks (every mode but
1260    /// `HistoricalTxNoSpv`), and the GASP anchor check, read the BEEF through
1261    /// the door's stream walk ([`crate::script_door`]) under this budget's
1262    /// two limbs, the work and the memory, each estimated from the bytes
1263    /// before it is spent. A breach is "the walk could not run"
1264    /// ([`WalkCouldNotRun`], on [`MutationReport::walk_could_not_run`]),
1265    /// never a refusal: the submit goes on as under `HistoricalTxNoSpv`.
1266    /// A library consumer that sets nothing gets [`DoorBudget::DEFAULT`]
1267    /// (64 MiB of work, 48 MiB of memory). Prefer
1268    /// [`crate::builder::EngineBuilder::with_walk_budget`].
1269    pub fn set_walk_budget(&mut self, budget: DoorBudget) {
1270        self.walk_budget = budget;
1271    }
1272
1273    /// The budget of the submit's own script walk, see
1274    /// [`Engine::set_walk_budget`].
1275    pub fn walk_budget(&self) -> DoorBudget {
1276        self.walk_budget
1277    }
1278
1279    /// The reference's `tx.verify('scripts only')` over a BEEF: every unproven
1280    /// transaction from `subject_txid` down executes every input's unlocking
1281    /// script against its source output and obeys the value rule, while a
1282    /// transaction that carries a merkle path is TRUSTED AS-IS — no root is
1283    /// computed and no chain tracker is consulted (the ts-sdk's own shape when
1284    /// `chainTracker === 'scripts only'`: a proven tx is added to the verified
1285    /// set and the walk stops there).
1286    ///
1287    /// Built for an admission path whose bar is the NETWORK, not a proof
1288    /// (bsv-low's `broadcast-gated`, register row D1): the overlay broadcasts
1289    /// and admits on network evidence, so a lagging tracker must never read as
1290    /// a refused transaction — but a spend the interpreter refuses is one the
1291    /// network will refuse too, and executing it at the door saves the
1292    /// broadcast and names the fault. Independent of
1293    /// [`Engine::set_script_verification`] (that switch governs `submit`; this
1294    /// is an explicit ask) and of [`Engine::submit`]'s mode (`HistoricalTxNoSpv`
1295    /// still skips, as the reference does).
1296    ///
1297    /// Runs under the engine's budget ([`Engine::set_walk_budget`], the
1298    /// submit's own; [`DoorBudget::DEFAULT`] unless set: one budget per
1299    /// engine, the E592 lens fold's NOTE-2): the work is bounded statically
1300    /// before anything executes, and the interpreter's stack memory limit is
1301    /// the budget's element limit. The body is read through the streaming
1302    /// reader ([`crate::script_door`], bsv-low #585): no bound on its size,
1303    /// its transactions or a transaction's inputs or bytes; the budget's two
1304    /// limbs are the work and the memory beside the body.
1305    ///
1306    /// Errors: [`EngineError::ScriptVerificationFailed`] is the INTERPRETER's
1307    /// verdict and the only refusal; [`EngineError::ScriptWalkInconclusive`]
1308    /// is a structural fault (a transaction or source missing from the BEEF,
1309    /// a parse fault, the value rule) and [`EngineError::ScriptWalkOverBudget`]
1310    /// the door's own bound (the static budget or the memory limit) — both
1311    /// name whether the SUBJECT was judged before the fault, and a caller with
1312    /// a stronger bar behind it does not refuse on either.
1313    #[allow(
1314        clippy::unused_async,
1315        reason = "the door's callers await it on an engine; 'scripts only' asks no tracker"
1316    )]
1317    pub async fn verify_scripts_only(
1318        &self,
1319        beef_bytes: &[u8],
1320        subject_txid: &str,
1321    ) -> Result<WalkStats, EngineError> {
1322        crate::script_door::walk(beef_bytes, subject_txid, self.walk_budget)
1323    }
1324
1325    /// [`Engine::verify_scripts_only`] under a budget the caller names (the
1326    /// pins measure the door with a limb lifted; a caller with another
1327    /// platform's ceiling sets its own).
1328    #[allow(
1329        clippy::unused_async,
1330        clippy::unused_self,
1331        reason = "the twin of `verify_scripts_only`"
1332    )]
1333    pub async fn verify_scripts_only_under(
1334        &self,
1335        beef_bytes: &[u8],
1336        subject_txid: &str,
1337        budget: DoorBudget,
1338    ) -> Result<WalkStats, EngineError> {
1339        crate::script_door::walk(beef_bytes, subject_txid, budget)
1340    }
1341
1342    // ========================================================================
1343    // Submit — 3-phase pipeline
1344    // ========================================================================
1345
1346    /// Validate a tagged BEEF without applying mutations.
1347    ///
1348    /// Runs Phase 1 (topic validation) and Phase 2 (broadcast) but NOT Phase 3
1349    /// (storage mutations). Returns the Steak (admittance decisions) that
1350    /// `submit()` would return.
1351    ///
1352    /// Used by the onSteakReady pattern to return results to clients before
1353    /// mutations are applied via a queue consumer or `ctx.wait_until()`.
1354    ///
1355    /// The topic managers are called with `dry_run: true`
1356    /// (`TopicAdmittanceContext::DRY_RUN`): nothing is admitted on this call, so a
1357    /// manager that writes on admission leaves no trace of it.
1358    pub async fn submit_validate_only(
1359        &self,
1360        tagged_beef: &TaggedBEEF,
1361        mode: SubmitMode,
1362    ) -> Result<Steak, EngineError> {
1363        // A validate-only call admits nothing: a dry run by its own name. A
1364        // manager that writes on admission must not write here, or the real
1365        // submit that follows meets a head this call already advanced.
1366        let (_validations, steak, _tx, _txid, _could_not_run) = self
1367            .run_validation(
1368                tagged_beef,
1369                mode,
1370                &TopicAdmittanceContext::DRY_RUN,
1371                None,
1372                None,
1373                WalkBreachPolicy::NotNow,
1374            )
1375            .await?;
1376        Ok(steak)
1377    }
1378
1379    /// Submit a transaction for processing by the overlay.
1380    ///
1381    /// Three phases:
1382    /// 1. **VALIDATE** — check topics, dedup, find previous coins, call topic managers
1383    /// 2. **BROADCAST** — broadcast to network (if mode is CurrentTx)
1384    /// 3. **MUTATE** — mark spent, delete stale, insert new outputs, notify lookup services
1385    ///
1386    /// Returns a STEAK mapping each topic to its admittance instructions.
1387    ///
1388    /// Phase-3 faults are reported through [`Engine::submit_with_report`];
1389    /// this convenience keeps the historical signature and only LOGS the
1390    /// report's summary. A caller that must not ack an admission whose
1391    /// writes did not land (the `/submit` route — S2) uses the report form.
1392    pub async fn submit(
1393        &self,
1394        tagged_beef: &TaggedBEEF,
1395        mode: SubmitMode,
1396    ) -> Result<Steak, EngineError> {
1397        let (steak, report) = self.submit_with_report(tagged_beef, mode).await?;
1398        if !report.is_durable() {
1399            warn!(
1400                "submit: {} mutation fault(s) — not durable: {}",
1401                report.faults.len(),
1402                report.summary()
1403            );
1404        }
1405        Ok(steak)
1406    }
1407
1408    /// [`Engine::submit`] plus the Phase-3 durability report (S2
1409    /// queue-durable admission, bsv-low 2026-08-29).
1410    ///
1411    /// The STEAK is the admission DECISION; the report says whether the
1412    /// decision was DURABLY WRITTEN. They are returned together because
1413    /// they are answered by different things: Phase 1+2 decide, Phase 3
1414    /// lands, and only the caller knows whether an undurable landing may
1415    /// be acked (never, on the money path — re-queue it instead).
1416    ///
1417    /// Every write failure is still logged AND still non-fatal to the
1418    /// other topics (a faulted topic does not un-admit its siblings); what
1419    /// changed is that the failure is also VISIBLE, and that the faulted
1420    /// topic is not recorded as applied — so a replay of the same bytes is
1421    /// re-validated and re-written (every backend write is idempotent:
1422    /// `INSERT OR IGNORE` / `OR REPLACE` / `UPDATE`).
1423    ///
1424    /// What a fault leaves (bsv-low #559 and its lens fold of 2026-10-07):
1425    /// a topic's previous coins are deleted once EVERY admitted output of
1426    /// the transaction is inserted, and only then. A fault before that
1427    /// (a validation read, the spent mark of a coin the manager retains, an
1428    /// insert) leaves the previous coins held and nothing of the
1429    /// transaction: what did get inserted is taken out again, so the replay
1430    /// is the same judgement and no successor can spend half of it. A fault
1431    /// after it (a lookup notification, the consumed-by update, the applied
1432    /// row) leaves the transaction's outputs and no previous coin. A fault
1433    /// before any delete was started (the record of the spent coin's
1434    /// transaction, the read of the coin) is the first case if the coins
1435    /// are read back still held; once a delete was started its fault never
1436    /// undoes, answered or not (it may land yet): the outputs stay beside
1437    /// the stale coin, marked spent. A submit that deletes a stale coin
1438    /// first records that coin's transaction as applied, so a replay of it
1439    /// after its successor is a dupe. So a non-retaining chain never has
1440    /// two unspent heads and never none, whatever single call faults. The
1441    /// applied row is withheld on ANY fault. And a successor that was
1442    /// judged without the coin an unlanded predecessor has yet to leave is
1443    /// reported as a fault (`predecessor_not_landed`), not recorded as
1444    /// applied.
1445    ///
1446    /// A walk that could not run within the engine's budget is "not now"
1447    /// here ([`WalkBreachPolicy::NotNow`], [`EngineError::WalkCouldNotRun`]):
1448    /// never an admission. [`Engine::submit_with_report_under`] names the
1449    /// policy.
1450    pub async fn submit_with_report(
1451        &self,
1452        tagged_beef: &TaggedBEEF,
1453        mode: SubmitMode,
1454    ) -> Result<(Steak, MutationReport), EngineError> {
1455        self.submit_with_report_under(tagged_beef, mode, WalkBreachPolicy::NotNow)
1456            .await
1457    }
1458
1459    /// [`Engine::submit_with_report`] under an explicit
1460    /// [`WalkBreachPolicy`] (the E592 lens fold, H1): what a walk that could
1461    /// not run within the budget does. `NetworkAccepted` is for a caller that
1462    /// holds the network's accept of these very bytes (the queue's replay of
1463    /// a gated submission) and for nothing else: it admits a breached body
1464    /// with its scripts unrun.
1465    pub async fn submit_with_report_under(
1466        &self,
1467        tagged_beef: &TaggedBEEF,
1468        mode: SubmitMode,
1469        policy: WalkBreachPolicy,
1470    ) -> Result<(Steak, MutationReport), EngineError> {
1471        self.submit_bounded(tagged_beef, mode, None, false, policy)
1472            .await
1473    }
1474
1475    /// [`Engine::submit_with_report`] with every storage call, lookup hook
1476    /// and topic manager call under `bound` (the GASP finalize submit,
1477    /// [`CallBound`]); `None` is the unbounded submit of every other door.
1478    /// `finalize` is `true` for the GASP finalize submit alone, bounded or
1479    /// not: it does not ask the store's predecessor question.
1480    async fn submit_bounded(
1481        &self,
1482        tagged_beef: &TaggedBEEF,
1483        mode: SubmitMode,
1484        bound: Option<&CallBound>,
1485        finalize: bool,
1486        policy: WalkBreachPolicy,
1487    ) -> Result<(Steak, MutationReport), EngineError> {
1488        // The reads the store's predecessor question has made in this
1489        // submit, every topic together ([`PREDECESSOR_READS_PER_SUBMIT`]),
1490        // the predecessors the door lands first from the BEEF included.
1491        let mut question_reads = 0usize;
1492        self.submit_counted(
1493            tagged_beef,
1494            mode,
1495            bound,
1496            finalize,
1497            None,
1498            &mut question_reads,
1499            policy,
1500        )
1501        .await
1502        .map(|(steak, report, _)| (steak, report))
1503    }
1504
1505    /// [`Engine::submit_bounded`] over the submit's read allowance
1506    /// (`question_reads`), which a predecessor the door lands first from the
1507    /// BEEF shares with the successor that carried it (lane E1D's lens fold,
1508    /// M1). `carried` is `Some` for such a predecessor's own submit
1509    /// ([`Engine::land_carried`]), holding the bodies whose SPV walk already
1510    /// passed in this submit (none is walked twice; the delta fold, L4): it
1511    /// lands nothing first itself, and it names instead the carried
1512    /// predecessor that blocks each topic (the third value), so the landing
1513    /// is an explicit stack and never a recursion of submits (nested polls
1514    /// grow the stack, and a Worker's is small). Its manager is asked as a
1515    /// DRY RUN first, and for real only once its topic is not blocked: a
1516    /// link blocked below is judged twice and admitted once. Boxed: the
1517    /// landing is a submit of its own.
1518    #[allow(clippy::too_many_arguments)]
1519    fn submit_counted<'a>(
1520        &'a self,
1521        tagged_beef: &'a TaggedBEEF,
1522        mode: SubmitMode,
1523        bound: Option<&'a CallBound>,
1524        finalize: bool,
1525        carried: Option<&'a HashSet<String>>,
1526        question_reads: &'a mut usize,
1527        policy: WalkBreachPolicy,
1528    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Counted, EngineError>> + 'a>>
1529    {
1530        Box::pin(async move {
1531            self.submit_counted_inner(
1532                tagged_beef,
1533                mode,
1534                bound,
1535                finalize,
1536                carried,
1537                question_reads,
1538                policy,
1539            )
1540            .await
1541        })
1542    }
1543
1544    #[allow(clippy::too_many_arguments)]
1545    async fn submit_counted_inner(
1546        &self,
1547        tagged_beef: &TaggedBEEF,
1548        mode: SubmitMode,
1549        bound: Option<&CallBound>,
1550        finalize: bool,
1551        walked: Option<&HashSet<String>>,
1552        question_reads: &mut usize,
1553        policy: WalkBreachPolicy,
1554    ) -> Result<Counted, EngineError> {
1555        // A submit is a real admission, never a dry run; a carried
1556        // predecessor's is judged dry until its topic is known not to wait.
1557        let carried = walked.is_some();
1558        let context = if carried {
1559            TopicAdmittanceContext::DRY_RUN
1560        } else {
1561            TopicAdmittanceContext::default()
1562        };
1563        let (mut validations, mut steak, tx, txid, could_not_run) = self
1564            .run_validation(tagged_beef, mode, &context, bound, walked, policy)
1565            .await?;
1566        if finalize {
1567            let now = (current_timestamp_ms() / 1000.0) as u64;
1568            for validation in &mut validations {
1569                let manager = &self.managers[&validation.topic];
1570                validation.admittance.outputs_to_admit.retain(|index| {
1571                    tx.outputs.get(*index as usize).is_none_or(|output| {
1572                        manager
1573                            .sync_expiry(&output.locking_script)
1574                            .is_none_or(|expiry| expiry > now)
1575                    })
1576                });
1577                steak.insert(validation.topic.clone(), validation.admittance.clone());
1578            }
1579        }
1580        let mut report = MutationReport {
1581            walk_could_not_run: could_not_run,
1582            ..MutationReport::default()
1583        };
1584        // The body every LOOKUP SERVICE receives NAMES the subject (BRC-95
1585        // atomic prefix over the WHOLE submitted body — bsv-rs's
1586        // `to_binary_atomic` sorts, names, never prunes). Lookup services
1587        // re-parse it with `from_beef(_, None)`; on a plain incomplete body
1588        // that pick is the wire-last ANCESTOR, so the pot index's admit hook
1589        // shape-checked a hop and wrote no record while the engine had
1590        // admitted the pot (loop-2 fleet, 2026-09-05; the live proof after
1591        // the engine's own subject fix). A body that cannot be re-serialized
1592        // (never, for a parsed subject) is handed through as submitted.
1593        let subject_named_beef: Vec<u8> =
1594            beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
1595                .ok()
1596                .and_then(|mut b| b.to_binary_atomic(&txid).ok())
1597                .unwrap_or_else(|| tagged_beef.beef.clone());
1598
1599        // The successor rule, asked of every topic that found no coin before
1600        // anything of this submit is written ([`Engine::successors_waiting`]):
1601        // the topics that must wait, each with why.
1602        let mut blocked: HashMap<String, String> = HashMap::new();
1603        // The landing trusts the subject's walk only if it RAN (the E592
1604        // lens fold, M1): after a breach nothing it reaches was walked.
1605        let landing = Landing {
1606            policy,
1607            subject_walk_ran: report.walk_could_not_run.is_none(),
1608        };
1609        let waits = self
1610            .successors_waiting(
1611                &tx,
1612                &txid,
1613                &subject_named_beef,
1614                &mut validations,
1615                &mut steak,
1616                tagged_beef.off_chain_values.as_deref(),
1617                mode,
1618                bound,
1619                finalize,
1620                !carried,
1621                &mut blocked,
1622                question_reads,
1623                &mut report.landed_predecessors,
1624                landing,
1625                &mut report.landed_walks_could_not_run,
1626            )
1627            .await;
1628        // A carried predecessor's topic that does not wait is judged again,
1629        // for real: what it admits is this call's word, over the same coins.
1630        if carried {
1631            for v in &mut validations {
1632                if v.is_dupe || v.failed || v.read_fault.is_some() || waits.contains_key(&v.topic) {
1633                    continue;
1634                }
1635                match self
1636                    .judge(
1637                        &tx,
1638                        &v.topic,
1639                        &v.previous_coins,
1640                        tagged_beef.off_chain_values.as_deref(),
1641                        mode,
1642                        &TopicAdmittanceContext::default(),
1643                        bound,
1644                    )
1645                    .await
1646                {
1647                    Ok(admittance) => v.admittance = admittance,
1648                    Err(e) => {
1649                        error!("Error validating topic {} during submit: {e}", v.topic);
1650                        v.failed = true;
1651                        v.previous_coins.clear();
1652                        v.previous_outputs.clear();
1653                        v.admittance = AdmittanceInstructions::default();
1654                    }
1655                }
1656                steak.insert(v.topic.clone(), v.admittance.clone());
1657            }
1658        }
1659
1660        // =================================================================
1661        // PHASE 3: MUTATE STORAGE
1662        // =================================================================
1663        for v in &validations {
1664            if v.is_dupe {
1665                report.deduped_topics.push(v.topic.clone());
1666                continue;
1667            }
1668            // A failed topic comes first, a read fault of it included: a
1669            // previous-coin read that faulted and a manager that then ERRED
1670            // is a failed topic with a durable report, no fault and no
1671            // replay. Parity: the reference fails the topic and never
1672            // replays. Nothing was written, so the store is as it was.
1673            if v.failed {
1674                continue;
1675            }
1676
1677            let topic = &v.topic;
1678            let admittance = &v.admittance;
1679            let faults_before = report.faults.len();
1680
1681            // A validation read that faulted: the manager judged without a
1682            // coin the store may hold, so NOTHING is written on that
1683            // judgement (the lens fold of 2026-10-07). Writing its outputs
1684            // would leave them beside the unseen coin, for a successor to
1685            // spend before the replay deletes it.
1686            if let Some((read, err)) = &v.read_fault {
1687                report.fault(topic, read, err.clone());
1688                self.hold_unapplied(&txid, topic, "a validation read faulted, nothing written");
1689                continue;
1690            }
1691
1692            // bsv-low #559 and lane E1D: a topic that found no coin while a
1693            // predecessor's landing is unknown is "not now", recorded
1694            // nowhere ([`Engine::successors_waiting`]).
1695            if let Some(not_now) = waits.get(topic) {
1696                report.fault(topic, "predecessor_not_landed", not_now.clone());
1697                self.hold_unapplied(
1698                    &txid,
1699                    topic,
1700                    &format!("found no coin and its predecessor {not_now}"),
1701                );
1702                continue;
1703            }
1704
1705            // ── Handle stale vs retained previous coins ──
1706            let mut outputs_consumed: Vec<Outpoint> = Vec::new();
1707            let mut stale_coins: Vec<&Output> = Vec::new();
1708
1709            for (coin_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1710                let input_index = v.previous_coins[coin_idx];
1711                if admittance.coins_to_retain.contains(&input_index) {
1712                    // Retained: track as consumed (will update consumedBy later)
1713                    outputs_consumed
1714                        .push(Outpoint::new(&prev_output.txid, prev_output.output_index));
1715                } else {
1716                    // Not retained: mark as stale for deletion
1717                    stale_coins.push(prev_output);
1718                }
1719            }
1720
1721            // Update STEAK with removed coins
1722            if let Some(steak_entry) = steak.get_mut(topic) {
1723                steak_entry.coins_removed = Some(
1724                    stale_coins
1725                        .iter()
1726                        .enumerate()
1727                        .filter_map(|(i, _)| {
1728                            // Map back to input indices
1729                            v.previous_coins.get(i).copied()
1730                        })
1731                        .collect(),
1732                );
1733            }
1734
1735            // ── Mark previous outputs as spent + notify lookup services ──
1736            // A mark that faults does not skip the notification (the
1737            // reference skips it): the coin is spent whatever the row says,
1738            // a stale one is deleted below and its lookup services must
1739            // know. The mark of a RETAINED coin is part of what landed: that
1740            // row stays, and left unspent it would be listed as a UTXO
1741            // beside the output that consumed it.
1742            let mut retained_mark_faulted = false;
1743            for (prev_idx, prev_output) in v.previous_outputs.iter().enumerate() {
1744                if let Err(e) = stored(
1745                    bound,
1746                    self.storage.mark_utxo_as_spent(
1747                        &prev_output.txid,
1748                        prev_output.output_index,
1749                        topic,
1750                    ),
1751                )
1752                .await
1753                {
1754                    error!("Error marking UTXO as spent: {e}");
1755                    report.fault(topic, "mark_utxo_as_spent", e.to_string());
1756                    retained_mark_faulted |= admittance
1757                        .coins_to_retain
1758                        .contains(&v.previous_coins[prev_idx]);
1759                }
1760
1761                // The input index within tx.inputs that spends this previous output.
1762                let spending_input_idx = v.previous_coins[prev_idx] as usize;
1763
1764                // Notify all lookup services about the spent output
1765                for ls in self.lookup_services.values() {
1766                    let payload = match ls.spend_notification_mode() {
1767                        SpendNotificationMode::None => OutputSpent::None {
1768                            txid: prev_output.txid.clone(),
1769                            output_index: prev_output.output_index,
1770                            topic: topic.clone(),
1771                        },
1772                        SpendNotificationMode::Txid => OutputSpent::Txid {
1773                            txid: prev_output.txid.clone(),
1774                            output_index: prev_output.output_index,
1775                            topic: topic.clone(),
1776                            spending_txid: txid.clone(),
1777                        },
1778                        SpendNotificationMode::Script => {
1779                            // Extract unlocking script and sequence from the spending input
1780                            let input = &tx.inputs[spending_input_idx];
1781                            let unlocking_script = input
1782                                .unlocking_script
1783                                .as_ref()
1784                                .map(bsv_rs::UnlockingScript::to_binary)
1785                                .unwrap_or_default();
1786                            OutputSpent::Script {
1787                                txid: prev_output.txid.clone(),
1788                                output_index: prev_output.output_index,
1789                                topic: topic.clone(),
1790                                spending_txid: txid.clone(),
1791                                input_index: spending_input_idx as u32,
1792                                unlocking_script,
1793                                sequence_number: input.sequence,
1794                                off_chain_values: tagged_beef.off_chain_values.clone(),
1795                            }
1796                        }
1797                        SpendNotificationMode::WholeTx => OutputSpent::WholeTx {
1798                            txid: prev_output.txid.clone(),
1799                            output_index: prev_output.output_index,
1800                            topic: topic.clone(),
1801                            spending_atomic_beef: subject_named_beef.clone(),
1802                            off_chain_values: tagged_beef.off_chain_values.clone(),
1803                        },
1804                    };
1805                    if let Err(e) = hooked(bound, ls.output_spent(&payload)).await {
1806                        error!("Error notifying lookup service of spent output: {e}");
1807                        report.fault(topic, "lookup_service.output_spent", e);
1808                    }
1809                }
1810            }
1811            if retained_mark_faulted {
1812                self.hold_unapplied(
1813                    &txid,
1814                    topic,
1815                    "the spent mark of a retained coin faulted, nothing inserted",
1816                );
1817                continue;
1818            }
1819
1820            // ── Insert admitted outputs ──
1821            // The inserts stop at the first one that faults: what follows is
1822            // the undo, not the rest of the transaction.
1823            let mut new_utxos: Vec<Outpoint> = Vec::new();
1824            let mut attempted: Vec<u32> = Vec::new();
1825            let mut insert_faulted = false;
1826
1827            for &output_index in &admittance.outputs_to_admit {
1828                let (script_bytes, sats) =
1829                    if let Some(tx_output) = tx.outputs.get(output_index as usize) {
1830                        (
1831                            tx_output.locking_script.to_binary(),
1832                            tx_output.get_satoshis(),
1833                        )
1834                    } else {
1835                        (Vec::new(), 0)
1836                    };
1837
1838                let output = Output {
1839                    txid: txid.clone(),
1840                    output_index,
1841                    output_script: script_bytes,
1842                    satoshis: sats,
1843                    topic: topic.clone(),
1844                    spent: false,
1845                    outputs_consumed: outputs_consumed.clone(),
1846                    consumed_by: Vec::new(),
1847                    beef: Some(tagged_beef.beef.clone()),
1848                    block_height: None,
1849                    score: Some(current_timestamp_ms()),
1850                };
1851
1852                attempted.push(output_index);
1853                if let Err(e) = stored(
1854                    bound,
1855                    self.storage.insert_output_with_origin(&output, finalize),
1856                )
1857                .await
1858                {
1859                    error!("Error inserting output for topic {topic}: {e}");
1860                    report.fault(topic, "insert_output", e.to_string());
1861                    insert_faulted = true;
1862                    break;
1863                }
1864
1865                new_utxos.push(Outpoint::new(&txid, output_index));
1866
1867                // Notify lookup services
1868                for ls in self.lookup_services.values() {
1869                    let (ls_script, ls_sats) =
1870                        if let Some(tx_out) = tx.outputs.get(output_index as usize) {
1871                            (tx_out.locking_script.to_binary(), tx_out.get_satoshis())
1872                        } else {
1873                            (Vec::new(), 0)
1874                        };
1875
1876                    let payload = match ls.admission_mode() {
1877                        AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
1878                            txid: txid.clone(),
1879                            output_index,
1880                            topic: topic.clone(),
1881                            satoshis: ls_sats,
1882                            locking_script: ls_script,
1883                            off_chain_values: tagged_beef.off_chain_values.clone(),
1884                        },
1885                        AdmissionMode::WholeTx => OutputAdmittedByTopic::WholeTx {
1886                            atomic_beef: subject_named_beef.clone(),
1887                            output_index,
1888                            topic: topic.clone(),
1889                            off_chain_values: tagged_beef.off_chain_values.clone(),
1890                        },
1891                    };
1892
1893                    if let Err(e) = hooked(bound, ls.output_admitted_by_topic(&payload)).await {
1894                        error!("Error notifying lookup service: {e}");
1895                        report.fault(topic, "lookup_service.output_admitted_by_topic", e);
1896                    }
1897                }
1898            }
1899
1900            // An insert faulted: the transaction did not land, so NOTHING of
1901            // it stays (the lens fold of 2026-10-07, F1). The previous coins
1902            // are kept (#559: with the delete first, one faulted insert left
1903            // a non-retaining head chain with NO head) and the outputs that
1904            // were inserted are taken out again, the faulted one included (a
1905            // backend's insert can fault after its row landed). Left in,
1906            // they could be spent and deleted by a successor before the
1907            // replay, and the replay, which still finds the previous coin,
1908            // would insert them a second time: a spent head back among the
1909            // UTXOs for good.
1910            if insert_faulted {
1911                if let Some(bound) = bound {
1912                    bound.renew();
1913                }
1914                self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
1915                    .await;
1916                self.hold_unapplied(
1917                    &txid,
1918                    topic,
1919                    &format!(
1920                        "an insert faulted: {} previous coin(s) kept, nothing of it held",
1921                        stale_coins.len()
1922                    ),
1923                );
1924                continue;
1925            }
1926
1927            // ── Delete stale outputs recursively ──
1928            // AFTER the inserts, and once every one of them landed (bsv-low
1929            // #559 and the lens fold of 2026-10-07, F1). The reference
1930            // deletes first (`applyTopicStorageMutation`: `removeStaleOutputs`,
1931            // then `admitOutput`), catches a throw per topic and never
1932            // replays; here a fault is survived and replayed, so the order
1933            // is ours, an addition. The delete does NOT wait for a clean
1934            // report (c9921ee did, and one faulted mark or notification
1935            // after the insert then left the stale coin beside the new
1936            // output for good: two unspent heads): a fault of the mark, of
1937            // a notification, deletes as the reference does.
1938            //
1939            // Before a stale coin is deleted its transaction is recorded as
1940            // applied in the topic (the delta fold of 2026-10-07, H2; an
1941            // addition, one idempotent write per deleted coin). A held coin
1942            // proves the inserts of its transaction landed, but that
1943            // transaction may have no applied row (a fault after its
1944            // inserts withheld it, or its insert landed after its timeout),
1945            // and then its replay is judged again: a manager that admits it
1946            // with NO previous coin (the opener of a chain) had it inserted
1947            // a second time, unspent, beside the output that spent it, for
1948            // good. With the row its replay is a dupe. A record that faults
1949            // leaves its coin undeleted: that is a delete fault, below.
1950            let due_before_the_delete = bound.is_some_and(CallBound::is_due);
1951            let mut delete_faulted = false;
1952            let mut delete_started = false;
1953            for stale in &stale_coins {
1954                let spent_coin_tx = AppliedTransaction {
1955                    txid: stale.txid.clone(),
1956                    topic: topic.clone(),
1957                };
1958                if let Err(e) = stored(
1959                    bound,
1960                    self.storage.insert_applied_transaction(&spent_coin_tx),
1961                )
1962                .await
1963                {
1964                    error!(
1965                        "Error recording {} as applied before its coin is deleted from topic {topic}: {e}",
1966                        stale.txid
1967                    );
1968                    report.fault(topic, "record_spent_coin_applied", e.to_string());
1969                    delete_faulted = true;
1970                    continue;
1971                }
1972                self.not_landed
1973                    .borrow_mut()
1974                    .remove(&(stale.txid.clone(), topic.clone()));
1975                match stored(
1976                    bound,
1977                    self.storage.find_output(
1978                        &stale.txid,
1979                        stale.output_index,
1980                        Some(topic),
1981                        None,
1982                        false,
1983                    ),
1984                )
1985                .await
1986                {
1987                    Ok(Some(stale_output)) => {
1988                        delete_started = true;
1989                        if let Err(e) = self.delete_utxo_deep(&stale_output, bound).await {
1990                            error!("Error deleting stale output for topic {topic}: {e}");
1991                            report.fault(topic, "delete_utxo_deep", e.to_string());
1992                            delete_faulted = true;
1993                        }
1994                    }
1995                    Ok(None) => {}
1996                    Err(e) => {
1997                        error!("Error reading stale output for topic {topic}: {e}");
1998                        report.fault(topic, "find_output", e.to_string());
1999                        delete_faulted = true;
2000                    }
2001                }
2002            }
2003
2004            // A fault here, and what it leaves. The store is put back as it
2005            // was found (the inserts undone, the replay does the whole
2006            // transaction) only when NO delete was started: the fault is of
2007            // H2's record or of the read before the delete, the call
2008            // answered, and EVERY stale coin is read back still held.
2009            //
2010            // Once a delete was STARTED nothing is undone, whether it
2011            // answered or not (the delta fold of 2026-10-07, H1, for a call
2012            // that did not answer; the second delta fold, M1, for one that
2013            // answered an error). A statement can land after its caller was
2014            // told it failed, as after its caller stopped waiting: the coin
2015            // is read back held now and is deleted a moment later, and the
2016            // undo then left the chain with no head, for good, for a manager
2017            // that needs the previous coin. So the outputs stay, the stale
2018            // coin stays (marked spent) until its delete lands or is done
2019            // again, and there is no applied row. Since H2 every order from
2020            // there converges. The replay first, the delete never landed:
2021            // the coin is found, the manager admits again, the inserts are
2022            // no-ops, the delete is done and the row written. The replay
2023            // first, the delete landed late: no previous coin, so a manager
2024            // that needs one admits nothing and the transaction is recorded
2025            // with its outputs held. A successor first: it recorded this
2026            // transaction as applied (H2) and the replay is a dupe; the
2027            // stale coin's spent row is then left behind, no UTXO.
2028            //
2029            // A call that did not ANSWER before any delete (the record, the
2030            // read) undoes nothing either: it is not known what it did. A
2031            // deadline that was already due BEFORE the first of these calls
2032            // started nothing here, so the coins are certainly held and the
2033            // store is put back.
2034            if delete_faulted {
2035                let unanswered = !due_before_the_delete && bound.is_some_and(CallBound::is_due);
2036                if let Some(bound) = bound {
2037                    bound.renew();
2038                }
2039                let mut all_held = !unanswered && !delete_started;
2040                for stale in &stale_coins {
2041                    if !all_held {
2042                        break;
2043                    }
2044                    let read = stored(
2045                        bound,
2046                        self.storage.find_output(
2047                            &stale.txid,
2048                            stale.output_index,
2049                            Some(topic),
2050                            None,
2051                            false,
2052                        ),
2053                    )
2054                    .await;
2055                    if !matches!(read, Ok(Some(_))) {
2056                        all_held = false;
2057                        break;
2058                    }
2059                }
2060                if all_held {
2061                    self.undo_inserts(&txid, topic, &attempted, bound, &mut report)
2062                        .await;
2063                }
2064                self.hold_unapplied(
2065                    &txid,
2066                    topic,
2067                    if all_held {
2068                        "no delete of a stale coin was started: the coins are held, its outputs taken out"
2069                    } else if unanswered {
2070                        "the delete of a stale coin did not answer: nothing undone, its outputs stay"
2071                    } else if delete_started {
2072                        "the delete of a stale coin faulted: nothing undone, its outputs stay"
2073                    } else {
2074                        "no delete was started and a stale coin was not read back held: nothing undone, its outputs stay"
2075                    },
2076                );
2077                continue;
2078            }
2079
2080            // ── Update consumedBy on retained previous outputs ──
2081            for consumed_outpoint in &outputs_consumed {
2082                let consumed_output = match stored(
2083                    bound,
2084                    self.storage.find_output(
2085                        &consumed_outpoint.txid,
2086                        consumed_outpoint.output_index,
2087                        Some(topic),
2088                        None,
2089                        false,
2090                    ),
2091                )
2092                .await
2093                {
2094                    Ok(Some(o)) => o,
2095                    Ok(None) => continue,
2096                    Err(e) => {
2097                        error!("Error reading consumed output for topic {topic}: {e}");
2098                        report.fault(topic, "find_output", e.to_string());
2099                        continue;
2100                    }
2101                };
2102                {
2103                    let mut new_consumed_by = consumed_output.consumed_by.clone();
2104                    for new_utxo in &new_utxos {
2105                        if !new_consumed_by.iter().any(|c| {
2106                            c.txid == new_utxo.txid && c.output_index == new_utxo.output_index
2107                        }) {
2108                            new_consumed_by.push(new_utxo.clone());
2109                        }
2110                    }
2111                    if let Err(e) = stored(
2112                        bound,
2113                        self.storage.update_consumed_by(
2114                            &consumed_outpoint.txid,
2115                            consumed_outpoint.output_index,
2116                            topic,
2117                            &new_consumed_by,
2118                        ),
2119                    )
2120                    .await
2121                    {
2122                        error!("Error updating consumedBy: {e}");
2123                        report.fault(topic, "update_consumed_by", e.to_string());
2124                    }
2125                }
2126            }
2127
2128            // Record applied transaction — ONLY when every write above
2129            // landed. A faulted topic stays unrecorded so the Phase-1 dedup
2130            // cannot turn its replay into a no-op (S2).
2131            if report.faults.len() > faults_before {
2132                self.hold_unapplied(
2133                    &txid,
2134                    topic,
2135                    &format!(
2136                        "{} mutation fault(s); a replay of these bytes re-applies",
2137                        report.faults.len() - faults_before
2138                    ),
2139                );
2140                continue;
2141            }
2142            let tx_record = AppliedTransaction {
2143                txid: txid.clone(),
2144                topic: topic.clone(),
2145            };
2146            match stored(bound, self.storage.insert_applied_transaction(&tx_record)).await {
2147                Ok(()) => {
2148                    report.applied_topics.push(topic.clone());
2149                    self.not_landed
2150                        .borrow_mut()
2151                        .remove(&(txid.clone(), topic.clone()));
2152                }
2153                Err(e) => {
2154                    error!("Error inserting applied transaction for topic {topic}: {e}");
2155                    report.fault(topic, "insert_applied_transaction", e.to_string());
2156                    self.not_landed
2157                        .borrow_mut()
2158                        .insert((txid.clone(), topic.clone()));
2159                }
2160            }
2161        }
2162
2163        Ok((steak, report, blocked))
2164    }
2165
2166    /// The successor rule (bsv-low #559; lane E1D, bsv-low #575; and its
2167    /// lens fold of 2026-10-08), asked of every topic of a submit that found
2168    /// NO previous coin while the transaction spends something, before
2169    /// anything of the submit is written. Returns the topics that must wait,
2170    /// each with why: those are reported as a fault (`predecessor_not_landed`)
2171    /// and recorded nowhere, "not now". An addition to the reference, which
2172    /// records every non-failed topic.
2173    ///
2174    /// Such a topic was judged without the coin a predecessor has yet to
2175    /// leave. Recorded as applied, its replay was a dupe for good: a
2176    /// transaction that admits nothing ended the chain at the predecessor
2177    /// (#559), and one the manager admits WITHOUT its coin (the pf head
2178    /// manager over its own head state, E1D) was inserted, and the
2179    /// predecessor, landing later, stood unspent beside the tip.
2180    ///
2181    /// "Has not landed" is known two ways: this engine saw it fault
2182    /// (`not_landed`, one invocation, every door), or the store says so
2183    /// ([`Engine::unlanded_predecessor`], any invocation, never at a GASP
2184    /// finalize submit: its graph passed the anchor check, every parent
2185    /// inside the graph was submitted just before it and the sequence stops
2186    /// at the first that does not land; the second delta fold of 2026-10-07,
2187    /// M2). For a successor that admits something, both look only at the
2188    /// inputs the manager NAMES as its history (`identify_needed_inputs`
2189    /// over the subject-named BEEF; the lens fold, L1): a manager that
2190    /// names nothing (all 16 of this workspace) asks nothing there, as in
2191    /// the reference. A successor that admits NOTHING is asked of every
2192    /// input whatever its manager names (D17 (a)), so under such a manager
2193    /// it still waits for, or lands first, a carried body that spends a
2194    /// coin the topic holds: there the store differs from the reference's
2195    /// (the E1D delta fold, L3).
2196    ///
2197    /// The door lands a carried predecessor FIRST (the lens fold, M1; the
2198    /// GASP walk's rule, at the door): when the question names an unlanded
2199    /// predecessor whose body the BEEF carries, that body is submitted on
2200    /// its own to this topic ([`Engine::land_carried`]), under the submit's
2201    /// read allowance, and once it lands the topic is judged again
2202    /// ([`Engine::validate_topic`]) and asked again. The re-judgement's
2203    /// reads (the applied row, one coin per input) are charged to that
2204    /// allowance BEFORE the landing (the E1D delta fold, M1): a landing whose
2205    /// re-judgement would not fit is not made, and the topic waits. So one
2206    /// submit reads at most its own validation and writes plus
2207    /// [`PREDECESSOR_READS_PER_SUBMIT`], landings (on their clean path) and
2208    /// re-judgements included; a faulted landing's read-backs and undo, and
2209    /// a deep delete's walk of retained history, are not charged.
2210    /// Nothing is landed into a topic whose manager or a lookup
2211    /// service reads off-chain values (the delta fold, L1: the landing has
2212    /// none to give, and the predecessor's own submit would be a dupe): the
2213    /// topic waits for that submit. The first predecessor that does not land
2214    /// ends it: the topic waits. A predecessor whose body the BEEF does not
2215    /// carry is never landed here: its successor converges only when it
2216    /// lands (its own replay, a GASP peer, a resubmit).
2217    #[allow(clippy::too_many_arguments)]
2218    async fn successors_waiting(
2219        &self,
2220        tx: &Transaction,
2221        txid: &str,
2222        subject_named_beef: &[u8],
2223        validations: &mut [TopicValidation],
2224        steak: &mut Steak,
2225        off_chain_values: Option<&[u8]>,
2226        mode: SubmitMode,
2227        bound: Option<&CallBound>,
2228        finalize: bool,
2229        lands: bool,
2230        blocked: &mut HashMap<String, String>,
2231        question_reads: &mut usize,
2232        landed: &mut Vec<(String, String)>,
2233        landing: Landing,
2234        landed_breaches: &mut Vec<WalkCouldNotRun>,
2235    ) -> HashMap<String, String> {
2236        let mut waits = HashMap::new();
2237        let spent: Vec<(String, u32)> = tx
2238            .inputs
2239            .iter()
2240            .filter_map(|input| {
2241                let source = input.get_source_txid().ok()?;
2242                (!source.is_empty()).then_some((source, input.source_output_index))
2243            })
2244            .collect();
2245        if spent.is_empty() {
2246            return waits;
2247        }
2248        let found_no_coin = |v: &TopicValidation| {
2249            !v.is_dupe && !v.failed && v.read_fault.is_none() && v.previous_outputs.is_empty()
2250        };
2251        for v in validations.iter_mut() {
2252            if !found_no_coin(v) {
2253                continue;
2254            }
2255            let Some(manager) = self.managers.get(&v.topic) else {
2256                continue;
2257            };
2258            let topic = v.topic.clone();
2259            // The manager's word on the subject's history, asked at most
2260            // once per topic: up front for a successor that admits, by the
2261            // question when it needs it for one that does not.
2262            let mut subject_names: Option<HashSet<String>> = None;
2263            loop {
2264                let admits = !v.admittance.outputs_to_admit.is_empty();
2265                if admits && subject_names.is_none() {
2266                    subject_names = Some(
2267                        self.named_history(manager.as_ref(), subject_named_beef, bound)
2268                            .await,
2269                    );
2270                }
2271                let remembered = spent.iter().find(|(source, vout)| {
2272                    (!admits
2273                        || subject_names
2274                            .as_ref()
2275                            .is_some_and(|named| named.contains(&format!("{source}.{vout}"))))
2276                        && self
2277                            .not_landed
2278                            .borrow()
2279                            .contains(&(source.clone(), topic.clone()))
2280                });
2281                if let Some((predecessor, _)) = remembered {
2282                    waits.insert(topic.clone(), format!("{predecessor} has not landed"));
2283                    break;
2284                }
2285                if finalize {
2286                    break;
2287                }
2288                let Some(not_now) = self
2289                    .unlanded_predecessor(
2290                        tx,
2291                        txid,
2292                        subject_named_beef,
2293                        &topic,
2294                        mode,
2295                        admits,
2296                        &mut subject_names,
2297                        question_reads,
2298                        bound,
2299                    )
2300                    .await
2301                else {
2302                    break;
2303                };
2304                let Some(carried) = not_now.carried else {
2305                    waits.insert(topic.clone(), not_now.why);
2306                    break;
2307                };
2308                if !lands {
2309                    blocked.insert(topic.clone(), carried);
2310                    waits.insert(topic.clone(), not_now.why);
2311                    break;
2312                }
2313                if !self.lands_without_values(&topic) {
2314                    waits.insert(
2315                        topic.clone(),
2316                        format!(
2317                            "{}; not landed first from the BEEF: the topic's manager or a lookup service reads off-chain values, and only the predecessor's own submit carries them",
2318                            not_now.why
2319                        ),
2320                    );
2321                    break;
2322                }
2323                // The re-judgement after the landing, charged before it.
2324                let rejudge = 1 + spent.len();
2325                if *question_reads + rejudge > PREDECESSOR_READS_PER_SUBMIT {
2326                    waits.insert(
2327                        topic.clone(),
2328                        format!(
2329                            "{}; its landing and the re-judgement after it do not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads",
2330                            not_now.why
2331                        ),
2332                    );
2333                    break;
2334                }
2335                *question_reads += rejudge;
2336                match self
2337                    .land_carried(
2338                        subject_named_beef,
2339                        txid,
2340                        &carried,
2341                        &topic,
2342                        mode,
2343                        bound,
2344                        question_reads,
2345                        landed,
2346                        landing,
2347                        landed_breaches,
2348                    )
2349                    .await
2350                {
2351                    Ok(()) => {
2352                        *v = self
2353                            .validate_topic(
2354                                tx,
2355                                txid,
2356                                &topic,
2357                                off_chain_values,
2358                                mode,
2359                                &TopicAdmittanceContext::default(),
2360                                bound,
2361                            )
2362                            .await;
2363                        steak.insert(topic.clone(), v.admittance.clone());
2364                        if !found_no_coin(v) {
2365                            break;
2366                        }
2367                    }
2368                    Err(did_not_land) => {
2369                        // No re-judgement follows: its reads were not made.
2370                        *question_reads -= rejudge;
2371                        waits.insert(
2372                            topic.clone(),
2373                            format!(
2374                                "{}; submitted first from the BEEF, it did not land ({did_not_land})",
2375                                not_now.why
2376                            ),
2377                        );
2378                        break;
2379                    }
2380                }
2381            }
2382            // A subject that already HOLDS an output in the topic landed
2383            // there before (the leftover of its own earlier submit whose
2384            // delete was started and faulted, D17 M1: its outputs held, no
2385            // applied row), and that admission was judged with its coin.
2386            // Its replay finishes it and does not wait (the E1D lens fold,
2387            // L4: a proven head spend that names a decoy no peer serves was
2388            // "not now" on every replay, three and a dead letter). One read,
2389            // only on the way to "not now", inside the allowance.
2390            if !finalize
2391                && waits.contains_key(&topic)
2392                && *question_reads < PREDECESSOR_READS_PER_SUBMIT
2393            {
2394                *question_reads += 1;
2395                if let Ok(outputs) = stored(
2396                    bound,
2397                    self.storage.find_outputs_for_transaction(txid, false),
2398                )
2399                .await
2400                {
2401                    if outputs.iter().any(|o| o.topic == topic) {
2402                        info!("topic {topic}: {txid} holds its outputs already; its replay finishes it");
2403                        waits.remove(&topic);
2404                        blocked.remove(&topic);
2405                    }
2406                }
2407            }
2408        }
2409        waits
2410    }
2411
2412    /// Land an unlanded predecessor whose body the successor's BEEF carries
2413    /// (lane E1D's lens fold, M1): submit it on its own to `topic`, its
2414    /// atomic BEEF out of the successor's, no off-chain values (the caller
2415    /// lands nothing into a topic that reads them; the delta fold, L1),
2416    /// never a broadcast (`current-tx` is submitted as `historical-tx`: the
2417    /// successor's BEEF carries it to the network).
2418    ///
2419    /// Each body is WALKED once in this submit (the delta fold, L4): the
2420    /// successor's own SPV walk (`subject`, the same mode, tracker and
2421    /// switch) already passed over every body it reaches without crossing a
2422    /// proven one, so those are not walked again; a body reached only
2423    /// through a proven one (which the successor's walk does not descend)
2424    /// gets its own walk, trusting what this submit already walked, and so
2425    /// does nothing twice. A walk that refuses a body ends the landing.
2426    ///
2427    /// A predecessor whose own submit waits on a carried predecessor of ITS
2428    /// own goes on a stack under it: ancestors first, the walk's rule at
2429    /// the door. Its manager was asked only a DRY RUN then and nothing was
2430    /// written; once the one under it landed it is submitted again and
2431    /// admitted for real, once ([`Engine::submit_counted`]). Each submit's
2432    /// reads on its clean path (its applied row, one coin per input, and
2433    /// its writes' one read of each coin it spends) are charged to the
2434    /// submit's allowance before it starts, and its predecessor
2435    /// question shares that allowance, so the landings of one submit stop
2436    /// at [`PREDECESSOR_READS_PER_SUBMIT`] reads, however deep the chain.
2437    /// Each body that lands is pushed on `landed` (the report's
2438    /// `landed_predecessors`, for the caller's admission guard; the delta
2439    /// fold, L2). Each body is asked of the caller's landing guard first,
2440    /// once ([`Engine::set_landing_guard`]; the delta-2 fold, L2): a refusal
2441    /// ends the landing before that body is submitted.
2442    /// `Ok` once `predecessor` LANDED in `topic` (recorded there,
2443    /// now or before); otherwise why not, at the first that did not.
2444    #[allow(clippy::too_many_arguments)]
2445    async fn land_carried(
2446        &self,
2447        beef_bytes: &[u8],
2448        subject: &str,
2449        predecessor: &str,
2450        topic: &str,
2451        mode: SubmitMode,
2452        bound: Option<&CallBound>,
2453        question_reads: &mut usize,
2454        landed: &mut Vec<(String, String)>,
2455        landing: Landing,
2456        landed_breaches: &mut Vec<WalkCouldNotRun>,
2457    ) -> Result<(), String> {
2458        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
2459            .map_err(|e| format!("the BEEF could not be read again ({e})"))?;
2460        let mode = match mode {
2461            SubmitMode::CurrentTx => SubmitMode::HistoricalTx,
2462            other => other,
2463        };
2464        let walks = mode != SubmitMode::HistoricalTxNoSpv;
2465        // The trust set is what a walk that RAN covered (the E592 lens fold,
2466        // M1): a subject whose walk breached covered nothing, so each carried
2467        // predecessor is walked by its own submit (and its own breach is
2468        // reported, `landed_breaches`), never trusted by a breached walk.
2469        let mut walked: HashSet<String> = if walks && landing.subject_walk_ran {
2470            walk_cover(&beef, subject)
2471        } else {
2472            HashSet::new()
2473        };
2474        let mut stack = vec![predecessor.to_string()];
2475        let mut guarded: HashSet<String> = HashSet::new();
2476        while let Some(next) = stack.last().cloned() {
2477            let body = beef
2478                .find_atomic_transaction(&next)
2479                .ok_or_else(|| format!("{next}: its body is not in the BEEF"))?;
2480            // The caller's admission predicate, before anything of this body
2481            // is submitted (the delta-2 fold, L2).
2482            if let Some(guard) = &self.landing_guard {
2483                if guarded.insert(next.clone()) {
2484                    bounded(bound, guard(&next, topic))
2485                        .await
2486                        .and_then(|answer| answer)
2487                        .map_err(|why| format!("{next}: refused by the landing guard ({why})"))?;
2488                }
2489            }
2490            // Its validation (the applied row, one coin per input) and its
2491            // writes' one read of each coin it spends.
2492            let cost = 1 + 2 * body.inputs.len();
2493            if *question_reads + cost > PREDECESSOR_READS_PER_SUBMIT {
2494                return Err(format!(
2495                    "{next}: its submit does not fit in the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2496                ));
2497            }
2498            *question_reads += cost;
2499            let atomic = body
2500                .to_atomic_beef(true)
2501                .map_err(|e| format!("{next}: its BEEF could not be built ({e})"))?;
2502            let tagged = TaggedBEEF::new(atomic, vec![topic.to_string()]);
2503            let (_, report, blocked) = match self
2504                .submit_counted(
2505                    &tagged,
2506                    mode,
2507                    bound,
2508                    false,
2509                    Some(&walked),
2510                    question_reads,
2511                    landing.policy,
2512                )
2513                .await
2514            {
2515                Ok(counted) => counted,
2516                // Its walk could not run and nothing stands in for it: it does
2517                // not land now, and the successor waits (counted, L3).
2518                Err(EngineError::WalkCouldNotRun(stop)) => {
2519                    let why = format!("{next}: {}", EngineError::WalkCouldNotRun(stop.clone()));
2520                    landed_breaches.push(stop);
2521                    return Err(why);
2522                }
2523                Err(e) => return Err(format!("{next}: {e}")),
2524            };
2525            // Its walk passed, so did that of every body it reaches; a walk
2526            // that went on past a breach (`NetworkAccepted`) passed nothing.
2527            if let Some(stop) = &report.walk_could_not_run {
2528                landed_breaches.push(stop.clone());
2529            } else if walks && !walked.contains(&next) {
2530                walked.extend(walk_cover(&beef, &next));
2531            }
2532            let landed_now = report.applied_topics.iter().any(|t| t == topic);
2533            if report.is_durable()
2534                && (landed_now || report.deduped_topics.iter().any(|t| t == topic))
2535            {
2536                info!("topic {topic}: {next} landed first from its successor's BEEF");
2537                if landed_now {
2538                    landed.push((next.clone(), topic.to_string()));
2539                }
2540                stack.pop();
2541                continue;
2542            }
2543            match blocked.get(topic) {
2544                Some(under) if !stack.contains(under) => stack.push(under.clone()),
2545                _ if report.is_durable() => return Err(format!("{next}: the manager failed it")),
2546                _ => return Err(format!("{next}: {}", report.summary())),
2547            }
2548        }
2549        Ok(())
2550    }
2551
2552    /// A topic of a submit that is NOT recorded as applied: logged, and
2553    /// remembered for the successor rule (`not_landed`).
2554    fn hold_unapplied(&self, txid: &str, topic: &str, why: &str) {
2555        warn!("topic {topic}: {txid}: {why}; applied_transactions NOT recorded (bsv-low #559)");
2556        self.not_landed
2557            .borrow_mut()
2558            .insert((txid.to_string(), topic.to_string()));
2559    }
2560
2561    /// Take out again the outputs a faulted submit inserted (bsv-low #559,
2562    /// the lens fold of 2026-10-07, F1), so that nothing of a transaction
2563    /// that did not land is held. Only a row nobody has touched since is
2564    /// deleted: unspent and consumed by nothing. A plain delete, not the
2565    /// deep one: the row's retained inputs were not pointed at it yet. A
2566    /// fault here is reported (`undo_insert_output`) and the row stays: the
2567    /// limit of an undo made of separate calls.
2568    async fn undo_inserts(
2569        &self,
2570        txid: &str,
2571        topic: &str,
2572        output_indices: &[u32],
2573        bound: Option<&CallBound>,
2574        report: &mut MutationReport,
2575    ) {
2576        for &output_index in output_indices {
2577            let row = stored(
2578                bound,
2579                self.storage
2580                    .find_output(txid, output_index, Some(topic), None, false),
2581            )
2582            .await;
2583            let undone = match row {
2584                Ok(Some(row)) if !row.spent && row.consumed_by.is_empty() => {
2585                    stored(bound, self.storage.delete_output(txid, output_index, topic)).await
2586                }
2587                Ok(_) => Ok(()),
2588                Err(e) => Err(e),
2589            };
2590            if let Err(e) = undone {
2591                error!("Error taking {txid}.{output_index} out of topic {topic} again: {e}");
2592                report.fault(topic, "undo_insert_output", e.to_string());
2593            }
2594        }
2595    }
2596
2597    /// The store's own answer to "has a predecessor of `tx` not landed?"
2598    /// (bsv-low #559, the lens fold of 2026-10-07, F3; lane E1D, bsv-low
2599    /// #575, and its lens fold of 2026-10-08), for a transaction that found
2600    /// no previous coin. The engine's memory of a faulted submit
2601    /// (`not_landed`) dies with the invocation; this does not. A transaction
2602    /// HAS LANDED in `topic` when it has an applied row there or an output
2603    /// held there; anything else is not known to have landed, and it is a
2604    /// PREDECESSOR of `tx` when one of these says so:
2605    ///
2606    /// - its body is in the BEEF and it spends a coin the topic HOLDS (or,
2607    ///   one step up, a coin of another such transaction): a spend of this
2608    ///   topic the store has not taken yet (#559);
2609    /// - its body is in the BEEF, it spends no coin the topic holds, the
2610    ///   manager NAMES the outpoint the walk spends from it as overlay
2611    ///   history (`identify_needed_inputs` over the BEEF of the transaction
2612    ///   that spends it, `tx` or a body the walk reached), and the manager,
2613    ///   asked of that body in a dry run with no coins
2614    ///   (`TopicAdmittanceContext::DRY_RUN`, the submit's mode, no off-chain
2615    ///   values), would admit that named output: an OPENER that has not
2616    ///   landed (E1D, the cure D17 named). Only a NAMED output is dry-run
2617    ///   (the lens fold, H1): with every unheld body dry-run, a manager that
2618    ///   admits on output shape (all 16 of this workspace) made a spend of a
2619    ///   shape-admissible output this node never held (a revocation of an
2620    ///   ad it never saw; a stranger's own few-sat SHIP output) "not now" on
2621    ///   every presentation, three replays and a dead letter each, where the
2622    ///   reference records it. A manager `Err` in the dry run is "not now"
2623    ///   (the manager's contract at the anchor replay, D15), except its
2624    ///   typed refusal `NoAdmissibleOutputs`, which is "not admitted";
2625    /// - its body is NOT in the BEEF (a PROVEN successor carries none) and
2626    ///   the manager names the outpoint `tx` spends from it as overlay
2627    ///   history (D13's word; an `Err` there names nothing, as in the walk).
2628    ///
2629    /// The manager is asked over `beef_bytes`, the SUBJECT-NAMED BEEF of the
2630    /// submit (BRC-95 atomic, the body the lookup services get; the lens
2631    /// fold, M2): over the submitted bytes a manager that parses
2632    /// `from_beef(_, None)` took the wire-LAST transaction of a non-atomic
2633    /// BEEF, named an ancestor's inputs, and the question asked nothing.
2634    ///
2635    /// `admits` says which inputs of `tx` start the walk. A transaction that
2636    /// admits nothing (D17's class) starts from every input. One that admits
2637    /// WITHOUT its coin (E1D) starts only from the inputs the manager names:
2638    /// a manager that admits a successor with no coin has said the coin does
2639    /// not decide the admission, so only its own word makes an input a
2640    /// predecessor. Every manager of this workspace names nothing: an
2641    /// admitting successor is then asked nothing, as in the reference, and
2642    /// so is a spend of an unheld output nobody names (the lens fold, H1);
2643    /// but a successor that admits nothing still finds case (a), a carried
2644    /// body that spends a coin the topic holds, and waits for it or has it
2645    /// landed first, where the reference records the successor and never
2646    /// admits that body (the E1D delta fold, L3). A named input whose body
2647    /// the BEEF carries is still walked and dry-run, so a named decoy the
2648    /// manager would not admit is no predecessor.
2649    ///
2650    /// The answer carries the predecessor when its body is in the BEEF (the
2651    /// first two cases): the door lands it first ([`Engine::successors_waiting`]).
2652    ///
2653    /// The limits, stated. It cannot tell a faulted predecessor from one
2654    /// nobody submitted yet: a successor is "not now" in both until the
2655    /// predecessor lands (the door lands a carried one itself). A named
2656    /// input whose body is absent and which never lands (a decoy no one
2657    /// submits, beside a real predecessor that landed and holds no coin)
2658    /// keeps its successor "not now" at the door; the GASP walk prunes such
2659    /// an input instead (D14). Deeper than the successor's own inputs, an
2660    /// absent body ends the walk as before.
2661    ///
2662    /// "Landed" needs a clean answer (the delta fold of 2026-10-07, M1 and
2663    /// M2): a read that faults, or the question running out of its reads
2664    /// (the 16 of a topic or the 256 of the submit), answers "not now" too,
2665    /// naming the transaction it could not settle. Recording the successor
2666    /// there made its replay a dupe and stopped the chain behind it for
2667    /// good; a retried no-op is the lesser cost.
2668    ///
2669    /// What the bound counts (the second delta fold of 2026-10-07, M2): the
2670    /// reads spent on a body that is NEITHER proven in the BEEF NOR found
2671    /// landed, [`PREDECESSOR_READS`] of them per topic. A body the BEEF
2672    /// proves, and one whose applied row or held output answers "landed", is
2673    /// read and costs nothing against that bound: counted, they made a
2674    /// transaction that admits nothing over 17 landed parents, over six
2675    /// proven parents the topic never saw, or over one proven parent with 15
2676    /// inputs "not now" on every submit, with no unlanded predecessor
2677    /// anywhere. A landed body still needs ROOM under it: each read of an
2678    /// unproven body is tested before it is made, so with 15 counted reads
2679    /// spent one that landed by a held output (no applied row) is refused at
2680    /// its second read, and with 16 spent any unproven body at its first.
2681    ///
2682    /// "Proven" is the BEEF's word here: a body that carries a merkle path
2683    /// (a bump index) is counted as proven for the read bound ONLY. No proof
2684    /// is checked at this question; the SPV walk of the submit and the GASP
2685    /// anchor check are where proofs are checked, and under
2686    /// `historical-tx-no-spv` or the scripts-only walk nothing checked this
2687    /// one, so the 16 are the caller's to switch off. Nothing is recorded on
2688    /// that word (a proven body is walked like any other), and the allowance
2689    /// below does not ask it.
2690    ///
2691    /// The hard allowance (the third delta fold of 2026-10-07, M1):
2692    /// `question_reads`, owned by the submit, counts EVERY read of the
2693    /// question, those of proven and landed bodies included, over every
2694    /// topic of the submit, and stops it at
2695    /// [`PREDECESSOR_READS_PER_SUBMIT`]; the 16 stay inside it. A later
2696    /// topic's question starts from what the earlier ones left.
2697    ///
2698    /// The costs, stated. A transaction that admits nothing, found no coin
2699    /// and carries more UNPROVEN, UNLANDED bodies than 16 reads settle (five
2700    /// single-input ancestors, fewer with more inputs), or a BEEF whose
2701    /// question needs more than 256 reads over the submit's topics (a
2702    /// landed body costs one read by its applied row or two by a held
2703    /// output, a proven, unlanded one two and one per input), is never
2704    /// recorded, where the reference records it: each submit of it is a
2705    /// fault and a replay. The manager CPU it adds: one names call per
2706    /// spender a dry run needs (the subject's, and one per walked body with
2707    /// an unheld source), and at most one dry run per body the reads
2708    /// reached; each candidate is built out of the BEEF parsed once (the
2709    /// lens fold, L2).
2710    ///
2711    /// Returns why the submit must wait, for the report.
2712    #[allow(clippy::too_many_arguments)]
2713    async fn unlanded_predecessor(
2714        &self,
2715        tx: &Transaction,
2716        txid: &str,
2717        beef_bytes: &[u8],
2718        topic: &str,
2719        mode: SubmitMode,
2720        admits: bool,
2721        subject_names: &mut Option<HashSet<String>>,
2722        question_reads: &mut usize,
2723        bound: Option<&CallBound>,
2724    ) -> Option<NotNow> {
2725        let manager = self.managers.get(topic)?;
2726        let wait = |why: String| Some(NotNow { why, carried: None });
2727        // The outpoints `tx` spends, as `txid.vout`.
2728        let starts: Vec<(String, u32)> = tx
2729            .inputs
2730            .iter()
2731            .filter_map(|input| {
2732                let source = input.get_source_txid().ok()?;
2733                (!source.is_empty()).then_some((source, input.source_output_index))
2734            })
2735            .collect();
2736        // The outputs of each candidate that the walk spends, and who spends
2737        // each: a dry run asks only of an output its spender names.
2738        let mut spent_of: HashMap<String, HashSet<u32>> = HashMap::new();
2739        let mut spenders: HashMap<String, Vec<(String, u32)>> = HashMap::new();
2740        let mut ask: Vec<String> = Vec::new();
2741        for (source, vout) in &starts {
2742            if admits
2743                && subject_names
2744                    .as_ref()
2745                    .is_some_and(|named| !named.contains(&format!("{source}.{vout}")))
2746            {
2747                continue;
2748            }
2749            spent_of.entry(source.clone()).or_default().insert(*vout);
2750            spenders
2751                .entry(source.clone())
2752                .or_default()
2753                .push((txid.to_string(), *vout));
2754            ask.push(source.clone());
2755        }
2756        // Nothing to ask (a transaction that admits and names nothing, every
2757        // manager of this workspace): the BEEF is not even parsed.
2758        if ask.is_empty() {
2759            return None;
2760        }
2761        let direct: HashSet<String> = ask.iter().cloned().collect();
2762        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS).ok()?;
2763        // Each body the BEEF carries, and whether the BEEF says it is
2764        // proven (a bump index: its word, no proof is checked here).
2765        let bodies: HashMap<String, (&Transaction, bool)> = beef
2766            .txs
2767            .iter()
2768            .filter_map(|btx| {
2769                btx.tx()
2770                    .map(|body| (btx.txid(), (body, btx.bump_index().is_some())))
2771            })
2772            .collect();
2773        // The manager's names over each walked body other than `tx`.
2774        let mut names_over: HashMap<String, HashSet<String>> = HashMap::new();
2775        // The reads spent on bodies neither proven nor found landed.
2776        let mut reads = 0usize;
2777        let mut asked: HashSet<String> = HashSet::new();
2778        while let Some(candidate) = ask.pop() {
2779            if candidate.is_empty() || !asked.insert(candidate.clone()) {
2780                continue;
2781            }
2782            let spent_outputs = spent_of.get(&candidate).cloned().unwrap_or_default();
2783            // An absent body is asked of the store only when it is one of
2784            // `tx`'s own inputs and the manager names it (E1D).
2785            let body = bodies.get(&candidate).copied();
2786            if body.is_none() {
2787                if !direct.contains(&candidate) {
2788                    continue;
2789                }
2790                if subject_names.is_none() {
2791                    *subject_names = Some(
2792                        self.named_history(manager.as_ref(), beef_bytes, bound)
2793                            .await,
2794                    );
2795                }
2796                let is_named = subject_names.as_ref().is_some_and(|named| {
2797                    spent_outputs
2798                        .iter()
2799                        .any(|vout| named.contains(&format!("{candidate}.{vout}")))
2800                });
2801                if !is_named {
2802                    continue;
2803                }
2804            }
2805            let proven = body.is_some_and(|(_, proven)| proven);
2806            // This candidate's own reads: they join `reads` once it is
2807            // found not landed, and never if the BEEF proves it.
2808            let mut spent = 0usize;
2809            let out_of = |spent: usize| !proven && reads + spent >= PREDECESSOR_READS;
2810            let out_of_reads = || {
2811                wait(format!(
2812                    "{candidate} is not known to have landed: the question ran out of its {PREDECESSOR_READS} reads"
2813                ))
2814            };
2815            // The submit's allowance, over every read: proven or landed,
2816            // this topic or an earlier one.
2817            let out_of_allowance = || {
2818                wait(format!(
2819                    "{candidate} is not known to have landed: the question ran out of the submit's {PREDECESSOR_READS_PER_SUBMIT} reads"
2820                ))
2821            };
2822            let unread = |e: StorageError| {
2823                wait(format!(
2824                    "{candidate} is not known to have landed: the store could not say ({e})"
2825                ))
2826            };
2827            let carried = |why: String| {
2828                Some(NotNow {
2829                    why,
2830                    carried: Some(candidate.clone()),
2831                })
2832            };
2833            let record = AppliedTransaction {
2834                txid: candidate.clone(),
2835                topic: topic.to_string(),
2836            };
2837            // An applied row, or an output held, ends the question for this
2838            // transaction: it landed.
2839            if out_of(spent) {
2840                return out_of_reads();
2841            }
2842            if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2843                return out_of_allowance();
2844            }
2845            spent += 1;
2846            *question_reads += 1;
2847            match stored(bound, self.storage.does_applied_transaction_exist(&record)).await {
2848                Ok(false) => {}
2849                Ok(true) => continue,
2850                Err(e) => return unread(e),
2851            }
2852            if out_of(spent) {
2853                return out_of_reads();
2854            }
2855            if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2856                return out_of_allowance();
2857            }
2858            spent += 1;
2859            *question_reads += 1;
2860            match stored(
2861                bound,
2862                self.storage.find_outputs_for_transaction(&candidate, false),
2863            )
2864            .await
2865            {
2866                Ok(outputs) if outputs.iter().all(|o| o.topic != topic) => {}
2867                Ok(_) => continue,
2868                Err(e) => return unread(e),
2869            }
2870            // Not landed. With no body to walk, the manager's word is the
2871            // whole answer (E1D).
2872            let Some((body, _)) = body else {
2873                return wait(format!(
2874                    "{candidate} has not landed (the manager names it as history; the BEEF does not carry it)"
2875                ));
2876            };
2877            for input in &body.inputs {
2878                let source = input.get_source_txid().unwrap_or_default();
2879                if source.is_empty() {
2880                    continue;
2881                }
2882                if out_of(spent) {
2883                    return out_of_reads();
2884                }
2885                if *question_reads >= PREDECESSOR_READS_PER_SUBMIT {
2886                    return out_of_allowance();
2887                }
2888                spent += 1;
2889                *question_reads += 1;
2890                let coin = stored(
2891                    bound,
2892                    self.storage.find_output(
2893                        &source,
2894                        input.source_output_index,
2895                        Some(topic),
2896                        None,
2897                        false,
2898                    ),
2899                )
2900                .await;
2901                match coin {
2902                    Ok(Some(_)) => return carried(format!("{candidate} has not landed")),
2903                    // Not held: its own transaction may be the one that has
2904                    // not landed.
2905                    Ok(None) => {
2906                        spent_of
2907                            .entry(source.clone())
2908                            .or_default()
2909                            .insert(input.source_output_index);
2910                        spenders
2911                            .entry(source.clone())
2912                            .or_default()
2913                            .push((candidate.clone(), input.source_output_index));
2914                        ask.push(source);
2915                    }
2916                    Err(e) => return unread(e),
2917                }
2918            }
2919            // It spends no coin the topic holds. Does a spender NAME an
2920            // output the walk spends of it (the lens fold, H1)? A names call
2921            // per spender, no store read.
2922            let mut named_outputs: HashSet<u32> = HashSet::new();
2923            for (spender, vout) in spenders.get(&candidate).cloned().unwrap_or_default() {
2924                let names = if spender == txid {
2925                    if subject_names.is_none() {
2926                        *subject_names = Some(
2927                            self.named_history(manager.as_ref(), beef_bytes, bound)
2928                                .await,
2929                        );
2930                    }
2931                    subject_names.as_ref()
2932                } else {
2933                    if !names_over.contains_key(&spender) {
2934                        let names = match beef
2935                            .find_atomic_transaction(&spender)
2936                            .and_then(|spender| spender.to_atomic_beef(true).ok())
2937                        {
2938                            Some(spender_beef) => {
2939                                self.named_history(manager.as_ref(), &spender_beef, bound)
2940                                    .await
2941                            }
2942                            None => HashSet::new(),
2943                        };
2944                        names_over.insert(spender.clone(), names);
2945                    }
2946                    names_over.get(&spender)
2947                };
2948                if names.is_some_and(|named| named.contains(&format!("{candidate}.{vout}"))) {
2949                    named_outputs.insert(vout);
2950                }
2951            }
2952            if !named_outputs.is_empty() {
2953                // Would the manager admit a named output of it with no coins
2954                // (E1D, the opener)? A dry run over the candidate built out
2955                // of the BEEF parsed above (its proof and, unproven, its
2956                // sources linked): no store read, nothing counted.
2957                let judged = beef
2958                    .find_atomic_transaction(&candidate)
2959                    .unwrap_or_else(|| body.clone());
2960                let verdict = bounded(
2961                    bound,
2962                    manager.identify_admissible_outputs(
2963                        &judged,
2964                        &[],
2965                        None,
2966                        mode,
2967                        &TopicAdmittanceContext::DRY_RUN,
2968                    ),
2969                )
2970                .await;
2971                match verdict {
2972                    Ok(Ok(admittance)) => {
2973                        if admittance
2974                            .outputs_to_admit
2975                            .iter()
2976                            .any(|vout| named_outputs.contains(vout))
2977                        {
2978                            return carried(format!(
2979                                "{candidate} has not landed (the manager names the output spent from it and would admit it with no coin: an opener)"
2980                            ));
2981                        }
2982                    }
2983                    Ok(Err(TopicManagerError::NoAdmissibleOutputs(_))) => {}
2984                    Ok(Err(e)) => {
2985                        return wait(format!(
2986                            "{candidate} is not known to have landed: the manager could not judge it ({e})"
2987                        ))
2988                    }
2989                    Err(no_answer) => {
2990                        return wait(format!(
2991                            "{candidate} is not known to have landed: the manager did not answer ({no_answer})"
2992                        ))
2993                    }
2994                }
2995            }
2996            if !proven {
2997                reads += spent;
2998            }
2999        }
3000        None
3001    }
3002
3003    /// The outpoints (`txid.vout`) the topic manager NAMES as overlay
3004    /// history of the transaction `beef_bytes` names (`identify_needed_inputs`,
3005    /// D13's word; an atomic BEEF, so a manager that parses
3006    /// `from_beef(_, None)` reads that transaction). An `Err` or no answer
3007    /// names nothing, as the GASP walk treats it (logged).
3008    async fn named_history(
3009        &self,
3010        manager: &dyn TopicManager,
3011        beef_bytes: &[u8],
3012        bound: Option<&CallBound>,
3013    ) -> HashSet<String> {
3014        match bounded(bound, manager.identify_needed_inputs(beef_bytes, None)).await {
3015            Ok(Ok(named)) => named.iter().map(Outpoint::to_graph_id).collect(),
3016            Ok(Err(e)) => {
3017                warn!("the predecessor question: the manager could not name its inputs: {e}");
3018                HashSet::new()
3019            }
3020            Err(no_answer) => {
3021                warn!("the predecessor question: the manager did not name its inputs: {no_answer}");
3022                HashSet::new()
3023            }
3024        }
3025    }
3026
3027    /// The reference's `tx.verify(chainTracker)` on submit: every unproven
3028    /// transaction from the subject down has every input's script executed
3029    /// against its source output and obeys the value rule; a proven one is
3030    /// not descended, and its BUMP's root is checked against the chain
3031    /// tracker (computed and accepted with none).
3032    ///
3033    /// Since bsv-low #592 (the owner's ruling 3a of 2026-10-10) the scripts
3034    /// are walked by the DOOR's stream walk ([`crate::script_door`]) under
3035    /// `budget`, the engine's [`DoorBudget`]: one walker, the same estimates
3036    /// charged from the bytes before anything is parsed or run. Before it this
3037    /// was a walk of its own over a hydrated `Beef` with no memory charge and
3038    /// no interpreter limit: 219.7 MB natively on a 1.9 MB push-only unproven
3039    /// parent, an isolate kill on Workers, met by every `historical-tx` replay
3040    /// of the queue. The roots are then asked for the proven transactions the
3041    /// walk reached, in its order, once per BUMP.
3042    ///
3043    /// `Ok(Some(_))` is a walk that COULD NOT RUN within the budget, never a
3044    /// refusal (below, at the match). The interpreter's refusal and a
3045    /// structural fault are what they were: `ScriptVerificationFailed`, and
3046    /// `SpvError` in the same words.
3047    ///
3048    /// The tracker is an ARGUMENT, not `self`'s: the GASP anchor check
3049    /// ([`verify_spv_like_the_reference`]) runs this same walk from
3050    /// `OverlayGASPStorage`, which borrows the engine's tracker.
3051    ///
3052    /// Why not bsv-rs `Transaction::verify`: it walks `source_transaction`
3053    /// links that `from_beef` builds by CLONING, once per input. Two inputs
3054    /// sourcing the same unproven parent (a covenant head output plus that
3055    /// spend's own change, the shape of every second head spend of a wallet)
3056    /// leave the second clone bare ("Input 0 has no source transaction"), and
3057    /// re-linking every clone materializes the ancestry EXPONENTIALLY along a
3058    /// diamond chain: a 12-deep unmined chain of head spends took a Worker past
3059    /// its memory on beta (zanaadu, 2026-09-08). The stream walk reads each
3060    /// transaction once, in place, keyed by txid.
3061    async fn verify_beef_linear(
3062        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3063        beef_bytes: &[u8],
3064        subject_txid: &str,
3065        trusted: &HashSet<String>,
3066        budget: DoorBudget,
3067    ) -> Result<Option<WalkCouldNotRun>, EngineError> {
3068        // A structural fault is the reference walk's `SpvError`.
3069        let spv =
3070            |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3071
3072        // THE SCRIPTS (bsv-low #592): the door's own stream walk, under the
3073        // engine's budget. One walker (Rule 10): the door and the submit run
3074        // the same function, the same estimates and the same interpreter
3075        // limit; the submit adds the roots below.
3076        let mut trace = crate::script_door::WalkTrace::default();
3077        match crate::script_door::walk_trusting(
3078            beef_bytes,
3079            subject_txid,
3080            budget,
3081            trusted,
3082            &mut trace,
3083        ) {
3084            Ok(_) => {}
3085            // The interpreter's verdict, the reference's refusal: unchanged.
3086            Err(refused @ EngineError::ScriptVerificationFailed { .. }) => return Err(refused),
3087            // A structural fault (a source missing, the value rule, ...):
3088            // the reference walk's `SpvError`, in the same words as before.
3089            Err(EngineError::ScriptWalkInconclusive { reason, .. }) => return Err(spv(reason)),
3090            // The budget, not the transaction: the walk COULD NOT RUN. Not a
3091            // refusal: the bytes are a BEEF the walk was not given the room
3092            // to judge, which says nothing about whether its scripts are
3093            // valid (refusing would refuse a valid BEEF for its size, the
3094            // owner's posture). Not an admission either: the caller's
3095            // [`WalkBreachPolicy`] decides, and only the network's word on
3096            // record lets the submit go on (the E592 lens fold, H1). The roots
3097            // the walk reached are checked first, below.
3098            Err(EngineError::ScriptWalkOverBudget {
3099                at_txid,
3100                subject_judged,
3101                limb,
3102                what,
3103            }) => {
3104                let limb = match limb {
3105                    DoorLimb::Memory => WalkLimb::OverMemory,
3106                    DoorLimb::Work if trace.interpreter_tripped => WalkLimb::InterpreterMemory,
3107                    DoorLimb::Work => WalkLimb::OverWork,
3108                };
3109                // THE ROOTS OF A BREACHED WALK (the E592 lens fold, H1): every
3110                // proven transaction it reached, and every proven source an
3111                // input read, whatever the scripts' verdict. A bad root is
3112                // the `SpvError` it is on a walk that ran: a breach never
3113                // skips a proof the walk relied on. Their cost is a tracker
3114                // question per BUMP, which the budget does not bound and
3115                // does not need to (the clean walk asks the same).
3116                let reached: Vec<String> = trace
3117                    .proven
3118                    .iter()
3119                    .chain(&trace.proven_sources)
3120                    .cloned()
3121                    .collect();
3122                Self::check_reached_roots(chain_tracker, beef_bytes, &reached).await?;
3123                return Ok(Some(WalkCouldNotRun {
3124                    subject_txid: subject_txid.to_string(),
3125                    at_txid,
3126                    subject_judged,
3127                    limb,
3128                    what,
3129                }));
3130            }
3131            Err(other) => return Err(other),
3132        }
3133
3134        // THE ROOTS: every proven transaction the walk reached, in the order
3135        // it reached them, its BUMP's root computed and asked of the tracker
3136        // once per BUMP (NL-6e). With no tracker the roots are computed and
3137        // accepted (`'scripts only'`). The walk trusted them as it went; the
3138        // reference checks them in the same walk, so a body with BOTH a bad
3139        // script and a bad root now names the script.
3140        Self::check_reached_roots(chain_tracker, beef_bytes, &trace.proven).await?;
3141        Ok(None)
3142    }
3143
3144    /// The roots of `reached` (display txids, in order; a repeat or a second
3145    /// transaction of one BUMP asks nothing more), each BUMP's root computed
3146    /// and asked of the tracker once. None reached: the body is not parsed.
3147    async fn check_reached_roots(
3148        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3149        beef_bytes: &[u8],
3150        reached: &[String],
3151    ) -> Result<(), EngineError> {
3152        let spv =
3153            |msg: String| EngineError::SpvError(format!("Unable to verify SPV information: {msg}"));
3154        if reached.is_empty() {
3155            return Ok(());
3156        }
3157        let beef = beef_limits::parse_beef(beef_bytes, &beef_limits::ENGINE_BEEF_LIMITS)
3158            .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3159        let proven_in = proven_index(&beef);
3160        let mut checked: HashSet<usize> = HashSet::new();
3161        for txid in reached {
3162            let Some(&at) = proven_in.get(txid.as_str()) else {
3163                return Err(spv(format!("transaction {txid} is not in the BEEF")));
3164            };
3165            if !checked.insert(at) {
3166                continue; // its BUMP's root is the chain's, asked once
3167            }
3168            let mp = &beef.bumps[at];
3169            let root = mp
3170                .compute_root(Some(txid))
3171                .map_err(|e| spv(format!("invalid merkle path for transaction {txid}: {e}")))?;
3172            if let Some(tracker) = chain_tracker {
3173                match tracker
3174                    .is_valid_root_for_height(&root, mp.block_height)
3175                    .await
3176                {
3177                    Ok(true) => {}
3178                    Ok(false) => {
3179                        return Err(EngineError::SpvError(format!(
3180                            "Invalid merkle path for transaction {txid}: root {root} is not valid for block height {}",
3181                            mp.block_height
3182                        )));
3183                    }
3184                    Err(e) => {
3185                        return Err(EngineError::SpvError(format!(
3186                            "Chain tracker error at height {}: {e}",
3187                            mp.block_height
3188                        )));
3189                    }
3190                }
3191            }
3192        }
3193        Ok(())
3194    }
3195
3196    /// The pre-2026-09-08 SPV block, kept as the
3197    /// [`Engine::set_script_verification`] `false` escape hatch: BEEF
3198    /// structural validity plus every root against the chain tracker, and
3199    /// NOTHING when no tracker is configured. It never executes a script.
3200    ///
3201    /// The structure is the streaming reader's
3202    /// ([`beef_limits::structure_roots`], bsv-rs 0.4.2): one element in hand,
3203    /// each BUMP walked once, linear in its leaves (NL-6e). Until NL-6e it
3204    /// was `Beef::verify_valid`, whose root check at bsv-rs 0.4.0 walked a
3205    /// BUMP once per leaf: one BUMP of 8,192 leaves gave no answer in 120 s
3206    /// (bsv-stack-lean NL-8 W1). The reader reads the wire's order, where
3207    /// `verify_valid` sorted first: an input that names a later transaction
3208    /// is refused here.
3209    async fn verify_spv_structurally(
3210        chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
3211        beef_bytes: &[u8],
3212    ) -> Result<(), EngineError> {
3213        let Some(chain_tracker) = chain_tracker else {
3214            return Ok(());
3215        };
3216        let roots = beef_limits::structure_roots(beef_bytes)
3217            .map_err(|r| EngineError::SpvError(format!("BEEF structure refused: {r}")))?;
3218        let mut asked: HashSet<(u64, &str)> = HashSet::new();
3219        for (height, root) in &roots {
3220            if !asked.insert((*height, root.as_str())) {
3221                continue;
3222            }
3223            let Ok(height) = u32::try_from(*height) else {
3224                return Err(EngineError::SpvError(format!(
3225                    "Merkle root {root} invalid for block height {height}"
3226                )));
3227            };
3228            match chain_tracker.is_valid_root_for_height(root, height).await {
3229                Ok(true) => {}
3230                Ok(false) => {
3231                    return Err(EngineError::SpvError(format!(
3232                        "Merkle root {root} invalid for block height {height}"
3233                    )));
3234                }
3235                Err(e) => {
3236                    return Err(EngineError::SpvError(format!(
3237                        "Chain tracker error at height {height}: {e}"
3238                    )));
3239                }
3240            }
3241        }
3242        Ok(())
3243    }
3244
3245    /// Phase 1 of ONE topic: the dedup read, the previous coins, the
3246    /// manager's judgement. Read-only. [`Engine::run_validation`] asks it of
3247    /// every topic; the door asks it again of a topic whose predecessor it
3248    /// landed first from the BEEF (lane E1D's lens fold, M1).
3249    #[allow(clippy::too_many_arguments)]
3250    async fn validate_topic(
3251        &self,
3252        tx: &Transaction,
3253        txid: &str,
3254        topic: &str,
3255        off_chain_values: Option<&[u8]>,
3256        mode: SubmitMode,
3257        context: &TopicAdmittanceContext,
3258        bound: Option<&CallBound>,
3259    ) -> TopicValidation {
3260        let tx_record = AppliedTransaction {
3261            txid: txid.to_string(),
3262            topic: topic.to_string(),
3263        };
3264        // A dedup read that FAULTS is the topic's read fault (the delta
3265        // fold of 2026-10-07, H3): nothing is judged and nothing is
3266        // written for the topic, the fault rides the report and the
3267        // replay asks again. The reference fails the topic there
3268        // (`Engine.ts` `submit`: the throw lands in the per-topic catch,
3269        // `failedTopics.add`). Read as "not a dupe", as it was, a
3270        // re-presented transaction the manager admits with no previous
3271        // coin was inserted a second time, unspent, beside the output
3272        // that had spent it, under a durable report.
3273        let is_dupe = match stored(
3274            bound,
3275            self.storage.does_applied_transaction_exist(&tx_record),
3276        )
3277        .await
3278        {
3279            Ok(is_dupe) => is_dupe,
3280            Err(e) => {
3281                error!("Error reading the applied row of {txid} for topic {topic}: {e}");
3282                return TopicValidation {
3283                    topic: topic.to_string(),
3284                    is_dupe: false,
3285                    previous_coins: vec![],
3286                    previous_outputs: vec![],
3287                    admittance: AdmittanceInstructions::default(),
3288                    failed: false,
3289                    read_fault: Some(("does_applied_transaction_exist", e.to_string())),
3290                };
3291            }
3292        };
3293
3294        if is_dupe {
3295            return TopicValidation {
3296                topic: topic.to_string(),
3297                is_dupe: true,
3298                previous_coins: vec![],
3299                previous_outputs: vec![],
3300                admittance: AdmittanceInstructions::default(),
3301                failed: false,
3302                read_fault: None,
3303            };
3304        }
3305
3306        let mut previous_coins: Vec<u32> = Vec::new();
3307        let mut previous_outputs: Vec<Output> = Vec::new();
3308        let mut read_fault: Option<(&'static str, String)> = None;
3309
3310        for (input_idx, input) in tx.inputs.iter().enumerate() {
3311            let source_txid = input.get_source_txid().unwrap_or_default();
3312            if source_txid.is_empty() {
3313                continue;
3314            }
3315            match stored(
3316                bound,
3317                self.storage.find_output(
3318                    &source_txid,
3319                    input.source_output_index,
3320                    Some(topic),
3321                    None,
3322                    false,
3323                ),
3324            )
3325            .await
3326            {
3327                Ok(Some(prev_output)) => {
3328                    previous_coins.push(input_idx as u32);
3329                    previous_outputs.push(prev_output);
3330                }
3331                Ok(None) => {}
3332                Err(e) => {
3333                    // A faulted read is NOT "no previous coin": the
3334                    // spend may consume an admitted output we simply
3335                    // could not read. Validation proceeds (the topic
3336                    // manager decides on what it can see) but the fault
3337                    // rides the report and blocks the applied record.
3338                    error!(
3339                        "Error reading previous output {source_txid}:{} for topic {topic}: {e}",
3340                        input.source_output_index
3341                    );
3342                    if read_fault.is_none() {
3343                        read_fault = Some(("find_output", e.to_string()));
3344                    }
3345                }
3346            }
3347        }
3348
3349        let judged = self
3350            .judge(
3351                tx,
3352                topic,
3353                &previous_coins,
3354                off_chain_values,
3355                mode,
3356                context,
3357                bound,
3358            )
3359            .await;
3360        match judged {
3361            Ok(admittance) => TopicValidation {
3362                topic: topic.to_string(),
3363                is_dupe: false,
3364                previous_coins,
3365                previous_outputs,
3366                admittance,
3367                failed: false,
3368                read_fault: read_fault.clone(),
3369            },
3370            Err(e) => {
3371                error!("Error validating topic {topic} during submit: {e}");
3372                TopicValidation {
3373                    topic: topic.to_string(),
3374                    is_dupe: false,
3375                    previous_coins: vec![],
3376                    previous_outputs: vec![],
3377                    admittance: AdmittanceInstructions::default(),
3378                    failed: true,
3379                    read_fault,
3380                }
3381            }
3382        }
3383    }
3384
3385    /// The topic manager's judgement of `tx` over the previous coins found
3386    /// (input indices), as [`Engine::validate_topic`] asks it; a carried
3387    /// predecessor's submit asks it again for real once its topic does not
3388    /// wait (the E1D delta fold, L4).
3389    #[allow(clippy::too_many_arguments)]
3390    async fn judge(
3391        &self,
3392        tx: &Transaction,
3393        topic: &str,
3394        previous_coins: &[u32],
3395        off_chain_values: Option<&[u8]>,
3396        mode: SubmitMode,
3397        context: &TopicAdmittanceContext,
3398        bound: Option<&CallBound>,
3399    ) -> Result<AdmittanceInstructions, TopicManagerError> {
3400        let manager = &self.managers[topic];
3401        let previous_coin_bytes = previous_coins
3402            .iter()
3403            .flat_map(|i| i.to_le_bytes())
3404            .collect::<Vec<u8>>();
3405        // Under a finalize submit's bound a manager that does not answer
3406        // is a manager that failed: nothing is written for the topic.
3407        bounded(
3408            bound,
3409            manager.identify_admissible_outputs(
3410                // The ONE engine parse of the submitted BEEF, shared by
3411                // every topic on this submit (bsv-low #289 — managers
3412                // used to re-parse the same bytes independently).
3413                tx,
3414                &previous_coin_bytes,
3415                off_chain_values,
3416                mode,
3417                // `dry_run` is `false` on a submit, `true` on a
3418                // validate-only call.
3419                context,
3420            ),
3421        )
3422        .await
3423        .map_err(TopicManagerError::Other)
3424        .and_then(|judged| judged)
3425    }
3426
3427    /// Whether the door may land a carried predecessor into `topic` (the
3428    /// E1D delta fold, L1): neither its manager nor any lookup service
3429    /// reads off-chain values there, which the landing does not have.
3430    fn lands_without_values(&self, topic: &str) -> bool {
3431        self.managers
3432            .get(topic)
3433            .is_some_and(|manager| !manager.reads_off_chain_values())
3434            && self
3435                .lookup_services
3436                .values()
3437                .all(|ls| !ls.reads_off_chain_values(topic))
3438    }
3439
3440    /// Run Phase 1 (topic validation) and Phase 2 (broadcast) without mutating storage.
3441    ///
3442    /// Returns (validations, steak, parsed_tx, txid) so the caller can either
3443    /// stop (validate-only) or proceed with Phase 3 (mutations).
3444    async fn run_validation(
3445        &self,
3446        tagged_beef: &TaggedBEEF,
3447        mode: SubmitMode,
3448        context: &TopicAdmittanceContext,
3449        bound: Option<&CallBound>,
3450        walked: Option<&HashSet<String>>,
3451        policy: WalkBreachPolicy,
3452    ) -> Result<Validated, EngineError> {
3453        // Validate all topics are supported
3454        for topic in &tagged_beef.topics {
3455            if !self.managers.contains_key(topic) {
3456                return Err(EngineError::UnsupportedTopic(topic.clone()));
3457            }
3458        }
3459
3460        // Parse the SUBJECT from the BEEF — order-independent (`subject.rs`,
3461        // loop-2 hardening 2026-09-05): `from_beef(_, None)` takes the last
3462        // tx in wire order, which an SDK-sorted INCOMPLETE ancestry fills
3463        // with a fully-sourced ancestor; the engine then judged a hop, admitted
3464        // nothing and the pot never entered the index while its JOIN mined.
3465        let subject = {
3466            let mut b =
3467                beef_limits::parse_beef(&tagged_beef.beef, &beef_limits::ENGINE_BEEF_LIMITS)
3468                    .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3469            crate::subject::subject_txid_of(&mut b)
3470        };
3471        let tx = beef_limits::transaction_from_beef(
3472            &tagged_beef.beef,
3473            subject.as_deref(),
3474            &beef_limits::ENGINE_BEEF_LIMITS,
3475        )
3476        .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3477        let txid = tx.id();
3478
3479        // SPV verification, skipped ONLY for HistoricalTxNoSpv, exactly the
3480        // reference's `if (mode !== 'historical-tx-no-spv') tx.verify(...)`.
3481        // That mode exists for GASP `finalizeGraph`, whose graphs
3482        // `validateGraphAnchor` already verified, and since bsv-low #551 that
3483        // is TRUE here: `OverlayGASPStorage::validate_graph_anchor` runs this
3484        // same check ([`verify_spv_like_the_reference`], the same tracker and
3485        // the same switch) over the ROOT node's BEEF and replays the graph
3486        // through the topic manager before a single BEEF of it reaches this
3487        // door. Before #551 the anchor check was a no-op and this skip
3488        // admitted a peer's graph on structure alone.
3489        //
3490        // A predecessor the door lands first (`walked`, the E1D delta fold,
3491        // L4) is not walked again when this submit's walks already passed
3492        // over it, and its walk trusts what they passed over.
3493        //
3494        // The walk runs under the engine's budget (bsv-low #592,
3495        // [`Engine::set_walk_budget`]): one that could not run within it is
3496        // NOT a refusal (see `verify_beef_linear`), and NOT an admission
3497        // unless the caller holds the network's accept of these bytes (the
3498        // E592 lens fold, H1, [`WalkBreachPolicy`]): "not now" before any
3499        // manager is asked, or, under `NetworkAccepted`, this submit goes on
3500        // as under `HistoricalTxNoSpv` and the report says so.
3501        let mut could_not_run = None;
3502        if mode != SubmitMode::HistoricalTxNoSpv {
3503            match walked {
3504                Some(walked) if walked.contains(&txid) => {}
3505                trusted => {
3506                    could_not_run = verify_spv_trusting(
3507                        self.chain_tracker.as_deref(),
3508                        self.verify_scripts,
3509                        &tagged_beef.beef,
3510                        &txid,
3511                        trusted.unwrap_or(&HashSet::new()),
3512                        self.walk_budget,
3513                    )
3514                    .await?;
3515                }
3516            }
3517        }
3518        if let Some(stop) = could_not_run.take() {
3519            match policy {
3520                WalkBreachPolicy::NotNow => {
3521                    warn!(
3522                        "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): not now, nothing judged or written; present it proven (bsv-low #592, E592 fold)",
3523                        stop.subject_txid,
3524                        stop.limb.as_str(),
3525                        stop.at_txid,
3526                        stop.subject_judged,
3527                        stop.what
3528                    );
3529                    return Err(EngineError::WalkCouldNotRun(stop));
3530                }
3531                WalkBreachPolicy::NetworkAccepted => {
3532                    warn!(
3533                        "submit: the engine's script walk could not run for {} ({}: at {}, subject judged: {}; {}): the network accepted these bytes, the submit goes on as historical-tx-no-spv (bsv-low #592)",
3534                        stop.subject_txid,
3535                        stop.limb.as_str(),
3536                        stop.at_txid,
3537                        stop.subject_judged,
3538                        stop.what
3539                    );
3540                    could_not_run = Some(stop);
3541                }
3542            }
3543        }
3544
3545        let mut steak = Steak::new();
3546
3547        // =============================================================
3548        // PHASE 1: VALIDATE (read-only)
3549        // =============================================================
3550        let mut validations = Vec::new();
3551
3552        for topic in &tagged_beef.topics {
3553            validations.push(
3554                self.validate_topic(
3555                    &tx,
3556                    &txid,
3557                    topic,
3558                    tagged_beef.off_chain_values.as_deref(),
3559                    mode,
3560                    context,
3561                    bound,
3562                )
3563                .await,
3564            );
3565        }
3566
3567        // Build preliminary STEAK
3568        for v in &validations {
3569            steak.insert(v.topic.clone(), v.admittance.clone());
3570        }
3571
3572        // =============================================================
3573        // PHASE 2: BROADCAST / SHIP propagation (before mutations)
3574        // =============================================================
3575        if mode == SubmitMode::CurrentTx {
3576            // ── ARC network broadcast ──────────────────────────────────
3577            // Broadcast to miners via ARC, matching the TS Engine pattern.
3578            // Skip if the tx already has a merkle path (already mined).
3579            //
3580            // INCIDENT D1-CALLBACK-FLOOD 2026-09-01: also skip when EVERY
3581            // topic classified the tx as a dupe (already applied) — the
3582            // exact `!v.is_dupe` filter the SHIP arm below has always had,
3583            // ten lines away. Without it, every RE-PRESENT of an
3584            // already-applied tx (cron ad-sync, peer crawl, a client
3585            // retrying a default-mode /submit) re-POSTed to Arcade and
3586            // re-registered the status callback, and registrations
3587            // accumulate per POST on Arcade's side — the flood's fuel. A
3588            // genuinely new tx (any topic non-dupe) broadcasts exactly as
3589            // before.
3590            let all_topics_dupe = !validations.is_empty() && validations.iter().all(|v| v.is_dupe);
3591            if let Some(ref arc) = self.arc_broadcaster {
3592                if all_topics_dupe {
3593                    info!(
3594                        "Skipping ARC broadcast — tx already applied to every topic (re-present)"
3595                    );
3596                } else if tx.merkle_path.is_none() {
3597                    let raw_tx_hex = tx.to_hex();
3598                    match arc.broadcast(&raw_tx_hex).await {
3599                        Ok(arc_txid) => {
3600                            info!("ARC broadcast succeeded: txid={arc_txid}");
3601                        }
3602                        Err(e) => {
3603                            error!("ARC broadcast failed (non-fatal): {e}");
3604                        }
3605                    }
3606                } else {
3607                    info!("Skipping ARC broadcast — tx already has merkle proof");
3608                }
3609            }
3610
3611            // ── SHIP peer propagation ──────────────────────────────────
3612            if let Some(ref broadcaster) = self.broadcaster {
3613                let relevant_topics: Vec<String> = validations
3614                    .iter()
3615                    .filter(|v| {
3616                        !v.is_dupe && !v.failed && !v.admittance.outputs_to_admit.is_empty()
3617                    })
3618                    .map(|v| v.topic.clone())
3619                    .collect();
3620
3621                if !relevant_topics.is_empty() {
3622                    if let Some(ship_ls) = self.lookup_services.get("ls_ship") {
3623                        for topic in &relevant_topics {
3624                            let question = LookupQuestion::new(
3625                                "ls_ship",
3626                                serde_json::json!({ "topics": [topic] }),
3627                            );
3628                            match ship_ls.lookup(&question).await {
3629                                Ok(LookupResult::OutputList(refs)) => {
3630                                    for reference in &refs {
3631                                        if let Ok(Some(output)) = self
3632                                            .storage
3633                                            .find_output(
3634                                                &reference.txid,
3635                                                reference.output_index,
3636                                                None,
3637                                                None,
3638                                                false,
3639                                            )
3640                                            .await
3641                                        {
3642                                            if let Some(domain) =
3643                                                parse_ship_domain_from_script(&output.output_script)
3644                                            {
3645                                                if let Some(ref our_url) = self.config.hosting_url {
3646                                                    if domain.trim_end_matches('/')
3647                                                        == our_url.trim_end_matches('/')
3648                                                    {
3649                                                        continue;
3650                                                    }
3651                                                }
3652                                                if let Err(e) = broadcaster
3653                                                    .broadcast_to_host(&domain, tagged_beef)
3654                                                    .await
3655                                                {
3656                                                    error!(
3657                                                        "SHIP propagation to {domain} failed for topic {topic}: {e}"
3658                                                    );
3659                                                }
3660                                            }
3661                                        }
3662                                    }
3663                                }
3664                                Ok(LookupResult::Answer(_)) => {
3665                                    // SHIP only ever returns OutputList; an Answer here means
3666                                    // a misconfigured replacement service. Skip rather than
3667                                    // attempt to extract refs from a freeform/formula payload.
3668                                    error!(
3669                                        "SHIP lookup for topic {topic} returned a pre-formed \
3670                                         LookupAnswer; expected OutputList. Skipping fanout."
3671                                    );
3672                                }
3673                                Err(e) => {
3674                                    error!("SHIP lookup for topic {topic} failed: {e}");
3675                                }
3676                            }
3677                        }
3678                    }
3679                }
3680            }
3681        }
3682
3683        Ok((validations, steak, tx, txid, could_not_run))
3684    }
3685
3686    // ========================================================================
3687    // Lookup
3688    // ========================================================================
3689
3690    /// Answer a lookup query.
3691    ///
3692    /// Delegates to the appropriate LookupService, then hydrates results with BEEF.
3693    ///
3694    /// When `history_selector` is provided, each output is hydrated with its
3695    /// ancestor spend chain (via `get_utxo_history`) before building the response.
3696    /// This matches the TS Engine behavior when a history selector is configured.
3697    pub async fn lookup(
3698        &self,
3699        question: &LookupQuestion,
3700        history_selector: Option<HistorySelector>,
3701    ) -> Result<LookupAnswer, EngineError> {
3702        Ok(self.lookup_with_txids(question, history_selector).await?.0)
3703    }
3704
3705    /// Same as [`Engine::lookup`], additionally returning each hydrated
3706    /// output's txid, aligned index-for-index with the `OutputList` items
3707    /// (empty for pre-formed `Answer`s).
3708    ///
3709    /// The txid IS the storage primary key the row was just fetched by —
3710    /// handing it to the caller lets the aggregated wire serializer write it
3711    /// directly instead of re-deriving it with a full BEEF parse plus a
3712    /// double-SHA256 per output (bsv-low #289).
3713    pub async fn lookup_with_txids(
3714        &self,
3715        question: &LookupQuestion,
3716        history_selector: Option<HistorySelector>,
3717    ) -> Result<(LookupAnswer, Vec<String>), EngineError> {
3718        let service = self
3719            .lookup_services
3720            .get(&question.service)
3721            .ok_or_else(|| EngineError::LookupServiceNotFound(question.service.clone()))?;
3722
3723        // Preserve WHOSE fault it was. A lookup service reports a malformed
3724        // query as `InvalidQuery` / `Unsupported`; stringifying both into
3725        // `LookupFailed` (as this did) threw that away and reported every
3726        // caller mistake as a 500.
3727        let result = service.lookup(question).await.map_err(|e| match e {
3728            LookupServiceError::InvalidQuery(m) | LookupServiceError::Unsupported(m) => {
3729                EngineError::InvalidQuery(m)
3730            }
3731            other => EngineError::LookupFailed(other.to_string()),
3732        })?;
3733
3734        // Two paths per LookupResult:
3735        // - OutputList(refs): the LS yields outpoints; we hydrate each with
3736        //   BEEF (and optional ancestor chain via history_selector) and
3737        //   assemble LookupAnswer::OutputList.
3738        // - Answer(answer): the LS already produced the full answer
3739        //   (Freeform/Formula); pass through verbatim. The Engine does NOT
3740        //   apply history-selector hydration to a pre-formed Answer — the
3741        //   LS is presumed to have embedded whatever ancestry it wants.
3742        let refs = match result {
3743            LookupResult::OutputList(refs) => refs,
3744            LookupResult::Answer(answer) => return Ok((answer, Vec::new())),
3745        };
3746
3747        // ONE batched hydration query for the whole result set instead of a
3748        // D1 round-trip per row (the #289 N+1). Order is preserved: the
3749        // batch contract returns outputs in input-outpoint order, skipping
3750        // outpoints with no stored row. A storage error is a REAL error now
3751        // (it used to be swallowed per-row into a silently-empty list — the
3752        // "vanishing table" failure mode): an uncertain read must never
3753        // masquerade as a confidently-empty lobby.
3754        let outpoints: Vec<Outpoint> = refs
3755            .iter()
3756            .map(|r| Outpoint::new(&r.txid, r.output_index))
3757            .collect();
3758        let found = self
3759            .storage
3760            .find_served_outputs(&outpoints)
3761            .await
3762            .map_err(|e| EngineError::StorageError(e.to_string()))?;
3763
3764        let mut outputs = Vec::new();
3765        let mut txids = Vec::new();
3766        for output in found {
3767            // If history selector provided, hydrate ancestor chain
3768            let final_output = if history_selector.is_some() {
3769                match self
3770                    .get_utxo_history(&output, history_selector.clone())
3771                    .await
3772                {
3773                    Ok(Some(hydrated)) => hydrated,
3774                    _ => output,
3775                }
3776            } else {
3777                output
3778            };
3779
3780            // Skip an output whose BEEF is missing OR empty: a lookup item
3781            // without decodable BEEF is useless to the client (it can only
3782            // drop it), and returning an empty-BEEF row is what made a fresh
3783            // LOW table show up in a query yet be undecodable/invisible to
3784            // opponents (the "vanishing table" — 2026-07-11). Only ever
3785            // return fully-hydrated, decodable outputs.
3786            if let Some(beef) = final_output.beef {
3787                if !beef.is_empty() {
3788                    txids.push(final_output.txid.clone());
3789                    outputs.push(OutputListItem {
3790                        beef,
3791                        output_index: final_output.output_index,
3792                        context: None,
3793                    });
3794                }
3795            }
3796        }
3797
3798        Ok((LookupAnswer::OutputList { outputs }, txids))
3799    }
3800
3801    // ========================================================================
3802    // Metadata
3803    // ========================================================================
3804
3805    // ========================================================================
3806    // UTXO History
3807    // ========================================================================
3808
3809    /// Traverse and return the history of a UTXO.
3810    ///
3811    /// If no `history_selector` is provided, returns the output as-is.
3812    /// If a depth selector is provided, includes up to N levels of ancestor
3813    /// transactions by embedding them as source_transactions in the BEEF.
3814    pub async fn get_utxo_history(
3815        &self,
3816        output: &Output,
3817        history_selector: Option<HistorySelector>,
3818    ) -> Result<Option<Output>, EngineError> {
3819        let Some(selector) = history_selector else {
3820            return Ok(Some(output.clone()));
3821        };
3822
3823        // Verify BEEF exists before attempting hydration
3824        if output.beef.is_none() {
3825            return Err(EngineError::Other(
3826                "Output must have associated transaction BEEF!".into(),
3827            ));
3828        }
3829
3830        match self.hydrate_utxo_history(output, &selector, 0).await {
3831            Ok(Some(hydrated_output)) => Ok(Some(hydrated_output)),
3832            Ok(None) => Ok(None),
3833            Err(e) => Err(EngineError::Other(format!(
3834                "Error retrieving UTXO history: {e}"
3835            ))),
3836        }
3837    }
3838
3839    /// Recursive UTXO history hydration.
3840    fn hydrate_utxo_history<'a>(
3841        &'a self,
3842        output: &'a Output,
3843        selector: &'a HistorySelector,
3844        current_depth: u32,
3845    ) -> std::pin::Pin<
3846        Box<dyn std::future::Future<Output = Result<Option<Output>, EngineError>> + 'a>,
3847    > {
3848        Box::pin(async move {
3849            let beef_data = output.beef.as_ref().ok_or_else(|| {
3850                EngineError::Other("Output must have associated transaction BEEF!".into())
3851            })?;
3852
3853            // Check if we should traverse at this depth
3854            let should_traverse = match selector {
3855                HistorySelector::Depth(max_depth) => current_depth <= *max_depth,
3856            };
3857
3858            if !should_traverse {
3859                return Ok(None);
3860            }
3861
3862            // Start from the STORED BEEF, not from a re-parsed subject.
3863            //
3864            // #4: this used to be `Transaction::from_beef(beef_data, None)` followed
3865            // by `tx.to_beef(true)`, which keeps only the subject transaction and
3866            // therefore DESTROYS the stored BUMPs. Measured on prod before the fix:
3867            // `ls_low` byGameId `73de6e48…` returned 1,172 B / 1 tx / 1 bump (PROVEN)
3868            // with no header, and 676 B / 1 tx / 0 bumps with `x-history-depth: 3` —
3869            // a caller asking for MORE provenance got back a BEEF no wallet can
3870            // verify, silently, behind a 200.
3871            //
3872            // Accumulating into the stored BEEF fixes both halves: its own bumps
3873            // survive, and so does any ancestry that arrived inside the submitted
3874            // bytes but was never admitted as an output of its own (a foreign
3875            // parent), which the `outputs_consumed` walk below cannot see.
3876            let mut acc = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
3877                .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
3878
3879            // For each consumed output, recursively hydrate and embed as source transaction
3880            for consumed in &output.outputs_consumed {
3881                if let Ok(Some(child_output)) = self
3882                    .storage
3883                    .find_output(&consumed.txid, consumed.output_index, None, None, true)
3884                    .await
3885                {
3886                    if let Ok(Some(hydrated_child)) = self
3887                        .hydrate_utxo_history(&child_output, selector, current_depth + 1)
3888                        .await
3889                    {
3890                        // MERGE the hydrated ancestor in, rather than embedding it
3891                        // as a `source_transaction` on a re-parsed subject: a merge
3892                        // carries the child's own BUMPs across, which is the whole
3893                        // point of asking for history. An unparseable child is
3894                        // skipped — never allowed to poison the accumulator.
3895                        if let Some(ref child_beef) = hydrated_child.beef {
3896                            if let Ok(child) = Beef::from_binary(child_beef) {
3897                                acc.merge_beef(&child);
3898                            }
3899                        }
3900                    }
3901                }
3902            }
3903
3904            Ok(Some(Output {
3905                beef: Some(acc.to_binary()),
3906                ..output.clone()
3907            }))
3908        })
3909    }
3910
3911    // ========================================================================
3912    // Metadata
3913    // ========================================================================
3914
3915    pub async fn list_topic_managers(&self) -> HashMap<String, ServiceMetadata> {
3916        let mut result = HashMap::new();
3917        for (name, manager) in &self.managers {
3918            let meta = manager.get_metadata().await;
3919            result.insert(name.clone(), meta);
3920        }
3921        result
3922    }
3923
3924    /// List all registered lookup services with their metadata.
3925    pub async fn list_lookup_service_providers(&self) -> HashMap<String, ServiceMetadata> {
3926        let mut result = HashMap::new();
3927        for (name, service) in &self.lookup_services {
3928            let meta = service.get_metadata().await;
3929            result.insert(name.clone(), meta);
3930        }
3931        result
3932    }
3933
3934    /// Get documentation for a specific topic manager.
3935    pub async fn get_documentation_for_topic_manager(&self, name: &str) -> String {
3936        match self.managers.get(name) {
3937            Some(manager) => manager.get_documentation().await,
3938            None => "No documentation found!".to_string(),
3939        }
3940    }
3941
3942    /// Get documentation for a specific lookup service.
3943    pub async fn get_documentation_for_lookup_service(&self, name: &str) -> String {
3944        match self.lookup_services.get(name) {
3945            Some(service) => service.get_documentation().await,
3946            None => "No documentation found!".to_string(),
3947        }
3948    }
3949
3950    // ========================================================================
3951    // GASP endpoints
3952    // ========================================================================
3953
3954    /// Respond to a GASP initial sync request.
3955    ///
3956    /// Returns UTXOs for the given topic since the requested score.
3957    ///
3958    /// The caller-supplied `limit` is CLAMPED to
3959    /// [`Engine::SYNC_RESPONSE_MAX_LIMIT`] (and defaulted when absent):
3960    /// `/requestSyncResponse` is a PUBLIC route, and an absent limit used to
3961    /// mean an unbounded scan of the outputs table (bsv-low #291). Bounding
3962    /// is lossless for sync correctness because the initiator
3963    /// ([`crate::gasp::GASPSync`]) pages for as long as its `since` score
3964    /// cursor advances — a truncated page is fetched by its NEXT request
3965    /// within the same run (gate finding M1: pagination must not demand a
3966    /// full page, which this clamp can make impossible).
3967    pub async fn provide_foreign_sync_response(
3968        &self,
3969        request: &GASPInitialRequest,
3970        topic: &str,
3971    ) -> Result<GASPInitialResponse, EngineError> {
3972        let limit = Self::clamp_sync_limit(request.limit);
3973        let outputs = self
3974            .storage
3975            .find_utxos_for_topic(topic, Some(request.since as f64), Some(limit), false)
3976            .await
3977            .map_err(|e| EngineError::StorageError(e.to_string()))?;
3978
3979        Ok(GASPInitialResponse {
3980            utxo_list: outputs
3981                .iter()
3982                .map(|o| GASPOutput {
3983                    txid: o.txid.clone(),
3984                    output_index: o.output_index,
3985                    score: o.score.unwrap_or(0.0),
3986                })
3987                .collect(),
3988            since: request.since,
3989        })
3990    }
3991
3992    /// Provide a GASPNode for a specific transaction within a graph.
3993    ///
3994    /// Searches the BEEF tree of the root output (identified by graphID) for
3995    /// the requested txid. If not found in the BEEF tree, recurses through
3996    /// outputsConsumed in storage.
3997    pub async fn provide_foreign_gasp_node(
3998        &self,
3999        graph_id: &str,
4000        txid: &str,
4001        output_index: u32,
4002    ) -> Result<GASPNode, EngineError> {
4003        let root_outpoint = Outpoint::from_graph_id(graph_id)
4004            .ok_or_else(|| EngineError::Other(format!("Invalid graphID: {graph_id}")))?;
4005
4006        let root_output = self
4007            .storage
4008            .find_output(
4009                &root_outpoint.txid,
4010                root_outpoint.output_index,
4011                None,
4012                None,
4013                true,
4014            )
4015            .await
4016            .map_err(|e| EngineError::StorageError(e.to_string()))?
4017            .ok_or(EngineError::NodeNotFound)?;
4018
4019        self.hydrate_gasp_node(&root_output, graph_id, txid, output_index)
4020            .await
4021    }
4022
4023    /// The nodes of up to [`crate::node_batch::NODE_BATCH_MAX_OUTPOINTS`]
4024    /// outpoints in one answer of at most
4025    /// [`crate::node_batch::NODE_BATCH_MAX_BYTES`] (engine 0.2.1, #58): each
4026    /// as [`Self::provide_foreign_gasp_node`] answers it for its own graph,
4027    /// in the asked order, a repeat answered once. `NodeNotFound` is the
4028    /// entry `not_found`; any other error the entry `error`. The outpoints
4029    /// from the one that does not fit on are left out (the asker names
4030    /// them again); a node larger than the whole budget is `over_budget`.
4031    pub async fn provide_foreign_gasp_nodes(
4032        &self,
4033        outpoints: &[crate::node_batch::BatchOutpoint],
4034    ) -> Result<crate::node_batch::NodeBatchAnswer, EngineError> {
4035        use crate::node_batch::{
4036            refusal, NodeBatchEntry, Packer, NODE_BATCH_MAX_BYTES, NODE_BATCH_MAX_OUTPOINTS,
4037        };
4038        if outpoints.len() > NODE_BATCH_MAX_OUTPOINTS {
4039            return Err(EngineError::InvalidQuery(format!(
4040                "a node batch names at most {NODE_BATCH_MAX_OUTPOINTS} outpoints"
4041            )));
4042        }
4043        let mut packer = Packer::new(NODE_BATCH_MAX_BYTES);
4044        let mut seen = std::collections::HashSet::new();
4045        for outpoint in outpoints {
4046            if !seen.insert(outpoint) {
4047                continue;
4048            }
4049            let graph_id = outpoint.key();
4050            let answer = self
4051                .provide_foreign_gasp_node(&graph_id, &outpoint.txid, outpoint.vout)
4052                .await;
4053            let (node, refused) = match answer {
4054                Ok(node) => (Some(node), None),
4055                Err(EngineError::NodeNotFound) => (None, Some(refusal::NOT_FOUND)),
4056                Err(_) => (None, Some(refusal::ERROR)),
4057            };
4058            if !packer.push(NodeBatchEntry {
4059                txid: outpoint.txid.clone(),
4060                vout: outpoint.vout,
4061                node,
4062                refused: refused.map(str::to_string),
4063            }) {
4064                break;
4065            }
4066        }
4067        Ok(packer.answer)
4068    }
4069
4070    /// Recursively search a BEEF tree for a specific txid and return a GASPNode.
4071    fn hydrate_gasp_node<'a>(
4072        &'a self,
4073        output: &'a Output,
4074        graph_id: &'a str,
4075        txid: &'a str,
4076        output_index: u32,
4077    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<GASPNode, EngineError>> + 'a>>
4078    {
4079        Box::pin(async move {
4080            let beef_data = output.beef.as_ref().ok_or(EngineError::NodeNotFound)?;
4081
4082            // The stored body's SUBJECT by the same order-independent rule
4083            // (`subject.rs`) — a plain stored body's last tx can be an ancestor.
4084            let root = beef_limits::parse_beef(beef_data, &beef_limits::STORED_BEEF_LIMITS)
4085                .ok()
4086                .and_then(|mut b| crate::subject::subject_txid_of(&mut b));
4087            let root_tx = beef_limits::transaction_from_beef(
4088                beef_data,
4089                root.as_deref(),
4090                &beef_limits::STORED_BEEF_LIMITS,
4091            )
4092            .map_err(|e| EngineError::BeefParseError(e.to_string()))?;
4093
4094            // Search the transaction tree for the requested txid
4095            if let Some(node) = Self::search_tx_tree(&root_tx, graph_id, txid, output_index) {
4096                return Ok(node);
4097            }
4098
4099            // Fallback: try parsing BEEF with the target txid directly
4100            // (some BEEF structures have the tx in the list but not linked as source_transaction)
4101            if let Some(node) = Self::search_beef_for_txid(beef_data, graph_id, txid, output_index)
4102            {
4103                return Ok(node);
4104            }
4105
4106            // Not found in BEEF tree — recurse through outputsConsumed in storage
4107            for consumed in &output.outputs_consumed {
4108                if let Ok(Some(consumed_output)) = self
4109                    .storage
4110                    .find_output(&consumed.txid, consumed.output_index, None, None, true)
4111                    .await
4112                {
4113                    if let Ok(node) = self
4114                        .hydrate_gasp_node(&consumed_output, graph_id, txid, output_index)
4115                        .await
4116                    {
4117                        return Ok(node);
4118                    }
4119                }
4120            }
4121
4122            // The graph root is known but neither its BEEF nor anything it
4123            // consumed holds the requested transaction. That is a definite
4124            // "not held", the same answer as an unknown root: the reference
4125            // throws `Unable to find output associated with your request!`
4126            // here and its route answers 400. A syncing peer reads the 400 as
4127            // `GASPError::NodeNotFound` and prunes a decoy input on it (the D8
4128            // rule, `GASPSync::process_incoming_node`); a 500 would read as a
4129            // fault of the moment and strand the chain behind the decoy.
4130            Err(EngineError::NodeNotFound)
4131        })
4132    }
4133
4134    /// Search a Transaction tree (via sourceTransaction links) for a specific txid.
4135    fn search_tx_tree(
4136        tx: &Transaction,
4137        graph_id: &str,
4138        target_txid: &str,
4139        output_index: u32,
4140    ) -> Option<GASPNode> {
4141        let current_txid = tx.id();
4142
4143        if current_txid == target_txid {
4144            let mut node = GASPNode {
4145                graph_id: graph_id.to_string(),
4146                raw_tx: tx.to_hex(),
4147                output_index,
4148                proof: None,
4149                tx_metadata: None,
4150                output_metadata: None,
4151                inputs: None,
4152            };
4153            if let Some(ref merkle_path) = tx.merkle_path {
4154                node.proof = Some(merkle_path.to_hex());
4155            }
4156            return Some(node);
4157        }
4158
4159        // Recurse into inputs via source_transaction
4160        for input in &tx.inputs {
4161            if let Some(ref source_tx) = input.source_transaction {
4162                if let Some(node) =
4163                    Self::search_tx_tree(source_tx, graph_id, target_txid, output_index)
4164                {
4165                    return Some(node);
4166                }
4167            }
4168        }
4169
4170        None
4171    }
4172
4173    /// Alternative search: try parsing the BEEF with the target txid directly.
4174    /// Some BEEF structures may not link source_transaction on inputs but
4175    /// still contain the transaction in the BEEF's transaction list.
4176    fn search_beef_for_txid(
4177        beef_data: &[u8],
4178        graph_id: &str,
4179        target_txid: &str,
4180        output_index: u32,
4181    ) -> Option<GASPNode> {
4182        // Try parsing BEEF with the specific txid
4183        if let Ok(tx) = beef_limits::transaction_from_beef(
4184            beef_data,
4185            Some(target_txid),
4186            &beef_limits::STORED_BEEF_LIMITS,
4187        ) {
4188            if tx.id() == target_txid {
4189                let mut node = GASPNode {
4190                    graph_id: graph_id.to_string(),
4191                    raw_tx: tx.to_hex(),
4192                    output_index,
4193                    proof: None,
4194                    tx_metadata: None,
4195                    output_metadata: None,
4196                    inputs: None,
4197                };
4198                if let Some(ref merkle_path) = tx.merkle_path {
4199                    node.proof = Some(merkle_path.to_hex());
4200                }
4201                return Some(node);
4202            }
4203        }
4204        None
4205    }
4206
4207    // ========================================================================
4208    // Merkle proof handling
4209    // ========================================================================
4210
4211    /// Handle a new merkle proof for a transaction.
4212    ///
4213    /// When a transaction gets mined, ARC calls back with the merkle proof.
4214    /// This method:
4215    /// 1. Finds all outputs for the txid in storage
4216    /// 2. Parses the BEEF, updates the merkle path in the transaction tree
4217    /// 3. Serializes updated BEEF back to storage
4218    /// 4. Recursively updates the consumedBy chain (outputs that spent this one)
4219    /// 5. Updates blockHeight on the outputs
4220    pub async fn handle_new_merkle_proof(
4221        &self,
4222        txid: &str,
4223        proof_hex: &str,
4224        block_height: Option<u32>,
4225    ) -> Result<(), EngineError> {
4226        let outputs = self
4227            .storage
4228            .find_outputs_for_transaction(txid, true)
4229            .await
4230            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4231
4232        if outputs.is_empty() {
4233            return Err(EngineError::Other(
4234                "Could not find matching transaction outputs for proof ingest!".into(),
4235            ));
4236        }
4237
4238        // Parse merkle proof if provided
4239        let proof = if proof_hex.is_empty() {
4240            None
4241        } else {
4242            Some(
4243                beef_limits::merkle_path_from_hex(proof_hex, beef_limits::PUSH_PROOF_MAX_BYTES)
4244                    .map_err(|e| EngineError::Other(format!("Invalid merkle proof hex: {e}")))?,
4245            )
4246        };
4247
4248        for output in &outputs {
4249            // Stitch the proof into the STORED BEEF (#284) — never rebuild it
4250            // around a re-picked subject; see stitch_proof_into_stored_beef.
4251            if let (Some(ref beef_data), Some(ref proof)) = (&output.beef, &proof) {
4252                if let Some(new_beef) = Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4253                {
4254                    let _ = self
4255                        .storage
4256                        .update_transaction_beef(&output.txid, &new_beef)
4257                        .await;
4258                }
4259            }
4260
4261            // Update block height
4262            if let Some(height) = block_height {
4263                let _ = self
4264                    .storage
4265                    .update_output_block_height(
4266                        &output.txid,
4267                        output.output_index,
4268                        &output.topic,
4269                        height,
4270                    )
4271                    .await;
4272            }
4273
4274            // Recursively update consumedBy chain
4275            for consuming in &output.consumed_by {
4276                if let Ok(consumed_outputs) = self
4277                    .storage
4278                    .find_outputs_for_transaction(&consuming.txid, true)
4279                    .await
4280                {
4281                    for consumed_output in &consumed_outputs {
4282                        // The consuming row's BEEF carries this tx as an
4283                        // ancestor — stitch the same bump in there too, again
4284                        // without rebuilding the tx set (#284: this branch is
4285                        // the one that was observed replacing a head-spend's
4286                        // stored BEEF with parent ancestry only).
4287                        if let (Some(ref beef_data), Some(ref proof)) =
4288                            (&consumed_output.beef, &proof)
4289                        {
4290                            if let Some(new_beef) =
4291                                Self::stitch_proof_into_stored_beef(beef_data, txid, proof)
4292                            {
4293                                let _ = self
4294                                    .storage
4295                                    .update_transaction_beef(&consumed_output.txid, &new_beef)
4296                                    .await;
4297                            }
4298                        }
4299                    }
4300                }
4301            }
4302        }
4303
4304        Ok(())
4305    }
4306
4307    /// Complete missing merkle proofs for stored, confirmed transactions.
4308    ///
4309    /// The `/submit`-admitted ingest path stores a proofless BEEF for each
4310    /// transaction (the submitter does not yet have a merkle proof at submit
4311    /// time). When such a transaction later mines, nothing in the overlay
4312    /// re-fetches its proof, so its stored BEEF stays proofless forever — and a
4313    /// frontend that trims `inputBEEF` to `{source tx + its BUMP}` for a P2PKH
4314    /// payment-import spend has no BUMP to trim to, hanging the import (#130).
4315    ///
4316    /// This pass closes that gap from chain (the one blessed server-side WoC
4317    /// use). It:
4318    /// 1. Pulls a bounded page (`limit`) of stored `(txid, beef)` from storage.
4319    /// 2. Parses each BEEF and keeps only those whose target tx still lacks a
4320    ///    merkle proof (`Beef::find_txid(txid).has_proof() == false`).
4321    /// 3. For each proofless candidate, fetches its BUMP via the configured
4322    ///    [`AncestorFetcher`](crate::gasp::AncestorFetcher). If a proof comes
4323    ///    back, it calls [`Self::handle_new_merkle_proof`], which stitches the
4324    ///    BUMP into the stored BEEF (D1 + R2 mirror), updates output block
4325    ///    height, and recurses the `consumedBy` chain.
4326    /// 4. Skips (does NOT error) txs the fetcher can't prove yet (still
4327    ///    unconfirmed) — those are simply retried on a later tick.
4328    ///
4329    /// **No-op when no [`AncestorFetcher`] is configured** — production-safe
4330    /// default (the same opt-in switch as GASP ancestor hydration). Bounded by
4331    /// `limit` (a per-tick budget, like the platform fetcher's own budget).
4332    ///
4333    /// `min_age_secs` is the PUSH-PRIMARY BACKSTOP gate (bsv-low #228 /
4334    /// arcade#259): since the Arcade MINED webhook (`/arc-ingest`) pushes a
4335    /// verified proof ~150 ms after a tx mines, this poll pass is a BACKSTOP,
4336    /// not the primary source — rows stored less than `min_age_secs` ago are
4337    /// skipped entirely (their push is still expected). Pass `0` to disable
4338    /// the gate (poll everything — the pre-#228 behaviour, and the degradation
4339    /// mode if pushes stop: an old-enough row is ALWAYS polled, so webhook
4340    /// loss degrades to polling, never to nothing). Unknown-age rows are
4341    /// always eligible (fail-safe — see the storage-trait doc).
4342    pub async fn complete_missing_proofs(
4343        &self,
4344        limit: u64,
4345        min_age_secs: u64,
4346    ) -> Result<ProofCompletionSummary, EngineError> {
4347        let Some(fetcher) = self.ancestor_fetcher.as_deref() else {
4348            return Ok(ProofCompletionSummary::default());
4349        };
4350        self.complete_missing_proofs_with(fetcher, limit, min_age_secs)
4351            .await
4352    }
4353
4354    /// Complete stored transactions using the caller's dedicated allowance.
4355    /// The engine's other passes retain their configured ancestor courier.
4356    pub async fn complete_missing_proofs_with(
4357        &self,
4358        fetcher: &dyn crate::gasp::AncestorFetcher,
4359        limit: u64,
4360        min_age_secs: u64,
4361    ) -> Result<ProofCompletionSummary, EngineError> {
4362        let mut summary = ProofCompletionSummary::default();
4363
4364        let candidates = self
4365            .storage
4366            .find_transactions_for_proof_check(limit, min_age_secs)
4367            .await
4368            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4369        summary.scanned = candidates.len();
4370
4371        for cand in candidates {
4372            summary.early_gasp += usize::from(cand.early_gasp);
4373            // Read the stored BEEF for the subject's OWN bump by streaming
4374            // folds (one element in hand, no `Beef` built: NL-6); skip
4375            // anything unparseable (don't error the whole pass on one bad row).
4376            let own_bump = match beef_limits::own_proof(&cand.beef, &cand.txid) {
4377                Ok(b) => b,
4378                Err(e) => {
4379                    warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] unparseable BEEF, skipping");
4380                    continue;
4381                }
4382            };
4383
4384            // Already structurally proven? The stored BEEF carries a merkle bump
4385            // for the target tx, but its backend `has_proof` flag may still be 0
4386            // — EITHER a legitimate GASP-synced proof written before overlay
4387            // migration 0010 (which defaulted every existing row to 0), OR an
4388            // admit-time bump that was NEVER SPV-verified (`/submit` skips SPV on
4389            // historical topics) or is outright forged. Serve-time BEEF trimming
4390            // trusts the `has_proof` flag, so latching one on STRUCTURE ALONE
4391            // would let a forged bump be trimmed on (#192/#193 MEDIUM). Re-verify
4392            // the STORED bump against chaintracks (via the fetcher — its header
4393            // source is the only arbiter of a merkle root). Genuine → latch
4394            // (fast, no re-fetch). Otherwise DON'T trust it: fall through to the
4395            // fetch+stitch path below, which overwrites the bump with a
4396            // chaintracks-verified one (or leaves the row proofless for retry).
4397            // Fail-closed throughout.
4398            // the tx's OWN bump (its `bump_index`), never `find_bump`
4399            // (the FIRST bump containing the txid — the stale one after a
4400            // same-height reorg; review HIGH-1).
4401            if let Some(bump_hex) = own_bump
4402                .as_ref()
4403                .map(bsv_rs::transaction::MerklePath::to_hex)
4404            {
4405                if fetcher.verify_proof(&cand.txid, &bump_hex).await {
4406                    // Idempotent + best-effort: a failure here is logged, not
4407                    // fatal — the row simply lingers one more tick.
4408                    // bsv-low M19 R2 round 3 (review MED-2): latch WITH the
4409                    // anchor height (from the tx's OWN verified bump) so the
4410                    // revalidation sweep's transactions leg can window this
4411                    // row — `mark_transaction_proven` alone left every
4412                    // fast-path row anchorless, in no window forever.
4413                    let height = bsv_rs::transaction::MerklePath::from_hex(&bump_hex)
4414                        .ok()
4415                        .map(|mp| u64::from(mp.block_height));
4416                    if let Err(e) = self
4417                        .storage
4418                        .mark_transaction_proven_at(&cand.txid, height)
4419                        .await
4420                    {
4421                        warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] failed to mark verified-proven row");
4422                    } else {
4423                        summary.already_proven += 1;
4424                    }
4425                    continue;
4426                }
4427                warn!(txid = %cand.txid, "[PROOF COMPLETION] stored structural bump FAILED chaintracks re-verify — not trusting it, refetching");
4428            }
4429            // Unverifiable structural bump → treat as proofless (fall through).
4430            summary.proofless += 1;
4431
4432            // Fetch this tx's own VERIFIED bump from chain — PROOF ONLY (the raw
4433            // is already in the stored BEEF, so no redundant raw fetch; #192/#193
4434            // FIX 2). A tx with no verified proof yet (still unconfirmed, or an
4435            // unverifiable/forged courier response) is skipped, not errored.
4436            let Some(proof_hex) = fetcher.verified_proof_for(&cand.txid).await else {
4437                summary.still_unconfirmed += 1;
4438                continue;
4439            };
4440
4441            // Pull the block height out of the BUMP so the output rows update too.
4442            let block_height =
4443                beef_limits::merkle_path_from_hex(&proof_hex, beef_limits::COURIER_PROOF_MAX_BYTES)
4444                    .ok()
4445                    .map(|mp| mp.block_height);
4446
4447            match self
4448                .handle_new_merkle_proof(&cand.txid, &proof_hex, block_height)
4449                .await
4450            {
4451                Ok(()) => summary.completed += 1,
4452                Err(e) => {
4453                    warn!(txid = %cand.txid, error = %e, "[PROOF COMPLETION] stitch failed");
4454                    summary.stitch_failed += 1;
4455                }
4456            }
4457        }
4458
4459        info!(
4460            scanned = summary.scanned,
4461            proofless = summary.proofless,
4462            completed = summary.completed,
4463            still_unconfirmed = summary.still_unconfirmed,
4464            fetch_failed = summary.fetch_failed,
4465            stitch_failed = summary.stitch_failed,
4466            already_proven = summary.already_proven,
4467            early_gasp = summary.early_gasp,
4468            "[PROOF COMPLETION] pass complete"
4469        );
4470
4471        Ok(summary)
4472    }
4473
4474    /// Recursively update merkle paths in a transaction's input tree.
4475    ///
4476    /// If the transaction's id matches txid, set its merkle_path.
4477    /// Otherwise, recurse into sourceTransactions of each input.
4478    /// Stitch a verified BUMP for `txid` into a STORED BEEF, preserving every
4479    /// transaction and every other proof in it (#284).
4480    ///
4481    /// The old path round-tripped the stored bytes through
4482    /// `Transaction::from_beef(bytes, None)` -> `to_beef(true)`, and
4483    /// `from_beef(_, None)` picks **`txs.last()`** — it ignores the atomic
4484    /// subject pointer entirely. Whenever the subject was not the last tx in
4485    /// wire order, a PARENT was picked, `to_beef(true)` then serialized that
4486    /// parent's world, and the rewrite dropped the subject transaction from its
4487    /// own stored BEEF — observed live on every pf head-spend (list / reprice /
4488    /// delist / buy) the moment it mined, breaking the NEXT spend on that name
4489    /// (zanaadu#284). `update_input_proofs`' "already has a proof — update it"
4490    /// arm could even stamp the child's bump onto that mis-picked parent.
4491    /// This is the same destroy-the-stored-BEEF class the #4 fix note above
4492    /// (`hydrate_utxo_history`) already documents for lookup hydration.
4493    ///
4494    /// Operating on [`Beef`] directly has none of those failure modes:
4495    /// `merge_bump` dedupes against existing bumps by (height, root) and
4496    /// assigns the bump ONLY to transactions that appear as flagged txid
4497    /// leaves, so stitching tx X's proof can never mislabel tx Y, and the tx
4498    /// set is never rebuilt at all.
4499    ///
4500    /// Returns `None` when the bytes do not parse or `txid` is not in this
4501    /// BEEF (nothing to stitch — e.g. a consuming row whose ancestry got
4502    /// trimmed), so callers skip the write instead of writing garbage.
4503    ///
4504    /// bsv-low M19 R2 round 3 (review HIGH-1): a same-height DIFFERENT-root
4505    /// proof (the 2026-09-07 reorg shape) does NOT combine in `merge_bump`
4506    /// (the roots differ) — it is pushed as a SECOND bump, and
4507    /// `update_bump_indices` assigns it only to txs whose `bump_index` is
4508    /// `None`, so the reorged subject keeps its index on the STALE bump.
4509    /// Left alone, `has_proof`/`proofHeight` and every `find_txid(txid)
4510    /// .bump_index()` reader then keep pointing at the orphan bump, and the
4511    /// row refutes again next sweep pass forever. So after the merge, FORCE
4512    /// the subject onto the bump that actually proves it, then drop any bump
4513    /// no transaction references (the orphan's, now unreferenced).
4514    pub fn stitch_proof_into_stored_beef(
4515        stored: &[u8],
4516        txid: &str,
4517        proof: &bsv_rs::transaction::MerklePath,
4518    ) -> Option<Vec<u8>> {
4519        let mut beef = beef_limits::parse_beef(stored, &beef_limits::STORED_BEEF_LIMITS).ok()?;
4520        beef.find_txid(txid)?;
4521        let i = beef.merge_bump(proof.clone());
4522        // Does the merged bump actually prove `txid` (a flagged txid leaf)?
4523        // (It always does for a completion proof; the guard keeps a stray
4524        // caller from mis-anchoring a subject onto an ancestor's bump.)
4525        let proves_subject = beef.bumps.get(i).is_some_and(|b| {
4526            b.path.first().is_some_and(|leaves| {
4527                leaves
4528                    .iter()
4529                    .any(|l| l.txid && l.hash.as_deref() == Some(txid))
4530            })
4531        });
4532        if proves_subject {
4533            if let Some(tx) = beef.find_txid_mut(txid) {
4534                tx.set_bump_index(Some(i));
4535            }
4536        }
4537        Self::gc_unreferenced_bumps(&mut beef);
4538        Some(beef.to_binary())
4539    }
4540
4541    /// Drop every BUMP no transaction references and re-index the survivors
4542    /// (bsv-low M19 R2 round 3, review HIGH-1): after the subject is moved
4543    /// off an orphan bump, that bump is usually dead weight the next reader
4544    /// would still `find_bump`. A bump still referenced by ANOTHER tx (a
4545    /// sibling mined in the same block) is kept.
4546    fn gc_unreferenced_bumps(beef: &mut bsv_rs::transaction::Beef) {
4547        let referenced: std::collections::HashSet<usize> = beef
4548            .txs
4549            .iter()
4550            .filter_map(bsv_rs::transaction::BeefTx::bump_index)
4551            .collect();
4552        if referenced.len() == beef.bumps.len() {
4553            return;
4554        }
4555        let mut remap: Vec<Option<usize>> = vec![None; beef.bumps.len()];
4556        let mut kept = Vec::with_capacity(referenced.len());
4557        for (old, bump) in std::mem::take(&mut beef.bumps).into_iter().enumerate() {
4558            if referenced.contains(&old) {
4559                remap[old] = Some(kept.len());
4560                kept.push(bump);
4561            }
4562        }
4563        beef.bumps = kept;
4564        for tx in &mut beef.txs {
4565            if let Some(old) = tx.bump_index() {
4566                tx.set_bump_index(remap[old]);
4567            }
4568        }
4569    }
4570
4571    // ========================================================================
4572    // Advertisement sync
4573    // ========================================================================
4574
4575    /// Sync SHIP/SLAP advertisements with configured managers and services.
4576    ///
4577    /// Creates missing advertisements and revokes stale ones. Returns a
4578    /// [`SyncAdvertisementsReport`] so a create/submit failure is VISIBLE to
4579    /// the caller (bsv-low #320 defect 3a): this method used to swallow both
4580    /// into `error!` and return `Ok(())`, so `/admin/syncAdvertisements`
4581    /// reported `success` while zero advertisements were admitted locally —
4582    /// a caller could not tell a converged no-op from a silent failure, and
4583    /// every cycle re-created the full ad set.
4584    pub async fn sync_advertisements(&self) -> Result<SyncAdvertisementsReport, EngineError> {
4585        let mut report = SyncAdvertisementsReport::default();
4586
4587        let Some(advertiser) = &self.advertiser else {
4588            return Ok(report); // No advertiser configured
4589        };
4590
4591        let hosting_url = match &self.config.hosting_url {
4592            Some(url) if !url.is_empty() => url.clone(),
4593            _ => return Ok(report), // No hosting URL
4594        };
4595
4596        // Get configured topics and services
4597        let mut configured_topics: Vec<String> = self.managers.keys().cloned().collect();
4598        let mut configured_services: Vec<String> = self.lookup_services.keys().cloned().collect();
4599
4600        if self.config.suppress_default_sync_advertisements {
4601            configured_topics.retain(|t| t != "tm_ship" && t != "tm_slap");
4602            configured_services.retain(|s| s != "ls_ship" && s != "ls_slap");
4603        }
4604
4605        // Fetch current advertisements. A read failure REFUSES creation
4606        // (#320 M2): swallowing it into "current ads = none" made a
4607        // transient D1/storage fault re-create — and re-pay for — the
4608        // entire advertisement set, reported as a legitimate `to_create`.
4609        let current_ship = match advertiser.find_all_advertisements(Protocol::Ship).await {
4610            Ok(ads) => ads,
4611            Err(e) => {
4612                error!("Failed to read current SHIP advertisements — refusing to create: {e}");
4613                report.lookup_error = Some(format!("ship: {e}"));
4614                return Ok(report);
4615            }
4616        };
4617        let current_slap = match advertiser.find_all_advertisements(Protocol::Slap).await {
4618            Ok(ads) => ads,
4619            Err(e) => {
4620                error!("Failed to read current SLAP advertisements — refusing to create: {e}");
4621                report.lookup_error = Some(format!("slap: {e}"));
4622                return Ok(report);
4623            }
4624        };
4625
4626        // Determine what to create
4627        let ships_to_create: Vec<AdvertisementData> = configured_topics
4628            .iter()
4629            .filter(|topic| {
4630                !current_ship
4631                    .iter()
4632                    .any(|a| a.topic_or_service == **topic && a.domain == hosting_url)
4633            })
4634            .map(|topic| AdvertisementData {
4635                protocol: Protocol::Ship,
4636                topic_or_service_name: topic.clone(),
4637            })
4638            .collect();
4639
4640        let slaps_to_create: Vec<AdvertisementData> = configured_services
4641            .iter()
4642            .filter(|service| {
4643                !current_slap
4644                    .iter()
4645                    .any(|a| a.topic_or_service == **service && a.domain == hosting_url)
4646            })
4647            .map(|service| AdvertisementData {
4648                protocol: Protocol::Slap,
4649                topic_or_service_name: service.clone(),
4650            })
4651            .collect();
4652
4653        // Determine what to revoke
4654        let ships_to_revoke: Vec<Advertisement> = current_ship
4655            .into_iter()
4656            .filter(|a| !configured_topics.contains(&a.topic_or_service))
4657            .collect();
4658
4659        let slaps_to_revoke: Vec<Advertisement> = current_slap
4660            .into_iter()
4661            .filter(|a| !configured_services.contains(&a.topic_or_service))
4662            .collect();
4663
4664        // Create new advertisements
4665        let mut all_to_create = ships_to_create;
4666        all_to_create.extend(slaps_to_create);
4667        report.to_create = all_to_create.len();
4668        if !all_to_create.is_empty() {
4669            match advertiser.create_advertisements(&all_to_create).await {
4670                Ok(tagged_beef) => match self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4671                    Ok(steak) => {
4672                        for (topic, instructions) in &steak {
4673                            report
4674                                .admitted
4675                                .insert(topic.clone(), instructions.outputs_to_admit.len());
4676                        }
4677                    }
4678                    Err(e) => {
4679                        error!("Failed to submit new advertisements: {e}");
4680                        report.submit_error = Some(e.to_string());
4681                    }
4682                },
4683                Err(e) => {
4684                    error!("Failed to create advertisements: {e}");
4685                    report.create_error = Some(e.to_string());
4686                }
4687            }
4688        }
4689
4690        // Revoke stale advertisements
4691        let mut all_to_revoke = ships_to_revoke;
4692        all_to_revoke.extend(slaps_to_revoke);
4693        report.to_revoke = all_to_revoke.len();
4694        if !all_to_revoke.is_empty() {
4695            match advertiser.revoke_advertisements(&all_to_revoke).await {
4696                // An advertiser that declines to build a revocation (e.g. the
4697                // CF advertiser's documented v1 no-op) returns an EMPTY
4698                // TaggedBEEF — submitting it would only manufacture a parse
4699                // error. Skip, but say so in the report (#320 L2): the stale
4700                // ads stay on-chain until they age out.
4701                Ok(tagged_beef) if tagged_beef.topics.is_empty() => {
4702                    report.revoke_skipped = all_to_revoke.len();
4703                }
4704                Ok(tagged_beef) => {
4705                    if let Err(e) = self.submit(&tagged_beef, SubmitMode::CurrentTx).await {
4706                        error!("Failed to submit revocation: {e}");
4707                        report.revoke_submit_error = Some(e.to_string());
4708                    }
4709                }
4710                Err(e) => {
4711                    error!("Failed to revoke advertisements: {e}");
4712                    report.revoke_error = Some(e.to_string());
4713                }
4714            }
4715        }
4716
4717        Ok(report)
4718    }
4719
4720    // ========================================================================
4721    // Deep deletion
4722    // ========================================================================
4723
4724    /// Recursively delete a UTXO and all stale consumed inputs.
4725    ///
4726    /// Only deletes if the output has no remaining consumers (consumedBy is empty).
4727    /// Then recurses into outputsConsumed, removing the deleted output from their
4728    /// consumedBy lists and deleting them if they become unreferenced.
4729    #[allow(dead_code)] // Used by submit() once BEEF parsing is wired up
4730    ///
4731    /// `bound` is a finalize submit's [`CallBound`] over each call here
4732    /// (bsv-low #559); `None` everywhere else.
4733    fn delete_utxo_deep<'a>(
4734        &'a self,
4735        output: &'a Output,
4736        bound: Option<&'a CallBound>,
4737    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), EngineError>> + 'a>> {
4738        Box::pin(async move {
4739            // Only delete if nothing consumes this output
4740            if output.consumed_by.is_empty() {
4741                stored(
4742                    bound,
4743                    self.storage
4744                        .delete_output(&output.txid, output.output_index, &output.topic),
4745                )
4746                .await
4747                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4748
4749                // Notify lookup services. A hook error is dropped, not
4750                // reported (parity: the reference catches it, best effort).
4751                for ls in self.lookup_services.values() {
4752                    let _ = hooked(
4753                        bound,
4754                        ls.output_no_longer_retained_in_history(
4755                            &output.txid,
4756                            output.output_index,
4757                            &output.topic,
4758                        ),
4759                    )
4760                    .await;
4761                }
4762            }
4763
4764            // Recurse into consumed outputs
4765            for consumed in &output.outputs_consumed {
4766                if let Ok(Some(mut stale_output)) = stored(
4767                    bound,
4768                    self.storage.find_output(
4769                        &consumed.txid,
4770                        consumed.output_index,
4771                        Some(&output.topic),
4772                        None,
4773                        false,
4774                    ),
4775                )
4776                .await
4777                {
4778                    // Remove the deleted output from consumedBy
4779                    stale_output.consumed_by.retain(|c| {
4780                        !(c.txid == output.txid && c.output_index == output.output_index)
4781                    });
4782
4783                    stored(
4784                        bound,
4785                        self.storage.update_consumed_by(
4786                            &consumed.txid,
4787                            consumed.output_index,
4788                            &output.topic,
4789                            &stale_output.consumed_by,
4790                        ),
4791                    )
4792                    .await
4793                    .map_err(|e| EngineError::StorageError(e.to_string()))?;
4794
4795                    // Recurse
4796                    let _ = self.delete_utxo_deep(&stale_output, bound).await;
4797                }
4798            }
4799
4800            Ok(())
4801        }) // Box::pin
4802    }
4803
4804    // ========================================================================
4805    // Eviction
4806    // ========================================================================
4807
4808    /// Evict a specific outpoint from the overlay.
4809    ///
4810    /// 1. Deletes the output from storage for the given topic.
4811    /// 2. Notifies all lookup services via `output_evicted()`.
4812    ///
4813    /// If no topic is specified, finds all outputs for the txid and evicts the
4814    /// matching outputIndex across every topic.
4815    ///
4816    /// Matches TS OverlayExpress `/admin/evictOutpoint` (lines 974-1004).
4817    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): RE-NOTIFY every lookup service
4818    /// of the outputs the engine ALREADY admitted for (`txid`, `topic`).
4819    ///
4820    /// Phase-1 dedup means a (txid, topic) once recorded in
4821    /// `applied_transactions` never re-enters through `/submit`, so a lookup
4822    /// service that missed — or mis-parsed — its admit notification could
4823    /// never be told again (the loop-2 subject trap: the pot index's admit
4824    /// hook shape-checked a wire-last hop and wrote no record while the engine
4825    /// had admitted the pot). This replays `output_admitted_by_topic` from the
4826    /// engine's OWN stored outputs (with their BEEF) and the subject-NAMED body
4827    /// exactly as `submit_with_report` sends it. It validates nothing anew
4828    /// (the topic manager judged these outputs at admit), writes none of the
4829    /// engine's tables, and is idempotent for every lookup service whose
4830    /// writes are (the pot index's `store_record` is insert-if-absent for the
4831    /// spend fields — re-admission never regresses spend state).
4832    /// `outputs == 0` means the engine never admitted this txid on this topic:
4833    /// nothing to re-notify — the caller re-SUBMITS instead.
4834    pub async fn renotify_admitted(
4835        &self,
4836        txid: &str,
4837        topic: &str,
4838    ) -> Result<RenotifyReport, EngineError> {
4839        let txid = txid.to_ascii_lowercase();
4840        let outputs = self
4841            .storage
4842            .find_outputs_for_transaction(&txid, true)
4843            .await
4844            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4845        let mut report = RenotifyReport::default();
4846        for output in outputs.iter().filter(|o| o.topic == topic) {
4847            report.outputs += 1;
4848            report.vouts.push(output.output_index);
4849            // the body every WholeTx service receives NAMES the subject (BRC-95
4850            // atomic prefix) — the same rule as the live submit path
4851            let subject_named: Option<Vec<u8>> = output.beef.as_ref().map(|beef| {
4852                beef_limits::parse_beef(beef, &beef_limits::STORED_BEEF_LIMITS)
4853                    .ok()
4854                    .and_then(|mut b| b.to_binary_atomic(&txid).ok())
4855                    .unwrap_or_else(|| beef.clone())
4856            });
4857            for (name, ls) in &self.lookup_services {
4858                let payload = match ls.admission_mode() {
4859                    AdmissionMode::LockingScript => OutputAdmittedByTopic::LockingScript {
4860                        txid: txid.clone(),
4861                        output_index: output.output_index,
4862                        topic: topic.to_string(),
4863                        satoshis: output.satoshis,
4864                        locking_script: output.output_script.clone(),
4865                        off_chain_values: None,
4866                    },
4867                    AdmissionMode::WholeTx => {
4868                        let Some(atomic_beef) = subject_named.as_ref() else {
4869                            report.faults.push(format!(
4870                                "{name}: {txid}:{} is stored without its BEEF — a WholeTx service cannot be re-notified",
4871                                output.output_index
4872                            ));
4873                            continue;
4874                        };
4875                        OutputAdmittedByTopic::WholeTx {
4876                            atomic_beef: atomic_beef.clone(),
4877                            output_index: output.output_index,
4878                            topic: topic.to_string(),
4879                            off_chain_values: None,
4880                        }
4881                    }
4882                };
4883                match ls.output_admitted_by_topic(&payload).await {
4884                    Ok(()) => report.notified += 1,
4885                    Err(e) => report.faults.push(format!("{name}: {e}")),
4886                }
4887            }
4888        }
4889        Ok(report)
4890    }
4891
4892    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): FORGET a PHANTOM applied row —
4893    /// (txid, topic) recorded in `applied_transactions` while the topic holds
4894    /// NO output of that txid (the engine judged the tx and admitted nothing:
4895    /// the loop-2 JOIN `f9e85aab…` under the pre-D5 subject rule). Such a row
4896    /// does nothing but block a correct re-submit through the Phase-1 dedup
4897    /// (`/admin/readmit` on beta answered `deduped: ["tm_pot_beta"]` and the
4898    /// pot stayed `known:false`). A row with ANY stored output on the topic is
4899    /// a real admission and is never touched (returns false, as does a missing
4900    /// row). The forgotten bytes are then re-VALIDATED by the topic manager
4901    /// like any first submit — nothing is admitted here.
4902    pub async fn forget_phantom_applied(
4903        &self,
4904        txid: &str,
4905        topic: &str,
4906    ) -> Result<bool, EngineError> {
4907        let txid = txid.to_ascii_lowercase();
4908        let outputs = self
4909            .storage
4910            .find_outputs_for_transaction(&txid, false)
4911            .await
4912            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4913        if outputs.iter().any(|o| o.topic == topic) {
4914            return Ok(false);
4915        }
4916        let rec = AppliedTransaction {
4917            txid: txid.clone(),
4918            topic: topic.to_string(),
4919        };
4920        let exists = self
4921            .storage
4922            .does_applied_transaction_exist(&rec)
4923            .await
4924            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4925        if !exists {
4926            return Ok(false);
4927        }
4928        self.storage
4929            .delete_applied_transaction(&rec)
4930            .await
4931            .map_err(|e| EngineError::StorageError(e.to_string()))?;
4932        Ok(true)
4933    }
4934
4935    pub async fn evict_output(
4936        &self,
4937        txid: &str,
4938        output_index: u32,
4939        topic: Option<&str>,
4940    ) -> Result<(), EngineError> {
4941        if let Some(topic) = topic {
4942            // Delete from storage
4943            self.storage
4944                .delete_output(txid, output_index, topic)
4945                .await
4946                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4947
4948            // Notify all lookup services
4949            for ls in self.lookup_services.values() {
4950                let _ = ls.output_evicted(txid, output_index).await;
4951            }
4952        } else {
4953            // No topic specified — find all outputs for this txid and evict matching ones
4954            let outputs = self
4955                .storage
4956                .find_outputs_for_transaction(txid, false)
4957                .await
4958                .map_err(|e| EngineError::StorageError(e.to_string()))?;
4959
4960            for output in &outputs {
4961                if output.output_index == output_index {
4962                    self.storage
4963                        .delete_output(txid, output_index, &output.topic)
4964                        .await
4965                        .map_err(|e| EngineError::StorageError(e.to_string()))?;
4966                }
4967            }
4968
4969            // Notify all lookup services once
4970            for ls in self.lookup_services.values() {
4971                let _ = ls.output_evicted(txid, output_index).await;
4972            }
4973        }
4974
4975        Ok(())
4976    }
4977
4978    // ========================================================================
4979    // GASP sync
4980    // ========================================================================
4981
4982    /// Start GASP synchronization with peers for all configured topics.
4983    ///
4984    /// For each topic in `sync_configuration`:
4985    /// - `SyncTarget::Ship`: unions local SHIP records, network discovery via
4986    ///   configured SLAP trackers, and the topic's bootstrap peers.
4987    /// - `SyncTarget::Peers(urls)` — uses the provided peer URLs directly.
4988    /// - `SyncTarget::Disabled` — skips the topic.
4989    ///
4990    /// Filters out our own `hosting_url` to avoid self-sync.
4991    ///
4992    /// Returns a `GASPSyncResult` summarizing discovered peers per topic.
4993    ///
4994    /// Discovers peers for each configured topic and runs GASP sync with each.
4995    ///
4996    /// When a `GASPRemoteFactory` is set (via `set_gasp_remote_factory`), this
4997    /// method creates `OverlayGASPStorage` + `GASPRemote` instances per
4998    /// (topic, peer) pair and runs `GASPSync::sync()` to exchange UTXOs.
4999    ///
5000    /// Without a factory, peer discovery still runs but no actual sync occurs
5001    /// (backwards-compatible with the previous discovery-only behavior).
5002    pub async fn start_gasp_sync(&self) -> Result<GASPSyncResult, EngineError> {
5003        self.start_gasp_sync_filtered(&crate::sync_load::SyncFilter::default())
5004            .await
5005    }
5006
5007    /// Run selected topic/peer pairs; explicit peers bypass network discovery.
5008    pub async fn start_gasp_sync_filtered(
5009        &self,
5010        filter: &crate::sync_load::SyncFilter,
5011    ) -> Result<GASPSyncResult, EngineError> {
5012        let meter = crate::meter::LoadMeter::default();
5013        self.start_gasp_sync_metered(filter, &meter).await
5014    }
5015
5016    /// A caller holding the meter can read partial costs after cancellation.
5017    pub async fn start_gasp_sync_metered(
5018        &self,
5019        filter: &crate::sync_load::SyncFilter,
5020        meter: &crate::meter::LoadMeter,
5021    ) -> Result<GASPSyncResult, EngineError> {
5022        let run = self.gasp_run();
5023        meter.scope(self.run_gasp_sync(filter, &run, None)).await
5024    }
5025
5026    fn gasp_run(&self) -> crate::backfill::Run {
5027        crate::backfill::Run::new(
5028            self.gasp_ancestor_fetcher
5029                .as_ref()
5030                .or(self.ancestor_fetcher.as_ref())
5031                .cloned(),
5032        )
5033    }
5034
5035    /// Walk one selected pair, checkpointing on wall, request or progress bounds.
5036    /// The continuation never supplies a cursor to storage or to the remote.
5037    pub async fn start_gasp_backfill_metered(
5038        &self,
5039        request: &crate::backfill::SyncRequest,
5040        meter: &crate::meter::LoadMeter,
5041    ) -> Result<crate::backfill::BackfillResult, EngineError> {
5042        use crate::backfill::{
5043            BackfillResult, Continuation, Control, BACKFILL_MAX_SLICES, BACKFILL_REQUEST_MS,
5044        };
5045        request.validate().map_err(EngineError::InvalidQuery)?;
5046        let (topic, peer) = request.pair().map_err(EngineError::InvalidQuery)?;
5047        if request.until.is_none() || self.peer_sync_budget.is_none() || self.graph_budget.is_none()
5048        {
5049            return Err(EngineError::InvalidQuery(
5050                "backfill requires until: tip and peer/graph bounds".into(),
5051            ));
5052        }
5053        if self.gasp_remote_factory.is_none()
5054            || !matches!(
5055                self.config.sync_configuration.get(topic),
5056                Some(SyncTarget::Ship | SyncTarget::Peers(_))
5057            )
5058        {
5059            return Err(EngineError::InvalidQuery(
5060                "backfill topic is not enabled for sync".into(),
5061            ));
5062        }
5063        meter
5064            .scope(async {
5065                let initial = self.storage.get_last_interaction(peer, topic).await?;
5066                if let Some(token) = &request.continuation {
5067                    let token = Continuation::decode(token).map_err(EngineError::InvalidQuery)?;
5068                    if token.cursor > initial {
5069                        return Err(EngineError::InvalidQuery(
5070                            "continuation is ahead of the durable cursor".into(),
5071                        ));
5072                    }
5073                }
5074                let filter = request.filter();
5075                let run = self.gasp_run();
5076                let control = Control {
5077                    meter: meter.clone(),
5078                    budget_ms: request.budget_ms.unwrap_or(0).min(BACKFILL_REQUEST_MS),
5079                };
5080                let mut result = BackfillResult {
5081                    sync: GASPSyncResult {
5082                        filter: filter.clone(),
5083                        ..GASPSyncResult::default()
5084                    },
5085                    slices: 0,
5086                    reached_tip: false,
5087                    stop_reason: "slice_allowance".into(),
5088                    continuation: None,
5089                };
5090                let mut previous_cursor = initial;
5091                for _ in 0..BACKFILL_MAX_SLICES {
5092                    if let Some(reason) = control.stop_reason() {
5093                        result.stop_reason = reason.into();
5094                        break;
5095                    }
5096                    // Boxed: the slice's sync is the largest future here
5097                    // (clippy's `large_futures`, past its bound since the
5098                    // run's batch memo of engine 0.2.1).
5099                    let slice = Box::pin(self.run_gasp_sync(&filter, &run, Some(&control))).await?;
5100                    let topic_result = slice.topics_synced.get(topic).ok_or_else(|| {
5101                        EngineError::InvalidQuery("backfill selection matched no topic".into())
5102                    })?;
5103                    if !topic_result.peers.iter().any(|p| p == peer) {
5104                        return Err(EngineError::InvalidQuery(
5105                            "backfill selection matched no peer".into(),
5106                        ));
5107                    }
5108                    let summary = topic_result.peer_results.get(peer).cloned();
5109                    let waiting = !topic_result.peers_skipped.is_empty();
5110                    result.sync.append_slice(slice);
5111                    if let Some(summary) = summary {
5112                        result.slices += summary.slices;
5113                        if summary.reached_tip {
5114                            result.reached_tip = true;
5115                            result.stop_reason = "tip".into();
5116                            break;
5117                        }
5118                        if let Some(reason) = control.stop_reason() {
5119                            result.stop_reason = reason.into();
5120                            break;
5121                        }
5122                        if summary.cursor <= previous_cursor {
5123                            result.stop_reason = "no_progress".into();
5124                            break;
5125                        }
5126                        previous_cursor = summary.cursor;
5127                    } else {
5128                        result.stop_reason =
5129                            if waiting { "peer_wait" } else { "no_progress" }.into();
5130                        break;
5131                    }
5132                }
5133                let cursor = self.storage.get_last_interaction(peer, topic).await?;
5134                if !result.reached_tip {
5135                    result.continuation = Some(Continuation::new(topic, peer, cursor).encode());
5136                }
5137                result.sync.cost = meter.snapshot();
5138                Ok(result)
5139            })
5140            .await
5141    }
5142
5143    async fn run_gasp_sync(
5144        &self,
5145        filter: &crate::sync_load::SyncFilter,
5146        run: &crate::backfill::Run,
5147        control: Option<&crate::backfill::Control>,
5148    ) -> Result<GASPSyncResult, EngineError> {
5149        use crate::gasp::{GASPSync, ENGINE_GASP_SYNC_PAGE_LIMIT};
5150        use crate::gasp_overlay::OverlayGASPStorage;
5151
5152        if self.config.sync_configuration.is_empty() {
5153            info!("[GASP SYNC] No sync configuration — nothing to sync");
5154            return Ok(GASPSyncResult {
5155                cost: crate::meter::SyncCost::default(),
5156                topics_synced: HashMap::new(),
5157                filter: filter.clone(),
5158                topics_filtered: Vec::new(),
5159            });
5160        }
5161
5162        let sync_fetcher = &run.fetcher;
5163        let mut topics_synced: HashMap<String, TopicSyncResult> = HashMap::new();
5164        // Resolve SLAP providers once per invocation, sharing their host cap
5165        // across all SHIP topics. Every topic still queries each lookup host.
5166        let mut ship_lookup_hosts = None;
5167        let mut topic_order = sync_order(&self.config.sync_configuration);
5168        // Explicit bootstrap-backed SHIP topics run ahead of large tracker
5169        // imports. Stable sorting preserves the existing order elsewhere.
5170        topic_order.sort_by_key(|(topic, target)| {
5171            !(matches!(target, SyncTarget::Ship)
5172                && self
5173                    .ship_bootstrap_peers
5174                    .get(topic.as_str())
5175                    .is_some_and(|peers| !peers.is_empty()))
5176        });
5177
5178        let mut topics_filtered = Vec::new();
5179        for (topic, target) in topic_order {
5180            if filter
5181                .topics
5182                .as_ref()
5183                .is_some_and(|names| !names.contains(topic))
5184            {
5185                topics_filtered.push(topic.clone());
5186                continue;
5187            }
5188            let cost_before = crate::meter::snapshot();
5189            // A CONFIGURED topic's peers are reserved part of a storage's
5190            // deferred-graph ceiling (bsv-low #555, the delta-2 fold's D2-M2).
5191            let configured_peers = matches!(target, SyncTarget::Peers(_));
5192            let (peers, sync_type) = match target {
5193                SyncTarget::Disabled => {
5194                    info!("[GASP SYNC] Topic {topic} is disabled — skipping");
5195                    continue;
5196                }
5197                SyncTarget::Ship => {
5198                    info!("[GASP SYNC] Topic {topic} configured for SHIP discovery");
5199                    let peers = if let Some(peers) = &filter.peers {
5200                        self.ship_peers_excluding_self(peers.clone())
5201                    } else {
5202                        self.discover_ship_peers(topic, &mut ship_lookup_hosts)
5203                            .await
5204                    };
5205                    info!(
5206                        "[GASP SYNC] Discovered {} peer(s) for topic {topic}",
5207                        peers.len()
5208                    );
5209                    (peers, "ship".to_string())
5210                }
5211                SyncTarget::Peers(peer_urls) => {
5212                    info!(
5213                        "[GASP SYNC] Topic {topic} configured with {} hardcoded peer(s)",
5214                        peer_urls.len()
5215                    );
5216                    let peers: Vec<String> = peer_urls
5217                        .iter()
5218                        .filter(|url| {
5219                            if let Some(ref our_url) = self.config.hosting_url {
5220                                url.trim_end_matches('/') != our_url.trim_end_matches('/')
5221                            } else {
5222                                true
5223                            }
5224                        })
5225                        .cloned()
5226                        .collect();
5227
5228                    info!(
5229                        "[GASP SYNC] {} peer(s) for topic {topic} after self-filtering",
5230                        peers.len()
5231                    );
5232                    (peers, "peers".to_string())
5233                }
5234            };
5235
5236            let (peers, peers_filtered): (Vec<_>, Vec<_>) = peers.into_iter().partition(|peer| {
5237                filter
5238                    .peers
5239                    .as_ref()
5240                    .is_none_or(|names| names.contains(peer))
5241            });
5242            let mut peers_skipped = Vec::new();
5243            let advertisements = self.peer_advertisement_versions(topic).await;
5244            let mut errors = Vec::new();
5245            let mut pruned_inputs: u64 = 0;
5246            let mut discarded_graphs: u64 = 0;
5247            let mut expired_refused: u64 = 0;
5248            let mut roots_proven = 0;
5249            let mut roots_unproven = 0;
5250            let mut cursor_held_by = Vec::new();
5251            let expired = run.expired(topic);
5252            let mut peer_results = HashMap::new();
5253            let mut anchor_walk_held_graphs: u64 = 0;
5254            let mut finalized_graphs: u64 = 0;
5255            let mut deadline_dropped_graphs: u64 = 0;
5256            let mut cursor_moves: Vec<CursorMove> = Vec::new();
5257            let mut deferred_graphs: u64 = 0;
5258            let mut resumed_graphs: u64 = 0;
5259            let mut converged_graphs: u64 = 0;
5260            let mut dropped_graphs: Vec<DroppedDeferral> = Vec::new();
5261            let mut stalled_graphs: u64 = 0;
5262            let mut held_back_graphs: u64 = 0;
5263            let mut dead_graphs = 0;
5264            let mut expired_swept = 0;
5265            let mut memo_folded = 0;
5266            let mut unanchorable_graphs: u64 = 0;
5267            let mut unanchorable_cleared: u64 = 0;
5268            let mut absent_struck: u64 = 0;
5269            let mut route_faults: Vec<crate::route_fault::RouteFaultSeen> = Vec::new();
5270            let mut strikes_refused: u64 = 0;
5271            let mut backed_off_graphs: u64 = 0;
5272            let mut waiting_graphs: u64 = 0;
5273            let mut idle_counted: u64 = 0;
5274            let mut root_bounds = Vec::new();
5275            let mut faulted_roots: Vec<crate::gasp::FaultedRoot> = Vec::new();
5276
5277            // If we have a remote factory, actually run GASP sync
5278            if let Some(ref factory) = self.gasp_remote_factory {
5279                for peer_url in &peers {
5280                    // bsv-low#302 quarantine gate: a peer at
5281                    // PEER_QUARANTINE_THRESHOLD consecutive failures is
5282                    // SKIPPED (no attempt recorded — its last-attempt age
5283                    // keeps growing) until the re-probe window opens. A
5284                    // health-read FAULT treats the peer as healthy: broken
5285                    // bookkeeping must never silence a live peer.
5286                    //
5287                    // The peer's health (its quarantine, its yieldless
5288                    // streak) is keyed on its NORMALIZED ORIGIN (bsv-low
5289                    // #555, the delta-2 fold's D2-M2): keyed on the URL, one
5290                    // server was a fresh peer for every spelling it
5291                    // advertised.
5292                    let health_key = crate::gasp::peer_origin(peer_url);
5293                    let health = self
5294                        .storage
5295                        .get_peer_sync_health(&health_key, topic)
5296                        .await
5297                        .unwrap_or_default();
5298                    if crate::gasp::peer_sync_quarantined(&health) {
5299                        peers_skipped.push(crate::sync_load::SkippedPeer {
5300                            peer: peer_url.clone(),
5301                            reason: "quarantine".into(),
5302                            retry_after_secs: crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5303                                .saturating_sub(health.secs_since_last_attempt.unwrap_or(0)),
5304                        });
5305                        warn!(
5306                            "[GASP SYNC] Peer {peer_url} for {topic} QUARANTINED ({} consecutive failed syncs, last attempt {:?}s ago) — skipped until the {}s re-probe window (bsv-low#302)",
5307                            health.consecutive_failures,
5308                            health.secs_since_last_attempt,
5309                            crate::gasp::PEER_QUARANTINE_REPROBE_SECS
5310                        );
5311                        continue;
5312                    }
5313
5314                    let cadence = self
5315                        .storage
5316                        .get_peer_cadence(&health_key, topic)
5317                        .await
5318                        .unwrap_or_default();
5319                    let advertisement =
5320                        advertisements.get(&health_key).cloned().unwrap_or_default();
5321                    let reset_cadence = filter.explicit() || cadence.advertisement != advertisement;
5322                    if !reset_cadence && cadence.retry_after_secs > 0 {
5323                        peers_skipped.push(crate::sync_load::SkippedPeer {
5324                            peer: peer_url.clone(),
5325                            reason: "no_yield".into(),
5326                            retry_after_secs: cadence.retry_after_secs,
5327                        });
5328                        continue;
5329                    }
5330
5331                    let peer_cost_before = crate::meter::snapshot();
5332                    info!("[GASP SYNC] Syncing topic {topic} with peer {peer_url}");
5333
5334                    // Get last interaction score for this (peer, topic) pair
5335                    let last_interaction = self
5336                        .storage
5337                        .get_last_interaction(peer_url, topic)
5338                        .await
5339                        .unwrap_or(0);
5340
5341                    // Create shared sink for finalized graphs
5342                    let sink = crate::gasp_overlay::new_finalized_graph_sink();
5343
5344                    // OPT-IN ancestor hydration: only when a fetcher is
5345                    // configured do we enable chain-backed ancestor fallback +
5346                    // strict-BEEF finalize. Default (None) = byte-identical to
5347                    // today (tolerant BEEF, peer errors abandon the graph;
5348                    // the one exception is the D8 prune of a manager-named
5349                    // input the peer answers it does not hold, which exists
5350                    // only WITHOUT a fetcher, see
5351                    // `GASPSync::process_incoming_node`).
5352                    let hydration_on = sync_fetcher.is_some();
5353
5354                    // Create storage adapter and remote
5355                    let mut gasp_storage =
5356                        OverlayGASPStorage::new(self.storage.as_ref(), topic, sink.clone())
5357                            .with_peer(peer_url.as_str())
5358                            .with_configured_peer(configured_peers)
5359                            .with_strict_beef(hydration_on)
5360                            .with_script_verification(self.verify_scripts)
5361                            .with_walk_budget(self.walk_budget);
5362                    if let Some(manager) = self.managers.get(topic) {
5363                        gasp_storage = gasp_storage.with_topic_manager(manager.as_ref());
5364                    }
5365                    // bsv-low #551: the anchor check verifies each graph's
5366                    // root against THIS engine's tracker, with this engine's
5367                    // script switch, before finalize. No tracker is the
5368                    // reference's 'scripts only'.
5369                    if let Some(tracker) = self.chain_tracker.as_deref() {
5370                        gasp_storage = gasp_storage.with_chain_tracker(tracker);
5371                    }
5372                    let gasp_remote = Box::new(crate::load_remote::MeteredRemote(
5373                        factory.create_remote(peer_url, topic),
5374                    ));
5375
5376                    let log_prefix = format!("[GASP {topic} <-> {peer_url}]");
5377                    // Graphs of this peer handed to submit (bsv-low #552).
5378                    let peer_finalized = std::cell::Cell::new(0u64);
5379                    let mut sync = GASPSync::new(
5380                        Box::new(gasp_storage),
5381                        gasp_remote,
5382                        last_interaction,
5383                        &log_prefix,
5384                        true, // unidirectional — overlay GASP is pull-only (submitNode throws); matches TS Engine.startGASPSync
5385                    )
5386                    .with_ancestor_fetcher(sync_fetcher.clone())
5387                    .with_expired_outpoints(std::rc::Rc::clone(&expired))
5388                    .with_batch_memo(run.single_only(peer_url));
5389                    if let Some(control) = control {
5390                        let control = control.clone();
5391                        sync = sync.with_page_slice(std::rc::Rc::new(move || {
5392                            control.stop_reason().is_some()
5393                        }));
5394                    }
5395                    // bsv-low #555: a graph past its own budget is deferred,
5396                    // not dropped with the sync.
5397                    if let Some((sleep, max_calls, budget_ms)) = &self.graph_budget {
5398                        let (sleep, budget_ms) =
5399                            (sleep.clone(), (*budget_ms).min(crate::meter::ROOT_MAX_MS));
5400                        let half_sleep = sleep.clone();
5401                        let share_sleep = sleep.clone();
5402                        sync = sync.with_graph_budget(crate::gasp::GraphBudget {
5403                            max_calls: *max_calls,
5404                            max_bytes_fetched: self.graph_budget_limbs.0,
5405                            max_nodes: self.graph_budget_limbs.1,
5406                            deadline: Box::new(move || sleep(budget_ms)),
5407                            half_deadline: Some(Box::new(move || half_sleep(budget_ms / 2))),
5408                        });
5409                        // Engine 0.1.11: the resumes take a named share of
5410                        // the per-peer slice, the rest is the page's.
5411                        if let Some((_, peer_ms)) = &self.peer_sync_budget {
5412                            let share_ms = crate::gasp::resume_share_ms(*peer_ms, budget_ms);
5413                            sync = sync.with_resume_share(Box::new(move || share_sleep(share_ms)));
5414                        }
5415                    }
5416                    // bsv-low #552: under a budget each graph is submitted as
5417                    // it finalizes, so the deadline cannot take it back. With
5418                    // no budget nothing can drop the sync, and the graphs are
5419                    // submitted after it as they always were.
5420                    if self.peer_sync_budget.is_some() {
5421                        sync = sync.with_finalized_graph_hook(Box::new(SubmitAsFinalized {
5422                            engine: self,
5423                            sink: sink.clone(),
5424                            peer_url,
5425                            topic,
5426                            submitted: &peer_finalized,
5427                        }));
5428                    }
5429
5430                    // bsv-low#302: bound THIS peer's sync to the configured
5431                    // budget. `None` from the race = the budget won and the
5432                    // sync future was dropped mid-flight. What it finalized
5433                    // before that is already submitted (the hook above); the
5434                    // cursor then advances to `completed_cursor` only, see
5435                    // the deadline arm below. The race is GUARDED: it never
5436                    // drops the sync inside one transaction's finalize
5437                    // submit (the lens fold's HIGH-1), it waits for that
5438                    // transaction and the hook stops at the boundary.
5439                    let sync_outcome = match &self.peer_sync_budget {
5440                        Some((sleep, budget_ms)) => {
5441                            let budget_ms =
5442                                control.map_or(*budget_ms, |c| (*budget_ms).min(c.remaining_ms()));
5443                            let mut deadline = sleep(budget_ms);
5444                            let deadline = std::future::poll_fn(|cx| {
5445                                if control.is_some_and(|c| c.stop_reason().is_some())
5446                                    || std::future::Future::poll(deadline.as_mut(), cx).is_ready()
5447                                {
5448                                    std::task::Poll::Ready(())
5449                                } else {
5450                                    std::task::Poll::Pending
5451                                }
5452                            });
5453                            crate::gasp::race_or_deadline_guarded(
5454                                sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)),
5455                                deadline,
5456                                &self.finalize_gate,
5457                            )
5458                            .await
5459                        }
5460                        None => Some(sync.sync(Some(ENGINE_GASP_SYNC_PAGE_LIMIT)).await),
5461                    };
5462
5463                    let mut outcome_success = matches!(sync_outcome, Some(Ok(())));
5464                    // Engine 0.2.0 (#57): the platform answered in the peer's
5465                    // place, so this attempt says nothing about the peer.
5466                    let route_faulted = matches!(
5467                        sync_outcome,
5468                        Some(Err(crate::gasp::GASPError::RouteFault { .. }))
5469                    );
5470                    // D8 decoy rule: branches pruned in this peer's walk. Not
5471                    // an error and not a failed UTXO, so it is counted apart
5472                    // from `errors` and never touches the outcome or cursor.
5473                    // Summed over peers: one decoy seen through two peers
5474                    // counts twice.
5475                    pruned_inputs += sync.pruned_inputs();
5476                    expired_refused += sync.expired_refused();
5477                    roots_proven += sync.roots_proven();
5478                    roots_unproven += sync.roots_unproven();
5479                    // bsv-low #551: graphs the anchor check refused. Counted
5480                    // apart from `errors` for the same reason as a prune: a
5481                    // refused graph is not a failed sync (the reference
5482                    // discards it and carries on).
5483                    discarded_graphs += sync.discarded_graphs();
5484                    anchor_walk_held_graphs += sync.anchor_walk_held();
5485                    // Whether the sync ran (to its end, or to the deadline)
5486                    // and whether it moved the cursor (bsv-low #555, the
5487                    // delta fold's D-M2).
5488                    let ran = !matches!(sync_outcome, Some(Err(_)));
5489                    let mut advanced_cursor = false;
5490                    match sync_outcome {
5491                        None => {
5492                            let budget_ms = self.peer_sync_budget.as_ref().map_or(0, |(_, ms)| *ms);
5493                            let msg = format!(
5494                                "{peer_url}: per-peer sync budget of {budget_ms} ms EXCEEDED — dropped (bsv-low#302)"
5495                            );
5496                            warn!("[GASP SYNC] {msg}");
5497                            errors.push(msg);
5498
5499                            // bsv-low #552: the work COMPLETED before the
5500                            // deadline is kept. Its graphs are already
5501                            // submitted (the hook); the cursor moves past the
5502                            // UTXOs that were completed and stops below the
5503                            // one in flight, whose graph is lost whole and
5504                            // walked again next tick. A graph the deadline
5505                            // met while it was being SUBMITTED is not "in
5506                            // flight" here: it left a prefix of whole
5507                            // transactions, is counted finalized, and its
5508                            // UTXO is below the cursor too.
5509                            let in_flight = u64::from(sync.graph_in_flight());
5510                            deadline_dropped_graphs += in_flight;
5511                            // bsv-low #555: the walk in hand is saved, so the
5512                            // next tick resumes it instead of walking it again.
5513                            sync.defer_in_flight().await;
5514                            let completed = sync.completed_cursor();
5515                            if completed > last_interaction {
5516                                match self
5517                                    .storage
5518                                    .update_last_interaction(peer_url, topic, completed)
5519                                    .await
5520                                {
5521                                    Ok(()) => cursor_moves.push(CursorMove {
5522                                        peer: peer_url.clone(),
5523                                        from: last_interaction,
5524                                        to: completed,
5525                                    }),
5526                                    Err(e) => warn!(
5527                                        "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5528                                    ),
5529                                }
5530                            }
5531                            // A tick that finalized a graph or moved the
5532                            // cursor reached a live peer: not a failed
5533                            // attempt for the quarantine count. So did one
5534                            // whose walk PROGRESSED (bsv-low #555: it
5535                            // appended a node, or completed its graph). A
5536                            // deferral that fetched nothing is no progress
5537                            // (the lens fold's H1: a hung peer deferred with
5538                            // 0 nodes on every tick and was never
5539                            // quarantined).
5540                            //
5541                            // Engine 0.1.8: the deadline drop itself is not a
5542                            // failure of the peer. The per-peer deadline is
5543                            // OUR slice of the tick, set by our cadence and
5544                            // the Worker's limits; a peer that served every
5545                            // page and node we asked for until it fell due
5546                            // did not fail. What the slice did is the test:
5547                            // a sync that refused an expired root, retained
5548                            // a graph or resumed one with progress did work
5549                            // too (`GASPSync::did_work`).
5550                            advanced_cursor = completed > last_interaction;
5551                            outcome_success = peer_finalized.get() > 0
5552                                || (completed > last_interaction && sync.cursor_yielded())
5553                                || sync.deferral_stats().progressed > 0
5554                                || sync.did_work();
5555                            warn!(
5556                                "[GASP SYNC] {peer_url} for {topic} at the deadline: finalized_graphs={} deadline_dropped_graphs={in_flight} cursor {last_interaction} -> {} (bsv-low #552)",
5557                                peer_finalized.get(),
5558                                completed.max(last_interaction)
5559                            );
5560                        }
5561                        Some(Ok(())) => {
5562                            // Submit finalized graphs to the Engine (under a
5563                            // budget the hook already did, graph by graph,
5564                            // and the sink is empty here).
5565                            let landed = self
5566                                .submit_finalized_graphs(&sink, peer_url, topic, &peer_finalized)
5567                                .await;
5568                            // bsv-low #555, the lens fold's H1: a sync that
5569                            // ran to its end with walks the per-graph budget
5570                            // cut having FETCHED NOTHING, and nothing else
5571                            // got done (no walk progressed, no graph
5572                            // finalized, the cursor did not move), reached
5573                            // a peer that does not answer: a FAILED attempt.
5574                            // Before #555 such a peer held the sync to the
5575                            // per-peer deadline, a failure; the per-graph
5576                            // budget had made it an `Ok` sync. A sync whose
5577                            // UTXOs failed in any other way is unchanged.
5578                            let stats = sync.deferral_stats();
5579                            if stats.stalled > 0
5580                                && stats.progressed == 0
5581                                && peer_finalized.get() == 0
5582                                && !sync.did_work()
5583                                && !(landed
5584                                    && sync.last_interaction > last_interaction
5585                                    && sync.cursor_yielded())
5586                            {
5587                                outcome_success = false;
5588                                let msg = format!(
5589                                    "{peer_url}: {} graph walk(s) cut by the per-graph budget with nothing fetched (bsv-low #555)",
5590                                    stats.stalled
5591                                );
5592                                warn!("[GASP SYNC] {msg}");
5593                                errors.push(msg);
5594                            }
5595
5596                            // Advance the persisted cursor when it moved forward — i.e.
5597                            // the peer reported UTXOs at a higher score than our last
5598                            // `since` (the in-memory cursor only grows on SEEN scores,
5599                            // so `>` already excludes the empty-bootstrap case where no
5600                            // UTXOs were seen). Mirrors TS `if (gasp.lastInteraction >
5601                            // lastInteraction)` — NOT gated on a new submission: a sync
5602                            // that re-sees already-known UTXOs must still advance, else
5603                            // every cron re-scans the same range forever (the cursor
5604                            // stayed pinned at 0 in prod).
5605                            //
5606                            // NOT advanced when a finalize submit above did
5607                            // not land (the lens fold's MEDIUM-1): the sink
5608                            // does not say which UTXO that graph was, so the
5609                            // whole cursor stays and the next sync is served
5610                            // the range again (what did land is known and
5611                            // skipped). Under a budget the hook fails that
5612                            // one UTXO instead and the gap guard has already
5613                            // capped `sync.last_interaction` below it.
5614                            advanced_cursor = landed && sync.last_interaction > last_interaction;
5615                            if !landed {
5616                                warn!(
5617                                    "[GASP SYNC] a finalize submit for {topic} from {peer_url} did not land: the cursor stays at {last_interaction}"
5618                                );
5619                            } else if sync.last_interaction > last_interaction {
5620                                match self
5621                                    .storage
5622                                    .update_last_interaction(peer_url, topic, sync.last_interaction)
5623                                    .await
5624                                {
5625                                    Ok(()) => cursor_moves.push(CursorMove {
5626                                        peer: peer_url.clone(),
5627                                        from: last_interaction,
5628                                        to: sync.last_interaction,
5629                                    }),
5630                                    Err(e) => warn!(
5631                                        "[GASP SYNC] Failed to update last_interaction for {peer_url}/{topic}: {e}"
5632                                    ),
5633                                }
5634                            }
5635                            info!(
5636                                "[GASP SYNC] Sync with {peer_url} for {topic} completed (last_interaction={})",
5637                                sync.last_interaction
5638                            );
5639                        }
5640                        Some(Err(e)) => {
5641                            let msg = format!("{peer_url}: {e}");
5642                            warn!("[GASP SYNC] Sync failed: {msg}");
5643                            errors.push(msg);
5644                            if let crate::gasp::GASPError::RouteFault { class, .. } = e {
5645                                route_faults.push(crate::route_fault::RouteFaultSeen {
5646                                    peer: peer_url.clone(),
5647                                    class,
5648                                });
5649                            }
5650                        }
5651                    }
5652                    // Engine 0.1.8: work is not a failure. A sync that
5653                    // finalized a graph, moved the cursor past what it
5654                    // finished, refused an expired root not refused before,
5655                    // retained a graph or resumed one with progress is never
5656                    // a failed attempt (a peer is quarantined for being
5657                    // dead, never for our cadence), even if it then faulted.
5658                    //
5659                    // The YIELD that ends the yieldless streak (and alone
5660                    // lifts a quarantine, D4-L2) is the narrower set: a
5661                    // finalized graph, a moved cursor, a refusal. A held or
5662                    // resumed walk is the peer's word until its anchor is
5663                    // checked: counted as a yield, a peer that lists one
5664                    // fabricated root a tick, serves it and hangs on its
5665                    // input would never be quarantined (bsv-low #555's D-M2).
5666                    // Such syncs stay inside the yieldless allowance (12
5667                    // syncs and 3 h), which an honest deep graph converges in.
5668                    let yielded = peer_finalized.get() > 0
5669                        || (advanced_cursor && sync.cursor_yielded())
5670                        || sync.expired_refused() > 0;
5671                    if yielded || sync.did_work() {
5672                        outcome_success = true;
5673                    }
5674                    // Engine 0.2.0 (#57): a route fault with no work done
5675                    // before it touches none of the peer's health rows: not
5676                    // its failures (the quarantine), not its yieldless
5677                    // streak, not its quiet cadence. The quarantine judges
5678                    // the peer, never the route. A sync the peer served work
5679                    // in before the route faulted is the peer's answer and
5680                    // is recorded as one.
5681                    let route_excused = route_faulted && !outcome_success;
5682                    if route_excused {
5683                        info!(
5684                            "[GASP SYNC] {peer_url} for {topic}: {} before the peer was reached; nothing counted against the peer (#57)",
5685                            route_faults
5686                                .last()
5687                                .map_or(String::new(), crate::route_fault::RouteFaultSeen::label)
5688                        );
5689                    }
5690                    // bsv-low #555, the delta fold's D-M2: "progress" (a node
5691                    // appended) is the peer's word. A sync the peer served
5692                    // work that finalized no graph and moved no cursor is
5693                    // YIELDLESS; past PEER_YIELDLESS_SYNCS_ALLOWED of them in
5694                    // a row each is a FAILED attempt, so a peer that serves
5695                    // one fresh root a tick and hangs on its input is
5696                    // quarantined. A quiet sync (nothing served) leaves the
5697                    // count; a fault of the count is fail-safe (no failure).
5698                    // Only with a per-graph budget: without one nothing is
5699                    // deferred and #302's rule is unchanged.
5700                    if ran
5701                        && self.graph_budget.is_some()
5702                        && (yielded || sync.graphs_attempted() > 0)
5703                    {
5704                        match self
5705                            .storage
5706                            .record_peer_sync_yield(&health_key, topic, yielded)
5707                            .await
5708                        {
5709                            Ok(streak)
5710                                if !yielded
5711                                    && crate::gasp::yieldless_sync_failed(&streak)
5712                                    && outcome_success =>
5713                            {
5714                                outcome_success = false;
5715                                let msg = format!(
5716                                    "{peer_url}: {} consecutive syncs over {} s finalized no graph and moved no cursor (past {} and {} s; bsv-low #555)",
5717                                    streak.yieldless_syncs,
5718                                    streak.secs_since_first.unwrap_or(0),
5719                                    crate::gasp::PEER_YIELDLESS_SYNCS_ALLOWED,
5720                                    crate::gasp::PEER_YIELDLESS_SECS_ALLOWED
5721                                );
5722                                warn!("[GASP SYNC] {msg}");
5723                                errors.push(msg);
5724                            }
5725                            Ok(_) => {}
5726                            Err(e) => warn!(
5727                                "[GASP SYNC] failed to record the yield of {peer_url}/{topic}: {e}"
5728                            ),
5729                        }
5730                    }
5731                    // Quiet success controls polling cadence, never peer health. Faults
5732                    // retain #302's attempt cadence; retained work keeps its resume slice.
5733                    let quiet = outcome_success
5734                        && !yielded
5735                        && !sync.did_work()
5736                        && sync.faulted_roots().is_empty()
5737                        && sync.deferral_stats().stalled == 0;
5738                    let quiet_syncs = if quiet && !filter.explicit() {
5739                        if reset_cadence {
5740                            1
5741                        } else {
5742                            cadence.quiet_syncs.saturating_add(1)
5743                        }
5744                    } else {
5745                        0
5746                    };
5747                    let next = crate::sync_load::PeerCadence {
5748                        quiet_syncs,
5749                        retry_after_secs: crate::sync_load::quiet_retry_secs(quiet_syncs),
5750                        advertisement,
5751                    };
5752                    if !route_excused {
5753                        if let Err(e) = self
5754                            .storage
5755                            .set_peer_cadence(&health_key, topic, &next)
5756                            .await
5757                        {
5758                            warn!(
5759                                "[GASP SYNC] failed to record cadence for {peer_url}/{topic}: {e}"
5760                            );
5761                        }
5762                    }
5763                    if !advanced_cursor {
5764                        if let Some(outpoint) = sync.cursor_held_by() {
5765                            cursor_held_by.push(outpoint);
5766                        }
5767                    }
5768                    finalized_graphs += peer_finalized.get();
5769                    let deferral = sync.deferral_stats();
5770                    root_bounds.extend(deferral.root_bounds.iter().cloned().map(|mut bound| {
5771                        bound.peer.clone_from(peer_url);
5772                        bound
5773                    }));
5774                    deferred_graphs += deferral.deferred;
5775                    resumed_graphs += deferral.resumed;
5776                    converged_graphs += deferral.converged;
5777                    stalled_graphs += deferral.stalled;
5778                    held_back_graphs += deferral.held_back;
5779                    backed_off_graphs += deferral.backed_off;
5780                    waiting_graphs += deferral.waiting;
5781                    idle_counted += deferral.idle_counted;
5782                    dropped_graphs.extend(deferral.dropped.into_iter().map(|d| DroppedDeferral {
5783                        peer: peer_url.clone(),
5784                        outpoint: d.outpoint,
5785                        reason: d.reason.as_str().to_string(),
5786                    }));
5787                    // Engine 0.1.9: the roots dropped, each with why.
5788                    dead_graphs += sync.dead_recorded();
5789                    expired_swept += sync.expired_swept();
5790                    memo_folded += sync.memo_folded();
5791                    unanchorable_graphs += sync.unanchorable_recorded();
5792                    unanchorable_cleared += sync.unanchorable_cleared();
5793                    absent_struck += sync.absent_struck();
5794                    // Engine 0.1.10: a strike not written is a fact.
5795                    strikes_refused += sync.strikes_refused();
5796                    faulted_roots.extend(sync.faulted_roots().into_iter().map(|mut f| {
5797                        f.peer.clone_from(peer_url);
5798                        f
5799                    }));
5800
5801                    // bsv-low#302: record the attempt outcome (success resets
5802                    // the consecutive-failure count; timeout/error increments
5803                    // it). Best-effort — a bookkeeping fault must never fail
5804                    // the sync pass itself.
5805                    //
5806                    // bsv-low #555, the delta-4 fold's D4-L2: with a
5807                    // per-graph budget, a peer that was at the quarantine
5808                    // threshold when this sync began is re-admitted only by
5809                    // a YIELD (a graph finalized, the cursor moved). A sync
5810                    // that did not fail and yielded nothing (an empty
5811                    // listing at its probe, a walk that only progressed) is
5812                    // NOT RECORDED: the failures stay, and so does the
5813                    // last-attempt stamp, so the peer is attended again on
5814                    // the next tick and its next failure re-arms the
5815                    // quarantine at once. Recorded as a success, a hostile
5816                    // peer's empty listing at each probe reset its failures
5817                    // and (the decay being for a peer that is not failed)
5818                    // its yieldless streak: 100 of 200 ticks. A health read
5819                    // that faulted read 0 failures: the old rule, fail-safe.
5820                    if route_excused {
5821                        // Nothing recorded: see `route_excused` above.
5822                    } else if outcome_success
5823                        && !yielded
5824                        && self.graph_budget.is_some()
5825                        && health.consecutive_failures >= crate::gasp::PEER_QUARANTINE_THRESHOLD
5826                    {
5827                        info!(
5828                            "[GASP SYNC] {peer_url} for {topic}: a sync with no yield does not lift its quarantine ({} consecutive failed syncs kept; bsv-low #555)",
5829                            health.consecutive_failures
5830                        );
5831                    } else if let Err(e) = self
5832                        .storage
5833                        .record_peer_sync_outcome(&health_key, topic, outcome_success)
5834                        .await
5835                    {
5836                        warn!(
5837                            "[GASP SYNC] failed to record peer sync outcome for {peer_url}/{topic}: {e}"
5838                        );
5839                    }
5840                    let cursor = self.storage.get_last_interaction(peer_url, topic).await?;
5841                    let cost = crate::meter::snapshot().since(peer_cost_before);
5842                    peer_results.insert(
5843                        peer_url.clone(),
5844                        crate::backfill::PeerSyncResult {
5845                            slices: u64::from(sync.page_advanced() || cost.outputs_walked > 0),
5846                            pages: sync.pages(),
5847                            reached_tip: sync.reached_tip() && cursor >= sync.completed_cursor(),
5848                            cursor,
5849                            cost,
5850                        },
5851                    );
5852                }
5853            }
5854
5855            topics_synced.insert(
5856                topic.clone(),
5857                TopicSyncResult {
5858                    cost: crate::meter::snapshot().since(cost_before),
5859                    root_bounds,
5860                    peers_skipped,
5861                    peers_filtered,
5862                    roots_proven,
5863                    roots_unproven,
5864                    cursor_held_by,
5865                    expired_refused,
5866                    peers,
5867                    sync_type,
5868                    errors,
5869                    pruned_inputs,
5870                    discarded_graphs,
5871                    anchor_walk_held_graphs,
5872                    finalized_graphs,
5873                    deadline_dropped_graphs,
5874                    cursor_moves,
5875                    deferred_graphs,
5876                    resumed_graphs,
5877                    converged_graphs,
5878                    dropped_graphs,
5879                    stalled_graphs,
5880                    held_back_graphs,
5881                    backed_off_graphs,
5882                    waiting_graphs,
5883                    idle_counted,
5884                    peer_results,
5885                    dead_graphs,
5886                    expired_swept,
5887                    memo_folded,
5888                    unanchorable_graphs,
5889                    unanchorable_cleared,
5890                    absent_struck,
5891                    route_faults,
5892                    strikes_refused,
5893                    faulted_roots,
5894                },
5895            );
5896        }
5897
5898        info!(
5899            "[GASP SYNC] Peer discovery complete for {} topic(s)",
5900            topics_synced.len()
5901        );
5902
5903        let expired: u64 = topics_synced.values().map(|r| r.expired_refused).sum();
5904        if expired > 0 {
5905            info!("[GASP SYNC] gaspExpiredRefused={expired}");
5906        }
5907        let unanchorable: u64 = topics_synced.values().map(|r| r.unanchorable_graphs).sum();
5908        if unanchorable > 0 {
5909            info!("[GASP SYNC] gaspUnanchorable={unanchorable}");
5910        }
5911        let absent: u64 = topics_synced.values().map(|r| r.absent_struck).sum();
5912        if absent > 0 {
5913            info!("[GASP SYNC] gaspAbsentStruck={absent}");
5914        }
5915        let routes: usize = topics_synced.values().map(|r| r.route_faults.len()).sum();
5916        if routes > 0 {
5917            info!("[GASP SYNC] gaspRouteFaults={routes}");
5918        }
5919        let refused: u64 = topics_synced.values().map(|r| r.strikes_refused).sum();
5920        if refused > 0 {
5921            info!("[GASP SYNC] gaspStrikesRefused={refused}");
5922        }
5923        Ok(GASPSyncResult {
5924            cost: crate::meter::snapshot(),
5925            topics_synced,
5926            filter: filter.clone(),
5927            topics_filtered,
5928        })
5929    }
5930
5931    /// Drain `sink` and submit every finalized graph in it, ancestors first
5932    /// (`historical-tx-no-spv`: the anchor check already verified them).
5933    /// Each graph is added to `submitted` as its first submit starts. Returns
5934    /// whether EVERY transaction of every graph landed.
5935    ///
5936    /// Each graph here passed the anchor check: its root verified and the
5937    /// replay admitted it, so these submits rest on a checked premise and are
5938    /// not expected to fail. If one does not LAND, the REST OF THAT GRAPH is
5939    /// not submitted: the reference's `finalizeGraph` awaits each submit in
5940    /// order and its throw ends the loop (the BEEFs already submitted stay,
5941    /// it has no rollback either). A later BEEF would otherwise be judged
5942    /// without the coin its ancestor failed to leave, admit nothing and be
5943    /// recorded as applied, a dupe for good. "Landed" is read from the
5944    /// durability report, not from `Ok`: `submit` answers `Ok` when a storage
5945    /// write faulted and when the topic manager failed, and in both it
5946    /// records no applied row. A transaction landed when its topic is applied
5947    /// or was already applied (a dupe). Other graphs carry on, and the caller
5948    /// keeps the cursor from passing a graph that did not land.
5949    ///
5950    /// Each transaction's submit is one write section of the engine's gate
5951    /// ([`Engine::finalize_submit_gate`]) and the gate is asked between two:
5952    /// a deadline that is due stops the sequence there (this future is
5953    /// dropped at that await, between two whole transactions).
5954    async fn submit_finalized_graphs(
5955        &self,
5956        sink: &crate::gasp_overlay::FinalizedGraphSink,
5957        peer_url: &str,
5958        topic: &str,
5959        submitted: &std::cell::Cell<u64>,
5960    ) -> bool {
5961        let finalized: Vec<_> = sink
5962            .lock()
5963            .unwrap_or_else(std::sync::PoisonError::into_inner)
5964            .drain(..)
5965            .collect();
5966
5967        let mut all_landed = true;
5968        for graph in &finalized {
5969            for (position, beef_bytes) in graph.beefs.iter().enumerate() {
5970                self.finalize_gate.stop_if_due().await;
5971                if position == 0 {
5972                    submitted.set(submitted.get() + 1);
5973                }
5974                let tagged = TaggedBEEF::new(beef_bytes.clone(), vec![graph.topic.clone()]);
5975                // No deadline drops a write section, and the section does
5976                // not drop ITSELF either (bsv-low #559, the delta lens's
5977                // DELTA-3 and the lens fold of 2026-10-07, F2,
5978                // `set_finalize_submit_budget`): the submit always runs to
5979                // its end. What is bounded is each storage call and hook
5980                // inside it ([`CallBound`]): one that never answers is that
5981                // call's FAULT, the submit handles it as any fault (nothing
5982                // of the transaction held, or all of it), the transaction
5983                // did not land, its UTXO fails and the cursor stays.
5984                let outcome = {
5985                    let _writing = self.finalize_gate.write_section();
5986                    let bound = self
5987                        .finalize_submit_budget
5988                        .as_ref()
5989                        .map(|(sleep, budget_ms)| CallBound::new(sleep, *budget_ms));
5990                    self.submit_bounded(
5991                        &tagged,
5992                        SubmitMode::HistoricalTxNoSpv,
5993                        bound.as_ref(),
5994                        true,
5995                        WalkBreachPolicy::NotNow,
5996                    )
5997                    .await
5998                };
5999                let not_landed = match outcome {
6000                    Ok((_, report)) => {
6001                        let landed = report.is_durable()
6002                            && (report.applied_topics.contains(&graph.topic)
6003                                || report.deduped_topics.contains(&graph.topic));
6004                        (!landed).then(|| {
6005                            if report.is_durable() {
6006                                "the topic manager failed on it".to_string()
6007                            } else {
6008                                format!("not durable: {}", report.summary())
6009                            }
6010                        })
6011                    }
6012                    Err(e) => Some(e.to_string()),
6013                };
6014                if let Some(why) = not_landed {
6015                    warn!(
6016                        "[GASP SYNC] A finalize submit for topic {} did not land ({why}); the rest of its graph is not submitted",
6017                        graph.topic
6018                    );
6019                    all_landed = false;
6020                    break;
6021                }
6022            }
6023        }
6024
6025        if !finalized.is_empty() {
6026            info!(
6027                "[GASP SYNC] Submitted {} finalized graph(s) for {topic} from {peer_url}",
6028                finalized.len()
6029            );
6030        }
6031        all_landed
6032    }
6033
6034    /// Union local records, tracker answers, SLAP-named providers and bootstrap.
6035    /// Reference: overlay-services@480d77a src/Engine.ts:839-876 and
6036    /// ts-stack@edf6e03 packages/sdk/src/overlay-tools/LookupResolver.ts:1976.
6037    /// Ask the configured topic peers for held outputs, using the same SHIP
6038    /// discovery as sync. Only a header-verified proof can change stored bytes.
6039    pub async fn rerequest_held_proofs(
6040        &self,
6041        candidates: &[crate::proof_window::HeldOutput],
6042        progress: &std::cell::Cell<crate::proof_window::PeerProofSummary>,
6043    ) {
6044        use crate::proof_window::{PEER_PROOF_REQUEST_MS, PEER_PROOF_ROW_LIMIT};
6045        let Some(factory) = &self.gasp_remote_factory else {
6046            return;
6047        };
6048        let Some(tracker) = self.chain_tracker.as_deref() else {
6049            return;
6050        };
6051        let mut lookup_hosts = None;
6052        let mut topic_peers = HashMap::new();
6053        let mut landed = HashSet::new();
6054        for candidate in candidates.iter().take(PEER_PROOF_ROW_LIMIT) {
6055            if !self.managers.contains_key(&candidate.topic) {
6056                continue;
6057            }
6058            if let std::collections::hash_map::Entry::Vacant(entry) =
6059                topic_peers.entry(candidate.topic.clone())
6060            {
6061                let peers = match self.config.sync_configuration.get(&candidate.topic) {
6062                    Some(SyncTarget::Ship) => {
6063                        self.discover_ship_peers(&candidate.topic, &mut lookup_hosts)
6064                            .await
6065                    }
6066                    Some(SyncTarget::Peers(peers)) => self.ship_peers_excluding_self(peers.clone()),
6067                    _ => Vec::new(),
6068                };
6069                entry.insert(peers);
6070            }
6071            for peer in &topic_peers[&candidate.topic] {
6072                let remote = factory.create_remote(peer, &candidate.topic);
6073                let graph_id = format!("{}.{}", candidate.txid, candidate.output_index);
6074                let mut summary = progress.get();
6075                summary.requests += 1;
6076                progress.set(summary);
6077                let ask =
6078                    remote.request_node(&graph_id, &candidate.txid, candidate.output_index, false);
6079                let response = if let Some((sleep, _)) = &self.peer_sync_budget {
6080                    crate::gasp::race_or_deadline(ask, sleep(PEER_PROOF_REQUEST_MS)).await
6081                } else {
6082                    Some(ask.await)
6083                };
6084                let Some(Ok(node)) = response else {
6085                    let mut summary = progress.get();
6086                    summary.faults += 1;
6087                    progress.set(summary);
6088                    continue;
6089                };
6090                if landed.contains(&candidate.txid) {
6091                    continue;
6092                }
6093                let Some(proof_hex) = node.proof else {
6094                    continue;
6095                };
6096                let Ok(mut proof) = beef_limits::merkle_path_from_hex(
6097                    &proof_hex,
6098                    beef_limits::COURIER_PROOF_MAX_BYTES,
6099                ) else {
6100                    let mut summary = progress.get();
6101                    summary.refused += 1;
6102                    progress.set(summary);
6103                    continue;
6104                };
6105                let valid = match proof.compute_root(Some(&candidate.txid)) {
6106                    Ok(root) => tracker
6107                        .is_valid_root_for_height(&root, proof.block_height)
6108                        .await
6109                        .unwrap_or(false),
6110                    Err(_) => false,
6111                };
6112                if !valid {
6113                    let mut summary = progress.get();
6114                    summary.refused += 1;
6115                    progress.set(summary);
6116                    continue;
6117                }
6118                // Root verification accepts a hash leaf too. Mark the verified
6119                // subject so BEEF attaches the path before its proof flag moves.
6120                for leaf in &mut proof.path[0] {
6121                    if leaf.hash.as_deref() == Some(&candidate.txid) {
6122                        leaf.txid = true;
6123                    }
6124                }
6125                match self.land_peer_proof(candidate, &proof).await {
6126                    Ok(()) => {
6127                        landed.insert(candidate.txid.clone());
6128                        let mut summary = progress.get();
6129                        summary.landed += 1;
6130                        progress.set(summary);
6131                    }
6132                    Err(error) => {
6133                        warn!(%error, "[proof window] verified proof could not be stored");
6134                        let mut summary = progress.get();
6135                        summary.faults += 1;
6136                        progress.set(summary);
6137                    }
6138                }
6139            }
6140        }
6141    }
6142
6143    async fn land_peer_proof(
6144        &self,
6145        candidate: &crate::proof_window::HeldOutput,
6146        proof: &bsv_rs::transaction::MerklePath,
6147    ) -> Result<(), EngineError> {
6148        let output = self
6149            .storage
6150            .find_output(
6151                &candidate.txid,
6152                candidate.output_index,
6153                Some(&candidate.topic),
6154                None,
6155                true,
6156            )
6157            .await
6158            .map_err(|e| EngineError::StorageError(e.to_string()))?
6159            .ok_or(EngineError::NodeNotFound)?;
6160        let beef = output
6161            .beef
6162            .ok_or_else(|| EngineError::BeefParseError("missing stored BEEF".into()))?;
6163        let stitched = Self::stitch_proof_into_stored_beef(&beef, &candidate.txid, proof)
6164            .ok_or_else(|| {
6165                EngineError::BeefParseError("proof does not stitch into stored subject".into())
6166            })?;
6167        self.storage
6168            .update_transaction_beef(&candidate.txid, &stitched)
6169            .await
6170            .map_err(|e| EngineError::StorageError(e.to_string()))?;
6171        self.storage
6172            .mark_transaction_proven_at(&candidate.txid, Some(u64::from(proof.block_height)))
6173            .await
6174            .map_err(|e| EngineError::StorageError(e.to_string()))
6175    }
6176
6177    /// Read our admitted advertisement set, not a foreign history. A changed
6178    /// SLAP outpoint wakes its origin; SHIP scopes that wake to its topic.
6179    async fn peer_advertisement_versions(&self, topic: &str) -> HashMap<String, String> {
6180        let mut versions: HashMap<String, Vec<String>> = HashMap::new();
6181        for (ad_topic, protocol, scope) in [
6182            ("tm_slap", &b"SLAP"[..], None),
6183            ("tm_ship", &b"SHIP"[..], Some(topic)),
6184        ] {
6185            let rows = self
6186                .storage
6187                .find_utxos_for_topic(ad_topic, None, None, false)
6188                .await
6189                .unwrap_or_default();
6190            for row in rows {
6191                if let Some(domain) =
6192                    parse_advertisement_domain_from_script(&row.output_script, protocol, scope)
6193                {
6194                    versions
6195                        .entry(crate::gasp::peer_origin(&domain))
6196                        .or_default()
6197                        .push(format!("{}:{}", row.txid, row.output_index));
6198                }
6199            }
6200        }
6201        versions
6202            .into_iter()
6203            .map(|(peer, mut keys)| {
6204                keys.sort();
6205                (peer, keys.join(","))
6206            })
6207            .collect()
6208    }
6209
6210    async fn discover_ship_peers(
6211        &self,
6212        topic: &str,
6213        lookup_hosts: &mut Option<Vec<String>>,
6214    ) -> Vec<String> {
6215        let mut domains = self.local_ship_peers(topic).await;
6216        if let Some(bootstrap) = self.ship_bootstrap_peers.get(topic) {
6217            domains.extend(bootstrap.iter().cloned());
6218        }
6219        if let Some(client) = &self.ship_lookup_client {
6220            if lookup_hosts.is_none() {
6221                *lookup_hosts = Some(self.discover_ship_lookup_hosts(client.as_ref()).await);
6222            }
6223            let question = LookupQuestion::new("ls_ship", serde_json::json!({"topics": [topic]}));
6224            for host in lookup_hosts.as_ref().into_iter().flatten() {
6225                crate::meter::peer_request();
6226                match client.lookup(host, &question).await {
6227                    Ok(outputs) => {
6228                        domains.extend(outputs.iter().filter_map(|output| {
6229                            parse_lookup_advertisement(output, b"SHIP", topic)
6230                        }));
6231                    }
6232                    Err(e) => warn!("[GASP SYNC] SHIP lookup at {host} for {topic} failed: {e}"),
6233                }
6234            }
6235        }
6236        self.ship_peers_excluding_self(domains)
6237    }
6238
6239    async fn discover_ship_lookup_hosts(
6240        &self,
6241        client: &dyn crate::ship_lookup::ShipLookupClient,
6242    ) -> Vec<String> {
6243        use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
6244        let trackers = self.ship_peers_excluding_self(self.config.slap_trackers.clone());
6245        let tracker_origins: HashSet<_> = trackers
6246            .iter()
6247            .map(|host| crate::gasp::peer_origin(host))
6248            .collect();
6249        let question = LookupQuestion::new("ls_slap", serde_json::json!({"service": "ls_ship"}));
6250        let mut advertised = Vec::new();
6251        for tracker in &trackers {
6252            crate::meter::peer_request();
6253            match client.lookup(tracker, &question).await {
6254                Ok(outputs) => {
6255                    advertised.extend(outputs.iter().filter_map(|output| {
6256                        parse_lookup_advertisement(output, b"SLAP", "ls_ship")
6257                    }));
6258                }
6259                Err(e) => warn!("[GASP SYNC] SLAP lookup at {tracker} failed: {e}"),
6260            }
6261        }
6262        let providers = self
6263            .ship_peers_excluding_self(advertised)
6264            .into_iter()
6265            .filter(|host| !tracker_origins.contains(&crate::gasp::peer_origin(host)))
6266            .take(SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
6267        trackers.into_iter().chain(providers).collect()
6268    }
6269
6270    fn ship_peers_excluding_self(&self, domains: impl IntoIterator<Item = String>) -> Vec<String> {
6271        crate::gasp::ship_peers_by_origin(domains.into_iter().filter(|domain| {
6272            self.config.hosting_url.as_ref().is_none_or(|our_url| {
6273                crate::gasp::peer_origin(domain) != crate::gasp::peer_origin(our_url)
6274            })
6275        }))
6276    }
6277
6278    /// Read local SHIP records independently of network discovery failures.
6279    async fn local_ship_peers(&self, topic: &str) -> Vec<String> {
6280        let Some(ship_ls) = self.lookup_services.get("ls_ship") else {
6281            warn!("[GASP SYNC] No local ls_ship; retaining network and bootstrap discovery");
6282            return Vec::new();
6283        };
6284
6285        let question = LookupQuestion::new("ls_ship", serde_json::json!({ "topics": [topic] }));
6286
6287        let refs = match ship_ls.lookup(&question).await {
6288            Ok(LookupResult::OutputList(refs)) => refs,
6289            Ok(LookupResult::Answer(_)) => {
6290                error!(
6291                    "[GASP SYNC] SHIP lookup for topic {topic} returned a pre-formed \
6292                     LookupAnswer; expected OutputList. Skipping."
6293                );
6294                return Vec::new();
6295            }
6296            Err(e) => {
6297                error!("[GASP SYNC] SHIP lookup for topic {topic} failed: {e}");
6298                return Vec::new();
6299            }
6300        };
6301
6302        let mut domains = HashSet::new();
6303        for reference in &refs {
6304            if let Ok(Some(output)) = self
6305                .storage
6306                .find_output(&reference.txid, reference.output_index, None, None, false)
6307                .await
6308            {
6309                if let Some(domain) = parse_ship_domain_from_script(&output.output_script) {
6310                    // Every spelling of our own origin is ourselves (the
6311                    // delta-2 fold's D2-M2: `https://us/?x` passed the
6312                    // string compare).
6313                    if let Some(ref our_url) = self.config.hosting_url {
6314                        if domain.trim_end_matches('/') == our_url.trim_end_matches('/')
6315                            || crate::gasp::peer_origin(&domain)
6316                                == crate::gasp::peer_origin(our_url)
6317                        {
6318                            continue;
6319                        }
6320                    }
6321                    domains.insert(domain);
6322                }
6323            }
6324        }
6325
6326        // One peer per normalized origin (bsv-low #555, the delta-2 fold's
6327        // D2-M2, restored by the delta-4 fold's D4-M1): eight spellings of
6328        // one server are one peer, the one its health row is keyed by.
6329        crate::gasp::ship_peers_by_origin(domains)
6330    }
6331
6332    // ========================================================================
6333    // Helpers
6334    // ========================================================================
6335
6336    /// Get a reference to the storage backend.
6337    pub fn storage(&self) -> &dyn Storage {
6338        self.storage.as_ref()
6339    }
6340
6341    /// Get the engine configuration.
6342    pub fn config(&self) -> &EngineConfig {
6343        &self.config
6344    }
6345}
6346
6347/// The SPV check of [`Engine::submit`], as one function over a BORROWED
6348/// tracker: the reference's `tx.verify(chainTracker)` (roots against the
6349/// tracker, every unproven input's script executed, the value rule), its
6350/// `tx.verify('scripts only')` when `chain_tracker` is `None` (roots accepted
6351/// unchecked, scripts still run), and the pre-2026-09-08 structural check when
6352/// `verify_scripts` is `false` ([`Engine::set_script_verification`]'s escape
6353/// hatch).
6354///
6355/// There is ONE verifier. `Engine::submit` calls this with its own tracker,
6356/// switch and walk budget; the GASP anchor check
6357/// (`OverlayGASPStorage::validate_graph_anchor`, bsv-low #551) calls it with
6358/// the same three, handed over by `Engine::start_gasp_sync`.
6359///
6360/// `Ok(Some(_))` is a walk that COULD NOT RUN within `budget` (bsv-low #592):
6361/// not a verdict on the transaction. The caller goes on as it would without a
6362/// walk and says so.
6363pub(crate) async fn verify_spv_like_the_reference(
6364    chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6365    verify_scripts: bool,
6366    beef_bytes: &[u8],
6367    subject_txid: &str,
6368    budget: DoorBudget,
6369) -> Result<Option<WalkCouldNotRun>, EngineError> {
6370    verify_spv_trusting(
6371        chain_tracker,
6372        verify_scripts,
6373        beef_bytes,
6374        subject_txid,
6375        &HashSet::new(),
6376        budget,
6377    )
6378    .await
6379}
6380
6381/// [`verify_spv_like_the_reference`] trusting the transactions an earlier
6382/// walk of the same submit passed over (a predecessor the door lands first,
6383/// the E1D delta fold, L4): the script walk neither checks nor descends
6384/// them. The structural escape hatch checks the whole BEEF it is given.
6385async fn verify_spv_trusting(
6386    chain_tracker: Option<&dyn bsv_rs::transaction::ChainTracker>,
6387    verify_scripts: bool,
6388    beef_bytes: &[u8],
6389    subject_txid: &str,
6390    trusted: &HashSet<String>,
6391    budget: DoorBudget,
6392) -> Result<Option<WalkCouldNotRun>, EngineError> {
6393    if verify_scripts {
6394        Engine::verify_beef_linear(chain_tracker, beef_bytes, subject_txid, trusted, budget).await
6395    } else {
6396        Engine::verify_spv_structurally(chain_tracker, beef_bytes)
6397            .await
6398            .map(|()| None)
6399    }
6400}
6401
6402/// The BUMP that proves each txid of `beef`: the first BUMP whose level 0
6403/// carries it, as `Beef::find_bump` picks, built in one pass over the BUMPs'
6404/// leaves (NL-6e; `find_bump` per transaction scans them all each time).
6405fn proven_index(beef: &Beef) -> HashMap<&str, usize> {
6406    let mut proven: HashMap<&str, usize> = HashMap::new();
6407    for (at, bump) in beef.bumps.iter().enumerate() {
6408        for leaf in bump.path.first().into_iter().flatten() {
6409            if let Some(hash) = leaf.hash.as_deref() {
6410                proven.entry(hash).or_insert(at);
6411            }
6412        }
6413    }
6414    proven
6415}
6416
6417/// The transactions the SPV walk of `from` passes over in `beef`
6418/// ([`Engine::verify_beef_linear_with`]'s traversal, nothing checked): `from`
6419/// and every source the BEEF carries, a proven transaction included and not
6420/// descended. The E1D delta fold, L4: what a landing need not walk again.
6421fn walk_cover(beef: &Beef, from: &str) -> HashSet<String> {
6422    let bodies: HashMap<String, &Transaction> = beef
6423        .txs
6424        .iter()
6425        .filter_map(|btx| btx.tx().map(|tx| (btx.txid(), tx)))
6426        .collect();
6427    let proven = proven_index(beef);
6428    let mut cover = HashSet::new();
6429    let mut queue = vec![from.to_string()];
6430    while let Some(txid) = queue.pop() {
6431        if !cover.insert(txid.clone()) || proven.contains_key(txid.as_str()) {
6432            continue;
6433        }
6434        if let Some(tx) = bodies.get(&txid) {
6435            queue.extend(tx.inputs.iter().filter_map(|i| i.source_txid.clone()));
6436        }
6437    }
6438    cover
6439}
6440
6441/// The engine's [`crate::gasp::FinalizedGraphHook`] (bsv-low #552): submit
6442/// what a peer's sync just finalized, before its next UTXO is asked for.
6443struct SubmitAsFinalized<'e> {
6444    engine: &'e Engine,
6445    sink: crate::gasp_overlay::FinalizedGraphSink,
6446    peer_url: &'e str,
6447    topic: &'e str,
6448    submitted: &'e std::cell::Cell<u64>,
6449}
6450
6451#[async_trait::async_trait(?Send)]
6452impl crate::gasp::FinalizedGraphHook for SubmitAsFinalized<'_> {
6453    async fn graph_completed(&self) -> Result<(), crate::gasp::GASPError> {
6454        // A graph is counted as its first submit starts, and the deadline
6455        // cannot land inside a submit: a sync dropped in here has written at
6456        // least that transaction whole (an ancestors-first prefix).
6457        let landed = self
6458            .engine
6459            .submit_finalized_graphs(&self.sink, self.peer_url, self.topic, self.submitted)
6460            .await;
6461        if landed {
6462            Ok(())
6463        } else {
6464            // Fails this UTXO: the cursor stays below it and the next sync
6465            // asks for it again (the lens fold's MEDIUM-1).
6466            Err(crate::gasp::GASPError::StorageError(
6467                "a finalize submit did not land".to_string(),
6468            ))
6469        }
6470    }
6471}
6472
6473/// Result of a GASP sync operation.
6474///
6475/// Returned by `Engine::start_gasp_sync()` to summarize what happened.
6476#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6477pub struct GASPSyncResult {
6478    #[serde(default, flatten)]
6479    pub cost: crate::meter::SyncCost,
6480    #[serde(default)]
6481    pub filter: crate::sync_load::SyncFilter,
6482    #[serde(default)]
6483    pub topics_filtered: Vec<String>,
6484    /// Per-topic sync results.
6485    pub topics_synced: HashMap<String, TopicSyncResult>,
6486}
6487
6488/// Sync result for a single topic.
6489#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6490pub struct TopicSyncResult {
6491    #[serde(default)]
6492    pub peer_results: HashMap<String, crate::backfill::PeerSyncResult>,
6493    #[serde(default)]
6494    pub dead_graphs: u64,
6495    #[serde(default)]
6496    pub expired_swept: u64,
6497    #[serde(default)]
6498    pub memo_folded: u64,
6499    #[serde(default, flatten)]
6500    pub cost: crate::meter::SyncCost,
6501    #[serde(default)]
6502    pub root_bounds: Vec<crate::sync_load::RootBound>,
6503    #[serde(default)]
6504    pub peers_skipped: Vec<crate::sync_load::SkippedPeer>,
6505    #[serde(default)]
6506    pub peers_filtered: Vec<String>,
6507    /// Root proof status after successful anchor validation and finalization.
6508    #[serde(default)]
6509    pub roots_proven: u64,
6510    #[serde(default)]
6511    pub roots_unproven: u64,
6512    /// Outstanding outpoints on peers whose cursor did not advance.
6513    #[serde(default)]
6514    pub cursor_held_by: Vec<String>,
6515    /// Distinct expired advert roots skipped before any proof request.
6516    #[serde(default)]
6517    pub expired_refused: u64,
6518    /// Peer URLs discovered or configured for this topic.
6519    pub peers: Vec<String>,
6520    /// How peers were determined: "ship" or "peers".
6521    pub sync_type: String,
6522    /// Any errors encountered during sync (peer URL -> error message): a
6523    /// peer whose sync failed or ran over its budget. A single UTXO whose
6524    /// graph ingest failed is NOT listed here: it is warned (`Error ingesting
6525    /// UTXO`), held back by the cursor gap guard and asked for again by the
6526    /// next sync.
6527    pub errors: Vec<String>,
6528    /// Manager-named inputs pruned because a peer answered that it does not
6529    /// hold them (the D8 decoy rule, `GASPSync::pruned_inputs`). Counted per
6530    /// distinct outpoint PER PEER per sync and summed over this topic's
6531    /// peers: one decoy seen through two peers reads 2. Not errors: the
6532    /// graphs completed without those branches.
6533    #[serde(default)]
6534    pub pruned_inputs: u64,
6535    /// Graphs the anchor check REFUSED and discarded whole (bsv-low #551,
6536    /// `GASPSync::discarded_graphs`): a root whose BEEF does not verify, or
6537    /// one the topic manager does not admit at the end of the replay. Summed
6538    /// over this topic's peers. Not errors, and nothing of them was admitted.
6539    #[serde(default)]
6540    pub discarded_graphs: u64,
6541    /// Graphs HELD because their anchor's script walk could not run within
6542    /// the engine's walk budget (bsv-low #592; the E592 lens fold, H1 and
6543    /// L3; `GASPSync::anchor_walk_held`), summed over this topic's peers.
6544    /// Nothing of them was finalized; each UTXO stays below its cursor until
6545    /// the peer serves its root proven. Not errors and not discarded.
6546    #[serde(default)]
6547    pub anchor_walk_held_graphs: u64,
6548    /// Graphs that passed the anchor check and were handed to submit, summed
6549    /// over this topic's peers (bsv-low #552). Under a per-peer budget a
6550    /// graph is counted, and submitted, the moment it finalizes, so the
6551    /// count includes those of a peer the deadline then dropped. A counted
6552    /// graph had its first transaction submitted whole (the deadline never
6553    /// lands inside a submit); one whose later submit did not land is still
6554    /// counted, and its UTXO stays below the cursor.
6555    /// A graph is counted as its first submit STARTS; a first submit that FAULTS
6556    /// still counts here, so this is not a delivered count (bsv-low #559).
6557    #[serde(default)]
6558    pub finalized_graphs: u64,
6559    /// Graphs that were mid-walk when a peer's sync was dropped at its
6560    /// budget (bsv-low #552): at most one per peer per sync. NOTHING of such
6561    /// a graph was admitted; the next sync walks it again. The same root
6562    /// counted tick after tick with no `finalized_graphs` and no
6563    /// `cursor_moves` is ONE graph whose walk outlasts the budget (with a
6564    /// per-graph budget it is also counted in `deferred_graphs` and resumed,
6565    /// bsv-low #555).
6566    #[serde(default)]
6567    pub deadline_dropped_graphs: u64,
6568    /// Every persisted cursor that moved in this sync (bsv-low #552), one
6569    /// entry per peer. After a completed sync `to` is the peer's highest
6570    /// score seen (less the gap guard); after a deadline it is
6571    /// `GASPSync::completed_cursor`: past completed UTXOs only.
6572    #[serde(default)]
6573    pub cursor_moves: Vec<CursorMove>,
6574    /// Graphs DEFERRED in this sync, summed over peers (bsv-low #555,
6575    /// `Engine::set_graph_budget`): a new record, or a resumed graph
6576    /// deferred again. Their UTXOs are held below the cursor.
6577    #[serde(default)]
6578    pub deferred_graphs: u64,
6579    /// Graphs RESUMED from a record in this sync.
6580    #[serde(default)]
6581    pub resumed_graphs: u64,
6582    /// Resumed graphs that completed and landed in this sync (their record
6583    /// deleted).
6584    #[serde(default)]
6585    pub converged_graphs: u64,
6586    /// Records deleted without converging, with their reason.
6587    #[serde(default)]
6588    pub dropped_graphs: Vec<DroppedDeferral>,
6589    /// Walks the per-graph budget (or the per-peer deadline) cut having
6590    /// fetched NOTHING in their pass (bsv-low #555, the lens fold's H1). A
6591    /// peer's sync whose only outcome was these is a failed attempt for the
6592    /// quarantine count.
6593    #[serde(default)]
6594    pub stalled_graphs: u64,
6595    /// Records NOT resumed in this sync: the resumes of a peer's pass share
6596    /// ONE per-graph budget and had spent it (the lens fold's M1). Held for
6597    /// the next sync, untouched.
6598    #[serde(default)]
6599    pub held_back_graphs: u64,
6600    /// Held records this sync sent to the long backoff (engine 0.1.10,
6601    /// `gasp::IDLE_RESUMES_BEFORE_BACKOFF`): resumed that many passes in a
6602    /// row with no node appended, not resumed again for
6603    /// `gasp::IDLE_RESUME_RETRY_SECS`.
6604    #[serde(default)]
6605    pub backed_off_graphs: u64,
6606    /// Held records this sync did not resume because they wait out that
6607    /// backoff: no request, the cursor passes them (engine 0.1.10).
6608    #[serde(default)]
6609    pub waiting_graphs: u64,
6610    /// Held records whose resumed pass this sync counted one idle resume
6611    /// on (engine 0.1.11, `gasp::DeferralStats::idle_counted`): the pass
6612    /// asked the peer and appended nothing it kept, whether it ended in a
6613    /// save, a drop or an error. A captain reads the idle rule firing here.
6614    #[serde(default)]
6615    pub idle_counted: u64,
6616    /// Roots recorded UNANCHORABLE in this sync (engine 0.1.9,
6617    /// `GASPSync::unanchorable_recorded`): their walk faulted in
6618    /// `gasp::UNANCHORABLE_AFTER_FAULTED_RUNS` consecutive runs; the cursor
6619    /// passes them, and each is walked again after
6620    /// `gasp::UNANCHORABLE_RETRY_SECS`. Nothing of them is admitted.
6621    #[serde(default)]
6622    pub unanchorable_graphs: u64,
6623    /// Roots struck `unanchorable:not_found` in this sync (engine 0.1.15,
6624    /// `GASPSync::absent_struck`): the advertising peer said it does not
6625    /// hold the root, or a transaction an unproven node of its graph needs,
6626    /// on `gasp::UNANCHORABLE_AFTER_FAULTED_RUNS` runs at least
6627    /// `gasp::ABSENCE_GAP_SECS` apart. Counted in `unanchorable_graphs` too.
6628    #[serde(default)]
6629    pub absent_struck: u64,
6630    /// Syncs a ROUTE FAULT ended (engine 0.2.0, bsv-overlay-cloudflare #57;
6631    /// [`crate::route_fault`]): the platform answered before the peer was
6632    /// reached. Each names its peer and class; none counted against the
6633    /// peer's health unless the peer had served work before it.
6634    #[serde(default)]
6635    pub route_faults: Vec<crate::route_fault::RouteFaultSeen>,
6636    /// Roots with a fault record that this sync walked again and admitted,
6637    /// or refused on a verdict: their record cleared (engine 0.1.9).
6638    #[serde(default)]
6639    pub unanchorable_cleared: u64,
6640    /// Strikes this sync could not write (engine 0.1.10,
6641    /// `GASPSync::strikes_refused`):
6642    /// at the storage's ceiling or on its fault. Each root is named in
6643    /// `faulted_roots` with its `refused` reason.
6644    #[serde(default)]
6645    pub strikes_refused: u64,
6646    /// Every root a peer's sync dropped in this run, once per (peer, root),
6647    /// named with its fault class, its faulted runs and whether it is now
6648    /// unanchorable (engine 0.1.9, `GASPSync::faulted_roots`). With
6649    /// `cursor_held_by` (what is neither finished nor held) the captain
6650    /// reads why the cursor stopped or moved.
6651    #[serde(default)]
6652    pub faulted_roots: Vec<crate::gasp::FaultedRoot>,
6653}
6654
6655/// A deferred graph's record deleted without converging (bsv-low #555,
6656/// [`TopicSyncResult::dropped_graphs`]).
6657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6658pub struct DroppedDeferral {
6659    /// The peer URL.
6660    pub peer: String,
6661    /// The graph's root outpoint.
6662    pub outpoint: String,
6663    /// [`crate::gasp::DropReason::as_str`].
6664    pub reason: String,
6665}
6666
6667/// One peer's persisted `last_interaction` cursor moving `from` -> `to` in a
6668/// sync ([`TopicSyncResult::cursor_moves`]).
6669#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6670pub struct CursorMove {
6671    /// The peer URL.
6672    pub peer: String,
6673    /// The cursor the sync entered with.
6674    pub from: u64,
6675    /// The cursor it persisted.
6676    pub to: u64,
6677}
6678
6679/// Get current time in milliseconds (for output scores).
6680///
6681/// Uses `js_sys::Date::now()` on wasm32 (Cloudflare Workers) since
6682/// `std::time::SystemTime` is not available on that platform.
6683pub(crate) fn current_timestamp_ms() -> f64 {
6684    #[cfg(target_arch = "wasm32")]
6685    {
6686        js_sys::Date::now()
6687    }
6688    #[cfg(not(target_arch = "wasm32"))]
6689    {
6690        std::time::SystemTime::now()
6691            .duration_since(std::time::UNIX_EPOCH)
6692            .map_or(0.0, |d| d.as_millis() as f64)
6693    }
6694}
6695
6696/// Extract the domain field from a SHIP PushDrop locking script.
6697///
6698/// SHIP PushDrop format: field[0] = "SHIP", field[1] = identity_key,
6699/// field[2] = domain (UTF-8), field[3] = topic.
6700/// Returns `None` if the script cannot be parsed or is not a SHIP advertisement.
6701fn parse_ship_domain_from_script(output_script: &[u8]) -> Option<String> {
6702    parse_advertisement_domain_from_script(output_script, b"SHIP", None)
6703}
6704
6705fn parse_lookup_advertisement(
6706    output: &OutputListItem,
6707    protocol: &[u8],
6708    scope: &str,
6709) -> Option<String> {
6710    let tx = beef_limits::transaction_from_beef(&output.beef, None, &beef_limits::PEER_BEEF_LIMITS)
6711        .ok()?;
6712    let script = tx
6713        .outputs
6714        .get(output.output_index as usize)?
6715        .locking_script
6716        .to_binary();
6717    parse_advertisement_domain_from_script(&script, protocol, Some(scope))
6718}
6719
6720fn parse_advertisement_domain_from_script(
6721    output_script: &[u8],
6722    protocol: &[u8],
6723    scope: Option<&str>,
6724) -> Option<String> {
6725    use bsv_rs::script::templates::PushDrop;
6726
6727    let script = bsv_rs::script::Script::from_binary(output_script).ok()?;
6728    let pushdrop = PushDrop::decode(&script.into()).ok()?;
6729
6730    if pushdrop.fields.len() < 3 {
6731        return None;
6732    }
6733
6734    if pushdrop.fields[0] != protocol {
6735        return None;
6736    }
6737
6738    if scope.is_some_and(|expected| {
6739        pushdrop
6740            .fields
6741            .get(3)
6742            .is_none_or(|actual| actual != expected.as_bytes())
6743    }) {
6744        return None;
6745    }
6746
6747    let domain = String::from_utf8_lossy(&pushdrop.fields[2]).to_string();
6748    if domain.is_empty() {
6749        return None;
6750    }
6751
6752    Some(domain)
6753}
6754
6755// ============================================================================
6756// Submit-time spend verification helpers (reference parity, 2026-09-08)
6757// ============================================================================
6758
6759// ============================================================================
6760// Error type
6761// ============================================================================
6762
6763#[derive(Debug, thiserror::Error)]
6764pub enum EngineError {
6765    #[error("This server does not support this topic: {0}")]
6766    UnsupportedTopic(String),
6767
6768    #[error("Lookup service not found for provider: {0}")]
6769    LookupServiceNotFound(String),
6770
6771    /// GASP: `/requestForeignGASPNode` asked for a graph/txid we don't
6772    /// have locally. Matches mainline's 400-class "not found" response.
6773    #[error("No matching output found!")]
6774    NodeNotFound,
6775
6776    #[error("lookup failed: {0}")]
6777    LookupFailed(String),
6778
6779    /// The CALLER's query was malformed — a bad identity key, a gameId that is
6780    /// not 32 bytes of hex, a missing required field. Distinct from
6781    /// `LookupFailed`, which means WE failed: without the distinction every
6782    /// caller mistake was reported as a server fault (HTTP 500), so a malformed
6783    /// query and a genuine outage were indistinguishable in logs and alerts on
6784    /// an unauthenticated endpoint anyone can hit.
6785    #[error("invalid query: {0}")]
6786    InvalidQuery(String),
6787
6788    #[error("storage error: {0}")]
6789    StorageError(String),
6790
6791    #[error("broadcast error: {0}")]
6792    BroadcastError(String),
6793
6794    #[error("SPV verification failed: {0}")]
6795    SpvError(String),
6796
6797    #[error("BEEF parsing failed: {0}")]
6798    BeefParseError(String),
6799
6800    /// A spend in the submitted BEEF does not satisfy its source output's
6801    /// locking script: the input's unlocking script was EXECUTED (reference
6802    /// parity, 2026-09-08) and the interpreter refused it. Distinct from
6803    /// [`EngineError::SpvError`] (a bad or unverifiable PROOF, a missing
6804    /// source, a chain-tracker fault) so an operator can tell a bad spend from
6805    /// a bad proof. `input_index` is the failing input's position in the
6806    /// transaction whose script failed (the subject, or an unproven
6807    /// ancestor of it in the same BEEF); `reason` is the interpreter's own
6808    /// message.
6809    #[error("script verification failed (subject {subject_txid}): input {input_index}: {reason}")]
6810    ScriptVerificationFailed {
6811        subject_txid: String,
6812        input_index: u32,
6813        reason: String,
6814    },
6815
6816    /// The DOOR walk ([`Engine::verify_scripts_only`]) hit a STRUCTURAL fault
6817    /// before reaching a verdict on `at_txid` (a source the BEEF lacks, a
6818    /// parse fault, the value rule): not the interpreter's verdict, so not a
6819    /// refusal for a caller whose bar is the network. `subject_judged` says
6820    /// whether the subject's own inputs had all executed before the fault
6821    /// (an ancestor faulted) or not (the subject itself is unjudged).
6822    #[error("script walk inconclusive at {at_txid} (subject judged: {subject_judged}): {reason}")]
6823    ScriptWalkInconclusive {
6824        at_txid: String,
6825        subject_judged: bool,
6826        reason: String,
6827    },
6828
6829    /// The DOOR walk exceeded its own bound ([`DoorBudget`]: the static work
6830    /// estimate, the interpreter's memory limit tripping, or the estimate of
6831    /// the door's own memory; `limb` says which): the door's verdict, never
6832    /// the network's — the request proceeds to the network.
6833    #[error("script walk over budget at {at_txid} (subject judged: {subject_judged}): {what}")]
6834    ScriptWalkOverBudget {
6835        at_txid: String,
6836        subject_judged: bool,
6837        limb: DoorLimb,
6838        what: String,
6839    },
6840
6841    /// The submit's own script walk COULD NOT RUN within the engine's
6842    /// [`DoorBudget`] (bsv-low #592) and nothing on record stands in for it
6843    /// ([`WalkBreachPolicy::NotNow`], every door but the queue replay of a
6844    /// gated submission; the E592 lens fold, H1). NOT NOW: nothing was
6845    /// judged, nothing written, and it is NOT a refusal either: the budget
6846    /// says nothing about the transaction. The way out is the subject
6847    /// PROVEN: a proven transaction is not walked, so an honest body too
6848    /// big to walk unmined lands once its block is mined (a client retries
6849    /// after the block, not at once).
6850    #[error(
6851        "the script walk could not run within the budget ({}: {}); not now: present the subject proven, after its block",
6852        .0.limb.as_str(),
6853        .0.what
6854    )]
6855    WalkCouldNotRun(WalkCouldNotRun),
6856
6857    #[error("{0}")]
6858    Other(String),
6859}
6860
6861impl From<StorageError> for EngineError {
6862    fn from(e: StorageError) -> Self {
6863        EngineError::StorageError(e.to_string())
6864    }
6865}
6866
6867impl From<TopicManagerError> for EngineError {
6868    fn from(e: TopicManagerError) -> Self {
6869        EngineError::Other(e.to_string())
6870    }
6871}
6872
6873impl From<LookupServiceError> for EngineError {
6874    fn from(e: LookupServiceError) -> Self {
6875        EngineError::LookupFailed(e.to_string())
6876    }
6877}
6878
6879impl From<AdvertiserError> for EngineError {
6880    fn from(e: AdvertiserError) -> Self {
6881        EngineError::Other(e.to_string())
6882    }
6883}
6884
6885// ============================================================================
6886// Tests
6887// ============================================================================
6888
6889/// Baseline sync order: configured peer topics before SHIP discovery topics,
6890/// each by name (bsv-low #555, D-M1). `start_gasp_sync` advances SHIP topics
6891/// with explicit bootstrap peers ahead of this order so their sync can run
6892/// without importing tracker records. Deferred-graph reservations still use
6893/// each topic's configured target.
6894pub(crate) fn sync_order(config: &SyncConfiguration) -> Vec<(&String, &SyncTarget)> {
6895    let mut order: Vec<(&String, &SyncTarget)> = config.iter().collect();
6896    order.sort_by_key(|(topic, target)| (!matches!(target, SyncTarget::Peers(_)), topic.as_str()));
6897    order
6898}
6899
6900#[cfg(test)]
6901mod tests {
6902    use super::*;
6903    use crate::lookup_service::LookupService as LookupServiceTrait;
6904    use crate::storage::memory::MemoryStorage;
6905    use crate::topic_manager::TopicManager as TopicManagerTrait;
6906    use async_trait::async_trait;
6907    use std::sync::Mutex;
6908
6909    // bsv-low #555, the delta fold's D-M1: the configured peers' topics are
6910    // synced before the ls_ship-discovered ones, each class by name; the map's
6911    // order was arbitrary.
6912    #[test]
6913    fn delta555_m1_configured_peers_are_synced_before_discovered_ones() {
6914        let peers = |p: &str| SyncTarget::Peers(vec![p.to_string()]);
6915        let config: SyncConfiguration = HashMap::from([
6916            ("tm_a".to_string(), SyncTarget::Ship),
6917            ("tm_z".to_string(), peers("https://z")),
6918            ("tm_b".to_string(), SyncTarget::Ship),
6919            ("tm_m".to_string(), peers("https://m")),
6920            ("tm_off".to_string(), SyncTarget::Disabled),
6921        ]);
6922        let order: Vec<&str> = sync_order(&config)
6923            .iter()
6924            .map(|(t, _)| t.as_str())
6925            .collect();
6926        assert_eq!(order, vec!["tm_m", "tm_z", "tm_a", "tm_b", "tm_off"]);
6927    }
6928
6929    // ── Mock TopicManager ──────────────────────────────────────────────
6930
6931    struct MockTopicManager {
6932        admit_indices: Vec<u32>,
6933    }
6934
6935    impl MockTopicManager {
6936        fn admitting(indices: Vec<u32>) -> Self {
6937            Self {
6938                admit_indices: indices,
6939            }
6940        }
6941    }
6942
6943    #[async_trait(?Send)]
6944    impl TopicManagerTrait for MockTopicManager {
6945        async fn identify_admissible_outputs(
6946            &self,
6947            _tx: &Transaction,
6948            _previous_coins: &[u8],
6949            _off_chain_values: Option<&[u8]>,
6950            _mode: SubmitMode,
6951            _context: &TopicAdmittanceContext,
6952        ) -> Result<AdmittanceInstructions, TopicManagerError> {
6953            Ok(AdmittanceInstructions {
6954                outputs_to_admit: self.admit_indices.clone(),
6955                coins_to_retain: vec![],
6956                coins_removed: None,
6957            })
6958        }
6959
6960        async fn get_documentation(&self) -> String {
6961            "Mock topic manager".to_string()
6962        }
6963
6964        async fn get_metadata(&self) -> ServiceMetadata {
6965            ServiceMetadata {
6966                name: "mock-tm".to_string(),
6967                description: Some("Mock for testing".to_string()),
6968                ..Default::default()
6969            }
6970        }
6971    }
6972
6973    // ── Mock LookupService ─────────────────────────────────────────────
6974
6975    struct MockLookupService {
6976        records: Mutex<Vec<UTXOReference>>,
6977    }
6978
6979    impl MockLookupService {
6980        fn new() -> Self {
6981            Self {
6982                records: Mutex::new(Vec::new()),
6983            }
6984        }
6985    }
6986
6987    #[async_trait(?Send)]
6988    impl LookupServiceTrait for MockLookupService {
6989        fn admission_mode(&self) -> AdmissionMode {
6990            AdmissionMode::LockingScript
6991        }
6992
6993        fn spend_notification_mode(&self) -> SpendNotificationMode {
6994            SpendNotificationMode::None
6995        }
6996
6997        async fn output_admitted_by_topic(
6998            &self,
6999            payload: &OutputAdmittedByTopic,
7000        ) -> Result<(), LookupServiceError> {
7001            let (txid, oi) = match payload {
7002                OutputAdmittedByTopic::LockingScript {
7003                    txid, output_index, ..
7004                } => (txid.clone(), *output_index),
7005                OutputAdmittedByTopic::WholeTx { output_index, .. } => {
7006                    ("whole".into(), *output_index)
7007                }
7008            };
7009            self.records.lock().unwrap().push(UTXOReference {
7010                txid,
7011                output_index: oi,
7012            });
7013            Ok(())
7014        }
7015
7016        async fn output_evicted(
7017            &self,
7018            txid: &str,
7019            output_index: u32,
7020        ) -> Result<(), LookupServiceError> {
7021            self.records
7022                .lock()
7023                .unwrap()
7024                .retain(|r| !(r.txid == txid && r.output_index == output_index));
7025            Ok(())
7026        }
7027
7028        async fn lookup(
7029            &self,
7030            _question: &LookupQuestion,
7031        ) -> Result<LookupResult, LookupServiceError> {
7032            Ok(LookupResult::OutputList(
7033                self.records.lock().unwrap().clone(),
7034            ))
7035        }
7036
7037        async fn get_documentation(&self) -> String {
7038            "Mock lookup service".to_string()
7039        }
7040
7041        async fn get_metadata(&self) -> ServiceMetadata {
7042            ServiceMetadata {
7043                name: "mock-ls".to_string(),
7044                ..Default::default()
7045            }
7046        }
7047    }
7048
7049    // ── Test BEEF data ──────────────────────────────────────────────────
7050
7051    /// Real BRC-62 BEEF from TS overlay-services test suite.
7052    const TEST_BEEF_HEX: &str = "0100beef01fe636d0c0007021400fe507c0c7aa754cef1f7889d5fd395cf1f785dd7de98eed895dbedfe4e5bc70d1502ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e010b00bc4ff395efd11719b277694cface5aa50d085a0bb81f613f70313acd28cf4557010400574b2d9142b8d28b61d88e3b2c3f44d858411356b49a28a4643b6d1a6a092a5201030051a05fc84d531b5d250c23f4f886f6812f9fe3f402d61607f977b4ecd2701c19010000fd781529d58fc2523cf396a7f25440b409857e7e221766c57214b1d38c7b481f01010062f542f45ea3660f86c013ced80534cb5fd4c19d66c56e7e8c5d4bf2d40acc5e010100b121e91836fd7cd5102b654e9f72f3cf6fdbfd0b161c53a9c54b12c841126331020100000001cd4e4cac3c7b56920d1e7655e7e260d31f29d9a388d04910f1bbd72304a79029010000006b483045022100e75279a205a547c445719420aa3138bf14743e3f42618e5f86a19bde14bb95f7022064777d34776b05d816daf1699493fcdf2ef5a5ab1ad710d9c97bfb5b8f7cef3641210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013e660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000001000100000001ac4e164f5bc16746bb0868404292ac8318bbac3800e4aad13a014da427adce3e000000006a47304402203a61a2e931612b4bda08d541cfb980885173b8dcf64a3471238ae7abcd368d6402204cbf24f04b9aa2256d8901f0ed97866603d2be8324c2bfb7a37bf8fc90edd5b441210263e2dee22b1ddc5e11f6fab8bcd2378bdd19580d640501ea956ec0e786f93e76ffffffff013c660000000000001976a9146bfd5c7fbe21529d45803dbcf0c87dd3c71efbc288ac0000000000";
7053    const TEST_TXID: &str = "157428aee67d11123203735e4c540fa1bdab3b36d5882c6f8c5ff79f07d20d1c";
7054
7055    fn decode_hex(hex: &str) -> Vec<u8> {
7056        (0..hex.len())
7057            .step_by(2)
7058            .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
7059            .collect()
7060    }
7061
7062    fn test_beef() -> Vec<u8> {
7063        decode_hex(TEST_BEEF_HEX)
7064    }
7065
7066    fn test_tagged_beef(topics: Vec<&str>) -> TaggedBEEF {
7067        TaggedBEEF::new(
7068            test_beef(),
7069            topics.into_iter().map(str::to_string).collect(),
7070        )
7071    }
7072
7073    // ── Helpers ────────────────────────────────────────────────────────
7074
7075    fn make_engine(admit_indices: Vec<u32>) -> Engine {
7076        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7077        managers.insert(
7078            "tm_test".to_string(),
7079            Box::new(MockTopicManager::admitting(admit_indices)),
7080        );
7081
7082        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7083        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7084
7085        let storage = Box::new(MemoryStorage::new());
7086
7087        Engine::new(
7088            managers,
7089            lookup_services,
7090            storage,
7091            None,
7092            EngineConfig::default(),
7093        )
7094    }
7095
7096    // ── Tests ──────────────────────────────────────────────────────────
7097
7098    /// bsv-low#289 parse-once + zanaadu#284 subject discipline: the tx handed
7099    /// to every topic manager must be the ATOMIC SUBJECT of the submitted
7100    /// BEEF, not whatever sits last in wire order. bsv-rs < 0.3.20's
7101    /// `from_beef(_, None)` picked `txs.last()`; wallet serializers do not all
7102    /// place the subject last, so a TM could be handed a PARENT to validate.
7103    /// This pins the engine on the fixed rule (`txid ?? atomicTxid ?? last`).
7104    #[tokio::test]
7105    async fn parse_once_hands_tms_the_atomic_subject_not_wire_order() {
7106        use bsv_rs::transaction::beef_tx::ATOMIC_BEEF;
7107        use std::cell::RefCell;
7108        use std::rc::Rc;
7109
7110        // Rebuild the fixture chain SUBJECT-FIRST via to_writer (to_binary
7111        // re-sorts parents-first and would hide the trigger).
7112        let parsed = Beef::from_binary(&test_beef()).unwrap();
7113        let parent = parsed.txs[0].tx().unwrap().clone();
7114        let child = parsed.txs[1].tx().unwrap().clone();
7115        let child_id = child.id();
7116        let parent_id = parent.id();
7117        let mut beef = Beef::new();
7118        // The parent's BUMP rides along: the reference-parity walk
7119        // (2026-09-08) executes the subject's script against the parent and
7120        // then trusts the parent on its proof; without the BUMP it would
7121        // demand the parent's own ancestry ("Input 0 has no source
7122        // transaction"), which this fixture does not carry.
7123        for bump in &parsed.bumps {
7124            beef.merge_bump(bump.clone());
7125        }
7126        beef.merge_transaction(child);
7127        beef.merge_transaction(parent);
7128        let mut w = bsv_rs::primitives::encoding::Writer::new();
7129        w.write_u32_le(ATOMIC_BEEF);
7130        let mut le = hex::decode(&child_id).unwrap();
7131        le.reverse();
7132        w.write_bytes(&le);
7133        beef.to_writer(&mut w);
7134        let atomic_wire_order = w.into_bytes();
7135
7136        // A TM that records the txid it is handed.
7137        struct RecordingTm(Rc<RefCell<Option<String>>>);
7138        #[async_trait(?Send)]
7139        impl TopicManagerTrait for RecordingTm {
7140            async fn identify_admissible_outputs(
7141                &self,
7142                tx: &Transaction,
7143                _previous_coins: &[u8],
7144                _off_chain_values: Option<&[u8]>,
7145                _mode: SubmitMode,
7146                _context: &TopicAdmittanceContext,
7147            ) -> Result<AdmittanceInstructions, TopicManagerError> {
7148                *self.0.borrow_mut() = Some(tx.id());
7149                Ok(AdmittanceInstructions {
7150                    outputs_to_admit: vec![0],
7151                    coins_to_retain: vec![],
7152                    coins_removed: None,
7153                })
7154            }
7155            async fn get_documentation(&self) -> String {
7156                String::new()
7157            }
7158            async fn get_metadata(&self) -> ServiceMetadata {
7159                ServiceMetadata {
7160                    name: "tm_test".to_string(),
7161                    ..Default::default()
7162                }
7163            }
7164        }
7165
7166        let seen: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
7167        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7168        managers.insert(
7169            "tm_test".to_string(),
7170            Box::new(RecordingTm(Rc::clone(&seen))),
7171        );
7172        let engine = Engine::new(
7173            managers,
7174            HashMap::new(),
7175            Box::new(MemoryStorage::new()),
7176            None,
7177            EngineConfig::default(),
7178        );
7179
7180        let tagged = TaggedBEEF::new(atomic_wire_order, vec!["tm_test".to_string()]);
7181        let _ = engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
7182
7183        let handed = seen.borrow().clone().expect("TM was not invoked");
7184        assert_eq!(
7185            handed, child_id,
7186            "TM must be handed the atomic subject, got the wire-order pick"
7187        );
7188        assert_ne!(handed, parent_id);
7189    }
7190
7191    /// #4 REGRESSION: history hydration must never DOWNGRADE provenance.
7192    ///
7193    /// `get_utxo_history` used to `Transaction::from_beef(beef, None)` and then
7194    /// `tx.to_beef(true)`, which keeps only the subject and DROPS the stored
7195    /// BUMPs. Measured on prod before the fix: `ls_low` byGameId `73de6e48…`
7196    /// returned 1,172 B / 1 tx / 1 bump (PROVEN) with no header, and 676 B /
7197    /// 1 tx / 0 bumps with `x-history-depth: 3`. A caller asking for MORE
7198    /// provenance got a BEEF no wallet can verify — silently, behind a 200.
7199    ///
7200    /// There was NO test on this function at all, which is exactly how that
7201    /// shipped. This one asserts the invariant that matters: whatever else
7202    /// hydration does, a proof that went in comes back out.
7203    #[tokio::test]
7204    async fn history_hydration_preserves_the_stored_proof() {
7205        use bsv_rs::transaction::Beef;
7206
7207        let engine = make_engine(vec![0]);
7208        let stored = test_beef();
7209        let bumps_before = Beef::from_binary(&stored)
7210            .expect("fixture BEEF parses")
7211            .bumps
7212            .len();
7213        assert!(
7214            bumps_before > 0,
7215            "fixture must actually carry a proof, or this cell proves nothing"
7216        );
7217
7218        let output = Output {
7219            txid: TEST_TXID.to_string(),
7220            output_index: 0,
7221            output_script: vec![0x76, 0xa9],
7222            satoshis: 1000,
7223            topic: "tm_test".to_string(),
7224            spent: false,
7225            // No admitted parents: hydration has nothing to add, so anything
7226            // it REMOVES is pure loss — the cleanest form of the bug.
7227            outputs_consumed: vec![],
7228            consumed_by: vec![],
7229            beef: Some(stored.clone()),
7230            block_height: None,
7231            score: Some(1000.0),
7232        };
7233
7234        let hydrated = engine
7235            .get_utxo_history(&output, Some(HistorySelector::Depth(3)))
7236            .await
7237            .expect("hydration must not error")
7238            .expect("hydration must return the output");
7239        let beef = Beef::from_binary(&hydrated.beef.expect("hydrated BEEF present"))
7240            .expect("hydrated BEEF parses");
7241
7242        assert_eq!(
7243            beef.bumps.len(),
7244            bumps_before,
7245            "asking for history must not strip the merkle proof (issue #4)"
7246        );
7247    }
7248
7249    #[tokio::test]
7250    async fn test_submit_unsupported_topic_errors() {
7251        let engine = make_engine(vec![0]);
7252        let beef = test_tagged_beef(vec!["tm_nonexistent"]);
7253
7254        let result = engine.submit(&beef, SubmitMode::CurrentTx).await;
7255        assert!(result.is_err());
7256        assert!(matches!(
7257            result.unwrap_err(),
7258            EngineError::UnsupportedTopic(_)
7259        ));
7260    }
7261
7262    #[tokio::test]
7263    async fn test_submit_admits_outputs() {
7264        let engine = make_engine(vec![0, 1]);
7265        let beef = test_tagged_beef(vec!["tm_test"]);
7266
7267        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7268
7269        // STEAK should have tm_test with outputs 0 and 1 admitted
7270        let instructions = steak.get("tm_test").unwrap();
7271        assert_eq!(instructions.outputs_to_admit, vec![0, 1]);
7272
7273        // Outputs should be in storage with real data
7274        let txid = TEST_TXID;
7275        let found = engine
7276            .storage()
7277            .find_output(txid, 0, Some("tm_test"), None, true)
7278            .await
7279            .unwrap()
7280            .expect("Output 0 should be in storage");
7281
7282        assert_eq!(found.txid, TEST_TXID);
7283        assert_eq!(found.satoshis, 26172); // Real value from BRC62 BEEF
7284        assert!(
7285            !found.output_script.is_empty(),
7286            "Script should be populated"
7287        );
7288        assert!(found.beef.is_some(), "BEEF should be stored");
7289        assert!(!found.spent);
7290    }
7291
7292    #[tokio::test]
7293    async fn test_submit_duplicate_is_skipped() {
7294        let engine = make_engine(vec![0]);
7295        let beef = test_tagged_beef(vec!["tm_test"]);
7296
7297        // First submit
7298        let steak1 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7299        assert_eq!(steak1["tm_test"].outputs_to_admit, vec![0]);
7300
7301        // Second submit — should be a dupe, no new outputs admitted
7302        let steak2 = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7303        assert!(steak2["tm_test"].outputs_to_admit.is_empty());
7304    }
7305
7306    #[tokio::test]
7307    async fn test_submit_notifies_lookup_service() {
7308        let engine = make_engine(vec![0]);
7309        let beef = test_tagged_beef(vec!["tm_test"]);
7310
7311        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7312
7313        // Lookup service should have the output
7314        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7315        let answer = engine.lookup(&question, None).await.unwrap();
7316
7317        match answer {
7318            LookupAnswer::OutputList { outputs } => {
7319                assert_eq!(outputs.len(), 1);
7320            }
7321            _ => panic!("Expected OutputList"),
7322        }
7323    }
7324
7325    /// #289: `lookup_with_txids` returns the storage txid per hydrated
7326    /// output, aligned with the OutputList — the aggregated serializer
7327    /// writes it instead of re-hashing the BEEF.
7328    #[tokio::test]
7329    async fn test_lookup_with_txids_aligns_txids_with_outputs() {
7330        let engine = make_engine(vec![0]);
7331        let beef = test_tagged_beef(vec!["tm_test"]);
7332        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7333
7334        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7335        let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7336        match answer {
7337            LookupAnswer::OutputList { outputs } => {
7338                assert_eq!(outputs.len(), 1);
7339                assert_eq!(txids.len(), outputs.len(), "one txid per output");
7340                assert_eq!(txids[0], TEST_TXID, "the storage primary key, verbatim");
7341            }
7342            _ => panic!("Expected OutputList"),
7343        }
7344    }
7345
7346    #[tokio::test]
7347    async fn proof_window_holds_an_aged_child_with_a_proven_parent() {
7348        let mut engine = make_engine(vec![0]);
7349        let storage = std::rc::Rc::new(MemoryStorage::new());
7350        engine.storage = Box::new(storage.clone());
7351        engine
7352            .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7353            .await
7354            .unwrap();
7355        assert!(beef_limits::own_proof(&test_beef(), TEST_TXID)
7356            .unwrap()
7357            .is_none());
7358        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7359        storage.advance_clock((crate::proof_window::PROOFLESS_FLAG_MS / 1000) as u64);
7360        assert_eq!(
7361            engine
7362                .lookup_with_txids(&question, None)
7363                .await
7364                .unwrap()
7365                .1
7366                .len(),
7367            1
7368        );
7369        storage.advance_clock(1);
7370        assert!(
7371            engine
7372                .lookup_with_txids(&question, None)
7373                .await
7374                .unwrap()
7375                .1
7376                .is_empty(),
7377            "an aged unproven child must be held even when its parent is proven"
7378        );
7379    }
7380
7381    #[tokio::test]
7382    async fn proof_window_peer_proof_verifies_then_restores_lookup() {
7383        use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7384        let mut engine = make_engine(vec![0]);
7385        let storage = std::rc::Rc::new(MemoryStorage::new());
7386        engine.storage = Box::new(storage.clone());
7387        engine
7388            .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7389            .await
7390            .unwrap();
7391        storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7392        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7393        let candidate = HeldOutput {
7394            txid: TEST_TXID.into(),
7395            output_index: 0,
7396            topic: "tm_test".into(),
7397        };
7398        let (_, mut remote) = budget_page_engine(1);
7399        remote.node_cost_ms = 0;
7400        remote.nodes = std::rc::Rc::new(HashMap::from([(
7401            TEST_TXID.into(),
7402            crate::types::GASPNode {
7403                graph_id: String::new(),
7404                raw_tx: String::new(),
7405                output_index: 0,
7406                proof: Some(single_leaf_bump_hex(TEST_TXID, 800_000)),
7407                tx_metadata: None,
7408                output_metadata: None,
7409                inputs: None,
7410            },
7411        )]));
7412        engine
7413            .config
7414            .sync_configuration
7415            .insert("tm_test".into(), SyncTarget::Ship);
7416        engine.ship_bootstrap_peers.insert(
7417            "tm_test".into(),
7418            vec!["https://a.example".into(), "https://b.example".into()],
7419        );
7420        engine.set_gasp_remote_factory(Box::new(remote));
7421        let progress = std::cell::Cell::new(PeerProofSummary::default());
7422        engine.chain_tracker = Some(Box::new(bsv_rs::transaction::MockChainTracker::new(
7423            800_001,
7424        )));
7425        engine
7426            .rerequest_held_proofs(std::slice::from_ref(&candidate), &progress)
7427            .await;
7428        assert_eq!(progress.get().requests, 2, "ask every topic peer");
7429        assert_eq!(progress.get().landed, 0);
7430        assert_eq!(progress.get().refused, 2);
7431        assert!(engine
7432            .lookup_with_txids(&question, None)
7433            .await
7434            .unwrap()
7435            .1
7436            .is_empty());
7437        let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7438        tracker.add_root(800_000, TEST_TXID.into());
7439        engine.chain_tracker = Some(Box::new(tracker));
7440        engine.rerequest_held_proofs(&[candidate], &progress).await;
7441        assert_eq!(progress.get().requests, 4);
7442        assert_eq!(
7443            progress.get().landed,
7444            1,
7445            "count durable proofs, not peer answers"
7446        );
7447        let (_, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7448        assert_eq!(txids, vec![TEST_TXID]);
7449        let stored = storage
7450            .find_output(TEST_TXID, 0, None, None, true)
7451            .await
7452            .unwrap()
7453            .unwrap();
7454        assert!(
7455            beef_limits::own_proof(stored.beef.as_ref().unwrap(), TEST_TXID)
7456                .unwrap()
7457                .is_some()
7458        );
7459        assert_eq!(storage.proven_at_height(TEST_TXID), Some(Some(800_000)));
7460    }
7461
7462    #[tokio::test]
7463    async fn proof_window_peer_hash_leaf_is_stored_as_own_proof() {
7464        use crate::proof_window::{HeldOutput, PeerProofSummary, PROOFLESS_FLAG_MS};
7465        let mut engine = make_engine(vec![0]);
7466        let storage = std::rc::Rc::new(MemoryStorage::new());
7467        engine.storage = Box::new(storage.clone());
7468        engine
7469            .submit(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
7470            .await
7471            .unwrap();
7472        storage.advance_clock((PROOFLESS_FLAG_MS / 1000) as u64 + 1);
7473        let mut proof =
7474            bsv_rs::transaction::MerklePath::from_hex(&single_leaf_bump_hex(TEST_TXID, 800_000))
7475                .unwrap();
7476        proof.path[0][0].txid = false;
7477        let mut tracker = bsv_rs::transaction::MockChainTracker::new(800_001);
7478        tracker.add_root(800_000, proof.compute_root(Some(TEST_TXID)).unwrap());
7479        engine.chain_tracker = Some(Box::new(tracker));
7480        let (_, mut remote) = budget_page_engine(1);
7481        remote.node_cost_ms = 0;
7482        remote.nodes = std::rc::Rc::new(HashMap::from([(
7483            TEST_TXID.into(),
7484            crate::types::GASPNode {
7485                graph_id: String::new(),
7486                raw_tx: String::new(),
7487                output_index: 0,
7488                proof: Some(proof.to_hex()),
7489                tx_metadata: None,
7490                output_metadata: None,
7491                inputs: None,
7492            },
7493        )]));
7494        engine.config.sync_configuration.insert(
7495            "tm_test".into(),
7496            SyncTarget::Peers(vec!["https://peer.example".into()]),
7497        );
7498        engine.set_gasp_remote_factory(Box::new(remote));
7499        let progress = std::cell::Cell::new(PeerProofSummary::default());
7500        engine
7501            .rerequest_held_proofs(
7502                &[HeldOutput {
7503                    txid: TEST_TXID.into(),
7504                    output_index: 0,
7505                    topic: "tm_test".into(),
7506                }],
7507                &progress,
7508            )
7509            .await;
7510        assert_eq!(progress.get().landed, 1);
7511        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7512        let (answer, txids) = engine.lookup_with_txids(&question, None).await.unwrap();
7513        assert_eq!(txids, vec![TEST_TXID]);
7514        let LookupAnswer::OutputList { outputs } = answer else {
7515            panic!("expected outputs")
7516        };
7517        assert!(
7518            beef_limits::own_proof(&outputs[0].beef, TEST_TXID)
7519                .unwrap()
7520                .is_some(),
7521            "a proven flag must have the child's own path in the served BEEF"
7522        );
7523    }
7524
7525    #[tokio::test]
7526    async fn proof_window_silent_peers_yield_at_the_request_budget() {
7527        use crate::proof_window::{HeldOutput, PeerProofSummary, PEER_PROOF_REQUEST_MS};
7528        let (mut engine, remote) = budget_page_engine(1);
7529        engine.config.sync_configuration.insert(
7530            "tm_test".into(),
7531            SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
7532        );
7533        let elapsed = remote.elapsed.clone();
7534        engine.set_peer_sync_budget(
7535            std::rc::Rc::new(move |ms| {
7536                assert_eq!(ms, PEER_PROOF_REQUEST_MS);
7537                let deadline = elapsed.get() + ms;
7538                let elapsed = elapsed.clone();
7539                Box::pin(std::future::poll_fn(move |_| {
7540                    if elapsed.get() >= deadline {
7541                        std::task::Poll::Ready(())
7542                    } else {
7543                        std::task::Poll::Pending
7544                    }
7545                }))
7546            }),
7547            30_000,
7548        );
7549        let candidate = HeldOutput {
7550            txid: remote.outputs[0].txid.clone(),
7551            output_index: 0,
7552            topic: "tm_test".into(),
7553        };
7554        engine.set_gasp_remote_factory(Box::new(remote));
7555        let progress = std::cell::Cell::new(PeerProofSummary::default());
7556        engine.rerequest_held_proofs(&[candidate], &progress).await;
7557        assert_eq!(progress.get().requests, 2);
7558        assert_eq!(progress.get().faults, 2);
7559        assert_eq!(progress.get().landed, 0);
7560    }
7561
7562    /// #291: the public sync-response page size is always bounded.
7563    #[test]
7564    fn sync_limit_clamp() {
7565        assert_eq!(
7566            Engine::clamp_sync_limit(None),
7567            Engine::SYNC_RESPONSE_DEFAULT_LIMIT,
7568            "absent limit gets the default page, never unbounded"
7569        );
7570        assert_eq!(Engine::clamp_sync_limit(Some(7)), 7);
7571        assert_eq!(
7572            Engine::clamp_sync_limit(Some(u64::MAX)),
7573            Engine::SYNC_RESPONSE_MAX_LIMIT,
7574            "a huge caller-supplied limit is capped"
7575        );
7576    }
7577
7578    #[tokio::test]
7579    async fn test_lookup_unknown_service_errors() {
7580        let engine = make_engine(vec![]);
7581        let question = LookupQuestion::new("ls_nonexistent", serde_json::json!({}));
7582
7583        let result = engine.lookup(&question, None).await;
7584        assert!(result.is_err());
7585        assert!(matches!(
7586            result.unwrap_err(),
7587            EngineError::LookupServiceNotFound(_)
7588        ));
7589    }
7590
7591    #[tokio::test]
7592    async fn test_list_topic_managers() {
7593        let engine = make_engine(vec![]);
7594        let managers = engine.list_topic_managers().await;
7595
7596        assert_eq!(managers.len(), 1);
7597        assert!(managers.contains_key("tm_test"));
7598        assert_eq!(managers["tm_test"].name, "mock-tm");
7599    }
7600
7601    #[tokio::test]
7602    async fn test_list_lookup_service_providers() {
7603        let engine = make_engine(vec![]);
7604        let services = engine.list_lookup_service_providers().await;
7605
7606        assert_eq!(services.len(), 1);
7607        assert!(services.contains_key("ls_test"));
7608        assert_eq!(services["ls_test"].name, "mock-ls");
7609    }
7610
7611    #[tokio::test]
7612    async fn test_get_documentation() {
7613        let engine = make_engine(vec![]);
7614
7615        let tm_docs = engine.get_documentation_for_topic_manager("tm_test").await;
7616        assert_eq!(tm_docs, "Mock topic manager");
7617
7618        let ls_docs = engine.get_documentation_for_lookup_service("ls_test").await;
7619        assert_eq!(ls_docs, "Mock lookup service");
7620
7621        let missing = engine
7622            .get_documentation_for_topic_manager("tm_missing")
7623            .await;
7624        assert_eq!(missing, "No documentation found!");
7625    }
7626
7627    #[tokio::test]
7628    async fn test_provide_foreign_sync_response() {
7629        let engine = make_engine(vec![0]);
7630
7631        // Submit data
7632        let beef = test_tagged_beef(vec!["tm_test"]);
7633        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7634
7635        // Query GASP sync
7636        let request = GASPInitialRequest {
7637            version: 1,
7638            since: 0,
7639            limit: Some(100),
7640        };
7641        let response = engine
7642            .provide_foreign_sync_response(&request, "tm_test")
7643            .await
7644            .unwrap();
7645
7646        // One tx admitted with output index 0
7647        assert_eq!(response.utxo_list.len(), 1);
7648        assert_eq!(response.utxo_list[0].txid, TEST_TXID);
7649        assert_eq!(response.since, 0);
7650    }
7651
7652    #[tokio::test]
7653    async fn test_delete_utxo_deep_simple() {
7654        let engine = make_engine(vec![0]);
7655        let beef = test_tagged_beef(vec!["tm_test"]);
7656
7657        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7658
7659        let txid = TEST_TXID;
7660        let output = engine
7661            .storage()
7662            .find_output(txid, 0, Some("tm_test"), None, false)
7663            .await
7664            .unwrap()
7665            .unwrap();
7666
7667        // Delete it
7668        engine.delete_utxo_deep(&output, None).await.unwrap();
7669
7670        // Should be gone
7671        let found = engine
7672            .storage()
7673            .find_output(txid, 0, Some("tm_test"), None, false)
7674            .await
7675            .unwrap();
7676        assert!(found.is_none());
7677    }
7678
7679    #[tokio::test]
7680    async fn test_sync_config_defaults_to_ship() {
7681        let engine = make_engine(vec![]);
7682        let sync = &engine.config.sync_configuration;
7683
7684        // tm_test should default to SHIP sync
7685        assert!(sync.contains_key("tm_test"));
7686        assert!(matches!(sync["tm_test"], SyncTarget::Ship));
7687    }
7688
7689    #[tokio::test]
7690    async fn test_multiple_topics_in_single_submit() {
7691        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7692        managers.insert(
7693            "tm_alpha".to_string(),
7694            Box::new(MockTopicManager::admitting(vec![0])),
7695        );
7696        managers.insert(
7697            "tm_beta".to_string(),
7698            Box::new(MockTopicManager::admitting(vec![1])),
7699        );
7700
7701        let engine = Engine::new(
7702            managers,
7703            HashMap::new(),
7704            Box::new(MemoryStorage::new()),
7705            None,
7706            EngineConfig::default(),
7707        );
7708
7709        let beef = test_tagged_beef(vec!["tm_alpha", "tm_beta"]);
7710
7711        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7712
7713        assert_eq!(steak["tm_alpha"].outputs_to_admit, vec![0]);
7714        assert_eq!(steak["tm_beta"].outputs_to_admit, vec![1]);
7715    }
7716
7717    #[tokio::test]
7718    async fn test_evict_output_with_topic() {
7719        let engine = make_engine(vec![0]);
7720        let beef = test_tagged_beef(vec!["tm_test"]);
7721        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7722
7723        // Output should exist
7724        let found = engine
7725            .storage()
7726            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7727            .await
7728            .unwrap();
7729        assert!(found.is_some(), "Output should exist before eviction");
7730
7731        // Evict with topic
7732        engine
7733            .evict_output(TEST_TXID, 0, Some("tm_test"))
7734            .await
7735            .unwrap();
7736
7737        // Output should be gone from storage
7738        let found = engine
7739            .storage()
7740            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7741            .await
7742            .unwrap();
7743        assert!(found.is_none(), "Output should be gone after eviction");
7744
7745        // Lookup service should also have been notified (output evicted)
7746        let question = LookupQuestion::new("ls_test", serde_json::json!({}));
7747        let answer = engine.lookup(&question, None).await.unwrap();
7748        match answer {
7749            LookupAnswer::OutputList { outputs } => {
7750                assert!(
7751                    outputs.is_empty(),
7752                    "Lookup should return empty after eviction"
7753                );
7754            }
7755            _ => panic!("Expected OutputList"),
7756        }
7757    }
7758
7759    #[tokio::test]
7760    async fn test_evict_output_without_topic() {
7761        let engine = make_engine(vec![0]);
7762        let beef = test_tagged_beef(vec!["tm_test"]);
7763        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
7764
7765        // Evict without topic — should find and remove across all topics
7766        engine.evict_output(TEST_TXID, 0, None).await.unwrap();
7767
7768        let found = engine
7769            .storage()
7770            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
7771            .await
7772            .unwrap();
7773        assert!(
7774            found.is_none(),
7775            "Output should be gone after topic-less eviction"
7776        );
7777    }
7778
7779    #[tokio::test]
7780    async fn test_evict_nonexistent_output_is_ok() {
7781        let engine = make_engine(vec![]);
7782
7783        // Evicting something that doesn't exist should not error
7784        let result = engine
7785            .evict_output("nonexistent_txid", 99, Some("tm_test"))
7786            .await;
7787        assert!(result.is_ok(), "Evicting nonexistent output should succeed");
7788    }
7789
7790    // ── Mock Broadcaster ──────────────────────────────────────────────
7791
7792    use crate::broadcaster::Broadcaster;
7793    use std::sync::Arc;
7794
7795    type BroadcastCallLog = Arc<Mutex<Vec<(String, Vec<String>)>>>;
7796
7797    struct MockBroadcaster {
7798        calls: BroadcastCallLog,
7799    }
7800
7801    impl MockBroadcaster {
7802        fn new() -> Self {
7803            Self {
7804                calls: Arc::new(Mutex::new(Vec::new())),
7805            }
7806        }
7807
7808        #[allow(dead_code, reason = "kept for future test extension")]
7809        fn call_count(&self) -> usize {
7810            self.calls.lock().unwrap().len()
7811        }
7812
7813        #[allow(dead_code, reason = "kept for future test extension")]
7814        fn calls(&self) -> Vec<(String, Vec<String>)> {
7815            self.calls.lock().unwrap().clone()
7816        }
7817    }
7818
7819    #[async_trait(?Send)]
7820    impl Broadcaster for MockBroadcaster {
7821        async fn broadcast_to_host(
7822            &self,
7823            host_url: &str,
7824            tagged_beef: &TaggedBEEF,
7825        ) -> Result<(), String> {
7826            self.calls
7827                .lock()
7828                .unwrap()
7829                .push((host_url.to_string(), tagged_beef.topics.clone()));
7830            Ok(())
7831        }
7832    }
7833
7834    // ── Mock SHIP LookupService ─────────────────────────────────────
7835
7836    /// A mock "ls_ship" lookup service that returns pre-configured UTXOReferences
7837    /// when queried. Used to test SHIP propagation without depending on
7838    /// overlay-discovery in the engine crate's unit tests.
7839    struct MockSHIPLookupService {
7840        /// Maps topic -> list of (txid, output_index) references
7841        records: Mutex<HashMap<String, Vec<UTXOReference>>>,
7842    }
7843
7844    impl MockSHIPLookupService {
7845        fn with_records(records: HashMap<String, Vec<UTXOReference>>) -> Self {
7846            Self {
7847                records: Mutex::new(records),
7848            }
7849        }
7850    }
7851
7852    #[async_trait(?Send)]
7853    impl LookupServiceTrait for MockSHIPLookupService {
7854        fn admission_mode(&self) -> AdmissionMode {
7855            AdmissionMode::LockingScript
7856        }
7857
7858        fn spend_notification_mode(&self) -> SpendNotificationMode {
7859            SpendNotificationMode::None
7860        }
7861
7862        async fn output_admitted_by_topic(
7863            &self,
7864            _payload: &OutputAdmittedByTopic,
7865        ) -> Result<(), LookupServiceError> {
7866            Ok(())
7867        }
7868
7869        async fn output_evicted(
7870            &self,
7871            _txid: &str,
7872            _output_index: u32,
7873        ) -> Result<(), LookupServiceError> {
7874            Ok(())
7875        }
7876
7877        async fn lookup(
7878            &self,
7879            question: &LookupQuestion,
7880        ) -> Result<LookupResult, LookupServiceError> {
7881            // Parse the topics from the query
7882            if let Some(topics) = question.query.get("topics").and_then(|v| v.as_array()) {
7883                let records = self.records.lock().unwrap();
7884                let mut results = Vec::new();
7885                for topic_val in topics {
7886                    if let Some(topic) = topic_val.as_str() {
7887                        if let Some(refs) = records.get(topic) {
7888                            results.extend(refs.iter().cloned());
7889                        }
7890                    }
7891                }
7892                return Ok(LookupResult::OutputList(results));
7893            }
7894            Ok(LookupResult::OutputList(vec![]))
7895        }
7896
7897        async fn get_documentation(&self) -> String {
7898            "Mock SHIP lookup service".to_string()
7899        }
7900
7901        async fn get_metadata(&self) -> ServiceMetadata {
7902            ServiceMetadata {
7903                name: "mock-ls-ship".to_string(),
7904                ..Default::default()
7905            }
7906        }
7907    }
7908
7909    /// Build a minimal PushDrop locking script for a SHIP advertisement.
7910    fn build_ship_pushdrop_script(domain: &str, topic: &str) -> Vec<u8> {
7911        use bsv_rs::script::templates::PushDrop;
7912        use bsv_rs::PublicKey;
7913
7914        let fields = vec![
7915            b"SHIP".to_vec(),
7916            vec![0x02; 33], // fake compressed pubkey bytes for identity_key field
7917            domain.as_bytes().to_vec(),
7918            topic.as_bytes().to_vec(),
7919        ];
7920
7921        // PushDrop requires a real compressed public key for the locking script
7922        // Use a well-known test key (generator point G)
7923        let pubkey = PublicKey::from_hex(
7924            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
7925        )
7926        .expect("valid test pubkey");
7927
7928        let pd = PushDrop::new(pubkey, fields);
7929        pd.lock().to_binary()
7930    }
7931
7932    /// Create engine with a broadcaster and a mock SHIP lookup service.
7933    /// The `ship_domains` parameter provides (domain, topic) pairs to pre-populate.
7934    async fn make_engine_with_broadcaster(
7935        admit_indices: Vec<u32>,
7936        broadcaster: MockBroadcaster,
7937        ship_domains: Vec<(&str, &str)>,
7938        hosting_url: Option<&str>,
7939    ) -> (Engine, Arc<Mutex<Vec<(String, Vec<String>)>>>) {
7940        let calls = broadcaster.calls.clone();
7941
7942        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
7943        managers.insert(
7944            "tm_test".to_string(),
7945            Box::new(MockTopicManager::admitting(admit_indices)),
7946        );
7947
7948        let storage = Box::new(MemoryStorage::new());
7949
7950        // Build SHIP records: map each topic to its UTXO references and
7951        // store outputs in storage with PushDrop scripts so the engine can
7952        // parse the domain when broadcasting.
7953        let mut ship_records: HashMap<String, Vec<UTXOReference>> = HashMap::new();
7954
7955        for (domain, topic) in &ship_domains {
7956            // Deterministic fake txid per domain+topic
7957            let fake_txid = format!("{:064x}", {
7958                let mut h: u64 = 0;
7959                for b in domain.bytes().chain(topic.bytes()) {
7960                    h = h.wrapping_mul(31).wrapping_add(u64::from(b));
7961                }
7962                h
7963            });
7964
7965            // Add to SHIP records for the mock lookup service
7966            ship_records
7967                .entry(topic.to_string())
7968                .or_default()
7969                .push(UTXOReference {
7970                    txid: fake_txid.clone(),
7971                    output_index: 0,
7972                });
7973
7974            // Build PushDrop SHIP script and store as an output in main storage
7975            let script = build_ship_pushdrop_script(domain, topic);
7976
7977            let output = Output {
7978                txid: fake_txid,
7979                output_index: 0,
7980                output_script: script,
7981                satoshis: 1,
7982                topic: "tm_ship".to_string(),
7983                spent: false,
7984                outputs_consumed: vec![],
7985                consumed_by: vec![],
7986                beef: None,
7987                block_height: None,
7988                score: None,
7989            };
7990
7991            storage.insert_output(&output).await.unwrap();
7992        }
7993
7994        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
7995        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
7996        lookup_services.insert(
7997            "ls_ship".to_string(),
7998            Box::new(MockSHIPLookupService::with_records(ship_records)),
7999        );
8000
8001        let config = EngineConfig {
8002            hosting_url: hosting_url.map(str::to_string),
8003            ..Default::default()
8004        };
8005
8006        let engine = Engine::with_chain_tracker(
8007            managers,
8008            lookup_services,
8009            storage,
8010            None,
8011            Some(Box::new(broadcaster)),
8012            None,
8013            config,
8014        );
8015
8016        (engine, calls)
8017    }
8018
8019    // ── Broadcaster Tests ─────────────────────────────────────────────
8020
8021    #[tokio::test]
8022    async fn test_broadcaster_called_on_current_tx_with_ship_peers() {
8023        let broadcaster = MockBroadcaster::new();
8024        let (engine, calls) = make_engine_with_broadcaster(
8025            vec![0],
8026            broadcaster,
8027            vec![("https://peer1.example.com", "tm_test")],
8028            Some("https://self.example.com"),
8029        )
8030        .await;
8031
8032        let beef = test_tagged_beef(vec!["tm_test"]);
8033        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8034
8035        // Submit should succeed
8036        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8037
8038        // Broadcaster should have been called for peer1
8039        let recorded = calls.lock().unwrap();
8040        assert_eq!(
8041            recorded.len(),
8042            1,
8043            "Broadcaster should be called once for the one SHIP peer"
8044        );
8045        assert_eq!(recorded[0].0, "https://peer1.example.com");
8046    }
8047
8048    #[tokio::test]
8049    async fn test_broadcaster_not_called_on_historical_tx() {
8050        let broadcaster = MockBroadcaster::new();
8051        let (engine, calls) = make_engine_with_broadcaster(
8052            vec![0],
8053            broadcaster,
8054            vec![("https://peer1.example.com", "tm_test")],
8055            None,
8056        )
8057        .await;
8058
8059        let beef = test_tagged_beef(vec!["tm_test"]);
8060        let steak = engine
8061            .submit(&beef, SubmitMode::HistoricalTx)
8062            .await
8063            .unwrap();
8064
8065        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8066
8067        // Broadcaster should NOT have been called
8068        assert_eq!(
8069            calls.lock().unwrap().len(),
8070            0,
8071            "Broadcaster should not be called for historical TX"
8072        );
8073    }
8074
8075    #[tokio::test]
8076    async fn test_broadcaster_not_called_on_historical_tx_no_spv() {
8077        let broadcaster = MockBroadcaster::new();
8078        let (engine, calls) = make_engine_with_broadcaster(
8079            vec![0],
8080            broadcaster,
8081            vec![("https://peer1.example.com", "tm_test")],
8082            None,
8083        )
8084        .await;
8085
8086        let beef = test_tagged_beef(vec!["tm_test"]);
8087        let steak = engine
8088            .submit(&beef, SubmitMode::HistoricalTxNoSpv)
8089            .await
8090            .unwrap();
8091
8092        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
8093
8094        assert_eq!(
8095            calls.lock().unwrap().len(),
8096            0,
8097            "Broadcaster should not be called for historical-tx-no-spv"
8098        );
8099    }
8100
8101    #[tokio::test]
8102    async fn test_broadcaster_skips_self_hosting_url() {
8103        let broadcaster = MockBroadcaster::new();
8104        let (engine, calls) = make_engine_with_broadcaster(
8105            vec![0],
8106            broadcaster,
8107            vec![
8108                ("https://self.example.com", "tm_test"),
8109                ("https://peer2.example.com", "tm_test"),
8110            ],
8111            Some("https://self.example.com"),
8112        )
8113        .await;
8114
8115        let beef = test_tagged_beef(vec!["tm_test"]);
8116        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8117
8118        // Should only broadcast to peer2, not to self
8119        let recorded = calls.lock().unwrap();
8120        assert_eq!(
8121            recorded.len(),
8122            1,
8123            "Should broadcast to peer2 only, skipping self"
8124        );
8125        assert_eq!(recorded[0].0, "https://peer2.example.com");
8126    }
8127
8128    #[tokio::test]
8129    async fn test_broadcaster_not_called_when_no_outputs_admitted() {
8130        let broadcaster = MockBroadcaster::new();
8131        // Topic manager admits nothing
8132        let (engine, calls) = make_engine_with_broadcaster(
8133            vec![],
8134            broadcaster,
8135            vec![("https://peer1.example.com", "tm_test")],
8136            None,
8137        )
8138        .await;
8139
8140        let beef = test_tagged_beef(vec!["tm_test"]);
8141        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8142
8143        assert!(steak["tm_test"].outputs_to_admit.is_empty());
8144
8145        // No outputs admitted -> no broadcast
8146        assert_eq!(
8147            calls.lock().unwrap().len(),
8148            0,
8149            "Broadcaster should not be called when no outputs are admitted"
8150        );
8151    }
8152
8153    #[tokio::test]
8154    async fn test_broadcaster_not_called_when_no_ship_peers() {
8155        let broadcaster = MockBroadcaster::new();
8156        // No SHIP peers registered
8157        let (engine, calls) = make_engine_with_broadcaster(
8158            vec![0],
8159            broadcaster,
8160            vec![], // no SHIP peers
8161            None,
8162        )
8163        .await;
8164
8165        let beef = test_tagged_beef(vec!["tm_test"]);
8166        engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8167
8168        assert_eq!(
8169            calls.lock().unwrap().len(),
8170            0,
8171            "Broadcaster should not be called when no SHIP peers exist"
8172        );
8173    }
8174
8175    // ── Configurable SpendNotification mock ─────────────────────────────
8176
8177    /// A mock lookup service whose SpendNotificationMode can be configured.
8178    /// Uses a shared Arc<Mutex<Vec<OutputSpent>>> so the caller can inspect
8179    /// captured payloads after submit().
8180    struct SpendModeLookupService {
8181        mode: SpendNotificationMode,
8182        records: Mutex<Vec<UTXOReference>>,
8183        spent_payloads: Arc<Mutex<Vec<OutputSpent>>>,
8184    }
8185
8186    impl SpendModeLookupService {
8187        fn with_mode(mode: SpendNotificationMode, capture: Arc<Mutex<Vec<OutputSpent>>>) -> Self {
8188            Self {
8189                mode,
8190                records: Mutex::new(Vec::new()),
8191                spent_payloads: capture,
8192            }
8193        }
8194    }
8195
8196    #[async_trait(?Send)]
8197    impl LookupServiceTrait for SpendModeLookupService {
8198        fn admission_mode(&self) -> AdmissionMode {
8199            AdmissionMode::LockingScript
8200        }
8201
8202        fn spend_notification_mode(&self) -> SpendNotificationMode {
8203            self.mode
8204        }
8205
8206        async fn output_admitted_by_topic(
8207            &self,
8208            payload: &OutputAdmittedByTopic,
8209        ) -> Result<(), LookupServiceError> {
8210            let (txid, oi) = match payload {
8211                OutputAdmittedByTopic::LockingScript {
8212                    txid, output_index, ..
8213                } => (txid.clone(), *output_index),
8214                OutputAdmittedByTopic::WholeTx { output_index, .. } => {
8215                    ("whole".into(), *output_index)
8216                }
8217            };
8218            self.records.lock().unwrap().push(UTXOReference {
8219                txid,
8220                output_index: oi,
8221            });
8222            Ok(())
8223        }
8224
8225        async fn output_spent(&self, payload: &OutputSpent) -> Result<(), LookupServiceError> {
8226            self.spent_payloads.lock().unwrap().push(payload.clone());
8227            Ok(())
8228        }
8229
8230        async fn output_evicted(
8231            &self,
8232            txid: &str,
8233            output_index: u32,
8234        ) -> Result<(), LookupServiceError> {
8235            self.records
8236                .lock()
8237                .unwrap()
8238                .retain(|r| !(r.txid == txid && r.output_index == output_index));
8239            Ok(())
8240        }
8241
8242        async fn lookup(
8243            &self,
8244            _question: &LookupQuestion,
8245        ) -> Result<LookupResult, LookupServiceError> {
8246            Ok(LookupResult::OutputList(
8247                self.records.lock().unwrap().clone(),
8248            ))
8249        }
8250
8251        async fn get_documentation(&self) -> String {
8252            "SpendMode mock lookup service".to_string()
8253        }
8254
8255        async fn get_metadata(&self) -> ServiceMetadata {
8256            ServiceMetadata {
8257                name: "spend-mode-ls".to_string(),
8258                ..Default::default()
8259            }
8260        }
8261    }
8262
8263    /// TXID of the input's source transaction in TEST_BEEF_HEX (the "previous coin").
8264    const PREVIOUS_TXID: &str = "3ecead27a44d013ad1aae40038acbb1883ac9242406808bb4667c15b4f164eac";
8265
8266    /// Build an engine with a pre-populated previous output and a SpendModeLookupService.
8267    /// Returns the engine and the shared capture vec for inspecting OutputSpent payloads.
8268    async fn make_spend_mode_engine(
8269        mode: SpendNotificationMode,
8270    ) -> (Engine, Arc<Mutex<Vec<OutputSpent>>>) {
8271        let storage = MemoryStorage::new();
8272
8273        // Pre-populate storage with the previous output that TEST_BEEF_HEX's input spends.
8274        let prev_output = Output {
8275            txid: PREVIOUS_TXID.to_string(),
8276            output_index: 0,
8277            output_script: vec![0x76, 0xa9],
8278            satoshis: 26174,
8279            topic: "tm_test".to_string(),
8280            spent: false,
8281            outputs_consumed: vec![],
8282            consumed_by: vec![],
8283            beef: Some(test_beef()),
8284            block_height: None,
8285            score: Some(1000.0),
8286        };
8287        storage.insert_output(&prev_output).await.unwrap();
8288
8289        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
8290        managers.insert(
8291            "tm_test".to_string(),
8292            Box::new(MockTopicManager::admitting(vec![0])),
8293        );
8294
8295        let capture: Arc<Mutex<Vec<OutputSpent>>> = Arc::new(Mutex::new(Vec::new()));
8296        let ls = SpendModeLookupService::with_mode(mode, capture.clone());
8297        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
8298        lookup_services.insert("ls_test".to_string(), Box::new(ls));
8299
8300        let engine = Engine::new(
8301            managers,
8302            lookup_services,
8303            Box::new(storage),
8304            None,
8305            EngineConfig::default(),
8306        );
8307
8308        (engine, capture)
8309    }
8310
8311    #[tokio::test]
8312    async fn test_spend_notification_mode_script() {
8313        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Script).await;
8314
8315        let beef = test_tagged_beef(vec!["tm_test"]);
8316        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8317
8318        // The new output should be admitted
8319        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8320
8321        // The lookup service should have received a Script spend notification
8322        let payloads = capture.lock().unwrap().clone();
8323        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8324
8325        match &payloads[0] {
8326            OutputSpent::Script {
8327                txid,
8328                output_index,
8329                topic,
8330                spending_txid,
8331                input_index,
8332                unlocking_script,
8333                sequence_number,
8334                ..
8335            } => {
8336                assert_eq!(txid, PREVIOUS_TXID);
8337                assert_eq!(*output_index, 0);
8338                assert_eq!(topic, "tm_test");
8339                assert_eq!(spending_txid, TEST_TXID);
8340                // The BEEF has a single input at index 0 that spends PREVIOUS_TXID:0
8341                assert_eq!(*input_index, 0);
8342                // The unlocking script should be non-empty (it's a P2PKH scriptSig)
8343                assert!(
8344                    !unlocking_script.is_empty(),
8345                    "Unlocking script should be non-empty"
8346                );
8347                // Standard final sequence number
8348                assert_eq!(*sequence_number, 0xffff_ffff);
8349            }
8350            other => panic!("Expected OutputSpent::Script, got: {other:?}"),
8351        }
8352    }
8353
8354    #[tokio::test]
8355    async fn test_spend_notification_mode_whole_tx() {
8356        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::WholeTx).await;
8357
8358        let beef = test_tagged_beef(vec!["tm_test"]);
8359        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8360
8361        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8362
8363        let payloads = capture.lock().unwrap().clone();
8364        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8365
8366        match &payloads[0] {
8367            OutputSpent::WholeTx {
8368                txid,
8369                output_index,
8370                topic,
8371                spending_atomic_beef,
8372                ..
8373            } => {
8374                assert_eq!(txid, PREVIOUS_TXID);
8375                assert_eq!(*output_index, 0);
8376                assert_eq!(topic, "tm_test");
8377                // The spending BEEF is the entire BEEF we submitted, NAMED
8378                // (atomic prefix) for the lookup service's own re-parse.
8379                let mut named =
8380                    bsv_rs::transaction::Beef::from_binary(spending_atomic_beef).unwrap();
8381                assert!(named.is_atomic());
8382                let submitted = bsv_rs::transaction::Beef::from_binary(&test_beef()).unwrap();
8383                assert_eq!(
8384                    named.txs.len(),
8385                    submitted.txs.len(),
8386                    "the whole body, never pruned"
8387                );
8388                let tip = crate::subject::subject_txid_of(&mut named).unwrap();
8389                assert_eq!(named.atomic_txid.as_deref(), Some(tip.as_str()));
8390            }
8391            other => panic!("Expected OutputSpent::WholeTx, got: {other:?}"),
8392        }
8393    }
8394
8395    #[tokio::test]
8396    async fn test_spend_notification_mode_txid() {
8397        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::Txid).await;
8398
8399        let beef = test_tagged_beef(vec!["tm_test"]);
8400        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8401
8402        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8403
8404        let payloads = capture.lock().unwrap().clone();
8405        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8406
8407        match &payloads[0] {
8408            OutputSpent::Txid {
8409                txid,
8410                output_index,
8411                topic,
8412                spending_txid,
8413            } => {
8414                assert_eq!(txid, PREVIOUS_TXID);
8415                assert_eq!(*output_index, 0);
8416                assert_eq!(topic, "tm_test");
8417                assert_eq!(spending_txid, TEST_TXID);
8418            }
8419            other => panic!("Expected OutputSpent::Txid, got: {other:?}"),
8420        }
8421    }
8422
8423    #[tokio::test]
8424    async fn test_spend_notification_mode_none() {
8425        let (engine, capture) = make_spend_mode_engine(SpendNotificationMode::None).await;
8426
8427        let beef = test_tagged_beef(vec!["tm_test"]);
8428        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
8429
8430        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
8431
8432        let payloads = capture.lock().unwrap().clone();
8433        assert_eq!(payloads.len(), 1, "Expected exactly one spend notification");
8434
8435        match &payloads[0] {
8436            OutputSpent::None {
8437                txid,
8438                output_index,
8439                topic,
8440            } => {
8441                assert_eq!(txid, PREVIOUS_TXID);
8442                assert_eq!(*output_index, 0);
8443                assert_eq!(topic, "tm_test");
8444            }
8445            other => panic!("Expected OutputSpent::None, got: {other:?}"),
8446        }
8447    }
8448
8449    // ── GASP Sync Tests ───────────────────────────────────────────────
8450
8451    #[tokio::test]
8452    async fn test_start_gasp_sync_empty_config() {
8453        let engine = Engine::new(
8454            HashMap::new(),
8455            HashMap::new(),
8456            Box::new(MemoryStorage::new()),
8457            None,
8458            EngineConfig {
8459                sync_configuration: HashMap::new(),
8460                ..Default::default()
8461            },
8462        );
8463
8464        let result = engine.start_gasp_sync().await.unwrap();
8465        assert!(
8466            result.topics_synced.is_empty(),
8467            "No topics should be synced with empty config"
8468        );
8469    }
8470
8471    #[tokio::test]
8472    async fn test_start_gasp_sync_disabled_topic_skipped() {
8473        let mut sync_config: SyncConfiguration = HashMap::new();
8474        sync_config.insert("tm_test".to_string(), SyncTarget::Disabled);
8475
8476        let engine = Engine::new(
8477            HashMap::new(),
8478            HashMap::new(),
8479            Box::new(MemoryStorage::new()),
8480            None,
8481            EngineConfig {
8482                sync_configuration: sync_config,
8483                ..Default::default()
8484            },
8485        );
8486
8487        let result = engine.start_gasp_sync().await.unwrap();
8488        assert!(
8489            result.topics_synced.is_empty(),
8490            "Disabled topics should not appear in results"
8491        );
8492    }
8493
8494    #[tokio::test]
8495    async fn test_start_gasp_sync_peers_config() {
8496        let mut sync_config: SyncConfiguration = HashMap::new();
8497        sync_config.insert(
8498            "tm_test".to_string(),
8499            SyncTarget::Peers(vec![
8500                "https://peer1.example.com".to_string(),
8501                "https://peer2.example.com".to_string(),
8502            ]),
8503        );
8504
8505        let engine = Engine::new(
8506            HashMap::new(),
8507            HashMap::new(),
8508            Box::new(MemoryStorage::new()),
8509            None,
8510            EngineConfig {
8511                sync_configuration: sync_config,
8512                ..Default::default()
8513            },
8514        );
8515
8516        let result = engine.start_gasp_sync().await.unwrap();
8517        assert_eq!(result.topics_synced.len(), 1);
8518
8519        let topic_result = &result.topics_synced["tm_test"];
8520        assert_eq!(topic_result.peers.len(), 2);
8521        assert_eq!(topic_result.sync_type, "peers");
8522        assert!(topic_result.errors.is_empty());
8523    }
8524
8525    #[tokio::test]
8526    async fn test_start_gasp_sync_peers_filters_self() {
8527        let mut sync_config: SyncConfiguration = HashMap::new();
8528        sync_config.insert(
8529            "tm_test".to_string(),
8530            SyncTarget::Peers(vec![
8531                "https://self.example.com".to_string(),
8532                "https://peer2.example.com".to_string(),
8533            ]),
8534        );
8535
8536        let engine = Engine::new(
8537            HashMap::new(),
8538            HashMap::new(),
8539            Box::new(MemoryStorage::new()),
8540            None,
8541            EngineConfig {
8542                hosting_url: Some("https://self.example.com".to_string()),
8543                sync_configuration: sync_config,
8544                ..Default::default()
8545            },
8546        );
8547
8548        let result = engine.start_gasp_sync().await.unwrap();
8549        let topic_result = &result.topics_synced["tm_test"];
8550        assert_eq!(
8551            topic_result.peers.len(),
8552            1,
8553            "Self URL should be filtered out"
8554        );
8555        assert_eq!(topic_result.peers[0], "https://peer2.example.com");
8556    }
8557
8558    #[tokio::test]
8559    async fn load_0112_quiet_peer_costs_no_request_during_backoff() {
8560        let calls = std::rc::Rc::default();
8561        let store = std::rc::Rc::new(MemoryStorage::new());
8562        let mut engine = Engine::new(
8563            HashMap::new(),
8564            HashMap::new(),
8565            Box::new(store.clone()),
8566            None,
8567            EngineConfig {
8568                sync_configuration: HashMap::from([(
8569                    "tm_test".into(),
8570                    SyncTarget::Peers(vec!["https://quiet.example".into()]),
8571                )]),
8572                ..Default::default()
8573            },
8574        );
8575        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8576            calls: std::rc::Rc::clone(&calls),
8577        }));
8578        for _ in 0..4 {
8579            engine.start_gasp_sync().await.unwrap();
8580        }
8581        assert_eq!(calls.borrow().len(), 3);
8582        assert_eq!(
8583            store
8584                .get_peer_sync_health("quiet.example", "tm_test")
8585                .await
8586                .unwrap()
8587                .consecutive_failures,
8588            0
8589        );
8590        store.advance_clock(60);
8591        engine.start_gasp_sync().await.unwrap();
8592        assert_eq!(calls.borrow().len(), 4);
8593        engine.start_gasp_sync().await.unwrap();
8594        assert_eq!(calls.borrow().len(), 4);
8595        engine
8596            .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8597                topics: Some(vec!["tm_test".into()]),
8598                peers: None,
8599            })
8600            .await
8601            .unwrap();
8602        assert_eq!(calls.borrow().len(), 5);
8603        engine.start_gasp_sync().await.unwrap();
8604        assert_eq!(
8605            calls.borrow().len(),
8606            6,
8607            "explicit selection reset the quiet streak"
8608        );
8609    }
8610
8611    #[tokio::test]
8612    async fn load_0112_yield_and_advertisement_reset_cadence() {
8613        let (mut engine, remote) = budget_page_engine(1);
8614        engine.set_gasp_remote_factory(Box::new(remote));
8615        engine
8616            .storage
8617            .set_peer_cadence(
8618                "budget.example",
8619                "tm_test",
8620                &crate::sync_load::PeerCadence {
8621                    quiet_syncs: 8,
8622                    retry_after_secs: 0,
8623                    advertisement: String::new(),
8624                },
8625            )
8626            .await
8627            .unwrap();
8628        let result = engine.start_gasp_sync().await.unwrap();
8629        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
8630        assert_eq!(
8631            engine
8632                .storage
8633                .get_peer_cadence("budget.example", "tm_test")
8634                .await
8635                .unwrap()
8636                .quiet_syncs,
8637            0
8638        );
8639        engine
8640            .storage
8641            .set_peer_cadence(
8642                "budget.example",
8643                "tm_test",
8644                &crate::sync_load::PeerCadence {
8645                    quiet_syncs: 8,
8646                    retry_after_secs: 3_600,
8647                    advertisement: "previous-advertisement".into(),
8648                },
8649            )
8650            .await
8651            .unwrap();
8652        let result = engine.start_gasp_sync().await.unwrap();
8653        assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8654        assert!(
8655            engine
8656                .storage
8657                .get_peer_cadence("budget.example", "tm_test")
8658                .await
8659                .unwrap()
8660                .quiet_syncs
8661                <= 1
8662        );
8663    }
8664
8665    #[tokio::test]
8666    async fn load_0112_an_admitted_slap_change_wakes_its_peer() {
8667        let (mut engine, mut remote) = budget_page_engine(0);
8668        remote.node_cost_ms = 0;
8669        engine.set_gasp_remote_factory(Box::new(remote.clone()));
8670        for _ in 0..3 {
8671            engine.start_gasp_sync().await.unwrap();
8672        }
8673        let skipped = engine.start_gasp_sync().await.unwrap();
8674        assert_eq!(
8675            skipped.topics_synced["tm_test"].peers_skipped[0].reason,
8676            "no_yield"
8677        );
8678        let count = remote.requests.borrow().len();
8679        let ad = discovery_output("SLAP", "https://budget.example", "ls_ship");
8680        let tx = beef_limits::transaction_from_beef(&ad.beef, None, &beef_limits::PEER_BEEF_LIMITS)
8681            .unwrap();
8682        engine
8683            .storage
8684            .insert_output(&Output {
8685                txid: tx.id(),
8686                output_index: 0,
8687                output_script: tx.outputs[0].locking_script.to_binary(),
8688                satoshis: 1,
8689                topic: "tm_slap".into(),
8690                spent: false,
8691                outputs_consumed: vec![],
8692                consumed_by: vec![],
8693                beef: None,
8694                block_height: None,
8695                score: Some(1.0),
8696            })
8697            .await
8698            .unwrap();
8699        let result = engine.start_gasp_sync().await.unwrap();
8700        assert!(result.topics_synced["tm_test"].peers_skipped.is_empty());
8701        assert_eq!(remote.requests.borrow().len(), count + 1);
8702    }
8703
8704    #[tokio::test]
8705    async fn load_0112_filter_selects_only_named_topic_and_peer() {
8706        let calls = std::rc::Rc::default();
8707        let mut engine = Engine::new(
8708            HashMap::new(),
8709            HashMap::new(),
8710            Box::new(MemoryStorage::new()),
8711            None,
8712            EngineConfig {
8713                sync_configuration: ["tm_alpha", "tm_beta"]
8714                    .into_iter()
8715                    .map(|t| {
8716                        (
8717                            t.to_string(),
8718                            SyncTarget::Peers(vec![
8719                                "https://a.example".into(),
8720                                "https://b.example".into(),
8721                            ]),
8722                        )
8723                    })
8724                    .collect(),
8725                ..Default::default()
8726            },
8727        );
8728        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8729            calls: std::rc::Rc::clone(&calls),
8730        }));
8731        let filter = crate::sync_load::SyncFilter::parse(
8732            r#"{"topics":["tm_beta"],"peers":["https://b.example"]}"#,
8733        )
8734        .unwrap();
8735        let result = engine.start_gasp_sync_filtered(&filter).await.unwrap();
8736        assert_eq!(
8737            *calls.borrow(),
8738            vec![("https://b.example".into(), "tm_beta".into())]
8739        );
8740        assert_eq!(result.topics_synced.len(), 1);
8741        calls.borrow_mut().clear();
8742        engine.start_gasp_sync().await.unwrap();
8743        assert_eq!(calls.borrow().len(), 4);
8744        calls.borrow_mut().clear();
8745        engine
8746            .start_gasp_sync_filtered(&crate::sync_load::SyncFilter {
8747                topics: Some(vec![]),
8748                peers: None,
8749            })
8750            .await
8751            .unwrap();
8752        assert!(calls.borrow().is_empty());
8753        assert!(crate::sync_load::SyncFilter::parse(r#"{"peers":"bad"}"#).is_err());
8754        assert!(crate::sync_load::SyncFilter::parse("null").is_err());
8755        assert_eq!(
8756            crate::sync_load::SyncFilter::parse("  ").unwrap(),
8757            crate::sync_load::SyncFilter::default()
8758        );
8759    }
8760
8761    #[derive(Clone, Default)]
8762    struct MockShipLookupClient {
8763        records: HashMap<(String, String), Vec<OutputListItem>>,
8764        failures: HashSet<String>,
8765        calls: std::rc::Rc<std::cell::RefCell<Vec<(String, LookupQuestion)>>>,
8766    }
8767
8768    struct RecordingDiscoverySyncFactory {
8769        calls: std::rc::Rc<std::cell::RefCell<Vec<(String, String)>>>,
8770    }
8771
8772    impl crate::gasp::GASPRemoteFactory for RecordingDiscoverySyncFactory {
8773        fn create_remote(&self, peer: &str, topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
8774            self.calls
8775                .borrow_mut()
8776                .push((peer.to_string(), topic.to_string()));
8777            Box::new(MockSyncRemote { utxos: Vec::new() })
8778        }
8779    }
8780
8781    #[async_trait(?Send)]
8782    impl crate::ship_lookup::ShipLookupClient for MockShipLookupClient {
8783        async fn lookup(
8784            &self,
8785            host: &str,
8786            question: &LookupQuestion,
8787        ) -> Result<Vec<OutputListItem>, String> {
8788            self.calls
8789                .borrow_mut()
8790                .push((host.to_string(), question.clone()));
8791            if self.failures.contains(host) {
8792                return Err("mock tracker unavailable".to_string());
8793            }
8794            Ok(self
8795                .records
8796                .get(&(host.to_string(), question.service.clone()))
8797                .cloned()
8798                .unwrap_or_default())
8799        }
8800    }
8801
8802    fn discovery_output(protocol: &str, domain: &str, scope: &str) -> OutputListItem {
8803        use bsv_rs::script::templates::PushDrop;
8804        let pubkey = bsv_rs::PublicKey::from_hex(
8805            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
8806        )
8807        .unwrap();
8808        let script = PushDrop::new(
8809            pubkey,
8810            vec![
8811                protocol.as_bytes().to_vec(),
8812                vec![0x02; 33],
8813                domain.as_bytes().to_vec(),
8814                scope.as_bytes().to_vec(),
8815            ],
8816        )
8817        .lock();
8818        let mut tx = Transaction::from_beef(&test_beef(), None).unwrap();
8819        tx.outputs[0].locking_script = script;
8820        tx.merkle_path = None;
8821        tx.invalidate_caches();
8822        let beef = tx.to_atomic_beef(true).unwrap();
8823        let parsed =
8824            beef_limits::transaction_from_beef(&beef, None, &beef_limits::PEER_BEEF_LIMITS)
8825                .unwrap();
8826        assert_eq!(
8827            parsed.outputs[0].locking_script,
8828            tx.outputs[0].locking_script
8829        );
8830        OutputListItem {
8831            beef,
8832            output_index: 0,
8833            context: None,
8834        }
8835    }
8836
8837    #[tokio::test]
8838    async fn ship_network_discovery_unions_local_trackers_and_slap_hosts_by_origin() {
8839        let (mut engine, _) = make_engine_with_broadcaster(
8840            vec![0],
8841            MockBroadcaster::new(),
8842            vec![
8843                ("https://local.example", "tm_test"),
8844                ("https://shared.example/?local", "tm_test"),
8845            ],
8846            Some("https://self.example"),
8847        )
8848        .await;
8849        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8850        engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
8851        let mut client = MockShipLookupClient::default();
8852        client.records.insert(
8853            ("https://tracker.example".to_string(), "ls_ship".to_string()),
8854            vec![
8855                discovery_output("SHIP", "https://direct.example", "tm_test"),
8856                discovery_output("SHIP", "https://SHARED.example:443/path", "tm_test"),
8857                discovery_output("SHIP", "http://SELF.example.:8443/?x#y", "tm_test"),
8858                discovery_output("SLAP", "https://wrong-protocol.example", "tm_test"),
8859                discovery_output("SHIP", "https://wrong-topic.example", "tm_other"),
8860                OutputListItem {
8861                    beef: vec![1, 2, 3],
8862                    output_index: 0,
8863                    context: None,
8864                },
8865            ],
8866        );
8867        client.records.insert(
8868            ("https://tracker.example".to_string(), "ls_slap".to_string()),
8869            vec![
8870                discovery_output("SLAP", "https://lookup.example", "ls_ship"),
8871                discovery_output("SLAP", "https://LOOKUP.example:443/path", "ls_ship"),
8872                discovery_output("SLAP", "https://self.example/?slap", "ls_ship"),
8873                discovery_output("SLAP", "https://tracker.example/path", "ls_ship"),
8874                discovery_output("SLAP", "https://other-service.example", "ls_other"),
8875            ],
8876        );
8877        client.records.insert(
8878            ("https://lookup.example".to_string(), "ls_ship".to_string()),
8879            vec![
8880                discovery_output("SHIP", "https://indirect.example", "tm_test"),
8881                discovery_output("SHIP", "https://self.example:443/#ship", "tm_test"),
8882            ],
8883        );
8884        let calls = client.calls.clone();
8885        engine.set_ship_lookup_client(Box::new(client));
8886        let result = engine.start_gasp_sync().await.unwrap();
8887        assert_eq!(
8888            result.topics_synced["tm_test"].peers,
8889            vec![
8890                "https://bootstrap.example",
8891                "https://direct.example",
8892                "https://indirect.example",
8893                "https://local.example",
8894                "https://shared.example",
8895            ]
8896        );
8897        let calls = calls.borrow();
8898        assert_eq!(calls.len(), 3);
8899        for (_, question) in calls.iter() {
8900            let expected = if question.service == "ls_slap" {
8901                serde_json::json!({"service": "ls_ship"})
8902            } else {
8903                serde_json::json!({"topics": ["tm_test"]})
8904            };
8905            assert_eq!(question.query, expected);
8906        }
8907    }
8908
8909    #[tokio::test]
8910    async fn ship_network_discovery_empty_local_finds_all_advertised_peers() {
8911        let (mut engine, _) =
8912            make_engine_with_broadcaster(vec![0], MockBroadcaster::new(), vec![], None).await;
8913        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8914        let mut client = MockShipLookupClient::default();
8915        let outputs = (0..9)
8916            .map(|n| discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"))
8917            .collect();
8918        client.records.insert(
8919            ("https://tracker.example".to_string(), "ls_ship".to_string()),
8920            outputs,
8921        );
8922        engine.set_ship_lookup_client(Box::new(client));
8923        let sync_calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
8924        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
8925            calls: sync_calls.clone(),
8926        }));
8927        let result = engine.start_gasp_sync().await.unwrap();
8928        let topic = &result.topics_synced["tm_test"];
8929        assert_eq!(topic.sync_type, "ship");
8930        assert_eq!(
8931            topic.peers.len(),
8932            9,
8933            "the lookup-host cap must not truncate sync peers"
8934        );
8935        assert!(topic.errors.is_empty());
8936        assert_eq!(sync_calls.borrow().len(), 9);
8937        for peer in &topic.peers {
8938            assert!(sync_calls
8939                .borrow()
8940                .contains(&(peer.clone(), "tm_test".to_string())));
8941        }
8942    }
8943
8944    #[tokio::test]
8945    async fn ship_network_discovery_tracker_failure_preserves_other_answers() {
8946        let mut engine = make_engine(vec![]);
8947        engine.config.slap_trackers = vec![
8948            "https://dead.example".to_string(),
8949            "https://live.example".to_string(),
8950        ];
8951        let mut client = MockShipLookupClient::default();
8952        client.failures.insert("https://dead.example".to_string());
8953        client.records.insert(
8954            ("https://live.example".to_string(), "ls_ship".to_string()),
8955            vec![discovery_output("SHIP", "https://peer.example", "tm_test")],
8956        );
8957        engine.set_ship_lookup_client(Box::new(client));
8958        let result = engine.start_gasp_sync().await.unwrap();
8959        assert_eq!(
8960            result.topics_synced["tm_test"].peers,
8961            vec!["https://peer.example"]
8962        );
8963    }
8964
8965    #[tokio::test]
8966    async fn ship_network_discovery_provider_cap_is_shared_across_topics() {
8967        use crate::ship_lookup::SHIP_DISCOVERY_MAX_HOSTS_PER_TICK;
8968        let mut engine = make_engine(vec![]);
8969        engine
8970            .config
8971            .sync_configuration
8972            .insert("tm_other".to_string(), SyncTarget::Ship);
8973        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
8974        let mut client = MockShipLookupClient::default();
8975        let mut providers = Vec::new();
8976        for n in 0..SHIP_DISCOVERY_MAX_HOSTS_PER_TICK + 3 {
8977            let host = format!("https://lookup{n}.example");
8978            providers.push(discovery_output("SLAP", &host, "ls_ship"));
8979            client.records.insert(
8980                (host, "ls_ship".to_string()),
8981                vec![
8982                    discovery_output("SHIP", &format!("https://peer{n}.example"), "tm_test"),
8983                    discovery_output("SHIP", &format!("https://other{n}.example"), "tm_other"),
8984                ],
8985            );
8986        }
8987        client.records.insert(
8988            ("https://tracker.example".to_string(), "ls_slap".to_string()),
8989            providers,
8990        );
8991        let calls = client.calls.clone();
8992        engine.set_ship_lookup_client(Box::new(client));
8993        let result = engine.start_gasp_sync().await.unwrap();
8994        assert_eq!(
8995            result.topics_synced["tm_test"].peers.len(),
8996            SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
8997        );
8998        assert_eq!(
8999            result.topics_synced["tm_other"].peers.len(),
9000            SHIP_DISCOVERY_MAX_HOSTS_PER_TICK
9001        );
9002        let calls = calls.borrow();
9003        assert_eq!(
9004            calls.iter().filter(|(_, q)| q.service == "ls_slap").count(),
9005            1
9006        );
9007        let providers: HashSet<_> = calls
9008            .iter()
9009            .filter(|(h, q)| h != "https://tracker.example" && q.service == "ls_ship")
9010            .map(|(h, _)| h)
9011            .collect();
9012        assert_eq!(providers.len(), SHIP_DISCOVERY_MAX_HOSTS_PER_TICK);
9013        assert_eq!(calls.len(), 1 + 2 * (1 + SHIP_DISCOVERY_MAX_HOSTS_PER_TICK));
9014    }
9015
9016    #[tokio::test]
9017    async fn ship_network_discovery_outage_keeps_bootstrap_and_excludes_self() {
9018        let mut engine = make_engine(vec![]);
9019        engine.config.hosting_url = Some("https://self.example".to_string());
9020        engine.config.slap_trackers = vec!["https://dead.example".to_string()];
9021        let mut client = MockShipLookupClient::default();
9022        client.failures.insert("https://dead.example".to_string());
9023        engine.set_ship_lookup_client(Box::new(client));
9024        engine.set_ship_bootstrap_peers(
9025            "tm_test",
9026            vec![
9027                "https://bootstrap.example".to_string(),
9028                "https://BOOTSTRAP.example.:443/?x".to_string(),
9029                "http://SELF.example.:8443/path".to_string(),
9030            ],
9031        );
9032        let result = engine.start_gasp_sync().await.unwrap();
9033        assert_eq!(
9034            result.topics_synced["tm_test"].peers,
9035            vec!["https://bootstrap.example"]
9036        );
9037    }
9038
9039    #[tokio::test]
9040    async fn ship_network_discovery_bootstrap_topic_runs_before_tracker_import() {
9041        let mut engine = make_engine(vec![]);
9042        for topic in ["tm_ship", "tm_slap"] {
9043            engine.config.sync_configuration.insert(
9044                topic.to_string(),
9045                SyncTarget::Peers(vec!["https://tracker.example".to_string()]),
9046            );
9047        }
9048        engine.config.slap_trackers = vec!["https://tracker.example".to_string()];
9049        let mut client = MockShipLookupClient::default();
9050        client.records.insert(
9051            ("https://tracker.example".to_string(), "ls_ship".to_string()),
9052            vec![discovery_output(
9053                "SHIP",
9054                "https://discovered.example",
9055                "tm_test",
9056            )],
9057        );
9058        let lookups = client.calls.clone();
9059        engine.set_ship_lookup_client(Box::new(client));
9060        engine.set_ship_bootstrap_peers("tm_test", vec!["https://bootstrap.example".to_string()]);
9061        let calls = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
9062        engine.set_gasp_remote_factory(Box::new(RecordingDiscoverySyncFactory {
9063            calls: calls.clone(),
9064        }));
9065        engine.start_gasp_sync().await.unwrap();
9066        assert_eq!(
9067            calls
9068                .borrow()
9069                .iter()
9070                .map(|(_, topic)| topic.as_str())
9071                .collect::<Vec<_>>(),
9072            vec!["tm_test", "tm_test", "tm_ship", "tm_slap"]
9073        );
9074        assert_eq!(lookups.borrow().len(), 2);
9075    }
9076
9077    #[tokio::test]
9078    async fn test_start_gasp_sync_ship_discovers_peers() {
9079        let broadcaster = MockBroadcaster::new();
9080        let (engine, _calls) = make_engine_with_broadcaster(
9081            vec![0],
9082            broadcaster,
9083            vec![
9084                ("https://peer1.example.com", "tm_test"),
9085                ("https://peer2.example.com", "tm_test"),
9086            ],
9087            Some("https://self.example.com"),
9088        )
9089        .await;
9090
9091        let result = engine.start_gasp_sync().await.unwrap();
9092        assert!(
9093            result.topics_synced.contains_key("tm_test"),
9094            "tm_test should be in sync results"
9095        );
9096
9097        let topic_result = &result.topics_synced["tm_test"];
9098        assert_eq!(topic_result.sync_type, "ship");
9099        assert_eq!(
9100            topic_result.peers.len(),
9101            2,
9102            "Should discover both SHIP peers"
9103        );
9104        assert!(topic_result
9105            .peers
9106            .contains(&"https://peer1.example.com".to_string()));
9107        assert!(topic_result
9108            .peers
9109            .contains(&"https://peer2.example.com".to_string()));
9110    }
9111
9112    #[tokio::test]
9113    async fn test_start_gasp_sync_ship_filters_self() {
9114        let broadcaster = MockBroadcaster::new();
9115        let (engine, _calls) = make_engine_with_broadcaster(
9116            vec![0],
9117            broadcaster,
9118            vec![
9119                ("https://self.example.com", "tm_test"),
9120                ("https://peer2.example.com", "tm_test"),
9121            ],
9122            Some("https://self.example.com"),
9123        )
9124        .await;
9125
9126        let result = engine.start_gasp_sync().await.unwrap();
9127        let topic_result = &result.topics_synced["tm_test"];
9128        assert_eq!(
9129            topic_result.peers.len(),
9130            1,
9131            "Self URL should be filtered out from SHIP discovery"
9132        );
9133        assert_eq!(topic_result.peers[0], "https://peer2.example.com");
9134    }
9135
9136    #[tokio::test]
9137    async fn test_start_gasp_sync_ship_no_ls_ship_returns_empty() {
9138        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9139        managers.insert(
9140            "tm_custom".to_string(),
9141            Box::new(MockTopicManager::admitting(vec![])),
9142        );
9143
9144        let mut sync_config: SyncConfiguration = HashMap::new();
9145        sync_config.insert("tm_custom".to_string(), SyncTarget::Ship);
9146
9147        let engine = Engine::new(
9148            managers,
9149            HashMap::new(),
9150            Box::new(MemoryStorage::new()),
9151            None,
9152            EngineConfig {
9153                sync_configuration: sync_config,
9154                ..Default::default()
9155            },
9156        );
9157
9158        let result = engine.start_gasp_sync().await.unwrap();
9159        let topic_result = &result.topics_synced["tm_custom"];
9160        assert!(
9161            topic_result.peers.is_empty(),
9162            "Without ls_ship, SHIP discovery should return no peers"
9163        );
9164    }
9165
9166    #[tokio::test]
9167    async fn test_start_gasp_sync_mixed_config() {
9168        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
9169        managers.insert(
9170            "tm_peers".to_string(),
9171            Box::new(MockTopicManager::admitting(vec![])),
9172        );
9173        managers.insert(
9174            "tm_disabled".to_string(),
9175            Box::new(MockTopicManager::admitting(vec![])),
9176        );
9177        managers.insert(
9178            "tm_ship_topic".to_string(),
9179            Box::new(MockTopicManager::admitting(vec![])),
9180        );
9181
9182        let mut sync_config: SyncConfiguration = HashMap::new();
9183        sync_config.insert(
9184            "tm_peers".to_string(),
9185            SyncTarget::Peers(vec!["https://peer.com".to_string()]),
9186        );
9187        sync_config.insert("tm_disabled".to_string(), SyncTarget::Disabled);
9188        sync_config.insert("tm_ship_topic".to_string(), SyncTarget::Ship);
9189
9190        let engine = Engine::new(
9191            managers,
9192            HashMap::new(),
9193            Box::new(MemoryStorage::new()),
9194            None,
9195            EngineConfig {
9196                sync_configuration: sync_config,
9197                ..Default::default()
9198            },
9199        );
9200
9201        let result = engine.start_gasp_sync().await.unwrap();
9202        assert_eq!(result.topics_synced.len(), 2);
9203        assert!(result.topics_synced.contains_key("tm_peers"));
9204        assert!(result.topics_synced.contains_key("tm_ship_topic"));
9205        assert!(!result.topics_synced.contains_key("tm_disabled"));
9206
9207        assert_eq!(result.topics_synced["tm_peers"].peers.len(), 1);
9208        assert_eq!(result.topics_synced["tm_peers"].sync_type, "peers");
9209        assert_eq!(result.topics_synced["tm_ship_topic"].sync_type, "ship");
9210    }
9211
9212    #[tokio::test]
9213    async fn test_gasp_sync_result_serialization() {
9214        let mut topics_synced = HashMap::new();
9215        topics_synced.insert(
9216            "tm_test".to_string(),
9217            super::TopicSyncResult {
9218                cost: crate::meter::SyncCost::default(),
9219                root_bounds: Vec::new(),
9220                peers_skipped: Vec::new(),
9221                peers_filtered: Vec::new(),
9222                roots_proven: 0,
9223                roots_unproven: 0,
9224                cursor_held_by: Vec::new(),
9225                expired_refused: 0,
9226                peers: vec!["https://peer.com".to_string()],
9227                sync_type: "ship".to_string(),
9228                errors: vec![],
9229                pruned_inputs: 0,
9230                discarded_graphs: 0,
9231                anchor_walk_held_graphs: 0,
9232                finalized_graphs: 0,
9233                deadline_dropped_graphs: 0,
9234                cursor_moves: Vec::new(),
9235                deferred_graphs: 0,
9236                resumed_graphs: 0,
9237                converged_graphs: 0,
9238                dropped_graphs: Vec::new(),
9239                stalled_graphs: 0,
9240                held_back_graphs: 0,
9241                backed_off_graphs: 0,
9242                waiting_graphs: 0,
9243                idle_counted: 0,
9244                peer_results: HashMap::new(),
9245                dead_graphs: 0,
9246                expired_swept: 0,
9247                memo_folded: 0,
9248                unanchorable_graphs: 0,
9249                unanchorable_cleared: 0,
9250                absent_struck: 0,
9251                route_faults: Vec::new(),
9252                strikes_refused: 0,
9253                faulted_roots: Vec::new(),
9254            },
9255        );
9256
9257        let result = super::GASPSyncResult {
9258            cost: crate::meter::SyncCost::default(),
9259            topics_synced,
9260            filter: crate::sync_load::SyncFilter::default(),
9261            topics_filtered: Vec::new(),
9262        };
9263        let json = serde_json::to_string(&result).unwrap();
9264        let back: super::GASPSyncResult = serde_json::from_str(&json).unwrap();
9265
9266        assert_eq!(back.topics_synced.len(), 1);
9267        assert_eq!(back.topics_synced["tm_test"].peers.len(), 1);
9268        assert_eq!(back.topics_synced["tm_test"].sync_type, "ship");
9269    }
9270
9271    // ── GASP Sync With Factory Tests ─────────────────────────────────
9272
9273    /// Mock GASPRemote for factory tests
9274    struct MockSyncRemote {
9275        utxos: Vec<crate::types::GASPOutput>,
9276    }
9277
9278    #[async_trait(?Send)]
9279    impl crate::gasp::GASPRemote for MockSyncRemote {
9280        async fn get_initial_response(
9281            &self,
9282            request: &crate::types::GASPInitialRequest,
9283        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9284            let utxos: Vec<crate::types::GASPOutput> = self
9285                .utxos
9286                .iter()
9287                .filter(|u| u.score as u64 >= request.since)
9288                .cloned()
9289                .collect();
9290            Ok(crate::types::GASPInitialResponse {
9291                utxo_list: utxos,
9292                since: request.since,
9293            })
9294        }
9295        async fn get_initial_reply(
9296            &self,
9297            _: &crate::types::GASPInitialResponse,
9298        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9299            Ok(crate::types::GASPInitialReply {
9300                utxo_list: Vec::new(),
9301            })
9302        }
9303        async fn request_node(
9304            &self,
9305            graph_id: &str,
9306            txid: &str,
9307            output_index: u32,
9308            _: bool,
9309        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9310            Ok(crate::types::GASPNode {
9311                graph_id: graph_id.to_string(),
9312                raw_tx: format!("rawtx_{txid}"),
9313                output_index,
9314                proof: None,
9315                tx_metadata: None,
9316                output_metadata: None,
9317                inputs: None,
9318            })
9319        }
9320        async fn submit_node(
9321            &self,
9322            _: &crate::types::GASPNode,
9323        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9324            Ok(None)
9325        }
9326    }
9327
9328    /// Mock factory that creates MockSyncRemote instances
9329    struct MockGASPRemoteFactory;
9330
9331    #[derive(Clone)]
9332    struct BudgetPageRemote {
9333        outputs: std::rc::Rc<Vec<crate::types::GASPOutput>>,
9334        nodes: std::rc::Rc<HashMap<String, crate::types::GASPNode>>,
9335        elapsed: std::rc::Rc<Cell<u64>>,
9336        requests: std::rc::Rc<std::cell::RefCell<Vec<crate::types::GASPInitialRequest>>>,
9337        clamp: Option<usize>,
9338        node_cost_ms: u64,
9339        /// One request of this txid costs this much more, once.
9340        slow_once: std::rc::Rc<std::cell::RefCell<Option<(String, u64)>>>,
9341        /// Node requests answered, by txid.
9342        asked: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
9343        /// Txids answered with a transport fault (a 503), not `NodeNotFound`.
9344        faults_for: std::rc::Rc<std::cell::RefCell<std::collections::HashSet<String>>>,
9345    }
9346
9347    #[async_trait(?Send)]
9348    impl crate::gasp::GASPRemote for BudgetPageRemote {
9349        async fn get_initial_response(
9350            &self,
9351            request: &crate::types::GASPInitialRequest,
9352        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
9353            self.requests.borrow_mut().push(request.clone());
9354            let page = self
9355                .outputs
9356                .iter()
9357                .filter(|u| u.score as u64 >= request.since);
9358            let limit = self.clamp.map_or(usize::MAX, |cap| {
9359                cap.min(request.limit.unwrap_or(u64::MAX) as usize)
9360            });
9361            Ok(crate::types::GASPInitialResponse {
9362                utxo_list: page.take(limit).cloned().collect(),
9363                since: request.since,
9364            })
9365        }
9366
9367        async fn get_initial_reply(
9368            &self,
9369            _: &crate::types::GASPInitialResponse,
9370        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
9371            unreachable!()
9372        }
9373
9374        async fn request_node(
9375            &self,
9376            graph_id: &str,
9377            txid: &str,
9378            _: u32,
9379            _: bool,
9380        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
9381            self.asked.borrow_mut().push(txid.to_string());
9382            self.elapsed.set(self.elapsed.get() + self.node_cost_ms);
9383            let slow = {
9384                let mut slow = self.slow_once.borrow_mut();
9385                match slow.as_ref() {
9386                    Some((t, _)) if t == txid => slow.take().map(|(_, ms)| ms),
9387                    _ => None,
9388                }
9389            };
9390            if let Some(ms) = slow {
9391                self.elapsed.set(self.elapsed.get() + ms);
9392            }
9393            if self.faults_for.borrow().contains(txid) {
9394                return Err(crate::gasp::GASPError::RemoteError("HTTP 503".into()));
9395            }
9396            if self.node_cost_ms > 0 {
9397                let mut yielded = false;
9398                std::future::poll_fn(|cx| {
9399                    if yielded {
9400                        std::task::Poll::Ready(())
9401                    } else {
9402                        yielded = true;
9403                        cx.waker().wake_by_ref();
9404                        std::task::Poll::Pending
9405                    }
9406                })
9407                .await;
9408            }
9409            let mut node = self
9410                .nodes
9411                .get(txid)
9412                .cloned()
9413                .ok_or_else(|| crate::gasp::GASPError::NodeNotFound(format!("{txid}.0")))?;
9414            node.graph_id = graph_id.to_string();
9415            Ok(node)
9416        }
9417
9418        async fn submit_node(
9419            &self,
9420            _: &crate::types::GASPNode,
9421        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
9422            unreachable!()
9423        }
9424    }
9425
9426    impl crate::gasp::GASPRemoteFactory for BudgetPageRemote {
9427        fn create_remote(&self, _: &str, _: &str) -> Box<dyn crate::gasp::GASPRemote> {
9428            Box::new(self.clone())
9429        }
9430    }
9431
9432    fn budget_page_engine(count: u32) -> (Engine, BudgetPageRemote) {
9433        let mut engine = make_engine(vec![0]);
9434        let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
9435        let mut outputs = Vec::new();
9436        let mut nodes = HashMap::new();
9437        for n in 1..=count {
9438            let tx = child_of(&format!("{n:064x}"), 0);
9439            let txid = tx.id();
9440            tracker.add_root(n, txid.clone());
9441            outputs.push(crate::types::GASPOutput {
9442                txid: txid.clone(),
9443                output_index: 0,
9444                score: f64::from(n * 10),
9445            });
9446            nodes.insert(
9447                txid.clone(),
9448                crate::types::GASPNode {
9449                    graph_id: String::new(),
9450                    raw_tx: tx.to_hex(),
9451                    output_index: 0,
9452                    proof: Some(single_leaf_bump_hex(&txid, n)),
9453                    tx_metadata: None,
9454                    output_metadata: None,
9455                    inputs: None,
9456                },
9457            );
9458        }
9459        engine.chain_tracker = Some(Box::new(tracker));
9460        engine.config.sync_configuration.insert(
9461            "tm_test".to_string(),
9462            SyncTarget::Peers(vec!["https://budget.example".to_string()]),
9463        );
9464        let remote = BudgetPageRemote {
9465            outputs: std::rc::Rc::new(outputs),
9466            nodes: std::rc::Rc::new(nodes),
9467            elapsed: std::rc::Rc::new(Cell::new(0)),
9468            requests: std::rc::Rc::default(),
9469            clamp: None,
9470            node_cost_ms: 5000,
9471            slow_once: std::rc::Rc::default(),
9472            asked: std::rc::Rc::default(),
9473            faults_for: std::rc::Rc::default(),
9474        };
9475        (engine, remote)
9476    }
9477
9478    fn virtual_peer_budget(engine: &mut Engine, remote: &BudgetPageRemote) {
9479        let elapsed = remote.elapsed.clone();
9480        engine.set_peer_sync_budget(
9481            std::rc::Rc::new(move |ms| {
9482                assert_eq!(ms, 30_000);
9483                let elapsed = elapsed.clone();
9484                let until = elapsed.get() + ms;
9485                Box::pin(std::future::poll_fn(move |_| {
9486                    if elapsed.get() >= until {
9487                        std::task::Poll::Ready(())
9488                    } else {
9489                        std::task::Poll::Pending
9490                    }
9491                }))
9492            }),
9493            30_000,
9494        );
9495    }
9496
9497    #[tokio::test]
9498    async fn sync_budget_large_page_persists_a_finalized_score_and_resumes() {
9499        // This responder ignores the limit: the initiator must safely handle
9500        // a 3,000-output page under a virtual 30-second peer budget.
9501        let (mut engine, remote) = budget_page_engine(3000);
9502        virtual_peer_budget(&mut engine, &remote);
9503        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9504        for round in 1..=2 {
9505            let result = engine.start_gasp_sync().await.unwrap();
9506            let topic = &result.topics_synced["tm_test"];
9507            assert_eq!(topic.finalized_graphs, 5);
9508            assert_eq!(topic.deadline_dropped_graphs, 1);
9509            assert_eq!(topic.cursor_moves.len(), 1);
9510            let expected = round * 50;
9511            assert_eq!(topic.cursor_moves[0].to, expected);
9512            assert_eq!(
9513                engine
9514                    .storage
9515                    .get_last_interaction("https://budget.example", "tm_test")
9516                    .await
9517                    .unwrap(),
9518                expected
9519            );
9520            {
9521                let requests = remote.requests.borrow();
9522                assert_eq!(requests[(round - 1) as usize].since, (round - 1) * 50);
9523            }
9524            for output in remote.outputs.iter().take((round * 5) as usize) {
9525                assert!(engine
9526                    .storage
9527                    .find_output(&output.txid, 0, Some("tm_test"), None, true)
9528                    .await
9529                    .unwrap()
9530                    .is_some());
9531            }
9532            let unfinished = &remote.outputs[(round * 5) as usize];
9533            assert!(engine
9534                .storage
9535                .find_output(&unfinished.txid, 0, Some("tm_test"), None, true)
9536                .await
9537                .unwrap()
9538                .is_none());
9539        }
9540    }
9541
9542    #[tokio::test]
9543    async fn sync_budget_requests_bounded_pages_and_accepts_responder_clamping() {
9544        let (mut engine, mut remote) = budget_page_engine(7);
9545        remote.clamp = Some(3);
9546        remote.node_cost_ms = 0;
9547        virtual_peer_budget(&mut engine, &remote);
9548        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9549        let result = engine.start_gasp_sync().await.unwrap();
9550        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 7);
9551        let requests = remote.requests.borrow();
9552        assert_eq!(requests.len(), 4);
9553        assert!(requests.iter().all(|request| request.limit == Some(100)));
9554        assert_eq!(
9555            requests.iter().map(|r| r.since).collect::<Vec<_>>(),
9556            vec![0, 30, 50, 70]
9557        );
9558    }
9559
9560    #[tokio::test]
9561    async fn sync_budget_unordered_page_keeps_the_completed_score_below_a_gap() {
9562        let (mut engine, mut remote) = budget_page_engine(3000);
9563        std::rc::Rc::make_mut(&mut remote.outputs).swap(0, 9);
9564        virtual_peer_budget(&mut engine, &remote);
9565        engine.set_gasp_remote_factory(Box::new(remote));
9566        let result = engine.start_gasp_sync().await.unwrap();
9567        let topic = &result.topics_synced["tm_test"];
9568        assert_eq!(topic.finalized_graphs, 5);
9569        // Score 100 landed, but score 10 is still ahead in this unordered
9570        // page. The unfinished lower score must hold the cursor at entry.
9571        assert!(topic.cursor_moves.is_empty());
9572    }
9573
9574    #[tokio::test]
9575    async fn sync_budget_tied_scores_do_not_skip_an_unfinished_output() {
9576        let (mut engine, mut remote) = budget_page_engine(3);
9577        std::rc::Rc::make_mut(&mut remote.outputs)[2].score = 20.0;
9578        remote.node_cost_ms = 10_000;
9579        virtual_peer_budget(&mut engine, &remote);
9580        engine.set_gasp_remote_factory(Box::new(remote));
9581        let result = engine.start_gasp_sync().await.unwrap();
9582        let topic = &result.topics_synced["tm_test"];
9583        assert_eq!(topic.finalized_graphs, 2);
9584        assert_eq!(topic.cursor_moves[0].to, 10);
9585    }
9586
9587    #[tokio::test]
9588    async fn sync_budget_failed_graph_holds_the_cursor_below_later_finalizations() {
9589        let (mut engine, mut remote) = budget_page_engine(3000);
9590        let failed = remote.outputs[2].txid.clone();
9591        std::rc::Rc::make_mut(&mut remote.nodes).remove(&failed);
9592        virtual_peer_budget(&mut engine, &remote);
9593        engine.set_gasp_remote_factory(Box::new(remote));
9594        let result = engine.start_gasp_sync().await.unwrap();
9595        let topic = &result.topics_synced["tm_test"];
9596        assert_eq!(topic.finalized_graphs, 4);
9597        assert_eq!(topic.cursor_moves[0].to, 20);
9598    }
9599
9600    struct ProofOnlyRootFetcher {
9601        proofs: HashMap<String, String>,
9602        requested: std::rc::Rc<Cell<u64>>,
9603    }
9604
9605    #[async_trait(?Send)]
9606    impl AncestorFetcher for ProofOnlyRootFetcher {
9607        async fn fetch_ancestor(&self, _: &str) -> Result<FetchedAncestor, GASPError> {
9608            assert!(
9609                self.requested.get() > 0,
9610                "root proof request must precede ancestry"
9611            );
9612            Err(GASPError::RemoteError("raw source unavailable".to_string()))
9613        }
9614        async fn verified_proof_for(&self, txid: &str) -> Option<String> {
9615            self.requested.set(self.requested.get() + 1);
9616            self.proofs.get(txid).cloned()
9617        }
9618    }
9619
9620    #[tokio::test]
9621    async fn copy_sync_leaves_completion_courier_untouched() {
9622        let (mut engine, mut remote) = budget_page_engine(1);
9623        remote.node_cost_ms = 0;
9624        let txid = remote.outputs[0].txid.clone();
9625        let proof = std::rc::Rc::make_mut(&mut remote.nodes)
9626            .get_mut(&txid)
9627            .unwrap()
9628            .proof
9629            .take()
9630            .unwrap();
9631        let completion_asks = std::rc::Rc::new(Cell::new(0));
9632        let sync_asks = std::rc::Rc::new(Cell::new(0));
9633        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9634            proofs: HashMap::new(),
9635            requested: completion_asks.clone(),
9636        }));
9637        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9638            proofs: HashMap::from([(txid.clone(), proof.clone())]),
9639            requested: sync_asks.clone(),
9640        }));
9641        engine.set_gasp_remote_factory(Box::new(remote));
9642        let result = engine.start_gasp_sync().await.unwrap();
9643        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 1);
9644        assert_eq!(result.topics_synced["tm_test"].roots_proven, 1);
9645        assert_eq!(result.topics_synced["tm_test"].roots_unproven, 0);
9646        let stored = engine
9647            .storage()
9648            .find_output(&txid, 0, Some("tm_test"), None, true)
9649            .await
9650            .unwrap()
9651            .unwrap();
9652        assert!(
9653            crate::beef_limits::own_proof(stored.beef.as_ref().unwrap(), &txid)
9654                .unwrap()
9655                .is_some()
9656        );
9657        assert_eq!(completion_asks.get(), 0);
9658        assert_eq!(sync_asks.get(), 1);
9659        let served = engine
9660            .provide_foreign_gasp_node(&format!("{txid}.0"), &txid, 0)
9661            .await
9662            .unwrap();
9663        assert_eq!(served.proof, Some(proof));
9664    }
9665
9666    #[tokio::test]
9667    async fn sync_budget_proofless_root_uses_own_proof_source_without_raw_refetch() {
9668        for proven in [true, false] {
9669            let (mut engine, mut remote) = budget_page_engine(1);
9670            let txid = remote.outputs[0].txid.clone();
9671            let proof = remote.nodes[&txid].proof.clone().unwrap();
9672            std::rc::Rc::make_mut(&mut remote.nodes)
9673                .get_mut(&txid)
9674                .unwrap()
9675                .proof = None;
9676            let requested = std::rc::Rc::new(Cell::new(0));
9677            engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9678                proofs: if proven {
9679                    HashMap::from([(txid.clone(), proof)])
9680                } else {
9681                    HashMap::new()
9682                },
9683                requested: requested.clone(),
9684            }));
9685            virtual_peer_budget(&mut engine, &remote);
9686            engine.set_gasp_remote_factory(Box::new(remote));
9687            let result = engine.start_gasp_sync().await.unwrap();
9688            assert!(requested.get() > 0);
9689            assert_eq!(
9690                result.topics_synced["tm_test"].finalized_graphs,
9691                u64::from(proven)
9692            );
9693            assert_eq!(
9694                engine
9695                    .storage
9696                    .find_output(&txid, 0, Some("tm_test"), None, true)
9697                    .await
9698                    .unwrap()
9699                    .is_some(),
9700                proven
9701            );
9702            if !proven {
9703                assert!(result.topics_synced["tm_test"].cursor_moves.is_empty());
9704            }
9705        }
9706    }
9707
9708    #[tokio::test]
9709    async fn sync_budget_deferred_root_resumes_when_own_proof_becomes_available() {
9710        let (mut engine, mut remote) = budget_page_engine(1);
9711        let txid = remote.outputs[0].txid.clone();
9712        // Engine 0.1.15: the peer is asked for the parent the fetcher cannot
9713        // serve. This witness's subject is a paid walk kept as a deferral,
9714        // so the peer answers that parent with a transport fault (#56).
9715        let parent = bsv_rs::transaction::Transaction::from_hex(&remote.nodes[&txid].raw_tx)
9716            .unwrap()
9717            .inputs[0]
9718            .source_txid
9719            .clone()
9720            .unwrap();
9721        remote.faults_for.borrow_mut().insert(parent);
9722        let proof = remote.nodes[&txid].proof.clone().unwrap();
9723        std::rc::Rc::make_mut(&mut remote.nodes)
9724            .get_mut(&txid)
9725            .unwrap()
9726            .proof = None;
9727        let requested = std::rc::Rc::new(Cell::new(0));
9728        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9729            proofs: HashMap::new(),
9730            requested: requested.clone(),
9731        }));
9732        virtual_peer_budget(&mut engine, &remote);
9733        engine.set_graph_budget(
9734            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9735            5,
9736            5000,
9737        );
9738        engine.set_gasp_remote_factory(Box::new(remote));
9739        let held = engine.start_gasp_sync().await.unwrap();
9740        assert_eq!(held.topics_synced["tm_test"].finalized_graphs, 0);
9741        assert_eq!(held.topics_synced["tm_test"].deferred_graphs, 1);
9742        assert_eq!(held.topics_synced["tm_test"].cursor_moves[0].to, 10);
9743        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
9744            proofs: HashMap::from([(txid.clone(), proof)]),
9745            requested,
9746        }));
9747        engine
9748            .storage()
9749            .update_last_interaction("https://budget.example", "tm_test", 20)
9750            .await
9751            .unwrap();
9752        let landed = engine.start_gasp_sync().await.unwrap();
9753        assert_eq!(landed.topics_synced["tm_test"].resumed_graphs, 1);
9754        assert_eq!(landed.topics_synced["tm_test"].finalized_graphs, 1);
9755        assert!(landed.topics_synced["tm_test"].cursor_moves.is_empty());
9756        assert!(engine
9757            .storage
9758            .find_output(&txid, 0, Some("tm_test"), None, true)
9759            .await
9760            .unwrap()
9761            .is_some());
9762    }
9763
9764    #[tokio::test]
9765    async fn proofs_deadline_stalls_become_durable_after_three_runs() {
9766        let (mut engine, remote) = budget_page_engine(1);
9767        let elapsed = remote.elapsed.clone();
9768        let root = format!("{}.0", remote.outputs[0].txid);
9769        engine.set_graph_budget(
9770            std::rc::Rc::new(move |ms| {
9771                let until = elapsed.get() + ms;
9772                let elapsed = elapsed.clone();
9773                Box::pin(std::future::poll_fn(move |_| {
9774                    if elapsed.get() >= until {
9775                        std::task::Poll::Ready(())
9776                    } else {
9777                        std::task::Poll::Pending
9778                    }
9779                }))
9780            }),
9781            10,
9782            1_000,
9783        );
9784        engine.set_gasp_remote_factory(Box::new(remote));
9785        for round in 1..=crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9786            let result = engine.start_gasp_sync().await.unwrap();
9787            let topic = &result.topics_synced["tm_test"];
9788            if round < crate::gasp::DEADLINE_STALL_DEFERRAL_RUNS {
9789                assert!(topic.cursor_moves.is_empty());
9790                assert_eq!(topic.cursor_held_by, vec![root.clone()]);
9791            } else {
9792                assert_eq!(topic.cursor_moves[0].to, 10);
9793                assert_eq!(topic.deferred_graphs, 1);
9794            }
9795        }
9796        let held = engine
9797            .storage()
9798            .get_deferred_graph("https://budget.example", "tm_test", &root)
9799            .await
9800            .unwrap()
9801            .unwrap();
9802        assert!(held.cursor_passable);
9803        assert!(held.nodes.is_empty());
9804    }
9805
9806    /// 0.1.8 (the cursor): a graph the per-PEER deadline cuts before its
9807    /// root comes back is held at once (`peer_deadline`), the cursor passes
9808    /// it to the page's last finished score, and the next run resumes it
9809    /// ahead of the pages until it converges. On 0.1.7 the cut was a retry
9810    /// for three runs, the cursor stayed at 20 and `cursor_held_by` named it.
9811    #[tokio::test]
9812    async fn cursor_018_the_graph_in_flight_at_the_peer_deadline_is_held_and_passed() {
9813        let (mut engine, remote) = budget_page_engine(6);
9814        let slow = remote.outputs[2].txid.clone();
9815        let root = format!("{slow}.0");
9816        *remote.slow_once.borrow_mut() = Some((slow.clone(), 60_000));
9817        virtual_peer_budget(&mut engine, &remote);
9818        engine.set_graph_budget(
9819            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9820            100,
9821            60_000,
9822        );
9823        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9824        let result = engine.start_gasp_sync().await.unwrap();
9825        let topic = &result.topics_synced["tm_test"];
9826        assert_eq!(topic.finalized_graphs, 2);
9827        assert_eq!(topic.deadline_dropped_graphs, 1);
9828        assert_eq!(topic.deferred_graphs, 1);
9829        assert_eq!(topic.cursor_moves.len(), 1);
9830        assert_eq!(topic.cursor_moves[0].to, 30, "past the held graph");
9831        assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
9832        let held = engine
9833            .storage()
9834            .get_deferred_graph("https://budget.example", "tm_test", &root)
9835            .await
9836            .unwrap()
9837            .unwrap();
9838        assert!(held.cursor_passable);
9839        assert_eq!(held.reason, "peer_deadline");
9840        assert!(held.nodes.is_empty());
9841        assert_eq!(held.score, 30);
9842
9843        let result = engine.start_gasp_sync().await.unwrap();
9844        let topic = &result.topics_synced["tm_test"];
9845        assert_eq!(topic.resumed_graphs, 1);
9846        assert_eq!(topic.converged_graphs, 1);
9847        assert_eq!(topic.finalized_graphs, 4, "the held graph and 40, 50, 60");
9848        assert_eq!(topic.cursor_moves[0].to, 60);
9849        assert!(engine
9850            .storage()
9851            .get_deferred_graph("https://budget.example", "tm_test", &root)
9852            .await
9853            .unwrap()
9854            .is_none());
9855        for output in remote.outputs.iter() {
9856            assert!(engine
9857                .storage
9858                .find_output(&output.txid, 0, Some("tm_test"), None, true)
9859                .await
9860                .unwrap()
9861                .is_some());
9862        }
9863    }
9864
9865    /// Every root's advert expired (the walk refuses each after its node).
9866    struct ExpiredRoots(MockTopicManager);
9867
9868    #[async_trait(?Send)]
9869    impl TopicManagerTrait for ExpiredRoots {
9870        async fn identify_admissible_outputs(
9871            &self,
9872            tx: &Transaction,
9873            previous_coins: &[u8],
9874            off_chain_values: Option<&[u8]>,
9875            mode: SubmitMode,
9876            context: &TopicAdmittanceContext,
9877        ) -> Result<AdmittanceInstructions, TopicManagerError> {
9878            self.0
9879                .identify_admissible_outputs(tx, previous_coins, off_chain_values, mode, context)
9880                .await
9881        }
9882        async fn get_documentation(&self) -> String {
9883            self.0.get_documentation().await
9884        }
9885        async fn get_metadata(&self) -> ServiceMetadata {
9886            self.0.get_metadata().await
9887        }
9888        fn sync_expiry(&self, _: &bsv_rs::script::LockingScript) -> Option<u64> {
9889            Some(1)
9890        }
9891    }
9892
9893    /// A page of dead adverts above one root the peer cannot serve (a gap
9894    /// the cursor holds below), on a shared store.
9895    fn dead_page_engine(count: u32) -> (Engine, BudgetPageRemote, std::rc::Rc<MemoryStorage>) {
9896        let (mut engine, mut remote) = budget_page_engine(count);
9897        let gap = remote.outputs[0].txid.clone();
9898        std::rc::Rc::make_mut(&mut remote.nodes).remove(&gap);
9899        engine.managers.insert(
9900            "tm_test".to_string(),
9901            Box::new(ExpiredRoots(MockTopicManager::admitting(vec![0]))),
9902        );
9903        let store = std::rc::Rc::new(MemoryStorage::new());
9904        engine.storage = Box::new(store.clone());
9905        engine.set_graph_budget(
9906            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
9907            100,
9908            60_000,
9909        );
9910        (engine, remote, store)
9911    }
9912
9913    /// 0.1.8: a sync that only refused expired adverts (the cursor held by
9914    /// a gap below them) did work: a yield, never a step of the yieldless
9915    /// streak. On 0.1.7 it was yieldless.
9916    #[tokio::test]
9917    async fn cursor_018_a_sync_that_only_refuses_the_dead_is_a_yield() {
9918        let (mut engine, mut remote, store) = dead_page_engine(3);
9919        remote.node_cost_ms = 0;
9920        virtual_peer_budget(&mut engine, &remote);
9921        engine.set_gasp_remote_factory(Box::new(remote));
9922        let result = engine.start_gasp_sync().await.unwrap();
9923        let topic = &result.topics_synced["tm_test"];
9924        assert_eq!(topic.expired_refused, 2);
9925        assert_eq!(topic.finalized_graphs, 0);
9926        assert!(topic.cursor_moves.is_empty());
9927        let host = crate::gasp::peer_origin("https://budget.example");
9928        assert_eq!(store.peer_yieldless_syncs(&host, "tm_test"), 0);
9929        let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9930        assert_eq!(health.consecutive_failures, 0);
9931    }
9932
9933    /// 0.1.8: a peer that served every page and every node, whose slice
9934    /// the per-peer deadline ended in each of nine runs in a row while it
9935    /// refused dead adverts (the cursor held by a gap below them), is never
9936    /// quarantined: each run did work. On 0.1.7 each was a failed attempt
9937    /// and the ninth was skipped.
9938    #[tokio::test]
9939    async fn cursor_018_deadline_drops_in_a_row_do_not_quarantine_a_serving_peer() {
9940        let (mut engine, remote, store) = dead_page_engine(200);
9941        virtual_peer_budget(&mut engine, &remote);
9942        engine.set_gasp_remote_factory(Box::new(remote.clone()));
9943        let host = crate::gasp::peer_origin("https://budget.example");
9944        let runs = crate::gasp::PEER_QUARANTINE_THRESHOLD + 1;
9945        let gap = format!("{}.0", remote.outputs[0].txid);
9946        for run in 1..=runs {
9947            // Engine 0.1.15: the gap root is absence (`not_found`), counted
9948            // on runs at least ABSENCE_GAP_SECS apart; these runs follow at
9949            // once, so the gap is passed by hand (#56).
9950            if let Some(mut record) = store
9951                .get_deferred_graph("https://budget.example", "tm_test", &gap)
9952                .await
9953                .unwrap()
9954                .filter(|r| r.reason.starts_with("retry:fault:"))
9955            {
9956                record.retry_at = 0;
9957                store.put_deferred_graph(&record).await.unwrap();
9958            }
9959            let asked = remote.asked.borrow().len();
9960            let result = engine.start_gasp_sync().await.unwrap();
9961            let topic = &result.topics_synced["tm_test"];
9962            assert!(remote.asked.borrow().len() > asked, "run {run}: attempted");
9963            assert_eq!(topic.deadline_dropped_graphs, 1, "run {run}");
9964            assert!(topic.expired_refused > 0, "run {run}: {topic:?}");
9965            // 0.1.9: the gap is a root the peer lists and does not serve
9966            // (`not_found` since 0.1.15, `not_served` before); its third
9967            // faulted run records it unanchorable and the cursor passes it.
9968            if run < u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9969                assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
9970            } else if run == u64::from(crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS) {
9971                assert_eq!(topic.unanchorable_graphs, 1, "run {run}: {topic:?}");
9972                assert!(!topic.cursor_moves.is_empty(), "run {run}: passed");
9973            }
9974            let health = store.get_peer_sync_health(&host, "tm_test").await.unwrap();
9975            assert_eq!(health.consecutive_failures, 0, "run {run}");
9976        }
9977        // A refused root is never fetched again. The one root fetched twice
9978        // per cut is the graph in flight: its answer was lost with the slice
9979        // and its hold is resumed (and refused) by the next run.
9980        let asked = remote.asked.borrow();
9981        let mut dead: Vec<&String> = asked
9982            .iter()
9983            .filter(|t| **t != remote.outputs[0].txid)
9984            .collect();
9985        let fetched = dead.len();
9986        dead.sort();
9987        dead.dedup();
9988        assert_eq!(
9989            fetched - dead.len(),
9990            (runs - 1) as usize,
9991            "one per resumed cut"
9992        );
9993        assert_eq!(store.expired_outpoints("tm_test").len(), dead.len() - 1);
9994    }
9995
9996    /// A courier that serves ONE ancestor, and only once told to (engine
9997    /// 0.1.9): before, every rung "could not serve" it.
9998    struct GatedCourier {
9999        txid: String,
10000        raw_tx: String,
10001        proof: String,
10002        serve: std::rc::Rc<Cell<bool>>,
10003        asked: std::rc::Rc<Cell<u64>>,
10004    }
10005
10006    #[async_trait(?Send)]
10007    impl AncestorFetcher for GatedCourier {
10008        async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10009            self.asked.set(self.asked.get() + 1);
10010            if self.serve.get() && txid == self.txid {
10011                Ok(FetchedAncestor {
10012                    raw_tx: self.raw_tx.clone(),
10013                    proof: Some(self.proof.clone()),
10014                })
10015            } else {
10016                Err(GASPError::RemoteError(format!(
10017                    "no rung served the raw of {txid}"
10018                )))
10019            }
10020        }
10021        async fn verified_proof_for(&self, _: &str) -> Option<String> {
10022            None
10023        }
10024    }
10025
10026    /// The fixture of 0.1.9: a page of three (scores 10, 20, 30) whose
10027    /// lowest root is served UNPROVEN, so its walk needs its parent, which
10028    /// only the courier can serve (a mined parent, proven at height 900).
10029    #[tokio::test]
10030    async fn load_0112_definitive_absence_strikes_a_resumed_obligation() {
10031        let mut fixture = obligation_fixture(1, 1, 0).await;
10032        fixture
10033            .engine
10034            .set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10035                calls: std::rc::Rc::new(Cell::new(0)),
10036                bounded: false,
10037            }));
10038        // Engine 0.1.15 (#56): the rungs' absence counts one run, never a
10039        // strike by itself (it struck at once on 0.1.12 to 0.1.14); three
10040        // runs with a gap strike it. The obligation stays passable.
10041        let host = "https://budget.example";
10042        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10043        for run in 1..=runs {
10044            let result = fixture.engine.start_gasp_sync().await.unwrap();
10045            let topic = &result.topics_synced["tm_test"];
10046            let record = fixture
10047                .store
10048                .get_deferred_graph(host, "tm_test", &fixture.obligations[0])
10049                .await
10050                .unwrap()
10051                .unwrap();
10052            assert_eq!(record.faults, run);
10053            assert!(record.cursor_passable);
10054            if run == 1 {
10055                assert_eq!(topic.finalized_graphs, 1);
10056            }
10057            if run < runs {
10058                assert_eq!(topic.unanchorable_graphs, 0);
10059                assert_eq!(record.reason, "retry:fault:not_found");
10060                let mut record = record;
10061                record.retry_at = 0;
10062                fixture.store.put_deferred_graph(&record).await.unwrap();
10063            } else {
10064                assert_eq!(topic.unanchorable_graphs, 1);
10065                assert_eq!(record.reason, "unanchorable:not_found");
10066            }
10067        }
10068    }
10069
10070    #[tokio::test]
10071    async fn load_0112_root_time_is_clamped_and_named() {
10072        let (mut engine, mut remote) = budget_page_engine(1);
10073        remote.node_cost_ms = 16_000;
10074        let elapsed = remote.elapsed.clone();
10075        let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10076            let clock = elapsed.clone();
10077            let due = clock.get() + ms;
10078            Box::pin(std::future::poll_fn(move |_| {
10079                if clock.get() >= due {
10080                    std::task::Poll::Ready(())
10081                } else {
10082                    std::task::Poll::Pending
10083                }
10084            }))
10085        });
10086        engine.set_peer_sync_budget(sleep.clone(), 30_000);
10087        engine.set_graph_budget(sleep, 100, 60_000);
10088        engine.set_gasp_remote_factory(Box::new(remote));
10089        let result = engine.start_gasp_sync().await.unwrap();
10090        let topic = &result.topics_synced["tm_test"];
10091        assert_eq!(topic.finalized_graphs, 0);
10092        assert_eq!(topic.root_bounds[0].reason, "time");
10093        assert!(!topic.cursor_held_by.is_empty() || topic.deferred_graphs > 0);
10094    }
10095
10096    #[tokio::test]
10097    async fn load_0112_storage_counts_are_attempts_at_the_boundary() {
10098        let engine = make_engine(vec![0]);
10099        let meter = crate::meter::LoadMeter::default();
10100        meter
10101            .scope(async {
10102                assert_eq!(
10103                    engine
10104                        .storage
10105                        .get_last_interaction("peer", "topic")
10106                        .await
10107                        .unwrap(),
10108                    0
10109                );
10110                engine
10111                    .storage
10112                    .update_last_interaction("peer", "topic", 12)
10113                    .await
10114                    .unwrap();
10115                assert_eq!(
10116                    engine
10117                        .storage
10118                        .get_last_interaction("peer", "topic")
10119                        .await
10120                        .unwrap(),
10121                    12
10122                );
10123            })
10124            .await;
10125        assert_eq!(
10126            (
10127                meter.snapshot().storage_reads,
10128                meter.snapshot().storage_writes
10129            ),
10130            (2, 1)
10131        );
10132        engine
10133            .storage
10134            .get_last_interaction("peer", "topic")
10135            .await
10136            .unwrap();
10137        assert_eq!(meter.snapshot().storage_reads, 2);
10138    }
10139
10140    struct LoadCourier {
10141        calls: std::rc::Rc<Cell<u64>>,
10142        bounded: bool,
10143    }
10144    #[async_trait(?Send)]
10145    impl crate::gasp::AncestorFetcher for LoadCourier {
10146        async fn fetch_ancestor(
10147            &self,
10148            txid: &str,
10149        ) -> Result<crate::gasp::FetchedAncestor, crate::gasp::GASPError> {
10150            self.calls.set(self.calls.get() + 1);
10151            if self.bounded {
10152                for _ in 0..13 {
10153                    crate::meter::courier_request()
10154                        .map_err(|e| crate::gasp::GASPError::Other(e.into()))?;
10155                }
10156                Err(crate::gasp::GASPError::RemoteError(
10157                    "still missing a proof".into(),
10158                ))
10159            } else {
10160                for _ in 0..3 {
10161                    crate::meter::courier_request().unwrap();
10162                }
10163                Err(crate::gasp::GASPError::DefinitiveNotFound(txid.into()))
10164            }
10165        }
10166        async fn verified_proof_for(&self, _: &str) -> Option<String> {
10167            None
10168        }
10169    }
10170
10171    #[tokio::test]
10172    async fn load_0112_definitive_absence_is_shared_across_peers_and_topics() {
10173        let UnanchorableFixture {
10174            mut engine,
10175            remote,
10176            root,
10177            ..
10178        } = unanchorable_fixture();
10179        let calls = std::rc::Rc::new(Cell::new(0));
10180        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10181            calls: calls.clone(),
10182            bounded: false,
10183        }));
10184        engine.managers.insert(
10185            "tm_other".into(),
10186            Box::new(MockTopicManager::admitting(vec![0])),
10187        );
10188        for topic in ["tm_test", "tm_other"] {
10189            engine.config.sync_configuration.insert(
10190                topic.into(),
10191                SyncTarget::Peers(vec!["https://a.example".into(), "https://b.example".into()]),
10192            );
10193        }
10194        let result = engine.start_gasp_sync().await.unwrap();
10195        assert_eq!(calls.get(), 1, "one ladder per missing ancestor per run");
10196        assert_eq!(result.cost.courier_not_found_memo_hits, 3);
10197        assert_eq!(result.cost.courier_rungs, 3);
10198        for (topic, res) in &result.topics_synced {
10199            // Engine 0.1.15 (#56): the rungs' absence counts one run on each
10200            // (peer, topic), not a strike (2 unanchorable on 0.1.12 to 0.1.14).
10201            assert_eq!(res.unanchorable_graphs, 0, "{res:?}");
10202            assert_eq!(
10203                res.cost.courier_rungs + res.cost.peer_requests,
10204                res.cost.calls_out
10205            );
10206            for host in ["https://a.example", "https://b.example"] {
10207                let mut record = engine
10208                    .storage
10209                    .get_deferred_graph(host, topic, &root)
10210                    .await
10211                    .unwrap()
10212                    .unwrap();
10213                assert_eq!(record.reason, "retry:fault:not_found");
10214                assert_eq!(record.faults, 1);
10215                assert!(!record.cursor_passable);
10216                record.retry_at = 0;
10217                engine.storage.put_deferred_graph(&record).await.unwrap();
10218            }
10219        }
10220        assert!(!remote.asked.borrow().is_empty());
10221        engine.start_gasp_sync().await.unwrap();
10222        assert_eq!(calls.get(), 2, "another run can retry absence");
10223    }
10224
10225    #[tokio::test]
10226    async fn load_0112_courier_bound_defers_the_root_and_names_it() {
10227        let UnanchorableFixture {
10228            mut engine, root, ..
10229        } = unanchorable_fixture();
10230        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10231            calls: std::rc::Rc::new(Cell::new(0)),
10232            bounded: true,
10233        }));
10234        let result = engine.start_gasp_sync().await.unwrap();
10235        let record = engine
10236            .storage
10237            .get_deferred_graph("https://budget.example", "tm_test", &root)
10238            .await
10239            .unwrap()
10240            .expect("bounded root must be retained");
10241        assert_eq!(record.reason, "courier_rungs");
10242        assert!(!record.nodes.is_empty());
10243        assert_eq!(result.cost.courier_rungs, 12);
10244        assert_eq!(
10245            result.topics_synced["tm_test"].root_bounds[0].reason,
10246            "courier_rungs"
10247        );
10248        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 2);
10249    }
10250
10251    #[tokio::test]
10252    async fn load_0112_refused_save_does_not_restart_a_bounded_root() {
10253        let UnanchorableFixture {
10254            mut engine,
10255            store,
10256            root,
10257            ..
10258        } = unanchorable_fixture();
10259        store.set_deferred_graph_ceiling(Some(0));
10260        let calls = std::rc::Rc::new(Cell::new(0));
10261        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(LoadCourier {
10262            calls: calls.clone(),
10263            bounded: true,
10264        }));
10265        let result = engine.start_gasp_sync().await.unwrap();
10266        let topic = &result.topics_synced["tm_test"];
10267        assert_eq!(calls.get(), 1, "no retry after a hard root cut");
10268        assert_eq!(topic.root_bounds.len(), 1);
10269        assert_eq!(result.cost.courier_rungs, 12);
10270        assert!(topic.cursor_held_by.contains(&root));
10271        assert_eq!(
10272            topic.strikes_refused, 0,
10273            "a budget cut is not a fault strike"
10274        );
10275        assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10276        assert_eq!(topic.finalized_graphs, 2);
10277    }
10278
10279    #[tokio::test]
10280    async fn load_0112_refused_save_does_not_extend_root_time() {
10281        let (mut engine, mut remote) = budget_page_engine(1);
10282        remote.node_cost_ms = 16_000;
10283        let elapsed = remote.elapsed.clone();
10284        let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10285            let clock = elapsed.clone();
10286            let due = clock.get() + ms;
10287            Box::pin(std::future::poll_fn(move |_| {
10288                if clock.get() >= due {
10289                    std::task::Poll::Ready(())
10290                } else {
10291                    std::task::Poll::Pending
10292                }
10293            }))
10294        });
10295        let store = std::rc::Rc::new(MemoryStorage::new());
10296        store.set_deferred_graph_ceiling(Some(0));
10297        engine.storage = Box::new(store);
10298        engine.set_peer_sync_budget(sleep.clone(), 30_000);
10299        engine.set_graph_budget(sleep, 100, 60_000);
10300        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10301        let result = engine.start_gasp_sync().await.unwrap();
10302        let topic = &result.topics_synced["tm_test"];
10303        assert_eq!(
10304            remote.asked.borrow().len(),
10305            1,
10306            "no request after the time cut"
10307        );
10308        assert_eq!(topic.root_bounds.len(), 1);
10309        assert_eq!(topic.root_bounds[0].reason, "time");
10310        assert_eq!(topic.finalized_graphs, 0);
10311        assert!(!topic.cursor_held_by.is_empty());
10312    }
10313
10314    #[tokio::test]
10315    async fn load_0112_call_limit_fallback_keeps_the_root_deadline() {
10316        let UnanchorableFixture {
10317            mut engine,
10318            mut remote,
10319            store,
10320            root,
10321            serve,
10322            ..
10323        } = unanchorable_fixture();
10324        store.set_deferred_graph_ceiling(Some(0));
10325        serve.set(true);
10326        remote.node_cost_ms = 8_000;
10327        struct DelayedCourier {
10328            inner: std::rc::Rc<dyn AncestorFetcher>,
10329            clock: std::rc::Rc<Cell<u64>>,
10330        }
10331        #[async_trait(?Send)]
10332        impl AncestorFetcher for DelayedCourier {
10333            async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10334                self.clock.set(self.clock.get() + 8_000);
10335                tokio::task::yield_now().await;
10336                self.inner.fetch_ancestor(txid).await
10337            }
10338            async fn verified_proof_for(&self, txid: &str) -> Option<String> {
10339                self.inner.verified_proof_for(txid).await
10340            }
10341        }
10342        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(DelayedCourier {
10343            inner: engine.gasp_ancestor_fetcher.as_ref().unwrap().clone(),
10344            clock: remote.elapsed.clone(),
10345        }));
10346        let elapsed = remote.elapsed.clone();
10347        let sleep: SleepFactory = std::rc::Rc::new(move |ms| {
10348            let clock = elapsed.clone();
10349            let due = clock.get() + ms;
10350            Box::pin(std::future::poll_fn(move |_| {
10351                if clock.get() >= due {
10352                    std::task::Poll::Ready(())
10353                } else {
10354                    std::task::Poll::Pending
10355                }
10356            }))
10357        });
10358        engine.set_peer_sync_budget(sleep.clone(), 30_000);
10359        engine.set_graph_budget(sleep, 1, 60_000);
10360        engine.set_gasp_remote_factory(Box::new(remote));
10361        let result = engine.start_gasp_sync().await.unwrap();
10362        let topic = &result.topics_synced["tm_test"];
10363        assert!(
10364            topic
10365                .root_bounds
10366                .iter()
10367                .any(|b| b.outpoint == root && b.reason == "time"),
10368            "{topic:?}"
10369        );
10370        assert!(topic.cursor_held_by.contains(&root));
10371        assert!(topic.dropped_graphs.iter().any(|g| g.reason == "too_many"));
10372    }
10373
10374    #[tokio::test]
10375    async fn load_0112_cost_matches_the_remote_call_log() {
10376        let (mut engine, remote) = budget_page_engine(2);
10377        remote.elapsed.set(0);
10378        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10379        let result = engine.start_gasp_sync().await.unwrap();
10380        let actual_calls = (remote.requests.borrow().len() + remote.asked.borrow().len()) as u64;
10381        assert_eq!(result.cost.peer_requests, actual_calls);
10382        assert_eq!(result.cost.calls_out, actual_calls);
10383        assert!(result.cost.storage_reads > 0);
10384        assert!(result.cost.storage_writes > 0);
10385        assert_eq!(
10386            result.cost.storage_reads,
10387            result.topics_synced["tm_test"].cost.storage_reads
10388        );
10389        assert_eq!(
10390            result.cost.storage_writes,
10391            result.topics_synced["tm_test"].cost.storage_writes
10392        );
10393    }
10394
10395    struct UnanchorableFixture {
10396        engine: Engine,
10397        remote: BudgetPageRemote,
10398        store: std::rc::Rc<MemoryStorage>,
10399        root: String,
10400        serve: std::rc::Rc<Cell<bool>>,
10401        asked: std::rc::Rc<Cell<u64>>,
10402    }
10403
10404    fn unanchorable_fixture() -> UnanchorableFixture {
10405        let (mut engine, mut remote) = budget_page_engine(3);
10406        remote.node_cost_ms = 0;
10407        let parent = child_of(&format!("{:064x}", 0xab), 0);
10408        let child = child_of(&parent.id(), 0);
10409        let mut tracker = bsv_rs::transaction::MockChainTracker::new(1000);
10410        for n in 1..=3u32 {
10411            tracker.add_root(n, child_of(&format!("{n:064x}"), 0).id());
10412        }
10413        tracker.add_root(900, parent.id());
10414        engine.chain_tracker = Some(Box::new(tracker));
10415        let replaced = std::mem::replace(
10416            &mut std::rc::Rc::make_mut(&mut remote.outputs)[0].txid,
10417            child.id(),
10418        );
10419        let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10420        nodes.remove(&replaced);
10421        nodes.insert(
10422            child.id(),
10423            crate::types::GASPNode {
10424                graph_id: String::new(),
10425                raw_tx: child.to_hex(),
10426                output_index: 0,
10427                proof: None,
10428                tx_metadata: None,
10429                output_metadata: None,
10430                inputs: None,
10431            },
10432        );
10433        // Engine 0.1.15: the peer is asked for an input the courier cannot
10434        // serve. This fixture's subject is the courier's fault, so its peer
10435        // answers that input with a transport fault, which counts nothing
10436        // (#56); the absence witnesses clear it to hear the peer's word.
10437        remote.faults_for.borrow_mut().insert(parent.id());
10438        let serve = std::rc::Rc::new(Cell::new(false));
10439        let asked = std::rc::Rc::new(Cell::new(0));
10440        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(GatedCourier {
10441            txid: parent.id(),
10442            raw_tx: parent.to_hex(),
10443            proof: single_leaf_bump_hex(&parent.id(), 900),
10444            serve: serve.clone(),
10445            asked: asked.clone(),
10446        }));
10447        let store = std::rc::Rc::new(MemoryStorage::new());
10448        engine.storage = Box::new(store.clone());
10449        virtual_peer_budget(&mut engine, &remote);
10450        engine.set_graph_budget(
10451            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10452            100,
10453            60_000,
10454        );
10455        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10456        UnanchorableFixture {
10457            engine,
10458            remote,
10459            store,
10460            root: format!("{}.0", child.id()),
10461            serve,
10462            asked,
10463        }
10464    }
10465
10466    /// 0.1.9 (unanchorable): a root whose ancestor the courier cannot serve
10467    /// faults three runs in a row; each run names it with its class
10468    /// (`courier`) and holds the cursor below it until the third, which
10469    /// records it `unanchorable:courier` and passes the cursor to the page's
10470    /// last finished score. Inside its backoff it costs no request. A later
10471    /// run in which the courier serves the ancestor admits it and clears the
10472    /// record. On 0.1.8 the cursor stayed at 0 on every run (fact 2: the
10473    /// keeper's hand).
10474    #[tokio::test]
10475    async fn unanchorable_019_a_root_faulted_three_runs_is_recorded_passed_and_admitted_later() {
10476        let UnanchorableFixture {
10477            engine,
10478            remote,
10479            store,
10480            root,
10481            serve,
10482            asked,
10483        } = unanchorable_fixture();
10484        let host = "https://budget.example";
10485        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10486        for run in 1..=runs {
10487            let result = engine.start_gasp_sync().await.unwrap();
10488            let topic = &result.topics_synced["tm_test"];
10489            assert_eq!(topic.faulted_roots.len(), 1, "run {run}: {topic:?}");
10490            let named = &topic.faulted_roots[0];
10491            assert_eq!(
10492                (named.peer.as_str(), named.outpoint.as_str()),
10493                (host, root.as_str())
10494            );
10495            assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10496            assert_eq!(named.faults, run, "run {run}");
10497            assert!(named.error.contains("no rung served"), "{named:?}");
10498            let record = engine
10499                .storage()
10500                .get_deferred_graph(host, "tm_test", &root)
10501                .await
10502                .unwrap()
10503                .unwrap();
10504            assert_eq!(record.faults, run);
10505            assert!(record.nodes.is_empty(), "the root alone, asked again");
10506            if run < runs {
10507                assert!(!named.unanchorable);
10508                assert_eq!(topic.unanchorable_graphs, 0);
10509                assert_eq!(record.reason, "retry:fault:courier");
10510                assert!(!record.cursor_passable);
10511                assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10512                assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10513            } else {
10514                assert!(named.unanchorable);
10515                assert_eq!(topic.unanchorable_graphs, 1);
10516                assert_eq!(record.reason, "unanchorable:courier");
10517                assert!(record.cursor_passable);
10518                assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
10519                assert_eq!(topic.cursor_moves.len(), 1);
10520                assert_eq!(
10521                    topic.cursor_moves[0].to, 30,
10522                    "the page's last finished score"
10523                );
10524                assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10525            }
10526            if run == 1 {
10527                assert_eq!(topic.finalized_graphs, 2, "20 and 30");
10528            }
10529        }
10530
10531        // Inside the backoff: the root is not asked of the peer or the
10532        // courier, nothing is named, and the quiet run is no attempt.
10533        let (courier_asks, node_asks) = (asked.get(), remote.asked.borrow().len());
10534        let streak = store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test");
10535        let result = engine.start_gasp_sync().await.unwrap();
10536        let topic = &result.topics_synced["tm_test"];
10537        assert!(topic.faulted_roots.is_empty(), "{topic:?}");
10538        assert!(topic.cursor_held_by.is_empty());
10539        assert_eq!(topic.unanchorable_graphs, 0);
10540        assert_eq!(asked.get(), courier_asks);
10541        assert_eq!(remote.asked.borrow().len(), node_asks);
10542        assert_eq!(
10543            store.peer_yieldless_syncs(&crate::gasp::peer_origin(host), "tm_test"),
10544            streak
10545        );
10546
10547        // The backoff runs out and the courier serves the ancestor: the
10548        // root is admitted as any root and its record is cleared.
10549        let mut record = engine
10550            .storage()
10551            .get_deferred_graph(host, "tm_test", &root)
10552            .await
10553            .unwrap()
10554            .unwrap();
10555        record.retry_at = 1;
10556        engine.storage().put_deferred_graph(&record).await.unwrap();
10557        serve.set(true);
10558        let result = engine.start_gasp_sync().await.unwrap();
10559        let topic = &result.topics_synced["tm_test"];
10560        assert_eq!(topic.finalized_graphs, 1, "{topic:?}");
10561        assert_eq!(topic.unanchorable_cleared, 1);
10562        assert!(topic.faulted_roots.is_empty());
10563        assert!(engine
10564            .storage()
10565            .get_deferred_graph(host, "tm_test", &root)
10566            .await
10567            .unwrap()
10568            .is_none());
10569        let (txid, _) = crate::gasp::parse_outpoint(&root).unwrap();
10570        assert!(engine
10571            .storage
10572            .find_output(&txid, 0, Some("tm_test"), None, true)
10573            .await
10574            .unwrap()
10575            .is_some());
10576    }
10577
10578    /// 0.1.9: only the run's LOWEST faulted root starts a fault record (the
10579    /// delta fold's D-M1: a peer that faults every node earns one record a
10580    /// run, not one per root); the others are named, class and all, and
10581    /// stay gaps above it.
10582    #[tokio::test]
10583    async fn unanchorable_019_only_the_lowest_faulted_root_is_recorded_and_all_are_named() {
10584        let (mut engine, mut remote) = budget_page_engine(4);
10585        remote.node_cost_ms = 0;
10586        // Engine 0.1.15 (#56): a root the peer does not hold is absence,
10587        // which counts on every root of a run (the 0.1.12 rule of the
10588        // rungs' absence, now the peers'). This witness's subject is D-M1
10589        // for the other striking classes, so its two faulting roots are
10590        // served unproven over a parent the courier cannot serve and the
10591        // peer answers with a transport fault (`courier`); on 0.1.9 to
10592        // 0.1.14 the peer did not hold them (`not_served`).
10593        for idx in [1usize, 3] {
10594            let parent = child_of(&format!("{:064x}", 0xdead_0000 + idx), 0);
10595            let child = child_of(&parent.id(), 0);
10596            let old = std::mem::replace(
10597                &mut std::rc::Rc::make_mut(&mut remote.outputs)[idx].txid,
10598                child.id(),
10599            );
10600            let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
10601            nodes.remove(&old);
10602            nodes.insert(
10603                child.id(),
10604                crate::types::GASPNode {
10605                    graph_id: String::new(),
10606                    raw_tx: child.to_hex(),
10607                    output_index: 0,
10608                    proof: None,
10609                    tx_metadata: None,
10610                    output_metadata: None,
10611                    inputs: None,
10612                },
10613            );
10614            remote.faults_for.borrow_mut().insert(parent.id());
10615        }
10616        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
10617            asked: std::rc::Rc::default(),
10618            transient: std::rc::Rc::default(),
10619        }));
10620        let store = std::rc::Rc::new(MemoryStorage::new());
10621        engine.storage = Box::new(store.clone());
10622        virtual_peer_budget(&mut engine, &remote);
10623        engine.set_graph_budget(
10624            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10625            100,
10626            60_000,
10627        );
10628        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10629        let result = engine.start_gasp_sync().await.unwrap();
10630        let topic = &result.topics_synced["tm_test"];
10631        let named: Vec<(&str, &str, u32)> = topic
10632            .faulted_roots
10633            .iter()
10634            .map(|f| (f.outpoint.as_str(), f.class.as_str(), f.faults))
10635            .collect();
10636        let low = format!("{}.0", remote.outputs[1].txid);
10637        let high = format!("{}.0", remote.outputs[3].txid);
10638        assert_eq!(
10639            named,
10640            vec![
10641                (low.as_str(), crate::gasp::fault_class::COURIER, 1),
10642                (high.as_str(), crate::gasp::fault_class::COURIER, 0),
10643            ]
10644        );
10645        assert_eq!(topic.cursor_moves[0].to, 10, "below the lower gap");
10646        let host = "https://budget.example";
10647        assert!(store
10648            .get_deferred_graph(host, "tm_test", &high)
10649            .await
10650            .unwrap()
10651            .is_none());
10652        assert_eq!(
10653            store
10654                .get_deferred_graph(host, "tm_test", &low)
10655                .await
10656                .unwrap()
10657                .unwrap()
10658                .reason,
10659            "retry:fault:courier"
10660        );
10661    }
10662
10663    /// A record of the budget page's peer and topic, holding nothing but
10664    /// its root (engine 0.1.10 tests).
10665    fn bare_record(n: u32, score: u64, reason: &str, passable: bool) -> crate::gasp::DeferredGraph {
10666        let outpoint = format!("{:064x}.0", 0xdead_0000 + n);
10667        crate::gasp::DeferredGraph {
10668            peer: "https://budget.example".into(),
10669            topic: "tm_test".into(),
10670            outpoint: outpoint.clone(),
10671            score,
10672            nodes: Vec::new(),
10673            pending: vec![crate::gasp::PendingInput {
10674                outpoint: outpoint.clone(),
10675                graph_id: outpoint,
10676                metadata: true,
10677                spent_by: None,
10678                parent_proven: false,
10679            }],
10680            calls: 0,
10681            passes: 0,
10682            reason: reason.into(),
10683            configured: true,
10684            idle_faults: 0,
10685            cursor_passable: passable,
10686            faults: 0,
10687            retry_at: 0,
10688            idle_resumes: 0,
10689            unanchorable_retries: 0,
10690            expiry: None,
10691        }
10692    }
10693
10694    /// Fill the budget page's (peer, topic) with `count` root retry
10695    /// obligations the peer does not serve (fact 3 of the 0.1.10 brief:
10696    /// `held:retry`, nodes 0, below the cursor), returning their roots.
10697    async fn hold_obligations(engine: &Engine, count: usize) -> Vec<String> {
10698        let mut roots = Vec::new();
10699        for n in 0..count {
10700            let record = bare_record(n as u32, 1 + n as u64, "retry", true);
10701            roots.push(record.outpoint.clone());
10702            engine.storage().put_deferred_graph(&record).await.unwrap();
10703        }
10704        roots
10705    }
10706
10707    /// 0.1.10 (room): a (peer, topic) whose deferred slots are all held by
10708    /// root retry obligations that never progress still records the root
10709    /// that holds its cursor: a fault record is bounded on its own
10710    /// (the separate compact memo), so the strike is written on each
10711    /// faulted run and the third passes the cursor. On 0.1.9 every strike
10712    /// found "no room" (`gasp.rs:1717`), the root was named with no run and
10713    /// the cursor stayed at 0 for ever (fact 2: the keeper's eighth hand).
10714    #[tokio::test]
10715    async fn room_0110_a_full_store_still_records_the_cursors_holder_and_passes_it() {
10716        let UnanchorableFixture {
10717            engine,
10718            store,
10719            root,
10720            ..
10721        } = unanchorable_fixture();
10722        let host = "https://budget.example";
10723        let obligations =
10724            hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10725        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
10726        for run in 1..=runs {
10727            let result = engine.start_gasp_sync().await.unwrap();
10728            let topic = &result.topics_synced["tm_test"];
10729            assert_eq!(topic.strikes_refused, 0, "run {run}: {topic:?}");
10730            let named = topic
10731                .faulted_roots
10732                .iter()
10733                .find(|f| f.outpoint == root)
10734                .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
10735            assert_eq!(named.class, crate::gasp::fault_class::COURIER);
10736            assert_eq!(named.faults, run, "run {run}: the strike was written");
10737            assert!(named.refused.is_empty(), "{named:?}");
10738            if run < runs {
10739                assert!(topic.cursor_moves.is_empty(), "run {run}: the gap holds");
10740                assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10741            } else {
10742                assert!(named.unanchorable);
10743                assert_eq!(topic.unanchorable_graphs, 1);
10744                assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10745                assert_eq!(topic.cursor_moves[0].to, 30, "passed");
10746                assert!(topic.cursor_held_by.is_empty(), "{topic:?}");
10747            }
10748        }
10749        // The obligations are all still held beside the fault record.
10750        let held: Vec<String> = store
10751            .deferred_graphs()
10752            .into_iter()
10753            .map(|r| r.outpoint)
10754            .collect();
10755        // 0.1.14: the fault fact no longer consumes a partial-graph row.
10756        assert_eq!(held.len(), obligations.len(), "{held:?}");
10757        assert_eq!(store.fault_memos().len(), 1);
10758        assert!(obligations.iter().all(|o| held.contains(o)));
10759        let record = store
10760            .get_deferred_graph(host, "tm_test", &root)
10761            .await
10762            .unwrap()
10763            .unwrap();
10764        assert_eq!(record.reason, "unanchorable:courier");
10765    }
10766
10767    /// 0.1.10 (the reason, readable): a strike the bound or the storage
10768    /// refuses is counted (`strikes_refused`) and named on the root
10769    /// (`refused`): `no_room` at the storage's own ceiling, `store_fault`
10770    /// on its fault. The compact memo replaces the old 32-fact limit.
10771    /// The root stays a gap the cursor holds below, as on 0.1.9, where the
10772    /// refusal was a `warn!` alone.
10773    #[tokio::test]
10774    async fn room_0110_a_refused_strike_is_counted_and_named() {
10775        let UnanchorableFixture {
10776            engine,
10777            store,
10778            root,
10779            ..
10780        } = unanchorable_fixture();
10781        // 0.1.14: only an actual storage ceiling refuses a compact fact.
10782        store.set_fault_memo_ceiling(Some(0));
10783        let refused_as = |result: &super::GASPSyncResult, why: &str| {
10784            let topic = &result.topics_synced["tm_test"];
10785            assert_eq!(topic.strikes_refused, 1, "{topic:?}");
10786            let named = topic
10787                .faulted_roots
10788                .iter()
10789                .find(|f| f.outpoint == root)
10790                .unwrap_or_else(|| panic!("{topic:?}"));
10791            assert_eq!(named.refused, why, "{named:?}");
10792            assert_eq!(named.faults, 0, "no record holds the count");
10793            assert!(topic.cursor_moves.is_empty(), "the gap holds");
10794            assert_eq!(topic.cursor_held_by, vec![root.clone()]);
10795        };
10796        let result = engine.start_gasp_sync().await.unwrap();
10797        refused_as(&result, crate::gasp::strike_refused::NO_ROOM);
10798        assert!(store
10799            .get_deferred_graph("https://budget.example", "tm_test", &root)
10800            .await
10801            .unwrap()
10802            .is_none());
10803
10804        // The storage faults.
10805        store.set_fault_memo_ceiling(None);
10806        store.set_deferred_graph_put_fault(true);
10807        let result = engine.start_gasp_sync().await.unwrap();
10808        refused_as(&result, crate::gasp::strike_refused::STORE_FAULT);
10809
10810        // The storage serves again: the strike is written.
10811        store.set_deferred_graph_put_fault(false);
10812        let result = engine.start_gasp_sync().await.unwrap();
10813        let topic = &result.topics_synced["tm_test"];
10814        assert_eq!(topic.strikes_refused, 0, "{topic:?}");
10815        assert!(store
10816            .get_deferred_graph("https://budget.example", "tm_test", &root)
10817            .await
10818            .unwrap()
10819            .is_some());
10820    }
10821
10822    /// 0.1.10 (the churn bounded): sixteen root retry obligations the peer
10823    /// no longer serves (fact 3: `held:retry`, nodes 0) took every run's
10824    /// slice on 0.1.9, resumed ahead of the page, appending nothing, so the
10825    /// page's fresh roots were never walked. Each now goes to the long
10826    /// backoff after `IDLE_RESUMES_BEFORE_BACKOFF` idle resumes, is asked
10827    /// nothing while it waits, and stays held; the page is walked.
10828    #[tokio::test]
10829    async fn room_0110_idle_obligations_back_off_and_the_page_is_walked() {
10830        let (mut engine, mut remote) = budget_page_engine(3);
10831        remote.node_cost_ms = 2000;
10832        let store = std::rc::Rc::new(MemoryStorage::new());
10833        engine.storage = Box::new(store.clone());
10834        virtual_peer_budget(&mut engine, &remote);
10835        engine.set_graph_budget(
10836            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10837            100,
10838            60_000,
10839        );
10840        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10841        let obligations =
10842            hold_obligations(&engine, crate::gasp::DEFERRED_GRAPHS_PER_PEER_TOPIC).await;
10843        let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10844        // The witness of 0.1.9: each run's slice goes to the obligations
10845        // (15 resumed, nothing appended, the last of them cut in flight by
10846        // the per-peer deadline) and the page is never reached.
10847        for run in 1..=idle {
10848            let result = engine.start_gasp_sync().await.unwrap();
10849            let topic = &result.topics_synced["tm_test"];
10850            assert_eq!(topic.finalized_graphs, 0, "run {run}: {topic:?}");
10851            assert!(topic.cursor_moves.is_empty(), "run {run}");
10852            assert_eq!(topic.resumed_graphs, 15, "run {run}");
10853            assert_eq!(topic.waiting_graphs, 0, "run {run}");
10854            let expect = if run == idle { 15 } else { 0 };
10855            assert_eq!(topic.backed_off_graphs, expect, "run {run}: {topic:?}");
10856        }
10857        // The fifteen wait; the slice reaches the page.
10858        let result = engine.start_gasp_sync().await.unwrap();
10859        let topic = &result.topics_synced["tm_test"];
10860        assert_eq!(topic.waiting_graphs, 15, "{topic:?}");
10861        assert_eq!(topic.finalized_graphs, 3, "the page's fresh roots");
10862        assert_eq!(topic.cursor_moves.len(), 1, "{topic:?}");
10863        assert_eq!(topic.cursor_moves[0].to, 30);
10864        // The last one follows; then every obligation waits, asked nothing.
10865        let mut backed_off = 15;
10866        for _ in 0..idle {
10867            let result = engine.start_gasp_sync().await.unwrap();
10868            backed_off += result.topics_synced["tm_test"].backed_off_graphs;
10869        }
10870        assert_eq!(backed_off, obligations.len() as u64);
10871        let asked = remote.asked.borrow().len();
10872        let result = engine.start_gasp_sync().await.unwrap();
10873        let topic = &result.topics_synced["tm_test"];
10874        assert_eq!(topic.waiting_graphs, obligations.len() as u64, "{topic:?}");
10875        assert_eq!(topic.resumed_graphs, 0);
10876        assert!(topic.dropped_graphs.is_empty(), "{topic:?}");
10877        assert_eq!(remote.asked.borrow().len(), asked, "no request");
10878        // Kept, not deleted (bsv-low #555's resume), each on its backoff.
10879        let now = (current_timestamp_ms() / 1000.0) as u64;
10880        let held = store.deferred_graphs();
10881        assert_eq!(held.len(), obligations.len());
10882        for record in &held {
10883            assert!(obligations.contains(&record.outpoint));
10884            assert!(record.cursor_passable);
10885            assert!(record.idle_resumes >= idle, "{record:?}");
10886            assert!(record.retry_at > now, "{record:?}");
10887        }
10888    }
10889
10890    /// 0.1.10: a record waiting out its idle backoff whose root is
10891    /// remembered expired (0.1.8's memo, keyed by topic: any peer's walk that
10892    /// refused it) is swept at the next sync ahead of the backoff gate, with
10893    /// no request: refused and deleted, counted in `dropped_graphs`
10894    /// (`refused`). A record that holds its root node is refused the same
10895    /// way from that node; one holding nothing is refused at its next
10896    /// resume, when its root is fetched.
10897    #[tokio::test]
10898    async fn room_0110_a_waiting_record_whose_root_expired_is_swept() {
10899        let (mut engine, remote) = budget_page_engine(1);
10900        let store = std::rc::Rc::new(MemoryStorage::new());
10901        engine.storage = Box::new(store.clone());
10902        engine.set_graph_budget(
10903            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
10904            100,
10905            60_000,
10906        );
10907        engine.set_gasp_remote_factory(Box::new(remote.clone()));
10908        let now = (current_timestamp_ms() / 1000.0) as u64;
10909        let mut waiting = bare_record(0, 1, "retry", true);
10910        waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10911        waiting.retry_at = now + 3600;
10912        let dead = bare_record(1, 2, "retry", true);
10913        let mut dead_waiting = dead.clone();
10914        dead_waiting.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
10915        dead_waiting.retry_at = now + 3600;
10916        for record in [&waiting, &dead_waiting] {
10917            engine.storage().put_deferred_graph(record).await.unwrap();
10918        }
10919        engine
10920            .storage()
10921            .remember_expired_outpoint("tm_test", &dead.outpoint, 1)
10922            .await
10923            .unwrap();
10924        let result = engine.start_gasp_sync().await.unwrap();
10925        let topic = &result.topics_synced["tm_test"];
10926        assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
10927        assert_eq!(
10928            topic
10929                .dropped_graphs
10930                .iter()
10931                .map(|d| (d.outpoint.as_str(), d.reason.as_str()))
10932                .collect::<Vec<_>>(),
10933            vec![(dead.outpoint.as_str(), "refused")]
10934        );
10935        let held: Vec<String> = store
10936            .deferred_graphs()
10937            .into_iter()
10938            .map(|r| r.outpoint)
10939            .collect();
10940        assert_eq!(held, vec![waiting.outpoint.clone()]);
10941        let asked = remote.asked.borrow();
10942        assert!(!asked.iter().any(|t| dead.outpoint.starts_with(t.as_str())));
10943        assert!(!asked
10944            .iter()
10945            .any(|t| waiting.outpoint.starts_with(t.as_str())));
10946    }
10947
10948    /// 0.1.10: a resume that appends a node resets the idle resumes (and
10949    /// any backoff), so a record that progresses is never backed off.
10950    #[tokio::test]
10951    async fn room_0110_a_resume_that_appends_a_node_resets_the_count() {
10952        let UnanchorableFixture {
10953            mut engine,
10954            remote,
10955            store,
10956            root,
10957            ..
10958        } = unanchorable_fixture();
10959        // One node a pass: the root is appended and its walk deferred.
10960        engine.set_graph_budget_limbs(u64::MAX, 1);
10961        engine.set_gasp_remote_factory(Box::new(remote));
10962        let mut record = bare_record(0, 10, "retry", true);
10963        record.outpoint.clone_from(&root);
10964        record.pending[0].outpoint.clone_from(&root);
10965        record.pending[0].graph_id.clone_from(&root);
10966        record.idle_resumes = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF - 1;
10967        engine.storage().put_deferred_graph(&record).await.unwrap();
10968        let result = engine.start_gasp_sync().await.unwrap();
10969        let topic = &result.topics_synced["tm_test"];
10970        assert_eq!(topic.resumed_graphs, 1, "{topic:?}");
10971        assert_eq!(topic.backed_off_graphs, 0);
10972        let held = store
10973            .get_deferred_graph("https://budget.example", "tm_test", &root)
10974            .await
10975            .unwrap()
10976            .unwrap();
10977        assert_eq!(held.nodes.len(), 1, "{held:?}");
10978        assert_eq!(held.reason, "nodes");
10979        assert_eq!(held.idle_resumes, 0);
10980        assert_eq!(held.retry_at, 0);
10981    }
10982
10983    /// The courier of the 0.1.11 brief's fact 3: every rung exhausted, the
10984    /// answer `NodeNotFound` ("no raw tx for <txid> (bitails + woc +
10985    /// bananablocks exhausted)", `proof_fetcher.rs`), no proof for a root.
10986    struct ExhaustedCourier {
10987        asked: std::rc::Rc<Cell<u64>>,
10988        /// Answer a fault of the moment instead (a rung's rate limit).
10989        transient: std::rc::Rc<Cell<bool>>,
10990    }
10991
10992    #[async_trait(?Send)]
10993    impl AncestorFetcher for ExhaustedCourier {
10994        async fn fetch_ancestor(&self, txid: &str) -> Result<FetchedAncestor, GASPError> {
10995            self.asked.set(self.asked.get() + 1);
10996            if self.transient.get() {
10997                return Err(GASPError::RemoteError(format!(
10998                    "rung rate limited for {txid}"
10999                )));
11000            }
11001            Err(GASPError::NodeNotFound(format!(
11002                "no raw tx for {txid} (bitails + woc + bananablocks exhausted)"
11003            )))
11004        }
11005        async fn verified_proof_for(&self, _: &str) -> Option<String> {
11006            None
11007        }
11008    }
11009
11010    /// The production shape of the 0.1.11 brief (facts 2 and 3): a budget
11011    /// page of `page` proven roots (scores 10, 20, ...) and `count` root
11012    /// retry obligations written by 0.1.8 and 0.1.9 (`reason "retry"`,
11013    /// passable, nodes 0, no `idleResumes`) at scores 1, 2, ..., each a root
11014    /// the peer serves UNPROVEN whose parent no rung serves.
11015    struct ObligationFixture {
11016        engine: Engine,
11017        remote: BudgetPageRemote,
11018        store: std::rc::Rc<MemoryStorage>,
11019        obligations: Vec<String>,
11020        courier: std::rc::Rc<Cell<u64>>,
11021        transient: std::rc::Rc<Cell<bool>>,
11022    }
11023
11024    async fn obligation_fixture(page: u32, count: u32, node_cost_ms: u64) -> ObligationFixture {
11025        let (mut engine, mut remote) = budget_page_engine(page);
11026        remote.node_cost_ms = node_cost_ms;
11027        let mut obligations = Vec::new();
11028        let nodes = std::rc::Rc::make_mut(&mut remote.nodes);
11029        let mut records = Vec::new();
11030        for n in 0..count {
11031            let parent = child_of(&format!("{:064x}", 0xbeef_0000 + n), 0);
11032            let child = child_of(&parent.id(), 0);
11033            // Engine 0.1.15: the peer is asked for an input the courier
11034            // cannot serve. This fixture's subject is the courier, so its
11035            // peer answers the parent with a transport fault, which counts
11036            // nothing (#56).
11037            remote.faults_for.borrow_mut().insert(parent.id());
11038            nodes.insert(
11039                child.id(),
11040                crate::types::GASPNode {
11041                    graph_id: String::new(),
11042                    raw_tx: child.to_hex(),
11043                    output_index: 0,
11044                    proof: None,
11045                    tx_metadata: None,
11046                    output_metadata: None,
11047                    inputs: None,
11048                },
11049            );
11050            let mut record = bare_record(n, 1 + u64::from(n), "retry", true);
11051            let outpoint = format!("{}.0", child.id());
11052            record.outpoint.clone_from(&outpoint);
11053            record.pending[0].outpoint.clone_from(&outpoint);
11054            record.pending[0].graph_id.clone_from(&outpoint);
11055            obligations.push(outpoint);
11056            records.push(record);
11057        }
11058        let courier = std::rc::Rc::new(Cell::new(0));
11059        let transient = std::rc::Rc::new(Cell::new(false));
11060        engine.set_gasp_ancestor_fetcher(std::rc::Rc::new(ExhaustedCourier {
11061            asked: courier.clone(),
11062            transient: transient.clone(),
11063        }));
11064        let store = std::rc::Rc::new(MemoryStorage::new());
11065        engine.storage = Box::new(store.clone());
11066        virtual_peer_budget(&mut engine, &remote);
11067        engine.set_graph_budget(
11068            std::rc::Rc::new(|_| Box::pin(std::future::pending())),
11069            100,
11070            60_000,
11071        );
11072        engine.set_gasp_remote_factory(Box::new(remote.clone()));
11073        for record in &records {
11074            // The JSON 0.1.8 and 0.1.9 wrote: no `idleResumes`, no `faults`.
11075            let json = serde_json::to_value(record).unwrap();
11076            assert!(json.get("idleResumes").is_none() && json.get("faults").is_none());
11077            engine.storage().put_deferred_graph(record).await.unwrap();
11078        }
11079        ObligationFixture {
11080            engine,
11081            remote,
11082            store,
11083            obligations,
11084            courier,
11085            transient,
11086        }
11087    }
11088
11089    /// 0.1.11 (a faulted resume is a strike): a held root retry obligation
11090    /// whose root the peer serves and whose parent the courier cannot serve
11091    /// (facts 2 and 3: `retry`, nodes 0, `courier`, `faults 0` on every run
11092    /// of 0.1.10, never struck, never backed off) is struck on its own slot
11093    /// on each run, recorded `unanchorable:courier` at the third, and not
11094    /// resumed inside its backoff; the page's roots behind it are walked in
11095    /// the same run, every run. The obligation stays passable throughout:
11096    /// its cursor had passed it, and a strike never takes that back.
11097    #[tokio::test]
11098    async fn obligations_0111_a_faulted_resume_is_struck_and_the_page_is_walked() {
11099        let ObligationFixture {
11100            engine,
11101            remote,
11102            store,
11103            obligations,
11104            courier,
11105            ..
11106        } = obligation_fixture(3, 1, 0).await;
11107        let host = "https://budget.example";
11108        let held = &obligations[0];
11109        let runs = crate::gasp::UNANCHORABLE_AFTER_FAULTED_RUNS;
11110        for run in 1..=runs {
11111            let result = engine.start_gasp_sync().await.unwrap();
11112            let topic = &result.topics_synced["tm_test"];
11113            let named = topic
11114                .faulted_roots
11115                .iter()
11116                .find(|f| &f.outpoint == held)
11117                .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11118            assert_eq!(named.class, crate::gasp::fault_class::COURIER);
11119            assert!(named.error.contains("exhausted"), "{named:?}");
11120            assert_eq!(named.faults, run, "run {run}: struck on its own slot");
11121            assert!(named.refused.is_empty(), "{named:?}");
11122            assert_eq!(topic.strikes_refused, 0);
11123            assert!(topic.cursor_held_by.is_empty(), "run {run}: {topic:?}");
11124            if run == 1 {
11125                assert_eq!(topic.finalized_graphs, 3, "the page, the same run");
11126                assert_eq!(topic.cursor_moves[0].to, 30);
11127            }
11128            let record = store
11129                .get_deferred_graph(host, "tm_test", held)
11130                .await
11131                .unwrap()
11132                .unwrap();
11133            assert_eq!(record.faults, run, "{record:?}");
11134            assert!(record.cursor_passable, "run {run}: never holds the cursor");
11135            assert!(record.nodes.is_empty());
11136            if run < runs {
11137                assert!(!named.unanchorable);
11138                assert_eq!(record.reason, "retry:fault:courier");
11139                assert_eq!(topic.unanchorable_graphs, 0);
11140            } else {
11141                assert!(named.unanchorable);
11142                assert_eq!(record.reason, "unanchorable:courier");
11143                assert!(record.retry_at > (current_timestamp_ms() / 1000.0) as u64);
11144                assert_eq!(topic.unanchorable_graphs, 1, "{topic:?}");
11145            }
11146        }
11147        // Inside its backoff: not resumed, nothing asked of the peer or the
11148        // courier, nothing named, held.
11149        let (asked, couriered) = (remote.asked.borrow().len(), courier.get());
11150        let result = engine.start_gasp_sync().await.unwrap();
11151        let topic = &result.topics_synced["tm_test"];
11152        assert!(topic.faulted_roots.is_empty(), "{topic:?}");
11153        assert_eq!(topic.resumed_graphs, 0);
11154        assert_eq!(remote.asked.borrow().len(), asked);
11155        assert_eq!(courier.get(), couriered);
11156        // 0.1.14: the obligation's fault fact lives outside partial graphs.
11157        assert!(store.deferred_graphs().is_empty());
11158        assert_eq!(store.fault_memos().len(), 1);
11159    }
11160
11161    /// 0.1.11 (the idle count fires): a resumed pass of a held record that
11162    /// asked and appended nothing it kept counts one idle resume whatever
11163    /// its ending, and the sync's accounting says how many records counted
11164    /// (`idle_counted`). Three endings: (a) the pass's own save (a rung's
11165    /// fault of the moment: the record is kept, reason `fault`); (b) a drop
11166    /// whose record is saved again as an obligation (the peer no longer
11167    /// serves the root, `not_held`); (c) the production shape of fact 3 (the
11168    /// root served, appended and thrown away with the drop, the courier
11169    /// exhausted) when its strike is refused at the fault bound, so the drop
11170    /// stands: on 0.1.10 the post-pass count read the sync's appended nodes,
11171    /// which the thrown-away root had raised, and never fired.
11172    #[tokio::test]
11173    async fn obligations_0111_a_resume_that_appends_nothing_counts_on_every_ending() {
11174        let host = "https://budget.example";
11175        // 0.1.14: exercise the same refusal ending through a real storage
11176        // ceiling, because the old per-peer fault bound no longer exists.
11177        let fixture = obligation_fixture(1, 1, 0).await;
11178        fixture.store.set_fault_memo_ceiling(Some(0));
11179        let held = &fixture.obligations[0];
11180        let idle = crate::gasp::IDLE_RESUMES_BEFORE_BACKOFF;
11181        for run in 1..=idle {
11182            let result = fixture.engine.start_gasp_sync().await.unwrap();
11183            let topic = &result.topics_synced["tm_test"];
11184            let record = fixture
11185                .store
11186                .get_deferred_graph(host, "tm_test", held)
11187                .await
11188                .unwrap()
11189                .unwrap();
11190            assert_eq!(record.idle_resumes, run, "run {run}: {record:?} {topic:?}");
11191            assert_eq!(record.reason, "retry", "the drop stands");
11192            assert!(record.cursor_passable && record.nodes.is_empty());
11193            assert_eq!(topic.idle_counted, 1, "run {run}: {topic:?}");
11194            let named = topic
11195                .faulted_roots
11196                .iter()
11197                .find(|f| &f.outpoint == held)
11198                .unwrap_or_else(|| panic!("run {run}: {topic:?}"));
11199            assert_eq!(named.refused, crate::gasp::strike_refused::NO_ROOM);
11200            assert_eq!(named.faults, 0);
11201            assert_eq!(topic.strikes_refused, 1);
11202            assert_eq!(topic.backed_off_graphs, u64::from(run == idle));
11203        }
11204        let result = fixture.engine.start_gasp_sync().await.unwrap();
11205        let topic = &result.topics_synced["tm_test"];
11206        assert_eq!(topic.waiting_graphs, 1, "{topic:?}");
11207        assert_eq!(topic.idle_counted, 0);
11208
11209        // (b) A drop: the peer no longer serves the root.
11210        let mut fixture = obligation_fixture(1, 1, 0).await;
11211        let gone = fixture.obligations[0].clone();
11212        std::rc::Rc::make_mut(&mut fixture.remote.nodes).remove(gone.strip_suffix(".0").unwrap());
11213        fixture
11214            .engine
11215            .set_gasp_remote_factory(Box::new(fixture.remote.clone()));
11216        let result = fixture.engine.start_gasp_sync().await.unwrap();
11217        let topic = &result.topics_synced["tm_test"];
11218        assert_eq!(topic.idle_counted, 1, "{topic:?}");
11219        assert_eq!(
11220            topic
11221                .dropped_graphs
11222                .iter()
11223                .map(|d| d.reason.as_str())
11224                .collect::<Vec<_>>(),
11225            vec!["not_held"]
11226        );
11227        let record = fixture
11228            .store
11229            .get_deferred_graph(host, "tm_test", &gone)
11230            .await
11231            .unwrap()
11232            .unwrap();
11233        assert_eq!((record.idle_resumes, record.faults), (1, 0), "{record:?}");
11234
11235        // (a) The pass's own save: a rung's fault of the moment. The pass
11236        // that appends the root progresses; the next asks and appends
11237        // nothing, and its save counts it.
11238        let fixture = obligation_fixture(1, 1, 0).await;
11239        fixture.transient.set(true);
11240        let kept = &fixture.obligations[0];
11241        let result = fixture.engine.start_gasp_sync().await.unwrap();
11242        assert_eq!(result.topics_synced["tm_test"].idle_counted, 0, "progress");
11243        let result = fixture.engine.start_gasp_sync().await.unwrap();
11244        let topic = &result.topics_synced["tm_test"];
11245        assert_eq!(topic.idle_counted, 1, "{topic:?}");
11246        let record = fixture
11247            .store
11248            .get_deferred_graph(host, "tm_test", kept)
11249            .await
11250            .unwrap()
11251            .unwrap();
11252        assert_eq!(record.reason, "fault");
11253        assert_eq!(
11254            (record.nodes.len(), record.idle_resumes),
11255            (1, 1),
11256            "{record:?}"
11257        );
11258    }
11259
11260    /// 0.1.11 (the page's share): fourteen obligations of fact 3's shape
11261    /// ahead of a page of twelve fresh roots, every request 1,500 ms of a
11262    /// virtual 30,000 ms per-peer slice and a 15,000 ms graph budget. The
11263    /// resumes take at most `resume_share_ms(30_000, 15_000)` = 9,900 ms
11264    /// together; those reached after it are held back (not resumed, not a
11265    /// failure, not stalled, not idle, their records as they were) and the
11266    /// page's twelve roots are all walked and finalized in the same run.
11267    /// On 0.1.10 the resumes shared one graph budget (15,000 ms): ten of
11268    /// them ran, and the per-peer deadline dropped the sync at nine of the
11269    /// page's twelve roots (fact 4: the page got what the resumes left).
11270    #[tokio::test]
11271    async fn obligations_0111_fourteen_obligations_leave_the_page_its_share() {
11272        let ObligationFixture {
11273            mut engine,
11274            remote,
11275            store,
11276            obligations,
11277            ..
11278        } = obligation_fixture(12, 14, 1500).await;
11279        let elapsed = remote.elapsed.clone();
11280        engine.set_graph_budget(
11281            std::rc::Rc::new(move |ms| {
11282                let elapsed = elapsed.clone();
11283                let until = elapsed.get() + ms;
11284                Box::pin(std::future::poll_fn(move |_| {
11285                    if elapsed.get() >= until {
11286                        std::task::Poll::Ready(())
11287                    } else {
11288                        std::task::Poll::Pending
11289                    }
11290                }))
11291            }),
11292            100,
11293            15_000,
11294        );
11295        assert_eq!(crate::gasp::resume_share_ms(30_000, 15_000), 9_900);
11296        let result = engine.start_gasp_sync().await.unwrap();
11297        let topic = &result.topics_synced["tm_test"];
11298        assert!(topic.errors.is_empty(), "inside the slice: {topic:?}");
11299        assert_eq!(topic.finalized_graphs, 12, "the page's share: {topic:?}");
11300        assert_eq!(topic.cursor_moves[0].to, 120);
11301        // Three resumes struck; the fourth cut in flight by the share, having
11302        // asked and appended nothing (counted idle and stalled, as 0.1.10
11303        // counts a deadline's cut); the other ten held back. Engine 0.1.15:
11304        // each resume asks the peer for the parent the courier cannot serve
11305        // (#56), two requests of 1,500 ms, so the 9,900 ms share holds three
11306        // (six on 0.1.11 to 0.1.14, one request each).
11307        assert_eq!(topic.resumed_graphs, 4, "{topic:?}");
11308        assert_eq!(topic.held_back_graphs, 10, "{topic:?}");
11309        assert_eq!(topic.faulted_roots.len(), 3, "{topic:?}");
11310        assert_eq!((topic.stalled_graphs, topic.idle_counted), (1, 1));
11311        assert!(topic.cursor_held_by.is_empty());
11312        let host = "https://budget.example";
11313        let mut reasons = std::collections::BTreeMap::new();
11314        for outpoint in &obligations {
11315            let record = store
11316                .get_deferred_graph(host, "tm_test", outpoint)
11317                .await
11318                .unwrap()
11319                .unwrap();
11320            assert!(record.cursor_passable, "{record:?}");
11321            if record.reason == "retry" {
11322                // Held back: as it was, not counted idle.
11323                assert_eq!((record.idle_resumes, record.retry_at), (0, 0));
11324                assert_eq!((record.faults, record.passes), (0, 0));
11325            }
11326            *reasons.entry(record.reason).or_insert(0) += 1;
11327        }
11328        assert_eq!(
11329            reasons,
11330            std::collections::BTreeMap::from([
11331                ("retry".to_string(), 10),
11332                ("retry:fault:courier".to_string(), 3),
11333                ("time".to_string(), 1),
11334            ])
11335        );
11336    }
11337
11338    #[tokio::test]
11339    async fn sync_budget_own_proof_without_a_valid_header_is_never_admitted() {
11340        let (mut engine, mut remote) = budget_page_engine(1);
11341        let txid = remote.outputs[0].txid.clone();
11342        std::rc::Rc::make_mut(&mut remote.nodes)
11343            .get_mut(&txid)
11344            .unwrap()
11345            .proof = None;
11346        engine.set_ancestor_fetcher(std::rc::Rc::new(ProofOnlyRootFetcher {
11347            proofs: HashMap::from([(txid.clone(), single_leaf_bump_hex(&txid, 999))]),
11348            requested: std::rc::Rc::default(),
11349        }));
11350        virtual_peer_budget(&mut engine, &remote);
11351        engine.set_gasp_remote_factory(Box::new(remote));
11352        let result = engine.start_gasp_sync().await.unwrap();
11353        assert_eq!(result.topics_synced["tm_test"].finalized_graphs, 0);
11354        assert!(engine
11355            .storage
11356            .find_output(&txid, 0, Some("tm_test"), None, true)
11357            .await
11358            .unwrap()
11359            .is_none());
11360    }
11361
11362    impl crate::gasp::GASPRemoteFactory for MockGASPRemoteFactory {
11363        fn create_remote(&self, _peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11364            Box::new(MockSyncRemote {
11365                utxos: vec![
11366                    crate::types::GASPOutput {
11367                        txid: "remote_tx1".to_string(),
11368                        output_index: 0,
11369                        score: 100.0,
11370                    },
11371                    crate::types::GASPOutput {
11372                        txid: "remote_tx2".to_string(),
11373                        output_index: 0,
11374                        score: 200.0,
11375                    },
11376                ],
11377            })
11378        }
11379    }
11380
11381    #[tokio::test]
11382    async fn test_start_gasp_sync_with_factory_runs_sync() {
11383        let mut sync_config: SyncConfiguration = HashMap::new();
11384        sync_config.insert(
11385            "tm_test".to_string(),
11386            SyncTarget::Peers(vec!["https://peer1.example.com".to_string()]),
11387        );
11388
11389        let mut engine = Engine::new(
11390            HashMap::new(),
11391            HashMap::new(),
11392            Box::new(MemoryStorage::new()),
11393            None,
11394            EngineConfig {
11395                sync_configuration: sync_config,
11396                ..Default::default()
11397            },
11398        );
11399
11400        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11401
11402        let result = engine.start_gasp_sync().await.unwrap();
11403        assert_eq!(result.topics_synced.len(), 1);
11404
11405        let topic_result = &result.topics_synced["tm_test"];
11406        assert_eq!(topic_result.peers.len(), 1);
11407        assert!(
11408            topic_result.errors.is_empty(),
11409            "Sync should succeed with mock remote"
11410        );
11411
11412        // Verify last_interaction was persisted
11413        let last = engine
11414            .storage()
11415            .get_last_interaction("https://peer1.example.com", "tm_test")
11416            .await
11417            .unwrap();
11418        // Synthetic nodes cannot assemble into BEEF. No score completed,
11419        // so the entry cursor stays and the next sync re-requests both.
11420        assert_eq!(
11421            last, 0,
11422            "cursor must remain at entry when no output completed"
11423        );
11424    }
11425
11426    #[tokio::test]
11427    async fn test_start_gasp_sync_with_factory_handles_error() {
11428        struct FailingFactory;
11429
11430        impl crate::gasp::GASPRemoteFactory for FailingFactory {
11431            fn create_remote(
11432                &self,
11433                _peer_url: &str,
11434                _topic: &str,
11435            ) -> Box<dyn crate::gasp::GASPRemote> {
11436                struct FailingRemote;
11437                #[async_trait(?Send)]
11438                impl crate::gasp::GASPRemote for FailingRemote {
11439                    async fn get_initial_response(
11440                        &self,
11441                        _: &crate::types::GASPInitialRequest,
11442                    ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError>
11443                    {
11444                        Err(crate::gasp::GASPError::RemoteError(
11445                            "connection refused".into(),
11446                        ))
11447                    }
11448                    async fn get_initial_reply(
11449                        &self,
11450                        _: &crate::types::GASPInitialResponse,
11451                    ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError>
11452                    {
11453                        unreachable!()
11454                    }
11455                    async fn request_node(
11456                        &self,
11457                        _: &str,
11458                        _: &str,
11459                        _: u32,
11460                        _: bool,
11461                    ) -> Result<crate::types::GASPNode, crate::gasp::GASPError>
11462                    {
11463                        unreachable!()
11464                    }
11465                    async fn submit_node(
11466                        &self,
11467                        _: &crate::types::GASPNode,
11468                    ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError>
11469                    {
11470                        unreachable!()
11471                    }
11472                }
11473                Box::new(FailingRemote)
11474            }
11475        }
11476
11477        let mut sync_config: SyncConfiguration = HashMap::new();
11478        sync_config.insert(
11479            "tm_test".to_string(),
11480            SyncTarget::Peers(vec!["https://bad-peer.example.com".to_string()]),
11481        );
11482
11483        let mut engine = Engine::new(
11484            HashMap::new(),
11485            HashMap::new(),
11486            Box::new(MemoryStorage::new()),
11487            None,
11488            EngineConfig {
11489                sync_configuration: sync_config,
11490                ..Default::default()
11491            },
11492        );
11493
11494        engine.set_gasp_remote_factory(Box::new(FailingFactory));
11495
11496        let result = engine.start_gasp_sync().await.unwrap();
11497        assert_eq!(result.topics_synced.len(), 1);
11498
11499        let topic_result = &result.topics_synced["tm_test"];
11500        assert_eq!(
11501            topic_result.errors.len(),
11502            1,
11503            "Should have one error for the failing peer"
11504        );
11505        assert!(topic_result.errors[0].contains("connection refused"));
11506    }
11507
11508    #[tokio::test]
11509    async fn test_start_gasp_sync_without_factory_still_discovers_peers() {
11510        let mut sync_config: SyncConfiguration = HashMap::new();
11511        sync_config.insert(
11512            "tm_test".to_string(),
11513            SyncTarget::Peers(vec!["https://peer.example.com".to_string()]),
11514        );
11515
11516        // No factory set — should still work (just peer discovery, no sync)
11517        let engine = Engine::new(
11518            HashMap::new(),
11519            HashMap::new(),
11520            Box::new(MemoryStorage::new()),
11521            None,
11522            EngineConfig {
11523                sync_configuration: sync_config,
11524                ..Default::default()
11525            },
11526        );
11527
11528        let result = engine.start_gasp_sync().await.unwrap();
11529        assert_eq!(result.topics_synced.len(), 1);
11530
11531        let topic_result = &result.topics_synced["tm_test"];
11532        assert_eq!(topic_result.peers.len(), 1);
11533        assert!(topic_result.errors.is_empty());
11534    }
11535
11536    #[tokio::test]
11537    async fn test_start_gasp_sync_with_factory_multiple_peers() {
11538        let mut sync_config: SyncConfiguration = HashMap::new();
11539        sync_config.insert(
11540            "tm_test".to_string(),
11541            SyncTarget::Peers(vec![
11542                "https://peer1.example.com".to_string(),
11543                "https://peer2.example.com".to_string(),
11544            ]),
11545        );
11546
11547        let mut engine = Engine::new(
11548            HashMap::new(),
11549            HashMap::new(),
11550            Box::new(MemoryStorage::new()),
11551            None,
11552            EngineConfig {
11553                sync_configuration: sync_config,
11554                ..Default::default()
11555            },
11556        );
11557
11558        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11559
11560        let result = engine.start_gasp_sync().await.unwrap();
11561        let topic_result = &result.topics_synced["tm_test"];
11562        assert_eq!(topic_result.peers.len(), 2);
11563        assert!(topic_result.errors.is_empty());
11564
11565        // Neither peer completed its synthetic graphs.
11566        let last1 = engine
11567            .storage()
11568            .get_last_interaction("https://peer1.example.com", "tm_test")
11569            .await
11570            .unwrap();
11571        let last2 = engine
11572            .storage()
11573            .get_last_interaction("https://peer2.example.com", "tm_test")
11574            .await
11575            .unwrap();
11576        assert_eq!(last1, 0);
11577        assert_eq!(last2, 0);
11578    }
11579
11580    // ── Per-peer budget + dead-peer quarantine (bsv-low#302) ──────────
11581
11582    /// A remote whose initial request HANGS forever — models the dead
11583    /// ephemeral peers (ngrok tunnels) whose fetch never returns, the #257
11584    /// root cause the per-peer budget exists for.
11585    struct HangingRemote;
11586
11587    #[async_trait(?Send)]
11588    impl crate::gasp::GASPRemote for HangingRemote {
11589        async fn get_initial_response(
11590            &self,
11591            _: &crate::types::GASPInitialRequest,
11592        ) -> Result<crate::types::GASPInitialResponse, crate::gasp::GASPError> {
11593            std::future::pending::<()>().await;
11594            unreachable!()
11595        }
11596        async fn get_initial_reply(
11597            &self,
11598            _: &crate::types::GASPInitialResponse,
11599        ) -> Result<crate::types::GASPInitialReply, crate::gasp::GASPError> {
11600            unreachable!()
11601        }
11602        async fn request_node(
11603            &self,
11604            _: &str,
11605            _: &str,
11606            _: u32,
11607            _: bool,
11608        ) -> Result<crate::types::GASPNode, crate::gasp::GASPError> {
11609            unreachable!()
11610        }
11611        async fn submit_node(
11612            &self,
11613            _: &crate::types::GASPNode,
11614        ) -> Result<Option<crate::types::GASPNodeResponse>, crate::gasp::GASPError> {
11615            unreachable!()
11616        }
11617    }
11618
11619    /// Factory that HANGS for `hang.example.com` peers and serves the normal
11620    /// mock sync remote for everyone else, counting `create_remote` calls
11621    /// (a quarantine-skipped peer never reaches the factory).
11622    struct SelectiveHangFactory {
11623        created: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
11624    }
11625
11626    impl crate::gasp::GASPRemoteFactory for SelectiveHangFactory {
11627        fn create_remote(&self, peer_url: &str, _topic: &str) -> Box<dyn crate::gasp::GASPRemote> {
11628            self.created.borrow_mut().push(peer_url.to_string());
11629            if peer_url.contains("hang.example.com") {
11630                Box::new(HangingRemote)
11631            } else {
11632                Box::new(MockSyncRemote {
11633                    utxos: vec![crate::types::GASPOutput {
11634                        txid: "remote_tx1".to_string(),
11635                        output_index: 0,
11636                        score: 100.0,
11637                    }],
11638                })
11639            }
11640        }
11641    }
11642
11643    /// The deterministic "instant deadline" sleep factory: `race_or_deadline`
11644    /// polls the sync future FIRST, so an in-memory sync that never yields
11645    /// still completes; only a future that actually returns Pending (the
11646    /// hanging remote) loses the race.
11647    fn instant_deadline() -> crate::engine::SleepFactory {
11648        std::rc::Rc::new(|_ms| Box::pin(std::future::ready(())))
11649    }
11650
11651    fn two_peer_engine(store: std::rc::Rc<MemoryStorage>) -> Engine {
11652        let mut sync_config: SyncConfiguration = HashMap::new();
11653        sync_config.insert(
11654            "tm_test".to_string(),
11655            SyncTarget::Peers(vec![
11656                "https://hang.example.com".to_string(),
11657                "https://good.example.com".to_string(),
11658            ]),
11659        );
11660        Engine::new(
11661            HashMap::new(),
11662            HashMap::new(),
11663            Box::new(store),
11664            None,
11665            EngineConfig {
11666                sync_configuration: sync_config,
11667                ..Default::default()
11668            },
11669        )
11670    }
11671
11672    #[tokio::test]
11673    async fn peer_budget_timeout_skips_dead_peer_continues_loop_and_never_advances_cursor() {
11674        let store = std::rc::Rc::new(MemoryStorage::new());
11675        let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11676        let mut engine = two_peer_engine(store.clone());
11677        engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11678            created: created.clone(),
11679        }));
11680        engine.set_peer_sync_budget(instant_deadline(), 1);
11681
11682        let result = engine.start_gasp_sync().await.unwrap();
11683        let topic_result = &result.topics_synced["tm_test"];
11684
11685        // The hanging peer was dropped LOUDLY (an error entry) and the loop
11686        // CONTINUED: the good peer was still attempted and completed.
11687        assert_eq!(topic_result.errors.len(), 1, "one budget-exceeded error");
11688        assert!(
11689            topic_result.errors[0].contains("budget"),
11690            "{:?}",
11691            topic_result.errors
11692        );
11693        assert_eq!(
11694            created.borrow().as_slice(),
11695            ["https://hang.example.com", "https://good.example.com"],
11696            "both peers attempted this tick"
11697        );
11698
11699        // A timed-out peer stays at entry. The other peer was reached but
11700        // served synthetic graphs that cannot complete, so its cursor stays too.
11701        let hang_cursor = store
11702            .get_last_interaction("https://hang.example.com", "tm_test")
11703            .await
11704            .unwrap();
11705        assert_eq!(hang_cursor, 0, "timeout must not advance the sync cursor");
11706        let good_cursor = store
11707            .get_last_interaction("https://good.example.com", "tm_test")
11708            .await
11709            .unwrap();
11710        assert_eq!(good_cursor, 0, "no synthetic graph completed");
11711
11712        // Health bookkeeping: timeout = failure, completion = success.
11713        let hang_health = store
11714            .get_peer_sync_health("hang.example.com", "tm_test")
11715            .await
11716            .unwrap();
11717        assert_eq!(hang_health.consecutive_failures, 1);
11718        let good_health = store
11719            .get_peer_sync_health("good.example.com", "tm_test")
11720            .await
11721            .unwrap();
11722        assert_eq!(good_health.consecutive_failures, 0);
11723    }
11724
11725    #[tokio::test]
11726    async fn peer_quarantined_after_threshold_reprobed_after_window_readmitted_on_success() {
11727        let store = std::rc::Rc::new(MemoryStorage::new());
11728        let created = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
11729
11730        let mut sync_config: SyncConfiguration = HashMap::new();
11731        sync_config.insert(
11732            "tm_test".to_string(),
11733            SyncTarget::Peers(vec!["https://hang.example.com".to_string()]),
11734        );
11735        let mut engine = Engine::new(
11736            HashMap::new(),
11737            HashMap::new(),
11738            Box::new(store.clone()),
11739            None,
11740            EngineConfig {
11741                sync_configuration: sync_config,
11742                ..Default::default()
11743            },
11744        );
11745        engine.set_gasp_remote_factory(Box::new(SelectiveHangFactory {
11746            created: created.clone(),
11747        }));
11748        engine.set_peer_sync_budget(instant_deadline(), 1);
11749
11750        // Fail the peer up to (but not past) the quarantine threshold — every
11751        // tick attempts it (advance the clock between ticks so the attempts
11752        // are distinguishable in age).
11753        for i in 0..crate::gasp::PEER_QUARANTINE_THRESHOLD {
11754            engine.start_gasp_sync().await.unwrap();
11755            store.advance_clock(900);
11756            assert_eq!(
11757                created.borrow().len() as u64,
11758                i + 1,
11759                "peer attempted while below the threshold"
11760            );
11761        }
11762        let health = store
11763            .get_peer_sync_health("hang.example.com", "tm_test")
11764            .await
11765            .unwrap();
11766        assert_eq!(
11767            health.consecutive_failures,
11768            crate::gasp::PEER_QUARANTINE_THRESHOLD
11769        );
11770
11771        // Next tick: QUARANTINED — the factory is never consulted, no error
11772        // is emitted, and the attempt count does not grow (a skip is not an
11773        // attempt, so the re-probe window keeps aging).
11774        let result = engine.start_gasp_sync().await.unwrap();
11775        assert!(result.topics_synced["tm_test"].errors.is_empty());
11776        assert_eq!(
11777            created.borrow().len() as u64,
11778            crate::gasp::PEER_QUARANTINE_THRESHOLD,
11779            "quarantined peer must be skipped"
11780        );
11781
11782        // Age past the re-probe window: exactly one fresh probe is allowed.
11783        store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11784        engine.start_gasp_sync().await.unwrap();
11785        assert_eq!(
11786            created.borrow().len() as u64,
11787            crate::gasp::PEER_QUARANTINE_THRESHOLD + 1,
11788            "re-probe attempted after the window"
11789        );
11790        // The probe failed again → failure count kept growing → re-armed.
11791        let health = store
11792            .get_peer_sync_health("hang.example.com", "tm_test")
11793            .await
11794            .unwrap();
11795        assert_eq!(
11796            health.consecutive_failures,
11797            crate::gasp::PEER_QUARANTINE_THRESHOLD + 1
11798        );
11799        let result = engine.start_gasp_sync().await.unwrap();
11800        assert!(
11801            result.topics_synced["tm_test"].errors.is_empty(),
11802            "re-quarantined"
11803        );
11804
11805        // The peer comes back to life: age to the next re-probe, then answer
11806        // like a healthy peer → SUCCESS resets the count to 0 (full
11807        // re-admission — quarantine is never a deletion).
11808        store.advance_clock(crate::gasp::PEER_QUARANTINE_REPROBE_SECS);
11809        // Swap the factory for one that serves the healthy mock for this URL.
11810        engine.set_gasp_remote_factory(Box::new(MockGASPRemoteFactory));
11811        let result = engine.start_gasp_sync().await.unwrap();
11812        assert!(result.topics_synced["tm_test"].errors.is_empty());
11813        let health = store
11814            .get_peer_sync_health("hang.example.com", "tm_test")
11815            .await
11816            .unwrap();
11817        assert_eq!(health.consecutive_failures, 0, "success fully re-admits");
11818    }
11819
11820    // ── Mock ARC Broadcaster ─────────────────────────────────────────
11821
11822    use crate::broadcaster::ArcBroadcaster;
11823
11824    struct MockArcBroadcaster {
11825        calls: Arc<Mutex<Vec<String>>>,
11826        should_fail: bool,
11827    }
11828
11829    impl MockArcBroadcaster {
11830        fn new() -> Self {
11831            Self {
11832                calls: Arc::new(Mutex::new(Vec::new())),
11833                should_fail: false,
11834            }
11835        }
11836
11837        fn failing() -> Self {
11838            Self {
11839                calls: Arc::new(Mutex::new(Vec::new())),
11840                should_fail: true,
11841            }
11842        }
11843    }
11844
11845    #[async_trait(?Send)]
11846    impl ArcBroadcaster for MockArcBroadcaster {
11847        async fn broadcast(&self, raw_tx_hex: &str) -> Result<String, String> {
11848            self.calls.lock().unwrap().push(raw_tx_hex.to_string());
11849            if self.should_fail {
11850                Err("mock ARC failure".to_string())
11851            } else {
11852                Ok("mock_txid_from_arc".to_string())
11853            }
11854        }
11855    }
11856
11857    /// Create engine with an ARC broadcaster (and optionally a SHIP broadcaster).
11858    fn make_engine_with_arc(
11859        admit_indices: Vec<u32>,
11860        arc: MockArcBroadcaster,
11861    ) -> (Engine, Arc<Mutex<Vec<String>>>) {
11862        let arc_calls = arc.calls.clone();
11863
11864        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
11865        managers.insert(
11866            "tm_test".to_string(),
11867            Box::new(MockTopicManager::admitting(admit_indices)),
11868        );
11869
11870        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
11871        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
11872
11873        let storage = Box::new(MemoryStorage::new());
11874
11875        let engine = Engine::with_all(
11876            managers,
11877            lookup_services,
11878            storage,
11879            None,
11880            None,
11881            Some(Box::new(arc)),
11882            None,
11883            EngineConfig::default(),
11884        );
11885
11886        (engine, arc_calls)
11887    }
11888
11889    // ── ARC Broadcaster Tests ────────────────────────────────────────
11890
11891    #[tokio::test]
11892    async fn test_arc_broadcaster_called_on_current_tx() {
11893        let arc = MockArcBroadcaster::new();
11894        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11895
11896        let beef = test_tagged_beef(vec!["tm_test"]);
11897        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11898
11899        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11900
11901        let calls = arc_calls.lock().unwrap();
11902        assert_eq!(
11903            calls.len(),
11904            1,
11905            "ARC broadcaster should be called once for CurrentTx"
11906        );
11907        assert!(
11908            !calls[0].is_empty(),
11909            "ARC should receive non-empty raw tx hex"
11910        );
11911    }
11912
11913    #[tokio::test]
11914    async fn test_arc_broadcaster_not_called_on_historical_tx() {
11915        let arc = MockArcBroadcaster::new();
11916        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11917
11918        let beef = test_tagged_beef(vec!["tm_test"]);
11919        engine
11920            .submit(&beef, SubmitMode::HistoricalTx)
11921            .await
11922            .unwrap();
11923
11924        assert_eq!(
11925            arc_calls.lock().unwrap().len(),
11926            0,
11927            "ARC broadcaster should NOT be called for HistoricalTx"
11928        );
11929    }
11930
11931    #[tokio::test]
11932    async fn test_arc_broadcaster_not_called_on_historical_tx_no_spv() {
11933        let arc = MockArcBroadcaster::new();
11934        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11935
11936        let beef = test_tagged_beef(vec!["tm_test"]);
11937        engine
11938            .submit(&beef, SubmitMode::HistoricalTxNoSpv)
11939            .await
11940            .unwrap();
11941
11942        assert_eq!(
11943            arc_calls.lock().unwrap().len(),
11944            0,
11945            "ARC broadcaster should NOT be called for HistoricalTxNoSpv"
11946        );
11947    }
11948
11949    #[tokio::test]
11950    async fn test_arc_broadcast_failure_does_not_fail_submit() {
11951        let arc = MockArcBroadcaster::failing();
11952        let (engine, arc_calls) = make_engine_with_arc(vec![0], arc);
11953
11954        let beef = test_tagged_beef(vec!["tm_test"]);
11955        // submit should succeed even when ARC fails
11956        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11957
11958        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11959
11960        // ARC was called (and failed), but submit still succeeded
11961        assert_eq!(
11962            arc_calls.lock().unwrap().len(),
11963            1,
11964            "ARC broadcaster should be attempted even if it will fail"
11965        );
11966    }
11967
11968    #[tokio::test]
11969    async fn test_arc_broadcaster_not_present_still_works() {
11970        // Engine without ARC broadcaster — should work fine
11971        let engine = make_engine(vec![0]);
11972
11973        let beef = test_tagged_beef(vec!["tm_test"]);
11974        let steak = engine.submit(&beef, SubmitMode::CurrentTx).await.unwrap();
11975
11976        assert!(!steak["tm_test"].outputs_to_admit.is_empty());
11977    }
11978
11979    // ── Sync advertisements URL validation ─────────────────────────────
11980
11981    /// Mock Advertiser that tracks created advertisements via shared state.
11982    struct TrackingAdvertiser {
11983        created: Arc<Mutex<Vec<AdvertisementData>>>,
11984        existing_ship: Vec<Advertisement>,
11985        existing_slap: Vec<Advertisement>,
11986        /// Topics the returned TaggedBEEF is tagged with — a topic with no
11987        /// registered manager forces the LOCAL submit to fail (#320 3a);
11988        /// several topics exercise independent per-topic admission (D1).
11989        tag_topics: Vec<String>,
11990        /// Simulate a create_advertisements failure (#320 3a).
11991        create_fails: bool,
11992        /// Simulate a find_all_advertisements failure (#320 M2).
11993        find_fails: bool,
11994        /// Return the v1 no-op EMPTY TaggedBEEF from revoke (#320 L2).
11995        revoke_empty: bool,
11996    }
11997
11998    impl TrackingAdvertiser {
11999        fn new() -> (Self, Arc<Mutex<Vec<AdvertisementData>>>) {
12000            let created = Arc::new(Mutex::new(Vec::new()));
12001            (
12002                Self {
12003                    created: created.clone(),
12004                    existing_ship: vec![],
12005                    existing_slap: vec![],
12006                    tag_topics: vec!["tm_test".to_string()],
12007                    create_fails: false,
12008                    find_fails: false,
12009                    revoke_empty: false,
12010                },
12011                created,
12012            )
12013        }
12014    }
12015
12016    use crate::advertiser::{Advertiser, AdvertiserError};
12017
12018    #[async_trait(?Send)]
12019    impl Advertiser for TrackingAdvertiser {
12020        async fn create_advertisements(
12021            &self,
12022            ads: &[AdvertisementData],
12023        ) -> Result<TaggedBEEF, AdvertiserError> {
12024            if self.create_fails {
12025                return Err(AdvertiserError::CreationFailed(
12026                    "mock create refused".into(),
12027                ));
12028            }
12029            self.created.lock().unwrap().extend(ads.iter().cloned());
12030            Ok(TaggedBEEF::new(test_beef(), self.tag_topics.clone()))
12031        }
12032
12033        async fn find_all_advertisements(
12034            &self,
12035            protocol: Protocol,
12036        ) -> Result<Vec<Advertisement>, AdvertiserError> {
12037            if self.find_fails {
12038                return Err(AdvertiserError::LookupFailed("mock lookup refused".into()));
12039            }
12040            match protocol {
12041                Protocol::Ship => Ok(self.existing_ship.clone()),
12042                Protocol::Slap => Ok(self.existing_slap.clone()),
12043            }
12044        }
12045
12046        async fn revoke_advertisements(
12047            &self,
12048            _ads: &[Advertisement],
12049        ) -> Result<TaggedBEEF, AdvertiserError> {
12050            if self.revoke_empty {
12051                return Ok(TaggedBEEF::new(vec![], vec![]));
12052            }
12053            Ok(TaggedBEEF::new(test_beef(), vec!["tm_test".to_string()]))
12054        }
12055
12056        fn parse_advertisement(&self, _script: &[u8]) -> Option<Advertisement> {
12057            None
12058        }
12059    }
12060
12061    /// TS: "Sync advertisements URL validation"
12062    /// sync_advertisements should be a no-op when hosting_url is empty string,
12063    /// and should skip suppressed topics (tm_ship/tm_slap) when configured.
12064    #[tokio::test]
12065    async fn test_sync_advertisements_url_validation() {
12066        // Case 1: Empty hosting URL — should return early without creating ads
12067        let (adv, created) = TrackingAdvertiser::new();
12068
12069        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12070        managers.insert(
12071            "tm_test".to_string(),
12072            Box::new(MockTopicManager::admitting(vec![0])),
12073        );
12074
12075        let engine = Engine::new(
12076            managers,
12077            HashMap::new(),
12078            Box::new(MemoryStorage::new()),
12079            Some(Box::new(adv)),
12080            EngineConfig {
12081                hosting_url: Some(String::new()), // empty string — invalid
12082                ..Default::default()
12083            },
12084        );
12085
12086        engine.sync_advertisements().await.unwrap();
12087        assert!(
12088            created.lock().unwrap().is_empty(),
12089            "Empty hosting URL should skip advertisement creation"
12090        );
12091
12092        // Case 2: Valid hosting URL — should create SHIP advertisements for non-suppressed topics
12093        let (adv2, created2) = TrackingAdvertiser::new();
12094
12095        let mut managers2: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12096        managers2.insert(
12097            "tm_test".to_string(),
12098            Box::new(MockTopicManager::admitting(vec![0])),
12099        );
12100        // Also register tm_ship to verify it gets suppressed
12101        managers2.insert(
12102            "tm_ship".to_string(),
12103            Box::new(MockTopicManager::admitting(vec![])),
12104        );
12105
12106        let engine2 = Engine::new(
12107            managers2,
12108            HashMap::new(),
12109            Box::new(MemoryStorage::new()),
12110            Some(Box::new(adv2)),
12111            EngineConfig {
12112                hosting_url: Some("https://valid.example.com".to_string()),
12113                suppress_default_sync_advertisements: true,
12114                ..Default::default()
12115            },
12116        );
12117
12118        engine2.sync_advertisements().await.unwrap();
12119        let ads = created2.lock().unwrap();
12120        // tm_ship should be suppressed, only tm_test should get an advertisement
12121        assert!(
12122            ads.iter().all(|a| a.topic_or_service_name != "tm_ship"),
12123            "tm_ship should be suppressed when suppress_default_sync_advertisements is true"
12124        );
12125        assert!(
12126            ads.iter().any(|a| a.topic_or_service_name == "tm_test"),
12127            "tm_test should get a SHIP advertisement"
12128        );
12129    }
12130
12131    // ── sync_advertisements report (bsv-low #320 defect 3a) ────────────
12132    //
12133    // Pre-#320 the engine returned `Ok(())` unconditionally: a failed
12134    // create or a failed LOCAL submit was `error!`-logged and invisible to
12135    // the caller — the admin route said `success` while the node's own
12136    // ls_ship/ls_slap never gained its ads, so every cycle re-created the
12137    // whole set. These cells pin the report contract.
12138
12139    fn sync_test_engine(adv: TrackingAdvertiser) -> Engine {
12140        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12141        managers.insert(
12142            "tm_test".to_string(),
12143            Box::new(MockTopicManager::admitting(vec![0])),
12144        );
12145        Engine::new(
12146            managers,
12147            HashMap::new(),
12148            Box::new(MemoryStorage::new()),
12149            Some(Box::new(adv)),
12150            EngineConfig {
12151                hosting_url: Some("https://valid.example.com".to_string()),
12152                suppress_default_sync_advertisements: true,
12153                ..Default::default()
12154            },
12155        )
12156    }
12157
12158    #[tokio::test]
12159    async fn sync_report_counts_admitted_outputs_on_success() {
12160        let (adv, _) = TrackingAdvertiser::new();
12161        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12162        assert!(report.ok(), "clean run must report ok: {report:?}");
12163        assert_eq!(report.to_create, 1, "tm_test was missing");
12164        assert_eq!(
12165            report.admitted.get("tm_test"),
12166            Some(&1),
12167            "the STEAK's admitted count must be surfaced: {report:?}"
12168        );
12169    }
12170
12171    #[tokio::test]
12172    async fn sync_report_surfaces_local_submit_failure() {
12173        // The TaggedBEEF comes back tagged for a topic with no registered
12174        // manager → the LOCAL submit fails. That failure must be in the
12175        // report, not swallowed (the live silent-failure shape).
12176        let (mut adv, _) = TrackingAdvertiser::new();
12177        adv.tag_topics = vec!["tm_unregistered".to_string()];
12178        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12179        assert!(!report.ok(), "a failed local submit must not report ok");
12180        assert_eq!(report.to_create, 1);
12181        let submit_error = report.submit_error.as_deref().unwrap_or_default();
12182        assert!(
12183            submit_error.contains("tm_unregistered"),
12184            "verbatim engine error expected, got: {submit_error}"
12185        );
12186        assert!(report.admitted.is_empty());
12187    }
12188
12189    #[tokio::test]
12190    async fn sync_report_surfaces_create_failure() {
12191        let (mut adv, _) = TrackingAdvertiser::new();
12192        adv.create_fails = true;
12193        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12194        assert!(!report.ok());
12195        assert!(
12196            report
12197                .create_error
12198                .as_deref()
12199                .unwrap_or_default()
12200                .contains("mock create refused"),
12201            "{report:?}"
12202        );
12203        assert!(report.submit_error.is_none(), "create failed before submit");
12204    }
12205
12206    #[tokio::test]
12207    async fn sync_report_converged_noop_is_ok_and_creates_nothing() {
12208        // A node whose own storage already lists its ads must attempt ZERO
12209        // creates — the convergence direction of #320 defect 3 (live, the
12210        // missing self-admission made every cycle re-create all 17 ads).
12211        let (mut adv, created) = TrackingAdvertiser::new();
12212        adv.existing_ship = vec![Advertisement {
12213            protocol: Protocol::Ship,
12214            identity_key: "02aa".to_string(),
12215            domain: "https://valid.example.com".to_string(),
12216            topic_or_service: "tm_test".to_string(),
12217            beef: None,
12218            output_index: None,
12219        }];
12220        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12221        assert!(report.ok(), "{report:?}");
12222        assert_eq!(report.to_create, 0, "converged ⇒ nothing to create");
12223        assert!(created.lock().unwrap().is_empty());
12224        assert!(
12225            report.effective(),
12226            "converged-noop is the one zero-admit shape that IS success"
12227        );
12228    }
12229
12230    #[tokio::test]
12231    async fn sync_report_zero_admit_is_ok_but_not_effective() {
12232        // #320 M1: the local submit SUCCEEDS but the topic manager admits
12233        // ZERO outputs — a non-error refusal. The ads never enter our own
12234        // ls_ship/ls_slap, so the diff re-creates (re-pays) the full set
12235        // next cycle. `ok()` (errors-only) stays true; `effective()` must
12236        // refuse, and the route keys success on `effective()`.
12237        let (adv, _) = TrackingAdvertiser::new();
12238        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12239        managers.insert(
12240            "tm_test".to_string(),
12241            Box::new(MockTopicManager::admitting(vec![])), // refuses everything
12242        );
12243        let engine = Engine::new(
12244            managers,
12245            HashMap::new(),
12246            Box::new(MemoryStorage::new()),
12247            Some(Box::new(adv)),
12248            EngineConfig {
12249                hosting_url: Some("https://valid.example.com".to_string()),
12250                suppress_default_sync_advertisements: true,
12251                ..Default::default()
12252            },
12253        );
12254        let report = engine.sync_advertisements().await.unwrap();
12255        assert!(report.ok(), "no ERROR occurred: {report:?}");
12256        assert_eq!(report.to_create, 1);
12257        assert_eq!(report.admitted_total(), 0, "{report:?}");
12258        assert!(
12259            !report.effective(),
12260            "zero admitted on a non-empty create must not read as success: {report:?}"
12261        );
12262    }
12263
12264    #[tokio::test]
12265    async fn sync_report_partial_admit_is_not_effective() {
12266        // Delta D1: TWO topics in one submitted tx, per-topic admission
12267        // independent — tm_test admits its output, tm_refuse refuses all.
12268        // admitted_total (1) > 0 but < to_create (2): the refused ads
12269        // would be re-created (re-paid) every cycle, so this must NOT
12270        // read as success.
12271        let (mut adv, _) = TrackingAdvertiser::new();
12272        adv.tag_topics = vec!["tm_test".to_string(), "tm_refuse".to_string()];
12273        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
12274        managers.insert(
12275            "tm_test".to_string(),
12276            Box::new(MockTopicManager::admitting(vec![0])),
12277        );
12278        managers.insert(
12279            "tm_refuse".to_string(),
12280            Box::new(MockTopicManager::admitting(vec![])), // refuses everything
12281        );
12282        let engine = Engine::new(
12283            managers,
12284            HashMap::new(),
12285            Box::new(MemoryStorage::new()),
12286            Some(Box::new(adv)),
12287            EngineConfig {
12288                hosting_url: Some("https://valid.example.com".to_string()),
12289                suppress_default_sync_advertisements: true,
12290                ..Default::default()
12291            },
12292        );
12293        let report = engine.sync_advertisements().await.unwrap();
12294        assert!(report.ok(), "no ERROR occurred: {report:?}");
12295        assert_eq!(report.to_create, 2, "{report:?}");
12296        assert_eq!(report.admitted_total(), 1, "{report:?}");
12297        assert!(
12298            !report.effective(),
12299            "a partial admit must not read as success: {report:?}"
12300        );
12301    }
12302
12303    #[tokio::test]
12304    async fn sync_report_lookup_failure_refuses_creation() {
12305        // #320 M2: a transient read failure must never become "current ads
12306        // = none" and re-create (re-pay) the whole set — creation is
12307        // REFUSED and the failure lands in the report.
12308        let (mut adv, created) = TrackingAdvertiser::new();
12309        adv.find_fails = true;
12310        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12311        assert!(!report.ok(), "{report:?}");
12312        assert!(!report.effective());
12313        assert!(
12314            report
12315                .lookup_error
12316                .as_deref()
12317                .unwrap_or_default()
12318                .contains("mock lookup refused"),
12319            "{report:?}"
12320        );
12321        assert_eq!(report.to_create, 0, "creation refused, not attempted");
12322        assert!(
12323            created.lock().unwrap().is_empty(),
12324            "no create call on a blind read"
12325        );
12326        assert!(report.submit_error.is_none());
12327    }
12328
12329    #[tokio::test]
12330    async fn sync_report_counts_skipped_revocations() {
12331        // #320 L2: `to_revoke: N` with a v1 no-op advertiser must be
12332        // distinguishable from a revoke that actually ran.
12333        let (mut adv, _) = TrackingAdvertiser::new();
12334        adv.revoke_empty = true;
12335        adv.existing_ship = vec![
12336            Advertisement {
12337                protocol: Protocol::Ship,
12338                identity_key: "02aa".to_string(),
12339                domain: "https://valid.example.com".to_string(),
12340                topic_or_service: "tm_test".to_string(),
12341                beef: None,
12342                output_index: None,
12343            },
12344            Advertisement {
12345                protocol: Protocol::Ship,
12346                identity_key: "02aa".to_string(),
12347                domain: "https://valid.example.com".to_string(),
12348                topic_or_service: "tm_stale".to_string(), // no longer configured
12349                beef: None,
12350                output_index: None,
12351            },
12352        ];
12353        let report = sync_test_engine(adv).sync_advertisements().await.unwrap();
12354        assert!(report.ok(), "{report:?}");
12355        assert_eq!(report.to_revoke, 1, "tm_stale is stale");
12356        assert_eq!(
12357            report.revoke_skipped, 1,
12358            "the v1 no-op must be visible, not silent: {report:?}"
12359        );
12360        assert!(report.revoke_error.is_none());
12361        assert!(report.revoke_submit_error.is_none());
12362    }
12363
12364    // ── complete_missing_proofs (#130) ─────────────────────────────────
12365
12366    use crate::gasp::{AncestorFetcher, FetchedAncestor, GASPError};
12367    use std::cell::Cell;
12368
12369    /// A single raw mainnet tx (no proof) — used to build a proofless BEEF.
12370    const PROOFLESS_RAW_TX: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12371
12372    /// Build a proofless single-tx BEEF + return `(beef_bytes, txid)`.
12373    fn proofless_beef() -> (Vec<u8>, String) {
12374        use bsv_rs::transaction::{Beef, Transaction};
12375        let tx = Transaction::from_hex(PROOFLESS_RAW_TX).unwrap();
12376        let txid = tx.id();
12377        let mut beef = Beef::new();
12378        beef.merge_transaction(tx);
12379        (beef.to_binary(), txid)
12380    }
12381
12382    // ── #284: stitching a proof must never rebuild the stored BEEF ─────────
12383
12384    /// The corruption shape observed live on rust-beta: a stored BEEF whose
12385    /// SUBJECT is not the last tx in wire order. The old rewrite went through
12386    /// `Transaction::from_beef(bytes, None)`, whose None arm picks
12387    /// `txs.last()` — the PARENT here — and `to_beef(true)` then serialized the
12388    /// parent's world, dropping the subject from its own row. TEST_BEEF_HEX is
12389    /// exactly a two-tx chain (parent + child spending it), so re-encoding it
12390    /// child-first reproduces the trigger byte-for-byte.
12391    fn subject_not_last_beef() -> (Vec<u8>, String, String) {
12392        use bsv_rs::transaction::Beef;
12393        let parsed = Beef::from_binary(&test_beef()).unwrap();
12394        let parent = parsed.txs[0].tx().unwrap().clone();
12395        let child = parsed.txs[1].tx().unwrap().clone();
12396        let (parent_id, child_id) = (parent.id(), child.id());
12397        assert_ne!(parent_id, child_id);
12398
12399        // Serialize via to_writer, NOT to_binary: to_binary re-sorts
12400        // parents-first (subject last), which is precisely why bsv_rs's own
12401        // output never triggers the bug. The corrupt rows held WALLET
12402        // serialized bytes stored verbatim at admit time, whose wire order is
12403        // whatever the wallet emitted — model that by writing the child first.
12404        let mut beef = Beef::new();
12405        beef.merge_transaction(child); // subject FIRST — the trigger
12406        beef.merge_transaction(parent);
12407        let mut w = bsv_rs::primitives::encoding::Writer::new();
12408        beef.to_writer(&mut w);
12409        let bytes = w.into_bytes();
12410
12411        // The premise the whole test rests on: wire order preserved, subject
12412        // genuinely not last.
12413        let reread = Beef::from_binary(&bytes).unwrap();
12414        assert_eq!(reread.txs.last().unwrap().txid(), parent_id);
12415        (bytes, child_id, parent_id)
12416    }
12417
12418    #[test]
12419    fn stitch_preserves_the_subject_when_it_is_not_last() {
12420        use bsv_rs::transaction::{Beef, MerklePath};
12421        let (stored, child_id, parent_id) = subject_not_last_beef();
12422        let proof = MerklePath::from_hex(&single_leaf_bump_hex(&child_id, 900_000)).unwrap();
12423
12424        let out = Engine::stitch_proof_into_stored_beef(&stored, &child_id, &proof)
12425            .expect("stitch must succeed on a beef that contains the txid");
12426
12427        let after = Beef::from_binary(&out).unwrap();
12428        assert!(
12429            after.find_txid(&child_id).is_some(),
12430            "the SUBJECT must survive its own proof-completion"
12431        );
12432        assert!(
12433            after.find_txid(&parent_id).is_some(),
12434            "the ancestry must survive too"
12435        );
12436        assert!(
12437            after.find_bump(&child_id).is_some(),
12438            "the stitched bump must prove the subject"
12439        );
12440        assert!(
12441            after.find_bump(&parent_id).is_none(),
12442            "the parent must NOT be claimed by the child's bump"
12443        );
12444    }
12445
12446    /// The old path, reproduced against the same input, to pin WHY the new one
12447    /// exists: it demonstrably drops the subject. If bsv_rs ever changes
12448    /// `from_beef(_, None)` to honor the subject, this starts failing and the
12449    /// comment trail can be revisited — until then it documents the hazard.
12450    #[test]
12451    fn the_old_round_trip_demonstrably_dropped_the_subject() {
12452        use bsv_rs::transaction::{Beef, Transaction};
12453        let (stored, child_id, _parent_id) = subject_not_last_beef();
12454        let tx = Transaction::from_beef(&stored, None).unwrap();
12455        assert_ne!(
12456            tx.id(),
12457            child_id,
12458            "from_beef(None) picks txs.last(), not the subject — the trigger"
12459        );
12460        let rebuilt = tx.to_beef(true).unwrap();
12461        let reparsed = Beef::from_binary(&rebuilt).unwrap();
12462        assert!(
12463            reparsed.find_txid(&child_id).is_none(),
12464            "the rebuild loses the subject — exactly the #284 artifact"
12465        );
12466    }
12467
12468    /// bsv-low M19 R2 round 3 (review HIGH-1): a stored BEEF whose SUBJECT
12469    /// sits on a stale (orphan) bump at height H, re-stitched with a
12470    /// same-height DIFFERENT-root proof, must serialize with the subject on
12471    /// the NEW bump and the orphan bump dropped — otherwise the hop leg
12472    /// churns and re-latches the orphan proof forever.
12473    #[test]
12474    fn stitch_reanchors_a_subject_off_a_same_height_orphan_bump() {
12475        use bsv_rs::transaction::{Beef, BeefTx, MerklePath, MerklePathLeaf, Transaction};
12476        // subject spends a real parent; both are raw in the BEEF
12477        let parent = Transaction::from_hex(RAW_PARENT).unwrap();
12478        let parent_id = parent.id();
12479        let subject = child_of(&parent_id, 0);
12480        let subject_id = subject.id();
12481        // the ORPHAN bump: a two-leaf block at H so its root is NOT the txid,
12482        // and it differs from the canonical block's root
12483        let orphan = MerklePath::new_unchecked(
12484            965_771,
12485            vec![vec![
12486                MerklePathLeaf::new_txid(0, subject_id.clone()),
12487                MerklePathLeaf::new(1, "aa".repeat(32)),
12488            ]],
12489        )
12490        .unwrap();
12491        let mut beef = Beef::new();
12492        let bi = beef.merge_bump(orphan.clone());
12493        beef.merge_raw_tx(parent.to_binary(), None);
12494        beef.merge_raw_tx(subject.to_binary(), Some(bi));
12495        let orphan_root = orphan.compute_root(Some(&subject_id)).unwrap();
12496        let stored = beef.to_binary();
12497        assert_eq!(
12498            Beef::from_binary(&stored)
12499                .unwrap()
12500                .find_bump(&subject_id)
12501                .map(|b| b.compute_root(Some(&subject_id)).unwrap()),
12502            Some(orphan_root.clone())
12503        );
12504        // the canonical proof at the SAME height, a DIFFERENT root
12505        let canonical = MerklePath::new_unchecked(
12506            965_771,
12507            vec![vec![
12508                MerklePathLeaf::new_txid(0, subject_id.clone()),
12509                MerklePathLeaf::new(1, "bb".repeat(32)),
12510            ]],
12511        )
12512        .unwrap();
12513        let canonical_root = canonical.compute_root(Some(&subject_id)).unwrap();
12514        assert_ne!(
12515            canonical_root, orphan_root,
12516            "the reorg shape: same height, new root"
12517        );
12518        let out = Engine::stitch_proof_into_stored_beef(&stored, &subject_id, &canonical).unwrap();
12519        let after = Beef::from_binary(&out).unwrap();
12520        // the subject's OWN bump is the canonical one
12521        let own = after
12522            .find_txid(&subject_id)
12523            .and_then(BeefTx::bump_index)
12524            .and_then(|bi| after.bumps.get(bi))
12525            .unwrap();
12526        assert_eq!(
12527            own.compute_root(Some(&subject_id)).unwrap(),
12528            canonical_root,
12529            "the subject re-anchored to the new bump"
12530        );
12531        // the orphan bump is gone (only the canonical one remains)
12532        assert_eq!(
12533            after.bumps.len(),
12534            1,
12535            "the unreferenced orphan bump was dropped"
12536        );
12537        // and a reader that uses the tx's OWN bump sees canonical, never orphan
12538        assert!(after.find_txid(&parent_id).is_some(), "ancestry preserved");
12539    }
12540
12541    #[test]
12542    fn stitch_refuses_a_beef_that_lacks_the_txid() {
12543        use bsv_rs::transaction::MerklePath;
12544        let (stored, _c, _p) = subject_not_last_beef();
12545        let foreign = "aa".repeat(32);
12546        let proof = MerklePath::from_hex(&single_leaf_bump_hex(&foreign, 900_000)).unwrap();
12547        assert!(
12548            Engine::stitch_proof_into_stored_beef(&stored, &foreign, &proof).is_none(),
12549            "no txid in the beef -> no write, never garbage"
12550        );
12551    }
12552
12553    /// A real mainnet raw tx, the unproven parent for the re-anchor pin.
12554    const RAW_PARENT: &str = "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000";
12555
12556    /// A minimal raw tx spending `source:vout` with one OP_TRUE output.
12557    fn child_of(source_txid: &str, vout: u32) -> bsv_rs::transaction::Transaction {
12558        let mut sx = String::from("0100000001");
12559        let mut prev = hex::decode(source_txid).unwrap();
12560        prev.reverse();
12561        sx.push_str(&hex::encode(prev));
12562        sx.push_str(&hex::encode(vout.to_le_bytes()));
12563        sx.push_str("00ffffffff01");
12564        sx.push_str(&hex::encode(1000u64.to_le_bytes()));
12565        sx.push_str("015100000000");
12566        bsv_rs::transaction::Transaction::from_hex(&sx).unwrap()
12567    }
12568
12569    /// A minimal valid single-leaf BUMP hex proving `txid` at `height`.
12570    fn single_leaf_bump_hex(txid: &str, height: u32) -> String {
12571        use bsv_rs::transaction::{MerklePath, MerklePathLeaf};
12572        let leaf = MerklePathLeaf::new_txid(0, txid.to_string());
12573        MerklePath::new_unchecked(height, vec![vec![leaf]])
12574            .unwrap()
12575            .to_hex()
12576    }
12577
12578    /// Mock fetcher: returns a configured proof (or none) for any txid, and
12579    /// counts how many times it was asked.
12580    struct StubFetcher {
12581        proof: Option<String>,
12582        calls: Cell<u32>,
12583    }
12584
12585    #[async_trait(?Send)]
12586    impl AncestorFetcher for StubFetcher {
12587        async fn fetch_ancestor(&self, _txid: &str) -> Result<FetchedAncestor, GASPError> {
12588            self.calls.set(self.calls.get() + 1);
12589            Ok(FetchedAncestor {
12590                raw_tx: PROOFLESS_RAW_TX.to_string(),
12591                proof: self.proof.clone(),
12592            })
12593        }
12594
12595        /// Models a fetcher whose header source CONFIRMS a stored bump — so the
12596        /// already-structurally-proven re-verify gate (the window-clog path)
12597        /// latches without a re-fetch, exactly as before the #192/#193 hardening.
12598        /// (`verified_proof_for` is left at its default: it delegates to
12599        /// `fetch_ancestor` above, so the `calls` counter still tracks fetches.)
12600        async fn verify_proof(&self, _txid: &str, _bump_hex: &str) -> bool {
12601            true
12602        }
12603    }
12604
12605    /// Build an engine over a storage holding one proofless output, with an
12606    /// optional ancestor fetcher.
12607    async fn engine_with_proofless_output(
12608        fetcher: Option<std::rc::Rc<StubFetcher>>,
12609    ) -> (Engine, String) {
12610        let (beef, txid) = proofless_beef();
12611        let storage = MemoryStorage::new();
12612        storage
12613            .insert_output(&Output {
12614                txid: txid.clone(),
12615                output_index: 0,
12616                output_script: vec![0x76],
12617                satoshis: 1_000_000_000,
12618                topic: "Hello".to_string(),
12619                spent: false,
12620                outputs_consumed: vec![],
12621                consumed_by: vec![],
12622                beef: Some(beef),
12623                block_height: None,
12624                score: Some(1.0),
12625            })
12626            .await
12627            .unwrap();
12628
12629        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12630        managers.insert(
12631            "Hello".into(),
12632            Box::new(MockTopicManager::admitting(vec![0])),
12633        );
12634        let mut engine = Engine::new(
12635            managers,
12636            HashMap::new(),
12637            Box::new(storage),
12638            None,
12639            EngineConfig::default(),
12640        );
12641        if let Some(f) = fetcher {
12642            engine.set_ancestor_fetcher(f);
12643        }
12644        (engine, txid)
12645    }
12646
12647    #[tokio::test]
12648    async fn proofs_completion_uses_dedicated_courier() {
12649        let (_, txid) = proofless_beef();
12650        let shared = std::rc::Rc::new(StubFetcher {
12651            proof: None,
12652            calls: Cell::new(0),
12653        });
12654        let dedicated = StubFetcher {
12655            proof: Some(single_leaf_bump_hex(&txid, 850_000)),
12656            calls: Cell::new(0),
12657        };
12658        let (engine, _) = engine_with_proofless_output(Some(shared.clone())).await;
12659        assert_eq!(
12660            engine
12661                .complete_missing_proofs(1, 0)
12662                .await
12663                .unwrap()
12664                .completed,
12665            0
12666        );
12667        assert_eq!(
12668            engine
12669                .complete_missing_proofs_with(&dedicated, 1, 0)
12670                .await
12671                .unwrap()
12672                .completed,
12673            1
12674        );
12675        assert_eq!(shared.calls.get(), 1);
12676        assert_eq!(dedicated.calls.get(), 1);
12677        let output = engine
12678            .storage()
12679            .find_output(&txid, 0, Some("Hello"), None, true)
12680            .await
12681            .unwrap()
12682            .unwrap();
12683        assert!(
12684            crate::beef_limits::own_proof(output.beef.as_ref().unwrap(), &txid)
12685                .unwrap()
12686                .is_some()
12687        );
12688    }
12689
12690    #[tokio::test]
12691    async fn complete_missing_proofs_noop_without_fetcher() {
12692        // No ancestor fetcher configured → pure no-op (production default).
12693        let (engine, _txid) = engine_with_proofless_output(None).await;
12694        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12695        assert_eq!(summary, ProofCompletionSummary::default());
12696    }
12697
12698    #[tokio::test]
12699    async fn complete_missing_proofs_stitches_when_fetcher_returns_proof() {
12700        let (_beef, txid) = proofless_beef();
12701        let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12702        let fetcher = std::rc::Rc::new(StubFetcher {
12703            proof: Some(proof_hex),
12704            calls: Cell::new(0),
12705        });
12706        let (engine, txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12707
12708        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12709        assert_eq!(summary.scanned, 1);
12710        assert_eq!(summary.proofless, 1);
12711        assert_eq!(
12712            summary.completed, 1,
12713            "the proofless BEEF should be completed"
12714        );
12715        assert_eq!(fetcher.calls.get(), 1);
12716
12717        // The stored BEEF now carries a proof for the target tx + the output
12718        // got its block height.
12719        let out = engine
12720            .storage()
12721            .find_output(&txid, 0, Some("Hello"), None, true)
12722            .await
12723            .unwrap()
12724            .unwrap();
12725        assert_eq!(out.block_height, Some(850_000));
12726        let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
12727        assert!(
12728            beef.find_txid(&txid)
12729                .is_some_and(bsv_rs::transaction::BeefTx::has_proof),
12730            "completed BEEF must prove the target tx"
12731        );
12732
12733        // A second pass is a no-op: the tx is now proven, so it is not counted
12734        // proofless and the fetcher is not asked again.
12735        let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
12736        assert_eq!(summary2.proofless, 0);
12737        assert_eq!(summary2.completed, 0);
12738        assert_eq!(fetcher.calls.get(), 1, "no re-fetch once proven");
12739    }
12740
12741    #[tokio::test]
12742    async fn complete_missing_proofs_skips_unconfirmed() {
12743        // Fetcher reachable but the tx is unmined (no BUMP) → skipped, not
12744        // errored, and counted still_unconfirmed for retry.
12745        let fetcher = std::rc::Rc::new(StubFetcher {
12746            proof: None,
12747            calls: Cell::new(0),
12748        });
12749        let (engine, _txid) = engine_with_proofless_output(Some(fetcher.clone())).await;
12750        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12751        assert_eq!(summary.proofless, 1);
12752        assert_eq!(summary.completed, 0);
12753        assert_eq!(summary.still_unconfirmed, 1);
12754        assert_eq!(fetcher.calls.get(), 1);
12755    }
12756
12757    /// Build an engine like [`engine_with_proofless_output`] but keep a shared
12758    /// handle to the `MemoryStorage` (via the `Rc<T: Storage>` delegation) so
12759    /// the test can advance its deterministic clock — the push-primary
12760    /// backstop age-gate tests (#228) need to age rows without sleeping.
12761    async fn engine_with_proofless_output_shared(
12762        fetcher: Option<std::rc::Rc<StubFetcher>>,
12763    ) -> (Engine, String, std::rc::Rc<MemoryStorage>) {
12764        let (beef, txid) = proofless_beef();
12765        let storage = std::rc::Rc::new(MemoryStorage::new());
12766        storage
12767            .insert_output(&Output {
12768                txid: txid.clone(),
12769                output_index: 0,
12770                output_script: vec![0x76],
12771                satoshis: 1_000_000_000,
12772                topic: "Hello".to_string(),
12773                spent: false,
12774                outputs_consumed: vec![],
12775                consumed_by: vec![],
12776                beef: Some(beef),
12777                block_height: None,
12778                score: Some(1.0),
12779            })
12780            .await
12781            .unwrap();
12782
12783        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12784        managers.insert(
12785            "Hello".into(),
12786            Box::new(MockTopicManager::admitting(vec![0])),
12787        );
12788        let mut engine = Engine::new(
12789            managers,
12790            HashMap::new(),
12791            Box::new(storage.clone()),
12792            None,
12793            EngineConfig::default(),
12794        );
12795        if let Some(f) = fetcher {
12796            engine.set_ancestor_fetcher(f);
12797        }
12798        (engine, txid, storage)
12799    }
12800
12801    #[tokio::test]
12802    async fn complete_missing_proofs_age_gate_young_rows_wait_for_the_push() {
12803        // Push-primary backstop (#228): a FRESHLY stored proofless tx must NOT
12804        // be polled — its proof is expected via /arc-ingest at push speed. The
12805        // gated pass scans nothing and never asks the fetcher.
12806        let proof_hex = {
12807            let (_beef, txid) = proofless_beef();
12808            single_leaf_bump_hex(&txid, 850_000)
12809        };
12810        let fetcher = std::rc::Rc::new(StubFetcher {
12811            proof: Some(proof_hex),
12812            calls: Cell::new(0),
12813        });
12814        let (engine, _txid, storage) =
12815            engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12816
12817        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12818        assert_eq!(summary.scanned, 0, "a young row is skipped entirely");
12819        assert_eq!(fetcher.calls.get(), 0, "no courier fetch for a young row");
12820
12821        // WEBHOOK-OUTAGE DEGRADATION: no push ever arrives. Once the row is
12822        // older than the gate, the SAME backstop pass polls and completes it
12823        // exactly as the pre-#228 behaviour — degradation is to polling,
12824        // never to nothing.
12825        storage.advance_clock(1800);
12826        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12827        assert_eq!(summary.scanned, 1, "an old-enough row is always polled");
12828        assert_eq!(summary.completed, 1, "the backstop completes the proof");
12829        assert_eq!(fetcher.calls.get(), 1);
12830    }
12831
12832    #[tokio::test]
12833    async fn complete_missing_proofs_skips_tx_whose_proof_was_pushed() {
12834        // Pushed-proof-then-chaser-skips (#228): a proof arriving via the real
12835        // /arc-ingest producer path (`handle_new_merkle_proof` → the
12836        // `update_transaction_beef` stitch, which latches `has_proof`) drops
12837        // the tx out of the poll candidate set entirely — even after the age
12838        // gate opens, the chaser never asks the fetcher about it.
12839        let fetcher = std::rc::Rc::new(StubFetcher {
12840            proof: None,
12841            calls: Cell::new(0),
12842        });
12843        let (engine, txid, storage) =
12844            engine_with_proofless_output_shared(Some(fetcher.clone())).await;
12845
12846        // The push lands (arc-ingest calls exactly this after its own
12847        // chaintracks verify).
12848        let proof_hex = single_leaf_bump_hex(&txid, 850_000);
12849        engine
12850            .handle_new_merkle_proof(&txid, &proof_hex, Some(850_000))
12851            .await
12852            .unwrap();
12853
12854        // Age the row well past the backstop gate: still skipped — the pushed
12855        // proof, not the age, is what removes it from the chaser's world.
12856        storage.advance_clock(1_000_000);
12857        let summary = engine.complete_missing_proofs(50, 1800).await.unwrap();
12858        assert_eq!(summary.scanned, 0, "a pushed-proof tx is never re-polled");
12859        assert_eq!(summary.completed, 0);
12860        assert_eq!(fetcher.calls.get(), 0, "the chaser must skip it entirely");
12861    }
12862
12863    #[tokio::test]
12864    async fn complete_missing_proofs_respects_limit() {
12865        // Two proofless txs, limit 1 → only one row is scanned this tick.
12866        let (beef1, txid1) = proofless_beef();
12867        // A second distinct proofless tx (different raw tx → different txid).
12868        let raw2 = "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000";
12869        let (beef2, txid2) = {
12870            use bsv_rs::transaction::{Beef, Transaction};
12871            let tx = Transaction::from_hex(raw2).unwrap();
12872            let id = tx.id();
12873            let mut b = Beef::new();
12874            b.merge_transaction(tx);
12875            (b.to_binary(), id)
12876        };
12877
12878        let storage = MemoryStorage::new();
12879        for (txid, beef) in [(&txid1, beef1), (&txid2, beef2)] {
12880            storage
12881                .insert_output(&Output {
12882                    txid: txid.clone(),
12883                    output_index: 0,
12884                    output_script: vec![0x76],
12885                    satoshis: 1000,
12886                    topic: "Hello".to_string(),
12887                    spent: false,
12888                    outputs_consumed: vec![],
12889                    consumed_by: vec![],
12890                    beef: Some(beef),
12891                    block_height: None,
12892                    score: Some(1.0),
12893                })
12894                .await
12895                .unwrap();
12896        }
12897
12898        let fetcher = std::rc::Rc::new(StubFetcher {
12899            proof: None,
12900            calls: Cell::new(0),
12901        });
12902        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12903        managers.insert(
12904            "Hello".into(),
12905            Box::new(MockTopicManager::admitting(vec![0])),
12906        );
12907        let mut engine = Engine::new(
12908            managers,
12909            HashMap::new(),
12910            Box::new(storage),
12911            None,
12912            EngineConfig::default(),
12913        );
12914        engine.set_ancestor_fetcher(fetcher.clone());
12915
12916        let summary = engine.complete_missing_proofs(1, 0).await.unwrap();
12917        assert_eq!(summary.scanned, 1, "limit 1 → only one row scanned");
12918    }
12919
12920    #[tokio::test]
12921    async fn complete_missing_proofs_marks_already_proven_rows_so_window_advances() {
12922        // Window-clog regression (#130): migration 0010 defaulted EVERY
12923        // existing `transactions` row to has_proof = 0, including rows whose
12924        // stored BEEF already carries a proof (e.g. GASP-synced txs). The
12925        // candidate query (`WHERE has_proof = 0 LIMIT n`) returns those rows
12926        // every tick; the engine skips them but — before this fix — never
12927        // flipped the flag, so they lingered in the bounded window forever and
12928        // starved genuinely-proofless rows behind them. The fix marks each
12929        // already-proven scanned row so it drops out of the NEXT pass.
12930        use bsv_rs::transaction::{Beef, MerklePath, Transaction};
12931
12932        let storage = MemoryStorage::new();
12933
12934        // Seed several ALREADY-PROVEN rows. They are inserted via
12935        // `insert_output`, which (modeling migration 0010's default) does NOT
12936        // set the proof flag — so despite carrying a valid proof they show up
12937        // as has_proof = 0 candidates: the exact clog.
12938        let proven_raws = [
12939            "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
12940            "0100000001c997a5e56e104102fa209c6a852dd90660a20b2d9c352423edce25857fcd3704000000004847304402204e45e16932b8af514961a1d3a1a25fdf3f4f7732e9d624c6c61548ab5fb8cd410220181522ec8eca07de4860a4acdd12909d831cc56cbbac4622082221a8768d1d0901ffffffff0200ca9a3b00000000434104ae1a62fe09c5f51b13905f07f06b99a2f7159b2225f374cd378d71302fa28414e7aab37397f554a7df5f142c21c1b7303b8a0626f1baded5c72a704f7e6cd84cac00286bee0000000043410411db93e1dcdb8a016b49840f8c53bc1eb68a382e97b1482ecad7b148a6909a5cb2e0eaddfb84ccf9744464f82e160bfa9b8b64f9d4c03f999b8643f656b412a3ac00000000",
12941        ];
12942        let mut proven_ids = Vec::new();
12943        for raw in proven_raws {
12944            let mut tx = Transaction::from_hex(raw).unwrap();
12945            let id = tx.id();
12946            tx.merkle_path =
12947                Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
12948            // to_beef(true) carries the merkle proof into the BEEF, so the row
12949            // genuinely IS proven — yet its flag stays 0 (insert path).
12950            let proven_beef = tx.to_beef(true).unwrap();
12951            assert_eq!(
12952                Beef::from_binary(&proven_beef)
12953                    .unwrap()
12954                    .find_txid(&id)
12955                    .map(bsv_rs::transaction::BeefTx::has_proof),
12956                Some(true),
12957                "proven fixture row must carry its proof"
12958            );
12959            proven_ids.push(id.clone());
12960            storage
12961                .insert_output(&Output {
12962                    txid: id,
12963                    output_index: 0,
12964                    output_script: vec![0x76],
12965                    satoshis: 1000,
12966                    topic: "Hello".to_string(),
12967                    spent: false,
12968                    outputs_consumed: vec![],
12969                    consumed_by: vec![],
12970                    beef: Some(proven_beef),
12971                    block_height: Some(800_000),
12972                    score: Some(1.0),
12973                })
12974                .await
12975                .unwrap();
12976        }
12977
12978        let fetcher = std::rc::Rc::new(StubFetcher {
12979            proof: None,
12980            calls: Cell::new(0),
12981        });
12982        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
12983        managers.insert(
12984            "Hello".into(),
12985            Box::new(MockTopicManager::admitting(vec![0])),
12986        );
12987        let mut engine = Engine::new(
12988            managers,
12989            HashMap::new(),
12990            Box::new(storage),
12991            None,
12992            EngineConfig::default(),
12993        );
12994        engine.set_ancestor_fetcher(fetcher.clone());
12995
12996        // First pass over a window covering both proven rows: each is detected
12997        // already-proven and marked. Nothing proofless, nothing fetched.
12998        let summary = engine.complete_missing_proofs(50, 0).await.unwrap();
12999        assert_eq!(summary.scanned, proven_ids.len());
13000        assert_eq!(summary.proofless, 0);
13001        assert_eq!(summary.completed, 0);
13002        assert_eq!(
13003            summary.already_proven,
13004            proven_ids.len(),
13005            "every already-proven row is marked this pass"
13006        );
13007        assert_eq!(fetcher.calls.get(), 0, "no fetch for already-proven rows");
13008
13009        // Window has advanced: the NEXT candidate query no longer returns the
13010        // marked rows, so the pass scans nothing.
13011        let summary2 = engine.complete_missing_proofs(50, 0).await.unwrap();
13012        assert_eq!(summary2.scanned, 0, "marked rows dropped out of the window");
13013        assert_eq!(summary2.already_proven, 0);
13014    }
13015
13016    #[tokio::test]
13017    async fn complete_missing_proofs_reaches_proofless_tx_behind_clogging_proven_rows() {
13018        // The window-clog in action with a small limit: a genuinely-proofless
13019        // backlog row sits behind already-proven (stale-flag) rows. Each tick
13020        // marks the proven rows it scans (dropping them out), so over a few
13021        // bounded ticks the window advances until the proofless tx is reached
13022        // + completed — instead of being starved forever.
13023        use bsv_rs::transaction::{MerklePath, Transaction};
13024
13025        let storage = MemoryStorage::new();
13026
13027        let proven_raws = [
13028            "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff2803dc7e0e0499170e6a0003cf341b017e0000152f476f72696c6c61506f6f6c2e696f20f09fa68d2f0000000003000000000000000032006a0547504f4f4c08dc7e0e0000000000200158a2360a03939451e72c3a9302f5d48712bf54a5b2edf8f3c69aed35a668e312236000000000001976a914068a58835bb93b152c901ffb18f6578824f9d5b788ac6eb66612000000001976a91402fd5a91155231d5799e2d22c490d1664cde62cb88ac00000000",
13029        ];
13030        for raw in proven_raws {
13031            let mut tx = Transaction::from_hex(raw).unwrap();
13032            let id = tx.id();
13033            tx.merkle_path =
13034                Some(MerklePath::from_hex(&single_leaf_bump_hex(&id, 800_000)).unwrap());
13035            let proven_beef = tx.to_beef(true).unwrap();
13036            storage
13037                .insert_output(&Output {
13038                    txid: id,
13039                    output_index: 0,
13040                    output_script: vec![0x76],
13041                    satoshis: 1000,
13042                    topic: "Hello".to_string(),
13043                    spent: false,
13044                    outputs_consumed: vec![],
13045                    consumed_by: vec![],
13046                    beef: Some(proven_beef),
13047                    block_height: Some(800_000),
13048                    score: Some(1.0),
13049                })
13050                .await
13051                .unwrap();
13052        }
13053
13054        // The historical proofless tx (the f584f846-style backlog row).
13055        let (proofless, proofless_txid) = proofless_beef();
13056        storage
13057            .insert_output(&Output {
13058                txid: proofless_txid.clone(),
13059                output_index: 0,
13060                output_script: vec![0x76],
13061                satoshis: 1000,
13062                topic: "Hello".to_string(),
13063                spent: false,
13064                outputs_consumed: vec![],
13065                consumed_by: vec![],
13066                beef: Some(proofless),
13067                block_height: None,
13068                score: Some(1.0),
13069            })
13070            .await
13071            .unwrap();
13072
13073        let proof_hex = single_leaf_bump_hex(&proofless_txid, 850_000);
13074        let fetcher = std::rc::Rc::new(StubFetcher {
13075            proof: Some(proof_hex),
13076            calls: Cell::new(0),
13077        });
13078        let mut managers: HashMap<String, Box<dyn TopicManager>> = HashMap::new();
13079        managers.insert(
13080            "Hello".into(),
13081            Box::new(MockTopicManager::admitting(vec![0])),
13082        );
13083        let mut engine = Engine::new(
13084            managers,
13085            HashMap::new(),
13086            Box::new(storage),
13087            None,
13088            EngineConfig::default(),
13089        );
13090        engine.set_ancestor_fetcher(fetcher.clone());
13091
13092        // Run bounded ticks (limit 1) until the proofless tx is completed. With
13093        // 2 rows total a couple of ticks suffice; cap iterations defensively.
13094        let mut completed_total = 0;
13095        for _ in 0..5 {
13096            let s = engine.complete_missing_proofs(1, 0).await.unwrap();
13097            completed_total += s.completed;
13098            if completed_total > 0 {
13099                break;
13100            }
13101        }
13102        assert_eq!(
13103            completed_total, 1,
13104            "the proofless backlog tx is reached + completed once the proven rows are marked out of the window"
13105        );
13106
13107        let out = engine
13108            .storage()
13109            .find_output(&proofless_txid, 0, Some("Hello"), None, true)
13110            .await
13111            .unwrap()
13112            .unwrap();
13113        let beef = bsv_rs::transaction::Beef::from_binary(out.beef.as_ref().unwrap()).unwrap();
13114        assert_eq!(
13115            beef.find_txid(&proofless_txid)
13116                .map(bsv_rs::transaction::BeefTx::has_proof),
13117            Some(true),
13118            "the old proofless tx now carries its proof"
13119        );
13120    }
13121
13122    // ── S2 queue-durable admission (bsv-low 2026-08-29): Phase-3 faults ──
13123    //
13124    // The phantom class: under a D1 storm `submit` returned Ok(steak) while
13125    // its writes failed — the ack outlived the admission. These pins hold
13126    // the new contract: a fault is REPORTED, the faulted topic is NOT
13127    // recorded as applied, and a replay of the same bytes re-applies.
13128
13129    use crate::storage::StorageError;
13130    use std::rc::Rc;
13131
13132    /// `MemoryStorage` behind two fault switches. Every method delegates;
13133    /// the two the tests flip model a D1 write fault (`insert_output`) and
13134    /// a D1 read fault (`find_output` — the validation's previous-coin scan
13135    /// AND Phase 3's stale/consumed lookups).
13136    struct FaultingStorage {
13137        inner: MemoryStorage,
13138        fail_insert_output: Cell<bool>,
13139        fail_find_output: Cell<bool>,
13140    }
13141
13142    impl FaultingStorage {
13143        fn new() -> Rc<Self> {
13144            Rc::new(Self {
13145                inner: MemoryStorage::new(),
13146                fail_insert_output: Cell::new(false),
13147                fail_find_output: Cell::new(false),
13148            })
13149        }
13150    }
13151
13152    #[async_trait(?Send)]
13153    impl Storage for FaultingStorage {
13154        async fn insert_output_with_origin(
13155            &self,
13156            output: &Output,
13157            gasp: bool,
13158        ) -> Result<(), StorageError> {
13159            if self.fail_insert_output.get() {
13160                return Err(StorageError::Database(
13161                    "D1_ERROR: storage overloaded".into(),
13162                ));
13163            }
13164            self.inner.insert_output_with_origin(output, gasp).await
13165        }
13166        async fn read_fault_memos(
13167            &self,
13168            host: &str,
13169            topic: &str,
13170            outpoints: Option<&[String]>,
13171        ) -> Result<crate::fault_memo::FaultMemoBatch, StorageError> {
13172            self.inner.read_fault_memos(host, topic, outpoints).await
13173        }
13174
13175        async fn put_deferred_graph(
13176            &self,
13177            record: &crate::gasp::DeferredGraph,
13178        ) -> Result<crate::gasp::DeferredGraphSave, StorageError> {
13179            self.inner.put_deferred_graph(record).await
13180        }
13181        async fn reserve_unanchorable_retry(
13182            &self,
13183            record: &crate::gasp::DeferredGraph,
13184        ) -> Result<bool, StorageError> {
13185            self.inner.reserve_unanchorable_retry(record).await
13186        }
13187        async fn find_deferred_graphs(
13188            &self,
13189            host: &str,
13190            topic: &str,
13191        ) -> Result<Vec<crate::gasp::DeferredGraphKey>, StorageError> {
13192            self.inner.find_deferred_graphs(host, topic).await
13193        }
13194        async fn get_deferred_graph(
13195            &self,
13196            host: &str,
13197            topic: &str,
13198            outpoint: &str,
13199        ) -> Result<Option<crate::gasp::DeferredGraph>, StorageError> {
13200            self.inner.get_deferred_graph(host, topic, outpoint).await
13201        }
13202        async fn delete_deferred_graph(
13203            &self,
13204            host: &str,
13205            topic: &str,
13206            outpoint: &str,
13207        ) -> Result<(), StorageError> {
13208            self.inner
13209                .delete_deferred_graph(host, topic, outpoint)
13210                .await
13211        }
13212        async fn record_peer_sync_outcome(
13213            &self,
13214            host: &str,
13215            topic: &str,
13216            success: bool,
13217        ) -> Result<(), StorageError> {
13218            self.inner
13219                .record_peer_sync_outcome(host, topic, success)
13220                .await
13221        }
13222        async fn get_peer_sync_health(
13223            &self,
13224            host: &str,
13225            topic: &str,
13226        ) -> Result<crate::storage::PeerSyncHealth, StorageError> {
13227            self.inner.get_peer_sync_health(host, topic).await
13228        }
13229        async fn record_peer_sync_yield(
13230            &self,
13231            host: &str,
13232            topic: &str,
13233            yielded: bool,
13234        ) -> Result<crate::storage::PeerYieldStreak, StorageError> {
13235            self.inner
13236                .record_peer_sync_yield(host, topic, yielded)
13237                .await
13238        }
13239        async fn insert_output(&self, output: &Output) -> Result<(), StorageError> {
13240            self.insert_output_with_origin(output, false).await
13241        }
13242        async fn delete_output(
13243            &self,
13244            txid: &str,
13245            output_index: u32,
13246            topic: &str,
13247        ) -> Result<(), StorageError> {
13248            self.inner.delete_output(txid, output_index, topic).await
13249        }
13250        async fn mark_utxo_as_spent(
13251            &self,
13252            txid: &str,
13253            output_index: u32,
13254            topic: &str,
13255        ) -> Result<(), StorageError> {
13256            self.inner
13257                .mark_utxo_as_spent(txid, output_index, topic)
13258                .await
13259        }
13260        async fn update_consumed_by(
13261            &self,
13262            txid: &str,
13263            output_index: u32,
13264            topic: &str,
13265            consumed_by: &[Outpoint],
13266        ) -> Result<(), StorageError> {
13267            self.inner
13268                .update_consumed_by(txid, output_index, topic, consumed_by)
13269                .await
13270        }
13271        async fn update_transaction_beef(
13272            &self,
13273            txid: &str,
13274            beef: &[u8],
13275        ) -> Result<(), StorageError> {
13276            self.inner.update_transaction_beef(txid, beef).await
13277        }
13278        async fn insert_applied_transaction(
13279            &self,
13280            tx: &AppliedTransaction,
13281        ) -> Result<(), StorageError> {
13282            self.inner.insert_applied_transaction(tx).await
13283        }
13284        async fn does_applied_transaction_exist(
13285            &self,
13286            tx: &AppliedTransaction,
13287        ) -> Result<bool, StorageError> {
13288            self.inner.does_applied_transaction_exist(tx).await
13289        }
13290        async fn delete_applied_transaction(
13291            &self,
13292            tx: &AppliedTransaction,
13293        ) -> Result<(), StorageError> {
13294            self.inner.delete_applied_transaction(tx).await
13295        }
13296        async fn find_output(
13297            &self,
13298            txid: &str,
13299            output_index: u32,
13300            topic: Option<&str>,
13301            spent: Option<bool>,
13302            include_beef: bool,
13303        ) -> Result<Option<Output>, StorageError> {
13304            if self.fail_find_output.get() {
13305                return Err(StorageError::Database("D1_ERROR: read timed out".into()));
13306            }
13307            self.inner
13308                .find_output(txid, output_index, topic, spent, include_beef)
13309                .await
13310        }
13311        async fn find_outputs_for_transaction(
13312            &self,
13313            txid: &str,
13314            include_beef: bool,
13315        ) -> Result<Vec<Output>, StorageError> {
13316            self.inner
13317                .find_outputs_for_transaction(txid, include_beef)
13318                .await
13319        }
13320        async fn find_utxos_for_topic(
13321            &self,
13322            topic: &str,
13323            since: Option<f64>,
13324            limit: Option<u64>,
13325            include_beef: bool,
13326        ) -> Result<Vec<Output>, StorageError> {
13327            self.inner
13328                .find_utxos_for_topic(topic, since, limit, include_beef)
13329                .await
13330        }
13331        async fn update_last_interaction(
13332            &self,
13333            host: &str,
13334            topic: &str,
13335            since: u64,
13336        ) -> Result<(), StorageError> {
13337            self.inner.update_last_interaction(host, topic, since).await
13338        }
13339        async fn get_last_interaction(&self, host: &str, topic: &str) -> Result<u64, StorageError> {
13340            self.inner.get_last_interaction(host, topic).await
13341        }
13342    }
13343
13344    fn make_faulting_engine(admit_indices: Vec<u32>) -> (Engine, Rc<FaultingStorage>) {
13345        let storage = FaultingStorage::new();
13346        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13347        managers.insert(
13348            "tm_test".to_string(),
13349            Box::new(MockTopicManager::admitting(admit_indices)),
13350        );
13351        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13352        lookup_services.insert("ls_test".to_string(), Box::new(MockLookupService::new()));
13353        let engine = Engine::new(
13354            managers,
13355            lookup_services,
13356            Box::new(Rc::clone(&storage)),
13357            None,
13358            EngineConfig::default(),
13359        );
13360        (engine, storage)
13361    }
13362
13363    async fn applied(storage: &FaultingStorage) -> bool {
13364        storage
13365            .does_applied_transaction_exist(&AppliedTransaction {
13366                txid: TEST_TXID.to_string(),
13367                topic: "tm_test".to_string(),
13368            })
13369            .await
13370            .unwrap()
13371    }
13372
13373    /// The old behaviour, pinned: a clean submit is durable, records the
13374    /// topic as applied, and the output is in storage.
13375    #[tokio::test]
13376    async fn clean_submit_is_durable_and_records_applied() {
13377        let (engine, storage) = make_faulting_engine(vec![0]);
13378        let (steak, report) = engine
13379            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13380            .await
13381            .unwrap();
13382        assert_eq!(steak["tm_test"].outputs_to_admit, vec![0]);
13383        assert!(report.is_durable(), "{}", report.summary());
13384        assert_eq!(report.applied_topics, vec!["tm_test".to_string()]);
13385        assert!(applied(&storage).await);
13386        assert!(storage
13387            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13388            .await
13389            .unwrap()
13390            .is_some());
13391    }
13392
13393    /// THE PHANTOM, red→green: the insert fails, the steak still says
13394    /// "admitted" (the decision stands), but the report says NOT DURABLE and
13395    /// names the site — and the topic is NOT recorded as applied, so nothing
13396    /// downstream can treat the loss as final.
13397    #[tokio::test]
13398    async fn phase3_write_fault_is_reported_and_blocks_the_applied_record() {
13399        let (engine, storage) = make_faulting_engine(vec![0]);
13400        storage.fail_insert_output.set(true);
13401        let (steak, report) = engine
13402            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13403            .await
13404            .unwrap();
13405        assert_eq!(
13406            steak["tm_test"].outputs_to_admit,
13407            vec![0],
13408            "the admission DECISION is unchanged by a write fault"
13409        );
13410        assert!(!report.is_durable());
13411        assert_eq!(report.faults.len(), 1, "{}", report.summary());
13412        assert_eq!(report.faults[0].topic, "tm_test");
13413        assert_eq!(report.faults[0].site, "insert_output");
13414        assert!(report.faults[0].error.contains("D1_ERROR"));
13415        assert!(report.applied_topics.is_empty());
13416        assert!(
13417            !applied(&storage).await,
13418            "a faulted topic must not be recorded as applied — the replay would be a dupe"
13419        );
13420        assert!(storage
13421            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13422            .await
13423            .unwrap()
13424            .is_none());
13425    }
13426
13427    /// The replay: same bytes, storage healthy again → re-validated (not a
13428    /// dupe), re-written, recorded. A THIRD submit is then the dupe it
13429    /// should be: nothing admitted, nothing to write, durable.
13430    #[tokio::test]
13431    async fn replay_after_a_fault_reapplies_and_records_applied() {
13432        let (engine, storage) = make_faulting_engine(vec![0]);
13433        storage.fail_insert_output.set(true);
13434        let (_, first) = engine
13435            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13436            .await
13437            .unwrap();
13438        assert!(!first.is_durable());
13439
13440        storage.fail_insert_output.set(false);
13441        let (steak, replay) = engine
13442            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13443            .await
13444            .unwrap();
13445        assert_eq!(
13446            steak["tm_test"].outputs_to_admit,
13447            vec![0],
13448            "re-validated, not deduped"
13449        );
13450        assert!(replay.is_durable(), "{}", replay.summary());
13451        assert_eq!(replay.applied_topics, vec!["tm_test".to_string()]);
13452        assert!(applied(&storage).await);
13453        assert!(storage
13454            .find_output(TEST_TXID, 0, Some("tm_test"), None, false)
13455            .await
13456            .unwrap()
13457            .is_some());
13458
13459        let (steak, third) = engine
13460            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13461            .await
13462            .unwrap();
13463        assert!(
13464            steak["tm_test"].outputs_to_admit.is_empty(),
13465            "now a genuine dupe: nothing admitted"
13466        );
13467        assert!(third.is_durable() && third.applied_topics.is_empty());
13468    }
13469
13470    /// The SPEND half of the phantom: a `find_output` fault during
13471    /// validation made a settle look like it consumed nothing, so the spend
13472    /// pointer was never written and the ack stood. Now the read fault is
13473    /// reported, the topic stays unrecorded, the previously-admitted coin
13474    /// is untouched — and the healthy replay consumes it.
13475    #[tokio::test]
13476    async fn validation_read_fault_is_reported_and_the_replay_consumes_the_coin() {
13477        let (engine, storage) = make_faulting_engine(vec![0]);
13478        // The subject's own input: seed the coin it spends as a previously
13479        // admitted tm_test output.
13480        let tx = Transaction::from_beef(&test_beef(), None).unwrap();
13481        let source_txid = tx.inputs[0].get_source_txid().unwrap();
13482        let source_vout = tx.inputs[0].source_output_index;
13483        storage
13484            .insert_output(&Output {
13485                txid: source_txid.clone(),
13486                output_index: source_vout,
13487                output_script: vec![0x51],
13488                satoshis: 1,
13489                topic: "tm_test".to_string(),
13490                spent: false,
13491                outputs_consumed: vec![],
13492                consumed_by: vec![],
13493                beef: None,
13494                block_height: None,
13495                score: Some(1.0),
13496            })
13497            .await
13498            .unwrap();
13499
13500        storage.fail_find_output.set(true);
13501        let (_, faulted) = engine
13502            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13503            .await
13504            .unwrap();
13505        assert!(!faulted.is_durable());
13506        assert!(
13507            faulted.faults.iter().any(|f| f.site == "find_output"),
13508            "{}",
13509            faulted.summary()
13510        );
13511        assert!(
13512            !applied(&storage).await,
13513            "unrecorded: the replay must re-read"
13514        );
13515        storage.fail_find_output.set(false);
13516        assert!(
13517            storage
13518                .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13519                .await
13520                .unwrap()
13521                .is_some(),
13522            "the coin the faulted pass could not see is still there, untouched"
13523        );
13524
13525        let (_, replay) = engine
13526            .submit_with_report(&test_tagged_beef(vec!["tm_test"]), SubmitMode::CurrentTx)
13527            .await
13528            .unwrap();
13529        assert!(replay.is_durable(), "{}", replay.summary());
13530        assert!(applied(&storage).await);
13531        // The mock manager retains no coins, so the consumed coin is stale
13532        // and deleted — the observable proof the spend was PROCESSED.
13533        assert!(
13534            storage
13535                .find_output(&source_txid, source_vout, Some("tm_test"), None, false)
13536                .await
13537                .unwrap()
13538                .is_none(),
13539            "the healthy replay consumed the coin the faulted pass missed"
13540        );
13541    }
13542
13543    /// bsv-low PLAN-PRE-LOOP4 §H4 (2026-09-06): a lookup service that missed
13544    /// its admit notification is told AGAIN from the engine's own stored
13545    /// outputs — one call per admitted output per service, the WholeTx body
13546    /// naming the subject — while `/submit` of the same bytes is deduped and
13547    /// tells nobody (the premise, RED-verified in the same test); a topic the
13548    /// engine never admitted this txid on yields 0 (the caller re-submits).
13549    #[tokio::test]
13550    async fn renotify_admitted_replays_the_admit_from_stored_outputs() {
13551        use std::cell::RefCell;
13552        use std::rc::Rc;
13553
13554        struct CountingLs(Rc<RefCell<Vec<(String, u32, String)>>>, AdmissionMode);
13555        #[async_trait(?Send)]
13556        impl LookupServiceTrait for CountingLs {
13557            fn admission_mode(&self) -> AdmissionMode {
13558                self.1
13559            }
13560            fn spend_notification_mode(&self) -> SpendNotificationMode {
13561                SpendNotificationMode::None
13562            }
13563            async fn output_admitted_by_topic(
13564                &self,
13565                payload: &OutputAdmittedByTopic,
13566            ) -> Result<(), LookupServiceError> {
13567                let rec = match payload {
13568                    OutputAdmittedByTopic::LockingScript {
13569                        txid,
13570                        output_index,
13571                        topic,
13572                        ..
13573                    } => (txid.clone(), *output_index, topic.clone()),
13574                    OutputAdmittedByTopic::WholeTx {
13575                        atomic_beef,
13576                        output_index,
13577                        topic,
13578                        ..
13579                    } => {
13580                        let beef = Beef::from_binary(atomic_beef).expect("the WholeTx body parses");
13581                        (
13582                            beef.atomic_txid
13583                                .clone()
13584                                .expect("the WholeTx body NAMES its subject"),
13585                            *output_index,
13586                            topic.clone(),
13587                        )
13588                    }
13589                };
13590                self.0.borrow_mut().push(rec);
13591                Ok(())
13592            }
13593            async fn output_evicted(
13594                &self,
13595                _txid: &str,
13596                _output_index: u32,
13597            ) -> Result<(), LookupServiceError> {
13598                Ok(())
13599            }
13600            async fn lookup(
13601                &self,
13602                _question: &LookupQuestion,
13603            ) -> Result<LookupResult, LookupServiceError> {
13604                Ok(LookupResult::OutputList(Vec::new()))
13605            }
13606            async fn get_documentation(&self) -> String {
13607                "counting".to_string()
13608            }
13609            async fn get_metadata(&self) -> ServiceMetadata {
13610                ServiceMetadata {
13611                    name: "counting-ls".to_string(),
13612                    ..Default::default()
13613                }
13614            }
13615        }
13616
13617        let mut managers: HashMap<String, Box<dyn TopicManagerTrait>> = HashMap::new();
13618        managers.insert(
13619            "tm_test".to_string(),
13620            Box::new(MockTopicManager::admitting(vec![0])),
13621        );
13622        let seen = Rc::new(RefCell::new(Vec::new()));
13623        let mut lookup_services: HashMap<String, Box<dyn LookupServiceTrait>> = HashMap::new();
13624        lookup_services.insert(
13625            "ls_whole".to_string(),
13626            Box::new(CountingLs(seen.clone(), AdmissionMode::WholeTx)),
13627        );
13628        lookup_services.insert(
13629            "ls_script".to_string(),
13630            Box::new(CountingLs(seen.clone(), AdmissionMode::LockingScript)),
13631        );
13632        let engine = Engine::new(
13633            managers,
13634            lookup_services,
13635            Box::new(MemoryStorage::new()),
13636            None,
13637            EngineConfig::default(),
13638        );
13639
13640        let tagged = test_tagged_beef(vec!["tm_test"]);
13641        engine.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13642        assert_eq!(seen.borrow().len(), 2, "one admit per service");
13643        let (txid, vout, _) = seen.borrow()[0].clone();
13644        assert_eq!(txid, TEST_TXID);
13645
13646        // the premise, RED-verified: the same bytes again are DEDUPED — /submit can never re-notify
13647        let (_, report) = engine
13648            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13649            .await
13650            .unwrap();
13651        assert_eq!(report.deduped_topics, vec!["tm_test".to_string()]);
13652        assert_eq!(seen.borrow().len(), 2, "dedup told nobody");
13653
13654        // the verb: told again from the stored outputs, the WholeTx body naming the subject
13655        let r = engine.renotify_admitted(&txid, "tm_test").await.unwrap();
13656        assert_eq!((r.outputs, r.notified, r.faults.len()), (1, 2, 0));
13657        assert_eq!(r.vouts, vec![vout]);
13658        assert_eq!(seen.borrow().len(), 4);
13659        assert!(seen.borrow()[2..]
13660            .iter()
13661            .all(|(t, v, topic)| *t == txid && *v == vout && topic == "tm_test"));
13662
13663        // a topic the engine never admitted this txid on: nothing to re-notify (re-submit instead)
13664        let r0 = engine.renotify_admitted(&txid, "tm_other").await.unwrap();
13665        assert_eq!((r0.outputs, r0.notified), (0, 0));
13666        assert_eq!(seen.borrow().len(), 4);
13667
13668        // idempotent: a second re-notify tells everyone again, faults nothing
13669        let r2 = engine
13670            .renotify_admitted(&txid.to_ascii_uppercase(), "tm_test")
13671            .await
13672            .unwrap();
13673        assert_eq!((r2.outputs, r2.notified, r2.faults.len()), (1, 2, 0));
13674        assert_eq!(seen.borrow().len(), 6);
13675    }
13676
13677    /// bsv-low PLAN-PRE-LOOP4 §H4: a PHANTOM applied row (the topic admitted
13678    /// NOTHING) dedups every re-submit of those bytes; forgetting it lets the
13679    /// same bytes be judged again — while a REAL admission's row is never
13680    /// forgotten and a missing row is nothing to forget.
13681    #[tokio::test]
13682    async fn forget_phantom_applied_unblocks_a_resubmit_but_never_a_real_admission() {
13683        // phantom: a TM that admits nothing still records the topic as applied
13684        let engine = make_engine(vec![]);
13685        let tagged = test_tagged_beef(vec!["tm_test"]);
13686        let (steak, r1) = engine
13687            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13688            .await
13689            .unwrap();
13690        assert!(steak.get("tm_test").unwrap().outputs_to_admit.is_empty());
13691        assert_eq!(r1.applied_topics, vec!["tm_test".to_string()]);
13692        let (_, r2) = engine
13693            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13694            .await
13695            .unwrap();
13696        assert_eq!(
13697            r2.deduped_topics,
13698            vec!["tm_test".to_string()],
13699            "the phantom row dedups the re-submit"
13700        );
13701        assert!(engine
13702            .forget_phantom_applied(TEST_TXID, "tm_test")
13703            .await
13704            .unwrap());
13705        let (_, r3) = engine
13706            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13707            .await
13708            .unwrap();
13709        assert!(
13710            r3.deduped_topics.is_empty(),
13711            "judged again after the forget"
13712        );
13713        assert!(
13714            !engine
13715                .forget_phantom_applied(TEST_TXID, "tm_other")
13716                .await
13717                .unwrap(),
13718            "no row: nothing to forget"
13719        );
13720
13721        // real: a TM that admits output 0 — its row is a real admission, never forgotten
13722        let real = make_engine(vec![0]);
13723        real.submit(&tagged, SubmitMode::CurrentTx).await.unwrap();
13724        assert!(!real
13725            .forget_phantom_applied(TEST_TXID, "tm_test")
13726            .await
13727            .unwrap());
13728        let (_, r4) = real
13729            .submit_with_report(&tagged, SubmitMode::CurrentTx)
13730            .await
13731            .unwrap();
13732        assert_eq!(
13733            r4.deduped_topics,
13734            vec!["tm_test".to_string()],
13735            "a real admission still dedups"
13736        );
13737    }
13738
13739    // ── NL-6e: the SPV walk is linear in a BUMP's leaves (NL-8 W1) ─────
13740
13741    /// Named and generous (a debug build on a loaded machine): the walk at
13742    /// bsv-rs 0.4.0 took 30 to 36 s at 4,096 leaves in release and gave no
13743    /// answer in 120 s at 8,192 (NL-8 W1).
13744    const WIDE_BUMP_WALK_BOUND: std::time::Duration = std::time::Duration::from_secs(10);
13745
13746    /// The wide BUMP's height (NL-8's `wide k` row).
13747    const WIDE_HEIGHT: u32 = 800_001;
13748
13749    /// `2^k` txids, display hex: the double SHA-256 of `i` (NL-8's probe).
13750    fn wide_leaves(k: u32) -> Vec<String> {
13751        (0..1u32 << k)
13752            .map(|i| {
13753                let mut id = bsv_rs::primitives::sha256d(&i.to_le_bytes()).to_vec();
13754                id.reverse();
13755                hex::encode(id)
13756            })
13757            .collect()
13758    }
13759
13760    /// One BUMP at [`WIDE_HEIGHT`] whose level 0 carries `txids` as hashed
13761    /// leaves and whose `k - 1` upper levels are empty: every node above
13762    /// level 0 is computed from it. Its root, by one walk.
13763    fn wide_bump(txids: &[String]) -> (bsv_rs::transaction::MerklePath, String) {
13764        let k = txids.len().trailing_zeros() as usize;
13765        let mut levels = vec![Vec::new(); k];
13766        levels[0] = txids
13767            .iter()
13768            .enumerate()
13769            .map(|(i, id)| bsv_rs::transaction::MerklePathLeaf::new_txid(i as u64, id.clone()))
13770            .collect();
13771        let bump = bsv_rs::transaction::MerklePath::new(WIDE_HEIGHT, levels).unwrap();
13772        let root = bump.compute_root(None).unwrap();
13773        (bump, root)
13774    }
13775
13776    /// NL-8 W1's bytes: a V1 BEEF of the one wide BUMP and no transaction
13777    /// (`wide_14` at `2^k` leaves, byte for byte the runner's probe).
13778    fn w1_bytes(k: u32) -> (Vec<u8>, String) {
13779        let (bump, root) = wide_bump(&wide_leaves(k));
13780        let mut beef = vec![0x01, 0x00, 0xbe, 0xef, 0x01];
13781        beef.extend_from_slice(&bump.to_binary());
13782        beef.push(0x00);
13783        (beef, root)
13784    }
13785
13786    /// The entry, [`verify_spv_like_the_reference`], on its own thread under
13787    /// a tracker that carries `root` at [`WIDE_HEIGHT`]: its answer and its
13788    /// time, or a panic naming the bound.
13789    fn walk_within_bound(
13790        bytes: Vec<u8>,
13791        root: String,
13792        verify_scripts: bool,
13793        subject: String,
13794        what: &str,
13795    ) -> (Result<(), String>, std::time::Duration) {
13796        let (tx, rx) = std::sync::mpsc::channel();
13797        std::thread::spawn(move || {
13798            let mut tracker = bsv_rs::transaction::MockChainTracker::new(WIDE_HEIGHT + 10);
13799            tracker.add_root(WIDE_HEIGHT, root);
13800            let runtime = tokio::runtime::Builder::new_current_thread()
13801                .build()
13802                .unwrap();
13803            let started = std::time::Instant::now();
13804            let verdict = runtime.block_on(verify_spv_like_the_reference(
13805                Some(&tracker),
13806                verify_scripts,
13807                &bytes,
13808                &subject,
13809                DoorBudget::DEFAULT,
13810            ));
13811            let verdict = match verdict {
13812                Ok(None) => Ok(()),
13813                Ok(Some(stop)) => Err(format!("the walk could not run: {stop:?}")),
13814                Err(e) => Err(e.to_string()),
13815            };
13816            let _ = tx.send((verdict, started.elapsed()));
13817        });
13818        rx.recv_timeout(WIDE_BUMP_WALK_BOUND).unwrap_or_else(|_| {
13819            panic!("{what}: no answer from the walk within {WIDE_BUMP_WALK_BOUND:?}")
13820        })
13821    }
13822
13823    /// The escape hatch's structural walk (`verify_scripts` false) on NL-8
13824    /// W1's bytes, 4,096 and 8,192 leaves: accepted, the one root asked of
13825    /// the tracker, well inside the bound.
13826    #[test]
13827    fn nl6e_the_structural_walk_is_linear_in_a_bump_s_leaves() {
13828        for k in [12u32, 13] {
13829            let (bytes, root) = w1_bytes(k);
13830            let what = format!("2^{k} leaves, {} bytes, structural", bytes.len());
13831            let (verdict, took) = walk_within_bound(bytes, root, false, String::new(), &what);
13832            println!(
13833                "NL-6e W1 {what}: {verdict:?} in {:.3} s",
13834                took.as_secs_f64()
13835            );
13836            assert_eq!(verdict, Ok(()), "{what}");
13837        }
13838    }
13839
13840    /// The default walk (scripts run, `verify_scripts` true) over `2^k`
13841    /// proven parents in the one wide BUMP, each spent by a binary tree of
13842    /// two-input transactions whose root is the subject: every parent's
13843    /// root is the BUMP's, asked of the tracker, the inputs `2^(k+1) - 2`.
13844    fn proven_fan_in(k: u32) -> (Vec<u8>, String, String) {
13845        use bsv_rs::script::{LockingScript, UnlockingScript};
13846        use bsv_rs::transaction::{TransactionInput, TransactionOutput};
13847        let op_true = || LockingScript::from_binary(&[0x51]).unwrap();
13848        let spending = |txid: &str| {
13849            let mut input = TransactionInput::new(txid.to_string(), 0);
13850            input.unlocking_script = Some(UnlockingScript::from_binary(&[]).unwrap());
13851            input
13852        };
13853        let parents: Vec<Transaction> = (0..1u32 << k)
13854            .map(|i| {
13855                let mut tx = Transaction::new();
13856                // A coinbase's null outpoint, a distinct index each.
13857                let mut input = TransactionInput::new("00".repeat(32), u32::MAX - i);
13858                input.unlocking_script = Some(UnlockingScript::from_binary(&[0x51]).unwrap());
13859                tx.inputs.push(input);
13860                tx.outputs.push(TransactionOutput::new(1, op_true()));
13861                tx
13862            })
13863            .collect();
13864        let ids: Vec<String> = parents.iter().map(Transaction::id).collect();
13865        let (bump, root) = wide_bump(&ids);
13866        let mut beef = Beef::new();
13867        let at = beef.merge_bump(bump);
13868        for parent in &parents {
13869            beef.merge_raw_tx(parent.to_binary(), Some(at));
13870        }
13871        let mut layer = ids;
13872        while layer.len() > 1 {
13873            layer = layer
13874                .chunks(2)
13875                .map(|pair| {
13876                    let mut tx = Transaction::new();
13877                    tx.inputs.push(spending(&pair[0]));
13878                    tx.inputs.push(spending(&pair[1]));
13879                    tx.outputs.push(TransactionOutput::new(0, op_true()));
13880                    beef.merge_raw_tx(tx.to_binary(), None);
13881                    tx.id()
13882                })
13883                .collect();
13884        }
13885        (beef.to_binary(), root, layer.remove(0))
13886    }
13887
13888    #[test]
13889    fn nl6e_the_script_walk_is_linear_in_a_bump_s_leaves() {
13890        for k in [12u32, 13] {
13891            let (bytes, root, subject) = proven_fan_in(k);
13892            let what = format!("2^{k} proven parents, {} bytes, scripts", bytes.len());
13893            let (verdict, took) = walk_within_bound(bytes, root, true, subject, &what);
13894            println!(
13895                "NL-6e fan-in {what}: {verdict:?} in {:.3} s",
13896                took.as_secs_f64()
13897            );
13898            assert_eq!(verdict, Ok(()), "{what}");
13899        }
13900    }
13901    include!("minutes_tests.rs");
13902    include!("memo_tests.rs");
13903    include!("absence_tests.rs");
13904    include!("route_fault_tests.rs");
13905    include!("batch_tests.rs");
13906}